@learncard/network-brain-service 4.0.4 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/.env.example +14 -0
  2. package/CHANGELOG.md +41 -0
  3. package/config/config.dev.json +5 -0
  4. package/config/config.example.json +5 -0
  5. package/config/config.production.json +5 -0
  6. package/config/config.scouts.dev.json +5 -0
  7. package/config/config.scouts.production.json +5 -0
  8. package/dist/accesslayer/campaign/audience.d.ts +7 -0
  9. package/dist/accesslayer/campaign/audience.d.ts.map +1 -0
  10. package/dist/accesslayer/campaign/create.d.ts +3 -0
  11. package/dist/accesslayer/campaign/create.d.ts.map +1 -0
  12. package/dist/accesslayer/campaign/delete.d.ts +2 -0
  13. package/dist/accesslayer/campaign/delete.d.ts.map +1 -0
  14. package/dist/accesslayer/campaign/read.d.ts +6 -0
  15. package/dist/accesslayer/campaign/read.d.ts.map +1 -0
  16. package/dist/accesslayer/campaign/shared.d.ts +16 -0
  17. package/dist/accesslayer/campaign/shared.d.ts.map +1 -0
  18. package/dist/accesslayer/campaign/state.d.ts +4 -0
  19. package/dist/accesslayer/campaign/state.d.ts.map +1 -0
  20. package/dist/accesslayer/campaign/update.d.ts +3 -0
  21. package/dist/accesslayer/campaign/update.d.ts.map +1 -0
  22. package/dist/accesslayer/credential-share-previews.d.ts +5 -0
  23. package/dist/accesslayer/credential-share-previews.d.ts.map +1 -0
  24. package/dist/accesslayer/profile/delete.d.ts.map +1 -1
  25. package/dist/accesslayer/skill/read.d.ts +12 -2
  26. package/dist/accesslayer/skill/read.d.ts.map +1 -1
  27. package/dist/accesslayer/skill/update.d.ts +11 -1
  28. package/dist/accesslayer/skill/update.d.ts.map +1 -1
  29. package/dist/app.d.ts +4 -0
  30. package/dist/app.d.ts.map +1 -1
  31. package/dist/cache/app-store.caches.d.ts +2 -1
  32. package/dist/cache/app-store.caches.d.ts.map +1 -1
  33. package/dist/config/environment.d.ts +268 -178
  34. package/dist/config/environment.d.ts.map +1 -1
  35. package/dist/config/skillEmbedding.d.ts +70 -0
  36. package/dist/config/skillEmbedding.d.ts.map +1 -0
  37. package/dist/config/skillEmbedding.neo4j.test.d.ts +2 -0
  38. package/dist/config/skillEmbedding.neo4j.test.d.ts.map +1 -0
  39. package/dist/config/stageConfig.d.ts +20 -0
  40. package/dist/config/stageConfig.d.ts.map +1 -1
  41. package/dist/constants/campaigns.d.ts +3 -0
  42. package/dist/constants/campaigns.d.ts.map +1 -0
  43. package/dist/didkit_wasm_bg.wasm +0 -0
  44. package/dist/helpers/bedrock-embedding.helpers.d.ts +40 -0
  45. package/dist/helpers/bedrock-embedding.helpers.d.ts.map +1 -0
  46. package/dist/helpers/bedrock-embedding.helpers.test.d.ts +2 -0
  47. package/dist/helpers/bedrock-embedding.helpers.test.d.ts.map +1 -0
  48. package/dist/helpers/bedrock-embedding.smoke.test.d.ts +2 -0
  49. package/dist/helpers/bedrock-embedding.smoke.test.d.ts.map +1 -0
  50. package/dist/helpers/skill-embedding.helpers.d.ts +65 -7
  51. package/dist/helpers/skill-embedding.helpers.d.ts.map +1 -1
  52. package/dist/helpers/skill-embedding.helpers.test.d.ts +2 -0
  53. package/dist/helpers/skill-embedding.helpers.test.d.ts.map +1 -0
  54. package/dist/index.js +2 -2
  55. package/dist/models/Campaign.d.ts +10 -0
  56. package/dist/models/Campaign.d.ts.map +1 -0
  57. package/dist/models/CredentialSharePreview.d.ts +14 -0
  58. package/dist/models/CredentialSharePreview.d.ts.map +1 -0
  59. package/dist/models/Integration.d.ts +1 -1
  60. package/dist/models/Profile.d.ts.map +1 -1
  61. package/dist/models/campaign-constraints.d.ts +2 -0
  62. package/dist/models/campaign-constraints.d.ts.map +1 -0
  63. package/dist/models/index.d.ts +2 -0
  64. package/dist/models/index.d.ts.map +1 -1
  65. package/dist/publish-metafile.json +1 -1
  66. package/dist/routes/app-store.d.ts +1 -1
  67. package/dist/routes/boosts.d.ts +24 -4
  68. package/dist/routes/boosts.d.ts.map +1 -1
  69. package/dist/routes/campaigns.d.ts +1411 -0
  70. package/dist/routes/campaigns.d.ts.map +1 -0
  71. package/dist/routes/contracts.d.ts +19 -6
  72. package/dist/routes/contracts.d.ts.map +1 -1
  73. package/dist/routes/credential-share-previews.d.ts +39 -0
  74. package/dist/routes/credential-share-previews.d.ts.map +1 -0
  75. package/dist/routes/credentials.d.ts +4 -1
  76. package/dist/routes/credentials.d.ts.map +1 -1
  77. package/dist/routes/inbox.d.ts +5 -5
  78. package/dist/routes/index.d.ts +4 -0
  79. package/dist/routes/index.d.ts.map +1 -1
  80. package/dist/routes/integrations.d.ts +3 -3
  81. package/dist/routes/profile-manager.d.ts +4 -0
  82. package/dist/routes/profile-manager.d.ts.map +1 -1
  83. package/dist/routes/profiles.d.ts +44 -0
  84. package/dist/routes/profiles.d.ts.map +1 -1
  85. package/dist/routes/skill-frameworks.d.ts +1 -0
  86. package/dist/routes/skill-frameworks.d.ts.map +1 -1
  87. package/dist/routes/skills-semantic-search.test.d.ts +2 -0
  88. package/dist/routes/skills-semantic-search.test.d.ts.map +1 -0
  89. package/dist/routes/skills.d.ts.map +1 -1
  90. package/dist/routes/test.d.ts +3 -1
  91. package/dist/routes/test.d.ts.map +1 -1
  92. package/dist/seed/seedSkillFrameworks.d.ts +1 -0
  93. package/dist/seed/seedSkillFrameworks.d.ts.map +1 -1
  94. package/dist/seed/seedSkillFrameworks.embeddings.test.d.ts +2 -0
  95. package/dist/seed/seedSkillFrameworks.embeddings.test.d.ts.map +1 -0
  96. package/dist/types/app-manifest.d.ts +2 -2
  97. package/dist/types/campaign.d.ts +19 -0
  98. package/dist/types/campaign.d.ts.map +1 -0
  99. package/dist/types/consentflowcontract.d.ts +1 -1
  100. package/dist/types/integration.d.ts +3 -3
  101. package/dist/types/profile.d.ts +9 -0
  102. package/dist/types/profile.d.ts.map +1 -1
  103. package/dist/types/skill.d.ts.map +1 -1
  104. package/package.json +20 -17
  105. package/project.json +14 -0
  106. package/scripts/skill-embeddings-backfill.ts +161 -0
  107. package/serverless-local.yml +4 -0
  108. package/serverless.function-env.cjs +3 -0
  109. package/serverless.local.yml +4 -0
  110. package/serverless.yml +12 -1
  111. package/src/accesslayer/campaign/README.md +174 -0
  112. package/src/accesslayer/campaign/audience.ts +152 -0
  113. package/src/accesslayer/campaign/create.ts +47 -0
  114. package/src/accesslayer/campaign/delete.ts +18 -0
  115. package/src/accesslayer/campaign/read.ts +125 -0
  116. package/src/accesslayer/campaign/shared.ts +92 -0
  117. package/src/accesslayer/campaign/state.ts +80 -0
  118. package/src/accesslayer/campaign/update.ts +50 -0
  119. package/src/accesslayer/credential-share-previews.ts +76 -0
  120. package/src/accesslayer/profile/delete.ts +6 -0
  121. package/src/accesslayer/skill/read.ts +56 -16
  122. package/src/accesslayer/skill/update.ts +43 -1
  123. package/src/app.ts +9 -0
  124. package/src/config/environment.test.ts +208 -0
  125. package/src/config/environment.ts +166 -11
  126. package/src/config/skillEmbedding.neo4j.test.ts +138 -0
  127. package/src/config/skillEmbedding.ts +202 -0
  128. package/src/constants/campaigns.ts +9 -0
  129. package/src/helpers/bedrock-embedding.helpers.test.ts +208 -0
  130. package/src/helpers/bedrock-embedding.helpers.ts +243 -0
  131. package/src/helpers/bedrock-embedding.smoke.test.ts +53 -0
  132. package/src/helpers/skill-embedding.helpers.test.ts +1044 -0
  133. package/src/helpers/skill-embedding.helpers.ts +611 -123
  134. package/src/models/Campaign.ts +32 -0
  135. package/src/models/CredentialSharePreview.ts +30 -0
  136. package/src/models/Profile.ts +1 -0
  137. package/src/models/campaign-constraints.ts +22 -0
  138. package/src/models/index.ts +29 -1
  139. package/src/routes/campaigns.ts +179 -0
  140. package/src/routes/credential-share-previews.ts +74 -0
  141. package/src/routes/profiles.ts +7 -0
  142. package/src/routes/skills-semantic-search.test.ts +168 -0
  143. package/src/routes/skills.ts +42 -6
  144. package/src/seed/seedSkillFrameworks.embeddings.test.ts +82 -0
  145. package/src/seed/seedSkillFrameworks.test.ts +6 -0
  146. package/src/seed/seedSkillFrameworks.ts +83 -18
  147. package/src/types/campaign.ts +17 -0
  148. package/src/types/skill.ts +6 -0
  149. package/test/campaigns.spec.ts +789 -0
  150. package/test/credential-share-previews.spec.ts +97 -0
  151. package/test/verification/campaigns.ts +49 -0
  152. package/vitest.campaigns.config.ts +21 -0
  153. package/vitest.config.ts +10 -0
  154. package/vitest.integration.config.ts +1 -0
