@learncard/network-brain-service 4.0.3 → 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 (176) hide show
  1. package/.env.example +14 -0
  2. package/CHANGELOG.md +76 -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/consentflowcontract/relationships/create.d.ts.map +1 -1
  23. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
  24. package/dist/accesslayer/credential-share-previews.d.ts +5 -0
  25. package/dist/accesslayer/credential-share-previews.d.ts.map +1 -0
  26. package/dist/accesslayer/inbox-batch/schema.d.ts +4 -0
  27. package/dist/accesslayer/inbox-batch/schema.d.ts.map +1 -0
  28. package/dist/accesslayer/inbox-batch/schema.test.d.ts +2 -0
  29. package/dist/accesslayer/inbox-batch/schema.test.d.ts.map +1 -0
  30. package/dist/accesslayer/inbox-batch/store.d.ts +1 -1
  31. package/dist/accesslayer/inbox-batch/store.d.ts.map +1 -1
  32. package/dist/accesslayer/profile/delete.d.ts.map +1 -1
  33. package/dist/accesslayer/skill/read.d.ts +12 -2
  34. package/dist/accesslayer/skill/read.d.ts.map +1 -1
  35. package/dist/accesslayer/skill/update.d.ts +11 -1
  36. package/dist/accesslayer/skill/update.d.ts.map +1 -1
  37. package/dist/app.d.ts +4 -0
  38. package/dist/app.d.ts.map +1 -1
  39. package/dist/cache/app-store.caches.d.ts +2 -1
  40. package/dist/cache/app-store.caches.d.ts.map +1 -1
  41. package/dist/config/environment.d.ts +268 -178
  42. package/dist/config/environment.d.ts.map +1 -1
  43. package/dist/config/skillEmbedding.d.ts +70 -0
  44. package/dist/config/skillEmbedding.d.ts.map +1 -0
  45. package/dist/config/skillEmbedding.neo4j.test.d.ts +2 -0
  46. package/dist/config/skillEmbedding.neo4j.test.d.ts.map +1 -0
  47. package/dist/config/stageConfig.d.ts +20 -0
  48. package/dist/config/stageConfig.d.ts.map +1 -1
  49. package/dist/constants/campaigns.d.ts +3 -0
  50. package/dist/constants/campaigns.d.ts.map +1 -0
  51. package/dist/didkit_wasm_bg.wasm +0 -0
  52. package/dist/helpers/bedrock-embedding.helpers.d.ts +40 -0
  53. package/dist/helpers/bedrock-embedding.helpers.d.ts.map +1 -0
  54. package/dist/helpers/bedrock-embedding.helpers.test.d.ts +2 -0
  55. package/dist/helpers/bedrock-embedding.helpers.test.d.ts.map +1 -0
  56. package/dist/helpers/bedrock-embedding.smoke.test.d.ts +2 -0
  57. package/dist/helpers/bedrock-embedding.smoke.test.d.ts.map +1 -0
  58. package/dist/helpers/boost.helpers.d.ts +1 -0
  59. package/dist/helpers/boost.helpers.d.ts.map +1 -1
  60. package/dist/helpers/skill-embedding.helpers.d.ts +65 -7
  61. package/dist/helpers/skill-embedding.helpers.d.ts.map +1 -1
  62. package/dist/helpers/skill-embedding.helpers.test.d.ts +2 -0
  63. package/dist/helpers/skill-embedding.helpers.test.d.ts.map +1 -0
  64. package/dist/index.js +2 -2
  65. package/dist/models/Campaign.d.ts +10 -0
  66. package/dist/models/Campaign.d.ts.map +1 -0
  67. package/dist/models/CredentialSharePreview.d.ts +14 -0
  68. package/dist/models/CredentialSharePreview.d.ts.map +1 -0
  69. package/dist/models/Integration.d.ts +1 -1
  70. package/dist/models/Profile.d.ts.map +1 -1
  71. package/dist/models/campaign-constraints.d.ts +2 -0
  72. package/dist/models/campaign-constraints.d.ts.map +1 -0
  73. package/dist/models/index.d.ts +2 -0
  74. package/dist/models/index.d.ts.map +1 -1
  75. package/dist/publish-metafile.json +1 -1
  76. package/dist/routes/app-store.d.ts +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/routes/workflows.d.ts.map +1 -1
  103. package/dist/seed/seedSkillFrameworks.d.ts +1 -0
  104. package/dist/seed/seedSkillFrameworks.d.ts.map +1 -1
  105. package/dist/seed/seedSkillFrameworks.embeddings.test.d.ts +2 -0
  106. package/dist/seed/seedSkillFrameworks.embeddings.test.d.ts.map +1 -0
  107. package/dist/types/app-manifest.d.ts +2 -2
  108. package/dist/types/campaign.d.ts +19 -0
  109. package/dist/types/campaign.d.ts.map +1 -0
  110. package/dist/types/consentflowcontract.d.ts +1 -1
  111. package/dist/types/integration.d.ts +3 -3
  112. package/dist/types/profile.d.ts +9 -0
  113. package/dist/types/profile.d.ts.map +1 -1
  114. package/dist/types/skill.d.ts.map +1 -1
  115. package/esbuildPlugins.cjs +9 -6
  116. package/package.json +20 -17
  117. package/project.json +14 -0
  118. package/scripts/skill-embeddings-backfill.ts +161 -0
  119. package/serverless-local.yml +4 -0
  120. package/serverless.function-env.cjs +3 -0
  121. package/serverless.local.yml +4 -0
  122. package/serverless.yml +12 -1
  123. package/src/accesslayer/campaign/README.md +174 -0
  124. package/src/accesslayer/campaign/audience.ts +152 -0
  125. package/src/accesslayer/campaign/create.ts +47 -0
  126. package/src/accesslayer/campaign/delete.ts +18 -0
  127. package/src/accesslayer/campaign/read.ts +125 -0
  128. package/src/accesslayer/campaign/shared.ts +92 -0
  129. package/src/accesslayer/campaign/state.ts +80 -0
  130. package/src/accesslayer/campaign/update.ts +50 -0
  131. package/src/accesslayer/consentflowcontract/relationships/create.ts +3 -1
  132. package/src/accesslayer/consentflowcontract/relationships/update.ts +5 -1
  133. package/src/accesslayer/credential-share-previews.ts +76 -0
  134. package/src/accesslayer/inbox-batch/schema.test.ts +91 -0
  135. package/src/accesslayer/inbox-batch/schema.ts +54 -0
  136. package/src/accesslayer/inbox-batch/store.ts +2 -38
  137. package/src/accesslayer/profile/delete.ts +6 -0
  138. package/src/accesslayer/skill/read.ts +56 -16
  139. package/src/accesslayer/skill/update.ts +43 -1
  140. package/src/app.ts +9 -0
  141. package/src/config/environment.test.ts +208 -0
  142. package/src/config/environment.ts +166 -11
  143. package/src/config/skillEmbedding.neo4j.test.ts +138 -0
  144. package/src/config/skillEmbedding.ts +202 -0
  145. package/src/constants/campaigns.ts +9 -0
  146. package/src/helpers/bedrock-embedding.helpers.test.ts +208 -0
  147. package/src/helpers/bedrock-embedding.helpers.ts +243 -0
  148. package/src/helpers/bedrock-embedding.smoke.test.ts +53 -0
  149. package/src/helpers/boost-storage.helpers.test.ts +155 -1
  150. package/src/helpers/boost.helpers.ts +52 -10
  151. package/src/helpers/skill-embedding.helpers.test.ts +1044 -0
  152. package/src/helpers/skill-embedding.helpers.ts +611 -123
  153. package/src/models/Campaign.ts +32 -0
  154. package/src/models/CredentialSharePreview.ts +30 -0
  155. package/src/models/Profile.ts +1 -0
  156. package/src/models/campaign-constraints.ts +22 -0
  157. package/src/models/index.ts +29 -1
  158. package/src/routes/campaigns.ts +179 -0
  159. package/src/routes/credential-share-previews.ts +74 -0
  160. package/src/routes/profiles.ts +7 -0
  161. package/src/routes/skills-semantic-search.test.ts +168 -0
  162. package/src/routes/skills.ts +42 -6
  163. package/src/routes/workflows.ts +8 -1
  164. package/src/seed/seedSkillFrameworks.embeddings.test.ts +82 -0
  165. package/src/seed/seedSkillFrameworks.test.ts +6 -0
  166. package/src/seed/seedSkillFrameworks.ts +83 -18
  167. package/src/types/campaign.ts +17 -0
  168. package/src/types/skill.ts +6 -0
  169. package/test/campaigns.spec.ts +789 -0
  170. package/test/consentflow.spec.ts +101 -0
  171. package/test/credential-share-previews.spec.ts +97 -0
  172. package/test/inbox-batch.spec.ts +189 -1
  173. package/test/verification/campaigns.ts +49 -0
  174. package/vitest.campaigns.config.ts +21 -0
  175. package/vitest.config.ts +11 -0
  176. package/vitest.integration.config.ts +1 -0
