@headless-lms/adapter-db 0.1.0 → 0.2.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 (98) 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 +2 -1
  5. package/dist/index.js +3 -2
  6. package/dist/migrate.js.map +1 -1
  7. package/dist/repositories/assets.d.ts +1 -1
  8. package/dist/repositories/assets.js +4 -1
  9. package/dist/repositories/assets.js.map +1 -1
  10. package/dist/repositories/automations.d.ts +7 -5
  11. package/dist/repositories/automations.js +35 -10
  12. package/dist/repositories/automations.js.map +1 -1
  13. package/dist/repositories/content.d.ts +21 -7
  14. package/dist/repositories/content.js +168 -18
  15. package/dist/repositories/content.js.map +1 -1
  16. package/dist/repositories/course-analytics.d.ts +15 -0
  17. package/dist/repositories/course-analytics.js +137 -0
  18. package/dist/repositories/course-analytics.js.map +1 -0
  19. package/dist/repositories/credentials.js +2 -0
  20. package/dist/repositories/credentials.js.map +1 -1
  21. package/dist/repositories/dashboard.js +2 -0
  22. package/dist/repositories/dashboard.js.map +1 -1
  23. package/dist/repositories/discussion.d.ts +1 -1
  24. package/dist/repositories/discussion.js +2 -0
  25. package/dist/repositories/discussion.js.map +1 -1
  26. package/dist/repositories/entitlements.d.ts +11 -5
  27. package/dist/repositories/entitlements.js +42 -17
  28. package/dist/repositories/entitlements.js.map +1 -1
  29. package/dist/repositories/identity.js +2 -0
  30. package/dist/repositories/identity.js.map +1 -1
  31. package/dist/repositories/integrations.d.ts +1 -1
  32. package/dist/repositories/integrations.js +2 -0
  33. package/dist/repositories/integrations.js.map +1 -1
  34. package/dist/repositories/learn.js +6 -4
  35. package/dist/repositories/learn.js.map +1 -1
  36. package/dist/repositories/members.js +2 -0
  37. package/dist/repositories/members.js.map +1 -1
  38. package/dist/repositories/org-user-profile.js.map +1 -1
  39. package/dist/repositories/organizations.d.ts +4 -6
  40. package/dist/repositories/organizations.js +26 -45
  41. package/dist/repositories/organizations.js.map +1 -1
  42. package/dist/repositories/outbox.js +3 -0
  43. package/dist/repositories/outbox.js.map +1 -1
  44. package/dist/repositories/pg-errors.d.ts +53 -5
  45. package/dist/repositories/pg-errors.js +97 -8
  46. package/dist/repositories/pg-errors.js.map +1 -1
  47. package/dist/repositories/progress.d.ts +1 -1
  48. package/dist/repositories/progress.js +3 -0
  49. package/dist/repositories/progress.js.map +1 -1
  50. package/dist/repositories/settings.js +2 -0
  51. package/dist/repositories/settings.js.map +1 -1
  52. package/dist/repositories/students.d.ts +2 -1
  53. package/dist/repositories/students.js +80 -3
  54. package/dist/repositories/students.js.map +1 -1
  55. package/dist/schema/assets.d.ts +17 -0
  56. package/dist/schema/assets.js +2 -1
  57. package/dist/schema/assets.js.map +1 -1
  58. package/dist/schema/automations.d.ts +85 -8
  59. package/dist/schema/automations.js +7 -4
  60. package/dist/schema/automations.js.map +1 -1
  61. package/dist/schema/better-auth.js.map +1 -1
  62. package/dist/schema/content.d.ts +272 -22
  63. package/dist/schema/content.js +40 -5
  64. package/dist/schema/content.js.map +1 -1
  65. package/dist/schema/credentials.js +1 -1
  66. package/dist/schema/credentials.js.map +1 -1
  67. package/dist/schema/discussion.d.ts +34 -0
  68. package/dist/schema/discussion.js +4 -2
  69. package/dist/schema/discussion.js.map +1 -1
  70. package/dist/schema/entitlements.d.ts +52 -1
  71. package/dist/schema/entitlements.js +15 -5
  72. package/dist/schema/entitlements.js.map +1 -1
  73. package/dist/schema/identity.js.map +1 -1
  74. package/dist/schema/index.d.ts +3 -3
  75. package/dist/schema/index.js +4 -2
  76. package/dist/schema/index.js.map +1 -1
  77. package/dist/schema/integrations.js +1 -1
  78. package/dist/schema/integrations.js.map +1 -1
  79. package/dist/schema/organizations.d.ts +17 -0
  80. package/dist/schema/organizations.js +2 -1
  81. package/dist/schema/organizations.js.map +1 -1
  82. package/dist/schema/outbox.d.ts +17 -0
  83. package/dist/schema/outbox.js +1 -0
  84. package/dist/schema/outbox.js.map +1 -1
  85. package/dist/schema/progress.d.ts +17 -0
  86. package/dist/schema/progress.js +1 -0
  87. package/dist/schema/progress.js.map +1 -1
  88. package/dist/schema/settings.js.map +1 -1
  89. package/dist/unit-of-work.js +7 -2
  90. package/dist/unit-of-work.js.map +1 -1
  91. package/drizzle/0000_baseline.sql +0 -1
  92. package/drizzle/0001_new_sentry.sql +43 -0
  93. package/drizzle/0002_automation_kinds.sql +7 -0
  94. package/drizzle/meta/0000_snapshot.json +167 -597
  95. package/drizzle/meta/0001_snapshot.json +3769 -0
  96. package/drizzle/meta/0002_snapshot.json +3770 -0
  97. package/drizzle/meta/_journal.json +16 -2
  98. package/package.json +3 -2
@@ -1,6 +1,7 @@
1
1
  import { courses, downloadAssets, downloads } from "../schema/content.js";
2
2
  import { entitlements } from "../schema/entitlements.js";
3
3
  import "../schema/index.js";
4
+ import { translateDbErrors } from "./pg-errors.js";
4
5
  import { and, eq, gt, isNull, or, sql } from "drizzle-orm";
5
6
  import { noopLogger } from "@headless-lms/core/shared/logger";
6
7
  //#region src/repositories/learn.ts
