@headless-lms/adapter-db 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/dist/index.d.ts +7 -6
  2. package/dist/index.js +7 -6
  3. package/dist/repositories/admin.d.ts +23 -0
  4. package/dist/repositories/admin.js +393 -0
  5. package/dist/repositories/admin.js.map +1 -0
  6. package/dist/repositories/assets.d.ts +6 -2
  7. package/dist/repositories/assets.js +50 -9
  8. package/dist/repositories/assets.js.map +1 -1
  9. package/dist/repositories/automations.d.ts +6 -2
  10. package/dist/repositories/automations.js +45 -2
  11. package/dist/repositories/automations.js.map +1 -1
  12. package/dist/repositories/chats.d.ts +55 -0
  13. package/dist/repositories/chats.js +454 -0
  14. package/dist/repositories/chats.js.map +1 -0
  15. package/dist/repositories/content.d.ts +24 -1
  16. package/dist/repositories/content.js +293 -9
  17. package/dist/repositories/content.js.map +1 -1
  18. package/dist/repositories/discussion.d.ts +10 -3
  19. package/dist/repositories/discussion.js +70 -15
  20. package/dist/repositories/discussion.js.map +1 -1
  21. package/dist/repositories/entitlements.d.ts +29 -9
  22. package/dist/repositories/entitlements.js +207 -17
  23. package/dist/repositories/entitlements.js.map +1 -1
  24. package/dist/repositories/grant-covers.d.ts +20 -0
  25. package/dist/repositories/grant-covers.js +55 -0
  26. package/dist/repositories/grant-covers.js.map +1 -0
  27. package/dist/repositories/identity.d.ts +4 -2
  28. package/dist/repositories/identity.js +32 -1
  29. package/dist/repositories/identity.js.map +1 -1
  30. package/dist/repositories/member.d.ts +16 -0
  31. package/dist/repositories/member.js +63 -0
  32. package/dist/repositories/member.js.map +1 -0
  33. package/dist/repositories/members.js +2 -2
  34. package/dist/repositories/members.js.map +1 -1
  35. package/dist/repositories/notifications.d.ts +23 -0
  36. package/dist/repositories/notifications.js +70 -0
  37. package/dist/repositories/notifications.js.map +1 -0
  38. package/dist/repositories/org-user-profile.d.ts +214 -1
  39. package/dist/repositories/org-user-profile.js +23 -2
  40. package/dist/repositories/org-user-profile.js.map +1 -1
  41. package/dist/repositories/organizations.d.ts +3 -2
  42. package/dist/repositories/organizations.js +20 -8
  43. package/dist/repositories/organizations.js.map +1 -1
  44. package/dist/repositories/pg-errors.js +3 -1
  45. package/dist/repositories/pg-errors.js.map +1 -1
  46. package/dist/repositories/posts.d.ts +39 -0
  47. package/dist/repositories/posts.js +255 -0
  48. package/dist/repositories/posts.js.map +1 -0
  49. package/dist/schema/assets.d.ts +19 -2
  50. package/dist/schema/assets.js +10 -4
  51. package/dist/schema/assets.js.map +1 -1
  52. package/dist/schema/automations.d.ts +38 -0
  53. package/dist/schema/automations.js +5 -1
  54. package/dist/schema/automations.js.map +1 -1
  55. package/dist/schema/better-auth.d.ts +1613 -238
  56. package/dist/schema/better-auth.js +107 -22
  57. package/dist/schema/better-auth.js.map +1 -1
  58. package/dist/schema/content.d.ts +642 -5
  59. package/dist/schema/content.js +119 -10
  60. package/dist/schema/content.js.map +1 -1
  61. package/dist/schema/discussion.d.ts +2045 -55
  62. package/dist/schema/discussion.js +426 -11
  63. package/dist/schema/discussion.js.map +1 -1
  64. package/dist/schema/entitlements.d.ts +432 -1
  65. package/dist/schema/entitlements.js +109 -4
  66. package/dist/schema/entitlements.js.map +1 -1
  67. package/dist/schema/identity.d.ts +517 -1
  68. package/dist/schema/identity.js +118 -2
  69. package/dist/schema/identity.js.map +1 -1
  70. package/dist/schema/index.d.ts +6 -6
  71. package/dist/schema/index.js +30 -7
  72. package/dist/schema/organizations.d.ts +4 -4
  73. package/dist/schema/organizations.js +2 -0
  74. package/dist/schema/organizations.js.map +1 -1
  75. package/drizzle/0003_community.sql +340 -0
  76. package/drizzle/0004_assets_sources.sql +1 -0
  77. package/drizzle/0005_notices_and_seed_index.sql +12 -0
  78. package/drizzle/0006_community_review_fixes.sql +24 -0
  79. package/drizzle/0007_notifications.sql +37 -0
  80. package/drizzle/0008_content_access.sql +13 -0
  81. package/drizzle/0009_community_indexes.sql +56 -0
  82. package/drizzle/0010_mcp_access.sql +114 -0
  83. package/drizzle/meta/0003_snapshot.json +7061 -0
  84. package/drizzle/meta/0004_snapshot.json +7068 -0
  85. package/drizzle/meta/0005_snapshot.json +7180 -0
  86. package/drizzle/meta/0006_snapshot.json +7298 -0
  87. package/drizzle/meta/0007_snapshot.json +7620 -0
  88. package/drizzle/meta/0008_snapshot.json +7666 -0
  89. package/drizzle/meta/0009_snapshot.json +8185 -0
  90. package/drizzle/meta/0010_snapshot.json +8813 -0
  91. package/drizzle/meta/_journal.json +57 -1
  92. package/package.json +2 -2
  93. package/dist/repositories/course-analytics.d.ts +0 -15
  94. package/dist/repositories/course-analytics.js +0 -137
  95. package/dist/repositories/course-analytics.js.map +0 -1
  96. package/dist/repositories/dashboard.d.ts +0 -14
  97. package/dist/repositories/dashboard.js +0 -57
  98. package/dist/repositories/dashboard.js.map +0 -1
  99. package/dist/repositories/learn.d.ts +0 -19
  100. package/dist/repositories/learn.js +0 -56
  101. package/dist/repositories/learn.js.map +0 -1
  102. package/dist/repositories/students.d.ts +0 -16
  103. package/dist/repositories/students.js +0 -160
  104. package/dist/repositories/students.js.map +0 -1
package/dist/index.d.ts CHANGED
@@ -5,19 +5,20 @@ import { migrationsFolder, runMigrations } from "./migrate.js";
5
5
  import { DrizzleAssetsRepository } from "./repositories/assets.js";
6
6
  import { DrizzleAutomationRunsRepository, DrizzleAutomationsRepository } from "./repositories/automations.js";
7
7
  import { DrizzleContentRepository } from "./repositories/content.js";
8
- import { DrizzleCourseAnalyticsRepository } from "./repositories/course-analytics.js";
9
8
  import { DrizzleCredentialStore, decryptSecret, encryptSecret } from "./repositories/credentials.js";
10
- import { DrizzleDashboardRepository } from "./repositories/dashboard.js";
11
9
  import { DrizzleDiscussionRepository } from "./repositories/discussion.js";
10
+ import { DrizzlePostRepository } from "./repositories/posts.js";
11
+ import { DrizzleChatRepository } from "./repositories/chats.js";
12
12
  import { DrizzleEntitlementsRepository } from "./repositories/entitlements.js";
13
13
  import { DrizzleIdentityRepository } from "./repositories/identity.js";