@@ -0,0 +1,152 @@
1
+ import { TRPCError } from '@trpc/server';
2
+ import { int } from 'neo4j-driver';
3
+ import {
4
+ CampaignAudienceSourceValidator,
5
+ type CampaignAudienceSource,
6
+ type PaginatedCampaignAudience,
7
+ } from '@learncard/types';
8
+
9
+ import { neogma } from '@instance';
10
+ import { traceDb } from '@tracing';
11
+ import { getDidWeb } from '@helpers/did.helpers';
12
+ import { constructUri, getUriParts } from '@helpers/uri.helpers';
13
+ import type { CampaignAudienceQuery } from 'types/campaign';
14
+ import {
15
+ assertCampaignEditable,
16
+ campaignAgePredicate,
17
+ campaignTimeParams,
18
+ lockCampaign,
19
+ withCampaignTransaction,
20
+ } from './shared';
21
+ import { getCampaign } from './read';
22
+
23
+ const canonicalNetworkDomain = (domain: string): string => {
24
+ const decodedDomain = domain.replace(/\/trpc$/, '').replace(/%3A/gi, ':');
25
+
26
+ return getUriParts(constructUri('boost', 'domain-check', decodedDomain)).domain;
27
+ };
28
+
29
+ export const buildCampaignAudienceQuery = (
30
+ rawSource: CampaignAudienceSource,
31
+ domain: string
32
+ ): CampaignAudienceQuery => {
33
+ const source = CampaignAudienceSourceValidator.parse(rawSource);
34
+ switch (source.type) {
35
+ case 'dids':
36
+ return {
37
+ match: `MATCH (p:Profile) WHERE p.did IN $dids OR ($didWebPrefix + p.profileId) IN $dids`,
38
+ params: { dids: source.dids, didWebPrefix: getDidWeb(domain, '') },
39
+ };
40
+ case 'emails':
41
+ return {
42
+ match: `MATCH (p:Profile)-[:HAS_CONTACT_METHOD]->(contact:ContactMethod)
43
+ WHERE contact.type = 'email' AND contact.isVerified = true
44
+ AND toLower(trim(contact.value)) IN $emails`,
45
+ params: { emails: source.emails },
46
+ };
47
+ case 'inboxClaims':
48
+ return {
49
+ match: `MATCH (p:Profile)-[claim:CLAIMED_INBOX_CREDENTIAL]->(inbox:InboxCredential)
50
+ WHERE inbox.issuerDid = $issuerDid AND ($since IS NULL OR claim.timestamp >= $since)`,
51
+ params: { issuerDid: source.issuerDid, since: source.since ?? null },
52
+ };
53
+ default: {
54
+ let match: string;
55
+ const params: Record<string, unknown> = { includePending: source.includePending };
56
+ if (source.type === 'boostRecipients') {
57
+ const uri = getUriParts(source.boostUri);
58
+ if (
59
+ uri.type !== 'boost' ||
60
+ canonicalNetworkDomain(uri.domain) !== canonicalNetworkDomain(domain)
61
+ ) {
62
+ throw new TRPCError({
63
+ code: 'BAD_REQUEST',
64
+ message: 'A boost URI from this network is required',
65
+ });
66
+ }
67
+ match = `MATCH (boost:Boost {id: $boostId})<-[:INSTANCE_OF]-(credential:Credential)<-[sent:CREDENTIAL_SENT]-(sender)
68
+ WHERE sender:Profile OR sender:AppStoreListing`;
69
+ params.boostId = uri.id;
70
+ } else {
71
+ match = source.senderProfileId
72
+ ? `MATCH (sender:Profile {profileId: $senderId})-[sent:CREDENTIAL_SENT]->(credential:Credential)`
73
+ : `MATCH (sender:AppStoreListing {listing_id: $senderId})-[sent:CREDENTIAL_SENT]->(credential:Credential)`;
74
+ params.senderId = source.senderProfileId ?? source.listingId;
75
+ params.since = source.since ?? null;
76
+ params.metadata = source.metadata ?? {};
77
+ match += ` WHERE ($since IS NULL OR sent.date >= $since)
78
+ AND all(key IN keys($metadata) WHERE sent['metadata.' + key] = $metadata[key])`;
79
+ }
80
+ return {
81
+ match: `${match}
82
+ MATCH (p:Profile {profileId: sent.to})
83
+ OPTIONAL MATCH (credential)-[received:CREDENTIAL_RECEIVED]->(p)
84
+ WITH p, sent, received
85
+ WHERE coalesce(sent.status, '') <> 'revoked'
86
+ AND coalesce(received.status, '') <> 'revoked'
87
+ AND ($includePending OR (received IS NOT NULL AND coalesce(received.status, '') <> 'pending'))`,
88
+ params,
89
+ };
90
+ }
91
+ }
92
+ };
93
+
94
+ export const previewCampaignAudience = async (
95
+ campaignId: string,
96
+ source: CampaignAudienceSource,
97
+ domain: string
98
+ ): Promise<number> => {
99
+ await getCampaign(campaignId);
100
+ const query = buildCampaignAudienceQuery(source, domain);
101
+ const result = await traceDb('campaign:previewAudience', () =>
102
+ neogma.queryRunner.run(
103
+ `${query.match} WITH DISTINCT p MATCH (c:Campaign {id: $campaignId})
104
+ WHERE ${campaignAgePredicate} RETURN count(p) AS count`,
105
+ { ...query.params, campaignId, ...campaignTimeParams() }
106
+ )
107
+ );
108
+ return result.records[0]!.get('count').toNumber();
109
+ };
110
+
111
+ export const addCampaignAudience = async (
112
+ campaignId: string,
113
+ source: CampaignAudienceSource,
114
+ domain: string
115
+ ): Promise<number> => {
116
+ const query = buildCampaignAudienceQuery(source, domain);
117
+ return withCampaignTransaction('campaign:addAudience', async transaction => {
118
+ assertCampaignEditable(await lockCampaign(transaction, campaignId));
119
+ const result = await transaction.run(
120
+ `${query.match} WITH DISTINCT p MATCH (c:Campaign {id: $campaignId})
121
+ WHERE ${campaignAgePredicate}
122
+ MERGE (p)-[audience:IN_CAMPAIGN_AUDIENCE]->(c)
123
+ ON CREATE SET audience.addedAt = $now, audience.source = $source
124
+ RETURN count(p) AS count`,
125
+ { ...query.params, campaignId, source: source.type, ...campaignTimeParams() }
126
+ );
127
+ return result.records[0]!.get('count').toNumber();
128
+ });
129
+ };
130
+
131
+ export const pageCampaignAudience = async (
132
+ campaignId: string,
133
+ cursor?: string,
134
+ limit = 100
135
+ ): Promise<PaginatedCampaignAudience> => {
136
+ await getCampaign(campaignId);
137
+ const result = await traceDb('campaign:pageAudience', () =>
138
+ neogma.queryRunner.run(
139
+ `MATCH (p:Profile)-[:IN_CAMPAIGN_AUDIENCE]->(c:Campaign {id: $campaignId})
140
+ WHERE ($cursor IS NULL OR p.profileId > $cursor) AND ${campaignAgePredicate}
141
+ RETURN DISTINCT p.profileId AS profileId, p.did AS did ORDER BY profileId LIMIT $limit`,
142
+ { campaignId, cursor: cursor ?? null, limit: int(limit + 1), ...campaignTimeParams() }
143
+ )
144
+ );
145
+ const rows = result.records.map(record => ({
146
+ profileId: record.get('profileId') as string,
147
+ did: record.get('did') as string,
148
+ }));
149
+ const hasMore = rows.length > limit;
150
+ const records = rows.slice(0, limit);
151
+ return { records, hasMore, cursor: hasMore ? records.at(-1)?.profileId : undefined };
152
+ };
@@ -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
+ };
@@ -21,7 +21,7 @@ import { BoostType } from 'types/boost';
21
21
  import { flattenObject, inflateObject } from '@helpers/objects.helpers';
