@learncard/network-brain-service 4.0.4 → 4.1.1

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 (175) hide show
  1. package/.env.example +14 -0
  2. package/AGENTS.md +16 -0
  3. package/CHANGELOG.md +54 -0
  4. package/config/config.dev.json +5 -0
  5. package/config/config.example.json +5 -0
  6. package/config/config.production.json +5 -0
  7. package/config/config.scouts.dev.json +5 -0
  8. package/config/config.scouts.production.json +5 -0
  9. package/dist/accesslayer/campaign/audience.d.ts +7 -0
  10. package/dist/accesslayer/campaign/audience.d.ts.map +1 -0
  11. package/dist/accesslayer/campaign/create.d.ts +3 -0
  12. package/dist/accesslayer/campaign/create.d.ts.map +1 -0
  13. package/dist/accesslayer/campaign/delete.d.ts +2 -0
  14. package/dist/accesslayer/campaign/delete.d.ts.map +1 -0
  15. package/dist/accesslayer/campaign/read.d.ts +6 -0
  16. package/dist/accesslayer/campaign/read.d.ts.map +1 -0
  17. package/dist/accesslayer/campaign/shared.d.ts +16 -0
  18. package/dist/accesslayer/campaign/shared.d.ts.map +1 -0
  19. package/dist/accesslayer/campaign/state.d.ts +4 -0
  20. package/dist/accesslayer/campaign/state.d.ts.map +1 -0
  21. package/dist/accesslayer/campaign/update.d.ts +3 -0
  22. package/dist/accesslayer/campaign/update.d.ts.map +1 -0
  23. package/dist/accesslayer/credential-share-previews.d.ts +5 -0
  24. package/dist/accesslayer/credential-share-previews.d.ts.map +1 -0
  25. package/dist/accesslayer/profile/delete.d.ts.map +1 -1
  26. package/dist/accesslayer/skill/read.d.ts +12 -2
  27. package/dist/accesslayer/skill/read.d.ts.map +1 -1
  28. package/dist/accesslayer/skill/update.d.ts +11 -1
  29. package/dist/accesslayer/skill/update.d.ts.map +1 -1
  30. package/dist/app.d.ts +4 -0
  31. package/dist/app.d.ts.map +1 -1
  32. package/dist/cache/app-store.caches.d.ts +2 -1
  33. package/dist/cache/app-store.caches.d.ts.map +1 -1
  34. package/dist/config/environment.d.ts +268 -178
  35. package/dist/config/environment.d.ts.map +1 -1
  36. package/dist/config/skillEmbedding.d.ts +70 -0
  37. package/dist/config/skillEmbedding.d.ts.map +1 -0
  38. package/dist/config/skillEmbedding.neo4j.test.d.ts +2 -0
  39. package/dist/config/skillEmbedding.neo4j.test.d.ts.map +1 -0
  40. package/dist/config/stageConfig.d.ts +20 -0
  41. package/dist/config/stageConfig.d.ts.map +1 -1
  42. package/dist/constants/campaigns.d.ts +3 -0
  43. package/dist/constants/campaigns.d.ts.map +1 -0
  44. package/dist/didkit_wasm_bg.wasm +0 -0
  45. package/dist/helpers/app-credential-delivery.helpers.d.ts +4 -0
  46. package/dist/helpers/app-credential-delivery.helpers.d.ts.map +1 -0
  47. package/dist/helpers/app-credential-delivery.helpers.test.d.ts +2 -0
  48. package/dist/helpers/app-credential-delivery.helpers.test.d.ts.map +1 -0
  49. package/dist/helpers/bedrock-embedding.helpers.d.ts +40 -0
  50. package/dist/helpers/bedrock-embedding.helpers.d.ts.map +1 -0
  51. package/dist/helpers/bedrock-embedding.helpers.test.d.ts +2 -0
  52. package/dist/helpers/bedrock-embedding.helpers.test.d.ts.map +1 -0
  53. package/dist/helpers/bedrock-embedding.smoke.test.d.ts +2 -0
  54. package/dist/helpers/bedrock-embedding.smoke.test.d.ts.map +1 -0
  55. package/dist/helpers/boost.helpers.d.ts.map +1 -1
  56. package/dist/helpers/sentry.helpers.d.ts.map +1 -1
  57. package/dist/helpers/sentry.helpers.test.d.ts +2 -0
  58. package/dist/helpers/sentry.helpers.test.d.ts.map +1 -0
  59. package/dist/helpers/skill-embedding.helpers.d.ts +65 -7
  60. package/dist/helpers/skill-embedding.helpers.d.ts.map +1 -1
  61. package/dist/helpers/skill-embedding.helpers.test.d.ts +2 -0
  62. package/dist/helpers/skill-embedding.helpers.test.d.ts.map +1 -0
  63. package/dist/index.js +2 -2
  64. package/dist/models/Campaign.d.ts +10 -0
  65. package/dist/models/Campaign.d.ts.map +1 -0
  66. package/dist/models/CredentialSharePreview.d.ts +14 -0
  67. package/dist/models/CredentialSharePreview.d.ts.map +1 -0
  68. package/dist/models/Integration.d.ts +1 -1
  69. package/dist/models/Profile.d.ts.map +1 -1
  70. package/dist/models/campaign-constraints.d.ts +2 -0
  71. package/dist/models/campaign-constraints.d.ts.map +1 -0
  72. package/dist/models/index.d.ts +2 -0
  73. package/dist/models/index.d.ts.map +1 -1
  74. package/dist/publish-metafile.json +1 -1
  75. package/dist/routes/app-store.d.ts +1 -1
  76. package/dist/routes/app-store.d.ts.map +1 -1
  77. package/dist/routes/boosts.d.ts +24 -4
  78. package/dist/routes/boosts.d.ts.map +1 -1
  79. package/dist/routes/campaigns.d.ts +1411 -0
  80. package/dist/routes/campaigns.d.ts.map +1 -0
  81. package/dist/routes/contracts.d.ts +19 -6
  82. package/dist/routes/contracts.d.ts.map +1 -1
  83. package/dist/routes/credential-share-previews.d.ts +39 -0
  84. package/dist/routes/credential-share-previews.d.ts.map +1 -0
  85. package/dist/routes/credentials.d.ts +4 -1
  86. package/dist/routes/credentials.d.ts.map +1 -1
  87. package/dist/routes/inbox.d.ts +5 -5
  88. package/dist/routes/index.d.ts +4 -0
  89. package/dist/routes/index.d.ts.map +1 -1
  90. package/dist/routes/integrations.d.ts +3 -3
  91. package/dist/routes/profile-manager.d.ts +4 -0
  92. package/dist/routes/profile-manager.d.ts.map +1 -1
  93. package/dist/routes/profiles.d.ts +44 -0
  94. package/dist/routes/profiles.d.ts.map +1 -1
  95. package/dist/routes/skill-frameworks.d.ts +1 -0
  96. package/dist/routes/skill-frameworks.d.ts.map +1 -1
  97. package/dist/routes/skills-semantic-search.test.d.ts +2 -0
  98. package/dist/routes/skills-semantic-search.test.d.ts.map +1 -0
  99. package/dist/routes/skills.d.ts.map +1 -1
  100. package/dist/routes/test.d.ts +3 -1
  101. package/dist/routes/test.d.ts.map +1 -1
  102. package/dist/seed/seedSkillFrameworks.d.ts +1 -0
  103. package/dist/seed/seedSkillFrameworks.d.ts.map +1 -1
  104. package/dist/seed/seedSkillFrameworks.embeddings.test.d.ts +2 -0
  105. package/dist/seed/seedSkillFrameworks.embeddings.test.d.ts.map +1 -0
  106. package/dist/types/app-manifest.d.ts +2 -2
  107. package/dist/types/campaign.d.ts +19 -0
  108. package/dist/types/campaign.d.ts.map +1 -0
  109. package/dist/types/consentflowcontract.d.ts +1 -1
  110. package/dist/types/integration.d.ts +3 -3
  111. package/dist/types/profile.d.ts +9 -0
  112. package/dist/types/profile.d.ts.map +1 -1
  113. package/dist/types/skill.d.ts.map +1 -1
  114. package/package.json +21 -18
  115. package/project.json +15 -1
  116. package/scripts/skill-embeddings-backfill.ts +161 -0
  117. package/serverless-local.yml +4 -0
  118. package/serverless.function-env.cjs +3 -0
  119. package/serverless.local.yml +4 -0
  120. package/serverless.yml +12 -1
  121. package/src/accesslayer/campaign/README.md +174 -0
  122. package/src/accesslayer/campaign/audience.ts +152 -0
  123. package/src/accesslayer/campaign/create.ts +47 -0
  124. package/src/accesslayer/campaign/delete.ts +18 -0
  125. package/src/accesslayer/campaign/read.ts +125 -0
  126. package/src/accesslayer/campaign/shared.ts +92 -0
  127. package/src/accesslayer/campaign/state.ts +80 -0
  128. package/src/accesslayer/campaign/update.ts +50 -0
  129. package/src/accesslayer/credential-share-previews.ts +76 -0
  130. package/src/accesslayer/profile/delete.ts +6 -0
  131. package/src/accesslayer/skill/read.ts +56 -16
  132. package/src/accesslayer/skill/update.ts +43 -1
  133. package/src/app.ts +9 -0
  134. package/src/config/environment.test.ts +208 -0
  135. package/src/config/environment.ts +166 -11
  136. package/src/config/skillEmbedding.neo4j.test.ts +138 -0
  137. package/src/config/skillEmbedding.ts +202 -0
  138. package/src/constants/campaigns.ts +9 -0
  139. package/src/helpers/activity.helpers.ts +3 -3
  140. package/src/helpers/app-credential-delivery.helpers.test.ts +56 -0
  141. package/src/helpers/app-credential-delivery.helpers.ts +11 -0
  142. package/src/helpers/bedrock-embedding.helpers.test.ts +208 -0
  143. package/src/helpers/bedrock-embedding.helpers.ts +243 -0
  144. package/src/helpers/bedrock-embedding.smoke.test.ts +53 -0
  145. package/src/helpers/boost-storage.helpers.test.ts +63 -0
  146. package/src/helpers/boost.helpers.ts +23 -18
  147. package/src/helpers/sentry.helpers.test.ts +56 -0
  148. package/src/helpers/sentry.helpers.ts +4 -3
  149. package/src/helpers/skill-embedding.helpers.test.ts +1044 -0
  150. package/src/helpers/skill-embedding.helpers.ts +611 -123
  151. package/src/models/Campaign.ts +32 -0
  152. package/src/models/CredentialSharePreview.ts +30 -0
  153. package/src/models/Profile.ts +1 -0
  154. package/src/models/campaign-constraints.ts +22 -0
  155. package/src/models/index.ts +29 -1
  156. package/src/routes/app-store.ts +9 -16
  157. package/src/routes/campaigns.ts +179 -0
  158. package/src/routes/credential-share-previews.ts +74 -0
  159. package/src/routes/profiles.ts +7 -0
  160. package/src/routes/skills-semantic-search.test.ts +168 -0
  161. package/src/routes/skills.ts +42 -6
  162. package/src/seed/seedSkillFrameworks.embeddings.test.ts +82 -0
  163. package/src/seed/seedSkillFrameworks.test.ts +6 -0
  164. package/src/seed/seedSkillFrameworks.ts +83 -18
  165. package/src/types/campaign.ts +17 -0
  166. package/src/types/skill.ts +6 -0
  167. package/test/app-credential-delivery.neo4j.spec.ts +110 -0
  168. package/test/campaigns.spec.ts +789 -0
  169. package/test/credential-share-previews.spec.ts +97 -0
  170. package/test/verification/campaigns.ts +49 -0
  171. package/tsconfig.json +13 -13
  172. package/vitest.campaigns.config.ts +21 -0
  173. package/vitest.config.ts +12 -0
  174. package/vitest.inbox-e2e.config.ts +1 -0
  175. package/vitest.integration.config.ts +1 -0
