diff --git a/backend/src/database/migrations/V1793181595__credly-badges-template.sql b/backend/src/database/migrations/V1793181595__credly-badges-template.sql new file mode 100644 index 0000000000..d21341472b --- /dev/null +++ b/backend/src/database/migrations/V1793181595__credly-badges-template.sql @@ -0,0 +1,6 @@ +ALTER TABLE integration."credlyBadges" + ADD COLUMN "badgeTemplateName" TEXT NOT NULL DEFAULT '', + ADD COLUMN "badgeTemplateUrl" TEXT NULL; + +ALTER TABLE integration."credlyBadges" + ALTER COLUMN "badgeTemplateName" DROP DEFAULT; diff --git a/services/apps/connectors_worker/src/bin/dry-run-credly-badge-poll.ts b/services/apps/connectors_worker/src/bin/dry-run-credly-badge-poll.ts index 8106bc94ec..1883bc9f7e 100644 --- a/services/apps/connectors_worker/src/bin/dry-run-credly-badge-poll.ts +++ b/services/apps/connectors_worker/src/bin/dry-run-credly-badge-poll.ts @@ -121,6 +121,8 @@ async function main(): Promise { const deps: CredlyDeps = { loadTemplateRouting: async () => options.routing, lockTemplate: async () => true, + findBadges: async () => [], + saveBadges: async () => undefined, } const outcome = await runBadgePoll({ diff --git a/services/libs/connectors/src/connectors/credly/deps.test.ts b/services/libs/connectors/src/connectors/credly/deps.test.ts index 71c1711cf3..dbef0aa0f0 100644 --- a/services/libs/connectors/src/connectors/credly/deps.test.ts +++ b/services/libs/connectors/src/connectors/credly/deps.test.ts @@ -5,6 +5,7 @@ import { listStaleCredlyTemplateMappings, lockCredlyTemplateMapping, } from '@crowd/data-access-layer/src/credly' +import { findCredlyBadges, upsertCredlyBadges } from '@crowd/data-access-layer/src/credly/badges' import type { QueryExecutor } from '@crowd/data-access-layer/src/queryExecutor' import { createCredlyDeps } from './deps' @@ -14,6 +15,10 @@ vi.mock('@crowd/data-access-layer/src/credly', () => ({ listStaleCredlyTemplateMappings: vi.fn(), lockCredlyTemplateMapping: vi.fn(), })) +vi.mock('@crowd/data-access-layer/src/credly/badges', () => ({ + findCredlyBadges: vi.fn(), + upsertCredlyBadges: vi.fn(), +})) const mockedActive = vi.mocked(listActiveCredlyTemplateMappings) const mockedStale = vi.mocked(listStaleCredlyTemplateMappings) @@ -91,4 +96,15 @@ describe('createCredlyDeps', () => { expect(await deps.lockTemplate('tpl-cka', target)).toBe(true) expect(lockCredlyTemplateMapping).toHaveBeenCalledWith(qx, 'tpl-cka', target) }) + + it('reads and writes the badge state through the table', async () => { + vi.mocked(findCredlyBadges).mockResolvedValueOnce([]) + const deps = createCredlyDeps({ qx, integrationId: 'anchor' }) + + expect(await deps.findBadges(['b-1'])).toEqual([]) + await deps.saveBadges([]) + + expect(findCredlyBadges).toHaveBeenCalledWith(qx, ['b-1']) + expect(upsertCredlyBadges).toHaveBeenCalledWith(qx, []) + }) }) diff --git a/services/libs/connectors/src/connectors/credly/deps.ts b/services/libs/connectors/src/connectors/credly/deps.ts index 7114f25136..5faf2ae258 100644 --- a/services/libs/connectors/src/connectors/credly/deps.ts +++ b/services/libs/connectors/src/connectors/credly/deps.ts @@ -3,6 +3,8 @@ import { listStaleCredlyTemplateMappings, lockCredlyTemplateMapping, } from '@crowd/data-access-layer/src/credly' +import type { ICredlyBadgeInput, ICredlyBadgeRow } from '@crowd/data-access-layer/src/credly/badges' +import { findCredlyBadges, upsertCredlyBadges } from '@crowd/data-access-layer/src/credly/badges' import type { DepsFactoryInput } from '../../types' @@ -20,6 +22,8 @@ export interface CredlyTemplateRouting { export interface CredlyDeps { loadTemplateRouting(): Promise lockTemplate(badgeTemplateId: string, target: CredlyTemplateTarget): Promise + findBadges(badgeIds: string[]): Promise + saveBadges(badges: ICredlyBadgeInput[]): Promise } export function createCredlyDeps({ qx }: DepsFactoryInput): CredlyDeps { @@ -44,5 +48,11 @@ export function createCredlyDeps({ qx }: DepsFactoryInput): CredlyDeps { lockTemplate(badgeTemplateId, target) { return lockCredlyTemplateMapping(qx, badgeTemplateId, target) }, + findBadges(badgeIds) { + return findCredlyBadges(qx, badgeIds) + }, + saveBadges(badges) { + return upsertCredlyBadges(qx, badges) + }, } } diff --git a/services/libs/connectors/src/connectors/credly/syncs/badgePoll.test.ts b/services/libs/connectors/src/connectors/credly/syncs/badgePoll.test.ts index e106562d8a..43a75eab0c 100644 --- a/services/libs/connectors/src/connectors/credly/syncs/badgePoll.test.ts +++ b/services/libs/connectors/src/connectors/credly/syncs/badgePoll.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ICredlyBadgeInput, ICredlyBadgeRow } from '@crowd/data-access-layer/src/credly/badges' import { CredlyActivityType } from '@crowd/integrations/src/integrations/credly/types' import type { Logger } from '@crowd/logging' @@ -65,18 +66,47 @@ const DEFAULT_ROUTING: CredlyTemplateRouting = { staleTemplateIds: [], } +function storedBadge(overrides: Partial = {}): ICredlyBadgeRow { + return { + badgeId: 'badge-1', + badgeTemplateId: 'template-1', + badgeTemplateName: 'CKA: Certified Kubernetes Administrator', + badgeTemplateUrl: null, + state: 'accepted', + stateUpdatedAt: '2026-10-22T10:00:00.000Z', + issuedAt: '2026-10-22T00:00:00.000Z', + expiresAt: null, + replacementBadgeId: null, + replacesBadgeId: null, + segmentId: 'seg-stored', + integrationId: 'int-stored', + recipientEmail: 'maria.rossi@example.org', + issuedTo: 'Maria Rossi', + expiredEmittedAt: null, + firstSeenAt: '2026-10-22T12:00:00.000Z', + lastSeenAt: '2026-10-22T12:00:00.000Z', + ...overrides, + } +} + function makeCtx( watermark: Record | null = null, hasRunBudget = () => true, routing: CredlyTemplateRouting = DEFAULT_ROUTING, emitEnabled = true, + stored: ICredlyBadgeRow[] = [], ) { const emitted: unknown[][] = [] const emits: { records: unknown[]; segmentId?: string; integrationId?: string }[] = [] const watermarks: Record[] = [] + const saved: ICredlyBadgeInput[] = [] const deps: CredlyDeps = { loadTemplateRouting: vi.fn(async () => routing), lockTemplate: vi.fn(async () => true), + findBadges: vi.fn(async (ids: string[]) => stored.filter((row) => ids.includes(row.badgeId))), + saveBadges: vi.fn(async (rows: ICredlyBadgeInput[]) => { + saved.push(...rows) + }), } const ctx: SyncContext = { channel: { channelId: ORGANIZATION_ID, channelName: ORGANIZATION_ID }, @@ -101,6 +131,7 @@ function makeCtx( emitted, emits, deps, + saved, watermarks, lastWatermark: () => watermarks[watermarks.length - 1], } @@ -346,29 +377,223 @@ describe('runBadgePoll', () => { }) }) - it('emits expired-badge only for accepted badges whose expiry has already passed', async () => { + it('stores a new badge with the routing target and leaves expiries to the expiry sync', async () => { mockedFetchBadgesPage.mockResolvedValueOnce( page([ badge({ id: 'expired', expires_at: '2026-10-01T00:00:00.000Z' }), - badge({ id: 'live', expires_at: '2028-10-01T00:00:00.000Z' }), badge({ id: 'pending', state: 'pending', state_updated_at: null }), badge({ id: 'revoked', state: 'revoked', expires_at: '2026-10-01T00:00:00.000Z' }), ]), ) - const { ctx, emitted } = makeCtx() + const { ctx, emitted, saved, deps } = makeCtx() await runBadgePoll(ctx) expect(types(emitted[0])).toEqual([ CredlyActivityType.ISSUED_BADGE, CredlyActivityType.ACCEPTED_BADGE, - CredlyActivityType.EXPIRED_BADGE, + CredlyActivityType.ISSUED_BADGE, + CredlyActivityType.REVOKED_BADGE, + ]) + expect(deps.findBadges).toHaveBeenCalledWith(['expired', 'pending', 'revoked']) + expect(saved.map((row) => [row.badgeId, row.state, row.segmentId, row.expiresAt])).toEqual([ + ['expired', 'accepted', 'seg-default', '2026-10-01T00:00:00.000Z'], + ['pending', 'pending', 'seg-default', null], + ['revoked', 'revoked', 'seg-default', '2026-10-01T00:00:00.000Z'], + ]) + expect(saved[0]).toMatchObject({ + badgeTemplateName: 'CKA: Certified Kubernetes Administrator', + recipientEmail: 'maria.rossi@example.org', + issuedTo: 'Maria Rossi', + }) + expect(LOG.info).toHaveBeenCalledWith( + expect.objectContaining({ routed: 2, pendingStored: 1, stateChanges: 0, unchanged: 0 }), + expect.any(String), + ) + }) + + it('emits nothing for a known badge whose state did not change, but marks it seen', async () => { + mockedFetchBadgesPage.mockResolvedValueOnce( + page([badge({ state_updated_at: '2026-10-27T10:00:00.000Z' })]), + ) + const { ctx, emits, saved, deps } = makeCtx(null, () => true, DEFAULT_ROUTING, true, [ + storedBadge(), + ]) + + await runBadgePoll(ctx) + + expect(emits).toEqual([]) + expect(deps.lockTemplate).toHaveBeenCalledWith('template-1', { + segmentId: 'seg-stored', + integrationId: 'int-stored', + locked: true, + }) + expect(saved.map((row) => [row.badgeId, row.segmentId, row.stateUpdatedAt])).toEqual([ + ['badge-1', 'seg-stored', '2026-10-27T10:00:00.000Z'], + ]) + expect(LOG.info).toHaveBeenCalledWith( + expect.objectContaining({ unchanged: 1, routed: 0 }), + expect.any(String), + ) + }) + + it('emits only the state activity on the stored segment when a known badge changes state', async () => { + mockedFetchBadgesPage.mockResolvedValueOnce( + page([ + badge({ + state: 'replaced', + state_updated_at: '2026-10-27T10:00:00.000Z', + replacement_badge_id: 'badge-2', + }), + badge({ id: 'badge-2', replaces_badge_id: 'badge-1' }), + ]), + ) + const { ctx, emits, saved, deps } = makeCtx(null, () => true, DEFAULT_ROUTING, true, [ + storedBadge(), + ]) + + await runBadgePoll(ctx) + + expect( + emits.map(({ records, segmentId, integrationId }) => ({ + types: types(records), + segmentId, + integrationId, + })), + ).toEqual([ + { + types: [CredlyActivityType.REPLACED_BADGE], + segmentId: 'seg-stored', + integrationId: 'int-stored', + }, + { + types: [CredlyActivityType.ISSUED_BADGE, CredlyActivityType.ACCEPTED_BADGE], + segmentId: 'seg-default', + integrationId: 'int-default', + }, + ]) + expect(deps.lockTemplate).toHaveBeenCalledTimes(1) + expect(deps.lockTemplate).toHaveBeenCalledWith('template-1', { + segmentId: 'seg-stored', + integrationId: 'int-stored', + locked: true, + }) + expect( + saved.map((row) => [row.badgeId, row.state, row.segmentId, row.replacementBadgeId]), + ).toEqual([ + ['badge-1', 'replaced', 'seg-stored', 'badge-2'], + ['badge-2', 'accepted', 'seg-default', null], + ]) + expect(LOG.info).toHaveBeenCalledWith( + expect.objectContaining({ stateChanges: 1, routed: 1 }), + expect.any(String), + ) + }) + + it('emits issued and accepted for a stored pending badge once it is accepted', async () => { + mockedFetchBadgesPage.mockResolvedValueOnce(page([badge()])) + const { ctx, emitted, emits } = makeCtx(null, () => true, DEFAULT_ROUTING, true, [ + storedBadge({ state: 'pending', stateUpdatedAt: null }), + ]) + + await runBadgePoll(ctx) + + expect(emitted).toHaveLength(1) + expect(types(emitted[0])).toEqual([ CredlyActivityType.ISSUED_BADGE, CredlyActivityType.ACCEPTED_BADGE, + ]) + expect(emits[0]).toMatchObject({ segmentId: 'seg-stored', integrationId: 'int-stored' }) + }) + + it('locks the template of a new pending badge even though nothing is emitted yet', async () => { + mockedFetchBadgesPage.mockResolvedValueOnce( + page([badge({ state: 'pending', state_updated_at: null })]), + ) + const { ctx, emits, saved, deps } = makeCtx() + + await runBadgePoll(ctx) + + expect(emits).toEqual([]) + expect(deps.lockTemplate).toHaveBeenCalledWith('template-1', DEFAULT_TARGET) + expect(saved.map((row) => [row.badgeId, row.state])).toEqual([['badge-1', 'pending']]) + }) + + it('builds a state activity with the member of the first sight, not the live one', async () => { + mockedFetchBadgesPage.mockResolvedValueOnce( + page([ + badge({ + state: 'revoked', + state_updated_at: '2026-10-27T10:00:00.000Z', + recipient_email: 'new.address@example.org', + issued_to: 'M. Rossi', + }), + ]), + ) + const { ctx, emitted, saved } = makeCtx(null, () => true, DEFAULT_ROUTING, true, [ + storedBadge({ recipientEmail: 'maria.rossi@example.org', issuedTo: 'Maria Rossi' }), + ]) + + await runBadgePoll(ctx) + + const [activity] = emitted[0] as { member: { identities: { value: string }[] } }[] + expect(activity.member.identities.map((identity) => identity.value)).toEqual([ + 'maria.rossi@example.org', + 'maria.rossi@example.org', + ]) + expect(saved[0]).toMatchObject({ recipientEmail: 'maria.rossi@example.org', state: 'revoked' }) + }) + + it('skips a badge whose stored dates do not parse and keeps the rest of the page', async () => { + mockedFetchBadgesPage.mockResolvedValueOnce( + page([ + badge({ id: 'bad-expiry', expires_at: 'not a date' }), + badge({ id: 'bad-pending', state: 'pending', state_updated_at: null, issued_at: '' }), + badge({ id: 'good' }), + ]), + ) + const { ctx, emitted, saved } = makeCtx() + + await runBadgePoll(ctx) + + expect(emitted).toHaveLength(1) + expect(saved.map((row) => row.badgeId)).toEqual(['good']) + expect(LOG.info).toHaveBeenCalledWith( + expect.objectContaining({ skipped: 2, routed: 1 }), + expect.any(String), + ) + }) + + it('emits a badge listed twice on one page only once, with its last version', async () => { + mockedFetchBadgesPage.mockResolvedValueOnce( + page([badge({ state: 'pending', state_updated_at: null }), badge()]), + ) + const { ctx, emitted, saved } = makeCtx() + + await runBadgePoll(ctx) + + expect(emitted).toHaveLength(1) + expect(types(emitted[0])).toEqual([ CredlyActivityType.ISSUED_BADGE, + CredlyActivityType.ACCEPTED_BADGE, + ]) + expect(saved.map((row) => [row.badgeId, row.state])).toEqual([['badge-1', 'accepted']]) + }) + + it('neither reads nor writes the state table during a shadow run', async () => { + mockedFetchBadgesPage.mockResolvedValueOnce(page([badge()])) + const { ctx, emitted, deps } = makeCtx(null, () => true, DEFAULT_ROUTING, false, [ + storedBadge(), + ]) + + await runBadgePoll(ctx) + + expect(types(emitted[0])).toEqual([ CredlyActivityType.ISSUED_BADGE, - CredlyActivityType.REVOKED_BADGE, + CredlyActivityType.ACCEPTED_BADGE, ]) + expect(deps.findBadges).not.toHaveBeenCalled() + expect(deps.saveBadges).not.toHaveBeenCalled() }) it('skips a badge the mapper cannot process and keeps the rest of the page', async () => { diff --git a/services/libs/connectors/src/connectors/credly/syncs/badgePoll.ts b/services/libs/connectors/src/connectors/credly/syncs/badgePoll.ts index 4117981cb0..9ac4a16030 100644 --- a/services/libs/connectors/src/connectors/credly/syncs/badgePoll.ts +++ b/services/libs/connectors/src/connectors/credly/syncs/badgePoll.ts @@ -1,3 +1,5 @@ +import type { ICredlyBadgeInput, ICredlyBadgeRow } from '@crowd/data-access-layer/src/credly/badges' + import { ProviderUnavailableError } from '../../../http/errors' import { assertCredentialKind } from '../../../types' import type { SyncContext, SyncOutcome } from '../../../types' @@ -6,9 +8,9 @@ import type { CredlyBadge } from '../badge' import type { CredlyDeps, CredlyTemplateTarget } from '../deps' import { credlyActivitySchema, - expiredBadgeActivity, issuedBadgeActivity, stateBadgeActivity, + toIsoTimestamp, } from '../mapper' import type { CredlyActivity } from '../mapper' @@ -92,16 +94,45 @@ function isCredlyServerError(err: unknown): boolean { return err instanceof ProviderUnavailableError && (err.options?.status ?? 0) >= 500 } -function badgeActivities(badge: CredlyBadge, nowMs: number): CredlyActivity[] { +function newBadgeActivities(badge: CredlyBadge): CredlyActivity[] { const activities = [issuedBadgeActivity(badge)] const state = stateBadgeActivity(badge) if (state) activities.push(state) - // TODO(linuxfoundation/lfx-cdp-insights-ops#873): expiries after the last state change need the state table. - const expired = expiredBadgeActivity(badge) - if (expired && Date.parse(expired.timestamp) <= nowMs) activities.push(expired) return activities.map((activity) => credlyActivitySchema.parse(activity)) } +function stateChangeActivities(badge: CredlyBadge): CredlyActivity[] { + const state = stateBadgeActivity(badge) + return state ? [credlyActivitySchema.parse(state)] : [] +} + +function badgeRow(badge: CredlyBadge, target: CredlyTemplateTarget): ICredlyBadgeInput { + return { + badgeId: badge.id, + badgeTemplateId: badge.badge_template.id, + badgeTemplateName: badge.badge_template.name, + badgeTemplateUrl: badge.badge_template.url ?? null, + state: badge.state, + stateUpdatedAt: badge.state_updated_at ? toIsoTimestamp(badge.state_updated_at) : null, + issuedAt: toIsoTimestamp(badge.issued_at), + expiresAt: badge.expires_at ? toIsoTimestamp(badge.expires_at) : null, + replacementBadgeId: badge.replacement_badge_id ?? null, + replacesBadgeId: badge.replaces_badge_id ?? null, + segmentId: target.segmentId, + integrationId: target.integrationId, + recipientEmail: badge.recipient_email.trim().toLowerCase(), + issuedTo: badge.issued_to ?? null, + } +} + +function storedTarget(row: ICredlyBadgeRow): CredlyTemplateTarget { + return { segmentId: row.segmentId, integrationId: row.integrationId, locked: true } +} + +function withFirstSight(badge: CredlyBadge, stored: ICredlyBadgeRow): CredlyBadge { + return { ...badge, recipient_email: stored.recipientEmail, issued_to: stored.issuedTo } +} + export async function runBadgePoll(ctx: SyncContext): Promise { const credential = await ctx.getCredential() assertCredentialKind(credential, 'credly-oauth-client') @@ -127,6 +158,9 @@ export async function runBadgePoll(ctx: SyncContext): Promise): Promise { - const byTarget = new Map< + const lastByBadgeId = new Map(badges.map((badge) => [badge.id, badge])) + const routed = [...lastByBadgeId.values()].filter((badge) => + routing.targets.has(badge.badge_template.id), + ) + counters.held += lastByBadgeId.size - routed.length + const known = new Map() + if (ctx.emitEnabled && routed.length > 0) { + for (const row of await ctx.deps.findBadges(routed.map((badge) => badge.id))) { + known.set(row.badgeId, row) + } + } + + const groups = new Map< string, - { target: CredlyTemplateTarget; templateIds: Set; activities: CredlyActivity[] } - >() - for (const badge of badges) { - const target = routing.targets.get(badge.badge_template.id) - if (!target) { - counters.held += 1 - continue + { + target: CredlyTemplateTarget + templateIds: Set + activities: CredlyActivity[] + rows: ICredlyBadgeInput[] } + >() + for (const badge of routed) { + const stored = known.get(badge.id) + const target = stored ? storedTarget(stored) : routing.targets.get(badge.badge_template.id)! + const seen = stored ? withFirstSight(badge, stored) : badge let activities: CredlyActivity[] + let row: ICredlyBadgeInput try { - activities = badgeActivities(badge, startedAtMs) + row = badgeRow(seen, target) + if (!stored || stored.state === 'pending') { + activities = seen.state === 'pending' ? [] : newBadgeActivities(seen) + } else { + activities = stored.state === seen.state ? [] : stateChangeActivities(seen) + } } catch (err) { counters.skipped += 1 ctx.log.warn( @@ -173,17 +230,29 @@ export async function runBadgePoll(ctx: SyncContext): Promise 0) counters.stateChanges += 1 + else counters.unchanged += 1 + } else if (activities.length > 0) counters.routed += 1 + else counters.pendingStored += 1 + const key = `${target.integrationId}:${target.segmentId}` - const group = byTarget.get(key) ?? { target, templateIds: new Set(), activities: [] } + const group = groups.get(key) ?? { + target, + templateIds: new Set(), + activities: [], + rows: [], + } group.activities.push(...activities) group.templateIds.add(badge.badge_template.id) - byTarget.set(key, group) + group.rows.push(row) + groups.set(key, group) counters.activities += activities.length - counters.routed += 1 } - for (const { target, templateIds, activities } of byTarget.values()) { + for (const { target, templateIds, activities, rows } of groups.values()) { await lockBeforeEmit(templateIds, target) - await ctx.emit(activities, target) + if (activities.length > 0) await ctx.emit(activities, target) + if (ctx.emitEnabled) await ctx.deps.saveBadges(rows) } counters.badges += badges.length } diff --git a/services/libs/data-access-layer/src/credly/badges.test.ts b/services/libs/data-access-layer/src/credly/badges.test.ts index 2699783e9f..d101e12151 100644 --- a/services/libs/data-access-layer/src/credly/badges.test.ts +++ b/services/libs/data-access-layer/src/credly/badges.test.ts @@ -5,7 +5,12 @@ import type { QueryExecutor } from '@crowd/database' import { withQx } from '@crowd/test-kit/db' import type { ICredlyBadgeInput } from './badges' -import { listDueCredlyExpiries, markCredlyExpiriesEmitted, upsertCredlyBadges } from './badges' +import { + findCredlyBadges, + listDueCredlyExpiries, + markCredlyExpiriesEmitted, + upsertCredlyBadges, +} from './badges' import { findOrCreateCredlyIntegration } from './index' const test = withQx(base) @@ -31,6 +36,8 @@ function badge( return { badgeId, badgeTemplateId: 'tpl-cka', + badgeTemplateName: 'CKA: Certified Kubernetes Administrator', + badgeTemplateUrl: 'https://www.credly.com/org/the-linux-foundation/badge/cka', state: 'accepted', stateUpdatedAt: '2026-10-20T10:00:00.000Z', issuedAt: '2026-10-20T00:00:00.000Z', @@ -99,11 +106,14 @@ describe('credly badges', () => { expect(stored.state).toBe('replaced') expect(stored.segmentId).toBe(first.segmentId) expect(millis(stored.updatedAt)).toBeGreaterThan(millis(BACKDATED)) - expect( - await qx.selectOne( - `SELECT "replacementBadgeId", "integrationId" FROM integration."credlyBadges" WHERE "badgeId" = 'b-2'`, - ), - ).toEqual({ replacementBadgeId: 'b-3', integrationId: first.integrationId }) + const [row] = await findCredlyBadges(qx, ['b-2', 'b-unknown']) + expect(row).toMatchObject({ + badgeId: 'b-2', + replacementBadgeId: 'b-3', + integrationId: first.integrationId, + badgeTemplateName: 'CKA: Certified Kubernetes Administrator', + }) + expect(await findCredlyBadges(qx, [])).toEqual([]) }) test('a page with the same badge twice keeps the last version', async ({ qx }) => { diff --git a/services/libs/data-access-layer/src/credly/badges.ts b/services/libs/data-access-layer/src/credly/badges.ts index a2da537b56..64bbdeb656 100644 --- a/services/libs/data-access-layer/src/credly/badges.ts +++ b/services/libs/data-access-layer/src/credly/badges.ts @@ -5,6 +5,8 @@ export type CredlyBadgeState = 'pending' | 'accepted' | 'rejected' | 'revoked' | export interface ICredlyBadgeInput { badgeId: string badgeTemplateId: string + badgeTemplateName: string + badgeTemplateUrl: string | null state: CredlyBadgeState stateUpdatedAt: string | null issuedAt: string @@ -24,9 +26,20 @@ export interface ICredlyBadgeRow extends ICredlyBadgeInput { } const ROW_COLUMNS = ` - "badgeId", "badgeTemplateId", state, "stateUpdatedAt", "issuedAt", "expiresAt", - "replacementBadgeId", "replacesBadgeId", "segmentId", "integrationId", "recipientEmail", - "issuedTo", "expiredEmittedAt", "firstSeenAt", "lastSeenAt"` + "badgeId", "badgeTemplateId", "badgeTemplateName", "badgeTemplateUrl", state, "stateUpdatedAt", + "issuedAt", "expiresAt", "replacementBadgeId", "replacesBadgeId", "segmentId", "integrationId", + "recipientEmail", "issuedTo", "expiredEmittedAt", "firstSeenAt", "lastSeenAt"` + +export async function findCredlyBadges( + qx: QueryExecutor, + badgeIds: string[], +): Promise { + if (badgeIds.length === 0) return [] + return qx.select( + `SELECT ${ROW_COLUMNS} FROM integration."credlyBadges" WHERE "badgeId" = ANY($(badgeIds)::text[])`, + { badgeIds }, + ) +} // Segment and earner email stay those of the first sight for the badge's whole life: every later // activity must land on the same member and segment as its issued-badge. @@ -40,12 +53,14 @@ export async function upsertCredlyBadges( await qx.result( ` INSERT INTO integration."credlyBadges" AS b - ("badgeId", "badgeTemplateId", state, "stateUpdatedAt", "issuedAt", "expiresAt", - "replacementBadgeId", "replacesBadgeId", "segmentId", "integrationId", "recipientEmail", - "issuedTo") + ("badgeId", "badgeTemplateId", "badgeTemplateName", "badgeTemplateUrl", state, + "stateUpdatedAt", "issuedAt", "expiresAt", "replacementBadgeId", "replacesBadgeId", + "segmentId", "integrationId", "recipientEmail", "issuedTo") SELECT * FROM unnest( $(badgeIds)::text[], $(badgeTemplateIds)::text[], + $(badgeTemplateNames)::text[], + $(badgeTemplateUrls)::text[], $(states)::text[], $(stateUpdatedAts)::timestamptz[], $(issuedAts)::timestamptz[], @@ -58,6 +73,8 @@ export async function upsertCredlyBadges( $(issuedTos)::text[] ) ON CONFLICT ("badgeId") DO UPDATE SET + "badgeTemplateName" = EXCLUDED."badgeTemplateName", + "badgeTemplateUrl" = EXCLUDED."badgeTemplateUrl", state = EXCLUDED.state, "stateUpdatedAt" = EXCLUDED."stateUpdatedAt", "expiresAt" = EXCLUDED."expiresAt", @@ -79,6 +96,8 @@ export async function upsertCredlyBadges( { badgeIds: rows.map((r) => r.badgeId), badgeTemplateIds: rows.map((r) => r.badgeTemplateId), + badgeTemplateNames: rows.map((r) => r.badgeTemplateName), + badgeTemplateUrls: rows.map((r) => r.badgeTemplateUrl), states: rows.map((r) => r.state), stateUpdatedAts: rows.map((r) => r.stateUpdatedAt), issuedAts: rows.map((r) => r.issuedAt),