@commonpub/server 0.1.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 (147) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +59 -0
  3. package/dist/admin/admin.d.ts +113 -0
  4. package/dist/admin/admin.d.ts.map +1 -0
  5. package/dist/admin/admin.js +426 -0
  6. package/dist/admin/admin.js.map +1 -0
  7. package/dist/admin/index.d.ts +3 -0
  8. package/dist/admin/index.d.ts.map +1 -0
  9. package/dist/admin/index.js +2 -0
  10. package/dist/admin/index.js.map +1 -0
  11. package/dist/content/content.d.ts +39 -0
  12. package/dist/content/content.d.ts.map +1 -0
  13. package/dist/content/content.js +507 -0
  14. package/dist/content/content.js.map +1 -0
  15. package/dist/content/index.d.ts +3 -0
  16. package/dist/content/index.d.ts.map +1 -0
  17. package/dist/content/index.js +2 -0
  18. package/dist/content/index.js.map +1 -0
  19. package/dist/contest/contest.d.ts +92 -0
  20. package/dist/contest/contest.d.ts.map +1 -0
  21. package/dist/contest/contest.js +343 -0
  22. package/dist/contest/contest.js.map +1 -0
  23. package/dist/contest/index.d.ts +3 -0
  24. package/dist/contest/index.d.ts.map +1 -0
  25. package/dist/contest/index.js +2 -0
  26. package/dist/contest/index.js.map +1 -0
  27. package/dist/docs/docs.d.ts +71 -0
  28. package/dist/docs/docs.d.ts.map +1 -0
  29. package/dist/docs/docs.js +398 -0
  30. package/dist/docs/docs.js.map +1 -0
  31. package/dist/docs/index.d.ts +2 -0
  32. package/dist/docs/index.d.ts.map +1 -0
  33. package/dist/docs/index.js +2 -0
  34. package/dist/docs/index.js.map +1 -0
  35. package/dist/email.d.ts +3 -0
  36. package/dist/email.d.ts.map +1 -0
  37. package/dist/email.js +3 -0
  38. package/dist/email.js.map +1 -0
  39. package/dist/federation/federation.d.ts +46 -0
  40. package/dist/federation/federation.d.ts.map +1 -0
  41. package/dist/federation/federation.js +308 -0
  42. package/dist/federation/federation.js.map +1 -0
  43. package/dist/federation/index.d.ts +2 -0
  44. package/dist/federation/index.d.ts.map +1 -0
  45. package/dist/federation/index.js +2 -0
  46. package/dist/federation/index.js.map +1 -0
  47. package/dist/hub/hub.d.ts +110 -0
  48. package/dist/hub/hub.d.ts.map +1 -0
  49. package/dist/hub/hub.js +917 -0
  50. package/dist/hub/hub.js.map +1 -0
  51. package/dist/hub/index.d.ts +2 -0
  52. package/dist/hub/index.d.ts.map +1 -0
  53. package/dist/hub/index.js +2 -0
  54. package/dist/hub/index.js.map +1 -0
  55. package/dist/image.d.ts +3 -0
  56. package/dist/image.d.ts.map +1 -0
  57. package/dist/image.js +3 -0
  58. package/dist/image.js.map +1 -0
  59. package/dist/index.d.ts +38 -0
  60. package/dist/index.d.ts.map +1 -0
  61. package/dist/index.js +43 -0
  62. package/dist/index.js.map +1 -0
  63. package/dist/learning/index.d.ts +6 -0
  64. package/dist/learning/index.d.ts.map +1 -0
  65. package/dist/learning/index.js +6 -0
  66. package/dist/learning/index.js.map +1 -0
  67. package/dist/learning/learning.d.ts +85 -0
  68. package/dist/learning/learning.d.ts.map +1 -0
  69. package/dist/learning/learning.js +681 -0
  70. package/dist/learning/learning.js.map +1 -0
  71. package/dist/messaging/index.d.ts +3 -0
  72. package/dist/messaging/index.d.ts.map +1 -0
  73. package/dist/messaging/index.js +2 -0
  74. package/dist/messaging/index.js.map +1 -0
  75. package/dist/messaging/messaging.d.ts +27 -0
  76. package/dist/messaging/messaging.d.ts.map +1 -0
  77. package/dist/messaging/messaging.js +132 -0
  78. package/dist/messaging/messaging.js.map +1 -0
  79. package/dist/notification/index.d.ts +3 -0
  80. package/dist/notification/index.d.ts.map +1 -0
  81. package/dist/notification/index.js +2 -0
  82. package/dist/notification/index.js.map +1 -0
  83. package/dist/notification/notification.d.ts +38 -0
  84. package/dist/notification/notification.d.ts.map +1 -0
  85. package/dist/notification/notification.js +92 -0
  86. package/dist/notification/notification.js.map +1 -0
  87. package/dist/oauthCodes.d.ts +14 -0
  88. package/dist/oauthCodes.d.ts.map +1 -0
  89. package/dist/oauthCodes.js +40 -0
  90. package/dist/oauthCodes.js.map +1 -0
  91. package/dist/product/index.d.ts +3 -0
  92. package/dist/product/index.d.ts.map +1 -0
  93. package/dist/product/index.js +2 -0
  94. package/dist/product/index.js.map +1 -0
  95. package/dist/product/product.d.ts +143 -0
  96. package/dist/product/product.d.ts.map +1 -0
  97. package/dist/product/product.js +493 -0
  98. package/dist/product/product.js.map +1 -0
  99. package/dist/profile/index.d.ts +2 -0
  100. package/dist/profile/index.d.ts.map +1 -0
  101. package/dist/profile/index.js +2 -0
  102. package/dist/profile/index.js.map +1 -0
  103. package/dist/profile/profile.d.ts +28 -0
  104. package/dist/profile/profile.d.ts.map +1 -0
  105. package/dist/profile/profile.js +122 -0
  106. package/dist/profile/profile.js.map +1 -0
  107. package/dist/query.d.ts +331 -0
  108. package/dist/query.d.ts.map +1 -0
  109. package/dist/query.js +103 -0
  110. package/dist/query.js.map +1 -0
  111. package/dist/security.d.ts +3 -0
  112. package/dist/security.d.ts.map +1 -0
  113. package/dist/security.js +3 -0
  114. package/dist/security.js.map +1 -0
  115. package/dist/social/index.d.ts +3 -0
  116. package/dist/social/index.d.ts.map +1 -0
  117. package/dist/social/index.js +2 -0
  118. package/dist/social/index.js.map +1 -0
  119. package/dist/social/social.d.ts +84 -0
  120. package/dist/social/social.d.ts.map +1 -0
  121. package/dist/social/social.js +353 -0
  122. package/dist/social/social.js.map +1 -0
  123. package/dist/storage.d.ts +3 -0
  124. package/dist/storage.d.ts.map +1 -0
  125. package/dist/storage.js +3 -0
  126. package/dist/storage.js.map +1 -0
  127. package/dist/theme.d.ts +12 -0
  128. package/dist/theme.d.ts.map +1 -0
  129. package/dist/theme.js +55 -0
  130. package/dist/theme.js.map +1 -0
  131. package/dist/types.d.ts +266 -0
  132. package/dist/types.d.ts.map +1 -0
  133. package/dist/types.js +2 -0
  134. package/dist/types.js.map +1 -0
  135. package/dist/utils.d.ts +7 -0
  136. package/dist/utils.d.ts.map +1 -0
  137. package/dist/utils.js +46 -0
  138. package/dist/utils.js.map +1 -0
  139. package/dist/video/index.d.ts +3 -0
  140. package/dist/video/index.d.ts.map +1 -0
  141. package/dist/video/index.js +2 -0
  142. package/dist/video/index.js.map +1 -0
  143. package/dist/video/video.d.ts +61 -0
  144. package/dist/video/video.d.ts.map +1 -0
  145. package/dist/video/video.js +157 -0
  146. package/dist/video/video.js.map +1 -0
  147. package/package.json +129 -0
