@headless-lms/adapter-db 0.2.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 (104) hide show
  1. package/dist/index.d.ts +7 -6
  2. package/dist/index.js +7 -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 -2
  7. package/dist/repositories/assets.js +50 -9
  8. package/dist/repositories/assets.js.map +1 -1
  9. package/dist/repositories/automations.d.ts +6 -2
  10. package/dist/repositories/automations.js +45 -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 +454 -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 +293 -9
  17. package/dist/repositories/content.js.map +1 -1
  18. package/dist/repositories/discussion.d.ts +10 -3
  19. package/dist/repositories/discussion.js +70 -15
  20. package/dist/repositories/discussion.js.map +1 -1
  21. package/dist/repositories/entitlements.d.ts +29 -9
  22. package/dist/repositories/entitlements.js +207 -17
  23. package/dist/repositories/entitlements.js.map +1 -1
  24. package/dist/repositories/grant-covers.d.ts +20 -0
  25. package/dist/repositories/grant-covers.js +55 -0
  26. package/dist/repositories/grant-covers.js.map +1 -0
  27. package/dist/repositories/identity.d.ts +4 -2
  28. package/dist/repositories/identity.js +32 -1
  29. package/dist/repositories/identity.js.map +1 -1
  30. package/dist/repositories/member.d.ts +16 -0
  31. package/dist/repositories/member.js +63 -0
  32. package/dist/repositories/member.js.map +1 -0
  33. package/dist/repositories/members.js +2 -2
  34. package/dist/repositories/members.js.map +1 -1
  35. package/dist/repositories/notifications.d.ts +23 -0
  36. package/dist/repositories/notifications.js +70 -0
  37. package/dist/repositories/notifications.js.map +1 -0
  38. package/dist/repositories/org-user-profile.d.ts +214 -1
  39. package/dist/repositories/org-user-profile.js +23 -2
  40. package/dist/repositories/org-user-profile.js.map +1 -1
  41. package/dist/repositories/organizations.d.ts +3 -2
  42. package/dist/repositories/organizations.js +20 -8
  43. package/dist/repositories/organizations.js.map +1 -1
  44. package/dist/repositories/pg-errors.js +3 -1
  45. package/dist/repositories/pg-errors.js.map +1 -1
  46. package/dist/repositories/posts.d.ts +39 -0
  47. package/dist/repositories/posts.js +255 -0
  48. package/dist/repositories/posts.js.map +1 -0
  49. package/dist/schema/assets.d.ts +19 -2
  50. package/dist/schema/assets.js +10 -4
  51. package/dist/schema/assets.js.map +1 -1
  52. package/dist/schema/automations.d.ts +38 -0
  53. package/dist/schema/automations.js +5 -1
  54. package/dist/schema/automations.js.map +1 -1
  55. package/dist/schema/better-auth.d.ts +1613 -238
  56. package/dist/schema/better-auth.js +107 -22
  57. package/dist/schema/better-auth.js.map +1 -1
  58. package/dist/schema/content.d.ts +642 -5
  59. package/dist/schema/content.js +119 -10
  60. package/dist/schema/content.js.map +1 -1
  61. package/dist/schema/discussion.d.ts +2045 -55
  62. package/dist/schema/discussion.js +426 -11
  63. package/dist/schema/discussion.js.map +1 -1
  64. package/dist/schema/entitlements.d.ts +432 -1
  65. package/dist/schema/entitlements.js +109 -4
  66. package/dist/schema/entitlements.js.map +1 -1
  67. package/dist/schema/identity.d.ts +517 -1
  68. package/dist/schema/identity.js +118 -2
  69. package/dist/schema/identity.js.map +1 -1
  70. package/dist/schema/index.d.ts +6 -6
  71. package/dist/schema/index.js +30 -7
  72. package/dist/schema/organizations.d.ts +4 -4
  73. package/dist/schema/organizations.js +2 -0
  74. package/dist/schema/organizations.js.map +1 -1
  75. package/drizzle/0003_community.sql +340 -0
  76. package/drizzle/0004_assets_sources.sql +1 -0
  77. package/drizzle/0005_notices_and_seed_index.sql +12 -0
  78. package/drizzle/0006_community_review_fixes.sql +24 -0
  79. package/drizzle/0007_notifications.sql +37 -0
  80. package/drizzle/0008_content_access.sql +13 -0
  81. package/drizzle/0009_community_indexes.sql +56 -0
  82. package/drizzle/0010_mcp_access.sql +114 -0
  83. package/drizzle/meta/0003_snapshot.json +7061 -0
  84. package/drizzle/meta/0004_snapshot.json +7068 -0
  85. package/drizzle/meta/0005_snapshot.json +7180 -0
  86. package/drizzle/meta/0006_snapshot.json +7298 -0
  87. package/drizzle/meta/0007_snapshot.json +7620 -0
  88. package/drizzle/meta/0008_snapshot.json +7666 -0
  89. package/drizzle/meta/0009_snapshot.json +8185 -0
  90. package/drizzle/meta/0010_snapshot.json +8813 -0
  91. package/drizzle/meta/_journal.json +57 -1
  92. package/package.json +2 -2
  93. package/dist/repositories/course-analytics.d.ts +0 -15
  94. package/dist/repositories/course-analytics.js +0 -137
  95. package/dist/repositories/course-analytics.js.map +0 -1
  96. package/dist/repositories/dashboard.d.ts +0 -14
  97. package/dist/repositories/dashboard.js +0 -57
  98. package/dist/repositories/dashboard.js.map +0 -1
  99. package/dist/repositories/learn.d.ts +0 -19
  100. package/dist/repositories/learn.js +0 -56
  101. package/dist/repositories/learn.js.map +0 -1
  102. package/dist/repositories/students.d.ts +0 -16
  103. package/dist/repositories/students.js +0 -160
  104. package/dist/repositories/students.js.map +0 -1
