@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.
- package/LICENSE +661 -0
- package/README.md +59 -0
- package/dist/admin/admin.d.ts +113 -0
- package/dist/admin/admin.d.ts.map +1 -0
- package/dist/admin/admin.js +426 -0
- package/dist/admin/admin.js.map +1 -0
- package/dist/admin/index.d.ts +3 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +2 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/content/content.d.ts +39 -0
- package/dist/content/content.d.ts.map +1 -0
- package/dist/content/content.js +507 -0
- package/dist/content/content.js.map +1 -0
- package/dist/content/index.d.ts +3 -0
- package/dist/content/index.d.ts.map +1 -0
- package/dist/content/index.js +2 -0
- package/dist/content/index.js.map +1 -0
- package/dist/contest/contest.d.ts +92 -0
- package/dist/contest/contest.d.ts.map +1 -0
- package/dist/contest/contest.js +343 -0
- package/dist/contest/contest.js.map +1 -0
- package/dist/contest/index.d.ts +3 -0
- package/dist/contest/index.d.ts.map +1 -0
- package/dist/contest/index.js +2 -0
- package/dist/contest/index.js.map +1 -0
- package/dist/docs/docs.d.ts +71 -0
- package/dist/docs/docs.d.ts.map +1 -0
- package/dist/docs/docs.js +398 -0
- package/dist/docs/docs.js.map +1 -0
- package/dist/docs/index.d.ts +2 -0
- package/dist/docs/index.d.ts.map +1 -0
- package/dist/docs/index.js +2 -0
- package/dist/docs/index.js.map +1 -0
- package/dist/email.d.ts +3 -0
- package/dist/email.d.ts.map +1 -0
- package/dist/email.js +3 -0
- package/dist/email.js.map +1 -0
- package/dist/federation/federation.d.ts +46 -0
- package/dist/federation/federation.d.ts.map +1 -0
- package/dist/federation/federation.js +308 -0
- package/dist/federation/federation.js.map +1 -0
- package/dist/federation/index.d.ts +2 -0
- package/dist/federation/index.d.ts.map +1 -0
- package/dist/federation/index.js +2 -0
- package/dist/federation/index.js.map +1 -0
- package/dist/hub/hub.d.ts +110 -0
- package/dist/hub/hub.d.ts.map +1 -0
- package/dist/hub/hub.js +917 -0
- package/dist/hub/hub.js.map +1 -0
- package/dist/hub/index.d.ts +2 -0
- package/dist/hub/index.d.ts.map +1 -0
- package/dist/hub/index.js +2 -0
- package/dist/hub/index.js.map +1 -0
- package/dist/image.d.ts +3 -0
- package/dist/image.d.ts.map +1 -0
- package/dist/image.js +3 -0
- package/dist/image.js.map +1 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +43 -0
- package/dist/index.js.map +1 -0
- package/dist/learning/index.d.ts +6 -0
- package/dist/learning/index.d.ts.map +1 -0
- package/dist/learning/index.js +6 -0
- package/dist/learning/index.js.map +1 -0
- package/dist/learning/learning.d.ts +85 -0
- package/dist/learning/learning.d.ts.map +1 -0
- package/dist/learning/learning.js +681 -0
- package/dist/learning/learning.js.map +1 -0
- package/dist/messaging/index.d.ts +3 -0
- package/dist/messaging/index.d.ts.map +1 -0
- package/dist/messaging/index.js +2 -0
- package/dist/messaging/index.js.map +1 -0
- package/dist/messaging/messaging.d.ts +27 -0
- package/dist/messaging/messaging.d.ts.map +1 -0
- package/dist/messaging/messaging.js +132 -0
- package/dist/messaging/messaging.js.map +1 -0
- package/dist/notification/index.d.ts +3 -0
- package/dist/notification/index.d.ts.map +1 -0
- package/dist/notification/index.js +2 -0
- package/dist/notification/index.js.map +1 -0
- package/dist/notification/notification.d.ts +38 -0
- package/dist/notification/notification.d.ts.map +1 -0
- package/dist/notification/notification.js +92 -0
- package/dist/notification/notification.js.map +1 -0
- package/dist/oauthCodes.d.ts +14 -0
- package/dist/oauthCodes.d.ts.map +1 -0
- package/dist/oauthCodes.js +40 -0
- package/dist/oauthCodes.js.map +1 -0
- package/dist/product/index.d.ts +3 -0
- package/dist/product/index.d.ts.map +1 -0
- package/dist/product/index.js +2 -0
- package/dist/product/index.js.map +1 -0
- package/dist/product/product.d.ts +143 -0
- package/dist/product/product.d.ts.map +1 -0
- package/dist/product/product.js +493 -0
- package/dist/product/product.js.map +1 -0
- package/dist/profile/index.d.ts +2 -0
- package/dist/profile/index.d.ts.map +1 -0
- package/dist/profile/index.js +2 -0
- package/dist/profile/index.js.map +1 -0
- package/dist/profile/profile.d.ts +28 -0
- package/dist/profile/profile.d.ts.map +1 -0
- package/dist/profile/profile.js +122 -0
- package/dist/profile/profile.js.map +1 -0
- package/dist/query.d.ts +331 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +103 -0
- package/dist/query.js.map +1 -0
- package/dist/security.d.ts +3 -0
- package/dist/security.d.ts.map +1 -0
- package/dist/security.js +3 -0
- package/dist/security.js.map +1 -0
- package/dist/social/index.d.ts +3 -0
- package/dist/social/index.d.ts.map +1 -0
- package/dist/social/index.js +2 -0
- package/dist/social/index.js.map +1 -0
- package/dist/social/social.d.ts +84 -0
- package/dist/social/social.d.ts.map +1 -0
- package/dist/social/social.js +353 -0
- package/dist/social/social.js.map +1 -0
- package/dist/storage.d.ts +3 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +3 -0
- package/dist/storage.js.map +1 -0
- package/dist/theme.d.ts +12 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +55 -0
- package/dist/theme.js.map +1 -0
- package/dist/types.d.ts +266 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +7 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +46 -0
- package/dist/utils.js.map +1 -0
- package/dist/video/index.d.ts +3 -0
- package/dist/video/index.d.ts.map +1 -0
- package/dist/video/index.js +2 -0
- package/dist/video/index.js.map +1 -0
- package/dist/video/video.d.ts +61 -0
- package/dist/video/video.d.ts.map +1 -0
- package/dist/video/video.js +157 -0
- package/dist/video/video.js.map +1 -0
- 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
|