@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 +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';\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}\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\";\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 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<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(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,22 +1,48 @@
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;
7
7
  private readonly logger;
8
8
  constructor(db: DbExecutor, logger?: Logger);
9
- /** entitlements → org_users + content_items (type) + one LEFT JOIN per
10
- * concrete content table (title). */
9
+ /** entitlements → org_users + the grant's target: content_items (type) and one
10
+ * LEFT JOIN per concrete content table (title), or bundles (name). Both target
11
+ * joins are LEFT — a row carries one of the two, never both. */
11
12
  private joined;
12
13
  list(orgId: string, query: EntitlementsQuery): Promise<Page<Entitlement>>;
14
+ /** A grant targets exactly one of a bundle or a content item (db check
15
+ * constraint), so the upsert is inferred on whichever of the two partial
16
+ * uniques the target uses. */
13
17
  insert(orgId: string, input: GrantEntitlementInput): Promise<Entitlement>;
14
- setStatus(orgId: string, id: string, status: 'active' | 'revoked'): Promise<Entitlement | null>;
15
- /** Existence check scoped to the course case: same status predicate as
16
- * `list` (revoked never counts; an elapsed expiry reads as expired, not
17
- * active), joined to content_items and constrained to type = 'course' so a
18
- * grant on some other content type can never be mistaken for course access. */
19
- hasCourseAccess(orgId: string, orgUserId: string, courseId: string): Promise<boolean>;
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>;
21
+ setStatus(orgId: string, id: string, status: "active" | "revoked"): Promise<Entitlement | null>;
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
+ hasDueNotices(orgId: string, now: Date, until: Date): Promise<boolean>;
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[]>;
20
46
  }
21
47
  //#endregion
22
48
  export { DrizzleEntitlementsRepository };
