@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.
@@ -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]="6ab21e9e-024c-5347-9864-92a8edf76dbe")}catch(e){}}();
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
- return createTag(input);
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: input.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=6ab21e9e-024c-5347-9864-92a8edf76dbe
213
+ //# debugId=b6385fba-13fc-519e-9095-49126706f392
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@studious-lms/server",
3
- "version": "1.16.1",
3
+ "version": "1.17.1",
4
4
  "description": "Backend server for Studious application",
5
5
  "main": "dist/exportType.js",
6
6
  "types": "dist/exportType.d.ts",
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;
@@ -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)),
@@ -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: {
@@ -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
- return createTag(input);
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: input.name,
143
+ name,
107
144
  color: input.color,
108
145
  departmentId: input.departmentId,
109
146
  });