14
14
  import { DrizzleConnectionsRepository } from "./repositories/integrations.js";
15
- import { DrizzleLearnRepository } from "./repositories/learn.js";
15
+ import { DrizzleMemberRepository } from "./repositories/member.js";
16
16
  import { DrizzleMembersRepository } from "./repositories/members.js";
17
- import { orgUserProfileColumns } from "./repositories/org-user-profile.js";
17
+ import { DrizzleNotificationsRepository } from "./repositories/notifications.js";
18
+ import { authorProfileColumns, orgUserProfileColumns, studentFirstName, studentLastName, studentProfileColumns } from "./repositories/org-user-profile.js";
18
19
  import { DrizzleOrganizationsRepository } from "./repositories/organizations.js";
19
20
  import { DrizzleOutboxAppender, DrizzleOutboxStore, OUTBOX_MAX_ATTEMPTS } from "./repositories/outbox.js";
20
21
  import { DrizzleProgressRepository } from "./repositories/progress.js";
21
22
  import { DrizzleSettingsRepository, deepMerge } from "./repositories/settings.js";
22
- import { DrizzleStudentsRepository } from "./repositories/students.js";
23
- export { type Db, DrizzleAssetsRepository, DrizzleAutomationRunsRepository, DrizzleAutomationsRepository, DrizzleConnectionsRepository, DrizzleContentRepository, DrizzleCourseAnalyticsRepository, DrizzleCredentialStore, DrizzleDashboardRepository, DrizzleDiscussionRepository, DrizzleEntitlementsRepository, DrizzleIdentityRepository, DrizzleLearnRepository, DrizzleMembersRepository, DrizzleOrganizationsRepository, DrizzleOutboxAppender, DrizzleOutboxStore, DrizzleProgressRepository, DrizzleSettingsRepository, DrizzleStudentsRepository, DrizzleUnitOfWork, OUTBOX_MAX_ATTEMPTS, createDb, decryptSecret, deepMerge, encryptSecret, migrationsFolder, orgUserProfileColumns, runMigrations, index_d_exports as schema };
23
+ import { DrizzleAdminRepository } from "./repositories/admin.js";
24
+ export { type Db, DrizzleAdminRepository, DrizzleAssetsRepository, DrizzleAutomationRunsRepository, DrizzleAutomationsRepository, DrizzleChatRepository, DrizzleConnectionsRepository, DrizzleContentRepository, DrizzleCredentialStore, DrizzleDiscussionRepository, DrizzleEntitlementsRepository, DrizzleIdentityRepository, DrizzleMemberRepository, DrizzleMembersRepository, DrizzleNotificationsRepository, DrizzleOrganizationsRepository, DrizzleOutboxAppender, DrizzleOutboxStore, DrizzlePostRepository, DrizzleProgressRepository, DrizzleSettingsRepository, DrizzleUnitOfWork, OUTBOX_MAX_ATTEMPTS, authorProfileColumns, createDb, decryptSecret, deepMerge, encryptSecret, migrationsFolder, orgUserProfileColumns, runMigrations, index_d_exports as schema, studentFirstName, studentLastName, studentProfileColumns };
package/dist/index.js CHANGED
@@ -6,18 +6,19 @@ import { DrizzleAssetsRepository } from "./repositories/assets.js";
6
6
  import { DrizzleAutomationRunsRepository, DrizzleAutomationsRepository } from "./repositories/automations.js";
7
7
  import { DrizzleSettingsRepository, deepMerge } from "./repositories/settings.js";
8
8
  import { DrizzleContentRepository } from "./repositories/content.js";
9
- import { DrizzleCourseAnalyticsRepository } from "./repositories/course-analytics.js";
10
9
  import { DrizzleCredentialStore, decryptSecret, encryptSecret } from "./repositories/credentials.js";
11
- import { DrizzleDashboardRepository } from "./repositories/dashboard.js";
12
- import { orgUserProfileColumns } from "./repositories/org-user-profile.js";
10
+ import { authorProfileColumns, orgUserProfileColumns, studentFirstName, studentLastName, studentProfileColumns } from "./repositories/org-user-profile.js";
13
11
  import { DrizzleDiscussionRepository } from "./repositories/discussion.js";
12
+ import { DrizzlePostRepository } from "./repositories/posts.js";
13
+ import { DrizzleChatRepository } from "./repositories/chats.js";
14
14
  import { DrizzleEntitlementsRepository } from "./repositories/entitlements.js";
15
15
  import { DrizzleIdentityRepository } from "./repositories/identity.js";
16
16
  import { DrizzleConnectionsRepository } from "./repositories/integrations.js";
17
- import { DrizzleLearnRepository } from "./repositories/learn.js";
17
+ import { DrizzleMemberRepository } from "./repositories/member.js";
18
18
  import { DrizzleMembersRepository } from "./repositories/members.js";
19
+ import { DrizzleNotificationsRepository } from "./repositories/notifications.js";
19
20
  import { DrizzleOrganizationsRepository } from "./repositories/organizations.js";
20
21
  import { DrizzleOutboxAppender, DrizzleOutboxStore, OUTBOX_MAX_ATTEMPTS } from "./repositories/outbox.js";
21
22
  import { DrizzleProgressRepository } from "./repositories/progress.js";