@@ -17,13 +18,13 @@ var DrizzleLearnRepository = class {
17
18
  async activeRefs(orgId, orgUserId) {
18
19
  return await this.db.select({
19
20
  orgId: entitlements.orgId,
20
- contentId: entitlements.contentId
21
+ contentId: courses.id
21
22
  }).from(entitlements).innerJoin(courses, and(eq(courses.orgId, entitlements.orgId), eq(courses.id, entitlements.contentId))).where(this.baseFilters(orgId, orgUserId));
22
23
  }
23
24
  async activeRef(orgId, orgUserId, courseId) {
24
25
  const [row] = await this.db.select({
25
26
  orgId: entitlements.orgId,
26
- contentId: entitlements.contentId
27
+ contentId: courses.id
27
28
  }).from(entitlements).innerJoin(courses, and(eq(courses.orgId, entitlements.orgId), eq(courses.id, entitlements.contentId))).where(and(this.baseFilters(orgId, orgUserId), eq(entitlements.contentId, courseId))).limit(1);
28
29
  return row ?? null;
29
30
  }
@@ -33,13 +34,13 @@ var DrizzleLearnRepository = class {
33
34
  async activeDownloadRefs(orgId, orgUserId) {
34
35
  return this.db.select({
35
36
  orgId: entitlements.orgId,
36
- contentId: entitlements.contentId
37
+ contentId: downloads.id
37
38
  }).from(entitlements).innerJoin(downloads, and(eq(downloads.orgId, entitlements.orgId), eq(downloads.id, entitlements.contentId))).where(this.downloadFilters(orgId, orgUserId));
38
39
  }
39
40
  async activeDownloadRef(orgId, orgUserId, downloadId) {
40
41
  const [row] = await this.db.select({
41
42
  orgId: entitlements.orgId,
42
- contentId: entitlements.contentId
43
+ contentId: downloads.id
43
44
  }).from(entitlements).innerJoin(downloads, and(eq(downloads.orgId, entitlements.orgId), eq(downloads.id, entitlements.contentId))).where(and(this.downloadFilters(orgId, orgUserId), eq(entitlements.contentId, downloadId))).limit(1);
44
45
  return row ?? null;
45
46
  }
@@ -48,6 +49,7 @@ var DrizzleLearnRepository = class {
48
49
  return row !== void 0;
49
50
  }
50
51
  };
52
+ translateDbErrors(DrizzleLearnRepository);
51
53
  //#endregion
52
54
  export { DrizzleLearnRepository };
53
55
 
@@ -1 +1 @@
1
- {"version":3,"file":"learn.js","names":[],"sources":["../../src/repositories/learn.ts"],"sourcesContent":["// learn — Drizzle read repo (implements the reporting/learn outbound port).\n// Returns the (org, content) refs a student is ACTIVELY entitled to and whose\n// course is PUBLISHED. \"Active\" excludes revoked and expired grants (expiry is\n// derived from expires_at at read time — no row flip). Org-scoped: the portal\n// org bounds the read (`entitlements.org_id`), so no cross-org grants leak.\n// The inner join to `courses` on the content id restricts to course grants —\n// no explicit content-type filter needed.\nimport { and, eq, gt, isNull, or, sql, type SQL } from 'drizzle-orm';\nimport type { NodePgDatabase } from 'drizzle-orm/node-postgres';\nimport type { LearnEntitlementReader, ContentRef } from '@headless-lms/core/reporting/learn';\nimport { entitlements, courses, downloads, downloadAssets } from '../schema/index.js';\nimport type { Logger } from '@headless-lms/core/shared/ports';\nimport { noopLogger } from '@headless-lms/core/shared/logger';\n\nexport class DrizzleLearnRepository implements LearnEntitlementReader {\n constructor(\n private readonly db: NodePgDatabase,\n private readonly logger: Logger = noopLogger,\n ) {}\n\n private baseFilters(orgId: string, orgUserId: string): SQL {\n return and(\n eq(entitlements.orgId, orgId),\n eq(entitlements.orgUserId, orgUserId),\n eq(entitlements.status, 'active'),\n or(isNull(entitlements.expiresAt), gt(entitlements.expiresAt, sql`now()`))!,\n eq(courses.status, 'published'),\n )!;\n }\n\n async activeRefs(orgId: string, orgUserId: string): Promise<ContentRef[]> {\n const rows = await this.db\n .select({ orgId: entitlements.orgId, contentId: entitlements.contentId })\n .from(entitlements)\n .innerJoin(\n courses,\n and(eq(courses.orgId, entitlements.orgId), eq(courses.id, entitlements.contentId)),\n )\n .where(this.baseFilters(orgId, orgUserId));\n return rows;\n }\n\n async activeRef(orgId: string, orgUserId: string, courseId: string): Promise<ContentRef | null> {\n const [row] = await this.db\n .select({ orgId: entitlements.orgId, contentId: entitlements.contentId })\n .from(entitlements)\n .innerJoin(\n courses,\n and(eq(courses.orgId, entitlements.orgId), eq(courses.id, entitlements.contentId)),\n )\n .where(and(this.baseFilters(orgId, orgUserId), eq(entitlements.contentId, courseId)))\n .limit(1);\n return row ?? null;\n }\n\n private downloadFilters(orgId: string, orgUserId: string): SQL {\n return and(\n eq(entitlements.orgId, orgId),\n eq(entitlements.orgUserId, orgUserId),\n eq(entitlements.status, 'active'),\n or(isNull(entitlements.expiresAt), gt(entitlements.expiresAt, sql`now()`))!,\n eq(downloads.status, 'published'),\n )!;\n }\n\n async activeDownloadRefs(orgId: string, orgUserId: string): Promise<ContentRef[]> {\n return this.db\n .select({ orgId: entitlements.orgId, contentId: entitlements.contentId })\n .from(entitlements)\n .innerJoin(\n downloads,\n and(eq(downloads.orgId, entitlements.orgId), eq(downloads.id, entitlements.contentId)),\n )\n .where(this.downloadFilters(orgId, orgUserId));\n }\n\n async activeDownloadRef(\n orgId: string,\n orgUserId: string,\n downloadId: string,\n ): Promise<ContentRef | null> {\n const [row] = await this.db\n .select({ orgId: entitlements.orgId, contentId: entitlements.contentId })\n .from(entitlements)\n .innerJoin(\n downloads,\n and(eq(downloads.orgId, entitlements.orgId), eq(downloads.id, entitlements.contentId)),\n )\n .where(and(this.downloadFilters(orgId, orgUserId), eq(entitlements.contentId, downloadId)))\n .limit(1);\n return row ?? null;\n }\n\n async downloadHasAsset(orgId: string, downloadId: string, assetId: string): Promise<boolean> {\n const [row] = await this.db\n .select({ id: downloadAssets.id })\n .from(downloadAssets)\n .where(\n and(\n eq(downloadAssets.orgId, orgId),\n eq(downloadAssets.downloadId, downloadId),\n eq(downloadAssets.assetId, assetId),\n ),\n )\n .limit(1);\n return row !== undefined;\n }\n}\n"],"mappings":";;;;;;AAcA,IAAa,yBAAb,MAAsE;CAEjD;CACA;CAFnB,YACE,IACA,SAAkC,YAClC;EAFiB,KAAA,KAAA;EACA,KAAA,SAAA;CAChB;CAEH,YAAoB,OAAe,WAAwB;EACzD,OAAO,IACL,GAAG,aAAa,OAAO,KAAK,GAC5B,GAAG,aAAa,WAAW,SAAS,GACpC,GAAG,aAAa,QAAQ,QAAQ,GAChC,GAAG,OAAO,aAAa,SAAS,GAAG,GAAG,aAAa,WAAW,GAAG,OAAO,CAAC,GACzE,GAAG,QAAQ,QAAQ,WAAW,CAChC;CACF;CAEA,MAAM,WAAW,OAAe,WAA0C;EASxE,OAAO,MARY,KAAK,GACrB,OAAO;GAAE,OAAO,aAAa;GAAO,WAAW,aAAa;EAAU,CAAC,CAAC,CACxE,KAAK,YAAY,CAAC,CAClB,UACC,SACA,IAAI,GAAG,QAAQ,OAAO,aAAa,KAAK,GAAG,GAAG,QAAQ,IAAI,aAAa,SAAS,CAAC,CACnF,CAAC,CACA,MAAM,KAAK,YAAY,OAAO,SAAS,CAAC;CAE7C;CAEA,MAAM,UAAU,OAAe,WAAmB,UAA8C;EAC9F,MAAM,CAAC,OAAO,MAAM,KAAK,GACtB,OAAO;GAAE,OAAO,aAAa;GAAO,WAAW,aAAa;EAAU,CAAC,CAAC,CACxE,KAAK,YAAY,CAAC,CAClB,UACC,SACA,IAAI,GAAG,QAAQ,OAAO,aAAa,KAAK,GAAG,GAAG,QAAQ,IAAI,aAAa,SAAS,CAAC,CACnF,CAAC,CACA,MAAM,IAAI,KAAK,YAAY,OAAO,SAAS,GAAG,GAAG,aAAa,WAAW,QAAQ,CAAC,CAAC,CAAC,CACpF,MAAM,CAAC;EACV,OAAO,OAAO;CAChB;CAEA,gBAAwB,OAAe,WAAwB;EAC7D,OAAO,IACL,GAAG,aAAa,OAAO,KAAK,GAC5B,GAAG,aAAa,WAAW,SAAS,GACpC,GAAG,aAAa,QAAQ,QAAQ,GAChC,GAAG,OAAO,aAAa,SAAS,GAAG,GAAG,aAAa,WAAW,GAAG,OAAO,CAAC,GACzE,GAAG,UAAU,QAAQ,WAAW,CAClC;CACF;CAEA,MAAM,mBAAmB,OAAe,WAA0C;EAChF,OAAO,KAAK,GACT,OAAO;GAAE,OAAO,aAAa;GAAO,WAAW,aAAa;EAAU,CAAC,CAAC,CACxE,KAAK,YAAY,CAAC,CAClB,UACC,WACA,IAAI,GAAG,UAAU,OAAO,aAAa,KAAK,GAAG,GAAG,UAAU,IAAI,aAAa,SAAS,CAAC,CACvF,CAAC,CACA,MAAM,KAAK,gBAAgB,OAAO,SAAS,CAAC;CACjD;CAEA,MAAM,kBACJ,OACA,WACA,YAC4B;EAC5B,MAAM,CAAC,OAAO,MAAM,KAAK,GACtB,OAAO;GAAE,OAAO,aAAa;GAAO,WAAW,aAAa;EAAU,CAAC,CAAC,CACxE,KAAK,YAAY,CAAC,CAClB,UACC,WACA,IAAI,GAAG,UAAU,OAAO,aAAa,KAAK,GAAG,GAAG,UAAU,IAAI,aAAa,SAAS,CAAC,CACvF,CAAC,CACA,MAAM,IAAI,KAAK,gBAAgB,OAAO,SAAS,GAAG,GAAG,aAAa,WAAW,UAAU,CAAC,CAAC,CAAC,CAC1F,MAAM,CAAC;EACV,OAAO,OAAO;CAChB;CAEA,MAAM,iBAAiB,OAAe,YAAoB,SAAmC;EAC3F,MAAM,CAAC,OAAO,MAAM,KAAK,GACtB,OAAO,EAAE,IAAI,eAAe,GAAG,CAAC,CAAC,CACjC,KAAK,cAAc,CAAC,CACpB,MACC,IACE,GAAG,eAAe,OAAO,KAAK,GAC9B,GAAG,eAAe,YAAY,UAAU,GACxC,GAAG,eAAe,SAAS,OAAO,CACpC,CACF,CAAC,CACA,MAAM,CAAC;EACV,OAAO,QAAQ,KAAA;CACjB;AACF"}
1
+ {"version":3,"file":"learn.js","names":[],"sources":["../../src/repositories/learn.ts"],"sourcesContent":["// learn — Drizzle read repo (implements the reporting/learn outbound port).\n// Returns the (org, content) refs a student is ACTIVELY entitled to and whose\n// course is PUBLISHED. \"Active\" excludes revoked and expired grants (expiry is\n// derived from expires_at at read time — no row flip). Org-scoped: the portal\n// org bounds the read (`entitlements.org_id`), so no cross-org grants leak.\n// The inner join to `courses` on the content id restricts to course grants —\n// no explicit content-type filter needed.\nimport { and, eq, gt, isNull, or, sql, type SQL } from \"drizzle-orm\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\nimport type { LearnEntitlementReader, ContentRef } from \"@headless-lms/core/reporting/learn\";\nimport { entitlements, courses, downloads, downloadAssets } from \"../schema/index.js\";\nimport type { Logger } from \"@headless-lms/core/shared/ports\";\nimport { noopLogger } from \"@headless-lms/core/shared/logger\";\nimport { translateDbErrors } from \"./pg-errors.js\";\n\nexport class DrizzleLearnRepository implements LearnEntitlementReader {\n constructor(\n private readonly db: NodePgDatabase,\n private readonly logger: Logger = noopLogger,\n ) {}\n\n private baseFilters(orgId: string, orgUserId: string): SQL {\n return and(\n eq(entitlements.orgId, orgId),\n eq(entitlements.orgUserId, orgUserId),\n eq(entitlements.status, \"active\"),\n or(isNull(entitlements.expiresAt), gt(entitlements.expiresAt, sql`now()`))!,\n eq(courses.status, \"published\"),\n )!;\n }\n\n async activeRefs(orgId: string, orgUserId: string): Promise<ContentRef[]> {\n const rows = await this.db\n .select({ orgId: entitlements.orgId, contentId: courses.id })\n .from(entitlements)\n .innerJoin(\n courses,\n and(eq(courses.orgId, entitlements.orgId), eq(courses.id, entitlements.contentId)),\n )\n .where(this.baseFilters(orgId, orgUserId));\n return rows;\n }\n\n async activeRef(orgId: string, orgUserId: string, courseId: string): Promise<ContentRef | null> {\n const [row] = await this.db\n .select({ orgId: entitlements.orgId, contentId: courses.id })\n .from(entitlements)\n .innerJoin(\n courses,\n and(eq(courses.orgId, entitlements.orgId), eq(courses.id, entitlements.contentId)),\n )\n .where(and(this.baseFilters(orgId, orgUserId), eq(entitlements.contentId, courseId)))\n .limit(1);\n return row ?? null;\n }\n\n private downloadFilters(orgId: string, orgUserId: string): SQL {\n return and(\n eq(entitlements.orgId, orgId),\n eq(entitlements.orgUserId, orgUserId),\n eq(entitlements.status, \"active\"),\n or(isNull(entitlements.expiresAt), gt(entitlements.expiresAt, sql`now()`))!,\n eq(downloads.status, \"published\"),\n )!;\n }\n\n async activeDownloadRefs(orgId: string, orgUserId: string): Promise<ContentRef[]> {\n return this.db\n .select({ orgId: entitlements.orgId, contentId: downloads.id })\n .from(entitlements)\n .innerJoin(\n downloads,\n and(eq(downloads.orgId, entitlements.orgId), eq(downloads.id, entitlements.contentId)),\n )\n .where(this.downloadFilters(orgId, orgUserId));\n }\n\n async activeDownloadRef(\n orgId: string,\n orgUserId: string,\n downloadId: string,\n ): Promise<ContentRef | null> {\n const [row] = await this.db\n .select({ orgId: entitlements.orgId, contentId: downloads.id })\n .from(entitlements)\n .innerJoin(\n downloads,\n and(eq(downloads.orgId, entitlements.orgId), eq(downloads.id, entitlements.contentId)),\n )\n .where(and(this.downloadFilters(orgId, orgUserId), eq(entitlements.contentId, downloadId)))\n .limit(1);\n return row ?? null;\n }\n\n async downloadHasAsset(orgId: string, downloadId: string, assetId: string): Promise<boolean> {\n const [row] = await this.db\n .select({ id: downloadAssets.id })\n .from(downloadAssets)\n .where(\n and(\n eq(downloadAssets.orgId, orgId),\n eq(downloadAssets.downloadId, downloadId),\n eq(downloadAssets.assetId, assetId),\n ),\n )\n .limit(1);\n return row !== undefined;\n }\n}\ntranslateDbErrors(DrizzleLearnRepository);\n"],"mappings":"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"}
@@ -1,5 +1,6 @@
1
1
  import { users } from "../schema/identity.js";
2
2
  import { invites, orgUsers } from "../schema/organizations.js";
3
+ import { translateDbErrors } from "./pg-errors.js";
3
4
  import { user } from "../schema/better-auth.js";
4
5
  import { orgUserProfileColumns } from "./org-user-profile.js";
5
6
  import { and, eq, ne } from "drizzle-orm";
@@ -110,6 +111,7 @@ var DrizzleMembersRepository = class {
110
111
  return (await this.loadAll(orgId)).find((r) => r.id === id) ?? null;
111
112
  }
112
113
  };
114
+ translateDbErrors(DrizzleMembersRepository);
113
115
  //#endregion
114
116
  export { DrizzleMembersRepository };
115
117
 