@@ -0,0 +1,47 @@
1
+ import { TRPCError } from '@trpc/server';
2
+ import {
3
+ CampaignValidator,
4
+ CreateCampaignInputValidator,
5
+ type Campaign,
6
+ type CreateCampaignInput,
7
+ } from '@learncard/types';
8
+
9
+ import { neogma } from '@instance';
10
+ import { traceDb } from '@tracing';
11
+ import { ensureCampaignConstraints } from '../../models/campaign-constraints';
12
+ import { campaignProperties } from './shared';
13
+
14
+ export const createCampaign = async (
15
+ input: CreateCampaignInput,
16
+ createdByProfileId: string
17
+ ): Promise<Campaign> => {
18
+ const parsed = CreateCampaignInputValidator.parse(input);
19
+ const now = new Date().toISOString();
20
+ const campaign = CampaignValidator.parse({
21
+ ...parsed,
22
+ message: { ...parsed.message, id: parsed.id },
23
+ createdAt: now,
24
+ updatedAt: now,
25
+ });
26
+ await ensureCampaignConstraints();
27
+ try {
28
+ await traceDb('campaign:create', () =>
29
+ neogma.queryRunner.run('CREATE (c:Campaign) SET c = $properties', {
30
+ properties: { ...campaignProperties(campaign), createdByProfileId },
31
+ })
32
+ );
33
+ } catch (error) {
34
+ if (
35
+ (error as { code?: string })?.code ===
36
+ 'Neo.ClientError.Schema.ConstraintValidationFailed'
37
+ ) {
38
+ throw new TRPCError({
39
+ code: 'CONFLICT',
40
+ message: 'Campaign ID already exists',
41
+ cause: error,
42
+ });
43
+ }
44
+ throw error;
45
+ }
46
+ return campaign;
47
+ };
@@ -0,0 +1,18 @@
1
+ import { TRPCError } from '@trpc/server';
2
+
3
+ import { lockCampaign, withCampaignTransaction } from './shared';
4
+
5
+ export const deleteCampaign = async (campaignId: string): Promise<boolean> =>
6
+ withCampaignTransaction('campaign:delete', async transaction => {
7
+ const campaign = await lockCampaign(transaction, campaignId);
8
+ if (campaign.status === 'live' || campaign.status === 'paused') {
9
+ throw new TRPCError({
10
+ code: 'BAD_REQUEST',
11
+ message: 'Archive live or paused campaigns before deleting them',
12
+ });
13
+ }
14
+ await transaction.run('MATCH (c:Campaign {id: $campaignId}) DETACH DELETE c', {
15
+ campaignId,
16
+ });
17
+ return true;
18
+ });
@@ -0,0 +1,125 @@
1
+ import { TRPCError } from '@trpc/server';
2
+ import { int, isInt } from 'neo4j-driver';
3
+ import {
4
+ CampaignUserStateValidator,
5
+ type Campaign,
6
+ type CampaignStats,
7
+ type ListCampaignsInput,
8
+ type MyCampaign,
9
+ type PaginatedCampaigns,
10
+ } from '@learncard/types';
11
+
12
+ import { neogma } from '@instance';
13
+ import { traceDb } from '@tracing';
14
+ import {
15
+ campaignAgePredicate,
16
+ campaignTimeParams,
17
+ liveCampaignPredicate,
18
+ parseCampaign,
19
+ safeParseCampaign,
20
+ } from './shared';
21
+
22
+ export const getCampaign = async (campaignId: string): Promise<Campaign> => {
23
+ const result = await traceDb('campaign:get', () =>
24
+ neogma.queryRunner.run(
25
+ 'MATCH (c:Campaign {id: $campaignId}) RETURN c.definition AS definition',
26
+ { campaignId }
27
+ )
28
+ );
29
+ if (!result.records.length)
30
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Campaign not found' });
31
+ return parseCampaign(result.records[0]!.get('definition'));
32
+ };
33
+
34
+ export const listCampaigns = async ({
35
+ cursor,
36
+ limit = 100,
37
+ status,
38
+ }: ListCampaignsInput): Promise<PaginatedCampaigns> => {
39
+ const result = await traceDb('campaign:list', () =>
40
+ neogma.queryRunner.run(
41
+ `MATCH (c:Campaign) WHERE ($cursor IS NULL OR c.id > $cursor)
42
+ AND ($status IS NULL OR c.status = $status)
43
+ RETURN c.definition AS definition ORDER BY c.id LIMIT $limit`,
44
+ { cursor: cursor ?? null, status: status ?? null, limit: int(limit + 1) }
45
+ )
46
+ );
47
+ const rows = result.records.map(record => parseCampaign(record.get('definition')));
48
+ const hasMore = rows.length > limit;
49
+ const records = rows.slice(0, limit);
50
+ return { records, hasMore, cursor: hasMore ? records.at(-1)?.id : undefined };
51
+ };
52
+
53
+ export const getLiveCampaignsForProfile = async (
54
+ profileId: string,
55
+ now = new Date()
56
+ ): Promise<MyCampaign[]> => {
57
+ const result = await traceDb('campaign:getMyCampaigns', () =>
58
+ neogma.queryRunner.run(
59
+ `MATCH (p:Profile {profileId: $profileId}), (c:Campaign)
60
+ WHERE ${liveCampaignPredicate} AND ${campaignAgePredicate}
61
+ AND (c.selfEnroll = true OR EXISTS { MATCH (p)-[:IN_CAMPAIGN_AUDIENCE]->(c) })
62
+ OPTIONAL MATCH (p)-[state:CAMPAIGN_STATE]->(c)
63
+ RETURN c.id AS campaignId, c.definition AS definition, properties(state) AS state
64
+ ORDER BY c.id`,
65
+ { profileId, ...campaignTimeParams(now) }
66
+ )
67
+ );
68
+ return result.records.flatMap(record => {
69
+ const campaign = safeParseCampaign(record.get('definition'));
70
+ if (!campaign) {
71
+ console.warn('campaign:getMyCampaigns skipped invalid stored definition', {
72
+ campaignId: record.get('campaignId'),
73
+ });
74
+ return [];
75
+ }
76
+ if (!campaign.channels.includes('inApp') || !campaign.message.enabled) return [];
77
+ const state = Object.fromEntries(
78
+ Object.entries(record.get('state') ?? {}).map(([key, value]) => [
79
+ key,
80
+ isInt(value) ? value.toNumber() : value,
81
+ ])
82
+ );
83
+ return [
84
+ {
85
+ campaign: {
86
+ id: campaign.id,
87
+ message: campaign.message,
88
+ startsAt: campaign.startsAt,
89
+ endsAt: campaign.endsAt,
90
+ selfEnroll: campaign.selfEnroll,
91
+ },
92
+ state: CampaignUserStateValidator.parse(state),
93
+ },
94
+ ];
95
+ });
96
+ };
97
+
98
+ export const getCampaignStats = async (campaignId: string): Promise<CampaignStats> => {
99
+ await getCampaign(campaignId);
100
+ const result = await traceDb('campaign:stats', () =>
101
+ neogma.queryRunner.run(
102
+ `MATCH (c:Campaign {id: $campaignId})
103
+ OPTIONAL MATCH (p:Profile)-[audience:IN_CAMPAIGN_AUDIENCE]->(c)
104
+ OPTIONAL MATCH (p)-[state:CAMPAIGN_STATE]->(c)
105
+ RETURN count(DISTINCT p) AS audience,
106
+ count(DISTINCT CASE WHEN audience.source = 'selfEnrolled' THEN p END) AS enrolled,
107
+ count(DISTINCT CASE WHEN state.notifiedAt IS NOT NULL THEN p END) AS notified,
108
+ count(DISTINCT CASE WHEN state.firstShownAt IS NOT NULL THEN p END) AS shown,
109
+ count(DISTINCT CASE WHEN state.clickedAt IS NOT NULL THEN p END) AS clicked,
110
+ count(DISTINCT CASE WHEN state.dismissedAt IS NOT NULL THEN p END) AS dismissed,
111
+ count(DISTINCT CASE WHEN state.convertedAt IS NOT NULL THEN p END) AS converted`,
112
+ { campaignId }
113
+ )
114
+ );
115
+ const record = result.records[0]!;
116
+ return {
117
+ audience: record.get('audience').toNumber(),
118
+ enrolled: record.get('enrolled').toNumber(),
119
+ notified: record.get('notified').toNumber(),
120
+ shown: record.get('shown').toNumber(),
121
+ clicked: record.get('clicked').toNumber(),
122
+ dismissed: record.get('dismissed').toNumber(),
123
+ converted: record.get('converted').toNumber(),
124
+ };
125
+ };
@@ -0,0 +1,92 @@
1
+ import { TRPCError } from '@trpc/server';
2
+ import { CampaignValidator, type Campaign } from '@learncard/types';
3
+ import type { ManagedTransaction } from 'neo4j-driver';
4
+
5
+ import { neogma } from '@instance';
6
+ import { traceDb } from '@tracing';
7
+
8
+ const knownBirthdayPredicate = `p.dob IS NOT NULL
9
+ AND p.dob =~ '[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])'`;
10
+ const managedProfilePredicate = `(EXISTS { MATCH (p)-[:MANAGED_BY]->(:Profile) }
11
+ OR EXISTS { MATCH (:ProfileManager)-[:MANAGES]->(p) })`;
12
+ const serviceProfilePredicate = `(p.isServiceProfile = true AND coalesce(p.type, '') <> 'child')`;
13
+ export const adultProfilePredicate = `${knownBirthdayPredicate}
14
+ AND coalesce(p.type, '') <> 'child' AND p.dob <= $adultCutoff
15
+ AND (${serviceProfilePredicate} OR NOT ${managedProfilePredicate})`;
16
+ export const campaignAgePredicate = `(${knownBirthdayPredicate}
17
+ AND (c.excludeMinors = false OR (${adultProfilePredicate})))`;
18
+ export const liveCampaignPredicate = `c.status = 'live'
19
+ AND (c.startsAt IS NULL OR c.startsAt <= $now)
20
+ AND (c.endsAt IS NULL OR c.endsAt > $now)`;
21
+
22
+ export const campaignTimeParams = (now = new Date()): { now: string; adultCutoff: string } => {
23
+ const timestamp = now.toISOString();
24
+ // Compare calendar dates, not elapsed milliseconds. Keeping the month/day avoids
25
+ // JS rolling Feb 29 into March 1 and admitting March 1 birthdays a day early.
26
+ const adultCutoff = `${now.getUTCFullYear() - 18}${timestamp.slice(4, 10)}`;
27
+ return { now: timestamp, adultCutoff };
28
+ };
29
+
30
+ export const parseCampaign = (definition: string): Campaign =>
31
+ CampaignValidator.parse(JSON.parse(definition));
32
+
33
+ export const safeParseCampaign = (definition: unknown): Campaign | undefined => {
34
+ if (typeof definition !== 'string') return undefined;
35
+ try {
36
+ const result = CampaignValidator.safeParse(JSON.parse(definition));
37
+ return result.success ? result.data : undefined;
38
+ } catch {
39
+ return undefined;
40
+ }
41
+ };
42
+
43
+ export const campaignProperties = (campaign: Campaign): Record<string, unknown> => ({
44
+ id: campaign.id,
45
+ name: campaign.name,
46
+ status: campaign.status,
47
+ excludeMinors: campaign.excludeMinors,
48
+ selfEnroll: campaign.selfEnroll,
49
+ createdAt: campaign.createdAt,
50
+ updatedAt: campaign.updatedAt,
51
+ definition: JSON.stringify(campaign),
52
+ startsAt: campaign.startsAt ?? null,
53
+ endsAt: campaign.endsAt ?? null,
54
+ });
55
+
56
+ export const withCampaignTransaction = async <Result>(
57
+ name: string,
58
+ work: (transaction: ManagedTransaction) => Promise<Result>
59
+ ): Promise<Result> =>
60
+ traceDb(name, async () => {
61
+ const session = neogma.queryRunner.getDriver().session();
62
+ try {
63
+ return await session.executeWrite(work);
64
+ } finally {
65
+ await session.close();
66
+ }
67
+ });
68
+
69
+ /** Serialize campaign edits/audience enrollment before reading their current definition. */
70
+ export const lockCampaign = async (
71
+ transaction: ManagedTransaction,
72
+ campaignId: string
73
+ ): Promise<Campaign> => {
74
+ const result = await transaction.run(
75
+ `MATCH (c:Campaign {id: $campaignId})
76
+ SET c.lockVersion = coalesce(c.lockVersion, 0) + 1
77
+ RETURN c.definition AS definition`,
78
+ { campaignId }
79
+ );
80
+ if (!result.records.length)
81
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Campaign not found' });
82
+ return parseCampaign(result.records[0]!.get('definition'));
83
+ };
84
+
85
+ export const assertCampaignEditable = (campaign: Campaign): void => {
86
+ if (campaign.status === 'archived') {
87
+ throw new TRPCError({
88
+ code: 'BAD_REQUEST',
89
+ message: 'Archived campaigns cannot be changed',
90
+ });
91
+ }
92
+ };
@@ -0,0 +1,80 @@
1
+ import { TRPCError } from '@trpc/server';
2
+ import { CampaignEventValidator, type CampaignEvent } from '@learncard/types';
3
+
4
+ import {
5
+ adultProfilePredicate,
6
+ campaignAgePredicate,
7
+ campaignTimeParams,
8
+ liveCampaignPredicate,
9
+ lockCampaign,
10
+ withCampaignTransaction,
11
+ } from './shared';
12
+
13
+ export const enrollInCampaign = async (profileId: string, campaignId: string): Promise<boolean> =>
14
+ withCampaignTransaction('campaign:enroll', async transaction => {
15
+ const campaign = await lockCampaign(transaction, campaignId);
16
+ const params = campaignTimeParams();
17
+ if (
18
+ !campaign.selfEnroll ||
19
+ campaign.status !== 'live' ||
20
+ !campaign.message.enabled ||
21
+ !campaign.channels.includes('inApp') ||
22
+ (campaign.startsAt && campaign.startsAt > params.now) ||
23
+ (campaign.endsAt && campaign.endsAt <= params.now)
24
+ ) {
25
+ throw new TRPCError({
26
+ code: 'FORBIDDEN',
27
+ message: 'Campaign is not accepting enrollment',
28
+ });
29
+ }
30
+ const result = await transaction.run(
31
+ `MATCH (p:Profile {profileId: $profileId}), (c:Campaign {id: $campaignId})
32
+ WHERE ${adultProfilePredicate}
33
+ MERGE (p)-[audience:IN_CAMPAIGN_AUDIENCE]->(c)
34
+ ON CREATE SET audience.addedAt = $now, audience.source = 'selfEnrolled'
35
+ RETURN p.profileId AS profileId`,
36
+ { profileId, campaignId, ...params }
37
+ );
38
+ if (!result.records.length)
39
+ throw new TRPCError({
40
+ code: 'FORBIDDEN',
41
+ message: 'Profile is not eligible for enrollment',
42
+ });
43
+ return true;
44
+ });
45
+
46
+ export const recordCampaignEvent = async (
47
+ profileId: string,
48
+ campaignId: string,
49
+ rawEvent: CampaignEvent
50
+ ): Promise<boolean> => {
51
+ const event = CampaignEventValidator.parse(rawEvent);
52
+ const assignments: Record<CampaignEvent, string> = {
53
+ shown: `state.firstShownAt = coalesce(state.firstShownAt, $now), state.lastShownAt = $now,
54
+ state.shownCount = coalesce(state.shownCount, 0) + 1`,
55
+ clicked: 'state.clickedAt = $now',
56
+ dismissed: 'state.dismissedAt = $now',
57
+ converted: 'state.convertedAt = $now',
58
+ };
59
+ return withCampaignTransaction('campaign:recordEvent', async transaction => {
60
+ const params = { profileId, campaignId, ...campaignTimeParams() };
61
+ // A profile lock prevents concurrent devices from creating duplicate state edges
62
+ // or losing shown increments, without locking the entire campaign audience.
63
+ const locked = await transaction.run(
64
+ `MATCH (p:Profile {profileId: $profileId})-[:IN_CAMPAIGN_AUDIENCE]->(c:Campaign {id: $campaignId})
65
+ WHERE ${liveCampaignPredicate} AND ${campaignAgePredicate}
66
+ SET p.campaignStateLock = coalesce(p.campaignStateLock, 0) + 1
67
+ RETURN p.profileId AS profileId`,
68
+ params
69
+ );
70
+ if (!locked.records.length) return false;
71
+ const result = await transaction.run(
72
+ `MATCH (p:Profile {profileId: $profileId})-[:IN_CAMPAIGN_AUDIENCE]->(c:Campaign {id: $campaignId})
73
+ WHERE ${liveCampaignPredicate} AND ${campaignAgePredicate}
74
+ MERGE (p)-[state:CAMPAIGN_STATE]->(c) SET ${assignments[event]}
75
+ RETURN state`,
76
+ params
77
+ );
78
+ return result.records.length > 0;
79
+ });
80
+ };
@@ -0,0 +1,50 @@
1
+ import { TRPCError } from '@trpc/server';
2
+ import {
3
+ CampaignValidator,
4
+ UpdateCampaignInputValidator,
5
+ type Campaign,
6
+ type UpdateCampaignInput,
7
+ } from '@learncard/types';
8
+
9
+ import {
10
+ assertCampaignEditable,
11
+ campaignProperties,
12
+ lockCampaign,
13
+ withCampaignTransaction,
14
+ } from './shared';
15
+
16
+ export const updateCampaign = async (input: UpdateCampaignInput): Promise<Campaign> => {
17
+ const { campaignId, updates } = UpdateCampaignInputValidator.parse(input);
18
+ return withCampaignTransaction('campaign:update', async transaction => {
19
+ const existing = await lockCampaign(transaction, campaignId);
20
+ assertCampaignEditable(existing);
21
+ const message = updates.message ?? existing.message;
22
+ const result = CampaignValidator.safeParse({
23
+ ...existing,
24
+ ...updates,
25
+ id: campaignId,
26
+ message: { ...message, id: campaignId },
27
+ notification:
28
+ updates.notification === null
29
+ ? undefined
30
+ : (updates.notification ?? existing.notification),
31
+ startsAt:
32
+ updates.startsAt === null ? undefined : (updates.startsAt ?? existing.startsAt),
33
+ endsAt: updates.endsAt === null ? undefined : (updates.endsAt ?? existing.endsAt),
34
+ updatedAt: new Date().toISOString(),
35
+ });
36
+ if (!result.success)
37
+ throw new TRPCError({ code: 'BAD_REQUEST', message: result.error.message });
38
+ if (existing.status !== 'draft' && result.data.status === 'draft') {
39
+ throw new TRPCError({
40
+ code: 'BAD_REQUEST',
41
+ message: `Campaign status cannot transition from ${existing.status} to draft`,
42
+ });
43
+ }
44
+ await transaction.run('MATCH (c:Campaign {id: $campaignId}) SET c += $properties', {
45
+ campaignId,
46
+ properties: campaignProperties(result.data),
47
+ });
48
+ return result.data;
49
+ });
50
+ };
@@ -0,0 +1,76 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { BindParam, QueryBuilder } from 'neogma';
3
+ import { neogma } from '@instance';
4
+ import { CredentialSharePreview } from '@models';
5
+ import type {
6
+ CredentialSharePreview as Preview,
7
+ PublishCredentialSharePreview,
8
+ } from '@learncard/types';
9
+
10
+ // shareUri is a client-supplied match key, not proof of presentation ownership or issuer claims.
11
+ export const createCredentialSharePreview = async (
12
+ ownerProfileId: string,
13
+ input: PublishCredentialSharePreview
14
+ ): Promise<string | null> => {
15
+ const id = input.id ?? randomUUID();
16
+ const now = new Date().toISOString();
17
+ const params = {
18
+ id,
19
+ ownerProfileId,
20
+ name: input.name,
21
+ issuer: input.issuer,
22
+ shareUri: input.shareUri,
23
+ image: input.image ?? null,
24
+ createdAt: now,
25
+ updatedAt: now,
26
+ };
27
+ const result = input.id
28
+ ? await neogma.queryRunner.run(
29
+ `MATCH (owner:Profile {profileId: $ownerProfileId})-[:PUBLISHED_CREDENTIAL_SHARE_PREVIEW]->(preview:CredentialSharePreview {id: $id, shareUri: $shareUri})
30
+ SET preview.name = $name, preview.issuer = $issuer, preview.image = $image, preview.updatedAt = $updatedAt
31
+ RETURN preview.id AS id`,
32
+ params
33
+ )
34
+ : await neogma.queryRunner.run(
35
+ `MATCH (owner:Profile {profileId: $ownerProfileId})
36
+ CREATE (preview:CredentialSharePreview {
37
+ id: $id, name: $name, issuer: $issuer, shareUri: $shareUri,
38
+ image: $image, createdAt: $createdAt, updatedAt: $updatedAt
39
+ })
40
+ CREATE (owner)-[:PUBLISHED_CREDENTIAL_SHARE_PREVIEW]->(preview)
41
+ RETURN preview.id AS id`,
42
+ params
43
+ );
44
+ return result.records[0]?.get('id') ?? null;
45
+ };
46
+
47
+ export const getCredentialSharePreview = async (
48
+ id: string,
49
+ shareUri: string
50
+ ): Promise<Preview | null> => {
51
+ const result = await new QueryBuilder(new BindParam({ id, shareUri }))
52
+ .match({ model: CredentialSharePreview, identifier: 'preview' })
53
+ .where('preview.id = $id AND preview.shareUri = $shareUri')
54
+ .return('preview')
55
+ .limit(1)
56
+ .run();
57
+ const properties = result.records[0]?.get('preview').properties;
58
+ if (!properties) return null;
59
+ return {
60
+ name: String(properties.name),
61
+ issuer: String(properties.issuer),
62
+ ...(typeof properties.image === 'string' ? { image: properties.image } : {}),
63
+ };
64
+ };
65
+
66
+ export const deleteCredentialSharePreview = async (
67
+ ownerProfileId: string,
68
+ id: string
69
+ ): Promise<boolean> => {
70
+ const result = await neogma.queryRunner.run(
71
+ `MATCH (owner:Profile {profileId: $ownerProfileId})-[:PUBLISHED_CREDENTIAL_SHARE_PREVIEW]->(preview:CredentialSharePreview {id: $id})
72
+ DETACH DELETE preview RETURN count(*) AS deleted`,
73
+ { ownerProfileId, id }
74
+ );
75
+ return Number(result.records[0]?.get('deleted') ?? 0) > 0;
76
+ };
@@ -27,5 +27,11 @@ export const deleteProfile = async (profile: ProfileType): Promise<void> => {
27
27
  { profileId: profile.profileId }
28
28
  );
