@headless-lms/adapter-db 0.1.0 → 0.3.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 (138) hide show
  1. package/README.md +79 -0
  2. package/dist/client.d.ts +1 -1
  3. package/dist/client.js.map +1 -1
  4. package/dist/index.d.ts +7 -5
  5. package/dist/index.js +8 -6
  6. package/dist/migrate.js.map +1 -1
  7. package/dist/repositories/admin.d.ts +23 -0
  8. package/dist/repositories/admin.js +393 -0
  9. package/dist/repositories/admin.js.map +1 -0
  10. package/dist/repositories/assets.d.ts +6 -2
  11. package/dist/repositories/assets.js +53 -9
  12. package/dist/repositories/assets.js.map +1 -1
  13. package/dist/repositories/automations.d.ts +12 -6
  14. package/dist/repositories/automations.js +79 -11
  15. package/dist/repositories/automations.js.map +1 -1
  16. package/dist/repositories/chats.d.ts +55 -0
  17. package/dist/repositories/chats.js +454 -0
  18. package/dist/repositories/chats.js.map +1 -0
  19. package/dist/repositories/content.d.ts +44 -7
  20. package/dist/repositories/content.js +458 -24
  21. package/dist/repositories/content.js.map +1 -1
  22. package/dist/repositories/credentials.js +2 -0
  23. package/dist/repositories/credentials.js.map +1 -1
  24. package/dist/repositories/discussion.d.ts +11 -4
  25. package/dist/repositories/discussion.js +72 -15
  26. package/dist/repositories/discussion.js.map +1 -1
  27. package/dist/repositories/entitlements.d.ts +36 -10
  28. package/dist/repositories/entitlements.js +238 -23
  29. package/dist/repositories/entitlements.js.map +1 -1
  30. package/dist/repositories/grant-covers.d.ts +20 -0
  31. package/dist/repositories/grant-covers.js +55 -0
  32. package/dist/repositories/grant-covers.js.map +1 -0
  33. package/dist/repositories/identity.d.ts +4 -2
  34. package/dist/repositories/identity.js +34 -1
  35. package/dist/repositories/identity.js.map +1 -1
  36. package/dist/repositories/integrations.d.ts +1 -1
  37. package/dist/repositories/integrations.js +2 -0
  38. package/dist/repositories/integrations.js.map +1 -1
  39. package/dist/repositories/member.d.ts +16 -0
  40. package/dist/repositories/member.js +63 -0
  41. package/dist/repositories/member.js.map +1 -0
  42. package/dist/repositories/members.js +4 -2
  43. package/dist/repositories/members.js.map +1 -1
  44. package/dist/repositories/notifications.d.ts +23 -0
  45. package/dist/repositories/notifications.js +70 -0
  46. package/dist/repositories/notifications.js.map +1 -0
  47. package/dist/repositories/org-user-profile.d.ts +214 -1
  48. package/dist/repositories/org-user-profile.js +23 -2
  49. package/dist/repositories/org-user-profile.js.map +1 -1
  50. package/dist/repositories/organizations.d.ts +6 -7
  51. package/dist/repositories/organizations.js +45 -52
  52. package/dist/repositories/organizations.js.map +1 -1
  53. package/dist/repositories/outbox.js +3 -0
  54. package/dist/repositories/outbox.js.map +1 -1
  55. package/dist/repositories/pg-errors.d.ts +53 -5
  56. package/dist/repositories/pg-errors.js +99 -8
  57. package/dist/repositories/pg-errors.js.map +1 -1
  58. package/dist/repositories/posts.d.ts +39 -0
  59. package/dist/repositories/posts.js +255 -0
  60. package/dist/repositories/posts.js.map +1 -0
  61. package/dist/repositories/progress.d.ts +1 -1
  62. package/dist/repositories/progress.js +3 -0
  63. package/dist/repositories/progress.js.map +1 -1
  64. package/dist/repositories/settings.js +2 -0
  65. package/dist/repositories/settings.js.map +1 -1
  66. package/dist/schema/assets.d.ts +36 -2
  67. package/dist/schema/assets.js +12 -5
  68. package/dist/schema/assets.js.map +1 -1
  69. package/dist/schema/automations.d.ts +123 -8
  70. package/dist/schema/automations.js +11 -4
  71. package/dist/schema/automations.js.map +1 -1
  72. package/dist/schema/better-auth.d.ts +1613 -238
  73. package/dist/schema/better-auth.js +107 -22
  74. package/dist/schema/better-auth.js.map +1 -1
  75. package/dist/schema/content.d.ts +909 -22
  76. package/dist/schema/content.js +157 -13
  77. package/dist/schema/content.js.map +1 -1
  78. package/dist/schema/credentials.js +1 -1
  79. package/dist/schema/credentials.js.map +1 -1
  80. package/dist/schema/discussion.d.ts +2079 -55
  81. package/dist/schema/discussion.js +430 -13
  82. package/dist/schema/discussion.js.map +1 -1
  83. package/dist/schema/entitlements.d.ts +484 -2
  84. package/dist/schema/entitlements.js +121 -6
  85. package/dist/schema/entitlements.js.map +1 -1
  86. package/dist/schema/identity.d.ts +517 -1
  87. package/dist/schema/identity.js +118 -2
  88. package/dist/schema/identity.js.map +1 -1
  89. package/dist/schema/index.d.ts +6 -6
  90. package/dist/schema/index.js +32 -7
  91. package/dist/schema/index.js.map +1 -1
  92. package/dist/schema/integrations.js +1 -1
  93. package/dist/schema/integrations.js.map +1 -1
  94. package/dist/schema/organizations.d.ts +21 -4
  95. package/dist/schema/organizations.js +4 -1
  96. package/dist/schema/organizations.js.map +1 -1
  97. package/dist/schema/outbox.d.ts +17 -0
  98. package/dist/schema/outbox.js +1 -0
  99. package/dist/schema/outbox.js.map +1 -1
  100. package/dist/schema/progress.d.ts +17 -0
  101. package/dist/schema/progress.js +1 -0
  102. package/dist/schema/progress.js.map +1 -1
  103. package/dist/schema/settings.js.map +1 -1
  104. package/dist/unit-of-work.js +7 -2
  105. package/dist/unit-of-work.js.map +1 -1
  106. package/drizzle/0000_baseline.sql +0 -1
  107. package/drizzle/0001_new_sentry.sql +43 -0
  108. package/drizzle/0002_automation_kinds.sql +7 -0
  109. package/drizzle/0003_community.sql +340 -0
  110. package/drizzle/0004_assets_sources.sql +1 -0
  111. package/drizzle/0005_notices_and_seed_index.sql +12 -0
  112. package/drizzle/0006_community_review_fixes.sql +24 -0
  113. package/drizzle/0007_notifications.sql +37 -0
  114. package/drizzle/0008_content_access.sql +13 -0
  115. package/drizzle/0009_community_indexes.sql +56 -0
  116. package/drizzle/0010_mcp_access.sql +114 -0
  117. package/drizzle/meta/0000_snapshot.json +167 -597
  118. package/drizzle/meta/0001_snapshot.json +3769 -0
  119. package/drizzle/meta/0002_snapshot.json +3770 -0
  120. package/drizzle/meta/0003_snapshot.json +7061 -0
  121. package/drizzle/meta/0004_snapshot.json +7068 -0
  122. package/drizzle/meta/0005_snapshot.json +7180 -0
  123. package/drizzle/meta/0006_snapshot.json +7298 -0
  124. package/drizzle/meta/0007_snapshot.json +7620 -0
  125. package/drizzle/meta/0008_snapshot.json +7666 -0
  126. package/drizzle/meta/0009_snapshot.json +8185 -0
  127. package/drizzle/meta/0010_snapshot.json +8813 -0
  128. package/drizzle/meta/_journal.json +71 -1
  129. package/package.json +3 -2
  130. package/dist/repositories/dashboard.d.ts +0 -14
  131. package/dist/repositories/dashboard.js +0 -55
  132. package/dist/repositories/dashboard.js.map +0 -1
  133. package/dist/repositories/learn.d.ts +0 -19
  134. package/dist/repositories/learn.js +0 -54
  135. package/dist/repositories/learn.js.map +0 -1
  136. package/dist/repositories/students.d.ts +0 -15
  137. package/dist/repositories/students.js +0 -83
  138. package/dist/repositories/students.js.map +0 -1
