@studious-lms/server 1.24.0 → 1.26.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/data/curriculum/nepal-cdc-g4-g5/subjects/english.json +4 -4
- package/data/curriculum/nepal-cdc-g4-g5/subjects/mathematics.json +1 -1
- package/data/curriculum/nepal-cdc-g4-g5/subjects/science-technology.json +4 -4
- package/data/curriculum/nepal-cdc-g4-g5/subjects/social-studies.json +4 -4
- package/dist/models/attendance.d.ts.map +1 -1
- package/dist/models/attendance.js +17 -14
- package/dist/models/attendance.js.map +1 -1
- package/dist/models/marketplace.d.ts +37 -0
- package/dist/models/marketplace.d.ts.map +1 -1
- package/dist/models/marketplace.js +10 -2
- package/dist/models/marketplace.js.map +1 -1
- package/dist/models/module.d.ts +228 -0
- package/dist/models/module.d.ts.map +1 -0
- package/dist/models/module.js +69 -0
- package/dist/models/module.js.map +1 -0
- package/dist/models/moduleItem.d.ts +5 -1
- package/dist/models/moduleItem.d.ts.map +1 -1
- package/dist/models/moduleItem.js +19 -4
- package/dist/models/moduleItem.js.map +1 -1
- package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
- package/dist/pipelines/gradeWorksheet.js +4 -2
- package/dist/pipelines/gradeWorksheet.js.map +1 -1
- package/dist/routers/_app.d.ts +768 -6
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/_app.js +4 -2
- package/dist/routers/_app.js.map +1 -1
- package/dist/routers/fees.d.ts +1 -1
- package/dist/routers/marketplace.d.ts +41 -0
- package/dist/routers/marketplace.d.ts.map +1 -1
- package/dist/routers/marketplace.js +13 -3
- package/dist/routers/marketplace.js.map +1 -1
- package/dist/routers/module.d.ts +345 -0
- package/dist/routers/module.d.ts.map +1 -0
- package/dist/routers/module.js +82 -0
- package/dist/routers/module.js.map +1 -0
- package/dist/routers/moduleItem.d.ts.map +1 -1
- package/dist/routers/moduleItem.js +7 -6
- package/dist/routers/moduleItem.js.map +1 -1
- package/dist/routers/parent.d.ts +2 -1
- package/dist/routers/parent.d.ts.map +1 -1
- package/dist/routers/school.d.ts +1 -1
- package/dist/routers/school.d.ts.map +1 -1
- package/dist/routers/school.js +14 -4
- package/dist/routers/school.js.map +1 -1
- package/dist/services/academicCalendar.d.ts.map +1 -1
- package/dist/services/academicCalendar.js +30 -2
- package/dist/services/academicCalendar.js.map +1 -1
- package/dist/services/assignment.d.ts.map +1 -1
- package/dist/services/assignment.js +8 -3
- package/dist/services/assignment.js.map +1 -1
- package/dist/services/attendance.d.ts +6 -2
- package/dist/services/attendance.d.ts.map +1 -1
- package/dist/services/attendance.js +8 -4
- package/dist/services/attendance.js.map +1 -1
- package/dist/services/class.d.ts +14 -9
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/class.js +4 -4
- package/dist/services/class.js.map +1 -1
- package/dist/services/fees.d.ts.map +1 -1
- package/dist/services/fees.js +24 -4
- package/dist/services/fees.js.map +1 -1
- package/dist/services/feesAuthz.d.ts +2 -5
- package/dist/services/feesAuthz.d.ts.map +1 -1
- package/dist/services/feesAuthz.js +5 -8
- package/dist/services/feesAuthz.js.map +1 -1
- package/dist/services/marketplace.d.ts +36 -0
- package/dist/services/marketplace.d.ts.map +1 -1
- package/dist/services/marketplace.js +36 -6
- package/dist/services/marketplace.js.map +1 -1
- package/dist/services/module.d.ts +288 -0
- package/dist/services/module.d.ts.map +1 -0
- package/dist/services/module.js +518 -0
- package/dist/services/module.js.map +1 -0
- package/dist/services/moduleItem.d.ts.map +1 -1
- package/dist/services/moduleItem.js +23 -7
- package/dist/services/moduleItem.js.map +1 -1
- package/dist/services/parentPortal.d.ts +3 -5
- package/dist/services/parentPortal.d.ts.map +1 -1
- package/dist/services/parentPortal.js +29 -43
- package/dist/services/parentPortal.js.map +1 -1
- package/dist/services/school.d.ts.map +1 -1
- package/dist/services/school.js +52 -16
- package/dist/services/school.js.map +1 -1
- package/dist/services/studentImport.d.ts.map +1 -1
- package/dist/services/studentImport.js +38 -25
- package/dist/services/studentImport.js.map +1 -1
- package/dist/services/transcript.d.ts +2 -3
- package/dist/services/transcript.d.ts.map +1 -1
- package/dist/services/transcript.js +13 -25
- package/dist/services/transcript.js.map +1 -1
- package/package.json +1 -1
- package/prisma/migrations/20260708013000_add_modules/migration.sql +100 -0
- package/prisma/schema.prisma +93 -0
- package/scripts/import-nepal-cdc-curriculum.ts +29 -6
- package/src/models/attendance.ts +10 -12
- package/src/models/marketplace.ts +19 -0
- package/src/models/module.ts +77 -0
- package/src/models/moduleItem.ts +20 -2
- package/src/pipelines/gradeWorksheet.ts +6 -0
- package/src/routers/_app.ts +2 -0
- package/src/routers/marketplace.ts +16 -0
- package/src/routers/module.ts +118 -0
- package/src/routers/moduleItem.ts +6 -4
- package/src/routers/school.ts +16 -6
- package/src/services/academicCalendar.ts +28 -0
- package/src/services/assignment.ts +12 -1
- package/src/services/attendance.ts +7 -3
- package/src/services/class.ts +2 -2
- package/src/services/fees.ts +25 -2
- package/src/services/feesAuthz.ts +3 -7
- package/src/services/marketplace.ts +47 -3
- package/src/services/module.ts +683 -0
- package/src/services/moduleItem.ts +28 -4
- package/src/services/parentPortal.ts +26 -39
- package/src/services/school.ts +52 -15
- package/src/services/studentImport.ts +37 -25
- package/src/services/transcript.ts +11 -23
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { CourseModuleItemType as ModuleItemType, ModuleItemRequirementType } from "@prisma/client";
|
|
3
|
+
import {
|
|
4
|
+
createTRPCRouter,
|
|
5
|
+
protectedClassMemberProcedure,
|
|
6
|
+
protectedTeacherProcedure,
|
|
7
|
+
} from "../trpc.js";
|
|
8
|
+
import {
|
|
9
|
+
listModulesRecord,
|
|
10
|
+
createModuleRecord,
|
|
11
|
+
updateModuleRecord,
|
|
12
|
+
deleteModuleRecord,
|
|
13
|
+
reorderModulesRecord,
|
|
14
|
+
createModuleItemRecord,
|
|
15
|
+
updateModuleItemRecord,
|
|
16
|
+
deleteModuleItemRecord,
|
|
17
|
+
reorderModuleItemsRecord,
|
|
18
|
+
getStudentModulesState,
|
|
19
|
+
markItemViewedRecord,
|
|
20
|
+
} from "../services/module.js";
|
|
21
|
+
|
|
22
|
+
const itemTypeSchema = z.nativeEnum(ModuleItemType);
|
|
23
|
+
const requirementTypeSchema = z.nativeEnum(ModuleItemRequirementType);
|
|
24
|
+
|
|
25
|
+
export const moduleRouter = createTRPCRouter({
|
|
26
|
+
/** Teacher view: every module (published or not) with its items. */
|
|
27
|
+
list: protectedTeacherProcedure
|
|
28
|
+
.input(z.object({ classId: z.string() }))
|
|
29
|
+
.query(({ ctx, input }) => listModulesRecord(ctx.user!.id, input.classId)),
|
|
30
|
+
|
|
31
|
+
/** Student view: published modules with lock state and progress. */
|
|
32
|
+
getStudentState: protectedClassMemberProcedure
|
|
33
|
+
.input(z.object({ classId: z.string() }))
|
|
34
|
+
.query(({ ctx, input }) =>
|
|
35
|
+
getStudentModulesState(input.classId, ctx.user!.id)
|
|
36
|
+
),
|
|
37
|
+
|
|
38
|
+
create: protectedTeacherProcedure
|
|
39
|
+
.input(
|
|
40
|
+
z.object({
|
|
41
|
+
classId: z.string(),
|
|
42
|
+
title: z.string().min(1),
|
|
43
|
+
unlockAt: z.coerce.date().nullish(),
|
|
44
|
+
requireSequential: z.boolean().optional(),
|
|
45
|
+
})
|
|
46
|
+
)
|
|
47
|
+
.mutation(({ ctx, input }) => createModuleRecord(ctx.user!.id, input)),
|
|
48
|
+
|
|
49
|
+
update: protectedTeacherProcedure
|
|
50
|
+
.input(
|
|
51
|
+
z.object({
|
|
52
|
+
classId: z.string(),
|
|
53
|
+
id: z.string(),
|
|
54
|
+
title: z.string().min(1).optional(),
|
|
55
|
+
unlockAt: z.coerce.date().nullable().optional(),
|
|
56
|
+
requireSequential: z.boolean().optional(),
|
|
57
|
+
published: z.boolean().optional(),
|
|
58
|
+
prerequisiteIds: z.array(z.string()).optional(),
|
|
59
|
+
})
|
|
60
|
+
)
|
|
61
|
+
.mutation(({ ctx, input }) => updateModuleRecord(ctx.user!.id, input)),
|
|
62
|
+
|
|
63
|
+
delete: protectedTeacherProcedure
|
|
64
|
+
.input(z.object({ classId: z.string(), id: z.string() }))
|
|
65
|
+
.mutation(({ ctx, input }) => deleteModuleRecord(ctx.user!.id, input)),
|
|
66
|
+
|
|
67
|
+
reorder: protectedTeacherProcedure
|
|
68
|
+
.input(z.object({ classId: z.string(), orderedIds: z.array(z.string()) }))
|
|
69
|
+
.mutation(({ ctx, input }) => reorderModulesRecord(ctx.user!.id, input)),
|
|
70
|
+
|
|
71
|
+
createItem: protectedTeacherProcedure
|
|
72
|
+
.input(
|
|
73
|
+
z.object({
|
|
74
|
+
classId: z.string(),
|
|
75
|
+
moduleId: z.string(),
|
|
76
|
+
itemType: itemTypeSchema,
|
|
77
|
+
itemId: z.string().optional(),
|
|
78
|
+
externalUrl: z.string().url().optional(),
|
|
79
|
+
title: z.string().optional(),
|
|
80
|
+
indent: z.number().int().min(0).max(4).optional(),
|
|
81
|
+
requirementType: requirementTypeSchema.optional(),
|
|
82
|
+
requiredScore: z.number().min(0).optional(),
|
|
83
|
+
})
|
|
84
|
+
)
|
|
85
|
+
.mutation(({ ctx, input }) => createModuleItemRecord(ctx.user!.id, input)),
|
|
86
|
+
|
|
87
|
+
updateItem: protectedTeacherProcedure
|
|
88
|
+
.input(
|
|
89
|
+
z.object({
|
|
90
|
+
classId: z.string(),
|
|
91
|
+
id: z.string(),
|
|
92
|
+
title: z.string().nullable().optional(),
|
|
93
|
+
externalUrl: z.string().url().optional(),
|
|
94
|
+
indent: z.number().int().min(0).max(4).optional(),
|
|
95
|
+
requirementType: requirementTypeSchema.nullable().optional(),
|
|
96
|
+
requiredScore: z.number().min(0).nullable().optional(),
|
|
97
|
+
})
|
|
98
|
+
)
|
|
99
|
+
.mutation(({ ctx, input }) => updateModuleItemRecord(ctx.user!.id, input)),
|
|
100
|
+
|
|
101
|
+
deleteItem: protectedTeacherProcedure
|
|
102
|
+
.input(z.object({ classId: z.string(), id: z.string() }))
|
|
103
|
+
.mutation(({ ctx, input }) => deleteModuleItemRecord(ctx.user!.id, input)),
|
|
104
|
+
|
|
105
|
+
reorderItems: protectedTeacherProcedure
|
|
106
|
+
.input(
|
|
107
|
+
z.object({
|
|
108
|
+
classId: z.string(),
|
|
109
|
+
items: z.array(z.object({ id: z.string(), moduleId: z.string() })),
|
|
110
|
+
})
|
|
111
|
+
)
|
|
112
|
+
.mutation(({ ctx, input }) => reorderModuleItemsRecord(ctx.user!.id, input)),
|
|
113
|
+
|
|
114
|
+
/** Record that a student viewed an item (completes VIEW requirements). */
|
|
115
|
+
markItemViewed: protectedClassMemberProcedure
|
|
116
|
+
.input(z.object({ classId: z.string(), itemId: z.string() }))
|
|
117
|
+
.mutation(({ ctx, input }) => markItemViewedRecord(ctx.user!.id, input)),
|
|
118
|
+
});
|
|
@@ -14,10 +14,12 @@ import {
|
|
|
14
14
|
updateModuleItemRecord,
|
|
15
15
|
} from "../services/moduleItem.js";
|
|
16
16
|
|
|
17
|
+
const MAX_MODULE_FILE_BYTES = 2 * 1024 * 1024 * 1024; // 2 GB (videos)
|
|
18
|
+
|
|
17
19
|
const directFileSchema = z.object({
|
|
18
|
-
name: z.string(),
|
|
19
|
-
type: z.string(),
|
|
20
|
-
size: z.number(),
|
|
20
|
+
name: z.string().trim().min(1).max(255),
|
|
21
|
+
type: z.string().trim().min(1).max(255),
|
|
22
|
+
size: z.number().int().positive().max(MAX_MODULE_FILE_BYTES),
|
|
21
23
|
});
|
|
22
24
|
|
|
23
25
|
const moduleItemTypeSchema = z.enum(["PAGE", "FILES", "VIDEO"]);
|
|
@@ -76,7 +78,7 @@ export const moduleItemRouter = createTRPCRouter({
|
|
|
76
78
|
z.object({
|
|
77
79
|
classId: z.string(),
|
|
78
80
|
sectionId: z.string(),
|
|
79
|
-
orderedIds: z.array(z.string()),
|
|
81
|
+
orderedIds: z.array(z.string()).min(1).max(500),
|
|
80
82
|
})
|
|
81
83
|
)
|
|
82
84
|
.mutation(({ ctx, input }) => reorderModuleItems(ctx.user!.id, input)),
|
package/src/routers/school.ts
CHANGED
|
@@ -325,9 +325,14 @@ export const schoolRouter = createTRPCRouter({
|
|
|
325
325
|
*/
|
|
326
326
|
generateTranscript: protectedProcedure
|
|
327
327
|
.input(
|
|
328
|
-
transcriptPeriodSchema
|
|
329
|
-
|
|
330
|
-
|
|
328
|
+
transcriptPeriodSchema
|
|
329
|
+
.extend({
|
|
330
|
+
studentId: z.string(),
|
|
331
|
+
})
|
|
332
|
+
.refine((v) => !v.from || !v.to || v.from <= v.to, {
|
|
333
|
+
message: "from must be on or before to",
|
|
334
|
+
path: ["from"],
|
|
335
|
+
})
|
|
331
336
|
)
|
|
332
337
|
.mutation(({ ctx, input }) => generateTranscript(ctx.user!.id, input)),
|
|
333
338
|
|
|
@@ -337,9 +342,14 @@ export const schoolRouter = createTRPCRouter({
|
|
|
337
342
|
*/
|
|
338
343
|
generateTranscripts: protectedProcedure
|
|
339
344
|
.input(
|
|
340
|
-
transcriptPeriodSchema
|
|
341
|
-
|
|
342
|
-
|
|
345
|
+
transcriptPeriodSchema
|
|
346
|
+
.extend({
|
|
347
|
+
studentIds: z.array(z.string()).min(1).max(50),
|
|
348
|
+
})
|
|
349
|
+
.refine((v) => !v.from || !v.to || v.from <= v.to, {
|
|
350
|
+
message: "from must be on or before to",
|
|
351
|
+
path: ["from"],
|
|
352
|
+
})
|
|
343
353
|
)
|
|
344
354
|
.mutation(({ ctx, input }) => generateTranscripts(ctx.user!.id, input)),
|
|
345
355
|
|
|
@@ -89,6 +89,20 @@ export async function updateYear(
|
|
|
89
89
|
}
|
|
90
90
|
) {
|
|
91
91
|
await requireSchoolAdmin(input.schoolId, userId);
|
|
92
|
+
if (input.startDate !== undefined || input.endDate !== undefined) {
|
|
93
|
+
const existing = await getAcademicYearById(input.yearId, input.schoolId);
|
|
94
|
+
if (!existing) {
|
|
95
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Academic year not found" });
|
|
96
|
+
}
|
|
97
|
+
const nextStart = input.startDate ?? existing.startDate;
|
|
98
|
+
const nextEnd = input.endDate ?? existing.endDate;
|
|
99
|
+
if (nextEnd < nextStart) {
|
|
100
|
+
throw new TRPCError({
|
|
101
|
+
code: "BAD_REQUEST",
|
|
102
|
+
message: "endDate must be on or after startDate",
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
92
106
|
const year = await updateAcademicYear(input.yearId, input.schoolId, {
|
|
93
107
|
name: input.name,
|
|
94
108
|
startDate: input.startDate,
|
|
@@ -177,6 +191,20 @@ export async function updateTerm(
|
|
|
177
191
|
if (!year) {
|
|
178
192
|
throw new TRPCError({ code: "NOT_FOUND", message: "Academic year not found" });
|
|
179
193
|
}
|
|
194
|
+
if (input.startDate !== undefined || input.endDate !== undefined) {
|
|
195
|
+
const existingTerm = year.terms.find((t) => t.id === input.termId);
|
|
196
|
+
if (!existingTerm) {
|
|
197
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Academic term not found" });
|
|
198
|
+
}
|
|
199
|
+
const nextStart = input.startDate ?? existingTerm.startDate;
|
|
200
|
+
const nextEnd = input.endDate ?? existingTerm.endDate;
|
|
201
|
+
if (nextEnd < nextStart) {
|
|
202
|
+
throw new TRPCError({
|
|
203
|
+
code: "BAD_REQUEST",
|
|
204
|
+
message: "endDate must be on or after startDate",
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
180
208
|
const term = await updateAcademicTerm(input.termId, input.yearId, {
|
|
181
209
|
name: input.name,
|
|
182
210
|
startDate: input.startDate,
|
|
@@ -28,6 +28,7 @@ import { enqueueRunJob } from "../workers/queues.js";
|
|
|
28
28
|
import { logger } from "../utils/logger.js";
|
|
29
29
|
import { invalidateStudentProgressInferenceCache } from "./studentProgressInferenceCache.js";
|
|
30
30
|
import { enqueueEmbedding } from "./embedding.js";
|
|
31
|
+
import { syncAssignmentModuleProgress } from "./module.js";
|
|
31
32
|
import { resolveAssignmentBehavior } from "../lib/assignmentTypeDefaults.js";
|
|
32
33
|
|
|
33
34
|
export async function assignmentExists(id: string) {
|
|
@@ -803,7 +804,7 @@ export async function updateSubmissionRecord(
|
|
|
803
804
|
}
|
|
804
805
|
}
|
|
805
806
|
}
|
|
806
|
-
|
|
807
|
+
const updatedSubmission = await prisma.submission.update({
|
|
807
808
|
where: { id: submission.id },
|
|
808
809
|
data: {
|
|
809
810
|
submitted: !submission.submitted,
|
|
@@ -821,6 +822,8 @@ export async function updateSubmissionRecord(
|
|
|
821
822
|
},
|
|
822
823
|
},
|
|
823
824
|
});
|
|
825
|
+
await syncAssignmentModuleProgress(userId, submission.assignmentId);
|
|
826
|
+
return updatedSubmission;
|
|
824
827
|
}
|
|
825
828
|
|
|
826
829
|
const isLateUpdate = submission.assignment.dueDate < new Date();
|
|
@@ -1002,6 +1005,10 @@ export async function updateSubmissionAsTeacherRecord(
|
|
|
1002
1005
|
data: { returned: !submission.returned },
|
|
1003
1006
|
include: submissionTeacherInclude,
|
|
1004
1007
|
});
|
|
1008
|
+
await syncAssignmentModuleProgress(
|
|
1009
|
+
submission.studentId,
|
|
1010
|
+
submission.assignmentId
|
|
1011
|
+
);
|
|
1005
1012
|
|
|
1006
1013
|
if (updated.returned) {
|
|
1007
1014
|
sendNotification({
|
|
@@ -1072,6 +1079,10 @@ export async function updateSubmissionAsTeacherRecord(
|
|
|
1072
1079
|
submission.assignment.classId,
|
|
1073
1080
|
submission.studentId,
|
|
1074
1081
|
);
|
|
1082
|
+
await syncAssignmentModuleProgress(
|
|
1083
|
+
submission.studentId,
|
|
1084
|
+
submission.assignmentId
|
|
1085
|
+
);
|
|
1075
1086
|
}
|
|
1076
1087
|
|
|
1077
1088
|
// Index submission for RAG search after teacher grades/feedback
|
|
@@ -291,7 +291,11 @@ export async function updateAttendanceRecord(
|
|
|
291
291
|
return shapeAttendanceSession(updated);
|
|
292
292
|
}
|
|
293
293
|
|
|
294
|
-
/**
|
|
294
|
+
/**
|
|
295
|
+
* School-admin: today's attendance rollup across classes.
|
|
296
|
+
* `percentage` is session-weighted (share of attended class-session records),
|
|
297
|
+
* not a share of `totalStudents`; a student in six classes contributes six records.
|
|
298
|
+
*/
|
|
295
299
|
export async function listTodayAttendanceSummary(
|
|
296
300
|
userId: string,
|
|
297
301
|
schoolId: string
|
|
@@ -440,13 +444,13 @@ export async function listExcusals(
|
|
|
440
444
|
const now = toDateOnly(new Date());
|
|
441
445
|
|
|
442
446
|
return excusals.map((excusal) => {
|
|
443
|
-
let status: "ACTIVE" | "UPCOMING" | "REVOKED" = "ACTIVE";
|
|
447
|
+
let status: "ACTIVE" | "UPCOMING" | "EXPIRED" | "REVOKED" = "ACTIVE";
|
|
444
448
|
if (excusal.revokedAt) {
|
|
445
449
|
status = "REVOKED";
|
|
446
450
|
} else if (toDateOnly(excusal.startDate) > now) {
|
|
447
451
|
status = "UPCOMING";
|
|
448
452
|
} else if (toDateOnly(excusal.endDate) < now) {
|
|
449
|
-
status = "
|
|
453
|
+
status = "EXPIRED";
|
|
450
454
|
}
|
|
451
455
|
|
|
452
456
|
return {
|
package/src/services/class.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Class service – CRUD for classes, members, grades, mark schemes, grading boundaries,
|
|
3
3
|
* syllabus, lab drafts, and invite codes.
|
|
4
4
|
*/
|
|
5
|
-
import { SessionType } from "@prisma/client";
|
|
5
|
+
import { Prisma, SessionType } from "@prisma/client";
|
|
6
6
|
import { TRPCError } from "@trpc/server";
|
|
7
7
|
import { prisma } from "../lib/prisma.js";
|
|
8
8
|
import { generateInviteCode } from "../utils/generateInviteCode.js";
|
|
@@ -247,7 +247,7 @@ export async function updateClass(
|
|
|
247
247
|
studentsCanReply: data.studentsCanReply,
|
|
248
248
|
}),
|
|
249
249
|
...(data.sidebarFeatures !== undefined && {
|
|
250
|
-
sidebarFeatures: data.sidebarFeatures,
|
|
250
|
+
sidebarFeatures: data.sidebarFeatures ?? Prisma.DbNull,
|
|
251
251
|
}),
|
|
252
252
|
} as Parameters<typeof prisma.class.update>[0]["data"],
|
|
253
253
|
select: {
|
package/src/services/fees.ts
CHANGED
|
@@ -39,6 +39,20 @@ async function requireAdmin(schoolId: string, userId: string) {
|
|
|
39
39
|
return membership;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
async function assertActiveSchoolStudents(
|
|
43
|
+
schoolId: string,
|
|
44
|
+
studentIds: string[]
|
|
45
|
+
) {
|
|
46
|
+
const activeIds = new Set(await listActiveStudentIds(schoolId));
|
|
47
|
+
const invalid = studentIds.filter((id) => !activeIds.has(id));
|
|
48
|
+
if (invalid.length > 0) {
|
|
49
|
+
throw new TRPCError({
|
|
50
|
+
code: "BAD_REQUEST",
|
|
51
|
+
message: "One or more students are not active members of this school",
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
42
56
|
function assertMonth(month: number) {
|
|
43
57
|
if (month < 1 || month > 12) {
|
|
44
58
|
throw new TRPCError({
|
|
@@ -117,6 +131,7 @@ export async function createPlan(
|
|
|
117
131
|
amountNpr: input.amountNpr,
|
|
118
132
|
});
|
|
119
133
|
if (input.studentIds?.length) {
|
|
134
|
+
await assertActiveSchoolStudents(input.schoolId, input.studentIds);
|
|
120
135
|
const updated = await setFeePlanEnrollments(plan.id, input.studentIds);
|
|
121
136
|
return { plan: updated };
|
|
122
137
|
}
|
|
@@ -162,6 +177,7 @@ export async function setPlanStudents(
|
|
|
162
177
|
throw new TRPCError({ code: "NOT_FOUND", message: "Fee plan not found" });
|
|
163
178
|
}
|
|
164
179
|
await requireAdmin(existing.schoolId, userId);
|
|
180
|
+
await assertActiveSchoolStudents(existing.schoolId, input.studentIds);
|
|
165
181
|
const plan = await setFeePlanEnrollments(input.planId, input.studentIds);
|
|
166
182
|
return { plan };
|
|
167
183
|
}
|
|
@@ -192,7 +208,9 @@ export async function generateMonthlyInvoices(
|
|
|
192
208
|
}
|
|
193
209
|
|
|
194
210
|
let studentIds = input.studentIds;
|
|
195
|
-
if (
|
|
211
|
+
if (studentIds?.length) {
|
|
212
|
+
await assertActiveSchoolStudents(input.schoolId, studentIds);
|
|
213
|
+
} else {
|
|
196
214
|
const enrolled = await listPlanEnrollmentIds(plan.id);
|
|
197
215
|
studentIds = enrolled.length
|
|
198
216
|
? enrolled
|
|
@@ -410,7 +428,12 @@ export async function getReceiptPdf(userId: string, paymentId: string) {
|
|
|
410
428
|
},
|
|
411
429
|
{
|
|
412
430
|
label: "Paid at",
|
|
413
|
-
value:
|
|
431
|
+
value: new Intl.DateTimeFormat("en-CA", {
|
|
432
|
+
timeZone: "Asia/Kathmandu",
|
|
433
|
+
year: "numeric",
|
|
434
|
+
month: "2-digit",
|
|
435
|
+
day: "2-digit",
|
|
436
|
+
}).format(payment.paidAt),
|
|
414
437
|
},
|
|
415
438
|
],
|
|
416
439
|
footerNote: "Manual ledger receipt — gateway integration deferred.",
|
|
@@ -54,10 +54,7 @@ export function canManageFees(role: SchoolMembershipRole) {
|
|
|
54
54
|
return FEE_WRITE_ROLES.includes(role);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
/**
|
|
58
|
-
* Placeholder procedure surface until A2 fee models exist.
|
|
59
|
-
* Returns capability flags so the client can show/hide fee UI.
|
|
60
|
-
*/
|
|
57
|
+
/** Returns capability flags so the client can show/hide fee UI. */
|
|
61
58
|
export async function getFeeCapabilities(userId: string, schoolId: string) {
|
|
62
59
|
const membership = await findActiveSchoolMembership(schoolId, userId);
|
|
63
60
|
if (!membership) {
|
|
@@ -70,8 +67,7 @@ export async function getFeeCapabilities(userId: string, schoolId: string) {
|
|
|
70
67
|
role: membership.role,
|
|
71
68
|
canManageFees: canManageFees(membership.role),
|
|
72
69
|
canViewFees: FEE_READ_ROLES.includes(membership.role),
|
|
73
|
-
feesImplemented:
|
|
74
|
-
message:
|
|
75
|
-
"Fee models are not implemented on this branch yet (owned by A2). Authz stubs are ready.",
|
|
70
|
+
feesImplemented: true as const,
|
|
71
|
+
message: "Fee features are available.",
|
|
76
72
|
};
|
|
77
73
|
}
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
incrementMarketplaceReportCount,
|
|
22
22
|
searchMarketplaceListings,
|
|
23
23
|
setMarketplaceListingHidden,
|
|
24
|
+
updateMarketplaceListing,
|
|
24
25
|
} from "../models/marketplace.js";
|
|
25
26
|
import {
|
|
26
27
|
findActiveSchoolAdminMembership,
|
|
@@ -146,14 +147,19 @@ export async function listMarketplace(
|
|
|
146
147
|
}
|
|
147
148
|
) {
|
|
148
149
|
const memberships = await listUserSchoolMemberships(userId);
|
|
150
|
+
if (
|
|
151
|
+
input.schoolId &&
|
|
152
|
+
!memberships.some((m) => m.schoolId === input.schoolId)
|
|
153
|
+
) {
|
|
154
|
+
return { items: [], nextCursor: undefined };
|
|
155
|
+
}
|
|
149
156
|
const schoolIds = input.schoolId
|
|
150
|
-
?
|
|
151
|
-
? [input.schoolId]
|
|
152
|
-
: []
|
|
157
|
+
? [input.schoolId]
|
|
153
158
|
: memberships.map((m) => m.schoolId);
|
|
154
159
|
|
|
155
160
|
return searchMarketplaceListings({
|
|
156
161
|
schoolIds,
|
|
162
|
+
scopeSchoolId: input.schoolId,
|
|
157
163
|
type: input.type,
|
|
158
164
|
search: input.search,
|
|
159
165
|
tags: input.tags,
|
|
@@ -654,6 +660,44 @@ export async function reportMarketplaceListing(
|
|
|
654
660
|
return incrementMarketplaceReportCount(listingId);
|
|
655
661
|
}
|
|
656
662
|
|
|
663
|
+
export async function updateMarketplaceListingDetails(
|
|
664
|
+
userId: string,
|
|
665
|
+
input: {
|
|
666
|
+
listingId: string;
|
|
667
|
+
title?: string;
|
|
668
|
+
description?: string | null;
|
|
669
|
+
visibility?: "SCHOOL" | "PUBLIC";
|
|
670
|
+
tags?: string[];
|
|
671
|
+
}
|
|
672
|
+
) {
|
|
673
|
+
const listing = await findMarketplaceListingById(input.listingId);
|
|
674
|
+
if (!listing) {
|
|
675
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Listing not found" });
|
|
676
|
+
}
|
|
677
|
+
const isAuthor = listing.authorId === userId;
|
|
678
|
+
const isSchoolAdmin =
|
|
679
|
+
listing.schoolId != null &&
|
|
680
|
+
Boolean(await findActiveSchoolAdminMembership(listing.schoolId, userId));
|
|
681
|
+
if (!isAuthor && !isSchoolAdmin) {
|
|
682
|
+
throw new TRPCError({
|
|
683
|
+
code: "FORBIDDEN",
|
|
684
|
+
message: "Only the author or a school admin can edit this listing",
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
if (input.visibility === "SCHOOL" && listing.schoolId == null) {
|
|
688
|
+
throw new TRPCError({
|
|
689
|
+
code: "BAD_REQUEST",
|
|
690
|
+
message: "Listing has no school; it cannot use school visibility",
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
return updateMarketplaceListing(input.listingId, {
|
|
694
|
+
title: input.title,
|
|
695
|
+
description: input.description,
|
|
696
|
+
visibility: input.visibility,
|
|
697
|
+
tags: input.tags ? normalizeTags(input.tags) : undefined,
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
|
|
657
701
|
export async function hideMarketplaceListing(
|
|
658
702
|
userId: string,
|
|
659
703
|
listingId: string,
|