@@ -1 +1 @@
1
- {"version":3,"file":"members.js","names":[],"sources":["../../src/repositories/members.ts"],"sourcesContent":["// organizations members — Drizzle read repository. Reads the domain mirror of the\n// org's members (orgUsers) and pending invites, joined to the identity user for\n// display. Writes go through the auth provider (see adapters/auth/org-admin.ts).\nimport { and, eq, ne } from 'drizzle-orm';\nimport type { NodePgDatabase } from 'drizzle-orm/node-postgres';\nimport {\n isStaffRole,\n STUDENT_ROLE,\n type Member,\n type MemberRecord,\n type MembersQuery,\n type MembersRepository,\n type Page,\n type StaffRole,\n} from '@headless-lms/core/organizations';\nimport { orgUsers, invites } from '../schema/organizations.js';\nimport { users } from '../schema/identity.js';\nimport { user } from '../schema/better-auth.js';\nimport type { Logger } from '@headless-lms/core/shared/ports';\nimport { noopLogger } from '@headless-lms/core/shared/logger';\nimport { orgUserProfileColumns } from './org-user-profile.js';\n\n// The member surface is staff-only; a student org user never reaches here\n// (the queries below exclude it), so an unrecognized role falls back to the\n// least-privileged staff role rather than widening to the full Role union.\nconst roleOf = (t: string): StaffRole => (isStaffRole(t) ? t : 'instructor');\n\nfunction toMember(r: MemberRecord): Member {\n return {\n id: r.id,\n firstName: r.firstName,\n lastName: r.lastName,\n email: r.email,\n image: r.image,\n role: r.role,\n status: r.status,\n joinedAt: r.joinedAt,\n invitedAt: r.invitedAt,\n };\n}\n\nexport class DrizzleMembersRepository implements MembersRepository {\n constructor(\n private readonly db: NodePgDatabase,\n private readonly logger: Logger = noopLogger,\n ) {}\n\n private async loadAll(orgId: string): Promise<MemberRecord[]> {\n // Learners share this table now, so the role filter is what keeps them off\n // the staff list. Name/email come from the identity user the row links to.\n const memberRows = await this.db\n .select({\n ...orgUserProfileColumns,\n role: orgUsers.role,\n joinedAt: orgUsers.createdAt,\n userExternalId: users.externalId,\n })\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), ne(orgUsers.role, STUDENT_ROLE)));\n\n // Student invites live in the same table but belong to the students\n // surface, not the members list.\n const inviteRows = await this.db\n .select({\n id: invites.id,\n email: invites.email,\n role: invites.role,\n invitedAt: invites.createdAt,\n })\n .from(invites)\n .where(\n and(\n eq(invites.orgId, orgId),\n eq(invites.status, 'pending'),\n ne(invites.role, STUDENT_ROLE),\n ),\n );\n\n const members: MemberRecord[] = memberRows.map((m) => ({\n id: m.id,\n firstName: m.firstName,\n lastName: m.lastName,\n email: m.email,\n image: m.image ?? null,\n role: roleOf(m.role),\n status: 'active',\n joinedAt: m.joinedAt.toISOString(),\n invitedAt: null,\n kind: 'member',\n userExternalId: m.userExternalId,\n inviteId: null,\n }));\n // A pending invite is an address and nothing else — the invites table\n // carries no names, so there is nobody to name until they accept.\n const invited: MemberRecord[] = inviteRows.map((i) => ({\n id: i.id,\n firstName: null,\n lastName: null,\n email: i.email,\n image: null,\n role: roleOf(i.role),\n status: 'invited',\n joinedAt: null,\n invitedAt: i.invitedAt.toISOString(),\n kind: 'invite',\n userExternalId: null,\n inviteId: i.id,\n }));\n return [...members, ...invited];\n }\n\n async list(orgId: string, query: MembersQuery): Promise<Page<Member>> {\n let rows = await this.loadAll(orgId);\n if (query.role) {\n rows = rows.filter((r) => r.role === query.role);\n }\n if (query.status) {\n rows = rows.filter((r) => r.status === query.status);\n }\n const q = query.search?.trim().toLowerCase();\n if (q) {\n rows = rows.filter((r) =>\n [r.firstName, r.lastName, r.email].some((v) => v?.toLowerCase().includes(q)),\n );\n }\n\n const sort = query.sort;\n const desc = sort?.startsWith('-') ?? false;\n const key = (desc ? sort!.slice(1) : sort) as keyof Member | undefined;\n // Nulls last whichever way the column is sorted — an unaccepted invite has\n // no name and belongs at the bottom, not interleaved.\n const byName = (a: string | null, b: string | null): number =>\n a === b ? 0 : a === null ? 1 : b === null ? -1 : a.localeCompare(b);\n rows.sort((a, b) => {\n if (key === 'email') {\n return desc ? -a.email.localeCompare(b.email) : a.email.localeCompare(b.email);\n }\n if (key === 'role') {\n return desc ? -a.role.localeCompare(b.role) : a.role.localeCompare(b.role);\n }\n if (key === 'lastName') {\n const cmp = byName(a.lastName, b.lastName) || byName(a.firstName, b.firstName);\n return desc ? -cmp : cmp;\n }\n const cmp = byName(a.firstName, b.firstName) || byName(a.lastName, b.lastName);\n return desc ? -cmp : cmp;\n });\n\n const total = rows.length;\n const start = (query.page - 1) * query.pageSize;\n return {\n rows: rows.slice(start, start + query.pageSize).map(toMember),\n total,\n page: query.page,\n pageSize: query.pageSize,\n };\n }\n\n async findByEmail(orgId: string, email: string): Promise<MemberRecord | null> {\n const all = await this.loadAll(orgId);\n return all.find((r) => r.email.toLowerCase() === email.toLowerCase()) ?? null;\n }\n\n async findById(orgId: string, id: string): Promise<MemberRecord | null> {\n const all = await this.loadAll(orgId);\n return all.find((r) => r.id === id) ?? null;\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"members.js","names":[],"sources":["../../src/repositories/members.ts"],"sourcesContent":["// organizations members — Drizzle read repository. Reads the domain mirror of the\n// org's members (orgUsers) and pending invites, joined to the identity user for\n// display. Writes go through the auth provider (see adapters/auth/org-admin.ts).\nimport { and, eq, ne } from \"drizzle-orm\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\nimport {\n isStaffRole,\n STUDENT_ROLE,\n type Member,\n type MemberRecord,\n type MembersQuery,\n type MembersRepository,\n type Page,\n type StaffRole,\n} from \"@headless-lms/core/organizations\";\nimport { orgUsers, invites } from \"../schema/organizations.js\";\nimport { users } from \"../schema/identity.js\";\nimport { user } from \"../schema/better-auth.js\";\nimport type { Logger } from \"@headless-lms/core/shared/ports\";\nimport { noopLogger } from \"@headless-lms/core/shared/logger\";\nimport { orgUserProfileColumns } from \"./org-user-profile.js\";\nimport { translateDbErrors } from \"./pg-errors.js\";\n\n// The member surface is staff-only; a student org user never reaches here\n// (the queries below exclude it), so an unrecognized role falls back to the\n// least-privileged staff role rather than widening to the full Role union.\nconst roleOf = (t: string): StaffRole => (isStaffRole(t) ? t : \"instructor\");\n\nfunction toMember(r: MemberRecord): Member {\n return {\n id: r.id,\n firstName: r.firstName,\n lastName: r.lastName,\n email: r.email,\n image: r.image,\n role: r.role,\n status: r.status,\n joinedAt: r.joinedAt,\n invitedAt: r.invitedAt,\n };\n}\n\nexport class DrizzleMembersRepository implements MembersRepository {\n constructor(\n private readonly db: NodePgDatabase,\n private readonly logger: Logger = noopLogger,\n ) {}\n\n private async loadAll(orgId: string): Promise<MemberRecord[]> {\n // Learners share this table now, so the role filter is what keeps them off\n // the staff list. Name/email come from the identity user the row links to.\n const memberRows = await this.db\n .select({\n ...orgUserProfileColumns,\n role: orgUsers.role,\n joinedAt: orgUsers.createdAt,\n userExternalId: users.externalId,\n })\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), ne(orgUsers.role, STUDENT_ROLE)));\n\n // Student invites live in the same table but belong to the students\n // surface, not the members list.\n const inviteRows = await this.db\n .select({\n id: invites.id,\n email: invites.email,\n role: invites.role,\n invitedAt: invites.createdAt,\n })\n .from(invites)\n .where(\n and(\n eq(invites.orgId, orgId),\n eq(invites.status, \"pending\"),\n ne(invites.role, STUDENT_ROLE),\n ),\n );\n\n const members: MemberRecord[] = memberRows.map((m) => ({\n id: m.id,\n firstName: m.firstName,\n lastName: m.lastName,\n email: m.email,\n image: m.image ?? null,\n role: roleOf(m.role),\n status: \"active\",\n joinedAt: m.joinedAt.toISOString(),\n invitedAt: null,\n kind: \"member\",\n userExternalId: m.userExternalId,\n inviteId: null,\n }));\n // A pending invite is an address and nothing else — the invites table\n // carries no names, so there is nobody to name until they accept.\n const invited: MemberRecord[] = inviteRows.map((i) => ({\n id: i.id,\n firstName: null,\n lastName: null,\n email: i.email,\n image: null,\n role: roleOf(i.role),\n status: \"invited\",\n joinedAt: null,\n invitedAt: i.invitedAt.toISOString(),\n kind: \"invite\",\n userExternalId: null,\n inviteId: i.id,\n }));\n return [...members, ...invited];\n }\n\n async list(orgId: string, query: MembersQuery): Promise<Page<Member>> {\n let rows = await this.loadAll(orgId);\n if (query.role) {\n rows = rows.filter((r) => r.role === query.role);\n }\n if (query.status) {\n rows = rows.filter((r) => r.status === query.status);\n }\n const q = query.search?.trim().toLowerCase();\n if (q) {\n rows = rows.filter((r) =>\n [r.firstName, r.lastName, r.email].some((v) => v?.toLowerCase().includes(q)),\n );\n }\n\n const sort = query.sort;\n const desc = sort?.startsWith(\"-\") ?? false;\n const key = (desc ? sort!.slice(1) : sort) as keyof Member | undefined;\n // Nulls last whichever way the column is sorted — an unaccepted invite has\n // no name and belongs at the bottom, not interleaved.\n const byName = (a: string | null, b: string | null): number =>\n a === b ? 0 : a === null ? 1 : b === null ? -1 : a.localeCompare(b);\n rows.sort((a, b) => {\n if (key === \"email\") {\n return desc ? -a.email.localeCompare(b.email) : a.email.localeCompare(b.email);\n }\n if (key === \"role\") {\n return desc ? -a.role.localeCompare(b.role) : a.role.localeCompare(b.role);\n }\n if (key === \"lastName\") {\n const cmp = byName(a.lastName, b.lastName) || byName(a.firstName, b.firstName);\n return desc ? -cmp : cmp;\n }\n const cmp = byName(a.firstName, b.firstName) || byName(a.lastName, b.lastName);\n return desc ? -cmp : cmp;\n });\n\n const total = rows.length;\n const start = (query.page - 1) * query.pageSize;\n return {\n rows: rows.slice(start, start + query.pageSize).map(toMember),\n total,\n page: query.page,\n pageSize: query.pageSize,\n };\n }\n\n async findByEmail(orgId: string, email: string): Promise<MemberRecord | null> {\n const all = await this.loadAll(orgId);\n return all.find((r) => r.email.toLowerCase() === email.toLowerCase()) ?? null;\n }\n\n async findById(orgId: string, id: string): Promise<MemberRecord | null> {\n const all = await this.loadAll(orgId);\n return all.find((r) => r.id === id) ?? null;\n }\n}\ntranslateDbErrors(DrizzleMembersRepository);\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"org-user-profile.js","names":[],"sources":["../../src/repositories/org-user-profile.ts"],"sourcesContent":["// The one display join for an org user's profile.\n//\n// The names and email live on the identity `users` row the org_users row links\n// to; the avatar lives on the auth engine's mirrored `user` table, reached\n// through that same identity row. The identity join is INNER (user_id is NOT\n// NULL); the auth join is LEFT, since only the avatar depends on it.\n//\n// Sanctioned by .eslintrc.cjs — \"db repositories read the auth adapter's\n// mirrored `user` table for display joins\".\n//\n// No `joinOrgUserProfile` helper: Drizzle's query builder type narrows on every\n// chained call, so a generic wrapper around `.innerJoin(...).leftJoin(...)`\n// can't be typed without widening it to `unknown` — each repository chains the\n// two joins inline instead.\nimport { orgUsers } from '../schema/organizations.js';\nimport { users } from '../schema/identity.js';\nimport { user } from '../schema/better-auth.js';\n\n/** Spread into a `.select({ ... })` to get the profile columns. */\nexport const orgUserProfileColumns = {\n id: orgUsers.id,\n firstName: users.firstName,\n lastName: users.lastName,\n email: users.email,\n image: user.image,\n};\n"],"mappings":";;;;;AAmBA,MAAa,wBAAwB;CACnC,IAAI,SAAS;CACb,WAAW,MAAM;CACjB,UAAU,MAAM;CAChB,OAAO,MAAM;CACb,OAAO,KAAK;AACd"}
1
+ {"version":3,"file":"org-user-profile.js","names":[],"sources":["../../src/repositories/org-user-profile.ts"],"sourcesContent":["// The one display join for an org user's profile.\n//\n// The names and email live on the identity `users` row the org_users row links\n// to; the avatar lives on the auth engine's mirrored `user` table, reached\n// through that same identity row. The identity join is INNER (user_id is NOT\n// NULL); the auth join is LEFT, since only the avatar depends on it.\n//\n// Sanctioned by .eslintrc.cjs — \"db repositories read the auth adapter's\n// mirrored `user` table for display joins\".\n//\n// No `joinOrgUserProfile` helper: Drizzle's query builder type narrows on every\n// chained call, so a generic wrapper around `.innerJoin(...).leftJoin(...)`\n// can't be typed without widening it to `unknown` — each repository chains the\n// two joins inline instead.\nimport { orgUsers } from \"../schema/organizations.js\";\nimport { users } from \"../schema/identity.js\";\nimport { user } from \"../schema/better-auth.js\";\n\n/** Spread into a `.select({ ... })` to get the profile columns. */\nexport const orgUserProfileColumns = {\n id: orgUsers.id,\n firstName: users.firstName,\n lastName: users.lastName,\n email: users.email,\n image: user.image,\n};\n"],"mappings":";;;;;AAmBA,MAAa,wBAAwB;CACnC,IAAI,SAAS;CACb,WAAW,MAAM;CACjB,UAAU,MAAM;CAChB,OAAO,MAAM;CACb,OAAO,KAAK;AACd"}
@@ -1,5 +1,5 @@
1
1
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
2
- import { AddOrgUserInput, CreateOrgUserInput, Invite, NewInviteRow, NewOrganizationRow, OrgUser, Organization, OrganizationsRepository, UpdateOrganizationInput } from "@headless-lms/core/organizations";
2
+ import { CreateOrgUserInput, Invite, NewInviteRow, NewOrganizationRow, OrgUser, Organization, OrganizationsRepository, UpdateOrgUserInput, UpdateOrganizationInput } from "@headless-lms/core/organizations";
3
3
  import { Logger } from "@headless-lms/core/shared/ports";
4
4
  //#region src/repositories/organizations.d.ts
5
5
  declare class DrizzleOrganizationsRepository implements OrganizationsRepository {
@@ -12,18 +12,16 @@ declare class DrizzleOrganizationsRepository implements OrganizationsRepository
12
12
  findById(id: string): Promise<Organization | null>;
13
13
  findByExternalId(externalId: string): Promise<Organization | null>;
14
14
  findBySlug(slug: string): Promise<Organization | null>;
15
- upsertOrgUser(orgId: string, input: AddOrgUserInput): Promise<OrgUser>;
16
15
  upsertPendingInvite(orgId: string, input: NewInviteRow): Promise<Invite>;
17
16
  setInviteStatus(orgId: string, id: string, status: string): Promise<Invite | null>;
18
17
  findInviteByTokenHash(tokenHash: string): Promise<Invite | null>;
18
+ findPendingInvite(orgId: string, email: string): Promise<Invite | null>;
19
+ setInviteToken(orgId: string, id: string, tokenHash: string, expiresAt: Date): Promise<Invite | null>;
19
20
  findOrgUser(orgId: string, userId: string): Promise<OrgUser | null>;
20
21
  findOrgUsersByUser(userId: string): Promise<OrgUser[]>;
21
22
  findOrgUserById(orgId: string, id: string): Promise<OrgUser | null>;
22
23
  createOrgUser(input: CreateOrgUserInput): Promise<OrgUser>;
23
- ensureOrgUser(input: CreateOrgUserInput): Promise<{
24
- orgUser: OrgUser;
25
- created: boolean;
26
- }>;
24
+ updateOrgUser(orgId: string, id: string, patch: UpdateOrgUserInput): Promise<OrgUser | null>;
27
25
  findStudentOrgUsers(userId: string): Promise<OrgUser[]>;
28
26
  cancelPendingInvite(orgId: string, email: string): Promise<Invite | null>;
29
27
  deleteOrgUser(orgId: string, id: string): Promise<OrgUser | null>;
@@ -1,9 +1,9 @@
1
1
  import { invites, orgUsers, organizations } from "../schema/organizations.js";
2
- import { isUniqueViolation } from "./pg-errors.js";
2
+ import { isUniqueViolation, translateDbErrors } from "./pg-errors.js";
3
3
  import { and, eq, sql } from "drizzle-orm";
4
- import { noopLogger } from "@headless-lms/core/shared/logger";
5
4
  import { ConflictError } from "@headless-lms/core/shared/errors";
6
- import { normalizeRole, parseRole } from "@headless-lms/core/organizations";
5
+ import { noopLogger } from "@headless-lms/core/shared/logger";
6
+ import { parseRole } from "@headless-lms/core/organizations";
7
7
  //#region src/repositories/organizations.ts
8
8
  const INVITE_STATUSES = [
9
9
  "pending",
@@ -22,7 +22,8 @@ function toInvite(row) {
22
22
  invitedBy: row.invitedBy,
23
23
  tokenHash: row.tokenHash,
24
24
  expiresAt: row.expiresAt,
25
- createdAt: row.createdAt
25
+ createdAt: row.createdAt,
26
+ updatedAt: row.updatedAt
26
27
  };
27
28
  }
28
29
  var DrizzleOrganizationsRepository = class {
@@ -67,22 +68,6 @@ var DrizzleOrganizationsRepository = class {
67
68
  const [row] = await this.db.select().from(organizations).where(eq(organizations.slug, slug)).limit(1);
68
69
  return row ?? null;
69
70
  }
70
- async upsertOrgUser(orgId, input) {
71
- const role = normalizeRole(input.role);
72
- const [row] = await this.db.insert(orgUsers).values({
73
- orgId,
74
- userId: input.userId,
75
- role
76
- }).onConflictDoUpdate({
77
- target: [orgUsers.orgId, orgUsers.userId],
78
- set: { role }
79
- }).returning();
80
- if (!row) throw new Error("failed to upsert orgUser");
81
- return {
82
- ...row,
83
- role: parseRole(row.role)
84
- };
85
- }
86
71
  async upsertPendingInvite(orgId, input) {
87
72
  const [row] = await this.db.insert(invites).values({
88
73
  orgId,
@@ -113,6 +98,17 @@ var DrizzleOrganizationsRepository = class {
113
98
  const [row] = await this.db.select().from(invites).where(eq(invites.tokenHash, tokenHash)).limit(1);
114
99
  return row ? toInvite(row) : null;
115
100
  }
101
+ async findPendingInvite(orgId, email) {
102
+ const [row] = await this.db.select().from(invites).where(and(eq(invites.orgId, orgId), sql`lower(${invites.email}) = lower(${email})`, eq(invites.status, "pending"))).limit(1);
103
+ return row ? toInvite(row) : null;
104
+ }
105
+ async setInviteToken(orgId, id, tokenHash, expiresAt) {
106
+ const [row] = await this.db.update(invites).set({
107
+ tokenHash,
108
+ expiresAt
109
+ }).where(and(eq(invites.orgId, orgId), eq(invites.id, id), eq(invites.status, "pending"))).returning();
110
+ return row ? toInvite(row) : null;
111
+ }
116
112
  async findOrgUser(orgId, userId) {
117
113
  const [row] = await this.db.select().from(orgUsers).where(and(eq(orgUsers.orgId, orgId), eq(orgUsers.userId, userId))).limit(1);
118
114
  return row ? {
@@ -151,31 +147,15 @@ var DrizzleOrganizationsRepository = class {
151
147
  throw err;
152
148
  }
153
149
  }
154
- async ensureOrgUser(input) {
155
- const status = input.status ?? "active";
156
- const [inserted] = await this.db.insert(orgUsers).values({
157
- orgId: input.orgId,
158
- userId: input.userId,
159
- role: input.role,
160
- status
161
- }).onConflictDoNothing({ target: [orgUsers.orgId, orgUsers.userId] }).returning();
162
- if (inserted) return {
163
- orgUser: {
164
- ...inserted,
165
- role: parseRole(inserted.role)
166
- },
167
- created: true
168
- };
169
- const where = and(eq(orgUsers.orgId, input.orgId), eq(orgUsers.userId, input.userId));
170
- const [existing] = status === "active" ? await this.db.update(orgUsers).set({ status }).where(where).returning() : await this.db.select().from(orgUsers).where(where).limit(1);
171
- if (!existing) throw new Error("failed to ensure org user");
172
- return {
173
- orgUser: {
174
- ...existing,
175
- role: parseRole(existing.role)
176
- },
177
- created: false
178
- };
150
+ async updateOrgUser(orgId, id, patch) {
151
+ const [row] = await this.db.update(orgUsers).set({
152
+ ...patch.role !== void 0 && { role: patch.role },
153
+ ...patch.status !== void 0 && { status: patch.status }
154
+ }).where(and(eq(orgUsers.orgId, orgId), eq(orgUsers.id, id))).returning();
155
+ return row ? {
156
+ ...row,
157
+ role: parseRole(row.role)
158
+ } : null;
179
159
  }
180
160
  async findStudentOrgUsers(userId) {
181
161
  return (await this.db.select().from(orgUsers).where(and(eq(orgUsers.userId, userId), eq(orgUsers.role, "student"))).orderBy(orgUsers.createdAt)).map((row) => ({
@@ -192,6 +172,7 @@ var DrizzleOrganizationsRepository = class {
192
172
  return row ?? null;
193
173
  }
194
174
  };
175
+ translateDbErrors(DrizzleOrganizationsRepository);
195
176
  //#endregion
196
177
  export { DrizzleOrganizationsRepository };
197
178
 
@@ -1 +1 @@
1
- {"version":3,"file":"organizations.js","names":[],"sources":["../../src/repositories/organizations.ts"],"sourcesContent":["// organizations — Drizzle repository (implements the core outbound port).\nimport { and, eq, sql } from 'drizzle-orm';\nimport type { NodePgDatabase } from 'drizzle-orm/node-postgres';\nimport {\n normalizeRole,\n parseRole,\n type AddOrgUserInput,\n type CreateOrgUserInput,\n type Invite,\n type NewInviteRow,\n type NewOrganizationRow,\n type Organization,\n type OrganizationsRepository,\n type OrgUser,\n type UpdateOrganizationInput,\n} from '@headless-lms/core/organizations';\nimport { invites, organizations, orgUsers } from '../schema/organizations.js';\nimport type { Logger } from '@headless-lms/core/shared/ports';\nimport { noopLogger } from '@headless-lms/core/shared/logger';\nimport { ConflictError } from '@headless-lms/core/shared/errors';\nimport { isUniqueViolation } from './pg-errors.js';\n\nconst INVITE_STATUSES = ['pending', 'accepted', 'rejected', 'canceled'] as const;\ntype InviteStatus = (typeof INVITE_STATUSES)[number];\nconst toStatus = (s: string): InviteStatus =>\n (INVITE_STATUSES as readonly string[]).includes(s) ? (s as InviteStatus) : 'pending';\n\nfunction toInvite(row: typeof invites.$inferSelect): Invite {\n return {\n id: row.id,\n orgId: row.orgId,\n email: row.email,\n role: row.role,\n status: row.status,\n invitedBy: row.invitedBy,\n tokenHash: row.tokenHash,\n expiresAt: row.expiresAt,\n createdAt: row.createdAt,\n };\n}\n\nexport class DrizzleOrganizationsRepository implements OrganizationsRepository {\n constructor(\n private readonly db: NodePgDatabase,\n private readonly logger: Logger = noopLogger,\n ) {}\n\n async create(input: NewOrganizationRow): Promise<Organization> {\n const [row] = await this.db\n .insert(organizations)\n .values({\n externalId: input.externalId,\n name: input.name,\n slug: input.slug,\n ownerId: input.ownerId,\n id: input.id,\n })\n .returning();\n if (!row) {\n throw new Error('failed to insert organization');\n }\n return row!;\n }\n\n async update(id: string, input: UpdateOrganizationInput): Promise<Organization | null> {\n const [row] = await this.db\n .update(organizations)\n .set({ name: input.name, slug: input.slug })\n .where(eq(organizations.id, id))\n .returning();\n return row ?? null;\n }\n\n async delete(id: string): Promise<Organization> {\n const [row] = await this.db.delete(organizations).where(eq(organizations.id, id)).returning();\n\n if (!row) {\n throw new Error('failed to delete organization');\n }\n return row;\n }\n\n async findById(id: string): Promise<Organization | null> {\n const [row] = await this.db.select().from(organizations).where(eq(organizations.id, id));\n return row ?? null;\n }\n\n async findByExternalId(externalId: string): Promise<Organization | null> {\n const [row] = await this.db\n .select()\n .from(organizations)\n .where(eq(organizations.externalId, externalId))\n .limit(1);\n return row ?? null;\n }\n\n async findBySlug(slug: string): Promise<Organization | null> {\n const [row] = await this.db\n .select()\n .from(organizations)\n .where(eq(organizations.slug, slug))\n .limit(1);\n return row ?? null;\n }\n\n async upsertOrgUser(orgId: string, input: AddOrgUserInput): Promise<OrgUser> {\n const role = normalizeRole(input.role);\n const [row] = await this.db\n .insert(orgUsers)\n .values({ orgId, userId: input.userId, role })\n .onConflictDoUpdate({ target: [orgUsers.orgId, orgUsers.userId], set: { role } })\n .returning();\n if (!row) {\n throw new Error('failed to upsert orgUser');\n }\n return { ...row, role: parseRole(row.role) };\n }\n\n async upsertPendingInvite(orgId: string, input: NewInviteRow): Promise<Invite> {\n const [row] = await this.db\n .insert(invites)\n .values({\n orgId,\n email: input.email,\n role: input.role,\n status: 'pending',\n invitedBy: input.invitedBy,\n tokenHash: input.tokenHash,\n expiresAt: input.expiresAt,\n })\n .onConflictDoUpdate({\n target: [invites.orgId, invites.email],\n targetWhere: sql`${invites.status} = 'pending'`,\n set: {\n role: input.role,\n invitedBy: input.invitedBy,\n tokenHash: input.tokenHash,\n expiresAt: input.expiresAt,\n },\n })\n .returning();\n if (!row) {\n throw new Error('failed to upsert invite');\n }\n return toInvite(row);\n }\n\n async setInviteStatus(orgId: string, id: string, status: string): Promise<Invite | null> {\n const [row] = await this.db\n .update(invites)\n .set({ status: toStatus(status) })\n .where(and(eq(invites.orgId, orgId), eq(invites.id, id)))\n .returning();\n return row ? toInvite(row) : null;\n }\n\n async findInviteByTokenHash(tokenHash: string): Promise<Invite | null> {\n const [row] = await this.db\n .select()\n .from(invites)\n .where(eq(invites.tokenHash, tokenHash))\n .limit(1);\n return row ? toInvite(row) : null;\n }\n\n async findOrgUser(orgId: string, userId: string): Promise<OrgUser | null> {\n const [row] = await this.db\n .select()\n .from(orgUsers)\n .where(and(eq(orgUsers.orgId, orgId), eq(orgUsers.userId, userId)))\n .limit(1);\n return row ? { ...row, role: parseRole(row.role) } : null;\n }\n\n async findOrgUsersByUser(userId: string): Promise<OrgUser[]> {\n const rows = await this.db\n .select()\n .from(orgUsers)\n .where(eq(orgUsers.userId, userId))\n .orderBy(orgUsers.createdAt);\n return rows.map((row) => ({ ...row, role: parseRole(row.role) }));\n }\n\n async findOrgUserById(orgId: string, id: string): Promise<OrgUser | null> {\n const [row] = await this.db\n .select()\n .from(orgUsers)\n .where(and(eq(orgUsers.orgId, orgId), eq(orgUsers.id, id)))\n .limit(1);\n return row ? { ...row, role: parseRole(row.role) } : null;\n }\n\n async createOrgUser(input: CreateOrgUserInput): Promise<OrgUser> {\n try {\n const [row] = await this.db\n .insert(orgUsers)\n .values({\n orgId: input.orgId,\n userId: input.userId,\n role: input.role,\n status: input.status ?? 'active',\n })\n .returning();\n if (!row) {\n throw new Error('failed to insert org user');\n }\n return { ...row, role: parseRole(row.role) };\n } catch (err) {\n if (isUniqueViolation(err)) {\n throw new ConflictError('That account already belongs to this organization');\n }\n throw err;\n }\n }\n\n async ensureOrgUser(input: CreateOrgUserInput): Promise<{ orgUser: OrgUser; created: boolean }> {\n const status = input.status ?? 'active';\n const [inserted] = await this.db\n .insert(orgUsers)\n .values({\n orgId: input.orgId,\n userId: input.userId,\n role: input.role,\n status,\n })\n .onConflictDoNothing({ target: [orgUsers.orgId, orgUsers.userId] })\n .returning();\n if (inserted) {\n return { orgUser: { ...inserted, role: parseRole(inserted.role) }, created: true };\n }\n\n const where = and(eq(orgUsers.orgId, input.orgId), eq(orgUsers.userId, input.userId));\n // Only an acceptance writes. A re-invite asks for 'invited' against a row\n // that already exists, and has nothing to say about it.\n const [existing] =\n status === 'active'\n ? await this.db.update(orgUsers).set({ status }).where(where).returning()\n : await this.db.select().from(orgUsers).where(where).limit(1);\n if (!existing) {\n throw new Error('failed to ensure org user');\n }\n return { orgUser: { ...existing, role: parseRole(existing.role) }, created: false };\n }\n\n async findStudentOrgUsers(userId: string): Promise<OrgUser[]> {\n const rows = await this.db\n .select()\n .from(orgUsers)\n .where(and(eq(orgUsers.userId, userId), eq(orgUsers.role, 'student')))\n .orderBy(orgUsers.createdAt);\n return rows.map((row) => ({ ...row, role: parseRole(row.role) }));\n }\n\n async cancelPendingInvite(orgId: string, email: string): Promise<Invite | null> {\n const [row] = await this.db\n .update(invites)\n .set({ status: 'canceled' })\n .where(\n and(\n eq(invites.orgId, orgId),\n sql`lower(${invites.email}) = lower(${email})`,\n eq(invites.status, 'pending'),\n ),\n )\n .returning();\n return row ? toInvite(row) : null;\n }\n\n async deleteOrgUser(orgId: string, id: string): Promise<OrgUser | null> {\n const [row] = await this.db\n .delete(orgUsers)\n .where(and(eq(orgUsers.orgId, orgId), eq(orgUsers.id, id)))\n .returning();\n\n return row ?? null;\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"organizations.js","names":[],"sources":["../../src/repositories/organizations.ts"],"sourcesContent":["// organizations — Drizzle repository (implements the core outbound port).\nimport { and, eq, sql } from \"drizzle-orm\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\nimport {\n parseRole,\n type CreateOrgUserInput,\n type Invite,\n type NewInviteRow,\n type NewOrganizationRow,\n type Organization,\n type OrganizationsRepository,\n type OrgUser,\n type UpdateOrganizationInput,\n type UpdateOrgUserInput,\n} from \"@headless-lms/core/organizations\";\nimport { invites, organizations, orgUsers } from \"../schema/organizations.js\";\nimport type { Logger } from \"@headless-lms/core/shared/ports\";\nimport { noopLogger } from \"@headless-lms/core/shared/logger\";\nimport { ConflictError } from \"@headless-lms/core/shared/errors\";\nimport { isUniqueViolation, translateDbErrors } from \"./pg-errors.js\";\n\nconst INVITE_STATUSES = [\"pending\", \"accepted\", \"rejected\", \"canceled\"] as const;\ntype InviteStatus = (typeof INVITE_STATUSES)[number];\nconst toStatus = (s: string): InviteStatus =>\n (INVITE_STATUSES as readonly string[]).includes(s) ? (s as InviteStatus) : \"pending\";\n\nfunction toInvite(row: typeof invites.$inferSelect): Invite {\n return {\n id: row.id,\n orgId: row.orgId,\n email: row.email,\n role: row.role,\n status: row.status,\n invitedBy: row.invitedBy,\n tokenHash: row.tokenHash,\n expiresAt: row.expiresAt,\n createdAt: row.createdAt,\n updatedAt: row.updatedAt,\n };\n}\n\nexport class DrizzleOrganizationsRepository implements OrganizationsRepository {\n constructor(\n private readonly db: NodePgDatabase,\n private readonly logger: Logger = noopLogger,\n ) {}\n\n async create(input: NewOrganizationRow): Promise<Organization> {\n const [row] = await this.db\n .insert(organizations)\n .values({\n externalId: input.externalId,\n name: input.name,\n slug: input.slug,\n ownerId: input.ownerId,\n id: input.id,\n })\n .returning();\n if (!row) {\n throw new Error(\"failed to insert organization\");\n }\n return row!;\n }\n\n async update(id: string, input: UpdateOrganizationInput): Promise<Organization | null> {\n const [row] = await this.db\n .update(organizations)\n .set({ name: input.name, slug: input.slug })\n .where(eq(organizations.id, id))\n .returning();\n return row ?? null;\n }\n\n async delete(id: string): Promise<Organization> {\n const [row] = await this.db.delete(organizations).where(eq(organizations.id, id)).returning();\n\n if (!row) {\n throw new Error(\"failed to delete organization\");\n }\n return row;\n }\n\n async findById(id: string): Promise<Organization | null> {\n const [row] = await this.db.select().from(organizations).where(eq(organizations.id, id));\n return row ?? null;\n }\n\n async findByExternalId(externalId: string): Promise<Organization | null> {\n const [row] = await this.db\n .select()\n .from(organizations)\n .where(eq(organizations.externalId, externalId))\n .limit(1);\n return row ?? null;\n }\n\n async findBySlug(slug: string): Promise<Organization | null> {\n const [row] = await this.db\n .select()\n .from(organizations)\n .where(eq(organizations.slug, slug))\n .limit(1);\n return row ?? null;\n }\n\n async upsertPendingInvite(orgId: string, input: NewInviteRow): Promise<Invite> {\n const [row] = await this.db\n .insert(invites)\n .values({\n orgId,\n email: input.email,\n role: input.role,\n status: \"pending\",\n invitedBy: input.invitedBy,\n tokenHash: input.tokenHash,\n expiresAt: input.expiresAt,\n })\n .onConflictDoUpdate({\n target: [invites.orgId, invites.email],\n targetWhere: sql`${invites.status} = 'pending'`,\n set: {\n role: input.role,\n invitedBy: input.invitedBy,\n tokenHash: input.tokenHash,\n expiresAt: input.expiresAt,\n },\n })\n .returning();\n if (!row) {\n throw new Error(\"failed to upsert invite\");\n }\n return toInvite(row);\n }\n\n async setInviteStatus(orgId: string, id: string, status: string): Promise<Invite | null> {\n const [row] = await this.db\n .update(invites)\n .set({ status: toStatus(status) })\n .where(and(eq(invites.orgId, orgId), eq(invites.id, id)))\n .returning();\n return row ? toInvite(row) : null;\n }\n\n async findInviteByTokenHash(tokenHash: string): Promise<Invite | null> {\n const [row] = await this.db\n .select()\n .from(invites)\n .where(eq(invites.tokenHash, tokenHash))\n .limit(1);\n return row ? toInvite(row) : null;\n }\n\n async findPendingInvite(orgId: string, email: string): Promise<Invite | null> {\n const [row] = await this.db\n .select()\n .from(invites)\n .where(\n and(\n eq(invites.orgId, orgId),\n sql`lower(${invites.email}) = lower(${email})`,\n eq(invites.status, \"pending\"),\n ),\n )\n .limit(1);\n return row ? toInvite(row) : null;\n }\n\n async setInviteToken(\n orgId: string,\n id: string,\n tokenHash: string,\n expiresAt: Date,\n ): Promise<Invite | null> {\n const [row] = await this.db\n .update(invites)\n .set({ tokenHash, expiresAt })\n .where(and(eq(invites.orgId, orgId), eq(invites.id, id), eq(invites.status, \"pending\")))\n .returning();\n return row ? toInvite(row) : null;\n }\n\n async findOrgUser(orgId: string, userId: string): Promise<OrgUser | null> {\n const [row] = await this.db\n .select()\n .from(orgUsers)\n .where(and(eq(orgUsers.orgId, orgId), eq(orgUsers.userId, userId)))\n .limit(1);\n return row ? { ...row, role: parseRole(row.role) } : null;\n }\n\n async findOrgUsersByUser(userId: string): Promise<OrgUser[]> {\n const rows = await this.db\n .select()\n .from(orgUsers)\n .where(eq(orgUsers.userId, userId))\n .orderBy(orgUsers.createdAt);\n return rows.map((row) => ({ ...row, role: parseRole(row.role) }));\n }\n\n async findOrgUserById(orgId: string, id: string): Promise<OrgUser | null> {\n const [row] = await this.db\n .select()\n .from(orgUsers)\n .where(and(eq(orgUsers.orgId, orgId), eq(orgUsers.id, id)))\n .limit(1);\n return row ? { ...row, role: parseRole(row.role) } : null;\n }\n\n async createOrgUser(input: CreateOrgUserInput): Promise<OrgUser> {\n try {\n const [row] = await this.db\n .insert(orgUsers)\n .values({\n orgId: input.orgId,\n userId: input.userId,\n role: input.role,\n status: input.status ?? \"active\",\n })\n .returning();\n if (!row) {\n throw new Error(\"failed to insert org user\");\n }\n return { ...row, role: parseRole(row.role) };\n } catch (err) {\n if (isUniqueViolation(err)) {\n throw new ConflictError(\"That account already belongs to this organization\");\n }\n throw err;\n }\n }\n\n async updateOrgUser(\n orgId: string,\n id: string,\n patch: UpdateOrgUserInput,\n ): Promise<OrgUser | null> {\n const [row] = await this.db\n .update(orgUsers)\n .set({\n ...(patch.role !== undefined && { role: patch.role }),\n ...(patch.status !== undefined && { status: patch.status }),\n })\n .where(and(eq(orgUsers.orgId, orgId), eq(orgUsers.id, id)))\n .returning();\n return row ? { ...row, role: parseRole(row.role) } : null;\n }\n\n async findStudentOrgUsers(userId: string): Promise<OrgUser[]> {\n const rows = await this.db\n .select()\n .from(orgUsers)\n .where(and(eq(orgUsers.userId, userId), eq(orgUsers.role, \"student\")))\n .orderBy(orgUsers.createdAt);\n return rows.map((row) => ({ ...row, role: parseRole(row.role) }));\n }\n\n async cancelPendingInvite(orgId: string, email: string): Promise<Invite | null> {\n const [row] = await this.db\n .update(invites)\n .set({ status: \"canceled\" })\n .where(\n and(\n eq(invites.orgId, orgId),\n sql`lower(${invites.email}) = lower(${email})`,\n eq(invites.status, \"pending\"),\n ),\n )\n .returning();\n return row ? toInvite(row) : null;\n }\n\n async deleteOrgUser(orgId: string, id: string): Promise<OrgUser | null> {\n const [row] = await this.db\n .delete(orgUsers)\n .where(and(eq(orgUsers.orgId, orgId), eq(orgUsers.id, id)))\n .returning();\n\n return row ?? null;\n }\n}\ntranslateDbErrors(DrizzleOrganizationsRepository);\n"],"mappings":"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"}
@@ -1,4 +1,5 @@
1
1
  import { eventOutbox } from "../schema/outbox.js";
2
+ import { translateDbErrors } from "./pg-errors.js";
2
3
  import { and, asc, eq, isNull, lt, lte, sql } from "drizzle-orm";
3
4
  import { noopLogger } from "@headless-lms/core/shared/logger";
4
5
  //#region src/repositories/outbox.ts
@@ -50,6 +51,8 @@ var DrizzleOutboxStore = class {
50
51
  }).where(eq(eventOutbox.id, id));
51
52
  }
52
53
  };
54
+ translateDbErrors(DrizzleOutboxAppender);
55
+ translateDbErrors(DrizzleOutboxStore);
53
56
  //#endregion
54
57
  export { DrizzleOutboxAppender, DrizzleOutboxStore, OUTBOX_MAX_ATTEMPTS };
55
58
 
@@ -1 +1 @@
1
- {"version":3,"file":"outbox.js","names":[],"sources":["../../src/repositories/outbox.ts"],"sourcesContent":["import { and, asc, eq, isNull, lt, lte, sql } from 'drizzle-orm';\nimport type { NodePgDatabase } from 'drizzle-orm/node-postgres';\nimport type {\n DomainEvent,\n NewDomainEvent,\n OutboxAppender,\n OutboxMessage,\n OutboxStore,\n Logger,\n} from '@headless-lms/core/shared/ports';\nimport { noopLogger } from '@headless-lms/core/shared/logger';\nimport type { EventOutbox } from '@headless-lms/core/schemas';\nimport type { DbExecutor } from '../client.js';\nimport { eventOutbox } from '../schema/outbox.js';\n\n/** A message that fails this many dispatches is parked: fetchBatch stops\n * claiming it, leaving the row (with its last_error) for inspection. */\nexport const OUTBOX_MAX_ATTEMPTS = 10;\n\nexport class DrizzleOutboxAppender implements OutboxAppender {\n constructor(\n private readonly tx: DbExecutor,\n private readonly logger: Logger = noopLogger,\n ) {}\n\n async append<E extends NewDomainEvent>(events: E[]): Promise<void> {\n if (events.length === 0) {\n return;\n }\n await this.tx.insert(eventOutbox).values(\n events.map((event) => ({\n type: event.type,\n orgId: event.orgId,\n payload: event as unknown as EventOutbox['payload'],\n })),\n );\n }\n}\n\nexport class DrizzleOutboxStore implements OutboxStore {\n constructor(\n private readonly db: NodePgDatabase,\n private readonly logger: Logger = noopLogger,\n ) {}\n\n async fetchBatch(limit: number): Promise<OutboxMessage[]> {\n const rows = await this.db.transaction((tx) =>\n tx\n .select()\n .from(eventOutbox)\n .where(\n and(\n isNull(eventOutbox.processedAt),\n lte(eventOutbox.nextAttemptAt, new Date()),\n lt(eventOutbox.attempts, OUTBOX_MAX_ATTEMPTS),\n ),\n )\n .orderBy(asc(eventOutbox.id))\n .limit(limit)\n .for('update', { skipLocked: true }),\n );\n return rows.map((row) => ({\n id: row.id,\n attempts: row.attempts,\n payload: {\n ...row.payload,\n id: row.id,\n occurredAt: row.createdAt.toISOString(),\n } as unknown as DomainEvent,\n }));\n }\n\n async markProcessed(id: string): Promise<void> {\n await this.db\n .update(eventOutbox)\n .set({ processedAt: new Date() })\n .where(eq(eventOutbox.id, id));\n }\n\n async markFailed(id: string, error: string, nextAttemptAt: Date): Promise<void> {\n await this.db\n .update(eventOutbox)\n .set({\n attempts: sql`${eventOutbox.attempts} + 1`,\n lastError: error,\n nextAttemptAt,\n })\n .where(eq(eventOutbox.id, id));\n }\n}\n"],"mappings":";;;;;;AAiBA,MAAa,sBAAsB;AAEnC,IAAa,wBAAb,MAA6D;CAExC;CACA;CAFnB,YACE,IACA,SAAkC,YAClC;EAFiB,KAAA,KAAA;EACA,KAAA,SAAA;CAChB;CAEH,MAAM,OAAiC,QAA4B;EACjE,IAAI,OAAO,WAAW,GACpB;EAEF,MAAM,KAAK,GAAG,OAAO,WAAW,CAAC,CAAC,OAChC,OAAO,KAAK,WAAW;GACrB,MAAM,MAAM;GACZ,OAAO,MAAM;GACb,SAAS;EACX,EAAE,CACJ;CACF;AACF;AAEA,IAAa,qBAAb,MAAuD;CAElC;CACA;CAFnB,YACE,IACA,SAAkC,YAClC;EAFiB,KAAA,KAAA;EACA,KAAA,SAAA;CAChB;CAEH,MAAM,WAAW,OAAyC;EAgBxD,QAAO,MAfY,KAAK,GAAG,aAAa,OACtC,GACG,OAAO,CAAC,CACR,KAAK,WAAW,CAAC,CACjB,MACC,IACE,OAAO,YAAY,WAAW,GAC9B,IAAI,YAAY,+BAAe,IAAI,KAAK,CAAC,GACzC,GAAG,YAAY,UAAA,EAA6B,CAC9C,CACF,CAAC,CACA,QAAQ,IAAI,YAAY,EAAE,CAAC,CAAC,CAC5B,MAAM,KAAK,CAAC,CACZ,IAAI,UAAU,EAAE,YAAY,KAAK,CAAC,CACvC,EAAA,CACY,KAAK,SAAS;GACxB,IAAI,IAAI;GACR,UAAU,IAAI;GACd,SAAS;IACP,GAAG,IAAI;IACP,IAAI,IAAI;IACR,YAAY,IAAI,UAAU,YAAY;GACxC;EACF,EAAE;CACJ;CAEA,MAAM,cAAc,IAA2B;EAC7C,MAAM,KAAK,GACR,OAAO,WAAW,CAAC,CACnB,IAAI,EAAE,6BAAa,IAAI,KAAK,EAAE,CAAC,CAAC,CAChC,MAAM,GAAG,YAAY,IAAI,EAAE,CAAC;CACjC;CAEA,MAAM,WAAW,IAAY,OAAe,eAAoC;EAC9E,MAAM,KAAK,GACR,OAAO,WAAW,CAAC,CACnB,IAAI;GACH,UAAU,GAAG,GAAG,YAAY,SAAS;GACrC,WAAW;GACX;EACF,CAAC,CAAC,CACD,MAAM,GAAG,YAAY,IAAI,EAAE,CAAC;CACjC;AACF"}
1
+ {"version":3,"file":"outbox.js","names":[],"sources":["../../src/repositories/outbox.ts"],"sourcesContent":["import { and, asc, eq, isNull, lt, lte, sql } from \"drizzle-orm\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\nimport type {\n DomainEvent,\n NewDomainEvent,\n OutboxAppender,\n OutboxMessage,\n OutboxStore,\n Logger,\n} from \"@headless-lms/core/shared/ports\";\nimport { noopLogger } from \"@headless-lms/core/shared/logger\";\nimport type { EventOutbox } from \"@headless-lms/core/schemas\";\nimport type { DbExecutor } from \"../client.js\";\nimport { eventOutbox } from \"../schema/outbox.js\";\nimport { translateDbErrors } from \"./pg-errors.js\";\n\n/** A message that fails this many dispatches is parked: fetchBatch stops\n * claiming it, leaving the row (with its last_error) for inspection. */\nexport const OUTBOX_MAX_ATTEMPTS = 10;\n\nexport class DrizzleOutboxAppender implements OutboxAppender {\n constructor(\n private readonly tx: DbExecutor,\n private readonly logger: Logger = noopLogger,\n ) {}\n\n async append<E extends NewDomainEvent>(events: E[]): Promise<void> {\n if (events.length === 0) {\n return;\n }\n await this.tx.insert(eventOutbox).values(\n events.map((event) => ({\n type: event.type,\n orgId: event.orgId,\n payload: event as unknown as EventOutbox[\"payload\"],\n })),\n );\n }\n}\n\nexport class DrizzleOutboxStore implements OutboxStore {\n constructor(\n private readonly db: NodePgDatabase,\n private readonly logger: Logger = noopLogger,\n ) {}\n\n async fetchBatch(limit: number): Promise<OutboxMessage[]> {\n const rows = await this.db.transaction((tx) =>\n tx\n .select()\n .from(eventOutbox)\n .where(\n and(\n isNull(eventOutbox.processedAt),\n lte(eventOutbox.nextAttemptAt, new Date()),\n lt(eventOutbox.attempts, OUTBOX_MAX_ATTEMPTS),\n ),\n )\n .orderBy(asc(eventOutbox.id))\n .limit(limit)\n .for(\"update\", { skipLocked: true }),\n );\n return rows.map((row) => ({\n id: row.id,\n attempts: row.attempts,\n payload: {\n ...row.payload,\n id: row.id,\n occurredAt: row.createdAt.toISOString(),\n } as unknown as DomainEvent,\n }));\n }\n\n async markProcessed(id: string): Promise<void> {\n await this.db\n .update(eventOutbox)\n .set({ processedAt: new Date() })\n .where(eq(eventOutbox.id, id));\n }\n\n async markFailed(id: string, error: string, nextAttemptAt: Date): Promise<void> {\n await this.db\n .update(eventOutbox)\n .set({\n attempts: sql`${eventOutbox.attempts} + 1`,\n lastError: error,\n nextAttemptAt,\n })\n .where(eq(eventOutbox.id, id));\n }\n}\ntranslateDbErrors(DrizzleOutboxAppender);\ntranslateDbErrors(DrizzleOutboxStore);\n"],"mappings":";;;;;;;AAkBA,MAAa,sBAAsB;AAEnC,IAAa,wBAAb,MAA6D;CAExC;CACA;CAFnB,YACE,IACA,SAAkC,YAClC;EAFiB,KAAA,KAAA;EACA,KAAA,SAAA;CAChB;CAEH,MAAM,OAAiC,QAA4B;EACjE,IAAI,OAAO,WAAW,GACpB;EAEF,MAAM,KAAK,GAAG,OAAO,WAAW,CAAC,CAAC,OAChC,OAAO,KAAK,WAAW;GACrB,MAAM,MAAM;GACZ,OAAO,MAAM;GACb,SAAS;EACX,EAAE,CACJ;CACF;AACF;AAEA,IAAa,qBAAb,MAAuD;CAElC;CACA;CAFnB,YACE,IACA,SAAkC,YAClC;EAFiB,KAAA,KAAA;EACA,KAAA,SAAA;CAChB;CAEH,MAAM,WAAW,OAAyC;EAgBxD,QAAO,MAfY,KAAK,GAAG,aAAa,OACtC,GACG,OAAO,CAAC,CACR,KAAK,WAAW,CAAC,CACjB,MACC,IACE,OAAO,YAAY,WAAW,GAC9B,IAAI,YAAY,+BAAe,IAAI,KAAK,CAAC,GACzC,GAAG,YAAY,UAAA,EAA6B,CAC9C,CACF,CAAC,CACA,QAAQ,IAAI,YAAY,EAAE,CAAC,CAAC,CAC5B,MAAM,KAAK,CAAC,CACZ,IAAI,UAAU,EAAE,YAAY,KAAK,CAAC,CACvC,EAAA,CACY,KAAK,SAAS;GACxB,IAAI,IAAI;GACR,UAAU,IAAI;GACd,SAAS;IACP,GAAG,IAAI;IACP,IAAI,IAAI;IACR,YAAY,IAAI,UAAU,YAAY;GACxC;EACF,EAAE;CACJ;CAEA,MAAM,cAAc,IAA2B;EAC7C,MAAM,KAAK,GACR,OAAO,WAAW,CAAC,CACnB,IAAI,EAAE,6BAAa,IAAI,KAAK,EAAE,CAAC,CAAC,CAChC,MAAM,GAAG,YAAY,IAAI,EAAE,CAAC;CACjC;CAEA,MAAM,WAAW,IAAY,OAAe,eAAoC;EAC9E,MAAM,KAAK,GACR,OAAO,WAAW,CAAC,CACnB,IAAI;GACH,UAAU,GAAG,GAAG,YAAY,SAAS;GACrC,WAAW;GACX;EACF,CAAC,CAAC,CACD,MAAM,GAAG,YAAY,IAAI,EAAE,CAAC;CACjC;AACF;AACA,kBAAkB,qBAAqB;AACvC,kBAAkB,kBAAkB"}
@@ -1,11 +1,59 @@
1
+ import { ConflictError } from "@headless-lms/core/shared/errors";
1
2
  //#region src/repositories/pg-errors.d.ts
2
3
  /**
3
- * Postgres unique_violation (23505).
4
- *
5
- * The node-postgres driver sometimes wraps the original error (e.g. behind
6
- * `cause`), so both levels are checked.
4
+ * Structured fields off a node-postgres DatabaseError. The driver (and
5
+ * drizzle's DrizzleQueryError) sometimes wraps the original error behind
6
+ * `cause`, so both levels are checked.
7
7
  */
8
+ type PgErrorFields = {
9
+ code: string;
10
+ constraint?: string;
11
+ table?: string;
12
+ column?: string;
13
+ detail?: string;
14
+ };
15
+ declare function pgErrorFields(err: unknown): PgErrorFields | undefined;
16
+ /** Postgres unique_violation (23505). */
8
17
  declare function isUniqueViolation(err: unknown): boolean;
18
+ /**
19
+ * A database failure sanitized for logging: the driver's message, the SQL
20
+ * text, and the structured pg fields — never the bind params, which a
21
+ * DrizzleQueryError embeds in its message and can carry PII.
22
+ */
23
+ declare class DbQueryError extends Error {
24
+ readonly pg?: PgErrorFields;
25
+ readonly query?: string;
26
+ constructor(message: string, opts: {
27
+ pg?: PgErrorFields;
28
+ query?: string;
29
+ cause?: Error;
30
+ });
31
+ }
32
+ /**
33
+ * What the unit of work rethrows: constraint violations become domain
34
+ * ConflictErrors, other query failures become sanitized DbQueryErrors, and
35
+ * anything non-database passes through untouched.
36
+ */
37
+ declare function translateDbError(err: unknown): unknown;
38
+ /**
39
+ * Route every method of a repository class through translateDbError, so no
40
+ * drizzle or pg error ever leaves this package — regardless of whether the
41
+ * call runs inside a unit of work. Applied once per class, right below its
42
+ * declaration.
43
+ */
44
+ declare function translateDbErrors(cls: {
45
+ prototype: object;
46
+ }): void;
47
+ /**
48
+ * Only unique violations (23505) become ConflictErrors: a duplicate is the
49
+ * one constraint failure the caller can act on, so it earns a 409 with a
50
+ * client-facing message — the offending column and the caller's own value,
51
+ * never pg's `detail`. Every other constraint failure (foreign keys
52
+ * included) is a server-side integrity problem: it stays an internal error,
53
+ * sanitized by translateDbError and logged, and the client sees a generic
54
+ * 500. The raw error stays on `cause` for server-side logs.
55
+ */
56
+ declare function translateConstraintViolation(err: unknown): ConflictError | undefined;
9
57
  //#endregion
10
- export { isUniqueViolation };
58
+ export { DbQueryError, PgErrorFields, isUniqueViolation, pgErrorFields, translateConstraintViolation, translateDbError, translateDbErrors };
11
59
  //# sourceMappingURL=pg-errors.d.ts.map
@@ -1,15 +1,104 @@
1
+ import { ConflictError } from "@headless-lms/core/shared/errors";
1
2
  //#region src/repositories/pg-errors.ts
3
+ const PG_CODE = /^[0-9A-Z]{5}$/;
4
+ function pgErrorFields(err) {
5
+ for (const level of [err, err?.cause]) {
6
+ const e = level;
7
+ if (typeof e?.code === "string" && PG_CODE.test(e.code)) return {
8
+ code: e.code,
9
+ ...typeof e.constraint === "string" && { constraint: e.constraint },
10
+ ...typeof e.table === "string" && { table: e.table },
11
+ ...typeof e.column === "string" && { column: e.column },
12
+ ...typeof e.detail === "string" && { detail: e.detail }
13
+ };
14
+ }
15
+ }
16
+ /** Postgres unique_violation (23505). */
17
+ function isUniqueViolation(err) {
18
+ return pgErrorFields(err)?.code === "23505";
19
+ }
2
20
  /**
3
- * Postgres unique_violation (23505).
4
- *
5
- * The node-postgres driver sometimes wraps the original error (e.g. behind
6
- * `cause`), so both levels are checked.
21
+ * A database failure sanitized for logging: the driver's message, the SQL
22
+ * text, and the structured pg fields — never the bind params, which a
23
+ * DrizzleQueryError embeds in its message and can carry PII.
7
24
  */
8
- function isUniqueViolation(err) {
9
- if (err?.code === "23505") return true;
10
- return (err?.cause)?.code === "23505";
25
+ var DbQueryError = class extends Error {
26
+ pg;
27
+ query;
28
+ constructor(message, opts) {
29
+ super(message, opts.cause && { cause: opts.cause });
30
+ this.name = "DbQueryError";
31
+ if (opts.pg) this.pg = opts.pg;
32
+ if (opts.query) this.query = opts.query;
33
+ }
34
+ };
35
+ /**
36
+ * What the unit of work rethrows: constraint violations become domain
37
+ * ConflictErrors, other query failures become sanitized DbQueryErrors, and
38
+ * anything non-database passes through untouched.
39
+ */
40
+ function translateDbError(err) {
41
+ if (err instanceof ConflictError || err instanceof DbQueryError) return err;
42
+ const conflict = translateConstraintViolation(err);
43
+ if (conflict) return conflict;
44
+ const e = err;
45
+ if (typeof e?.query === "string" && Array.isArray(e.params)) {
46
+ const cause = e.cause instanceof Error ? e.cause : void 0;
47
+ const pg = pgErrorFields(err);
48
+ return new DbQueryError(cause?.message ?? "database query failed", {
49
+ ...cause && { cause },
50
+ query: e.query,
51
+ ...pg && { pg }
52
+ });
53
+ }
54
+ return err;
55
+ }
56
+ /**
57
+ * Route every method of a repository class through translateDbError, so no
58
+ * drizzle or pg error ever leaves this package — regardless of whether the
59
+ * call runs inside a unit of work. Applied once per class, right below its
60
+ * declaration.
61
+ */
62
+ function translateDbErrors(cls) {
63
+ const proto = cls.prototype;
64
+ for (const name of Object.getOwnPropertyNames(proto)) {
65
+ if (name === "constructor") continue;
66
+ const desc = Object.getOwnPropertyDescriptor(proto, name);
67
+ if (!desc || typeof desc.value !== "function") continue;
68
+ const original = desc.value;
69
+ proto[name] = function(...args) {
70
+ let result;
71
+ try {
72
+ result = original.apply(this, args);
73
+ } catch (err) {
74
+ throw translateDbError(err);
75
+ }
76
+ if (result instanceof Promise) return result.catch((err) => {
77
+ throw translateDbError(err);
78
+ });
79
+ return result;
80
+ };
81
+ }
82
+ }
83
+ /** `Key (slug)=(orgie) already exists.` → ['slug', 'orgie'] */
84
+ const KEY_DETAIL = /^Key \((.+?)\)=\((.+?)\)/;
85
+ /**
86
+ * Only unique violations (23505) become ConflictErrors: a duplicate is the
87
+ * one constraint failure the caller can act on, so it earns a 409 with a
88
+ * client-facing message — the offending column and the caller's own value,
89
+ * never pg's `detail`. Every other constraint failure (foreign keys
90
+ * included) is a server-side integrity problem: it stays an internal error,
91
+ * sanitized by translateDbError and logged, and the client sees a generic
92
+ * 500. The raw error stays on `cause` for server-side logs.
93
+ */
94
+ function translateConstraintViolation(err) {
95
+ const pg = pgErrorFields(err);
96
+ if (pg?.code !== "23505") return;
97
+ const key = pg.detail?.match(KEY_DETAIL);
98
+ const conflict = new ConflictError(key ? `${key[1]} "${key[2]}" is already in use` : "This value is already in use");
99
+ return Object.assign(conflict, { cause: err });
11
100
  }
12
101
  //#endregion
13
- export { isUniqueViolation };
102
+ export { DbQueryError, isUniqueViolation, pgErrorFields, translateConstraintViolation, translateDbError, translateDbErrors };
14
103
 
15
104
  //# sourceMappingURL=pg-errors.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"pg-errors.js","names":[],"sources":["../../src/repositories/pg-errors.ts"],"sourcesContent":["/**\n * Postgres unique_violation (23505).\n *\n * The node-postgres driver sometimes wraps the original error (e.g. behind\n * `cause`), so both levels are checked.\n */\nexport function isUniqueViolation(err: unknown): boolean {\n const code = (err as { code?: unknown } | undefined)?.code;\n if (code === '23505') {\n return true;\n }\n const cause = (err as { cause?: unknown } | undefined)?.cause;\n const causeCode = (cause as { code?: unknown } | undefined)?.code;\n return causeCode === '23505';\n}\n"],"mappings":";;;;;;;AAMA,SAAgB,kBAAkB,KAAuB;CAEvD,IADc,KAAwC,SACzC,SACX,OAAO;CAIT,QAFe,KAAyC,MAAA,EACK,SACxC;AACvB"}
1
+ {"version":3,"file":"pg-errors.js","names":[],"sources":["../../src/repositories/pg-errors.ts"],"sourcesContent":["import { ConflictError } from \"@headless-lms/core/shared/errors\";\n\n/**\n * Structured fields off a node-postgres DatabaseError. The driver (and\n * drizzle's DrizzleQueryError) sometimes wraps the original error behind\n * `cause`, so both levels are checked.\n */\nexport type PgErrorFields = {\n code: string;\n constraint?: string;\n table?: string;\n column?: string;\n detail?: string;\n};\n\nconst PG_CODE = /^[0-9A-Z]{5}$/;\n\nexport function pgErrorFields(err: unknown): PgErrorFields | undefined {\n for (const level of [err, (err as { cause?: unknown } | undefined)?.cause]) {\n const e = level as Partial<PgErrorFields> | undefined;\n if (typeof e?.code === \"string\" && PG_CODE.test(e.code)) {\n return {\n code: e.code,\n ...(typeof e.constraint === \"string\" && { constraint: e.constraint }),\n ...(typeof e.table === \"string\" && { table: e.table }),\n ...(typeof e.column === \"string\" && { column: e.column }),\n ...(typeof e.detail === \"string\" && { detail: e.detail }),\n };\n }\n }\n return undefined;\n}\n\n/** Postgres unique_violation (23505). */\nexport function isUniqueViolation(err: unknown): boolean {\n return pgErrorFields(err)?.code === \"23505\";\n}\n\n/**\n * A database failure sanitized for logging: the driver's message, the SQL\n * text, and the structured pg fields — never the bind params, which a\n * DrizzleQueryError embeds in its message and can carry PII.\n */\nexport class DbQueryError extends Error {\n readonly pg?: PgErrorFields;\n readonly query?: string;\n\n constructor(message: string, opts: { pg?: PgErrorFields; query?: string; cause?: Error }) {\n super(message, opts.cause && { cause: opts.cause });\n this.name = \"DbQueryError\";\n if (opts.pg) {\n this.pg = opts.pg;\n }\n if (opts.query) {\n this.query = opts.query;\n }\n }\n}\n\n/**\n * What the unit of work rethrows: constraint violations become domain\n * ConflictErrors, other query failures become sanitized DbQueryErrors, and\n * anything non-database passes through untouched.\n */\nexport function translateDbError(err: unknown): unknown {\n if (err instanceof ConflictError || err instanceof DbQueryError) {\n return err;\n }\n const conflict = translateConstraintViolation(err);\n if (conflict) {\n return conflict;\n }\n const e = err as { query?: unknown; params?: unknown; cause?: unknown; message?: unknown };\n if (typeof e?.query === \"string\" && Array.isArray(e.params)) {\n const cause = e.cause instanceof Error ? e.cause : undefined;\n const pg = pgErrorFields(err);\n return new DbQueryError(cause?.message ?? \"database query failed\", {\n ...(cause && { cause }),\n query: e.query,\n ...(pg && { pg }),\n });\n }\n return err;\n}\n\n/**\n * Route every method of a repository class through translateDbError, so no\n * drizzle or pg error ever leaves this package — regardless of whether the\n * call runs inside a unit of work. Applied once per class, right below its\n * declaration.\n */\nexport function translateDbErrors(cls: { prototype: object }): void {\n const proto = cls.prototype as Record<string, unknown>;\n for (const name of Object.getOwnPropertyNames(proto)) {\n if (name === \"constructor\") {\n continue;\n }\n const desc = Object.getOwnPropertyDescriptor(proto, name);\n if (!desc || typeof desc.value !== \"function\") {\n continue;\n }\n const original = desc.value as (...args: unknown[]) => unknown;\n proto[name] = function (this: unknown, ...args: unknown[]) {\n let result: unknown;\n try {\n result = original.apply(this, args);\n } catch (err) {\n throw translateDbError(err);\n }\n if (result instanceof Promise) {\n return result.catch((err: unknown) => {\n throw translateDbError(err);\n });\n }\n return result;\n };\n }\n}\n\n/** `Key (slug)=(orgie) already exists.` → ['slug', 'orgie'] */\nconst KEY_DETAIL = /^Key \\((.+?)\\)=\\((.+?)\\)/;\n\n/**\n * Only unique violations (23505) become ConflictErrors: a duplicate is the\n * one constraint failure the caller can act on, so it earns a 409 with a\n * client-facing message — the offending column and the caller's own value,\n * never pg's `detail`. Every other constraint failure (foreign keys\n * included) is a server-side integrity problem: it stays an internal error,\n * sanitized by translateDbError and logged, and the client sees a generic\n * 500. The raw error stays on `cause` for server-side logs.\n */\nexport function translateConstraintViolation(err: unknown): ConflictError | undefined {\n const pg = pgErrorFields(err);\n if (pg?.code !== \"23505\") {\n return undefined;\n }\n const key = pg.detail?.match(KEY_DETAIL);\n const conflict = new ConflictError(\n key ? `${key[1]} \"${key[2]}\" is already in use` : \"This value is already in use\",\n );\n return Object.assign(conflict, { cause: err });\n}\n"],"mappings":";;AAeA,MAAM,UAAU;AAEhB,SAAgB,cAAc,KAAyC;CACrE,KAAK,MAAM,SAAS,CAAC,KAAM,KAAyC,KAAK,GAAG;EAC1E,MAAM,IAAI;EACV,IAAI,OAAO,GAAG,SAAS,YAAY,QAAQ,KAAK,EAAE,IAAI,GACpD,OAAO;GACL,MAAM,EAAE;GACR,GAAI,OAAO,EAAE,eAAe,YAAY,EAAE,YAAY,EAAE,WAAW;GACnE,GAAI,OAAO,EAAE,UAAU,YAAY,EAAE,OAAO,EAAE,MAAM;GACpD,GAAI,OAAO,EAAE,WAAW,YAAY,EAAE,QAAQ,EAAE,OAAO;GACvD,GAAI,OAAO,EAAE,WAAW,YAAY,EAAE,QAAQ,EAAE,OAAO;EACzD;CAEJ;AAEF;;AAGA,SAAgB,kBAAkB,KAAuB;CACvD,OAAO,cAAc,GAAG,CAAC,EAAE,SAAS;AACtC;;;;;;AAOA,IAAa,eAAb,cAAkC,MAAM;CACtC;CACA;CAEA,YAAY,SAAiB,MAA6D;EACxF,MAAM,SAAS,KAAK,SAAS,EAAE,OAAO,KAAK,MAAM,CAAC;EAClD,KAAK,OAAO;EACZ,IAAI,KAAK,IACP,KAAK,KAAK,KAAK;EAEjB,IAAI,KAAK,OACP,KAAK,QAAQ,KAAK;CAEtB;AACF;;;;;;AAOA,SAAgB,iBAAiB,KAAuB;CACtD,IAAI,eAAe,iBAAiB,eAAe,cACjD,OAAO;CAET,MAAM,WAAW,6BAA6B,GAAG;CACjD,IAAI,UACF,OAAO;CAET,MAAM,IAAI;CACV,IAAI,OAAO,GAAG,UAAU,YAAY,MAAM,QAAQ,EAAE,MAAM,GAAG;EAC3D,MAAM,QAAQ,EAAE,iBAAiB,QAAQ,EAAE,QAAQ,KAAA;EACnD,MAAM,KAAK,cAAc,GAAG;EAC5B,OAAO,IAAI,aAAa,OAAO,WAAW,yBAAyB;GACjE,GAAI,SAAS,EAAE,MAAM;GACrB,OAAO,EAAE;GACT,GAAI,MAAM,EAAE,GAAG;EACjB,CAAC;CACH;CACA,OAAO;AACT;;;;;;;AAQA,SAAgB,kBAAkB,KAAkC;CAClE,MAAM,QAAQ,IAAI;CAClB,KAAK,MAAM,QAAQ,OAAO,oBAAoB,KAAK,GAAG;EACpD,IAAI,SAAS,eACX;EAEF,MAAM,OAAO,OAAO,yBAAyB,OAAO,IAAI;EACxD,IAAI,CAAC,QAAQ,OAAO,KAAK,UAAU,YACjC;EAEF,MAAM,WAAW,KAAK;EACtB,MAAM,QAAQ,SAAyB,GAAG,MAAiB;GACzD,IAAI;GACJ,IAAI;IACF,SAAS,SAAS,MAAM,MAAM,IAAI;GACpC,SAAS,KAAK;IACZ,MAAM,iBAAiB,GAAG;GAC5B;GACA,IAAI,kBAAkB,SACpB,OAAO,OAAO,OAAO,QAAiB;IACpC,MAAM,iBAAiB,GAAG;GAC5B,CAAC;GAEH,OAAO;EACT;CACF;AACF;;AAGA,MAAM,aAAa;;;;;;;;;;AAWnB,SAAgB,6BAA6B,KAAyC;CACpF,MAAM,KAAK,cAAc,GAAG;CAC5B,IAAI,IAAI,SAAS,SACf;CAEF,MAAM,MAAM,GAAG,QAAQ,MAAM,UAAU;CACvC,MAAM,WAAW,IAAI,cACnB,MAAM,GAAG,IAAI,GAAG,IAAI,IAAI,GAAG,uBAAuB,8BACpD;CACA,OAAO,OAAO,OAAO,UAAU,EAAE,OAAO,IAAI,CAAC;AAC/C"}
@@ -11,7 +11,7 @@ declare class DrizzleProgressRepository implements ProgressRepository {
11
11
  findByTargets(orgId: string, orgUserId: string, targetIds: string[], opts?: {
12
12
  forUpdate?: boolean;
13
13
  }): Promise<ProgressRecord[]>;
14
- update(orgId: string, id: string, patch: Partial<Pick<ProgressRecord, 'position' | 'completedAt'>>): Promise<ProgressRecord | null>;
14
+ update(orgId: string, id: string, patch: Partial<Pick<ProgressRecord, "position" | "completedAt">>): Promise<ProgressRecord | null>;
15
15
  }
16
16
  //#endregion
17
17
  export { DrizzleProgressRepository };