streamby-core 0.37.0 → 0.38.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.
@@ -15,6 +15,9 @@ const builtinAccess_1 = require("../../utils/builtinAccess");
15
15
  const connectionResolver_1 = require("../../services/connectionResolver");
16
16
  const mongodb_1 = require("mongodb");
17
17
  const crypto_1 = __importDefault(require("crypto"));
18
+ const signedUrls_1 = require("../../services/signedUrls");
19
+ const assetVersions_1 = require("../../services/assetVersions");
20
+ const deliverables_1 = require("../../services/deliverables");
18
21
  const VALID_CATEGORIES = ['images', 'audios', 'videos', '3d-models'];
19
22
  const VALID_3D_EXTENSIONS = new Set(['.glb', '.gltf', '.obj', '.fbx', '.stl', '.ply']);
20
23
  function validateContentType(contentType, filename, category) {
@@ -135,7 +138,7 @@ function storageRouter(config) {
135
138
  const storageKey = `${projectId}/${category}/${fileId}${ext}`;
136
139
  const collection = getRawFilesCollection();
137
140
  if (collection) {
138
- await collection.insertOne({
141
+ const fileDoc = {
139
142
  _id: new mongodb_1.ObjectId(),
140
143
  fileId,
141
144
  projectId,
@@ -145,10 +148,13 @@ function storageRouter(config) {
145
148
  contentType,
146
149
  uploadedBy: auth.userId,
147
150
  createdAt: new Date(),
148
- });
151
+ };
152
+ await collection.insertOne(fileDoc);
153
+ await (0, assetVersions_1.recordFirstVersion)(fileDoc);
149
154
  }
150
- const url = await adapter.getPresignedUploadUrl(storageKey, contentType);
151
- res.json({ url, fileId, storageKey, displayName: fileName });
155
+ const expiresIn = (0, signedUrls_1.signedUrlTtl)(auth, config, 'upload');
156
+ const url = await adapter.getPresignedUploadUrl(storageKey, contentType, expiresIn);
157
+ res.json({ url, fileId, storageKey, displayName: fileName, ...expiry(expiresIn) });
152
158
  }
153
159
  catch (err) {
154
160
  res.status(500).json({ message: 'Failed to generate upload URL', details: err.message });
@@ -165,7 +171,7 @@ function storageRouter(config) {
165
171
  const project = await Project.findOne({ _id: projectId });
166
172
  if (!project)
167
173
  return res.status(404).json({ message: 'Project not found' });
168
- if (!(0, auth_1.isProjectMember)(project, auth.userId))
174
+ if (!(0, auth_1.canEditProject)(project, auth.userId))
169
175
  return res.status(403).json({ message: 'Unauthorized access' });
170
176
  const collection = getRawFilesCollection();
171
177
  if (!collection)
@@ -179,9 +185,21 @@ function storageRouter(config) {
179
185
  if (!adapter.getPresignedUploadUrl) {
180
186
  return res.status(501).json({ message: 'Storage adapter does not support presigned uploads' });
181
187
  }
182
- const url = await adapter.getPresignedUploadUrl(fileDoc.storageKey, contentType);
188
+ // The new version goes to the asset's own key, so its URL doesn't change; the current
189
+ // one is archived first, and without a way to archive it nothing is replaced.
190
+ if (!adapter.copyFile)
191
+ return res.status(501).json({ message: 'Storage adapter cannot keep versions' });
192
+ let version;
193
+ try {
194
+ version = await (0, assetVersions_1.archiveCurrentVersion)(fileDoc, { fileName, contentType, userId: auth.userId }, adapter.copyFile.bind(adapter));
195
+ }
196
+ catch (err) {
197
+ return res.status(502).json({ message: 'Failed to archive the current version; nothing was replaced', details: err.message });
198
+ }
199
+ const expiresIn = (0, signedUrls_1.signedUrlTtl)(auth, config, 'upload');
200
+ const url = await adapter.getPresignedUploadUrl(fileDoc.storageKey, contentType, expiresIn);
183
201
  await collection.updateOne({ fileId, projectId }, { $set: { contentType, updatedAt: new Date() } });
184
- res.json({ url, storageKey: fileDoc.storageKey, fileId });
202
+ res.json({ url, storageKey: fileDoc.storageKey, fileId, versionId: version.versionId, version: version.version, ...expiry(expiresIn) });
185
203
  }
186
204
  catch (err) {
187
205
  res.status(500).json({ message: 'Failed to generate replace URL', details: err.message });
@@ -227,8 +245,7 @@ function storageRouter(config) {
227
245
  const fileDoc = await collection.findOne({ fileId, projectId });
228
246
  if (!fileDoc)
229
247
  return res.status(404).json({ message: 'File not found' });
230
- if (adapter.deleteFile)
231
- await adapter.deleteFile(fileDoc.storageKey);
248
+ await deleteAsset(fileDoc, adapter);
232
249
  await collection.deleteOne({ fileId, projectId });
233
250
  res.json({ message: 'File deleted successfully' });
234
251
  }
@@ -249,7 +266,7 @@ function storageRouter(config) {
249
266
  if (!collection)
250
267
  return res.status(500).json({ message: 'Database not available' });
251
268
  const files = await collection.find({ projectId }).sort({ createdAt: -1 }).limit(12).toArray();
252
- const data = await Promise.all(files.map(f => resolveFileUrl(f, adapter)));
269
+ const data = await Promise.all(files.map(f => resolveFileUrl(f, adapter, (0, signedUrls_1.signedUrlTtl)(auth, config, 'download'))));
253
270
  res.json({ data });
254
271
  }
255
272
  catch (err) {
@@ -272,13 +289,61 @@ function storageRouter(config) {
272
289
  if (!collection)
273
290
  return res.status(500).json({ message: 'Database not available' });
274
291
  const files = await collection.find({ projectId, category }).sort({ createdAt: -1 }).toArray();
275
- const data = await Promise.all(files.map(f => resolveFileUrl(f, adapter)));
292
+ const data = await Promise.all(files.map(f => resolveFileUrl(f, adapter, (0, signedUrls_1.signedUrlTtl)(auth, config, 'download'))));
276
293
  res.json({ data });
277
294
  }
278
295
  catch (err) {
279
296
  res.status(500).json({ message: 'Failed to list files', details: err.message });
280
297
  }
281
298
  });
299
+ // A short-lived URL to download an asset, or one of its versions, wherever it's stored
300
+ // (the built-in storage or one of the project's storage connections). It's for that file
301
+ // only, and downloads it with its name.
302
+ router.get('/projects/:projectId/assets/:fileId/download-url', async (req, res) => {
303
+ try {
304
+ const auth = req.auth;
305
+ const { projectId, fileId } = req.params;
306
+ const { versionId } = req.query;
307
+ const project = await Project.findOne({ _id: projectId });
308
+ if (!project)
309
+ return res.status(404).json({ message: 'Project not found' });
310
+ if (!(0, auth_1.isProjectMember)(project, auth.userId))
311
+ return res.status(403).json({ message: 'Unauthorized access' });
312
+ const collection = getRawFilesCollection();
313
+ if (!collection)
314
+ return res.status(500).json({ message: 'Database not available' });
315
+ const fileDoc = await collection.findOne({ fileId, projectId });
316
+ if (!fileDoc)
317
+ return res.status(404).json({ message: 'File not found' });
318
+ let storageKey = fileDoc.storageKey;
319
+ let fileName = fileDoc.displayName;
320
+ let contentType = fileDoc.contentType;
321
+ let version;
322
+ if (versionId) {
323
+ const found = await (0, assetVersions_1.findVersion)(projectId, fileId, versionId);
324
+ if (!found)
325
+ return res.status(404).json({ message: 'Version not found' });
326
+ storageKey = found.storageKey;
327
+ fileName = found.fileName ?? fileName;
328
+ contentType = found.contentType ?? contentType;
329
+ version = found.version;
330
+ }
331
+ // Its storage connection's adapter; files stored before connections had their own row
332
+ // (no connection, or the built-in provider's id) are in the default storage.
333
+ const connId = fileDoc.storageConnectionId;
334
+ const fileAdapter = connId && findStorageConn(project, connId) ? await resolveConnAdapter(connId, project, auth) : adapter;
335
+ if ('error' in fileAdapter)
336
+ return res.status(fileAdapter.status).json({ message: fileAdapter.error });
337
+ if (!fileAdapter.getPresignedGetUrl)
338
+ return res.status(501).json({ message: 'Storage adapter does not support download URLs' });
339
+ const expiresIn = (0, signedUrls_1.signedUrlTtl)(auth, config, 'download') ?? 3600;
340
+ const url = await fileAdapter.getPresignedGetUrl(storageKey, expiresIn, fileName);
341
+ res.json({ url, fileId, fileName, contentType: contentType ?? null, ...(version ? { versionId, version } : {}), ...expiry(expiresIn) });
342
+ }
343
+ catch (err) {
344
+ res.status(500).json({ message: 'Failed to generate download URL', details: err.message });
345
+ }
346
+ });
282
347
  // ─── Per-connection file routes ───────────────────────────────────────────
283
348
  router.get('/projects/:projectId/connections/storage/:connId/upload-url', async (req, res) => {
284
349
  try {
@@ -309,7 +374,7 @@ function storageRouter(config) {
309
374
  const storageKey = `${projectId}/${category}/${fileId}${ext}`;
310
375
  const collection = getRawFilesCollection();
311
376
  if (collection) {
312
- await collection.insertOne({
377
+ const fileDoc = {
313
378
  _id: new mongodb_1.ObjectId(),
314
379
  fileId,
315
380
  projectId,
@@ -320,10 +385,13 @@ function storageRouter(config) {
320
385
  contentType,
321
386
  uploadedBy: auth.userId,
322
387
  createdAt: new Date(),
323
- });
388
+ };
389
+ await collection.insertOne(fileDoc);
390
+ await (0, assetVersions_1.recordFirstVersion)(fileDoc);
324
391
  }
325
- const url = await connAdapter.getPresignedUploadUrl(storageKey, contentType);
326
- res.json({ url, fileId, storageKey, displayName: fileName });
392
+ const expiresIn = (0, signedUrls_1.signedUrlTtl)(auth, config, 'upload');
393
+ const url = await connAdapter.getPresignedUploadUrl(storageKey, contentType, expiresIn);
394
+ res.json({ url, fileId, storageKey, displayName: fileName, ...expiry(expiresIn) });
327
395
  }
328
396
  catch (err) {
329
397
  res.status(500).json({ message: 'Failed to generate upload URL', details: err.message });
@@ -345,7 +413,7 @@ function storageRouter(config) {
345
413
  if (!collection)
346
414
  return res.status(500).json({ message: 'Database not available' });
347
415
  const files = await collection.find(connFilter(projectId, connId, project)).sort({ createdAt: -1 }).limit(12).toArray();
348
- const data = await Promise.all(files.map(f => resolveFileUrl(f, connAdapter)));
416
+ const data = await Promise.all(files.map(f => resolveFileUrl(f, connAdapter, (0, signedUrls_1.signedUrlTtl)(auth, config, 'download'))));
349
417
  res.json({ data });
350
418
  }
351
419
  catch (err) {
@@ -371,7 +439,7 @@ function storageRouter(config) {
371
439
  if (!collection)
372
440
  return res.status(500).json({ message: 'Database not available' });
373
441
  const files = await collection.find(connFilter(projectId, connId, project, { category })).sort({ createdAt: -1 }).toArray();
374
- const data = await Promise.all(files.map(f => resolveFileUrl(f, connAdapter)));
442
+ const data = await Promise.all(files.map(f => resolveFileUrl(f, connAdapter, (0, signedUrls_1.signedUrlTtl)(auth, config, 'download'))));
375
443
  res.json({ data });
376
444
  }
377
445
  catch (err) {
@@ -389,7 +457,7 @@ function storageRouter(config) {
389
457
  const project = await Project.findOne({ _id: projectId });
390
458
  if (!project)
391
459
  return res.status(404).json({ message: 'Project not found' });
392
- if (!(0, auth_1.isProjectMember)(project, auth.userId))
460
+ if (!(0, auth_1.canEditProject)(project, auth.userId))
393
461
  return res.status(403).json({ message: 'Unauthorized access' });
394
462
  const collection = getRawFilesCollection();
395
463
  if (!collection)
@@ -406,9 +474,21 @@ function storageRouter(config) {
406
474
  if (!connAdapter.getPresignedUploadUrl) {
407
475
  return res.status(501).json({ message: 'Storage adapter does not support presigned uploads' });
408
476
  }
409
- const url = await connAdapter.getPresignedUploadUrl(fileDoc.storageKey, contentType);
477
+ // The new version goes to the asset's own key, so its URL doesn't change; the current
478
+ // one is archived first, and without a way to archive it nothing is replaced.
479
+ if (!connAdapter.copyFile)
480
+ return res.status(501).json({ message: 'Storage adapter cannot keep versions' });
481
+ let version;
482
+ try {
483
+ version = await (0, assetVersions_1.archiveCurrentVersion)(fileDoc, { fileName, contentType, userId: auth.userId }, connAdapter.copyFile.bind(connAdapter));
484
+ }
485
+ catch (err) {
486
+ return res.status(502).json({ message: 'Failed to archive the current version; nothing was replaced', details: err.message });
487
+ }
488
+ const expiresIn = (0, signedUrls_1.signedUrlTtl)(auth, config, 'upload');
489
+ const url = await connAdapter.getPresignedUploadUrl(fileDoc.storageKey, contentType, expiresIn);
410
490
  await collection.updateOne(connFilter(projectId, connId, project, { fileId }), { $set: { contentType, updatedAt: new Date() } });
411
- res.json({ url, storageKey: fileDoc.storageKey, fileId });
491
+ res.json({ url, storageKey: fileDoc.storageKey, fileId, versionId: version.versionId, version: version.version, ...expiry(expiresIn) });
412
492
  }
413
493
  catch (err) {
414
494
  res.status(500).json({ message: 'Failed to generate replace URL', details: err.message });
@@ -457,8 +537,7 @@ function storageRouter(config) {
457
537
  const fileDoc = await collection.findOne(connFilter(projectId, connId, project, { fileId }));
458
538
  if (!fileDoc)
459
539
  return res.status(404).json({ message: 'File not found' });
460
- if (connAdapter.deleteFile)
461
- await connAdapter.deleteFile(fileDoc.storageKey);
540
+ await deleteAsset(fileDoc, connAdapter);
462
541
  await collection.deleteOne(connFilter(projectId, connId, project, { fileId }));
463
542
  res.json({ message: 'File deleted successfully' });
464
543
  }
@@ -731,11 +810,11 @@ function storageRouter(config) {
731
810
  });
732
811
  return router;
733
812
  }
734
- async function resolveFileUrl(file, adapter) {
813
+ async function resolveFileUrl(file, adapter, expiresIn) {
735
814
  let url = null;
736
815
  if (adapter.getPresignedGetUrl) {
737
816
  try {
738
- url = await adapter.getPresignedGetUrl(file.storageKey);
817
+ url = await adapter.getPresignedGetUrl(file.storageKey, expiresIn);
739
818
  }
740
819
  catch { /* leave null */ }
741
820
  }
@@ -751,3 +830,15 @@ async function resolveFileUrl(file, adapter) {
751
830
  url,
752
831
  };
753
832
  }
833
+ // When a signed URL stops working, for a client to know (only when it's short-lived).
834
+ const expiry = (expiresIn) => expiresIn ? { expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString() } : {};
835
+ // An asset's files (every version), its versions and its deliverables. Its record is the
836
+ // caller's to delete.
837
+ async function deleteAsset(fileDoc, adapter) {
838
+ const keys = await (0, assetVersions_1.versionStorageKeys)(fileDoc.projectId, fileDoc.fileId, fileDoc.storageKey);
839
+ if (adapter.deleteFile)
840
+ for (const key of keys)
841
+ await adapter.deleteFile(key);
842
+ await (0, assetVersions_1.deleteVersions)(fileDoc.projectId, fileDoc.fileId);
843
+ await (0, deliverables_1.deleteDeliverablesOf)(fileDoc.projectId, { assetId: fileDoc.fileId });
844
+ }
@@ -62,11 +62,15 @@ const RULES = [
62
62
  rule('read', `${P}/connections/storage/[^/]+/(files|folders)(/.+)?`, 'assets:read'),
63
63
  rule(['POST', 'PATCH'], `${P}/connections/storage/[^/]+/(files|folders)(/.+)?`, 'assets:write'),
64
64
  rule('delete', `${P}/connections/storage/[^/]+/(files|folders)/[^/]+`, 'assets:delete'),
65
- rule('read', `${P}/assets/[^/]+/(metadata|versions|qc-report|dependency-graph)`, 'assets:read'),
65
+ rule('read', `${P}/assets/[^/]+/(metadata|versions|qc-report|dependency-graph|download-url)`, 'assets:read'),
66
66
  rule(['PATCH'], `${P}/assets/[^/]+/metadata`, 'assets:write'),
67
67
  rule(['POST'], `${P}/assets/[^/]+/qc-report`, 'assets:write'),
68
68
  rule('read', `${P}/(reviews(/[^/]+)?|assets/[^/]+/versions/[^/]+/annotations)`, 'assets:read'),
69
69
  rule(['POST', 'PATCH'], `${P}/(reviews(/[^/]+/decision)?|assets/[^/]+/versions/[^/]+/annotations(/[^/]+/resolve)?)`, 'assets:write'),
70
+ // Deliverables: an asset (or a version) delivered for a shot or an export.
71
+ rule('read', `${P}/deliverables`, 'assets:read'),
72
+ rule(['POST'], `${P}/deliverables`, 'assets:write'),
73
+ rule('delete', `${P}/deliverables/[^/]+`, 'assets:write'),
70
74
  // Rights.
71
75
  rule('read', `${P}/assets/[^/]+/rights`, 'rights:read'),
72
76
  rule(['PUT'], `${P}/assets/[^/]+/rights`, 'rights:write'),
@@ -0,0 +1,48 @@
1
+ export interface AssetVersion {
2
+ versionId: string;
3
+ assetId: string;
4
+ projectId: string;
5
+ version: number;
6
+ storageKey: string;
7
+ contentType: string | null;
8
+ fileName: string | null;
9
+ createdBy: string | null;
10
+ createdAt: Date;
11
+ }
12
+ export declare const versionsCollection: () => import("mongodb").Collection<import("bson").Document> | null;
13
+ /** The key an asset's version `n` is archived in, next to the asset's own key. */
14
+ export declare const archivedVersionKey: (assetKey: string, version: number) => string;
15
+ /** Records the first version of an asset just made (or one made before versions were kept). */
16
+ export declare function recordFirstVersion(file: {
17
+ fileId: string;
18
+ projectId: string;
19
+ storageKey: string;
20
+ contentType?: string;
21
+ displayName?: string;
22
+ uploadedBy?: string;
23
+ createdAt?: Date;
24
+ }): Promise<void>;
25
+ /**
26
+ * Makes room for a new version of an asset, to be uploaded to its own key: its current version
27
+ * is copied to an archive key first (`copyFile`) and recorded there. If the copy fails nothing
28
+ * changes, and it throws: the current version is never lost.
29
+ */
30
+ export declare function archiveCurrentVersion(file: {
31
+ fileId: string;
32
+ projectId: string;
33
+ storageKey: string;
34
+ contentType?: string;
35
+ displayName?: string;
36
+ uploadedBy?: string;
37
+ createdAt?: Date;
38
+ }, upload: {
39
+ fileName: string;
40
+ contentType: string;
41
+ userId: string;
42
+ }, copyFile: (from: string, to: string) => Promise<void>): Promise<AssetVersion>;
43
+ /** One of an asset's versions, or null. */
44
+ export declare function findVersion(projectId: string, assetId: string, versionId: string): Promise<AssetVersion | null>;
45
+ /** Every key an asset's versions are stored in (its current one included). */
46
+ export declare function versionStorageKeys(projectId: string, assetId: string, currentKey?: string): Promise<string[]>;
47
+ /** Forgets an asset's versions (once their files are deleted). */
48
+ export declare function deleteVersions(projectId: string, assetId: string): Promise<void>;
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.archivedVersionKey = exports.versionsCollection = void 0;
7
+ exports.recordFirstVersion = recordFirstVersion;
8
+ exports.archiveCurrentVersion = archiveCurrentVersion;
9
+ exports.findVersion = findVersion;
10
+ exports.versionStorageKeys = versionStorageKeys;
11
+ exports.deleteVersions = deleteVersions;
12
+ const crypto_1 = __importDefault(require("crypto"));
13
+ const mongodb_1 = require("mongodb");
14
+ const nosqlDb_1 = require("../utils/nosqlDb");
15
+ const versionsCollection = () => (0, nosqlDb_1.nosqlDb)()?.collection('asset_versions') ?? null;
16
+ exports.versionsCollection = versionsCollection;
17
+ /** The key an asset's version `n` is archived in, next to the asset's own key. */
18
+ const archivedVersionKey = (assetKey, version) => {
19
+ const extension = /(\.[^./]*)$/.exec(assetKey)?.[1] ?? '';
20
+ return `${assetKey.slice(0, assetKey.length - extension.length)}.v${version}${extension}`;
21
+ };
22
+ exports.archivedVersionKey = archivedVersionKey;
23
+ /** Records the first version of an asset just made (or one made before versions were kept). */
24
+ async function recordFirstVersion(file) {
25
+ const col = (0, exports.versionsCollection)();
26
+ if (!col)
27
+ return;
28
+ if (await col.findOne({ assetId: file.fileId, projectId: file.projectId }))
29
+ return;
30
+ await col.insertOne({
31
+ _id: new mongodb_1.ObjectId(),
32
+ versionId: crypto_1.default.randomUUID(),
33
+ assetId: file.fileId,
34
+ projectId: file.projectId,
35
+ version: 1,
36
+ storageKey: file.storageKey,
37
+ contentType: file.contentType ?? null,
38
+ fileName: file.displayName ?? null,
39
+ createdBy: file.uploadedBy ?? null,
40
+ createdAt: file.createdAt ?? new Date(),
41
+ });
42
+ }
43
+ /**
44
+ * Makes room for a new version of an asset, to be uploaded to its own key: its current version
45
+ * is copied to an archive key first (`copyFile`) and recorded there. If the copy fails nothing
46
+ * changes, and it throws: the current version is never lost.
47
+ */
48
+ async function archiveCurrentVersion(file, upload, copyFile) {
49
+ const col = (0, exports.versionsCollection)();
50
+ if (!col)
51
+ throw new Error('Database not available');
52
+ await recordFirstVersion(file);
53
+ const [current] = await col.find({ assetId: file.fileId, projectId: file.projectId }).sort({ version: -1 }).limit(1).toArray();
54
+ const currentVersion = current?.version ?? 1;
55
+ const archiveKey = (0, exports.archivedVersionKey)(file.storageKey, currentVersion);
56
+ await copyFile(file.storageKey, archiveKey);
57
+ await col.updateOne({ assetId: file.fileId, projectId: file.projectId, version: currentVersion }, { $set: { storageKey: archiveKey } });
58
+ const record = {
59
+ versionId: crypto_1.default.randomUUID(),
60
+ assetId: file.fileId,
61
+ projectId: file.projectId,
62
+ version: currentVersion + 1,
63
+ storageKey: file.storageKey,
64
+ contentType: upload.contentType,
65
+ fileName: upload.fileName,
66
+ createdBy: upload.userId,
67
+ createdAt: new Date(),
68
+ };
69
+ await col.insertOne({ _id: new mongodb_1.ObjectId(), ...record });
70
+ return record;
71
+ }
72
+ /** One of an asset's versions, or null. */
73
+ async function findVersion(projectId, assetId, versionId) {
74
+ return (await (0, exports.versionsCollection)()?.findOne({ projectId, assetId, versionId })) ?? null;
75
+ }
76
+ /** Every key an asset's versions are stored in (its current one included). */
77
+ async function versionStorageKeys(projectId, assetId, currentKey) {
78
+ const versions = ((await (0, exports.versionsCollection)()?.find({ projectId, assetId }).toArray()) ?? []);
79
+ return [...new Set([...(currentKey ? [currentKey] : []), ...versions.map(v => v.storageKey)])];
80
+ }
81
+ /** Forgets an asset's versions (once their files are deleted). */
82
+ async function deleteVersions(projectId, assetId) {
83
+ await (0, exports.versionsCollection)()?.deleteMany({ projectId, assetId });
84
+ }
@@ -0,0 +1,26 @@
1
+ export interface Deliverable {
2
+ id: string;
3
+ projectId: string;
4
+ assetId: string;
5
+ versionId: string | null;
6
+ shotId: string | null;
7
+ exportId: string | null;
8
+ note: string | null;
9
+ createdBy: string;
10
+ createdAt: Date;
11
+ }
12
+ export declare const deliverablesCollection: () => import("mongodb").Collection<import("bson").Document> | null;
13
+ export declare function createDeliverable(fields: Omit<Deliverable, 'id' | 'createdAt'>): Promise<Deliverable>;
14
+ /** A project's deliverables, of one shot or export if given, newest first. */
15
+ export declare function listDeliverables(projectId: string, of?: {
16
+ shotId?: string;
17
+ exportId?: string;
18
+ }): Promise<Deliverable[]>;
19
+ /** Removes a deliverable (never its asset). Whether there was one. */
20
+ export declare function deleteDeliverable(projectId: string, id: string): Promise<boolean>;
21
+ /** Forgets the deliverables of what's gone: an asset, shots, or an export. */
22
+ export declare function deleteDeliverablesOf(projectId: string, of: {
23
+ assetId?: string;
24
+ shotIds?: string[];
25
+ exportId?: string;
26
+ }): Promise<void>;
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.deliverablesCollection = void 0;
7
+ exports.createDeliverable = createDeliverable;
8
+ exports.listDeliverables = listDeliverables;
9
+ exports.deleteDeliverable = deleteDeliverable;
10
+ exports.deleteDeliverablesOf = deleteDeliverablesOf;
11
+ const crypto_1 = __importDefault(require("crypto"));
12
+ const mongodb_1 = require("mongodb");
13
+ const nosqlDb_1 = require("../utils/nosqlDb");
14
+ const deliverablesCollection = () => (0, nosqlDb_1.nosqlDb)()?.collection('deliverables') ?? null;
15
+ exports.deliverablesCollection = deliverablesCollection;
16
+ const strip = ({ _id, ...deliverable }) => deliverable;
17
+ async function createDeliverable(fields) {
18
+ const col = (0, exports.deliverablesCollection)();
19
+ if (!col)
20
+ throw new Error('Database not available');
21
+ const deliverable = { id: crypto_1.default.randomUUID(), ...fields, createdAt: new Date() };
22
+ await col.insertOne({ _id: new mongodb_1.ObjectId(), ...deliverable });
23
+ return deliverable;
24
+ }
25
+ /** A project's deliverables, of one shot or export if given, newest first. */
26
+ async function listDeliverables(projectId, of = {}) {
27
+ const col = (0, exports.deliverablesCollection)();
28
+ if (!col)
29
+ throw new Error('Database not available');
30
+ const filter = { projectId };
31
+ if (of.shotId)
32
+ filter.shotId = of.shotId;
33
+ if (of.exportId)
34
+ filter.exportId = of.exportId;
35
+ return (await col.find(filter).sort({ createdAt: -1 }).toArray()).map(strip);
36
+ }
37
+ /** Removes a deliverable (never its asset). Whether there was one. */
38
+ async function deleteDeliverable(projectId, id) {
39
+ const col = (0, exports.deliverablesCollection)();
40
+ if (!col)
41
+ throw new Error('Database not available');
42
+ return (await col.deleteOne({ projectId, id })).deletedCount > 0;
43
+ }
44
+ /** Forgets the deliverables of what's gone: an asset, shots, or an export. */
45
+ async function deleteDeliverablesOf(projectId, of) {
46
+ const col = (0, exports.deliverablesCollection)();
47
+ if (!col)
48
+ return;
49
+ if (of.assetId)
50
+ await col.deleteMany({ projectId, assetId: of.assetId });
51
+ if (of.shotIds?.length)
52
+ await col.deleteMany({ projectId, shotId: { $in: of.shotIds } });
53
+ if (of.exportId)
54
+ await col.deleteMany({ projectId, exportId: of.exportId });
55
+ }
@@ -69,7 +69,9 @@ async function runIngestJob(jobId, fileId, projectId) {
69
69
  }
70
70
  (0, jobQueue_1.updateJob)(jobId, { stage: 'creating-version', progress: 70 });
71
71
  const versionsCol = getVersionsCollection();
72
- if (versionsCol) {
72
+ // Its upload already recorded the version it's in; only an asset from before versions
73
+ // were kept gets one here.
74
+ if (versionsCol && !(await versionsCol.findOne({ assetId: fileId, projectId, storageKey: fileDoc.storageKey }))) {
73
75
  const existing = await versionsCol.countDocuments({ assetId: fileId, projectId });
74
76
  await versionsCol.insertOne({
75
77
  _id: new mongodb_1.ObjectId(),
@@ -0,0 +1,2 @@
1
+ /** A deleted project's sequences, shots, tasks and deliverables: only that project's. */
2
+ export declare function deleteProductionData(projectId: string): Promise<void>;
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.deleteProductionData = deleteProductionData;
4
+ const nosqlDb_1 = require("../utils/nosqlDb");
5
+ /** A deleted project's sequences, shots, tasks and deliverables: only that project's. */
6
+ async function deleteProductionData(projectId) {
7
+ const db = (0, nosqlDb_1.nosqlDb)();
8
+ if (!db || !projectId)
9
+ return;
10
+ for (const name of ['production_tasks', 'production_shots', 'production_sequences', 'deliverables']) {
11
+ await db.collection(name).deleteMany({ projectId });
12
+ }
13
+ }
@@ -0,0 +1,16 @@
1
+ export declare const PRODUCTION_STATUSES: readonly ["todo", "in_progress", "review", "done"];
2
+ export declare const TASK_PRIORITIES: readonly ["low", "medium", "high"];
3
+ type Field = 'name' | 'description' | 'notes' | 'status' | 'priority' | 'assignees' | 'assignee' | 'dueDate' | 'order' | 'assetId' | 'exportId';
4
+ export type FieldsResult = {
5
+ values: Record<string, any>;
6
+ } | {
7
+ error: string;
8
+ };
9
+ /**
10
+ * The fields of `body` that `allowed` lists, checked and ready to save (`assignees` and
11
+ * `assignee` are saved as `assignedTo`). With `creating`, a name is required.
12
+ */
13
+ export declare function checkProductionFields(body: any, allowed: readonly Field[], project: any, { creating }?: {
14
+ creating?: boolean | undefined;
15
+ }): FieldsResult;
16
+ export {};
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TASK_PRIORITIES = exports.PRODUCTION_STATUSES = void 0;
4
+ exports.checkProductionFields = checkProductionFields;
5
+ const auth_1 = require("../utils/auth");
6
+ // What a production sequence, shot or task may be given, checked before it's saved: a name
7
+ // that isn't blank, a known status and priority, assignees who are members of the project, a
8
+ // due date that is a date.
9
+ exports.PRODUCTION_STATUSES = ['todo', 'in_progress', 'review', 'done'];
10
+ exports.TASK_PRIORITIES = ['low', 'medium', 'high'];
11
+ const MAX_NAME = 200;
12
+ const MAX_TEXT = 5000;
13
+ /**
14
+ * The fields of `body` that `allowed` lists, checked and ready to save (`assignees` and
15
+ * `assignee` are saved as `assignedTo`). With `creating`, a name is required.
16
+ */
17
+ function checkProductionFields(body, allowed, project, { creating = false } = {}) {
18
+ const values = {};
19
+ const given = (field) => body?.[field] !== undefined;
20
+ const has = (field) => allowed.includes(field);
21
+ if (creating && !given('name'))
22
+ return { error: 'name is required' };
23
+ if (has('name') && given('name')) {
24
+ if (typeof body.name !== 'string' || !body.name.trim())
25
+ return { error: 'name must not be blank' };
26
+ if (body.name.trim().length > MAX_NAME)
27
+ return { error: `name must be at most ${MAX_NAME} characters` };
28
+ values.name = body.name.trim();
29
+ }
30
+ for (const field of ['description', 'notes']) {
31
+ if (!has(field) || !given(field))
32
+ continue;
33
+ const value = body[field];
34
+ if (value !== null && (typeof value !== 'string' || value.length > MAX_TEXT)) {
35
+ return { error: `${field} must be text of at most ${MAX_TEXT} characters, or null` };
36
+ }
37
+ values[field] = value === null || !value.trim() ? null : value;
38
+ }
39
+ if (has('status') && given('status')) {
40
+ if (!exports.PRODUCTION_STATUSES.includes(body.status))
41
+ return { error: `status must be one of ${exports.PRODUCTION_STATUSES.join(', ')}` };
42
+ values.status = body.status;
43
+ }
44
+ if (has('priority') && given('priority')) {
45
+ if (!exports.TASK_PRIORITIES.includes(body.priority))
46
+ return { error: `priority must be one of ${exports.TASK_PRIORITIES.join(', ')}` };
47
+ values.priority = body.priority;
48
+ }
49
+ const isMember = (id) => typeof id === 'string' && !!(0, auth_1.activeMemberOf)(project, id);
50
+ if (has('assignees') && given('assignedTo')) {
51
+ const ids = body.assignedTo;
52
+ if (!Array.isArray(ids) || !ids.every(isMember))
53
+ return { error: 'assignedTo must be a list of members of the project' };
54
+ values.assignedTo = [...new Set(ids)];
55
+ }
56
+ if (has('assignee') && given('assignedTo')) {
57
+ if (body.assignedTo !== null && !isMember(body.assignedTo))
58
+ return { error: 'assignedTo must be a member of the project, or null' };
59
+ values.assignedTo = body.assignedTo;
60
+ }
61
+ if (has('dueDate') && given('dueDate')) {
62
+ const date = body.dueDate === null || body.dueDate === '' ? null : new Date(body.dueDate);
63
+ if (date && Number.isNaN(date.getTime()))
64
+ return { error: 'dueDate must be a date, or null' };
65
+ values.dueDate = date;
66
+ }
67
+ if (has('order') && given('order')) {
68
+ if (typeof body.order !== 'number' || !Number.isFinite(body.order))
69
+ return { error: 'order must be a number' };
70
+ values.order = body.order;
71
+ }
72
+ for (const field of ['assetId', 'exportId']) {
73
+ if (!has(field) || !given(field))
74
+ continue;
75
+ if (body[field] !== null && typeof body[field] !== 'string')
76
+ return { error: `${field} must be an id, or null` };
77
+ values[field] = body[field] || null;
78
+ }
79
+ return { values };
80
+ }
@@ -0,0 +1,6 @@
1
+ import { Auth, StreamByConfig } from '../types';
2
+ export declare const DEFAULT_TOKEN_SIGNED_URL_TTL: {
3
+ upload: number;
4
+ download: number;
5
+ };
6
+ export declare const signedUrlTtl: (auth: Auth | undefined, config: StreamByConfig, kind: "upload" | "download") => number | undefined;
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.signedUrlTtl = exports.DEFAULT_TOKEN_SIGNED_URL_TTL = void 0;
4
+ // How long a signed storage URL works. A request made with an API token (an agent, the MCP
5
+ // server) gets short-lived ones; a browser session, the adapter's own (an hour to upload, a
6
+ // day to read).
7
+ exports.DEFAULT_TOKEN_SIGNED_URL_TTL = { upload: 15 * 60, download: 5 * 60 };
8
+ const signedUrlTtl = (auth, config, kind) => auth?.apiToken ? config.mcp?.signedUrlTtl?.[kind] ?? exports.DEFAULT_TOKEN_SIGNED_URL_TTL[kind] : undefined;
9
+ exports.signedUrlTtl = signedUrlTtl;