@headless-lms/adapter-db 0.2.0 → 0.4.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 (134) hide show
  1. package/dist/index.d.ts +12 -6
  2. package/dist/index.js +12 -6
  3. package/dist/repositories/admin.d.ts +23 -0
  4. package/dist/repositories/admin.js +393 -0
  5. package/dist/repositories/admin.js.map +1 -0
  6. package/dist/repositories/assets.d.ts +6 -3
  7. package/dist/repositories/assets.js +46 -9
  8. package/dist/repositories/assets.js.map +1 -1
  9. package/dist/repositories/automations.d.ts +5 -2
  10. package/dist/repositories/automations.js +29 -2
  11. package/dist/repositories/automations.js.map +1 -1
  12. package/dist/repositories/chats.d.ts +55 -0
  13. package/dist/repositories/chats.js +469 -0
  14. package/dist/repositories/chats.js.map +1 -0
  15. package/dist/repositories/content.d.ts +24 -1
  16. package/dist/repositories/content.js +316 -9
  17. package/dist/repositories/content.js.map +1 -1
  18. package/dist/repositories/devices.d.ts +17 -0
  19. package/dist/repositories/devices.js +75 -0
  20. package/dist/repositories/devices.js.map +1 -0
  21. package/dist/repositories/digest-runs.d.ts +14 -0
  22. package/dist/repositories/digest-runs.js +39 -0
  23. package/dist/repositories/digest-runs.js.map +1 -0
  24. package/dist/repositories/discussion.d.ts +16 -4
  25. package/dist/repositories/discussion.js +78 -18
  26. package/dist/repositories/discussion.js.map +1 -1
  27. package/dist/repositories/entitlements.d.ts +31 -9
  28. package/dist/repositories/entitlements.js +213 -17
  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 +32 -1
  35. package/dist/repositories/identity.js.map +1 -1
  36. package/dist/repositories/inserted-row.d.ts +5 -0
  37. package/dist/repositories/inserted-row.js +7 -0
  38. package/dist/repositories/inserted-row.js.map +1 -0
  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 +2 -2
  43. package/dist/repositories/members.js.map +1 -1
  44. package/dist/repositories/mentions.d.ts +17 -0
  45. package/dist/repositories/mentions.js +55 -0
  46. package/dist/repositories/mentions.js.map +1 -0
  47. package/dist/repositories/notification-settings.d.ts +19 -0
  48. package/dist/repositories/notification-settings.js +67 -0
  49. package/dist/repositories/notification-settings.js.map +1 -0
  50. package/dist/repositories/notifications.d.ts +30 -0
  51. package/dist/repositories/notifications.js +178 -0
  52. package/dist/repositories/notifications.js.map +1 -0
  53. package/dist/repositories/org-user-profile.d.ts +214 -1
  54. package/dist/repositories/org-user-profile.js +23 -2
  55. package/dist/repositories/org-user-profile.js.map +1 -1
  56. package/dist/repositories/organizations.d.ts +4 -2
  57. package/dist/repositories/organizations.js +23 -8
  58. package/dist/repositories/organizations.js.map +1 -1
  59. package/dist/repositories/pg-errors.js +3 -1
  60. package/dist/repositories/pg-errors.js.map +1 -1
  61. package/dist/repositories/posts.d.ts +35 -0
  62. package/dist/repositories/posts.js +252 -0
  63. package/dist/repositories/posts.js.map +1 -0
  64. package/dist/repositories/subscriptions.d.ts +18 -0
  65. package/dist/repositories/subscriptions.js +72 -0
  66. package/dist/repositories/subscriptions.js.map +1 -0
  67. package/dist/schema/assets.d.ts +19 -2
  68. package/dist/schema/assets.js +10 -4
  69. package/dist/schema/assets.js.map +1 -1
  70. package/dist/schema/automations.d.ts +545 -2
  71. package/dist/schema/automations.js +73 -3
  72. package/dist/schema/automations.js.map +1 -1
  73. package/dist/schema/better-auth.d.ts +1613 -238
  74. package/dist/schema/better-auth.js +107 -22
  75. package/dist/schema/better-auth.js.map +1 -1
  76. package/dist/schema/content.d.ts +642 -5
  77. package/dist/schema/content.js +119 -10
  78. package/dist/schema/content.js.map +1 -1
  79. package/dist/schema/discussion.d.ts +2182 -42
  80. package/dist/schema/discussion.js +461 -11
  81. package/dist/schema/discussion.js.map +1 -1
  82. package/dist/schema/entitlements.d.ts +432 -1
  83. package/dist/schema/entitlements.js +109 -4
  84. package/dist/schema/entitlements.js.map +1 -1
  85. package/dist/schema/identity.d.ts +517 -1
  86. package/dist/schema/identity.js +76 -2
  87. package/dist/schema/identity.js.map +1 -1
  88. package/dist/schema/index.d.ts +7 -7
  89. package/dist/schema/index.js +35 -8
  90. package/dist/schema/organizations.d.ts +4 -4
  91. package/dist/schema/organizations.js +2 -0
  92. package/dist/schema/organizations.js.map +1 -1
  93. package/drizzle/0003_community.sql +340 -0
  94. package/drizzle/0004_assets_sources.sql +1 -0
  95. package/drizzle/0005_notices_and_seed_index.sql +12 -0
  96. package/drizzle/0006_community_review_fixes.sql +24 -0
  97. package/drizzle/0007_notifications.sql +37 -0
  98. package/drizzle/0008_content_access.sql +13 -0
  99. package/drizzle/0009_community_indexes.sql +56 -0
  100. package/drizzle/0010_mcp_access.sql +114 -0
  101. package/drizzle/0011_digest_runs.sql +8 -0
  102. package/drizzle/0012_subscriptions.sql +16 -0
  103. package/drizzle/0013_mentions.sql +10 -0
  104. package/drizzle/0014_notification_settings_devices.sql +36 -0
  105. package/drizzle/0015_automations_drop_source.sql +2 -0
  106. package/drizzle/0016_notifications_automations.sql +48 -0
  107. package/drizzle/meta/0003_snapshot.json +7061 -0
  108. package/drizzle/meta/0004_snapshot.json +7068 -0
  109. package/drizzle/meta/0005_snapshot.json +7180 -0
  110. package/drizzle/meta/0006_snapshot.json +7298 -0
  111. package/drizzle/meta/0007_snapshot.json +7620 -0
  112. package/drizzle/meta/0008_snapshot.json +7666 -0
  113. package/drizzle/meta/0009_snapshot.json +8185 -0
  114. package/drizzle/meta/0010_snapshot.json +8813 -0
  115. package/drizzle/meta/0011_snapshot.json +8868 -0
  116. package/drizzle/meta/0012_snapshot.json +8873 -0
  117. package/drizzle/meta/0013_snapshot.json +8943 -0
  118. package/drizzle/meta/0014_snapshot.json +9305 -0
  119. package/drizzle/meta/0015_snapshot.json +9270 -0
  120. package/drizzle/meta/0016_snapshot.json +9230 -0
  121. package/drizzle/meta/_journal.json +99 -1
  122. package/package.json +2 -2
  123. package/dist/repositories/course-analytics.d.ts +0 -15
  124. package/dist/repositories/course-analytics.js +0 -137
  125. package/dist/repositories/course-analytics.js.map +0 -1
  126. package/dist/repositories/dashboard.d.ts +0 -14
  127. package/dist/repositories/dashboard.js +0 -57
  128. package/dist/repositories/dashboard.js.map +0 -1
  129. package/dist/repositories/learn.d.ts +0 -19
  130. package/dist/repositories/learn.js +0 -56
  131. package/dist/repositories/learn.js.map +0 -1
  132. package/dist/repositories/students.d.ts +0 -16
  133. package/dist/repositories/students.js +0 -160
  134. package/dist/repositories/students.js.map +0 -1
@@ -1,12 +1,13 @@
1
- import { users } from "../schema/identity.js";
2
- import { orgUsers } from "../schema/organizations.js";
3
1
  import { activities } from "../schema/content.js";
4
- import { commentReactions, commentReports, comments } from "../schema/discussion.js";
2
+ import { userProfiles, users } from "../schema/identity.js";
3
+ import { orgUsers } from "../schema/organizations.js";
4
+ import { commentFlags, commentReactions, comments, posts } from "../schema/discussion.js";
5
5
  import "../schema/index.js";
6
6
  import { translateDbErrors } from "./pg-errors.js";
7
7
  import { user } from "../schema/better-auth.js";
