@studious-lms/server 1.17.0 → 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.
@@ -43,6 +43,40 @@ export declare function getTag(tagId: string): import("@prisma/client/runtime/li
43
43
  color: string;
44
44
  departmentId: string | null;
45
45
  } | null>;
46
+ /**
47
+ * Finds a tag in the same school whose name matches case-insensitively, used to
48
+ * keep tag names normalised (no "Honors" / "honors" duplicates). Pass
49
+ * `excludeTagId` when checking during an update so the tag doesn't clash with
50
+ * itself.
51
+ */
52
+ export declare function findTagByName(schoolId: string, name: string, excludeTagId?: string): import("@prisma/client/runtime/library.js").DynamicModelExtensionFluentApi<import(".prisma/client").Prisma.TypeMap<import("@prisma/client/runtime/library.js").InternalArgs & {
53
+ result: {
54
+ userProfile: {
55
+ profilePicture: () => {
56
+ needs: {
57
+ profilePicture: true;
58
+ };
59
+ compute(profile: {
60
+ profilePicture: string | null;
61
+ }): string | null;
62
+ };
63
+ profilePictureThumbnail: () => {
64
+ needs: {
65
+ profilePictureThumbnail: true;
66
+ };
67
+ compute(profile: {
68
+ profilePictureThumbnail: string | null;
69
+ }): string | null;
70
+ };
71
+ };
72
+ };
73
+ model: {};
74
+ query: {};
75
+ client: {};
76
+ }, {}>, "Tag", "findFirst", null> & import("@prisma/client/runtime/library.js").PrismaPromise<{
77
+ id: string;
78
+ name: string;
79
+ } | null>;
46
80
  export declare function createTag(data: {
47
81
  schoolId: string;
48
82
  departmentId?: string | null;
@@ -1 +1 @@
1
- {"version":3,"file":"tag.d.ts","sourceRoot":"/","sources":["models/tag.ts"],"names":[],"mappings":"AAgBA,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,GAAG,UAAU,CAAC;AAEjE,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE;IAAE,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE;;;;;;;;KAc3C;AAED,wBAAgB,MAAM,CAAC,KAAK,EAAE,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;UAKnC;AAED,wBAAgB,SAAS,CAAC,IAAI,EAAE;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAUA;AAED,wBAAgB,SAAS,CACvB,KAAK,EAAE,MAAM,EACb,IAAI,EAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAOtE;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAEtC;AASD,wBAAgB,iBAAiB,CAAC,MAAM,EAAE;IACxC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,SAAS,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;CAClB;;;;;;;;;;;;;;;;;;;;;;;;;;GASA;AAED,wBAAgB,mBAAmB,CAAC,MAAM,EAAE;IAC1C,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,SAAS,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;CAClB;;;;;;;;;;;;;;;;;;;;;;;;;;GASA;AAED,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM;;;;;;;;KA2BrE"}
1
+ {"version":3,"file":"tag.d.ts","sourceRoot":"/","sources":["models/tag.ts"],"names":[],"mappings":"AAgBA,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,GAAG,UAAU,CAAC;AAEjE,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE;IAAE,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE;;;;;;;;KAc3C;AAED,wBAAgB,MAAM,CAAC,KAAK,EAAE,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;UAKnC;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,EACZ,YAAY,CAAC,EAAE,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;UAUtB;AAED,wBAAgB,SAAS,CAAC,IAAI,EAAE;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAUA;AAED,wBAAgB,SAAS,CACvB,KAAK,EAAE,MAAM,EACb,IAAI,EAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAOtE;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAEtC;AASD,wBAAgB,iBAAiB,CAAC,MAAM,EAAE;IACxC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,SAAS,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;CAClB;;;;;;;;;;;;;;;;;;;;;;;;;;GASA;AAED,wBAAgB,mBAAmB,CAAC,MAAM,EAAE;IAC1C,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,SAAS,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;CAClB;;;;;;;;;;;;;;;;;;;;;;;;;;GASA;AAED,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM;;;;;;;;KA2BrE"}
@@ -3,7 +3,7 @@
3
3
  * and grading boundaries. Optionally associated with a department.
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]="1f1da484-7079-5ae5-8503-0d22add1dd3e")}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]="49abc52c-9751-5de2-a871-b58e5d925c9a")}catch(e){}}();
7
7
  import { prisma } from "../lib/prisma.js";
