@agent-native/core 0.102.2 → 0.103.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 (206) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +6 -0
  3. package/corpus/core/docs/content/local-file-mode.mdx +99 -489
  4. package/corpus/core/docs/content/locales/ar-SA/local-file-mode.mdx +35 -490
  5. package/corpus/core/docs/content/locales/ar-SA/template-content.mdx +10 -24
  6. package/corpus/core/docs/content/locales/de-DE/local-file-mode.mdx +37 -491
  7. package/corpus/core/docs/content/locales/de-DE/template-content.mdx +10 -24
  8. package/corpus/core/docs/content/locales/es-ES/local-file-mode.mdx +35 -493
  9. package/corpus/core/docs/content/locales/es-ES/template-content.mdx +10 -24
  10. package/corpus/core/docs/content/locales/fr-FR/local-file-mode.mdx +38 -491
  11. package/corpus/core/docs/content/locales/fr-FR/template-content.mdx +10 -24
  12. package/corpus/core/docs/content/locales/hi-IN/local-file-mode.mdx +33 -492
  13. package/corpus/core/docs/content/locales/hi-IN/template-content.mdx +10 -24
  14. package/corpus/core/docs/content/locales/ja-JP/local-file-mode.mdx +36 -492
  15. package/corpus/core/docs/content/locales/ja-JP/template-content.mdx +10 -24
  16. package/corpus/core/docs/content/locales/ko-KR/local-file-mode.mdx +35 -490
  17. package/corpus/core/docs/content/locales/ko-KR/template-content.mdx +10 -24
  18. package/corpus/core/docs/content/locales/pt-BR/local-file-mode.mdx +35 -493
  19. package/corpus/core/docs/content/locales/pt-BR/template-content.mdx +10 -24
  20. package/corpus/core/docs/content/locales/zh-CN/local-file-mode.mdx +31 -489
  21. package/corpus/core/docs/content/locales/zh-CN/template-content.mdx +9 -24
  22. package/corpus/core/docs/content/locales/zh-TW/local-file-mode.mdx +31 -489
  23. package/corpus/core/docs/content/locales/zh-TW/template-content.mdx +9 -24
  24. package/corpus/core/docs/content/template-content.mdx +26 -42
  25. package/corpus/core/package.json +1 -1
  26. package/corpus/core/src/cli/content-local.ts +86 -18
  27. package/corpus/core/src/cli/index.ts +2 -2
  28. package/corpus/core/src/cli/skills-content/content-skill.ts +36 -25
  29. package/corpus/core/src/cli/skills.ts +47 -13
  30. package/corpus/core/src/local-artifacts/index.ts +65 -0
  31. package/corpus/templates/assets/app/components/library/GenerationPresetsPanel.tsx +325 -0
  32. package/corpus/templates/assets/app/hooks/use-navigation-state.ts +12 -0
  33. package/corpus/templates/assets/app/i18n/zh-TW.ts +21 -0
  34. package/corpus/templates/assets/app/i18n-data.ts +215 -0
  35. package/corpus/templates/assets/app/routes/brand-kits.$id.tsx +19 -479
  36. package/corpus/templates/assets/app/routes/brand-kits.$id_.presets.$presetId.tsx +1 -1
  37. package/corpus/templates/assets/app/routes/brand-kits.$id_.settings.tsx +446 -0
  38. package/corpus/templates/clips/app/components/editor/editor-layout.tsx +12 -2
  39. package/corpus/templates/clips/app/components/player/settings-panel.tsx +30 -12
  40. package/corpus/templates/clips/app/components/player/video-player.tsx +5 -1
  41. package/corpus/templates/clips/app/components/recorder/microphone-visualizer.tsx +22 -3
  42. package/corpus/templates/clips/app/i18n/ar-SA.ts +3 -2
  43. package/corpus/templates/clips/app/i18n/de-DE.ts +4 -2
  44. package/corpus/templates/clips/app/i18n/en-US.ts +3 -2
  45. package/corpus/templates/clips/app/i18n/es-ES.ts +4 -2
  46. package/corpus/templates/clips/app/i18n/fr-FR.ts +3 -2
  47. package/corpus/templates/clips/app/i18n/hi-IN.ts +3 -2
  48. package/corpus/templates/clips/app/i18n/ja-JP.ts +4 -1
  49. package/corpus/templates/clips/app/i18n/ko-KR.ts +3 -2
  50. package/corpus/templates/clips/app/i18n/pt-BR.ts +4 -2
  51. package/corpus/templates/clips/app/i18n/zh-CN.ts +3 -2
  52. package/corpus/templates/clips/app/i18n/zh-TW.ts +3 -2
  53. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +27 -1
  54. package/corpus/templates/clips/server/lib/share-password.ts +6 -3
  55. package/corpus/templates/content/.agents/skills/content/SKILL.md +26 -39
  56. package/corpus/templates/content/AGENTS.md +25 -30
  57. package/corpus/templates/content/actions/_builder-docs-client.ts +23 -0
  58. package/corpus/templates/content/actions/_content-database-source-adapters.ts +21 -3
  59. package/corpus/templates/content/actions/_content-files.ts +338 -0
  60. package/corpus/templates/content/actions/_content-space-access.ts +135 -0
  61. package/corpus/templates/content/actions/_content-spaces.ts +640 -0
  62. package/corpus/templates/content/actions/_database-source-utils.ts +90 -7
  63. package/corpus/templates/content/actions/_database-utils.ts +7 -1
  64. package/corpus/templates/content/actions/_local-file-documents.ts +0 -9
  65. package/corpus/templates/content/actions/_local-folder-source.ts +53 -0
  66. package/corpus/templates/content/actions/_property-utils.ts +9 -4
  67. package/corpus/templates/content/actions/add-database-item.ts +108 -65
  68. package/corpus/templates/content/actions/backfill-content-files.ts +22 -0
  69. package/corpus/templates/content/actions/connect-local-folder-source.ts +221 -0
  70. package/corpus/templates/content/actions/create-content-database.ts +129 -3
  71. package/corpus/templates/content/actions/create-document.ts +65 -55
  72. package/corpus/templates/content/actions/create-inline-content-database.ts +6 -1
  73. package/corpus/templates/content/actions/delete-content-database.ts +3 -0
  74. package/corpus/templates/content/actions/delete-document.ts +7 -11
  75. package/corpus/templates/content/actions/disconnect-local-folder-source.ts +175 -0
  76. package/corpus/templates/content/actions/duplicate-database-item.ts +10 -0
  77. package/corpus/templates/content/actions/duplicate-database-items.ts +13 -0
  78. package/corpus/templates/content/actions/edit-document.ts +2 -28
  79. package/corpus/templates/content/actions/ensure-content-spaces.ts +26 -0
  80. package/corpus/templates/content/actions/export-content-source.ts +47 -9
  81. package/corpus/templates/content/actions/get-document.ts +0 -14
  82. package/corpus/templates/content/actions/import-content-source.ts +43 -3
  83. package/corpus/templates/content/actions/list-content-spaces.ts +103 -0
  84. package/corpus/templates/content/actions/list-documents.ts +12 -9
  85. package/corpus/templates/content/actions/move-document.ts +3 -14
  86. package/corpus/templates/content/actions/pull-document.ts +0 -23
  87. package/corpus/templates/content/actions/remove-local-file-source.ts +50 -29
  88. package/corpus/templates/content/actions/resolve-local-folder-conflict.ts +442 -0
  89. package/corpus/templates/content/actions/reveal-local-source-file.ts +6 -17
  90. package/corpus/templates/content/actions/search-documents.ts +0 -34
  91. package/corpus/templates/content/actions/share-local-file-document.ts +25 -1
  92. package/corpus/templates/content/actions/submit-content-database-form.ts +12 -0
  93. package/corpus/templates/content/actions/sync-local-folder-source.ts +731 -0
  94. package/corpus/templates/content/actions/sync-manifest-local-folder-source.ts +123 -0
  95. package/corpus/templates/content/actions/update-content-database-view.ts +10 -1
  96. package/corpus/templates/content/actions/update-document.ts +0 -15
  97. package/corpus/templates/content/actions/view-screen.ts +2 -50
  98. package/corpus/templates/content/app/components/editor/SlashCommandMenu.tsx +76 -62
  99. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +70 -22
  100. package/corpus/templates/content/app/components/editor/database/grouping.ts +2 -1
  101. package/corpus/templates/content/app/components/editor/database/settings.tsx +23 -20
  102. package/corpus/templates/content/app/components/editor/database/shared.tsx +2 -0
  103. package/corpus/templates/content/app/components/editor/database/sidebar.tsx +330 -0
  104. package/corpus/templates/content/app/components/editor/database/types.ts +1 -0
  105. package/corpus/templates/content/app/components/editor/database/view-config.ts +3 -1
  106. package/corpus/templates/content/app/components/layout/Layout.tsx +25 -1
  107. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +209 -113
  108. package/corpus/templates/content/app/components/sidebar/select-content-space.ts +31 -0
  109. package/corpus/templates/content/app/hooks/use-content-database.ts +16 -0
  110. package/corpus/templates/content/app/hooks/use-content-spaces.ts +39 -0
  111. package/corpus/templates/content/app/i18n/zh-TW.ts +11 -0
  112. package/corpus/templates/content/app/i18n-data.ts +75 -0
  113. package/corpus/templates/content/app/routes/_app.local-files.tsx +174 -65
  114. package/corpus/templates/content/changelog/2026-07-16-content-callouts-can-now-be-reviewed-and-published-to-builder.md +6 -0
  115. package/corpus/templates/content/changelog/2026-07-16-heading-5-and-heading-6-are-available-from-the-editor-slash-.md +6 -0
  116. package/corpus/templates/content/changelog/2026-07-16-organize-every-workspace-through-a-customizable-files-databa.md +6 -0
  117. package/corpus/templates/content/parity/matrix.md +2 -1
  118. package/corpus/templates/content/parity/matrix.ts +37 -1
  119. package/corpus/templates/content/scripts/dev-database.mjs +7 -5
  120. package/corpus/templates/content/server/db/schema.ts +113 -40
  121. package/corpus/templates/content/server/lib/document-context.ts +15 -1
  122. package/corpus/templates/content/server/lib/notion-sync.ts +6 -0
  123. package/corpus/templates/content/server/plugins/db.ts +153 -0
  124. package/corpus/templates/content/shared/api.ts +17 -3
  125. package/corpus/templates/content/shared/builder-mdx.ts +163 -16
  126. package/corpus/templates/design/.agents/skills/design-templates/SKILL.md +59 -0
  127. package/corpus/templates/design/AGENTS.md +6 -1
  128. package/corpus/templates/design/actions/create-design-from-template.ts +19 -7
  129. package/corpus/templates/design/actions/list-design-templates.ts +69 -21
  130. package/corpus/templates/design/actions/navigate.ts +2 -2
  131. package/corpus/templates/design/actions/view-screen.ts +15 -2
  132. package/corpus/templates/design/agent-native.app-skill.json +5 -0
  133. package/corpus/templates/design/app/components/editor/PromptDialog.tsx +281 -56
  134. package/corpus/templates/design/app/components/templates/TemplatePreview.tsx +14 -2
  135. package/corpus/templates/design/app/hooks/use-navigation-state.ts +3 -2
  136. package/corpus/templates/design/app/i18n/zh-TW.ts +3 -3
  137. package/corpus/templates/design/app/i18n-data.ts +21 -2
  138. package/corpus/templates/design/app/i18n-template-feature.ts +218 -10
  139. package/corpus/templates/design/app/pages/Index.tsx +195 -24
  140. package/corpus/templates/design/app/pages/Templates.tsx +144 -55
  141. package/corpus/templates/design/app/pages/design-editor/generation-prompt-directives.ts +12 -1
  142. package/corpus/templates/design/changelog/2026-07-14-templates-now-appear-in-the-new-design-picker-with-previews-.md +6 -0
  143. package/corpus/templates/design/changelog/2026-07-15-templates-now-open-immediately-after-copying-even-while-the-.md +6 -0
  144. package/corpus/templates/design/server/plugins/agent-chat.ts +1 -1
  145. package/corpus/templates/mail/AGENTS.md +23 -18
  146. package/corpus/templates/mail/actions/bulk-archive.ts +32 -28
  147. package/corpus/templates/mail/actions/mark-read.ts +131 -20
  148. package/corpus/templates/mail/actions/mark-thread-read.ts +1 -1
  149. package/corpus/templates/mail/actions/move-email.ts +30 -24
  150. package/corpus/templates/mail/actions/send-email.ts +73 -68
  151. package/corpus/templates/mail/changelog/2026-07-15-unread-inbox-cleanup-now-completes-reliably.md +6 -0
  152. package/corpus/templates/mail/server/handlers/emails.ts +218 -209
  153. package/corpus/templates/mail/server/lib/bulk-mark-read.ts +30 -0
  154. package/corpus/templates/mail/server/lib/email-state.ts +198 -113
  155. package/corpus/templates/mail/server/lib/google-auth.ts +218 -25
  156. package/corpus/templates/mail/server/lib/jobs.ts +69 -74
  157. package/corpus/templates/mail/server/lib/local-email-store.ts +265 -0
  158. package/corpus/templates/mail/server/plugins/agent-chat.ts +6 -0
  159. package/dist/cli/content-local.d.ts +1 -0
  160. package/dist/cli/content-local.d.ts.map +1 -1
  161. package/dist/cli/content-local.js +75 -17
  162. package/dist/cli/content-local.js.map +1 -1
  163. package/dist/cli/index.js +2 -2
  164. package/dist/cli/index.js.map +1 -1
  165. package/dist/cli/skills-content/content-skill.d.ts +1 -1
  166. package/dist/cli/skills-content/content-skill.d.ts.map +1 -1
  167. package/dist/cli/skills-content/content-skill.js +36 -25
  168. package/dist/cli/skills-content/content-skill.js.map +1 -1
  169. package/dist/cli/skills.d.ts.map +1 -1
  170. package/dist/cli/skills.js +47 -14
  171. package/dist/cli/skills.js.map +1 -1
  172. package/dist/collab/awareness.d.ts +2 -2
  173. package/dist/collab/awareness.d.ts.map +1 -1
  174. package/dist/collab/struct-routes.d.ts +1 -1
  175. package/dist/local-artifacts/index.d.ts +10 -0
  176. package/dist/local-artifacts/index.d.ts.map +1 -1
  177. package/dist/local-artifacts/index.js +39 -0
  178. package/dist/local-artifacts/index.js.map +1 -1
  179. package/dist/notifications/routes.d.ts +1 -1
  180. package/dist/observability/routes.d.ts +5 -5
  181. package/dist/resources/handlers.d.ts +1 -1
  182. package/dist/secrets/routes.d.ts +9 -9
  183. package/docs/content/local-file-mode.mdx +99 -489
  184. package/docs/content/locales/ar-SA/local-file-mode.mdx +35 -490
  185. package/docs/content/locales/ar-SA/template-content.mdx +10 -24
  186. package/docs/content/locales/de-DE/local-file-mode.mdx +37 -491
  187. package/docs/content/locales/de-DE/template-content.mdx +10 -24
  188. package/docs/content/locales/es-ES/local-file-mode.mdx +35 -493
  189. package/docs/content/locales/es-ES/template-content.mdx +10 -24
  190. package/docs/content/locales/fr-FR/local-file-mode.mdx +38 -491
  191. package/docs/content/locales/fr-FR/template-content.mdx +10 -24
  192. package/docs/content/locales/hi-IN/local-file-mode.mdx +33 -492
  193. package/docs/content/locales/hi-IN/template-content.mdx +10 -24
  194. package/docs/content/locales/ja-JP/local-file-mode.mdx +36 -492
  195. package/docs/content/locales/ja-JP/template-content.mdx +10 -24
  196. package/docs/content/locales/ko-KR/local-file-mode.mdx +35 -490
  197. package/docs/content/locales/ko-KR/template-content.mdx +10 -24
  198. package/docs/content/locales/pt-BR/local-file-mode.mdx +35 -493
  199. package/docs/content/locales/pt-BR/template-content.mdx +10 -24
  200. package/docs/content/locales/zh-CN/local-file-mode.mdx +31 -489
  201. package/docs/content/locales/zh-CN/template-content.mdx +9 -24
  202. package/docs/content/locales/zh-TW/local-file-mode.mdx +31 -489
  203. package/docs/content/locales/zh-TW/template-content.mdx +9 -24
  204. package/docs/content/template-content.mdx +26 -42
  205. package/package.json +1 -1
  206. package/corpus/templates/assets/app/components/library/EditLibraryDialog.tsx +0 -126
