@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.
- package/.env.example +14 -0
- package/CHANGELOG.md +41 -0
- package/config/config.dev.json +5 -0
- package/config/config.example.json +5 -0
- package/config/config.production.json +5 -0
- package/config/config.scouts.dev.json +5 -0
- package/config/config.scouts.production.json +5 -0
- package/dist/accesslayer/campaign/audience.d.ts +7 -0
- package/dist/accesslayer/campaign/audience.d.ts.map +1 -0
- package/dist/accesslayer/campaign/create.d.ts +3 -0
- package/dist/accesslayer/campaign/create.d.ts.map +1 -0
- package/dist/accesslayer/campaign/delete.d.ts +2 -0
- package/dist/accesslayer/campaign/delete.d.ts.map +1 -0
- package/dist/accesslayer/campaign/read.d.ts +6 -0
- package/dist/accesslayer/campaign/read.d.ts.map +1 -0
- package/dist/accesslayer/campaign/shared.d.ts +16 -0
- package/dist/accesslayer/campaign/shared.d.ts.map +1 -0
- package/dist/accesslayer/campaign/state.d.ts +4 -0
- package/dist/accesslayer/campaign/state.d.ts.map +1 -0
- package/dist/accesslayer/campaign/update.d.ts +3 -0
- package/dist/accesslayer/campaign/update.d.ts.map +1 -0
- package/dist/accesslayer/credential-share-previews.d.ts +5 -0
- package/dist/accesslayer/credential-share-previews.d.ts.map +1 -0
- package/dist/accesslayer/profile/delete.d.ts.map +1 -1
- package/dist/accesslayer/skill/read.d.ts +12 -2
- package/dist/accesslayer/skill/read.d.ts.map +1 -1
- package/dist/accesslayer/skill/update.d.ts +11 -1
- package/dist/accesslayer/skill/update.d.ts.map +1 -1
- package/dist/app.d.ts +4 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/cache/app-store.caches.d.ts +2 -1
- package/dist/cache/app-store.caches.d.ts.map +1 -1
- package/dist/config/environment.d.ts +268 -178
- package/dist/config/environment.d.ts.map +1 -1
- package/dist/config/skillEmbedding.d.ts +70 -0
- package/dist/config/skillEmbedding.d.ts.map +1 -0
- package/dist/config/skillEmbedding.neo4j.test.d.ts +2 -0
- package/dist/config/skillEmbedding.neo4j.test.d.ts.map +1 -0
- package/dist/config/stageConfig.d.ts +20 -0
- package/dist/config/stageConfig.d.ts.map +1 -1
- package/dist/constants/campaigns.d.ts +3 -0
- package/dist/constants/campaigns.d.ts.map +1 -0
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/helpers/bedrock-embedding.helpers.d.ts +40 -0
- package/dist/helpers/bedrock-embedding.helpers.d.ts.map +1 -0
- package/dist/helpers/bedrock-embedding.helpers.test.d.ts +2 -0
- package/dist/helpers/bedrock-embedding.helpers.test.d.ts.map +1 -0
- package/dist/helpers/bedrock-embedding.smoke.test.d.ts +2 -0
- package/dist/helpers/bedrock-embedding.smoke.test.d.ts.map +1 -0
- package/dist/helpers/skill-embedding.helpers.d.ts +65 -7
- package/dist/helpers/skill-embedding.helpers.d.ts.map +1 -1
- package/dist/helpers/skill-embedding.helpers.test.d.ts +2 -0
- package/dist/helpers/skill-embedding.helpers.test.d.ts.map +1 -0
- package/dist/index.js +2 -2
- package/dist/models/Campaign.d.ts +10 -0
- package/dist/models/Campaign.d.ts.map +1 -0
- package/dist/models/CredentialSharePreview.d.ts +14 -0
- package/dist/models/CredentialSharePreview.d.ts.map +1 -0
- package/dist/models/Integration.d.ts +1 -1
- package/dist/models/Profile.d.ts.map +1 -1
- package/dist/models/campaign-constraints.d.ts +2 -0
- package/dist/models/campaign-constraints.d.ts.map +1 -0
- package/dist/models/index.d.ts +2 -0
- package/dist/models/index.d.ts.map +1 -1
- package/dist/publish-metafile.json +1 -1
- package/dist/routes/app-store.d.ts +1 -1
- package/dist/routes/boosts.d.ts +24 -4
- package/dist/routes/boosts.d.ts.map +1 -1
- package/dist/routes/campaigns.d.ts +1411 -0
- package/dist/routes/campaigns.d.ts.map +1 -0
- package/dist/routes/contracts.d.ts +19 -6
- package/dist/routes/contracts.d.ts.map +1 -1
- package/dist/routes/credential-share-previews.d.ts +39 -0
- package/dist/routes/credential-share-previews.d.ts.map +1 -0
- package/dist/routes/credentials.d.ts +4 -1
- package/dist/routes/credentials.d.ts.map +1 -1
- package/dist/routes/inbox.d.ts +5 -5
- package/dist/routes/index.d.ts +4 -0
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/integrations.d.ts +3 -3
- package/dist/routes/profile-manager.d.ts +4 -0
- package/dist/routes/profile-manager.d.ts.map +1 -1
- package/dist/routes/profiles.d.ts +44 -0
- package/dist/routes/profiles.d.ts.map +1 -1
- package/dist/routes/skill-frameworks.d.ts +1 -0
- package/dist/routes/skill-frameworks.d.ts.map +1 -1
- package/dist/routes/skills-semantic-search.test.d.ts +2 -0
- package/dist/routes/skills-semantic-search.test.d.ts.map +1 -0
- package/dist/routes/skills.d.ts.map +1 -1
- package/dist/routes/test.d.ts +3 -1
- package/dist/routes/test.d.ts.map +1 -1
- package/dist/seed/seedSkillFrameworks.d.ts +1 -0
- package/dist/seed/seedSkillFrameworks.d.ts.map +1 -1
- package/dist/seed/seedSkillFrameworks.embeddings.test.d.ts +2 -0
- package/dist/seed/seedSkillFrameworks.embeddings.test.d.ts.map +1 -0
- package/dist/types/app-manifest.d.ts +2 -2
- package/dist/types/campaign.d.ts +19 -0
- package/dist/types/campaign.d.ts.map +1 -0
- package/dist/types/consentflowcontract.d.ts +1 -1
- package/dist/types/integration.d.ts +3 -3
- package/dist/types/profile.d.ts +9 -0
- package/dist/types/profile.d.ts.map +1 -1
- package/dist/types/skill.d.ts.map +1 -1
- package/package.json +20 -17
- package/project.json +14 -0
- package/scripts/skill-embeddings-backfill.ts +161 -0
- package/serverless-local.yml +4 -0
- package/serverless.function-env.cjs +3 -0
- package/serverless.local.yml +4 -0
- package/serverless.yml +12 -1
- package/src/accesslayer/campaign/README.md +174 -0
- package/src/accesslayer/campaign/audience.ts +152 -0
- package/src/accesslayer/campaign/create.ts +47 -0
- package/src/accesslayer/campaign/delete.ts +18 -0
- package/src/accesslayer/campaign/read.ts +125 -0
- package/src/accesslayer/campaign/shared.ts +92 -0
- package/src/accesslayer/campaign/state.ts +80 -0
- package/src/accesslayer/campaign/update.ts +50 -0
- package/src/accesslayer/credential-share-previews.ts +76 -0
- package/src/accesslayer/profile/delete.ts +6 -0
- package/src/accesslayer/skill/read.ts +56 -16
- package/src/accesslayer/skill/update.ts +43 -1
- package/src/app.ts +9 -0
- package/src/config/environment.test.ts +208 -0
- package/src/config/environment.ts +166 -11
- package/src/config/skillEmbedding.neo4j.test.ts +138 -0
- package/src/config/skillEmbedding.ts +202 -0
- package/src/constants/campaigns.ts +9 -0
- package/src/helpers/bedrock-embedding.helpers.test.ts +208 -0
- package/src/helpers/bedrock-embedding.helpers.ts +243 -0
- package/src/helpers/bedrock-embedding.smoke.test.ts +53 -0
- package/src/helpers/skill-embedding.helpers.test.ts +1044 -0
- package/src/helpers/skill-embedding.helpers.ts +611 -123
- package/src/models/Campaign.ts +32 -0
- package/src/models/CredentialSharePreview.ts +30 -0
- package/src/models/Profile.ts +1 -0
- package/src/models/campaign-constraints.ts +22 -0
- package/src/models/index.ts +29 -1
- package/src/routes/campaigns.ts +179 -0
- package/src/routes/credential-share-previews.ts +74 -0
- package/src/routes/profiles.ts +7 -0
- package/src/routes/skills-semantic-search.test.ts +168 -0
- package/src/routes/skills.ts +42 -6
- package/src/seed/seedSkillFrameworks.embeddings.test.ts +82 -0
- package/src/seed/seedSkillFrameworks.test.ts +6 -0
- package/src/seed/seedSkillFrameworks.ts +83 -18
- package/src/types/campaign.ts +17 -0
- package/src/types/skill.ts +6 -0
- package/test/campaigns.spec.ts +789 -0
- package/test/credential-share-previews.spec.ts +97 -0
- package/test/verification/campaigns.ts +49 -0
- package/vitest.campaigns.config.ts +21 -0
- package/vitest.config.ts +10 -0
- 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
|
|
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(
|
|
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 ?? {})
|
|
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
|
|
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<
|
|
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.
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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);
|