@@ -1,17 +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 { 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
- import { and, asc, desc, eq, ilike, or, sql } from "drizzle-orm";
6
+ import { translateDbErrors } from "./pg-errors.js";
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";
7
9
  import { noopLogger } from "@headless-lms/core/shared/logger";
10
+ import { ROLES, canModerate } from "@headless-lms/core/organizations";
8
11
  //#region src/repositories/entitlements.ts
12
+ const MODERATING_ROLES = ROLES.filter((role) => canModerate(role));
9
13
  const derivedStatus = sql`case
10
14
  when ${entitlements.status} = 'revoked' then 'revoked'
11
- when ${entitlements.expiresAt} is not null and ${entitlements.expiresAt} < now() then 'expired'
15
+ when ${grantExpired(entitlements.expiresAt)} then 'expired'
12
16
  else 'active'
13
17
  end`;
14
- const contentTitle = sql`coalesce(${courses.title}, ${downloads.title})`;
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})`;
15
36
  const sortColumns = {
16
37
  firstName: users.firstName,
17
38
  lastName: users.lastName,
@@ -26,22 +47,28 @@ const selection = {
26
47
  orgId: entitlements.orgId,
27
48
  id: entitlements.id,
28
49
  orgUserId: entitlements.orgUserId,
50
+ bundleId: entitlements.bundleId,
29
51
  contentId: entitlements.contentId,
30
52
  status: entitlements.status,
31
53
  source: entitlements.source,
32
54
  grantedAt: entitlements.grantedAt,
33
- expiresAt: entitlements.expiresAt
55
+ expiresAt: entitlements.expiresAt,
56
+ createdAt: entitlements.createdAt,
57
+ updatedAt: entitlements.updatedAt
34
58
  };
35
59
  function toEntitlement(row) {
36
60
  return {
37
61
  orgId: row.orgId,
38
62
  id: row.id,
39
63
  orgUserId: row.orgUserId,
64
+ bundleId: row.bundleId,
40
65
  contentId: row.contentId,
41
66
  status: row.status,
42
67
  source: row.source,
43
68
  grantedAt: row.grantedAt,
44
- expiresAt: row.expiresAt
69
+ expiresAt: row.expiresAt,
70
+ createdAt: row.createdAt,
71
+ updatedAt: row.updatedAt
45
72
  };
46
73
  }
47
74
  var DrizzleEntitlementsRepository = class {
@@ -51,10 +78,11 @@ var DrizzleEntitlementsRepository = class {
51
78
  this.db = db;
52
79
  this.logger = logger;
53
80
  }
54
- /** entitlements → org_users + content_items (type) + one LEFT JOIN per
55
- * concrete content table (title). */
81
+ /** entitlements → org_users + the grant's target: content_items (type) and one
82
+ * LEFT JOIN per concrete content table (title), or bundles (name). Both target
83
+ * joins are LEFT — a row carries one of the two, never both. */
56
84
  joined(where) {
57
- 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)).innerJoin(contentItems, and(eq(contentItems.orgId, entitlements.orgId), eq(contentItems.id, entitlements.contentId))).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);
58
86
  }
59
87
  async list(orgId, query) {
60
88
  const conditions = [eq(entitlements.orgId, orgId)];
@@ -62,6 +90,7 @@ var DrizzleEntitlementsRepository = class {
62
90
  if (query.source) conditions.push(eq(entitlements.source, query.source));
63
91
  if (query.orgUserId) conditions.push(eq(entitlements.orgUserId, query.orgUserId));
64
92
  if (query.contentId) conditions.push(eq(entitlements.contentId, query.contentId));
93
+ if (query.bundleId) conditions.push(eq(entitlements.bundleId, query.bundleId));
65
94
  if (query.type) conditions.push(eq(contentItems.type, query.type));
66
95
  if (query.search) {
67
96
  const pattern = `%${query.search}%`;
@@ -77,7 +106,7 @@ var DrizzleEntitlementsRepository = class {
77
106
  } else orderBy = desc(entitlements.grantedAt);
78
107
  const offset = (query.page - 1) * query.pageSize;
79
108
  const rows = await this.joined(where).orderBy(orderBy).limit(query.pageSize).offset(offset);
80
- 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(contentItems, and(eq(contentItems.orgId, entitlements.orgId), eq(contentItems.id, entitlements.contentId))).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);
81
110
  return {
82
111
  rows: rows.map(toEntitlement),
83
112
  total,
@@ -85,21 +114,32 @@ var DrizzleEntitlementsRepository = class {
85
114
  pageSize: query.pageSize
86
115
  };
87
116
  }
117
+ /** A grant targets exactly one of a bundle or a content item (db check
118
+ * constraint), so the upsert is inferred on whichever of the two partial
119
+ * uniques the target uses. */
88
120
  async insert(orgId, input) {
121
+ const bundleId = input.bundleId ?? null;
122
+ const contentId = input.contentId ?? null;
123
+ const target = bundleId ? [
124
+ entitlements.orgId,
125
+ entitlements.orgUserId,
126
+ entitlements.bundleId
127
+ ] : [
128
+ entitlements.orgId,
129
+ entitlements.orgUserId,
130
+ entitlements.contentId
131
+ ];
89
132
  const [row] = await this.db.insert(entitlements).values({
90
133
  orgId,
91
134
  orgUserId: input.orgUserId,
92
- contentId: input.contentId,
135
+ bundleId,
136
+ contentId,
93
137
  status: "active",
94
138
  source: "manual",
95
139
  grantedAt: /* @__PURE__ */ new Date(),
96
140
  expiresAt: input.expiresAt
97
141
  }).onConflictDoUpdate({
98
- target: [
99
- entitlements.orgId,
100
- entitlements.orgUserId,
101
- entitlements.contentId
102
- ],
142
+ target,
103
143
  set: {
104
144
  status: "active",
105
145
  source: "manual",
@@ -110,19 +150,194 @@ var DrizzleEntitlementsRepository = class {
110
150
  if (!row) throw new Error("failed to insert entitlement");
111
151
  return toEntitlement(row);
112
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
+ }
113
187
  async setStatus(orgId, id, status) {
114
188
  const [row] = await this.db.update(entitlements).set({ status }).where(and(eq(entitlements.orgId, orgId), eq(entitlements.id, id))).returning(selection);
115
189
  if (!row) return null;
116
190
  return toEntitlement(row);
117
191
  }
118
- /** Existence check scoped to the course case: same status predicate as
119
- * `list` (revoked never counts; an elapsed expiry reads as expired, not
120
- * active), joined to content_items and constrained to type = 'course' so a
121
- * grant on some other content type can never be mistaken for course access. */
122
- async hasCourseAccess(orgId, orgUserId, courseId) {
123
- return (await this.db.select({ id: entitlements.id }).from(entitlements).innerJoin(contentItems, and(eq(contentItems.orgId, entitlements.orgId), eq(contentItems.id, entitlements.contentId))).where(and(eq(entitlements.orgId, orgId), eq(entitlements.orgUserId, orgUserId), eq(entitlements.contentId, courseId), eq(contentItems.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 hasDueNotices(orgId, now, until) {
296
+ const expired = this.db.select({ id: entitlements.id }).from(entitlements).where(this.expiredUnnoticed(orgId, now));
297
+ const ending = this.db.select({ id: entitlements.id }).from(entitlements).innerJoin(communities, opensPublishedCommunity).where(this.endingUnnoticed(orgId, now, until));
298
+ return this.holds(sql`(exists (${expired}) or exists (${ending}))`);
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(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(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);
124
325
  }
125
326
  };
327
+ function toSpaceMember(row) {
328
+ return {
329
+ orgId: row.orgId,
330
+ id: row.id,
331
+ communityId: row.communityId,
332
+ spaceId: row.spaceId,
333
+ orgUserId: row.orgUserId,
334
+ status: row.status,
335
+ source: row.source,
336
+ createdAt: row.createdAt,
337
+ updatedAt: row.updatedAt
338
+ };
339
+ }
340
+ translateDbErrors(DrizzleEntitlementsRepository);
126
341
  //#endregion
127
342
  export { DrizzleEntitlementsRepository };
128
343
 
@@ -1 +1 @@
1
- {"version":3,"file":"entitlements.js","names":[],"sources":["../../src/repositories/entitlements.ts"],"sourcesContent":["import { and, asc, desc, eq, ilike, or, sql, type SQL } from 'drizzle-orm';\nimport type { DbExecutor } from '../client.js';\nimport type { EntitlementsRepository } from '@headless-lms/core/entitlements';\nimport type {\n Entitlement,\n EntitlementStatus,\n EntitlementsQuery,\n GrantEntitlementInput,\n Logger,\n Page,\n} from '@headless-lms/core/types';\nimport { entitlements, orgUsers, users } from '../schema/index.js';\nimport { contentItems, courses, downloads } from '../schema/content.js';\nimport { noopLogger } from '@headless-lms/core/shared/logger';\n\ntype ResolvedStatus = 'active' | 'expired' | 'revoked';\n\n// CASE expression: revoked beats everything; otherwise an elapsed expiry reads as\n// expired; otherwise active.\nconst derivedStatus = sql<ResolvedStatus>`case\n when ${entitlements.status} = 'revoked' then 'revoked'\n when ${entitlements.expiresAt} is not null and ${entitlements.expiresAt} < now() then 'expired'\n else 'active'\nend`;\n\n// Display name of the granted content, whatever its type. One LEFT JOIN per\n// concrete content table; exactly one hits per row (type-pinned FKs), so the\n// COALESCE picks the single non-null title. Extended per new content type.\nconst contentTitle = sql<string>`coalesce(${courses.title}, ${downloads.title})`;\n\n// Sortable columns by the client-facing field name. `status` sorts on the derived\n// expression so the ordering matches the displayed value; `contentTitle` on the\n// coalesced join expression.\nconst sortColumns = {\n firstName: users.firstName,\n lastName: users.lastName,\n email: users.email,\n contentTitle,\n status: derivedStatus,\n grantedAt: entitlements.grantedAt,\n expiresAt: entitlements.expiresAt,\n source: entitlements.source,\n} as const;\n\nconst selection = {\n orgId: entitlements.orgId,\n id: entitlements.id,\n orgUserId: entitlements.orgUserId,\n contentId: entitlements.contentId,\n status: entitlements.status,\n source: entitlements.source,\n grantedAt: entitlements.grantedAt,\n expiresAt: entitlements.expiresAt,\n} as const;\n\ninterface Row {\n orgId: string;\n id: string;\n orgUserId: string;\n contentId: string;\n status: EntitlementStatus;\n source: string;\n grantedAt: Date;\n expiresAt: Date | null;\n}\n\nfunction toEntitlement(row: Row): Entitlement {\n return {\n orgId: row.orgId,\n id: row.id,\n orgUserId: row.orgUserId,\n contentId: row.contentId,\n status: row.status,\n source: row.source,\n grantedAt: row.grantedAt,\n expiresAt: row.expiresAt,\n };\n}\n\nexport class DrizzleEntitlementsRepository implements EntitlementsRepository {\n constructor(\n private readonly db: DbExecutor,\n private readonly logger: Logger = noopLogger,\n ) {}\n\n /** entitlements → org_users + content_items (type) + one LEFT JOIN per\n * concrete content table (title). */\n private joined(where: SQL | undefined) {\n return this.db\n .select(selection)\n .from(entitlements)\n .innerJoin(\n orgUsers,\n and(eq(orgUsers.orgId, entitlements.orgId), eq(orgUsers.id, entitlements.orgUserId)),\n )\n .innerJoin(users, eq(users.id, orgUsers.userId))\n .innerJoin(\n contentItems,\n and(eq(contentItems.orgId, entitlements.orgId), eq(contentItems.id, entitlements.contentId)),\n )\n .leftJoin(\n courses,\n and(eq(courses.orgId, entitlements.orgId), eq(courses.id, entitlements.contentId)),\n )\n .leftJoin(\n downloads,\n and(eq(downloads.orgId, entitlements.orgId), eq(downloads.id, entitlements.contentId)),\n )\n .where(where);\n }\n\n async list(orgId: string, query: EntitlementsQuery): Promise<Page<Entitlement>> {\n const conditions: SQL[] = [eq(entitlements.orgId, orgId)];\n if (query.status) {\n conditions.push(sql`${derivedStatus} = ${query.status}`);\n }\n if (query.source) {\n conditions.push(eq(entitlements.source, query.source));\n }\n if (query.orgUserId) {\n conditions.push(eq(entitlements.orgUserId, query.orgUserId));\n }\n if (query.contentId) {\n conditions.push(eq(entitlements.contentId, query.contentId));\n }\n if (query.type) {\n conditions.push(eq(contentItems.type, query.type));\n }\n if (query.search) {\n const pattern = `%${query.search}%`;\n conditions.push(\n or(\n ilike(users.firstName, pattern),\n ilike(users.lastName, pattern),\n ilike(users.email, pattern),\n ilike(contentTitle, pattern),\n ) as SQL,\n );\n }\n const where = and(...conditions);\n\n // Sort: `-field` for descending; default to most-recently granted first.\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] ?? entitlements.grantedAt;\n orderBy = isDesc ? desc(col) : asc(col);\n } else {\n orderBy = desc(entitlements.grantedAt);\n }\n\n const offset = (query.page - 1) * query.pageSize;\n\n const rows = await this.joined(where).orderBy(orderBy).limit(query.pageSize).offset(offset);\n\n const [{ total } = { total: 0 }] = await this.db\n .select({ total: sql<number>`cast(count(*) as int)` })\n .from(entitlements)\n .innerJoin(\n orgUsers,\n and(eq(orgUsers.orgId, entitlements.orgId), eq(orgUsers.id, entitlements.orgUserId)),\n )\n .innerJoin(\n contentItems,\n and(eq(contentItems.orgId, entitlements.orgId), eq(contentItems.id, entitlements.contentId)),\n )\n .leftJoin(\n courses,\n and(eq(courses.orgId, entitlements.orgId), eq(courses.id, entitlements.contentId)),\n )\n .leftJoin(\n downloads,\n and(eq(downloads.orgId, entitlements.orgId), eq(downloads.id, entitlements.contentId)),\n )\n .where(where);\n\n return {\n rows: rows.map(toEntitlement),\n total,\n page: query.page,\n pageSize: query.pageSize,\n };\n }\n\n async insert(orgId: string, input: GrantEntitlementInput): Promise<Entitlement> {\n const [row] = await this.db\n .insert(entitlements)\n .values({\n orgId,\n orgUserId: input.orgUserId,\n contentId: input.contentId,\n status: 'active',\n source: 'manual',\n grantedAt: new Date(),\n expiresAt: input.expiresAt,\n })\n .onConflictDoUpdate({\n target: [entitlements.orgId, entitlements.orgUserId, entitlements.contentId],\n set: {\n status: 'active',\n source: 'manual',\n grantedAt: new Date(),\n expiresAt: input.expiresAt,\n },\n })\n .returning(selection);\n if (!row) {\n throw new Error('failed to insert entitlement');\n }\n return toEntitlement(row);\n }\n\n async setStatus(\n orgId: string,\n id: string,\n status: 'active' | 'revoked',\n ): Promise<Entitlement | null> {\n const [row] = await this.db\n .update(entitlements)\n .set({ status })\n .where(and(eq(entitlements.orgId, orgId), eq(entitlements.id, id)))\n .returning(selection);\n if (!row) {\n return null;\n }\n return toEntitlement(row);\n }\n\n /** Existence check scoped to the course case: same status predicate as\n * `list` (revoked never counts; an elapsed expiry reads as expired, not\n * active), joined to content_items and constrained to type = 'course' so a\n * grant on some other content type can never be mistaken for course access. */\n async hasCourseAccess(orgId: string, orgUserId: string, courseId: string): Promise<boolean> {\n const rows = await this.db\n .select({ id: entitlements.id })\n .from(entitlements)\n .innerJoin(\n contentItems,\n and(eq(contentItems.orgId, entitlements.orgId), eq(contentItems.id, entitlements.contentId)),\n )\n .where(\n and(\n eq(entitlements.orgId, orgId),\n eq(entitlements.orgUserId, orgUserId),\n eq(entitlements.contentId, courseId),\n eq(contentItems.type, 'course'),\n sql`${derivedStatus} = 'active'`,\n ),\n )\n .limit(1);\n return rows.length > 0;\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"entitlements.js","names":[],"sources":["../../src/repositories/entitlements.ts"],"sourcesContent":["import { and, asc, desc, eq, gt, ilike, inArray, or, sql, type SQL } from \"drizzle-orm\";\nimport type { DbExecutor } from \"../client.js\";\nimport type {\n AccessFacts,\n EndingGrant,\n EndingGrantKey,\n EntitlementNotice,\n EntitlementsRepository,\n GrantKey,\n GrantTarget,\n InsertSpaceMemberInput,\n SpaceMembersQuery,\n} from \"@headless-lms/core/entitlements\";\nimport type {\n ContentType,\n Entitlement,\n EntitlementStatus,\n EntitlementsQuery,\n GrantEntitlementInput,\n Logger,\n Page,\n Role,\n SpaceMember,\n SpaceMemberStatus,\n} from \"@headless-lms/core/types\";\nimport {\n contentAccess,\n entitlementEndingSoonNotices,\n entitlementExpiryNotices,\n entitlements,\n orgUsers,\n spaceMembers,\n users,\n} from \"../schema/index.js\";\nimport {\n grantExpired,\n grantIsActive,\n communityMember,\n grantOpens,\n holdsCommunity,\n} from \"./grant-covers.js\";\nimport { bundles, communities, contentItems, courses, downloads } from \"../schema/content.js\";\nimport { noopLogger } from \"@headless-lms/core/shared/logger\";\nimport { ROLES, canModerate } from \"@headless-lms/core/organizations\";\nimport { translateDbErrors } from \"./pg-errors.js\";\n\ntype ResolvedStatus = \"active\" | \"expired\" | \"revoked\";\n\nconst MODERATING_ROLES: Role[] = ROLES.filter((role) => canModerate(role));\n\n// CASE expression: revoked beats everything; otherwise an elapsed expiry reads as\n// expired; otherwise active.\nconst derivedStatus = sql<ResolvedStatus>`case\n when ${entitlements.status} = 'revoked' then 'revoked'\n when ${grantExpired(entitlements.expiresAt)} then 'expired'\n else 'active'\nend`;\n\nfunction at(date: Date): SQL {\n return sql`${date.toISOString()}::timestamptz`;\n}\n\nconst expiryNoticed = sql`exists (\n select 1 from ${entitlementExpiryNotices}\n where ${entitlementExpiryNotices.orgId} = ${entitlements.orgId}\n and ${entitlementExpiryNotices.entitlementId} = ${entitlements.id}\n and ${entitlementExpiryNotices.expiresAt} = ${entitlements.expiresAt}\n)`;\n\nconst endingSoonNoticed = sql`exists (\n select 1 from ${entitlementEndingSoonNotices}\n where ${entitlementEndingSoonNotices.orgId} = ${entitlements.orgId}\n and ${entitlementEndingSoonNotices.entitlementId} = ${entitlements.id}\n and ${entitlementEndingSoonNotices.communityId} = ${communities.id}\n and ${entitlementEndingSoonNotices.expiresAt} = ${entitlements.expiresAt}\n)`;\n\nconst opensPublishedCommunity = and(\n eq(communities.orgId, entitlements.orgId),\n eq(communities.status, \"published\"),\n grantOpens(entitlements, sql`${communities.id}`, \"community\"),\n);\n\n// Display name of the grant's target, whatever it is. One LEFT JOIN per concrete\n// content table plus one for bundles; exactly one hits per row (a grant targets\n// either a bundle or a single content item), so the COALESCE picks the single\n// non-null title. Extended per new content type.\nconst contentTitle = sql<string>`coalesce(${courses.title}, ${downloads.title}, ${communities.title}, ${bundles.name})`;\n\n// Sortable columns by the client-facing field name. `status` sorts on the derived\n// expression so the ordering matches the displayed value; `contentTitle` on the\n// coalesced join expression.\nconst sortColumns = {\n firstName: users.firstName,\n lastName: users.lastName,\n email: users.email,\n contentTitle,\n status: derivedStatus,\n grantedAt: entitlements.grantedAt,\n expiresAt: entitlements.expiresAt,\n source: entitlements.source,\n} as const;\n\nconst selection = {\n orgId: entitlements.orgId,\n id: entitlements.id,\n orgUserId: entitlements.orgUserId,\n bundleId: entitlements.bundleId,\n contentId: entitlements.contentId,\n status: entitlements.status,\n source: entitlements.source,\n grantedAt: entitlements.grantedAt,\n expiresAt: entitlements.expiresAt,\n createdAt: entitlements.createdAt,\n updatedAt: entitlements.updatedAt,\n} as const;\n\ninterface Row {\n orgId: string;\n id: string;\n orgUserId: string;\n bundleId: string | null;\n contentId: string | null;\n status: EntitlementStatus;\n source: string;\n grantedAt: Date;\n expiresAt: Date | null;\n createdAt: Date;\n updatedAt: Date;\n}\n\nfunction toEntitlement(row: Row): Entitlement {\n return {\n orgId: row.orgId,\n id: row.id,\n orgUserId: row.orgUserId,\n bundleId: row.bundleId,\n contentId: row.contentId,\n status: row.status,\n source: row.source,\n grantedAt: row.grantedAt,\n expiresAt: row.expiresAt,\n createdAt: row.createdAt,\n updatedAt: row.updatedAt,\n };\n}\n\nexport class DrizzleEntitlementsRepository implements EntitlementsRepository {\n constructor(\n private readonly db: DbExecutor,\n private readonly logger: Logger = noopLogger,\n ) {}\n\n /** entitlements → org_users + the grant's target: content_items (type) and one\n * LEFT JOIN per concrete content table (title), or bundles (name). Both target\n * joins are LEFT — a row carries one of the two, never both. */\n private joined(where: SQL | undefined) {\n return this.db\n .select(selection)\n .from(entitlements)\n .innerJoin(\n orgUsers,\n and(eq(orgUsers.orgId, entitlements.orgId), eq(orgUsers.id, entitlements.orgUserId)),\n )\n .innerJoin(users, eq(users.id, orgUsers.userId))\n .leftJoin(\n contentItems,\n and(\n eq(contentItems.orgId, entitlements.orgId),\n eq(contentItems.id, entitlements.contentId),\n ),\n )\n .leftJoin(\n bundles,\n and(eq(bundles.orgId, entitlements.orgId), eq(bundles.id, entitlements.bundleId)),\n )\n .leftJoin(\n courses,\n and(eq(courses.orgId, entitlements.orgId), eq(courses.id, entitlements.contentId)),\n )\n .leftJoin(\n downloads,\n and(eq(downloads.orgId, entitlements.orgId), eq(downloads.id, entitlements.contentId)),\n )\n .leftJoin(\n communities,\n and(eq(communities.orgId, entitlements.orgId), eq(communities.id, entitlements.contentId)),\n )\n .where(where);\n }\n\n async list(orgId: string, query: EntitlementsQuery): Promise<Page<Entitlement>> {\n const conditions: SQL[] = [eq(entitlements.orgId, orgId)];\n if (query.status) {\n conditions.push(sql`${derivedStatus} = ${query.status}`);\n }\n if (query.source) {\n conditions.push(eq(entitlements.source, query.source));\n }\n if (query.orgUserId) {\n conditions.push(eq(entitlements.orgUserId, query.orgUserId));\n }\n if (query.contentId) {\n conditions.push(eq(entitlements.contentId, query.contentId));\n }\n if (query.bundleId) {\n conditions.push(eq(entitlements.bundleId, query.bundleId));\n }\n if (query.type) {\n conditions.push(eq(contentItems.type, query.type));\n }\n if (query.search) {\n const pattern = `%${query.search}%`;\n conditions.push(\n or(\n ilike(users.firstName, pattern),\n ilike(users.lastName, pattern),\n ilike(users.email, pattern),\n ilike(contentTitle, pattern),\n ) as SQL,\n );\n }\n const where = and(...conditions);\n\n // Sort: `-field` for descending; default to most-recently granted first.\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] ?? entitlements.grantedAt;\n orderBy = isDesc ? desc(col) : asc(col);\n } else {\n orderBy = desc(entitlements.grantedAt);\n }\n\n const offset = (query.page - 1) * query.pageSize;\n\n const rows = await this.joined(where).orderBy(orderBy).limit(query.pageSize).offset(offset);\n\n const [{ total } = { total: 0 }] = await this.db\n .select({ total: sql<number>`cast(count(*) as int)` })\n .from(entitlements)\n .innerJoin(\n orgUsers,\n and(eq(orgUsers.orgId, entitlements.orgId), eq(orgUsers.id, entitlements.orgUserId)),\n )\n .innerJoin(users, eq(users.id, orgUsers.userId))\n .leftJoin(\n contentItems,\n and(\n eq(contentItems.orgId, entitlements.orgId),\n eq(contentItems.id, entitlements.contentId),\n ),\n )\n .leftJoin(\n bundles,\n and(eq(bundles.orgId, entitlements.orgId), eq(bundles.id, entitlements.bundleId)),\n )\n .leftJoin(\n courses,\n and(eq(courses.orgId, entitlements.orgId), eq(courses.id, entitlements.contentId)),\n )\n .leftJoin(\n downloads,\n and(eq(downloads.orgId, entitlements.orgId), eq(downloads.id, entitlements.contentId)),\n )\n .leftJoin(\n communities,\n and(eq(communities.orgId, entitlements.orgId), eq(communities.id, entitlements.contentId)),\n )\n .where(where);\n\n return {\n rows: rows.map(toEntitlement),\n total,\n page: query.page,\n pageSize: query.pageSize,\n };\n }\n\n /** A grant targets exactly one of a bundle or a content item (db check\n * constraint), so the upsert is inferred on whichever of the two partial\n * uniques the target uses. */\n async insert(orgId: string, input: GrantEntitlementInput): Promise<Entitlement> {\n const bundleId = input.bundleId ?? null;\n const contentId = input.contentId ?? null;\n const target = bundleId\n ? [entitlements.orgId, entitlements.orgUserId, entitlements.bundleId]\n : [entitlements.orgId, entitlements.orgUserId, entitlements.contentId];\n\n const [row] = await this.db\n .insert(entitlements)\n .values({\n orgId,\n orgUserId: input.orgUserId,\n bundleId,\n contentId,\n status: \"active\",\n source: \"manual\",\n grantedAt: new Date(),\n expiresAt: input.expiresAt,\n })\n .onConflictDoUpdate({\n target,\n set: {\n status: \"active\",\n source: \"manual\",\n grantedAt: new Date(),\n expiresAt: input.expiresAt,\n },\n })\n .returning(selection);\n if (!row) {\n throw new Error(\"failed to insert entitlement\");\n }\n return toEntitlement(row);\n }\n\n async insertIfAbsent(orgId: string, input: GrantEntitlementInput): Promise<Entitlement | null> {\n const bundleId = input.bundleId ?? null;\n const contentId = input.contentId ?? null;\n const target = bundleId\n ? [entitlements.orgId, entitlements.orgUserId, entitlements.bundleId]\n : [entitlements.orgId, entitlements.orgUserId, entitlements.contentId];\n const [row] = await this.db\n .insert(entitlements)\n .values({\n orgId,\n orgUserId: input.orgUserId,\n bundleId,\n contentId,\n status: \"active\",\n source: \"manual\",\n grantedAt: new Date(),\n expiresAt: input.expiresAt,\n })\n .onConflictDoNothing({ target })\n .returning(selection);\n return row ? toEntitlement(row) : null;\n }\n\n async lockGrant(orgId: string, key: GrantKey): Promise<Entitlement | null> {\n const target = key.bundleId\n ? eq(entitlements.bundleId, key.bundleId)\n : key.contentId\n ? eq(entitlements.contentId, key.contentId)\n : undefined;\n if (!target) {\n return null;\n }\n const [row] = await this.db\n .select(selection)\n .from(entitlements)\n .where(and(eq(entitlements.orgId, orgId), eq(entitlements.orgUserId, key.orgUserId), target))\n .for(\"update\")\n .limit(1);\n return row ? toEntitlement(row) : null;\n }\n\n async setExpiresAt(\n orgId: string,\n id: string,\n expiresAt: Date | null,\n ): Promise<Entitlement | null> {\n const [row] = await this.db\n .update(entitlements)\n .set({ expiresAt })\n .where(and(eq(entitlements.orgId, orgId), eq(entitlements.id, id)))\n .returning(selection);\n return row ? toEntitlement(row) : null;\n }\n\n async setStatus(\n orgId: string,\n id: string,\n status: \"active\" | \"revoked\",\n ): Promise<Entitlement | null> {\n const [row] = await this.db\n .update(entitlements)\n .set({ status })\n .where(and(eq(entitlements.orgId, orgId), eq(entitlements.id, id)))\n .returning(selection);\n if (!row) {\n return null;\n }\n return toEntitlement(row);\n }\n\n async hasAccess(orgId: string, orgUserId: string, contentId: string): Promise<boolean> {\n const rows = await this.db\n .select({ id: contentAccess.entitlementId })\n .from(contentAccess)\n .where(\n and(\n eq(contentAccess.orgId, orgId),\n eq(contentAccess.orgUserId, orgUserId),\n eq(contentAccess.contentId, contentId),\n ),\n )\n .limit(1);\n return rows.length > 0;\n }\n\n async listAccessibleContent(\n orgId: string,\n orgUserId: string,\n type?: ContentType,\n ): Promise<string[]> {\n const rows = await this.db\n .selectDistinct({ contentId: contentAccess.contentId })\n .from(contentAccess)\n .innerJoin(\n contentItems,\n and(\n eq(contentItems.orgId, contentAccess.orgId),\n eq(contentItems.id, contentAccess.contentId),\n ),\n )\n .where(\n and(\n eq(contentAccess.orgId, orgId),\n eq(contentAccess.orgUserId, orgUserId),\n type ? eq(contentItems.type, type) : undefined,\n ),\n );\n return rows.map((r) => r.contentId);\n }\n\n hasCommunityAccess(orgId: string, orgUserId: string, communityId: string): Promise<boolean> {\n return this.holds(holdsCommunity(sql`${orgId}`, sql`${orgUserId}`, sql`${communityId}`));\n }\n\n private async holds(predicate: SQL): Promise<boolean> {\n const result = await this.db.execute<{ covered: boolean }>(sql`select ${predicate} as covered`);\n const rows = Array.isArray(result) ? result : result.rows;\n return rows[0]?.covered === true;\n }\n\n async communitiesOpenedBy(orgId: string, grant: GrantTarget): Promise<string[]> {\n if (!grant.contentId && !grant.bundleId) {\n return [];\n }\n const target = {\n orgId: sql`${orgId}`,\n contentId: sql`${grant.contentId ?? null}::text`,\n bundleId: sql`${grant.bundleId ?? null}::text`,\n };\n const rows = await this.db\n .select({ id: communities.id })\n .from(communities)\n .where(\n and(\n eq(communities.orgId, orgId),\n eq(communities.status, \"published\"),\n grantOpens(target, sql`${communities.id}`, \"community\"),\n ),\n )\n .orderBy(asc(communities.id));\n return rows.map((row) => row.id);\n }\n\n async accessAmong(\n orgId: string,\n communityId: string,\n spaceId: string | null,\n orgUserIds: string[],\n ): Promise<AccessFacts[]> {\n if (orgUserIds.length === 0) {\n return [];\n }\n const activeInSpace = spaceId\n ? sql<boolean>`exists (\n select 1 from space_members sm\n where sm.org_id = ${sql`${orgUsers.orgId}`}\n and sm.space_id = ${spaceId}\n and sm.org_user_id = ${sql`${orgUsers.id}`}\n and sm.status = 'active'\n )`\n : sql<boolean>`false`;\n const rows = await this.db\n .select({\n orgUserId: orgUsers.id,\n role: orgUsers.role,\n member: sql<boolean>`${communityMember(sql`${orgUsers.orgId}`, sql`${orgUsers.id}`, sql`${orgUsers.role}`, sql`${communityId}`)}`,\n activeInSpace,\n })\n .from(orgUsers)\n .where(and(eq(orgUsers.orgId, orgId), inArray(orgUsers.id, orgUserIds)));\n return rows.map((row) => ({\n orgUserId: row.orgUserId,\n role: row.role as Role,\n member: row.member === true,\n activeInSpace: row.activeInSpace === true,\n }));\n }\n\n async findSpaceMember(\n orgId: string,\n spaceId: string,\n orgUserId: string,\n ): Promise<SpaceMember | null> {\n const [row] = await this.db\n .select()\n .from(spaceMembers)\n .where(\n and(\n eq(spaceMembers.orgId, orgId),\n eq(spaceMembers.spaceId, spaceId),\n eq(spaceMembers.orgUserId, orgUserId),\n ),\n )\n .limit(1);\n return row ? toSpaceMember(row) : null;\n }\n\n async listSpaceMembersFor(\n orgId: string,\n orgUserId: string,\n communityId: string,\n ): Promise<SpaceMember[]> {\n const rows = await this.db\n .select()\n .from(spaceMembers)\n .where(\n and(\n eq(spaceMembers.orgId, orgId),\n eq(spaceMembers.orgUserId, orgUserId),\n eq(spaceMembers.communityId, communityId),\n ),\n );\n return rows.map(toSpaceMember);\n }\n\n async lockSpaceMember(\n orgId: string,\n spaceId: string,\n orgUserId: string,\n ): Promise<SpaceMember | null> {\n const [row] = await this.db\n .select()\n .from(spaceMembers)\n .where(\n and(\n eq(spaceMembers.orgId, orgId),\n eq(spaceMembers.spaceId, spaceId),\n eq(spaceMembers.orgUserId, orgUserId),\n ),\n )\n .for(\"update\")\n .limit(1);\n return row ? toSpaceMember(row) : null;\n }\n\n async listSpaceMembers(\n orgId: string,\n spaceId: string,\n query: SpaceMembersQuery,\n ): Promise<Page<SpaceMember>> {\n const where = and(\n eq(spaceMembers.orgId, orgId),\n eq(spaceMembers.spaceId, spaceId),\n query.statuses ? inArray(spaceMembers.status, query.statuses) : undefined,\n );\n const rows = await this.db\n .select()\n .from(spaceMembers)\n .where(where)\n .orderBy(asc(spaceMembers.createdAt), asc(spaceMembers.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(spaceMembers)\n .where(where);\n return { rows: rows.map(toSpaceMember), total, page: query.page, pageSize: query.pageSize };\n }\n\n async insertSpaceMember(orgId: string, input: InsertSpaceMemberInput): Promise<SpaceMember> {\n const [row] = await this.db\n .insert(spaceMembers)\n .values({\n orgId,\n communityId: input.communityId,\n spaceId: input.spaceId,\n orgUserId: input.orgUserId,\n status: input.status,\n source: input.source,\n })\n .returning();\n if (!row) {\n throw new Error(\"failed to insert space member\");\n }\n return toSpaceMember(row);\n }\n\n async updateSpaceMemberStatus(\n orgId: string,\n id: string,\n status: SpaceMemberStatus,\n ): Promise<SpaceMember | null> {\n const [row] = await this.db\n .update(spaceMembers)\n .set({ status })\n .where(and(eq(spaceMembers.orgId, orgId), eq(spaceMembers.id, id)))\n .returning();\n return row ? toSpaceMember(row) : null;\n }\n\n async deleteSpaceMember(orgId: string, id: string): Promise<void> {\n await this.db\n .delete(spaceMembers)\n .where(and(eq(spaceMembers.orgId, orgId), eq(spaceMembers.id, id)));\n }\n\n async countActiveSpaceMembers(orgId: string, spaceId: string): Promise<number> {\n const [row] = await this.db\n .select({ total: sql<number>`cast(count(*) as int)` })\n .from(spaceMembers)\n .where(\n and(\n eq(spaceMembers.orgId, orgId),\n eq(spaceMembers.spaceId, spaceId),\n eq(spaceMembers.status, \"active\"),\n holdsCommunity(\n sql`${spaceMembers.orgId}`,\n sql`${spaceMembers.orgUserId}`,\n sql`${spaceMembers.communityId}`,\n ),\n ),\n );\n return row?.total ?? 0;\n }\n\n async activeSpaceIds(orgId: string, orgUserId: string, communityId: string): Promise<string[]> {\n const rows = await this.db\n .select({ spaceId: spaceMembers.spaceId })\n .from(spaceMembers)\n .where(\n and(\n eq(spaceMembers.orgId, orgId),\n eq(spaceMembers.orgUserId, orgUserId),\n eq(spaceMembers.communityId, communityId),\n eq(spaceMembers.status, \"active\"),\n ),\n );\n return rows.map((row) => row.spaceId);\n }\n\n async listGrantsExpiredBy(\n orgId: string,\n now: Date,\n limit: number,\n afterId?: string,\n ): Promise<Entitlement[]> {\n const rows = await this.db\n .select(selection)\n .from(entitlements)\n .where(\n and(\n this.expiredUnnoticed(orgId, now),\n afterId === undefined ? undefined : gt(entitlements.id, afterId),\n ),\n )\n .orderBy(asc(entitlements.id))\n .limit(limit);\n return rows.map(toEntitlement);\n }\n\n async listGrantsEndingBetween(\n orgId: string,\n after: Date,\n until: Date,\n limit: number,\n afterKey?: EndingGrantKey,\n ): Promise<EndingGrant[]> {\n const rows = await this.db\n .select({ ...selection, communityId: communities.id })\n .from(entitlements)\n .innerJoin(communities, opensPublishedCommunity)\n .where(\n and(\n this.endingUnnoticed(orgId, after, until),\n afterKey\n ? sql`(${entitlements.id}, ${communities.id}) > (${afterKey.entitlementId}, ${afterKey.communityId})`\n : undefined,\n ),\n )\n .orderBy(asc(entitlements.id), asc(communities.id))\n .limit(limit);\n return rows.map(({ communityId, ...grant }) => ({\n grant: toEntitlement(grant),\n communityId,\n }));\n }\n\n async hasDueNotices(orgId: string, now: Date, until: Date): Promise<boolean> {\n const expired = this.db\n .select({ id: entitlements.id })\n .from(entitlements)\n .where(this.expiredUnnoticed(orgId, now));\n const ending = this.db\n .select({ id: entitlements.id })\n .from(entitlements)\n .innerJoin(communities, opensPublishedCommunity)\n .where(this.endingUnnoticed(orgId, now, until));\n return this.holds(sql`(exists (${expired}) or exists (${ending}))`);\n }\n\n async claimNotice(orgId: string, notice: EntitlementNotice, now: Date): Promise<boolean> {\n const statement =\n notice.kind === \"expired\"\n ? sql`insert into ${entitlementExpiryNotices} (\"org_id\", \"entitlement_id\", \"expires_at\", \"notified_at\")\n values (${orgId}, ${notice.entitlementId}, ${at(notice.expiresAt)}, ${at(now)})\n on conflict do nothing\n returning \"entitlement_id\"`\n : sql`insert into ${entitlementEndingSoonNotices} (\"org_id\", \"entitlement_id\", \"community_id\", \"expires_at\", \"notified_at\")\n values (${orgId}, ${notice.entitlementId}, ${notice.communityId}, ${at(notice.expiresAt)}, ${at(now)})\n on conflict do nothing\n returning \"entitlement_id\"`;\n const result = await this.db.execute<{ entitlement_id: string }>(statement);\n const rows = Array.isArray(result) ? result : result.rows;\n return rows.length > 0;\n }\n\n private expiredUnnoticed(orgId: string, now: Date): SQL {\n return and(\n eq(entitlements.orgId, orgId),\n eq(entitlements.status, \"active\"),\n grantExpired(entitlements.expiresAt, at(now)),\n sql`not ${expiryNoticed}`,\n )!;\n }\n\n private endingUnnoticed(orgId: string, after: Date, until: Date): SQL {\n return and(\n eq(entitlements.orgId, orgId),\n grantIsActive(entitlements, at(after)),\n grantExpired(entitlements.expiresAt, at(until)),\n sql`not ${endingSoonNoticed}`,\n )!;\n }\n\n async communityModeratorIds(orgId: string, communityId: string): Promise<string[]> {\n const rows = await this.db\n .select({ id: orgUsers.id })\n .from(orgUsers)\n .where(\n and(\n eq(orgUsers.orgId, orgId),\n inArray(orgUsers.role, MODERATING_ROLES),\n communityMember(\n sql`${orgUsers.orgId}`,\n sql`${orgUsers.id}`,\n sql`${orgUsers.role}`,\n sql`${communityId}`,\n ),\n ),\n )\n .orderBy(asc(orgUsers.id));\n return rows.map((row) => row.id);\n }\n\n async communityMemberIds(orgId: string, communityId: string): Promise<string[]> {\n const rows = await this.db\n .select({ id: orgUsers.id })\n .from(orgUsers)\n .where(\n and(\n eq(orgUsers.orgId, orgId),\n communityMember(\n sql`${orgUsers.orgId}`,\n sql`${orgUsers.id}`,\n sql`${orgUsers.role}`,\n sql`${communityId}`,\n ),\n ),\n )\n .orderBy(asc(orgUsers.id));\n return rows.map((row) => row.id);\n }\n\n async communityIdsOpenedFor(orgId: string, orgUserId: string): Promise<string[]> {\n const rows = await this.db\n .selectDistinct({ id: communities.id })\n .from(communities)\n .where(\n and(\n eq(communities.orgId, orgId),\n holdsCommunity(sql`${communities.orgId}`, sql`${orgUserId}`, sql`${communities.id}`),\n ),\n )\n .orderBy(asc(communities.id));\n return rows.map((row) => row.id);\n }\n}\n\nfunction toSpaceMember(row: {\n orgId: string;\n id: string;\n communityId: string;\n spaceId: string;\n orgUserId: string;\n status: SpaceMemberStatus;\n source: \"request\" | \"invite\" | \"manual\";\n createdAt: Date;\n updatedAt: Date;\n}): SpaceMember {\n return {\n orgId: row.orgId,\n id: row.id,\n communityId: row.communityId,\n spaceId: row.spaceId,\n orgUserId: row.orgUserId,\n status: row.status,\n source: row.source,\n createdAt: row.createdAt,\n updatedAt: row.updatedAt,\n };\n}\n\ntranslateDbErrors(DrizzleEntitlementsRepository);\n"],"mappings":"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"}