@@ -0,0 +1,32 @@
1
+ import { ModelFactory, type ModelRelatedNodesI, type NeogmaInstance } from 'neogma';
2
+
3
+ import { neogma } from '@instance';
4
+ import type { CampaignRecord } from 'types/campaign';
5
+ import type { Profile, ProfileInstance } from './Profile';
6
+
7
+ export type CampaignRelationships = {
8
+ audience: ModelRelatedNodesI<typeof Profile, ProfileInstance>;
9
+ state: ModelRelatedNodesI<typeof Profile, ProfileInstance>;
10
+ };
11
+ export type CampaignInstance = NeogmaInstance<CampaignRecord, CampaignRelationships>;
12
+
13
+ export const Campaign = ModelFactory<CampaignRecord, CampaignRelationships>(
14
+ {
15
+ label: 'Campaign',
16
+ primaryKeyField: 'id',
17
+ schema: {
18
+ id: { type: 'string', required: true },
19
+ name: { type: 'string', required: true },
20
+ status: { type: 'string', required: true },
21
+ definition: { type: 'string', required: true },
22
+ startsAt: { type: 'string', required: false },
23
+ endsAt: { type: 'string', required: false },
24
+ excludeMinors: { type: 'boolean', required: true },
25
+ selfEnroll: { type: 'boolean', required: true },
26
+ createdByProfileId: { type: 'string', required: true },
27
+ createdAt: { type: 'string', required: true },
28
+ updatedAt: { type: 'string', required: true },
29
+ },
30
+ },
31
+ neogma
32
+ );
@@ -0,0 +1,30 @@
1
+ import { ModelFactory, NeogmaInstance } from 'neogma';
2
+ import { neogma } from '@instance';
3
+
4
+ type Properties = {
5
+ id: string;
6
+ name: string;
7
+ issuer: string;
8
+ shareUri: string;
9
+ createdAt: string;
10
+ updatedAt: string;
11
+ image?: string;
12
+ };
13
+ export type CredentialSharePreviewInstance = NeogmaInstance<Properties, Record<string, never>>;
14
+
15
+ export const CredentialSharePreview = ModelFactory<Properties, Record<string, never>>(
16
+ {
17
+ label: 'CredentialSharePreview',
18
+ primaryKeyField: 'id',
19
+ schema: {
20
+ id: { type: 'string', required: true },
21
+ name: { type: 'string', required: true },
22
+ issuer: { type: 'string', required: true },
23
+ shareUri: { type: 'string', required: true },
24
+ createdAt: { type: 'string', required: true },
25
+ updatedAt: { type: 'string', required: true },
26
+ image: { type: 'string', required: false },
27
+ },
28
+ },
29
+ neogma
30
+ );
@@ -120,6 +120,7 @@ export const Profile: any = ModelFactory<FlatProfileType, ProfileRelationships>(
120
120
  image: { type: 'string', required: false },
121
121
  heroImage: { type: 'string', required: false },
122
122
  websiteLink: { type: 'string', required: false },
123
+ linkedInOrganizationId: { type: 'string', required: false },
123
124
  isServiceProfile: { type: 'boolean', required: false },
124
125
  type: { type: 'string', required: false },
125
126
  notificationsWebhook: { type: 'string', required: false },
@@ -0,0 +1,22 @@
1
+ import { neogma } from '@instance';
2
+
3
+ let readiness: Promise<void> | undefined;
4
+
5
+ /** Await uniqueness before accepting creates, even during concurrent cold starts. */
6
+ export const ensureCampaignConstraints = (): Promise<void> => {
7
+ readiness ??= neogma.queryRunner
8
+ .run(
9
+ 'CREATE CONSTRAINT campaign_id_unique IF NOT EXISTS FOR (c:Campaign) REQUIRE c.id IS UNIQUE'
10
+ )
11
+ .then(() => undefined)
12
+ .catch((error: unknown) => {
13
+ if (
14
+ (error as { code?: string })?.code ===
15
+ 'Neo.ClientError.Schema.EquivalentSchemaRuleAlreadyExists'
16
+ )
17
+ return;
18
+ readiness = undefined;
19
+ throw error;
20
+ });
21
+ return readiness;
22
+ };
@@ -1,6 +1,11 @@
1
1
  import { environment } from '@environment';
2
2
  import { neogma } from '@instance';
3
3
  import { contractEventMaintenanceSchema } from '@helpers/contract-event-maintenance.helpers';
4
+ import {
5
+ LEGACY_GOOGLE_SKILL_EMBEDDING_VECTOR_INDEX_QUERY,
6
+ buildSkillEmbeddingVectorIndexQuery,
7
+ resolveSkillEmbeddingGeneration,
8
+ } from '../config/skillEmbedding';
4
9
 
5
10
  import { Boost } from './Boost';
6
11
  import { Profile } from './Profile';
@@ -17,6 +22,7 @@ import { CredentialActivity } from './CredentialActivity';
17
22
  import { CredentialRefresh } from './CredentialRefresh';
18
23
  import { StatusList } from './StatusList';
19
24
  import { ContactMethod } from './ContactMethod';
25
+ import { Campaign } from './Campaign';
20
26
  import { ensureCredentialRefreshConstraints } from './credential-refresh-constraints';
21
27
 
22
28
  // Ensure CredentialActivity model is registered by referencing it
@@ -139,14 +145,22 @@ ContactMethod.addRelationships({
139
145
  profile: { model: Profile, direction: 'in', name: 'HAS_CONTACT_METHOD' },
140
146
  });
141
147
 
148
+ Campaign.addRelationships({
149
+ audience: { model: Profile, direction: 'in', name: 'IN_CAMPAIGN_AUDIENCE' },
150
+ state: { model: Profile, direction: 'in', name: 'CAMPAIGN_STATE' },
151
+ });
152
+
142
153
  const shouldCreateIndices =
143
154
  environment.NODE_ENV === 'production' || !environment.NEO4J_SKIP_INDICES;
144
155
 
145
156
  const indexQueries = [
157
+ 'CREATE CONSTRAINT campaign_id_unique IF NOT EXISTS FOR (c:Campaign) REQUIRE c.id IS UNIQUE',
158
+ 'CREATE INDEX campaign_status_idx IF NOT EXISTS FOR (c:Campaign) ON (c.status)',
146
159
  'CREATE CONSTRAINT consent_event_id IF NOT EXISTS FOR (e:ConsentFlowEvent) REQUIRE e.id IS UNIQUE',
147
160
  'CREATE CONSTRAINT consent_delivery_id IF NOT EXISTS FOR (d:ConsentFlowEventDelivery) REQUIRE d.id IS UNIQUE',
148
161
  ...contractEventMaintenanceSchema,
149
162
  'CREATE INDEX profileId_idx IF NOT EXISTS FOR (p:Profile) ON (p.profileId)',
163
+ 'CREATE CONSTRAINT credential_share_preview_id_unique IF NOT EXISTS FOR (p:CredentialSharePreview) REQUIRE p.id IS UNIQUE',
150
164
  'CREATE INDEX profile_did_idx IF NOT EXISTS FOR (p:Profile) ON (p.did)',
151
165
  'CREATE TEXT INDEX profileId_text_idx IF NOT EXISTS FOR (n:Profile) ON (n.profileId)',
152
166
  'CREATE TEXT INDEX profile_displayname_text_idx IF NOT EXISTS FOR (n:Profile) ON (n.displayName)',
@@ -180,7 +194,19 @@ const indexQueries = [
180
194
  'CREATE TEXT INDEX skill_framework_name_text_idx IF NOT EXISTS FOR (f:SkillFramework) ON (f.name)',
181
195
  'CREATE INDEX skill_id_idx IF NOT EXISTS FOR (s:Skill) ON (s.id)',
182
196
  'CREATE TEXT INDEX skill_statement_text_idx IF NOT EXISTS FOR (s:Skill) ON (s.statement)',
183
- "CREATE VECTOR INDEX skill_embedding_vector_idx IF NOT EXISTS FOR (s:Skill) ON (s.embedding) OPTIONS {indexConfig: {`vector.dimensions`: 768, `vector.similarity_function`: 'cosine'}}",
197
+ // LC-2206: the legacy Google 768-dim index (main's DDL, unchanged) stays because semantic
198
+ // search reads it until SKILL_EMBEDDING_BEDROCK_READ_ENABLED is on, and flipping the flag
199
+ // back off is the rollback. The post-rollback-window cleanup drops it.
200
+ LEGACY_GOOGLE_SKILL_EMBEDDING_VECTOR_INDEX_QUERY,
201
+ // One vector index per Nova embedding generation (1024 dims by default), written regardless
202
+ // of the read flag.
203
+ buildSkillEmbeddingVectorIndexQuery(
204
+ resolveSkillEmbeddingGeneration({
205
+ modelId: environment.SKILL_EMBEDDING_BEDROCK_MODEL_ID,
206
+ dimension: environment.SKILL_EMBEDDING_DIMENSION,
207
+ region: environment.SKILL_EMBEDDING_BEDROCK_REGION,
208
+ })
209
+ ),
184
210
  'CREATE CONSTRAINT tag_slug_unique IF NOT EXISTS FOR (t:Tag) REQUIRE (t.slug) IS UNIQUE',
185
211
  'CREATE TEXT INDEX tag_name_text_idx IF NOT EXISTS FOR (t:Tag) ON (t.name)',
186
212
  'CREATE INDEX has_role_id_idx IF NOT EXISTS FOR ()-[r:HAS_ROLE]-() ON (r.roleId)',
@@ -291,5 +317,7 @@ export * from './AppManifestVersion';
291
317
  export * from './AppStoreListing';
292
318
  export * from './CredentialActivity';
293
319
  export * from './CredentialRefresh';
320
+ export * from './CredentialSharePreview';
294
321
  export * from './ShareLink';
295
322
  export * from './StatusList';
323
+ export * from './Campaign';
@@ -1,5 +1,4 @@
1
1
  import { TRPCError } from '@trpc/server';
2
- import { v4 as uuid } from 'uuid';
3
2
  import { z } from 'zod';
4
3
  import {
5
4
  PendingListingUpdateValidator,
@@ -48,6 +47,7 @@ import {
48
47
  invalidateConsentContractForListingByScopeHashCache,
49
48
  } from '@cache/app-store.caches';
50
49
  import { logCredentialSent } from '@helpers/activity.helpers';
50
+ import { sendAppCredentialWithActivity } from '@helpers/app-credential-delivery.helpers';
51
51
  import { getCredentialUri } from '@helpers/credential.helpers';
52
52
  import { constructUri, resolveUri } from '@helpers/uri.helpers';
53
53
  import { inflateObject } from '@helpers/objects.helpers';
@@ -947,15 +947,10 @@ export const handleSendCredentialEvent = async (
947
947
 
948
948
  perf.mark('saIssue');
949
949
 
950
- // LC-1644 Phase 1d: run logCredentialSent + sendBoost in parallel. They previously
951
- // ran serially because sendBoost needed activityId as a foreign key on the
952
- // SentCredential relationship. We now pre-generate the activityId upfront (matching
953
- // the uuid the activity log would have chosen itself) and pass it to both calls.
954
- // Both writes produce the same Neo4j linkage as before; we just fan them out.
955
- // At staging this saves ~40ms on the serial sum (logActivity 40ms + sendBoost 50ms).
956
- const activityId = uuid();
957
- const [, credentialUri] = await Promise.all([
958
- logCredentialSent({
950
+ // Activity and delivery both write listing relationships; finish one before
951
+ // starting the other so one request cannot deadlock against its own writes.
952
+ const credentialUri = await sendAppCredentialWithActivity(
953
+ {
959
954
  actorProfileId: integrationOwner.profileId,
960
955
  recipientType: 'profile',
961
956
  recipientIdentifier: target.profileId,
@@ -965,20 +960,18 @@ export const handleSendCredentialEvent = async (
965
960
  listingId,
966
961
  source: 'appEvent',
967
962
  metadata: { listingId, templateAlias },
968
- activityId,
969
- }),
970
- sendBoost({
963
+ },
964
+ {
971
965
  from: issuer,
972
966
  to: target,
973
967
  boost,
974
968
  credential,
975
969
  domain: ctx.domain,
976
970
  skipNotification: false,
977
- activityId,
978
971
  integrationId: integration.id,
979
972
  listingId,
980
- }),
981
- ]);
973
+ }
974
+ );
982
975
 
983
976
  perf.mark('logActivityAndSendBoost');
984
977
 
@@ -0,0 +1,179 @@
1
+ import { TRPCError } from '@trpc/server';
2
+ import { z } from 'zod';
3
+ import {
4
+ CampaignAudienceInputValidator,
5
+ CampaignEventValidator,
6
+ CampaignIdValidator,
7
+ CampaignStatsValidator,
8
+ CampaignStatusValidator,
9
+ CampaignValidator,
10
+ CreateCampaignInputValidator,
11
+ ListCampaignsInputValidator,
12
+ MyCampaignValidator,
13
+ PageCampaignAudienceInputValidator,
14
+ PaginatedCampaignAudienceValidator,
15
+ PaginatedCampaignsValidator,
16
+ UpdateCampaignInputValidator,
17
+ type Campaign,
18
+ type CampaignStats,
19
+ type MyCampaign,
20
+ type PaginatedCampaignAudience,
21
+ type PaginatedCampaigns,
22
+ } from '@learncard/types';
23
+
24
+ import { t, profileRouteWithoutInputCapture } from '@routes';
25
+ import { isCampaignAdmin } from 'src/constants/campaigns';
26
+ import { enforceRateLimits } from '@helpers/rateLimit.helpers';
27
+ import { trace } from '@tracing';
28
+ import { createCampaign } from '@accesslayer/campaign/create';
29
+ import {
30
+ getCampaign,
31
+ listCampaigns,
32
+ getLiveCampaignsForProfile,
33
+ getCampaignStats,
34
+ } from '@accesslayer/campaign/read';
35
+ import { updateCampaign } from '@accesslayer/campaign/update';
36
+ import { deleteCampaign } from '@accesslayer/campaign/delete';
37
+ import {
38
+ addCampaignAudience,
39
+ previewCampaignAudience,
40
+ pageCampaignAudience,
41
+ } from '@accesslayer/campaign/audience';
42
+ import { enrollInCampaign, recordCampaignEvent } from '@accesslayer/campaign/state';
43
+
44
+ const campaignAdminRoute = profileRouteWithoutInputCapture
45
+ .meta({ requiredScope: 'campaigns:admin' })
46
+ .use(({ ctx, next }) => {
47
+ if (!isCampaignAdmin(ctx.user.profile.profileId))
48
+ throw new TRPCError({
49
+ code: 'FORBIDDEN',
50
+ message: 'Campaign administrator access required',
51
+ });
52
+ return next({ ctx });
53
+ });
54
+
55
+ const campaignUserRoute = profileRouteWithoutInputCapture.use(async ({ ctx, next }) => {
56
+ await enforceRateLimits([
57
+ {
58
+ key: `campaigns:user:${ctx.user.profile.profileId}`,
59
+ limit: 60,
60
+ windowSeconds: 60,
61
+ description: '60 campaign requests per profile per minute',
62
+ },
63
+ ]);
64
+ return next({ ctx });
65
+ });
66
+
67
+ const idInput = z.object({ campaignId: CampaignIdValidator }).strict();
68
+ const noInput = z.object({}).strict().default({});
69
+
70
+ export const campaignsRouter = t.router({
71
+ isAdmin: campaignUserRoute
72
+ .meta({ requiredScope: 'campaigns:read' })
73
+ .input(noInput)
74
+ .output(z.boolean())
75
+ .query(async ({ ctx }): Promise<boolean> =>
76
+ trace('route', 'campaigns:isAdmin', async () =>
77
+ isCampaignAdmin(ctx.user.profile.profileId)
78
+ )
79
+ ),
80
+ create: campaignAdminRoute
81
+ .input(CreateCampaignInputValidator)
82
+ .output(CampaignValidator)
83
+ .mutation(async ({ ctx, input }): Promise<Campaign> =>
84
+ trace('route', 'campaigns:create', () =>
85
+ createCampaign(input, ctx.user.profile.profileId)
86
+ )
87
+ ),
88
+ update: campaignAdminRoute
89
+ .input(UpdateCampaignInputValidator)
90
+ .output(CampaignValidator)
91
+ .mutation(async ({ input }): Promise<Campaign> =>
92
+ trace('route', 'campaigns:update', () => updateCampaign(input))
93
+ ),
94
+ get: campaignAdminRoute
95
+ .input(idInput)
96
+ .output(CampaignValidator)
97
+ .query(async ({ input }): Promise<Campaign> =>
98
+ trace('route', 'campaigns:get', () => getCampaign(input.campaignId))
99
+ ),
100
+ list: campaignAdminRoute
101
+ .input(ListCampaignsInputValidator.prefault({}))
102
+ .output(PaginatedCampaignsValidator)
103
+ .query(async ({ input }): Promise<PaginatedCampaigns> =>
104
+ trace('route', 'campaigns:list', () => listCampaigns(input))
105
+ ),
106
+ delete: campaignAdminRoute
107
+ .input(idInput)
108
+ .output(z.boolean())
109
+ .mutation(async ({ input }): Promise<boolean> =>
110
+ trace('route', 'campaigns:delete', () => deleteCampaign(input.campaignId))
111
+ ),
112
+ setStatus: campaignAdminRoute
113
+ .input(idInput.extend({ status: CampaignStatusValidator }))
114
+ .output(CampaignValidator)
115
+ .mutation(async ({ input }): Promise<Campaign> =>
116
+ trace('route', 'campaigns:setStatus', () =>
117
+ updateCampaign({ campaignId: input.campaignId, updates: { status: input.status } })
118
+ )
119
+ ),
120
+ previewAudience: campaignAdminRoute
121
+ .input(CampaignAudienceInputValidator)
122
+ .output(z.object({ count: z.number() }))
123
+ .query(async ({ input, ctx }): Promise<{ count: number }> =>
124
+ trace('route', 'campaigns:previewAudience', async () => ({
125
+ count: await previewCampaignAudience(input.campaignId, input.source, ctx.domain),
126
+ }))
127
+ ),
128
+ addAudience: campaignAdminRoute
129
+ .input(CampaignAudienceInputValidator)
130
+ .output(z.object({ count: z.number() }))
131
+ .mutation(async ({ input, ctx }): Promise<{ count: number }> =>
132
+ trace('route', 'campaigns:addAudience', async () => ({
133
+ count: await addCampaignAudience(input.campaignId, input.source, ctx.domain),
134
+ }))
135
+ ),
136
+ pageAudience: campaignAdminRoute
137
+ .input(PageCampaignAudienceInputValidator)
138
+ .output(PaginatedCampaignAudienceValidator)
139
+ .query(async ({ input }): Promise<PaginatedCampaignAudience> =>
140
+ trace('route', 'campaigns:pageAudience', () =>
141
+ pageCampaignAudience(input.campaignId, input.cursor, input.limit)
142
+ )
143
+ ),
144
+ getStats: campaignAdminRoute
145
+ .input(idInput)
146
+ .output(CampaignStatsValidator)
147
+ .query(async ({ input }): Promise<CampaignStats> =>
148
+ trace('route', 'campaigns:getStats', () => getCampaignStats(input.campaignId))
149
+ ),
150
+ getMyCampaigns: campaignUserRoute
151
+ .meta({ requiredScope: 'campaigns:read' })
152
+ .input(noInput)
153
+ .output(z.array(MyCampaignValidator))
154
+ .query(async ({ ctx }): Promise<MyCampaign[]> =>
155
+ trace('route', 'campaigns:getMyCampaigns', () =>
156
+ getLiveCampaignsForProfile(ctx.user.profile.profileId)
157
+ )
158
+ ),
159
+ recordEvent: campaignUserRoute
160
+ .meta({ requiredScope: 'campaigns:write' })
161
+ .input(idInput.extend({ event: CampaignEventValidator }))
162
+ .output(z.boolean())
163
+ .mutation(async ({ input, ctx }): Promise<boolean> =>
164
+ trace('route', 'campaigns:recordEvent', () =>
165
+ recordCampaignEvent(ctx.user.profile.profileId, input.campaignId, input.event)
166
+ )
167
+ ),
168
+ enroll: campaignUserRoute
169
+ .meta({ requiredScope: 'campaigns:write' })
170
+ .input(idInput)
171
+ .output(z.boolean())
172
+ .mutation(async ({ input, ctx }): Promise<boolean> =>
173
+ trace('route', 'campaigns:enroll', () =>
174
+ enrollInCampaign(ctx.user.profile.profileId, input.campaignId)
175
+ )
176
+ ),
177
+ });
178
+
179
+ export type CampaignsRouter = typeof campaignsRouter;
@@ -0,0 +1,74 @@
1
+ import { TRPCError } from '@trpc/server';
2
+ import { z } from 'zod';
3
+ import {
4
+ CredentialSharePreviewValidator,
5
+ PublishCredentialSharePreviewValidator,
6
+ } from '@learncard/types';
7
+ import {
8
+ createCredentialSharePreview,
9
+ deleteCredentialSharePreview,
10
+ getCredentialSharePreview,
11
+ } from '@accesslayer/credential-share-previews';
12
+ import { t, profileRoute, openRoute } from '@routes';
13
+
14
+ const Id = z.string().uuid();
15
+ const previewOutput = CredentialSharePreviewValidator;
16
+ const openapi = (method: 'GET' | 'POST', path: `/${string}`, summary: string) => ({
17
+ method,
18
+ path,
19
+ summary,
20
+ tags: ['Credential Share Previews'],
21
+ });
22
+
23
+ export const credentialSharePreviewsRouter = t.router({
24
+ publish: profileRoute
25
+ .meta({
26
+ openapi: {
27
+ ...openapi(
28
+ 'POST',
29
+ '/credential-share-previews',
30
+ 'Publish credential share preview'
31
+ ),
32
+ protect: true,
33
+ },
34
+ })
35
+ .input(PublishCredentialSharePreviewValidator)
36
+ .output(Id)
37
+ .mutation(async ({ ctx, input }) => {
38
+ const id = await createCredentialSharePreview(ctx.user.profile.profileId, input);
39
+ if (!id) throw new TRPCError({ code: 'NOT_FOUND' });
40
+ return id;
41
+ }),
42
+ delete: profileRoute
43
+ .meta({
44
+ openapi: {
45
+ ...openapi(
46
+ 'POST',
47
+ '/credential-share-previews/{id}/delete',
48
+ 'Delete owned credential share preview'
49
+ ),
50
+ protect: true,
51
+ },
52
+ })
53
+ .input(z.object({ id: Id }).strict())
54
+ .output(z.boolean())
55
+ .mutation(({ ctx, input }) =>
56
+ deleteCredentialSharePreview(ctx.user.profile.profileId, input.id)
57
+ ),
58
+ get: openRoute
59
+ .meta({
60
+ openapi: openapi(
61
+ 'GET',
62
+ '/credential-share-previews/{id}',
63
+ 'Get public credential share preview'
64
+ ),
65
+ })
66
+ .input(z.object({ id: Id, shareUri: z.string().min(1).max(4096) }).strict())
67
+ .output(previewOutput)
68
+ .query(async ({ input }) => {
69
+ const preview = await getCredentialSharePreview(input.id, input.shareUri);
70
+ if (!preview) throw new TRPCError({ code: 'NOT_FOUND' });
71
+ return preview;
72
+ }),
73
+ });
74
+ export type CredentialSharePreviewsRouter = typeof credentialSharePreviewsRouter;
@@ -113,6 +113,7 @@ const UpdateProfileInputValidator = z.object({
113
113
  image: z.string().optional(),
114
114
  heroImage: z.string().optional(),
115
115
  websiteLink: z.string().optional(),
116
+ linkedInOrganizationId: z.string().regex(/^\d*$/).optional(),
116
117
  type: z.string().optional(),
117
118
  email: z.string().optional(),
118
119
  notificationsWebhook: z.string().optional(),
@@ -599,6 +600,7 @@ export const profilesRouter = t.router({
599
600
  image,
600
601
  heroImage,
601
602
  websiteLink,
603
+ linkedInOrganizationId,
602
604
  type,
603
605
  email,
604
606
  notificationsWebhook,
@@ -662,6 +664,11 @@ export const profilesRouter = t.router({
662
664
  if (typeof bio === 'string') actualUpdates.bio = bio;
663
665
  if (typeof heroImage === 'string') actualUpdates.heroImage = heroImage;
664
666
  if (typeof websiteLink === 'string') actualUpdates.websiteLink = websiteLink;
667
+ if (typeof linkedInOrganizationId === 'string') {
668
+ // Neo4j removes a property when SET += receives null.
669
+ (actualUpdates as Record<string, unknown>).linkedInOrganizationId =
670
+ linkedInOrganizationId || null;
671
+ }
665
672
  if (typeof type === 'string') actualUpdates.type = type;
666
673
  if (typeof notificationsWebhook === 'string')
667
674
  actualUpdates.notificationsWebhook = notificationsWebhook;
@@ -0,0 +1,168 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { resolveTenantFromRequest } from '@learncard/email-templates';
3
+ import type { ProfileType } from 'types/profile';
4
+
5
+ /**
6
+ * LC-2206: semantic search read routing behind SKILL_EMBEDDING_BEDROCK_READ_ENABLED, through
7
+ * the real router and embedding helpers. Bedrock and Google are faked; nothing leaves the box.
8
+ */
9
+
10
+ const mocks = vi.hoisted(() => ({
11
+ store: new Map<string, string>(),
12
+ getProfileByDid: vi.fn(),
13
+ searchSkillsByEmbedding: vi.fn(),
14
+ }));
15
+
16
+ vi.mock('@cache', () => ({
17
+ default: {
18
+ get: vi.fn(async (key: string) => mocks.store.get(key)),
19
+ set: vi.fn(async (key: string, value: string) => {
20
+ mocks.store.set(key, value);
21
+ return 'OK';
22
+ }),
23
+ incr: vi.fn(async () => 1),
24
+ node: {},
25
+ },
26
+ }));
27
+ vi.mock('@accesslayer/profile/read', () => ({ getProfileByDid: mocks.getProfileByDid }));
28
+ vi.mock('@accesslayer/skill/read', async importOriginal => ({
29
+ ...(await importOriginal<typeof import('@accesslayer/skill/read')>()),
30
+ searchSkillsByEmbedding: mocks.searchSkillsByEmbedding,
31
+ }));
32
+
33
+ import { environment } from '@environment';
34
+ import {
35
+ setLegacySkillEmbeddingForTests,
36
+ setSkillEmbeddingInvokeForTests,
37
+ } from '@helpers/skill-embedding.helpers';
38
+
39
+ import { type Context } from './index';
40
+ import { skillsRouter } from './skills';
41
+
42
+ const profile: ProfileType = {
43
+ profileId: 'searcher',
44
+ did: 'did:key:searcher',
45
+ displayName: 'Searcher',
46
+ shortBio: '',
47
+ bio: '',
48
+ };
49
+
50
+ const context: Context = {
51
+ domain: 'network.example.com',
52
+ tenant: resolveTenantFromRequest({}),
53
+ user: { did: profile.did, isChallengeValid: true, scope: '*:*' },
54
+ };
55
+
56
+ const vector = (dimension: number) =>
57
+ Array.from({ length: dimension }, () => 1 / Math.sqrt(dimension));
58
+
59
+ const novaBody = (values: number[]) =>
60
+ new TextEncoder().encode(
61
+ JSON.stringify({ embeddings: [{ embeddingType: 'TEXT', embedding: values }] })
62
+ );
63
+
64
+ const ORIGINAL = {
65
+ SKILL_EMBEDDING_BEDROCK_ENABLED: environment.SKILL_EMBEDDING_BEDROCK_ENABLED,
66
+ SKILL_EMBEDDING_BEDROCK_READ_ENABLED: environment.SKILL_EMBEDDING_BEDROCK_READ_ENABLED,
67
+ SKILL_EMBEDDING_GOOGLE_API_KEY: environment.SKILL_EMBEDDING_GOOGLE_API_KEY,
68
+ };
69
+
70
+ describe('skills.semanticSearchSkills read routing (LC-2206)', () => {
71
+ const invoke = vi.fn();
72
+ const legacyEmbed = vi.fn();
73
+
74
+ const search = (text = 'help customers with complaints') =>
75
+ skillsRouter.createCaller(context).semanticSearchSkills({ text, limit: 5 });
76
+
77
+ beforeEach(() => {
78
+ mocks.store.clear();
79
+ mocks.getProfileByDid.mockResolvedValue(profile);
80
+ mocks.searchSkillsByEmbedding.mockReset().mockResolvedValue([]);
81
+ invoke.mockReset().mockImplementation(async () => novaBody(vector(1024)));
82
+ legacyEmbed
83
+ .mockReset()
84
+ .mockImplementation(async (texts: string[]) => texts.map(() => vector(768)));
85
+ setSkillEmbeddingInvokeForTests(invoke as never);
86
+ setLegacySkillEmbeddingForTests(legacyEmbed);
87
+
88
+ // Deployed shape: Nova writes on, Google key present for the legacy read path.
89
+ environment.SKILL_EMBEDDING_BEDROCK_ENABLED = true;
90
+ environment.SKILL_EMBEDDING_GOOGLE_API_KEY = 'legacy-google-key';
91
+ });
92
+
93
+ afterEach(() => {
94
+ setSkillEmbeddingInvokeForTests(undefined);
95
+ setLegacySkillEmbeddingForTests(undefined);
96
+ Object.assign(environment, ORIGINAL);
97
+ });
98
+
99
+ it('flag off (default): queries the legacy 768-d index with a Google query embedding', async () => {
100
+ environment.SKILL_EMBEDDING_BEDROCK_READ_ENABLED = false;
101
+
102
+ await search();
103
+
104
+ expect(legacyEmbed).toHaveBeenCalledWith(['help customers with complaints']);
105
+ expect(invoke).not.toHaveBeenCalled();
106
+ const [embedding, limit, frameworkId, index] = mocks.searchSkillsByEmbedding.mock.calls[0]!;
107
+ expect(embedding).toHaveLength(768);
108
+ expect(limit).toBe(5);
109
+ expect(frameworkId).toBeUndefined();
110
+ expect(index).toEqual({
111
+ source: 'legacy',
112
+ indexName: 'skill_embedding_vector_idx',
113
+ dimension: 768,
114
+ });
115
+ });
116
+
117
+ it('flag on: queries the Nova 1024-d index with a TEXT_RETRIEVAL query embedding', async () => {
118
+ environment.SKILL_EMBEDDING_BEDROCK_READ_ENABLED = true;
119
+
120
+ await search();
121
+
122
+ expect(legacyEmbed).not.toHaveBeenCalled();
123
+ expect(invoke).toHaveBeenCalledTimes(1);
124
+ const request = JSON.parse(invoke.mock.calls[0]![0].body);
125
+ expect(request.singleEmbeddingParams.embeddingPurpose).toBe('TEXT_RETRIEVAL');
126
+ const [embedding, , , index] = mocks.searchSkillsByEmbedding.mock.calls[0]!;
127
+ expect(embedding).toHaveLength(1024);
128
+ expect(index).toEqual({
129
+ source: 'nova',
130
+ indexName: 'skill_embedding_nova2_v1_0_d1024_idx',
131
+ dimension: 1024,
132
+ });
133
+ });
134
+
135
+ it('flipping the flag back off is the rollback: the same query goes back to legacy', async () => {
136
+ environment.SKILL_EMBEDDING_BEDROCK_READ_ENABLED = true;
137
+ await search();
138
+ environment.SKILL_EMBEDDING_BEDROCK_READ_ENABLED = false;
139
+ await search();
140
+
141
+ const indexes = mocks.searchSkillsByEmbedding.mock.calls.map(call => call[3].indexName);
142
+ expect(indexes).toEqual([
143
+ 'skill_embedding_nova2_v1_0_d1024_idx',
144
+ 'skill_embedding_vector_idx',
145
+ ]);
146
+ // The cached Nova vector was not reused for the legacy index (or vice versa).
147
+ expect(mocks.searchSkillsByEmbedding.mock.calls[1]![0]).toHaveLength(768);
148
+ expect(legacyEmbed).toHaveBeenCalledTimes(1);
149
+ expect(invoke).toHaveBeenCalledTimes(1);
150
+ });
151
+
152
+ it('flag off keeps main behaviour for long text (no Nova 8,192-character limit)', async () => {
153
+ environment.SKILL_EMBEDDING_BEDROCK_READ_ENABLED = false;
154
+ await expect(search('x'.repeat(9000))).resolves.toMatchObject({ records: [] });
155
+
156
+ environment.SKILL_EMBEDDING_BEDROCK_READ_ENABLED = true;
157
+ await expect(search('x'.repeat(9000))).rejects.toMatchObject({ code: 'BAD_REQUEST' });
158
+ });
159
+
160
+ it('flag off without a Google key fails closed instead of querying Nova', async () => {
161
+ environment.SKILL_EMBEDDING_BEDROCK_READ_ENABLED = false;
162
+ environment.SKILL_EMBEDDING_GOOGLE_API_KEY = undefined;
163
+
164
+ await expect(search()).rejects.toMatchObject({ code: 'SERVICE_UNAVAILABLE' });
165
+ expect(invoke).not.toHaveBeenCalled();
166
+ expect(mocks.searchSkillsByEmbedding).not.toHaveBeenCalled();
167
+ });
168
+ });