@transloadit/convex 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +154 -122
  2. package/dist/client/index.d.ts +54 -13
  3. package/dist/client/index.d.ts.map +1 -1
  4. package/dist/client/index.js +48 -5
  5. package/dist/client/index.js.map +1 -1
  6. package/dist/component/_generated/api.d.ts +2 -2
  7. package/dist/component/_generated/component.d.ts +11 -0
  8. package/dist/component/_generated/component.d.ts.map +1 -1
  9. package/dist/component/_generated/dataModel.d.ts +1 -1
  10. package/dist/component/_generated/server.d.ts +1 -1
  11. package/dist/component/apiUtils.d.ts +26 -6
  12. package/dist/component/apiUtils.d.ts.map +1 -1
  13. package/dist/component/apiUtils.js +48 -38
  14. package/dist/component/apiUtils.js.map +1 -1
  15. package/dist/component/lib.d.ts +37 -8
  16. package/dist/component/lib.d.ts.map +1 -1
  17. package/dist/component/lib.js +145 -18
  18. package/dist/component/lib.js.map +1 -1
  19. package/dist/component/schema.d.ts +9 -6
  20. package/dist/component/schema.d.ts.map +1 -1
  21. package/dist/component/schema.js +4 -8
  22. package/dist/component/schema.js.map +1 -1
  23. package/dist/debug/index.d.ts +19 -0
  24. package/dist/debug/index.d.ts.map +1 -0
  25. package/dist/debug/index.js +49 -0
  26. package/dist/debug/index.js.map +1 -0
  27. package/dist/react/index.d.ts +201 -3
  28. package/dist/react/index.d.ts.map +1 -1
  29. package/dist/react/index.js +674 -94
  30. package/dist/react/index.js.map +1 -1
  31. package/dist/shared/assemblyUrls.d.ts +10 -0
  32. package/dist/shared/assemblyUrls.d.ts.map +1 -0
  33. package/dist/shared/assemblyUrls.js +26 -0
  34. package/dist/shared/assemblyUrls.js.map +1 -0
  35. package/dist/shared/errors.d.ts +7 -0
  36. package/dist/shared/errors.d.ts.map +1 -0
  37. package/dist/shared/errors.js +10 -0
  38. package/dist/shared/errors.js.map +1 -0
  39. package/dist/shared/pollAssembly.d.ts +12 -0
  40. package/dist/shared/pollAssembly.d.ts.map +1 -0
  41. package/dist/shared/pollAssembly.js +50 -0
  42. package/dist/shared/pollAssembly.js.map +1 -0
  43. package/dist/shared/resultTypes.d.ts +37 -0
  44. package/dist/shared/resultTypes.d.ts.map +1 -0
  45. package/dist/shared/resultTypes.js +2 -0
  46. package/dist/shared/resultTypes.js.map +1 -0
  47. package/dist/shared/resultUtils.d.ts +4 -0
  48. package/dist/shared/resultUtils.d.ts.map +1 -0
  49. package/dist/shared/resultUtils.js +69 -0
  50. package/dist/shared/resultUtils.js.map +1 -0
  51. package/dist/shared/tusUpload.d.ts +13 -0
  52. package/dist/shared/tusUpload.d.ts.map +1 -0
  53. package/dist/shared/tusUpload.js +32 -0
  54. package/dist/shared/tusUpload.js.map +1 -0
  55. package/dist/test/index.d.ts +9 -4
  56. package/dist/test/index.d.ts.map +1 -1
  57. package/dist/test/nodeModules.d.ts +2 -0
  58. package/dist/test/nodeModules.d.ts.map +1 -0
  59. package/dist/test/nodeModules.js +19 -0
  60. package/dist/test/nodeModules.js.map +1 -0
  61. package/package.json +40 -7
  62. package/src/client/index.ts +111 -9
  63. package/src/component/_generated/api.ts +2 -2
  64. package/src/component/_generated/component.ts +14 -0
  65. package/src/component/_generated/dataModel.ts +1 -1
  66. package/src/component/_generated/server.ts +1 -1
  67. package/src/component/apiUtils.test.ts +166 -2
  68. package/src/component/apiUtils.ts +96 -64
  69. package/src/component/lib.test.ts +213 -4
  70. package/src/component/lib.ts +192 -25
  71. package/src/component/schema.ts +4 -11
  72. package/src/debug/index.ts +84 -0
  73. package/src/react/index.test.tsx +340 -0
  74. package/src/react/index.tsx +1089 -179
  75. package/src/react/uploadWithTus.test.tsx +192 -0
  76. package/src/shared/assemblyUrls.test.ts +71 -0
  77. package/src/shared/assemblyUrls.ts +59 -0
  78. package/src/shared/errors.ts +23 -0
  79. package/src/shared/pollAssembly.ts +65 -0
  80. package/src/shared/resultTypes.ts +44 -0
  81. package/src/shared/resultUtils.test.ts +29 -0
  82. package/src/shared/resultUtils.ts +71 -0
  83. package/src/shared/tusUpload.ts +59 -0
  84. package/src/test/index.ts +1 -1
  85. package/src/test/nodeModules.ts +19 -0