8
- import { orgUserProfileColumns } from "./org-user-profile.js";
9
- import { and, asc, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm";
8
+ import { authorProfileColumns } from "./org-user-profile.js";
9
+ import { insertedRow } from "./inserted-row.js";
10
+ import { and, asc, desc, eq, ilike, inArray, isNull, ne, or, sql } from "drizzle-orm";
10
11
  import { noopLogger } from "@headless-lms/core/shared/logger";
11
12
  //#region src/repositories/discussion.ts
12
13
  /** The title lives in the activity's opaque settings blob; the moderation card
@@ -25,6 +26,8 @@ function toComment(row) {
25
26
  id: row.id,
26
27
  orgId: row.orgId,
27
28
  activityId: row.activityId,
29
+ postId: row.postId,
30
+ communityId: row.communityId,
28
31
  parentId: row.parentId ?? null,
29
32
  orgUserId: row.orgUserId,
30
33
  body: row.body,
@@ -46,6 +49,8 @@ var DrizzleDiscussionRepository = class {
46
49
  id: comment.id,
47
50
  orgId,
48
51
  activityId: comment.activityId,
52
+ postId: comment.postId,
53
+ communityId: comment.communityId,
49
54
  parentId: comment.parentId,
50
55
  orgUserId: comment.orgUserId,
51
56
  body: comment.body,
@@ -60,6 +65,9 @@ var DrizzleDiscussionRepository = class {
60
65
  const [row] = await this.db.select().from(comments).where(and(eq(comments.orgId, orgId), eq(comments.id, id))).limit(1);
61
66
  return row ? toComment(row) : null;
62
67
  }
68
+ async commenterIds(orgId, target) {
69
+ return (await this.db.selectDistinct({ orgUserId: comments.orgUserId }).from(comments).where(and(eq(comments.orgId, orgId), "postId" in target ? eq(comments.postId, target.postId) : eq(comments.activityId, target.activityId), eq(comments.status, "published"))).orderBy(asc(comments.orgUserId))).map((row) => row.orgUserId);
70
+ }
63
71
  async updateComment(orgId, id, patch) {
64
72
  const [row] = await this.db.update(comments).set({
65
73
  ..."body" in patch ? { body: patch.body } : {},
@@ -71,6 +79,25 @@ var DrizzleDiscussionRepository = class {
71
79
  async listByActivity(orgId, activityId) {
72
80
  return (await this.db.select().from(comments).where(and(eq(comments.orgId, orgId), eq(comments.activityId, activityId))).orderBy(comments.createdAt)).map(toComment);
73
81
  }
82
+ async listThreadsByPost(orgId, postId, query) {
83
+ const rootReadable = query.seesHeld ? void 0 : or(ne(comments.status, "pending"), eq(comments.orgUserId, query.viewerId));
84
+ const replyReadable = query.seesHeld ? sql`` : sql` and (rep.status = 'published' or rep.org_user_id = ${query.viewerId})`;
85
+ const where = and(eq(comments.orgId, orgId), eq(comments.postId, postId), isNull(comments.parentId), rootReadable, or(ne(comments.status, "removed"), sql`exists (
86
+ select 1 from comments rep
87
+ where rep.org_id = ${comments.orgId}
88
+ and rep.parent_id = ${comments.id}
89
+ and rep.status <> 'removed'${replyReadable}
90
+ )`));
91
+ const roots = await this.db.select().from(comments).where(where).orderBy(asc(comments.createdAt), asc(comments.id)).limit(query.pageSize).offset((query.page - 1) * query.pageSize);
92
+ const [{ total } = { total: 0 }] = await this.db.select({ total: sql`cast(count(*) as int)` }).from(comments).where(where);
93
+ const replies = roots.length === 0 ? [] : await this.db.select().from(comments).where(and(eq(comments.orgId, orgId), inArray(comments.parentId, roots.map((root) => root.id)))).orderBy(asc(comments.createdAt), asc(comments.id));
94
+ return {
95
+ rows: [...roots, ...replies].map(toComment),
96
+ total,
97
+ page: query.page,
98
+ pageSize: query.pageSize
99
+ };
100
+ }
74
101
  async reactionsOf(orgId, commentIds, viewerId) {
75
102
  if (commentIds.length === 0) return {};
76
103
  const rows = await this.db.select({
@@ -90,9 +117,9 @@ var DrizzleDiscussionRepository = class {
90
117
  async setReaction(orgId, commentId, orgUserId, emoji) {
91
118
  if (emoji === null) {
92
119
  await this.db.delete(commentReactions).where(and(eq(commentReactions.orgId, orgId), eq(commentReactions.commentId, commentId), eq(commentReactions.orgUserId, orgUserId)));
93
- return;
120
+ return false;
94
121
  }
95
- await this.db.insert(commentReactions).values({
122
+ const [row] = await this.db.insert(commentReactions).values({
96
123
  orgId,
97
124
  commentId,
98
125
  orgUserId,
@@ -104,14 +131,12 @@ var DrizzleDiscussionRepository = class {
104
131
  commentReactions.orgUserId
105
132
  ],
106
133
  set: { emoji }
107
- });
134
+ }).returning({ inserted: insertedRow });
135
+ return row?.inserted === true;
108
136
  }
109
137
  async authorsOf(orgId, orgUserIds) {
110
138
  if (orgUserIds.length === 0) return {};
111
- const rows = await this.db.select({
112
- ...orgUserProfileColumns,
113
- role: orgUsers.role
114
- }).from(orgUsers).innerJoin(users, eq(users.id, orgUsers.userId)).leftJoin(user, eq(user.id, users.externalId)).where(and(eq(orgUsers.orgId, orgId), inArray(orgUsers.id, orgUserIds)));
139
+ const rows = await this.db.select(authorProfileColumns).from(orgUsers).innerJoin(users, eq(users.id, orgUsers.userId)).leftJoin(user, eq(user.id, users.externalId)).leftJoin(userProfiles, eq(userProfiles.userId, orgUsers.userId)).where(and(eq(orgUsers.orgId, orgId), inArray(orgUsers.id, orgUserIds)));
115
140
  const byId = {};
116
141
  for (const row of rows) byId[row.id] = {
117
142
  id: row.id,
@@ -119,28 +144,63 @@ var DrizzleDiscussionRepository = class {
119
144
  lastName: row.lastName,
120
145
  email: row.email,
121
146
  image: row.image ?? null,
122
- role: row.role
147
+ role: row.role,
148
+ displayName: row.displayName ?? null,
149
+ headline: row.headline ?? null,
150
+ imageAssetOrgId: row.imageAssetOrgId ?? null,
151
+ imageAssetId: row.imageAssetId ?? null
123
152
  };
124
153
  return byId;
125
154
  }
126
155
  async insertReport(orgId, report) {
127
- const [row] = await this.db.insert(commentReports).values({
156
+ const [row] = await this.db.insert(commentFlags).values({
128
157
  id: report.id,
129
158
  orgId,
130
159
  commentId: report.commentId,
131
160
  orgUserId: report.orgUserId,
132
161
  reason: report.reason,
133
162
  resolvedAt: report.resolvedAt ? new Date(report.resolvedAt) : null,
163
+ resolution: report.resolution,
164
+ resolvedBy: report.resolvedBy,
134
165
  createdAt: new Date(report.createdAt)
135
166
  }).onConflictDoNothing().returning();
136
167
  return row ?? null;
137
168
  }
169
+ async listOpenFlagsForSpace(orgId, spaceId, limit) {
170
+ return (await this.db.select({
171
+ flag: commentFlags,
172
+ comment: {
173
+ id: comments.id,
174
+ postId: posts.id,
175
+ body: comments.body,
176
+ orgUserId: comments.orgUserId,
177
+ status: comments.status
178
+ }
179
+ }).from(commentFlags).innerJoin(comments, and(eq(comments.orgId, commentFlags.orgId), eq(comments.id, commentFlags.commentId))).innerJoin(posts, and(eq(posts.orgId, comments.orgId), eq(posts.id, comments.postId))).where(and(eq(commentFlags.orgId, orgId), eq(posts.spaceId, spaceId), isNull(commentFlags.resolvedAt))).orderBy(asc(commentFlags.createdAt), asc(commentFlags.id)).limit(limit)).map((row) => ({
180
+ ...row.flag,
181
+ comment: row.comment
182
+ }));
183
+ }
184
+ async publishedCommentCounts(orgId, postIds) {
185
+ if (postIds.length === 0) return {};
186
+ const rows = await this.db.select({
187
+ postId: comments.postId,
188
+ count: sql`cast(count(*) as int)`
189
+ }).from(comments).where(and(eq(comments.orgId, orgId), inArray(comments.postId, postIds), eq(comments.status, "published"))).groupBy(comments.postId);
190
+ const byPost = {};
191
+ for (const row of rows) if (row.postId !== null) byPost[row.postId] = row.count;
192
+ return byPost;
193
+ }
138
194
  async listOpenReports(orgId, commentIds) {
139
195
  if (commentIds.length === 0) return [];
140
- return await this.db.select().from(commentReports).where(and(eq(commentReports.orgId, orgId), inArray(commentReports.commentId, commentIds), isNull(commentReports.resolvedAt))).orderBy(commentReports.createdAt);
196
+ return await this.db.select().from(commentFlags).where(and(eq(commentFlags.orgId, orgId), inArray(commentFlags.commentId, commentIds), isNull(commentFlags.resolvedAt))).orderBy(commentFlags.createdAt);
141
197
  }
142
- async resolveReportsFor(orgId, commentId, resolvedAt) {
143
- await this.db.update(commentReports).set({ resolvedAt }).where(and(eq(commentReports.orgId, orgId), eq(commentReports.commentId, commentId), isNull(commentReports.resolvedAt)));
198
+ async resolveReportsFor(orgId, commentId, resolved) {
199
+ await this.db.update(commentFlags).set({
200
+ resolvedAt: resolved.resolvedAt,
201
+ resolvedBy: resolved.resolvedBy,
202
+ resolution: resolved.resolution
203
+ }).where(and(eq(commentFlags.orgId, orgId), eq(commentFlags.commentId, commentId), isNull(commentFlags.resolvedAt)));
144
204
  }
145
205
  /** The list's one join: comments to their activity, which carries both the
146
206
  * course (denormalised, so the module never enters the query) and the title.
@@ -166,7 +226,7 @@ var DrizzleDiscussionRepository = class {
166
226
  if (query.orgUserId) conditions.push(eq(comments.orgUserId, query.orgUserId));
167
227
  if (query.reported !== void 0) {
168
228
  const hasOpenReport = sql`exists (
169
- select 1 from ${commentReports} r
229
+ select 1 from ${commentFlags} r
170
230
  where r.org_id = ${comments.orgId}
171
231
  and r.comment_id = ${comments.id}
172
232
  and r.resolved_at is null
@@ -1 +1 @@
1
- {"version":3,"file":"discussion.js","names":[],"sources":["../../src/repositories/discussion.ts"],"sourcesContent":["import { and, asc, desc, eq, ilike, inArray, isNull, sql, type SQL } from \"drizzle-orm\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\nimport type {\n AuthorRecord,\n CommentReactions,\n CommentWithContext,\n DiscussionRepository,\n} from \"@headless-lms/core/discussion\";\nimport type {\n Comment,\n CommentReport,\n ListCommentsQuery,\n Logger,\n Page,\n ReactionEmoji,\n Role,\n} from \"@headless-lms/core/types\";\nimport { commentReactions, commentReports, comments } from \"../schema/index.js\";\nimport { activities } from \"../schema/content.js\";\nimport { orgUsers } from \"../schema/organizations.js\";\nimport { users } from \"../schema/identity.js\";\nimport { user } from \"../schema/better-auth.js\";\nimport { orgUserProfileColumns } from \"./org-user-profile.js\";\nimport { noopLogger } from \"@headless-lms/core/shared/logger\";\nimport { translateDbErrors } from \"./pg-errors.js\";\n\ntype CommentRow = typeof comments.$inferSelect;\n\n/** The title lives in the activity's opaque settings blob; the moderation card\n * needs it to say \"Lesson 3\" rather than an id. */\nconst activityTitleExpr = sql<string>`coalesce(${activities.settings} ->> 'title', '')`;\n\n/** Sortable columns of the staff comment list, by the client-facing field name.\n * `activityTitle` sorts on the same expression the row is served from. */\nconst sortColumns = {\n createdAt: comments.createdAt,\n updatedAt: comments.updatedAt,\n status: comments.status,\n activityTitle: activityTitleExpr,\n} as const;\n\nfunction toComment(row: CommentRow): Comment {\n return {\n id: row.id,\n orgId: row.orgId,\n activityId: row.activityId,\n parentId: row.parentId ?? null,\n orgUserId: row.orgUserId,\n body: row.body,\n status: row.status,\n removedBy: row.removedBy ?? null,\n createdAt: row.createdAt,\n updatedAt: row.updatedAt,\n };\n}\n\nexport class DrizzleDiscussionRepository implements DiscussionRepository {\n constructor(\n private readonly db: NodePgDatabase,\n private readonly logger: Logger = noopLogger,\n ) {}\n\n async insertComment(orgId: string, comment: Comment): Promise<Comment> {\n const [row] = await this.db\n .insert(comments)\n .values({\n id: comment.id,\n orgId,\n activityId: comment.activityId,\n parentId: comment.parentId,\n orgUserId: comment.orgUserId,\n body: comment.body,\n status: comment.status,\n removedBy: comment.removedBy,\n createdAt: new Date(comment.createdAt),\n updatedAt: new Date(comment.updatedAt),\n })\n .returning();\n return toComment(row!);\n }\n\n async findComment(orgId: string, id: string): Promise<Comment | null> {\n const [row] = await this.db\n .select()\n .from(comments)\n .where(and(eq(comments.orgId, orgId), eq(comments.id, id)))\n .limit(1);\n return row ? toComment(row) : null;\n }\n\n async updateComment(\n orgId: string,\n id: string,\n patch: Partial<Pick<Comment, \"body\" | \"status\" | \"removedBy\" | \"updatedAt\">>,\n ): Promise<Comment | null> {\n const [row] = await this.db\n .update(comments)\n .set({\n ...(\"body\" in patch ? { body: patch.body } : {}),\n ...(\"status\" in patch ? { status: patch.status } : {}),\n ...(\"removedBy\" in patch ? { removedBy: patch.removedBy ?? null } : {}),\n })\n .where(and(eq(comments.orgId, orgId), eq(comments.id, id)))\n .returning();\n return row ? toComment(row) : null;\n }\n\n async listByActivity(orgId: string, activityId: string): Promise<Comment[]> {\n const rows = await this.db\n .select()\n .from(comments)\n .where(and(eq(comments.orgId, orgId), eq(comments.activityId, activityId)))\n .orderBy(comments.createdAt);\n return rows.map(toComment);\n }\n\n async reactionsOf(\n orgId: string,\n commentIds: string[],\n viewerId: string,\n ): Promise<Record<string, CommentReactions>> {\n if (commentIds.length === 0) {\n return {};\n }\n const rows = await this.db\n .select({\n commentId: commentReactions.commentId,\n emoji: commentReactions.emoji,\n count: sql<number>`cast(count(*) as int)`,\n mine: sql<boolean>`bool_or(${commentReactions.orgUserId} = ${viewerId})`,\n })\n .from(commentReactions)\n .where(\n and(eq(commentReactions.orgId, orgId), inArray(commentReactions.commentId, commentIds)),\n )\n .groupBy(commentReactions.commentId, commentReactions.emoji);\n\n const byComment: Record<string, CommentReactions> = {};\n for (const row of rows) {\n const entry = (byComment[row.commentId] ??= { reactions: {} });\n entry.reactions[row.emoji] = row.count;\n if (row.mine) {\n entry.viewerReaction = row.emoji;\n }\n }\n return byComment;\n }\n\n async setReaction(\n orgId: string,\n commentId: string,\n orgUserId: string,\n emoji: ReactionEmoji | null,\n ): Promise<void> {\n if (emoji === null) {\n await this.db\n .delete(commentReactions)\n .where(\n and(\n eq(commentReactions.orgId, orgId),\n eq(commentReactions.commentId, commentId),\n eq(commentReactions.orgUserId, orgUserId),\n ),\n );\n return;\n }\n await this.db\n .insert(commentReactions)\n .values({ orgId, commentId, orgUserId, emoji })\n .onConflictDoUpdate({\n target: [commentReactions.orgId, commentReactions.commentId, commentReactions.orgUserId],\n set: { emoji },\n });\n }\n\n async authorsOf(orgId: string, orgUserIds: string[]): Promise<Record<string, AuthorRecord>> {\n if (orgUserIds.length === 0) {\n return {};\n }\n const rows = await this.db\n .select({ ...orgUserProfileColumns, role: orgUsers.role })\n .from(orgUsers)\n .innerJoin(users, eq(users.id, orgUsers.userId))\n .leftJoin(user, eq(user.id, users.externalId))\n .where(and(eq(orgUsers.orgId, orgId), inArray(orgUsers.id, orgUserIds)));\n\n const byId: Record<string, AuthorRecord> = {};\n for (const row of rows) {\n byId[row.id] = {\n id: row.id,\n firstName: row.firstName,\n lastName: row.lastName,\n email: row.email,\n image: row.image ?? null,\n role: row.role as Role,\n };\n }\n return byId;\n }\n\n async insertReport(orgId: string, report: CommentReport): Promise<CommentReport | null> {\n const [row] = await this.db\n .insert(commentReports)\n .values({\n id: report.id,\n orgId,\n commentId: report.commentId,\n orgUserId: report.orgUserId,\n reason: report.reason,\n resolvedAt: report.resolvedAt ? new Date(report.resolvedAt) : null,\n createdAt: new Date(report.createdAt),\n })\n .onConflictDoNothing()\n .returning();\n return row ?? null;\n }\n\n async listOpenReports(orgId: string, commentIds: string[]): Promise<CommentReport[]> {\n if (commentIds.length === 0) {\n return [];\n }\n const rows = await this.db\n .select()\n .from(commentReports)\n .where(\n and(\n eq(commentReports.orgId, orgId),\n inArray(commentReports.commentId, commentIds),\n isNull(commentReports.resolvedAt),\n ),\n )\n .orderBy(commentReports.createdAt);\n return rows;\n }\n\n async resolveReportsFor(orgId: string, commentId: string, resolvedAt: Date): Promise<void> {\n await this.db\n .update(commentReports)\n .set({ resolvedAt })\n .where(\n and(\n eq(commentReports.orgId, orgId),\n eq(commentReports.commentId, commentId),\n isNull(commentReports.resolvedAt),\n ),\n );\n }\n\n /** The list's one join: comments to their activity, which carries both the\n * course (denormalised, so the module never enters the query) and the title.\n * The course filter and the row's activity title come out of that same pass,\n * so neither costs an extra query. */\n private listRows(where: SQL | undefined) {\n return this.db\n .select({\n comment: comments,\n courseId: activities.courseId,\n activityTitle: activityTitleExpr,\n })\n .from(comments)\n .innerJoin(\n activities,\n and(eq(activities.orgId, comments.orgId), eq(activities.id, comments.activityId)),\n )\n .where(where);\n }\n\n /** The same join and filters, counted. Separate from `listRows` because the\n * total must ignore limit/offset. */\n private listTotal(where: SQL | undefined) {\n return this.db\n .select({ total: sql<number>`cast(count(*) as int)` })\n .from(comments)\n .innerJoin(\n activities,\n and(eq(activities.orgId, comments.orgId), eq(activities.id, comments.activityId)),\n )\n .where(where);\n }\n\n async listComments(orgId: string, query: ListCommentsQuery): Promise<Page<CommentWithContext>> {\n const conditions: SQL[] = [eq(comments.orgId, orgId)];\n if (query.status) {\n conditions.push(eq(comments.status, query.status));\n }\n if (query.courseId) {\n conditions.push(eq(activities.courseId, query.courseId));\n }\n if (query.activityId) {\n conditions.push(eq(comments.activityId, query.activityId));\n }\n if (query.orgUserId) {\n conditions.push(eq(comments.orgUserId, query.orgUserId));\n }\n if (query.reported !== undefined) {\n // Correlated EXISTS rather than a join to comment_reports: a comment with\n // three open reports must stay one row.\n const hasOpenReport = sql`exists (\n select 1 from ${commentReports} r\n where r.org_id = ${comments.orgId}\n and r.comment_id = ${comments.id}\n and r.resolved_at is null\n )`;\n conditions.push(query.reported ? hasOpenReport : sql`not ${hasOpenReport}`);\n }\n if (query.search) {\n conditions.push(ilike(comments.body, `%${query.search}%`));\n }\n const where = and(...conditions);\n\n // Sort: `-field` for descending; default to newest first, which is the\n // order a moderator works in.\n let orderBy: SQL;\n if (query.sort) {\n const isDesc = query.sort.startsWith(\"-\");\n const field = (isDesc ? query.sort.slice(1) : query.sort) as keyof typeof sortColumns;\n const col = sortColumns[field] ?? comments.createdAt;\n orderBy = isDesc ? desc(col) : asc(col);\n } else {\n orderBy = desc(comments.createdAt);\n }\n\n const rows = await this.listRows(where)\n .orderBy(orderBy)\n .limit(query.pageSize)\n .offset((query.page - 1) * query.pageSize);\n\n const [{ total } = { total: 0 }] = await this.listTotal(where);\n\n return {\n rows: rows.map((r) => ({\n comment: toComment(r.comment),\n courseId: r.courseId,\n activityTitle: r.activityTitle,\n })),\n total,\n page: query.page,\n pageSize: query.pageSize,\n };\n }\n}\ntranslateDbErrors(DrizzleDiscussionRepository);\n"],"mappings":"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"}
1
+ {"version":3,"file":"discussion.js","names":[],"sources":["../../src/repositories/discussion.ts"],"sourcesContent":["import { and, asc, desc, eq, ilike, inArray, isNull, ne, or, sql, type SQL } from \"drizzle-orm\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\nimport type {\n AuthorRecord,\n CommentReactions,\n CommentWithContext,\n DiscussionRepository,\n PostThreadsQuery,\n} from \"@headless-lms/core/discussion\";\nimport type {\n Comment,\n CommentReport,\n FlaggedComment,\n ListCommentsQuery,\n Logger,\n Page,\n ReactionEmoji,\n Role,\n} from \"@headless-lms/core/types\";\nimport { commentFlags, commentReactions, comments, posts } from \"../schema/index.js\";\nimport { activities } from \"../schema/content.js\";\nimport { orgUsers } from \"../schema/organizations.js\";\nimport { userProfiles, users } from \"../schema/identity.js\";\nimport { user } from \"../schema/better-auth.js\";\nimport { authorProfileColumns } from \"./org-user-profile.js\";\nimport { noopLogger } from \"@headless-lms/core/shared/logger\";\nimport { translateDbErrors } from \"./pg-errors.js\";\nimport { insertedRow } from \"./inserted-row.js\";\n\ntype CommentRow = typeof comments.$inferSelect;\n\n/** The title lives in the activity's opaque settings blob; the moderation card\n * needs it to say \"Lesson 3\" rather than an id. */\nconst activityTitleExpr = sql<string>`coalesce(${activities.settings} ->> 'title', '')`;\n\n/** Sortable columns of the staff comment list, by the client-facing field name.\n * `activityTitle` sorts on the same expression the row is served from. */\nconst sortColumns = {\n createdAt: comments.createdAt,\n updatedAt: comments.updatedAt,\n status: comments.status,\n activityTitle: activityTitleExpr,\n} as const;\n\nfunction toComment(row: CommentRow): Comment {\n return {\n id: row.id,\n orgId: row.orgId,\n activityId: row.activityId,\n postId: row.postId,\n communityId: row.communityId,\n parentId: row.parentId ?? null,\n orgUserId: row.orgUserId,\n body: row.body,\n status: row.status,\n removedBy: row.removedBy ?? null,\n createdAt: row.createdAt,\n updatedAt: row.updatedAt,\n };\n}\n\nexport class DrizzleDiscussionRepository implements DiscussionRepository {\n constructor(\n private readonly db: NodePgDatabase,\n private readonly logger: Logger = noopLogger,\n ) {}\n\n async insertComment(orgId: string, comment: Comment): Promise<Comment> {\n const [row] = await this.db\n .insert(comments)\n .values({\n id: comment.id,\n orgId,\n activityId: comment.activityId,\n postId: comment.postId,\n communityId: comment.communityId,\n parentId: comment.parentId,\n orgUserId: comment.orgUserId,\n body: comment.body,\n status: comment.status,\n removedBy: comment.removedBy,\n createdAt: new Date(comment.createdAt),\n updatedAt: new Date(comment.updatedAt),\n })\n .returning();\n return toComment(row!);\n }\n\n async findComment(orgId: string, id: string): Promise<Comment | null> {\n const [row] = await this.db\n .select()\n .from(comments)\n .where(and(eq(comments.orgId, orgId), eq(comments.id, id)))\n .limit(1);\n return row ? toComment(row) : null;\n }\n\n async commenterIds(\n orgId: string,\n target: { postId: string } | { activityId: string },\n ): Promise<string[]> {\n const rows = await this.db\n .selectDistinct({ orgUserId: comments.orgUserId })\n .from(comments)\n .where(\n and(\n eq(comments.orgId, orgId),\n \"postId\" in target\n ? eq(comments.postId, target.postId)\n : eq(comments.activityId, target.activityId),\n eq(comments.status, \"published\"),\n ),\n )\n .orderBy(asc(comments.orgUserId));\n return rows.map((row) => row.orgUserId);\n }\n\n async updateComment(\n orgId: string,\n id: string,\n patch: Partial<Pick<Comment, \"body\" | \"status\" | \"removedBy\" | \"updatedAt\">>,\n ): Promise<Comment | null> {\n const [row] = await this.db\n .update(comments)\n .set({\n ...(\"body\" in patch ? { body: patch.body } : {}),\n ...(\"status\" in patch ? { status: patch.status } : {}),\n ...(\"removedBy\" in patch ? { removedBy: patch.removedBy ?? null } : {}),\n })\n .where(and(eq(comments.orgId, orgId), eq(comments.id, id)))\n .returning();\n return row ? toComment(row) : null;\n }\n\n async listByActivity(orgId: string, activityId: string): Promise<Comment[]> {\n const rows = await this.db\n .select()\n .from(comments)\n .where(and(eq(comments.orgId, orgId), eq(comments.activityId, activityId)))\n .orderBy(comments.createdAt);\n return rows.map(toComment);\n }\n\n async listThreadsByPost(\n orgId: string,\n postId: string,\n query: PostThreadsQuery,\n ): Promise<Page<Comment>> {\n const rootReadable = query.seesHeld\n ? undefined\n : or(ne(comments.status, \"pending\"), eq(comments.orgUserId, query.viewerId));\n const replyReadable = query.seesHeld\n ? sql``\n : sql` and (rep.status = 'published' or rep.org_user_id = ${query.viewerId})`;\n const where = and(\n eq(comments.orgId, orgId),\n eq(comments.postId, postId),\n isNull(comments.parentId),\n rootReadable,\n or(\n ne(comments.status, \"removed\"),\n sql`exists (\n select 1 from comments rep\n where rep.org_id = ${comments.orgId}\n and rep.parent_id = ${comments.id}\n and rep.status <> 'removed'${replyReadable}\n )`,\n ),\n );\n const roots = await this.db\n .select()\n .from(comments)\n .where(where)\n .orderBy(asc(comments.createdAt), asc(comments.id))\n .limit(query.pageSize)\n .offset((query.page - 1) * query.pageSize);\n const [{ total } = { total: 0 }] = await this.db\n .select({ total: sql<number>`cast(count(*) as int)` })\n .from(comments)\n .where(where);\n const replies =\n roots.length === 0\n ? []\n : await this.db\n .select()\n .from(comments)\n .where(\n and(\n eq(comments.orgId, orgId),\n inArray(\n comments.parentId,\n roots.map((root) => root.id),\n ),\n ),\n )\n .orderBy(asc(comments.createdAt), asc(comments.id));\n return {\n rows: [...roots, ...replies].map(toComment),\n total,\n page: query.page,\n pageSize: query.pageSize,\n };\n }\n\n async reactionsOf(\n orgId: string,\n commentIds: string[],\n viewerId: string,\n ): Promise<Record<string, CommentReactions>> {\n if (commentIds.length === 0) {\n return {};\n }\n const rows = await this.db\n .select({\n commentId: commentReactions.commentId,\n emoji: commentReactions.emoji,\n count: sql<number>`cast(count(*) as int)`,\n mine: sql<boolean>`bool_or(${commentReactions.orgUserId} = ${viewerId})`,\n })\n .from(commentReactions)\n .where(\n and(eq(commentReactions.orgId, orgId), inArray(commentReactions.commentId, commentIds)),\n )\n .groupBy(commentReactions.commentId, commentReactions.emoji);\n\n const byComment: Record<string, CommentReactions> = {};\n for (const row of rows) {\n const entry = (byComment[row.commentId] ??= { reactions: {} });\n entry.reactions[row.emoji] = row.count;\n if (row.mine) {\n entry.viewerReaction = row.emoji;\n }\n }\n return byComment;\n }\n\n async setReaction(\n orgId: string,\n commentId: string,\n orgUserId: string,\n emoji: ReactionEmoji | null,\n ): Promise<boolean> {\n if (emoji === null) {\n await this.db\n .delete(commentReactions)\n .where(\n and(\n eq(commentReactions.orgId, orgId),\n eq(commentReactions.commentId, commentId),\n eq(commentReactions.orgUserId, orgUserId),\n ),\n );\n return false;\n }\n const [row] = await this.db\n .insert(commentReactions)\n .values({ orgId, commentId, orgUserId, emoji })\n .onConflictDoUpdate({\n target: [commentReactions.orgId, commentReactions.commentId, commentReactions.orgUserId],\n set: { emoji },\n })\n .returning({ inserted: insertedRow });\n return row?.inserted === true;\n }\n\n async authorsOf(orgId: string, orgUserIds: string[]): Promise<Record<string, AuthorRecord>> {\n if (orgUserIds.length === 0) {\n return {};\n }\n const rows = await this.db\n .select(authorProfileColumns)\n .from(orgUsers)\n .innerJoin(users, eq(users.id, orgUsers.userId))\n .leftJoin(user, eq(user.id, users.externalId))\n .leftJoin(userProfiles, eq(userProfiles.userId, orgUsers.userId))\n .where(and(eq(orgUsers.orgId, orgId), inArray(orgUsers.id, orgUserIds)));\n\n const byId: Record<string, AuthorRecord> = {};\n for (const row of rows) {\n byId[row.id] = {\n id: row.id,\n firstName: row.firstName,\n lastName: row.lastName,\n email: row.email,\n image: row.image ?? null,\n role: row.role as Role,\n displayName: row.displayName ?? null,\n headline: row.headline ?? null,\n imageAssetOrgId: row.imageAssetOrgId ?? null,\n imageAssetId: row.imageAssetId ?? null,\n };\n }\n return byId;\n }\n\n async insertReport(orgId: string, report: CommentReport): Promise<CommentReport | null> {\n const [row] = await this.db\n .insert(commentFlags)\n .values({\n id: report.id,\n orgId,\n commentId: report.commentId,\n orgUserId: report.orgUserId,\n reason: report.reason,\n resolvedAt: report.resolvedAt ? new Date(report.resolvedAt) : null,\n resolution: report.resolution,\n resolvedBy: report.resolvedBy,\n createdAt: new Date(report.createdAt),\n })\n .onConflictDoNothing()\n .returning();\n return row ?? null;\n }\n\n async listOpenFlagsForSpace(\n orgId: string,\n spaceId: string,\n limit: number,\n ): Promise<FlaggedComment[]> {\n const rows = await this.db\n .select({\n flag: commentFlags,\n comment: {\n id: comments.id,\n postId: posts.id,\n body: comments.body,\n orgUserId: comments.orgUserId,\n status: comments.status,\n },\n })\n .from(commentFlags)\n .innerJoin(\n comments,\n and(eq(comments.orgId, commentFlags.orgId), eq(comments.id, commentFlags.commentId)),\n )\n .innerJoin(posts, and(eq(posts.orgId, comments.orgId), eq(posts.id, comments.postId)))\n .where(\n and(\n eq(commentFlags.orgId, orgId),\n eq(posts.spaceId, spaceId),\n isNull(commentFlags.resolvedAt),\n ),\n )\n .orderBy(asc(commentFlags.createdAt), asc(commentFlags.id))\n .limit(limit);\n return rows.map((row) => ({ ...row.flag, comment: row.comment }));\n }\n\n async publishedCommentCounts(orgId: string, postIds: string[]): Promise<Record<string, number>> {\n if (postIds.length === 0) {\n return {};\n }\n const rows = await this.db\n .select({ postId: comments.postId, count: sql<number>`cast(count(*) as int)` })\n .from(comments)\n .where(\n and(\n eq(comments.orgId, orgId),\n inArray(comments.postId, postIds),\n eq(comments.status, \"published\"),\n ),\n )\n .groupBy(comments.postId);\n const byPost: Record<string, number> = {};\n for (const row of rows) {\n if (row.postId !== null) {\n byPost[row.postId] = row.count;\n }\n }\n return byPost;\n }\n\n async listOpenReports(orgId: string, commentIds: string[]): Promise<CommentReport[]> {\n if (commentIds.length === 0) {\n return [];\n }\n const rows = await this.db\n .select()\n .from(commentFlags)\n .where(\n and(\n eq(commentFlags.orgId, orgId),\n inArray(commentFlags.commentId, commentIds),\n isNull(commentFlags.resolvedAt),\n ),\n )\n .orderBy(commentFlags.createdAt);\n return rows;\n }\n\n async resolveReportsFor(\n orgId: string,\n commentId: string,\n resolved: {\n resolvedAt: Date;\n resolvedBy: string;\n resolution: \"dismissed\" | \"removed\";\n },\n ): Promise<void> {\n await this.db\n .update(commentFlags)\n .set({\n resolvedAt: resolved.resolvedAt,\n resolvedBy: resolved.resolvedBy,\n resolution: resolved.resolution,\n })\n .where(\n and(\n eq(commentFlags.orgId, orgId),\n eq(commentFlags.commentId, commentId),\n isNull(commentFlags.resolvedAt),\n ),\n );\n }\n\n /** The list's one join: comments to their activity, which carries both the\n * course (denormalised, so the module never enters the query) and the title.\n * The course filter and the row's activity title come out of that same pass,\n * so neither costs an extra query. */\n private listRows(where: SQL | undefined) {\n return this.db\n .select({\n comment: comments,\n courseId: activities.courseId,\n activityTitle: activityTitleExpr,\n })\n .from(comments)\n .innerJoin(\n activities,\n and(eq(activities.orgId, comments.orgId), eq(activities.id, comments.activityId)),\n )\n .where(where);\n }\n\n /** The same join and filters, counted. Separate from `listRows` because the\n * total must ignore limit/offset. */\n private listTotal(where: SQL | undefined) {\n return this.db\n .select({ total: sql<number>`cast(count(*) as int)` })\n .from(comments)\n .innerJoin(\n activities,\n and(eq(activities.orgId, comments.orgId), eq(activities.id, comments.activityId)),\n )\n .where(where);\n }\n\n async listComments(orgId: string, query: ListCommentsQuery): Promise<Page<CommentWithContext>> {\n const conditions: SQL[] = [eq(comments.orgId, orgId)];\n if (query.status) {\n conditions.push(eq(comments.status, query.status));\n }\n if (query.courseId) {\n conditions.push(eq(activities.courseId, query.courseId));\n }\n if (query.activityId) {\n conditions.push(eq(comments.activityId, query.activityId));\n }\n if (query.orgUserId) {\n conditions.push(eq(comments.orgUserId, query.orgUserId));\n }\n if (query.reported !== undefined) {\n // Correlated EXISTS rather than a join to comment_flags: a comment with\n // three open reports must stay one row.\n const hasOpenReport = sql`exists (\n select 1 from ${commentFlags} r\n where r.org_id = ${comments.orgId}\n and r.comment_id = ${comments.id}\n and r.resolved_at is null\n )`;\n conditions.push(query.reported ? hasOpenReport : sql`not ${hasOpenReport}`);\n }\n if (query.search) {\n conditions.push(ilike(comments.body, `%${query.search}%`));\n }\n const where = and(...conditions);\n\n // Sort: `-field` for descending; default to newest first, which is the\n // order a moderator works in.\n let orderBy: SQL;\n if (query.sort) {\n const isDesc = query.sort.startsWith(\"-\");\n const field = (isDesc ? query.sort.slice(1) : query.sort) as keyof typeof sortColumns;\n const col = sortColumns[field] ?? comments.createdAt;\n orderBy = isDesc ? desc(col) : asc(col);\n } else {\n orderBy = desc(comments.createdAt);\n }\n\n const rows = await this.listRows(where)\n .orderBy(orderBy)\n .limit(query.pageSize)\n .offset((query.page - 1) * query.pageSize);\n\n const [{ total } = { total: 0 }] = await this.listTotal(where);\n\n return {\n rows: rows.map((r) => ({\n comment: toComment(r.comment),\n courseId: r.courseId,\n activityTitle: r.activityTitle,\n })),\n total,\n page: query.page,\n pageSize: query.pageSize,\n };\n }\n}\ntranslateDbErrors(DrizzleDiscussionRepository);\n"],"mappings":"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"}
@@ -1,6 +1,6 @@
1
1
  import { DbExecutor } from "../client.js";
2
- import { Entitlement, EntitlementsQuery, GrantEntitlementInput, Logger, Page } from "@headless-lms/core/types";
3
- import { EntitlementsRepository } from "@headless-lms/core/entitlements";
2
+ import { ContentType, Entitlement, EntitlementsQuery, GrantEntitlementInput, Logger, Page, SpaceMember, SpaceMemberStatus } from "@headless-lms/core/types";
3
+ import { AccessFacts, EndingGrant, EndingGrantKey, EntitlementNotice, EntitlementsRepository, GrantKey, GrantTarget, InsertSpaceMemberInput, SpaceMembersQuery } from "@headless-lms/core/entitlements";
4
4
  //#region src/repositories/entitlements.d.ts
5
5
  declare class DrizzleEntitlementsRepository implements EntitlementsRepository {
6
6
  private readonly db;
@@ -15,14 +15,36 @@ declare class DrizzleEntitlementsRepository implements EntitlementsRepository {
15
15
  * constraint), so the upsert is inferred on whichever of the two partial
16
16
  * uniques the target uses. */
17
17
  insert(orgId: string, input: GrantEntitlementInput): Promise<Entitlement>;
18
+ insertIfAbsent(orgId: string, input: GrantEntitlementInput): Promise<Entitlement | null>;
19
+ lockGrant(orgId: string, key: GrantKey): Promise<Entitlement | null>;
20
+ setExpiresAt(orgId: string, id: string, expiresAt: Date | null): Promise<Entitlement | null>;
18
21
  setStatus(orgId: string, id: string, status: "active" | "revoked"): Promise<Entitlement | null>;
19
- /** Existence check scoped to the course case: same status predicate as
20
- * `list` (revoked never counts; an elapsed expiry reads as expired, not
21
- * active). The course is reachable two ways — granted directly, or granted
22
- * through a bundle that holds it — and either way the row is joined to
23
- * content_items and constrained to type = 'course', so a grant on some other
24
- * content type can never be mistaken for course access. */
25
- hasCourseAccess(orgId: string, orgUserId: string, courseId: string): Promise<boolean>;
22
+ hasAccess(orgId: string, orgUserId: string, contentId: string): Promise<boolean>;
23
+ listAccessibleContent(orgId: string, orgUserId: string, type?: ContentType): Promise<string[]>;
24
+ hasCommunityAccess(orgId: string, orgUserId: string, communityId: string): Promise<boolean>;
25
+ private holds;
26
+ communitiesOpenedBy(orgId: string, grant: GrantTarget): Promise<string[]>;
27
+ accessAmong(orgId: string, communityId: string, spaceId: string | null, orgUserIds: string[]): Promise<AccessFacts[]>;
28
+ findSpaceMember(orgId: string, spaceId: string, orgUserId: string): Promise<SpaceMember | null>;
29
+ listSpaceMembersFor(orgId: string, orgUserId: string, communityId: string): Promise<SpaceMember[]>;
30
+ lockSpaceMember(orgId: string, spaceId: string, orgUserId: string): Promise<SpaceMember | null>;
31
+ listSpaceMembers(orgId: string, spaceId: string, query: SpaceMembersQuery): Promise<Page<SpaceMember>>;
32
+ insertSpaceMember(orgId: string, input: InsertSpaceMemberInput): Promise<SpaceMember>;
33
+ updateSpaceMemberStatus(orgId: string, id: string, status: SpaceMemberStatus): Promise<SpaceMember | null>;
34
+ deleteSpaceMember(orgId: string, id: string): Promise<void>;
35
+ countActiveSpaceMembers(orgId: string, spaceId: string): Promise<number>;
36
+ activeSpaceIds(orgId: string, orgUserId: string, communityId: string): Promise<string[]>;
37
+ listGrantsExpiredBy(orgId: string, now: Date, limit: number, afterId?: string): Promise<Entitlement[]>;
38
+ listGrantsEndingBetween(orgId: string, after: Date, until: Date, limit: number, afterKey?: EndingGrantKey): Promise<EndingGrant[]>;
39
+ orgIdsWithDueNotices(now: Date, until: Date): Promise<string[]>;
40
+ claimNotice(orgId: string, notice: EntitlementNotice, now: Date): Promise<boolean>;
41
+ private expiredUnnoticed;
42
+ private endingUnnoticed;
43
+ communityModeratorIds(orgId: string, communityId: string): Promise<string[]>;
44
+ communityMemberIds(orgId: string, communityId: string): Promise<string[]>;
45
+ communityIdsOpenedFor(orgId: string, orgUserId: string): Promise<string[]>;
46
+ contentHolderIds(orgId: string, contentId: string): Promise<string[]>;
47
+ openCommunityIds(orgId: string, orgUserId: string): Promise<string[]>;
26
48
  }
27
49
  //#endregion
28
50
  export { DrizzleEntitlementsRepository };
@@ -1,19 +1,38 @@
1
+ import { bundles, communities, contentItems, courses, downloads } from "../schema/content.js";
1
2
  import { users } from "../schema/identity.js";
2
3
  import { orgUsers } from "../schema/organizations.js";
3
- import { bundleItems, bundles, contentItems, courses, downloads } from "../schema/content.js";
4
- import { entitlements } from "../schema/entitlements.js";
4
+ import { contentAccess, entitlementEndingSoonNotices, entitlementExpiryNotices, entitlements, spaceMembers } from "../schema/entitlements.js";
5
5
  import "../schema/index.js";
6
6
  import { translateDbErrors } from "./pg-errors.js";
7
- import { and, asc, desc, eq, ilike, or, sql } from "drizzle-orm";
8
- import { alias } from "drizzle-orm/pg-core";
7
+ import { communityMember, grantExpired, grantIsActive, grantOpens, holdsCommunity } from "./grant-covers.js";
8
+ import { and, asc, desc, eq, gt, ilike, inArray, or, sql } from "drizzle-orm";
9
9
  import { noopLogger } from "@headless-lms/core/shared/logger";
10
+ import { ROLES, canModerate } from "@headless-lms/core/organizations";
10
11
  //#region src/repositories/entitlements.ts
12
+ const MODERATING_ROLES = ROLES.filter((role) => canModerate(role));
11
13
  const derivedStatus = sql`case
12
14
  when ${entitlements.status} = 'revoked' then 'revoked'
13
- when ${entitlements.expiresAt} is not null and ${entitlements.expiresAt} < now() then 'expired'
15
+ when ${grantExpired(entitlements.expiresAt)} then 'expired'
14
16
  else 'active'
15
17
  end`;
16
- const contentTitle = sql`coalesce(${courses.title}, ${downloads.title}, ${bundles.name})`;
18
+ function at(date) {
19
+ return sql`${date.toISOString()}::timestamptz`;
20
+ }
21
+ const expiryNoticed = sql`exists (
22
+ select 1 from ${entitlementExpiryNotices}
23
+ where ${entitlementExpiryNotices.orgId} = ${entitlements.orgId}
24
+ and ${entitlementExpiryNotices.entitlementId} = ${entitlements.id}
25
+ and ${entitlementExpiryNotices.expiresAt} = ${entitlements.expiresAt}
26
+ )`;
27
+ const endingSoonNoticed = sql`exists (
28
+ select 1 from ${entitlementEndingSoonNotices}
29
+ where ${entitlementEndingSoonNotices.orgId} = ${entitlements.orgId}
30
+ and ${entitlementEndingSoonNotices.entitlementId} = ${entitlements.id}
31
+ and ${entitlementEndingSoonNotices.communityId} = ${communities.id}
32
+ and ${entitlementEndingSoonNotices.expiresAt} = ${entitlements.expiresAt}
33
+ )`;
34
+ const opensPublishedCommunity = and(eq(communities.orgId, entitlements.orgId), eq(communities.status, "published"), grantOpens(entitlements, sql`${communities.id}`, "community"));
35
+ const contentTitle = sql`coalesce(${courses.title}, ${downloads.title}, ${communities.title}, ${bundles.name})`;
17
36
  const sortColumns = {
18
37
  firstName: users.firstName,
19
38
  lastName: users.lastName,
@@ -63,7 +82,7 @@ var DrizzleEntitlementsRepository = class {
63
82
  * LEFT JOIN per concrete content table (title), or bundles (name). Both target
64
83
  * joins are LEFT — a row carries one of the two, never both. */
65
84
  joined(where) {
66
- return this.db.select(selection).from(entitlements).innerJoin(orgUsers, and(eq(orgUsers.orgId, entitlements.orgId), eq(orgUsers.id, entitlements.orgUserId))).innerJoin(users, eq(users.id, orgUsers.userId)).leftJoin(contentItems, and(eq(contentItems.orgId, entitlements.orgId), eq(contentItems.id, entitlements.contentId))).leftJoin(bundles, and(eq(bundles.orgId, entitlements.orgId), eq(bundles.id, entitlements.bundleId))).leftJoin(courses, and(eq(courses.orgId, entitlements.orgId), eq(courses.id, entitlements.contentId))).leftJoin(downloads, and(eq(downloads.orgId, entitlements.orgId), eq(downloads.id, entitlements.contentId))).where(where);
85
+ return this.db.select(selection).from(entitlements).innerJoin(orgUsers, and(eq(orgUsers.orgId, entitlements.orgId), eq(orgUsers.id, entitlements.orgUserId))).innerJoin(users, eq(users.id, orgUsers.userId)).leftJoin(contentItems, and(eq(contentItems.orgId, entitlements.orgId), eq(contentItems.id, entitlements.contentId))).leftJoin(bundles, and(eq(bundles.orgId, entitlements.orgId), eq(bundles.id, entitlements.bundleId))).leftJoin(courses, and(eq(courses.orgId, entitlements.orgId), eq(courses.id, entitlements.contentId))).leftJoin(downloads, and(eq(downloads.orgId, entitlements.orgId), eq(downloads.id, entitlements.contentId))).leftJoin(communities, and(eq(communities.orgId, entitlements.orgId), eq(communities.id, entitlements.contentId))).where(where);
67
86
  }
68
87
  async list(orgId, query) {
69
88
  const conditions = [eq(entitlements.orgId, orgId)];
@@ -87,7 +106,7 @@ var DrizzleEntitlementsRepository = class {
87
106
  } else orderBy = desc(entitlements.grantedAt);
88
107
  const offset = (query.page - 1) * query.pageSize;
89
108
  const rows = await this.joined(where).orderBy(orderBy).limit(query.pageSize).offset(offset);
90
- const [{ total } = { total: 0 }] = await this.db.select({ total: sql`cast(count(*) as int)` }).from(entitlements).innerJoin(orgUsers, and(eq(orgUsers.orgId, entitlements.orgId), eq(orgUsers.id, entitlements.orgUserId))).innerJoin(users, eq(users.id, orgUsers.userId)).leftJoin(contentItems, and(eq(contentItems.orgId, entitlements.orgId), eq(contentItems.id, entitlements.contentId))).leftJoin(bundles, and(eq(bundles.orgId, entitlements.orgId), eq(bundles.id, entitlements.bundleId))).leftJoin(courses, and(eq(courses.orgId, entitlements.orgId), eq(courses.id, entitlements.contentId))).leftJoin(downloads, and(eq(downloads.orgId, entitlements.orgId), eq(downloads.id, entitlements.contentId))).where(where);
109
+ const [{ total } = { total: 0 }] = await this.db.select({ total: sql`cast(count(*) as int)` }).from(entitlements).innerJoin(orgUsers, and(eq(orgUsers.orgId, entitlements.orgId), eq(orgUsers.id, entitlements.orgUserId))).innerJoin(users, eq(users.id, orgUsers.userId)).leftJoin(contentItems, and(eq(contentItems.orgId, entitlements.orgId), eq(contentItems.id, entitlements.contentId))).leftJoin(bundles, and(eq(bundles.orgId, entitlements.orgId), eq(bundles.id, entitlements.bundleId))).leftJoin(courses, and(eq(courses.orgId, entitlements.orgId), eq(courses.id, entitlements.contentId))).leftJoin(downloads, and(eq(downloads.orgId, entitlements.orgId), eq(downloads.id, entitlements.contentId))).leftJoin(communities, and(eq(communities.orgId, entitlements.orgId), eq(communities.id, entitlements.contentId))).where(where);
91
110
  return {
92
111
  rows: rows.map(toEntitlement),
93
112
  total,
@@ -131,22 +150,199 @@ var DrizzleEntitlementsRepository = class {
131
150
  if (!row) throw new Error("failed to insert entitlement");
132
151
  return toEntitlement(row);
133
152
  }
153
+ async insertIfAbsent(orgId, input) {
154
+ const bundleId = input.bundleId ?? null;
155
+ const contentId = input.contentId ?? null;
156
+ const target = bundleId ? [
157
+ entitlements.orgId,
158
+ entitlements.orgUserId,
159
+ entitlements.bundleId
160
+ ] : [
161
+ entitlements.orgId,
162
+ entitlements.orgUserId,
163
+ entitlements.contentId
164
+ ];
165
+ const [row] = await this.db.insert(entitlements).values({
166
+ orgId,
167
+ orgUserId: input.orgUserId,
168
+ bundleId,
169
+ contentId,
170
+ status: "active",
171
+ source: "manual",
172
+ grantedAt: /* @__PURE__ */ new Date(),
173
+ expiresAt: input.expiresAt
174
+ }).onConflictDoNothing({ target }).returning(selection);
175
+ return row ? toEntitlement(row) : null;
176
+ }
177
+ async lockGrant(orgId, key) {
178
+ const target = key.bundleId ? eq(entitlements.bundleId, key.bundleId) : key.contentId ? eq(entitlements.contentId, key.contentId) : void 0;
179
+ if (!target) return null;
180
+ const [row] = await this.db.select(selection).from(entitlements).where(and(eq(entitlements.orgId, orgId), eq(entitlements.orgUserId, key.orgUserId), target)).for("update").limit(1);
181
+ return row ? toEntitlement(row) : null;
182
+ }
183
+ async setExpiresAt(orgId, id, expiresAt) {
184
+ const [row] = await this.db.update(entitlements).set({ expiresAt }).where(and(eq(entitlements.orgId, orgId), eq(entitlements.id, id))).returning(selection);
185
+ return row ? toEntitlement(row) : null;
186
+ }
134
187
  async setStatus(orgId, id, status) {
135
188
  const [row] = await this.db.update(entitlements).set({ status }).where(and(eq(entitlements.orgId, orgId), eq(entitlements.id, id))).returning(selection);
136
189
  if (!row) return null;
137
190
  return toEntitlement(row);
138
191
  }
139
- /** Existence check scoped to the course case: same status predicate as
140
- * `list` (revoked never counts; an elapsed expiry reads as expired, not
141
- * active). The course is reachable two ways — granted directly, or granted
142
- * through a bundle that holds it — and either way the row is joined to
143
- * content_items and constrained to type = 'course', so a grant on some other
144
- * content type can never be mistaken for course access. */
145
- async hasCourseAccess(orgId, orgUserId, courseId) {
146
- const bundledItems = alias(contentItems, "bundled_content_items");
147
- return (await this.db.select({ id: entitlements.id }).from(entitlements).leftJoin(contentItems, and(eq(contentItems.orgId, entitlements.orgId), eq(contentItems.id, entitlements.contentId))).leftJoin(bundleItems, and(eq(bundleItems.orgId, entitlements.orgId), eq(bundleItems.bundleId, entitlements.bundleId))).leftJoin(bundledItems, and(eq(bundledItems.orgId, bundleItems.orgId), eq(bundledItems.id, bundleItems.contentId))).where(and(eq(entitlements.orgId, orgId), eq(entitlements.orgUserId, orgUserId), or(and(eq(entitlements.contentId, courseId), eq(contentItems.type, "course")), and(eq(bundleItems.contentId, courseId), eq(bundledItems.type, "course"))), sql`${derivedStatus} = 'active'`)).limit(1)).length > 0;
192
+ async hasAccess(orgId, orgUserId, contentId) {
193
+ return (await this.db.select({ id: contentAccess.entitlementId }).from(contentAccess).where(and(eq(contentAccess.orgId, orgId), eq(contentAccess.orgUserId, orgUserId), eq(contentAccess.contentId, contentId))).limit(1)).length > 0;
194
+ }
195
+ async listAccessibleContent(orgId, orgUserId, type) {
196
+ return (await this.db.selectDistinct({ contentId: contentAccess.contentId }).from(contentAccess).innerJoin(contentItems, and(eq(contentItems.orgId, contentAccess.orgId), eq(contentItems.id, contentAccess.contentId))).where(and(eq(contentAccess.orgId, orgId), eq(contentAccess.orgUserId, orgUserId), type ? eq(contentItems.type, type) : void 0))).map((r) => r.contentId);
197
+ }
198
+ hasCommunityAccess(orgId, orgUserId, communityId) {
199
+ return this.holds(holdsCommunity(sql`${orgId}`, sql`${orgUserId}`, sql`${communityId}`));
200
+ }
201
+ async holds(predicate) {
202
+ const result = await this.db.execute(sql`select ${predicate} as covered`);
203
+ return (Array.isArray(result) ? result : result.rows)[0]?.covered === true;
204
+ }
205
+ async communitiesOpenedBy(orgId, grant) {
206
+ if (!grant.contentId && !grant.bundleId) return [];
207
+ const target = {
208
+ orgId: sql`${orgId}`,
209
+ contentId: sql`${grant.contentId ?? null}::text`,
210
+ bundleId: sql`${grant.bundleId ?? null}::text`
211
+ };
212
+ return (await this.db.select({ id: communities.id }).from(communities).where(and(eq(communities.orgId, orgId), eq(communities.status, "published"), grantOpens(target, sql`${communities.id}`, "community"))).orderBy(asc(communities.id))).map((row) => row.id);
213
+ }
214
+ async accessAmong(orgId, communityId, spaceId, orgUserIds) {
215
+ if (orgUserIds.length === 0) return [];
216
+ const activeInSpace = spaceId ? sql`exists (
217
+ select 1 from space_members sm
218
+ where sm.org_id = ${sql`${orgUsers.orgId}`}
219
+ and sm.space_id = ${spaceId}
220
+ and sm.org_user_id = ${sql`${orgUsers.id}`}
221
+ and sm.status = 'active'
222
+ )` : sql`false`;
223
+ return (await this.db.select({
224
+ orgUserId: orgUsers.id,
225
+ role: orgUsers.role,
226
+ member: sql`${communityMember(sql`${orgUsers.orgId}`, sql`${orgUsers.id}`, sql`${orgUsers.role}`, sql`${communityId}`)}`,
227
+ activeInSpace
228
+ }).from(orgUsers).where(and(eq(orgUsers.orgId, orgId), inArray(orgUsers.id, orgUserIds)))).map((row) => ({
229
+ orgUserId: row.orgUserId,
230
+ role: row.role,
231
+ member: row.member === true,
232
+ activeInSpace: row.activeInSpace === true
233
+ }));
234
+ }
235
+ async findSpaceMember(orgId, spaceId, orgUserId) {
236
+ const [row] = await this.db.select().from(spaceMembers).where(and(eq(spaceMembers.orgId, orgId), eq(spaceMembers.spaceId, spaceId), eq(spaceMembers.orgUserId, orgUserId))).limit(1);
237
+ return row ? toSpaceMember(row) : null;
238
+ }
239
+ async listSpaceMembersFor(orgId, orgUserId, communityId) {
240
+ return (await this.db.select().from(spaceMembers).where(and(eq(spaceMembers.orgId, orgId), eq(spaceMembers.orgUserId, orgUserId), eq(spaceMembers.communityId, communityId)))).map(toSpaceMember);
241
+ }
242
+ async lockSpaceMember(orgId, spaceId, orgUserId) {
243
+ const [row] = await this.db.select().from(spaceMembers).where(and(eq(spaceMembers.orgId, orgId), eq(spaceMembers.spaceId, spaceId), eq(spaceMembers.orgUserId, orgUserId))).for("update").limit(1);
244
+ return row ? toSpaceMember(row) : null;
245
+ }
246
+ async listSpaceMembers(orgId, spaceId, query) {
247
+ const where = and(eq(spaceMembers.orgId, orgId), eq(spaceMembers.spaceId, spaceId), query.statuses ? inArray(spaceMembers.status, query.statuses) : void 0);
248
+ const rows = await this.db.select().from(spaceMembers).where(where).orderBy(asc(spaceMembers.createdAt), asc(spaceMembers.id)).limit(query.pageSize).offset((query.page - 1) * query.pageSize);
249
+ const [{ total } = { total: 0 }] = await this.db.select({ total: sql`cast(count(*) as int)` }).from(spaceMembers).where(where);
250
+ return {
251
+ rows: rows.map(toSpaceMember),
252
+ total,
253
+ page: query.page,
254
+ pageSize: query.pageSize
255
+ };
256
+ }
257
+ async insertSpaceMember(orgId, input) {
258
+ const [row] = await this.db.insert(spaceMembers).values({
259
+ orgId,
260
+ communityId: input.communityId,
261
+ spaceId: input.spaceId,
262
+ orgUserId: input.orgUserId,
263
+ status: input.status,
264
+ source: input.source
265
+ }).returning();
266
+ if (!row) throw new Error("failed to insert space member");
267
+ return toSpaceMember(row);
268
+ }
269
+ async updateSpaceMemberStatus(orgId, id, status) {
270
+ const [row] = await this.db.update(spaceMembers).set({ status }).where(and(eq(spaceMembers.orgId, orgId), eq(spaceMembers.id, id))).returning();
271
+ return row ? toSpaceMember(row) : null;
272
+ }
273
+ async deleteSpaceMember(orgId, id) {
274
+ await this.db.delete(spaceMembers).where(and(eq(spaceMembers.orgId, orgId), eq(spaceMembers.id, id)));
275
+ }
276
+ async countActiveSpaceMembers(orgId, spaceId) {
277
+ const [row] = await this.db.select({ total: sql`cast(count(*) as int)` }).from(spaceMembers).where(and(eq(spaceMembers.orgId, orgId), eq(spaceMembers.spaceId, spaceId), eq(spaceMembers.status, "active"), holdsCommunity(sql`${spaceMembers.orgId}`, sql`${spaceMembers.orgUserId}`, sql`${spaceMembers.communityId}`)));
278
+ return row?.total ?? 0;
279
+ }
280
+ async activeSpaceIds(orgId, orgUserId, communityId) {
281
+ return (await this.db.select({ spaceId: spaceMembers.spaceId }).from(spaceMembers).where(and(eq(spaceMembers.orgId, orgId), eq(spaceMembers.orgUserId, orgUserId), eq(spaceMembers.communityId, communityId), eq(spaceMembers.status, "active")))).map((row) => row.spaceId);
282
+ }
283
+ async listGrantsExpiredBy(orgId, now, limit, afterId) {
284
+ return (await this.db.select(selection).from(entitlements).where(and(this.expiredUnnoticed(orgId, now), afterId === void 0 ? void 0 : gt(entitlements.id, afterId))).orderBy(asc(entitlements.id)).limit(limit)).map(toEntitlement);
285
+ }
286
+ async listGrantsEndingBetween(orgId, after, until, limit, afterKey) {
287
+ return (await this.db.select({
288
+ ...selection,
289
+ communityId: communities.id
290
+ }).from(entitlements).innerJoin(communities, opensPublishedCommunity).where(and(this.endingUnnoticed(orgId, after, until), afterKey ? sql`(${entitlements.id}, ${communities.id}) > (${afterKey.entitlementId}, ${afterKey.communityId})` : void 0)).orderBy(asc(entitlements.id), asc(communities.id)).limit(limit)).map(({ communityId, ...grant }) => ({
291
+ grant: toEntitlement(grant),
292
+ communityId
293
+ }));
294
+ }
295
+ async orgIdsWithDueNotices(now, until) {
296
+ const expired = this.db.selectDistinct({ orgId: entitlements.orgId }).from(entitlements).where(this.expiredUnnoticed(null, now));
297
+ const ending = this.db.selectDistinct({ orgId: entitlements.orgId }).from(entitlements).innerJoin(communities, opensPublishedCommunity).where(this.endingUnnoticed(null, now, until));
298
+ return (await expired.union(ending)).map((row) => row.orgId).sort();
299
+ }
300
+ async claimNotice(orgId, notice, now) {
301
+ const statement = notice.kind === "expired" ? sql`insert into ${entitlementExpiryNotices} ("org_id", "entitlement_id", "expires_at", "notified_at")
302
+ values (${orgId}, ${notice.entitlementId}, ${at(notice.expiresAt)}, ${at(now)})
303
+ on conflict do nothing
304
+ returning "entitlement_id"` : sql`insert into ${entitlementEndingSoonNotices} ("org_id", "entitlement_id", "community_id", "expires_at", "notified_at")
305
+ values (${orgId}, ${notice.entitlementId}, ${notice.communityId}, ${at(notice.expiresAt)}, ${at(now)})
306
+ on conflict do nothing
307
+ returning "entitlement_id"`;
308
+ const result = await this.db.execute(statement);
309
+ return (Array.isArray(result) ? result : result.rows).length > 0;
310
+ }
311
+ expiredUnnoticed(orgId, now) {
312
+ return and(orgId === null ? void 0 : eq(entitlements.orgId, orgId), eq(entitlements.status, "active"), grantExpired(entitlements.expiresAt, at(now)), sql`not ${expiryNoticed}`);
313
+ }
314
+ endingUnnoticed(orgId, after, until) {
315
+ return and(orgId === null ? void 0 : eq(entitlements.orgId, orgId), grantIsActive(entitlements, at(after)), grantExpired(entitlements.expiresAt, at(until)), sql`not ${endingSoonNoticed}`);
316
+ }
317
+ async communityModeratorIds(orgId, communityId) {
318
+ return (await this.db.select({ id: orgUsers.id }).from(orgUsers).where(and(eq(orgUsers.orgId, orgId), inArray(orgUsers.role, MODERATING_ROLES), communityMember(sql`${orgUsers.orgId}`, sql`${orgUsers.id}`, sql`${orgUsers.role}`, sql`${communityId}`))).orderBy(asc(orgUsers.id))).map((row) => row.id);
319
+ }
320
+ async communityMemberIds(orgId, communityId) {
321
+ return (await this.db.select({ id: orgUsers.id }).from(orgUsers).where(and(eq(orgUsers.orgId, orgId), communityMember(sql`${orgUsers.orgId}`, sql`${orgUsers.id}`, sql`${orgUsers.role}`, sql`${communityId}`))).orderBy(asc(orgUsers.id))).map((row) => row.id);
322
+ }
323
+ async communityIdsOpenedFor(orgId, orgUserId) {
324
+ return (await this.db.selectDistinct({ id: communities.id }).from(communities).where(and(eq(communities.orgId, orgId), holdsCommunity(sql`${communities.orgId}`, sql`${orgUserId}`, sql`${communities.id}`))).orderBy(asc(communities.id))).map((row) => row.id);
325
+ }
326
+ async contentHolderIds(orgId, contentId) {
327
+ return (await this.db.selectDistinct({ orgUserId: contentAccess.orgUserId }).from(contentAccess).where(and(eq(contentAccess.orgId, orgId), eq(contentAccess.contentId, contentId))).orderBy(asc(contentAccess.orgUserId))).map((row) => row.orgUserId);
328
+ }
329
+ async openCommunityIds(orgId, orgUserId) {
330
+ return (await this.db.select({ id: communities.id }).from(communities).innerJoin(orgUsers, and(eq(orgUsers.orgId, communities.orgId), eq(orgUsers.id, orgUserId))).where(and(eq(communities.orgId, orgId), communityMember(sql`${communities.orgId}`, sql`${orgUsers.id}`, sql`${orgUsers.role}`, sql`${communities.id}`))).orderBy(asc(communities.id))).map((row) => row.id);
148
331
  }
149
332
  };
333
+ function toSpaceMember(row) {
334
+ return {
335
+ orgId: row.orgId,
336
+ id: row.id,
337
+ communityId: row.communityId,
338
+ spaceId: row.spaceId,
339
+ orgUserId: row.orgUserId,
340
+ status: row.status,
341
+ source: row.source,
342
+ createdAt: row.createdAt,
343
+ updatedAt: row.updatedAt
344
+ };
345
+ }
150
346
  translateDbErrors(DrizzleEntitlementsRepository);
151
347
  //#endregion
152
348
  export { DrizzleEntitlementsRepository };