@studious-lms/server 1.16.1 → 1.17.1
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/dist/models/tag.d.ts +34 -0
- package/dist/models/tag.d.ts.map +1 -1
- package/dist/models/tag.js +18 -2
- package/dist/models/tag.js.map +1 -1
- package/dist/routers/_app.d.ts +204 -0
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/school.d.ts +102 -0
- package/dist/routers/school.d.ts.map +1 -1
- package/dist/routers/school.js +11 -3
- package/dist/routers/school.js.map +1 -1
- package/dist/services/school.d.ts +112 -0
- package/dist/services/school.d.ts.map +1 -1
- package/dist/services/school.js +213 -2
- package/dist/services/school.js.map +1 -1
- package/dist/services/tag.d.ts.map +1 -1
- package/dist/services/tag.js +40 -5
- package/dist/services/tag.js.map +1 -1
- package/package.json +1 -1
- package/src/models/tag.ts +21 -0
- package/src/routers/school.ts +12 -0
- package/src/services/school.ts +231 -0
- package/src/services/tag.ts +39 -2
package/dist/services/tag.js
CHANGED
|
@@ -3,11 +3,27 @@
|
|
|
3
3
|
* targets (users, classes, rubrics, boundaries).
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
6
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="b6385fba-13fc-519e-9095-49126706f392")}catch(e){}}();
|
|
7
7
|
import { TRPCError } from "@trpc/server";
|
|
8
8
|
import { prisma } from "../lib/prisma.js";
|
|
9
|
-
import { attachTagToTarget, createTag, deleteTag, detachTagFromTarget, getTag, listTagsAttachedTo, listTagsForSchool, updateTag, } from "../models/tag.js";
|
|
9
|
+
import { attachTagToTarget, createTag, deleteTag, detachTagFromTarget, findTagByName, getTag, listTagsAttachedTo, listTagsForSchool, updateTag, } from "../models/tag.js";
|
|
10
10
|
import { findActiveSchoolAdminMembership, findSchoolMembership, } from "../models/school.js";
|
|
11
|
+
/**
|
|
12
|
+
* Normalises a tag name to a canonical display form: trimmed with internal
|
|
13
|
+
* whitespace collapsed to single spaces. Casing is preserved so labels like
|
|
14
|
+
* "AP" or "Year 12" read naturally; uniqueness is enforced case-insensitively
|
|
15
|
+
* separately via {@link findTagByName}.
|
|
16
|
+
*/
|
|
17
|
+
function normalizeTagName(name) {
|
|
18
|
+
const normalized = name.trim().replace(/\s+/g, " ");
|
|
19
|
+
if (!normalized) {
|
|
20
|
+
throw new TRPCError({
|
|
21
|
+
code: "BAD_REQUEST",
|
|
22
|
+
message: "Tag name can't be empty",
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
return normalized;
|
|
26
|
+
}
|
|
11
27
|
async function requireAdmin(schoolId, userId) {
|
|
12
28
|
const membership = await findActiveSchoolAdminMembership(schoolId, userId);
|
|
13
29
|
if (!membership) {
|
|
@@ -47,7 +63,15 @@ export async function createTagRecord(userId, input) {
|
|
|
47
63
|
});
|
|
48
64
|
}
|
|
49
65
|
}
|
|
50
|
-
|
|
66
|
+
const name = normalizeTagName(input.name);
|
|
67
|
+
const existing = await findTagByName(input.schoolId, name);
|
|
68
|
+
if (existing) {
|
|
69
|
+
throw new TRPCError({
|
|
70
|
+
code: "CONFLICT",
|
|
71
|
+
message: `A tag named "${existing.name}" already exists in this institution`,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return createTag({ ...input, name });
|
|
51
75
|
}
|
|
52
76
|
export async function updateTagRecord(userId, input) {
|
|
53
77
|
const tag = await getTag(input.tagId);
|
|
@@ -67,8 +91,19 @@ export async function updateTagRecord(userId, input) {
|
|
|
67
91
|
});
|
|
68
92
|
}
|
|
69
93
|
}
|
|
94
|
+
let name = input.name;
|
|
95
|
+
if (name !== undefined) {
|
|
96
|
+
name = normalizeTagName(name);
|
|
97
|
+
const existing = await findTagByName(tag.schoolId, name, input.tagId);
|
|
98
|
+
if (existing) {
|
|
99
|
+
throw new TRPCError({
|
|
100
|
+
code: "CONFLICT",
|
|
101
|
+
message: `A tag named "${existing.name}" already exists in this institution`,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
70
105
|
return updateTag(input.tagId, {
|
|
71
|
-
name
|
|
106
|
+
name,
|
|
72
107
|
color: input.color,
|
|
73
108
|
departmentId: input.departmentId,
|
|
74
109
|
});
|
|
@@ -175,4 +210,4 @@ export async function getAttachedTags(userId, input) {
|
|
|
175
210
|
return tags;
|
|
176
211
|
}
|
|
177
212
|
//# sourceMappingURL=tag.js.map
|
|
178
|
-
//# debugId=
|
|
213
|
+
//# debugId=b6385fba-13fc-519e-9095-49126706f392
|
package/dist/services/tag.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tag.js","sources":["services/tag.ts"],"sourceRoot":"/","sourcesContent":["/**\n * Tag service — institution-scoped tag CRUD and assignment to taggable\n * targets (users, classes, rubrics, boundaries).\n */\nimport { TRPCError } from \"@trpc/server\";\nimport { prisma } from \"../lib/prisma.js\";\nimport {\n attachTagToTarget,\n createTag,\n deleteTag,\n detachTagFromTarget,\n getTag,\n listTagsAttachedTo,\n listTagsForSchool,\n type TagTarget,\n updateTag,\n} from \"../models/tag.js\";\nimport {\n findActiveSchoolAdminMembership,\n findSchoolMembership,\n} from \"../models/school.js\";\n\nasync function requireAdmin(schoolId: string, userId: string) {\n const membership = await findActiveSchoolAdminMembership(schoolId, userId);\n if (!membership) {\n throw new TRPCError({\n code: \"FORBIDDEN\",\n message: \"Only school admins can manage tags\",\n });\n }\n}\n\nasync function requireMember(schoolId: string, userId: string) {\n const membership = await findSchoolMembership(schoolId, userId);\n if (!membership || membership.status !== \"ACTIVE\") {\n throw new TRPCError({\n code: \"FORBIDDEN\",\n message: \"You aren't a member of this institution\",\n });\n }\n return membership;\n}\n\nexport async function listTags(\n userId: string,\n input: { schoolId: string; departmentId?: string | null }\n) {\n await requireMember(input.schoolId, userId);\n return listTagsForSchool(input.schoolId, {\n departmentId: input.departmentId,\n });\n}\n\nexport async function createTagRecord(\n userId: string,\n input: {\n schoolId: string;\n departmentId?: string | null;\n name: string;\n color?: string;\n }\n) {\n await requireAdmin(input.schoolId, userId);\n if (input.departmentId) {\n const dept = await prisma.department.findUnique({\n where: { id: input.departmentId },\n select: { schoolId: true },\n });\n if (!dept || dept.schoolId !== input.schoolId) {\n throw new TRPCError({\n code: \"BAD_REQUEST\",\n message: \"Department does not belong to this institution\",\n });\n }\n }\n return createTag(input);\n}\n\nexport async function updateTagRecord(\n userId: string,\n input: {\n tagId: string;\n name?: string;\n color?: string;\n departmentId?: string | null;\n }\n) {\n const tag = await getTag(input.tagId);\n if (!tag) {\n throw new TRPCError({ code: \"NOT_FOUND\", message: \"Tag not found\" });\n }\n await requireAdmin(tag.schoolId, userId);\n if (input.departmentId) {\n const dept = await prisma.department.findUnique({\n where: { id: input.departmentId },\n select: { schoolId: true },\n });\n if (!dept || dept.schoolId !== tag.schoolId) {\n throw new TRPCError({\n code: \"BAD_REQUEST\",\n message: \"Department does not belong to this institution\",\n });\n }\n }\n return updateTag(input.tagId, {\n name: input.name,\n color: input.color,\n departmentId: input.departmentId,\n });\n}\n\nexport async function deleteTagRecord(userId: string, tagId: string) {\n const tag = await getTag(tagId);\n if (!tag) {\n throw new TRPCError({ code: \"NOT_FOUND\", message: \"Tag not found\" });\n }\n await requireAdmin(tag.schoolId, userId);\n await deleteTag(tagId);\n return { success: true };\n}\n\nasync function ensureTargetInScope(\n schoolId: string,\n target: TagTarget,\n targetId: string\n) {\n switch (target) {\n case \"USER\": {\n const inSchool = await prisma.schoolMembership.findFirst({\n where: { schoolId, userId: targetId, status: \"ACTIVE\" },\n select: { id: true },\n });\n if (!inSchool) {\n throw new TRPCError({\n code: \"BAD_REQUEST\",\n message: \"User is not in this institution\",\n });\n }\n return;\n }\n case \"CLASS\": {\n const inSchool = await prisma.class.findFirst({\n where: { id: targetId, schoolId },\n select: { id: true },\n });\n if (!inSchool) {\n throw new TRPCError({\n code: \"BAD_REQUEST\",\n message: \"Class is not in this institution\",\n });\n }\n return;\n }\n case \"RUBRIC\": {\n const inSchool = await prisma.markScheme.findFirst({\n where: {\n id: targetId,\n OR: [\n { class: { schoolId } },\n { department: { schoolId } },\n ],\n },\n select: { id: true },\n });\n if (!inSchool) {\n throw new TRPCError({\n code: \"BAD_REQUEST\",\n message: \"Rubric is not in this institution\",\n });\n }\n return;\n }\n case \"BOUNDARY\": {\n const inSchool = await prisma.gradingBoundary.findFirst({\n where: {\n id: targetId,\n OR: [\n { class: { schoolId } },\n { department: { schoolId } },\n ],\n },\n select: { id: true },\n });\n if (!inSchool) {\n throw new TRPCError({\n code: \"BAD_REQUEST\",\n message: \"Boundary is not in this institution\",\n });\n }\n return;\n }\n }\n}\n\nexport async function attachTag(\n userId: string,\n input: { tagId: string; target: TagTarget; targetId: string }\n) {\n const tag = await getTag(input.tagId);\n if (!tag) {\n throw new TRPCError({ code: \"NOT_FOUND\", message: \"Tag not found\" });\n }\n await requireMember(tag.schoolId, userId);\n await ensureTargetInScope(tag.schoolId, input.target, input.targetId);\n return attachTagToTarget(input);\n}\n\nexport async function detachTag(\n userId: string,\n input: { tagId: string; target: TagTarget; targetId: string }\n) {\n const tag = await getTag(input.tagId);\n if (!tag) {\n throw new TRPCError({ code: \"NOT_FOUND\", message: \"Tag not found\" });\n }\n await requireMember(tag.schoolId, userId);\n return detachTagFromTarget(input);\n}\n\nexport async function getAttachedTags(\n userId: string,\n input: { target: TagTarget; targetId: string }\n) {\n const tags = await listTagsAttachedTo(input.target, input.targetId);\n if (tags[0]) {\n await requireMember(tags[0].schoolId, userId);\n }\n return tags;\n}\n"],"names":[],"mappings":"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","debug_id":"6ab21e9e-024c-5347-9864-92a8edf76dbe"}
|
|
1
|
+
{"version":3,"file":"tag.js","sources":["services/tag.ts"],"sourceRoot":"/","sourcesContent":["/**\n * Tag service — institution-scoped tag CRUD and assignment to taggable\n * targets (users, classes, rubrics, boundaries).\n */\nimport { TRPCError } from \"@trpc/server\";\nimport { prisma } from \"../lib/prisma.js\";\nimport {\n attachTagToTarget,\n createTag,\n deleteTag,\n detachTagFromTarget,\n findTagByName,\n getTag,\n listTagsAttachedTo,\n listTagsForSchool,\n type TagTarget,\n updateTag,\n} from \"../models/tag.js\";\nimport {\n findActiveSchoolAdminMembership,\n findSchoolMembership,\n} from \"../models/school.js\";\n\n/**\n * Normalises a tag name to a canonical display form: trimmed with internal\n * whitespace collapsed to single spaces. Casing is preserved so labels like\n * \"AP\" or \"Year 12\" read naturally; uniqueness is enforced case-insensitively\n * separately via {@link findTagByName}.\n */\nfunction normalizeTagName(name: string): string {\n const normalized = name.trim().replace(/\\s+/g, \" \");\n if (!normalized) {\n throw new TRPCError({\n code: \"BAD_REQUEST\",\n message: \"Tag name can't be empty\",\n });\n }\n return normalized;\n}\n\nasync function requireAdmin(schoolId: string, userId: string) {\n const membership = await findActiveSchoolAdminMembership(schoolId, userId);\n if (!membership) {\n throw new TRPCError({\n code: \"FORBIDDEN\",\n message: \"Only school admins can manage tags\",\n });\n }\n}\n\nasync function requireMember(schoolId: string, userId: string) {\n const membership = await findSchoolMembership(schoolId, userId);\n if (!membership || membership.status !== \"ACTIVE\") {\n throw new TRPCError({\n code: \"FORBIDDEN\",\n message: \"You aren't a member of this institution\",\n });\n }\n return membership;\n}\n\nexport async function listTags(\n userId: string,\n input: { schoolId: string; departmentId?: string | null }\n) {\n await requireMember(input.schoolId, userId);\n return listTagsForSchool(input.schoolId, {\n departmentId: input.departmentId,\n });\n}\n\nexport async function createTagRecord(\n userId: string,\n input: {\n schoolId: string;\n departmentId?: string | null;\n name: string;\n color?: string;\n }\n) {\n await requireAdmin(input.schoolId, userId);\n if (input.departmentId) {\n const dept = await prisma.department.findUnique({\n where: { id: input.departmentId },\n select: { schoolId: true },\n });\n if (!dept || dept.schoolId !== input.schoolId) {\n throw new TRPCError({\n code: \"BAD_REQUEST\",\n message: \"Department does not belong to this institution\",\n });\n }\n }\n const name = normalizeTagName(input.name);\n const existing = await findTagByName(input.schoolId, name);\n if (existing) {\n throw new TRPCError({\n code: \"CONFLICT\",\n message: `A tag named \"${existing.name}\" already exists in this institution`,\n });\n }\n return createTag({ ...input, name });\n}\n\nexport async function updateTagRecord(\n userId: string,\n input: {\n tagId: string;\n name?: string;\n color?: string;\n departmentId?: string | null;\n }\n) {\n const tag = await getTag(input.tagId);\n if (!tag) {\n throw new TRPCError({ code: \"NOT_FOUND\", message: \"Tag not found\" });\n }\n await requireAdmin(tag.schoolId, userId);\n if (input.departmentId) {\n const dept = await prisma.department.findUnique({\n where: { id: input.departmentId },\n select: { schoolId: true },\n });\n if (!dept || dept.schoolId !== tag.schoolId) {\n throw new TRPCError({\n code: \"BAD_REQUEST\",\n message: \"Department does not belong to this institution\",\n });\n }\n }\n let name = input.name;\n if (name !== undefined) {\n name = normalizeTagName(name);\n const existing = await findTagByName(tag.schoolId, name, input.tagId);\n if (existing) {\n throw new TRPCError({\n code: \"CONFLICT\",\n message: `A tag named \"${existing.name}\" already exists in this institution`,\n });\n }\n }\n return updateTag(input.tagId, {\n name,\n color: input.color,\n departmentId: input.departmentId,\n });\n}\n\nexport async function deleteTagRecord(userId: string, tagId: string) {\n const tag = await getTag(tagId);\n if (!tag) {\n throw new TRPCError({ code: \"NOT_FOUND\", message: \"Tag not found\" });\n }\n await requireAdmin(tag.schoolId, userId);\n await deleteTag(tagId);\n return { success: true };\n}\n\nasync function ensureTargetInScope(\n schoolId: string,\n target: TagTarget,\n targetId: string\n) {\n switch (target) {\n case \"USER\": {\n const inSchool = await prisma.schoolMembership.findFirst({\n where: { schoolId, userId: targetId, status: \"ACTIVE\" },\n select: { id: true },\n });\n if (!inSchool) {\n throw new TRPCError({\n code: \"BAD_REQUEST\",\n message: \"User is not in this institution\",\n });\n }\n return;\n }\n case \"CLASS\": {\n const inSchool = await prisma.class.findFirst({\n where: { id: targetId, schoolId },\n select: { id: true },\n });\n if (!inSchool) {\n throw new TRPCError({\n code: \"BAD_REQUEST\",\n message: \"Class is not in this institution\",\n });\n }\n return;\n }\n case \"RUBRIC\": {\n const inSchool = await prisma.markScheme.findFirst({\n where: {\n id: targetId,\n OR: [\n { class: { schoolId } },\n { department: { schoolId } },\n ],\n },\n select: { id: true },\n });\n if (!inSchool) {\n throw new TRPCError({\n code: \"BAD_REQUEST\",\n message: \"Rubric is not in this institution\",\n });\n }\n return;\n }\n case \"BOUNDARY\": {\n const inSchool = await prisma.gradingBoundary.findFirst({\n where: {\n id: targetId,\n OR: [\n { class: { schoolId } },\n { department: { schoolId } },\n ],\n },\n select: { id: true },\n });\n if (!inSchool) {\n throw new TRPCError({\n code: \"BAD_REQUEST\",\n message: \"Boundary is not in this institution\",\n });\n }\n return;\n }\n }\n}\n\nexport async function attachTag(\n userId: string,\n input: { tagId: string; target: TagTarget; targetId: string }\n) {\n const tag = await getTag(input.tagId);\n if (!tag) {\n throw new TRPCError({ code: \"NOT_FOUND\", message: \"Tag not found\" });\n }\n await requireMember(tag.schoolId, userId);\n await ensureTargetInScope(tag.schoolId, input.target, input.targetId);\n return attachTagToTarget(input);\n}\n\nexport async function detachTag(\n userId: string,\n input: { tagId: string; target: TagTarget; targetId: string }\n) {\n const tag = await getTag(input.tagId);\n if (!tag) {\n throw new TRPCError({ code: \"NOT_FOUND\", message: \"Tag not found\" });\n }\n await requireMember(tag.schoolId, userId);\n return detachTagFromTarget(input);\n}\n\nexport async function getAttachedTags(\n userId: string,\n input: { target: TagTarget; targetId: string }\n) {\n const tags = await listTagsAttachedTo(input.target, input.targetId);\n if (tags[0]) {\n await requireMember(tags[0].schoolId, userId);\n }\n return tags;\n}\n"],"names":[],"mappings":"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","debug_id":"b6385fba-13fc-519e-9095-49126706f392"}
|
package/package.json
CHANGED
package/src/models/tag.ts
CHANGED
|
@@ -41,6 +41,27 @@ export function getTag(tagId: string) {
|
|
|
41
41
|
});
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Finds a tag in the same school whose name matches case-insensitively, used to
|
|
46
|
+
* keep tag names normalised (no "Honors" / "honors" duplicates). Pass
|
|
47
|
+
* `excludeTagId` when checking during an update so the tag doesn't clash with
|
|
48
|
+
* itself.
|
|
49
|
+
*/
|
|
50
|
+
export function findTagByName(
|
|
51
|
+
schoolId: string,
|
|
52
|
+
name: string,
|
|
53
|
+
excludeTagId?: string
|
|
54
|
+
) {
|
|
55
|
+
return prisma.tag.findFirst({
|
|
56
|
+
where: {
|
|
57
|
+
schoolId,
|
|
58
|
+
name: { equals: name, mode: "insensitive" },
|
|
59
|
+
...(excludeTagId ? { id: { not: excludeTagId } } : {}),
|
|
60
|
+
},
|
|
61
|
+
select: { id: true, name: true },
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
44
65
|
export function createTag(data: {
|
|
45
66
|
schoolId: string;
|
|
46
67
|
departmentId?: string | null;
|
package/src/routers/school.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
getAdminOverview,
|
|
15
15
|
getCurrentSchool,
|
|
16
16
|
getInvite,
|
|
17
|
+
getMemberTimeline,
|
|
17
18
|
getSchoolAssetUploadUrl,
|
|
18
19
|
getStudentTimeline,
|
|
19
20
|
listClasses,
|
|
@@ -126,6 +127,17 @@ export const schoolRouter = createTRPCRouter({
|
|
|
126
127
|
getStudentTimeline(ctx.user!.id, input)
|
|
127
128
|
),
|
|
128
129
|
|
|
130
|
+
getMemberTimeline: protectedProcedure
|
|
131
|
+
.input(
|
|
132
|
+
z.object({
|
|
133
|
+
schoolId: z.string(),
|
|
134
|
+
memberId: z.string(),
|
|
135
|
+
from: z.date().optional(),
|
|
136
|
+
to: z.date().optional(),
|
|
137
|
+
})
|
|
138
|
+
)
|
|
139
|
+
.query(({ ctx, input }) => getMemberTimeline(ctx.user!.id, input)),
|
|
140
|
+
|
|
129
141
|
getAdminOverview: protectedProcedure
|
|
130
142
|
.input(schoolIdSchema)
|
|
131
143
|
.query(({ ctx, input }) => getAdminOverview(ctx.user!.id, input.schoolId)),
|
package/src/services/school.ts
CHANGED
|
@@ -540,6 +540,237 @@ export async function getStudentTimeline(
|
|
|
540
540
|
};
|
|
541
541
|
}
|
|
542
542
|
|
|
543
|
+
/**
|
|
544
|
+
* Role-aware overview used by `/view/[userId]`.
|
|
545
|
+
*
|
|
546
|
+
* - For STUDENT members it returns the same timeline as `getStudentTimeline`
|
|
547
|
+
* (events, assignments, attendance) so teachers/admins can audit progress.
|
|
548
|
+
* - For TEACHER/ADMIN members it returns their teaching agenda: every event in
|
|
549
|
+
* a class they teach plus their personal events, alongside the classes they
|
|
550
|
+
* run with enrolment counts.
|
|
551
|
+
*
|
|
552
|
+
* Permissions:
|
|
553
|
+
* - School admins can view anyone in the institution.
|
|
554
|
+
* - A teacher can view a STUDENT they share a class with.
|
|
555
|
+
* - A teacher/admin can always view their own overview.
|
|
556
|
+
*/
|
|
557
|
+
export async function getMemberTimeline(
|
|
558
|
+
userId: string,
|
|
559
|
+
input: {
|
|
560
|
+
schoolId: string;
|
|
561
|
+
memberId: string;
|
|
562
|
+
from?: Date;
|
|
563
|
+
to?: Date;
|
|
564
|
+
}
|
|
565
|
+
) {
|
|
566
|
+
const callerIsAdmin = Boolean(
|
|
567
|
+
await findActiveSchoolAdminMembership(input.schoolId, userId)
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
const membership = await prisma.schoolMembership.findFirst({
|
|
571
|
+
where: {
|
|
572
|
+
schoolId: input.schoolId,
|
|
573
|
+
userId: input.memberId,
|
|
574
|
+
status: "ACTIVE",
|
|
575
|
+
},
|
|
576
|
+
select: {
|
|
577
|
+
role: true,
|
|
578
|
+
user: {
|
|
579
|
+
select: {
|
|
580
|
+
id: true,
|
|
581
|
+
username: true,
|
|
582
|
+
profile: { select: { displayName: true, profilePicture: true } },
|
|
583
|
+
},
|
|
584
|
+
},
|
|
585
|
+
},
|
|
586
|
+
});
|
|
587
|
+
if (!membership) {
|
|
588
|
+
throw new TRPCError({
|
|
589
|
+
code: "NOT_FOUND",
|
|
590
|
+
message: "This person is not in this institution",
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const range = {
|
|
595
|
+
...(input.from ? { gte: input.from } : {}),
|
|
596
|
+
...(input.to ? { lte: input.to } : {}),
|
|
597
|
+
};
|
|
598
|
+
const hasRange = Object.keys(range).length > 0;
|
|
599
|
+
|
|
600
|
+
if (membership.role === "STUDENT") {
|
|
601
|
+
if (!callerIsAdmin) {
|
|
602
|
+
const sharedClass = await prisma.class.findFirst({
|
|
603
|
+
where: {
|
|
604
|
+
schoolId: input.schoolId,
|
|
605
|
+
teachers: { some: { id: userId } },
|
|
606
|
+
students: { some: { id: input.memberId } },
|
|
607
|
+
},
|
|
608
|
+
select: { id: true },
|
|
609
|
+
});
|
|
610
|
+
if (!sharedClass) {
|
|
611
|
+
throw new TRPCError({
|
|
612
|
+
code: "FORBIDDEN",
|
|
613
|
+
message: "You don't share a class with this student",
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const [events, assignments, attendance] = await Promise.all([
|
|
619
|
+
prisma.event.findMany({
|
|
620
|
+
where: {
|
|
621
|
+
userId: input.memberId,
|
|
622
|
+
...(hasRange ? { startTime: range } : {}),
|
|
623
|
+
},
|
|
624
|
+
select: {
|
|
625
|
+
id: true,
|
|
626
|
+
name: true,
|
|
627
|
+
startTime: true,
|
|
628
|
+
endTime: true,
|
|
629
|
+
location: true,
|
|
630
|
+
remarks: true,
|
|
631
|
+
color: true,
|
|
632
|
+
class: { select: { id: true, name: true, color: true } },
|
|
633
|
+
},
|
|
634
|
+
orderBy: { startTime: "asc" },
|
|
635
|
+
}),
|
|
636
|
+
prisma.assignment.findMany({
|
|
637
|
+
where: {
|
|
638
|
+
class: { students: { some: { id: input.memberId } } },
|
|
639
|
+
template: false,
|
|
640
|
+
...(hasRange ? { dueDate: range } : {}),
|
|
641
|
+
},
|
|
642
|
+
select: {
|
|
643
|
+
id: true,
|
|
644
|
+
title: true,
|
|
645
|
+
dueDate: true,
|
|
646
|
+
type: true,
|
|
647
|
+
graded: true,
|
|
648
|
+
maxGrade: true,
|
|
649
|
+
weight: true,
|
|
650
|
+
class: {
|
|
651
|
+
select: {
|
|
652
|
+
id: true,
|
|
653
|
+
name: true,
|
|
654
|
+
section: true,
|
|
655
|
+
subject: true,
|
|
656
|
+
color: true,
|
|
657
|
+
},
|
|
658
|
+
},
|
|
659
|
+
submissions: {
|
|
660
|
+
where: { studentId: input.memberId },
|
|
661
|
+
select: {
|
|
662
|
+
id: true,
|
|
663
|
+
submitted: true,
|
|
664
|
+
submittedAt: true,
|
|
665
|
+
returned: true,
|
|
666
|
+
gradeReceived: true,
|
|
667
|
+
},
|
|
668
|
+
},
|
|
669
|
+
},
|
|
670
|
+
orderBy: { dueDate: "asc" },
|
|
671
|
+
}),
|
|
672
|
+
prisma.attendance.findMany({
|
|
673
|
+
where: {
|
|
674
|
+
OR: [
|
|
675
|
+
{ present: { some: { id: input.memberId } } },
|
|
676
|
+
{ late: { some: { id: input.memberId } } },
|
|
677
|
+
{ absent: { some: { id: input.memberId } } },
|
|
678
|
+
],
|
|
679
|
+
...(hasRange ? { date: range } : {}),
|
|
680
|
+
},
|
|
681
|
+
select: {
|
|
682
|
+
id: true,
|
|
683
|
+
date: true,
|
|
684
|
+
class: { select: { id: true, name: true, color: true } },
|
|
685
|
+
present: { where: { id: input.memberId }, select: { id: true } },
|
|
686
|
+
late: { where: { id: input.memberId }, select: { id: true } },
|
|
687
|
+
absent: { where: { id: input.memberId }, select: { id: true } },
|
|
688
|
+
},
|
|
689
|
+
orderBy: { date: "asc" },
|
|
690
|
+
}),
|
|
691
|
+
]);
|
|
692
|
+
|
|
693
|
+
const attendanceShaped = attendance.map((record) => ({
|
|
694
|
+
id: record.id,
|
|
695
|
+
date: record.date,
|
|
696
|
+
class: record.class,
|
|
697
|
+
status: record.present.length
|
|
698
|
+
? ("PRESENT" as const)
|
|
699
|
+
: record.late.length
|
|
700
|
+
? ("LATE" as const)
|
|
701
|
+
: ("ABSENT" as const),
|
|
702
|
+
}));
|
|
703
|
+
|
|
704
|
+
return {
|
|
705
|
+
role: "STUDENT" as const,
|
|
706
|
+
member: membership.user,
|
|
707
|
+
events,
|
|
708
|
+
assignments,
|
|
709
|
+
attendance: attendanceShaped,
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// TEACHER or ADMIN: only admins (or the member themselves) may view.
|
|
714
|
+
if (!callerIsAdmin && userId !== input.memberId) {
|
|
715
|
+
throw new TRPCError({
|
|
716
|
+
code: "FORBIDDEN",
|
|
717
|
+
message: "Only school admins can view this member",
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const [events, classes] = await Promise.all([
|
|
722
|
+
prisma.event.findMany({
|
|
723
|
+
where: {
|
|
724
|
+
OR: [
|
|
725
|
+
{ userId: input.memberId },
|
|
726
|
+
{ class: { teachers: { some: { id: input.memberId } } } },
|
|
727
|
+
],
|
|
728
|
+
...(hasRange ? { startTime: range } : {}),
|
|
729
|
+
},
|
|
730
|
+
select: {
|
|
731
|
+
id: true,
|
|
732
|
+
name: true,
|
|
733
|
+
startTime: true,
|
|
734
|
+
endTime: true,
|
|
735
|
+
location: true,
|
|
736
|
+
remarks: true,
|
|
737
|
+
color: true,
|
|
738
|
+
class: { select: { id: true, name: true, color: true } },
|
|
739
|
+
},
|
|
740
|
+
orderBy: { startTime: "asc" },
|
|
741
|
+
}),
|
|
742
|
+
prisma.class.findMany({
|
|
743
|
+
where: {
|
|
744
|
+
schoolId: input.schoolId,
|
|
745
|
+
teachers: { some: { id: input.memberId } },
|
|
746
|
+
},
|
|
747
|
+
select: {
|
|
748
|
+
id: true,
|
|
749
|
+
name: true,
|
|
750
|
+
section: true,
|
|
751
|
+
subject: true,
|
|
752
|
+
color: true,
|
|
753
|
+
_count: { select: { students: true } },
|
|
754
|
+
},
|
|
755
|
+
orderBy: { name: "asc" },
|
|
756
|
+
}),
|
|
757
|
+
]);
|
|
758
|
+
|
|
759
|
+
return {
|
|
760
|
+
role: membership.role as "TEACHER" | "ADMIN",
|
|
761
|
+
member: membership.user,
|
|
762
|
+
events,
|
|
763
|
+
classes: classes.map((c) => ({
|
|
764
|
+
id: c.id,
|
|
765
|
+
name: c.name,
|
|
766
|
+
section: c.section,
|
|
767
|
+
subject: c.subject,
|
|
768
|
+
color: c.color,
|
|
769
|
+
studentCount: c._count.students,
|
|
770
|
+
})),
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
|
|
543
774
|
export async function createInvite(
|
|
544
775
|
userId: string,
|
|
545
776
|
input: {
|
package/src/services/tag.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
createTag,
|
|
10
10
|
deleteTag,
|
|
11
11
|
detachTagFromTarget,
|
|
12
|
+
findTagByName,
|
|
12
13
|
getTag,
|
|
13
14
|
listTagsAttachedTo,
|
|
14
15
|
listTagsForSchool,
|
|
@@ -20,6 +21,23 @@ import {
|
|
|
20
21
|
findSchoolMembership,
|
|
21
22
|
} from "../models/school.js";
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Normalises a tag name to a canonical display form: trimmed with internal
|
|
26
|
+
* whitespace collapsed to single spaces. Casing is preserved so labels like
|
|
27
|
+
* "AP" or "Year 12" read naturally; uniqueness is enforced case-insensitively
|
|
28
|
+
* separately via {@link findTagByName}.
|
|
29
|
+
*/
|
|
30
|
+
function normalizeTagName(name: string): string {
|
|
31
|
+
const normalized = name.trim().replace(/\s+/g, " ");
|
|
32
|
+
if (!normalized) {
|
|
33
|
+
throw new TRPCError({
|
|
34
|
+
code: "BAD_REQUEST",
|
|
35
|
+
message: "Tag name can't be empty",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return normalized;
|
|
39
|
+
}
|
|
40
|
+
|
|
23
41
|
async function requireAdmin(schoolId: string, userId: string) {
|
|
24
42
|
const membership = await findActiveSchoolAdminMembership(schoolId, userId);
|
|
25
43
|
if (!membership) {
|
|
@@ -73,7 +91,15 @@ export async function createTagRecord(
|
|
|
73
91
|
});
|
|
74
92
|
}
|
|
75
93
|
}
|
|
76
|
-
|
|
94
|
+
const name = normalizeTagName(input.name);
|
|
95
|
+
const existing = await findTagByName(input.schoolId, name);
|
|
96
|
+
if (existing) {
|
|
97
|
+
throw new TRPCError({
|
|
98
|
+
code: "CONFLICT",
|
|
99
|
+
message: `A tag named "${existing.name}" already exists in this institution`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return createTag({ ...input, name });
|
|
77
103
|
}
|
|
78
104
|
|
|
79
105
|
export async function updateTagRecord(
|
|
@@ -102,8 +128,19 @@ export async function updateTagRecord(
|
|
|
102
128
|
});
|
|
103
129
|
}
|
|
104
130
|
}
|
|
131
|
+
let name = input.name;
|
|
132
|
+
if (name !== undefined) {
|
|
133
|
+
name = normalizeTagName(name);
|
|
134
|
+
const existing = await findTagByName(tag.schoolId, name, input.tagId);
|
|
135
|
+
if (existing) {
|
|
136
|
+
throw new TRPCError({
|
|
137
|
+
code: "CONFLICT",
|
|
138
|
+
message: `A tag named "${existing.name}" already exists in this institution`,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
105
142
|
return updateTag(input.tagId, {
|
|
106
|
-
name
|
|
143
|
+
name,
|
|
107
144
|
color: input.color,
|
|
108
145
|
departmentId: input.departmentId,
|
|
109
146
|
});
|