@@ -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,34 @@ 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
+ 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[]>;
26
46
  }
27
47
  //#endregion
28
48
  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,193 @@ 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 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);
148
325
  }
149
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
+ }
150
340
  translateDbErrors(DrizzleEntitlementsRepository);
151
341
  //#endregion
152
342
  export { DrizzleEntitlementsRepository };
@@ -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 { alias } from \"drizzle-orm/pg-core\";\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 { bundleItems, bundles, contentItems, courses, downloads } from \"../schema/content.js\";\nimport { noopLogger } from \"@headless-lms/core/shared/logger\";\nimport { translateDbErrors } from \"./pg-errors.js\";\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 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}, ${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 .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 .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 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). The course is reachable two ways — granted directly, or granted\n * through a bundle that holds it — and either way the row is joined to\n * content_items and constrained to type = 'course', so a grant on some other\n * content type can never be mistaken for course access. */\n async hasCourseAccess(orgId: string, orgUserId: string, courseId: string): Promise<boolean> {\n const bundledItems = alias(contentItems, \"bundled_content_items\");\n const rows = await this.db\n .select({ id: entitlements.id })\n .from(entitlements)\n .leftJoin(\n contentItems,\n and(\n eq(contentItems.orgId, entitlements.orgId),\n eq(contentItems.id, entitlements.contentId),\n ),\n )\n .leftJoin(\n bundleItems,\n and(\n eq(bundleItems.orgId, entitlements.orgId),\n eq(bundleItems.bundleId, entitlements.bundleId),\n ),\n )\n .leftJoin(\n bundledItems,\n and(eq(bundledItems.orgId, bundleItems.orgId), eq(bundledItems.id, bundleItems.contentId)),\n )\n .where(\n and(\n eq(entitlements.orgId, orgId),\n eq(entitlements.orgUserId, orgUserId),\n or(\n and(eq(entitlements.contentId, courseId), eq(contentItems.type, \"course\")),\n and(eq(bundleItems.contentId, courseId), eq(bundledItems.type, \"course\")),\n ),\n sql`${derivedStatus} = 'active'`,\n ),\n )\n .limit(1);\n return rows.length > 0;\n }\n}\ntranslateDbErrors(DrizzleEntitlementsRepository);\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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"}
@@ -0,0 +1,20 @@
1
+ import { entitlements } from "../schema/entitlements.js";
2
+ import { SQL, SQLWrapper } from "drizzle-orm";
3
+ //#region src/repositories/grant-covers.d.ts
4
+ type GrantedContentType = "course" | "download" | "community";
5
+ interface GrantColumns {
6
+ orgId: SQLWrapper;
7
+ contentId: SQLWrapper;
8
+ bundleId: SQLWrapper;
9
+ }
10
+ declare function grantUnexpired(expiresAt?: SQLWrapper, at?: SQL): SQL;
11
+ declare function grantExpired(expiresAt?: SQLWrapper, at?: SQL): SQL;
12
+ declare function grantIsActive(row?: typeof entitlements, at?: SQL): SQL;
13
+ declare function grantOpens(row: GrantColumns, contentId: SQL, type: GrantedContentType): SQL;
14
+ declare function activeGrantCovers(orgId: SQL, orgUserId: SQL, contentId: SQL, type: GrantedContentType): SQL;
15
+ declare function communityPublished(orgId: SQL, communityId: SQL): SQL;
16
+ declare function holdsCommunity(orgId: SQL, orgUserId: SQL, communityId: SQL): SQL;
17
+ declare function communityMember(orgId: SQL, orgUserId: SQL, role: SQL, communityId: SQL): SQL;
18
+ //#endregion
19
+ export { GrantColumns, GrantedContentType, activeGrantCovers, communityMember, communityPublished, grantExpired, grantIsActive, grantOpens, grantUnexpired, holdsCommunity };
20
+ //# sourceMappingURL=grant-covers.d.ts.map
@@ -0,0 +1,55 @@
1
+ import { entitlements } from "../schema/entitlements.js";
2
+ import { sql } from "drizzle-orm";
3
+ import { STAFF_ROLES } from "@headless-lms/core/organizations";
4
+ //#region src/repositories/grant-covers.ts
5
+ function grantUnexpired(expiresAt = entitlements.expiresAt, at = sql`now()`) {
6
+ return sql`(${expiresAt} is null or ${expiresAt} > ${at})`;
7
+ }
8
+ function grantExpired(expiresAt = entitlements.expiresAt, at = sql`now()`) {
9
+ return sql`(${expiresAt} is not null and ${expiresAt} <= ${at})`;
10
+ }
11
+ function grantIsActive(row = entitlements, at) {
12
+ return sql`(${row.status} = 'active' and ${grantUnexpired(row.expiresAt, at)})`;
13
+ }
14
+ function grantOpens(row, contentId, type) {
15
+ return sql`(
16
+ exists (
17
+ select 1 from content_items ci
18
+ where ci.org_id = ${row.orgId} and ci.id = ${contentId} and ci.type = ${type}
19
+ )
20
+ and (
21
+ ${row.contentId} = ${contentId}
22
+ or exists (
23
+ select 1 from bundle_items bi
24
+ where bi.org_id = ${row.orgId}
25
+ and bi.bundle_id = ${row.bundleId}
26
+ and bi.content_id = ${contentId}
27
+ )
28
+ )
29
+ )`;
30
+ }
31
+ function activeGrantCovers(orgId, orgUserId, contentId, type) {
32
+ return sql`exists (
33
+ select 1 from content_access ca
34
+ join content_items ci on ci.org_id = ca.org_id and ci.id = ca.content_id and ci.type = ${type}
35
+ where ca.org_id = ${orgId}
36
+ and ca.org_user_id = ${orgUserId}
37
+ and ca.content_id = ${contentId}
38
+ )`;
39
+ }
40
+ function communityPublished(orgId, communityId) {
41
+ return sql`exists (
42
+ select 1 from communities pc
43
+ where pc.org_id = ${orgId} and pc.id = ${communityId} and pc.status = 'published'
44
+ )`;
45
+ }
46
+ function holdsCommunity(orgId, orgUserId, communityId) {
47
+ return sql`(${communityPublished(orgId, communityId)} and ${activeGrantCovers(orgId, orgUserId, communityId, "community")})`;
48
+ }
49
+ function communityMember(orgId, orgUserId, role, communityId) {
50
+ return sql`(${role} in (${sql.join(STAFF_ROLES.map((r) => sql`${r}`), sql`, `)}) or ${holdsCommunity(orgId, orgUserId, communityId)})`;
51
+ }
52
+ //#endregion
53
+ export { activeGrantCovers, communityMember, communityPublished, grantExpired, grantIsActive, grantOpens, grantUnexpired, holdsCommunity };
54
+
55
+ //# sourceMappingURL=grant-covers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"grant-covers.js","names":[],"sources":["../../src/repositories/grant-covers.ts"],"sourcesContent":["import { sql, type SQL, type SQLWrapper } from \"drizzle-orm\";\nimport { STAFF_ROLES } from \"@headless-lms/core/organizations\";\nimport { entitlements } from \"../schema/entitlements.js\";\n\nexport type GrantedContentType = \"course\" | \"download\" | \"community\";\n\nexport interface GrantColumns {\n orgId: SQLWrapper;\n contentId: SQLWrapper;\n bundleId: SQLWrapper;\n}\n\nexport function grantUnexpired(\n expiresAt: SQLWrapper = entitlements.expiresAt,\n at: SQL = sql`now()`,\n): SQL {\n return sql`(${expiresAt} is null or ${expiresAt} > ${at})`;\n}\n\nexport function grantExpired(\n expiresAt: SQLWrapper = entitlements.expiresAt,\n at: SQL = sql`now()`,\n): SQL {\n return sql`(${expiresAt} is not null and ${expiresAt} <= ${at})`;\n}\n\nexport function grantIsActive(row: typeof entitlements = entitlements, at?: SQL): SQL {\n return sql`(${row.status} = 'active' and ${grantUnexpired(row.expiresAt, at)})`;\n}\n\nexport function grantOpens(row: GrantColumns, contentId: SQL, type: GrantedContentType): SQL {\n return sql`(\n exists (\n select 1 from content_items ci\n where ci.org_id = ${row.orgId} and ci.id = ${contentId} and ci.type = ${type}\n )\n and (\n ${row.contentId} = ${contentId}\n or exists (\n select 1 from bundle_items bi\n where bi.org_id = ${row.orgId}\n and bi.bundle_id = ${row.bundleId}\n and bi.content_id = ${contentId}\n )\n )\n )`;\n}\n\nexport function activeGrantCovers(\n orgId: SQL,\n orgUserId: SQL,\n contentId: SQL,\n type: GrantedContentType,\n): SQL {\n return sql`exists (\n select 1 from content_access ca\n join content_items ci on ci.org_id = ca.org_id and ci.id = ca.content_id and ci.type = ${type}\n where ca.org_id = ${orgId}\n and ca.org_user_id = ${orgUserId}\n and ca.content_id = ${contentId}\n )`;\n}\n\nexport function communityPublished(orgId: SQL, communityId: SQL): SQL {\n return sql`exists (\n select 1 from communities pc\n where pc.org_id = ${orgId} and pc.id = ${communityId} and pc.status = 'published'\n )`;\n}\n\nexport function holdsCommunity(orgId: SQL, orgUserId: SQL, communityId: SQL): SQL {\n return sql`(${communityPublished(orgId, communityId)} and ${activeGrantCovers(orgId, orgUserId, communityId, \"community\")})`;\n}\n\nexport function communityMember(orgId: SQL, orgUserId: SQL, role: SQL, communityId: SQL): SQL {\n const staff = sql.join(\n STAFF_ROLES.map((r) => sql`${r}`),\n sql`, `,\n );\n return sql`(${role} in (${staff}) or ${holdsCommunity(orgId, orgUserId, communityId)})`;\n}\n"],"mappings":";;;;AAYA,SAAgB,eACd,YAAwB,aAAa,WACrC,KAAU,GAAG,SACR;CACL,OAAO,GAAG,IAAI,UAAU,cAAc,UAAU,KAAK,GAAG;AAC1D;AAEA,SAAgB,aACd,YAAwB,aAAa,WACrC,KAAU,GAAG,SACR;CACL,OAAO,GAAG,IAAI,UAAU,mBAAmB,UAAU,MAAM,GAAG;AAChE;AAEA,SAAgB,cAAc,MAA2B,cAAc,IAAe;CACpF,OAAO,GAAG,IAAI,IAAI,OAAO,kBAAkB,eAAe,IAAI,WAAW,EAAE,EAAE;AAC/E;AAEA,SAAgB,WAAW,KAAmB,WAAgB,MAA+B;CAC3F,OAAO,GAAG;;;0BAGc,IAAI,MAAM,eAAe,UAAU,iBAAiB,KAAK;;;QAG3E,IAAI,UAAU,KAAK,UAAU;;;4BAGT,IAAI,MAAM;+BACP,IAAI,SAAS;gCACZ,UAAU;;;;AAI1C;AAEA,SAAgB,kBACd,OACA,WACA,WACA,MACK;CACL,OAAO,GAAG;;6FAEiF,KAAK;wBAC1E,MAAM;6BACD,UAAU;4BACX,UAAU;;AAEtC;AAEA,SAAgB,mBAAmB,OAAY,aAAuB;CACpE,OAAO,GAAG;;wBAEY,MAAM,eAAe,YAAY;;AAEzD;AAEA,SAAgB,eAAe,OAAY,WAAgB,aAAuB;CAChF,OAAO,GAAG,IAAI,mBAAmB,OAAO,WAAW,EAAE,OAAO,kBAAkB,OAAO,WAAW,aAAa,WAAW,EAAE;AAC5H;AAEA,SAAgB,gBAAgB,OAAY,WAAgB,MAAW,aAAuB;CAK5F,OAAO,GAAG,IAAI,KAAK,OAJL,IAAI,KAChB,YAAY,KAAK,MAAM,GAAG,GAAG,GAAG,GAChC,GAAG,IAEyB,EAAE,OAAO,eAAe,OAAO,WAAW,WAAW,EAAE;AACvF"}
@@ -1,6 +1,6 @@
1
1
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
2
- import { CreateUserInput, Logger, UpdateUserInput, User } from "@headless-lms/core/types";
3
- import { IdentityRepository } from "@headless-lms/core/identity";
2
+ import { CreateUserInput, Logger, UpdateUserInput, User, UserProfile } from "@headless-lms/core/types";
3
+ import { IdentityRepository, ProfilePatch } from "@headless-lms/core/identity";
4
4
  //#region src/repositories/identity.d.ts