@@ -3,9 +3,9 @@
3
3
  import { createHmac } from "node:crypto";
4
4
  import { convexTest } from "convex-test";
5
5
  import { describe, expect, test, vi } from "vitest";
6
- import { api } from "./_generated/api.js";
7
- import schema from "./schema.js";
8
- import { modules } from "./setup.test.js";
6
+ import { api } from "./_generated/api.ts";
7
+ import schema from "./schema.ts";
8
+ import { modules } from "./setup.test.ts";
9
9
 
10
10
  process.env.TRANSLOADIT_KEY = "test-key";
11
11
  process.env.TRANSLOADIT_SECRET = "test-secret";
@@ -40,7 +40,6 @@ describe("Transloadit component lib", () => {
40
40
  payload,
41
41
  rawBody,
42
42
  signature: `sha1:${signature}`,
43
- verifySignature: true,
44
43
  });
45
44
 
46
45
  expect(result.assemblyId).toBe("asm_123");
@@ -61,6 +60,216 @@ describe("Transloadit component lib", () => {
61
60
  expect(results[0]?.stepName).toBe("resized");
62
61
  });
63
62
 
63
+ test("listAlbumResults returns album-scoped results", async () => {
64
+ const t = convexTest(schema, modules);
65
+
66
+ const payload = {
67
+ assembly_id: "asm_album",
68
+ ok: "ASSEMBLY_COMPLETED",
69
+ fields: {
70
+ album: "wedding-gallery",
71
+ userId: "user_123",
72
+ },
73
+ results: {
74
+ resized: [
75
+ {
76
+ id: "file_album",
77
+ ssl_url: "https://example.com/album.jpg",
78
+ name: "album.jpg",
79
+ size: 100,
80
+ mime: "image/jpeg",
81
+ },
82
+ ],
83
+ },
84
+ };
85
+
86
+ const rawBody = JSON.stringify(payload);
87
+ const signature = createHmac("sha1", "test-secret")
88
+ .update(rawBody)
89
+ .digest("hex");
90
+
91
+ await t.action(api.lib.handleWebhook, {
92
+ payload,
93
+ rawBody,
94
+ signature: `sha1:${signature}`,
95
+ });
96
+
97
+ const results = await t.query(api.lib.listAlbumResults, {
98
+ album: "wedding-gallery",
99
+ });
100
+
101
+ expect(results).toHaveLength(1);
102
+ expect(results[0]?.album).toBe("wedding-gallery");
103
+ expect(results[0]?.userId).toBe("user_123");
104
+ });
105
+
106
+ test("handleWebhook stores url when ssl_url missing", async () => {
107
+ const t = convexTest(schema, modules);
108
+
109
+ const payload = {
110
+ assembly_id: "asm_url",
111
+ ok: "ASSEMBLY_COMPLETED",
112
+ results: {
113
+ stored: [
114
+ {
115
+ id: "file_3",
116
+ url: "https://example.com/file-3.jpg",
117
+ name: "file-3.jpg",
118
+ size: 42,
119
+ mime: "image/jpeg",
120
+ },
121
+ ],
122
+ },
123
+ };
124
+
125
+ const rawBody = JSON.stringify(payload);
126
+ const signature = createHmac("sha1", "test-secret")
127
+ .update(rawBody)
128
+ .digest("hex");
129
+
130
+ await t.action(api.lib.handleWebhook, {
131
+ payload,
132
+ rawBody,
133
+ signature: `sha1:${signature}`,
134
+ });
135
+
136
+ const results = await t.query(api.lib.listResults, {
137
+ assemblyId: "asm_url",
138
+ });
139
+
140
+ expect(results).toHaveLength(1);
141
+ expect(results[0]?.sslUrl).toBe("https://example.com/file-3.jpg");
142
+ });
143
+
144
+ test("listResults exposes expected fields for common robot outputs", async () => {
145
+ const t = convexTest(schema, modules);
146
+
147
+ const payload = {
148
+ assembly_id: "asm_schema",
149
+ ok: "ASSEMBLY_COMPLETED",
150
+ results: {
151
+ images_resized: [
152
+ {
153
+ id: "img_1",
154
+ ssl_url: "https://example.com/img.jpg",
155
+ name: "img.jpg",
156
+ mime: "image/jpeg",
157
+ width: 1600,
158
+ height: 1200,
159
+ },
160
+ ],
161
+ videos_encoded: [
162
+ {
163
+ id: "vid_1",
164
+ ssl_url: "https://example.com/vid.mp4",
165
+ name: "vid.mp4",
166
+ mime: "video/mp4",
167
+ duration: 12.5,
168
+ },
169
+ ],
170
+ videos_thumbs_output: [
171
+ {
172
+ id: "thumb_1",
173
+ ssl_url: "https://example.com/thumb.jpg",
174
+ name: "thumb.jpg",
175
+ mime: "image/jpeg",
176
+ original_id: "vid_1",
177
+ },
178
+ ],
179
+ },
180
+ };
181
+
182
+ const rawBody = JSON.stringify(payload);
183
+ const signature = createHmac("sha1", "test-secret")
184
+ .update(rawBody)
185
+ .digest("hex");
186
+
187
+ await t.action(api.lib.handleWebhook, {
188
+ payload,
189
+ rawBody,
190
+ signature: `sha1:${signature}`,
191
+ });
192
+
193
+ const results = await t.query(api.lib.listResults, {
194
+ assemblyId: "asm_schema",
195
+ });
196
+
197
+ expect(results).toHaveLength(3);
198
+
199
+ const byStep = new Map(results.map((result) => [result.stepName, result]));
200
+ const image = byStep.get("images_resized");
201
+ const video = byStep.get("videos_encoded");
202
+ const thumb = byStep.get("videos_thumbs_output");
203
+
204
+ expect(image?.sslUrl).toBe("https://example.com/img.jpg");
205
+ expect(image?.mime).toBe("image/jpeg");
206
+ expect(image?.raw?.width).toBe(1600);
207
+ expect(image?.raw?.height).toBe(1200);
208
+
209
+ expect(video?.sslUrl).toBe("https://example.com/vid.mp4");
210
+ expect(video?.mime).toBe("video/mp4");
211
+ expect(video?.raw?.duration).toBe(12.5);
212
+
213
+ expect(thumb?.sslUrl).toBe("https://example.com/thumb.jpg");
214
+ expect(thumb?.raw?.original_id).toBe("vid_1");
215
+ });
216
+
217
+ test("handleWebhook requires rawBody when verifying signature", async () => {
218
+ const t = convexTest(schema, modules);
219
+ const payload = { assembly_id: "asm_missing" };
220
+ const signature = createHmac("sha1", "test-secret")
221
+ .update(JSON.stringify(payload))
222
+ .digest("hex");
223
+
224
+ await expect(
225
+ t.action(api.lib.handleWebhook, {
226
+ payload,
227
+ signature: `sha1:${signature}`,
228
+ }),
229
+ ).rejects.toThrow("Missing rawBody for webhook verification");
230
+ });
231
+
232
+ test("handleWebhook can skip verification when configured", async () => {
233
+ const t = convexTest(schema, modules);
234
+ const payload = {
235
+ assembly_id: "asm_skip",
236
+ ok: "ASSEMBLY_COMPLETED",
237
+ results: {
238
+ resized: [
239
+ {
240
+ id: "file_skip",
241
+ ssl_url: "https://example.com/skip.jpg",
242
+ name: "skip.jpg",
243
+ size: 123,
244
+ mime: "image/jpeg",
245
+ },
246
+ ],
247
+ },
248
+ };
249
+
250
+ const result = await t.action(api.lib.handleWebhook, {
251
+ payload,
252
+ verifySignature: false,
253
+ });
254
+
255
+ expect(result.assemblyId).toBe("asm_skip");
256
+ expect(result.resultCount).toBe(1);
257
+ });
258
+
259
+ test("queueWebhook rejects invalid signature", async () => {
260
+ const t = convexTest(schema, modules);
261
+ const payload = { assembly_id: "asm_bad" };
262
+ const rawBody = JSON.stringify(payload);
263
+
264
+ await expect(
265
+ t.action(api.lib.queueWebhook, {
266
+ payload,
267
+ rawBody,
268
+ signature: "sha1:bad",
269
+ }),
270
+ ).rejects.toThrow("Invalid Transloadit webhook signature");
271
+ });
272
+
64
273
  test("refreshAssembly fetches status and stores results", async () => {
65
274
  const t = convexTest(schema, modules);
66
275
 
@@ -1,20 +1,23 @@
1
- import type { AssemblyStatus } from "@transloadit/types/assemblyStatus";
2
- import type { AssemblyInstructionsInput } from "@transloadit/types/template";
1
+ import type { AssemblyStatus } from "@transloadit/zod/v3/assemblyStatus";
2
+ import type { AssemblyInstructionsInput } from "@transloadit/zod/v3/template";
3
3
  import { anyApi, type FunctionReference } from "convex/server";
4
4
  import { type Infer, v } from "convex/values";
5
+ import { parseAssemblyStatus } from "../shared/assemblyUrls.ts";
6
+ import { transloaditError } from "../shared/errors.ts";
7
+ import { getResultUrl } from "../shared/resultUtils.ts";
5
8
  import {
6
9
  action,
7
10
  internalAction,
8
11
  internalMutation,
9
12
  mutation,
10
13
  query,
11
- } from "./_generated/server.js";
14
+ } from "./_generated/server.ts";
12
15
  import {
13
16
  buildTransloaditParams,
14
17
  flattenResults,
15
18
  signTransloaditParams,
16
19
  verifyWebhookSignature,
17
- } from "./apiUtils.js";
20
+ } from "./apiUtils.ts";
18
21
 
