@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.
- package/.env.example +14 -0
- package/CHANGELOG.md +76 -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/consentflowcontract/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
- package/dist/accesslayer/credential-share-previews.d.ts +5 -0
- package/dist/accesslayer/credential-share-previews.d.ts.map +1 -0
- package/dist/accesslayer/inbox-batch/schema.d.ts +4 -0
- package/dist/accesslayer/inbox-batch/schema.d.ts.map +1 -0
- package/dist/accesslayer/inbox-batch/schema.test.d.ts +2 -0
- package/dist/accesslayer/inbox-batch/schema.test.d.ts.map +1 -0
- package/dist/accesslayer/inbox-batch/store.d.ts +1 -1
- package/dist/accesslayer/inbox-batch/store.d.ts.map +1 -1
- 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/boost.helpers.d.ts +1 -0
- package/dist/helpers/boost.helpers.d.ts.map +1 -1
- 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/routes/workflows.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/esbuildPlugins.cjs +9 -6
- 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/consentflowcontract/relationships/create.ts +3 -1
- package/src/accesslayer/consentflowcontract/relationships/update.ts +5 -1
- package/src/accesslayer/credential-share-previews.ts +76 -0
- package/src/accesslayer/inbox-batch/schema.test.ts +91 -0
- package/src/accesslayer/inbox-batch/schema.ts +54 -0
- package/src/accesslayer/inbox-batch/store.ts +2 -38
- 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/boost-storage.helpers.test.ts +155 -1
- package/src/helpers/boost.helpers.ts +52 -10
- 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/routes/workflows.ts +8 -1
- 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/consentflow.spec.ts +101 -0
- package/test/credential-share-previews.spec.ts +97 -0
- package/test/inbox-batch.spec.ts +189 -1
- package/test/verification/campaigns.ts +49 -0
- package/vitest.campaigns.config.ts +21 -0
- package/vitest.config.ts +11 -0
- 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
|
}
|