29
29
 
30
+ // Delete share previews owned by this profile to prevent orphaned nodes.
31
+ await neogma.queryRunner.run(
32
+ `MATCH (p:Profile {profileId: $profileId})-[:PUBLISHED_CREDENTIAL_SHARE_PREVIEW]->(preview:CredentialSharePreview)
33
+ DETACH DELETE preview`,
34
+ { profileId: profile.profileId }
35
+ );
30
36
  await Profile.delete({ detach: true, where: { profileId: profile.profileId } });
31
37
  };
@@ -3,6 +3,10 @@ import { int } from 'neo4j-driver';
3
3
  import { convertObjectRegExpToNeo4j, buildWhereClause } from '@helpers/neo4j.helpers';
4
4
  import type { FlatSkillType, SkillType } from 'types/skill';
5
5
  import type { SkillQuery } from '@learncard/types';
6
+ import type {
7
+ SkillEmbeddingGeneration,
8
+ SkillEmbeddingSearchIndex,
9
+ } from '../../config/skillEmbedding';
6
10
 
7
11
  export const doesProfileManageSkill = async (
8
12
  profileId: string,
@@ -448,21 +452,36 @@ export type SkillEmbeddingSearchResult = {
448
452
  score: number;
449
453
  };
450
454
 
455
+ /** Drop stored vectors (current and legacy generations) from node properties returned to callers. */
456
+ const withoutEmbeddingProperties = (props: Record<string, unknown>): Record<string, unknown> =>
457
+ Object.fromEntries(Object.entries(props).filter(([key]) => !key.startsWith('embedding')));
458
+
459
+ /**
460
+ * LC-2206: query one vector index (the Nova generation's, or the legacy Google one while
461
+ * SKILL_EMBEDDING_BEDROCK_READ_ENABLED is off). Vectors from other models or dimensions live
462
+ * in other properties and indexes, so one query never mixes embedding spaces.
463
+ */
451
464
  export const searchSkillsByEmbedding = async (
452
465
  embedding: number[],
453
466
  limit: number,
454
- frameworkId?: string
467
+ frameworkId: string | undefined,
468
+ index: Pick<SkillEmbeddingSearchIndex, 'indexName' | 'dimension'>
455
469
  ): Promise<SkillEmbeddingSearchResult[]> => {
470
+ if (embedding.length !== index.dimension) {
471
+ throw new Error('Skill search embedding does not match the index dimension');
472
+ }
473
+
456
474
  const normalizedLimit = Math.min(Math.max(limit, 1), 200);
457
475
  const queryLimit = frameworkId ? Math.min(normalizedLimit * 3, 200) : normalizedLimit;
458
476
 
459
477
  const result = await neogma.queryRunner.run(
460
- `CALL db.index.vector.queryNodes('skill_embedding_vector_idx', $limit, $embedding)
478
+ `CALL db.index.vector.queryNodes($indexName, $limit, $embedding)
461
479
  YIELD node, score
462
480
  WHERE $frameworkId IS NULL OR node.frameworkId = $frameworkId
463
481
  RETURN node AS s, score AS score
464
482
  ORDER BY score DESC`,
465
483
  {
484
+ indexName: index.indexName,
466
485
  limit: int(queryLimit),
467
486
  embedding,
468
487
  frameworkId: frameworkId ?? null,
@@ -471,7 +490,7 @@ export const searchSkillsByEmbedding = async (
471
490
 
472
491
  const rows = result.records.map(record => {
473
492
  const node = record.get('s') as { properties?: Record<string, unknown> } | undefined;
474
- const props = (node?.properties ?? {}) as Record<string, unknown>;
493
+ const props = withoutEmbeddingProperties(node?.properties ?? {});
475
494
  const skill = {
476
495
  ...props,
477
496
  type: (props.type as string | undefined) ?? 'skill',
@@ -486,30 +505,51 @@ export const searchSkillsByEmbedding = async (
486
505
  return rows.filter(row => row.skill.frameworkId === frameworkId).slice(0, normalizedLimit);
487
506
  };
488
507
 
489
- export const getSkillsMissingEmbedding = async (
508
+ export type SkillEmbeddingCandidate = {
509
+ id: string;
510
+ frameworkId?: string;
511
+ statement: string;
512
+ description?: string | null;
513
+ code?: string | null;
514
+ /** sha256(identity + text) stored with the current generation's vector, if any. */
515
+ embeddingKey: string | null;
516
+ hasEmbedding: boolean;
517
+ };
518
+
519
+ /**
520
+ * Page through every skill (ordered by id) with its current-generation embedding key, without
521
+ * loading any vectors. The caller compares keys to decide which skills need (re-)embedding,
522
+ * which makes the backfill idempotent and resumable from any cursor.
523
+ */
524
+ export const getSkillEmbeddingCandidates = async (
525
+ generation: SkillEmbeddingGeneration,
490
526
  limit: number,
491
527
  cursorId?: string | null
492
- ): Promise<Paginated<FlatSkillType>> => {
528
+ ): Promise<Paginated<SkillEmbeddingCandidate>> => {
493
529
  const normalizedLimit = Math.min(Math.max(limit, 1), 500);
494
530
 
531
+ // Property names come from resolveSkillEmbeddingGeneration ([a-z0-9_] only).
495
532
  const result = await neogma.queryRunner.run(
496
533
  `MATCH (s:Skill)
497
- WHERE s.embedding IS NULL
498
- AND ($cursorId IS NULL OR s.id > $cursorId)
499
- RETURN s AS s
534
+ WHERE $cursorId IS NULL OR s.id > $cursorId
535
+ RETURN s.id AS id, s.frameworkId AS frameworkId, s.statement AS statement,
536
+ s.description AS description, s.code AS code,
537
+ s.${generation.keyProperty} AS embeddingKey,
538
+ s.${generation.vectorProperty} IS NOT NULL AS hasEmbedding
500
539
  ORDER BY s.id
501
540
  LIMIT $limitPlusOne`,
502
541
  { cursorId: cursorId ?? null, limitPlusOne: int(normalizedLimit + 1) }
503
542
  );
504
543
 
505
- const all = result.records.map(record => {
506
- const node = record.get('s') as { properties?: Record<string, unknown> } | undefined;
507
- const props = (node?.properties ?? {}) as Record<string, unknown>;
508
- return {
509
- ...props,
510
- type: (props.type as string | undefined) ?? 'skill',
511
- } as FlatSkillType;
512
- });
544
+ const all = result.records.map(record => ({
545
+ id: String(record.get('id')),
546
+ frameworkId: (record.get('frameworkId') as string | null) ?? undefined,
547
+ statement: String(record.get('statement') ?? ''),
548
+ description: record.get('description') as string | null,
549
+ code: record.get('code') as string | null,
550
+ embeddingKey: (record.get('embeddingKey') as string | null) ?? null,
551
+ hasEmbedding: Boolean(record.get('hasEmbedding')),
552
+ }));
513
553
 
514
554
  const hasMore = all.length > normalizedLimit;
515
555
  const page = all.slice(0, normalizedLimit);