@agent-native/creative-context 0.3.0 → 0.5.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.
Files changed (185) hide show
  1. package/README.md +51 -9
  2. package/agent-native.package.json +4 -0
  3. package/dist/actions/index.d.ts.map +1 -1
  4. package/dist/actions/index.js +8 -0
  5. package/dist/actions/index.js.map +1 -1
  6. package/dist/actions/list-context-memberships.d.ts +11 -0
  7. package/dist/actions/list-context-memberships.d.ts.map +1 -0
  8. package/dist/actions/list-context-memberships.js +47 -0
  9. package/dist/actions/list-context-memberships.js.map +1 -0
  10. package/dist/actions/list-creative-contexts.d.ts +12 -0
  11. package/dist/actions/list-creative-contexts.d.ts.map +1 -0
  12. package/dist/actions/list-creative-contexts.js +28 -0
  13. package/dist/actions/list-creative-contexts.js.map +1 -0
  14. package/dist/actions/manage-context-membership.d.ts +64 -0
  15. package/dist/actions/manage-context-membership.d.ts.map +1 -0
  16. package/dist/actions/manage-context-membership.js +70 -0
  17. package/dist/actions/manage-context-membership.js.map +1 -0
  18. package/dist/actions/manage-context-pack.d.ts +3 -0
  19. package/dist/actions/manage-context-pack.js +6 -0
  20. package/dist/actions/manage-context-pack.js.map +1 -1
  21. package/dist/actions/manage-context-source.js +4 -11
  22. package/dist/actions/manage-context-source.js.map +1 -1
  23. package/dist/actions/manage-creative-context.d.ts +28 -0
  24. package/dist/actions/manage-creative-context.d.ts.map +1 -0
  25. package/dist/actions/manage-creative-context.js +65 -0
  26. package/dist/actions/manage-creative-context.js.map +1 -0
  27. package/dist/actions/process-context-purge.d.ts +16 -1
  28. package/dist/actions/process-context-purge.js +2 -1
  29. package/dist/actions/process-context-purge.js.map +1 -1
  30. package/dist/actions/search-creative-context.d.ts +1 -0
  31. package/dist/actions/search-creative-context.js +2 -1
  32. package/dist/actions/search-creative-context.js.map +1 -1
  33. package/dist/client/CreativeContextChip.d.ts +5 -1
  34. package/dist/client/CreativeContextChip.d.ts.map +1 -1
  35. package/dist/client/CreativeContextChip.js +36 -7
  36. package/dist/client/CreativeContextChip.js.map +1 -1
  37. package/dist/client/CreativeContextPanel.d.ts.map +1 -1
  38. package/dist/client/CreativeContextPanel.js +406 -89
  39. package/dist/client/CreativeContextPanel.js.map +1 -1
  40. package/dist/client/CreativeContextShareTab.d.ts +56 -0
  41. package/dist/client/CreativeContextShareTab.d.ts.map +1 -0
  42. package/dist/client/CreativeContextShareTab.js +213 -0
  43. package/dist/client/CreativeContextShareTab.js.map +1 -0
  44. package/dist/client/actions.d.ts +210 -0
  45. package/dist/client/actions.d.ts.map +1 -1
  46. package/dist/client/actions.js +318 -0
  47. package/dist/client/actions.js.map +1 -1
  48. package/dist/client/application-state.d.ts +2 -0
  49. package/dist/client/application-state.d.ts.map +1 -1
  50. package/dist/client/application-state.js +25 -2
  51. package/dist/client/application-state.js.map +1 -1
  52. package/dist/client/index.d.ts +4 -3
  53. package/dist/client/index.d.ts.map +1 -1
  54. package/dist/client/index.js +4 -3
  55. package/dist/client/index.js.map +1 -1
  56. package/dist/client/messages.d.ts +1 -1
  57. package/dist/client/messages.d.ts.map +1 -1
  58. package/dist/client/messages.js +77 -0
  59. package/dist/client/messages.js.map +1 -1
  60. package/dist/connectors/types.d.ts +2 -2
  61. package/dist/connectors/types.d.ts.map +1 -1
  62. package/dist/schema/index.d.ts +1592 -0
  63. package/dist/schema/index.d.ts.map +1 -1
  64. package/dist/schema/index.js +95 -0
  65. package/dist/schema/index.js.map +1 -1
  66. package/dist/schema/migrations.d.ts.map +1 -1
  67. package/dist/schema/migrations.js +80 -0
  68. package/dist/schema/migrations.js.map +1 -1
  69. package/dist/server/generation-artifact-access.js +2 -0
  70. package/dist/server/generation-artifact-access.js.map +1 -1
  71. package/dist/server/generation-context.d.ts +12 -4
  72. package/dist/server/generation-context.d.ts.map +1 -1
  73. package/dist/server/generation-context.js +129 -7
  74. package/dist/server/generation-context.js.map +1 -1
  75. package/dist/server/index.d.ts +4 -0
  76. package/dist/server/index.d.ts.map +1 -1
  77. package/dist/server/index.js +16 -0
  78. package/dist/server/index.js.map +1 -1
  79. package/dist/server/isolated-a2a.d.ts +3 -0
  80. package/dist/server/isolated-a2a.d.ts.map +1 -1
  81. package/dist/server/isolated-a2a.js +3 -1
  82. package/dist/server/isolated-a2a.js.map +1 -1
  83. package/dist/server/media.d.ts +1 -1
  84. package/dist/server/media.d.ts.map +1 -1
  85. package/dist/server/media.js +24 -9
  86. package/dist/server/media.js.map +1 -1
  87. package/dist/server/native-resource-capture.d.ts +78 -0
  88. package/dist/server/native-resource-capture.d.ts.map +1 -0
  89. package/dist/server/native-resource-capture.js +94 -0
  90. package/dist/server/native-resource-capture.js.map +1 -0
  91. package/dist/server/public-serialization.js +1 -1
  92. package/dist/server/public-serialization.js.map +1 -1
  93. package/dist/server/retrieval.d.ts +1 -0
  94. package/dist/server/retrieval.d.ts.map +1 -1
  95. package/dist/server/retrieval.js +2 -0
  96. package/dist/server/retrieval.js.map +1 -1
  97. package/dist/server/safe-native-preview.d.ts +15 -0
  98. package/dist/server/safe-native-preview.d.ts.map +1 -0
  99. package/dist/server/safe-native-preview.js +149 -0
  100. package/dist/server/safe-native-preview.js.map +1 -0
  101. package/dist/store/brand.d.ts.map +1 -1
  102. package/dist/store/brand.js +2 -16
  103. package/dist/store/brand.js.map +1 -1
  104. package/dist/store/content.d.ts +1 -0
  105. package/dist/store/content.d.ts.map +1 -1
  106. package/dist/store/content.js +84 -20
  107. package/dist/store/content.js.map +1 -1
  108. package/dist/store/contexts.d.ts +216 -0
  109. package/dist/store/contexts.d.ts.map +1 -0
  110. package/dist/store/contexts.js +1341 -0
  111. package/dist/store/contexts.js.map +1 -0
  112. package/dist/store/index.d.ts +1 -0
  113. package/dist/store/index.d.ts.map +1 -1
  114. package/dist/store/index.js +1 -0
  115. package/dist/store/index.js.map +1 -1
  116. package/dist/store/packs.d.ts +3 -0
  117. package/dist/store/packs.d.ts.map +1 -1
  118. package/dist/store/packs.js +126 -9
  119. package/dist/store/packs.js.map +1 -1
  120. package/dist/store/purge.d.ts.map +1 -1
  121. package/dist/store/purge.js +103 -6
  122. package/dist/store/purge.js.map +1 -1
  123. package/dist/store/sources.d.ts.map +1 -1
  124. package/dist/store/sources.js +2 -15
  125. package/dist/store/sources.js.map +1 -1
  126. package/dist/store/suggestions.d.ts.map +1 -1
  127. package/dist/store/suggestions.js +3 -1
  128. package/dist/store/suggestions.js.map +1 -1
  129. package/dist/types.d.ts +77 -0
  130. package/dist/types.d.ts.map +1 -1
  131. package/dist/types.js +9 -1
  132. package/dist/types.js.map +1 -1
  133. package/docs/skills/creative-context/SKILL.md +13 -5
  134. package/package.json +3 -3
  135. package/src/actions/index.ts +8 -0
  136. package/src/actions/job-public-serialization.spec.ts +21 -0
  137. package/src/actions/list-context-memberships.ts +56 -0
  138. package/src/actions/list-creative-contexts.spec.ts +38 -0
  139. package/src/actions/list-creative-contexts.ts +32 -0
  140. package/src/actions/manage-context-membership.ts +80 -0
  141. package/src/actions/manage-context-pack.ts +6 -0
  142. package/src/actions/manage-context-source.ts +8 -11
  143. package/src/actions/manage-creative-context.ts +73 -0
  144. package/src/actions/process-context-purge.ts +4 -1
  145. package/src/actions/search-creative-context.ts +2 -1
  146. package/src/client/CreativeContextChip.spec.ts +32 -0
  147. package/src/client/CreativeContextChip.tsx +83 -14
  148. package/src/client/CreativeContextPanel.tsx +1947 -885
  149. package/src/client/CreativeContextShareTab.spec.ts +70 -0
  150. package/src/client/CreativeContextShareTab.tsx +657 -0
  151. package/src/client/actions.spec.ts +177 -0
  152. package/src/client/actions.ts +566 -0
  153. package/src/client/application-state.spec.ts +5 -0
  154. package/src/client/application-state.ts +30 -2
  155. package/src/client/index.ts +33 -0
  156. package/src/client/messages.ts +95 -0
  157. package/src/connectors/types.ts +6 -8
  158. package/src/eval/library-flow.acceptance.spec.tsx +1 -0
  159. package/src/schema/index.ts +116 -0
  160. package/src/schema/migrations.ts +80 -0
  161. package/src/server/generation-artifact-access.ts +1 -0
  162. package/src/server/generation-context-a2a.spec.ts +133 -0
  163. package/src/server/generation-context.ts +153 -7
  164. package/src/server/index.spec.ts +15 -0
  165. package/src/server/index.ts +16 -0
  166. package/src/server/isolated-a2a.ts +4 -1
  167. package/src/server/media.spec.ts +30 -2
  168. package/src/server/media.ts +31 -12
  169. package/src/server/native-resource-capture.spec.ts +121 -0
  170. package/src/server/native-resource-capture.ts +198 -0
  171. package/src/server/public-serialization.spec.ts +1 -0
  172. package/src/server/public-serialization.ts +1 -1
  173. package/src/server/retrieval.ts +3 -0
  174. package/src/server/safe-native-preview.spec.ts +29 -0
  175. package/src/server/safe-native-preview.ts +200 -0
  176. package/src/store/access.integration.spec.ts +1116 -0
  177. package/src/store/brand.ts +2 -21
  178. package/src/store/content.ts +217 -17
  179. package/src/store/contexts.ts +1826 -0
  180. package/src/store/index.ts +1 -0
  181. package/src/store/packs.ts +279 -24
  182. package/src/store/purge.ts +139 -6
  183. package/src/store/sources.ts +2 -18
  184. package/src/store/suggestions.ts +3 -1
  185. package/src/types.ts +95 -0