22
22
  import { convertQueryResultToPropertiesObjectArray } from '@helpers/neo4j.helpers';
23
23
  import { reconsentTerms } from './update';
24
- import { getBoostUri, sendBoost } from '@helpers/boost.helpers';
24
+ import { getBoostUri, sendBoost, stampIssuanceDate } from '@helpers/boost.helpers';
25
25
  import { setCredentialSubjectIds } from '@helpers/credentialSubject.helpers';
26
26
  import { getDidWeb } from '@helpers/did.helpers';
27
27
  import { getSigningAuthorityForUserByName } from '@accesslayer/signing-authority/relationships/read';
@@ -296,6 +296,8 @@ export const consentToContract = async (
296
296
 
297
297
  boostCredential.issuer = { id: contractOwnerSigningAuthority.relationship.did };
298
298
 
299
+ stampIssuanceDate(boostCredential);
300
+
299
301
  if (boostCredential.type.includes('BoostCredential')) {
300
302
  boostCredential.boostId = getBoostUri(boost.target.id, domain);
301
303
  }
@@ -15,7 +15,7 @@ import { ConsentFlowTerms, ConsentFlowTransaction, ConsentFlowContract } from '@
15
15
  import { neogma } from '@instance';
16
16
  import { flattenObject } from '@helpers/objects.helpers';
17
17
  import { DbContractType, DbTermsType } from 'types/consentflowcontract';
18
- import { getBoostUri, sendBoost } from '@helpers/boost.helpers';
18
+ import { getBoostUri, sendBoost, stampIssuanceDate } from '@helpers/boost.helpers';
19
19
  import { setCredentialSubjectIds } from '@helpers/credentialSubject.helpers';
20
20
  import { getDidWeb } from '@helpers/did.helpers';
21
21
  import { getSigningAuthorityForUserByName } from '@accesslayer/signing-authority/relationships/read';
@@ -201,6 +201,8 @@ export const reconsentTerms = async (
201
201
  // Set the issuer and subject
202
202
  boostCredential.issuer = { id: contractOwnerSigningAuthority.relationship.did };
203
203
 
204
+ stampIssuanceDate(boostCredential);
205
+
204
206
  if (boostCredential.type.includes('BoostCredential')) {
205
207
  boostCredential.boostId = getBoostUri(boost.dataValues.id, domain);
206
208
  }
@@ -439,6 +441,8 @@ export const updateTerms = async (
439
441
  // Set the issuer and subject
440
442
  boostCredential.issuer = { id: contractOwnerSigningAuthority.relationship.did };
441
443
 
444
+ stampIssuanceDate(boostCredential);
445
+
442
446
  if (boostCredential.type.includes('BoostCredential')) {
443
447
  boostCredential.boostId = getBoostUri(boost.target.id, domain);
444
448
  }