19
22
  const TRANSLOADIT_ASSEMBLY_URL = "https://api2.transloadit.com/assemblies";
20
23
 
@@ -56,6 +59,32 @@ const resolveAssemblyId = (payload: AssemblyStatus): string => {
56
59
  return "";
57
60
  };
58
61
 
62
+ const getFieldString = (fields: unknown, key: string): string | undefined => {
63
+ if (!fields || typeof fields !== "object") return undefined;
64
+ const value = (fields as Record<string, unknown>)[key];
65
+ return typeof value === "string" ? value : undefined;
66
+ };
67
+
68
+ const parseAssemblyPayload = (payload: unknown): AssemblyStatus => {
69
+ const parsed = parseAssemblyStatus(payload);
70
+ if (!parsed) {
71
+ throw transloaditError("payload", "Invalid Transloadit payload");
72
+ }
73
+ return parsed;
74
+ };
75
+
76
+ const resolveWebhookRawBody = (args: {
77
+ payload: unknown;
78
+ rawBody?: string;
79
+ verifySignature?: boolean;
80
+ }) => {
81
+ if (typeof args.rawBody === "string") return args.rawBody;
82
+ if (args.verifySignature === false) {
83
+ return JSON.stringify(args.payload ?? {});
84
+ }
85
+ return null;
86
+ };
87
+
59
88
  const buildSignedAssemblyUrl = async (
60
89
  assemblyId: string,
61
90
  authKey: string,
@@ -75,12 +104,12 @@ const buildSignedAssemblyUrl = async (
75
104
  };
76
105
 
77
106
  const applyAssemblyStatus = async (
78
- ctx: Pick<import("./_generated/server.js").FunctionCtx, "runMutation">,
107
+ ctx: Pick<import("./_generated/server.ts").FunctionCtx, "runMutation">,
79
108
  payload: AssemblyStatus,
80
109
  ) => {
81
110
  const assemblyId = resolveAssemblyId(payload);
82
111
  if (!assemblyId) {
83
- throw new Error("Webhook payload missing assembly_id");
112
+ throw transloaditError("webhook", "Webhook payload missing assembly_id");
84
113
  }
85
114
 
86
115
  const results = flattenResults(payload.results ?? undefined);
@@ -94,10 +123,15 @@ const applyAssemblyStatus = async (
94
123
  typeof payload.template_id === "string" ? payload.template_id : undefined,
95
124
  notifyUrl:
96
125
  typeof payload.notify_url === "string" ? payload.notify_url : undefined,
126
+ fields: payload.fields,
97
127
  uploads: payload.uploads,
98
128
  results: payload.results,
99
129
  error: payload.error,
100
130
  raw: payload,
131
+ userId:
132
+ typeof payload.user_id === "string"
133
+ ? payload.user_id
134
+ : getFieldString(payload.fields, "userId"),
101
135
  });
102
136
 
103
137
  await ctx.runMutation(internal.lib.replaceResultsForAssembly, {
@@ -139,6 +173,8 @@ export const vAssemblyResult = v.object({
139
173
  _id: v.id("results"),
140
174
  _creationTime: v.number(),
141
175
  assemblyId: v.string(),
176
+ album: v.optional(v.string()),
177
+ userId: v.optional(v.string()),
142
178
  stepName: v.string(),
143
179
  resultId: v.optional(v.string()),
144
180
  sslUrl: v.optional(v.string()),
@@ -185,6 +221,9 @@ export const upsertAssembly = internalMutation({
185
221
  },
186
222
  returns: v.id("assemblies"),
187
223
  handler: async (ctx, args) => {
224
+ // Note: we persist full `raw` + `results` for debugging/fidelity. Large
225
+ // assemblies can hit Convex document size limits; trim or externalize
226
+ // payloads if this becomes an issue for your workload.
188
227
  const existing = await ctx.db
189
228
  .query("assemblies")
190
229
  .withIndex("by_assemblyId", (q) => q.eq("assemblyId", args.assemblyId))
@@ -244,6 +283,10 @@ export const replaceResultsForAssembly = internalMutation({
244
283
  },
245
284
  returns: v.null(),
246
285
  handler: async (ctx, args) => {
286
+ // We store raw result payloads for fidelity. For very large assemblies,
287
+ // consider trimming or externalizing these fields to avoid size limits.
288
+ // This mutation replaces all results in one transaction; extremely large
289
+ // result sets may need batching or external storage to avoid Convex limits.
247
290
  const existingResults = await ctx.db
248
291
  .query("results")
249
292
  .withIndex("by_assemblyId", (q) => q.eq("assemblyId", args.assemblyId))
@@ -253,14 +296,27 @@ export const replaceResultsForAssembly = internalMutation({
253
296
  await ctx.db.delete(existing._id);
254
297
  }
255
298
 
299
+ const assembly = await ctx.db
300
+ .query("assemblies")
301
+ .withIndex("by_assemblyId", (q) => q.eq("assemblyId", args.assemblyId))
302
+ .unique();
303
+ const album = getFieldString(assembly?.fields, "album");
304
+ const userId =
305
+ typeof assembly?.userId === "string"
306
+ ? assembly.userId
307
+ : getFieldString(assembly?.fields, "userId");
308
+
256
309
  const now = Date.now();
257
310
  for (const entry of args.results) {
258
311
  const raw = entry.result as Record<string, unknown>;
312
+ const sslUrl = getResultUrl(entry.result);
259
313
  await ctx.db.insert("results", {
260
314
  assemblyId: args.assemblyId,
315
+ album,
316
+ userId,
261
317
  stepName: entry.stepName,
262
318
  resultId: typeof raw.id === "string" ? raw.id : undefined,
263
- sslUrl: typeof raw.ssl_url === "string" ? raw.ssl_url : undefined,
319
+ sslUrl,
264
320
  name: typeof raw.name === "string" ? raw.name : undefined,
265
321
  size: typeof raw.size === "number" ? raw.size : undefined,
266
322
  mime: typeof raw.mime === "string" ? raw.mime : undefined,
@@ -318,8 +374,9 @@ export const createAssembly = action({
318
374
 
319
375
  const data = (await response.json()) as Record<string, unknown>;
320
376
  if (!response.ok) {
321
- throw new Error(
322
- `Transloadit error ${response.status}: ${JSON.stringify(data)}`,
377
+ throw transloaditError(
378
+ "createAssembly",
379
+ `HTTP ${response.status}: ${JSON.stringify(data)}`,
323
380
  );
324
381
  }
325
382
 
@@ -331,7 +388,10 @@ export const createAssembly = action({
331
388
  : "";
332
389
 
333
390
  if (!assemblyId) {
334
- throw new Error("Transloadit response missing assembly_id");
391
+ throw transloaditError(
392
+ "createAssembly",
393
+ "Transloadit response missing assembly_id",
394
+ );
335
395
  }
336
396
 
337
397
  await ctx.runMutation(internal.lib.upsertAssembly, {
@@ -362,6 +422,13 @@ const vWebhookArgs = {
362
422
  authSecret: v.optional(v.string()),
363
423
  };
364
424
 
425
+ const vPublicWebhookArgs = {
426
+ payload: v.any(),
427
+ rawBody: v.optional(v.string()),
428
+ signature: v.optional(v.string()),
429
+ verifySignature: v.optional(v.boolean()),
430
+ };
431
+
365
432
  export const processWebhook = internalAction({
366
433
  args: vWebhookArgs,
367
434
  returns: v.object({
@@ -371,13 +438,22 @@ export const processWebhook = internalAction({
371
438
  status: v.optional(v.string()),
372
439
  }),
373
440
  handler: async (ctx, args) => {
374
- const rawBody = args.rawBody ?? JSON.stringify(args.payload ?? {});
441
+ const rawBody = resolveWebhookRawBody(args);
375
442
  const shouldVerify = args.verifySignature ?? true;
376
443
  const authSecret = args.authSecret ?? process.env.TRANSLOADIT_SECRET;
377
444
 
378
445
  if (shouldVerify) {
446
+ if (!rawBody) {
447
+ throw transloaditError(
448
+ "webhook",
449
+ "Missing rawBody for webhook verification",
450
+ );
451
+ }
379
452
  if (!authSecret) {
380
- throw new Error("Missing TRANSLOADIT_SECRET for webhook validation");
453
+ throw transloaditError(
454
+ "webhook",
455
+ "Missing TRANSLOADIT_SECRET for webhook validation",
456
+ );
381
457
  }
382
458
  const verified = await verifyWebhookSignature({
383
459
  rawBody,
@@ -385,17 +461,21 @@ export const processWebhook = internalAction({
385
461
  authSecret,
386
462
  });
387
463
  if (!verified) {
388
- throw new Error("Invalid Transloadit webhook signature");
464
+ throw transloaditError(
465
+ "webhook",
466
+ "Invalid Transloadit webhook signature",
467
+ );
389
468
  }
390
469
  }
391
470
 
392
- return applyAssemblyStatus(ctx, args.payload as AssemblyStatus);
471
+ const parsed = parseAssemblyPayload(args.payload);
472
+ return applyAssemblyStatus(ctx, parsed);
393
473
  },
394
474
  });
395
475
 
396
476
  export const handleWebhook = action({
397
477
  args: {
398
- ...vWebhookArgs,
478
+ ...vPublicWebhookArgs,
399
479
  config: v.optional(
400
480
  v.object({
401
481
  authSecret: v.string(),
@@ -409,11 +489,12 @@ export const handleWebhook = action({
409
489
  status: v.optional(v.string()),
410
490
  }),
411
491
  handler: async (ctx, args) => {
492
+ const verifySignature = args.verifySignature ?? true;
412
493
  return ctx.runAction(internal.lib.processWebhook, {
413
494
  payload: args.payload,
414
495
  rawBody: args.rawBody,
415
496
  signature: args.signature,
416
- verifySignature: args.verifySignature,
497
+ verifySignature,
417
498
  authSecret: args.config?.authSecret,
418
499
  });
419
500
  },
@@ -421,7 +502,7 @@ export const handleWebhook = action({
421
502
 
422
503
  export const queueWebhook = action({
423
504
  args: {
424
- ...vWebhookArgs,
505
+ ...vPublicWebhookArgs,
425
506
  config: v.optional(
426
507
  v.object({
427
508
  authSecret: v.string(),
@@ -433,17 +514,48 @@ export const queueWebhook = action({
433
514
  queued: v.boolean(),
434
515
  }),
435
516
  handler: async (ctx, args) => {
436
- const payload = args.payload as AssemblyStatus;
437
- const assemblyId = resolveAssemblyId(payload);
517
+ const rawBody = resolveWebhookRawBody(args);
518
+ const shouldVerify = args.verifySignature ?? true;
519
+ const authSecret =
520
+ args.config?.authSecret ?? process.env.TRANSLOADIT_SECRET;
521
+
522
+ if (shouldVerify) {
523
+ if (!rawBody) {
524
+ throw transloaditError(
525
+ "webhook",
526
+ "Missing rawBody for webhook verification",
527
+ );
528
+ }
529
+ if (!authSecret) {
530
+ throw transloaditError(
531
+ "webhook",
532
+ "Missing TRANSLOADIT_SECRET for webhook validation",
533
+ );
534
+ }
535
+ const verified = await verifyWebhookSignature({
536
+ rawBody,
537
+ signatureHeader: args.signature,
538
+ authSecret,
539
+ });
540
+ if (!verified) {
541
+ throw transloaditError(
542
+ "webhook",
543
+ "Invalid Transloadit webhook signature",
544
+ );
545
+ }
546
+ }
547
+
548
+ const parsed = parseAssemblyPayload(args.payload);
549
+ const assemblyId = resolveAssemblyId(parsed);
438
550
  if (!assemblyId) {
439
- throw new Error("Webhook payload missing assembly_id");
551
+ throw transloaditError("webhook", "Webhook payload missing assembly_id");
440
552
  }
441
553
 
442
554
  await ctx.scheduler.runAfter(0, internal.lib.processWebhook, {
443
- payload: args.payload,
555
+ payload: parsed,
444
556
  rawBody: args.rawBody,
445
557
  signature: args.signature,
446
- verifySignature: args.verifySignature,
558
+ verifySignature: true,
447
559
  authSecret: args.config?.authSecret,
448
560
  });
449
561
 
@@ -478,10 +590,11 @@ export const refreshAssembly = action({
478
590
  : `${TRANSLOADIT_ASSEMBLY_URL}/${assemblyId}`;
479
591
 
480
592
  const response = await fetch(url);
481
- const payload = (await response.json()) as AssemblyStatus;
593
+ const payload = parseAssemblyPayload(await response.json());
482
594
  if (!response.ok) {
483
- throw new Error(
484
- `Transloadit status error ${response.status}: ${JSON.stringify(payload)}`,
595
+ throw transloaditError(
596
+ "status",
597
+ `HTTP ${response.status}: ${JSON.stringify(payload)}`,
485
598
  );
486
599
  }
487
600
 
@@ -557,6 +670,60 @@ export const listResults = query({
557
670
  },
558
671
  });
559
672
 
673
+ export const listAlbumResults = query({
674
+ args: {
675
+ album: v.string(),
676
+ limit: v.optional(v.number()),
677
+ },
678
+ returns: v.array(vAssemblyResult),
679
+ handler: async (ctx, args) => {
680
+ return ctx.db
681
+ .query("results")
682
+ .withIndex("by_album", (q) => q.eq("album", args.album))
683
+ .order("desc")
684
+ .take(args.limit ?? 200);
685
+ },
686
+ });
687
+
688
+ export const purgeAlbum = mutation({
689
+ args: {
690
+ album: v.string(),
691
+ deleteAssemblies: v.optional(v.boolean()),
692
+ },
693
+ returns: v.object({
694
+ deletedResults: v.number(),
695
+ deletedAssemblies: v.number(),
696
+ }),
697
+ handler: async (ctx, args) => {
698
+ const results = await ctx.db
699
+ .query("results")
700
+ .withIndex("by_album", (q) => q.eq("album", args.album))
701
+ .collect();
702
+ const assemblyIds = new Set<string>();
703
+
704
+ for (const result of results) {
705
+ assemblyIds.add(result.assemblyId);
706
+ await ctx.db.delete(result._id);
707
+ }
708
+
709
+ let deletedAssemblies = 0;
710
+ if (args.deleteAssemblies ?? true) {
711
+ for (const assemblyId of assemblyIds) {
712
+ const assembly = await ctx.db
713
+ .query("assemblies")
714
+ .withIndex("by_assemblyId", (q) => q.eq("assemblyId", assemblyId))
715
+ .unique();
716
+ if (assembly) {
717
+ await ctx.db.delete(assembly._id);
718
+ deletedAssemblies += 1;
719
+ }
720
+ }
721
+ }
722
+
723
+ return { deletedResults: results.length, deletedAssemblies };
724
+ },
725
+ });
726
+
560
727
  export const storeAssemblyMetadata = mutation({
561
728
  args: {
562
729
  assemblyId: v.string(),
@@ -1,16 +1,6 @@
1
1
  import { defineSchema, defineTable } from "convex/server";
2
2
  import { v } from "convex/values";
3
3
 
4
- export const assemblyStatusValues = [
5
- "ASSEMBLY_UPLOADING",
6
- "ASSEMBLY_EXECUTING",
7
- "ASSEMBLY_COMPLETED",
8
- "ASSEMBLY_CANCELED",
9
- "ASSEMBLY_FAILED",
10
- ] as const;
11
-
12
- export type AssemblyStatus = (typeof assemblyStatusValues)[number];
13
-
14
4
  export default defineSchema({
15
5
  assemblies: defineTable({
16
6
  assemblyId: v.string(),
@@ -34,6 +24,8 @@ export default defineSchema({
34
24
  .index("by_userId", ["userId"]),
35
25
  results: defineTable({
36
26
  assemblyId: v.string(),
27
+ album: v.optional(v.string()),
28
+ userId: v.optional(v.string()),
37
29
  stepName: v.string(),
38
30
  resultId: v.optional(v.string()),
39
31
  sslUrl: v.optional(v.string()),
@@ -44,5 +36,6 @@ export default defineSchema({
44
36
  createdAt: v.number(),
45
37
  })
46
38
  .index("by_assemblyId", ["assemblyId"])
47
- .index("by_assemblyId_and_step", ["assemblyId", "stepName"]),
39
+ .index("by_assemblyId_and_step", ["assemblyId", "stepName"])
40
+ .index("by_album", ["album"]),
48
41
  });