@@ -1,14 +1,63 @@
1
- import { activities, activityAssets, contentItems, courses, downloadAssets, downloads, modules } from "../schema/content.js";
2
- import { isUniqueViolation } from "./pg-errors.js";
1
+ import { activities, activityAssets, bundleItems, bundles, communities, contentItems, courses, downloadAssets, downloads, modules, spaceLinks, spaceTopics, spaces } from "../schema/content.js";
2
+ import { posts } from "../schema/discussion.js";
3
+ import { settings } from "../schema/settings.js";
4
+ import { isUniqueViolation, pgErrorFields, translateDbErrors } from "./pg-errors.js";
5
+ import { deepMerge } from "./settings.js";
3
6
  import { and, asc, count, desc, eq, ilike, inArray, or, sql } from "drizzle-orm";
4
7
  import { genId } from "@headless-lms/core/shared/id";
5
- import { noopLogger } from "@headless-lms/core/shared/logger";
6
8
  import { ConflictError, NotFoundError } from "@headless-lms/core/shared/errors";
9
+ import { noopLogger } from "@headless-lms/core/shared/logger";
10
+ import { communitySettingsDefaults, courseSettingsDefaults, spaceSettingsDefaults } from "@headless-lms/core/schemas";
7
11
  //#region src/repositories/content.ts