@@ -0,0 +1,681 @@
1
+ import { eq, and, desc, sql, asc, inArray } from 'drizzle-orm';
2
+ import { learningPaths, learningModules, learningLessons, enrollments, lessonProgress, certificates, users, contentItems, } from '@commonpub/schema';
3
+ import { calculatePathProgress, isPathComplete } from '@commonpub/learning';
4
+ import { generateVerificationCode } from '@commonpub/learning';
5
+ import { generateSlug } from '../utils.js';
6
+ import { ensureUniqueSlugFor, USER_REF_SELECT, normalizePagination, countRows } from '../query.js';
7
+ // --- Path CRUD ---
8
+ export async function listPaths(db, filters = {}) {
9
+ const conditions = [];
10
+ if (filters.status) {
11
+ conditions.push(eq(learningPaths.status, filters.status));
12
+ }
13
+ if (filters.difficulty) {
14
+ conditions.push(eq(learningPaths.difficulty, filters.difficulty));
15
+ }
16
+ if (filters.authorId) {
17
+ conditions.push(eq(learningPaths.authorId, filters.authorId));
18
+ }
19
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
20
+ const { limit, offset } = normalizePagination(filters);
21
+ const moduleCountSubquery = db
22
+ .select({
23
+ pathId: learningModules.pathId,
24
+ moduleCount: sql `count(*)::int`.as('module_count'),
25
+ })
26
+ .from(learningModules)
27
+ .groupBy(learningModules.pathId)
28
+ .as('mc');
29
+ const [rows, total] = await Promise.all([
30
+ db
31
+ .select({
32
+ path: learningPaths,
33
+ author: {
34
+ id: users.id,
35
+ username: users.username,
36
+ displayName: users.displayName,
37
+ avatarUrl: users.avatarUrl,
38
+ },
39
+ moduleCount: sql `coalesce(${moduleCountSubquery.moduleCount}, 0)`.mapWith(Number),
40
+ })
41
+ .from(learningPaths)
42
+ .innerJoin(users, eq(learningPaths.authorId, users.id))
43
+ .leftJoin(moduleCountSubquery, eq(learningPaths.id, moduleCountSubquery.pathId))
44
+ .where(where)
45
+ .orderBy(desc(learningPaths.createdAt))
46
+ .limit(limit)
47
+ .offset(offset),
48
+ countRows(db, learningPaths, where),
49
+ ]);
50
+ const items = rows.map((row) => ({
51
+ id: row.path.id,
52
+ title: row.path.title,
53
+ slug: row.path.slug,
54
+ description: row.path.description,
55
+ coverImageUrl: row.path.coverImageUrl,
56
+ difficulty: row.path.difficulty,
57
+ estimatedHours: row.path.estimatedHours,
58
+ enrollmentCount: row.path.enrollmentCount,
59
+ completionCount: row.path.completionCount,
60
+ averageRating: row.path.averageRating,
61
+ moduleCount: row.moduleCount,
62
+ status: row.path.status,
63
+ createdAt: row.path.createdAt,
64
+ author: row.author,
65
+ }));
66
+ return { items, total };
67
+ }
68
+ export async function getPathBySlug(db, slug, requesterId) {
69
+ const rows = await db
70
+ .select({
71
+ path: learningPaths,
72
+ author: USER_REF_SELECT,
73
+ })
74
+ .from(learningPaths)
75
+ .innerJoin(users, eq(learningPaths.authorId, users.id))
76
+ .where(eq(learningPaths.slug, slug))
77
+ .limit(1);
78
+ if (rows.length === 0)
79
+ return null;
80
+ const row = rows[0];
81
+ const path = row.path;
82
+ if (path.status !== 'published' && path.authorId !== requesterId) {
83
+ return null;
84
+ }
85
+ const modules = await db
86
+ .select()
87
+ .from(learningModules)
88
+ .where(eq(learningModules.pathId, path.id))
89
+ .orderBy(asc(learningModules.sortOrder));
90
+ const moduleIds = modules.map((m) => m.id);
91
+ let lessons = [];
92
+ if (moduleIds.length > 0) {
93
+ lessons = await db
94
+ .select()
95
+ .from(learningLessons)
96
+ .where(inArray(learningLessons.moduleId, moduleIds))
97
+ .orderBy(asc(learningLessons.sortOrder));
98
+ }
99
+ let enrollment = null;
100
+ let isEnrolled = false;
101
+ if (requesterId) {
102
+ const enrollmentRows = await db
103
+ .select()
104
+ .from(enrollments)
105
+ .where(and(eq(enrollments.userId, requesterId), eq(enrollments.pathId, path.id)))
106
+ .limit(1);
107
+ if (enrollmentRows.length > 0) {
108
+ const e = enrollmentRows[0];
109
+ enrollment = {
110
+ id: e.id,
111
+ progress: e.progress,
112
+ startedAt: e.startedAt,
113
+ completedAt: e.completedAt,
114
+ };
115
+ isEnrolled = true;
116
+ }
117
+ }
118
+ const modulesWithLessons = modules.map((mod) => ({
119
+ id: mod.id,
120
+ title: mod.title,
121
+ description: mod.description,
122
+ sortOrder: mod.sortOrder,
123
+ lessons: lessons
124
+ .filter((l) => l.moduleId === mod.id)
125
+ .map((l) => ({
126
+ id: l.id,
127
+ title: l.title,
128
+ slug: l.slug,
129
+ type: l.type,
130
+ duration: l.duration,
131
+ sortOrder: l.sortOrder,
132
+ contentItemId: l.contentItemId ?? null,
133
+ })),
134
+ }));
135
+ return {
136
+ id: path.id,
137
+ title: path.title,
138
+ slug: path.slug,
139
+ description: path.description,
140
+ coverImageUrl: path.coverImageUrl,
141
+ difficulty: path.difficulty,
142
+ estimatedHours: path.estimatedHours,
143
+ enrollmentCount: path.enrollmentCount,
144
+ completionCount: path.completionCount,
145
+ averageRating: path.averageRating,
146
+ moduleCount: modulesWithLessons.length,
147
+ reviewCount: path.reviewCount,
148
+ status: path.status,
149
+ createdAt: path.createdAt,
150
+ updatedAt: path.updatedAt,
151
+ author: row.author,
152
+ modules: modulesWithLessons,
153
+ isEnrolled,
154
+ enrollment,
155
+ };
156
+ }
157
+ export async function createPath(db, authorId, input) {
158
+ const slug = await ensureUniqueSlugFor(db, learningPaths, learningPaths.slug, learningPaths.id, generateSlug(input.title), 'untitled');
159
+ const [path] = await db
160
+ .insert(learningPaths)
161
+ .values({
162
+ authorId,
163
+ title: input.title,
164
+ slug,
165
+ description: input.description ?? null,
166
+ difficulty: input.difficulty ?? null,
167
+ estimatedHours: input.estimatedHours?.toString() ?? null,
168
+ status: 'draft',
169
+ })
170
+ .returning();
171
+ return (await getPathBySlug(db, path.slug, authorId));
172
+ }
173
+ export async function updatePath(db, pathId, authorId, input) {
174
+ const existing = await db
175
+ .select()
176
+ .from(learningPaths)
177
+ .where(and(eq(learningPaths.id, pathId), eq(learningPaths.authorId, authorId)))
178
+ .limit(1);
179
+ if (existing.length === 0)
180
+ return null;
181
+ const current = existing[0];
182
+ const updates = { updatedAt: new Date() };
183
+ if (input.title !== undefined) {
184
+ updates.title = input.title;
185
+ if (input.title !== current.title) {
186
+ updates.slug = await ensureUniqueSlugFor(db, learningPaths, learningPaths.slug, learningPaths.id, generateSlug(input.title), 'untitled', pathId);
187
+ }
188
+ }
189
+ if (input.description !== undefined)
190
+ updates.description = input.description;
191
+ if (input.difficulty !== undefined)
192
+ updates.difficulty = input.difficulty;
193
+ if (input.estimatedHours !== undefined)
194
+ updates.estimatedHours = input.estimatedHours.toString();
195
+ if (input.coverImageUrl !== undefined)
196
+ updates.coverImageUrl = input.coverImageUrl;
197
+ await db.update(learningPaths).set(updates).where(eq(learningPaths.id, pathId));
198
+ const slug = updates.slug ?? current.slug;
199
+ return (await getPathBySlug(db, slug, authorId));
200
+ }
201
+ export async function deletePath(db, pathId, authorId) {
202
+ const result = await db
203
+ .update(learningPaths)
204
+ .set({ status: 'archived', updatedAt: new Date() })
205
+ .where(and(eq(learningPaths.id, pathId), eq(learningPaths.authorId, authorId)));
206
+ return (result.rowCount ?? 0) > 0;
207
+ }
208
+ export async function publishPath(db, pathId, authorId) {
209
+ const existing = await db
210
+ .select()
211
+ .from(learningPaths)
212
+ .where(and(eq(learningPaths.id, pathId), eq(learningPaths.authorId, authorId)))
213
+ .limit(1);
214
+ if (existing.length === 0)
215
+ return null;
216
+ await db
217
+ .update(learningPaths)
218
+ .set({ status: 'published', updatedAt: new Date() })
219
+ .where(eq(learningPaths.id, pathId));
220
+ return (await getPathBySlug(db, existing[0].slug, authorId));
221
+ }
222
+ // --- Module CRUD ---
223
+ export async function createModule(db, authorId, input) {
224
+ const path = await db
225
+ .select()
226
+ .from(learningPaths)
227
+ .where(and(eq(learningPaths.id, input.pathId), eq(learningPaths.authorId, authorId)))
228
+ .limit(1);
229
+ if (path.length === 0)
230
+ throw new Error('Not authorized');
231
+ let sortOrder = input.sortOrder;
232
+ if (sortOrder === undefined) {
233
+ const maxSort = await db
234
+ .select({ max: sql `coalesce(max(${learningModules.sortOrder}), -1)` })
235
+ .from(learningModules)
236
+ .where(eq(learningModules.pathId, input.pathId));
237
+ sortOrder = (maxSort[0]?.max ?? -1) + 1;
238
+ }
239
+ const [mod] = await db
240
+ .insert(learningModules)
241
+ .values({
242
+ pathId: input.pathId,
243
+ title: input.title,
244
+ description: input.description ?? null,
245
+ sortOrder,
246
+ })
247
+ .returning();
248
+ return mod;
249
+ }
250
+ export async function updateModule(db, moduleId, authorId, input) {
251
+ const mod = await db
252
+ .select({ module: learningModules, path: learningPaths })
253
+ .from(learningModules)
254
+ .innerJoin(learningPaths, eq(learningModules.pathId, learningPaths.id))
255
+ .where(and(eq(learningModules.id, moduleId), eq(learningPaths.authorId, authorId)))
256
+ .limit(1);
257
+ if (mod.length === 0)
258
+ return null;
259
+ const updates = {};
260
+ if (input.title !== undefined)
261
+ updates.title = input.title;
262
+ if (input.description !== undefined)
263
+ updates.description = input.description;
264
+ if (Object.keys(updates).length === 0)
265
+ return mod[0].module;
266
+ const [updated] = await db
267
+ .update(learningModules)
268
+ .set(updates)
269
+ .where(eq(learningModules.id, moduleId))
270
+ .returning();
271
+ return updated;
272
+ }
273
+ export async function deleteModule(db, moduleId, authorId) {
274
+ const mod = await db
275
+ .select({ module: learningModules, path: learningPaths })
276
+ .from(learningModules)
277
+ .innerJoin(learningPaths, eq(learningModules.pathId, learningPaths.id))
278
+ .where(and(eq(learningModules.id, moduleId), eq(learningPaths.authorId, authorId)))
279
+ .limit(1);
280
+ if (mod.length === 0)
281
+ return false;
282
+ await db.delete(learningModules).where(eq(learningModules.id, moduleId));
283
+ return true;
284
+ }
285
+ export async function reorderModules(db, pathId, authorId, moduleIds) {
286
+ const path = await db
287
+ .select()
288
+ .from(learningPaths)
289
+ .where(and(eq(learningPaths.id, pathId), eq(learningPaths.authorId, authorId)))
290
+ .limit(1);
291
+ if (path.length === 0)
292
+ return false;
293
+ await db.transaction(async (tx) => {
294
+ for (let i = 0; i < moduleIds.length; i++) {
295
+ await tx
296
+ .update(learningModules)
297
+ .set({ sortOrder: i })
298
+ .where(and(eq(learningModules.id, moduleIds[i]), eq(learningModules.pathId, pathId)));
299
+ }
300
+ });
301
+ return true;
302
+ }
303
+ // --- Lesson CRUD ---
304
+ export async function createLesson(db, authorId, input) {
305
+ const mod = await db
306
+ .select({ module: learningModules, path: learningPaths })
307
+ .from(learningModules)
308
+ .innerJoin(learningPaths, eq(learningModules.pathId, learningPaths.id))
309
+ .where(and(eq(learningModules.id, input.moduleId), eq(learningPaths.authorId, authorId)))
310
+ .limit(1);
311
+ if (mod.length === 0)
312
+ throw new Error('Not authorized');
313
+ // If linking to existing content, validate it
314
+ let resolvedType = input.type;
315
+ if (input.contentItemId) {
316
+ const item = await db
317
+ .select({ id: contentItems.id, type: contentItems.type, authorId: contentItems.authorId })
318
+ .from(contentItems)
319
+ .where(eq(contentItems.id, input.contentItemId))
320
+ .limit(1);
321
+ if (item.length === 0)
322
+ throw new Error('Content item not found');
323
+ if (item[0].authorId !== authorId)
324
+ throw new Error('You can only link your own content');
325
+ resolvedType = item[0].type;
326
+ }
327
+ const slug = generateSlug(input.title) || `lesson-${Date.now()}`;
328
+ const maxSort = await db
329
+ .select({ max: sql `coalesce(max(${learningLessons.sortOrder}), -1)` })
330
+ .from(learningLessons)
331
+ .where(eq(learningLessons.moduleId, input.moduleId));
332
+ const [lesson] = await db
333
+ .insert(learningLessons)
334
+ .values({
335
+ moduleId: input.moduleId,
336
+ title: input.title,
337
+ slug,
338
+ type: resolvedType,
339
+ content: input.contentItemId ? null : (input.content ?? null),
340
+ contentItemId: input.contentItemId ?? null,
341
+ duration: input.durationMinutes ?? null,
342
+ sortOrder: (maxSort[0]?.max ?? -1) + 1,
343
+ })
344
+ .returning();
345
+ return lesson;
346
+ }
347
+ export async function updateLesson(db, lessonId, authorId, input) {
348
+ const lesson = await db
349
+ .select({ lesson: learningLessons, module: learningModules, path: learningPaths })
350
+ .from(learningLessons)
351
+ .innerJoin(learningModules, eq(learningLessons.moduleId, learningModules.id))
352
+ .innerJoin(learningPaths, eq(learningModules.pathId, learningPaths.id))
353
+ .where(and(eq(learningLessons.id, lessonId), eq(learningPaths.authorId, authorId)))
354
+ .limit(1);
355
+ if (lesson.length === 0)
356
+ return null;
357
+ const updates = { updatedAt: new Date() };
358
+ if (input.title !== undefined) {
359
+ updates.title = input.title;
360
+ updates.slug = generateSlug(input.title) || lesson[0].lesson.slug;
361
+ }
362
+ if (input.type !== undefined)
363
+ updates.type = input.type;
364
+ if (input.content !== undefined)
365
+ updates.content = input.content;
366
+ if (input.contentItemId !== undefined) {
367
+ updates.contentItemId = input.contentItemId;
368
+ if (input.contentItemId === null) {
369
+ // Unlinking — clear the reference
370
+ }
371
+ else {
372
+ // Linking — validate content item belongs to author
373
+ const item = await db
374
+ .select({ id: contentItems.id, type: contentItems.type, authorId: contentItems.authorId })
375
+ .from(contentItems)
376
+ .where(eq(contentItems.id, input.contentItemId))
377
+ .limit(1);
378
+ if (item.length === 0)
379
+ throw new Error('Content item not found');
380
+ if (item[0].authorId !== authorId)
381
+ throw new Error('You can only link your own content');
382
+ updates.type = item[0].type;
383
+ updates.content = null;
384
+ }
385
+ }
386
+ if (input.durationMinutes !== undefined)
387
+ updates.duration = input.durationMinutes;
388
+ const [updated] = await db
389
+ .update(learningLessons)
390
+ .set(updates)
391
+ .where(eq(learningLessons.id, lessonId))
392
+ .returning();
393
+ return updated;
394
+ }
395
+ export async function deleteLesson(db, lessonId, authorId) {
396
+ const lesson = await db
397
+ .select({ lesson: learningLessons, module: learningModules, path: learningPaths })
398
+ .from(learningLessons)
399
+ .innerJoin(learningModules, eq(learningLessons.moduleId, learningModules.id))
400
+ .innerJoin(learningPaths, eq(learningModules.pathId, learningPaths.id))
401
+ .where(and(eq(learningLessons.id, lessonId), eq(learningPaths.authorId, authorId)))
402
+ .limit(1);
403
+ if (lesson.length === 0)
404
+ return false;
405
+ await db.delete(learningLessons).where(eq(learningLessons.id, lessonId));
406
+ return true;
407
+ }
408
+ export async function reorderLessons(db, moduleId, authorId, lessonIds) {
409
+ const mod = await db
410
+ .select({ module: learningModules, path: learningPaths })
411
+ .from(learningModules)
412
+ .innerJoin(learningPaths, eq(learningModules.pathId, learningPaths.id))
413
+ .where(and(eq(learningModules.id, moduleId), eq(learningPaths.authorId, authorId)))
414
+ .limit(1);
415
+ if (mod.length === 0)
416
+ return false;
417
+ await db.transaction(async (tx) => {
418
+ for (let i = 0; i < lessonIds.length; i++) {
419
+ await tx
420
+ .update(learningLessons)
421
+ .set({ sortOrder: i })
422
+ .where(and(eq(learningLessons.id, lessonIds[i]), eq(learningLessons.moduleId, moduleId)));
423
+ }
424
+ });
425
+ return true;
426
+ }
427
+ // --- Enrollment ---
428
+ export async function enroll(db, userId, pathId) {
429
+ const existing = await db
430
+ .select()
431
+ .from(enrollments)
432
+ .where(and(eq(enrollments.userId, userId), eq(enrollments.pathId, pathId)))
433
+ .limit(1);
434
+ if (existing.length > 0)
435
+ return existing[0];
436
+ const path = await db
437
+ .select()
438
+ .from(learningPaths)
439
+ .where(and(eq(learningPaths.id, pathId), eq(learningPaths.status, 'published')))
440
+ .limit(1);
441
+ if (path.length === 0)
442
+ throw new Error('Path not found or not published');
443
+ const [enrollment] = await db.insert(enrollments).values({ userId, pathId }).returning();
444
+ await db
445
+ .update(learningPaths)
446
+ .set({ enrollmentCount: sql `${learningPaths.enrollmentCount} + 1` })
447
+ .where(eq(learningPaths.id, pathId));
448
+ return enrollment;
449
+ }
450
+ export async function unenroll(db, userId, pathId) {
451
+ const existing = await db
452
+ .select()
453
+ .from(enrollments)
454
+ .where(and(eq(enrollments.userId, userId), eq(enrollments.pathId, pathId)))
455
+ .limit(1);
456
+ if (existing.length === 0)
457
+ return false;
458
+ await db
459
+ .delete(enrollments)
460
+ .where(and(eq(enrollments.userId, userId), eq(enrollments.pathId, pathId)));
461
+ await db
462
+ .update(learningPaths)
463
+ .set({ enrollmentCount: sql `GREATEST(${learningPaths.enrollmentCount} - 1, 0)` })
464
+ .where(eq(learningPaths.id, pathId));
465
+ return true;
466
+ }
467
+ // --- Progress ---
468
+ export async function markLessonComplete(db, userId, lessonId, quizScore, quizPassed) {
469
+ const lessonRow = await db
470
+ .select({ lesson: learningLessons, module: learningModules })
471
+ .from(learningLessons)
472
+ .innerJoin(learningModules, eq(learningLessons.moduleId, learningModules.id))
473
+ .where(eq(learningLessons.id, lessonId))
474
+ .limit(1);
475
+ if (lessonRow.length === 0)
476
+ throw new Error('Lesson not found');
477
+ const pathId = lessonRow[0].module.pathId;
478
+ return db.transaction(async (tx) => {
479
+ const enrollmentRow = await tx
480
+ .select()
481
+ .from(enrollments)
482
+ .where(and(eq(enrollments.userId, userId), eq(enrollments.pathId, pathId)))
483
+ .for('update')
484
+ .limit(1);
485
+ if (enrollmentRow.length === 0)
486
+ throw new Error('Not enrolled');
487
+ const existingProgress = await tx
488
+ .select()
489
+ .from(lessonProgress)
490
+ .where(and(eq(lessonProgress.userId, userId), eq(lessonProgress.lessonId, lessonId)))
491
+ .limit(1);
492
+ if (existingProgress.length === 0) {
493
+ await tx.insert(lessonProgress).values({
494
+ userId,
495
+ lessonId,
496
+ completed: true,
497
+ completedAt: new Date(),
498
+ quizScore: quizScore?.toString() ?? null,
499
+ quizPassed: quizPassed ?? null,
500
+ });
501
+ }
502
+ else {
503
+ await tx
504
+ .update(lessonProgress)
505
+ .set({
506
+ completed: true,
507
+ completedAt: new Date(),
508
+ quizScore: quizScore?.toString() ?? existingProgress[0].quizScore,
509
+ quizPassed: quizPassed ?? existingProgress[0].quizPassed,
510
+ })
511
+ .where(and(eq(lessonProgress.userId, userId), eq(lessonProgress.lessonId, lessonId)));
512
+ }
513
+ const totalLessons = await tx
514
+ .select({ count: sql `count(*)::int` })
515
+ .from(learningLessons)
516
+ .innerJoin(learningModules, eq(learningLessons.moduleId, learningModules.id))
517
+ .where(eq(learningModules.pathId, pathId));
518
+ const completedLessons = await tx
519
+ .select({ count: sql `count(*)::int` })
520
+ .from(lessonProgress)
521
+ .innerJoin(learningLessons, eq(lessonProgress.lessonId, learningLessons.id))
522
+ .innerJoin(learningModules, eq(learningLessons.moduleId, learningModules.id))
523
+ .where(and(eq(lessonProgress.userId, userId), eq(lessonProgress.completed, true), eq(learningModules.pathId, pathId)));
524
+ const total = totalLessons[0]?.count ?? 0;
525
+ const completed = completedLessons[0]?.count ?? 0;
526
+ const progress = calculatePathProgress(total, completed);
527
+ const enrollmentUpdates = { progress: progress.toString() };
528
+ if (isPathComplete(progress)) {
529
+ enrollmentUpdates.completedAt = new Date();
530
+ }
531
+ await tx
532
+ .update(enrollments)
533
+ .set(enrollmentUpdates)
534
+ .where(eq(enrollments.id, enrollmentRow[0].id));
535
+ let certificateIssued = false;
536
+ if (isPathComplete(progress)) {
537
+ const existingCert = await tx
538
+ .select()
539
+ .from(certificates)
540
+ .where(and(eq(certificates.userId, userId), eq(certificates.pathId, pathId)))
541
+ .limit(1);
542
+ if (existingCert.length === 0) {
543
+ await tx.insert(certificates).values({
544
+ userId,
545
+ pathId,
546
+ verificationCode: generateVerificationCode(),
547
+ });
548
+ certificateIssued = true;
549
+ await tx
550
+ .update(learningPaths)
551
+ .set({ completionCount: sql `${learningPaths.completionCount} + 1` })
552
+ .where(eq(learningPaths.id, pathId));
553
+ }
554
+ }
555
+ return { progress, certificateIssued };
556
+ });
557
+ }
558
+ // --- Queries ---
559
+ export async function getEnrollment(db, userId, pathId) {
560
+ const rows = await db
561
+ .select()
562
+ .from(enrollments)
563
+ .where(and(eq(enrollments.userId, userId), eq(enrollments.pathId, pathId)))
564
+ .limit(1);
565
+ return rows[0] ?? null;
566
+ }
567
+ export async function getUserEnrollments(db, userId) {
568
+ const rows = await db
569
+ .select({
570
+ enrollment: enrollments,
571
+ path: {
572
+ id: learningPaths.id,
573
+ title: learningPaths.title,
574
+ slug: learningPaths.slug,
575
+ coverImageUrl: learningPaths.coverImageUrl,
576
+ difficulty: learningPaths.difficulty,
577
+ },
578
+ })
579
+ .from(enrollments)
580
+ .innerJoin(learningPaths, eq(enrollments.pathId, learningPaths.id))
581
+ .where(eq(enrollments.userId, userId))
582
+ .orderBy(desc(enrollments.startedAt));
583
+ return rows.map((row) => ({
584
+ id: row.enrollment.id,
585
+ progress: row.enrollment.progress,
586
+ startedAt: row.enrollment.startedAt,
587
+ completedAt: row.enrollment.completedAt,
588
+ path: row.path,
589
+ }));
590
+ }
591
+ export async function getUserCertificates(db, userId) {
592
+ const rows = await db
593
+ .select({
594
+ certificate: certificates,
595
+ path: {
596
+ id: learningPaths.id,
597
+ title: learningPaths.title,
598
+ slug: learningPaths.slug,
599
+ },
600
+ })
601
+ .from(certificates)
602
+ .innerJoin(learningPaths, eq(certificates.pathId, learningPaths.id))
603
+ .where(eq(certificates.userId, userId))
604
+ .orderBy(desc(certificates.issuedAt));
605
+ return rows.map((row) => ({
606
+ id: row.certificate.id,
607
+ verificationCode: row.certificate.verificationCode,
608
+ issuedAt: row.certificate.issuedAt,
609
+ path: row.path,
610
+ }));
611
+ }
612
+ export async function getCertificateByCode(db, code) {
613
+ const rows = await db
614
+ .select({
615
+ certificate: certificates,
616
+ path: {
617
+ title: learningPaths.title,
618
+ slug: learningPaths.slug,
619
+ },
620
+ user: {
621
+ displayName: users.displayName,
622
+ username: users.username,
623
+ },
624
+ })
625
+ .from(certificates)
626
+ .innerJoin(learningPaths, eq(certificates.pathId, learningPaths.id))
627
+ .innerJoin(users, eq(certificates.userId, users.id))
628
+ .where(eq(certificates.verificationCode, code))
629
+ .limit(1);
630
+ return rows[0] ?? null;
631
+ }
632
+ export async function getLessonBySlug(db, pathSlug, lessonSlug) {
633
+ const path = await db
634
+ .select()
635
+ .from(learningPaths)
636
+ .where(eq(learningPaths.slug, pathSlug))
637
+ .limit(1);
638
+ if (path.length === 0)
639
+ return null;
640
+ const rows = await db
641
+ .select({ lesson: learningLessons, module: learningModules })
642
+ .from(learningLessons)
643
+ .innerJoin(learningModules, eq(learningLessons.moduleId, learningModules.id))
644
+ .where(and(eq(learningLessons.slug, lessonSlug), eq(learningModules.pathId, path[0].id)))
645
+ .limit(1);
646
+ if (rows.length === 0)
647
+ return null;
648
+ const result = {
649
+ lesson: rows[0].lesson,
650
+ module: rows[0].module,
651
+ pathId: path[0].id,
652
+ };
653
+ // Resolve linked content item if present
654
+ if (rows[0].lesson.contentItemId) {
655
+ const items = await db
656
+ .select({
657
+ id: contentItems.id,
658
+ title: contentItems.title,
659
+ slug: contentItems.slug,
660
+ type: contentItems.type,
661
+ content: contentItems.content,
662
+ })
663
+ .from(contentItems)
664
+ .where(eq(contentItems.id, rows[0].lesson.contentItemId))
665
+ .limit(1);
666
+ if (items.length > 0) {
667
+ result.linkedContent = items[0];
668
+ }
669
+ }
670
+ return result;
671
+ }
672
+ export async function getCompletedLessonIds(db, userId, pathId) {
673
+ const rows = await db
674
+ .select({ lessonId: lessonProgress.lessonId })
675
+ .from(lessonProgress)
676
+ .innerJoin(learningLessons, eq(lessonProgress.lessonId, learningLessons.id))
677
+ .innerJoin(learningModules, eq(learningLessons.moduleId, learningModules.id))
678
+ .where(and(eq(lessonProgress.userId, userId), eq(lessonProgress.completed, true), eq(learningModules.pathId, pathId)));
679
+ return new Set(rows.map((r) => r.lessonId));
680
+ }
681
+ //# sourceMappingURL=learning.js.map