@@ -0,0 +1,338 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { accessFilter } from "@agent-native/core/sharing";
4
+ import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
5
+
6
+ import { schema } from "../server/db/index.js";
7
+ import {
8
+ listContentOrganizationMemberships,
9
+ normalizeContentSpaceEmail,
10
+ resolveContentSpaceAccess,
11
+ } from "./_content-space-access.js";
12
+ import {
13
+ organizationContentSpaceId,
14
+ personalContentSpaceId,
15
+ } from "./_content-spaces.js";
16
+
17
+ type Db = any;
18
+
19
+ export type ContentFilesReconciliation = {
20
+ assignedSpaces: number;
21
+ insertedMemberships: number;
22
+ removedMemberships: number;
23
+ documents: number;
24
+ };
25
+
26
+ export function contentFilesItemId(databaseId: string, documentId: string) {
27
+ return `content_database_item_${createHash("sha256")
28
+ .update(`${databaseId}:${documentId}`)
29
+ .digest("hex")
30
+ .slice(0, 32)}`;
31
+ }
32
+
33
+ async function remapItemReferences(db: Db, replacements: Map<string, string>) {
34
+ if (!replacements.size) return;
35
+ const duplicateIds = [...replacements.keys()];
36
+ const remap = (column: any) => {
37
+ let expression = sql`CASE ${column}`;
38
+ for (const [duplicateId, canonicalId] of replacements)
39
+ expression = sql`${expression} WHEN ${duplicateId} THEN ${canonicalId}`;
40
+ return sql`${expression} ELSE ${column} END`;
41
+ };
42
+ for (const table of [
43
+ schema.contentSpaceCatalogItems,
44
+ schema.contentDatabaseBodyHydrationQueue,
45
+ schema.contentDatabaseSourceRows,
46
+ schema.contentDatabaseSourceChangeSets,
47
+ ]) {
48
+ await db
49
+ .update(table)
50
+ .set({ databaseItemId: remap(table.databaseItemId) })
51
+ .where(inArray(table.databaseItemId, duplicateIds));
52
+ }
53
+ }
54
+
55
+ async function reconcileDocuments(args: {
56
+ db: Db;
57
+ documents: Array<typeof schema.documents.$inferSelect>;
58
+ filesDatabases: Array<typeof schema.contentDatabases.$inferSelect>;
59
+ now: string;
60
+ }) {
61
+ const filesBySpace = new Map(
62
+ args.filesDatabases.map((database) => [database.spaceId, database]),
63
+ );
64
+ const filesDatabaseIds = args.filesDatabases.map((database) => database.id);
65
+ const filesBackingDocumentIds = new Set(
66
+ args.filesDatabases.map((database) => database.documentId),
67
+ );
68
+ const existingItems = filesDatabaseIds.length
69
+ ? await args.db
70
+ .select()
71
+ .from(schema.contentDatabaseItems)
72
+ .where(
73
+ inArray(schema.contentDatabaseItems.databaseId, filesDatabaseIds),
74
+ )
75
+ : [];
76
+ const itemsByDocument = new Map<
77
+ string,
78
+ Array<typeof schema.contentDatabaseItems.$inferSelect>
79
+ >();
80
+ const nextPosition = new Map<string, number>();
81
+ for (const item of existingItems) {
82
+ const documentItems = itemsByDocument.get(item.documentId) ?? [];
83
+ documentItems.push(item);
84
+ itemsByDocument.set(item.documentId, documentItems);
85
+ nextPosition.set(
86
+ item.databaseId,
87
+ Math.max(nextPosition.get(item.databaseId) ?? 0, item.position + 1),
88
+ );
89
+ }
90
+
91
+ const deleteIds = new Set<string>();
92
+ const replacements = new Map<string, string>();
93
+ const inserts: Array<typeof schema.contentDatabaseItems.$inferInsert> = [];
94
+ for (const document of args.documents) {
95
+ const existing = itemsByDocument.get(document.id) ?? [];
96
+ if (filesBackingDocumentIds.has(document.id)) {
97
+ for (const item of existing) deleteIds.add(item.id);
98
+ continue;
99
+ }
100
+ const canonicalDatabase = filesBySpace.get(document.spaceId);
101
+ if (!canonicalDatabase) {
102
+ throw new Error(
103
+ `Content space for document "${document.id}" has no Files database`,
104
+ );
105
+ }
106
+ const canonicalItems = existing
107
+ .filter((item) => item.databaseId === canonicalDatabase.id)
108
+ .sort((left, right) => left.id.localeCompare(right.id));
109
+ const canonicalItemId =
110
+ canonicalItems[0]?.id ??
111
+ contentFilesItemId(canonicalDatabase.id, document.id);
112
+ for (const item of existing) {
113
+ if (item.databaseId !== canonicalDatabase.id) {
114
+ deleteIds.add(item.id);
115
+ replacements.set(item.id, canonicalItemId);
116
+ }
117
+ }
118
+ for (const duplicate of canonicalItems.slice(1)) {
119
+ deleteIds.add(duplicate.id);
120
+ replacements.set(duplicate.id, canonicalItemId);
121
+ }
122
+ if (canonicalItems.length === 0) {
123
+ const position = nextPosition.get(canonicalDatabase.id) ?? 0;
124
+ nextPosition.set(canonicalDatabase.id, position + 1);
125
+ inserts.push({
126
+ id: contentFilesItemId(canonicalDatabase.id, document.id),
127
+ ownerEmail: document.ownerEmail,
128
+ orgId: document.orgId,
129
+ databaseId: canonicalDatabase.id,
130
+ documentId: document.id,
131
+ position,
132
+ createdAt: args.now,
133
+ updatedAt: args.now,
134
+ });
135
+ }
136
+ }
137
+ await remapItemReferences(args.db, replacements);
138
+ if (deleteIds.size) {
139
+ await args.db
140
+ .delete(schema.contentDatabaseItems)
141
+ .where(inArray(schema.contentDatabaseItems.id, [...deleteIds]));
142
+ }
143
+ if (inserts.length) {
144
+ await args.db
145
+ .insert(schema.contentDatabaseItems)
146
+ .values(inserts)
147
+ .onConflictDoNothing();
148
+ }
149
+ return { inserted: inserts.length, removed: deleteIds.size };
150
+ }
151
+
152
+ export async function ensureDocumentFilesMembership(
153
+ db: Db,
154
+ documentId: string,
155
+ now = new Date().toISOString(),
156
+ accessContext?: { userEmail?: string; orgId?: string },
157
+ ) {
158
+ const [document] = await db
159
+ .select()
160
+ .from(schema.documents)
161
+ .where(
162
+ and(
163
+ eq(schema.documents.id, documentId),
164
+ accessFilter(schema.documents, schema.documentShares, accessContext),
165
+ ),
166
+ );
167
+ if (!document) throw new Error(`Document "${documentId}" not found`);
168
+ if (!document.spaceId)
169
+ throw new Error(`Document "${documentId}" does not have a Content space`);
170
+ const filesDatabases = await db
171
+ .select()
172
+ .from(schema.contentDatabases)
173
+ .where(
174
+ and(
175
+ eq(schema.contentDatabases.systemRole, "files"),
176
+ eq(schema.contentDatabases.spaceId, document.spaceId),
177
+ ),
178
+ );
179
+ return reconcileDocuments({ db, documents: [document], filesDatabases, now });
180
+ }
181
+
182
+ export async function ensureDocumentsFilesMembership(
183
+ db: Db,
184
+ documentIds: string[],
185
+ now = new Date().toISOString(),
186
+ ownerEmail?: string,
187
+ ) {
188
+ const ids = [...new Set(documentIds)];
189
+ if (ids.length === 0) return { inserted: 0, removed: 0 };
190
+ const documents = await db
191
+ .select()
192
+ .from(schema.documents)
193
+ .where(
194
+ and(
195
+ inArray(schema.documents.id, ids),
196
+ ownerEmail
197
+ ? or(
198
+ eq(schema.documents.ownerEmail, ownerEmail),
199
+ accessFilter(schema.documents, schema.documentShares),
200
+ )
201
+ : accessFilter(schema.documents, schema.documentShares),
202
+ ),
203
+ );
204
+ if (documents.length !== ids.length) {
205
+ const found = new Set(documents.map((document: any) => document.id));
206
+ const missing = ids.filter((id) => !found.has(id));
207
+ throw new Error(`Documents not found: ${missing.join(", ")}`);
208
+ }
209
+ for (const document of documents) {
210
+ if (!document.spaceId) {
211
+ throw new Error(
212
+ `Document "${document.id}" does not have a Content space`,
213
+ );
214
+ }
215
+ }
216
+ const spaceIds: string[] = Array.from(
217
+ new Set<string>(documents.map((document: any) => String(document.spaceId))),
218
+ );
219
+ const filesDatabases = await db
220
+ .select()
221
+ .from(schema.contentDatabases)
222
+ .where(
223
+ and(
224
+ eq(schema.contentDatabases.systemRole, "files"),
225
+ or(
226
+ ...spaceIds.map((spaceId) =>
227
+ eq(schema.contentDatabases.spaceId, spaceId),
228
+ ),
229
+ ),
230
+ ),
231
+ );
232
+ return reconcileDocuments({ db, documents, filesDatabases, now });
233
+ }
234
+
235
+ export async function reconcileContentFilesMemberships(
236
+ db: Db,
237
+ userEmail: string,
238
+ ): Promise<ContentFilesReconciliation> {
239
+ const email = normalizeContentSpaceEmail(userEmail);
240
+ const memberships = await listContentOrganizationMemberships(email);
241
+ const personalSpaceId = personalContentSpaceId(email);
242
+ const orgSpaceIds = new Map<string, string>();
243
+ for (const membership of memberships) {
244
+ const spaceId = organizationContentSpaceId(membership.orgId);
245
+ try {
246
+ await resolveContentSpaceAccess(spaceId, "editor");
247
+ orgSpaceIds.set(membership.orgId, spaceId);
248
+ } catch (error) {
249
+ if (
250
+ error instanceof Error &&
251
+ error.message ===
252
+ `Editor access is required for Content space "${spaceId}"`
253
+ ) {
254
+ continue;
255
+ }
256
+ throw error;
257
+ }
258
+ }
259
+ const accessibleSpaceIds = [personalSpaceId, ...orgSpaceIds.values()];
260
+ const now = new Date().toISOString();
261
+ const result: ContentFilesReconciliation = {
262
+ assignedSpaces: 0,
263
+ insertedMemberships: 0,
264
+ removedMemberships: 0,
265
+ documents: 0,
266
+ };
267
+
268
+ await db.transaction(async (tx: Db) => {
269
+ const personalLegacyDocuments = await tx
270
+ .select({ id: schema.documents.id })
271
+ .from(schema.documents)
272
+ .where(
273
+ and(
274
+ eq(schema.documents.ownerEmail, email),
275
+ isNull(schema.documents.orgId),
276
+ isNull(schema.documents.spaceId),
277
+ ),
278
+ );
279
+ if (personalLegacyDocuments.length) {
280
+ await tx
281
+ .update(schema.documents)
282
+ .set({ spaceId: personalSpaceId, updatedAt: now })
283
+ .where(
284
+ inArray(
285
+ schema.documents.id,
286
+ personalLegacyDocuments.map((row: any) => row.id),
287
+ ),
288
+ );
289
+ result.assignedSpaces += personalLegacyDocuments.length;
290
+ }
291
+ for (const [orgId, spaceId] of orgSpaceIds) {
292
+ const legacyDocuments = await tx
293
+ .select({ id: schema.documents.id })
294
+ .from(schema.documents)
295
+ .where(
296
+ and(
297
+ eq(schema.documents.orgId, orgId),
298
+ isNull(schema.documents.spaceId),
299
+ ),
300
+ );
301
+ if (!legacyDocuments.length) continue;
302
+ await tx
303
+ .update(schema.documents)
304
+ .set({ spaceId, updatedAt: now })
305
+ .where(
306
+ inArray(
307
+ schema.documents.id,
308
+ legacyDocuments.map((row: any) => row.id),
309
+ ),
310
+ );
311
+ result.assignedSpaces += legacyDocuments.length;
312
+ }
313
+
314
+ const documents = await tx
315
+ .select()
316
+ .from(schema.documents)
317
+ .where(inArray(schema.documents.spaceId, accessibleSpaceIds));
318
+ const filesDatabases = await tx
319
+ .select()
320
+ .from(schema.contentDatabases)
321
+ .where(
322
+ and(
323
+ eq(schema.contentDatabases.systemRole, "files"),
324
+ inArray(schema.contentDatabases.spaceId, accessibleSpaceIds),
325
+ ),
326
+ );
327
+ const membershipResult = await reconcileDocuments({
328
+ db: tx,
329
+ documents,
330
+ filesDatabases,
331
+ now,
332
+ });
333
+ result.documents = documents.length;
334
+ result.insertedMemberships = membershipResult.inserted;
335
+ result.removedMemberships = membershipResult.removed;
336
+ });
337
+ return result;
338
+ }
@@ -0,0 +1,135 @@
1
+ import { getDbExec } from "@agent-native/core/db";
2
+ import { getRequestUserEmail } from "@agent-native/core/server/request-context";
3
+ import { eq } from "drizzle-orm";
4
+
5
+ import { getDb, schema } from "../server/db/index.js";
6
+
7
+ export type ContentSpaceRole = "viewer" | "editor" | "owner";
8
+
9
+ export type ContentSpaceAccess = {
10
+ space: typeof schema.contentSpaces.$inferSelect;
11
+ authority: { userEmail: string; orgId: string | null };
12
+ role: ContentSpaceRole;
13
+ };
14
+
15
+ export function normalizeContentSpaceEmail(email: string): string {
16
+ const normalized = email.trim().toLowerCase();
17
+ if (!normalized) throw new Error("no authenticated user");
18
+ return normalized;
19
+ }
20
+
21
+ export async function getContentOrganizationMembership(
22
+ orgId: string,
23
+ userEmail: string,
24
+ ): Promise<{ role: string; name: string; createdBy: string } | null> {
25
+ const result = await getDbExec().execute({
26
+ sql: `SELECT m.role AS role, o.name AS name, o.created_by AS "createdBy"
27
+ FROM org_members m
28
+ INNER JOIN organizations o ON o.id = m.org_id
29
+ WHERE m.org_id = ? AND LOWER(m.email) = ?
30
+ LIMIT 1`,
31
+ args: [orgId, normalizeContentSpaceEmail(userEmail)],
32
+ });
33
+ const row = result.rows[0] as
34
+ | { role?: unknown; name?: unknown; createdBy?: unknown }
35
+ | undefined;
36
+ if (
37
+ !row ||
38
+ typeof row.name !== "string" ||
39
+ typeof row.createdBy !== "string"
40
+ ) {
41
+ return null;
42
+ }
43
+ return {
44
+ role: String(row.role ?? "member").toLowerCase(),
45
+ name: row.name,
46
+ createdBy: row.createdBy,
47
+ };
48
+ }
49
+
50
+ export async function listContentOrganizationMemberships(userEmail: string) {
51
+ let result;
52
+ try {
53
+ result = await getDbExec().execute({
54
+ sql: `SELECT m.org_id AS "orgId", m.role AS role, o.name AS name,
55
+ o.created_by AS "createdBy"
56
+ FROM org_members m
57
+ INNER JOIN organizations o ON o.id = m.org_id
58
+ WHERE LOWER(m.email) = ?
59
+ ORDER BY m.org_id ASC`,
60
+ args: [normalizeContentSpaceEmail(userEmail)],
61
+ });
62
+ } catch (error) {
63
+ if (error instanceof Error && error.message.includes("no such table")) {
64
+ return [];
65
+ }
66
+ throw error;
67
+ }
68
+ return result.rows
69
+ .map((row: any) => ({
70
+ orgId: typeof row.orgId === "string" ? row.orgId : row.org_id,
71
+ role: String(row.role ?? "member").toLowerCase(),
72
+ name: row.name,
73
+ createdBy: row.createdBy ?? row.created_by,
74
+ }))
75
+ .filter(
76
+ (
77
+ row,
78
+ ): row is {
79
+ orgId: string;
80
+ role: string;
81
+ name: string;
82
+ createdBy: string;
83
+ } =>
84
+ typeof row.orgId === "string" &&
85
+ typeof row.name === "string" &&
86
+ typeof row.createdBy === "string",
87
+ );
88
+ }
89
+
90
+ export async function resolveContentSpaceAccess(
91
+ spaceId: string,
92
+ requiredRole: "viewer" | "editor" = "viewer",
93
+ ): Promise<ContentSpaceAccess> {
94
+ const userEmail = getRequestUserEmail();
95
+ if (!userEmail) throw new Error("no authenticated user");
96
+ const normalizedUserEmail = normalizeContentSpaceEmail(userEmail);
97
+ const [space] = await getDb()
98
+ .select()
99
+ .from(schema.contentSpaces)
100
+ .where(eq(schema.contentSpaces.id, spaceId));
101
+ if (!space || space.archivedAt)
102
+ throw new Error(`Content space "${spaceId}" not found`);
103
+
104
+ if (!space.orgId) {
105
+ if (normalizeContentSpaceEmail(space.ownerEmail) !== normalizedUserEmail) {
106
+ throw new Error(`Not authorized for Content space "${spaceId}"`);
107
+ }
108
+ return {
109
+ space,
110
+ authority: { userEmail: normalizedUserEmail, orgId: null },
111
+ role: "owner",
112
+ };
113
+ }
114
+
115
+ const membership = await getContentOrganizationMembership(
116
+ space.orgId,
117
+ normalizedUserEmail,
118
+ );
119
+ if (!membership)
120
+ throw new Error(`Not authorized for Content space "${spaceId}"`);
121
+ const role: ContentSpaceRole =
122
+ membership.role === "owner"
123
+ ? "owner"
124
+ : membership.role === "admin"
125
+ ? "editor"
126
+ : "viewer";
127
+ if (requiredRole === "editor" && role === "viewer") {
128
+ throw new Error(`Editor access is required for Content space "${spaceId}"`);
129
+ }
130
+ return {
131
+ space,
132
+ authority: { userEmail: normalizedUserEmail, orgId: space.orgId },
133
+ role,
134
+ };
135
+ }