8
- function toCourse(row) {
12
+ const LIBRARY_ONLY = "An activity can only use ready files from the media library";
13
+ const MISSING_ASSET_DETAIL = /^Key \(org_id, asset_id\)=\((?:[^,]+), (.+?)\) is not present/;
14
+ function refusedLink(err) {
15
+ const pg = pgErrorFields(err);
16
+ if (pg?.code !== "23503") return;
17
+ const missing = pg.detail?.match(MISSING_ASSET_DETAIL)?.[1];
18
+ const message = missing ? `${LIBRARY_ONLY}. Not available: ${missing}` : LIBRARY_ONLY;
19
+ return Object.assign(new ConflictError(message), { cause: err });
20
+ }
21
+ const SETTINGS_NAMESPACE = "content";
22
+ const courseSettingsJoin = and(eq(settings.orgId, courses.orgId), eq(settings.namespace, SETTINGS_NAMESPACE), eq(settings.scopeId, courses.id));
23
+ const communitySettingsJoin = and(eq(settings.orgId, communities.orgId), eq(settings.namespace, SETTINGS_NAMESPACE), eq(settings.scopeId, communities.id));
24
+ function toCommunity(row, storedSettings) {
25
+ const raw = storedSettings?.dm;
26
+ const stored = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
9
27
  return {
10
28
  ...row,
11
- status: row.status
29
+ status: row.status,
30
+ settings: { dm: {
31
+ ...communitySettingsDefaults.dm,
32
+ ...stored
33
+ } }
34
+ };
35
+ }
36
+ function toSpace(row) {
37
+ return {
38
+ ...row,
39
+ accessRule: row.accessRule,
40
+ settings: {
41
+ ...spaceSettingsDefaults,
42
+ ...row.settings
43
+ }
44
+ };
45
+ }
46
+ const communitySortColumns = {
47
+ title: communities.title,
48
+ slug: communities.slug,
49
+ status: communities.status,
50
+ createdAt: communities.createdAt,
51
+ updatedAt: communities.updatedAt
52
+ };
53
+ function toCourse(row, storedSettings) {
54
+ return {
55
+ ...row,
56
+ status: row.status,
57
+ settings: {
58
+ ...courseSettingsDefaults,
59
+ ...storedSettings
60
+ }
12
61
  };
13
62
  }
14
63
  const sortColumns = {
@@ -33,6 +82,11 @@ function toDownload(row) {
33
82
  status: row.status
34
83
  };
35
84
  }
85
+ const bundleSortColumns = {
86
+ name: bundles.name,
87
+ createdAt: bundles.createdAt,
88
+ updatedAt: bundles.updatedAt
89
+ };
36
90
  var DrizzleContentRepository = class {
37
91
  db;
38
92
  logger;
@@ -45,7 +99,7 @@ var DrizzleContentRepository = class {
45
99
  tx(fn) {
46
100
  return this.db.transaction(fn);
47
101
  }
48
- async list(orgId, query) {
102
+ async listCourses(orgId, query) {
49
103
  const conditions = [eq(courses.orgId, orgId)];
50
104
  if (query.status) conditions.push(eq(courses.status, query.status));
51
105
  if (query.category) conditions.push(eq(courses.category, query.category));
@@ -68,20 +122,26 @@ var DrizzleContentRepository = class {
68
122
  }
69
123
  const sortExpr = sortColumns[sortKey] ?? courses.createdAt;
70
124
  const orderBy = direction === "desc" ? desc(sortExpr) : asc(sortExpr);
71
- const rows = await this.db.select().from(courses).where(where).orderBy(orderBy).limit(query.pageSize).offset((query.page - 1) * query.pageSize);
125
+ const rows = await this.db.select({
126
+ course: courses,
127
+ settingsValue: settings.value
128
+ }).from(courses).leftJoin(settings, courseSettingsJoin).where(where).orderBy(orderBy).limit(query.pageSize).offset((query.page - 1) * query.pageSize);
72
129
  const [totalRow] = await this.db.select({ value: count() }).from(courses).where(where);
73
130
  return {
74
- rows: rows.map(toCourse),
131
+ rows: rows.map((row) => toCourse(row.course, row.settingsValue)),
75
132
  total: totalRow?.value ?? 0,
76
133
  page: query.page,
77
134
  pageSize: query.pageSize
78
135
  };
79
136
  }
80
- async findById(orgId, id) {
81
- const [row] = await this.db.select().from(courses).where(and(eq(courses.orgId, orgId), eq(courses.id, id))).limit(1);
82
- return row ? toCourse(row) : null;
137
+ async findCourseById(orgId, id) {
138
+ const [row] = await this.db.select({
139
+ course: courses,
140
+ settingsValue: settings.value
141
+ }).from(courses).leftJoin(settings, courseSettingsJoin).where(and(eq(courses.orgId, orgId), eq(courses.id, id))).limit(1);
142
+ return row ? toCourse(row.course, row.settingsValue) : null;
83
143
  }
84
- async create(orgId, input, slug) {
144
+ async createCourse(orgId, input, slug) {
85
145
  const id = genId("course");
86
146
  await this.db.insert(contentItems).values({
87
147
  orgId,
@@ -97,31 +157,68 @@ var DrizzleContentRepository = class {
97
157
  category: input.category ?? ""
98
158
  }).returning({ id: courses.id });
99
159
  if (!inserted) throw new Error("failed to insert course");
100
- const created = await this.findById(orgId, inserted.id);
160
+ const created = await this.findCourseById(orgId, inserted.id);
101
161
  if (!created) throw new Error("failed to load created course");
102
162
  return created;
103
163
  }
104
- async update(orgId, id, patch) {
164
+ async updateCourse(orgId, id, patch) {
105
165
  const set = { updatedAt: /* @__PURE__ */ new Date() };
106
166
  if (patch.title !== void 0) set.title = patch.title;
107
167
  if (patch.description !== void 0) set.description = patch.description;
108
168
  if (patch.category !== void 0) set.category = patch.category;
109
169
  if (patch.status !== void 0) set.status = patch.status;
110
170
  if (patch.thumbnailAssetId !== void 0) set.thumbnailAssetId = patch.thumbnailAssetId;
111
- if (patch.settings !== void 0) set.settings = sql`${courses.settings} || ${JSON.stringify(patch.settings)}::jsonb`;
112
171
  const [updated] = await this.db.update(courses).set(set).where(and(eq(courses.orgId, orgId), eq(courses.id, id))).returning({ id: courses.id });
113
172
  if (!updated) return null;
114
- return this.findById(orgId, id);
173
+ return this.findCourseById(orgId, id);
174
+ }
175
+ async patchCourseSettings(orgId, id, value) {
176
+ const key = and(eq(settings.orgId, orgId), eq(settings.namespace, SETTINGS_NAMESPACE), eq(settings.scopeId, id));
177
+ const merged = await this.tx(async (tx) => {
178
+ let [existing] = await tx.select().from(settings).where(key).for("update");
179
+ if (!existing) {
180
+ const [inserted] = await tx.insert(settings).values({
181
+ orgId,
182
+ namespace: SETTINGS_NAMESPACE,
183
+ scopeId: id,
184
+ value
185
+ }).onConflictDoNothing().returning();
186
+ if (inserted) return inserted.value;
187
+ [existing] = await tx.select().from(settings).where(key).for("update");
188
+ if (!existing) throw new Error("course settings patch: row vanished mid-transaction");
189
+ }
190
+ const [updated] = await tx.update(settings).set({
191
+ value: deepMerge(existing.value, value),
192
+ updatedAt: /* @__PURE__ */ new Date()
193
+ }).where(key).returning();
194
+ if (!updated) throw new Error("course settings patch returned no row");
195
+ return updated.value;
196
+ });
197
+ return {
198
+ ...courseSettingsDefaults,
199
+ ...merged
200
+ };
115
201
  }
116
- async delete(orgId, id) {
202
+ async deleteCourse(orgId, id) {
117
203
  return (await this.db.delete(contentItems).where(and(eq(contentItems.orgId, orgId), eq(contentItems.id, id), eq(contentItems.type, "course"))).returning({ id: contentItems.id })).length > 0;
118
204
  }
119
- listForCourse(orgId, courseId) {
205
+ listCourseModules(orgId, courseId) {
120
206
  return this.tx((tx) => this.listModules(tx, orgId, courseId));
121
207
  }
122
208
  async listActivitiesForCourse(orgId, courseId) {
123
209
  return this.db.select().from(activities).where(and(eq(activities.orgId, orgId), eq(activities.courseId, courseId))).orderBy(activities.moduleId, activities.seq);
124
210
  }
211
+ async listActivityAssetsForCourse(orgId, courseId) {
212
+ return this.db.select({
213
+ orgId: activityAssets.orgId,
214
+ id: activityAssets.id,
215
+ activityId: activityAssets.activityId,
216
+ assetId: activityAssets.assetId,
217
+ seq: activityAssets.seq,
218
+ createdAt: activityAssets.createdAt,
219
+ updatedAt: activityAssets.updatedAt
220
+ }).from(activityAssets).innerJoin(activities, and(eq(activities.orgId, activityAssets.orgId), eq(activities.id, activityAssets.activityId))).where(and(eq(activityAssets.orgId, orgId), eq(activities.courseId, courseId))).orderBy(activityAssets.activityId, activityAssets.seq);
221
+ }
125
222
  async findActivity(orgId, activityId) {
126
223
  const [row] = await this.db.select().from(activities).where(and(eq(activities.orgId, orgId), eq(activities.id, activityId))).limit(1);
127
224
  return row ?? null;
@@ -142,12 +239,16 @@ var DrizzleContentRepository = class {
142
239
  /** Replace an activity's ordered asset links with `assetIds`. */
143
240
  async replaceActivityAssets(tx, orgId, activityId, assetIds) {
144
241
  await tx.delete(activityAssets).where(and(eq(activityAssets.orgId, orgId), eq(activityAssets.activityId, activityId)));
145
- if (assetIds.length) await tx.insert(activityAssets).values(assetIds.map((assetId, i) => ({
146
- orgId,
147
- activityId,
148
- assetId,
149
- seq: i
150
- })));
242
+ if (assetIds.length) try {
243
+ await tx.insert(activityAssets).values(assetIds.map((assetId, i) => ({
244
+ orgId,
245
+ activityId,
246
+ assetId,
247
+ seq: i
248
+ })));
249
+ } catch (err) {
250
+ throw refusedLink(err) ?? err;
251
+ }
151
252
  }
152
253
  createModule(orgId, courseId, title) {
153
254
  return this.tx(async (tx) => {
@@ -343,7 +444,340 @@ var DrizzleContentRepository = class {
343
444
  for (const [seq, assetId] of assetIds.entries()) await this.db.update(downloadAssets).set({ seq }).where(and(eq(downloadAssets.orgId, orgId), eq(downloadAssets.downloadId, downloadId), eq(downloadAssets.assetId, assetId)));
344
445
  return this.listDownloadAssets(orgId, downloadId);
345
446
  }
447
+ async listBundles(orgId, query) {
448
+ const conditions = [eq(bundles.orgId, orgId)];
449
+ const search = query.search?.trim();
450
+ if (search) conditions.push(ilike(bundles.name, `%${search}%`));
451
+ const where = and(...conditions);
452
+ let sortKey = "createdAt";
453
+ let direction = "desc";
454
+ if (query.sort) {
455
+ const isDesc = query.sort.startsWith("-");
456
+ const key = isDesc ? query.sort.slice(1) : query.sort;
457
+ if (key in bundleSortColumns) {
458
+ sortKey = key;
459
+ direction = isDesc ? "desc" : "asc";
460
+ }
461
+ }
462
+ const sortExpr = bundleSortColumns[sortKey] ?? bundles.createdAt;
463
+ const orderBy = direction === "desc" ? desc(sortExpr) : asc(sortExpr);
464
+ const rows = await this.db.select().from(bundles).where(where).orderBy(orderBy).limit(query.pageSize).offset((query.page - 1) * query.pageSize);
465
+ const [totalRow] = await this.db.select({ value: count() }).from(bundles).where(where);
466
+ return {
467
+ rows,
468
+ total: totalRow?.value ?? 0,
469
+ page: query.page,
470
+ pageSize: query.pageSize
471
+ };
472
+ }
473
+ async findBundleById(orgId, id) {
474
+ const [row] = await this.db.select().from(bundles).where(and(eq(bundles.orgId, orgId), eq(bundles.id, id))).limit(1);
475
+ return row ?? null;
476
+ }
477
+ /** The bundle and its initial items are one write: an unknown content id
478
+ * rolls the bundle back rather than leaving it created and half-filled. */
479
+ async createBundle(orgId, input) {
480
+ const contentIds = [...new Set(input.contentIds ?? [])];
481
+ return this.tx(async (tx) => {
482
+ const [row] = await tx.insert(bundles).values({
483
+ orgId,
484
+ id: genId("bundle"),
485
+ name: input.name
486
+ }).returning();
487
+ if (!row) throw new Error("failed to load created bundle");
488
+ if (contentIds.length > 0) {
489
+ const found = await tx.select({ id: contentItems.id }).from(contentItems).where(and(eq(contentItems.orgId, orgId), inArray(contentItems.id, contentIds)));
490
+ const known = new Set(found.map((item) => item.id));
491
+ const missing = contentIds.find((id) => !known.has(id));
492
+ if (missing) throw new NotFoundError("Content", missing);
493
+ await tx.insert(bundleItems).values(contentIds.map((contentId) => ({
494
+ orgId,
495
+ bundleId: row.id,
496
+ contentId
497
+ })));
498
+ }
499
+ return row;
500
+ });
501
+ }
502
+ async updateBundle(orgId, id, patch) {
503
+ const set = { updatedAt: /* @__PURE__ */ new Date() };
504
+ if (patch.name !== void 0) set.name = patch.name;
505
+ const [row] = await this.db.update(bundles).set(set).where(and(eq(bundles.orgId, orgId), eq(bundles.id, id))).returning();
506
+ return row ?? null;
507
+ }
508
+ async deleteBundle(orgId, id) {
509
+ return (await this.db.delete(bundles).where(and(eq(bundles.orgId, orgId), eq(bundles.id, id))).returning({ id: bundles.id })).length > 0;
510
+ }
511
+ async listBundleItems(orgId, bundleId) {
512
+ return this.db.select().from(bundleItems).where(and(eq(bundleItems.orgId, orgId), eq(bundleItems.bundleId, bundleId))).orderBy(asc(bundleItems.createdAt));
513
+ }
514
+ /** Assert the bundle exists in the org; throw otherwise. */
515
+ async assertBundle(orgId, bundleId) {
516
+ const [row] = await this.db.select({ id: bundles.id }).from(bundles).where(and(eq(bundles.orgId, orgId), eq(bundles.id, bundleId))).limit(1);
517
+ if (!row) throw new NotFoundError("Bundle", bundleId);
518
+ }
519
+ async addBundleItem(orgId, bundleId, contentId) {
520
+ await this.assertBundle(orgId, bundleId);
521
+ const [item] = await this.db.select({ id: contentItems.id }).from(contentItems).where(and(eq(contentItems.orgId, orgId), eq(contentItems.id, contentId))).limit(1);
522
+ if (!item) throw new NotFoundError("Content", contentId);
523
+ await this.db.insert(bundleItems).values({
524
+ orgId,
525
+ bundleId,
526
+ contentId
527
+ }).onConflictDoNothing({ target: [
528
+ bundleItems.orgId,
529
+ bundleItems.bundleId,
530
+ bundleItems.contentId
531
+ ] });
532
+ return this.listBundleItems(orgId, bundleId);
533
+ }
534
+ async removeBundleItem(orgId, bundleId, contentId) {
535
+ await this.assertBundle(orgId, bundleId);
536
+ await this.db.delete(bundleItems).where(and(eq(bundleItems.orgId, orgId), eq(bundleItems.bundleId, bundleId), eq(bundleItems.contentId, contentId)));
537
+ return this.listBundleItems(orgId, bundleId);
538
+ }
539
+ async listCommunities(orgId, query) {
540
+ const conditions = [eq(communities.orgId, orgId)];
541
+ if (query.status) conditions.push(eq(communities.status, query.status));
542
+ const search = query.search?.trim();
543
+ if (search) {
544
+ const like = `%${search}%`;
545
+ const match = or(ilike(communities.title, like), ilike(communities.description, like));
546
+ if (match) conditions.push(match);
547
+ }
548
+ const where = and(...conditions);
549
+ let sortKey = "createdAt";
550
+ let direction = "desc";
551
+ if (query.sort) {
552
+ const isDesc = query.sort.startsWith("-");
553
+ const key = isDesc ? query.sort.slice(1) : query.sort;
554
+ if (key in communitySortColumns) {
555
+ sortKey = key;
556
+ direction = isDesc ? "desc" : "asc";
557
+ }
558
+ }
559
+ const sortExpr = communitySortColumns[sortKey] ?? communities.createdAt;
560
+ const orderBy = direction === "desc" ? desc(sortExpr) : asc(sortExpr);
561
+ const rows = await this.db.select({
562
+ community: communities,
563
+ settingsValue: settings.value
564
+ }).from(communities).leftJoin(settings, communitySettingsJoin).where(where).orderBy(orderBy).limit(query.pageSize).offset((query.page - 1) * query.pageSize);
565
+ const [totalRow] = await this.db.select({ value: count() }).from(communities).where(where);
566
+ return {
567
+ rows: rows.map((row) => toCommunity(row.community, row.settingsValue)),
568
+ total: totalRow?.value ?? 0,
569
+ page: query.page,
570
+ pageSize: query.pageSize
571
+ };
572
+ }
573
+ async findCommunityById(orgId, id) {
574
+ const [row] = await this.db.select({
575
+ community: communities,
576
+ settingsValue: settings.value
577
+ }).from(communities).leftJoin(settings, communitySettingsJoin).where(and(eq(communities.orgId, orgId), eq(communities.id, id))).limit(1);
578
+ return row ? toCommunity(row.community, row.settingsValue) : null;
579
+ }
580
+ async findCommunitiesByIds(orgId, ids) {
581
+ return (await this.db.select({
582
+ community: communities,
583
+ settingsValue: settings.value
584
+ }).from(communities).leftJoin(settings, communitySettingsJoin).where(and(eq(communities.orgId, orgId), inArray(communities.id, ids))).orderBy(asc(communities.title), asc(communities.id))).map((row) => toCommunity(row.community, row.settingsValue));
585
+ }
586
+ async listPublishedCommunityIds(orgId) {
587
+ return (await this.db.select({ id: communities.id }).from(communities).where(and(eq(communities.orgId, orgId), eq(communities.status, "published"))).orderBy(asc(communities.createdAt), asc(communities.id))).map((row) => row.id);
588
+ }
589
+ async createCommunity(orgId, input, slug) {
590
+ const id = genId("community");
591
+ await this.db.insert(contentItems).values({
592
+ orgId,
593
+ id,
594
+ type: "community"
595
+ });
596
+ const [inserted] = await this.db.insert(communities).values({
597
+ orgId,
598
+ id,
599
+ title: input.title,
600
+ slug,
601
+ description: input.description ?? ""
602
+ }).returning({ id: communities.id });
603
+ if (!inserted) throw new Error("failed to insert community");
604
+ const created = await this.findCommunityById(orgId, inserted.id);
605
+ if (!created) throw new Error("failed to load created community");
606
+ return created;
607
+ }
608
+ async updateCommunity(orgId, id, patch) {
609
+ const set = { updatedAt: /* @__PURE__ */ new Date() };
610
+ if (patch.title !== void 0) set.title = patch.title;
611
+ if (patch.description !== void 0) set.description = patch.description;
612
+ if (patch.status !== void 0) set.status = patch.status;
613
+ if (patch.thumbnailAssetId !== void 0) set.thumbnailAssetId = patch.thumbnailAssetId;
614
+ const [updated] = await this.db.update(communities).set(set).where(and(eq(communities.orgId, orgId), eq(communities.id, id))).returning({ id: communities.id });
615
+ if (!updated) return null;
616
+ return this.findCommunityById(orgId, id);
617
+ }
618
+ async patchDmSettings(orgId, id, patch) {
619
+ if (!await this.findCommunityById(orgId, id)) return null;
620
+ const key = and(eq(settings.orgId, orgId), eq(settings.namespace, SETTINGS_NAMESPACE), eq(settings.scopeId, id));
621
+ await this.tx(async (tx) => {
622
+ let [existing] = await tx.select().from(settings).where(key).for("update");
623
+ const value = { dm: patch };
624
+ if (!existing) {
625
+ const [inserted] = await tx.insert(settings).values({
626
+ orgId,
627
+ namespace: SETTINGS_NAMESPACE,
628
+ scopeId: id,
629
+ value
630
+ }).onConflictDoNothing().returning();
631
+ if (inserted) return;
632
+ [existing] = await tx.select().from(settings).where(key).for("update");
633
+ if (!existing) throw new Error("community settings patch: row vanished mid-transaction");
634
+ }
635
+ await tx.update(settings).set({
636
+ value: deepMerge(existing.value, { dm: patch }),
637
+ updatedAt: /* @__PURE__ */ new Date()
638
+ }).where(key);
639
+ });
640
+ return this.findCommunityById(orgId, id);
641
+ }
642
+ async deleteCommunity(orgId, id) {
643
+ return (await this.db.delete(contentItems).where(and(eq(contentItems.orgId, orgId), eq(contentItems.id, id), eq(contentItems.type, "community"))).returning({ id: contentItems.id })).length > 0;
644
+ }
645
+ async deleteCommunitySettings(orgId, id) {
646
+ await this.db.delete(settings).where(and(eq(settings.orgId, orgId), eq(settings.namespace, SETTINGS_NAMESPACE), eq(settings.scopeId, id)));
647
+ }
648
+ async listSpaces(orgId, communityId) {
649
+ const rows = await this.db.select().from(spaces).where(and(eq(spaces.orgId, orgId), eq(spaces.communityId, communityId))).orderBy(asc(spaces.seq));
650
+ return this.withSpaceParts(orgId, rows.map(toSpace));
651
+ }
652
+ async findSpace(orgId, spaceId) {
653
+ const [row] = await this.db.select().from(spaces).where(and(eq(spaces.orgId, orgId), eq(spaces.id, spaceId))).limit(1);
654
+ if (!row) return null;
655
+ const [detail] = await this.withSpaceParts(orgId, [toSpace(row)]);
656
+ return detail ?? null;
657
+ }
658
+ async lockSpace(orgId, spaceId) {
659
+ const [row] = await this.db.select().from(spaces).where(and(eq(spaces.orgId, orgId), eq(spaces.id, spaceId))).for("update").limit(1);
660
+ return row ? toSpace(row) : null;
661
+ }
662
+ async createSpace(orgId, communityId, input) {
663
+ const nextSeq = (await this.db.select({ seq: spaces.seq }).from(spaces).where(and(eq(spaces.orgId, orgId), eq(spaces.communityId, communityId)))).reduce((max, row) => Math.max(max, row.seq), -1) + 1;
664
+ const [inserted] = await this.db.insert(spaces).values({
665
+ orgId,
666
+ communityId,
667
+ name: input.name,
668
+ description: input.description ?? "",
669
+ seq: nextSeq,
670
+ accessRule: input.accessRule ?? "all_members",
671
+ capacity: input.capacity ?? null,
672
+ settings: {
673
+ ...spaceSettingsDefaults,
674
+ ...input.settings
675
+ }
676
+ }).returning({ id: spaces.id });
677
+ if (!inserted) throw new Error("failed to insert space");
678
+ const created = await this.findSpace(orgId, inserted.id);
679
+ if (!created) throw new Error("failed to load created space");
680
+ return created;
681
+ }
682
+ async updateSpace(orgId, communityId, spaceId, patch) {
683
+ const set = { updatedAt: /* @__PURE__ */ new Date() };
684
+ if (patch.name !== void 0) set.name = patch.name;
685
+ if (patch.description !== void 0) set.description = patch.description;
686
+ if (patch.accessRule !== void 0) set.accessRule = patch.accessRule;
687
+ if (patch.capacity !== void 0) set.capacity = patch.capacity;
688
+ if (patch.settings !== void 0) set.settings = {
689
+ ...spaceSettingsDefaults,
690
+ ...patch.settings
691
+ };
692
+ const [updated] = await this.db.update(spaces).set(set).where(and(eq(spaces.orgId, orgId), eq(spaces.id, spaceId), eq(spaces.communityId, communityId))).returning({ id: spaces.id });
693
+ if (!updated) return null;
694
+ return this.findSpace(orgId, spaceId);
695
+ }
696
+ async deleteSpace(orgId, communityId, spaceId) {
697
+ return (await this.db.delete(spaces).where(and(eq(spaces.orgId, orgId), eq(spaces.id, spaceId), eq(spaces.communityId, communityId))).returning({ id: spaces.id })).length > 0;
698
+ }
699
+ async reorderSpaces(orgId, communityId, orderedIds) {
700
+ for (let i = 0; i < orderedIds.length; i++) await this.db.update(spaces).set({ seq: i }).where(and(eq(spaces.orgId, orgId), eq(spaces.communityId, communityId), eq(spaces.id, orderedIds[i])));
701
+ return this.listSpaces(orgId, communityId);
702
+ }
703
+ async setSpaceTopics(orgId, spaceId, input) {
704
+ await this.tx(async (tx) => {
705
+ const existing = await tx.select({ id: spaceTopics.id }).from(spaceTopics).where(and(eq(spaceTopics.orgId, orgId), eq(spaceTopics.spaceId, spaceId))).for("update");
706
+ const kept = new Set(input.topics.flatMap((topic) => topic.id ? [topic.id] : []));
707
+ const known = new Set(existing.map((topic) => topic.id));
708
+ for (const id of kept) if (!known.has(id)) throw new NotFoundError("Topic", id);
709
+ const removed = existing.filter((topic) => !kept.has(topic.id)).map((topic) => topic.id);
710
+ if (removed.length) await tx.delete(spaceTopics).where(and(eq(spaceTopics.orgId, orgId), inArray(spaceTopics.id, removed)));
711
+ for (let seq = 0; seq < input.topics.length; seq++) {
712
+ const topic = input.topics[seq];
713
+ if (topic.id) await tx.update(spaceTopics).set({
714
+ name: topic.name,
715
+ seq,
716
+ updatedAt: /* @__PURE__ */ new Date()
717
+ }).where(and(eq(spaceTopics.orgId, orgId), eq(spaceTopics.id, topic.id)));
718
+ else await tx.insert(spaceTopics).values({
719
+ orgId,
720
+ spaceId,
721
+ name: topic.name,
722
+ seq
723
+ });
724
+ }
725
+ });
726
+ const space = await this.findSpace(orgId, spaceId);
727
+ if (!space) throw new NotFoundError("Space", spaceId);
728
+ return space;
729
+ }
730
+ async topicsInUse(orgId, spaceId, topicIds) {
731
+ if (topicIds.length === 0) return [];
732
+ await this.db.select({ id: spaceTopics.id }).from(spaceTopics).where(and(eq(spaceTopics.orgId, orgId), eq(spaceTopics.spaceId, spaceId), inArray(spaceTopics.id, topicIds))).for("update");
733
+ return (await this.db.selectDistinct({ topicId: posts.topicId }).from(posts).where(and(eq(posts.orgId, orgId), eq(posts.spaceId, spaceId), inArray(posts.topicId, topicIds)))).flatMap((row) => row.topicId ? [row.topicId] : []);
734
+ }
735
+ async addSpaceLink(orgId, spaceId, contentId) {
736
+ const [item] = await this.db.select({
737
+ id: contentItems.id,
738
+ type: contentItems.type
739
+ }).from(contentItems).where(and(eq(contentItems.orgId, orgId), eq(contentItems.id, contentId))).limit(1);
740
+ if (!item || item.type !== "course" && item.type !== "download" && item.type !== "community") throw new NotFoundError("Content", contentId);
741
+ const nextSeq = (await this.db.select({ seq: spaceLinks.seq }).from(spaceLinks).where(and(eq(spaceLinks.orgId, orgId), eq(spaceLinks.spaceId, spaceId)))).reduce((max, row) => Math.max(max, row.seq), -1) + 1;
742
+ await this.db.insert(spaceLinks).values({
743
+ orgId,
744
+ spaceId,
745
+ contentId,
746
+ seq: nextSeq
747
+ });
748
+ const space = await this.findSpace(orgId, spaceId);
749
+ if (!space) throw new NotFoundError("Space", spaceId);
750
+ return space;
751
+ }
752
+ async removeSpaceLink(orgId, spaceId, linkId) {
753
+ if ((await this.db.delete(spaceLinks).where(and(eq(spaceLinks.orgId, orgId), eq(spaceLinks.spaceId, spaceId), eq(spaceLinks.id, linkId))).returning({ id: spaceLinks.id })).length === 0) throw new NotFoundError("Link", linkId);
754
+ const space = await this.findSpace(orgId, spaceId);
755
+ if (!space) throw new NotFoundError("Space", spaceId);
756
+ return space;
757
+ }
758
+ async reorderSpaceLinks(orgId, spaceId, orderedIds) {
759
+ for (let i = 0; i < orderedIds.length; i++) await this.db.update(spaceLinks).set({ seq: i }).where(and(eq(spaceLinks.orgId, orgId), eq(spaceLinks.spaceId, spaceId), eq(spaceLinks.id, orderedIds[i])));
760
+ const space = await this.findSpace(orgId, spaceId);
761
+ if (!space) throw new NotFoundError("Space", spaceId);
762
+ return space;
763
+ }
764
+ async spacesLinkingContent(orgId, contentId) {
765
+ const rows = await this.db.select({ spaceId: spaceLinks.spaceId }).from(spaceLinks).where(and(eq(spaceLinks.orgId, orgId), eq(spaceLinks.contentId, contentId)));
766
+ return [...new Set(rows.map((row) => row.spaceId))];
767
+ }
768
+ async withSpaceParts(orgId, rows) {
769
+ if (rows.length === 0) return [];
770
+ const ids = rows.map((row) => row.id);
771
+ const topics = await this.db.select().from(spaceTopics).where(and(eq(spaceTopics.orgId, orgId), inArray(spaceTopics.spaceId, ids))).orderBy(asc(spaceTopics.seq));
772
+ const links = await this.db.select().from(spaceLinks).where(and(eq(spaceLinks.orgId, orgId), inArray(spaceLinks.spaceId, ids))).orderBy(asc(spaceLinks.seq));
773
+ return rows.map((space) => ({
774
+ ...space,
775
+ topics: topics.filter((topic) => topic.spaceId === space.id),
776
+ links: links.filter((link) => link.spaceId === space.id)
777
+ }));
778
+ }
346
779
  };
780
+ translateDbErrors(DrizzleContentRepository);
347
781
  //#endregion
348
782
  export { DrizzleContentRepository };
349
783