8
8
  const tagSelect = {
9
9
  id: true,
@@ -34,6 +34,22 @@ export function getTag(tagId) {
34
34
  select: tagSelect,
35
35
  });
36
36
  }
37
+ /**
38
+ * Finds a tag in the same school whose name matches case-insensitively, used to
39
+ * keep tag names normalised (no "Honors" / "honors" duplicates). Pass
40
+ * `excludeTagId` when checking during an update so the tag doesn't clash with
41
+ * itself.
42
+ */
43
+ export function findTagByName(schoolId, name, excludeTagId) {
44
+ return prisma.tag.findFirst({
45
+ where: {
46
+ schoolId,
47
+ name: { equals: name, mode: "insensitive" },
48
+ ...(excludeTagId ? { id: { not: excludeTagId } } : {}),
49
+ },
50
+ select: { id: true, name: true },
51
+ });
52
+ }
37
53
  export function createTag(data) {
38
54
  return prisma.tag.create({
39
55
  data: {
@@ -110,4 +126,4 @@ export function listTagsAttachedTo(target, targetId) {
110
126
  }
111
127
  }
112
128
  //# sourceMappingURL=tag.js.map
113
- //# debugId=1f1da484-7079-5ae5-8503-0d22add1dd3e
129
+ //# debugId=49abc52c-9751-5de2-a871-b58e5d925c9a
@@ -1 +1 @@
1
- {"version":3,"file":"tag.js","sources":["models/tag.ts"],"sourceRoot":"/","sourcesContent":["/**\n * Tag model — institution-scoped tags applicable to users, classes, rubrics,\n * and grading boundaries. Optionally associated with a department.\n */\nimport { prisma } from \"../lib/prisma.js\";\n\nconst tagSelect = {\n id: true,\n schoolId: true,\n departmentId: true,\n name: true,\n color: true,\n createdAt: true,\n updatedAt: true,\n} as const;\n\nexport type TagTarget = \"USER\" | \"CLASS\" | \"RUBRIC\" | \"BOUNDARY\";\n\nexport function listTagsForSchool(\n schoolId: string,\n options?: { departmentId?: string | null }\n) {\n return prisma.tag.findMany({\n where: {\n schoolId,\n ...(options?.departmentId === null\n ? { departmentId: null }\n : options?.departmentId\n ? { departmentId: options.departmentId }\n : {}),\n },\n orderBy: [{ name: \"asc\" }],\n select: tagSelect,\n });\n}\n\nexport function getTag(tagId: string) {\n return prisma.tag.findUnique({\n where: { id: tagId },\n select: tagSelect,\n });\n}\n\nexport function createTag(data: {\n schoolId: string;\n departmentId?: string | null;\n name: string;\n color?: string;\n}) {\n return prisma.tag.create({\n data: {\n schoolId: data.schoolId,\n departmentId: data.departmentId ?? null,\n name: data.name,\n color: data.color ?? \"#3B82F6\",\n },\n select: tagSelect,\n });\n}\n\nexport function updateTag(\n tagId: string,\n data: { name?: string; color?: string; departmentId?: string | null }\n) {\n return prisma.tag.update({\n where: { id: tagId },\n data,\n select: tagSelect,\n });\n}\n\nexport function deleteTag(tagId: string) {\n return prisma.tag.delete({ where: { id: tagId } });\n}\n\nconst targetRelation: Record<TagTarget, \"users\" | \"classes\" | \"rubrics\" | \"boundaries\"> = {\n USER: \"users\",\n CLASS: \"classes\",\n RUBRIC: \"rubrics\",\n BOUNDARY: \"boundaries\",\n};\n\nexport function attachTagToTarget(params: {\n tagId: string;\n target: TagTarget;\n targetId: string;\n}) {\n const relation = targetRelation[params.target];\n return prisma.tag.update({\n where: { id: params.tagId },\n data: {\n [relation]: { connect: { id: params.targetId } },\n },\n select: { id: true },\n });\n}\n\nexport function detachTagFromTarget(params: {\n tagId: string;\n target: TagTarget;\n targetId: string;\n}) {\n const relation = targetRelation[params.target];\n return prisma.tag.update({\n where: { id: params.tagId },\n data: {\n [relation]: { disconnect: { id: params.targetId } },\n },\n select: { id: true },\n });\n}\n\nexport function listTagsAttachedTo(target: TagTarget, targetId: string) {\n switch (target) {\n case \"USER\":\n return prisma.tag.findMany({\n where: { users: { some: { id: targetId } } },\n select: tagSelect,\n orderBy: { name: \"asc\" },\n });\n case \"CLASS\":\n return prisma.tag.findMany({\n where: { classes: { some: { id: targetId } } },\n select: tagSelect,\n orderBy: { name: \"asc\" },\n });\n case \"RUBRIC\":\n return prisma.tag.findMany({\n where: { rubrics: { some: { id: targetId } } },\n select: tagSelect,\n orderBy: { name: \"asc\" },\n });\n case \"BOUNDARY\":\n return prisma.tag.findMany({\n where: { boundaries: { some: { id: targetId } } },\n select: tagSelect,\n orderBy: { name: \"asc\" },\n });\n }\n}\n"],"names":[],"mappings":"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","debug_id":"1f1da484-7079-5ae5-8503-0d22add1dd3e"}
1
+ {"version":3,"file":"tag.js","sources":["models/tag.ts"],"sourceRoot":"/","sourcesContent":["/**\n * Tag model — institution-scoped tags applicable to users, classes, rubrics,\n * and grading boundaries. Optionally associated with a department.\n */\nimport { prisma } from \"../lib/prisma.js\";\n\nconst tagSelect = {\n id: true,\n schoolId: true,\n departmentId: true,\n name: true,\n color: true,\n createdAt: true,\n updatedAt: true,\n} as const;\n\nexport type TagTarget = \"USER\" | \"CLASS\" | \"RUBRIC\" | \"BOUNDARY\";\n\nexport function listTagsForSchool(\n schoolId: string,\n options?: { departmentId?: string | null }\n) {\n return prisma.tag.findMany({\n where: {\n schoolId,\n ...(options?.departmentId === null\n ? { departmentId: null }\n : options?.departmentId\n ? { departmentId: options.departmentId }\n : {}),\n },\n orderBy: [{ name: \"asc\" }],\n select: tagSelect,\n });\n}\n\nexport function getTag(tagId: string) {\n return prisma.tag.findUnique({\n where: { id: tagId },\n select: tagSelect,\n });\n}\n\n/**\n * Finds a tag in the same school whose name matches case-insensitively, used to\n * keep tag names normalised (no \"Honors\" / \"honors\" duplicates). Pass\n * `excludeTagId` when checking during an update so the tag doesn't clash with\n * itself.\n */\nexport function findTagByName(\n schoolId: string,\n name: string,\n excludeTagId?: string\n) {\n return prisma.tag.findFirst({\n where: {\n schoolId,\n name: { equals: name, mode: \"insensitive\" },\n ...(excludeTagId ? { id: { not: excludeTagId } } : {}),\n },\n select: { id: true, name: true },\n });\n}\n\nexport function createTag(data: {\n schoolId: string;\n departmentId?: string | null;\n name: string;\n color?: string;\n}) {\n return prisma.tag.create({\n data: {\n schoolId: data.schoolId,\n departmentId: data.departmentId ?? null,\n name: data.name,\n color: data.color ?? \"#3B82F6\",\n },\n select: tagSelect,\n });\n}\n\nexport function updateTag(\n tagId: string,\n data: { name?: string; color?: string; departmentId?: string | null }\n) {\n return prisma.tag.update({\n where: { id: tagId },\n data,\n select: tagSelect,\n });\n}\n\nexport function deleteTag(tagId: string) {\n return prisma.tag.delete({ where: { id: tagId } });\n}\n\nconst targetRelation: Record<TagTarget, \"users\" | \"classes\" | \"rubrics\" | \"boundaries\"> = {\n USER: \"users\",\n CLASS: \"classes\",\n RUBRIC: \"rubrics\",\n BOUNDARY: \"boundaries\",\n};\n\nexport function attachTagToTarget(params: {\n tagId: string;\n target: TagTarget;\n targetId: string;\n}) {\n const relation = targetRelation[params.target];\n return prisma.tag.update({\n where: { id: params.tagId },\n data: {\n [relation]: { connect: { id: params.targetId } },\n },\n select: { id: true },\n });\n}\n\nexport function detachTagFromTarget(params: {\n tagId: string;\n target: TagTarget;\n targetId: string;\n}) {\n const relation = targetRelation[params.target];\n return prisma.tag.update({\n where: { id: params.tagId },\n data: {\n [relation]: { disconnect: { id: params.targetId } },\n },\n select: { id: true },\n });\n}\n\nexport function listTagsAttachedTo(target: TagTarget, targetId: string) {\n switch (target) {\n case \"USER\":\n return prisma.tag.findMany({\n where: { users: { some: { id: targetId } } },\n select: tagSelect,\n orderBy: { name: \"asc\" },\n });\n case \"CLASS\":\n return prisma.tag.findMany({\n where: { classes: { some: { id: targetId } } },\n select: tagSelect,\n orderBy: { name: \"asc\" },\n });\n case \"RUBRIC\":\n return prisma.tag.findMany({\n where: { rubrics: { some: { id: targetId } } },\n select: tagSelect,\n orderBy: { name: \"asc\" },\n });\n case \"BOUNDARY\":\n return prisma.tag.findMany({\n where: { boundaries: { some: { id: targetId } } },\n select: tagSelect,\n orderBy: { name: \"asc\" },\n });\n }\n}\n"],"names":[],"mappings":"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","debug_id":"49abc52c-9751-5de2-a871-b58e5d925c9a"}
@@ -1 +1 @@
1
- {"version":3,"file":"tag.d.ts","sourceRoot":"/","sources":["services/tag.ts"],"names":[],"mappings":"AAMA,OAAO,EAQL,KAAK,SAAS,EAEf,MAAM,kBAAkB,CAAC;AA2B1B,wBAAsB,QAAQ,CAC5B,MAAM,EAAE,MAAM,EACd,KAAK,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE;;;;;;;;KAM1D;AAED,wBAAsB,eAAe,CACnC,MAAM,EAAE,MAAM,EACd,KAAK,EAAE;IACL,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;;;;;;;;GAgBF;AAED,wBAAsB,eAAe,CACnC,MAAM,EAAE,MAAM,EACd,KAAK,EAAE;IACL,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;;;;;;;;GAwBF;AAED,wBAAsB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM;;GAQlE;AA2ED,wBAAsB,SAAS,CAC7B,MAAM,EAAE,MAAM,EACd,KAAK,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,SAAS,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE;;GAS9D;AAED,wBAAsB,SAAS,CAC7B,MAAM,EAAE,MAAM,EACd,KAAK,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,SAAS,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE;;GAQ9D;AAED,wBAAsB,eAAe,CACnC,MAAM,EAAE,MAAM,EACd,KAAK,EAAE;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE;;;;;;;;KAO/C"}
1
+ {"version":3,"file":"tag.d.ts","sourceRoot":"/","sources":["services/tag.ts"],"names":[],"mappings":"AAMA,OAAO,EASL,KAAK,SAAS,EAEf,MAAM,kBAAkB,CAAC;AA4C1B,wBAAsB,QAAQ,CAC5B,MAAM,EAAE,MAAM,EACd,KAAK,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE;;;;;;;;KAM1D;AAED,wBAAsB,eAAe,CACnC,MAAM,EAAE,MAAM,EACd,KAAK,EAAE;IACL,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;;;;;;;;GAwBF;AAED,wBAAsB,eAAe,CACnC,MAAM,EAAE,MAAM,EACd,KAAK,EAAE;IACL,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;;;;;;;;GAmCF;AAED,wBAAsB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM;;GAQlE;AA2ED,wBAAsB,SAAS,CAC7B,MAAM,EAAE,MAAM,EACd,KAAK,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,SAAS,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE;;GAS9D;AAED,wBAAsB,SAAS,CAC7B,MAAM,EAAE,MAAM,EACd,KAAK,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,SAAS,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE;;GAQ9D;AAED,wBAAsB,eAAe,CACnC,MAAM,EAAE,MAAM,EACd,KAAK,EAAE;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE;;;;;;;;KAO/C"}
@@ -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":"AAAA;;;GAGG;;;AACH,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAC1C,OAAO,EACL,iBAAiB,EACjB,SAAS,EACT,SAAS,EACT,mBAAmB,EACnB,aAAa,EACb,MAAM,EACN,kBAAkB,EAClB,iBAAiB,EAEjB,SAAS,GACV,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,+BAA+B,EAC/B,oBAAoB,GACrB,MAAM,qBAAqB,CAAC;AAE7B;;;;;GAKG;AACH,SAAS,gBAAgB,CAAC,IAAY;IACpC,MAAM,UAAU,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC,OAAO,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IACpD,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,MAAM,IAAI,SAAS,CAAC;YAClB,IAAI,EAAE,aAAa;YACnB,OAAO,EAAE,yBAAyB;SACnC,CAAC,CAAC;IACL,CAAC;IACD,OAAO,UAAU,CAAC;AACpB,CAAC;AAED,KAAK,UAAU,YAAY,CAAC,QAAgB,EAAE,MAAc;IAC1D,MAAM,UAAU,GAAG,MAAM,+BAA+B,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IAC3E,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,MAAM,IAAI,SAAS,CAAC;YAClB,IAAI,EAAE,WAAW;YACjB,OAAO,EAAE,oCAAoC;SAC9C,CAAC,CAAC;IACL,CAAC;AACH,CAAC;AAED,KAAK,UAAU,aAAa,CAAC,QAAgB,EAAE,MAAc;IAC3D,MAAM,UAAU,GAAG,MAAM,oBAAoB,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IAChE,IAAI,CAAC,UAAU,IAAI,UAAU,CAAC,MAAM,KAAK,QAAQ,EAAE,CAAC;QAClD,MAAM,IAAI,SAAS,CAAC;YAClB,IAAI,EAAE,WAAW;YACjB,OAAO,EAAE,yCAAyC;SACnD,CAAC,CAAC;IACL,CAAC;IACD,OAAO,UAAU,CAAC;AACpB,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,QAAQ,CAC5B,MAAc,EACd,KAAyD;IAEzD,MAAM,aAAa,CAAC,KAAK,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IAC5C,OAAO,iBAAiB,CAAC,KAAK,CAAC,QAAQ,EAAE;QACvC,YAAY,EAAE,KAAK,CAAC,YAAY;KACjC,CAAC,CAAC;AACL,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,MAAc,EACd,KAKC;IAED,MAAM,YAAY,CAAC,KAAK,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IAC3C,IAAI,KAAK,CAAC,YAAY,EAAE,CAAC;QACvB,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,UAAU,CAAC,UAAU,CAAC;YAC9C,KAAK,EAAE,EAAE,EAAE,EAAE,KAAK,CAAC,YAAY,EAAE;YACjC,MAAM,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE;SAC3B,CAAC,CAAC;QACH,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,QAAQ,KAAK,KAAK,CAAC,QAAQ,EAAE,CAAC;YAC9C,MAAM,IAAI,SAAS,CAAC;gBAClB,IAAI,EAAE,aAAa;gBACnB,OAAO,EAAE,gDAAgD;aAC1D,CAAC,CAAC;QACL,CAAC;IACH,CAAC;IACD,MAAM,IAAI,GAAG,gBAAgB,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAC1C,MAAM,QAAQ,GAAG,MAAM,aAAa,CAAC,KAAK,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;IAC3D,IAAI,QAAQ,EAAE,CAAC;QACb,MAAM,IAAI,SAAS,CAAC;YAClB,IAAI,EAAE,UAAU;YAChB,OAAO,EAAE,gBAAgB,QAAQ,CAAC,IAAI,sCAAsC;SAC7E,CAAC,CAAC;IACL,CAAC;IACD,OAAO,SAAS,CAAC,EAAE,GAAG,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;AACvC,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,MAAc,EACd,KAKC;IAED,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;IACtC,IAAI,CAAC,GAAG,EAAE,CAAC;QACT,MAAM,IAAI,SAAS,CAAC,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,eAAe,EAAE,CAAC,CAAC;IACvE,CAAC;IACD,MAAM,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IACzC,IAAI,KAAK,CAAC,YAAY,EAAE,CAAC;QACvB,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,UAAU,CAAC,UAAU,CAAC;YAC9C,KAAK,EAAE,EAAE,EAAE,EAAE,KAAK,CAAC,YAAY,EAAE;YACjC,MAAM,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE;SAC3B,CAAC,CAAC;QACH,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,QAAQ,KAAK,GAAG,CAAC,QAAQ,EAAE,CAAC;YAC5C,MAAM,IAAI,SAAS,CAAC;gBAClB,IAAI,EAAE,aAAa;gBACnB,OAAO,EAAE,gDAAgD;aAC1D,CAAC,CAAC;QACL,CAAC;IACH,CAAC;IACD,IAAI,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC;IACtB,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;QACvB,IAAI,GAAG,gBAAgB,CAAC,IAAI,CAAC,CAAC;QAC9B,MAAM,QAAQ,GAAG,MAAM,aAAa,CAAC,GAAG,CAAC,QAAQ,EAAE,IAAI,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC;QACtE,IAAI,QAAQ,EAAE,CAAC;YACb,MAAM,IAAI,SAAS,CAAC;gBAClB,IAAI,EAAE,UAAU;gBAChB,OAAO,EAAE,gBAAgB,QAAQ,CAAC,IAAI,sCAAsC;aAC7E,CAAC,CAAC;QACL,CAAC;IACH,CAAC;IACD,OAAO,SAAS,CAAC,KAAK,CAAC,KAAK,EAAE;QAC5B,IAAI;QACJ,KAAK,EAAE,KAAK,CAAC,KAAK;QAClB,YAAY,EAAE,KAAK,CAAC,YAAY;KACjC,CAAC,CAAC;AACL,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,MAAc,EAAE,KAAa;IACjE,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC,CAAC;IAChC,IAAI,CAAC,GAAG,EAAE,CAAC;QACT,MAAM,IAAI,SAAS,CAAC,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,eAAe,EAAE,CAAC,CAAC;IACvE,CAAC;IACD,MAAM,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IACzC,MAAM,SAAS,CAAC,KAAK,CAAC,CAAC;IACvB,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;AAC3B,CAAC;AAED,KAAK,UAAU,mBAAmB,CAChC,QAAgB,EAChB,MAAiB,EACjB,QAAgB;IAEhB,QAAQ,MAAM,EAAE,CAAC;QACf,KAAK,MAAM,CAAC,CAAC,CAAC;YACZ,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,gBAAgB,CAAC,SAAS,CAAC;gBACvD,KAAK,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE;gBACvD,MAAM,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE;aACrB,CAAC,CAAC;YACH,IAAI,CAAC,QAAQ,EAAE,CAAC;gBACd,MAAM,IAAI,SAAS,CAAC;oBAClB,IAAI,EAAE,aAAa;oBACnB,OAAO,EAAE,iCAAiC;iBAC3C,CAAC,CAAC;YACL,CAAC;YACD,OAAO;QACT,CAAC;QACD,KAAK,OAAO,CAAC,CAAC,CAAC;YACb,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC,SAAS,CAAC;gBAC5C,KAAK,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,QAAQ,EAAE;gBACjC,MAAM,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE;aACrB,CAAC,CAAC;YACH,IAAI,CAAC,QAAQ,EAAE,CAAC;gBACd,MAAM,IAAI,SAAS,CAAC;oBAClB,IAAI,EAAE,aAAa;oBACnB,OAAO,EAAE,kCAAkC;iBAC5C,CAAC,CAAC;YACL,CAAC;YACD,OAAO;QACT,CAAC;QACD,KAAK,QAAQ,CAAC,CAAC,CAAC;YACd,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,UAAU,CAAC,SAAS,CAAC;gBACjD,KAAK,EAAE;oBACL,EAAE,EAAE,QAAQ;oBACZ,EAAE,EAAE;wBACF,EAAE,KAAK,EAAE,EAAE,QAAQ,EAAE,EAAE;wBACvB,EAAE,UAAU,EAAE,EAAE,QAAQ,EAAE,EAAE;qBAC7B;iBACF;gBACD,MAAM,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE;aACrB,CAAC,CAAC;YACH,IAAI,CAAC,QAAQ,EAAE,CAAC;gBACd,MAAM,IAAI,SAAS,CAAC;oBAClB,IAAI,EAAE,aAAa;oBACnB,OAAO,EAAE,mCAAmC;iBAC7C,CAAC,CAAC;YACL,CAAC;YACD,OAAO;QACT,CAAC;QACD,KAAK,UAAU,CAAC,CAAC,CAAC;YAChB,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,eAAe,CAAC,SAAS,CAAC;gBACtD,KAAK,EAAE;oBACL,EAAE,EAAE,QAAQ;oBACZ,EAAE,EAAE;wBACF,EAAE,KAAK,EAAE,EAAE,QAAQ,EAAE,EAAE;wBACvB,EAAE,UAAU,EAAE,EAAE,QAAQ,EAAE,EAAE;qBAC7B;iBACF;gBACD,MAAM,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE;aACrB,CAAC,CAAC;YACH,IAAI,CAAC,QAAQ,EAAE,CAAC;gBACd,MAAM,IAAI,SAAS,CAAC;oBAClB,IAAI,EAAE,aAAa;oBACnB,OAAO,EAAE,qCAAqC;iBAC/C,CAAC,CAAC;YACL,CAAC;YACD,OAAO;QACT,CAAC;IACH,CAAC;AACH,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,SAAS,CAC7B,MAAc,EACd,KAA6D;IAE7D,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;IACtC,IAAI,CAAC,GAAG,EAAE,CAAC;QACT,MAAM,IAAI,SAAS,CAAC,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,eAAe,EAAE,CAAC,CAAC;IACvE,CAAC;IACD,MAAM,aAAa,CAAC,GAAG,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IAC1C,MAAM,mBAAmB,CAAC,GAAG,CAAC,QAAQ,EAAE,KAAK,CAAC,MAAM,EAAE,KAAK,CAAC,QAAQ,CAAC,CAAC;IACtE,OAAO,iBAAiB,CAAC,KAAK,CAAC,CAAC;AAClC,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,SAAS,CAC7B,MAAc,EACd,KAA6D;IAE7D,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;IACtC,IAAI,CAAC,GAAG,EAAE,CAAC;QACT,MAAM,IAAI,SAAS,CAAC,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,eAAe,EAAE,CAAC,CAAC;IACvE,CAAC;IACD,MAAM,aAAa,CAAC,GAAG,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IAC1C,OAAO,mBAAmB,CAAC,KAAK,CAAC,CAAC;AACpC,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,MAAc,EACd,KAA8C;IAE9C,MAAM,IAAI,GAAG,MAAM,kBAAkB,CAAC,KAAK,CAAC,MAAM,EAAE,KAAK,CAAC,QAAQ,CAAC,CAAC;IACpE,IAAI,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC;QACZ,MAAM,aAAa,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IAChD,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC","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.17.0",
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;
@@ -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
  });