@mevdragon/vidfarm-devcli 0.5.3 → 0.6.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/README.md +3 -3
- package/demo/README.md +28 -0
- package/demo/dist/app.css +1 -0
- package/demo/dist/app.js +1184 -0
- package/demo/dist/chunks/chunk-DXB73IDG.js +1 -0
- package/demo/dist/chunks/chunk-S7OWAJDS.js +36 -0
- package/demo/dist/chunks/chunk-VTIBZ6AN.js +1 -0
- package/demo/dist/chunks/dist-ADSJKBVE.js +332 -0
- package/demo/dist/chunks/domEditingLayers-VZMLL4AP-SGHWPND4.js +1 -0
- package/demo/dist/chunks/hyperframes-player-XB65TCD6.js +425 -0
- package/demo/dist/chunks/lib-XAQ37YOE.js +1 -0
- package/demo/dist/chunks/src-TJ2QYA4U.js +207 -0
- package/demo/dist/favicon.ico +0 -0
- package/demo/dist/icons/timeline/audio.svg +7 -0
- package/demo/dist/icons/timeline/captions.svg +5 -0
- package/demo/dist/icons/timeline/composition.svg +12 -0
- package/demo/dist/icons/timeline/image.svg +18 -0
- package/demo/dist/icons/timeline/music.svg +10 -0
- package/demo/dist/icons/timeline/text.svg +3 -0
- package/demo/dist/index.html +15 -0
- package/dist/src/account-pages-legacy.js +9396 -0
- package/dist/src/account-pages.js +61 -0
- package/dist/src/app.js +14378 -0
- package/dist/src/cli.js +1 -1
- package/dist/src/composition-runtime.js +613 -0
- package/dist/src/config.js +166 -0
- package/dist/src/context.js +447 -0
- package/dist/src/dev-app-legacy.js +739 -0
- package/dist/src/dev-app.js +6 -0
- package/dist/src/domain.js +2 -0
- package/dist/src/editor-chat-history.js +82 -0
- package/dist/src/editor-chat.js +449 -0
- package/dist/src/editor-dark-theme.js +1128 -0
- package/dist/src/frontend/debug.js +71 -0
- package/dist/src/frontend/flockposter-cache-store.js +124 -0
- package/dist/src/frontend/homepage-client.js +182 -0
- package/dist/src/frontend/homepage-shared.js +4 -0
- package/dist/src/frontend/homepage-store.js +28 -0
- package/dist/src/frontend/homepage-view.js +547 -0
- package/dist/src/frontend/page-runtime-client.js +132 -0
- package/dist/src/frontend/page-runtime-store.js +9 -0
- package/dist/src/frontend/sentry.js +42 -0
- package/dist/src/frontend/template-editor-chat.js +3960 -0
- package/dist/src/help-page.js +346 -0
- package/dist/src/homepage.js +1235 -0
- package/dist/src/hyperframes/composition.js +180 -0
- package/dist/src/index.js +16 -0
- package/dist/src/instrument.js +30 -0
- package/dist/src/lib/crypto.js +45 -0
- package/dist/src/lib/dev-log.js +54 -0
- package/dist/src/lib/display-name.js +11 -0
- package/dist/src/lib/ids.js +24 -0
- package/dist/src/lib/images.js +19 -0
- package/dist/src/lib/json.js +15 -0
- package/dist/src/lib/package-root.js +47 -0
- package/dist/src/lib/template-paths.js +28 -0
- package/dist/src/lib/time.js +7 -0
- package/dist/src/lib/url-clean.js +85 -0
- package/dist/src/page-runtime.js +2 -0
- package/dist/src/page-shell.js +1381 -0
- package/dist/src/primitive-context.js +357 -0
- package/dist/src/primitive-registry.js +2436 -0
- package/dist/src/primitive-sdk.js +4 -0
- package/dist/src/primitives/hyperframes-media.js +108 -0
- package/dist/src/react-page-shell.js +35 -0
- package/dist/src/ready-post-schedule-component.js +1540 -0
- package/dist/src/registry.js +296 -0
- package/dist/src/runtime.js +35 -0
- package/dist/src/services/api-call-history.js +249 -0
- package/dist/src/services/auth.js +152 -0
- package/dist/src/services/billing-pricing.js +39 -0
- package/dist/src/services/billing.js +228 -0
- package/dist/src/services/cast.js +127 -0
- package/dist/src/services/chat-threads.js +92 -0
- package/dist/src/services/composition-sanitize.js +124 -0
- package/dist/src/services/composition-watch.js +79 -0
- package/dist/src/services/fork-access.js +93 -0
- package/dist/src/services/fork-manifest.js +42 -0
- package/dist/src/services/ghostcut.js +179 -0
- package/dist/src/services/hyperframes.js +2307 -0
- package/dist/src/services/job-capacity.js +14 -0
- package/dist/src/services/job-logs.js +197 -0
- package/dist/src/services/jobs.js +136 -0
- package/dist/src/services/local-dynamo.js +0 -0
- package/dist/src/services/media-processing.js +766 -0
- package/dist/src/services/primitive-media-lambda.js +280 -0
- package/dist/src/services/providers.js +2926 -0
- package/dist/src/services/rate-limits.js +262 -0
- package/dist/src/services/serverless-auth.js +382 -0
- package/dist/src/services/serverless-jobs.js +1082 -0
- package/dist/src/services/serverless-provider-keys.js +409 -0
- package/dist/src/services/serverless-records.js +1385 -0
- package/dist/src/services/serverless-template-configs.js +75 -0
- package/dist/src/services/storage.js +383 -0
- package/dist/src/services/template-certification.js +413 -0
- package/dist/src/services/template-loader.js +99 -0
- package/dist/src/services/template-runtime-bundles.js +217 -0
- package/dist/src/services/template-sources.js +1017 -0
- package/dist/src/services/video-normalization.js +2 -0
- package/dist/src/services/webhooks.js +62 -0
- package/dist/src/template-editor-pages.js +2576 -0
- package/dist/src/template-editor-shell.js +2840 -0
- package/dist/src/template-sdk.js +4 -0
- package/dist/src/worker.js +17 -0
- package/package.json +6 -4
- package/public/assets/homepage-app.js +54 -0
- package/public/assets/homepage-client-app.js +80 -0
- package/public/assets/page-runtime-client-app.js +94 -0
- package/src/assets/SELLING_AWARENESS_STAGES.md +579 -0
- package/src/assets/SELLING_WITH_HOOKS.md +377 -0
- package/src/assets/SELLING_WITH_VSLS.md +606 -0
- package/src/assets/favicon.ico +0 -0
- package/src/assets/logo-vidfarm.png +0 -0
|
@@ -0,0 +1,1385 @@
|
|
|
1
|
+
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
2
|
+
import { DeleteCommand, DynamoDBDocumentClient, GetCommand, PutCommand, QueryCommand, ScanCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb";
|
|
3
|
+
import { config } from "../config.js";
|
|
4
|
+
import { devLog } from "../lib/dev-log.js";
|
|
5
|
+
import { displayNameFromEmail } from "../lib/display-name.js";
|
|
6
|
+
import { createId, createIdV7 } from "../lib/ids.js";
|
|
7
|
+
import { nowIso } from "../lib/time.js";
|
|
8
|
+
import { ServerlessAuthService } from "./serverless-auth.js";
|
|
9
|
+
import { createLocalDynamoClient } from "./local-dynamo.js";
|
|
10
|
+
// In local (`vidfarm serve`) mode every DynamoDB call is serviced from disk by
|
|
11
|
+
// the shim; deployed environments keep the real client. Call sites are
|
|
12
|
+
// identical either way — only this construction switches.
|
|
13
|
+
const dynamodb = config.RECORDS_DRIVER === "local"
|
|
14
|
+
? createLocalDynamoClient()
|
|
15
|
+
: DynamoDBDocumentClient.from(new DynamoDBClient({}), {
|
|
16
|
+
marshallOptions: {
|
|
17
|
+
removeUndefinedValues: true
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
const METHOD_COLLECTIONS = {
|
|
21
|
+
archiveReadyPost: "ready_post",
|
|
22
|
+
createCompositionFork: "composition_fork",
|
|
23
|
+
deleteCompositionFork: "composition_fork",
|
|
24
|
+
getCompositionFork: "composition_fork",
|
|
25
|
+
listCompositionForksForCustomer: "composition_fork",
|
|
26
|
+
listCompositionForksForTemplate: "composition_fork",
|
|
27
|
+
updateCompositionFork: "composition_fork",
|
|
28
|
+
createInspiration: "inspiration",
|
|
29
|
+
getInspiration: "inspiration",
|
|
30
|
+
findInspirationByOriginalUrl: "inspiration",
|
|
31
|
+
updateInspiration: "inspiration",
|
|
32
|
+
listInspirationsForCustomer: "inspiration",
|
|
33
|
+
listInspirationsFeed: "inspiration",
|
|
34
|
+
createTemplate: "template",
|
|
35
|
+
getTemplate: "template",
|
|
36
|
+
updateTemplate: "template",
|
|
37
|
+
deleteTemplate: "template",
|
|
38
|
+
deleteInspiration: "inspiration",
|
|
39
|
+
listTemplatesFeed: "template",
|
|
40
|
+
listTemplatesForCustomer: "template",
|
|
41
|
+
createEmailChannel: "email_channel",
|
|
42
|
+
createPostSchedule: "post_schedule",
|
|
43
|
+
createReadyPost: "ready_post",
|
|
44
|
+
createTemplateRelease: "template_release",
|
|
45
|
+
createTemplateSource: "template_source",
|
|
46
|
+
createUserAttachment: "user_attachment",
|
|
47
|
+
createUserFileFolder: "user_file_folder",
|
|
48
|
+
createUserTemporaryFile: "user_temporary_file",
|
|
49
|
+
deleteEmailChannel: "email_channel",
|
|
50
|
+
deleteReadyPost: "ready_post",
|
|
51
|
+
deleteUserAttachment: "user_attachment",
|
|
52
|
+
deleteUserFileFolder: "user_file_folder",
|
|
53
|
+
deleteUserTemporaryFile: "user_temporary_file",
|
|
54
|
+
getEmailChannel: "email_channel",
|
|
55
|
+
getEmailChannelByEmail: "email_channel",
|
|
56
|
+
getPostSchedule: "post_schedule",
|
|
57
|
+
getReadyPost: "ready_post",
|
|
58
|
+
getTemplateRelease: "template_release",
|
|
59
|
+
getTemplateSource: "template_source",
|
|
60
|
+
getTemplateSourceByLocation: "template_source",
|
|
61
|
+
getTemplateSourceBySlugId: "template_source",
|
|
62
|
+
getTemplateSourceByTemplateId: "template_source",
|
|
63
|
+
getUserAttachment: "user_attachment",
|
|
64
|
+
getUserTemporaryFile: "user_temporary_file",
|
|
65
|
+
listEmailChannels: "email_channel",
|
|
66
|
+
listPostSchedulesForCalendar: "post_schedule",
|
|
67
|
+
listPostSchedulesForPost: "post_schedule",
|
|
68
|
+
listReadyPosts: "ready_post",
|
|
69
|
+
listTemplateReleases: "template_release",
|
|
70
|
+
listTemplateSources: "template_source",
|
|
71
|
+
listUserAttachments: "user_attachment",
|
|
72
|
+
listUserFileFolders: "user_file_folder",
|
|
73
|
+
listUserTemporaryFiles: "user_temporary_file",
|
|
74
|
+
markPostScheduleCancelled: "post_schedule",
|
|
75
|
+
markPostScheduleFailed: "post_schedule",
|
|
76
|
+
moveUserAttachmentFolder: "user_attachment",
|
|
77
|
+
moveUserFileFolder: "user_file_folder",
|
|
78
|
+
moveUserTemporaryFolder: "user_temporary_file",
|
|
79
|
+
queueWebhookDelivery: "webhook_delivery",
|
|
80
|
+
setReadyPostZip: "ready_post",
|
|
81
|
+
updateEmailChannel: "email_channel",
|
|
82
|
+
updateEmailChannelAvatar: "email_channel",
|
|
83
|
+
updateEmailChannelVerification: "email_channel",
|
|
84
|
+
updatePostScheduleProvider: "post_schedule",
|
|
85
|
+
updateTemplateSource: "template_source",
|
|
86
|
+
upsertExternalPostSchedule: "post_schedule"
|
|
87
|
+
};
|
|
88
|
+
class ServerlessRecordsServiceImpl {
|
|
89
|
+
auth = new ServerlessAuthService();
|
|
90
|
+
async getCustomerById(customerId) {
|
|
91
|
+
return this.auth.getCustomerById(customerId);
|
|
92
|
+
}
|
|
93
|
+
async getCustomerByEmail(email) {
|
|
94
|
+
return this.auth.getCustomerByEmailPublic(email);
|
|
95
|
+
}
|
|
96
|
+
async upsertCustomer(input) {
|
|
97
|
+
return this.auth.upsertCustomer(input);
|
|
98
|
+
}
|
|
99
|
+
async insertApiKey(input) {
|
|
100
|
+
return this.auth.insertApiKey(input);
|
|
101
|
+
}
|
|
102
|
+
async getFirstPaidCustomer() {
|
|
103
|
+
const response = await dynamodb.send(new ScanCommand({
|
|
104
|
+
TableName: requireMainTableName(),
|
|
105
|
+
FilterExpression: "entity_type = :type AND is_paid_plan = :paid",
|
|
106
|
+
ExpressionAttributeValues: {
|
|
107
|
+
":type": "Customer",
|
|
108
|
+
":paid": true
|
|
109
|
+
},
|
|
110
|
+
Limit: 1
|
|
111
|
+
}));
|
|
112
|
+
return response.Items?.[0] ? itemToCustomer(response.Items[0]) : null;
|
|
113
|
+
}
|
|
114
|
+
async setCustomerAccessByEmail(input) {
|
|
115
|
+
const normalizedEmail = input.email.trim().toLowerCase();
|
|
116
|
+
const existing = await this.auth.getCustomerByEmailPublic(normalizedEmail);
|
|
117
|
+
return this.auth.upsertCustomer({
|
|
118
|
+
id: existing?.id ?? createId("cus"),
|
|
119
|
+
email: normalizedEmail,
|
|
120
|
+
name: input.name?.trim() || existing?.name || displayNameFromEmail(normalizedEmail),
|
|
121
|
+
defaultWebhookUrl: existing?.defaultWebhookUrl ?? null,
|
|
122
|
+
isDeveloper: input.isDeveloper ?? existing?.isDeveloper ?? false,
|
|
123
|
+
isAgency: input.isAgency ?? existing?.isAgency ?? false,
|
|
124
|
+
isPaidPlan: input.isPaidPlan ?? existing?.isPaidPlan ?? false,
|
|
125
|
+
planTier: input.planTier ?? existing?.planTier ?? "client"
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
async updateCustomerPaidPlan(customerId, isPaidPlan, planTier = "client") {
|
|
129
|
+
const existing = await this.auth.getCustomerById(customerId);
|
|
130
|
+
if (!existing)
|
|
131
|
+
return null;
|
|
132
|
+
return this.auth.upsertCustomer({ ...existing, isPaidPlan, planTier });
|
|
133
|
+
}
|
|
134
|
+
async updateCustomerAgencyStatus(customerId, isAgency) {
|
|
135
|
+
const existing = await this.auth.getCustomerById(customerId);
|
|
136
|
+
if (!existing)
|
|
137
|
+
return null;
|
|
138
|
+
return this.auth.upsertCustomer({ ...existing, isAgency, planTier: isAgency ? "agency" : existing.planTier });
|
|
139
|
+
}
|
|
140
|
+
async updateCustomerProfile(input) {
|
|
141
|
+
const existing = await this.auth.getCustomerById(input.customerId);
|
|
142
|
+
if (!existing)
|
|
143
|
+
return null;
|
|
144
|
+
return this.auth.upsertCustomer({
|
|
145
|
+
...existing,
|
|
146
|
+
name: input.name === undefined ? existing.name : input.name,
|
|
147
|
+
about: input.about === undefined ? existing.about : input.about,
|
|
148
|
+
defaultWebhookUrl: input.defaultWebhookUrl === undefined ? existing.defaultWebhookUrl : input.defaultWebhookUrl,
|
|
149
|
+
groupchatUrl: input.groupchatUrl === undefined ? existing.groupchatUrl : input.groupchatUrl,
|
|
150
|
+
flockposterApiKey: input.flockposterApiKey === undefined ? existing.flockposterApiKey : input.flockposterApiKey
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
async getLatestApiKeyForCustomer(customerId) {
|
|
154
|
+
const response = await dynamodb.send(new QueryCommand({
|
|
155
|
+
TableName: requireMainTableName(),
|
|
156
|
+
IndexName: "gsi1",
|
|
157
|
+
KeyConditionExpression: "gsi1pk = :pk",
|
|
158
|
+
FilterExpression: "#status = :status",
|
|
159
|
+
ExpressionAttributeNames: {
|
|
160
|
+
"#status": "status"
|
|
161
|
+
},
|
|
162
|
+
ExpressionAttributeValues: {
|
|
163
|
+
":pk": `CUSTOMER#${customerId}#APIKEYS`,
|
|
164
|
+
":status": "active"
|
|
165
|
+
},
|
|
166
|
+
ScanIndexForward: false,
|
|
167
|
+
Limit: 1
|
|
168
|
+
}));
|
|
169
|
+
return response.Items?.[0] ?? null;
|
|
170
|
+
}
|
|
171
|
+
async hasCustomerCreditGrant(customerId, grantType) {
|
|
172
|
+
return Boolean(await this.getById("credit_grant", `${customerId}:${grantType}`));
|
|
173
|
+
}
|
|
174
|
+
async insertCustomerCreditGrant(input) {
|
|
175
|
+
await this.putRecord("credit_grant", {
|
|
176
|
+
...input,
|
|
177
|
+
id: `${input.customerId}:${input.grantType}`,
|
|
178
|
+
customerId: input.customerId
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
async grantAgencyClientAccess(input) {
|
|
182
|
+
return this.putRecord("agency_access", {
|
|
183
|
+
id: `${input.agencyCustomerId}:${input.clientCustomerId}`,
|
|
184
|
+
customerId: input.agencyCustomerId,
|
|
185
|
+
agencyCustomerId: input.agencyCustomerId,
|
|
186
|
+
clientCustomerId: input.clientCustomerId
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
async revokeAgencyClientAccess(input) {
|
|
190
|
+
const record = await this.getById("agency_access", `${input.agencyCustomerId}:${input.clientCustomerId}`);
|
|
191
|
+
await this.deleteById("agency_access", `${input.agencyCustomerId}:${input.clientCustomerId}`);
|
|
192
|
+
return record;
|
|
193
|
+
}
|
|
194
|
+
async listAgencyClients(input) {
|
|
195
|
+
const links = await this.listByCustomer("agency_access", input.agencyCustomerId);
|
|
196
|
+
const clients = [];
|
|
197
|
+
for (const link of links) {
|
|
198
|
+
const clientId = String(link.clientCustomerId ?? "");
|
|
199
|
+
const customer = clientId ? await this.auth.getCustomerById(clientId) : null;
|
|
200
|
+
if (customer)
|
|
201
|
+
clients.push({ ...link, customer });
|
|
202
|
+
}
|
|
203
|
+
return clients;
|
|
204
|
+
}
|
|
205
|
+
async getAgencyClient(agencyCustomerId, clientCustomerId) {
|
|
206
|
+
return this.getById("agency_access", `${agencyCustomerId}:${clientCustomerId}`);
|
|
207
|
+
}
|
|
208
|
+
async createCompositionFork(input) {
|
|
209
|
+
const timestamp = nowIso();
|
|
210
|
+
return this.putRecord("composition_fork", {
|
|
211
|
+
id: input.id ?? createIdV7("fork"),
|
|
212
|
+
customerId: input.customerId,
|
|
213
|
+
templateId: input.templateId,
|
|
214
|
+
parentForkId: input.parentForkId ?? null,
|
|
215
|
+
parentVersion: input.parentVersion ?? null,
|
|
216
|
+
title: input.title ?? null,
|
|
217
|
+
visibility: input.visibility ?? "private",
|
|
218
|
+
latestVersion: 0,
|
|
219
|
+
currentRenderJobId: null,
|
|
220
|
+
deletedAt: null,
|
|
221
|
+
createdAt: timestamp,
|
|
222
|
+
updatedAt: timestamp
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
async getCompositionFork(forkId) {
|
|
226
|
+
const fork = await this.getById("composition_fork", forkId);
|
|
227
|
+
if (!fork)
|
|
228
|
+
return null;
|
|
229
|
+
return {
|
|
230
|
+
...fork,
|
|
231
|
+
parentForkId: fork.parentForkId ?? null,
|
|
232
|
+
parentVersion: fork.parentVersion ?? null,
|
|
233
|
+
visibility: fork.visibility ?? "private",
|
|
234
|
+
latestVersion: fork.latestVersion ?? 0
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
async listCompositionForksForTemplate(input) {
|
|
238
|
+
const owned = await this.listByCustomer("composition_fork", input.customerId);
|
|
239
|
+
return owned.filter((fork) => fork.templateId === input.templateId && !fork.deletedAt);
|
|
240
|
+
}
|
|
241
|
+
async listCompositionForksForCustomer(customerId) {
|
|
242
|
+
return this.listByCustomer("composition_fork", customerId);
|
|
243
|
+
}
|
|
244
|
+
async listPublicCompositionForks() {
|
|
245
|
+
return (await this.scanCollection("composition_fork"))
|
|
246
|
+
.filter((fork) => !fork.deletedAt && fork.visibility === "public");
|
|
247
|
+
}
|
|
248
|
+
async updateCompositionFork(input) {
|
|
249
|
+
const existing = await this.getCompositionFork(input.forkId);
|
|
250
|
+
if (!existing)
|
|
251
|
+
return null;
|
|
252
|
+
return this.putRecord("composition_fork", {
|
|
253
|
+
...existing,
|
|
254
|
+
...input.patch,
|
|
255
|
+
id: existing.id,
|
|
256
|
+
customerId: existing.customerId,
|
|
257
|
+
updatedAt: nowIso()
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
async deleteCompositionFork(input) {
|
|
261
|
+
const existing = await this.getCompositionFork(input.forkId);
|
|
262
|
+
if (!existing || existing.customerId !== input.customerId)
|
|
263
|
+
return null;
|
|
264
|
+
return this.putRecord("composition_fork", {
|
|
265
|
+
...existing,
|
|
266
|
+
deletedAt: nowIso(),
|
|
267
|
+
updatedAt: nowIso()
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
async grantForkPermission(input) {
|
|
271
|
+
const existing = (await this.listForkPermissions(input.forkId))
|
|
272
|
+
.find((perm) => perm.granteeType === input.granteeType && perm.granteeId === input.granteeId);
|
|
273
|
+
const timestamp = nowIso();
|
|
274
|
+
const id = existing?.id ?? createId("perm");
|
|
275
|
+
return this.putRecord("fork_permission", {
|
|
276
|
+
id,
|
|
277
|
+
forkId: input.forkId,
|
|
278
|
+
ownerCustomerId: input.ownerCustomerId,
|
|
279
|
+
granteeType: input.granteeType,
|
|
280
|
+
granteeId: input.granteeId,
|
|
281
|
+
role: input.role,
|
|
282
|
+
grantedBy: input.grantedBy,
|
|
283
|
+
expiresAt: input.expiresAt ?? null,
|
|
284
|
+
createdAt: existing?.createdAt ?? timestamp,
|
|
285
|
+
updatedAt: timestamp
|
|
286
|
+
}, { partitionKey: forkPartitionPk("fork_permission", input.forkId) });
|
|
287
|
+
}
|
|
288
|
+
async listForkPermissions(forkId) {
|
|
289
|
+
return this.listByPartition("fork_permission", forkPartitionPk("fork_permission", forkId));
|
|
290
|
+
}
|
|
291
|
+
async listForkPermissionsForGrantee(customerId) {
|
|
292
|
+
return (await this.scanCollection("fork_permission"))
|
|
293
|
+
.filter((perm) => perm.granteeType === "customer" && perm.granteeId === customerId);
|
|
294
|
+
}
|
|
295
|
+
async deleteForkPermission(input) {
|
|
296
|
+
const existing = (await this.listForkPermissions(input.forkId)).find((perm) => perm.id === input.permissionId);
|
|
297
|
+
if (!existing)
|
|
298
|
+
return null;
|
|
299
|
+
await this.deleteById("fork_permission", input.permissionId);
|
|
300
|
+
return existing;
|
|
301
|
+
}
|
|
302
|
+
async createForkShareLink(input) {
|
|
303
|
+
const timestamp = nowIso();
|
|
304
|
+
return this.putRecord("fork_share_link", {
|
|
305
|
+
id: input.token,
|
|
306
|
+
forkId: input.forkId,
|
|
307
|
+
ownerCustomerId: input.ownerCustomerId,
|
|
308
|
+
token: input.token,
|
|
309
|
+
role: input.role,
|
|
310
|
+
createdBy: input.createdBy,
|
|
311
|
+
expiresAt: input.expiresAt ?? null,
|
|
312
|
+
revokedAt: null,
|
|
313
|
+
createdAt: timestamp,
|
|
314
|
+
updatedAt: timestamp
|
|
315
|
+
}, { partitionKey: forkPartitionPk("fork_share_link", input.forkId) });
|
|
316
|
+
}
|
|
317
|
+
async getForkShareLinkByToken(token) {
|
|
318
|
+
return this.getById("fork_share_link", token);
|
|
319
|
+
}
|
|
320
|
+
async listForkShareLinks(forkId) {
|
|
321
|
+
return this.listByPartition("fork_share_link", forkPartitionPk("fork_share_link", forkId));
|
|
322
|
+
}
|
|
323
|
+
async revokeForkShareLink(input) {
|
|
324
|
+
const existing = await this.getForkShareLinkByToken(input.token);
|
|
325
|
+
if (!existing || existing.forkId !== input.forkId)
|
|
326
|
+
return null;
|
|
327
|
+
return this.putRecord("fork_share_link", {
|
|
328
|
+
...existing,
|
|
329
|
+
revokedAt: nowIso(),
|
|
330
|
+
updatedAt: nowIso()
|
|
331
|
+
}, { partitionKey: forkPartitionPk("fork_share_link", input.forkId) });
|
|
332
|
+
}
|
|
333
|
+
async createForkVersion(input) {
|
|
334
|
+
const timestamp = nowIso();
|
|
335
|
+
return this.putRecord("fork_version", {
|
|
336
|
+
id: `${input.forkId}:${input.version}`,
|
|
337
|
+
forkId: input.forkId,
|
|
338
|
+
ownerCustomerId: input.ownerCustomerId,
|
|
339
|
+
version: input.version,
|
|
340
|
+
reason: input.reason,
|
|
341
|
+
message: input.message ?? null,
|
|
342
|
+
createdBy: input.createdBy,
|
|
343
|
+
storagePrefix: input.storagePrefix,
|
|
344
|
+
renderJobId: input.renderJobId ?? null,
|
|
345
|
+
createdAt: timestamp,
|
|
346
|
+
updatedAt: timestamp
|
|
347
|
+
}, { partitionKey: forkPartitionPk("fork_version", input.forkId) });
|
|
348
|
+
}
|
|
349
|
+
async getForkVersion(forkId, version) {
|
|
350
|
+
return this.getById("fork_version", `${forkId}:${version}`);
|
|
351
|
+
}
|
|
352
|
+
async listForkVersions(input) {
|
|
353
|
+
const limit = Math.max(1, Math.min(input.limit ?? 25, 200));
|
|
354
|
+
let ExclusiveStartKey = decodeDynamoCursor(input.cursor);
|
|
355
|
+
const items = [];
|
|
356
|
+
do {
|
|
357
|
+
const response = await dynamodb.send(new QueryCommand({
|
|
358
|
+
TableName: requireMainTableName(),
|
|
359
|
+
IndexName: "gsi1",
|
|
360
|
+
KeyConditionExpression: "gsi1pk = :pk",
|
|
361
|
+
ExpressionAttributeValues: {
|
|
362
|
+
":pk": forkPartitionPk("fork_version", input.forkId)
|
|
363
|
+
},
|
|
364
|
+
ScanIndexForward: false,
|
|
365
|
+
Limit: Math.max(1, limit - items.length),
|
|
366
|
+
ExclusiveStartKey
|
|
367
|
+
}));
|
|
368
|
+
items.push(...(response.Items ?? []).map((item) => item.data));
|
|
369
|
+
ExclusiveStartKey = response.LastEvaluatedKey;
|
|
370
|
+
} while (ExclusiveStartKey && items.length < limit);
|
|
371
|
+
return { items, nextCursor: encodeDynamoCursor(ExclusiveStartKey) };
|
|
372
|
+
}
|
|
373
|
+
async createInspiration(input) {
|
|
374
|
+
const timestamp = nowIso();
|
|
375
|
+
return this.putRecord("inspiration", {
|
|
376
|
+
id: input.id ?? createIdV7("inspiration"),
|
|
377
|
+
customerId: input.customerId,
|
|
378
|
+
sourceUrl: input.sourceUrl,
|
|
379
|
+
originalUrl: input.originalUrl,
|
|
380
|
+
sourceHost: input.sourceHost,
|
|
381
|
+
status: "downloading",
|
|
382
|
+
downloadJobId: input.downloadJobId ?? null,
|
|
383
|
+
videoStorageKey: null,
|
|
384
|
+
videoUrl: null,
|
|
385
|
+
thumbnailUrl: null,
|
|
386
|
+
title: null,
|
|
387
|
+
durationSeconds: null,
|
|
388
|
+
songName: null,
|
|
389
|
+
songUrl: null,
|
|
390
|
+
templateId: null,
|
|
391
|
+
trendTagline: input.trendTagline ?? null,
|
|
392
|
+
visibility: input.visibility ?? "public",
|
|
393
|
+
notes: input.notes ?? null,
|
|
394
|
+
errorMessage: null,
|
|
395
|
+
createdAt: timestamp,
|
|
396
|
+
updatedAt: timestamp
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
async getInspiration(inspirationId) {
|
|
400
|
+
const record = await this.getById("inspiration", inspirationId);
|
|
401
|
+
return record ? normalizeInspirationVisibility(record) : null;
|
|
402
|
+
}
|
|
403
|
+
async findInspirationByOriginalUrl(originalUrl) {
|
|
404
|
+
const record = await this.findByScan("inspiration", (item) => item.originalUrl === originalUrl || item.sourceUrl === originalUrl);
|
|
405
|
+
return record ? normalizeInspirationVisibility(record) : null;
|
|
406
|
+
}
|
|
407
|
+
async updateInspiration(input) {
|
|
408
|
+
const existing = await this.getInspiration(input.inspirationId);
|
|
409
|
+
if (!existing)
|
|
410
|
+
return null;
|
|
411
|
+
return this.putRecord("inspiration", {
|
|
412
|
+
...existing,
|
|
413
|
+
...input.patch,
|
|
414
|
+
id: existing.id,
|
|
415
|
+
customerId: existing.customerId,
|
|
416
|
+
updatedAt: nowIso()
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
async listInspirationsForCustomer(customerId) {
|
|
420
|
+
const records = await this.listByCustomer("inspiration", customerId);
|
|
421
|
+
return records.map(normalizeInspirationVisibility);
|
|
422
|
+
}
|
|
423
|
+
async listInspirationsFeed(input = {}) {
|
|
424
|
+
const limit = Math.max(1, Math.min(input.limit ?? 50, 200));
|
|
425
|
+
const all = (await this.scanCollection("inspiration"))
|
|
426
|
+
.filter((video) => video.status === "ready" && video.visibility !== "private");
|
|
427
|
+
const ranked = all
|
|
428
|
+
.map(normalizeInspirationVisibility)
|
|
429
|
+
.sort((a, b) => String(b.createdAt ?? "").localeCompare(String(a.createdAt ?? "")));
|
|
430
|
+
return filterAndRankBySearch(ranked, input.query).slice(0, limit);
|
|
431
|
+
}
|
|
432
|
+
async createTemplate(input) {
|
|
433
|
+
const timestamp = nowIso();
|
|
434
|
+
return this.putRecord("template", {
|
|
435
|
+
id: input.id ?? createIdV7("template"),
|
|
436
|
+
inspirationId: input.inspirationId,
|
|
437
|
+
customerId: input.customerId,
|
|
438
|
+
originalUrl: input.originalUrl,
|
|
439
|
+
sourceHost: input.sourceHost,
|
|
440
|
+
title: input.title ?? null,
|
|
441
|
+
thumbnailUrl: input.thumbnailUrl ?? null,
|
|
442
|
+
videoUrl: input.videoUrl ?? null,
|
|
443
|
+
durationSeconds: input.durationSeconds ?? null,
|
|
444
|
+
songName: input.songName ?? null,
|
|
445
|
+
songUrl: input.songUrl ?? null,
|
|
446
|
+
viralDna: input.viralDna ?? null,
|
|
447
|
+
defaultForkId: input.defaultForkId ?? null,
|
|
448
|
+
visibility: input.visibility ?? "public",
|
|
449
|
+
notes: input.notes ?? null,
|
|
450
|
+
createdAt: timestamp,
|
|
451
|
+
updatedAt: timestamp
|
|
452
|
+
}).then((record) => {
|
|
453
|
+
this.bustTemplatesFeedCache();
|
|
454
|
+
return record;
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
async getTemplate(templateId) {
|
|
458
|
+
const record = await this.getById("template", templateId);
|
|
459
|
+
return record ? normalizeTemplateVisibility(record) : null;
|
|
460
|
+
}
|
|
461
|
+
async listTemplatesForCustomer(customerId) {
|
|
462
|
+
const records = await this.listByCustomer("template", customerId);
|
|
463
|
+
return records.map(normalizeTemplateVisibility);
|
|
464
|
+
}
|
|
465
|
+
async deleteTemplate(templateId) {
|
|
466
|
+
await this.deleteById("template", templateId);
|
|
467
|
+
this.bustTemplatesFeedCache();
|
|
468
|
+
}
|
|
469
|
+
async deleteInspiration(inspirationId) {
|
|
470
|
+
await this.deleteById("inspiration", inspirationId);
|
|
471
|
+
}
|
|
472
|
+
async updateTemplate(input) {
|
|
473
|
+
const existing = await this.getTemplate(input.templateId);
|
|
474
|
+
if (!existing)
|
|
475
|
+
return null;
|
|
476
|
+
return this.putRecord("template", {
|
|
477
|
+
...existing,
|
|
478
|
+
...input.patch,
|
|
479
|
+
id: existing.id,
|
|
480
|
+
customerId: existing.customerId,
|
|
481
|
+
updatedAt: nowIso()
|
|
482
|
+
}).then((record) => {
|
|
483
|
+
this.bustTemplatesFeedCache();
|
|
484
|
+
return record;
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
// Atomically claim this template's default fork for the FIRST decompose only.
|
|
488
|
+
// Uses a conditional DynamoDB UpdateCommand (attribute_not_exists / == null)
|
|
489
|
+
// so concurrent first-decomposes can't both win — the loser's write is
|
|
490
|
+
// rejected at the storage layer and it re-reads the winner. A plain
|
|
491
|
+
// read-check-then-updateTemplate() is racy (both callers pass the JS guard,
|
|
492
|
+
// the later putRecord clobbers the earlier) AND clobbers concurrent edits to
|
|
493
|
+
// other template fields; this touches only defaultForkId + the derived index
|
|
494
|
+
// keys (gsi1sk recency, gsi2sk feed tier — decomposed templates promote from
|
|
495
|
+
// tier "0" to "1"). Returns whether THIS call became the default and the
|
|
496
|
+
// resulting template (the winner's, if we lost the race).
|
|
497
|
+
async stampDefaultForkIfUnset(input) {
|
|
498
|
+
const existing = await this.getTemplate(input.templateId);
|
|
499
|
+
if (!existing)
|
|
500
|
+
return { template: null, becameDefault: false };
|
|
501
|
+
if (existing.defaultForkId)
|
|
502
|
+
return { template: existing, becameDefault: false };
|
|
503
|
+
const now = nowIso();
|
|
504
|
+
const names = {
|
|
505
|
+
"#data": "data",
|
|
506
|
+
"#dfk": "defaultForkId",
|
|
507
|
+
"#updatedAt": "updatedAt"
|
|
508
|
+
};
|
|
509
|
+
const values = {
|
|
510
|
+
":forkId": input.forkId,
|
|
511
|
+
":now": now,
|
|
512
|
+
":gsi1sk": `${now}#${existing.id}`,
|
|
513
|
+
":null": null
|
|
514
|
+
};
|
|
515
|
+
const setClauses = ["#data.#dfk = :forkId", "#data.#updatedAt = :now", "gsi1sk = :gsi1sk"];
|
|
516
|
+
// Only public/unlisted templates carry the sparse gsi2 feed keys; a private
|
|
517
|
+
// template has no gsi2pk, so writing a bare gsi2sk would be inconsistent.
|
|
518
|
+
if (existing.visibility !== "private") {
|
|
519
|
+
values[":gsi2sk"] = templateFeedSortKey({ defaultForkId: input.forkId, createdAt: existing.createdAt, id: existing.id });
|
|
520
|
+
setClauses.push("gsi2sk = :gsi2sk");
|
|
521
|
+
}
|
|
522
|
+
try {
|
|
523
|
+
await dynamodb.send(new UpdateCommand({
|
|
524
|
+
TableName: requireMainTableName(),
|
|
525
|
+
Key: recordKey("template", existing.id),
|
|
526
|
+
UpdateExpression: `SET ${setClauses.join(", ")}`,
|
|
527
|
+
ConditionExpression: "attribute_not_exists(#data.#dfk) OR #data.#dfk = :null",
|
|
528
|
+
ExpressionAttributeNames: names,
|
|
529
|
+
ExpressionAttributeValues: values
|
|
530
|
+
}));
|
|
531
|
+
this.bustTemplatesFeedCache();
|
|
532
|
+
return { template: { ...existing, defaultForkId: input.forkId, updatedAt: now }, becameDefault: true };
|
|
533
|
+
}
|
|
534
|
+
catch (error) {
|
|
535
|
+
// Lost the race: another decompose already stamped a default fork. Not an
|
|
536
|
+
// error — re-read so callers see the winning fork id.
|
|
537
|
+
if (error?.name === "ConditionalCheckFailedException") {
|
|
538
|
+
const winner = await this.getTemplate(input.templateId);
|
|
539
|
+
return { template: winner, becameDefault: false };
|
|
540
|
+
}
|
|
541
|
+
throw error;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
// The discover feed is global (identical for every caller) and read on every
|
|
545
|
+
// homepage load, but templates live in per-customer gsi1 partitions — so
|
|
546
|
+
// template items also carry a FEED#template gsi2 partition whose sort key
|
|
547
|
+
// tiers decomposed templates above undecomposed ingests, each newest-first
|
|
548
|
+
// under one descending Query. The 60s per-container cache keeps warm hits at
|
|
549
|
+
// zero DynamoDB reads; template writes bust it so new content shows up
|
|
550
|
+
// immediately on the container that wrote it.
|
|
551
|
+
templatesFeedCache = null;
|
|
552
|
+
static TEMPLATES_FEED_TTL_MS = 60_000;
|
|
553
|
+
static TEMPLATES_FEED_MAX = 200;
|
|
554
|
+
bustTemplatesFeedCache() {
|
|
555
|
+
this.templatesFeedCache = null;
|
|
556
|
+
}
|
|
557
|
+
async listTemplatesFeed(input = {}) {
|
|
558
|
+
const limit = Math.max(1, Math.min(input.limit ?? 50, ServerlessRecordsServiceImpl.TEMPLATES_FEED_MAX));
|
|
559
|
+
const now = Date.now();
|
|
560
|
+
if (!this.templatesFeedCache || now - this.templatesFeedCache.fetchedAt > ServerlessRecordsServiceImpl.TEMPLATES_FEED_TTL_MS) {
|
|
561
|
+
let records = await this.queryTemplatesFeed();
|
|
562
|
+
if (records.length === 0) {
|
|
563
|
+
// Feed partition empty — either no templates exist or the gsi2
|
|
564
|
+
// attributes have not been backfilled yet (scripts/
|
|
565
|
+
// backfill-template-feed-gsi.ts). Fall back to the legacy scan with
|
|
566
|
+
// the same tiered ordering so the homepage never goes blank.
|
|
567
|
+
devLog("serverless_records.templates_feed_fallback_scan", {}, "warn");
|
|
568
|
+
records = (await this.scanCollection("template"))
|
|
569
|
+
.sort((a, b) => templateFeedSortKey(b).localeCompare(templateFeedSortKey(a)));
|
|
570
|
+
}
|
|
571
|
+
// Private templates are excluded from the FEED partition at write time,
|
|
572
|
+
// but filter here too so the scan fallback (and any legacy feed items)
|
|
573
|
+
// never leak customer-scoped templates into the public feed.
|
|
574
|
+
records = records
|
|
575
|
+
.filter((record) => record.visibility !== "private")
|
|
576
|
+
.map(normalizeTemplateVisibility);
|
|
577
|
+
this.templatesFeedCache = { records, fetchedAt: now };
|
|
578
|
+
}
|
|
579
|
+
// Search is applied over the cached full feed (not baked into the cache key)
|
|
580
|
+
// so the coarse keyword filter never fights the TTL cache.
|
|
581
|
+
return filterAndRankBySearch(this.templatesFeedCache.records, input.query).slice(0, limit);
|
|
582
|
+
}
|
|
583
|
+
async queryTemplatesFeed() {
|
|
584
|
+
const response = await dynamodb.send(new QueryCommand({
|
|
585
|
+
TableName: requireMainTableName(),
|
|
586
|
+
IndexName: "gsi2",
|
|
587
|
+
KeyConditionExpression: "gsi2pk = :pk",
|
|
588
|
+
ExpressionAttributeValues: {
|
|
589
|
+
":pk": TEMPLATE_FEED_PARTITION
|
|
590
|
+
},
|
|
591
|
+
ScanIndexForward: false,
|
|
592
|
+
Limit: ServerlessRecordsServiceImpl.TEMPLATES_FEED_MAX
|
|
593
|
+
}));
|
|
594
|
+
return (response.Items ?? []).map((item) => item.data);
|
|
595
|
+
}
|
|
596
|
+
async createReadyPost(input) {
|
|
597
|
+
const timestamp = nowIso();
|
|
598
|
+
return this.putRecord("ready_post", {
|
|
599
|
+
id: input.id ?? createId("post"),
|
|
600
|
+
customerId: input.customerId,
|
|
601
|
+
status: input.status ?? "ready",
|
|
602
|
+
source: input.source ?? "hyperframes",
|
|
603
|
+
compositionId: input.compositionId ?? null,
|
|
604
|
+
tracer: input.tracer ?? null,
|
|
605
|
+
title: input.title ?? null,
|
|
606
|
+
caption: input.caption ?? "",
|
|
607
|
+
pinnedComment: input.pinnedComment ?? null,
|
|
608
|
+
mediaAssets: input.mediaAssets ?? [],
|
|
609
|
+
sharePasswordHash: input.sharePasswordHash ?? null,
|
|
610
|
+
zipStorageKey: input.zipStorageKey ?? null,
|
|
611
|
+
zipPublicUrl: input.zipPublicUrl ?? null,
|
|
612
|
+
archivedAt: input.archivedAt ?? null,
|
|
613
|
+
deletedAt: input.deletedAt ?? null,
|
|
614
|
+
createdAt: input.createdAt ?? timestamp,
|
|
615
|
+
updatedAt: timestamp
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
async getReadyPost(postId) {
|
|
619
|
+
return this.getById("ready_post", postId);
|
|
620
|
+
}
|
|
621
|
+
async listReadyPosts(customerId) {
|
|
622
|
+
return this.listByCustomer("ready_post", customerId);
|
|
623
|
+
}
|
|
624
|
+
async archiveReadyPost(customerId, postId) {
|
|
625
|
+
const post = await this.getReadyPost(postId);
|
|
626
|
+
if (!post || post.customerId !== customerId)
|
|
627
|
+
return null;
|
|
628
|
+
return this.putRecord("ready_post", { ...post, status: "archived", archivedAt: nowIso(), updatedAt: nowIso() });
|
|
629
|
+
}
|
|
630
|
+
async deleteReadyPost(customerId, postId) {
|
|
631
|
+
const post = await this.getReadyPost(postId);
|
|
632
|
+
if (!post || post.customerId !== customerId)
|
|
633
|
+
return null;
|
|
634
|
+
const updated = await this.putRecord("ready_post", { ...post, status: "deleted", deletedAt: nowIso(), updatedAt: nowIso() });
|
|
635
|
+
return updated;
|
|
636
|
+
}
|
|
637
|
+
async setReadyPostZip(input) {
|
|
638
|
+
const post = await this.getReadyPost(input.postId);
|
|
639
|
+
if (!post || post.customerId !== input.customerId)
|
|
640
|
+
return null;
|
|
641
|
+
return this.putRecord("ready_post", {
|
|
642
|
+
...post,
|
|
643
|
+
zipStorageKey: input.zipStorageKey,
|
|
644
|
+
zipPublicUrl: input.zipPublicUrl,
|
|
645
|
+
updatedAt: nowIso()
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
async createPostSchedule(input) {
|
|
649
|
+
const timestamp = nowIso();
|
|
650
|
+
return this.putRecord("post_schedule", {
|
|
651
|
+
id: input.id ?? createId("sched"),
|
|
652
|
+
customerId: input.customerId,
|
|
653
|
+
readyPostId: input.readyPostId ?? null,
|
|
654
|
+
managedBy: input.managedBy ?? "vidfarm",
|
|
655
|
+
provider: input.provider ?? "flockposter",
|
|
656
|
+
destinationType: input.destinationType ?? "flockposter",
|
|
657
|
+
destinationId: input.destinationId ?? "",
|
|
658
|
+
scheduledAt: input.scheduledAt ?? timestamp,
|
|
659
|
+
timezone: input.timezone ?? null,
|
|
660
|
+
status: input.status ?? "scheduled",
|
|
661
|
+
providerStatus: input.providerStatus ?? null,
|
|
662
|
+
providerScheduleId: input.providerScheduleId ?? null,
|
|
663
|
+
title: input.title ?? null,
|
|
664
|
+
summary: input.summary ?? null,
|
|
665
|
+
url: input.url ?? null,
|
|
666
|
+
additionalNotes: input.additionalNotes ?? null,
|
|
667
|
+
providerPayload: input.providerPayload ?? {},
|
|
668
|
+
providerResponse: input.providerResponse ?? {},
|
|
669
|
+
lastSyncedAt: input.lastSyncedAt ?? null,
|
|
670
|
+
lastError: input.lastError ?? null,
|
|
671
|
+
cancelledAt: input.cancelledAt ?? null,
|
|
672
|
+
sentAt: input.sentAt ?? null,
|
|
673
|
+
createdAt: input.createdAt ?? timestamp,
|
|
674
|
+
updatedAt: timestamp
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
async getPostSchedule(customerId, scheduleId) {
|
|
678
|
+
const schedule = await this.getById("post_schedule", scheduleId);
|
|
679
|
+
return schedule?.customerId === customerId ? schedule : null;
|
|
680
|
+
}
|
|
681
|
+
async listPostSchedulesForPost(customerId, readyPostId) {
|
|
682
|
+
return (await this.listByCustomer("post_schedule", customerId)).filter((schedule) => schedule.readyPostId === readyPostId);
|
|
683
|
+
}
|
|
684
|
+
async listPostSchedulesForCalendar(input) {
|
|
685
|
+
return (await this.listByCustomer("post_schedule", input.customerId)).filter((schedule) => {
|
|
686
|
+
const scheduledAt = String(schedule.scheduledAt ?? "");
|
|
687
|
+
if (input.startTime && scheduledAt < input.startTime)
|
|
688
|
+
return false;
|
|
689
|
+
if (input.endTime && scheduledAt > input.endTime)
|
|
690
|
+
return false;
|
|
691
|
+
return true;
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
async updatePostScheduleProvider(input) {
|
|
695
|
+
return this.mergeByIdForCustomer("post_schedule", input.customerId, input.scheduleId, input);
|
|
696
|
+
}
|
|
697
|
+
async markPostScheduleCancelled(input) {
|
|
698
|
+
return this.mergeByIdForCustomer("post_schedule", input.customerId, input.scheduleId, {
|
|
699
|
+
...input,
|
|
700
|
+
status: "cancelled",
|
|
701
|
+
cancelledAt: nowIso()
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
async markPostScheduleFailed(input) {
|
|
705
|
+
return this.mergeByIdForCustomer("post_schedule", input.customerId, input.scheduleId, {
|
|
706
|
+
...input,
|
|
707
|
+
status: "failed",
|
|
708
|
+
lastError: input.lastError ?? "Schedule failed"
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
async upsertExternalPostSchedule(input) {
|
|
712
|
+
const existing = (await this.listByCustomer("post_schedule", input.customerId))
|
|
713
|
+
.find((schedule) => schedule.providerScheduleId === input.providerScheduleId);
|
|
714
|
+
return this.createPostSchedule({ ...existing, ...input, managedBy: "external" });
|
|
715
|
+
}
|
|
716
|
+
async createEmailChannel(input) {
|
|
717
|
+
const timestamp = nowIso();
|
|
718
|
+
return this.putRecord("email_channel", {
|
|
719
|
+
id: input.id ?? createId("email"),
|
|
720
|
+
customerId: input.customerId,
|
|
721
|
+
title: input.title ?? input.email,
|
|
722
|
+
email: input.email,
|
|
723
|
+
note: input.note ?? null,
|
|
724
|
+
avatarUrl: input.avatarUrl ?? null,
|
|
725
|
+
avatarStorageKey: input.avatarStorageKey ?? null,
|
|
726
|
+
status: input.status ?? "unverified",
|
|
727
|
+
verificationTokenHash: input.verificationTokenHash ?? null,
|
|
728
|
+
verificationSentAt: input.verificationSentAt ?? null,
|
|
729
|
+
verifiedAt: input.verifiedAt ?? null,
|
|
730
|
+
createdAt: input.createdAt ?? timestamp,
|
|
731
|
+
updatedAt: timestamp
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
async listEmailChannels(customerId) {
|
|
735
|
+
return this.listByCustomer("email_channel", customerId);
|
|
736
|
+
}
|
|
737
|
+
async getEmailChannel(customerId, channelId) {
|
|
738
|
+
const channel = await this.getById("email_channel", channelId);
|
|
739
|
+
return channel?.customerId === customerId ? channel : null;
|
|
740
|
+
}
|
|
741
|
+
async getEmailChannelByEmail(customerId, email) {
|
|
742
|
+
return (await this.listEmailChannels(customerId)).find((channel) => channel.email.toLowerCase() === email.toLowerCase()) ?? null;
|
|
743
|
+
}
|
|
744
|
+
async updateEmailChannel(input) {
|
|
745
|
+
return this.mergeByIdForCustomer("email_channel", input.customerId, input.channelId, input);
|
|
746
|
+
}
|
|
747
|
+
async updateEmailChannelAvatar(input) {
|
|
748
|
+
await this.updateEmailChannel(input);
|
|
749
|
+
}
|
|
750
|
+
async updateEmailChannelVerification(input) {
|
|
751
|
+
return this.updateEmailChannel(input);
|
|
752
|
+
}
|
|
753
|
+
async verifyEmailChannelByTokenHash(tokenHash) {
|
|
754
|
+
const response = await dynamodb.send(new ScanCommand({
|
|
755
|
+
TableName: requireMainTableName(),
|
|
756
|
+
FilterExpression: "entity_type = :type AND verificationTokenHash = :token",
|
|
757
|
+
ExpressionAttributeValues: {
|
|
758
|
+
":type": "serverless_record#email_channel",
|
|
759
|
+
":token": tokenHash
|
|
760
|
+
},
|
|
761
|
+
Limit: 1
|
|
762
|
+
}));
|
|
763
|
+
const channel = response.Items?.[0]?.data;
|
|
764
|
+
if (!channel)
|
|
765
|
+
return null;
|
|
766
|
+
return this.putRecord("email_channel", {
|
|
767
|
+
...channel,
|
|
768
|
+
status: "verified",
|
|
769
|
+
verifiedAt: nowIso(),
|
|
770
|
+
verificationTokenHash: null,
|
|
771
|
+
updatedAt: nowIso()
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
async deleteEmailChannel(customerId, channelId) {
|
|
775
|
+
const existing = await this.getEmailChannel(customerId, channelId);
|
|
776
|
+
if (existing)
|
|
777
|
+
await this.deleteById("email_channel", channelId);
|
|
778
|
+
}
|
|
779
|
+
async createUserAttachment(input) {
|
|
780
|
+
const timestamp = nowIso();
|
|
781
|
+
return this.putRecord("user_attachment", {
|
|
782
|
+
id: input.id ?? createId("att"),
|
|
783
|
+
customerId: input.customerId,
|
|
784
|
+
fileName: input.fileName,
|
|
785
|
+
contentType: input.contentType ?? "application/octet-stream",
|
|
786
|
+
sizeBytes: input.sizeBytes ?? 0,
|
|
787
|
+
storageKey: input.storageKey,
|
|
788
|
+
folderPath: normalizeFolder(input.folderPath),
|
|
789
|
+
publicUrl: input.publicUrl ?? null,
|
|
790
|
+
createdAt: input.createdAt ?? timestamp
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
async listUserAttachments(customerId) {
|
|
794
|
+
return this.listByCustomer("user_attachment", customerId);
|
|
795
|
+
}
|
|
796
|
+
async getUserAttachment(customerId, attachmentId) {
|
|
797
|
+
const attachment = await this.getById("user_attachment", attachmentId);
|
|
798
|
+
return attachment?.customerId === customerId ? attachment : null;
|
|
799
|
+
}
|
|
800
|
+
async deleteUserAttachment(customerId, attachmentId) {
|
|
801
|
+
const existing = await this.getUserAttachment(customerId, attachmentId);
|
|
802
|
+
if (existing)
|
|
803
|
+
await this.deleteById("user_attachment", attachmentId);
|
|
804
|
+
}
|
|
805
|
+
async createUserTemporaryFile(input) {
|
|
806
|
+
const timestamp = nowIso();
|
|
807
|
+
return this.putRecord("user_temporary_file", {
|
|
808
|
+
id: input.id ?? createId("tmp"),
|
|
809
|
+
customerId: input.customerId,
|
|
810
|
+
fileName: input.fileName,
|
|
811
|
+
contentType: input.contentType ?? "application/octet-stream",
|
|
812
|
+
sizeBytes: input.sizeBytes ?? 0,
|
|
813
|
+
storageKey: input.storageKey,
|
|
814
|
+
folderPath: normalizeFolder(input.folderPath),
|
|
815
|
+
expiresAt: input.expiresAt ?? timestamp,
|
|
816
|
+
createdAt: input.createdAt ?? timestamp
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
async listUserTemporaryFiles(customerId) {
|
|
820
|
+
return this.listByCustomer("user_temporary_file", customerId);
|
|
821
|
+
}
|
|
822
|
+
async getUserTemporaryFile(customerId, fileId) {
|
|
823
|
+
const file = await this.getById("user_temporary_file", fileId);
|
|
824
|
+
return file?.customerId === customerId ? file : null;
|
|
825
|
+
}
|
|
826
|
+
async deleteUserTemporaryFile(customerId, fileId) {
|
|
827
|
+
const existing = await this.getUserTemporaryFile(customerId, fileId);
|
|
828
|
+
if (existing)
|
|
829
|
+
await this.deleteById("user_temporary_file", fileId);
|
|
830
|
+
}
|
|
831
|
+
async deleteExpiredUserTemporaryFiles() {
|
|
832
|
+
const now = nowIso();
|
|
833
|
+
const files = (await this.scanCollection("user_temporary_file"))
|
|
834
|
+
.filter(isExpiredUserTemporaryFileRecord);
|
|
835
|
+
const expired = files.filter((file) => file.expiresAt <= now);
|
|
836
|
+
for (const file of expired)
|
|
837
|
+
await this.deleteById("user_temporary_file", file.id);
|
|
838
|
+
return expired;
|
|
839
|
+
}
|
|
840
|
+
async createUserFileFolder(customerId, scope, folderPath) {
|
|
841
|
+
const normalized = normalizeFolder(folderPath);
|
|
842
|
+
await this.putRecord("user_file_folder", {
|
|
843
|
+
id: `${customerId}:${scope}:${normalized}`,
|
|
844
|
+
customerId,
|
|
845
|
+
scope,
|
|
846
|
+
folderPath: normalized
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
async listUserFileFolders(customerId, scope) {
|
|
850
|
+
return (await this.listByCustomer("user_file_folder", customerId))
|
|
851
|
+
.filter((folder) => folder.scope === scope)
|
|
852
|
+
.map((folder) => String(folder.folderPath));
|
|
853
|
+
}
|
|
854
|
+
async moveUserFileFolder(customerId, scope, fromFolder, toFolder) {
|
|
855
|
+
await this.deleteUserFileFolder(customerId, scope, fromFolder);
|
|
856
|
+
await this.createUserFileFolder(customerId, scope, toFolder);
|
|
857
|
+
}
|
|
858
|
+
async deleteUserFileFolder(customerId, scope, folderPath) {
|
|
859
|
+
await this.deleteById("user_file_folder", `${customerId}:${scope}:${normalizeFolder(folderPath)}`);
|
|
860
|
+
}
|
|
861
|
+
async moveUserAttachmentFolder(customerId, fromFolder, toFolder) {
|
|
862
|
+
await this.moveCollectionFolder("user_attachment", customerId, fromFolder, toFolder);
|
|
863
|
+
}
|
|
864
|
+
async deleteUserAttachmentFolder(customerId, folderPath) {
|
|
865
|
+
return this.deleteCollectionFolder("user_attachment", customerId, folderPath);
|
|
866
|
+
}
|
|
867
|
+
async moveUserTemporaryFolder(customerId, fromFolder, toFolder) {
|
|
868
|
+
await this.moveCollectionFolder("user_temporary_file", customerId, fromFolder, toFolder);
|
|
869
|
+
}
|
|
870
|
+
async deleteUserTemporaryFolder(customerId, folderPath) {
|
|
871
|
+
return this.deleteCollectionFolder("user_temporary_file", customerId, folderPath);
|
|
872
|
+
}
|
|
873
|
+
async queueWebhookDelivery(input) {
|
|
874
|
+
const timestamp = nowIso();
|
|
875
|
+
await this.putRecord("webhook_delivery", {
|
|
876
|
+
id: typeof input.id === "string" ? input.id : createId("webhook"),
|
|
877
|
+
customerId: typeof input.customerId === "string" ? input.customerId : undefined,
|
|
878
|
+
jobId: input.jobId,
|
|
879
|
+
destinationUrl: input.destinationUrl,
|
|
880
|
+
destination_url: input.destinationUrl,
|
|
881
|
+
eventType: input.eventType,
|
|
882
|
+
event_type: input.eventType,
|
|
883
|
+
payload: input.payload,
|
|
884
|
+
payload_json: JSON.stringify(input.payload ?? {}),
|
|
885
|
+
status: "pending",
|
|
886
|
+
attemptCount: 0,
|
|
887
|
+
attempt_count: 0,
|
|
888
|
+
nextAttemptAt: timestamp,
|
|
889
|
+
next_attempt_at: timestamp,
|
|
890
|
+
createdAt: timestamp,
|
|
891
|
+
updatedAt: timestamp
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
async listPendingWebhookDeliveries(limit = 100) {
|
|
895
|
+
const response = await dynamodb.send(new ScanCommand({
|
|
896
|
+
TableName: requireMainTableName(),
|
|
897
|
+
FilterExpression: "entity_type = :type AND #data.#status = :status",
|
|
898
|
+
ExpressionAttributeNames: { "#data": "data", "#status": "status" },
|
|
899
|
+
ExpressionAttributeValues: {
|
|
900
|
+
":type": "serverless_record#webhook_delivery",
|
|
901
|
+
":status": "pending"
|
|
902
|
+
},
|
|
903
|
+
Limit: limit
|
|
904
|
+
}));
|
|
905
|
+
return (response.Items ?? []).map((item) => item.data);
|
|
906
|
+
}
|
|
907
|
+
async markWebhookDelivery(input) {
|
|
908
|
+
const existing = await this.getById("webhook_delivery", input.id);
|
|
909
|
+
if (!existing)
|
|
910
|
+
return;
|
|
911
|
+
await this.putRecord("webhook_delivery", {
|
|
912
|
+
...existing,
|
|
913
|
+
...input,
|
|
914
|
+
attempt_count: input.attemptCount ?? input.attempt_count ?? existing.attempt_count,
|
|
915
|
+
next_attempt_at: input.nextAttemptAt ?? input.next_attempt_at ?? existing.next_attempt_at,
|
|
916
|
+
updatedAt: nowIso()
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
async listChatThreadMetas(input) {
|
|
920
|
+
const limit = Math.max(1, Math.min(input.limit ?? 100, 500));
|
|
921
|
+
return (await this.listByCustomer("chat_thread", input.customerId))
|
|
922
|
+
.filter((thread) => !input.templateId || thread.templateId === input.templateId)
|
|
923
|
+
.sort((a, b) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? "")))
|
|
924
|
+
.slice(0, limit);
|
|
925
|
+
}
|
|
926
|
+
async getChatThreadMeta(customerId, threadId) {
|
|
927
|
+
const thread = await this.getById("chat_thread", threadId);
|
|
928
|
+
return thread?.customerId === customerId ? thread : null;
|
|
929
|
+
}
|
|
930
|
+
async appendChatThreadMessages(input) {
|
|
931
|
+
const existing = await this.getChatThreadMeta(input.customerId, input.threadId);
|
|
932
|
+
const timestamp = nowIso();
|
|
933
|
+
const thread = {
|
|
934
|
+
id: input.threadId,
|
|
935
|
+
customerId: input.customerId,
|
|
936
|
+
templateId: input.templateId,
|
|
937
|
+
title: input.title,
|
|
938
|
+
storagePrefix: input.storagePrefix,
|
|
939
|
+
tracers: input.tracers,
|
|
940
|
+
messageCount: (existing?.messageCount ?? 0) + input.messageCountDelta,
|
|
941
|
+
lastMessageAt: input.lastMessageAt,
|
|
942
|
+
hiddenFromView: existing?.hiddenFromView ?? false,
|
|
943
|
+
archivedAt: existing?.archivedAt ?? null,
|
|
944
|
+
createdAt: existing?.createdAt ?? timestamp,
|
|
945
|
+
updatedAt: timestamp,
|
|
946
|
+
messages: []
|
|
947
|
+
};
|
|
948
|
+
return this.putRecord("chat_thread", thread);
|
|
949
|
+
}
|
|
950
|
+
async updateChatThreadState(input) {
|
|
951
|
+
const existing = await this.getChatThreadMeta(input.customerId, input.threadId);
|
|
952
|
+
if (!existing)
|
|
953
|
+
return null;
|
|
954
|
+
return this.putRecord("chat_thread", {
|
|
955
|
+
...existing,
|
|
956
|
+
title: input.title ?? existing.title,
|
|
957
|
+
tracers: input.tracers ?? existing.tracers,
|
|
958
|
+
hiddenFromView: input.hiddenFromView ?? existing.hiddenFromView,
|
|
959
|
+
archivedAt: input.archivedAt === undefined ? existing.archivedAt : input.archivedAt,
|
|
960
|
+
updatedAt: nowIso()
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
async deleteChatThread(customerId, threadId) {
|
|
964
|
+
const existing = await this.getChatThreadMeta(customerId, threadId);
|
|
965
|
+
if (!existing)
|
|
966
|
+
return null;
|
|
967
|
+
await this.deleteById("chat_thread", threadId);
|
|
968
|
+
return existing;
|
|
969
|
+
}
|
|
970
|
+
async createTemplateSource(input) {
|
|
971
|
+
return this.putRecord("template_source", { id: input.id ?? createId("tsrc"), ...input });
|
|
972
|
+
}
|
|
973
|
+
async updateTemplateSource(input) {
|
|
974
|
+
const existing = await this.getById("template_source", String(input.id));
|
|
975
|
+
return this.putRecord("template_source", { ...existing, ...input, updatedAt: nowIso() });
|
|
976
|
+
}
|
|
977
|
+
async getTemplateSource(sourceId) {
|
|
978
|
+
return this.getById("template_source", sourceId);
|
|
979
|
+
}
|
|
980
|
+
async getTemplateSourceByTemplateId(templateId) {
|
|
981
|
+
return this.findByScan("template_source", (item) => item.templateId === templateId);
|
|
982
|
+
}
|
|
983
|
+
async getTemplateSourceBySlugId(slugId) {
|
|
984
|
+
return this.findByScan("template_source", (item) => item.slugId === slugId);
|
|
985
|
+
}
|
|
986
|
+
async getTemplateSourceByLocation(repoUrl, branch, templateModulePath) {
|
|
987
|
+
return this.findByScan("template_source", (item) => item.repoUrl === repoUrl && item.branch === branch && item.templateModulePath === templateModulePath);
|
|
988
|
+
}
|
|
989
|
+
async listTemplateSources() {
|
|
990
|
+
return this.scanCollection("template_source");
|
|
991
|
+
}
|
|
992
|
+
async createTemplateRelease(input) {
|
|
993
|
+
return this.putRecord("template_release", { id: input.id ?? createId("trel"), ...input });
|
|
994
|
+
}
|
|
995
|
+
async getTemplateRelease(releaseId) {
|
|
996
|
+
return this.getById("template_release", releaseId);
|
|
997
|
+
}
|
|
998
|
+
async getTemplateReleaseBySourceAndCommit(sourceId, commitSha) {
|
|
999
|
+
return this.findByScan("template_release", (item) => item.sourceId === sourceId && item.commitSha === commitSha);
|
|
1000
|
+
}
|
|
1001
|
+
async getLatestTemplateReleaseForSource(sourceId) {
|
|
1002
|
+
return (await this.scanCollection("template_release"))
|
|
1003
|
+
.filter((release) => release.sourceId === sourceId)
|
|
1004
|
+
.sort((a, b) => String(b.createdAt ?? "").localeCompare(String(a.createdAt ?? "")))[0] ?? null;
|
|
1005
|
+
}
|
|
1006
|
+
async listTemplateReleases() {
|
|
1007
|
+
return this.scanCollection("template_release");
|
|
1008
|
+
}
|
|
1009
|
+
async listActiveTemplateReleases() {
|
|
1010
|
+
const releases = [];
|
|
1011
|
+
let cursor = null;
|
|
1012
|
+
do {
|
|
1013
|
+
const page = await this.listActiveTemplateReleasePage({ cursor, limit: 500 });
|
|
1014
|
+
releases.push(...page.items);
|
|
1015
|
+
cursor = page.nextCursor;
|
|
1016
|
+
} while (cursor);
|
|
1017
|
+
return releases;
|
|
1018
|
+
}
|
|
1019
|
+
async listActiveTemplateReleasePage(input = {}) {
|
|
1020
|
+
const limit = Math.max(1, Math.min(input.limit ?? 100, 500));
|
|
1021
|
+
const releases = [];
|
|
1022
|
+
let ExclusiveStartKey = decodeDynamoCursor(input.cursor);
|
|
1023
|
+
do {
|
|
1024
|
+
const response = await dynamodb.send(new QueryCommand({
|
|
1025
|
+
TableName: requireMainTableName(),
|
|
1026
|
+
IndexName: "gsi1",
|
|
1027
|
+
KeyConditionExpression: "gsi1pk = :pk",
|
|
1028
|
+
FilterExpression: "#data.#record_kind = :kind",
|
|
1029
|
+
ExpressionAttributeNames: {
|
|
1030
|
+
"#data": "data",
|
|
1031
|
+
"#record_kind": "recordKind"
|
|
1032
|
+
},
|
|
1033
|
+
ExpressionAttributeValues: {
|
|
1034
|
+
":pk": collectionPk("active_template"),
|
|
1035
|
+
":kind": "active_template_release"
|
|
1036
|
+
},
|
|
1037
|
+
ScanIndexForward: false,
|
|
1038
|
+
Limit: Math.max(1, limit - releases.length),
|
|
1039
|
+
ExclusiveStartKey
|
|
1040
|
+
}));
|
|
1041
|
+
releases.push(...(response.Items ?? []).map((item) => item.data));
|
|
1042
|
+
ExclusiveStartKey = response.LastEvaluatedKey;
|
|
1043
|
+
} while (ExclusiveStartKey && releases.length < limit);
|
|
1044
|
+
return {
|
|
1045
|
+
items: releases,
|
|
1046
|
+
nextCursor: encodeDynamoCursor(ExclusiveStartKey)
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
async getActiveTemplateReleaseByTemplateId(templateId) {
|
|
1050
|
+
const direct = await this.getById("active_template", templateId);
|
|
1051
|
+
if (direct?.recordKind === "active_template_release") {
|
|
1052
|
+
return direct;
|
|
1053
|
+
}
|
|
1054
|
+
const alias = await this.getById("active_template", activeTemplateSlugAliasId(templateId));
|
|
1055
|
+
if (alias?.recordKind === "active_template_slug_alias" && typeof alias.templateId === "string") {
|
|
1056
|
+
const release = await this.getById("active_template", alias.templateId);
|
|
1057
|
+
return release?.recordKind === "active_template_release"
|
|
1058
|
+
? release
|
|
1059
|
+
: null;
|
|
1060
|
+
}
|
|
1061
|
+
return null;
|
|
1062
|
+
}
|
|
1063
|
+
async putActiveTemplateRelease(release) {
|
|
1064
|
+
const slugId = release.templateSnapshot?.slugId;
|
|
1065
|
+
const data = await this.putRecord("active_template", {
|
|
1066
|
+
...release,
|
|
1067
|
+
id: release.templateId,
|
|
1068
|
+
recordKind: "active_template_release",
|
|
1069
|
+
updatedAt: release.activatedAt ?? release.updatedAt ?? nowIso()
|
|
1070
|
+
});
|
|
1071
|
+
if (slugId) {
|
|
1072
|
+
await this.putRecord("active_template", {
|
|
1073
|
+
id: activeTemplateSlugAliasId(slugId),
|
|
1074
|
+
recordKind: "active_template_slug_alias",
|
|
1075
|
+
templateId: release.templateId,
|
|
1076
|
+
slugId,
|
|
1077
|
+
releaseId: release.id,
|
|
1078
|
+
updatedAt: release.activatedAt ?? release.updatedAt ?? nowIso()
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
return data;
|
|
1082
|
+
}
|
|
1083
|
+
async updateTemplateReleaseStatus(input) {
|
|
1084
|
+
const existing = await this.getById("template_release", input.releaseId);
|
|
1085
|
+
if (existing)
|
|
1086
|
+
await this.putRecord("template_release", { ...existing, ...input, id: input.releaseId, updatedAt: nowIso() });
|
|
1087
|
+
}
|
|
1088
|
+
async clearActiveTemplateReleases(templateId) {
|
|
1089
|
+
const active = await this.getActiveTemplateReleaseByTemplateId(templateId);
|
|
1090
|
+
if (active) {
|
|
1091
|
+
await this.putRecord("template_release", { ...active, id: String(active.id), status: "approved", activatedAt: null, updatedAt: nowIso() });
|
|
1092
|
+
await this.deleteById("active_template", active.templateId);
|
|
1093
|
+
if (active.templateSnapshot?.slugId) {
|
|
1094
|
+
await this.deleteById("active_template", activeTemplateSlugAliasId(active.templateSnapshot.slugId));
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
async call(method, args) {
|
|
1099
|
+
const collection = METHOD_COLLECTIONS[method];
|
|
1100
|
+
if (!collection) {
|
|
1101
|
+
throw new Error(`Serverless record method ${method} is not implemented.`);
|
|
1102
|
+
}
|
|
1103
|
+
devLog("serverless_records.dynamic_call", { method, collection });
|
|
1104
|
+
throw new Error(`Serverless record method ${method} requires a typed implementation.`);
|
|
1105
|
+
}
|
|
1106
|
+
async mergeByIdForCustomer(collection, customerId, id, patch) {
|
|
1107
|
+
const existing = await this.getById(collection, id);
|
|
1108
|
+
if (!existing || existing.customerId !== customerId)
|
|
1109
|
+
return null;
|
|
1110
|
+
return this.putRecord(collection, { ...existing, ...patch, id, customerId, updatedAt: nowIso() });
|
|
1111
|
+
}
|
|
1112
|
+
async moveCollectionFolder(collection, customerId, fromFolder, toFolder) {
|
|
1113
|
+
for (const record of await this.listByCustomer(collection, customerId)) {
|
|
1114
|
+
if (normalizeFolder(String(record.folderPath ?? "")) === normalizeFolder(fromFolder)) {
|
|
1115
|
+
await this.putRecord(collection, { ...record, folderPath: normalizeFolder(toFolder), updatedAt: nowIso() });
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
async deleteCollectionFolder(collection, customerId, folderPath) {
|
|
1120
|
+
const deleted = [];
|
|
1121
|
+
for (const record of await this.listByCustomer(collection, customerId)) {
|
|
1122
|
+
if (normalizeFolder(String(record.folderPath ?? "")) === normalizeFolder(folderPath)) {
|
|
1123
|
+
await this.deleteById(collection, String(record.id));
|
|
1124
|
+
deleted.push(record);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
return deleted;
|
|
1128
|
+
}
|
|
1129
|
+
async findByScan(collection, predicate) {
|
|
1130
|
+
return (await this.scanCollection(collection)).find(predicate) ?? null;
|
|
1131
|
+
}
|
|
1132
|
+
async scanCollection(collection) {
|
|
1133
|
+
const results = [];
|
|
1134
|
+
let exclusiveStartKey;
|
|
1135
|
+
// DDB Scan with FilterExpression only reads one page at a time; matching
|
|
1136
|
+
// items later in the table are silently dropped unless you follow
|
|
1137
|
+
// LastEvaluatedKey. Paginate until DDB stops returning a cursor.
|
|
1138
|
+
do {
|
|
1139
|
+
const response = await dynamodb.send(new ScanCommand({
|
|
1140
|
+
TableName: requireMainTableName(),
|
|
1141
|
+
FilterExpression: "entity_type = :type",
|
|
1142
|
+
ExpressionAttributeValues: {
|
|
1143
|
+
":type": entityType(collection)
|
|
1144
|
+
},
|
|
1145
|
+
ExclusiveStartKey: exclusiveStartKey
|
|
1146
|
+
}));
|
|
1147
|
+
for (const item of response.Items ?? []) {
|
|
1148
|
+
results.push(item.data);
|
|
1149
|
+
}
|
|
1150
|
+
exclusiveStartKey = response.LastEvaluatedKey;
|
|
1151
|
+
} while (exclusiveStartKey);
|
|
1152
|
+
return results;
|
|
1153
|
+
}
|
|
1154
|
+
async listByCustomer(collection, customerId) {
|
|
1155
|
+
return this.listByPartition(collection, customerCollectionPk(collection, customerId));
|
|
1156
|
+
}
|
|
1157
|
+
async listByPartition(_collection, partitionKey) {
|
|
1158
|
+
const response = await dynamodb.send(new QueryCommand({
|
|
1159
|
+
TableName: requireMainTableName(),
|
|
1160
|
+
IndexName: "gsi1",
|
|
1161
|
+
KeyConditionExpression: "gsi1pk = :pk",
|
|
1162
|
+
ExpressionAttributeValues: {
|
|
1163
|
+
":pk": partitionKey
|
|
1164
|
+
},
|
|
1165
|
+
ScanIndexForward: false
|
|
1166
|
+
}));
|
|
1167
|
+
return (response.Items ?? []).map((item) => item.data);
|
|
1168
|
+
}
|
|
1169
|
+
async getById(collection, id) {
|
|
1170
|
+
const response = await dynamodb.send(new GetCommand({
|
|
1171
|
+
TableName: requireMainTableName(),
|
|
1172
|
+
Key: recordKey(collection, id)
|
|
1173
|
+
}));
|
|
1174
|
+
return response.Item?.data ?? null;
|
|
1175
|
+
}
|
|
1176
|
+
async deleteById(collection, id) {
|
|
1177
|
+
await dynamodb.send(new DeleteCommand({
|
|
1178
|
+
TableName: requireMainTableName(),
|
|
1179
|
+
Key: recordKey(collection, id)
|
|
1180
|
+
}));
|
|
1181
|
+
devLog("serverless_records.deleted", { collection, id });
|
|
1182
|
+
}
|
|
1183
|
+
async putRecord(collection, record, options) {
|
|
1184
|
+
const timestamp = nowIso();
|
|
1185
|
+
const id = typeof record.id === "string" && record.id ? record.id : createId("rec");
|
|
1186
|
+
const data = {
|
|
1187
|
+
...record,
|
|
1188
|
+
id,
|
|
1189
|
+
createdAt: record.createdAt ?? timestamp,
|
|
1190
|
+
updatedAt: record.updatedAt ?? timestamp
|
|
1191
|
+
};
|
|
1192
|
+
const gsi1pk = options?.partitionKey
|
|
1193
|
+
?? (data.customerId ? customerCollectionPk(collection, String(data.customerId)) : collectionPk(collection));
|
|
1194
|
+
const item = {
|
|
1195
|
+
...recordKey(collection, id),
|
|
1196
|
+
entity_type: entityType(collection),
|
|
1197
|
+
gsi1pk,
|
|
1198
|
+
gsi1sk: `${String(data.updatedAt)}#${id}`,
|
|
1199
|
+
data
|
|
1200
|
+
};
|
|
1201
|
+
// Private templates stay out of the global FEED partition (sparse gsi2):
|
|
1202
|
+
// they are only reachable via their owner's gsi1 customer partition.
|
|
1203
|
+
if (collection === "template" && data.visibility !== "private") {
|
|
1204
|
+
item.gsi2pk = TEMPLATE_FEED_PARTITION;
|
|
1205
|
+
item.gsi2sk = templateFeedSortKey(data);
|
|
1206
|
+
}
|
|
1207
|
+
await dynamodb.send(new PutCommand({
|
|
1208
|
+
TableName: requireMainTableName(),
|
|
1209
|
+
Item: item
|
|
1210
|
+
}));
|
|
1211
|
+
devLog("serverless_records.put", {
|
|
1212
|
+
collection,
|
|
1213
|
+
id,
|
|
1214
|
+
customer_id: data.customerId ?? null,
|
|
1215
|
+
partition_key: gsi1pk
|
|
1216
|
+
});
|
|
1217
|
+
return data;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
function forkPartitionPk(collection, forkId) {
|
|
1221
|
+
return `FORK#${forkId}#${collection}`;
|
|
1222
|
+
}
|
|
1223
|
+
// Records written before the visibility/notes fields existed have no such
|
|
1224
|
+
// attributes — they are all the seeded public catalog, so default to public.
|
|
1225
|
+
function normalizeTemplateVisibility(record) {
|
|
1226
|
+
return {
|
|
1227
|
+
...record,
|
|
1228
|
+
visibility: record.visibility ?? "public",
|
|
1229
|
+
notes: record.notes ?? null
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
1232
|
+
function normalizeInspirationVisibility(record) {
|
|
1233
|
+
return {
|
|
1234
|
+
...record,
|
|
1235
|
+
visibility: record.visibility ?? "public",
|
|
1236
|
+
notes: record.notes ?? null
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
1239
|
+
function contentSearchHaystack(record) {
|
|
1240
|
+
const parts = [];
|
|
1241
|
+
const push = (value) => {
|
|
1242
|
+
if (typeof value === "string") {
|
|
1243
|
+
if (value.trim())
|
|
1244
|
+
parts.push(value.toLowerCase());
|
|
1245
|
+
}
|
|
1246
|
+
else if (Array.isArray(value)) {
|
|
1247
|
+
for (const item of value) {
|
|
1248
|
+
if (typeof item === "string" && item.trim())
|
|
1249
|
+
parts.push(item.toLowerCase());
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
};
|
|
1253
|
+
push(record.title);
|
|
1254
|
+
push(record.trendTagline);
|
|
1255
|
+
push(record.notes);
|
|
1256
|
+
push(record.viralDna);
|
|
1257
|
+
push(record.searchSummary);
|
|
1258
|
+
push(record.promotions);
|
|
1259
|
+
push(record.keywords);
|
|
1260
|
+
return parts.join(" ");
|
|
1261
|
+
}
|
|
1262
|
+
export function tokenizeSearchQuery(query) {
|
|
1263
|
+
if (!query)
|
|
1264
|
+
return [];
|
|
1265
|
+
return Array.from(new Set(query
|
|
1266
|
+
.toLowerCase()
|
|
1267
|
+
.split(/[^a-z0-9]+/i)
|
|
1268
|
+
.map((token) => token.trim())
|
|
1269
|
+
.filter((token) => token.length >= 2)));
|
|
1270
|
+
}
|
|
1271
|
+
// Exported predicate for callers that interleave records outside the feed
|
|
1272
|
+
// methods (e.g. a logged-in user's own templates) and want them filtered by the
|
|
1273
|
+
// same coarse keyword rule. An empty/whitespace query matches everything.
|
|
1274
|
+
export function recordMatchesSearchQuery(record, query) {
|
|
1275
|
+
const tokens = tokenizeSearchQuery(query);
|
|
1276
|
+
return tokens.length === 0 || scoreContentMatch(record, tokens) > 0;
|
|
1277
|
+
}
|
|
1278
|
+
function scoreContentMatch(record, tokens) {
|
|
1279
|
+
if (tokens.length === 0)
|
|
1280
|
+
return 1;
|
|
1281
|
+
const haystack = contentSearchHaystack(record);
|
|
1282
|
+
let score = 0;
|
|
1283
|
+
for (const token of tokens) {
|
|
1284
|
+
if (haystack.includes(token))
|
|
1285
|
+
score += 1;
|
|
1286
|
+
}
|
|
1287
|
+
return score;
|
|
1288
|
+
}
|
|
1289
|
+
// Filter to records matching at least one query token, ranked by token-hit
|
|
1290
|
+
// count (descending), preserving the caller's incoming order on ties. Returns
|
|
1291
|
+
// the input unchanged when the query is empty.
|
|
1292
|
+
function filterAndRankBySearch(records, query) {
|
|
1293
|
+
const tokens = tokenizeSearchQuery(query);
|
|
1294
|
+
if (tokens.length === 0)
|
|
1295
|
+
return records;
|
|
1296
|
+
return records
|
|
1297
|
+
.map((record, index) => ({ record, index, score: scoreContentMatch(record, tokens) }))
|
|
1298
|
+
.filter((entry) => entry.score > 0)
|
|
1299
|
+
.sort((a, b) => (b.score - a.score) || (a.index - b.index))
|
|
1300
|
+
.map((entry) => entry.record);
|
|
1301
|
+
}
|
|
1302
|
+
// Global discover-feed partition on gsi2 (job records use gsi2 under
|
|
1303
|
+
// CUSTOMER#... partitions — no overlap). The sort key tiers the feed:
|
|
1304
|
+
// "1#createdAt#id" (decomposed, has defaultForkId) sorts above
|
|
1305
|
+
// "0#createdAt#id" (undecomposed ingest) in a descending query, giving
|
|
1306
|
+
// decomposed templates newest-first, then fresh ingests newest-first.
|
|
1307
|
+
export const TEMPLATE_FEED_PARTITION = "FEED#template";
|
|
1308
|
+
export function templateFeedSortKey(record) {
|
|
1309
|
+
const tier = record.defaultForkId ? "1" : "0";
|
|
1310
|
+
return `${tier}#${String(record.createdAt ?? "")}#${String(record.id ?? "")}`;
|
|
1311
|
+
}
|
|
1312
|
+
function requireMainTableName() {
|
|
1313
|
+
// In local mode the shim keys files by this name only, so a fixed default is
|
|
1314
|
+
// fine and the env var is not required.
|
|
1315
|
+
if (config.RECORDS_DRIVER === "local") {
|
|
1316
|
+
return config.VIDFARM_SERVERLESS_MAIN_TABLE_NAME || "vidfarm-local-main";
|
|
1317
|
+
}
|
|
1318
|
+
if (!config.VIDFARM_SERVERLESS_MAIN_TABLE_NAME) {
|
|
1319
|
+
throw new Error("Serverless record store requires VIDFARM_SERVERLESS_MAIN_TABLE_NAME.");
|
|
1320
|
+
}
|
|
1321
|
+
return config.VIDFARM_SERVERLESS_MAIN_TABLE_NAME;
|
|
1322
|
+
}
|
|
1323
|
+
function recordKey(collection, id) {
|
|
1324
|
+
return {
|
|
1325
|
+
pk: `${collectionPk(collection)}#${id}`,
|
|
1326
|
+
sk: "META"
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
function collectionPk(collection) {
|
|
1330
|
+
return `SERVERLESS_RECORD#${collection}`;
|
|
1331
|
+
}
|
|
1332
|
+
function activeTemplateSlugAliasId(slugId) {
|
|
1333
|
+
return `slug#${slugId}`;
|
|
1334
|
+
}
|
|
1335
|
+
function encodeDynamoCursor(value) {
|
|
1336
|
+
return value ? Buffer.from(JSON.stringify(value), "utf8").toString("base64url") : null;
|
|
1337
|
+
}
|
|
1338
|
+
function decodeDynamoCursor(raw) {
|
|
1339
|
+
if (!raw) {
|
|
1340
|
+
return undefined;
|
|
1341
|
+
}
|
|
1342
|
+
try {
|
|
1343
|
+
const parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
|
|
1344
|
+
return parsed && typeof parsed === "object" ? parsed : undefined;
|
|
1345
|
+
}
|
|
1346
|
+
catch {
|
|
1347
|
+
return undefined;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
function customerCollectionPk(collection, customerId) {
|
|
1351
|
+
return `CUSTOMER#${customerId}#${collection}`;
|
|
1352
|
+
}
|
|
1353
|
+
function entityType(collection) {
|
|
1354
|
+
return `serverless_record#${collection}`;
|
|
1355
|
+
}
|
|
1356
|
+
function normalizeFolder(value) {
|
|
1357
|
+
if (typeof value !== "string")
|
|
1358
|
+
return "";
|
|
1359
|
+
return value.trim().replace(/^\/+|\/+$/g, "");
|
|
1360
|
+
}
|
|
1361
|
+
function isExpiredUserTemporaryFileRecord(record) {
|
|
1362
|
+
return typeof record.id === "string"
|
|
1363
|
+
&& record.id.length > 0
|
|
1364
|
+
&& typeof record.expiresAt === "string"
|
|
1365
|
+
&& record.expiresAt.length > 0;
|
|
1366
|
+
}
|
|
1367
|
+
function itemToCustomer(item) {
|
|
1368
|
+
return {
|
|
1369
|
+
id: String(item.customer_id),
|
|
1370
|
+
email: String(item.email),
|
|
1371
|
+
name: typeof item.name === "string" ? item.name : null,
|
|
1372
|
+
defaultWebhookUrl: typeof item.default_webhook_url === "string" ? item.default_webhook_url : null,
|
|
1373
|
+
isDeveloper: Boolean(item.is_developer),
|
|
1374
|
+
isAgency: Boolean(item.is_agency),
|
|
1375
|
+
isPaidPlan: Boolean(item.is_paid_plan),
|
|
1376
|
+
planTier: item.plan_tier === "standalone" || item.plan_tier === "agency" ? item.plan_tier : "client",
|
|
1377
|
+
about: typeof item.about === "string" ? item.about : null,
|
|
1378
|
+
groupchatUrl: typeof item.groupchat_url === "string" ? item.groupchat_url : null,
|
|
1379
|
+
flockposterApiKey: typeof item.flockposter_api_key === "string" ? item.flockposter_api_key : null,
|
|
1380
|
+
createdAt: typeof item.created_at === "string" ? item.created_at : nowIso(),
|
|
1381
|
+
updatedAt: typeof item.updated_at === "string" ? item.updated_at : nowIso()
|
|
1382
|
+
};
|
|
1383
|
+
}
|
|
1384
|
+
export const serverlessRecords = new ServerlessRecordsServiceImpl();
|
|
1385
|
+
//# sourceMappingURL=serverless-records.js.map
|