5
5
  declare class DrizzleIdentityRepository implements IdentityRepository {
6
6
  private readonly db;
@@ -11,6 +11,8 @@ declare class DrizzleIdentityRepository implements IdentityRepository {
11
11
  findUserById(id: string): Promise<User | null>;
12
12
  findUserByExternalId(externalId: string): Promise<User | null>;
13
13
  findUserByEmail(email: string): Promise<User | null>;
14
+ findProfile(userId: string): Promise<UserProfile | null>;
15
+ upsertProfile(userId: string, patch: ProfilePatch): Promise<UserProfile>;
14
16
  }
15
17
  //#endregion
16
18
  export { DrizzleIdentityRepository };
@@ -1,4 +1,4 @@
1
- import { users } from "../schema/identity.js";
1
+ import { userProfiles, users } from "../schema/identity.js";
2
2
  import { translateDbErrors } from "./pg-errors.js";
3
3
  import { eq, sql } from "drizzle-orm";
4
4
  import { noopLogger } from "@headless-lms/core/shared/logger";
@@ -38,6 +38,37 @@ var DrizzleIdentityRepository = class {
38
38
  const [row] = await this.db.select().from(users).where(sql`lower(${users.email}) = lower(${email})`).limit(1);
39
39
  return row ?? null;
40
40
  }
41
+ async findProfile(userId) {
42
+ const [row] = await this.db.select().from(userProfiles).where(eq(userProfiles.userId, userId)).limit(1);
43
+ return row ?? null;
44
+ }
45
+ async upsertProfile(userId, patch) {
46
+ if (!await this.findProfile(userId)) {
47
+ const [row] = await this.db.insert(userProfiles).values({
48
+ userId,
49
+ displayName: patch.displayName ?? null,
50
+ headline: patch.headline ?? null,
51
+ bio: patch.bio ?? null,
52
+ imageAssetOrgId: patch.imageAssetOrgId ?? null,
53
+ imageAssetId: patch.imageAssetId ?? null,
54
+ extra: patch.extra ?? {}
55
+ }).returning();
56
+ if (!row) throw new Error("failed to insert profile");
57
+ return row;
58
+ }
59
+ const set = { updatedAt: /* @__PURE__ */ new Date() };
60
+ if (patch.displayName !== void 0) set.displayName = patch.displayName;
61
+ if (patch.headline !== void 0) set.headline = patch.headline;
62
+ if (patch.bio !== void 0) set.bio = patch.bio;
63
+ if (patch.imageAssetOrgId !== void 0) {
64
+ set.imageAssetOrgId = patch.imageAssetOrgId;
65
+ set.imageAssetId = patch.imageAssetId;
66
+ }
67
+ if (patch.extra !== void 0) set.extra = patch.extra;
68
+ const [row] = await this.db.update(userProfiles).set(set).where(eq(userProfiles.userId, userId)).returning();
69
+ if (!row) throw new Error("failed to update profile");
70
+ return row;
71
+ }
41
72
  };
42
73
  translateDbErrors(DrizzleIdentityRepository);
43
74
  //#endregion