@@ -0,0 +1,1826 @@
1
+ import {
2
+ assertCurrentRequestUserIsOrgAdmin,
3
+ currentRequestUserIsOrgAdmin,
4
+ } from "@agent-native/core/server";
5
+ import {
6
+ accessFilter,
7
+ assertAccess,
8
+ resolveAccess,
9
+ } from "@agent-native/core/sharing";
10
+ import {
11
+ and,
12
+ asc,
13
+ count,
14
+ eq,
15
+ gt,
16
+ inArray,
17
+ isNotNull,
18
+ isNull,
19
+ or,
20
+ } from "drizzle-orm";
21
+
22
+ import { creativeContextMediaUrl } from "../media-url.js";
23
+ import { getCreativeContext } from "../server/context.js";
24
+ import {
25
+ captureNativeCreativeResource,
26
+ parseNativeCreativeArtifactKey,
27
+ resolveNativeCreativeResourceUpdateStatuses,
28
+ type NativeCreativeResourceRef,
29
+ } from "../server/native-resource-capture.js";
30
+ import { sanitizePublicMetadata } from "../server/public-serialization.js";
31
+ import type {
32
+ CreativeContextApprovalPolicy,
33
+ CreativeContextMembership,
34
+ CreativeContextSubmissionSummary,
35
+ CreativeContextSummary,
36
+ NormalizedContextItem,
37
+ } from "../types.js";
38
+ import { getCreativeContextItem } from "./content.js";
39
+ import {
40
+ newId,
41
+ nowIso,
42
+ parseJson,
43
+ requireActor,
44
+ stringifyJson,
45
+ } from "./helpers.js";
46
+
47
+ type Rank = "canonical" | "exemplar" | "normal";
48
+
49
+ function defaultContextScopeKey(actor: {
50
+ ownerEmail: string;
51
+ orgId?: string | null;
52
+ }) {
53
+ return actor.orgId ? `org:${actor.orgId}` : `user:${actor.ownerEmail}`;
54
+ }
55
+
56
+ function mapContext(
57
+ row: any,
58
+ memberCount = 0,
59
+ role: "viewer" | "editor" | "admin" | "owner" = "viewer",
60
+ ): CreativeContextSummary {
61
+ return {
62
+ id: row.id,
63
+ name: row.name,
64
+ description: row.description ?? null,
65
+ kind: row.kind,
66
+ brandProfileId: row.brandProfileId ?? null,
67
+ approvalPolicy: row.approvalPolicy,
68
+ archivedAt: row.archivedAt ?? null,
69
+ visibility: row.visibility,
70
+ memberCount,
71
+ createdAt: row.createdAt,
72
+ updatedAt: row.updatedAt,
73
+ access: {
74
+ role,
75
+ canSubmit: role !== "viewer",
76
+ canReview: role === "owner" || role === "admin",
77
+ canAdmin: role === "owner" || role === "admin",
78
+ },
79
+ };
80
+ }
81
+
82
+ function mapMembership(row: any): CreativeContextMembership {
83
+ return {
84
+ id: row.id,
85
+ contextId: row.contextId,
86
+ artifactKey: row.artifactKey,
87
+ publishedItemId: row.publishedItemId ?? null,
88
+ publishedItemVersionId: row.publishedItemVersionId ?? null,
89
+ pendingSubmissionId: row.pendingSubmissionId ?? null,
90
+ rank: row.rank,
91
+ purpose: row.purpose ?? null,
92
+ status: row.status,
93
+ createdAt: row.createdAt,
94
+ updatedAt: row.updatedAt,
95
+ };
96
+ }
97
+
98
+ function mapSubmission(row: any): CreativeContextSubmissionSummary {
99
+ return {
100
+ id: row.id,
101
+ contextId: row.contextId,
102
+ membershipId: row.membershipId,
103
+ artifactKey: row.artifactKey,
104
+ publishedItemId: row.publishedItemId ?? null,
105
+ publishedItemVersionId: row.publishedItemVersionId ?? null,
106
+ note: row.note ?? null,
107
+ status: row.status,
108
+ submittedBy: row.submittedBy,
109
+ reviewedBy: row.reviewedBy ?? null,
110
+ reviewNote: row.reviewNote ?? null,
111
+ createdAt: row.createdAt,
112
+ reviewedAt: row.reviewedAt ?? null,
113
+ };
114
+ }
115
+
116
+ async function appendAudit(
117
+ tx: any,
118
+ contextId: string,
119
+ operation: string,
120
+ details: Record<string, unknown>,
121
+ ) {
122
+ const { schema } = getCreativeContext();
123
+ const actor = requireActor();
124
+ await tx.insert(schema.creativeContextAudit).values({
125
+ id: newId("cca"),
126
+ contextId,
127
+ operation,
128
+ actorEmail: actor.ownerEmail,
129
+ details: stringifyJson(details),
130
+ createdAt: nowIso(),
131
+ ownerEmail: actor.ownerEmail,
132
+ orgId: actor.orgId,
133
+ });
134
+ }
135
+
136
+ async function assertContextRole(
137
+ contextId: string,
138
+ role: "viewer" | "editor" | "admin",
139
+ ) {
140
+ return assertAccess("creative-context", contextId, role, undefined, {
141
+ skipResourceBody: true,
142
+ });
143
+ }
144
+
145
+ async function requireReviewer(contextId: string) {
146
+ await assertContextRole(contextId, "admin");
147
+ }
148
+
149
+ function normalizedFromDetail(
150
+ detail: NonNullable<Awaited<ReturnType<typeof getCreativeContextItem>>>,
151
+ ): NormalizedContextItem {
152
+ return {
153
+ externalId: detail.item.externalId,
154
+ kind: detail.item.kind,
155
+ title: detail.version.title,
156
+ canonicalUrl: detail.item.canonicalUrl ?? undefined,
157
+ mimeType: detail.version.mimeType ?? undefined,
158
+ content: detail.version.content,
159
+ summary: detail.version.summary ?? undefined,
160
+ contentHash: detail.version.contentHash,
161
+ sourceModifiedAt: detail.version.sourceModifiedAt ?? undefined,
162
+ sourceVersion: detail.version.sourceVersion ?? undefined,
163
+ rawSnapshotBlobRef: detail.version.rawSnapshotBlobRef ?? undefined,
164
+ parseStatus: detail.version.parseStatus,
165
+ parseError: detail.version.parseError ?? undefined,
166
+ upstreamAccess: detail.item.upstreamAccess,
167
+ curationStatus: "included",
168
+ curationRank:
169
+ detail.item.curationRank === "ignored"
170
+ ? "normal"
171
+ : detail.item.curationRank,
172
+ tags: detail.item.tags,
173
+ colors: detail.item.colors,
174
+ provenance: detail.item.provenance,
175
+ thumbnailBlobRef: detail.item.thumbnailBlobRef ?? undefined,
176
+ // Keep compiler/reassembly manifests while stripping capability-like metadata.
177
+ metadata: (sanitizePublicMetadata(detail.version.metadata) ?? {}) as Record<
178
+ string,
179
+ unknown
180
+ >,
181
+ chunks: detail.chunks.map((chunk) => ({
182
+ ordinal: chunk.ordinal,
183
+ kind: chunk.kind,
184
+ text: chunk.text,
185
+ ...(chunk.startOffset === null ? {} : { startOffset: chunk.startOffset }),
186
+ ...(chunk.endOffset === null ? {} : { endOffset: chunk.endOffset }),
187
+ ...(chunk.tokenCount === null ? {} : { tokenCount: chunk.tokenCount }),
188
+ metadata: chunk.metadata,
189
+ })),
190
+ media: detail.media.map((media) => ({
191
+ kind: media.kind,
192
+ ...(media.mimeType ? { mimeType: media.mimeType } : {}),
193
+ accessMode: media.accessMode,
194
+ ...(media.url ? { url: media.url } : {}),
195
+ ...(media.storageKey ? { storageKey: media.storageKey } : {}),
196
+ ...(media.provenanceUrl ? { provenanceUrl: media.provenanceUrl } : {}),
197
+ ...(media.altText ? { altText: media.altText } : {}),
198
+ ...(media.caption ? { caption: media.caption } : {}),
199
+ captionStatus: media.captionStatus,
200
+ ...(media.ocrText ? { ocrText: media.ocrText } : {}),
201
+ palette: media.palette,
202
+ ...(media.contentHash ? { contentHash: media.contentHash } : {}),
203
+ ...(media.width === null ? {} : { width: media.width }),
204
+ ...(media.height === null ? {} : { height: media.height }),
205
+ ...(media.durationMs === null ? {} : { durationMs: media.durationMs }),
206
+ metadata: media.metadata,
207
+ })),
208
+ edges: detail.edges.map((edge) => ({
209
+ relation: edge.relation,
210
+ ...(edge.toItemId ? { toItemId: edge.toItemId } : {}),
211
+ ...(edge.toItemVersionId
212
+ ? { toItemVersionId: edge.toItemVersionId }
213
+ : {}),
214
+ ...(edge.toExternalId ? { toExternalId: edge.toExternalId } : {}),
215
+ metadata: edge.metadata,
216
+ })),
217
+ };
218
+ }
219
+
220
+ async function writeSnapshot(input: {
221
+ sourceId: string;
222
+ artifactKey: string;
223
+ item: NormalizedContextItem;
224
+ ownerEmail: string;
225
+ orgId: string | null;
226
+ }) {
227
+ const { getDb, schema } = getCreativeContext();
228
+ const timestamp = nowIso();
229
+ const existing = await getDb()
230
+ .select()
231
+ .from(schema.contextItems)
232
+ .where(
233
+ and(
234
+ eq(schema.contextItems.sourceId, input.sourceId),
235
+ eq(schema.contextItems.externalId, input.artifactKey),
236
+ ),
237
+ )
238
+ .limit(1);
239
+ const previous = existing[0] as any;
240
+ const itemId = previous?.id ?? newId("cci");
241
+ const itemVersionId = newId("ccv");
242
+ const versionNumber = previous
243
+ ? (
244
+ await getDb()
245
+ .select({ versionNumber: schema.contextItemVersions.versionNumber })
246
+ .from(schema.contextItemVersions)
247
+ .where(eq(schema.contextItemVersions.id, previous.currentVersionId))
248
+ .limit(1)
249
+ )[0]?.versionNumber + 1
250
+ : 1;
251
+ const itemValues = {
252
+ kind: input.item.kind,
253
+ title: input.item.title,
254
+ canonicalUrl: input.item.canonicalUrl ?? null,
255
+ mimeType: input.item.mimeType ?? null,
256
+ currentVersionId: itemVersionId,
257
+ currentContentHash: input.item.contentHash,
258
+ status: "active" as const,
259
+ upstreamAccess: input.item.upstreamAccess ?? "available",
260
+ curationStatus: "included" as const,
261
+ curationRank: input.item.curationRank ?? "normal",
262
+ starred: input.item.starred ? 1 : 0,
263
+ inventoryState: input.item.inventoryState ?? "available",
264
+ indexState: input.item.indexState ?? "pending",
265
+ tags: stringifyJson(input.item.tags ?? []),
266
+ colors: stringifyJson(
267
+ input.item.colors ?? (input.item.color ? [input.item.color] : []),
268
+ ),
269
+ sortOrder: input.item.sortOrder ?? 0,
270
+ parentItemId: input.item.parentItemId ?? null,
271
+ provenance: stringifyJson(input.item.provenance),
272
+ thumbnailBlobRef: input.item.thumbnailBlobRef ?? null,
273
+ metadata: stringifyJson(input.item.metadata),
274
+ updatedAt: timestamp,
275
+ };
276
+ await getDb().transaction(async (tx: any) => {
277
+ if (previous)
278
+ await tx
279
+ .update(schema.contextItems)
280
+ .set(itemValues)
281
+ .where(eq(schema.contextItems.id, itemId));
282
+ else
283
+ await tx.insert(schema.contextItems).values({
284
+ id: itemId,
285
+ sourceId: input.sourceId,
286
+ externalId: input.artifactKey,
287
+ ...itemValues,
288
+ createdAt: timestamp,
289
+ ownerEmail: input.ownerEmail,
290
+ orgId: input.orgId,
291
+ });
292
+ await tx.insert(schema.contextItemVersions).values({
293
+ id: itemVersionId,
294
+ itemId,
295
+ versionNumber: Number(versionNumber ?? 1),
296
+ contentHash: input.item.contentHash,
297
+ title: input.item.title,
298
+ content: input.item.content,
299
+ summary: input.item.summary ?? null,
300
+ mimeType: input.item.mimeType ?? null,
301
+ sourceModifiedAt: input.item.sourceModifiedAt ?? null,
302
+ sourceVersion: input.item.sourceVersion ?? null,
303
+ rawSnapshotBlobRef: input.item.rawSnapshotBlobRef ?? null,
304
+ parseStatus: input.item.parseStatus ?? "parsed",
305
+ parseError: input.item.parseError ?? null,
306
+ metadata: stringifyJson(input.item.metadata),
307
+ createdAt: timestamp,
308
+ ownerEmail: input.ownerEmail,
309
+ orgId: input.orgId,
310
+ });
311
+ const chunks = input.item.chunks?.length
312
+ ? input.item.chunks
313
+ : [{ ordinal: 0, kind: "text", text: input.item.content }];
314
+ await tx.insert(schema.contextChunks).values(
315
+ chunks.map((chunk) => ({
316
+ id: newId("ccc"),
317
+ itemId,
318
+ itemVersionId,
319
+ ordinal: chunk.ordinal,
320
+ kind: chunk.kind ?? "text",
321
+ text: chunk.text,
322
+ startOffset: chunk.startOffset ?? null,
323
+ endOffset: chunk.endOffset ?? null,
324
+ tokenCount: chunk.tokenCount ?? null,
325
+ metadata: stringifyJson(chunk.metadata),
326
+ createdAt: timestamp,
327
+ ownerEmail: input.ownerEmail,
328
+ orgId: input.orgId,
329
+ })),
330
+ );
331
+ if (input.item.media?.length)
332
+ await tx.insert(schema.contextMedia).values(
333
+ input.item.media.map((media) => ({
334
+ id: newId("ccm"),
335
+ itemId,
336
+ itemVersionId,
337
+ kind: media.kind,
338
+ mimeType: media.mimeType ?? null,
339
+ accessMode: media.accessMode ?? "public",
340
+ url: media.accessMode === "public" ? (media.url ?? null) : null,
341
+ storageKey: media.storageKey ?? null,
342
+ provenanceUrl: media.provenanceUrl ?? media.url ?? null,
343
+ altText: media.altText ?? null,
344
+ caption: media.caption ?? null,
345
+ captionStatus: media.captionStatus ?? "pending",
346
+ ocrText: media.ocrText ?? null,
347
+ palette: stringifyJson(media.palette ?? []),
348
+ contentHash: media.contentHash ?? null,
349
+ width: media.width ?? null,
350
+ height: media.height ?? null,
351
+ durationMs: media.durationMs ?? null,
352
+ metadata: stringifyJson(media.metadata),
353
+ createdAt: timestamp,
354
+ ownerEmail: input.ownerEmail,
355
+ orgId: input.orgId,
356
+ })),
357
+ );
358
+ if (input.item.edges?.length)
359
+ await tx.insert(schema.contextEdges).values(
360
+ input.item.edges.map((edge) => ({
361
+ id: newId("cce"),
362
+ fromItemId: itemId,
363
+ fromItemVersionId: itemVersionId,
364
+ toItemId: edge.toItemId ?? null,
365
+ toItemVersionId: edge.toItemVersionId ?? null,
366
+ toExternalId: edge.toExternalId ?? null,
367
+ relation: edge.relation,
368
+ metadata: stringifyJson(edge.metadata),
369
+ createdAt: timestamp,
370
+ ownerEmail: input.ownerEmail,
371
+ orgId: input.orgId,
372
+ })),
373
+ );
374
+ await tx
375
+ .update(schema.contextSources)
376
+ .set({ updatedAt: timestamp })
377
+ .where(eq(schema.contextSources.id, input.sourceId));
378
+ });
379
+ return { itemId, itemVersionId };
380
+ }
381
+
382
+ async function readSnapshot(
383
+ itemId: string,
384
+ itemVersionId: string,
385
+ ): Promise<NormalizedContextItem> {
386
+ const { getDb, schema } = getCreativeContext();
387
+ const [item] = await getDb()
388
+ .select()
389
+ .from(schema.contextItems)
390
+ .where(eq(schema.contextItems.id, itemId))
391
+ .limit(1);
392
+ const [version] = await getDb()
393
+ .select()
394
+ .from(schema.contextItemVersions)
395
+ .where(eq(schema.contextItemVersions.id, itemVersionId))
396
+ .limit(1);
397
+ if (!item || !version)
398
+ throw new Error("Staged context snapshot was not found");
399
+ const [chunks, media, edges] = await Promise.all([
400
+ getDb()
401
+ .select()
402
+ .from(schema.contextChunks)
403
+ .where(eq(schema.contextChunks.itemVersionId, itemVersionId)),
404
+ getDb()
405
+ .select()
406
+ .from(schema.contextMedia)
407
+ .where(eq(schema.contextMedia.itemVersionId, itemVersionId)),
408
+ getDb()
409
+ .select()
410
+ .from(schema.contextEdges)
411
+ .where(eq(schema.contextEdges.fromItemVersionId, itemVersionId)),
412
+ ]);
413
+ return {
414
+ externalId: item.externalId,
415
+ kind: item.kind,
416
+ title: version.title,
417
+ canonicalUrl: item.canonicalUrl ?? undefined,
418
+ mimeType: version.mimeType ?? undefined,
419
+ content: version.content,
420
+ summary: version.summary ?? undefined,
421
+ contentHash: version.contentHash,
422
+ sourceModifiedAt: version.sourceModifiedAt ?? undefined,
423
+ sourceVersion: version.sourceVersion ?? undefined,
424
+ rawSnapshotBlobRef: version.rawSnapshotBlobRef ?? undefined,
425
+ parseStatus: version.parseStatus,
426
+ parseError: version.parseError ?? undefined,
427
+ upstreamAccess: item.upstreamAccess,
428
+ curationStatus: "included",
429
+ curationRank: item.curationRank,
430
+ tags: parseJson(item.tags, []),
431
+ colors: parseJson(item.colors, []),
432
+ provenance: parseJson(item.provenance, {}),
433
+ thumbnailBlobRef: item.thumbnailBlobRef ?? undefined,
434
+ metadata: parseJson(version.metadata, {}),
435
+ chunks: chunks.map((row: any) => ({
436
+ ordinal: row.ordinal,
437
+ kind: row.kind,
438
+ text: row.text,
439
+ startOffset: row.startOffset ?? undefined,
440
+ endOffset: row.endOffset ?? undefined,
441
+ tokenCount: row.tokenCount ?? undefined,
442
+ metadata: parseJson(row.metadata, {}),
443
+ })),
444
+ media: media.map((row: any) => ({
445
+ kind: row.kind,
446
+ mimeType: row.mimeType ?? undefined,
447
+ accessMode: row.accessMode,
448
+ url: row.url ?? undefined,
449
+ storageKey: row.storageKey ?? undefined,
450
+ provenanceUrl: row.provenanceUrl ?? undefined,
451
+ altText: row.altText ?? undefined,
452
+ caption: row.caption ?? undefined,
453
+ captionStatus: row.captionStatus,
454
+ ocrText: row.ocrText ?? undefined,
455
+ palette: parseJson(row.palette, []),
456
+ contentHash: row.contentHash ?? undefined,
457
+ width: row.width ?? undefined,
458
+ height: row.height ?? undefined,
459
+ durationMs: row.durationMs ?? undefined,
460
+ metadata: parseJson(row.metadata, {}),
461
+ })),
462
+ edges: edges.map((row: any) => ({
463
+ relation: row.relation,
464
+ ...(row.toItemId ? { toItemId: row.toItemId } : {}),
465
+ ...(row.toItemVersionId ? { toItemVersionId: row.toItemVersionId } : {}),
466
+ ...(row.toExternalId ? { toExternalId: row.toExternalId } : {}),
467
+ metadata: parseJson(row.metadata, {}),
468
+ })),
469
+ };
470
+ }
471
+
472
+ export async function createCreativeContext(input: {
473
+ name: string;
474
+ description?: string | null;
475
+ kind: "default" | "specialty";
476
+ brandProfileId?: string | null;
477
+ approvalPolicy?: CreativeContextApprovalPolicy;
478
+ }) {
479
+ const { getDb, schema } = getCreativeContext();
480
+ const actor = requireActor();
481
+ const timestamp = nowIso();
482
+ if (actor.orgId) await assertCurrentRequestUserIsOrgAdmin(actor.orgId);
483
+ const defaultScopeKey =
484
+ input.kind === "default" ? defaultContextScopeKey(actor) : null;
485
+ if (input.kind === "default") {
486
+ const existing = await getDb()
487
+ .select({ id: schema.creativeContexts.id })
488
+ .from(schema.creativeContexts)
489
+ .where(eq(schema.creativeContexts.defaultScopeKey, defaultScopeKey!))
490
+ .limit(1);
491
+ if (existing[0]) return getCreativeContextById(existing[0].id);
492
+ }
493
+ const id = newId("ccx"),
494
+ stagingSourceId = newId("ccs"),
495
+ publishedSourceId = newId("ccs");
496
+ try {
497
+ await getDb().transaction(async (tx: any) => {
498
+ for (const [sourceId, name, purpose] of [
499
+ [stagingSourceId, `${input.name} staging`, "staging"],
500
+ [publishedSourceId, `${input.name} published`, "published"],
501
+ ] as const)
502
+ await tx.insert(schema.contextSources).values({
503
+ id: sourceId,
504
+ name,
505
+ kind: "native-app",
506
+ config: stringifyJson({ governedContextId: id, purpose }),
507
+ upstreamAccess: "available",
508
+ status: "active",
509
+ healthStatus: "healthy",
510
+ itemCount: 0,
511
+ restrictedItemCount: 0,
512
+ createdAt: timestamp,
513
+ updatedAt: timestamp,
514
+ ownerEmail: actor.ownerEmail,
515
+ orgId: actor.orgId,
516
+ visibility: "private",
517
+ });
518
+ await tx.insert(schema.creativeContexts).values({
519
+ id,
520
+ name: input.name,
521
+ description: input.description ?? null,
522
+ kind: input.kind,
523
+ defaultScopeKey,
524
+ brandProfileId: input.brandProfileId ?? null,
525
+ stagingSourceId,
526
+ publishedSourceId,
527
+ approvalPolicy: input.approvalPolicy ?? "open",
528
+ archivedAt: null,
529
+ createdAt: timestamp,
530
+ updatedAt: timestamp,
531
+ ownerEmail: actor.ownerEmail,
532
+ orgId: actor.orgId,
533
+ visibility: actor.orgId ? "org" : "private",
534
+ });
535
+ await appendAudit(tx, id, "create", {
536
+ kind: input.kind,
537
+ approvalPolicy: input.approvalPolicy ?? "open",
538
+ });
539
+ });
540
+ } catch (error) {
541
+ if (input.kind === "default") {
542
+ const existing = await getDb()
543
+ .select({ id: schema.creativeContexts.id })
544
+ .from(schema.creativeContexts)
545
+ .where(eq(schema.creativeContexts.defaultScopeKey, defaultScopeKey!))
546
+ .limit(1);
547
+ if (existing[0]) return getCreativeContextById(existing[0].id);
548
+ }
549
+ throw error;
550
+ }
551
+ return getCreativeContextById(id);
552
+ }
553
+
554
+ /** Idempotently establishes the actor's governed Default with the currently usable corpus. */
555
+ export async function ensureDefaultCreativeContext(): Promise<CreativeContextSummary | null> {
556
+ const { getDb, schema } = getCreativeContext();
557
+ const actor = requireActor();
558
+ const existing = await getDb()
559
+ .select()
560
+ .from(schema.creativeContexts)
561
+ .where(
562
+ eq(
563
+ schema.creativeContexts.defaultScopeKey,
564
+ defaultContextScopeKey(actor),
565
+ ),
566
+ )
567
+ .limit(1);
568
+ if (existing[0]) return getCreativeContextById(existing[0].id);
569
+ if (actor.orgId && !(await currentRequestUserIsOrgAdmin(actor.orgId)))
570
+ return null;
571
+ const profileRows = await getDb()
572
+ .select({ id: schema.brandProfiles.id })
573
+ .from(schema.brandProfiles)
574
+ .innerJoin(
575
+ schema.brandDnaVersions,
576
+ eq(schema.brandDnaVersions.profileId, schema.brandProfiles.id),
577
+ )
578
+ .where(
579
+ and(
580
+ accessFilter(schema.brandProfiles, schema.brandProfileShares),
581
+ eq(schema.brandDnaVersions.status, "published"),
582
+ ),
583
+ )
584
+ .limit(1);
585
+ const created = await createCreativeContext({
586
+ name: "Default",
587
+ kind: "default",
588
+ brandProfileId: profileRows[0]?.id ?? null,
589
+ approvalPolicy: "open",
590
+ });
591
+ if (!created) return null;
592
+ const corpus = await getDb()
593
+ .select({
594
+ itemId: schema.contextItems.id,
595
+ itemVersionId: schema.contextItems.currentVersionId,
596
+ sourceId: schema.contextSources.id,
597
+ externalId: schema.contextItems.externalId,
598
+ rank: schema.contextItems.curationRank,
599
+ })
600
+ .from(schema.contextItems)
601
+ .innerJoin(
602
+ schema.contextSources,
603
+ eq(schema.contextSources.id, schema.contextItems.sourceId),
604
+ )
605
+ .where(
606
+ and(
607
+ accessFilter(schema.contextSources, schema.contextSourceShares),
608
+ ...(created.visibility === "org"
609
+ ? [inArray(schema.contextSources.visibility, ["org", "public"])]
610
+ : []),
611
+ eq(schema.contextItems.curationStatus, "included"),
612
+ eq(schema.contextItems.status, "active"),
613
+ eq(schema.contextSources.status, "active"),
614
+ eq(schema.contextSources.upstreamAccess, "available"),
615
+ ),
616
+ )
617
+ .limit(5_000);
618
+ const timestamp = nowIso();
619
+ if (corpus.length)
620
+ await getDb().transaction(async (tx: any) => {
621
+ await tx.insert(schema.creativeContextMemberships).values(
622
+ (corpus as any[]).map((item) => ({
623
+ id: newId("ccmbr"),
624
+ contextId: created.id,
625
+ artifactKey: `${item.sourceId}:${item.externalId}`,
626
+ publishedItemId: item.itemId,
627
+ publishedItemVersionId: item.itemVersionId,
628
+ pendingSubmissionId: null,
629
+ rank:
630
+ item.rank === "canonical" || item.rank === "exemplar"
631
+ ? item.rank
632
+ : "normal",
633
+ purpose: "Backfilled accessible corpus",
634
+ status: "active",
635
+ createdAt: timestamp,
636
+ updatedAt: timestamp,
637
+ ownerEmail: actor.ownerEmail,
638
+ orgId: actor.orgId,
639
+ })),
640
+ );
641
+ await appendAudit(tx, created.id, "backfill-default", {
642
+ members: corpus.length,
643
+ brandProfileId: profileRows[0]?.id ?? null,
644
+ });
645
+ });
646
+ return getCreativeContextById(created.id);
647
+ }
648
+
649
+ export async function getCreativeContextById(
650
+ contextId: string,
651
+ ): Promise<CreativeContextSummary | null> {
652
+ const access = await resolveAccess("creative-context", contextId);
653
+ if (!access) return null;
654
+ const { getDb, schema } = getCreativeContext();
655
+ const [membershipCount] = await getDb()
656
+ .select({ value: count() })
657
+ .from(schema.creativeContextMemberships)
658
+ .where(
659
+ and(
660
+ eq(schema.creativeContextMemberships.contextId, contextId),
661
+ eq(schema.creativeContextMemberships.status, "active"),
662
+ ),
663
+ );
664
+ return mapContext(
665
+ access.resource,
666
+ Number(membershipCount?.value ?? 0),
667
+ access.role,
668
+ );
669
+ }
670
+
671
+ export async function getCreativeContextAppBinding(
672
+ appId: string,
673
+ ): Promise<CreativeContextSummary | null> {
674
+ const { getDb, schema } = getCreativeContext();
675
+ const actor = requireActor();
676
+ const scope = actor.orgId
677
+ ? eq(schema.creativeContextAppBindings.orgId, actor.orgId)
678
+ : isNull(schema.creativeContextAppBindings.orgId);
679
+ const rows = await getDb()
680
+ .select({ contextId: schema.creativeContextAppBindings.contextId })
681
+ .from(schema.creativeContextAppBindings)
682
+ .where(
683
+ and(
684
+ eq(schema.creativeContextAppBindings.appId, appId),
685
+ scope,
686
+ ...(actor.orgId
687
+ ? []
688
+ : [
689
+ eq(
690
+ schema.creativeContextAppBindings.ownerEmail,
691
+ actor.ownerEmail,
692
+ ),
693
+ ]),
694
+ ),
695
+ )
696
+ .orderBy(asc(schema.creativeContextAppBindings.updatedAt))
697
+ .limit(1);
698
+ return rows[0] ? getCreativeContextById(rows[0].contextId) : null;
699
+ }
700
+
701
+ export async function listCreativeContexts(input: {
702
+ limit: number;
703
+ cursor?: string;
704
+ includeArchived?: boolean;
705
+ }) {
706
+ await ensureDefaultCreativeContext();
707
+ const { getDb, schema } = getCreativeContext();
708
+ const filters: any[] = [
709
+ accessFilter(schema.creativeContexts, schema.creativeContextShares),
710
+ ];
711
+ if (input.cursor) filters.push(gt(schema.creativeContexts.id, input.cursor));
712
+ if (!input.includeArchived)
713
+ filters.push(isNull(schema.creativeContexts.archivedAt));
714
+ const rows = await getDb()
715
+ .select()
716
+ .from(schema.creativeContexts)
717
+ .where(and(...filters))
718
+ .orderBy(asc(schema.creativeContexts.id))
719
+ .limit(input.limit + 1);
720
+ const page = rows.slice(0, input.limit) as any[];
721
+ const counts = page.length
722
+ ? await getDb()
723
+ .select({
724
+ contextId: schema.creativeContextMemberships.contextId,
725
+ id: schema.creativeContextMemberships.id,
726
+ })
727
+ .from(schema.creativeContextMemberships)
728
+ .where(
729
+ and(
730
+ inArray(
731
+ schema.creativeContextMemberships.contextId,
732
+ page.map((row) => row.id),
733
+ ),
734
+ eq(schema.creativeContextMemberships.status, "active"),
735
+ ),
736
+ )
737
+ : [];
738
+ const byContext = new Map<string, number>();
739
+ for (const row of counts as any[])
740
+ byContext.set(row.contextId, (byContext.get(row.contextId) ?? 0) + 1);
741
+ const accessById = new Map(
742
+ (
743
+ await Promise.all(
744
+ page.map(
745
+ async (row) =>
746
+ [row.id, await resolveAccess("creative-context", row.id)] as const,
747
+ ),
748
+ )
749
+ ).flatMap(([id, access]) => (access ? [[id, access]] : [])),
750
+ );
751
+ return {
752
+ contexts: page.flatMap((row) => {
753
+ const access = accessById.get(row.id);
754
+ return access
755
+ ? [mapContext(row, byContext.get(row.id) ?? 0, access.role)]
756
+ : [];
757
+ }),
758
+ nextCursor: rows.length > input.limit ? page.at(-1)?.id : undefined,
759
+ };
760
+ }
761
+
762
+ export async function updateCreativeContext(
763
+ contextId: string,
764
+ patch: {
765
+ name?: string;
766
+ description?: string | null;
767
+ brandProfileId?: string | null;
768
+ approvalPolicy?: CreativeContextApprovalPolicy;
769
+ },
770
+ ) {
771
+ await assertContextRole(contextId, "admin");
772
+ const { getDb, schema } = getCreativeContext();
773
+ const values = { ...patch, updatedAt: nowIso() };
774
+ await getDb().transaction(async (tx: any) => {
775
+ await tx
776
+ .update(schema.creativeContexts)
777
+ .set(values)
778
+ .where(eq(schema.creativeContexts.id, contextId));
779
+ await appendAudit(tx, contextId, "update", { fields: Object.keys(patch) });
780
+ });
781
+ return getCreativeContextById(contextId);
782
+ }
783
+
784
+ export async function archiveCreativeContext(contextId: string) {
785
+ await assertContextRole(contextId, "admin");
786
+ const { getDb, schema } = getCreativeContext();
787
+ const [context] = await getDb()
788
+ .select({ kind: schema.creativeContexts.kind })
789
+ .from(schema.creativeContexts)
790
+ .where(eq(schema.creativeContexts.id, contextId))
791
+ .limit(1);
792
+ if (context?.kind === "default")
793
+ throw new Error("The Default Creative Context cannot be archived");
794
+ await getDb()
795
+ .update(schema.creativeContexts)
796
+ .set({ archivedAt: nowIso(), updatedAt: nowIso() })
797
+ .where(eq(schema.creativeContexts.id, contextId));
798
+ return getCreativeContextById(contextId);
799
+ }
800
+
801
+ export async function setCreativeContextAppDefault(
802
+ contextId: string,
803
+ appId: string,
804
+ ) {
805
+ const access = await assertContextRole(contextId, "admin");
806
+ if (access.resource.orgId)
807
+ await assertCurrentRequestUserIsOrgAdmin(access.resource.orgId);
808
+ const { getDb, schema } = getCreativeContext();
809
+ const actor = requireActor();
810
+ const timestamp = nowIso();
811
+ await getDb().transaction(async (tx: any) => {
812
+ await tx
813
+ .delete(schema.creativeContextAppBindings)
814
+ .where(
815
+ and(
816
+ eq(schema.creativeContextAppBindings.appId, appId),
817
+ actor.orgId
818
+ ? eq(schema.creativeContextAppBindings.orgId, actor.orgId)
819
+ : and(
820
+ isNull(schema.creativeContextAppBindings.orgId),
821
+ eq(
822
+ schema.creativeContextAppBindings.ownerEmail,
823
+ actor.ownerEmail,
824
+ ),
825
+ ),
826
+ ),
827
+ );
828
+ await tx.insert(schema.creativeContextAppBindings).values({
829
+ id: newId("ccab"),
830
+ appId,
831
+ contextId,
832
+ createdAt: timestamp,
833
+ updatedAt: timestamp,
834
+ ownerEmail: actor.ownerEmail,
835
+ orgId: actor.orgId,
836
+ });
837
+ await appendAudit(tx, contextId, "set-app-default", { appId });
838
+ });
839
+ return getCreativeContextById(contextId);
840
+ }
841
+
842
+ export async function listContextMemberships(input: {
843
+ contextId: string;
844
+ status?: "active" | "removed";
845
+ limit: number;
846
+ cursor?: string;
847
+ }) {
848
+ const access = await assertContextRole(input.contextId, "viewer");
849
+ const { getDb, schema } = getCreativeContext();
850
+ const actor = requireActor();
851
+ const canReview = access.role === "owner" || access.role === "admin";
852
+ const filters: any[] = [
853
+ eq(schema.creativeContextMemberships.contextId, input.contextId),
854
+ ];
855
+ if (input.status)
856
+ filters.push(eq(schema.creativeContextMemberships.status, input.status));
857
+ if (input.cursor)
858
+ filters.push(gt(schema.creativeContextMemberships.id, input.cursor));
859
+ if (!canReview)
860
+ filters.push(
861
+ or(
862
+ isNotNull(schema.creativeContextMemberships.publishedItemId),
863
+ eq(schema.creativeContextSubmissions.submittedBy, actor.ownerEmail),
864
+ ),
865
+ );
866
+ const rows = await getDb()
867
+ .select({
868
+ membership: schema.creativeContextMemberships,
869
+ pendingSubmission: schema.creativeContextSubmissions,
870
+ })
871
+ .from(schema.creativeContextMemberships)
872
+ .leftJoin(
873
+ schema.creativeContextSubmissions,
874
+ eq(
875
+ schema.creativeContextSubmissions.id,
876
+ schema.creativeContextMemberships.pendingSubmissionId,
877
+ ),
878
+ )
879
+ .where(and(...filters))
880
+ .orderBy(asc(schema.creativeContextMemberships.id))
881
+ .limit(input.limit + 1);
882
+ const page = rows.slice(0, input.limit) as Array<{
883
+ membership: any;
884
+ pendingSubmission: any | null;
885
+ }>;
886
+ const canViewPendingSubmission = (submission: any | null) =>
887
+ Boolean(
888
+ submission && (canReview || submission.submittedBy === actor.ownerEmail),
889
+ );
890
+ const publishedVersionIds = page.flatMap(({ membership }) =>
891
+ membership.publishedItemVersionId
892
+ ? [membership.publishedItemVersionId]
893
+ : [],
894
+ );
895
+ const pendingVersionIds = page.flatMap(({ pendingSubmission }) =>
896
+ canViewPendingSubmission(pendingSubmission) &&
897
+ pendingSubmission.stagingItemVersionId
898
+ ? [pendingSubmission.stagingItemVersionId]
899
+ : [],
900
+ );
901
+ const previewVersionIds = [
902
+ ...new Set([...publishedVersionIds, ...pendingVersionIds]),
903
+ ];
904
+ const [previewItems, previewMedia] = previewVersionIds.length
905
+ ? await Promise.all([
906
+ getDb()
907
+ .select({
908
+ id: schema.contextItems.id,
909
+ itemVersionId: schema.contextItemVersions.id,
910
+ title: schema.contextItemVersions.title,
911
+ kind: schema.contextItems.kind,
912
+ canonicalUrl: schema.contextItems.canonicalUrl,
913
+ status: schema.contextItems.status,
914
+ sourceModifiedAt: schema.contextItemVersions.sourceModifiedAt,
915
+ metadata: schema.contextItemVersions.metadata,
916
+ })
917
+ .from(schema.contextItemVersions)
918
+ .innerJoin(
919
+ schema.contextItems,
920
+ eq(schema.contextItems.id, schema.contextItemVersions.itemId),
921
+ )
922
+ .where(inArray(schema.contextItemVersions.id, previewVersionIds)),
923
+ getDb()
924
+ .select({
925
+ id: schema.contextMedia.id,
926
+ itemVersionId: schema.contextMedia.itemVersionId,
927
+ kind: schema.contextMedia.kind,
928
+ mimeType: schema.contextMedia.mimeType,
929
+ })
930
+ .from(schema.contextMedia)
931
+ .where(inArray(schema.contextMedia.itemVersionId, previewVersionIds)),
932
+ ])
933
+ : [[], []];
934
+ const mediaByVersion = new Map<
935
+ string,
936
+ Array<{ id: string; kind: any; mimeType: string | null; url: string }>
937
+ >();
938
+ for (const media of previewMedia as any[]) {
939
+ const list = mediaByVersion.get(media.itemVersionId) ?? [];
940
+ list.push({
941
+ id: media.id,
942
+ kind: media.kind,
943
+ mimeType: media.mimeType ?? null,
944
+ url: creativeContextMediaUrl({ mediaId: media.id }),
945
+ });
946
+ mediaByVersion.set(media.itemVersionId, list);
947
+ }
948
+ const previewByVersion = new Map(
949
+ (previewItems as any[]).map((item) => {
950
+ const preview = sanitizePublicMetadata(
951
+ parseJson<Record<string, unknown>>(item.metadata, {}).preview,
952
+ );
953
+ return [
954
+ item.itemVersionId,
955
+ {
956
+ id: item.id,
957
+ itemVersionId: item.itemVersionId,
958
+ title: item.title,
959
+ kind: item.kind,
960
+ canonicalUrl:
961
+ typeof sanitizePublicMetadata(item.canonicalUrl) === "string"
962
+ ? (sanitizePublicMetadata(item.canonicalUrl) as string)
963
+ : null,
964
+ status: item.status,
965
+ sourceModifiedAt: item.sourceModifiedAt ?? null,
966
+ media: mediaByVersion.get(item.itemVersionId) ?? [],
967
+ ...(preview && typeof preview === "object" && !Array.isArray(preview)
968
+ ? { preview: preview as Record<string, unknown> }
969
+ : {}),
970
+ },
971
+ ];
972
+ }),
973
+ );
974
+ return {
975
+ memberships: page.map(({ membership, pendingSubmission }) => {
976
+ const canViewPending = canViewPendingSubmission(pendingSubmission);
977
+ return {
978
+ ...mapMembership(
979
+ canViewPending
980
+ ? membership
981
+ : { ...membership, pendingSubmissionId: null },
982
+ ),
983
+ publishedItem: membership.publishedItemVersionId
984
+ ? (previewByVersion.get(membership.publishedItemVersionId) ?? null)
985
+ : null,
986
+ pendingSubmission:
987
+ canViewPending && pendingSubmission
988
+ ? {
989
+ ...mapSubmission(pendingSubmission),
990
+ proposedItem: pendingSubmission.stagingItemVersionId
991
+ ? (previewByVersion.get(
992
+ pendingSubmission.stagingItemVersionId,
993
+ ) ?? null)
994
+ : null,
995
+ }
996
+ : null,
997
+ };
998
+ }),
999
+ nextCursor:
1000
+ rows.length > input.limit ? page.at(-1)?.membership.id : undefined,
1001
+ };
1002
+ }
1003
+
1004
+ /**
1005
+ * Resolves private media for a pending submission without making the staged
1006
+ * item generally readable. This is intentionally server-only: callers must
1007
+ * already have an authenticated request context and can only read the exact
1008
+ * staged version they submitted or are allowed to review.
1009
+ */
1010
+ export async function readPendingCreativeContextMedia(input: {
1011
+ mediaId?: string;
1012
+ itemId?: string;
1013
+ itemVersionId?: string;
1014
+ }): Promise<{
1015
+ itemId: string;
1016
+ itemVersionId: string;
1017
+ mediaId: string | null;
1018
+ storageKey: string | null;
1019
+ mimeType: string | null;
1020
+ } | null> {
1021
+ const { getDb, schema } = getCreativeContext();
1022
+ const actor = requireActor();
1023
+ let itemId = input.itemId;
1024
+ let itemVersionId = input.itemVersionId;
1025
+ let media: {
1026
+ id: string;
1027
+ itemId: string;
1028
+ itemVersionId: string;
1029
+ storageKey: string | null;
1030
+ mimeType: string | null;
1031
+ } | null = null;
1032
+
1033
+ if (input.mediaId) {
1034
+ const rows = await getDb()
1035
+ .select({
1036
+ id: schema.contextMedia.id,
1037
+ itemId: schema.contextMedia.itemId,
1038
+ itemVersionId: schema.contextMedia.itemVersionId,
1039
+ storageKey: schema.contextMedia.storageKey,
1040
+ mimeType: schema.contextMedia.mimeType,
1041
+ })
1042
+ .from(schema.contextMedia)
1043
+ .where(eq(schema.contextMedia.id, input.mediaId))
1044
+ .limit(1);
1045
+ media = rows[0] ?? null;
1046
+ if (!media) return null;
1047
+ if (
1048
+ (itemId && itemId !== media.itemId) ||
1049
+ (itemVersionId && itemVersionId !== media.itemVersionId)
1050
+ ) {
1051
+ return null;
1052
+ }
1053
+ itemId = media.itemId;
1054
+ itemVersionId = media.itemVersionId;
1055
+ }
1056
+ if (!itemId || !itemVersionId) return null;
1057
+
1058
+ const submissions = await getDb()
1059
+ .select({
1060
+ contextId: schema.creativeContextSubmissions.contextId,
1061
+ submittedBy: schema.creativeContextSubmissions.submittedBy,
1062
+ })
1063
+ .from(schema.creativeContextSubmissions)
1064
+ .where(
1065
+ and(
1066
+ eq(schema.creativeContextSubmissions.status, "pending"),
1067
+ eq(schema.creativeContextSubmissions.stagingItemId, itemId),
1068
+ eq(
1069
+ schema.creativeContextSubmissions.stagingItemVersionId,
1070
+ itemVersionId,
1071
+ ),
1072
+ ),
1073
+ )
1074
+ .limit(1);
1075
+ const submission = submissions[0];
1076
+ if (!submission) return null;
1077
+ const access = await resolveAccess("creative-context", submission.contextId);
1078
+ if (!access) return null;
1079
+ const canReview = access.role === "owner" || access.role === "admin";
1080
+ if (!canReview && submission.submittedBy !== actor.ownerEmail) return null;
1081
+
1082
+ if (media) {
1083
+ return {
1084
+ itemId,
1085
+ itemVersionId,
1086
+ mediaId: media.id,
1087
+ storageKey: media.storageKey,
1088
+ mimeType: media.mimeType,
1089
+ };
1090
+ }
1091
+ const rows = await getDb()
1092
+ .select({
1093
+ thumbnailBlobRef: schema.contextItems.thumbnailBlobRef,
1094
+ mimeType: schema.contextItemVersions.mimeType,
1095
+ })
1096
+ .from(schema.contextItemVersions)
1097
+ .innerJoin(
1098
+ schema.contextItems,
1099
+ eq(schema.contextItems.id, schema.contextItemVersions.itemId),
1100
+ )
1101
+ .where(
1102
+ and(
1103
+ eq(schema.contextItems.id, itemId),
1104
+ eq(schema.contextItemVersions.id, itemVersionId),
1105
+ ),
1106
+ )
1107
+ .limit(1);
1108
+ const item = rows[0];
1109
+ return item
1110
+ ? {
1111
+ itemId,
1112
+ itemVersionId,
1113
+ mediaId: null,
1114
+ storageKey: item.thumbnailBlobRef,
1115
+ mimeType: item.mimeType,
1116
+ }
1117
+ : null;
1118
+ }
1119
+
1120
+ async function resolveSubmissionItem(input: {
1121
+ itemId?: string;
1122
+ itemVersionId?: string;
1123
+ nativeResource?: NativeCreativeResourceRef;
1124
+ }) {
1125
+ if (input.nativeResource) {
1126
+ const captured = await captureNativeCreativeResource(input.nativeResource);
1127
+ if (!captured.items.length)
1128
+ throw new Error("Native capture returned no artifacts");
1129
+ return {
1130
+ artifactKey: captured.artifactKey,
1131
+ items: captured.items,
1132
+ privateMetadata: {
1133
+ ...(captured.privateMetadata ?? {}),
1134
+ nativeResource: input.nativeResource,
1135
+ },
1136
+ sourceAccess: captured.source.access ?? null,
1137
+ };
1138
+ }
1139
+ if (!input.itemId) throw new Error("itemId or nativeResource is required");
1140
+ const detail = await getCreativeContextItem(
1141
+ input.itemId,
1142
+ input.itemVersionId,
1143
+ );
1144
+ if (!detail)
1145
+ throw new Error("Context item version not found or not accessible");
1146
+ const sourceAccess = await resolveAccess(
1147
+ "creative-context-source",
1148
+ detail.item.sourceId,
1149
+ );
1150
+ const children = await Promise.all(
1151
+ detail.edges
1152
+ .filter(
1153
+ (edge) =>
1154
+ edge.relation === "contains-native-child" &&
1155
+ typeof edge.toItemId === "string",
1156
+ )
1157
+ .map((edge) =>
1158
+ getCreativeContextItem(
1159
+ edge.toItemId!,
1160
+ edge.toItemVersionId ?? undefined,
1161
+ ),
1162
+ ),
1163
+ );
1164
+ return {
1165
+ artifactKey: `${detail.item.sourceId}:${detail.item.externalId}`,
1166
+ items: [
1167
+ normalizedFromDetail(detail),
1168
+ ...children.flatMap((child) =>
1169
+ child ? [normalizedFromDetail(child)] : [],
1170
+ ),
1171
+ ],
1172
+ privateMetadata: {},
1173
+ sourceAccess: sourceAccess
1174
+ ? {
1175
+ visibility: sourceAccess.resource.visibility as
1176
+ | "private"
1177
+ | "org"
1178
+ | "public",
1179
+ canManage:
1180
+ sourceAccess.role === "owner" || sourceAccess.role === "admin",
1181
+ }
1182
+ : null,
1183
+ };
1184
+ }
1185
+
1186
+ function visibilityRank(visibility: "private" | "org" | "public") {
1187
+ return visibility === "public" ? 2 : visibility === "org" ? 1 : 0;
1188
+ }
1189
+
1190
+ async function approveSubmission(
1191
+ context: any,
1192
+ membership: any,
1193
+ submission: any,
1194
+ ) {
1195
+ const { getDb, schema } = getCreativeContext();
1196
+ const actor = requireActor();
1197
+ const staged = await readSnapshot(
1198
+ submission.stagingItemId,
1199
+ submission.stagingItemVersionId,
1200
+ );
1201
+ const published = await writeSnapshot({
1202
+ sourceId: context.publishedSourceId,
1203
+ artifactKey: submission.artifactKey,
1204
+ item: staged,
1205
+ ownerEmail: context.ownerEmail,
1206
+ orgId: context.orgId ?? null,
1207
+ });
1208
+ const privateMetadata = parseJson<Record<string, unknown>>(
1209
+ submission.privateMetadata,
1210
+ {},
1211
+ );
1212
+ const publishedChildren: Array<{
1213
+ artifactKey: string;
1214
+ itemId: string;
1215
+ itemVersionId: string;
1216
+ }> = [];
1217
+ for (const child of Array.isArray(privateMetadata.stagedChildren)
1218
+ ? (privateMetadata.stagedChildren as Array<{
1219
+ artifactKey?: string;
1220
+ itemId?: string;
1221
+ itemVersionId?: string;
1222
+ }>)
1223
+ : []) {
1224
+ if (
1225
+ typeof child.artifactKey !== "string" ||
1226
+ typeof child.itemId !== "string" ||
1227
+ typeof child.itemVersionId !== "string"
1228
+ )
1229
+ continue;
1230
+ const publishedChild = await writeSnapshot({
1231
+ sourceId: context.publishedSourceId,
1232
+ artifactKey: child.artifactKey,
1233
+ item: await readSnapshot(child.itemId, child.itemVersionId),
1234
+ ownerEmail: context.ownerEmail,
1235
+ orgId: context.orgId ?? null,
1236
+ });
1237
+ publishedChildren.push({
1238
+ artifactKey: child.artifactKey,
1239
+ ...publishedChild,
1240
+ });
1241
+ }
1242
+ for (const child of publishedChildren) {
1243
+ await getDb()
1244
+ .update(schema.contextEdges)
1245
+ .set({ toItemId: child.itemId, toItemVersionId: child.itemVersionId })
1246
+ .where(
1247
+ and(
1248
+ eq(schema.contextEdges.fromItemVersionId, published.itemVersionId),
1249
+ eq(schema.contextEdges.toExternalId, child.artifactKey),
1250
+ ),
1251
+ );
1252
+ }
1253
+ const timestamp = nowIso();
1254
+ await getDb().transaction(async (tx: any) => {
1255
+ await tx
1256
+ .update(schema.creativeContextSubmissions)
1257
+ .set({
1258
+ status: "approved",
1259
+ publishedItemId: published.itemId,
1260
+ publishedItemVersionId: published.itemVersionId,
1261
+ reviewedBy: actor.ownerEmail,
1262
+ reviewedAt: timestamp,
1263
+ })
1264
+ .where(eq(schema.creativeContextSubmissions.id, submission.id));
1265
+ await tx
1266
+ .update(schema.creativeContextMemberships)
1267
+ .set({
1268
+ publishedItemId: published.itemId,
1269
+ publishedItemVersionId: published.itemVersionId,
1270
+ pendingSubmissionId: null,
1271
+ status: "active",
1272
+ updatedAt: timestamp,
1273
+ })
1274
+ .where(eq(schema.creativeContextMemberships.id, membership.id));
1275
+ for (const child of publishedChildren) {
1276
+ const existingChild = await tx
1277
+ .select({ id: schema.creativeContextMemberships.id })
1278
+ .from(schema.creativeContextMemberships)
1279
+ .where(
1280
+ and(
1281
+ eq(schema.creativeContextMemberships.contextId, context.id),
1282
+ eq(
1283
+ schema.creativeContextMemberships.artifactKey,
1284
+ child.artifactKey,
1285
+ ),
1286
+ ),
1287
+ )
1288
+ .limit(1);
1289
+ const values = {
1290
+ publishedItemId: child.itemId,
1291
+ publishedItemVersionId: child.itemVersionId,
1292
+ pendingSubmissionId: null,
1293
+ status: "active" as const,
1294
+ updatedAt: timestamp,
1295
+ };
1296
+ if (existingChild[0]) {
1297
+ await tx
1298
+ .update(schema.creativeContextMemberships)
1299
+ .set(values)
1300
+ .where(eq(schema.creativeContextMemberships.id, existingChild[0].id));
1301
+ } else {
1302
+ await tx.insert(schema.creativeContextMemberships).values({
1303
+ id: newId("ccmbr"),
1304
+ contextId: context.id,
1305
+ artifactKey: child.artifactKey,
1306
+ ...values,
1307
+ rank: "normal",
1308
+ purpose: "Native artifact child",
1309
+ createdAt: timestamp,
1310
+ ownerEmail: context.ownerEmail,
1311
+ orgId: context.orgId ?? null,
1312
+ });
1313
+ }
1314
+ }
1315
+ await tx.insert(schema.creativeContextPublishedSnapshots).values([
1316
+ {
1317
+ id: newId("ccps"),
1318
+ contextId: context.id,
1319
+ sourceId: context.publishedSourceId,
1320
+ membershipId: membership.id,
1321
+ itemId: published.itemId,
1322
+ itemVersionId: published.itemVersionId,
1323
+ submissionId: submission.id,
1324
+ createdAt: timestamp,
1325
+ ownerEmail: context.ownerEmail,
1326
+ orgId: context.orgId ?? null,
1327
+ },
1328
+ ...publishedChildren.map((child) => ({
1329
+ id: newId("ccps"),
1330
+ contextId: context.id,
1331
+ sourceId: context.publishedSourceId,
1332
+ membershipId: membership.id,
1333
+ itemId: child.itemId,
1334
+ itemVersionId: child.itemVersionId,
1335
+ submissionId: submission.id,
1336
+ createdAt: timestamp,
1337
+ ownerEmail: context.ownerEmail,
1338
+ orgId: context.orgId ?? null,
1339
+ })),
1340
+ ]);
1341
+ await appendAudit(tx, context.id, "approve-submission", {
1342
+ submissionId: submission.id,
1343
+ membershipId: membership.id,
1344
+ });
1345
+ });
1346
+ }
1347
+
1348
+ export async function submitLatestContextMembershipUpdate(input: {
1349
+ contextId: string;
1350
+ membershipId: string;
1351
+ note?: string;
1352
+ confirmBroaderPublication?: boolean;
1353
+ }) {
1354
+ await assertContextRole(input.contextId, "editor");
1355
+ const configured = getCreativeContext();
1356
+ const { getDb, schema } = configured;
1357
+ const [published] = await getDb()
1358
+ .select({
1359
+ membership: schema.creativeContextMemberships,
1360
+ sourceModifiedAt: schema.contextItemVersions.sourceModifiedAt,
1361
+ })
1362
+ .from(schema.creativeContextMemberships)
1363
+ .innerJoin(
1364
+ schema.contextItemVersions,
1365
+ eq(
1366
+ schema.contextItemVersions.id,
1367
+ schema.creativeContextMemberships.publishedItemVersionId,
1368
+ ),
1369
+ )
1370
+ .where(
1371
+ and(
1372
+ eq(schema.creativeContextMemberships.id, input.membershipId),
1373
+ eq(schema.creativeContextMemberships.contextId, input.contextId),
1374
+ eq(schema.creativeContextMemberships.status, "active"),
1375
+ ),
1376
+ )
1377
+ .limit(1);
1378
+ if (!published) throw new Error("Published context membership not found");
1379
+
1380
+ const reference = parseNativeCreativeArtifactKey(
1381
+ published.membership.artifactKey,
1382
+ );
1383
+ if (!reference || reference.appId !== configured.connectorContext.appId) {
1384
+ throw new Error(
1385
+ "This context membership is not backed by a native resource in the active app",
1386
+ );
1387
+ }
1388
+ const statuses = await resolveNativeCreativeResourceUpdateStatuses([
1389
+ {
1390
+ key: published.membership.id,
1391
+ ...reference,
1392
+ publishedSourceModifiedAt: published.sourceModifiedAt,
1393
+ },
1394
+ ]);
1395
+ const status = statuses.get(published.membership.id);
1396
+ if (!status)
1397
+ throw new Error(
1398
+ "Native resource not found or update status is unavailable",
1399
+ );
1400
+ if (status.state !== "update-available")
1401
+ throw new Error("This context membership is already up to date");
1402
+
1403
+ return manageContextMembership({
1404
+ operation: "submit",
1405
+ contextId: input.contextId,
1406
+ nativeResource: status.reference,
1407
+ note: input.note,
1408
+ rank: published.membership.rank,
1409
+ purpose: published.membership.purpose ?? undefined,
1410
+ confirmBroaderPublication: input.confirmBroaderPublication,
1411
+ });
1412
+ }
1413
+
1414
+ export async function manageContextMembership(input: {
1415
+ operation: "submit" | "approve" | "request-changes" | "withdraw" | "remove";
1416
+ contextId: string;
1417
+ membershipId?: string;
1418
+ itemId?: string;
1419
+ itemVersionId?: string;
1420
+ nativeResource?: NativeCreativeResourceRef;
1421
+ note?: string;
1422
+ rank?: Rank;
1423
+ purpose?: string;
1424
+ confirmBroaderPublication?: boolean;
1425
+ }) {
1426
+ const { getDb, schema } = getCreativeContext();
1427
+ const access = await assertContextRole(
1428
+ input.contextId,
1429
+ input.operation === "submit" ? "editor" : "viewer",
1430
+ );
1431
+ const [context] = await getDb()
1432
+ .select()
1433
+ .from(schema.creativeContexts)
1434
+ .where(eq(schema.creativeContexts.id, access.resource.id))
1435
+ .limit(1);
1436
+ if (!context) throw new Error("Creative Context no longer exists");
1437
+ const actor = requireActor();
1438
+ if (input.operation === "submit") {
1439
+ const captured = await resolveSubmissionItem(input);
1440
+ if (
1441
+ !captured.sourceAccess ||
1442
+ visibilityRank(context.visibility) >
1443
+ visibilityRank(captured.sourceAccess.visibility)
1444
+ ) {
1445
+ if (!captured.sourceAccess?.canManage) {
1446
+ throw new Error(
1447
+ "Only a source manager may publish this artifact into a broader Creative Context",
1448
+ );
1449
+ }
1450
+ if (!input.confirmBroaderPublication) {
1451
+ throw new Error(
1452
+ "Confirm broader publication before submitting an artifact into a broader Creative Context",
1453
+ );
1454
+ }
1455
+ }
1456
+ const root = captured.items[0];
1457
+ if (!root) throw new Error("Submission did not include a root artifact");
1458
+ const timestamp = nowIso();
1459
+ let membership = (
1460
+ await getDb()
1461
+ .select()
1462
+ .from(schema.creativeContextMemberships)
1463
+ .where(
1464
+ and(
1465
+ eq(schema.creativeContextMemberships.contextId, input.contextId),
1466
+ eq(
1467
+ schema.creativeContextMemberships.artifactKey,
1468
+ captured.artifactKey,
1469
+ ),
1470
+ ),
1471
+ )
1472
+ .limit(1)
1473
+ )[0] as any;
1474
+ const membershipId = membership?.id ?? newId("ccmbr");
1475
+ const staged = await writeSnapshot({
1476
+ sourceId: context.stagingSourceId,
1477
+ artifactKey: captured.artifactKey,
1478
+ item: root,
1479
+ ownerEmail: context.ownerEmail,
1480
+ orgId: context.orgId ?? null,
1481
+ });
1482
+ const stagedChildren = await Promise.all(
1483
+ captured.items.slice(1).map(async (item) => {
1484
+ const snapshot = await writeSnapshot({
1485
+ sourceId: context.stagingSourceId,
1486
+ artifactKey: item.externalId,
1487
+ item,
1488
+ ownerEmail: context.ownerEmail,
1489
+ orgId: context.orgId ?? null,
1490
+ });
1491
+ return {
1492
+ artifactKey: item.externalId,
1493
+ ...snapshot,
1494
+ };
1495
+ }),
1496
+ );
1497
+ for (const child of stagedChildren) {
1498
+ await getDb()
1499
+ .update(schema.contextEdges)
1500
+ .set({ toItemId: child.itemId, toItemVersionId: child.itemVersionId })
1501
+ .where(
1502
+ and(
1503
+ eq(schema.contextEdges.fromItemVersionId, staged.itemVersionId),
1504
+ eq(schema.contextEdges.toExternalId, child.artifactKey),
1505
+ ),
1506
+ );
1507
+ }
1508
+ const submissionId = newId("ccsub");
1509
+ const autoApprove =
1510
+ context.approvalPolicy === "open" ||
1511
+ (context.approvalPolicy === "admins-only" &&
1512
+ (await currentRequestUserIsOrgAdmin(context.orgId ?? undefined)));
1513
+ await getDb().transaction(async (tx: any) => {
1514
+ if (membership?.pendingSubmissionId)
1515
+ await tx
1516
+ .update(schema.creativeContextSubmissions)
1517
+ .set({
1518
+ status: "superseded",
1519
+ reviewedBy: actor.ownerEmail,
1520
+ reviewedAt: timestamp,
1521
+ })
1522
+ .where(
1523
+ eq(
1524
+ schema.creativeContextSubmissions.id,
1525
+ membership.pendingSubmissionId,
1526
+ ),
1527
+ );
1528
+ if (!membership)
1529
+ await tx.insert(schema.creativeContextMemberships).values({
1530
+ id: membershipId,
1531
+ contextId: input.contextId,
1532
+ artifactKey: captured.artifactKey,
1533
+ publishedItemId: null,
1534
+ publishedItemVersionId: null,
1535
+ pendingSubmissionId: submissionId,
1536
+ rank: input.rank ?? "normal",
1537
+ purpose: input.purpose ?? null,
1538
+ status: "active",
1539
+ createdAt: timestamp,
1540
+ updatedAt: timestamp,
1541
+ ownerEmail: context.ownerEmail,
1542
+ orgId: context.orgId ?? null,
1543
+ });
1544
+ else
1545
+ await tx
1546
+ .update(schema.creativeContextMemberships)
1547
+ .set({
1548
+ pendingSubmissionId: submissionId,
1549
+ rank: input.rank ?? membership.rank,
1550
+ purpose: input.purpose ?? membership.purpose,
1551
+ status: "active",
1552
+ updatedAt: timestamp,
1553
+ })
1554
+ .where(eq(schema.creativeContextMemberships.id, membershipId));
1555
+ await tx.insert(schema.creativeContextSubmissions).values({
1556
+ id: submissionId,
1557
+ contextId: input.contextId,
1558
+ membershipId,
1559
+ artifactKey: captured.artifactKey,
1560
+ stagingItemId: staged.itemId,
1561
+ stagingItemVersionId: staged.itemVersionId,
1562
+ publishedItemId: null,
1563
+ publishedItemVersionId: null,
1564
+ note: input.note ?? null,
1565
+ privateMetadata: stringifyJson({
1566
+ ...captured.privateMetadata,
1567
+ stagedChildren,
1568
+ }),
1569
+ status: "pending",
1570
+ submittedBy: actor.ownerEmail,
1571
+ reviewedBy: null,
1572
+ reviewNote: null,
1573
+ createdAt: timestamp,
1574
+ reviewedAt: null,
1575
+ ownerEmail: context.ownerEmail,
1576
+ orgId: context.orgId ?? null,
1577
+ });
1578
+ await appendAudit(tx, input.contextId, "submit", {
1579
+ membershipId,
1580
+ submissionId,
1581
+ childCount: stagedChildren.length,
1582
+ });
1583
+ });
1584
+ membership = { id: membershipId };
1585
+ const submission = (
1586
+ await getDb()
1587
+ .select()
1588
+ .from(schema.creativeContextSubmissions)
1589
+ .where(eq(schema.creativeContextSubmissions.id, submissionId))
1590
+ .limit(1)
1591
+ )[0];
1592
+ if (autoApprove) await approveSubmission(context, membership, submission);
1593
+ return {
1594
+ membershipId,
1595
+ submission: mapSubmission(
1596
+ (
1597
+ await getDb()
1598
+ .select()
1599
+ .from(schema.creativeContextSubmissions)
1600
+ .where(eq(schema.creativeContextSubmissions.id, submissionId))
1601
+ .limit(1)
1602
+ )[0],
1603
+ ),
1604
+ };
1605
+ }
1606
+ if (!input.membershipId) throw new Error("membershipId is required");
1607
+ const membership = (
1608
+ await getDb()
1609
+ .select()
1610
+ .from(schema.creativeContextMemberships)
1611
+ .where(
1612
+ and(
1613
+ eq(schema.creativeContextMemberships.id, input.membershipId),
1614
+ eq(schema.creativeContextMemberships.contextId, input.contextId),
1615
+ ),
1616
+ )
1617
+ .limit(1)
1618
+ )[0] as any;
1619
+ if (!membership) throw new Error("Context membership not found");
1620
+ if (input.operation === "remove") {
1621
+ await requireReviewer(input.contextId);
1622
+ const timestamp = nowIso();
1623
+ await getDb().transaction(async (tx: any) => {
1624
+ await tx
1625
+ .update(schema.creativeContextMemberships)
1626
+ .set({
1627
+ status: "removed",
1628
+ pendingSubmissionId: null,
1629
+ updatedAt: timestamp,
1630
+ })
1631
+ .where(eq(schema.creativeContextMemberships.id, membership.id));
1632
+ const snapshots = await tx
1633
+ .select({
1634
+ itemId: schema.creativeContextPublishedSnapshots.itemId,
1635
+ itemVersionId: schema.creativeContextPublishedSnapshots.itemVersionId,
1636
+ })
1637
+ .from(schema.creativeContextPublishedSnapshots)
1638
+ .where(
1639
+ eq(
1640
+ schema.creativeContextPublishedSnapshots.membershipId,
1641
+ membership.id,
1642
+ ),
1643
+ );
1644
+ const dependencies = (
1645
+ snapshots as Array<{
1646
+ itemId: string;
1647
+ itemVersionId: string;
1648
+ }>
1649
+ ).filter(
1650
+ (snapshot) =>
1651
+ snapshot.itemId !== membership.publishedItemId ||
1652
+ snapshot.itemVersionId !== membership.publishedItemVersionId,
1653
+ );
1654
+ if (!dependencies.length) return;
1655
+ const dependentMemberships = await tx
1656
+ .select({ id: schema.creativeContextMemberships.id })
1657
+ .from(schema.creativeContextMemberships)
1658
+ .where(
1659
+ and(
1660
+ eq(schema.creativeContextMemberships.contextId, input.contextId),
1661
+ eq(schema.creativeContextMemberships.status, "active"),
1662
+ eq(
1663
+ schema.creativeContextMemberships.purpose,
1664
+ "Native artifact child",
1665
+ ),
1666
+ or(
1667
+ ...dependencies.map((dependency) =>
1668
+ and(
1669
+ eq(
1670
+ schema.creativeContextMemberships.publishedItemId,
1671
+ dependency.itemId,
1672
+ ),
1673
+ eq(
1674
+ schema.creativeContextMemberships.publishedItemVersionId,
1675
+ dependency.itemVersionId,
1676
+ ),
1677
+ ),
1678
+ ),
1679
+ ),
1680
+ ),
1681
+ );
1682
+ const dependentIds = (dependentMemberships as Array<{ id: string }>).map(
1683
+ (row) => row.id,
1684
+ );
1685
+ if (dependentIds.length) {
1686
+ await tx
1687
+ .update(schema.creativeContextMemberships)
1688
+ .set({
1689
+ status: "removed",
1690
+ pendingSubmissionId: null,
1691
+ updatedAt: timestamp,
1692
+ })
1693
+ .where(inArray(schema.creativeContextMemberships.id, dependentIds));
1694
+ }
1695
+ });
1696
+ return {
1697
+ membership: mapMembership({
1698
+ ...membership,
1699
+ status: "removed",
1700
+ pendingSubmissionId: null,
1701
+ }),
1702
+ };
1703
+ }
1704
+ const submission = membership.pendingSubmissionId
1705
+ ? ((
1706
+ await getDb()
1707
+ .select()
1708
+ .from(schema.creativeContextSubmissions)
1709
+ .where(
1710
+ eq(
1711
+ schema.creativeContextSubmissions.id,
1712
+ membership.pendingSubmissionId,
1713
+ ),
1714
+ )
1715
+ .limit(1)
1716
+ )[0] as any)
1717
+ : null;
1718
+ if (!submission) throw new Error("No pending submission for this membership");
1719
+ if (input.operation === "withdraw") {
1720
+ if (submission.submittedBy !== actor.ownerEmail)
1721
+ await requireReviewer(input.contextId);
1722
+ await getDb().transaction(async (tx: any) => {
1723
+ await tx
1724
+ .update(schema.creativeContextSubmissions)
1725
+ .set({
1726
+ status: "withdrawn",
1727
+ reviewedBy: actor.ownerEmail,
1728
+ reviewedAt: nowIso(),
1729
+ })
1730
+ .where(eq(schema.creativeContextSubmissions.id, submission.id));
1731
+ await tx
1732
+ .update(schema.creativeContextMemberships)
1733
+ .set({ pendingSubmissionId: null, updatedAt: nowIso() })
1734
+ .where(eq(schema.creativeContextMemberships.id, membership.id));
1735
+ });
1736
+ return { withdrawn: true };
1737
+ }
1738
+ await requireReviewer(input.contextId);
1739
+ if (input.operation === "request-changes") {
1740
+ await getDb().transaction(async (tx: any) => {
1741
+ await tx
1742
+ .update(schema.creativeContextSubmissions)
1743
+ .set({
1744
+ status: "rejected",
1745
+ reviewedBy: actor.ownerEmail,
1746
+ reviewNote: input.note ?? "Changes requested",
1747
+ reviewedAt: nowIso(),
1748
+ })
1749
+ .where(eq(schema.creativeContextSubmissions.id, submission.id));
1750
+ await tx
1751
+ .update(schema.creativeContextMemberships)
1752
+ .set({ pendingSubmissionId: null, updatedAt: nowIso() })
1753
+ .where(eq(schema.creativeContextMemberships.id, membership.id));
1754
+ await appendAudit(tx, input.contextId, "request-changes", {
1755
+ submissionId: submission.id,
1756
+ });
1757
+ });
1758
+ return { requestChanges: true };
1759
+ }
1760
+ await approveSubmission(context, membership, submission);
1761
+ return { approved: true };
1762
+ }
1763
+
1764
+ export async function resolveNativeContextCloneReference(
1765
+ input: NativeCreativeResourceRef & { contextId: string; artifactKey: string },
1766
+ ) {
1767
+ await assertContextRole(input.contextId, "viewer");
1768
+ const { getDb, schema } = getCreativeContext();
1769
+ const [membership] = await getDb()
1770
+ .select()
1771
+ .from(schema.creativeContextMemberships)
1772
+ .where(
1773
+ and(
1774
+ eq(schema.creativeContextMemberships.contextId, input.contextId),
1775
+ eq(schema.creativeContextMemberships.artifactKey, input.artifactKey),
1776
+ eq(schema.creativeContextMemberships.status, "active"),
1777
+ ),
1778
+ )
1779
+ .limit(1);
1780
+ if (!membership?.publishedItemId || !membership?.publishedItemVersionId)
1781
+ throw new Error("Creative context artifact is not published");
1782
+ const [submission] = await getDb()
1783
+ .select()
1784
+ .from(schema.creativeContextSubmissions)
1785
+ .where(
1786
+ and(
1787
+ eq(schema.creativeContextSubmissions.membershipId, membership.id),
1788
+ eq(schema.creativeContextSubmissions.status, "approved"),
1789
+ eq(
1790
+ schema.creativeContextSubmissions.publishedItemVersionId,
1791
+ membership.publishedItemVersionId,
1792
+ ),
1793
+ ),
1794
+ )
1795
+ .limit(1);
1796
+ const metadata = parseJson<Record<string, unknown>>(
1797
+ submission?.privateMetadata,
1798
+ {},
1799
+ );
1800
+ const clone = metadata.clone as
1801
+ | {
1802
+ handle?: unknown;
1803
+ appId?: unknown;
1804
+ resourceType?: unknown;
1805
+ resourceId?: unknown;
1806
+ updatedAt?: unknown;
1807
+ }
1808
+ | undefined;
1809
+ if (
1810
+ !clone ||
1811
+ !clone.handle ||
1812
+ clone.appId !== input.appId ||
1813
+ clone.resourceType !== input.resourceType ||
1814
+ clone.resourceId !== input.resourceId ||
1815
+ (input.expectedUpdatedAt !== undefined &&
1816
+ clone.updatedAt !== input.expectedUpdatedAt)
1817
+ )
1818
+ throw new Error(
1819
+ "Native creative resource reference does not match the governed context submission",
1820
+ );
1821
+ return {
1822
+ publishedItemId: membership.publishedItemId,
1823
+ publishedItemVersionId: membership.publishedItemVersionId,
1824
+ cloneHandle: clone.handle,
1825
+ };
1826
+ }