22
- import { DrizzleStudentsRepository } from "./repositories/students.js";
23
- export { DrizzleAssetsRepository, DrizzleAutomationRunsRepository, DrizzleAutomationsRepository, DrizzleConnectionsRepository, DrizzleContentRepository, DrizzleCourseAnalyticsRepository, DrizzleCredentialStore, DrizzleDashboardRepository, DrizzleDiscussionRepository, DrizzleEntitlementsRepository, DrizzleIdentityRepository, DrizzleLearnRepository, DrizzleMembersRepository, DrizzleOrganizationsRepository, DrizzleOutboxAppender, DrizzleOutboxStore, DrizzleProgressRepository, DrizzleSettingsRepository, DrizzleStudentsRepository, DrizzleUnitOfWork, OUTBOX_MAX_ATTEMPTS, createDb, decryptSecret, deepMerge, encryptSecret, migrationsFolder, orgUserProfileColumns, runMigrations, schema_exports as schema };
23
+ import { DrizzleAdminRepository } from "./repositories/admin.js";
24
+ export { DrizzleAdminRepository, DrizzleAssetsRepository, DrizzleAutomationRunsRepository, DrizzleAutomationsRepository, DrizzleChatRepository, DrizzleConnectionsRepository, DrizzleContentRepository, DrizzleCredentialStore, DrizzleDiscussionRepository, DrizzleEntitlementsRepository, DrizzleIdentityRepository, DrizzleMemberRepository, DrizzleMembersRepository, DrizzleNotificationsRepository, DrizzleOrganizationsRepository, DrizzleOutboxAppender, DrizzleOutboxStore, DrizzlePostRepository, DrizzleProgressRepository, DrizzleSettingsRepository, DrizzleUnitOfWork, OUTBOX_MAX_ATTEMPTS, authorProfileColumns, createDb, decryptSecret, deepMerge, encryptSecret, migrationsFolder, orgUserProfileColumns, runMigrations, schema_exports as schema, studentFirstName, studentLastName, studentProfileColumns };
@@ -0,0 +1,23 @@
1
+ import { NodePgDatabase } from "drizzle-orm/node-postgres";
2
+ import { Logger } from "@headless-lms/core/shared/ports";
3
+ import { AdminReportRepository, CommunityAnalytics, CommunityMember, CommunityMembersQuery, CourseAnalytics, CourseEnrollmentPoint, EnrollmentPoint, OverviewStats, Page, Student, StudentAnalytics, StudentsQuery } from "@headless-lms/core/reporting/admin";
4
+ //#region src/repositories/admin.d.ts
5
+ declare class DrizzleAdminRepository implements AdminReportRepository {
6
+ private readonly db;
7
+ private readonly logger;
8
+ constructor(db: NodePgDatabase, logger?: Logger);
9
+ overview(orgId: string): Promise<OverviewStats>;
10
+ enrollments(orgId: string, days: number): Promise<EnrollmentPoint[]>;
11
+ private courseExists;
12
+ courseAnalytics(orgId: string, courseId: string): Promise<CourseAnalytics | null>;
13
+ courseEnrollments(orgId: string, courseId: string, days: number): Promise<CourseEnrollmentPoint[] | null>;
14
+ listStudents(orgId: string, query: StudentsQuery): Promise<Page<Student>>;
15
+ getStudent(orgId: string, id: string): Promise<Student | null>;
16
+ studentAnalytics(orgId: string, id: string): Promise<StudentAnalytics | null>;
17
+ private resolveOrder;
18
+ communityAnalytics(orgId: string, communityId: string): Promise<CommunityAnalytics | null>;
19
+ communityMembers(orgId: string, communityId: string, query: CommunityMembersQuery): Promise<Page<CommunityMember> | null>;
20
+ }
21
+ //#endregion
22
+ export { DrizzleAdminRepository };
23
+ //# sourceMappingURL=admin.d.ts.map
@@ -0,0 +1,393 @@
1
+ import { communities, courses, spaces } from "../schema/content.js";
2
+ import { commentFlags, comments, messageFlags, messages, postFlags, posts } from "../schema/discussion.js";
3
+ import { users } from "../schema/identity.js";
4
+ import { orgUsers } from "../schema/organizations.js";
5
+ import { contentAccess, entitlements } from "../schema/entitlements.js";
6
+ import "../schema/index.js";
7
+ import { translateDbErrors } from "./pg-errors.js";
8
+ import { user } from "../schema/better-auth.js";
9
+ import { orgUserProfileColumns, studentFirstName, studentLastName, studentProfileColumns } from "./org-user-profile.js";
10
+ import { grantExpired, grantOpens } from "./grant-covers.js";
11
+ import { and, asc, desc, eq, exists, ilike, inArray, or, sql } from "drizzle-orm";
12
+ import { union } from "drizzle-orm/pg-core";
13
+ import { noopLogger } from "@headless-lms/core/shared/logger";
14
+ import { STUDENT_ROLE } from "@headless-lms/core/organizations";
15
+ //#region src/repositories/admin.ts
16
+ const entitlementCountExpr = sql`count(${entitlements.id})`;
17
+ const avgProgressExpr = sql`0`;
18
+ const lastActiveExpr = sql`(
19
+ select max(pr.updated_at) from progress_records pr
20
+ where pr.org_id = ${orgUsers.orgId} and pr.org_user_id = ${orgUsers.id}
21
+ )`;
22
+ function toStudent(row) {
23
+ return {
24
+ id: row.id,
25
+ email: row.email,
26
+ image: row.image ?? null,
27
+ firstName: row.firstName,
28
+ lastName: row.lastName,
29
+ status: row.status,
30
+ entitlementCount: Number(row.entitlementCount),
31
+ avgProgress: Number(row.avgProgress),
32
+ joinedAt: row.createdAt.toISOString(),
33
+ lastActiveAt: row.lastActiveAt ? new Date(row.lastActiveAt).toISOString() : null
34
+ };
35
+ }
36
+ var DrizzleAdminRepository = class {
37
+ db;
38
+ logger;
39
+ constructor(db, logger = noopLogger) {
40
+ this.db = db;
41
+ this.logger = logger;
42
+ }
43
+ async overview(orgId) {
44
+ const [courseCounts] = await this.db.select({
45
+ published: sql`count(*) filter (where ${courses.status} = 'published')`,
46
+ draft: sql`count(*) filter (where ${courses.status} = 'draft')`
47
+ }).from(courses).where(eq(courses.orgId, orgId));
48
+ const [entitlementCounts] = await this.db.select({
49
+ activeStudents: sql`count(distinct ${contentAccess.orgUserId})`,
50
+ expiringSoon: sql`count(distinct ${contentAccess.entitlementId}) filter (where ${grantExpired(contentAccess.expiresAt, sql`now() + interval '14 days'`)})`
51
+ }).from(contentAccess).where(eq(contentAccess.orgId, orgId));
52
+ return {
53
+ publishedCourses: Number(courseCounts?.published ?? 0),
54
+ draftCourses: Number(courseCounts?.draft ?? 0),
55
+ activeStudents: Number(entitlementCounts?.activeStudents ?? 0),
56
+ expiringSoon: Number(entitlementCounts?.expiringSoon ?? 0)
57
+ };
58
+ }
59
+ async enrollments(orgId, days) {
60
+ return (await this.db.execute(sql`
61
+ select
62
+ to_char(d.day, 'YYYY-MM-DD') as date,
63
+ count(${entitlements.id})::int as count
64
+ from generate_series(
65
+ (now() at time zone 'utc')::date - (${days} - 1),
66
+ (now() at time zone 'utc')::date,
67
+ interval '1 day'
68
+ ) as d(day)
69
+ left join ${entitlements}
70
+ on ${entitlements.orgId} = ${orgId}
71
+ and (${entitlements.grantedAt} at time zone 'utc')::date = d.day::date
72
+ group by d.day
73
+ order by d.day
74
+ `)).rows.map((r) => ({
75
+ date: r.date,
76
+ count: Number(r.count)
77
+ }));
78
+ }
79
+ async courseExists(orgId, courseId) {
80
+ const [row] = await this.db.select({ id: courses.id }).from(courses).where(and(eq(courses.orgId, orgId), eq(courses.id, courseId)));
81
+ return row !== void 0;
82
+ }
83
+ async courseAnalytics(orgId, courseId) {
84
+ if (!await this.courseExists(orgId, courseId)) return null;
85
+ const cohort = sql`
86
+ select distinct org_user_id from content_access
87
+ where org_id = ${orgId}
88
+ and content_id = ${courseId}
89
+ `;
90
+ const publishedActivities = sql`
91
+ select a.id, a.settings, a.seq, a.module_id
92
+ from activities a
93
+ where a.org_id = ${orgId}
94
+ and a.course_id = ${courseId}
95
+ and coalesce((a.settings ->> 'published')::boolean, true)
96
+ `;
97
+ const summary = (await this.db.execute(sql`
98
+ with cohort as (${cohort}),
99
+ acts as (${publishedActivities}),
100
+ per_learner as (
101
+ select c.org_user_id,
102
+ count(pr.id) filter (where pr.completed_at is not null) as done
103
+ from cohort c
104
+ left join progress_records pr
105
+ on pr.org_id = ${orgId}
106
+ and pr.org_user_id = c.org_user_id
107
+ and pr.target_type = 'activity'
108
+ and pr.target_id in (select id from acts)
109
+ group by c.org_user_id
110
+ )
111
+ select
112
+ (select count(*) from cohort)::int as enrolled,
113
+ (
114
+ select count(distinct pr.org_user_id) from progress_records pr
115
+ where pr.org_id = ${orgId}
116
+ and pr.org_user_id in (select org_user_id from cohort)
117
+ and (
118
+ (pr.target_type = 'activity' and pr.target_id in (select id from acts))
119
+ or (pr.target_type = 'course' and pr.target_id = ${courseId})
120
+ )
121
+ )::int as started,
122
+ (
123
+ select count(*) from progress_records pr
124
+ where pr.org_id = ${orgId}
125
+ and pr.org_user_id in (select org_user_id from cohort)
126
+ and pr.target_type = 'course'
127
+ and pr.target_id = ${courseId}
128
+ and pr.completed_at is not null
129
+ )::int as completed,
130
+ coalesce((
131
+ select round(avg(least(done::numeric / nullif((select count(*) from acts), 0), 1) * 100))
132
+ from per_learner
133
+ ), 0)::int as avg_progress
134
+ `)).rows[0];
135
+ const activities = (await this.db.execute(sql`
136
+ with cohort as (${cohort})
137
+ select
138
+ a.id,
139
+ coalesce(a.settings ->> 'title', '') as title,
140
+ m.id as module_id,
141
+ m.title as module_title,
142
+ count(pr.id)::int as started,
143
+ count(pr.id) filter (where pr.completed_at is not null)::int as completed
144
+ from activities a
145
+ join modules m on m.org_id = a.org_id and m.id = a.module_id
146
+ left join progress_records pr
147
+ on pr.org_id = ${orgId}
148
+ and pr.target_type = 'activity'
149
+ and pr.target_id = a.id
150
+ and pr.org_user_id in (select org_user_id from cohort)
151
+ where a.org_id = ${orgId}
152
+ and a.course_id = ${courseId}
153
+ and coalesce((a.settings ->> 'published')::boolean, true)
154
+ group by a.id, a.settings, m.id, m.title, m.seq, a.seq
155
+ order by m.seq, a.seq
156
+ `)).rows.map((r) => ({
157
+ activityId: r.id,
158
+ title: r.title,
159
+ moduleId: r.module_id,
160
+ moduleTitle: r.module_title,
161
+ started: Number(r.started),
162
+ completed: Number(r.completed)
163
+ }));
164
+ return {
165
+ enrolled: Number(summary?.enrolled ?? 0),
166
+ started: Number(summary?.started ?? 0),
167
+ completed: Number(summary?.completed ?? 0),
168
+ avgProgress: Number(summary?.avg_progress ?? 0),
169
+ activities
170
+ };
171
+ }
172
+ async courseEnrollments(orgId, courseId, days) {
173
+ if (!await this.courseExists(orgId, courseId)) return null;
174
+ return (await this.db.execute(sql`
175
+ select
176
+ to_char(d.day, 'YYYY-MM-DD') as date,
177
+ count(${entitlements.id})::int as count
178
+ from generate_series(
179
+ (now() at time zone 'utc')::date - (${days} - 1),
180
+ (now() at time zone 'utc')::date,
181
+ interval '1 day'
182
+ ) as d(day)
183
+ left join ${entitlements}
184
+ on ${entitlements.orgId} = ${orgId}
185
+ and ${entitlements.contentId} = ${courseId}
186
+ and (${entitlements.grantedAt} at time zone 'utc')::date = d.day::date
187
+ group by d.day
188
+ order by d.day
189
+ `)).rows.map((r) => ({
190
+ date: r.date,
191
+ count: Number(r.count)
192
+ }));
193
+ }
194
+ async listStudents(orgId, query) {
195
+ const filters = [eq(orgUsers.orgId, orgId), eq(orgUsers.role, STUDENT_ROLE)];
196
+ const q = query.search?.trim();
197
+ if (q) {
198
+ const like = `%${q}%`;
199
+ filters.push(or(ilike(studentFirstName, like), ilike(studentLastName, like), ilike(users.email, like)));
200
+ }
201
+ const where = and(...filters);
202
+ const [totals] = await this.db.select({ total: sql`count(*)` }).from(orgUsers).innerJoin(users, eq(users.id, orgUsers.userId)).where(where);
203
+ return {
204
+ rows: (await this.db.select({
205
+ ...studentProfileColumns,
206
+ status: orgUsers.status,
207
+ createdAt: orgUsers.createdAt,
208
+ entitlementCount: entitlementCountExpr,
209
+ avgProgress: avgProgressExpr,
210
+ lastActiveAt: lastActiveExpr
211
+ }).from(orgUsers).leftJoin(entitlements, and(eq(entitlements.orgId, orgUsers.orgId), eq(entitlements.orgUserId, orgUsers.id))).innerJoin(users, eq(users.id, orgUsers.userId)).leftJoin(user, eq(user.id, users.externalId)).where(where).groupBy(orgUsers.orgId, orgUsers.id, orgUsers.status, users.id, users.externalId, users.email, users.firstName, users.lastName, user.image).orderBy(...this.resolveOrder(query.sort)).limit(query.pageSize).offset((query.page - 1) * query.pageSize)).map(toStudent),
212
+ total: Number(totals?.total ?? 0),
213
+ page: query.page,
214
+ pageSize: query.pageSize
215
+ };
216
+ }
217
+ async getStudent(orgId, id) {
218
+ const [row] = await this.db.select({
219
+ ...studentProfileColumns,
220
+ status: orgUsers.status,
221
+ createdAt: orgUsers.createdAt,
222
+ entitlementCount: entitlementCountExpr,
223
+ avgProgress: avgProgressExpr,
224
+ lastActiveAt: lastActiveExpr
225
+ }).from(orgUsers).leftJoin(entitlements, and(eq(entitlements.orgId, orgUsers.orgId), eq(entitlements.orgUserId, orgUsers.id))).innerJoin(users, eq(users.id, orgUsers.userId)).leftJoin(user, eq(user.id, users.externalId)).where(and(eq(orgUsers.orgId, orgId), eq(orgUsers.id, id), eq(orgUsers.role, STUDENT_ROLE))).groupBy(orgUsers.orgId, orgUsers.id, orgUsers.status, users.id, users.externalId, users.email, users.firstName, users.lastName, user.image).limit(1);
226
+ return row ? toStudent(row) : null;
227
+ }
228
+ async studentAnalytics(orgId, id) {
229
+ const [exists] = await this.db.select({ id: orgUsers.id }).from(orgUsers).where(and(eq(orgUsers.orgId, orgId), eq(orgUsers.id, id), eq(orgUsers.role, STUDENT_ROLE)));
230
+ if (!exists) return null;
231
+ const courses = (await this.db.execute(sql`
232
+ with cohort_courses as (
233
+ select distinct c.id, c.title
234
+ from content_access ca
235
+ join courses c on c.org_id = ca.org_id and c.id = ca.content_id
236
+ where ca.org_id = ${orgId}
237
+ and ca.org_user_id = ${id}
238
+ ),
239
+ acts as (
240
+ select a.course_id, a.id
241
+ from activities a
242
+ where a.org_id = ${orgId}
243
+ and a.course_id in (select id from cohort_courses)
244
+ and coalesce((a.settings ->> 'published')::boolean, true)
245
+ )
246
+ select
247
+ cc.id as course_id,
248
+ cc.title,
249
+ count(a.id)::int as total_activities,
250
+ count(pr.id) filter (where pr.completed_at is not null)::int as completed_activities,
251
+ min(pr.started_at) as started_at,
252
+ max(pr.updated_at) as last_activity_at,
253
+ (
254
+ select prc.completed_at from progress_records prc
255
+ where prc.org_id = ${orgId}
256
+ and prc.org_user_id = ${id}
257
+ and prc.target_type = 'course'
258
+ and prc.target_id = cc.id
259
+ ) as completed_at
260
+ from cohort_courses cc
261
+ left join acts a on a.course_id = cc.id
262
+ left join progress_records pr
263
+ on pr.org_id = ${orgId}
264
+ and pr.org_user_id = ${id}
265
+ and pr.target_type = 'activity'
266
+ and pr.target_id = a.id
267
+ group by cc.id, cc.title
268
+ order by cc.title
269
+ `)).rows.map((r) => {
270
+ const total = Number(r.total_activities);
271
+ const done = Number(r.completed_activities);
272
+ return {
273
+ courseId: r.course_id,
274
+ title: r.title,
275
+ totalActivities: total,
276
+ completedActivities: done,
277
+ progress: total > 0 ? Math.round(Math.min(done / total, 1) * 100) : 0,
278
+ startedAt: r.started_at ? new Date(r.started_at).toISOString() : null,
279
+ lastActivityAt: r.last_activity_at ? new Date(r.last_activity_at).toISOString() : null,
280
+ completedAt: r.completed_at ? new Date(r.completed_at).toISOString() : null
281
+ };
282
+ });
283
+ const started = courses.filter((c) => c.startedAt !== null).length;
284
+ const completed = courses.filter((c) => c.completedAt !== null).length;
285
+ const avgProgress = courses.length > 0 ? Math.round(courses.reduce((sum, c) => sum + c.progress, 0) / courses.length) : 0;
286
+ return {
287
+ enrolled: courses.length,
288
+ started,
289
+ completed,
290
+ avgProgress,
291
+ courses
292
+ };
293
+ }
294
+ resolveOrder(sort) {
295
+ const descending = sort?.startsWith("-") ?? false;
296
+ const field = sort ? descending ? sort.slice(1) : sort : "firstName";
297
+ const dir = descending ? desc : asc;
298
+ switch (field) {
299
+ case "email": return [dir(users.email)];
300
+ case "entitlementCount": return [dir(entitlementCountExpr)];
301
+ case "avgProgress": return [dir(avgProgressExpr)];
302
+ case "joinedAt": return [dir(orgUsers.createdAt)];
303
+ case "lastActiveAt": return [dir(lastActiveExpr)];
304
+ case "lastName": return [dir(studentLastName), dir(studentFirstName)];
305
+ default: return [dir(studentFirstName), dir(studentLastName)];
306
+ }
307
+ }
308
+ async communityAnalytics(orgId, communityId) {
309
+ const [community] = await this.db.select({ id: communities.id }).from(communities).where(and(eq(communities.orgId, orgId), eq(communities.id, communityId))).limit(1);
310
+ if (!community) return null;
311
+ const opens = grantOpens(entitlements, sql`${communityId}`, "community");
312
+ const lapsed = and(eq(entitlements.orgId, orgId), eq(entitlements.status, "active"), grantExpired(entitlements.expiresAt), opens);
313
+ const recent = sql`now() - interval '30 days'`;
314
+ const [[members], [lapsedGrants], authors, postsPerSpace, commentsPerPost, [sent], [postFlagRow], [commentFlagRow], [messageFlagRow], lapsedHolders] = await Promise.all([
315
+ this.db.select({ count: sql`cast(count(distinct ${contentAccess.orgUserId}) as int)` }).from(contentAccess).where(and(eq(contentAccess.orgId, orgId), eq(contentAccess.contentId, communityId))),
316
+ this.db.select({ count: sql`cast(count(*) as int)` }).from(entitlements).where(lapsed),
317
+ union(this.db.select({ id: posts.orgUserId }).from(posts).where(and(eq(posts.orgId, orgId), eq(posts.communityId, communityId), sql`${posts.createdAt} >= ${recent}`)), this.db.select({ id: comments.orgUserId }).from(comments).where(and(eq(comments.orgId, orgId), eq(comments.communityId, communityId), sql`${comments.createdAt} >= ${recent}`)), this.db.select({ id: messages.orgUserId }).from(messages).where(and(eq(messages.orgId, orgId), eq(messages.communityId, communityId), sql`${messages.createdAt} >= ${recent}`))),
318
+ this.db.select({
319
+ spaceId: spaces.id,
320
+ count: sql`cast(count(${posts.id}) as int)`
321
+ }).from(spaces).leftJoin(posts, and(eq(posts.orgId, spaces.orgId), eq(posts.spaceId, spaces.id), eq(posts.status, "published"))).where(and(eq(spaces.orgId, orgId), eq(spaces.communityId, communityId))).groupBy(spaces.id),
322
+ this.db.select({
323
+ postId: posts.id,
324
+ count: sql`cast(count(${comments.id}) as int)`
325
+ }).from(posts).leftJoin(comments, and(eq(comments.orgId, posts.orgId), eq(comments.postId, posts.id), eq(comments.status, "published"))).where(and(eq(posts.orgId, orgId), eq(posts.communityId, communityId), eq(posts.status, "published"))).groupBy(posts.id),
326
+ this.db.select({ count: sql`cast(count(*) as int)` }).from(messages).where(and(eq(messages.orgId, orgId), eq(messages.communityId, communityId))),
327
+ this.db.select({
328
+ raised: sql`cast(count(*) as int)`,
329
+ resolved: sql`cast(count(*) filter (where ${postFlags.resolvedAt} is not null) as int)`
330
+ }).from(postFlags).innerJoin(posts, and(eq(posts.orgId, postFlags.orgId), eq(posts.id, postFlags.postId))).where(and(eq(postFlags.orgId, orgId), eq(posts.communityId, communityId))),
331
+ this.db.select({
332
+ raised: sql`cast(count(*) as int)`,
333
+ resolved: sql`cast(count(*) filter (where ${commentFlags.resolvedAt} is not null) as int)`
334
+ }).from(commentFlags).innerJoin(comments, and(eq(comments.orgId, commentFlags.orgId), eq(comments.id, commentFlags.commentId))).where(and(eq(commentFlags.orgId, orgId), eq(comments.communityId, communityId))),
335
+ this.db.select({
336
+ raised: sql`cast(count(*) as int)`,
337
+ resolved: sql`cast(count(*) filter (where ${messageFlags.resolvedAt} is not null) as int)`
338
+ }).from(messageFlags).where(and(eq(messageFlags.orgId, orgId), eq(messageFlags.communityId, communityId))),
339
+ this.db.select({ orgUserId: entitlements.orgUserId }).from(entitlements).where(lapsed).groupBy(entitlements.orgUserId)
340
+ ]);
341
+ const holderIds = lapsedHolders.map((row) => row.orgUserId);
342
+ const [renewed] = holderIds.length === 0 ? [{ count: 0 }] : await this.db.select({ count: sql`cast(count(distinct ${contentAccess.orgUserId}) as int)` }).from(contentAccess).where(and(eq(contentAccess.orgId, orgId), eq(contentAccess.contentId, communityId), inArray(contentAccess.orgUserId, holderIds)));
343
+ const raised = Number(postFlagRow?.raised ?? 0) + Number(commentFlagRow?.raised ?? 0) + Number(messageFlagRow?.raised ?? 0);
344
+ const resolved = Number(postFlagRow?.resolved ?? 0) + Number(commentFlagRow?.resolved ?? 0) + Number(messageFlagRow?.resolved ?? 0);
345
+ return {
346
+ memberCount: Number(members?.count ?? 0),
347
+ activeMembers: authors.length,
348
+ postsPerSpace: postsPerSpace.map((row) => ({
349
+ spaceId: row.spaceId,
350
+ count: Number(row.count)
351
+ })),
352
+ commentsPerPost: commentsPerPost.map((row) => ({
353
+ postId: row.postId,
354
+ count: Number(row.count)
355
+ })),
356
+ messagesSent: Number(sent?.count ?? 0),
357
+ flagsRaised: raised,
358
+ flagsResolved: resolved,
359
+ grantsLapsed: Number(lapsedGrants?.count ?? 0),
360
+ grantsRenewed: Number(renewed?.count ?? 0)
361
+ };
362
+ }
363
+ async communityMembers(orgId, communityId, query) {
364
+ const [community] = await this.db.select({ id: communities.id }).from(communities).where(and(eq(communities.orgId, orgId), eq(communities.id, communityId))).limit(1);
365
+ if (!community) return null;
366
+ const holds = exists(this.db.select({ id: contentAccess.entitlementId }).from(contentAccess).where(and(eq(contentAccess.orgId, orgUsers.orgId), eq(contentAccess.orgUserId, orgUsers.id), eq(contentAccess.contentId, communityId))));
367
+ const filters = [eq(orgUsers.orgId, orgId), holds];
368
+ const q = query.search?.trim();
369
+ if (q) {
370
+ const like = `%${q}%`;
371
+ filters.push(or(ilike(studentFirstName, like), ilike(studentLastName, like), ilike(users.email, like)));
372
+ }
373
+ const where = and(...filters);
374
+ const [totals] = await this.db.select({ total: sql`count(*)` }).from(orgUsers).innerJoin(users, eq(users.id, orgUsers.userId)).where(where);
375
+ return {
376
+ rows: (await this.db.select({
377
+ ...orgUserProfileColumns,
378
+ role: orgUsers.role
379
+ }).from(orgUsers).innerJoin(users, eq(users.id, orgUsers.userId)).leftJoin(user, eq(user.id, users.externalId)).where(where).orderBy(asc(users.firstName), asc(users.lastName), asc(orgUsers.id)).limit(query.pageSize).offset((query.page - 1) * query.pageSize)).map((row) => ({
380
+ ...row,
381
+ image: row.image ?? null
382
+ })),
383
+ total: Number(totals?.total ?? 0),
384
+ page: query.page,
385
+ pageSize: query.pageSize
386
+ };
387
+ }
388
+ };
389
+ translateDbErrors(DrizzleAdminRepository);
390
+ //#endregion
391
+ export { DrizzleAdminRepository };
392
+
393
+ //# sourceMappingURL=admin.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admin.js","names":[],"sources":["../../src/repositories/admin.ts"],"sourcesContent":["// reporting/admin — Drizzle repository.\nimport { and, asc, desc, eq, exists, ilike, inArray, or, sql, type SQL } from \"drizzle-orm\";\nimport { union } from \"drizzle-orm/pg-core\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\nimport type {\n AdminReportRepository,\n CommunityAnalytics,\n CommunityMember,\n CommunityMembersQuery,\n CourseActivityEngagement,\n CourseAnalytics,\n CourseEnrollmentPoint,\n EnrollmentPoint,\n OverviewStats,\n Page,\n Student,\n StudentAnalytics,\n StudentCourseProgress,\n StudentsQuery,\n} from \"@headless-lms/core/reporting/admin\";\nimport {\n commentFlags,\n comments,\n communities,\n contentAccess,\n courses,\n entitlements,\n messageFlags,\n messages,\n orgUsers,\n postFlags,\n posts,\n spaces,\n users,\n} from \"../schema/index.js\";\nimport { STUDENT_ROLE } from \"@headless-lms/core/organizations\";\nimport { user } from \"../schema/better-auth.js\";\nimport type { OrgUserStatus } from \"@headless-lms/core/types\";\nimport type { Logger } from \"@headless-lms/core/shared/ports\";\nimport { noopLogger } from \"@headless-lms/core/shared/logger\";\nimport {\n orgUserProfileColumns,\n studentFirstName,\n studentLastName,\n studentProfileColumns,\n} from \"./org-user-profile.js\";\nimport { translateDbErrors } from \"./pg-errors.js\";\nimport { grantExpired, grantOpens } from \"./grant-covers.js\";\n\nconst entitlementCountExpr = sql<number>`count(${entitlements.id})`;\n// Completion now lives in the progress domain; the students report no longer\n// derives a percentage from entitlements. Placeholder until wired to progress.\nconst avgProgressExpr = sql<number>`0`;\n// Last learning activity: the newest touch on any of the student's progress\n// records. A scalar subquery so it neither fans out the entitlement count nor\n// widens the GROUP BY.\nconst lastActiveExpr = sql<Date | null>`(\n select max(pr.updated_at) from progress_records pr\n where pr.org_id = ${orgUsers.orgId} and pr.org_user_id = ${orgUsers.id}\n)`;\n\ninterface StudentRow {\n id: string;\n email: string;\n image: string | null;\n firstName: string | null;\n lastName: string | null;\n status: OrgUserStatus;\n createdAt: Date;\n entitlementCount: number;\n avgProgress: number;\n lastActiveAt: Date | null;\n}\n\nfunction toStudent(row: StudentRow): Student {\n return {\n id: row.id,\n email: row.email,\n image: row.image ?? null,\n firstName: row.firstName,\n lastName: row.lastName,\n status: row.status,\n entitlementCount: Number(row.entitlementCount),\n avgProgress: Number(row.avgProgress),\n joinedAt: row.createdAt.toISOString(),\n lastActiveAt: row.lastActiveAt ? new Date(row.lastActiveAt).toISOString() : null,\n };\n}\n\nexport class DrizzleAdminRepository implements AdminReportRepository {\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 [entitlementCounts] = await this.db\n .select({\n activeStudents: sql<number>`count(distinct ${contentAccess.orgUserId})`,\n expiringSoon: sql<number>`count(distinct ${contentAccess.entitlementId}) filter (where ${grantExpired(contentAccess.expiresAt, sql`now() + interval '14 days'`)})`,\n })\n .from(contentAccess)\n .where(eq(contentAccess.orgId, orgId));\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\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 courseAnalytics(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 distinct org_user_id from content_access\n where org_id = ${orgId}\n and content_id = ${courseId}\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 courseEnrollments(\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\n async listStudents(orgId: string, query: StudentsQuery): Promise<Page<Student>> {\n // Staff share this table now — the role filter is what keeps them out of\n // the students report.\n const filters: SQL[] = [eq(orgUsers.orgId, orgId), eq(orgUsers.role, STUDENT_ROLE)];\n const q = query.search?.trim();\n if (q) {\n const like = `%${q}%`;\n filters.push(\n or(ilike(studentFirstName, like), ilike(studentLastName, like), ilike(users.email, like))!,\n );\n }\n const where = and(...filters);\n\n const [totals] = await this.db\n .select({ total: sql<number>`count(*)` })\n .from(orgUsers)\n .innerJoin(users, eq(users.id, orgUsers.userId))\n .where(where);\n\n const rows = await this.db\n .select({\n ...studentProfileColumns,\n status: orgUsers.status,\n createdAt: orgUsers.createdAt,\n entitlementCount: entitlementCountExpr,\n avgProgress: avgProgressExpr,\n lastActiveAt: lastActiveExpr,\n })\n .from(orgUsers)\n .leftJoin(\n entitlements,\n and(eq(entitlements.orgId, orgUsers.orgId), eq(entitlements.orgUserId, orgUsers.id)),\n )\n .innerJoin(users, eq(users.id, orgUsers.userId))\n .leftJoin(user, eq(user.id, users.externalId))\n .where(where)\n // Group by the full composite PK (orgId, id): grouping by id alone gives\n // Postgres no functional dependency for the other students columns.\n .groupBy(\n orgUsers.orgId,\n orgUsers.id,\n orgUsers.status,\n users.id,\n users.externalId,\n users.email,\n users.firstName,\n users.lastName,\n user.image,\n )\n .orderBy(...this.resolveOrder(query.sort))\n .limit(query.pageSize)\n .offset((query.page - 1) * query.pageSize);\n\n return {\n rows: rows.map(toStudent),\n total: Number(totals?.total ?? 0),\n page: query.page,\n pageSize: query.pageSize,\n };\n }\n\n async getStudent(orgId: string, id: string): Promise<Student | null> {\n const [row] = await this.db\n .select({\n ...studentProfileColumns,\n status: orgUsers.status,\n createdAt: orgUsers.createdAt,\n entitlementCount: entitlementCountExpr,\n avgProgress: avgProgressExpr,\n lastActiveAt: lastActiveExpr,\n })\n .from(orgUsers)\n .leftJoin(\n entitlements,\n and(eq(entitlements.orgId, orgUsers.orgId), eq(entitlements.orgUserId, orgUsers.id)),\n )\n .innerJoin(users, eq(users.id, orgUsers.userId))\n .leftJoin(user, eq(user.id, users.externalId))\n .where(and(eq(orgUsers.orgId, orgId), eq(orgUsers.id, id), eq(orgUsers.role, STUDENT_ROLE)))\n .groupBy(\n orgUsers.orgId,\n orgUsers.id,\n orgUsers.status,\n users.id,\n users.externalId,\n users.email,\n users.firstName,\n users.lastName,\n user.image,\n )\n .limit(1);\n return row ? toStudent(row) : null;\n }\n\n // Learner record: one row per course the student holds an effective-active\n // entitlement to (mirroring the course-analytics cohort rule), with progress\n // computed over published activities the same way the course analytics read does.\n async studentAnalytics(orgId: string, id: string): Promise<StudentAnalytics | null> {\n const [exists] = await this.db\n .select({ id: orgUsers.id })\n .from(orgUsers)\n .where(and(eq(orgUsers.orgId, orgId), eq(orgUsers.id, id), eq(orgUsers.role, STUDENT_ROLE)));\n if (!exists) {\n return null;\n }\n\n const result = await this.db.execute(sql`\n with cohort_courses as (\n select distinct c.id, c.title\n from content_access ca\n join courses c on c.org_id = ca.org_id and c.id = ca.content_id\n where ca.org_id = ${orgId}\n and ca.org_user_id = ${id}\n ),\n acts as (\n select a.course_id, a.id\n from activities a\n where a.org_id = ${orgId}\n and a.course_id in (select id from cohort_courses)\n and coalesce((a.settings ->> 'published')::boolean, true)\n )\n select\n cc.id as course_id,\n cc.title,\n count(a.id)::int as total_activities,\n count(pr.id) filter (where pr.completed_at is not null)::int as completed_activities,\n min(pr.started_at) as started_at,\n max(pr.updated_at) as last_activity_at,\n (\n select prc.completed_at from progress_records prc\n where prc.org_id = ${orgId}\n and prc.org_user_id = ${id}\n and prc.target_type = 'course'\n and prc.target_id = cc.id\n ) as completed_at\n from cohort_courses cc\n left join acts a on a.course_id = cc.id\n left join progress_records pr\n on pr.org_id = ${orgId}\n and pr.org_user_id = ${id}\n and pr.target_type = 'activity'\n and pr.target_id = a.id\n group by cc.id, cc.title\n order by cc.title\n `);\n\n const courses: StudentCourseProgress[] = (\n result.rows as {\n course_id: string;\n title: string;\n total_activities: number;\n completed_activities: number;\n started_at: Date | string | null;\n last_activity_at: Date | string | null;\n completed_at: Date | string | null;\n }[]\n ).map((r) => {\n const total = Number(r.total_activities);\n const done = Number(r.completed_activities);\n return {\n courseId: r.course_id,\n title: r.title,\n totalActivities: total,\n completedActivities: done,\n progress: total > 0 ? Math.round(Math.min(done / total, 1) * 100) : 0,\n startedAt: r.started_at ? new Date(r.started_at).toISOString() : null,\n lastActivityAt: r.last_activity_at ? new Date(r.last_activity_at).toISOString() : null,\n completedAt: r.completed_at ? new Date(r.completed_at).toISOString() : null,\n };\n });\n\n const started = courses.filter((c) => c.startedAt !== null).length;\n const completed = courses.filter((c) => c.completedAt !== null).length;\n const avgProgress =\n courses.length > 0\n ? Math.round(courses.reduce((sum, c) => sum + c.progress, 0) / courses.length)\n : 0;\n\n return { enrolled: courses.length, started, completed, avgProgress, courses };\n }\n\n private resolveOrder(sort?: string): SQL[] {\n const descending = sort?.startsWith(\"-\") ?? false;\n const field = sort ? (descending ? sort.slice(1) : sort) : \"firstName\";\n const dir = descending ? desc : asc;\n switch (field) {\n case \"email\":\n return [dir(users.email)];\n case \"entitlementCount\":\n return [dir(entitlementCountExpr)];\n case \"avgProgress\":\n return [dir(avgProgressExpr)];\n case \"joinedAt\":\n return [dir(orgUsers.createdAt)];\n case \"lastActiveAt\":\n return [dir(lastActiveExpr)];\n case \"lastName\":\n return [dir(studentLastName), dir(studentFirstName)];\n case \"firstName\":\n default:\n return [dir(studentFirstName), dir(studentLastName)];\n }\n }\n\n async communityAnalytics(orgId: string, communityId: string): Promise<CommunityAnalytics | null> {\n const [community] = await this.db\n .select({ id: communities.id })\n .from(communities)\n .where(and(eq(communities.orgId, orgId), eq(communities.id, communityId)))\n .limit(1);\n if (!community) {\n return null;\n }\n\n const opens = grantOpens(entitlements, sql`${communityId}`, \"community\");\n const lapsed = and(\n eq(entitlements.orgId, orgId),\n eq(entitlements.status, \"active\"),\n grantExpired(entitlements.expiresAt),\n opens,\n );\n const recent = sql`now() - interval '30 days'`;\n\n const [\n [members],\n [lapsedGrants],\n authors,\n postsPerSpace,\n commentsPerPost,\n [sent],\n [postFlagRow],\n [commentFlagRow],\n [messageFlagRow],\n lapsedHolders,\n ] = await Promise.all([\n this.db\n .select({ count: sql<number>`cast(count(distinct ${contentAccess.orgUserId}) as int)` })\n .from(contentAccess)\n .where(and(eq(contentAccess.orgId, orgId), eq(contentAccess.contentId, communityId))),\n this.db\n .select({ count: sql<number>`cast(count(*) as int)` })\n .from(entitlements)\n .where(lapsed),\n union(\n this.db\n .select({ id: posts.orgUserId })\n .from(posts)\n .where(\n and(\n eq(posts.orgId, orgId),\n eq(posts.communityId, communityId),\n sql`${posts.createdAt} >= ${recent}`,\n ),\n ),\n this.db\n .select({ id: comments.orgUserId })\n .from(comments)\n .where(\n and(\n eq(comments.orgId, orgId),\n eq(comments.communityId, communityId),\n sql`${comments.createdAt} >= ${recent}`,\n ),\n ),\n this.db\n .select({ id: messages.orgUserId })\n .from(messages)\n .where(\n and(\n eq(messages.orgId, orgId),\n eq(messages.communityId, communityId),\n sql`${messages.createdAt} >= ${recent}`,\n ),\n ),\n ),\n this.db\n .select({\n spaceId: spaces.id,\n count: sql<number>`cast(count(${posts.id}) as int)`,\n })\n .from(spaces)\n .leftJoin(\n posts,\n and(\n eq(posts.orgId, spaces.orgId),\n eq(posts.spaceId, spaces.id),\n eq(posts.status, \"published\"),\n ),\n )\n .where(and(eq(spaces.orgId, orgId), eq(spaces.communityId, communityId)))\n .groupBy(spaces.id),\n this.db\n .select({\n postId: posts.id,\n count: sql<number>`cast(count(${comments.id}) as int)`,\n })\n .from(posts)\n .leftJoin(\n comments,\n and(\n eq(comments.orgId, posts.orgId),\n eq(comments.postId, posts.id),\n eq(comments.status, \"published\"),\n ),\n )\n .where(\n and(\n eq(posts.orgId, orgId),\n eq(posts.communityId, communityId),\n eq(posts.status, \"published\"),\n ),\n )\n .groupBy(posts.id),\n this.db\n .select({ count: sql<number>`cast(count(*) as int)` })\n .from(messages)\n .where(and(eq(messages.orgId, orgId), eq(messages.communityId, communityId))),\n this.db\n .select({\n raised: sql<number>`cast(count(*) as int)`,\n resolved: sql<number>`cast(count(*) filter (where ${postFlags.resolvedAt} is not null) as int)`,\n })\n .from(postFlags)\n .innerJoin(posts, and(eq(posts.orgId, postFlags.orgId), eq(posts.id, postFlags.postId)))\n .where(and(eq(postFlags.orgId, orgId), eq(posts.communityId, communityId))),\n this.db\n .select({\n raised: sql<number>`cast(count(*) as int)`,\n resolved: sql<number>`cast(count(*) filter (where ${commentFlags.resolvedAt} is not null) as int)`,\n })\n .from(commentFlags)\n .innerJoin(\n comments,\n and(eq(comments.orgId, commentFlags.orgId), eq(comments.id, commentFlags.commentId)),\n )\n .where(and(eq(commentFlags.orgId, orgId), eq(comments.communityId, communityId))),\n this.db\n .select({\n raised: sql<number>`cast(count(*) as int)`,\n resolved: sql<number>`cast(count(*) filter (where ${messageFlags.resolvedAt} is not null) as int)`,\n })\n .from(messageFlags)\n .where(and(eq(messageFlags.orgId, orgId), eq(messageFlags.communityId, communityId))),\n this.db\n .select({ orgUserId: entitlements.orgUserId })\n .from(entitlements)\n .where(lapsed)\n .groupBy(entitlements.orgUserId),\n ]);\n\n const holderIds = lapsedHolders.map((row) => row.orgUserId);\n const [renewed] =\n holderIds.length === 0\n ? [{ count: 0 }]\n : await this.db\n .select({ count: sql<number>`cast(count(distinct ${contentAccess.orgUserId}) as int)` })\n .from(contentAccess)\n .where(\n and(\n eq(contentAccess.orgId, orgId),\n eq(contentAccess.contentId, communityId),\n inArray(contentAccess.orgUserId, holderIds),\n ),\n );\n\n const raised =\n Number(postFlagRow?.raised ?? 0) +\n Number(commentFlagRow?.raised ?? 0) +\n Number(messageFlagRow?.raised ?? 0);\n const resolved =\n Number(postFlagRow?.resolved ?? 0) +\n Number(commentFlagRow?.resolved ?? 0) +\n Number(messageFlagRow?.resolved ?? 0);\n\n return {\n memberCount: Number(members?.count ?? 0),\n activeMembers: authors.length,\n postsPerSpace: postsPerSpace.map((row) => ({\n spaceId: row.spaceId,\n count: Number(row.count),\n })),\n commentsPerPost: commentsPerPost.map((row) => ({\n postId: row.postId,\n count: Number(row.count),\n })),\n messagesSent: Number(sent?.count ?? 0),\n flagsRaised: raised,\n flagsResolved: resolved,\n grantsLapsed: Number(lapsedGrants?.count ?? 0),\n grantsRenewed: Number(renewed?.count ?? 0),\n };\n }\n\n async communityMembers(\n orgId: string,\n communityId: string,\n query: CommunityMembersQuery,\n ): Promise<Page<CommunityMember> | null> {\n const [community] = await this.db\n .select({ id: communities.id })\n .from(communities)\n .where(and(eq(communities.orgId, orgId), eq(communities.id, communityId)))\n .limit(1);\n if (!community) {\n return null;\n }\n const holds = exists(\n this.db\n .select({ id: contentAccess.entitlementId })\n .from(contentAccess)\n .where(\n and(\n eq(contentAccess.orgId, orgUsers.orgId),\n eq(contentAccess.orgUserId, orgUsers.id),\n eq(contentAccess.contentId, communityId),\n ),\n ),\n );\n const filters: SQL[] = [eq(orgUsers.orgId, orgId), holds];\n const q = query.search?.trim();\n if (q) {\n const like = `%${q}%`;\n filters.push(\n or(ilike(studentFirstName, like), ilike(studentLastName, like), ilike(users.email, like))!,\n );\n }\n const where = and(...filters);\n const [totals] = await this.db\n .select({ total: sql<number>`count(*)` })\n .from(orgUsers)\n .innerJoin(users, eq(users.id, orgUsers.userId))\n .where(where);\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(where)\n .orderBy(asc(users.firstName), asc(users.lastName), asc(orgUsers.id))\n .limit(query.pageSize)\n .offset((query.page - 1) * query.pageSize);\n return {\n rows: rows.map((row) => ({ ...row, image: row.image ?? null })),\n total: Number(totals?.total ?? 0),\n page: query.page,\n pageSize: query.pageSize,\n };\n }\n}\ntranslateDbErrors(DrizzleAdminRepository);\n"],"mappings":"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"}
@@ -9,8 +9,12 @@ declare class DrizzleAssetsRepository implements AssetsRepository {
9
9
  insert(orgId: string, asset: Asset): Promise<Asset>;
10
10
  list(orgId: string, query: AssetsQuery): Promise<Page<Asset>>;
11
11
  findById(orgId: string, id: string): Promise<Asset | null>;
12
- update(id: string, patch: Partial<Pick<Asset, "size" | "contentType" | "status">>): Promise<Asset | null>;
13
- delete(id: string): Promise<boolean>;
12
+ findByIds(orgId: string, ids: string[]): Promise<Asset[]>;
13
+ markReady(orgId: string, id: string, patch: Pick<Asset, "key" | "kind" | "size" | "contentType">): Promise<Asset | null>;
14
+ delete(orgId: string, id: string): Promise<boolean>;
15
+ hasUnreferencedMemberUploads(orgId: string, before: Date): Promise<boolean>;
16
+ listUnreferencedMemberUploads(orgId: string, before: Date): Promise<Asset[]>;
17
+ heldKeys(orgId: string, keys: string[]): Promise<string[]>;
14
18
  }
15
19
  //#endregion
16
20
  export { DrizzleAssetsRepository };