@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.
- package/README.md +79 -0
- package/dist/client.d.ts +1 -1
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +3 -2
- package/dist/migrate.js.map +1 -1
- package/dist/repositories/assets.d.ts +1 -1
- package/dist/repositories/assets.js +4 -1
- package/dist/repositories/assets.js.map +1 -1
- package/dist/repositories/automations.d.ts +7 -5
- package/dist/repositories/automations.js +35 -10
- package/dist/repositories/automations.js.map +1 -1
- package/dist/repositories/content.d.ts +21 -7
- package/dist/repositories/content.js +168 -18
- package/dist/repositories/content.js.map +1 -1
- package/dist/repositories/course-analytics.d.ts +15 -0
- package/dist/repositories/course-analytics.js +137 -0
- package/dist/repositories/course-analytics.js.map +1 -0
- package/dist/repositories/credentials.js +2 -0
- package/dist/repositories/credentials.js.map +1 -1
- package/dist/repositories/dashboard.js +2 -0
- package/dist/repositories/dashboard.js.map +1 -1
- package/dist/repositories/discussion.d.ts +1 -1
- package/dist/repositories/discussion.js +2 -0
- package/dist/repositories/discussion.js.map +1 -1
- package/dist/repositories/entitlements.d.ts +11 -5
- package/dist/repositories/entitlements.js +42 -17
- package/dist/repositories/entitlements.js.map +1 -1
- package/dist/repositories/identity.js +2 -0
- package/dist/repositories/identity.js.map +1 -1
- package/dist/repositories/integrations.d.ts +1 -1
- package/dist/repositories/integrations.js +2 -0
- package/dist/repositories/integrations.js.map +1 -1
- package/dist/repositories/learn.js +6 -4
- package/dist/repositories/learn.js.map +1 -1
- package/dist/repositories/members.js +2 -0
- package/dist/repositories/members.js.map +1 -1
- package/dist/repositories/org-user-profile.js.map +1 -1
- package/dist/repositories/organizations.d.ts +4 -6
- package/dist/repositories/organizations.js +26 -45
- package/dist/repositories/organizations.js.map +1 -1
- package/dist/repositories/outbox.js +3 -0
- package/dist/repositories/outbox.js.map +1 -1
- package/dist/repositories/pg-errors.d.ts +53 -5
- package/dist/repositories/pg-errors.js +97 -8
- package/dist/repositories/pg-errors.js.map +1 -1
- package/dist/repositories/progress.d.ts +1 -1
- package/dist/repositories/progress.js +3 -0
- package/dist/repositories/progress.js.map +1 -1
- package/dist/repositories/settings.js +2 -0
- package/dist/repositories/settings.js.map +1 -1
- package/dist/repositories/students.d.ts +2 -1
- package/dist/repositories/students.js +80 -3
- package/dist/repositories/students.js.map +1 -1
- package/dist/schema/assets.d.ts +17 -0
- package/dist/schema/assets.js +2 -1
- package/dist/schema/assets.js.map +1 -1
- package/dist/schema/automations.d.ts +85 -8
- package/dist/schema/automations.js +7 -4
- package/dist/schema/automations.js.map +1 -1
- package/dist/schema/better-auth.js.map +1 -1
- package/dist/schema/content.d.ts +272 -22
- package/dist/schema/content.js +40 -5
- package/dist/schema/content.js.map +1 -1
- package/dist/schema/credentials.js +1 -1
- package/dist/schema/credentials.js.map +1 -1
- package/dist/schema/discussion.d.ts +34 -0
- package/dist/schema/discussion.js +4 -2
- package/dist/schema/discussion.js.map +1 -1
- package/dist/schema/entitlements.d.ts +52 -1
- package/dist/schema/entitlements.js +15 -5
- package/dist/schema/entitlements.js.map +1 -1
- package/dist/schema/identity.js.map +1 -1
- package/dist/schema/index.d.ts +3 -3
- package/dist/schema/index.js +4 -2
- package/dist/schema/index.js.map +1 -1
- package/dist/schema/integrations.js +1 -1
- package/dist/schema/integrations.js.map +1 -1
- package/dist/schema/organizations.d.ts +17 -0
- package/dist/schema/organizations.js +2 -1
- package/dist/schema/organizations.js.map +1 -1
- package/dist/schema/outbox.d.ts +17 -0
- package/dist/schema/outbox.js +1 -0
- package/dist/schema/outbox.js.map +1 -1
- package/dist/schema/progress.d.ts +17 -0
- package/dist/schema/progress.js +1 -0
- package/dist/schema/progress.js.map +1 -1
- package/dist/schema/settings.js.map +1 -1
- package/dist/unit-of-work.js +7 -2
- package/dist/unit-of-work.js.map +1 -1
- package/drizzle/0000_baseline.sql +0 -1
- package/drizzle/0001_new_sentry.sql +43 -0
- package/drizzle/0002_automation_kinds.sql +7 -0
- package/drizzle/meta/0000_snapshot.json +167 -597
- package/drizzle/meta/0001_snapshot.json +3769 -0
- package/drizzle/meta/0002_snapshot.json +3770 -0
- package/drizzle/meta/_journal.json +16 -2
- package/package.json +3 -2
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"course-analytics.js","names":[],"sources":["../../src/repositories/course-analytics.ts"],"sourcesContent":["// reporting/courses — Drizzle repository (implements the core outbound port).\n// Per-course engagement figures. The cohort is every org user holding an\n// effective-active entitlement to the course (status='active' and not expired);\n// completions by revoked/expired students are deliberately excluded so the\n// numbers describe the current roster. An activity counts toward the\n// denominator when its settings blob does not say published=false (missing ⇒\n// published), mirroring reporting/learn.\nimport { and, eq, sql } from \"drizzle-orm\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\nimport type {\n CourseAnalytics,\n CourseActivityEngagement,\n CourseEnrollmentPoint,\n CoursesReportRepository,\n} from \"@headless-lms/core/reporting/courses\";\nimport { courses, entitlements } 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 DrizzleCourseAnalyticsRepository implements CoursesReportRepository {\n constructor(\n private readonly db: NodePgDatabase,\n private readonly logger: Logger = noopLogger,\n ) {}\n\n private async courseExists(orgId: string, courseId: string): Promise<boolean> {\n const [row] = await this.db\n .select({ id: courses.id })\n .from(courses)\n .where(and(eq(courses.orgId, orgId), eq(courses.id, courseId)));\n return row !== undefined;\n }\n\n async analytics(orgId: string, courseId: string): Promise<CourseAnalytics | null> {\n if (!(await this.courseExists(orgId, courseId))) {\n return null;\n }\n\n const cohort = sql`\n select org_user_id from entitlements\n where org_id = ${orgId}\n and content_id = ${courseId}\n and status = 'active'\n and (expires_at is null or expires_at >= now())\n `;\n const publishedActivities = sql`\n select a.id, a.settings, a.seq, a.module_id\n from activities a\n where a.org_id = ${orgId}\n and a.course_id = ${courseId}\n and coalesce((a.settings ->> 'published')::boolean, true)\n `;\n\n const summaryResult = await this.db.execute(sql`\n with cohort as (${cohort}),\n acts as (${publishedActivities}),\n per_learner as (\n select c.org_user_id,\n count(pr.id) filter (where pr.completed_at is not null) as done\n from cohort c\n left join progress_records pr\n on pr.org_id = ${orgId}\n and pr.org_user_id = c.org_user_id\n and pr.target_type = 'activity'\n and pr.target_id in (select id from acts)\n group by c.org_user_id\n )\n select\n (select count(*) from cohort)::int as enrolled,\n (\n select count(distinct pr.org_user_id) from progress_records pr\n where pr.org_id = ${orgId}\n and pr.org_user_id in (select org_user_id from cohort)\n and (\n (pr.target_type = 'activity' and pr.target_id in (select id from acts))\n or (pr.target_type = 'course' and pr.target_id = ${courseId})\n )\n )::int as started,\n (\n select count(*) from progress_records pr\n where pr.org_id = ${orgId}\n and pr.org_user_id in (select org_user_id from cohort)\n and pr.target_type = 'course'\n and pr.target_id = ${courseId}\n and pr.completed_at is not null\n )::int as completed,\n coalesce((\n select round(avg(least(done::numeric / nullif((select count(*) from acts), 0), 1) * 100))\n from per_learner\n ), 0)::int as avg_progress\n `);\n const summary = summaryResult.rows[0] as {\n enrolled: number;\n started: number;\n completed: number;\n avg_progress: number;\n };\n\n const funnelResult = await this.db.execute(sql`\n with cohort as (${cohort})\n select\n a.id,\n coalesce(a.settings ->> 'title', '') as title,\n m.id as module_id,\n m.title as module_title,\n count(pr.id)::int as started,\n count(pr.id) filter (where pr.completed_at is not null)::int as completed\n from activities a\n join modules m on m.org_id = a.org_id and m.id = a.module_id\n left join progress_records pr\n on pr.org_id = ${orgId}\n and pr.target_type = 'activity'\n and pr.target_id = a.id\n and pr.org_user_id in (select org_user_id from cohort)\n where a.org_id = ${orgId}\n and a.course_id = ${courseId}\n and coalesce((a.settings ->> 'published')::boolean, true)\n group by a.id, a.settings, m.id, m.title, m.seq, a.seq\n order by m.seq, a.seq\n `);\n const activities: CourseActivityEngagement[] = (\n funnelResult.rows as {\n id: string;\n title: string;\n module_id: string;\n module_title: string;\n started: number;\n completed: number;\n }[]\n ).map((r) => ({\n activityId: r.id,\n title: r.title,\n moduleId: r.module_id,\n moduleTitle: r.module_title,\n started: Number(r.started),\n completed: Number(r.completed),\n }));\n\n return {\n enrolled: Number(summary?.enrolled ?? 0),\n started: Number(summary?.started ?? 0),\n completed: Number(summary?.completed ?? 0),\n avgProgress: Number(summary?.avg_progress ?? 0),\n activities,\n };\n }\n\n // Grants per day for this course over the trailing window, zero-filled so the\n // series has one point per calendar day (UTC) even when nothing was granted.\n async enrollments(\n orgId: string,\n courseId: string,\n days: number,\n ): Promise<CourseEnrollmentPoint[] | null> {\n if (!(await this.courseExists(orgId, courseId))) {\n return null;\n }\n const result = await this.db.execute(sql`\n select\n to_char(d.day, 'YYYY-MM-DD') as date,\n count(${entitlements.id})::int as count\n from generate_series(\n (now() at time zone 'utc')::date - (${days} - 1),\n (now() at time zone 'utc')::date,\n interval '1 day'\n ) as d(day)\n left join ${entitlements}\n on ${entitlements.orgId} = ${orgId}\n and ${entitlements.contentId} = ${courseId}\n and (${entitlements.grantedAt} at time zone 'utc')::date = d.day::date\n group by d.day\n order by d.day\n `);\n const rows = result.rows as { date: string; count: number }[];\n return rows.map((r) => ({ date: r.date, count: Number(r.count) }));\n }\n}\ntranslateDbErrors(DrizzleCourseAnalyticsRepository);\n"],"mappings":";;;;;;;AAoBA,IAAa,mCAAb,MAAiF;CAE5D;CACA;CAFnB,YACE,IACA,SAAkC,YAClC;EAFiB,KAAA,KAAA;EACA,KAAA,SAAA;CAChB;CAEH,MAAc,aAAa,OAAe,UAAoC;EAC5E,MAAM,CAAC,OAAO,MAAM,KAAK,GACtB,OAAO,EAAE,IAAI,QAAQ,GAAG,CAAC,CAAC,CAC1B,KAAK,OAAO,CAAC,CACb,MAAM,IAAI,GAAG,QAAQ,OAAO,KAAK,GAAG,GAAG,QAAQ,IAAI,QAAQ,CAAC,CAAC;EAChE,OAAO,QAAQ,KAAA;CACjB;CAEA,MAAM,UAAU,OAAe,UAAmD;EAChF,IAAI,CAAE,MAAM,KAAK,aAAa,OAAO,QAAQ,GAC3C,OAAO;EAGT,MAAM,SAAS,GAAG;;uBAEC,MAAM;2BACF,SAAS;;;;EAIhC,MAAM,sBAAsB,GAAG;;;yBAGV,MAAM;4BACH,SAAS;;;EA0CjC,MAAM,WAAU,MAtCY,KAAK,GAAG,QAAQ,GAAG;wBAC3B,OAAO;iBACd,oBAAoB;;;;;;2BAMV,MAAM;;;;;;;;;;8BAUH,MAAM;;;;iEAI6B,SAAS;;;;;8BAK5C,MAAM;;;iCAGH,SAAS;;;;;;;KAOrC,EAAA,CAC6B,KAAK;EA6BnC,MAAM,cACJ,MAvByB,KAAK,GAAG,QAAQ,GAAG;wBAC1B,OAAO;;;;;;;;;;;yBAWN,MAAM;;;;yBAIN,MAAM;4BACH,SAAS;;;;KAIhC,EAAA,CAEc,KAQb,KAAK,OAAO;GACZ,YAAY,EAAE;GACd,OAAO,EAAE;GACT,UAAU,EAAE;GACZ,aAAa,EAAE;GACf,SAAS,OAAO,EAAE,OAAO;GACzB,WAAW,OAAO,EAAE,SAAS;EAC/B,EAAE;EAEF,OAAO;GACL,UAAU,OAAO,SAAS,YAAY,CAAC;GACvC,SAAS,OAAO,SAAS,WAAW,CAAC;GACrC,WAAW,OAAO,SAAS,aAAa,CAAC;GACzC,aAAa,OAAO,SAAS,gBAAgB,CAAC;GAC9C;EACF;CACF;CAIA,MAAM,YACJ,OACA,UACA,MACyC;EACzC,IAAI,CAAE,MAAM,KAAK,aAAa,OAAO,QAAQ,GAC3C,OAAO;EAmBT,QADa,MAhBQ,KAAK,GAAG,QAAQ,GAAG;;;gBAG5B,aAAa,GAAG;;8CAEc,KAAK;;;;kBAIjC,aAAa;aAClB,aAAa,MAAM,KAAK,MAAM;cAC7B,aAAa,UAAU,KAAK,SAAS;eACpC,aAAa,UAAU;;;KAGjC,EAAA,CACmB,KACR,KAAK,OAAO;GAAE,MAAM,EAAE;GAAM,OAAO,OAAO,EAAE,KAAK;EAAE,EAAE;CACnE;AACF;AACA,kBAAkB,gCAAgC"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { credentials } from "../schema/credentials.js";
|
|
2
|
+
import { translateDbErrors } from "./pg-errors.js";
|
|
2
3
|
import { and, eq } from "drizzle-orm";
|
|
3
4
|
import { genId } from "@headless-lms/core/shared/id";
|
|
4
5
|
import { noopLogger } from "@headless-lms/core/shared/logger";
|
|
@@ -80,6 +81,7 @@ var DrizzleCredentialStore = class {
|
|
|
80
81
|
await this.db.delete(credentials).where(and(eq(credentials.orgId, orgId), eq(credentials.id, ref)));
|
|
81
82
|
}
|
|
82
83
|
};
|
|
84
|
+
translateDbErrors(DrizzleCredentialStore);
|
|
83
85
|
//#endregion
|
|
84
86
|
export { DrizzleCredentialStore, decryptSecret, encryptSecret };
|
|
85
87
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"credentials.js","names":[],"sources":["../../src/repositories/credentials.ts"],"sourcesContent":["// credentials — Drizzle-backed secure credential store (implements the shared\n// core port). Secrets are JSON documents: serialized here, encrypted with\n// AES-256-GCM via node:crypto (single 32-byte key from the environment), and\n// parsed back to objects on reveal — callers never (de)serialize. The AAD\n// binds each ciphertext to its (org, id) row, so moving a ciphertext to\n// another row or org makes it undecryptable. Plaintext exists only inside\n// store/reveal — it is never logged, listed, or returned elsewhere.\nimport { createCipheriv, createDecipheriv, randomBytes } from
|
|
1
|
+
{"version":3,"file":"credentials.js","names":[],"sources":["../../src/repositories/credentials.ts"],"sourcesContent":["// credentials — Drizzle-backed secure credential store (implements the shared\n// core port). Secrets are JSON documents: serialized here, encrypted with\n// AES-256-GCM via node:crypto (single 32-byte key from the environment), and\n// parsed back to objects on reveal — callers never (de)serialize. The AAD\n// binds each ciphertext to its (org, id) row, so moving a ciphertext to\n// another row or org makes it undecryptable. Plaintext exists only inside\n// store/reveal — it is never logged, listed, or returned elsewhere.\nimport { createCipheriv, createDecipheriv, randomBytes } from \"node:crypto\";\nimport { and, eq } from \"drizzle-orm\";\nimport type { DbExecutor } from \"../client.js\";\nimport type { CredentialStore, Logger } from \"@headless-lms/core/shared/ports\";\nimport { noopLogger } from \"@headless-lms/core/shared/logger\";\nimport { genId } from \"@headless-lms/core/shared/id\";\nimport { credentials } from \"../schema/credentials.js\";\nimport { translateDbErrors } from \"./pg-errors.js\";\n\nconst IV_BYTES = 12;\nconst TAG_BYTES = 16;\n/** Version of the key currently used for new writes (stored per row for rotation). */\nconst KEY_VERSION = 1;\n\nfunction aad(orgId: string, id: string): Buffer {\n return Buffer.from(`${orgId}:${id}`, \"utf8\");\n}\n\n/** base64(iv ‖ auth tag ‖ ciphertext) */\nexport function encryptSecret(key: Buffer, plaintext: string, aadValue: Buffer): string {\n const iv = randomBytes(IV_BYTES);\n const cipher = createCipheriv(\"aes-256-gcm\", key, iv);\n cipher.setAAD(aadValue);\n const data = Buffer.concat([cipher.update(plaintext, \"utf8\"), cipher.final()]);\n return Buffer.concat([iv, cipher.getAuthTag(), data]).toString(\"base64\");\n}\n\n/** Throws if the payload was tampered with or the AAD/key doesn't match. */\nexport function decryptSecret(key: Buffer, payload: string, aadValue: Buffer): string {\n const buf = Buffer.from(payload, \"base64\");\n const iv = buf.subarray(0, IV_BYTES);\n const tag = buf.subarray(IV_BYTES, IV_BYTES + TAG_BYTES);\n const data = buf.subarray(IV_BYTES + TAG_BYTES);\n const decipher = createDecipheriv(\"aes-256-gcm\", key, iv);\n decipher.setAAD(aadValue);\n decipher.setAuthTag(tag);\n return Buffer.concat([decipher.update(data), decipher.final()]).toString(\"utf8\");\n}\n\nexport class DrizzleCredentialStore implements CredentialStore {\n private key: Buffer | undefined;\n\n constructor(\n private readonly db: DbExecutor,\n /** base64-encoded 32-byte key (CREDENTIAL_STORE_KEY). Validated on first use, not at boot. */\n private readonly keyBase64: string,\n private readonly logger: Logger = noopLogger,\n ) {}\n\n private getKey(): Buffer {\n if (!this.key) {\n const key = Buffer.from(this.keyBase64, \"base64\");\n if (key.length !== 32) {\n throw new Error(\n \"CREDENTIAL_STORE_KEY must be a base64-encoded 32-byte key (generate with: openssl rand -base64 32)\",\n );\n }\n this.key = key;\n }\n return this.key;\n }\n\n async store(orgId: string, secrets: Record<string, unknown>): Promise<string> {\n const id = genId(\"credential\");\n const ciphertext = encryptSecret(this.getKey(), JSON.stringify(secrets), aad(orgId, id));\n await this.db.insert(credentials).values({ orgId, id, ciphertext, keyVersion: KEY_VERSION });\n return id;\n }\n\n async reveal(orgId: string, ref: string): Promise<Record<string, unknown> | null> {\n const [row] = await this.db\n .select()\n .from(credentials)\n .where(and(eq(credentials.orgId, orgId), eq(credentials.id, ref)))\n .limit(1);\n if (!row) {\n return null;\n }\n const plaintext = decryptSecret(this.getKey(), row.ciphertext, aad(orgId, ref));\n return JSON.parse(plaintext) as Record<string, unknown>;\n }\n\n async update(orgId: string, ref: string, secrets: Record<string, unknown>): Promise<void> {\n const ciphertext = encryptSecret(this.getKey(), JSON.stringify(secrets), aad(orgId, ref));\n await this.db\n .update(credentials)\n .set({ ciphertext, keyVersion: KEY_VERSION, updatedAt: new Date() })\n .where(and(eq(credentials.orgId, orgId), eq(credentials.id, ref)));\n }\n\n async destroy(orgId: string, ref: string): Promise<void> {\n await this.db\n .delete(credentials)\n .where(and(eq(credentials.orgId, orgId), eq(credentials.id, ref)));\n }\n}\ntranslateDbErrors(DrizzleCredentialStore);\n"],"mappings":"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"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { courses } 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, gte, isNull, or, sql } from "drizzle-orm";
|
|
5
6
|
import { noopLogger } from "@headless-lms/core/shared/logger";
|
|
6
7
|
//#region src/repositories/dashboard.ts
|
|
@@ -49,6 +50,7 @@ var DrizzleDashboardRepository = class {
|
|
|
49
50
|
}));
|
|
50
51
|
}
|
|
51
52
|
};
|
|
53
|
+
translateDbErrors(DrizzleDashboardRepository);
|
|
52
54
|
//#endregion
|
|
53
55
|
export { DrizzleDashboardRepository };
|
|
54
56
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dashboard.js","names":[],"sources":["../../src/repositories/dashboard.ts"],"sourcesContent":["// dashboard — Drizzle repository (implements the core outbound port).\n// Back-office overview counts, every figure scoped to the active org. An\n// entitlement is \"effective-active\" when status='active' and it has not expired\n// (expires_at null or in the future) — expiry is derived at read time.\nimport { and, eq, gte, isNull, or, sql } from
|
|
1
|
+
{"version":3,"file":"dashboard.js","names":[],"sources":["../../src/repositories/dashboard.ts"],"sourcesContent":["// dashboard — Drizzle repository (implements the core outbound port).\n// Back-office overview counts, every figure scoped to the active org. An\n// entitlement is \"effective-active\" when status='active' and it has not expired\n// (expires_at null or in the future) — expiry is derived at read time.\nimport { and, eq, gte, isNull, or, sql } from \"drizzle-orm\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\nimport type {\n DashboardReportRepository,\n EnrollmentPoint,\n OverviewStats,\n} from \"@headless-lms/core/reporting/dashboard\";\nimport { courses, entitlements } 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 DrizzleDashboardRepository implements DashboardReportRepository {\n constructor(\n private readonly db: NodePgDatabase,\n private readonly logger: Logger = noopLogger,\n ) {}\n\n async overview(orgId: string): Promise<OverviewStats> {\n const [courseCounts] = await this.db\n .select({\n published: sql<number>`count(*) filter (where ${courses.status} = 'published')`,\n draft: sql<number>`count(*) filter (where ${courses.status} = 'draft')`,\n })\n .from(courses)\n .where(eq(courses.orgId, orgId));\n\n const effectiveActive = and(\n eq(entitlements.orgId, orgId),\n eq(entitlements.status, \"active\"),\n or(isNull(entitlements.expiresAt), gte(entitlements.expiresAt, sql`now()`)),\n );\n\n const [entitlementCounts] = await this.db\n .select({\n activeStudents: sql<number>`count(distinct ${entitlements.orgUserId})`,\n expiringSoon: sql<number>`count(*) filter (where ${entitlements.expiresAt} is not null and ${entitlements.expiresAt} < now() + interval '14 days')`,\n })\n .from(entitlements)\n .where(effectiveActive);\n\n return {\n publishedCourses: Number(courseCounts?.published ?? 0),\n draftCourses: Number(courseCounts?.draft ?? 0),\n activeStudents: Number(entitlementCounts?.activeStudents ?? 0),\n expiringSoon: Number(entitlementCounts?.expiringSoon ?? 0),\n };\n }\n\n // Grants per day over the trailing window, zero-filled so the series has one\n // point per calendar day (UTC) even when nothing was granted.\n async enrollments(orgId: string, days: number): Promise<EnrollmentPoint[]> {\n const result = await this.db.execute(sql`\n select\n to_char(d.day, 'YYYY-MM-DD') as date,\n count(${entitlements.id})::int as count\n from generate_series(\n (now() at time zone 'utc')::date - (${days} - 1),\n (now() at time zone 'utc')::date,\n interval '1 day'\n ) as d(day)\n left join ${entitlements}\n on ${entitlements.orgId} = ${orgId}\n and (${entitlements.grantedAt} at time zone 'utc')::date = d.day::date\n group by d.day\n order by d.day\n `);\n const rows = result.rows as { date: string; count: number }[];\n return rows.map((r) => ({ date: r.date, count: Number(r.count) }));\n }\n}\ntranslateDbErrors(DrizzleDashboardRepository);\n"],"mappings":";;;;;;;AAgBA,IAAa,6BAAb,MAA6E;CAExD;CACA;CAFnB,YACE,IACA,SAAkC,YAClC;EAFiB,KAAA,KAAA;EACA,KAAA,SAAA;CAChB;CAEH,MAAM,SAAS,OAAuC;EACpD,MAAM,CAAC,gBAAgB,MAAM,KAAK,GAC/B,OAAO;GACN,WAAW,GAAW,0BAA0B,QAAQ,OAAO;GAC/D,OAAO,GAAW,0BAA0B,QAAQ,OAAO;EAC7D,CAAC,CAAC,CACD,KAAK,OAAO,CAAC,CACb,MAAM,GAAG,QAAQ,OAAO,KAAK,CAAC;EAEjC,MAAM,kBAAkB,IACtB,GAAG,aAAa,OAAO,KAAK,GAC5B,GAAG,aAAa,QAAQ,QAAQ,GAChC,GAAG,OAAO,aAAa,SAAS,GAAG,IAAI,aAAa,WAAW,GAAG,OAAO,CAAC,CAC5E;EAEA,MAAM,CAAC,qBAAqB,MAAM,KAAK,GACpC,OAAO;GACN,gBAAgB,GAAW,kBAAkB,aAAa,UAAU;GACpE,cAAc,GAAW,0BAA0B,aAAa,UAAU,mBAAmB,aAAa,UAAU;EACtH,CAAC,CAAC,CACD,KAAK,YAAY,CAAC,CAClB,MAAM,eAAe;EAExB,OAAO;GACL,kBAAkB,OAAO,cAAc,aAAa,CAAC;GACrD,cAAc,OAAO,cAAc,SAAS,CAAC;GAC7C,gBAAgB,OAAO,mBAAmB,kBAAkB,CAAC;GAC7D,cAAc,OAAO,mBAAmB,gBAAgB,CAAC;EAC3D;CACF;CAIA,MAAM,YAAY,OAAe,MAA0C;EAiBzE,QADa,MAfQ,KAAK,GAAG,QAAQ,GAAG;;;gBAG5B,aAAa,GAAG;;8CAEc,KAAK;;;;kBAIjC,aAAa;aAClB,aAAa,MAAM,KAAK,MAAM;eAC5B,aAAa,UAAU;;;KAGjC,EAAA,CACmB,KACR,KAAK,OAAO;GAAE,MAAM,EAAE;GAAM,OAAO,OAAO,EAAE,KAAK;EAAE,EAAE;CACnE;AACF;AACA,kBAAkB,0BAA0B"}
|
|
@@ -8,7 +8,7 @@ declare class DrizzleDiscussionRepository implements DiscussionRepository {
|
|
|
8
8
|
constructor(db: NodePgDatabase, logger?: Logger);
|
|
9
9
|
insertComment(orgId: string, comment: Comment): Promise<Comment>;
|
|
10
10
|
findComment(orgId: string, id: string): Promise<Comment | null>;
|
|
11
|
-
updateComment(orgId: string, id: string, patch: Partial<Pick<Comment,
|
|
11
|
+
updateComment(orgId: string, id: string, patch: Partial<Pick<Comment, "body" | "status" | "removedBy" | "updatedAt">>): Promise<Comment | null>;
|
|
12
12
|
listByActivity(orgId: string, activityId: string): Promise<Comment[]>;
|
|
13
13
|
reactionsOf(orgId: string, commentIds: string[], viewerId: string): Promise<Record<string, CommentReactions>>;
|
|
14
14
|
setReaction(orgId: string, commentId: string, orgUserId: string, emoji: ReactionEmoji | null): Promise<void>;
|
|
@@ -3,6 +3,7 @@ import { orgUsers } from "../schema/organizations.js";
|
|
|
3
3
|
import { activities } from "../schema/content.js";
|
|
4
4
|
import { commentReactions, commentReports, comments } from "../schema/discussion.js";
|
|
5
5
|
import "../schema/index.js";
|
|
6
|
+
import { translateDbErrors } from "./pg-errors.js";
|
|
6
7
|
import { user } from "../schema/better-auth.js";
|
|
7
8
|
import { orgUserProfileColumns } from "./org-user-profile.js";
|
|
8
9
|
import { and, asc, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm";
|
|
@@ -195,6 +196,7 @@ var DrizzleDiscussionRepository = class {
|
|
|
195
196
|
};
|
|
196
197
|
}
|
|
197
198
|
};
|
|
199
|
+
translateDbErrors(DrizzleDiscussionRepository);
|
|
198
200
|
//#endregion
|
|
199
201
|
export { DrizzleDiscussionRepository };
|
|
200
202
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"discussion.js","names":[],"sources":["../../src/repositories/discussion.ts"],"sourcesContent":["import { and, asc, desc, eq, ilike, inArray, isNull, sql, type SQL } from 'drizzle-orm';\nimport type { NodePgDatabase } from 'drizzle-orm/node-postgres';\nimport type {\n AuthorRecord,\n CommentReactions,\n CommentWithContext,\n DiscussionRepository,\n} from '@headless-lms/core/discussion';\nimport type {\n Comment,\n CommentReport,\n ListCommentsQuery,\n Logger,\n Page,\n ReactionEmoji,\n Role,\n} from '@headless-lms/core/types';\nimport { commentReactions, commentReports, comments } from '../schema/index.js';\nimport { activities } from '../schema/content.js';\nimport { orgUsers } from '../schema/organizations.js';\nimport { users } from '../schema/identity.js';\nimport { user } from '../schema/better-auth.js';\nimport { orgUserProfileColumns } from './org-user-profile.js';\nimport { noopLogger } from '@headless-lms/core/shared/logger';\n\ntype CommentRow = typeof comments.$inferSelect;\n\n/** The title lives in the activity's opaque settings blob; the moderation card\n * needs it to say \"Lesson 3\" rather than an id. */\nconst activityTitleExpr = sql<string>`coalesce(${activities.settings} ->> 'title', '')`;\n\n/** Sortable columns of the staff comment list, by the client-facing field name.\n * `activityTitle` sorts on the same expression the row is served from. */\nconst sortColumns = {\n createdAt: comments.createdAt,\n updatedAt: comments.updatedAt,\n status: comments.status,\n activityTitle: activityTitleExpr,\n} as const;\n\nfunction toComment(row: CommentRow): Comment {\n return {\n id: row.id,\n orgId: row.orgId,\n activityId: row.activityId,\n parentId: row.parentId ?? null,\n orgUserId: row.orgUserId,\n body: row.body,\n status: row.status,\n removedBy: row.removedBy ?? null,\n createdAt: row.createdAt,\n updatedAt: row.updatedAt,\n };\n}\n\nexport class DrizzleDiscussionRepository implements DiscussionRepository {\n constructor(\n private readonly db: NodePgDatabase,\n private readonly logger: Logger = noopLogger,\n ) {}\n\n async insertComment(orgId: string, comment: Comment): Promise<Comment> {\n const [row] = await this.db\n .insert(comments)\n .values({\n id: comment.id,\n orgId,\n activityId: comment.activityId,\n parentId: comment.parentId,\n orgUserId: comment.orgUserId,\n body: comment.body,\n status: comment.status,\n removedBy: comment.removedBy,\n createdAt: new Date(comment.createdAt),\n updatedAt: new Date(comment.updatedAt),\n })\n .returning();\n return toComment(row!);\n }\n\n async findComment(orgId: string, id: string): Promise<Comment | null> {\n const [row] = await this.db\n .select()\n .from(comments)\n .where(and(eq(comments.orgId, orgId), eq(comments.id, id)))\n .limit(1);\n return row ? toComment(row) : null;\n }\n\n async updateComment(\n orgId: string,\n id: string,\n patch: Partial<Pick<Comment, 'body' | 'status' | 'removedBy' | 'updatedAt'>>,\n ): Promise<Comment | null> {\n const [row] = await this.db\n .update(comments)\n .set({\n ...('body' in patch ? { body: patch.body } : {}),\n ...('status' in patch ? { status: patch.status } : {}),\n ...('removedBy' in patch ? { removedBy: patch.removedBy ?? null } : {}),\n })\n .where(and(eq(comments.orgId, orgId), eq(comments.id, id)))\n .returning();\n return row ? toComment(row) : null;\n }\n\n async listByActivity(orgId: string, activityId: string): Promise<Comment[]> {\n const rows = await this.db\n .select()\n .from(comments)\n .where(and(eq(comments.orgId, orgId), eq(comments.activityId, activityId)))\n .orderBy(comments.createdAt);\n return rows.map(toComment);\n }\n\n async reactionsOf(\n orgId: string,\n commentIds: string[],\n viewerId: string,\n ): Promise<Record<string, CommentReactions>> {\n if (commentIds.length === 0) {\n return {};\n }\n const rows = await this.db\n .select({\n commentId: commentReactions.commentId,\n emoji: commentReactions.emoji,\n count: sql<number>`cast(count(*) as int)`,\n mine: sql<boolean>`bool_or(${commentReactions.orgUserId} = ${viewerId})`,\n })\n .from(commentReactions)\n .where(\n and(eq(commentReactions.orgId, orgId), inArray(commentReactions.commentId, commentIds)),\n )\n .groupBy(commentReactions.commentId, commentReactions.emoji);\n\n const byComment: Record<string, CommentReactions> = {};\n for (const row of rows) {\n const entry = (byComment[row.commentId] ??= { reactions: {} });\n entry.reactions[row.emoji] = row.count;\n if (row.mine) {\n entry.viewerReaction = row.emoji;\n }\n }\n return byComment;\n }\n\n async setReaction(\n orgId: string,\n commentId: string,\n orgUserId: string,\n emoji: ReactionEmoji | null,\n ): Promise<void> {\n if (emoji === null) {\n await this.db\n .delete(commentReactions)\n .where(\n and(\n eq(commentReactions.orgId, orgId),\n eq(commentReactions.commentId, commentId),\n eq(commentReactions.orgUserId, orgUserId),\n ),\n );\n return;\n }\n await this.db\n .insert(commentReactions)\n .values({ orgId, commentId, orgUserId, emoji })\n .onConflictDoUpdate({\n target: [commentReactions.orgId, commentReactions.commentId, commentReactions.orgUserId],\n set: { emoji },\n });\n }\n\n async authorsOf(orgId: string, orgUserIds: string[]): Promise<Record<string, AuthorRecord>> {\n if (orgUserIds.length === 0) {\n return {};\n }\n const rows = await this.db\n .select({ ...orgUserProfileColumns, role: orgUsers.role })\n .from(orgUsers)\n .innerJoin(users, eq(users.id, orgUsers.userId))\n .leftJoin(user, eq(user.id, users.externalId))\n .where(and(eq(orgUsers.orgId, orgId), inArray(orgUsers.id, orgUserIds)));\n\n const byId: Record<string, AuthorRecord> = {};\n for (const row of rows) {\n byId[row.id] = {\n id: row.id,\n firstName: row.firstName,\n lastName: row.lastName,\n email: row.email,\n image: row.image ?? null,\n role: row.role as Role,\n };\n }\n return byId;\n }\n\n async insertReport(orgId: string, report: CommentReport): Promise<CommentReport | null> {\n const [row] = await this.db\n .insert(commentReports)\n .values({\n id: report.id,\n orgId,\n commentId: report.commentId,\n orgUserId: report.orgUserId,\n reason: report.reason,\n resolvedAt: report.resolvedAt ? new Date(report.resolvedAt) : null,\n createdAt: new Date(report.createdAt),\n })\n .onConflictDoNothing()\n .returning();\n return row ?? null;\n }\n\n async listOpenReports(orgId: string, commentIds: string[]): Promise<CommentReport[]> {\n if (commentIds.length === 0) {\n return [];\n }\n const rows = await this.db\n .select()\n .from(commentReports)\n .where(\n and(\n eq(commentReports.orgId, orgId),\n inArray(commentReports.commentId, commentIds),\n isNull(commentReports.resolvedAt),\n ),\n )\n .orderBy(commentReports.createdAt);\n return rows;\n }\n\n async resolveReportsFor(orgId: string, commentId: string, resolvedAt: Date): Promise<void> {\n await this.db\n .update(commentReports)\n .set({ resolvedAt })\n .where(\n and(\n eq(commentReports.orgId, orgId),\n eq(commentReports.commentId, commentId),\n isNull(commentReports.resolvedAt),\n ),\n );\n }\n\n /** The list's one join: comments to their activity, which carries both the\n * course (denormalised, so the module never enters the query) and the title.\n * The course filter and the row's activity title come out of that same pass,\n * so neither costs an extra query. */\n private listRows(where: SQL | undefined) {\n return this.db\n .select({\n comment: comments,\n courseId: activities.courseId,\n activityTitle: activityTitleExpr,\n })\n .from(comments)\n .innerJoin(\n activities,\n and(eq(activities.orgId, comments.orgId), eq(activities.id, comments.activityId)),\n )\n .where(where);\n }\n\n /** The same join and filters, counted. Separate from `listRows` because the\n * total must ignore limit/offset. */\n private listTotal(where: SQL | undefined) {\n return this.db\n .select({ total: sql<number>`cast(count(*) as int)` })\n .from(comments)\n .innerJoin(\n activities,\n and(eq(activities.orgId, comments.orgId), eq(activities.id, comments.activityId)),\n )\n .where(where);\n }\n\n async listComments(orgId: string, query: ListCommentsQuery): Promise<Page<CommentWithContext>> {\n const conditions: SQL[] = [eq(comments.orgId, orgId)];\n if (query.status) {\n conditions.push(eq(comments.status, query.status));\n }\n if (query.courseId) {\n conditions.push(eq(activities.courseId, query.courseId));\n }\n if (query.activityId) {\n conditions.push(eq(comments.activityId, query.activityId));\n }\n if (query.orgUserId) {\n conditions.push(eq(comments.orgUserId, query.orgUserId));\n }\n if (query.reported !== undefined) {\n // Correlated EXISTS rather than a join to comment_reports: a comment with\n // three open reports must stay one row.\n const hasOpenReport = sql`exists (\n select 1 from ${commentReports} r\n where r.org_id = ${comments.orgId}\n and r.comment_id = ${comments.id}\n and r.resolved_at is null\n )`;\n conditions.push(query.reported ? hasOpenReport : sql`not ${hasOpenReport}`);\n }\n if (query.search) {\n conditions.push(ilike(comments.body, `%${query.search}%`));\n }\n const where = and(...conditions);\n\n // Sort: `-field` for descending; default to newest first, which is the\n // order a moderator works in.\n let orderBy: SQL;\n if (query.sort) {\n const isDesc = query.sort.startsWith('-');\n const field = (isDesc ? query.sort.slice(1) : query.sort) as keyof typeof sortColumns;\n const col = sortColumns[field] ?? comments.createdAt;\n orderBy = isDesc ? desc(col) : asc(col);\n } else {\n orderBy = desc(comments.createdAt);\n }\n\n const rows = await this.listRows(where)\n .orderBy(orderBy)\n .limit(query.pageSize)\n .offset((query.page - 1) * query.pageSize);\n\n const [{ total } = { total: 0 }] = await this.listTotal(where);\n\n return {\n rows: rows.map((r) => ({\n comment: toComment(r.comment),\n courseId: r.courseId,\n activityTitle: r.activityTitle,\n })),\n total,\n page: query.page,\n pageSize: query.pageSize,\n };\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"discussion.js","names":[],"sources":["../../src/repositories/discussion.ts"],"sourcesContent":["import { and, asc, desc, eq, ilike, inArray, isNull, sql, type SQL } from \"drizzle-orm\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\nimport type {\n AuthorRecord,\n CommentReactions,\n CommentWithContext,\n DiscussionRepository,\n} from \"@headless-lms/core/discussion\";\nimport type {\n Comment,\n CommentReport,\n ListCommentsQuery,\n Logger,\n Page,\n ReactionEmoji,\n Role,\n} from \"@headless-lms/core/types\";\nimport { commentReactions, commentReports, comments } from \"../schema/index.js\";\nimport { activities } from \"../schema/content.js\";\nimport { orgUsers } from \"../schema/organizations.js\";\nimport { users } from \"../schema/identity.js\";\nimport { user } from \"../schema/better-auth.js\";\nimport { orgUserProfileColumns } from \"./org-user-profile.js\";\nimport { noopLogger } from \"@headless-lms/core/shared/logger\";\nimport { translateDbErrors } from \"./pg-errors.js\";\n\ntype CommentRow = typeof comments.$inferSelect;\n\n/** The title lives in the activity's opaque settings blob; the moderation card\n * needs it to say \"Lesson 3\" rather than an id. */\nconst activityTitleExpr = sql<string>`coalesce(${activities.settings} ->> 'title', '')`;\n\n/** Sortable columns of the staff comment list, by the client-facing field name.\n * `activityTitle` sorts on the same expression the row is served from. */\nconst sortColumns = {\n createdAt: comments.createdAt,\n updatedAt: comments.updatedAt,\n status: comments.status,\n activityTitle: activityTitleExpr,\n} as const;\n\nfunction toComment(row: CommentRow): Comment {\n return {\n id: row.id,\n orgId: row.orgId,\n activityId: row.activityId,\n parentId: row.parentId ?? null,\n orgUserId: row.orgUserId,\n body: row.body,\n status: row.status,\n removedBy: row.removedBy ?? null,\n createdAt: row.createdAt,\n updatedAt: row.updatedAt,\n };\n}\n\nexport class DrizzleDiscussionRepository implements DiscussionRepository {\n constructor(\n private readonly db: NodePgDatabase,\n private readonly logger: Logger = noopLogger,\n ) {}\n\n async insertComment(orgId: string, comment: Comment): Promise<Comment> {\n const [row] = await this.db\n .insert(comments)\n .values({\n id: comment.id,\n orgId,\n activityId: comment.activityId,\n parentId: comment.parentId,\n orgUserId: comment.orgUserId,\n body: comment.body,\n status: comment.status,\n removedBy: comment.removedBy,\n createdAt: new Date(comment.createdAt),\n updatedAt: new Date(comment.updatedAt),\n })\n .returning();\n return toComment(row!);\n }\n\n async findComment(orgId: string, id: string): Promise<Comment | null> {\n const [row] = await this.db\n .select()\n .from(comments)\n .where(and(eq(comments.orgId, orgId), eq(comments.id, id)))\n .limit(1);\n return row ? toComment(row) : null;\n }\n\n async updateComment(\n orgId: string,\n id: string,\n patch: Partial<Pick<Comment, \"body\" | \"status\" | \"removedBy\" | \"updatedAt\">>,\n ): Promise<Comment | null> {\n const [row] = await this.db\n .update(comments)\n .set({\n ...(\"body\" in patch ? { body: patch.body } : {}),\n ...(\"status\" in patch ? { status: patch.status } : {}),\n ...(\"removedBy\" in patch ? { removedBy: patch.removedBy ?? null } : {}),\n })\n .where(and(eq(comments.orgId, orgId), eq(comments.id, id)))\n .returning();\n return row ? toComment(row) : null;\n }\n\n async listByActivity(orgId: string, activityId: string): Promise<Comment[]> {\n const rows = await this.db\n .select()\n .from(comments)\n .where(and(eq(comments.orgId, orgId), eq(comments.activityId, activityId)))\n .orderBy(comments.createdAt);\n return rows.map(toComment);\n }\n\n async reactionsOf(\n orgId: string,\n commentIds: string[],\n viewerId: string,\n ): Promise<Record<string, CommentReactions>> {\n if (commentIds.length === 0) {\n return {};\n }\n const rows = await this.db\n .select({\n commentId: commentReactions.commentId,\n emoji: commentReactions.emoji,\n count: sql<number>`cast(count(*) as int)`,\n mine: sql<boolean>`bool_or(${commentReactions.orgUserId} = ${viewerId})`,\n })\n .from(commentReactions)\n .where(\n and(eq(commentReactions.orgId, orgId), inArray(commentReactions.commentId, commentIds)),\n )\n .groupBy(commentReactions.commentId, commentReactions.emoji);\n\n const byComment: Record<string, CommentReactions> = {};\n for (const row of rows) {\n const entry = (byComment[row.commentId] ??= { reactions: {} });\n entry.reactions[row.emoji] = row.count;\n if (row.mine) {\n entry.viewerReaction = row.emoji;\n }\n }\n return byComment;\n }\n\n async setReaction(\n orgId: string,\n commentId: string,\n orgUserId: string,\n emoji: ReactionEmoji | null,\n ): Promise<void> {\n if (emoji === null) {\n await this.db\n .delete(commentReactions)\n .where(\n and(\n eq(commentReactions.orgId, orgId),\n eq(commentReactions.commentId, commentId),\n eq(commentReactions.orgUserId, orgUserId),\n ),\n );\n return;\n }\n await this.db\n .insert(commentReactions)\n .values({ orgId, commentId, orgUserId, emoji })\n .onConflictDoUpdate({\n target: [commentReactions.orgId, commentReactions.commentId, commentReactions.orgUserId],\n set: { emoji },\n });\n }\n\n async authorsOf(orgId: string, orgUserIds: string[]): Promise<Record<string, AuthorRecord>> {\n if (orgUserIds.length === 0) {\n return {};\n }\n const rows = await this.db\n .select({ ...orgUserProfileColumns, role: orgUsers.role })\n .from(orgUsers)\n .innerJoin(users, eq(users.id, orgUsers.userId))\n .leftJoin(user, eq(user.id, users.externalId))\n .where(and(eq(orgUsers.orgId, orgId), inArray(orgUsers.id, orgUserIds)));\n\n const byId: Record<string, AuthorRecord> = {};\n for (const row of rows) {\n byId[row.id] = {\n id: row.id,\n firstName: row.firstName,\n lastName: row.lastName,\n email: row.email,\n image: row.image ?? null,\n role: row.role as Role,\n };\n }\n return byId;\n }\n\n async insertReport(orgId: string, report: CommentReport): Promise<CommentReport | null> {\n const [row] = await this.db\n .insert(commentReports)\n .values({\n id: report.id,\n orgId,\n commentId: report.commentId,\n orgUserId: report.orgUserId,\n reason: report.reason,\n resolvedAt: report.resolvedAt ? new Date(report.resolvedAt) : null,\n createdAt: new Date(report.createdAt),\n })\n .onConflictDoNothing()\n .returning();\n return row ?? null;\n }\n\n async listOpenReports(orgId: string, commentIds: string[]): Promise<CommentReport[]> {\n if (commentIds.length === 0) {\n return [];\n }\n const rows = await this.db\n .select()\n .from(commentReports)\n .where(\n and(\n eq(commentReports.orgId, orgId),\n inArray(commentReports.commentId, commentIds),\n isNull(commentReports.resolvedAt),\n ),\n )\n .orderBy(commentReports.createdAt);\n return rows;\n }\n\n async resolveReportsFor(orgId: string, commentId: string, resolvedAt: Date): Promise<void> {\n await this.db\n .update(commentReports)\n .set({ resolvedAt })\n .where(\n and(\n eq(commentReports.orgId, orgId),\n eq(commentReports.commentId, commentId),\n isNull(commentReports.resolvedAt),\n ),\n );\n }\n\n /** The list's one join: comments to their activity, which carries both the\n * course (denormalised, so the module never enters the query) and the title.\n * The course filter and the row's activity title come out of that same pass,\n * so neither costs an extra query. */\n private listRows(where: SQL | undefined) {\n return this.db\n .select({\n comment: comments,\n courseId: activities.courseId,\n activityTitle: activityTitleExpr,\n })\n .from(comments)\n .innerJoin(\n activities,\n and(eq(activities.orgId, comments.orgId), eq(activities.id, comments.activityId)),\n )\n .where(where);\n }\n\n /** The same join and filters, counted. Separate from `listRows` because the\n * total must ignore limit/offset. */\n private listTotal(where: SQL | undefined) {\n return this.db\n .select({ total: sql<number>`cast(count(*) as int)` })\n .from(comments)\n .innerJoin(\n activities,\n and(eq(activities.orgId, comments.orgId), eq(activities.id, comments.activityId)),\n )\n .where(where);\n }\n\n async listComments(orgId: string, query: ListCommentsQuery): Promise<Page<CommentWithContext>> {\n const conditions: SQL[] = [eq(comments.orgId, orgId)];\n if (query.status) {\n conditions.push(eq(comments.status, query.status));\n }\n if (query.courseId) {\n conditions.push(eq(activities.courseId, query.courseId));\n }\n if (query.activityId) {\n conditions.push(eq(comments.activityId, query.activityId));\n }\n if (query.orgUserId) {\n conditions.push(eq(comments.orgUserId, query.orgUserId));\n }\n if (query.reported !== undefined) {\n // Correlated EXISTS rather than a join to comment_reports: a comment with\n // three open reports must stay one row.\n const hasOpenReport = sql`exists (\n select 1 from ${commentReports} r\n where r.org_id = ${comments.orgId}\n and r.comment_id = ${comments.id}\n and r.resolved_at is null\n )`;\n conditions.push(query.reported ? hasOpenReport : sql`not ${hasOpenReport}`);\n }\n if (query.search) {\n conditions.push(ilike(comments.body, `%${query.search}%`));\n }\n const where = and(...conditions);\n\n // Sort: `-field` for descending; default to newest first, which is the\n // order a moderator works in.\n let orderBy: SQL;\n if (query.sort) {\n const isDesc = query.sort.startsWith(\"-\");\n const field = (isDesc ? query.sort.slice(1) : query.sort) as keyof typeof sortColumns;\n const col = sortColumns[field] ?? comments.createdAt;\n orderBy = isDesc ? desc(col) : asc(col);\n } else {\n orderBy = desc(comments.createdAt);\n }\n\n const rows = await this.listRows(where)\n .orderBy(orderBy)\n .limit(query.pageSize)\n .offset((query.page - 1) * query.pageSize);\n\n const [{ total } = { total: 0 }] = await this.listTotal(where);\n\n return {\n rows: rows.map((r) => ({\n comment: toComment(r.comment),\n courseId: r.courseId,\n activityTitle: r.activityTitle,\n })),\n total,\n page: query.page,\n pageSize: query.pageSize,\n };\n }\n}\ntranslateDbErrors(DrizzleDiscussionRepository);\n"],"mappings":"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"}
|
|
@@ -6,16 +6,22 @@ declare class DrizzleEntitlementsRepository implements EntitlementsRepository {
|
|
|
6
6
|
private readonly db;
|
|
7
7
|
private readonly logger;
|
|
8
8
|
constructor(db: DbExecutor, logger?: Logger);
|
|
9
|
-
/** entitlements → org_users + content_items (type)
|
|
10
|
-
* concrete content table (title).
|
|
9
|
+
/** entitlements → org_users + the grant's target: content_items (type) and one
|
|
10
|
+
* LEFT JOIN per concrete content table (title), or bundles (name). Both target
|
|
11
|
+
* joins are LEFT — a row carries one of the two, never both. */
|
|
11
12
|
private joined;
|
|
12
13
|
list(orgId: string, query: EntitlementsQuery): Promise<Page<Entitlement>>;
|
|
14
|
+
/** A grant targets exactly one of a bundle or a content item (db check
|
|
15
|
+
* constraint), so the upsert is inferred on whichever of the two partial
|
|
16
|
+
* uniques the target uses. */
|
|
13
17
|
insert(orgId: string, input: GrantEntitlementInput): Promise<Entitlement>;
|
|
14
|
-
setStatus(orgId: string, id: string, status:
|
|
18
|
+
setStatus(orgId: string, id: string, status: "active" | "revoked"): Promise<Entitlement | null>;
|
|
15
19
|
/** Existence check scoped to the course case: same status predicate as
|
|
16
20
|
* `list` (revoked never counts; an elapsed expiry reads as expired, not
|
|
17
|
-
* active)
|
|
18
|
-
*
|
|
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. */
|
|
19
25
|
hasCourseAccess(orgId: string, orgUserId: string, courseId: string): Promise<boolean>;
|
|
20
26
|
}
|
|
21
27
|
//#endregion
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { users } from "../schema/identity.js";
|
|
2
2
|
import { orgUsers } from "../schema/organizations.js";
|
|
3
|
-
import { contentItems, courses, downloads } from "../schema/content.js";
|
|
3
|
+
import { bundleItems, bundles, contentItems, courses, downloads } from "../schema/content.js";
|
|
4
4
|
import { entitlements } from "../schema/entitlements.js";
|
|
5
5
|
import "../schema/index.js";
|
|
6
|
+
import { translateDbErrors } from "./pg-errors.js";
|
|
6
7
|
import { and, asc, desc, eq, ilike, or, sql } from "drizzle-orm";
|
|
8
|
+
import { alias } from "drizzle-orm/pg-core";
|
|
7
9
|
import { noopLogger } from "@headless-lms/core/shared/logger";
|
|
8
10
|
//#region src/repositories/entitlements.ts
|
|
9
11
|
const derivedStatus = sql`case
|
|
@@ -11,7 +13,7 @@ const derivedStatus = sql`case
|
|
|
11
13
|
when ${entitlements.expiresAt} is not null and ${entitlements.expiresAt} < now() then 'expired'
|
|
12
14
|
else 'active'
|
|
13
15
|
end`;
|
|
14
|
-
const contentTitle = sql`coalesce(${courses.title}, ${downloads.title})`;
|
|
16
|
+
const contentTitle = sql`coalesce(${courses.title}, ${downloads.title}, ${bundles.name})`;
|
|
15
17
|
const sortColumns = {
|
|
16
18
|
firstName: users.firstName,
|
|
17
19
|
lastName: users.lastName,
|
|
@@ -26,22 +28,28 @@ const selection = {
|
|
|
26
28
|
orgId: entitlements.orgId,
|
|
27
29
|
id: entitlements.id,
|
|
28
30
|
orgUserId: entitlements.orgUserId,
|
|
31
|
+
bundleId: entitlements.bundleId,
|
|
29
32
|
contentId: entitlements.contentId,
|
|
30
33
|
status: entitlements.status,
|
|
31
34
|
source: entitlements.source,
|
|
32
35
|
grantedAt: entitlements.grantedAt,
|
|
33
|
-
expiresAt: entitlements.expiresAt
|
|
36
|
+
expiresAt: entitlements.expiresAt,
|
|
37
|
+
createdAt: entitlements.createdAt,
|
|
38
|
+
updatedAt: entitlements.updatedAt
|
|
34
39
|
};
|
|
35
40
|
function toEntitlement(row) {
|
|
36
41
|
return {
|
|
37
42
|
orgId: row.orgId,
|
|
38
43
|
id: row.id,
|
|
39
44
|
orgUserId: row.orgUserId,
|
|
45
|
+
bundleId: row.bundleId,
|
|
40
46
|
contentId: row.contentId,
|
|
41
47
|
status: row.status,
|
|
42
48
|
source: row.source,
|
|
43
49
|
grantedAt: row.grantedAt,
|
|
44
|
-
expiresAt: row.expiresAt
|
|
50
|
+
expiresAt: row.expiresAt,
|
|
51
|
+
createdAt: row.createdAt,
|
|
52
|
+
updatedAt: row.updatedAt
|
|
45
53
|
};
|
|
46
54
|
}
|
|
47
55
|
var DrizzleEntitlementsRepository = class {
|
|
@@ -51,10 +59,11 @@ var DrizzleEntitlementsRepository = class {
|
|
|
51
59
|
this.db = db;
|
|
52
60
|
this.logger = logger;
|
|
53
61
|
}
|
|
54
|
-
/** entitlements → org_users + content_items (type)
|
|
55
|
-
* concrete content table (title).
|
|
62
|
+
/** entitlements → org_users + the grant's target: content_items (type) and one
|
|
63
|
+
* LEFT JOIN per concrete content table (title), or bundles (name). Both target
|
|
64
|
+
* joins are LEFT — a row carries one of the two, never both. */
|
|
56
65
|
joined(where) {
|
|
57
|
-
return this.db.select(selection).from(entitlements).innerJoin(orgUsers, and(eq(orgUsers.orgId, entitlements.orgId), eq(orgUsers.id, entitlements.orgUserId))).innerJoin(users, eq(users.id, orgUsers.userId)).
|
|
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);
|
|
58
67
|
}
|
|
59
68
|
async list(orgId, query) {
|
|
60
69
|
const conditions = [eq(entitlements.orgId, orgId)];
|
|
@@ -62,6 +71,7 @@ var DrizzleEntitlementsRepository = class {
|
|
|
62
71
|
if (query.source) conditions.push(eq(entitlements.source, query.source));
|
|
63
72
|
if (query.orgUserId) conditions.push(eq(entitlements.orgUserId, query.orgUserId));
|
|
64
73
|
if (query.contentId) conditions.push(eq(entitlements.contentId, query.contentId));
|
|
74
|
+
if (query.bundleId) conditions.push(eq(entitlements.bundleId, query.bundleId));
|
|
65
75
|
if (query.type) conditions.push(eq(contentItems.type, query.type));
|
|
66
76
|
if (query.search) {
|
|
67
77
|
const pattern = `%${query.search}%`;
|
|
@@ -77,7 +87,7 @@ var DrizzleEntitlementsRepository = class {
|
|
|
77
87
|
} else orderBy = desc(entitlements.grantedAt);
|
|
78
88
|
const offset = (query.page - 1) * query.pageSize;
|
|
79
89
|
const rows = await this.joined(where).orderBy(orderBy).limit(query.pageSize).offset(offset);
|
|
80
|
-
const [{ total } = { total: 0 }] = await this.db.select({ total: sql`cast(count(*) as int)` }).from(entitlements).innerJoin(orgUsers, and(eq(orgUsers.orgId, entitlements.orgId), eq(orgUsers.id, entitlements.orgUserId))).innerJoin(contentItems, and(eq(contentItems.orgId, entitlements.orgId), eq(contentItems.id, entitlements.contentId))).leftJoin(courses, and(eq(courses.orgId, entitlements.orgId), eq(courses.id, entitlements.contentId))).leftJoin(downloads, and(eq(downloads.orgId, entitlements.orgId), eq(downloads.id, entitlements.contentId))).where(where);
|
|
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);
|
|
81
91
|
return {
|
|
82
92
|
rows: rows.map(toEntitlement),
|
|
83
93
|
total,
|
|
@@ -85,21 +95,32 @@ var DrizzleEntitlementsRepository = class {
|
|
|
85
95
|
pageSize: query.pageSize
|
|
86
96
|
};
|
|
87
97
|
}
|
|
98
|
+
/** A grant targets exactly one of a bundle or a content item (db check
|
|
99
|
+
* constraint), so the upsert is inferred on whichever of the two partial
|
|
100
|
+
* uniques the target uses. */
|
|
88
101
|
async insert(orgId, input) {
|
|
102
|
+
const bundleId = input.bundleId ?? null;
|
|
103
|
+
const contentId = input.contentId ?? null;
|
|
104
|
+
const target = bundleId ? [
|
|
105
|
+
entitlements.orgId,
|
|
106
|
+
entitlements.orgUserId,
|
|
107
|
+
entitlements.bundleId
|
|
108
|
+
] : [
|
|
109
|
+
entitlements.orgId,
|
|
110
|
+
entitlements.orgUserId,
|
|
111
|
+
entitlements.contentId
|
|
112
|
+
];
|
|
89
113
|
const [row] = await this.db.insert(entitlements).values({
|
|
90
114
|
orgId,
|
|
91
115
|
orgUserId: input.orgUserId,
|
|
92
|
-
|
|
116
|
+
bundleId,
|
|
117
|
+
contentId,
|
|
93
118
|
status: "active",
|
|
94
119
|
source: "manual",
|
|
95
120
|
grantedAt: /* @__PURE__ */ new Date(),
|
|
96
121
|
expiresAt: input.expiresAt
|
|
97
122
|
}).onConflictDoUpdate({
|
|
98
|
-
target
|
|
99
|
-
entitlements.orgId,
|
|
100
|
-
entitlements.orgUserId,
|
|
101
|
-
entitlements.contentId
|
|
102
|
-
],
|
|
123
|
+
target,
|
|
103
124
|
set: {
|
|
104
125
|
status: "active",
|
|
105
126
|
source: "manual",
|
|
@@ -117,12 +138,16 @@ var DrizzleEntitlementsRepository = class {
|
|
|
117
138
|
}
|
|
118
139
|
/** Existence check scoped to the course case: same status predicate as
|
|
119
140
|
* `list` (revoked never counts; an elapsed expiry reads as expired, not
|
|
120
|
-
* active)
|
|
121
|
-
*
|
|
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. */
|
|
122
145
|
async hasCourseAccess(orgId, orgUserId, courseId) {
|
|
123
|
-
|
|
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;
|
|
124
148
|
}
|
|
125
149
|
};
|
|
150
|
+
translateDbErrors(DrizzleEntitlementsRepository);
|
|
126
151
|
//#endregion
|
|
127
152
|
export { DrizzleEntitlementsRepository };
|
|
128
153
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"entitlements.js","names":[],"sources":["../../src/repositories/entitlements.ts"],"sourcesContent":["import { and, asc, desc, eq, ilike, or, sql, type SQL } from 'drizzle-orm';\nimport type { DbExecutor } from '../client.js';\nimport type { EntitlementsRepository } from '@headless-lms/core/entitlements';\nimport type {\n Entitlement,\n EntitlementStatus,\n EntitlementsQuery,\n GrantEntitlementInput,\n Logger,\n Page,\n} from '@headless-lms/core/types';\nimport { entitlements, orgUsers, users } from '../schema/index.js';\nimport { contentItems, courses, downloads } from '../schema/content.js';\nimport { noopLogger } from '@headless-lms/core/shared/logger';\n\ntype ResolvedStatus = 'active' | 'expired' | 'revoked';\n\n// CASE expression: revoked beats everything; otherwise an elapsed expiry reads as\n// expired; otherwise active.\nconst derivedStatus = sql<ResolvedStatus>`case\n when ${entitlements.status} = 'revoked' then 'revoked'\n when ${entitlements.expiresAt} is not null and ${entitlements.expiresAt} < now() then 'expired'\n else 'active'\nend`;\n\n// Display name of the granted content, whatever its type. One LEFT JOIN per\n// concrete content table; exactly one hits per row (type-pinned FKs), so the\n// COALESCE picks the single non-null title. Extended per new content type.\nconst contentTitle = sql<string>`coalesce(${courses.title}, ${downloads.title})`;\n\n// Sortable columns by the client-facing field name. `status` sorts on the derived\n// expression so the ordering matches the displayed value; `contentTitle` on the\n// coalesced join expression.\nconst sortColumns = {\n firstName: users.firstName,\n lastName: users.lastName,\n email: users.email,\n contentTitle,\n status: derivedStatus,\n grantedAt: entitlements.grantedAt,\n expiresAt: entitlements.expiresAt,\n source: entitlements.source,\n} as const;\n\nconst selection = {\n orgId: entitlements.orgId,\n id: entitlements.id,\n orgUserId: entitlements.orgUserId,\n contentId: entitlements.contentId,\n status: entitlements.status,\n source: entitlements.source,\n grantedAt: entitlements.grantedAt,\n expiresAt: entitlements.expiresAt,\n} as const;\n\ninterface Row {\n orgId: string;\n id: string;\n orgUserId: string;\n contentId: string;\n status: EntitlementStatus;\n source: string;\n grantedAt: Date;\n expiresAt: Date | null;\n}\n\nfunction toEntitlement(row: Row): Entitlement {\n return {\n orgId: row.orgId,\n id: row.id,\n orgUserId: row.orgUserId,\n contentId: row.contentId,\n status: row.status,\n source: row.source,\n grantedAt: row.grantedAt,\n expiresAt: row.expiresAt,\n };\n}\n\nexport class DrizzleEntitlementsRepository implements EntitlementsRepository {\n constructor(\n private readonly db: DbExecutor,\n private readonly logger: Logger = noopLogger,\n ) {}\n\n /** entitlements → org_users + content_items (type) + one LEFT JOIN per\n * concrete content table (title). */\n private joined(where: SQL | undefined) {\n return this.db\n .select(selection)\n .from(entitlements)\n .innerJoin(\n orgUsers,\n and(eq(orgUsers.orgId, entitlements.orgId), eq(orgUsers.id, entitlements.orgUserId)),\n )\n .innerJoin(users, eq(users.id, orgUsers.userId))\n .innerJoin(\n contentItems,\n and(eq(contentItems.orgId, entitlements.orgId), eq(contentItems.id, entitlements.contentId)),\n )\n .leftJoin(\n courses,\n and(eq(courses.orgId, entitlements.orgId), eq(courses.id, entitlements.contentId)),\n )\n .leftJoin(\n downloads,\n and(eq(downloads.orgId, entitlements.orgId), eq(downloads.id, entitlements.contentId)),\n )\n .where(where);\n }\n\n async list(orgId: string, query: EntitlementsQuery): Promise<Page<Entitlement>> {\n const conditions: SQL[] = [eq(entitlements.orgId, orgId)];\n if (query.status) {\n conditions.push(sql`${derivedStatus} = ${query.status}`);\n }\n if (query.source) {\n conditions.push(eq(entitlements.source, query.source));\n }\n if (query.orgUserId) {\n conditions.push(eq(entitlements.orgUserId, query.orgUserId));\n }\n if (query.contentId) {\n conditions.push(eq(entitlements.contentId, query.contentId));\n }\n if (query.type) {\n conditions.push(eq(contentItems.type, query.type));\n }\n if (query.search) {\n const pattern = `%${query.search}%`;\n conditions.push(\n or(\n ilike(users.firstName, pattern),\n ilike(users.lastName, pattern),\n ilike(users.email, pattern),\n ilike(contentTitle, pattern),\n ) as SQL,\n );\n }\n const where = and(...conditions);\n\n // Sort: `-field` for descending; default to most-recently granted first.\n let orderBy: SQL;\n if (query.sort) {\n const isDesc = query.sort.startsWith('-');\n const field = (isDesc ? query.sort.slice(1) : query.sort) as keyof typeof sortColumns;\n const col = sortColumns[field] ?? entitlements.grantedAt;\n orderBy = isDesc ? desc(col) : asc(col);\n } else {\n orderBy = desc(entitlements.grantedAt);\n }\n\n const offset = (query.page - 1) * query.pageSize;\n\n const rows = await this.joined(where).orderBy(orderBy).limit(query.pageSize).offset(offset);\n\n const [{ total } = { total: 0 }] = await this.db\n .select({ total: sql<number>`cast(count(*) as int)` })\n .from(entitlements)\n .innerJoin(\n orgUsers,\n and(eq(orgUsers.orgId, entitlements.orgId), eq(orgUsers.id, entitlements.orgUserId)),\n )\n .innerJoin(\n contentItems,\n and(eq(contentItems.orgId, entitlements.orgId), eq(contentItems.id, entitlements.contentId)),\n )\n .leftJoin(\n courses,\n and(eq(courses.orgId, entitlements.orgId), eq(courses.id, entitlements.contentId)),\n )\n .leftJoin(\n downloads,\n and(eq(downloads.orgId, entitlements.orgId), eq(downloads.id, entitlements.contentId)),\n )\n .where(where);\n\n return {\n rows: rows.map(toEntitlement),\n total,\n page: query.page,\n pageSize: query.pageSize,\n };\n }\n\n async insert(orgId: string, input: GrantEntitlementInput): Promise<Entitlement> {\n const [row] = await this.db\n .insert(entitlements)\n .values({\n orgId,\n orgUserId: input.orgUserId,\n contentId: input.contentId,\n status: 'active',\n source: 'manual',\n grantedAt: new Date(),\n expiresAt: input.expiresAt,\n })\n .onConflictDoUpdate({\n target: [entitlements.orgId, entitlements.orgUserId, entitlements.contentId],\n set: {\n status: 'active',\n source: 'manual',\n grantedAt: new Date(),\n expiresAt: input.expiresAt,\n },\n })\n .returning(selection);\n if (!row) {\n throw new Error('failed to insert entitlement');\n }\n return toEntitlement(row);\n }\n\n async setStatus(\n orgId: string,\n id: string,\n status: 'active' | 'revoked',\n ): Promise<Entitlement | null> {\n const [row] = await this.db\n .update(entitlements)\n .set({ status })\n .where(and(eq(entitlements.orgId, orgId), eq(entitlements.id, id)))\n .returning(selection);\n if (!row) {\n return null;\n }\n return toEntitlement(row);\n }\n\n /** Existence check scoped to the course case: same status predicate as\n * `list` (revoked never counts; an elapsed expiry reads as expired, not\n * active), joined to content_items and constrained to type = 'course' so a\n * grant on some other content type can never be mistaken for course access. */\n async hasCourseAccess(orgId: string, orgUserId: string, courseId: string): Promise<boolean> {\n const rows = await this.db\n .select({ id: entitlements.id })\n .from(entitlements)\n .innerJoin(\n contentItems,\n and(eq(contentItems.orgId, entitlements.orgId), eq(contentItems.id, entitlements.contentId)),\n )\n .where(\n and(\n eq(entitlements.orgId, orgId),\n eq(entitlements.orgUserId, orgUserId),\n eq(entitlements.contentId, courseId),\n eq(contentItems.type, 'course'),\n sql`${derivedStatus} = 'active'`,\n ),\n )\n .limit(1);\n return rows.length > 0;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"entitlements.js","names":[],"sources":["../../src/repositories/entitlements.ts"],"sourcesContent":["import { and, asc, desc, eq, 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,4 +1,5 @@
|
|
|
1
1
|
import { users } from "../schema/identity.js";
|
|
2
|
+
import { translateDbErrors } from "./pg-errors.js";
|
|
2
3
|
import { eq, sql } from "drizzle-orm";
|
|
3
4
|
import { noopLogger } from "@headless-lms/core/shared/logger";
|
|
4
5
|
//#region src/repositories/identity.ts
|
|
@@ -38,6 +39,7 @@ var DrizzleIdentityRepository = class {
|
|
|
38
39
|
return row ?? null;
|
|
39
40
|
}
|
|
40
41
|
};
|
|
42
|
+
translateDbErrors(DrizzleIdentityRepository);
|
|
41
43
|
//#endregion
|
|
42
44
|
export { DrizzleIdentityRepository };
|
|
43
45
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"identity.js","names":[],"sources":["../../src/repositories/identity.ts"],"sourcesContent":["// identity — Drizzle repository (implements the core outbound port).\n//\n// The person only. A person's link to an organization lives in the\n// organizations context (`org_users`), not here.\nimport { eq, sql } from
|
|
1
|
+
{"version":3,"file":"identity.js","names":[],"sources":["../../src/repositories/identity.ts"],"sourcesContent":["// identity — Drizzle repository (implements the core outbound port).\n//\n// The person only. A person's link to an organization lives in the\n// organizations context (`org_users`), not here.\nimport { eq, sql } from \"drizzle-orm\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\nimport type { IdentityRepository } from \"@headless-lms/core/identity\";\nimport type { CreateUserInput, Logger, UpdateUserInput, User } from \"@headless-lms/core/types\";\nimport { users } from \"../schema/identity.js\";\nimport { noopLogger } from \"@headless-lms/core/shared/logger\";\nimport { translateDbErrors } from \"./pg-errors.js\";\n\nexport class DrizzleIdentityRepository implements IdentityRepository {\n constructor(\n private readonly db: NodePgDatabase,\n private readonly logger: Logger = noopLogger,\n ) {}\n\n async insertUser(input: CreateUserInput): Promise<User> {\n const [row] = await this.db\n .insert(users)\n .values({\n id: input.id,\n externalId: input.externalId ?? null,\n email: input.email,\n firstName: input.firstName ?? null,\n lastName: input.lastName ?? null,\n })\n .returning();\n if (!row) {\n throw new Error(\"failed to insert user\");\n }\n return row;\n }\n\n async updateUser(id: string, input: UpdateUserInput): Promise<User | null> {\n // An empty patch would be an UPDATE with no SET — read the row back instead.\n if (Object.keys(input).length === 0) {\n return this.findUserById(id);\n }\n const [row] = await this.db.update(users).set(input).where(eq(users.id, id)).returning();\n return row ?? null;\n }\n\n async findUserById(id: string): Promise<User | null> {\n const [row] = await this.db.select().from(users).where(eq(users.id, id)).limit(1);\n return row ?? null;\n }\n\n async findUserByExternalId(externalId: string): Promise<User | null> {\n const [row] = await this.db\n .select()\n .from(users)\n .where(eq(users.externalId, externalId))\n .limit(1);\n return row ?? null;\n }\n\n // Case-insensitive: the address comes from whatever an admin typed into an\n // invite, the row from whatever the auth provider stored.\n async findUserByEmail(email: string): Promise<User | null> {\n const [row] = await this.db\n .select()\n .from(users)\n .where(sql`lower(${users.email}) = lower(${email})`)\n .limit(1);\n return row ?? null;\n }\n}\ntranslateDbErrors(DrizzleIdentityRepository);\n"],"mappings":";;;;;AAYA,IAAa,4BAAb,MAAqE;CAEhD;CACA;CAFnB,YACE,IACA,SAAkC,YAClC;EAFiB,KAAA,KAAA;EACA,KAAA,SAAA;CAChB;CAEH,MAAM,WAAW,OAAuC;EACtD,MAAM,CAAC,OAAO,MAAM,KAAK,GACtB,OAAO,KAAK,CAAC,CACb,OAAO;GACN,IAAI,MAAM;GACV,YAAY,MAAM,cAAc;GAChC,OAAO,MAAM;GACb,WAAW,MAAM,aAAa;GAC9B,UAAU,MAAM,YAAY;EAC9B,CAAC,CAAC,CACD,UAAU;EACb,IAAI,CAAC,KACH,MAAM,IAAI,MAAM,uBAAuB;EAEzC,OAAO;CACT;CAEA,MAAM,WAAW,IAAY,OAA8C;EAEzE,IAAI,OAAO,KAAK,KAAK,CAAC,CAAC,WAAW,GAChC,OAAO,KAAK,aAAa,EAAE;EAE7B,MAAM,CAAC,OAAO,MAAM,KAAK,GAAG,OAAO,KAAK,CAAC,CAAC,IAAI,KAAK,CAAC,CAAC,MAAM,GAAG,MAAM,IAAI,EAAE,CAAC,CAAC,CAAC,UAAU;EACvF,OAAO,OAAO;CAChB;CAEA,MAAM,aAAa,IAAkC;EACnD,MAAM,CAAC,OAAO,MAAM,KAAK,GAAG,OAAO,CAAC,CAAC,KAAK,KAAK,CAAC,CAAC,MAAM,GAAG,MAAM,IAAI,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC;EAChF,OAAO,OAAO;CAChB;CAEA,MAAM,qBAAqB,YAA0C;EACnE,MAAM,CAAC,OAAO,MAAM,KAAK,GACtB,OAAO,CAAC,CACR,KAAK,KAAK,CAAC,CACX,MAAM,GAAG,MAAM,YAAY,UAAU,CAAC,CAAC,CACvC,MAAM,CAAC;EACV,OAAO,OAAO;CAChB;CAIA,MAAM,gBAAgB,OAAqC;EACzD,MAAM,CAAC,OAAO,MAAM,KAAK,GACtB,OAAO,CAAC,CACR,KAAK,KAAK,CAAC,CACX,MAAM,GAAG,SAAS,MAAM,MAAM,YAAY,MAAM,EAAE,CAAC,CACnD,MAAM,CAAC;EACV,OAAO,OAAO;CAChB;AACF;AACA,kBAAkB,yBAAyB"}
|
|
@@ -10,7 +10,7 @@ declare class DrizzleConnectionsRepository implements ConnectionsRepository {
|
|
|
10
10
|
findById(orgId: string, id: string): Promise<Connection | null>;
|
|
11
11
|
findByIntegration(orgId: string, integrationId: string): Promise<Connection | null>;
|
|
12
12
|
list(orgId: string): Promise<Connection[]>;
|
|
13
|
-
update(orgId: string, id: string, patch: Partial<Pick<Connection,
|
|
13
|
+
update(orgId: string, id: string, patch: Partial<Pick<Connection, "config" | "active" | "updatedAt">>): Promise<Connection | null>;
|
|
14
14
|
delete(orgId: string, id: string): Promise<boolean>;
|
|
15
15
|
}
|
|
16
16
|
//#endregion
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { connections } from "../schema/integrations.js";
|
|
2
|
+
import { translateDbErrors } from "./pg-errors.js";
|
|
2
3
|
import { and, asc, eq } from "drizzle-orm";
|
|
3
4
|
import { noopLogger } from "@headless-lms/core/shared/logger";
|
|
4
5
|
//#region src/repositories/integrations.ts
|
|
@@ -59,6 +60,7 @@ var DrizzleConnectionsRepository = class {
|
|
|
59
60
|
return (await this.db.delete(connections).where(and(eq(connections.orgId, orgId), eq(connections.id, id))).returning({ id: connections.id })).length > 0;
|
|
60
61
|
}
|
|
61
62
|
};
|
|
63
|
+
translateDbErrors(DrizzleConnectionsRepository);
|
|
62
64
|
//#endregion
|
|
63
65
|
export { DrizzleConnectionsRepository };
|
|
64
66
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"integrations.js","names":[],"sources":["../../src/repositories/integrations.ts"],"sourcesContent":["// integrations — Drizzle repository (implements the core outbound port).\nimport { and, asc, eq } from
|
|
1
|
+
{"version":3,"file":"integrations.js","names":[],"sources":["../../src/repositories/integrations.ts"],"sourcesContent":["// integrations — Drizzle repository (implements the core outbound port).\nimport { and, asc, eq } from \"drizzle-orm\";\nimport type { DbExecutor } from \"../client.js\";\nimport type { Connection, ConnectionsRepository } from \"@headless-lms/core/integrations\";\nimport { connections } from \"../schema/integrations.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\ntype Row = typeof connections.$inferSelect;\n\nfunction toConnection(row: Row): Connection {\n return {\n orgId: row.orgId,\n id: row.id,\n integrationId: row.integrationId,\n config: row.config,\n active: row.active,\n credentialRef: row.credentialRef,\n createdAt: row.createdAt,\n updatedAt: row.updatedAt,\n };\n}\n\nexport class DrizzleConnectionsRepository implements ConnectionsRepository {\n constructor(\n private readonly db: DbExecutor,\n private readonly logger: Logger = noopLogger,\n ) {}\n\n async insert(orgId: string, connection: Connection): Promise<Connection> {\n if (connection.orgId !== orgId) {\n throw new Error(\"connection org mismatch\");\n }\n const [row] = await this.db\n .insert(connections)\n .values({\n orgId: connection.orgId,\n id: connection.id,\n integrationId: connection.integrationId,\n config: connection.config,\n active: connection.active,\n credentialRef: connection.credentialRef,\n createdAt: new Date(connection.createdAt),\n updatedAt: new Date(connection.updatedAt),\n })\n .returning();\n if (!row) {\n throw new Error(\"failed to insert connection\");\n }\n return toConnection(row);\n }\n\n async findById(orgId: string, id: string): Promise<Connection | null> {\n const [row] = await this.db\n .select()\n .from(connections)\n .where(and(eq(connections.orgId, orgId), eq(connections.id, id)))\n .limit(1);\n return row ? toConnection(row) : null;\n }\n\n async findByIntegration(orgId: string, integrationId: string): Promise<Connection | null> {\n const [row] = await this.db\n .select()\n .from(connections)\n .where(and(eq(connections.orgId, orgId), eq(connections.integrationId, integrationId)))\n .limit(1);\n return row ? toConnection(row) : null;\n }\n\n async list(orgId: string): Promise<Connection[]> {\n const rows = await this.db\n .select()\n .from(connections)\n .where(eq(connections.orgId, orgId))\n .orderBy(asc(connections.integrationId));\n return rows.map(toConnection);\n }\n\n async update(\n orgId: string,\n id: string,\n patch: Partial<Pick<Connection, \"config\" | \"active\" | \"updatedAt\">>,\n ): Promise<Connection | null> {\n const [row] = await this.db\n .update(connections)\n .set({\n ...(patch.config !== undefined ? { config: patch.config } : {}),\n ...(patch.active !== undefined ? { active: patch.active } : {}),\n ...(patch.updatedAt !== undefined ? { updatedAt: new Date(patch.updatedAt) } : {}),\n })\n .where(and(eq(connections.orgId, orgId), eq(connections.id, id)))\n .returning();\n return row ? toConnection(row) : null;\n }\n\n async delete(orgId: string, id: string): Promise<boolean> {\n const deleted = await this.db\n .delete(connections)\n .where(and(eq(connections.orgId, orgId), eq(connections.id, id)))\n .returning({ id: connections.id });\n return deleted.length > 0;\n }\n}\ntranslateDbErrors(DrizzleConnectionsRepository);\n"],"mappings":";;;;;AAWA,SAAS,aAAa,KAAsB;CAC1C,OAAO;EACL,OAAO,IAAI;EACX,IAAI,IAAI;EACR,eAAe,IAAI;EACnB,QAAQ,IAAI;EACZ,QAAQ,IAAI;EACZ,eAAe,IAAI;EACnB,WAAW,IAAI;EACf,WAAW,IAAI;CACjB;AACF;AAEA,IAAa,+BAAb,MAA2E;CAEtD;CACA;CAFnB,YACE,IACA,SAAkC,YAClC;EAFiB,KAAA,KAAA;EACA,KAAA,SAAA;CAChB;CAEH,MAAM,OAAO,OAAe,YAA6C;EACvE,IAAI,WAAW,UAAU,OACvB,MAAM,IAAI,MAAM,yBAAyB;EAE3C,MAAM,CAAC,OAAO,MAAM,KAAK,GACtB,OAAO,WAAW,CAAC,CACnB,OAAO;GACN,OAAO,WAAW;GAClB,IAAI,WAAW;GACf,eAAe,WAAW;GAC1B,QAAQ,WAAW;GACnB,QAAQ,WAAW;GACnB,eAAe,WAAW;GAC1B,WAAW,IAAI,KAAK,WAAW,SAAS;GACxC,WAAW,IAAI,KAAK,WAAW,SAAS;EAC1C,CAAC,CAAC,CACD,UAAU;EACb,IAAI,CAAC,KACH,MAAM,IAAI,MAAM,6BAA6B;EAE/C,OAAO,aAAa,GAAG;CACzB;CAEA,MAAM,SAAS,OAAe,IAAwC;EACpE,MAAM,CAAC,OAAO,MAAM,KAAK,GACtB,OAAO,CAAC,CACR,KAAK,WAAW,CAAC,CACjB,MAAM,IAAI,GAAG,YAAY,OAAO,KAAK,GAAG,GAAG,YAAY,IAAI,EAAE,CAAC,CAAC,CAAC,CAChE,MAAM,CAAC;EACV,OAAO,MAAM,aAAa,GAAG,IAAI;CACnC;CAEA,MAAM,kBAAkB,OAAe,eAAmD;EACxF,MAAM,CAAC,OAAO,MAAM,KAAK,GACtB,OAAO,CAAC,CACR,KAAK,WAAW,CAAC,CACjB,MAAM,IAAI,GAAG,YAAY,OAAO,KAAK,GAAG,GAAG,YAAY,eAAe,aAAa,CAAC,CAAC,CAAC,CACtF,MAAM,CAAC;EACV,OAAO,MAAM,aAAa,GAAG,IAAI;CACnC;CAEA,MAAM,KAAK,OAAsC;EAM/C,QAAO,MALY,KAAK,GACrB,OAAO,CAAC,CACR,KAAK,WAAW,CAAC,CACjB,MAAM,GAAG,YAAY,OAAO,KAAK,CAAC,CAAC,CACnC,QAAQ,IAAI,YAAY,aAAa,CAAC,EAAA,CAC7B,IAAI,YAAY;CAC9B;CAEA,MAAM,OACJ,OACA,IACA,OAC4B;EAC5B,MAAM,CAAC,OAAO,MAAM,KAAK,GACtB,OAAO,WAAW,CAAC,CACnB,IAAI;GACH,GAAI,MAAM,WAAW,KAAA,IAAY,EAAE,QAAQ,MAAM,OAAO,IAAI,CAAC;GAC7D,GAAI,MAAM,WAAW,KAAA,IAAY,EAAE,QAAQ,MAAM,OAAO,IAAI,CAAC;GAC7D,GAAI,MAAM,cAAc,KAAA,IAAY,EAAE,WAAW,IAAI,KAAK,MAAM,SAAS,EAAE,IAAI,CAAC;EAClF,CAAC,CAAC,CACD,MAAM,IAAI,GAAG,YAAY,OAAO,KAAK,GAAG,GAAG,YAAY,IAAI,EAAE,CAAC,CAAC,CAAC,CAChE,UAAU;EACb,OAAO,MAAM,aAAa,GAAG,IAAI;CACnC;CAEA,MAAM,OAAO,OAAe,IAA8B;EAKxD,QAAO,MAJe,KAAK,GACxB,OAAO,WAAW,CAAC,CACnB,MAAM,IAAI,GAAG,YAAY,OAAO,KAAK,GAAG,GAAG,YAAY,IAAI,EAAE,CAAC,CAAC,CAAC,CAChE,UAAU,EAAE,IAAI,YAAY,GAAG,CAAC,EAAA,CACpB,SAAS;CAC1B;AACF;AACA,kBAAkB,4BAA4B"}
|