From 28f6474dbbfb839f5d171623e0f1ebfac5d07233 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 16:35:24 +0000 Subject: [PATCH] Fix EditTask/NotesTask rejecting canonical classId~taskId refs Router now canonicalizes flat task refs to two segments; the get_task guards still required three slash parts. Accept parseTaskId-valid refs (including classId~taskId) and reject empty/class-only/malformed. Co-authored-by: Sander Vonk --- .github/workflows/ci.yml | 3 +++ scripts/test-paths.mjs | 15 +++++++++++++++ src/common/paths.ts | 16 ++++++++++++++++ src/views/Portal/EditTask.vue | 13 +++++++++---- src/views/Portal/NotesTask.vue | 13 +++++++++---- 5 files changed, 52 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac4d04dd..9dafb05d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,5 +49,8 @@ jobs: - name: Join-class ref tests run: npm run test:join-class + - name: Path / task-ref guard tests + run: npm run test:paths + - name: Build run: npm run build diff --git a/scripts/test-paths.mjs b/scripts/test-paths.mjs index 700e71d6..bb41f331 100644 --- a/scripts/test-paths.mjs +++ b/scripts/test-paths.mjs @@ -18,6 +18,7 @@ import { bareClassIdFromEnrollment, flatClassPath, flatTaskPath, + isValidTaskRouteRef, } from "../src/common/paths.ts"; let failed = 0; @@ -130,6 +131,20 @@ assertEq( "writeTaskIds legacy local path" ); +console.log("\n--- isValidTaskRouteRef (EditTask/NotesTask guard) ---\n"); + +assert(isValidTaskRouteRef("abc123~task9"), "accepts canonical classId~taskId"); +assert(isValidTaskRouteRef("abc123/task9"), "accepts classId/taskId slash form"); +assert(isValidTaskRouteRef("t~abc123~task9", org), "accepts legacy local~classId~taskId"); +assert(isValidTaskRouteRef("t@mvla.net/abc123/task9", org), "accepts legacy email/classId/taskId"); +assert(!isValidTaskRouteRef(""), "rejects empty"); +assert(!isValidTaskRouteRef(null), "rejects null"); +assert(!isValidTaskRouteRef(" "), "rejects whitespace"); +assert(!isValidTaskRouteRef("abc123"), "rejects class-only (one segment)"); +assert(!isValidTaskRouteRef("t@mvla.net/abc123"), "rejects email/classId (class, not task)"); +assert(!isValidTaskRouteRef("a~b~c~d"), "rejects four-segment refs"); +assert(!isValidTaskRouteRef("classId~"), "rejects trailing empty task id"); + console.log("\n--- done ---\n"); if (failed) { console.error(`${failed} assertion(s) failed`); diff --git a/src/common/paths.ts b/src/common/paths.ts index f6764244..d5f134fb 100644 --- a/src/common/paths.ts +++ b/src/common/paths.ts @@ -158,6 +158,22 @@ export function parseTaskId( return null; } +/** + * True when `idOrRef` is a loadable task route ref: + * `classId~taskId` | `classId/taskId` | `local~classId~taskId` | `email/classId/taskId`. + * Rejects empty, class-only, email/classId, and extra segments. + * Used by EditTask / NotesTask so the router canonical 2-segment flat refs pass. + */ +export function isValidTaskRouteRef( + idOrRef: string | undefined | null, + orgDomain: string = "mvla.net" +): boolean { + if (!idOrRef || typeof idOrRef !== "string") return false; + const trimmed = idOrRef.trim(); + if (!trimmed) return false; + return parseTaskId(trimmed, orgDomain) != null; +} + /** * Ambiguous 2-segment refs: local~classId (class) vs classId~taskId (task). * Returns both interpretations; callers dual-read class-with-email first, then task. diff --git a/src/views/Portal/EditTask.vue b/src/views/Portal/EditTask.vue index 2b3199c8..5c129b02 100644 --- a/src/views/Portal/EditTask.vue +++ b/src/views/Portal/EditTask.vue @@ -89,6 +89,7 @@ */ import { compatDateObj } from "@/common"; +import { isValidTaskRouteRef } from "@/common/paths"; import { ErrorToast, WarningToast } from "@svonk/util"; import smoothReflow from "vue-smooth-reflow"; import OverlayWrapper from "@/components/Modal/OverlayWrapper.vue"; @@ -292,15 +293,19 @@ export default { }); }, async get_task() { - // get task ref from route params - const ref = this.$route.params.ref.split("~").join("/"); - if (!ref) { + // get task ref from route params (canonical flat form is classId~taskId) + const raw = this.$route.params.ref; + if (!raw) { new WarningToast("No task specified", 1500); this.$emit("close"); - } else if (ref.split("/").length < 3) { + return; + } + if (!isValidTaskRouteRef(raw)) { new WarningToast("Invalid task specified", 1500); this.$emit("close"); + return; } + const ref = String(raw).split("~").join("/"); // get task from store this.$store .task_from_ref(ref) diff --git a/src/views/Portal/NotesTask.vue b/src/views/Portal/NotesTask.vue index ac99c9e6..1f5f273e 100644 --- a/src/views/Portal/NotesTask.vue +++ b/src/views/Portal/NotesTask.vue @@ -83,6 +83,7 @@ * @emits {Function} close - An event emitted when the task is created or the modal is closed. */ +import { isValidTaskRouteRef } from "@/common/paths"; import { ErrorToast, WarningToast, SuccessToast } from "@svonk/util"; import smoothReflow from "vue-smooth-reflow"; @@ -162,15 +163,19 @@ export default { }); }, async get_task() { - // get task ref from route params - const ref = this.$route.params.ref.split("~").join("/"); - if (!ref) { + // get task ref from route params (canonical flat form is classId~taskId) + const raw = this.$route.params.ref; + if (!raw) { new WarningToast("No task specified", 1500); this.$emit("close"); - } else if (ref.split("/").length < 3) { + return; + } + if (!isValidTaskRouteRef(raw)) { new WarningToast("Invalid task specified", 1500); this.$emit("close"); + return; } + const ref = String(raw).split("~").join("/"); // get task from store this.$store .task_from_ref(ref)