From 36130edec18ff1efaf56ba7dea0fd64694f46942 Mon Sep 17 00:00:00 2001 From: Christopher Rodas Date: Sun, 9 Aug 2026 01:17:53 -0700 Subject: [PATCH 1/7] feat: add anonymous guided pathway escalation --- documents/ENDPOINT_ARCHITECTURE.md | 55 +- documents/MONGO_DATA_LAYER.md | 7 + package-lock.json | 4 +- package.json | 2 +- .../pathways/pathway-library-instructor.html | 24 + public/components/report/flag-instructor.html | 170 +++++ public/pages/admin-course-selection.html | 4 +- public/scripts/api/chat-api.ts | 5 +- .../scripts/api/guided-pathway-flags-api.ts | 148 +++++ public/scripts/api/pathways-api.ts | 1 + .../scripts/entry/admin-course-selection.ts | 1 + public/scripts/entry/instructor-mode.ts | 40 +- .../feature/admin-guided-pathway-flags.ts | 451 +++++++++++++ public/scripts/feature/chat.ts | 39 +- public/scripts/feature/dashboard.ts | 42 +- .../scripts/feature/guided-pathway-flags.ts | 344 ++++++++++ public/scripts/feature/pathway-library.ts | 47 ++ public/scripts/types.ts | 50 ++ public/scripts/utils/course-permissions.ts | 34 + public/styles/admin-guided-pathway-flags.css | 550 ++++++++++++++++ .../instructor-components/flag-instructor.css | 328 +++++++++ .../instructor-components/pathway-library.css | 59 +- src/chat/chat-app.ts | 58 +- src/db/enge-ai-mongodb.ts | 42 ++ .../__tests__/course-backup-mongo.test.ts | 80 ++- .../guided-pathway-flag-mongo.test.ts | 385 +++++++++++ .../mongo/__tests__/mongo-collections.test.ts | 26 +- src/db/mongo/__tests__/pathways-mongo.test.ts | 5 + src/db/mongo/course-backup-mongo.ts | 27 +- src/db/mongo/guided-pathway-flag-mongo.ts | 622 ++++++++++++++++++ src/db/mongo/mongo-collections.ts | 30 +- src/db/mongo/mongo-constants.ts | 5 +- src/db/mongo/pathways-mongo.ts | 14 + .../pathway-alert-persistence.test.ts | 83 +++ .../pathway-orchestrator-mock.test.ts | 5 + .../__tests__/pathway-schema.test.ts | 21 + .../pathway-alert-persistence.ts | 82 +++ src/guided-pathways/pathway-schema.ts | 14 + src/guided-pathways/pathway-seed.ts | 3 + .../__tests__/course-backup-path.test.ts | 7 +- src/helpers/course-backup-path.ts | 16 +- .../__tests__/require-course-role.test.ts | 66 +- src/middleware/require-course-role.ts | 34 + .../guided-pathway-flag-admin-routes.test.ts | 84 +++ .../guided-pathway-flag-date-filter.test.ts | 34 + src/routes/mongo/admin-course-routes.ts | 10 +- .../mongo/admin-guided-pathway-flag-routes.ts | 263 ++++++++ .../mongo/guided-pathway-flag-routes.ts | 143 ++++ src/routes/mongo/pathways-routes.ts | 16 +- src/routes/route-chat-app.ts | 93 ++- src/routes/route-course.ts | 29 +- src/routes/route-mongo.ts | 33 +- src/server.ts | 2 + src/types/shared.ts | 52 ++ 54 files changed, 4609 insertions(+), 180 deletions(-) create mode 100644 public/scripts/api/guided-pathway-flags-api.ts create mode 100644 public/scripts/feature/admin-guided-pathway-flags.ts create mode 100644 public/scripts/feature/guided-pathway-flags.ts create mode 100644 public/scripts/utils/course-permissions.ts create mode 100644 public/styles/admin-guided-pathway-flags.css create mode 100644 src/db/mongo/__tests__/guided-pathway-flag-mongo.test.ts create mode 100644 src/db/mongo/guided-pathway-flag-mongo.ts create mode 100644 src/guided-pathways/__tests__/pathway-alert-persistence.test.ts create mode 100644 src/guided-pathways/pathway-alert-persistence.ts create mode 100644 src/routes/__tests__/guided-pathway-flag-admin-routes.test.ts create mode 100644 src/routes/__tests__/guided-pathway-flag-date-filter.test.ts create mode 100644 src/routes/mongo/admin-guided-pathway-flag-routes.ts create mode 100644 src/routes/mongo/guided-pathway-flag-routes.ts diff --git a/documents/ENDPOINT_ARCHITECTURE.md b/documents/ENDPOINT_ARCHITECTURE.md index 285b83bc..e746b3b9 100644 --- a/documents/ENDPOINT_ARCHITECTURE.md +++ b/documents/ENDPOINT_ARCHITECTURE.md @@ -26,6 +26,7 @@ All API routes are prefixed with `/api/`. Page routes are served from `/` and `/ | `/api/rag` | ragAppRoutes | Document upload, retrieval, search, wipe | | `/api/courses` | mongodbRoutes | Courses, flags, objectives, materials, monitor | | `/api/courses` | writingFeedbackRoutes | Optional staff writing-feedback workspace | +| `/api/admin` | adminCourseRoutes | Platform-admin course catalog and cross-course Guided Pathway review | | `/api/course` | courseEntryRoutes | Course entry, enter-by-code, current course | | `/api/user` | userManagementRoutes | User profile, onboarding, activity | | `/api/health` | healthRoutes | Health check | @@ -58,7 +59,7 @@ All course-scoped pages use the same HTML shell; the frontend parses the URL to | `GET /course/:courseId/instructor/assistant-prompts` | Assistant prompts | | `GET /course/:courseId/instructor/system-prompts` | System prompts | | `GET /course/:courseId/instructor/scenario-questions` | Scenario Questions (Practice Scenarios authoring). Requires `scenarioGeneration` Extra Feature. Query: `?browse=questions`, `?topicOrWeekId=`, `?generate=1`, `?questionId=` | -| `GET /course/:courseId/instructor/pathway-library` | Capability-gated Guided Pathway Library; redirects to Dashboard when disabled | +| `GET /course/:courseId/instructor/pathway-library` | Capability-gated Guided Pathway Library for faculty instructors/platform admins; teaching assistants redirect to Dashboard | | `GET /course/:courseId/instructor/course-information` | Legacy redirect → dashboard (metadata in Advanced Settings; course code in topbar) | | `GET /course/:courseId/instructor/about` | About page | | `GET /course/:courseId/instructor/onboarding/course-setup` | Onboarding | @@ -298,6 +299,46 @@ Live Canvas OAuth routes are intentionally absent from this table until the priv | PUT | `/api/courses/:courseId/flags/:flagId` | Yes | Instructor | Update flag | | PATCH | `/api/courses/:courseId/flags/:flagId/response` | Yes | Instructor | Update response | +#### Guided Pathway Library and automatic alerts + +Guided Pathway configuration is separate from manual student-created flags. Faculty instructors +and platform admins may configure pathways; teaching assistants cannot. `enabled` controls whether +a pathway can trigger. The independent `notifyInstructorOnTrigger` setting controls whether a +successful trigger creates an automatic alert, and defaults to `true` for new, seeded, and legacy +records where the field is missing. + +| Method | Path | Auth | Role | Description | +|--------|------|------|------|-------------| +| GET | `/api/courses/:courseId/pathways` | Yes | Faculty instructor or **Admin** | List course pathways | +| POST | `/api/courses/:courseId/pathways` | Yes | Faculty instructor or **Admin** | Create a pathway; notification defaults on | +| PUT | `/api/courses/:courseId/pathways/reorder` | Yes | Faculty instructor or **Admin** | Reorder pathways | +| PUT | `/api/courses/:courseId/pathways/:pathwayId` | Yes | Faculty instructor or **Admin** | Update configuration, including either independent switch | +| DELETE | `/api/courses/:courseId/pathways/:pathwayId` | Yes | Faculty instructor or **Admin** | Delete a pathway definition | +| POST | `/api/courses/:courseId/pathways/reset` | Yes | Faculty instructor or **Admin** | Restore platform defaults with notification on | +| GET | `/api/courses/:courseId/guided-pathway-flags` | Yes | Faculty instructor or **Admin** | Paginated anonymous course alert list; optional `status` | +| PATCH | `/api/courses/:courseId/guided-pathway-flags/:flagId/decision` | Yes | Faculty instructor or **Admin** | Atomic pending decision; body `{ decision: 'escalate' | 'dismiss' }` | +| GET | `/api/admin/guided-pathway-flags` | Yes | **Admin** | Cross-course anonymous queue with period/course/pathway/status/reviewer/date filters | +| PATCH | `/api/admin/guided-pathway-flags/:flagId/review` | Yes | **Admin** | Mark an escalated item reviewed without deleting it | +| POST | `/api/admin/guided-pathway-flags/:flagId/reveal-identity` | Yes | **Admin** | Audit an escalated-item reveal, then return only the current roster display name | + +List and action responses use an explicit anonymous projection: pathway/course snapshots, exact +student message, trigger/decision/review times, state, and staff reviewer display names. They never +include the student's name or user ID, PUID, chat/request identifiers, deduplication key, or reveal +audit events. The exact message is not automatically redacted and can still identify its author if +the student writes personal information in it. + +Automatic alerts have `pending`, `escalated`, and `dismissed` states. Instructor decisions are +final in this version, and completed records remain viewable. Escalation is an internal decision: +EngE-AI surfaces it to platform admins but does not contact LTIC. Admin identity reveal is available +only on escalated records, requires confirmation in the client, is re-masked after refresh, and +fails closed when the audit write fails. Students and teaching assistants cannot call these APIs; +automatic alerts never enter Student Flag History. + +`GET /api/admin/course-selection` also returns +`data.guidedPathwayEscalationsAwaitingReview`, counting escalated records with no admin review time. +The dashboard refreshes this count on page load and after review actions; there is no polling, live +popup, email, or external notification. + #### Monitor (instructor roster; post-period analytics) | Method | Path | Auth | Role | Description | @@ -579,7 +620,17 @@ console.log(res.status, await res.json()); ### Chat RAG flow (`POST /api/chat/:chatId`) -On each student message, `ChatApp` orchestrates retrieval through two RAG classes (shared `RAGModule` from `RAGApp`): +The browser includes a stable opaque `clientMessageId` for each deliberate send and reuses it when +retrying the same failed transport. The server binds it to the authenticated student, course, chat, +and exact message before hashing it; a unique Mongo key prevents duplicate automatic pathway alerts. + +Before RAG, an enabled Guided Pathway may intercept the message and return its predefined response. +When its independent notification setting is on, the chat route attempts to create one anonymous +alert in a separate failure boundary. An alert-write failure never blocks the predefined safety or +redirection response. Trigger metadata remains backend-only and is not stored on `ChatMessage` or +returned to the student. + +When no pathway intercepts, `ChatApp` orchestrates retrieval through two RAG classes (shared `RAGModule` from `RAGApp`): 1. **`RAGApp.retrieveForChat`** — vector search with published-item filter (skipped in developer mode) 2. **`ragPrompts.formatRetrievedContext`** — wraps chunks in `...` diff --git a/documents/MONGO_DATA_LAYER.md b/documents/MONGO_DATA_LAYER.md index d96753b7..8a9d3011 100644 --- a/documents/MONGO_DATA_LAYER.md +++ b/documents/MONGO_DATA_LAYER.md @@ -39,6 +39,13 @@ - **Lazy migration (SP-001)** — `ensureSystemPromptConfig` maps legacy `collectionOfSystemPromptItems` → `systemPromptConfig`, then `$unset` the legacy field on access; no startup batch scan. Registry and sunset: [DATA_MIGRATIONS.md](DATA_MIGRATIONS.md#sp-001-system-prompt-v1--v2) (remove SP-001 code by **2026-06-30**). - **Runtime assembly** — chat uses JSON defaults when `usePlatformDefault: true`; learning objectives are injected into the `course main intro` module at compose time via `{{course_learning_objectives}}` (not stored in instructor config). - **Chat threads** (`chat-mongo.ts` on `{courseName}_users.chats[]`) — conversation-level starring has been retired. New records and API responses omit `isPinned`; legacy embedded values are ignored on reads and may remain inert in MongoDB without a destructive migration. Optional `pinnedMessageId` continues to represent the separate message-level pin feature. +- **Guided Pathway alerts** (`guided-pathway-flag-mongo.ts` on global `guided-pathway-flags`): + - One collection serves every course; every row carries `courseId` plus course/pathway title snapshots. It is intentionally separate from manual `{courseName}_flags` and is never queried by Student Flag History or `/flags/with-names`. + - Internal rows store the exact message, restricted `studentUserId`, opaque deduplication hash, decision/review actors and times, and append-only identity-reveal audit events. PUID and raw client/chat identifiers are never stored. + - Instructor/admin list delegates use inclusion projections and map to `GuidedPathwayFlagView`; student identity, deduplication data, and reveal events cannot reach normal API responses. Admin reveal first atomically appends its audit event, then resolves and returns only the current course-roster display name. Audit failure returns no name. + - A unique deduplication index makes transport retries an idempotent no-op. Additional indexes cover course/status/date, course/pathway/status/date, and escalated/unreviewed admin queries. There is no TTL because completed decisions remain viewable. + - Instructor decisions are atomic `pending` to `escalated`/`dismissed` transitions. Admin review is a soft completion marker; neither workflow hard-deletes rows. + - Course backup includes an anonymous, course-filtered projection from this global collection. Restarting onboarding or deleting a course removes rows by `courseId` so global records do not become orphaned. - **Topic/week embedded content** (`topic-week-mongo.ts` on `active-course-list`): - **`learningObjectives[]`** per `items[]` — instructor CRUD; flattened via `getAllLearningObjectives` for system-prompt injection. - **`instructorStruggleTopics[]`** per `items[]` — instructor CRUD (`/struggle-topics` API); gated by `features.memoryAgent`; flattened via `getAllInstructorStruggleTopics` for memory-agent catalog only (not main chat system prompt). diff --git a/package-lock.json b/package-lock.json index 803ef11f..cab77710 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tlef-EngE-AI", - "version": "1.7.13", + "version": "1.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tlef-EngE-AI", - "version": "1.7.13", + "version": "1.8.0", "license": "ISC", "dependencies": { "@qdrant/js-client-rest": "^1.15.1", diff --git a/package.json b/package.json index fd9a1fb4..617e76c4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tlef-EngE-AI", - "version": "1.7.13", + "version": "1.8.0", "description": "", "main": "dist/server.js", "scripts": { diff --git a/public/components/pathways/pathway-library-instructor.html b/public/components/pathways/pathway-library-instructor.html index 03f95297..192f1474 100644 --- a/public/components/pathways/pathway-library-instructor.html +++ b/public/components/pathways/pathway-library-instructor.html @@ -105,6 +105,30 @@

Call to Action button colors

+
+
+ Notify instructor when triggered + + Creates an anonymous Guided Pathway Alert when this active pathway is triggered. + +
+
+ On + +
+
+
Call to Action diff --git a/public/components/report/flag-instructor.html b/public/components/report/flag-instructor.html index 2ea589fd..65ff90d6 100644 --- a/public/components/report/flag-instructor.html +++ b/public/components/report/flag-instructor.html @@ -1,6 +1,45 @@
+
+ + +
@@ -123,5 +162,136 @@

Filter by flag type

+
+ +
diff --git a/public/pages/admin-course-selection.html b/public/pages/admin-course-selection.html index bd77f512..9bfbe6c6 100644 --- a/public/pages/admin-course-selection.html +++ b/public/pages/admin-course-selection.html @@ -34,7 +34,9 @@
-
+
+
+
diff --git a/public/scripts/api/chat-api.ts b/public/scripts/api/chat-api.ts index 48fb002d..9088b138 100644 --- a/public/scripts/api/chat-api.ts +++ b/public/scripts/api/chat-api.ts @@ -123,6 +123,7 @@ export async function createNewChat(chatRequest: CreateChatRequest): Promise * POST to /api/chat/:chatId. Streaming is handled by ChatManager, not this function. */ @@ -130,7 +131,8 @@ export async function sendMessageToChat( chatId: string, message: string, userId: string, - courseName: string + courseName: string, + clientMessageId: string ): Promise { try { const response = await fetch(`/api/chat/${chatId}`, { @@ -140,6 +142,7 @@ export async function sendMessageToChat( }, body: JSON.stringify({ message, + clientMessageId, userId, courseName }), diff --git a/public/scripts/api/guided-pathway-flags-api.ts b/public/scripts/api/guided-pathway-flags-api.ts new file mode 100644 index 00000000..cf9343e1 --- /dev/null +++ b/public/scripts/api/guided-pathway-flags-api.ts @@ -0,0 +1,148 @@ +// public/scripts/api/guided-pathway-flags-api.ts + +/** + * Anonymous Guided Pathway alert API helpers for course staff and platform admins. + * + * @author EngE-AI Team + * @date 2026-08-08 + * @version 1.0.0 + * @description Safe client helpers for Guided Pathway alert list and review APIs. + */ + +import type { + GuidedPathwayFlagDecision, + GuidedPathwayFlagListPage, + GuidedPathwayFlagReviewState, + GuidedPathwayFlagStatus, + GuidedPathwayFlagView, +} from '../types.js'; + +interface ApiEnvelope { + success?: boolean; + data?: T; + error?: string; +} + +export interface AdminGuidedPathwayFlagFilters { + page?: number; + pageSize?: number; + reviewState?: GuidedPathwayFlagReviewState; + status?: GuidedPathwayFlagStatus; + academicPeriodId?: string; + courseId?: string; + pathwayId?: string; + reviewer?: string; + dateFrom?: string; + dateTo?: string; +} + +export interface RevealedGuidedPathwayIdentity { + studentName: string; +} + +async function parseData(response: Response): Promise { + const body = (await response.json().catch(() => ({}))) as ApiEnvelope & Record; + if (!response.ok || body.success === false || body.data === undefined) { + throw new Error(body.error || `Request failed (${response.status})`); + } + return body.data; +} + +function setOptionalParam(params: URLSearchParams, key: string, value: unknown): void { + if (value === undefined || value === null || value === '' || value === 'all') return; + params.set(key, String(value)); +} + +/** Load one anonymous, course-scoped Guided Pathway alert page. */ +export async function listCourseGuidedPathwayFlags( + courseId: string, + options: { + status?: GuidedPathwayFlagStatus; + page?: number; + pageSize?: number; + } = {} +): Promise { + const params = new URLSearchParams(); + setOptionalParam(params, 'status', options.status); + params.set('page', String(options.page ?? 1)); + params.set('pageSize', String(options.pageSize ?? 20)); + const response = await fetch( + `/api/courses/${encodeURIComponent(courseId)}/guided-pathway-flags?${params.toString()}`, + { credentials: 'same-origin' } + ); + return parseData(response); +} + +/** Persist an instructor decision without deleting the alert. */ +export async function decideGuidedPathwayFlag( + courseId: string, + flagId: string, + decision: GuidedPathwayFlagDecision +): Promise { + const response = await fetch( + `/api/courses/${encodeURIComponent(courseId)}/guided-pathway-flags/${encodeURIComponent(flagId)}/decision`, + { + method: 'PATCH', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ decision }), + } + ); + return parseData(response); +} + +/** Load the platform-admin, cross-course anonymous alert queue. */ +export async function listAdminGuidedPathwayFlags( + filters: AdminGuidedPathwayFlagFilters = {} +): Promise { + const params = new URLSearchParams(); + params.set('page', String(filters.page ?? 1)); + params.set('pageSize', String(filters.pageSize ?? 20)); + setOptionalParam(params, 'reviewState', filters.reviewState); + setOptionalParam(params, 'status', filters.status); + setOptionalParam(params, 'academicPeriodId', filters.academicPeriodId); + setOptionalParam(params, 'courseId', filters.courseId); + setOptionalParam(params, 'pathwayId', filters.pathwayId); + setOptionalParam(params, 'reviewer', filters.reviewer); + setOptionalParam(params, 'dateFrom', filters.dateFrom); + setOptionalParam(params, 'dateTo', filters.dateTo); + + const response = await fetch(`/api/admin/guided-pathway-flags?${params.toString()}`, { + credentials: 'same-origin', + }); + return parseData(response); +} + +/** Mark an escalated alert as reviewed by the current platform administrator. */ +export async function reviewAdminGuidedPathwayFlag(flagId: string): Promise { + const response = await fetch( + `/api/admin/guided-pathway-flags/${encodeURIComponent(flagId)}/review`, + { + method: 'PATCH', + credentials: 'same-origin', + } + ); + return parseData(response); +} + +/** + * Reveal one escalated alert's student name after the server records the audit event. + * The returned name is intentionally not part of any list DTO. + */ +export async function revealAdminGuidedPathwayFlagIdentity( + flagId: string +): Promise { + const response = await fetch( + `/api/admin/guided-pathway-flags/${encodeURIComponent(flagId)}/reveal-identity`, + { + method: 'POST', + credentials: 'same-origin', + } + ); + const data = await parseData>(response); + const studentName = data.studentName ?? data.displayName ?? data.name; + if (typeof studentName !== 'string' || studentName.trim() === '') { + throw new Error('The server did not return a student name.'); + } + return { studentName }; +} diff --git a/public/scripts/api/pathways-api.ts b/public/scripts/api/pathways-api.ts index 7a58babd..873f5612 100644 --- a/public/scripts/api/pathways-api.ts +++ b/public/scripts/api/pathways-api.ts @@ -46,6 +46,7 @@ export async function updatePathway( triggerDescription?: string; assistantResponse?: string; enabled?: boolean; + notifyInstructorOnTrigger?: boolean; ctas?: PathwayCta[]; } ): Promise { diff --git a/public/scripts/entry/admin-course-selection.ts b/public/scripts/entry/admin-course-selection.ts index 5a00a88f..d96c7568 100644 --- a/public/scripts/entry/admin-course-selection.ts +++ b/public/scripts/entry/admin-course-selection.ts @@ -217,6 +217,7 @@ function attachPeriodListeners(): void { } }); }); + } async function enterCourseAsAdmin(courseId: string): Promise { diff --git a/public/scripts/entry/instructor-mode.ts b/public/scripts/entry/instructor-mode.ts index c32c0e66..280d9e3d 100644 --- a/public/scripts/entry/instructor-mode.ts +++ b/public/scripts/entry/instructor-mode.ts @@ -30,7 +30,9 @@ import { initializeAssistantPrompts, hasUnsavedPromptChanges, resetUnsavedPrompt import { initializeSystemPrompts, flushSystemPromptOnLeave } from '../feature/system-prompts.js'; import { initializeScenarioQuestionsInstructor, isScenarioQuestionsMounted, syncScenarioQuestionsFromURL } from '../feature/scenario-questions-instructor.js'; import { initializePathwayLibrary } from '../feature/pathway-library.js'; +import { initializeGuidedPathwayFlags } from '../feature/guided-pathway-flags.js'; import { initializeDashboard, renderDashboardCards } from '../feature/dashboard.js'; +import { canManageGuidedPathways } from '../utils/course-permissions.js'; import { getCourseIdFromURL, getInstructorViewFromURL, @@ -430,6 +432,7 @@ document.addEventListener('DOMContentLoaded', async () => { // Sidebar header: `{firstName} (Instructor|TA)` const authUser = authService.getAuthState().user; + const canManageGuidedPathwayFeatures = canManageGuidedPathways(currentClass, authUser); if (authUser) { updateSidebarCompanionText(authUser.name, authUser.userId, currentClass); } @@ -795,6 +798,12 @@ document.addEventListener('DOMContentLoaded', async () => { 'Feature unavailable' ); navigateToInstructorView('dashboard'); + } else if (view === 'pathway-library' && !canManageGuidedPathwayFeatures) { + await showSimpleErrorModal( + 'Only course instructors and platform administrators can access the Pathway Library.', + 'Access denied' + ); + navigateToInstructorView('dashboard'); } else if (view === 'pathway-library' && currentClass.features?.guidedPathway?.enabled !== true) { await showSimpleErrorModal( 'Guided Pathway is not enabled for this course. You can enable it from Advanced Settings on the Dashboard if you have instructor or admin access.', @@ -880,13 +889,18 @@ document.addEventListener('DOMContentLoaded', async () => { initializeDocumentsPage(currentClass); } else if (componentName === 'dashboard-instructor') { - await initializeDashboard(currentClass); + await initializeDashboard(currentClass, canManageGuidedPathwayFeatures); } else if (componentName === 'writing-feedback') { await initializeWritingFeedback(currentClass); } else if (componentName === 'flag-instructor') { await initializeFlags(); + await initializeGuidedPathwayFlags({ + courseId: currentClass.id, + canAccess: canManageGuidedPathwayFeatures, + isAdmin: authUser?.isAdmin === true, + }); } else if (componentName === 'monitor-instructor') { initializeMonitorDashboard(); @@ -907,7 +921,9 @@ document.addEventListener('DOMContentLoaded', async () => { await initializeScenarioQuestionsInstructor(currentClass); } else if (componentName === 'pathway-library-instructor') { - await initializePathwayLibrary(currentClass); + if (canManageGuidedPathwayFeatures) { + await initializePathwayLibrary(currentClass); + } } renderFeatherIcons(); @@ -1043,6 +1059,14 @@ document.addEventListener('DOMContentLoaded', async () => { hideChatList(); // Ensure chat list is hidden } else if ( currentState === StateEvent.PathwayLibrary){ + if (!canManageGuidedPathwayFeatures) { + void showSimpleErrorModal( + 'Only course instructors and platform administrators can access the Pathway Library.', + 'Access denied' + ); + navigateToInstructorView('dashboard'); + return; + } if (currentClass.features?.guidedPathway?.enabled !== true) { void showSimpleErrorModal( 'Guided Pathway is not enabled for this course. You can enable it from Advanced Settings on the Dashboard if you have instructor or admin access.', @@ -1174,9 +1198,9 @@ document.addEventListener('DOMContentLoaded', async () => { if (wfItem) wfItem.hidden = !wfEnabled; } if (pathwayLibraryStateEl) { - pathwayLibraryStateEl.hidden = !pathwayEnabled; + pathwayLibraryStateEl.hidden = !pathwayEnabled || !canManageGuidedPathwayFeatures; const pathwayItem = pathwayLibraryStateEl.closest('li'); - if (pathwayItem) pathwayItem.hidden = !pathwayEnabled; + if (pathwayItem) pathwayItem.hidden = !pathwayEnabled || !canManageGuidedPathwayFeatures; } if (scenarioQuestionsStateEl) { scenarioQuestionsStateEl.hidden = !scenarioEnabled; @@ -1184,7 +1208,7 @@ document.addEventListener('DOMContentLoaded', async () => { if (scenarioItem) scenarioItem.hidden = !scenarioEnabled; } if (currentState === StateEvent.Dashboard) { - renderDashboardCards(currentClass); + renderDashboardCards(currentClass, canManageGuidedPathwayFeatures); } }; updateFeatureNavigation(); @@ -1200,7 +1224,11 @@ document.addEventListener('DOMContentLoaded', async () => { if (detail.feature === 'writingFeedback' && !detail.enabled && currentState === StateEvent.WritingFeedback) { navigateToInstructorView('dashboard'); } - if (detail.feature === 'guidedPathway' && !detail.enabled && currentState === StateEvent.PathwayLibrary) { + if ( + detail.feature === 'guidedPathway' && + (!detail.enabled || !canManageGuidedPathwayFeatures) && + currentState === StateEvent.PathwayLibrary + ) { navigateToInstructorView('dashboard'); } if (detail.feature === 'scenarioGeneration' && !detail.enabled && currentState === StateEvent.ScenarioQuestions) { diff --git a/public/scripts/feature/admin-guided-pathway-flags.ts b/public/scripts/feature/admin-guided-pathway-flags.ts new file mode 100644 index 00000000..e6d2fef5 --- /dev/null +++ b/public/scripts/feature/admin-guided-pathway-flags.ts @@ -0,0 +1,451 @@ +// public/scripts/feature/admin-guided-pathway-flags.ts + +/** + * Platform-admin, cross-course Guided Pathway alert queue. + * List data stays anonymous; identity is requested only through the audited reveal action. + * + * @author EngE-AI Team + * @date 2026-08-09 + * @version 1.1.0 + * @description Global anonymous alert queue embedded in Flags, with audited reveal and admin review. + */ + +import type { + GuidedPathwayFlagFacets, + GuidedPathwayFlagListPage, + GuidedPathwayFlagReviewState, + GuidedPathwayFlagStatus, + GuidedPathwayFlagView, +} from '../types.js'; +import { + listAdminGuidedPathwayFlags, + revealAdminGuidedPathwayFlagIdentity, + reviewAdminGuidedPathwayFlag, + type AdminGuidedPathwayFlagFilters, +} from '../api/guided-pathway-flags-api.js'; +import { showConfirmModal, showErrorModal } from '../ui/modal-overlay.js'; + +const PAGE_SIZE = 20; + +export interface AdminGuidedPathwayPeriodOption { + id: string; + title: string; + courses: Array<{ id: string; courseName: string }>; +} + +let periods: AdminGuidedPathwayPeriodOption[] = []; +let currentPage = 1; +let pageData: GuidedPathwayFlagListPage | null = null; +let queueLoaded = false; +let boundControlsRoot: HTMLFormElement | null = null; + +interface AdminGuidedPathwayContextPayload { + periods: AdminGuidedPathwayPeriodOption[]; + guidedPathwayEscalationsAwaitingReview: number; +} + +function byId(id: string): T | null { + return document.getElementById(id) as T | null; +} + +function formatDate(value: string | undefined): string { + if (!value) return 'Not recorded'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return 'Not recorded'; + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(date); +} + +function statusLabel(status: GuidedPathwayFlagStatus): string { + if (status === 'escalated') return 'Escalated to LTIC'; + if (status === 'dismissed') return 'Dismissed'; + return 'Pending instructor decision'; +} + +function setQueueStatus(message: string): void { + const status = byId('admin-guided-alerts-status'); + if (status) status.textContent = message; +} + +function setQueueBusy(busy: boolean): void { + byId('admin-guided-alerts-list')?.setAttribute('aria-busy', String(busy)); + const form = byId('admin-guided-alert-filters'); + form?.querySelectorAll('button').forEach((button) => { + button.disabled = busy; + }); +} + +function setAwaitingReviewCount(count: number): void { + const badge = byId('admin-guided-alert-count'); + if (!badge) return; + const safeCount = Number.isFinite(count) && count > 0 ? Math.floor(count) : 0; + badge.textContent = String(safeCount); + badge.setAttribute('aria-label', `${safeCount} awaiting review`); +} + +async function loadAdminQueueContext(): Promise { + const response = await fetch('/api/admin/course-selection', { credentials: 'same-origin' }); + const body = await response.json().catch(() => ({})) as { + data?: AdminGuidedPathwayContextPayload; + error?: string; + }; + if (!response.ok || !body.data) { + throw new Error(body.error || 'Unable to load course filters.'); + } + return body.data; +} + +function replaceSelectOptions( + select: HTMLSelectElement | null, + firstLabel: string, + options: Array<{ value: string; label: string }> +): void { + if (!select) return; + const selected = select.value; + const selectedLabel = select.selectedOptions[0]?.textContent?.trim() || selected; + select.replaceChildren(); + const first = document.createElement('option'); + first.value = ''; + first.textContent = firstLabel; + select.appendChild(first); + options.forEach(({ value, label }) => { + const option = document.createElement('option'); + option.value = value; + option.textContent = label; + select.appendChild(option); + }); + if (selected && ![...select.options].some((option) => option.value === selected)) { + const preserved = document.createElement('option'); + preserved.value = selected; + preserved.textContent = selectedLabel; + select.appendChild(preserved); + } + if (selected) select.value = selected; +} + +function populatePeriodOptions(): void { + replaceSelectOptions( + byId('admin-guided-alert-period'), + 'All periods', + periods.map((period) => ({ value: period.id, label: period.title })) + ); + populateCourseOptions(); +} + +function populateCourseOptions(): void { + const periodId = byId('admin-guided-alert-period')?.value ?? ''; + const visiblePeriods = periodId ? periods.filter((period) => period.id === periodId) : periods; + const courses = visiblePeriods + .flatMap((period) => period.courses) + .filter((course, index, all) => all.findIndex((candidate) => candidate.id === course.id) === index) + .sort((a, b) => a.courseName.localeCompare(b.courseName)); + replaceSelectOptions( + byId('admin-guided-alert-course'), + 'All courses', + courses.map((course) => ({ value: course.id, label: course.courseName })) + ); +} + +function refreshFacetOptions(facets: GuidedPathwayFlagFacets | undefined): void { + if (!facets) return; + const pathways = facets.pathways + .map((pathway) => ({ value: pathway.pathwayId, label: pathway.pathwayTitle })) + .sort((a, b) => a.label.localeCompare(b.label)); + replaceSelectOptions(byId('admin-guided-alert-pathway'), 'All pathways', pathways); + + replaceSelectOptions( + byId('admin-guided-alert-reviewer'), + 'All reviewers', + [...facets.reviewers].sort().map((name) => ({ value: name, label: name })) + ); +} + +function currentFilters(): AdminGuidedPathwayFlagFilters { + const status = byId('admin-guided-alert-status-filter')?.value; + const reviewState = byId('admin-guided-alert-review-state')?.value; + return { + page: currentPage, + pageSize: PAGE_SIZE, + status: status ? (status as GuidedPathwayFlagStatus) : undefined, + reviewState: (reviewState || 'all') as GuidedPathwayFlagReviewState, + academicPeriodId: byId('admin-guided-alert-period')?.value || undefined, + courseId: byId('admin-guided-alert-course')?.value || undefined, + pathwayId: byId('admin-guided-alert-pathway')?.value || undefined, + reviewer: byId('admin-guided-alert-reviewer')?.value || undefined, + dateFrom: byId('admin-guided-alert-date-from')?.value || undefined, + dateTo: byId('admin-guided-alert-date-to')?.value || undefined, + }; +} + +async function loadQueue(): Promise { + setQueueBusy(true); + setQueueStatus('Loading Guided Pathway alerts...'); + try { + pageData = await listAdminGuidedPathwayFlags(currentFilters()); + refreshFacetOptions(pageData.facets); + renderQueue(); + setQueueStatus(`${pageData.total} ${pageData.total === 1 ? 'alert' : 'alerts'}`); + } catch (error) { + pageData = null; + renderQueue('Alerts could not be loaded. Use Refresh to try again.'); + setQueueStatus(error instanceof Error ? error.message : 'Unable to load Guided Pathway alerts.'); + } finally { + setQueueBusy(false); + } +} + +async function refreshAwaitingReviewCount(): Promise { + try { + const page = await listAdminGuidedPathwayFlags({ + page: 1, + pageSize: 1, + status: 'escalated', + reviewState: 'needs-review', + }); + setAwaitingReviewCount(page.total); + } catch { + // Keep the last known count; the queue itself reports actionable load errors. + } +} + +function metadataItem(label: string, value: string): HTMLElement { + const item = document.createElement('span'); + const strong = document.createElement('strong'); + strong.textContent = `${label}: `; + item.append(strong, document.createTextNode(value)); + return item; +} + +function createRevealControl(flag: GuidedPathwayFlagView): HTMLElement { + const wrapper = document.createElement('div'); + wrapper.className = 'admin-guided-alert-card__identity'; + const label = document.createElement('label'); + label.className = 'admin-guided-alert-card__identity-toggle'; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + const labelText = document.createElement('span'); + labelText.textContent = 'Reveal student identity'; + const revealed = document.createElement('span'); + revealed.className = 'admin-guided-alert-card__revealed-name'; + revealed.setAttribute('role', 'status'); + revealed.setAttribute('aria-live', 'polite'); + label.append(checkbox, labelText); + wrapper.append(label, revealed); + + checkbox.addEventListener('change', async () => { + if (!checkbox.checked) { + revealed.textContent = ''; + return; + } + + const confirmation = await showConfirmModal( + 'Reveal student identity', + 'This access is restricted to escalated alerts and will be recorded with your administrator account and the current time.', + 'Reveal identity', + 'Cancel', + 'danger' + ); + if (confirmation.action !== 'reveal-identity') { + checkbox.checked = false; + return; + } + + checkbox.disabled = true; + try { + const identity = await revealAdminGuidedPathwayFlagIdentity(flag.id); + revealed.textContent = `Student: ${identity.studentName}`; + } catch (error) { + checkbox.checked = false; + revealed.textContent = ''; + await showErrorModal( + 'Unable to reveal identity', + error instanceof Error ? error.message : 'The identity could not be revealed.' + ); + } finally { + checkbox.disabled = false; + } + }); + return wrapper; +} + +async function markReviewed(flag: GuidedPathwayFlagView, button: HTMLButtonElement): Promise { + button.disabled = true; + button.setAttribute('aria-busy', 'true'); + try { + await reviewAdminGuidedPathwayFlag(flag.id); + await Promise.all([loadQueue(), refreshAwaitingReviewCount()]); + } catch (error) { + button.disabled = false; + await showErrorModal( + 'Unable to mark alert reviewed', + error instanceof Error ? error.message : 'The review could not be saved.' + ); + } finally { + button.removeAttribute('aria-busy'); + } +} + +function createAlertCard(flag: GuidedPathwayFlagView): HTMLElement { + const card = document.createElement('article'); + card.className = `admin-guided-alert-card admin-guided-alert-card--${flag.status}`; + + const header = document.createElement('div'); + header.className = 'admin-guided-alert-card__header'; + const title = document.createElement('h2'); + title.textContent = flag.pathwayTitle; + const status = document.createElement('span'); + status.className = `admin-guided-alert-card__status admin-guided-alert-card__status--${flag.status}`; + status.textContent = statusLabel(flag.status); + header.append(title, status); + + const metadata = document.createElement('div'); + metadata.className = 'admin-guided-alert-card__metadata'; + metadata.append( + metadataItem('Course', flag.courseName), + metadataItem('Triggered', formatDate(flag.triggeredAt)) + ); + if (flag.decidedAt) metadata.append(metadataItem('Decision', formatDate(flag.decidedAt))); + if (flag.decidedByName) metadata.append(metadataItem('Decision by', flag.decidedByName)); + if (flag.adminReviewedAt) metadata.append(metadataItem('Admin review', formatDate(flag.adminReviewedAt))); + if (flag.adminReviewedByName) metadata.append(metadataItem('Reviewed by', flag.adminReviewedByName)); + + const messageLabel = document.createElement('h3'); + messageLabel.textContent = 'Student message'; + const message = document.createElement('p'); + message.className = 'admin-guided-alert-card__message'; + message.textContent = flag.messageText; + + card.append(header, metadata, messageLabel, message); + + if (flag.status === 'escalated') { + card.appendChild(createRevealControl(flag)); + if (!flag.adminReviewedAt) { + const actions = document.createElement('div'); + actions.className = 'admin-guided-alert-card__actions'; + const review = document.createElement('button'); + review.type = 'button'; + review.textContent = 'Mark reviewed'; + review.addEventListener('click', () => void markReviewed(flag, review)); + actions.appendChild(review); + card.appendChild(actions); + } + } + + return card; +} + +function renderQueue(errorMessage?: string): void { + const list = byId('admin-guided-alerts-list'); + if (!list) return; + list.replaceChildren(); + const items = pageData?.items ?? []; + if (errorMessage) { + const error = document.createElement('p'); + error.className = 'admin-guided-alerts__empty admin-guided-alerts__empty--error'; + error.textContent = errorMessage; + list.appendChild(error); + } else if (items.length === 0) { + const empty = document.createElement('p'); + empty.className = 'admin-guided-alerts__empty'; + empty.textContent = 'No Guided Pathway alerts match these filters.'; + list.appendChild(empty); + } else { + items.forEach((item) => list.appendChild(createAlertCard(item))); + } + + const page = pageData?.page ?? currentPage; + const total = pageData?.total ?? 0; + const pageSize = pageData?.pageSize ?? PAGE_SIZE; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const summary = byId('admin-guided-alerts-page-summary'); + const previous = byId('admin-guided-alerts-previous'); + const next = byId('admin-guided-alerts-next'); + if (summary) summary.textContent = `Page ${page} of ${totalPages}`; + if (previous) previous.disabled = page <= 1; + if (next) next.disabled = page >= totalPages; +} + +function clearFilters(): void { + const form = byId('admin-guided-alert-filters'); + form?.reset(); + populateCourseOptions(); + currentPage = 1; + void loadQueue(); +} + +function bindControls(): void { + byId('admin-guided-alerts-refresh')?.addEventListener('click', () => { + void Promise.all([loadQueue(), refreshAwaitingReviewCount()]); + }); + byId('admin-guided-alert-period')?.addEventListener('change', () => { + const course = byId('admin-guided-alert-course'); + if (course) course.value = ''; + populateCourseOptions(); + }); + byId('admin-guided-alert-review-state')?.addEventListener('change', (event) => { + const reviewState = (event.currentTarget as HTMLSelectElement).value; + const status = byId('admin-guided-alert-status-filter'); + if (reviewState !== 'all' && status) status.value = 'escalated'; + }); + byId('admin-guided-alert-status-filter')?.addEventListener('change', (event) => { + const status = (event.currentTarget as HTMLSelectElement).value; + const reviewState = byId('admin-guided-alert-review-state'); + if (status !== 'escalated' && reviewState) reviewState.value = 'all'; + }); + byId('admin-guided-alert-filters')?.addEventListener('submit', (event) => { + event.preventDefault(); + currentPage = 1; + void loadQueue(); + }); + byId('admin-guided-alert-filters-clear')?.addEventListener('click', clearFilters); + byId('admin-guided-alerts-previous')?.addEventListener('click', () => { + if (currentPage <= 1) return; + currentPage -= 1; + void loadQueue(); + }); + byId('admin-guided-alerts-next')?.addEventListener('click', () => { + currentPage += 1; + void loadQueue(); + }); +} + +/** Initialize the embedded admin queue, its course filters, and awaiting-review count. */ +export async function initializeAdminGuidedPathwayFlags(): Promise { + currentPage = 1; + pageData = null; + queueLoaded = false; + + try { + const context = await loadAdminQueueContext(); + periods = context.periods.map((period) => ({ + id: period.id, + title: period.title, + courses: period.courses.map((course) => ({ + id: course.id, + courseName: course.courseName, + })), + })); + setAwaitingReviewCount(context.guidedPathwayEscalationsAwaitingReview ?? 0); + } catch (error) { + periods = []; + setAwaitingReviewCount(0); + setQueueStatus(error instanceof Error ? error.message : 'Unable to load course filters.'); + } + + populatePeriodOptions(); + const controlsRoot = byId('admin-guided-alert-filters'); + if (controlsRoot && controlsRoot !== boundControlsRoot) { + bindControls(); + boundControlsRoot = controlsRoot; + } + renderQueue(); +} + +/** Load the global queue the first time an administrator opens its Flags tab. */ +export function activateAdminGuidedPathwayFlags(): void { + if (queueLoaded) return; + queueLoaded = true; + void loadQueue(); +} diff --git a/public/scripts/feature/chat.ts b/public/scripts/feature/chat.ts index 13b87e5b..237f6d8a 100644 --- a/public/scripts/feature/chat.ts +++ b/public/scripts/feature/chat.ts @@ -8,7 +8,7 @@ */ import { loadComponentHTML, renderFeatherIcons } from "../api/api.js"; -import { createNewChat, sendMessageToChat, deleteChat, dismissUnstruggleBlock, updateChatConversationMode } from "../api/chat-api.js"; +import { createNewChat, deleteChat, dismissUnstruggleBlock, updateChatConversationMode } from "../api/chat-api.js"; import { Chat, ChatMessage, @@ -184,6 +184,11 @@ export class ChatManager { private selectedConversationMode: ConversationModeId = 'socratic'; private conversationModePicker: ConversationModePicker | null = null; private isConversationModeUpdatePending = false; + private failedTransportAttempt: { + chatId: string; + messageText: string; + clientMessageId: string; + } | null = null; // ===== LOGGING HELPER METHODS ===== @@ -653,6 +658,21 @@ export class ChatManager { return; } const selectedModeForSend = this.selectedConversationMode; + const chatIdForSend = activeChat.id; + const retryAttempt = this.failedTransportAttempt; + const isManualRetry = Boolean( + retryAttempt && + retryAttempt.chatId === chatIdForSend && + retryAttempt.messageText === text + ); + const clientMessageId = isManualRetry + ? retryAttempt!.clientMessageId + : crypto.randomUUID(); + + // A different chat/text is a deliberate new send and receives a fresh request id. + if (!isManualRetry) { + this.failedTransportAttempt = null; + } // console.log('[CHAT-MANAGER] 💬 Sending message...'); // 🟢 MEDIUM: Debug info - keep for monitoring @@ -694,9 +714,10 @@ export class ChatManager { // Show loading state immediately onChunk?.('Thinking...', false); + let requestProcessed = false; try { - const response = await fetch(`/api/chat/${this.activeChatId}`, { + const response = await fetch(`/api/chat/${chatIdForSend}`, { method: 'POST', credentials: 'same-origin', headers: { @@ -704,6 +725,7 @@ export class ChatManager { }, body: JSON.stringify({ message: text, + clientMessageId, userId: this.config.userContext.userId, courseName: this.config.userContext.courseName, conversationMode: selectedModeForSend, @@ -720,6 +742,10 @@ export class ChatManager { throw new Error(data.error || 'Failed to send message'); } + // Successful processing closes the retry window; a later send is a new message. + requestProcessed = true; + this.failedTransportAttempt = null; + // console.log('[CHAT-MANAGER] ✅ Message sent successfully'); // 🟢 MEDIUM: Success info - keep for monitoring const persistedMode = data.conversationMode ?? selectedModeForSend; activeChat.conversationMode = persistedMode; @@ -773,6 +799,15 @@ export class ChatManager { } catch (error) { // console.error('[CHAT-MANAGER] 🚨 Error sending message:', error); + + // Reuse this id only when transport/server processing did not complete. + if (!requestProcessed) { + this.failedTransportAttempt = { + chatId: chatIdForSend, + messageText: text, + clientMessageId, + }; + } // Remove placeholder messages from DOM and data using incremental updates this.removeMessageFromDOM(botMessageId); diff --git a/public/scripts/feature/dashboard.ts b/public/scripts/feature/dashboard.ts index 6ce0b4f3..b60450e3 100644 --- a/public/scripts/feature/dashboard.ts +++ b/public/scripts/feature/dashboard.ts @@ -28,6 +28,7 @@ interface DashboardCardDef { title: string; feather: string; feature?: 'writingFeedback' | 'guidedPathway'; + managerOnly?: boolean; } type FeatureKey = keyof CourseFeatures; @@ -39,7 +40,7 @@ const CARD_DEFS: DashboardCardDef[] = [ { view: 'system-prompts', title: 'System Prompt', feather: 'sliders' }, { view: 'monitor', title: 'Monitor', feather: 'monitor' }, { view: 'writing-feedback', title: 'Writing Feedback', feather: 'edit-3', feature: 'writingFeedback' }, - { view: 'pathway-library', title: 'Pathway Library', feather: 'git-branch', feature: 'guidedPathway' } + { view: 'pathway-library', title: 'Pathway Library', feather: 'git-branch', feature: 'guidedPathway', managerOnly: true } ]; const FEATURE_ENDPOINTS: Record = { @@ -61,11 +62,14 @@ const FEATURE_INPUT_IDS: Record = { * * @param currentClass - Active course used for feature gating and metadata */ -export async function initializeDashboard(currentClass: activeCourse): Promise { +export async function initializeDashboard( + currentClass: activeCourse, + canManageCourse: boolean +): Promise { renderWelcomeHeader(); - renderDashboardCards(currentClass); + renderDashboardCards(currentClass, canManageCourse); wireCourseCodeFlip(currentClass); - await wireAdvancedSettings(currentClass); + await wireAdvancedSettings(currentClass, canManageCourse); renderFeatherIcons(); } @@ -210,11 +214,12 @@ function syncDashboardCardOrder( * * @param currentClass - Active course whose features gate optional cards */ -export function renderDashboardCards(currentClass: activeCourse): void { +export function renderDashboardCards(currentClass: activeCourse, canManageCourse: boolean): void { const container = document.getElementById('dashboard-cards'); if (!container) return; const desired = CARD_DEFS.filter((card) => { + if (card.managerOnly && !canManageCourse) return false; if (!card.feature) return true; return currentClass.features?.[card.feature]?.enabled === true; }); @@ -375,9 +380,8 @@ function bindAccordionToggle(itemId: string, toggleId: string, bodyId: string, c * * @param currentClass - Active course for toggles and metadata */ -async function wireAdvancedSettings(currentClass: activeCourse): Promise { +async function wireAdvancedSettings(currentClass: activeCourse, canManage: boolean): Promise { const featuresTaNote = document.getElementById('dashboard-features-ta-note'); - const canManage = await resolveCanManage(currentClass); fillCourseMetadata(currentClass); @@ -392,26 +396,6 @@ async function wireAdvancedSettings(currentClass: activeCourse): Promise { await wireFeatureToggles(currentClass, canManage); } -/** - * resolveCanManage - faculty instructor or platform admin (not TA). - * - * @param currentClass - Course whose instructors list is checked - * @returns Whether the current user may edit capabilities / open Advanced Settings - */ -async function resolveCanManage(currentClass: activeCourse): Promise { - try { - const currentUserResponse = await fetch('/auth/current-user', { credentials: 'same-origin' }); - const currentUserData = currentUserResponse.ok ? await currentUserResponse.json() : {}; - const currentUser = currentUserData.globalUser; - const instructorIds = (currentClass.instructors ?? []).map((item: string | InstructorInfo) => - typeof item === 'string' ? item : item.userId - ); - return Boolean(currentUser?.isAdmin === true || instructorIds.includes(currentUser?.userId)); - } catch { - return false; - } -} - /** * fillCourseMetadata - populate Advanced Settings course information fields. * @@ -564,7 +548,7 @@ async function wireFeatureToggles(currentClass: activeCourse, canManage: boolean } persistedFeatures = featureSnapshotFromCourse(currentClass); showSuccessToast('Extra Feature settings saved.'); - renderDashboardCards(currentClass); + renderDashboardCards(currentClass, canManage); refreshModelSettingsVisibility(currentClass); } catch (error) { currentClass.features = snapshot; @@ -573,7 +557,7 @@ async function wireFeatureToggles(currentClass: activeCourse, canManage: boolean if (input) input.checked = currentClass.features?.[key]?.enabled === true; } persistedFeatures = featureSnapshotFromCourse(currentClass); - renderDashboardCards(currentClass); + renderDashboardCards(currentClass, canManage); refreshModelSettingsVisibility(currentClass); await showErrorModal( 'Save Failed', diff --git a/public/scripts/feature/guided-pathway-flags.ts b/public/scripts/feature/guided-pathway-flags.ts new file mode 100644 index 00000000..5c872911 --- /dev/null +++ b/public/scripts/feature/guided-pathway-flags.ts @@ -0,0 +1,344 @@ +// public/scripts/feature/guided-pathway-flags.ts + +/** + * Instructor Guided Pathway Alerts tab. + * Keeps automatic anonymous alerts isolated from identity-enriched manual flags. + * + * @author EngE-AI Team + * @date 2026-08-09 + * @version 1.1.0 + * @description Shared Flags tab with course-scoped instructor and cross-course admin alert views. + */ + +import type { + GuidedPathwayFlagDecision, + GuidedPathwayFlagListPage, + GuidedPathwayFlagStatus, + GuidedPathwayFlagView, +} from '../types.js'; +import { + decideGuidedPathwayFlag, + listCourseGuidedPathwayFlags, +} from '../api/guided-pathway-flags-api.js'; +import { + activateAdminGuidedPathwayFlags, + initializeAdminGuidedPathwayFlags, +} from './admin-guided-pathway-flags.js'; +import { showErrorToast, showSuccessToast } from '../ui/toast-notification.js'; + +const PAGE_SIZE = 20; + +let activeCourseId = ''; +let activeStatus: GuidedPathwayFlagStatus = 'pending'; +let activePage = 1; +let currentPage: GuidedPathwayFlagListPage | null = null; +let hasLoadedGuidedAlerts = false; +let usesAdminQueue = false; + +function formatDate(value: string | undefined): string { + if (!value) return 'Unknown time'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return 'Unknown time'; + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(date); +} + +function statusLabel(status: GuidedPathwayFlagStatus): string { + if (status === 'escalated') return 'Escalated to LTIC'; + if (status === 'dismissed') return 'Dismissed'; + return 'Pending review'; +} + +function setDomainTab(domain: 'manual' | 'guided'): void { + const manualTab = document.getElementById('manual-flags-tab'); + const guidedTab = document.getElementById('guided-pathway-alerts-tab'); + const manualPanel = document.getElementById('manual-flags-panel'); + const guidedPanel = document.getElementById('guided-pathway-alerts-panel'); + const guidedActive = domain === 'guided'; + + manualTab?.setAttribute('aria-selected', String(!guidedActive)); + guidedTab?.setAttribute('aria-selected', String(guidedActive)); + if (manualTab instanceof HTMLButtonElement) manualTab.tabIndex = guidedActive ? -1 : 0; + if (guidedTab instanceof HTMLButtonElement) guidedTab.tabIndex = guidedActive ? 0 : -1; + if (manualPanel) manualPanel.hidden = guidedActive; + if (guidedPanel) guidedPanel.hidden = !guidedActive; + + if (guidedActive) { + if (usesAdminQueue) { + activateAdminGuidedPathwayFlags(); + } else if (!hasLoadedGuidedAlerts) { + hasLoadedGuidedAlerts = true; + void loadGuidedAlerts(); + } + } +} + +function updateStatusControls(): void { + document.querySelectorAll('[data-guided-alert-status]').forEach((button) => { + const selected = button.dataset.guidedAlertStatus === activeStatus; + button.setAttribute('aria-pressed', String(selected)); + }); +} + +function setListStatus(message: string): void { + const status = document.getElementById('guided-pathway-alerts-status'); + if (status) status.textContent = message; +} + +function setListBusy(busy: boolean): void { + const list = document.getElementById('guided-pathway-alerts-list'); + list?.setAttribute('aria-busy', String(busy)); + document.querySelectorAll('[data-guided-alert-status]').forEach((button) => { + button.disabled = busy; + }); +} + +async function loadGuidedAlerts(): Promise { + if (!activeCourseId) return; + setListBusy(true); + setListStatus('Loading Guided Pathway alerts...'); + try { + currentPage = await listCourseGuidedPathwayFlags(activeCourseId, { + status: activeStatus, + page: activePage, + pageSize: PAGE_SIZE, + }); + renderGuidedAlerts(); + setListStatus(''); + } catch (error) { + currentPage = null; + renderGuidedAlerts(); + setListStatus(error instanceof Error ? error.message : 'Unable to load Guided Pathway alerts.'); + } finally { + setListBusy(false); + } +} + +function createMetadata(label: string, value: string): HTMLElement { + const item = document.createElement('span'); + item.className = 'guided-pathway-alert-card__metadata-item'; + const key = document.createElement('strong'); + key.textContent = `${label}: `; + item.append(key, document.createTextNode(value)); + return item; +} + +function createDecisionButton( + flag: GuidedPathwayFlagView, + decision: GuidedPathwayFlagDecision, + label: string, + modifier: string +): HTMLButtonElement { + const button = document.createElement('button'); + button.type = 'button'; + button.className = `guided-pathway-alert-card__action ${modifier}`; + button.textContent = label; + button.addEventListener('click', () => void submitDecision(flag, decision, button)); + return button; +} + +function createGuidedAlertCard(flag: GuidedPathwayFlagView): HTMLElement { + const card = document.createElement('article'); + card.className = 'guided-pathway-alert-card'; + card.dataset.flagId = flag.id; + + const header = document.createElement('div'); + header.className = 'guided-pathway-alert-card__header'; + const title = document.createElement('h3'); + title.className = 'guided-pathway-alert-card__title'; + title.textContent = flag.pathwayTitle; + const status = document.createElement('span'); + status.className = `guided-pathway-alert-card__status guided-pathway-alert-card__status--${flag.status}`; + status.textContent = statusLabel(flag.status); + header.append(title, status); + + const metadata = document.createElement('div'); + metadata.className = 'guided-pathway-alert-card__metadata'; + metadata.append(createMetadata('Triggered', formatDate(flag.triggeredAt))); + if (flag.decidedAt) metadata.append(createMetadata('Decision recorded', formatDate(flag.decidedAt))); + + const messageLabel = document.createElement('h4'); + messageLabel.className = 'guided-pathway-alert-card__message-label'; + messageLabel.textContent = 'Student message'; + const message = document.createElement('p'); + message.className = 'guided-pathway-alert-card__message'; + message.textContent = flag.messageText; + + card.append(header, metadata, messageLabel, message); + + if (flag.status === 'pending') { + const actions = document.createElement('div'); + actions.className = 'guided-pathway-alert-card__actions'; + actions.append( + createDecisionButton( + flag, + 'dismiss', + 'Dismiss', + 'guided-pathway-alert-card__action--secondary' + ), + createDecisionButton( + flag, + 'escalate', + 'Escalate to LTIC', + 'guided-pathway-alert-card__action--primary' + ) + ); + card.appendChild(actions); + } + + return card; +} + +async function submitDecision( + flag: GuidedPathwayFlagView, + decision: GuidedPathwayFlagDecision, + clickedButton: HTMLButtonElement +): Promise { + const card = clickedButton.closest('.guided-pathway-alert-card'); + const buttons = card?.querySelectorAll('button') ?? []; + buttons.forEach((button) => { + button.disabled = true; + }); + clickedButton.setAttribute('aria-busy', 'true'); + + try { + await decideGuidedPathwayFlag(activeCourseId, flag.id, decision); + showSuccessToast(decision === 'escalate' ? 'Escalation decision recorded.' : 'Alert dismissed.'); + await loadGuidedAlerts(); + } catch (error) { + showErrorToast(error instanceof Error ? error.message : 'Unable to save this decision.'); + buttons.forEach((button) => { + button.disabled = false; + }); + } finally { + clickedButton.removeAttribute('aria-busy'); + } +} + +function renderPagination(): void { + const summary = document.getElementById('guided-pathway-alerts-page-summary'); + const previous = document.getElementById('guided-pathway-alerts-previous') as HTMLButtonElement | null; + const next = document.getElementById('guided-pathway-alerts-next') as HTMLButtonElement | null; + const page = currentPage?.page ?? activePage; + const total = currentPage?.total ?? 0; + const pageSize = currentPage?.pageSize ?? PAGE_SIZE; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + + if (summary) summary.textContent = `Page ${page} of ${totalPages}`; + if (previous) previous.disabled = page <= 1; + if (next) next.disabled = page >= totalPages; +} + +function renderGuidedAlerts(): void { + const list = document.getElementById('guided-pathway-alerts-list'); + if (!list) return; + list.replaceChildren(); + + const items = currentPage?.items ?? []; + if (items.length === 0) { + const empty = document.createElement('p'); + empty.className = 'guided-pathway-alerts__empty'; + empty.textContent = `No ${statusLabel(activeStatus).toLowerCase()} Guided Pathway alerts.`; + list.appendChild(empty); + } else { + items.forEach((flag) => list.appendChild(createGuidedAlertCard(flag))); + } + renderPagination(); +} + +/** Mount the role-appropriate Guided Pathway Alerts view inside the shared Flags tab. */ +export async function initializeGuidedPathwayFlags(options: { + courseId: string; + canAccess: boolean; + isAdmin?: boolean; +}): Promise { + activeCourseId = options.courseId; + activeStatus = 'pending'; + activePage = 1; + currentPage = null; + hasLoadedGuidedAlerts = false; + usesAdminQueue = options.isAdmin === true; + + const tabs = document.getElementById('flag-management-tabs'); + const guidedTab = document.getElementById('guided-pathway-alerts-tab'); + const manualPanel = document.getElementById('manual-flags-panel'); + const guidedPanel = document.getElementById('guided-pathway-alerts-panel'); + const courseContent = document.getElementById('course-guided-pathway-alerts-content'); + const adminContent = document.getElementById('admin-guided-pathway-alerts-content'); + const adminCount = document.getElementById('admin-guided-alert-count'); + + if (!options.canAccess) { + if (tabs) tabs.hidden = true; + if (guidedTab) guidedTab.hidden = true; + if (manualPanel) manualPanel.hidden = false; + if (guidedPanel) guidedPanel.hidden = true; + if (courseContent) courseContent.hidden = false; + if (adminContent) adminContent.hidden = true; + if (adminCount) adminCount.hidden = true; + manualPanel?.removeAttribute('role'); + manualPanel?.removeAttribute('aria-labelledby'); + return; + } + + manualPanel?.setAttribute('role', 'tabpanel'); + manualPanel?.setAttribute('aria-labelledby', 'manual-flags-tab'); + if (courseContent) courseContent.hidden = usesAdminQueue; + if (adminContent) adminContent.hidden = !usesAdminQueue; + if (adminCount) adminCount.hidden = !usesAdminQueue; + if (usesAdminQueue) { + await initializeAdminGuidedPathwayFlags(); + } + + if (tabs) tabs.hidden = false; + if (guidedTab) guidedTab.hidden = false; + setDomainTab('manual'); + + document.getElementById('manual-flags-tab')?.addEventListener('click', () => setDomainTab('manual')); + guidedTab?.addEventListener('click', () => setDomainTab('guided')); + tabs?.addEventListener('keydown', (event) => { + if (!(event instanceof KeyboardEvent)) return; + const tabButtons = [ + document.getElementById('manual-flags-tab'), + document.getElementById('guided-pathway-alerts-tab'), + ].filter((element): element is HTMLButtonElement => element instanceof HTMLButtonElement); + const currentIndex = tabButtons.indexOf(document.activeElement as HTMLButtonElement); + if (currentIndex < 0) return; + + let nextIndex: number | null = null; + if (event.key === 'ArrowRight') nextIndex = (currentIndex + 1) % tabButtons.length; + if (event.key === 'ArrowLeft') nextIndex = (currentIndex - 1 + tabButtons.length) % tabButtons.length; + if (event.key === 'Home') nextIndex = 0; + if (event.key === 'End') nextIndex = tabButtons.length - 1; + if (nextIndex === null) return; + + event.preventDefault(); + const nextTab = tabButtons[nextIndex]; + nextTab.focus(); + setDomainTab(nextTab.id === 'guided-pathway-alerts-tab' ? 'guided' : 'manual'); + }); + + document.querySelectorAll('[data-guided-alert-status]').forEach((button) => { + button.addEventListener('click', () => { + const status = button.dataset.guidedAlertStatus as GuidedPathwayFlagStatus | undefined; + if (!status || status === activeStatus) return; + activeStatus = status; + activePage = 1; + updateStatusControls(); + void loadGuidedAlerts(); + }); + }); + + document.getElementById('guided-pathway-alerts-previous')?.addEventListener('click', () => { + if (activePage <= 1) return; + activePage -= 1; + void loadGuidedAlerts(); + }); + document.getElementById('guided-pathway-alerts-next')?.addEventListener('click', () => { + activePage += 1; + void loadGuidedAlerts(); + }); + updateStatusControls(); + renderGuidedAlerts(); +} diff --git a/public/scripts/feature/pathway-library.ts b/public/scripts/feature/pathway-library.ts index b14fae02..f6c05ad3 100644 --- a/public/scripts/feature/pathway-library.ts +++ b/public/scripts/feature/pathway-library.ts @@ -154,6 +154,7 @@ function renderList(): void { const article = node.querySelector('.pathway-block') as HTMLElement; article.dataset.pathwayId = pathway.id; applyEnabledVisual(article, pathway.enabled !== false); + applyNotificationVisual(article, pathway.notifyInstructorOnTrigger !== false); const titleText = article.querySelector('.pathway-block__title-text') as HTMLElement; const titleInput = article.querySelector('.pathway-block__title-input') as HTMLInputElement; @@ -213,6 +214,11 @@ function renderList(): void { void onToggleEnabled(article, pathway.id); }); + article.querySelector('.pathway-block__toggle-notification')?.addEventListener('click', (e) => { + e.stopPropagation(); + void onToggleNotification(article, pathway.id); + }); + article.querySelector('.pathway-block__save')?.addEventListener('click', () => { void onSave(article, pathway.id); }); @@ -533,6 +539,21 @@ function isArticleEnabled(article: HTMLElement): boolean { return article.dataset.enabled !== 'false'; } +function applyNotificationVisual(article: HTMLElement, enabled: boolean): void { + article.dataset.notifyInstructorOnTrigger = enabled ? 'true' : 'false'; + const toggle = article.querySelector('.pathway-block__toggle-notification') as HTMLButtonElement | null; + const state = article.querySelector('.pathway-block__notification-state') as HTMLElement | null; + if (toggle) { + toggle.setAttribute('aria-checked', String(enabled)); + toggle.title = enabled ? 'Instructor notification on' : 'Instructor notification off'; + } + if (state) state.textContent = enabled ? 'On' : 'Off'; +} + +function isArticleNotificationEnabled(article: HTMLElement): boolean { + return article.dataset.notifyInstructorOnTrigger !== 'false'; +} + async function onToggleEnabled(article: HTMLElement, pathwayId: string): Promise { const nextEnabled = !isArticleEnabled(article); try { @@ -545,6 +566,29 @@ async function onToggleEnabled(article: HTMLElement, pathwayId: string): Promise } } +async function onToggleNotification(article: HTMLElement, pathwayId: string): Promise { + const nextEnabled = !isArticleNotificationEnabled(article); + const toggle = article.querySelector('.pathway-block__toggle-notification') as HTMLButtonElement | null; + if (toggle) toggle.disabled = true; + try { + const updated = await updatePathway(courseId, pathwayId, { + notifyInstructorOnTrigger: nextEnabled, + }); + const normalized = updated.notifyInstructorOnTrigger !== false; + pathways = pathways.map((pathway) => + pathway.id === pathwayId + ? { ...updated, notifyInstructorOnTrigger: normalized } + : pathway + ); + applyNotificationVisual(article, normalized); + showSuccessToast(normalized ? 'Instructor notification enabled' : 'Instructor notification disabled'); + } catch (error: any) { + showErrorToast(error?.message || 'Failed to update instructor notification'); + } finally { + if (toggle) toggle.disabled = false; + } +} + async function onSave(article: HTMLElement, pathwayId: string): Promise { const status = article.querySelector('.pathway-block__save-status') as HTMLElement | null; if (status) status.textContent = 'Saving…'; @@ -558,6 +602,7 @@ async function onSave(article: HTMLElement, pathwayId: string): Promise { const updated = await updatePathway(courseId, pathwayId, { title, enabled: isArticleEnabled(article), + notifyInstructorOnTrigger: isArticleNotificationEnabled(article), triggerDescription, assistantResponse, ctas, @@ -568,6 +613,7 @@ async function onSave(article: HTMLElement, pathwayId: string): Promise { if (titleText) titleText.textContent = updated.title; if (titleInput) titleInput.value = updated.title; applyEnabledVisual(article, updated.enabled !== false); + applyNotificationVisual(article, updated.notifyInstructorOnTrigger !== false); article.classList.remove('is-editing-title'); if (status) status.textContent = 'Saved'; showSuccessToast('Pathway saved'); @@ -585,6 +631,7 @@ async function onAddPathway(): Promise { const created = await createPathway(courseId, { title: DEFAULT_TITLE, enabled: true, + notifyInstructorOnTrigger: true, triggerDescription: '', assistantResponse: '', ctas: [], diff --git a/public/scripts/types.ts b/public/scripts/types.ts index 487e2b45..da120c8e 100644 --- a/public/scripts/types.ts +++ b/public/scripts/types.ts @@ -51,12 +51,62 @@ export interface GuidedPathway { order: number; // library list position title: string; enabled: boolean; // on for this course; false = listed but not evaluated + notifyInstructorOnTrigger: boolean; // creates an anonymous instructor alert when this active pathway wins triggerDescription: string; assistantResponse: string; ctas: PathwayCta[]; updatedAt: number; } +/** Must match src/types/shared.ts. Automatic Guided Pathway alert lifecycle. */ +export type GuidedPathwayFlagStatus = 'pending' | 'escalated' | 'dismissed'; + +/** Must match src/types/shared.ts. Instructor decision request value. */ +export type GuidedPathwayFlagDecision = 'escalate' | 'dismiss'; + +/** Must match src/types/shared.ts. Admin review-state filter. */ +export type GuidedPathwayFlagReviewState = 'needs-review' | 'reviewed' | 'all'; + +/** + * Must match src/types/shared.ts. Anonymous alert DTO used by instructor and admin pages. + * Restricted student identity and audit fields are deliberately absent. + */ +export interface GuidedPathwayFlagView { + id: string; // stable alert id for review actions + courseId: string; // owning course id + courseName: string; // course-name snapshot at trigger time + pathwayId: string; // winning pathway id + pathwayTitle: string; // winning pathway title snapshot + messageText: string; // exact student-authored message + status: GuidedPathwayFlagStatus; // instructor decision lifecycle + triggeredAt: string; // ISO trigger timestamp + decidedAt?: string; // ISO instructor-decision timestamp + decidedByName?: string; // staff display-name snapshot + adminReviewedAt?: string; // ISO platform-review timestamp + adminReviewedByName?: string; // platform-admin display-name snapshot +} + +/** Must match src/types/shared.ts. Safe pathway choice for administrator filtering. */ +export interface GuidedPathwayFlagPathwayFacet { + pathwayId: string; // stable winning-pathway id + pathwayTitle: string; // instructor-facing title snapshot +} + +/** Must match src/types/shared.ts. Full-queue administrator filter choices. */ +export interface GuidedPathwayFlagFacets { + pathways: GuidedPathwayFlagPathwayFacet[]; // all scoped pathways except the active pathway filter + reviewers: string[]; // all scoped staff names except the active reviewer filter +} + +/** Must match src/types/shared.ts. Paginated anonymous alert list. */ +export interface GuidedPathwayFlagListPage { + items: GuidedPathwayFlagView[]; // safe alerts for this page + page: number; // one-based page number + pageSize: number; // bounded page size + total: number; // total matching alerts + facets?: GuidedPathwayFlagFacets; // always present on admin list responses; omitted for course lists +} + /** * Must match src/types/shared.ts. * Persisted turn — plain UI text only (no RAG/struggle tags in MongoDB). diff --git a/public/scripts/utils/course-permissions.ts b/public/scripts/utils/course-permissions.ts new file mode 100644 index 00000000..132bc426 --- /dev/null +++ b/public/scripts/utils/course-permissions.ts @@ -0,0 +1,34 @@ +// public/scripts/utils/course-permissions.ts + +/** + * Shared frontend course-permission predicates. + * These guards control presentation only; backend middleware remains authoritative. + * + * @author EngE-AI Team + * @date 2026-08-08 + * @version 1.0.0 + * @description Shared, presentation-only course authorization helpers. + */ + +import type { activeCourse, AuthUser, InstructorInfo } from '../types.js'; + +/** Return the stable user id from either current or legacy course-roster entries. */ +function rosterUserId(entry: InstructorInfo | string): string { + return typeof entry === 'string' ? entry : entry.userId; +} + +/** + * Determine whether a user may configure or review Guided Pathway alerts. + * + * Platform administrators and faculty instructors qualify. Teaching assistants + * do not qualify unless they are also explicitly present in the instructor roster. + * This is a presentation guard only; APIs must enforce the same rule server-side. + */ +export function canManageGuidedPathways( + course: activeCourse, + user: AuthUser | null | undefined +): boolean { + if (!user) return false; + if (user.isAdmin === true) return true; + return (course.instructors ?? []).some((entry) => rosterUserId(entry) === user.userId); +} diff --git a/public/styles/admin-guided-pathway-flags.css b/public/styles/admin-guided-pathway-flags.css new file mode 100644 index 00000000..5a5046a8 --- /dev/null +++ b/public/styles/admin-guided-pathway-flags.css @@ -0,0 +1,550 @@ +/* Platform-admin Guided Pathway queue embedded in the shared Flags view. */ + +.admin-guided-alert-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.5rem; + padding: 0.1rem 0.45rem; + border-radius: 999px; + background: rgba(77, 122, 47, 0.14); + color: var(--color-chbe-green); + font-size: 0.78rem; + font-weight: 700; +} + +.flag-management-tab[aria-selected='true'] .admin-guided-alert-count { + background: #fff; + color: var(--color-chbe-green); +} + +.admin-guided-alert-count[hidden], +.admin-guided-alerts[hidden] { + display: none !important; +} + +.admin-guided-alerts { + --admin-guided-border: #d6dbd2; + --admin-guided-muted: #667063; + --admin-guided-soft-green: #f1f5ee; + --admin-guided-surface-muted: #f7f8f5; + width: 100%; + margin: 0 0 2rem; + color: var(--text-primary); +} + +.admin-guided-alerts__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.85rem; + padding: 1.15rem 1.25rem; + border: 1px solid var(--color-chbe-green); + border-radius: 10px; + background: var(--color-chbe-green); + color: #fff; + box-shadow: 0 2px 8px rgba(35, 55, 24, 0.14); +} + +.admin-guided-alerts__heading-row, +.admin-guided-alerts__title-group { + display: flex; + align-items: center; + min-width: 0; +} + +.admin-guided-alerts__heading-row { + gap: 0.75rem; +} + +.admin-guided-alerts__title-group { + flex-wrap: wrap; + gap: 0.55rem 0.75rem; +} + +.admin-guided-alerts__header .instructor-mobile-hamburger-btn { + display: none; +} + +.admin-guided-alerts__header h1 { + margin: 0; + color: #fff; + font-size: 1.25rem; + font-weight: 650; + line-height: 1.25; +} + +.admin-guided-alerts__scope { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0.2rem 0.65rem; + border: 1px solid rgba(255, 255, 255, 0.42); + border-radius: 999px; + background: rgba(255, 255, 255, 0.14); + color: #fff; + font-size: 0.78rem; + font-weight: 650; + white-space: nowrap; +} + +.admin-guided-alerts__refresh, +.admin-guided-alert-filters button, +.admin-guided-alerts__pagination button, +.admin-guided-alert-card__actions button { + min-height: 44px; + padding: 0.55rem 0.9rem; + border-radius: 8px; + font: inherit; + font-weight: 650; + cursor: pointer; + transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease, + box-shadow 0.18s ease, transform 0.1s ease; +} + +.admin-guided-alerts__refresh { + display: inline-flex; + align-items: center; + flex-shrink: 0; + gap: 0.45rem; + border: 1px solid rgba(255, 255, 255, 0.52); + background: rgba(255, 255, 255, 0.12); + color: #fff; +} + +.admin-guided-alerts__refresh:hover { + background: rgba(255, 255, 255, 0.22); +} + +.admin-guided-alerts__refresh svg, +.admin-guided-alerts__notice svg, +.admin-guided-alert-filters__heading svg { + width: 17px; + height: 17px; + flex: 0 0 auto; +} + +.admin-guided-alerts__notices { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.65rem; + margin: 0 0 1rem; +} + +.admin-guided-alerts__notice { + display: flex; + align-items: flex-start; + gap: 0.6rem; + margin: 0; + padding: 0.75rem 0.9rem; + border: 1px solid #dbe3d5; + border-radius: 8px; + background: var(--admin-guided-soft-green); + color: #4d5948; + font-size: 0.86rem; + line-height: 1.45; +} + +.admin-guided-alerts__notice svg { + margin-top: 0.12rem; + color: var(--color-chbe-green); +} + +.admin-guided-alert-filters { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.85rem; + padding: 1rem; + border: 1px solid var(--admin-guided-border); + border-radius: 10px; + background: var(--admin-guided-surface-muted); + box-shadow: 0 1px 3px rgba(35, 45, 30, 0.05); +} + +.admin-guided-alert-filters__heading { + display: flex; + align-items: center; + gap: 0.5rem; + grid-column: 1 / -1; + padding-bottom: 0.7rem; + border-bottom: 1px solid var(--admin-guided-border); + color: var(--color-chbe-green); +} + +.admin-guided-alert-filters__heading h2 { + margin: 0; + color: var(--text-primary); + font-size: 1rem; + font-weight: 650; +} + +.admin-guided-alert-filters label { + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.35rem; + color: #465044; + font-size: 0.8rem; + font-weight: 650; +} + +.admin-guided-alert-filters select, +.admin-guided-alert-filters input { + width: 100%; + min-width: 0; + min-height: 44px; + padding: 0.5rem 0.65rem; + box-sizing: border-box; + border: 1px solid #bcc4b8; + border-radius: 7px; + background: #fff; + color: var(--text-primary); + font: inherit; + font-size: 0.92rem; + transition: border-color 0.18s ease, box-shadow 0.18s ease; +} + +.admin-guided-alert-filters select:hover, +.admin-guided-alert-filters input:hover { + border-color: #8f9c88; +} + +.admin-guided-alert-filters__actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.6rem; + grid-column: 1 / -1; + padding-top: 0.1rem; +} + +.admin-guided-alert-filters button[type='submit'], +.admin-guided-alert-card__actions button { + border: 1px solid var(--color-chbe-green); + background: var(--color-chbe-green); + color: #fff; +} + +.admin-guided-alert-filters button[type='submit']:hover, +.admin-guided-alert-card__actions button:hover { + border-color: #3f6728; + background: #3f6728; + box-shadow: 0 2px 6px rgba(54, 87, 34, 0.18); +} + +#admin-guided-alert-filters-clear, +.admin-guided-alerts__pagination button { + border: 1px solid #aeb8aa; + background: #fff; + color: #465044; +} + +#admin-guided-alert-filters-clear:hover, +.admin-guided-alerts__pagination button:hover:not(:disabled) { + border-color: var(--color-chbe-green); + background: var(--admin-guided-soft-green); + color: var(--color-chbe-green); +} + +.admin-guided-alerts__status { + min-height: 1.5rem; + margin: 0.65rem 0 0.35rem; + color: var(--admin-guided-muted); + font-size: 0.88rem; +} + +.admin-guided-alerts__list { + display: grid; + gap: 0.8rem; +} + +.admin-guided-alerts__list[aria-busy='true'] { + opacity: 0.62; +} + +.admin-guided-alerts__empty { + margin: 0; + padding: 2.25rem 1rem; + border: 1px dashed #aeb8aa; + border-radius: 10px; + background: var(--admin-guided-surface-muted); + color: var(--admin-guided-muted); + text-align: center; +} + +.admin-guided-alerts__empty--error { + border-style: solid; + border-color: #d9bcbc; + background: #fbf3f3; + color: #7a2e2e; +} + +.admin-guided-alert-card { + padding: 1rem 1.1rem; + border: 1px solid var(--admin-guided-border); + border-inline-start: 4px solid #aab1a7; + border-radius: 10px; + background: var(--chat-bg, #fff); + box-shadow: 0 2px 8px rgba(35, 45, 30, 0.07); + transition: border-color 0.18s ease, box-shadow 0.18s ease; +} + +.admin-guided-alert-card--pending { + border-inline-start-color: #d3a12d; +} + +.admin-guided-alert-card--escalated { + border-inline-start-color: var(--color-ubc-blue); +} + +.admin-guided-alert-card--dismissed { + border-inline-start-color: #9da59a; +} + +.admin-guided-alert-card:hover { + border-color: #bdc6b9; + box-shadow: 0 4px 12px rgba(35, 45, 30, 0.1); +} + +.admin-guided-alert-card__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.admin-guided-alert-card__header h2 { + margin: 0; + color: var(--text-primary); + font-size: 1.05rem; + font-weight: 650; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.admin-guided-alert-card__status { + flex-shrink: 0; + padding: 0.28rem 0.6rem; + border: 1px solid transparent; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 700; + line-height: 1.3; + text-align: center; +} + +.admin-guided-alert-card__status--pending { + border-color: #e7c86a; + background: #fff4d8; + color: #725106; +} + +.admin-guided-alert-card__status--escalated { + border-color: #aec9e1; + background: #e5eff8; + color: #244d74; +} + +.admin-guided-alert-card__status--dismissed { + border-color: #cbd0c8; + background: #eff1ee; + color: #4f574c; +} + +.admin-guided-alert-card__metadata { + display: flex; + flex-wrap: wrap; + gap: 0.35rem 1rem; + margin-top: 0.55rem; + color: var(--admin-guided-muted); + font-size: 0.82rem; +} + +.admin-guided-alert-card__metadata strong { + color: #465044; + font-weight: 650; +} + +.admin-guided-alert-card h3 { + margin: 1rem 0 0.4rem; + color: #465044; + font-size: 0.84rem; + font-weight: 700; +} + +.admin-guided-alert-card__message { + margin: 0; + padding: 0.85rem 0.9rem; + border: 1px solid #e1e4de; + border-radius: 8px; + background: var(--admin-guided-surface-muted); + color: var(--text-primary); + line-height: 1.55; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.admin-guided-alert-card__identity { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + margin-top: 0.85rem; + padding: 0.75rem 0.85rem; + border: 1px solid #ded8cf; + border-radius: 8px; + background: #faf8f4; +} + +.admin-guided-alert-card__identity-toggle { + display: inline-flex; + align-items: center; + gap: 0.5rem; + min-height: 44px; + color: #4d5048; + font-weight: 650; + cursor: pointer; +} + +.admin-guided-alert-card__identity-toggle input { + width: 1.15rem; + height: 1.15rem; + accent-color: var(--color-chbe-green); +} + +.admin-guided-alert-card__revealed-name { + color: var(--color-eng-red); + font-weight: 700; +} + +.admin-guided-alert-card__actions, +.admin-guided-alerts__pagination { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 0.85rem; +} + +.admin-guided-alerts__pagination { + justify-content: center; + color: var(--admin-guided-muted); +} + +.admin-guided-alerts button:active:not(:disabled) { + transform: translateY(1px); +} + +.admin-guided-alerts button:disabled { + opacity: 0.52; + cursor: not-allowed; + transform: none; +} + +.admin-guided-alerts button:focus-visible, +.admin-guided-alerts select:focus-visible, +.admin-guided-alerts input:focus-visible { + outline: 3px solid var(--color-chbe-green); + outline-offset: 2px; +} + +.admin-guided-alerts__refresh:focus-visible { + outline-color: #fff; +} + +.admin-guided-alert-filters select:focus-visible, +.admin-guided-alert-filters input:focus-visible { + border-color: var(--color-chbe-green); + box-shadow: 0 0 0 1px var(--color-chbe-green); +} + +@media (max-width: 1050px) { + .admin-guided-alert-filters { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 768px) { + .admin-guided-alerts__notices { + grid-template-columns: 1fr; + } + + #main-content-area .admin-guided-alerts__header.mobile-header-bar { + align-items: stretch; + flex-direction: column; + margin-bottom: 0.75rem; + padding: 1rem; + border: 1px solid var(--admin-guided-border); + border-radius: 10px; + background: var(--chat-bg, #fff); + color: var(--text-primary); + box-shadow: none; + position: static; + } + + .admin-guided-alerts__header .instructor-mobile-hamburger-btn { + display: inline-flex; + flex-shrink: 0; + color: var(--color-chbe-green); + } + + .admin-guided-alerts__header h1 { + color: var(--color-chbe-green); + } + + .admin-guided-alerts__scope { + border-color: rgba(77, 122, 47, 0.28); + background: rgba(77, 122, 47, 0.1); + color: var(--color-chbe-green); + } + + .admin-guided-alerts__refresh { + align-self: flex-start; + border-color: var(--color-chbe-green); + background: #fff; + color: var(--color-chbe-green); + } + + .admin-guided-alerts__refresh:hover { + background: var(--admin-guided-soft-green); + } + + .admin-guided-alerts__refresh:focus-visible { + outline-color: var(--color-chbe-green); + } +} + +@media (max-width: 560px) { + .admin-guided-alert-filters { + grid-template-columns: 1fr; + padding: 0.85rem; + } + + .admin-guided-alert-filters__actions { + align-items: stretch; + flex-direction: column-reverse; + } + + .admin-guided-alert-filters__actions button { + width: 100%; + } + + .admin-guided-alert-card__header, + .admin-guided-alert-card__actions { + align-items: stretch; + flex-direction: column; + } + + .admin-guided-alert-card__status { + align-self: flex-start; + } + + .admin-guided-alert-card__actions button { + width: 100%; + } + + .admin-guided-alerts__pagination { + gap: 0.5rem; + } +} diff --git a/public/styles/instructor-components/flag-instructor.css b/public/styles/instructor-components/flag-instructor.css index 1964c223..da954022 100644 --- a/public/styles/instructor-components/flag-instructor.css +++ b/public/styles/instructor-components/flag-instructor.css @@ -917,3 +917,331 @@ } } +/* Manual and automatic flag workflows stay visually and structurally separate. */ +.flag-management-tabs { + display: inline-flex; + gap: 0.25rem; + margin: 0 0 1rem; + padding: 0.25rem; + border: 1px solid var(--border-color); + border-radius: 10px; + background: #f4f4f4; +} + +.flag-management-tabs[hidden], +.guided-pathway-alerts[hidden], +#manual-flags-panel[hidden], +#course-guided-pathway-alerts-content[hidden], +#admin-guided-pathway-alerts-content[hidden] { + display: none !important; +} + +.flag-management-tab { + min-height: 44px; + padding: 0.65rem 1rem; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--text-primary); + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.flag-management-tab:hover { + background: rgba(92, 138, 58, 0.1); +} + +.flag-management-tab[aria-selected='true'] { + background: var(--color-chbe-green); + color: #fff; +} + +.flag-management-tab:focus-visible, +.guided-pathway-alerts__status-filters button:focus-visible, +.guided-pathway-alert-card__action:focus-visible, +.guided-pathway-alerts__pagination button:focus-visible { + outline: 3px solid rgba(92, 138, 58, 0.35); + outline-offset: 2px; +} + +.guided-pathway-alerts { + padding-bottom: 1rem; +} + +.guided-pathway-alerts__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; + padding: 1.25rem; + border-radius: 10px; + background: var(--color-chbe-green); + color: #fff; +} + +.guided-pathway-alerts__heading-row { + display: flex; + align-items: flex-start; + gap: 0.75rem; + min-width: 0; +} + +.guided-pathway-alerts__heading-row h1 { + margin: 0 0 0.35rem; + font-size: 1.25rem; +} + +.guided-pathway-alerts__heading-row p { + max-width: 58rem; + margin: 0; + line-height: 1.5; +} + +.guided-pathway-alerts__header .instructor-mobile-hamburger-btn { + display: none; +} + +.guided-pathway-alerts__status-filters { + display: flex; + flex: 0 0 auto; + flex-wrap: nowrap; + justify-content: flex-end; + gap: 0.5rem; +} + +.guided-pathway-alerts__status-filters button { + flex: 0 0 auto; + min-height: 44px; + padding: 0.55rem 0.8rem; + border: 1px solid rgba(255, 255, 255, 0.55); + border-radius: 8px; + background: rgba(255, 255, 255, 0.12); + color: #fff; + font: inherit; + font-weight: 600; + white-space: nowrap; + cursor: pointer; +} + +.guided-pathway-alerts__status-filters button[aria-pressed='true'] { + background: #fff; + color: var(--color-chbe-green); +} + +.guided-pathway-alerts__status { + min-height: 1.5rem; + margin: 0.25rem 0; + color: var(--text-secondary); +} + +.guided-pathway-alerts__list { + display: grid; + gap: 0.75rem; +} + +.guided-pathway-alerts__list[aria-busy='true'] { + opacity: 0.65; +} + +.guided-pathway-alerts__empty { + margin: 0; + padding: 2rem; + border: 1px dashed var(--border-color); + border-radius: 10px; + color: var(--text-secondary); + text-align: center; +} + +.guided-pathway-alert-card { + padding: 1rem 1.1rem; + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--chat-bg); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} + +.guided-pathway-alert-card__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.guided-pathway-alert-card__title { + margin: 0; + color: var(--text-primary); + font-size: 1rem; + overflow-wrap: anywhere; +} + +.guided-pathway-alert-card__status { + flex-shrink: 0; + padding: 0.25rem 0.55rem; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 700; +} + +.guided-pathway-alert-card__status--pending { + background: #fff4cf; + color: #6b4f00; +} + +.guided-pathway-alert-card__status--escalated { + background: #dceaf8; + color: #173f63; +} + +.guided-pathway-alert-card__status--dismissed { + background: #e8e8e8; + color: #4b4b4b; +} + +.guided-pathway-alert-card__metadata { + display: flex; + flex-wrap: wrap; + gap: 0.35rem 1rem; + margin-top: 0.5rem; + color: var(--text-secondary); + font-size: 0.82rem; +} + +.guided-pathway-alert-card__message-label { + margin: 1rem 0 0.35rem; + font-size: 0.85rem; +} + +.guided-pathway-alert-card__message { + margin: 0; + padding: 0.85rem; + border-radius: 8px; + background: #f3f3f3; + color: var(--text-primary); + line-height: 1.55; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.guided-pathway-alert-card__actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.6rem; + margin-top: 1rem; +} + +.guided-pathway-alert-card__action, +.guided-pathway-alerts__pagination button { + min-height: 44px; + padding: 0.6rem 1rem; + border-radius: 7px; + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.guided-pathway-alert-card__action--primary { + border: 1px solid var(--color-chbe-green); + background: var(--color-chbe-green); + color: #fff; +} + +.guided-pathway-alert-card__action--secondary, +.guided-pathway-alerts__pagination button { + border: 1px solid var(--border-color); + background: #fff; + color: var(--text-primary); +} + +.guided-pathway-alert-card__action:disabled, +.guided-pathway-alerts__pagination button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.guided-pathway-alerts__pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + margin-top: 1rem; + color: var(--text-secondary); +} + +@media (max-width: 768px) { + .flag-management-tabs { + display: flex; + margin: 0.75rem; + overflow-x: auto; + } + + .flag-management-tab { + flex: 1 0 auto; + } + + .guided-pathway-alerts { + padding: 0.75rem; + } + + .guided-pathway-alerts__header { + flex-direction: column; + background: var(--chat-bg); + color: var(--text-primary); + } + + .guided-pathway-alerts__header .instructor-mobile-hamburger-btn { + display: inline-flex; + flex-shrink: 0; + color: var(--color-chbe-green); + } + + .guided-pathway-alerts__heading-row h1 { + color: var(--color-chbe-green); + } + + .guided-pathway-alerts__status-filters { + width: 100%; + padding-bottom: 0.25rem; + justify-content: flex-start; + overflow-x: auto; + scrollbar-color: rgba(0, 0, 0, 0.22) transparent; + scrollbar-gutter: stable; + } + + .guided-pathway-alerts__status-filters::-webkit-scrollbar { + height: 6px; + background: transparent; + } + + .guided-pathway-alerts__status-filters::-webkit-scrollbar-track { + background: transparent; + } + + .guided-pathway-alerts__status-filters::-webkit-scrollbar-thumb { + border-radius: 4px; + background: rgba(0, 0, 0, 0.22); + } + + .guided-pathway-alerts__status-filters button { + border-color: var(--color-chbe-green); + background: #fff; + color: var(--color-chbe-green); + } + + .guided-pathway-alerts__status-filters button[aria-pressed='true'] { + background: var(--color-chbe-green); + color: #fff; + } + + .guided-pathway-alert-card__header, + .guided-pathway-alert-card__actions { + align-items: stretch; + flex-direction: column; + } + + .guided-pathway-alert-card__status { + align-self: flex-start; + } +} diff --git a/public/styles/instructor-components/pathway-library.css b/public/styles/instructor-components/pathway-library.css index f1a9c84c..d9df04e3 100644 --- a/public/styles/instructor-components/pathway-library.css +++ b/public/styles/instructor-components/pathway-library.css @@ -230,7 +230,8 @@ flex-shrink: 0; } -.pathway-block__toggle-enabled { +.pathway-block__toggle-enabled, +.pathway-block__toggle-notification { appearance: none; border: none; background: transparent; @@ -243,11 +244,13 @@ transform-origin: center; } -.pathway-block__toggle-enabled:hover { +.pathway-block__toggle-enabled:hover, +.pathway-block__toggle-notification:hover { transform: scale(1.12); } -.pathway-block__toggle-enabled:focus-visible { +.pathway-block__toggle-enabled:focus-visible, +.pathway-block__toggle-notification:focus-visible { outline: 2px solid var(--color-chbe-green, #4d7a2f); outline-offset: 2px; } @@ -262,7 +265,8 @@ transition: background 0.15s ease; } -.pathway-block__toggle-enabled[aria-checked='true'] .pathway-block__toggle-track { +.pathway-block__toggle-enabled[aria-checked='true'] .pathway-block__toggle-track, +.pathway-block__toggle-notification[aria-checked='true'] .pathway-block__toggle-track { background: var(--color-chbe-green, #4d7a2f); } @@ -278,10 +282,48 @@ transition: transform 0.15s ease; } -.pathway-block__toggle-enabled[aria-checked='true'] .pathway-block__toggle-thumb { +.pathway-block__toggle-enabled[aria-checked='true'] .pathway-block__toggle-thumb, +.pathway-block__toggle-notification[aria-checked='true'] .pathway-block__toggle-thumb { transform: translateX(1rem); } +.pathway-block__notification-setting { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; + padding: 0.8rem; + border: 1px solid var(--border-color, #c8c8c8); + border-radius: 8px; + background: #f7f7f7; +} + +.pathway-block__notification-copy { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.pathway-block__notification-copy .pathway-block__label { + padding-top: 0; +} + +.pathway-block__notification-control { + display: inline-flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; +} + +.pathway-block__notification-state { + min-width: 1.8rem; + color: var(--text-secondary, #555); + font-size: 0.82rem; + font-weight: 600; + text-align: right; +} + .pathway-block__remove.icon-btn { display: inline-flex; align-items: center; @@ -747,11 +789,13 @@ .pathway-block__title-input, .pathway-block__title-edit, .pathway-block__toggle-enabled, + .pathway-block__toggle-notification, .pathway-block__remove.icon-btn { transition: none; } .pathway-block__toggle-enabled:hover, + .pathway-block__toggle-notification:hover, .pathway-block__remove.icon-btn:hover { transform: none; } @@ -772,6 +816,11 @@ grid-template-columns: 1fr; } + .pathway-block__notification-setting { + align-items: flex-start; + flex-direction: column; + } + .pathway-block__add-cta { grid-column: 1; } diff --git a/src/chat/chat-app.ts b/src/chat/chat-app.ts index 9369de29..c7c2277c 100644 --- a/src/chat/chat-app.ts +++ b/src/chat/chat-app.ts @@ -23,6 +23,7 @@ import { EngEAI_MongoDB } from '../db/enge-ai-mongodb'; import { memoryAgent } from '../memory-agent/memory-agent'; import { isMockResponse, generateMockStreamingResponse } from '../helpers/mock-response'; import { evaluatePathways } from '../guided-pathways/pathway-orchestrator'; +import type { PathwayTriggerSnapshot } from '../guided-pathways/pathway-schema'; import { isCourseFeatureEnabled } from '../dashboard-setting/course-features'; import { ModelSelectionService } from '../dashboard-setting/model-selection-service'; import { stripAllQuestionUnstruggleTags } from '../utils/message-utils'; @@ -50,6 +51,12 @@ export interface SendUserMessageOptions { /** Platform admin (`GlobalUser.isAdmin`); required for `/DEBUG` and sticky debug turns. */ isAdmin?: boolean; } + +/** Internal chat result; pathway trigger details are consumed by the route and never sent to students. */ +export interface SendUserMessageResult { + assistantMessage: ChatMessage; + pathwayTrigger: PathwayTriggerSnapshot | null; +} /** * Interface for initializing a new chat conversation */ @@ -219,17 +226,9 @@ export class ChatApp { * @returns Clean title string with first 10 words */ private generateChatTitleFromResponse(responseText: string): string { - //START DEBUG LOG : DEBUG-CODE(GENERATE-TITLE) - appLogger.log(`[CHAT-APP] 📝 Generating title from response: "${responseText.substring(0, 100)}..."`); - //END DEBUG LOG : DEBUG-CODE(GENERATE-TITLE) - try { const title = generateChatTitleFromResponse(responseText); - //START DEBUG LOG : DEBUG-CODE(GENERATE-TITLE-SUCCESS) - appLogger.log(`[CHAT-APP] ✅ Generated title: "${title}"`); - //END DEBUG LOG : DEBUG-CODE(GENERATE-TITLE-SUCCESS) - return title || 'New Chat'; // Fallback to "New Chat" if empty } catch (error) { //START DEBUG LOG : DEBUG-CODE(GENERATE-TITLE-ERROR) @@ -322,7 +321,7 @@ export class ChatApp { * @param courseName - The course name for RAG context * @param onChunk - Optional callback function for streaming chunks (defaults to no-op) * @param options - Optional flags (e.g. platform admin for `/DEBUG`) - * @returns Promise - The complete assistant's response message + * @returns The assistant response plus backend-only Guided Pathway trigger metadata */ public async sendUserMessage( message: string, @@ -331,7 +330,7 @@ export class ChatApp { courseName: string, onChunk: (chunk: string) => void, options?: SendUserMessageOptions - ): Promise { + ): Promise { // Reset the inactivity timer since user is actively using this chat this.resetChatTimer(chatId); @@ -363,12 +362,25 @@ export class ChatApp { if (!isAdmin) { throw new Error(DEBUG_MODE_FORBIDDEN); } - return this.toggleDebugMode(chatId, message, userId, courseName); + return { + assistantMessage: this.toggleDebugMode(chatId, message, userId, courseName), + pathwayTrigger: null, + }; } // Sticky debug turns: prompt-engineer path with full teaching system prompt if (isAdmin && this.debugModeByChat.get(chatId) === true) { - return this.sendDebugModeMessage(message, chatId, userId, courseName, onChunk, conversation); + return { + assistantMessage: await this.sendDebugModeMessage( + message, + chatId, + userId, + courseName, + onChunk, + conversation + ), + pathwayTrigger: null, + }; } // Non-admin must not keep a stale debug flag @@ -400,13 +412,16 @@ export class ChatApp { appLogger.log(`[CHAT-APP] Pathway: ${pathwayResult.winningPathwayId}`); appLogger.log(`########################################################`); - return this.addAssistantMessage( - chatId, - pathwayResult.responseText, - userId, - courseName, - pathwayResult.ctas - ); + return { + assistantMessage: this.addAssistantMessage( + chatId, + pathwayResult.responseText, + userId, + courseName, + pathwayResult.ctas + ), + pathwayTrigger: pathwayResult.triggerSnapshot, + }; } else { @@ -681,11 +696,9 @@ ${chunkDump} appLogger.log('[MOCK-RESPONSE] Using mock streaming response instead of LLM'); assistantResponse = await generateMockStreamingResponse(onChunk); appLogger.log(`\n✅ Mock streaming completed. Full response length: ${assistantResponse.length}`); - appLogger.log(`Full response: "${assistantResponse}"`); } else { await forkedConversation.stream( (chunk: string) => { - appLogger.log(`📦 Received chunk: "${chunk}"`); assistantResponse += chunk; onChunk(chunk); }, @@ -693,7 +706,6 @@ ${chunkDump} ); appLogger.log(`\n✅ Streaming completed. Full response length: ${assistantResponse.length}`); - appLogger.log(`Full response: "${assistantResponse}"`); } // ==================================================================== @@ -773,7 +785,7 @@ ${chunkDump} } } - return assistantMessage; + return { assistantMessage, pathwayTrigger: null }; } /** diff --git a/src/db/enge-ai-mongodb.ts b/src/db/enge-ai-mongodb.ts index d2800736..07124c4f 100644 --- a/src/db/enge-ai-mongodb.ts +++ b/src/db/enge-ai-mongodb.ts @@ -35,6 +35,7 @@ import * as CollectionRegistryMongo from './mongo/collection-registry-mongo'; import * as CourseMongo from './mongo/course-mongo'; import * as CourseUserMongo from './mongo/course-user-mongo'; import * as FlagMongo from './mongo/flag-mongo'; +import * as GuidedPathwayFlagMongo from './mongo/guided-pathway-flag-mongo'; import * as GlobalUserMongo from './mongo/global-user-mongo'; import * as InstructorPromptMongo from './mongo/instructor-prompt-mongo'; import * as SystemPromptConfigMongo from './mongo/system-prompt-config-mongo'; @@ -741,6 +742,47 @@ export class EngEAI_MongoDB { public getFlagReportsWithUserNames = async (courseName: string) => FlagMongo.getFlagReportsWithUserNames(this.ctx(), courseName); + /** + * ######################################################### + * Guided Pathway trigger alerts - guided-pathway-flag-mongo.ts + * ######################################################### + */ + /** Creates or deduplicates one global Guided Pathway trigger alert and returns its safe view. */ + public createGuidedPathwayFlag = async (input: GuidedPathwayFlagMongo.CreateGuidedPathwayFlagInput) => + GuidedPathwayFlagMongo.createGuidedPathwayFlag(this.ctx(), input); + + /** Lists a paginated, explicitly anonymous Guided Pathway alert queue. */ + public listGuidedPathwayFlags = async (filters: GuidedPathwayFlagMongo.GuidedPathwayFlagListFilters) => + GuidedPathwayFlagMongo.listGuidedPathwayFlags(this.ctx(), filters); + + /** Records an immutable course instructor Escalate or Dismiss decision. */ + public decideGuidedPathwayFlag = async ( + courseId: string, + flagId: string, + decision: import('../types/shared').GuidedPathwayFlagDecision, + actor: GuidedPathwayFlagMongo.GuidedPathwayFlagActor + ) => GuidedPathwayFlagMongo.decideGuidedPathwayFlag(this.ctx(), courseId, flagId, decision, actor); + + /** Marks one escalated alert reviewed by a platform administrator. */ + public markGuidedPathwayFlagAdminReviewed = async ( + flagId: string, + actor: GuidedPathwayFlagMongo.GuidedPathwayFlagActor + ) => GuidedPathwayFlagMongo.markGuidedPathwayFlagAdminReviewed(this.ctx(), flagId, actor); + + /** Audits an administrator reveal and returns only the current course-roster display name. */ + public revealGuidedPathwayFlagIdentity = async ( + flagId: string, + actor: GuidedPathwayFlagMongo.GuidedPathwayFlagActor + ) => GuidedPathwayFlagMongo.revealGuidedPathwayFlagIdentity(this.ctx(), flagId, actor); + + /** Counts escalated alerts that still require platform administrator review. */ + public countGuidedPathwayFlagsAwaitingAdminReview = async () => + GuidedPathwayFlagMongo.countGuidedPathwayFlagsAwaitingAdminReview(this.ctx()); + + /** Removes all global Guided Pathway alerts owned by one course lifecycle. */ + public deleteGuidedPathwayFlagsForCourse = async (courseId: string) => + GuidedPathwayFlagMongo.deleteGuidedPathwayFlagsForCourse(this.ctx(), courseId); + /** * ######################################################### * Scenario Questions (Practice Scenarios) — scenario-questions-mongo.ts diff --git a/src/db/mongo/__tests__/course-backup-mongo.test.ts b/src/db/mongo/__tests__/course-backup-mongo.test.ts index c030ce9a..55faaefd 100644 --- a/src/db/mongo/__tests__/course-backup-mongo.test.ts +++ b/src/db/mongo/__tests__/course-backup-mongo.test.ts @@ -15,15 +15,16 @@ jest.mock('../collection-registry-mongo', () => ({ }) })); -jest.mock('../mongo-collections', () => ({ - activeCourseListCollection: jest.fn(() => ({ - findOne: jest.fn().mockResolvedValue({ - id: 'course-id-1', - courseName: 'TestCourse', - _id: new ObjectId() - }) - })) -})); +jest.mock('../mongo-collections', () => ({ + activeCourseListCollection: jest.fn(() => ({ + findOne: jest.fn().mockResolvedValue({ + id: 'course-id-1', + courseName: 'TestCourse', + _id: new ObjectId() + }) + })), + guidedPathwayFlagsCollection: jest.fn((db) => db.collection('guided-pathway-flags')) +})); import { getCollectionNames } from '../collection-registry-mongo'; import { activeCourseListCollection } from '../mongo-collections'; @@ -33,17 +34,37 @@ describe('course-backup-mongo loadCourseMongoBackupPayloads', () => { const oid = new ObjectId(); const rows: Record = { TestCourse_users: [{ _id: oid, userId: 'student-1' }], - TestCourse_flags: [{ id: 'f1' }], - TestCourse_scheduled_tasks: [], - 'TestCourse_memory-agent': [{ userId: 'student-1', struggleTopics: ['a'] }] - }; - - const mockDb = { - collection: (name: string) => ({ - find: () => ({ - toArray: async () => rows[name] ?? [] - }) - }) + TestCourse_flags: [{ id: 'f1' }], + TestCourse_scheduled_tasks: [], + 'TestCourse_memory-agent': [{ userId: 'student-1', struggleTopics: ['a'] }], + 'guided-pathway-flags': [{ + id: 'gpf-1', + courseId: 'course-id-1', + courseName: 'TestCourse', + pathwayId: 'pathway-1', + pathwayTitle: 'Support', + messageText: 'I need help', + studentUserId: 'student-1', + dedupeKey: 'restricted', + status: 'pending', + triggeredAt: new Date('2026-08-08T12:00:00.000Z'), + identityRevealEvents: [] + }] + }; + + const mockDb = { + collection: (name: string) => ({ + find: (filter: { courseId?: string } = {}) => { + const matching = (rows[name] ?? []).filter((row: any) => + !filter.courseId || row.courseId === filter.courseId + ); + const cursor = { + sort: () => cursor, + toArray: async () => matching + }; + return cursor; + } + }) }; const ctx: MongoDalContext = { @@ -74,7 +95,18 @@ describe('course-backup-mongo loadCourseMongoBackupPayloads', () => { expect(JSON.parse(payloads.flagsJson)).toEqual([{ id: 'f1' }]); expect(JSON.parse(payloads.scheduledTasksJson)).toEqual([]); - const mem = EJSON.parse(payloads.memoryAgentJson, { relaxed: false }) as { userId: string }[]; - expect(mem[0].userId).toBe('student-1'); - }); -}); + const mem = EJSON.parse(payloads.memoryAgentJson, { relaxed: false }) as { userId: string }[]; + expect(mem[0].userId).toBe('student-1'); + + const pathwayFlags = JSON.parse(payloads.guidedPathwayFlagsJson) as Array>; + expect(pathwayFlags).toHaveLength(1); + expect(pathwayFlags[0]).toMatchObject({ + id: 'gpf-1', + messageText: 'I need help', + triggeredAt: '2026-08-08T12:00:00.000Z' + }); + expect(pathwayFlags[0]).not.toHaveProperty('studentUserId'); + expect(pathwayFlags[0]).not.toHaveProperty('dedupeKey'); + expect(pathwayFlags[0]).not.toHaveProperty('identityRevealEvents'); + }); +}); diff --git a/src/db/mongo/__tests__/guided-pathway-flag-mongo.test.ts b/src/db/mongo/__tests__/guided-pathway-flag-mongo.test.ts new file mode 100644 index 00000000..16d2b2a6 --- /dev/null +++ b/src/db/mongo/__tests__/guided-pathway-flag-mongo.test.ts @@ -0,0 +1,385 @@ +/** Focused persistence and privacy tests for the global Guided Pathway alert collection. */ + +import type { MongoDalContext } from '../mongo-context'; + +jest.mock('../mongo-collections', () => ({ + guidedPathwayFlagsCollection: jest.fn() +})); + +jest.mock('../course-user-mongo', () => ({ + getCourseUsersMongoCollection: jest.fn() +})); + +import { guidedPathwayFlagsCollection } from '../mongo-collections'; +import { getCourseUsersMongoCollection } from '../course-user-mongo'; +import { + countGuidedPathwayFlagsAwaitingAdminReview, + createGuidedPathwayFlag, + decideGuidedPathwayFlag, + deleteGuidedPathwayFlagsForCourse, + listGuidedPathwayFlags, + markGuidedPathwayFlagAdminReviewed, + revealGuidedPathwayFlagIdentity +} from '../guided-pathway-flag-mongo'; + +function context(): MongoDalContext { + return { + db: {} as MongoDalContext['db'], + idGenerator: {} as MongoDalContext['idGenerator'], + collectionNamesCache: new Map(), + scheduledTasksIndexesEnsured: new Set() + }; +} + +function rawFlag(overrides: Record = {}) { + const now = new Date('2026-08-08T12:00:00.000Z'); + return { + id: 'flag-1', + courseId: 'course-1', + courseName: 'Test Course', + pathwayId: 'pathway-1', + pathwayTitle: 'Support', + messageText: 'I need help', + studentUserId: 'student-1', + dedupeKey: 'restricted-dedupe', + status: 'pending', + adminSortPriority: 1, + triggeredAt: now, + identityRevealEvents: [], + createdAt: now, + updatedAt: now, + ...overrides + }; +} + +function cursorFor(rows: unknown[]) { + const cursor: any = { + sort: jest.fn(), + skip: jest.fn(), + limit: jest.fn(), + toArray: jest.fn().mockResolvedValue(rows) + }; + cursor.sort.mockReturnValue(cursor); + cursor.skip.mockReturnValue(cursor); + cursor.limit.mockReturnValue(cursor); + return cursor; +} + +function collection(overrides: Record = {}) { + return { + createIndex: jest.fn().mockResolvedValue('ok'), + insertOne: jest.fn().mockResolvedValue({ insertedId: 'mongo-id' }), + findOne: jest.fn().mockResolvedValue(null), + find: jest.fn().mockReturnValue(cursorFor([])), + countDocuments: jest.fn().mockResolvedValue(0), + findOneAndUpdate: jest.fn().mockResolvedValue(null), + deleteMany: jest.fn().mockResolvedValue({ deletedCount: 0 }), + ...overrides + } as any; +} + +const createInput = { + courseId: 'course-1', + courseName: 'Test Course', + pathwayId: 'pathway-1', + pathwayTitle: 'Support', + messageText: 'I need help', + studentUserId: 'student-1', + chatId: 'chat-1', + clientMessageId: 'client-message-1', + triggeredAt: new Date('2026-08-08T12:00:00.000Z') +}; + +describe('guided-pathway-flag-mongo', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('stores only an opaque message-bound dedupe key and returns an anonymous view', async () => { + const coll = collection(); + (guidedPathwayFlagsCollection as jest.Mock).mockReturnValue(coll); + const ctx = context(); + + const first = await createGuidedPathwayFlag(ctx, createInput); + await createGuidedPathwayFlag(ctx, { ...createInput, messageText: 'A different message' }); + + expect(first.created).toBe(true); + expect(first.flag).toMatchObject({ + courseId: 'course-1', + pathwayTitle: 'Support', + messageText: 'I need help', + status: 'pending' + }); + expect(first.flag).not.toHaveProperty('studentUserId'); + expect(first.flag).not.toHaveProperty('chatId'); + expect(first.flag).not.toHaveProperty('clientMessageId'); + + const firstDoc = coll.insertOne.mock.calls[0][0]; + const secondDoc = coll.insertOne.mock.calls[1][0]; + expect(firstDoc.dedupeKey).toMatch(/^[a-f0-9]{64}$/); + expect(firstDoc.dedupeKey).not.toBe(secondDoc.dedupeKey); + expect(firstDoc).not.toHaveProperty('chatId'); + expect(firstDoc).not.toHaveProperty('clientMessageId'); + }); + + it('returns the existing safe alert after an atomic duplicate-key collision', async () => { + const coll = collection({ + insertOne: jest.fn().mockRejectedValue({ code: 11000 }), + findOne: jest.fn().mockResolvedValue(rawFlag()) + }); + (guidedPathwayFlagsCollection as jest.Mock).mockReturnValue(coll); + + const result = await createGuidedPathwayFlag(context(), createInput); + + expect(result.created).toBe(false); + expect(result.flag.id).toBe('flag-1'); + expect(result.flag).not.toHaveProperty('studentUserId'); + expect(coll.findOne).toHaveBeenCalledWith( + expect.objectContaining({ dedupeKey: expect.any(String) }), + expect.objectContaining({ projection: expect.objectContaining({ id: 1, messageText: 1 }) }) + ); + }); + + it('double-enforces the safe allowlist when listing rows', async () => { + const cursor = cursorFor([rawFlag()]); + const coll = collection({ + find: jest.fn().mockReturnValue(cursor), + countDocuments: jest.fn().mockResolvedValue(1) + }); + (guidedPathwayFlagsCollection as jest.Mock).mockReturnValue(coll); + + const page = await listGuidedPathwayFlags(context(), { + courseId: 'course-1', + page: 1, + pageSize: 20 + }); + + expect(page.total).toBe(1); + expect(page.items[0]).not.toHaveProperty('studentUserId'); + expect(page.items[0]).not.toHaveProperty('dedupeKey'); + expect(page.items[0]).not.toHaveProperty('identityRevealEvents'); + expect(coll.find).toHaveBeenCalledWith( + { courseId: 'course-1' }, + expect.objectContaining({ projection: expect.objectContaining({ id: 1, messageText: 1 }) }) + ); + }); + + it('returns full-scope safe admin facets while excluding each facet own active filter', async () => { + const pageCursor = cursorFor([rawFlag({ pathwayId: 'pathway-1' })]); + const pathwayCursor = cursorFor([ + rawFlag({ pathwayId: 'pathway-1', pathwayTitle: 'Newest Support' }), + rawFlag({ pathwayId: 'pathway-1', pathwayTitle: 'Older Support' }), + rawFlag({ pathwayId: 'pathway-2', pathwayTitle: 'Academic Help' }) + ]); + const reviewerCursor = cursorFor([ + { decidedByName: 'Instructor B', messageText: 'must not be returned' }, + { decidedByName: 'Instructor A', adminReviewedByName: 'Admin C', studentUserId: 'restricted' }, + { adminReviewedByName: 'Admin C' } + ]); + const find = jest.fn() + .mockReturnValueOnce(pageCursor) + .mockReturnValueOnce(pathwayCursor) + .mockReturnValueOnce(reviewerCursor); + const coll = collection({ + find, + countDocuments: jest.fn().mockResolvedValue(1) + }); + (guidedPathwayFlagsCollection as jest.Mock).mockReturnValue(coll); + + const page = await listGuidedPathwayFlags(context(), { + courseId: 'course-1', + status: 'escalated', + pathwayId: 'pathway-1', + reviewer: 'Instructor A', + includeFacets: true, + escalatedFirst: true + }); + + expect(page.facets).toEqual({ + pathways: [ + { pathwayId: 'pathway-2', pathwayTitle: 'Academic Help' }, + { pathwayId: 'pathway-1', pathwayTitle: 'Newest Support' } + ], + reviewers: ['Admin C', 'Instructor A', 'Instructor B'] + }); + + const pageFilter = find.mock.calls[0][0]; + const pathwayFacetFilter = find.mock.calls[1][0]; + const reviewerFacetFilter = find.mock.calls[2][0]; + expect(pageFilter).toMatchObject({ + courseId: 'course-1', + status: 'escalated', + pathwayId: 'pathway-1', + $or: [{ decidedByName: 'Instructor A' }, { adminReviewedByName: 'Instructor A' }] + }); + expect(pathwayFacetFilter).not.toHaveProperty('pathwayId'); + expect(pathwayFacetFilter.$or).toBeDefined(); + expect(reviewerFacetFilter.pathwayId).toBe('pathway-1'); + expect(reviewerFacetFilter).not.toHaveProperty('$or'); + + const pathwayProjection = find.mock.calls[1][1].projection; + const reviewerProjection = find.mock.calls[2][1].projection; + expect(pathwayProjection).toEqual({ + _id: 0, + pathwayId: 1, + pathwayTitle: 1, + triggeredAt: 1 + }); + expect(reviewerProjection).toEqual({ + _id: 0, + decidedByName: 1, + adminReviewedByName: 1 + }); + expect(pathwayProjection).not.toHaveProperty('messageText'); + expect(reviewerProjection).not.toHaveProperty('studentUserId'); + }); + + it('atomically records an instructor escalation and returns a safe view', async () => { + const coll = collection({ + findOneAndUpdate: jest.fn().mockResolvedValue(rawFlag({ + status: 'escalated', + decidedAt: new Date('2026-08-08T12:05:00.000Z'), + decidedByName: 'Instructor' + })) + }); + (guidedPathwayFlagsCollection as jest.Mock).mockReturnValue(coll); + + const result = await decideGuidedPathwayFlag( + context(), + 'course-1', + 'flag-1', + 'escalate', + { userId: 'instructor-1', name: 'Instructor' } + ); + + expect(coll.findOneAndUpdate).toHaveBeenCalledWith( + { id: 'flag-1', courseId: 'course-1', status: 'pending' }, + expect.objectContaining({ + $set: expect.objectContaining({ + status: 'escalated', + decidedByUserId: 'instructor-1', + decidedByName: 'Instructor' + }) + }), + expect.any(Object) + ); + expect(result.status).toBe('escalated'); + expect(result).not.toHaveProperty('decidedByUserId'); + }); + + it('rejects a competing decision after another reviewer completed the pending transition', async () => { + const coll = collection({ + findOneAndUpdate: jest.fn().mockResolvedValue(null), + findOne: jest.fn().mockResolvedValue(rawFlag({ status: 'dismissed' })) + }); + (guidedPathwayFlagsCollection as jest.Mock).mockReturnValue(coll); + + await expect(decideGuidedPathwayFlag( + context(), + 'course-1', + 'flag-1', + 'escalate', + { userId: 'instructor-1', name: 'Instructor' } + )).rejects.toMatchObject({ + name: 'GuidedPathwayFlagConflictError' + }); + expect(coll.findOneAndUpdate.mock.calls[0][0]).toEqual({ + id: 'flag-1', + courseId: 'course-1', + status: 'pending' + }); + }); + + it('records platform review once using an atomic escalated-only filter', async () => { + const coll = collection({ + findOneAndUpdate: jest.fn().mockResolvedValue(rawFlag({ + status: 'escalated', + adminReviewedAt: new Date('2026-08-08T12:10:00.000Z'), + adminReviewedByName: 'Admin' + })) + }); + (guidedPathwayFlagsCollection as jest.Mock).mockReturnValue(coll); + + const result = await markGuidedPathwayFlagAdminReviewed( + context(), + 'flag-1', + { userId: 'admin-1', name: 'Admin' } + ); + + expect(coll.findOneAndUpdate.mock.calls[0][0]).toEqual({ + id: 'flag-1', + status: 'escalated', + adminReviewedAt: { $exists: false } + }); + expect(result.adminReviewedByName).toBe('Admin'); + expect(result).not.toHaveProperty('adminReviewedByUserId'); + }); + + it('appends the reveal audit before returning only the current roster display name', async () => { + const coll = collection({ + findOneAndUpdate: jest.fn().mockResolvedValue({ + courseName: 'Test Course', + studentUserId: 'student-1' + }) + }); + const roster = { + findOne: jest.fn().mockResolvedValue({ name: 'Current Roster Name' }) + }; + (guidedPathwayFlagsCollection as jest.Mock).mockReturnValue(coll); + (getCourseUsersMongoCollection as jest.Mock).mockResolvedValue(roster); + + const result = await revealGuidedPathwayFlagIdentity( + context(), + 'flag-1', + { userId: 'admin-1', name: 'Admin' } + ); + + expect(result).toEqual({ studentName: 'Current Roster Name' }); + expect(coll.findOneAndUpdate.mock.calls[0][1]).toEqual(expect.objectContaining({ + $push: { + identityRevealEvents: expect.objectContaining({ adminUserId: 'admin-1' }) + } + })); + expect(coll.findOneAndUpdate.mock.invocationCallOrder[0]).toBeLessThan( + roster.findOne.mock.invocationCallOrder[0] + ); + expect(roster.findOne).toHaveBeenCalledWith( + { userId: 'student-1' }, + { projection: { _id: 0, name: 1 } } + ); + }); + + it('fails closed without reading the roster when the reveal audit write fails', async () => { + const coll = collection({ + findOneAndUpdate: jest.fn().mockRejectedValue(new Error('audit write failed')) + }); + const roster = { findOne: jest.fn() }; + (guidedPathwayFlagsCollection as jest.Mock).mockReturnValue(coll); + (getCourseUsersMongoCollection as jest.Mock).mockResolvedValue(roster); + + await expect(revealGuidedPathwayFlagIdentity( + context(), + 'flag-1', + { userId: 'admin-1', name: 'Admin' } + )).rejects.toThrow('audit write failed'); + expect(getCourseUsersMongoCollection).not.toHaveBeenCalled(); + expect(roster.findOne).not.toHaveBeenCalled(); + }); + + it('counts awaiting admin reviews and cleans global rows by course id', async () => { + const coll = collection({ + countDocuments: jest.fn().mockResolvedValue(3), + deleteMany: jest.fn().mockResolvedValue({ deletedCount: 2 }) + }); + (guidedPathwayFlagsCollection as jest.Mock).mockReturnValue(coll); + const ctx = context(); + + await expect(countGuidedPathwayFlagsAwaitingAdminReview(ctx)).resolves.toBe(3); + await expect(deleteGuidedPathwayFlagsForCourse(ctx, 'course-1')).resolves.toBe(2); + expect(coll.countDocuments).toHaveBeenCalledWith({ + status: 'escalated', + adminReviewedAt: { $exists: false } + }); + expect(coll.deleteMany).toHaveBeenCalledWith({ courseId: 'course-1' }); + }); +}); diff --git a/src/db/mongo/__tests__/mongo-collections.test.ts b/src/db/mongo/__tests__/mongo-collections.test.ts index 9af398d4..3184512d 100644 --- a/src/db/mongo/__tests__/mongo-collections.test.ts +++ b/src/db/mongo/__tests__/mongo-collections.test.ts @@ -1,5 +1,13 @@ -import { ACTIVE_COURSE_LIST_COLLECTION, ACTIVE_USERS_COLLECTION } from '../mongo-constants'; -import { activeCourseListCollection, activeUsersMongoCollection } from '../mongo-collections'; +import { + ACTIVE_COURSE_LIST_COLLECTION, + ACTIVE_USERS_COLLECTION, + GUIDED_PATHWAY_FLAGS_COLLECTION +} from '../mongo-constants'; +import { + activeCourseListCollection, + activeUsersMongoCollection, + guidedPathwayFlagsCollection +} from '../mongo-collections'; describe('mongo collections helpers', () => { function mockDb() { @@ -17,7 +25,13 @@ describe('mongo collections helpers', () => { expect(activeCourseListCollection(mockDb()).collectionName).toBe(ACTIVE_COURSE_LIST_COLLECTION); }); - it('activeUsersMongoCollection uses canonical name', () => { - expect(activeUsersMongoCollection(mockDb()).collectionName).toBe(ACTIVE_USERS_COLLECTION); - }); -}); + it('activeUsersMongoCollection uses canonical name', () => { + expect(activeUsersMongoCollection(mockDb()).collectionName).toBe(ACTIVE_USERS_COLLECTION); + }); + + it('guidedPathwayFlagsCollection uses the single global canonical name', () => { + expect(guidedPathwayFlagsCollection(mockDb()).collectionName).toBe( + GUIDED_PATHWAY_FLAGS_COLLECTION + ); + }); +}); diff --git a/src/db/mongo/__tests__/pathways-mongo.test.ts b/src/db/mongo/__tests__/pathways-mongo.test.ts index d1810893..f82878f0 100644 --- a/src/db/mongo/__tests__/pathways-mongo.test.ts +++ b/src/db/mongo/__tests__/pathways-mongo.test.ts @@ -105,6 +105,7 @@ describe('pathways-mongo', () => { expect(n2).toBe(0); expect(store).toHaveLength(3); expect(store[0].title).toBe('Mental health crisis'); + expect(store.every((pathway) => pathway.notifyInstructorOnTrigger === true)).toBe(true); expect(store[0].triggerDescription).toMatch(/^Detects if/); }); @@ -139,6 +140,7 @@ describe('pathways-mongo', () => { expect(list.map((p) => p.id)).toEqual(['mental-health-crisis', 'custom-row']); expect(list[0].title).toBe('Mental health crisis'); expect(list[1].title).toBe('Untitled'); + expect(list.every((pathway) => pathway.notifyInstructorOnTrigger === true)).toBe(true); }); it('listPathways maps legacy enabledGlobally to enabled', async () => { @@ -165,15 +167,18 @@ describe('pathways-mongo', () => { expect(created.id).toMatch(/^pathway-/); expect(created.order).toBe(0); expect(created.title).toBe('Untitled'); + expect(created.notifyInstructorOnTrigger).toBe(true); expect(created.ctas[0].color).toBe('#4d7a2f'); const updated = await updatePathway(ctx, 'Test', created.id, { title: 'Spill response', assistantResponse: 'Updated', enabled: false, + notifyInstructorOnTrigger: false, }); expect(updated?.assistantResponse).toBe('Updated'); expect(updated?.enabled).toBe(false); + expect(updated?.notifyInstructorOnTrigger).toBe(false); expect(updated?.title).toBe('Spill response'); const deleted = await deletePathway(ctx, 'Test', created.id); diff --git a/src/db/mongo/course-backup-mongo.ts b/src/db/mongo/course-backup-mongo.ts index 0ede47c1..ed9dd8da 100644 --- a/src/db/mongo/course-backup-mongo.ts +++ b/src/db/mongo/course-backup-mongo.ts @@ -1,14 +1,15 @@ // course-backup-mongo.ts /** * course-backup-mongo.ts - * @description Reads catalog + four per-course collections for instructor ZIP backup (BSON EJSON). + * @description Reads catalog, four per-course collections, and anonymous Guided Pathway alerts for ZIP backup. */ import { EJSON } from 'bson'; import type { activeCourse } from '../../types/shared'; import { activeCourseListCollection } from './mongo-collections'; import type { MongoDalContext } from './mongo-context'; -import { getCollectionNames } from './collection-registry-mongo'; +import { getCollectionNames } from './collection-registry-mongo'; +import { listGuidedPathwayFlagsForBackup } from './guided-pathway-flag-mongo'; function ejsonPretty(value: unknown): string { return EJSON.stringify(value, undefined, 2, { relaxed: false }); @@ -20,7 +21,9 @@ export type CourseMongoBackupPayloads = { usersJson: string; flagsJson: string; scheduledTasksJson: string; - memoryAgentJson: string; + memoryAgentJson: string; + /** Anonymous safe projection; restricted identity and reveal-audit fields are excluded. */ + guidedPathwayFlagsJson: string; }; /** @@ -42,11 +45,12 @@ export async function loadCourseMongoBackupPayloads( const catalogDoc = await activeCourseListCollection(ctx.db).findOne({ id: course.id }); - const [users, flags, scheduledTasks, memoryAgent] = await Promise.all([ - ctx.db.collection(names.users).find({}).toArray(), - ctx.db.collection(names.flags).find({}).toArray(), - ctx.db.collection(names.scheduledTasks).find({}).toArray(), - ctx.db.collection(names.memoryAgent).find({}).toArray() + const [users, flags, scheduledTasks, memoryAgent, guidedPathwayFlags] = await Promise.all([ + ctx.db.collection(names.users).find({}).toArray(), + ctx.db.collection(names.flags).find({}).toArray(), + ctx.db.collection(names.scheduledTasks).find({}).toArray(), + ctx.db.collection(names.memoryAgent).find({}).toArray(), + listGuidedPathwayFlagsForBackup(ctx, course.id) ]); return { @@ -54,6 +58,7 @@ export async function loadCourseMongoBackupPayloads( usersJson: ejsonPretty(users), flagsJson: ejsonPretty(flags), scheduledTasksJson: ejsonPretty(scheduledTasks), - memoryAgentJson: ejsonPretty(memoryAgent) - }; -} + memoryAgentJson: ejsonPretty(memoryAgent), + guidedPathwayFlagsJson: ejsonPretty(guidedPathwayFlags) + }; +} diff --git a/src/db/mongo/guided-pathway-flag-mongo.ts b/src/db/mongo/guided-pathway-flag-mongo.ts new file mode 100644 index 00000000..75c449ef --- /dev/null +++ b/src/db/mongo/guided-pathway-flag-mongo.ts @@ -0,0 +1,622 @@ +/** + * Guided Pathway flag Mongo delegate + * + * Owns the single global `guided-pathway-flags` collection, including atomic + * trigger deduplication, instructor decisions, platform review, and audited + * identity reveal. Public reads always use an allowlisted anonymous projection. + * + * @author: EngE-AI Team + * @date: 2026-08-08 + * @version: 1.0.0 + * @description: Privacy-bounded persistence for Guided Pathway trigger alerts. + */ + +import { createHash, randomUUID } from 'crypto'; +import type { Collection, Filter } from 'mongodb'; +import type { + GuidedPathwayFlagDecision, + GuidedPathwayFlagFacets, + GuidedPathwayFlagListPage, + GuidedPathwayFlagReviewState, + GuidedPathwayFlagStatus, + GuidedPathwayFlagView +} from '../../types/shared'; +import { getCourseUsersMongoCollection } from './course-user-mongo'; +import { guidedPathwayFlagsCollection } from './mongo-collections'; +import type { MongoDalContext } from './mongo-context'; + +/** Server-owned actor snapshot used for decisions, review, and reveal audit. */ +export interface GuidedPathwayFlagActor { + userId: string; + name: string; +} + +/** Input from the chat trigger path. Chat/request identifiers are hashed, never stored verbatim. */ +export interface CreateGuidedPathwayFlagInput { + courseId: string; + courseName: string; + pathwayId: string; + pathwayTitle: string; + messageText: string; + studentUserId: string; + chatId: string; + clientMessageId: string; + triggeredAt?: Date; +} + +/** Filters shared by course and global administrator queues. */ +export interface GuidedPathwayFlagListFilters { + page?: number; + pageSize?: number; + status?: GuidedPathwayFlagStatus; + reviewState?: GuidedPathwayFlagReviewState; + courseId?: string; + courseIds?: string[]; + pathwayId?: string; + reviewer?: string; + dateFrom?: Date; + dateTo?: Date; + escalatedFirst?: boolean; + includeFacets?: boolean; +} + +/** Result of an idempotent trigger insert. */ +export interface CreateGuidedPathwayFlagResult { + created: boolean; + flag: GuidedPathwayFlagView; +} + +interface GuidedPathwayIdentityRevealEvent { + adminUserId: string; + revealedAt: Date; +} + +interface GuidedPathwayFlagDocument { + id: string; + courseId: string; + courseName: string; + pathwayId: string; + pathwayTitle: string; + messageText: string; + studentUserId: string; + dedupeKey: string; + status: GuidedPathwayFlagStatus; + adminSortPriority: number; + triggeredAt: Date; + decidedAt?: Date; + decidedByUserId?: string; + decidedByName?: string; + adminReviewedAt?: Date; + adminReviewedByUserId?: string; + adminReviewedByName?: string; + identityRevealEvents: GuidedPathwayIdentityRevealEvent[]; + createdAt: Date; + updatedAt: Date; +} + +/** Raised when an alert id is absent from the required scope. */ +export class GuidedPathwayFlagNotFoundError extends Error { + constructor(message = 'Guided Pathway alert not found') { + super(message); + this.name = 'GuidedPathwayFlagNotFoundError'; + } +} + +/** Raised when an action conflicts with the alert's completed lifecycle state. */ +export class GuidedPathwayFlagConflictError extends Error { + constructor(message: string) { + super(message); + this.name = 'GuidedPathwayFlagConflictError'; + } +} + +/** Raised after a successful reveal audit when the current roster name no longer exists. */ +export class GuidedPathwayFlagIdentityUnavailableError extends Error { + constructor() { + super('Student identity is unavailable in the current course roster'); + this.name = 'GuidedPathwayFlagIdentityUnavailableError'; + } +} + +const DEFAULT_PAGE_SIZE = 50; +const MAX_PAGE_SIZE = 200; + +/** Inclusion-only projection used by every queue and backup read. */ +const SAFE_FLAG_PROJECTION = { + _id: 0, + id: 1, + courseId: 1, + courseName: 1, + pathwayId: 1, + pathwayTitle: 1, + messageText: 1, + status: 1, + triggeredAt: 1, + decidedAt: 1, + decidedByName: 1, + adminReviewedAt: 1, + adminReviewedByName: 1 +} as const; + +const indexPromises = new WeakMap>(); + +function flags(ctx: MongoDalContext): Collection { + return guidedPathwayFlagsCollection(ctx.db) as unknown as Collection; +} + +function asIso(value: Date | string): string { + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); +} + +function toSafeView(doc: Partial): GuidedPathwayFlagView { + const view: GuidedPathwayFlagView = { + id: String(doc.id), + courseId: String(doc.courseId), + courseName: String(doc.courseName), + pathwayId: String(doc.pathwayId), + pathwayTitle: String(doc.pathwayTitle), + messageText: String(doc.messageText), + status: doc.status as GuidedPathwayFlagStatus, + triggeredAt: asIso(doc.triggeredAt as Date) + }; + if (doc.decidedAt) view.decidedAt = asIso(doc.decidedAt); + if (doc.decidedByName) view.decidedByName = doc.decidedByName; + if (doc.adminReviewedAt) view.adminReviewedAt = asIso(doc.adminReviewedAt); + if (doc.adminReviewedByName) view.adminReviewedByName = doc.adminReviewedByName; + return view; +} + +function dedupeKeyFor(input: CreateGuidedPathwayFlagInput): string { + return createHash('sha256') + .update(JSON.stringify([ + input.courseId, + input.studentUserId, + input.chatId, + input.clientMessageId, + input.messageText + ])) + .digest('hex'); +} + +function isDuplicateKeyError(error: unknown): boolean { + return Boolean(error && typeof error === 'object' && (error as { code?: number }).code === 11000); +} + +function statusPriority(status: GuidedPathwayFlagStatus): number { + if (status === 'escalated') return 0; + if (status === 'pending') return 1; + return 2; +} + +async function findSafeFlag( + ctx: MongoDalContext, + filter: Filter +): Promise { + const doc = await flags(ctx).findOne(filter, { projection: SAFE_FLAG_PROJECTION }); + return doc ? toSafeView(doc) : null; +} + +/** + * ensureGuidedPathwayFlagIndexes - Installs global dedupe, queue, and review indexes. + * + * A per-database shared promise prevents concurrent first-use callers from racing + * index installation. Failed attempts are removed so a later call can retry. + * + * @param ctx - Connected Mongo data-layer context + * @returns When all collection indexes are available + */ +export async function ensureGuidedPathwayFlagIndexes(ctx: MongoDalContext): Promise { + const key = ctx.db as object; + let pending = indexPromises.get(key); + if (!pending) { + const collection = flags(ctx); + pending = Promise.all([ + collection.createIndex({ id: 1 }, { unique: true, name: 'guided_pathway_flag_id_unique' }), + collection.createIndex({ dedupeKey: 1 }, { unique: true, name: 'guided_pathway_flag_dedupe_unique' }), + collection.createIndex( + { courseId: 1, status: 1, triggeredAt: -1 }, + { name: 'guided_pathway_flag_course_status_time' } + ), + collection.createIndex( + { status: 1, adminReviewedAt: 1, adminSortPriority: 1, triggeredAt: -1 }, + { name: 'guided_pathway_flag_admin_review_queue' } + ), + collection.createIndex( + { courseId: 1, pathwayId: 1, status: 1, triggeredAt: -1 }, + { name: 'guided_pathway_flag_course_pathway_status_time' } + ), + collection.createIndex( + { adminSortPriority: 1, triggeredAt: -1 }, + { name: 'guided_pathway_flag_admin_order' } + ) + ]).then(() => undefined); + indexPromises.set(key, pending); + } + + try { + await pending; + } catch (error) { + indexPromises.delete(key); + throw error; + } +} + +/** + * createGuidedPathwayFlag - Atomically creates one alert per processed client message. + * + * The opaque unique dedupe key includes course, student, chat, and client message + * identity. A duplicate insert returns the already stored anonymous alert. + * + * @param ctx - Connected Mongo data-layer context + * @param input - Trigger context from the chat pipeline + * @returns Whether this call inserted the alert and its safe anonymous view + */ +export async function createGuidedPathwayFlag( + ctx: MongoDalContext, + input: CreateGuidedPathwayFlagInput +): Promise { + await ensureGuidedPathwayFlagIndexes(ctx); + if (!input.clientMessageId || !input.chatId) { + throw new Error('chatId and clientMessageId are required for Guided Pathway alert deduplication'); + } + + const now = input.triggeredAt ?? new Date(); + const doc: GuidedPathwayFlagDocument = { + id: randomUUID(), + courseId: input.courseId, + courseName: input.courseName, + pathwayId: input.pathwayId, + pathwayTitle: input.pathwayTitle, + messageText: input.messageText, + studentUserId: input.studentUserId, + dedupeKey: dedupeKeyFor(input), + status: 'pending', + adminSortPriority: statusPriority('pending'), + triggeredAt: now, + identityRevealEvents: [], + createdAt: now, + updatedAt: now + }; + + try { + await flags(ctx).insertOne(doc); + return { created: true, flag: toSafeView(doc) }; + } catch (error) { + if (!isDuplicateKeyError(error)) throw error; + const existing = await findSafeFlag(ctx, { dedupeKey: doc.dedupeKey }); + if (!existing) throw error; + return { created: false, flag: existing }; + } +} + +function buildListFilter( + filters: GuidedPathwayFlagListFilters, + omitOwnFacet?: 'pathwayId' | 'reviewer' +): Filter { + const query: Filter = {}; + + if (filters.courseId) { + if (filters.courseIds && !filters.courseIds.includes(filters.courseId)) { + query.courseId = { $in: [] }; + } else { + query.courseId = filters.courseId; + } + } else if (filters.courseIds) { + query.courseId = { $in: filters.courseIds }; + } + + if (filters.status) query.status = filters.status; + if (filters.pathwayId && omitOwnFacet !== 'pathwayId') query.pathwayId = filters.pathwayId; + + if (filters.reviewState === 'needs-review') { + query.status = filters.status && filters.status !== 'escalated' + ? { $in: [] } + : 'escalated'; + query.adminReviewedAt = { $exists: false }; + } else if (filters.reviewState === 'reviewed') { + query.status = filters.status && filters.status !== 'escalated' + ? { $in: [] } + : 'escalated'; + query.adminReviewedAt = { $exists: true }; + } + + if (filters.reviewer && omitOwnFacet !== 'reviewer') { + query.$or = [ + { decidedByName: filters.reviewer }, + { adminReviewedByName: filters.reviewer } + ]; + } + + if (filters.dateFrom || filters.dateTo) { + query.triggeredAt = {}; + if (filters.dateFrom) query.triggeredAt.$gte = filters.dateFrom; + if (filters.dateTo) query.triggeredAt.$lte = filters.dateTo; + } + + return query; +} + +async function loadSafeFacets( + ctx: MongoDalContext, + filters: GuidedPathwayFlagListFilters +): Promise { + const collection = flags(ctx); + const pathwayFilter = buildListFilter(filters, 'pathwayId'); + const reviewerFilter = buildListFilter(filters, 'reviewer'); + + // Fetch only the non-student fields needed to build full-queue filter choices. + const pathwayCursor = collection.find(pathwayFilter, { + projection: { _id: 0, pathwayId: 1, pathwayTitle: 1, triggeredAt: 1 } + }); + pathwayCursor.sort({ triggeredAt: -1 }); + const reviewerCursor = collection.find(reviewerFilter, { + projection: { _id: 0, decidedByName: 1, adminReviewedByName: 1 } + }); + const [pathwayDocs, reviewerDocs] = await Promise.all([ + pathwayCursor.toArray(), + reviewerCursor.toArray() + ]); + + // Keep the newest title snapshot for each stable pathway id. + const pathwayById = new Map(); + for (const doc of pathwayDocs) { + if ( + typeof doc.pathwayId === 'string' && doc.pathwayId && + typeof doc.pathwayTitle === 'string' && doc.pathwayTitle && + !pathwayById.has(doc.pathwayId) + ) { + pathwayById.set(doc.pathwayId, doc.pathwayTitle); + } + } + + const reviewers = new Set(); + for (const doc of reviewerDocs) { + if (typeof doc.decidedByName === 'string' && doc.decidedByName.trim()) { + reviewers.add(doc.decidedByName); + } + if (typeof doc.adminReviewedByName === 'string' && doc.adminReviewedByName.trim()) { + reviewers.add(doc.adminReviewedByName); + } + } + + return { + pathways: [...pathwayById.entries()] + .map(([pathwayId, pathwayTitle]) => ({ pathwayId, pathwayTitle })) + .sort((a, b) => a.pathwayTitle.localeCompare(b.pathwayTitle) || a.pathwayId.localeCompare(b.pathwayId)), + reviewers: [...reviewers].sort((a, b) => a.localeCompare(b)) + }; +} + +/** + * listGuidedPathwayFlags - Returns one paginated anonymous queue page. + * + * The Mongo projection is inclusion-only and the mapper repeats the allowlist, + * preventing identity fields from leaking if the stored schema grows later. + * + * @param ctx - Connected Mongo data-layer context + * @param filters - Course/admin filters and pagination + * @returns Safe page with total matching count + */ +export async function listGuidedPathwayFlags( + ctx: MongoDalContext, + filters: GuidedPathwayFlagListFilters +): Promise { + await ensureGuidedPathwayFlagIndexes(ctx); + const page = Math.max(1, Math.floor(filters.page ?? 1)); + const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Math.floor(filters.pageSize ?? DEFAULT_PAGE_SIZE))); + const query = buildListFilter(filters); + const collection = flags(ctx); + const cursor = collection.find(query, { projection: SAFE_FLAG_PROJECTION }); + if (filters.escalatedFirst) { + cursor.sort({ adminSortPriority: 1, triggeredAt: -1 }); + } else { + cursor.sort({ triggeredAt: -1 }); + } + + const [docs, total, facets] = await Promise.all([ + cursor + .skip((page - 1) * pageSize) + .limit(pageSize) + .toArray(), + collection.countDocuments(query), + filters.includeFacets ? loadSafeFacets(ctx, filters) : Promise.resolve(undefined) + ]); + + const result: GuidedPathwayFlagListPage = { + items: docs.map((doc) => toSafeView(doc)), + page, + pageSize, + total + }; + if (facets) result.facets = facets; + return result; +} + +/** + * decideGuidedPathwayFlag - Records an immutable instructor Escalate or Dismiss decision. + * + * Only a pending row can transition. Repeating the same completed decision is + * idempotent; attempting the opposite decision returns a lifecycle conflict. + * + * @param ctx - Connected Mongo data-layer context + * @param courseId - Required course ownership boundary + * @param flagId - Alert being reviewed + * @param decision - Instructor action + * @param actor - Server-owned staff identity snapshot + * @returns Updated safe anonymous alert + */ +export async function decideGuidedPathwayFlag( + ctx: MongoDalContext, + courseId: string, + flagId: string, + decision: GuidedPathwayFlagDecision, + actor: GuidedPathwayFlagActor +): Promise { + await ensureGuidedPathwayFlagIndexes(ctx); + const nextStatus: GuidedPathwayFlagStatus = decision === 'escalate' ? 'escalated' : 'dismissed'; + const now = new Date(); + const updated = await flags(ctx).findOneAndUpdate( + { id: flagId, courseId, status: 'pending' }, + { + $set: { + status: nextStatus, + adminSortPriority: statusPriority(nextStatus), + decidedAt: now, + decidedByUserId: actor.userId, + decidedByName: actor.name, + updatedAt: now + } + }, + { returnDocument: 'after', projection: SAFE_FLAG_PROJECTION } + ); + if (updated) return toSafeView(updated); + + const existing = await findSafeFlag(ctx, { id: flagId, courseId }); + if (!existing) throw new GuidedPathwayFlagNotFoundError(); + if (existing.status === nextStatus) return existing; + throw new GuidedPathwayFlagConflictError('Guided Pathway alert already has a different decision'); +} + +/** + * markGuidedPathwayFlagAdminReviewed - Marks an escalated alert reviewed once. + * + * Repeated review calls return the original completed record without replacing + * its first-review actor or timestamp. + * + * @param ctx - Connected Mongo data-layer context + * @param flagId - Escalated alert id + * @param actor - Server-owned platform administrator snapshot + * @returns Updated safe anonymous alert + */ +export async function markGuidedPathwayFlagAdminReviewed( + ctx: MongoDalContext, + flagId: string, + actor: GuidedPathwayFlagActor +): Promise { + await ensureGuidedPathwayFlagIndexes(ctx); + const now = new Date(); + const updated = await flags(ctx).findOneAndUpdate( + { id: flagId, status: 'escalated', adminReviewedAt: { $exists: false } }, + { + $set: { + adminReviewedAt: now, + adminReviewedByUserId: actor.userId, + adminReviewedByName: actor.name, + updatedAt: now + } + }, + { returnDocument: 'after', projection: SAFE_FLAG_PROJECTION } + ); + if (updated) return toSafeView(updated); + + const existing = await findSafeFlag(ctx, { id: flagId }); + if (!existing) throw new GuidedPathwayFlagNotFoundError(); + if (existing.status !== 'escalated') { + throw new GuidedPathwayFlagConflictError('Only escalated alerts can be marked reviewed'); + } + if (existing.adminReviewedAt) return existing; + throw new GuidedPathwayFlagConflictError('Guided Pathway alert could not be marked reviewed'); +} + +/** + * revealGuidedPathwayFlagIdentity - Audits and returns the current course-roster name. + * + * The audit append must succeed before the roster is read. The method returns + * only a display name and never exposes the stored student user id or a PUID. + * + * @param ctx - Connected Mongo data-layer context + * @param flagId - Escalated alert whose author is being revealed + * @param actor - Platform administrator performing the reveal + * @returns Current course-roster display name + */ +export async function revealGuidedPathwayFlagIdentity( + ctx: MongoDalContext, + flagId: string, + actor: GuidedPathwayFlagActor +): Promise<{ studentName: string }> { + await ensureGuidedPathwayFlagIndexes(ctx); + const revealedAt = new Date(); + const audited = await flags(ctx).findOneAndUpdate( + { id: flagId, status: 'escalated' }, + { + $push: { + identityRevealEvents: { + adminUserId: actor.userId, + revealedAt + } + }, + $set: { updatedAt: revealedAt } + }, + { + returnDocument: 'after', + projection: { _id: 0, courseName: 1, studentUserId: 1 } + } + ) as Pick | null; + + if (!audited) { + const existing = await flags(ctx).findOne( + { id: flagId }, + { projection: { _id: 0, status: 1 } } + ); + if (!existing) throw new GuidedPathwayFlagNotFoundError(); + throw new GuidedPathwayFlagConflictError('Identity can be revealed only for escalated alerts'); + } + + // Read only the current display name from the course roster after the audit succeeds. + const roster = await getCourseUsersMongoCollection(ctx, audited.courseName); + const student = await roster.findOne( + { userId: audited.studentUserId }, + { projection: { _id: 0, name: 1 } } + ); + if (!student || typeof student.name !== 'string' || !student.name.trim()) { + throw new GuidedPathwayFlagIdentityUnavailableError(); + } + return { studentName: student.name }; +} + +/** + * countGuidedPathwayFlagsAwaitingAdminReview - Counts escalations without platform review. + * + * @param ctx - Connected Mongo data-layer context + * @returns Persistent administrator dashboard count + */ +export async function countGuidedPathwayFlagsAwaitingAdminReview(ctx: MongoDalContext): Promise { + await ensureGuidedPathwayFlagIndexes(ctx); + return flags(ctx).countDocuments({ status: 'escalated', adminReviewedAt: { $exists: false } }); +} + +/** + * listGuidedPathwayFlagsForBackup - Loads an anonymous course-scoped backup slice. + * + * Restricted identity, opaque dedupe material, request identifiers, and reveal + * audit events are excluded by the same allowlist used for interface reads. + * + * @param ctx - Connected Mongo data-layer context + * @param courseId - Course whose alerts are being exported + * @returns Safe alert snapshots ordered newest first + */ +export async function listGuidedPathwayFlagsForBackup( + ctx: MongoDalContext, + courseId: string +): Promise { + const docs = await flags(ctx) + .find({ courseId }, { projection: SAFE_FLAG_PROJECTION }) + .sort({ triggeredAt: -1 }) + .toArray(); + return docs.map((doc) => toSafeView(doc)); +} + +/** + * deleteGuidedPathwayFlagsForCourse - Removes global alert rows for a deleted/reset course. + * + * @param ctx - Connected Mongo data-layer context + * @param courseId - Course lifecycle boundary + * @returns Number of global alert rows removed + */ +export async function deleteGuidedPathwayFlagsForCourse( + ctx: MongoDalContext, + courseId: string +): Promise { + const result = await flags(ctx).deleteMany({ courseId }); + return result.deletedCount; +} diff --git a/src/db/mongo/mongo-collections.ts b/src/db/mongo/mongo-collections.ts index f1602f70..ff7849ea 100644 --- a/src/db/mongo/mongo-collections.ts +++ b/src/db/mongo/mongo-collections.ts @@ -9,11 +9,12 @@ import type { Db, Collection } from 'mongodb'; import { - ACADEMIC_PERIODS_COLLECTION, - ACTIVE_COURSE_LIST_COLLECTION, - ACTIVE_USERS_COLLECTION, - INSTRUCTOR_PERIOD_ALLOWANCES_COLLECTION -} from './mongo-constants'; + ACADEMIC_PERIODS_COLLECTION, + ACTIVE_COURSE_LIST_COLLECTION, + ACTIVE_USERS_COLLECTION, + GUIDED_PATHWAY_FLAGS_COLLECTION, + INSTRUCTOR_PERIOD_ALLOWANCES_COLLECTION +} from './mongo-constants'; /** * activeCourseListCollection @@ -55,6 +56,19 @@ export function academicPeriodsCollection(db: Db): Collection { * * @returns `Collection` — `instructor-period-allowances` */ -export function instructorPeriodAllowancesCollection(db: Db): Collection { - return db.collection(INSTRUCTOR_PERIOD_ALLOWANCES_COLLECTION); -} +export function instructorPeriodAllowancesCollection(db: Db): Collection { + return db.collection(INSTRUCTOR_PERIOD_ALLOWANCES_COLLECTION); +} + +/** + * guidedPathwayFlagsCollection + * + * Returns the global alert collection. Every query must still apply an explicit + * course or administrator scope and must project identity fields deliberately. + * + * @param db - Connected Mongo database handle + * @returns `Collection` - `guided-pathway-flags` + */ +export function guidedPathwayFlagsCollection(db: Db): Collection { + return db.collection(GUIDED_PATHWAY_FLAGS_COLLECTION); +} diff --git a/src/db/mongo/mongo-constants.ts b/src/db/mongo/mongo-constants.ts index 56479399..f1ab9acb 100644 --- a/src/db/mongo/mongo-constants.ts +++ b/src/db/mongo/mongo-constants.ts @@ -17,4 +17,7 @@ export const ACTIVE_USERS_COLLECTION = 'active-users'; export const ACADEMIC_PERIODS_COLLECTION = 'academic-periods'; /** MongoDB collection name for period-scoped instructor course allow-lists. */ -export const INSTRUCTOR_PERIOD_ALLOWANCES_COLLECTION = 'instructor-period-allowances'; +export const INSTRUCTOR_PERIOD_ALLOWANCES_COLLECTION = 'instructor-period-allowances'; + +/** MongoDB collection name for anonymous Guided Pathway trigger alerts across all courses. */ +export const GUIDED_PATHWAY_FLAGS_COLLECTION = 'guided-pathway-flags'; diff --git a/src/db/mongo/pathways-mongo.ts b/src/db/mongo/pathways-mongo.ts index 4ef9de0f..d32569ae 100644 --- a/src/db/mongo/pathways-mongo.ts +++ b/src/db/mongo/pathways-mongo.ts @@ -70,6 +70,7 @@ export interface CreatePathwayInput { triggerDescription?: string; assistantResponse?: string; enabled?: boolean; + notifyInstructorOnTrigger?: boolean; ctas?: PathwayCta[]; } @@ -79,6 +80,7 @@ export interface UpdatePathwayInput { triggerDescription?: string; assistantResponse?: string; enabled?: boolean; + notifyInstructorOnTrigger?: boolean; ctas?: PathwayCta[]; } @@ -90,6 +92,13 @@ function resolveEnabled(doc: any): boolean { return doc.enabledGlobally !== false; } +/** + * resolveNotifyInstructorOnTrigger - Preserve explicit settings and default legacy rows to on. + */ +function resolveNotifyInstructorOnTrigger(doc: any): boolean { + return doc.notifyInstructorOnTrigger !== false; +} + /** * normalizeTitle - Prefer stored title; for known platform ids fall back to seed title (not Untitled). */ @@ -223,6 +232,7 @@ function docToPathway(doc: any): GuidedPathway { order: typeof doc.order === 'number' ? doc.order : 0, title: normalizeTitle(doc.title, id), enabled: resolveEnabled(doc), + notifyInstructorOnTrigger: resolveNotifyInstructorOnTrigger(doc), triggerDescription: typeof doc.triggerDescription === 'string' ? doc.triggerDescription : '', assistantResponse: typeof doc.assistantResponse === 'string' ? doc.assistantResponse : '', ctas: normalizeCtas(doc.ctas), @@ -268,6 +278,7 @@ export async function createPathway( order: maxOrder + 1, title: normalizeTitle(input.title), enabled: input.enabled !== false, + notifyInstructorOnTrigger: input.notifyInstructorOnTrigger !== false, triggerDescription, assistantResponse: (input.assistantResponse ?? '').trim(), ctas: normalizeCtas(input.ctas), @@ -304,6 +315,9 @@ export async function updatePathway( if (typeof input.enabled === 'boolean') { $set.enabled = input.enabled; } + if (typeof input.notifyInstructorOnTrigger === 'boolean') { + $set.notifyInstructorOnTrigger = input.notifyInstructorOnTrigger; + } if (input.ctas !== undefined) { $set.ctas = normalizeCtas(input.ctas); } diff --git a/src/guided-pathways/__tests__/pathway-alert-persistence.test.ts b/src/guided-pathways/__tests__/pathway-alert-persistence.test.ts new file mode 100644 index 00000000..cb59b05c --- /dev/null +++ b/src/guided-pathways/__tests__/pathway-alert-persistence.test.ts @@ -0,0 +1,83 @@ +/** + * Tests for failure-isolated Guided Pathway alert persistence. + * + * @author: EngE-AI Team + * @date: 2026-08-08 + * @version: 1.0.0 + * @description: Verifies eligibility, exact-message forwarding, and safe write failure behavior. + */ + +import { + persistGuidedPathwayAlertSafely, + type GuidedPathwayFlagWriter, +} from '../pathway-alert-persistence'; + +const trigger = { + pathwayId: 'pathway-1', + pathwayTitle: 'Support pathway', + notifyInstructorOnTrigger: true, +}; + +function baseInput(writer: GuidedPathwayFlagWriter) { + return { + writer, + trigger, + courseId: 'course-1', + courseName: 'Course One', + messageText: ' Keep my exact spacing. ', + studentUserId: 'student-1', + chatId: 'chat-1', + clientMessageId: 'client-message-1', + isEligibleStudent: true, + }; +} + +describe('persistGuidedPathwayAlertSafely', () => { + it('writes one alert with the exact message for an eligible notification-enabled trigger', async () => { + const writer: GuidedPathwayFlagWriter = { + createGuidedPathwayFlag: jest.fn().mockResolvedValue({ created: true }), + }; + + await expect(persistGuidedPathwayAlertSafely(baseInput(writer))).resolves.toEqual({ + status: 'created', + }); + expect(writer.createGuidedPathwayFlag).toHaveBeenCalledWith(expect.objectContaining({ + courseId: 'course-1', + pathwayId: 'pathway-1', + messageText: ' Keep my exact spacing. ', + studentUserId: 'student-1', + chatId: 'chat-1', + clientMessageId: 'client-message-1', + })); + }); + + it('returns failed instead of throwing when alert storage rejects', async () => { + const writer: GuidedPathwayFlagWriter = { + createGuidedPathwayFlag: jest.fn().mockRejectedValue({ + code: 91, + message: 'database unavailable', + }), + }; + + await expect(persistGuidedPathwayAlertSafely(baseInput(writer))).resolves.toEqual({ + status: 'failed', + errorCode: 91, + }); + }); + + it.each([ + ['notification is disabled', { trigger: { ...trigger, notifyInstructorOnTrigger: false } }], + ['the sender is not an eligible student', { isEligibleStudent: false }], + ['the course id is unavailable', { courseId: undefined }], + ])('skips storage when %s', async (_label, overrides) => { + const writer: GuidedPathwayFlagWriter = { + createGuidedPathwayFlag: jest.fn(), + }; + + await expect(persistGuidedPathwayAlertSafely({ + ...baseInput(writer), + ...overrides, + })).resolves.toEqual({ status: 'skipped' }); + expect(writer.createGuidedPathwayFlag).not.toHaveBeenCalled(); + }); +}); diff --git a/src/guided-pathways/__tests__/pathway-orchestrator-mock.test.ts b/src/guided-pathways/__tests__/pathway-orchestrator-mock.test.ts index a8341fe3..068e589e 100644 --- a/src/guided-pathways/__tests__/pathway-orchestrator-mock.test.ts +++ b/src/guided-pathways/__tests__/pathway-orchestrator-mock.test.ts @@ -117,6 +117,11 @@ describe('evaluatePathways under MOCK_RESPONSE', () => { expect(result.triggered).toBe(true); expect(result.winningPathwayId).toBe('mental-health-crisis'); + expect(result.triggerSnapshot).toEqual({ + pathwayId: 'mental-health-crisis', + pathwayTitle: 'Mental health crisis', + notifyInstructorOnTrigger: true, + }); expect(sendStructuredConversation).not.toHaveBeenCalled(); }); }); diff --git a/src/guided-pathways/__tests__/pathway-schema.test.ts b/src/guided-pathways/__tests__/pathway-schema.test.ts index dd4c4daf..ed2537b5 100644 --- a/src/guided-pathways/__tests__/pathway-schema.test.ts +++ b/src/guided-pathways/__tests__/pathway-schema.test.ts @@ -23,6 +23,12 @@ describe('pathway-schema', () => { assistantResponse: ' ', }) ).toBe(false); + expect( + isPathwayEvaluable({ + ...seeds[0], + notifyInstructorOnTrigger: false, + }) + ).toBe(true); expect( isPathwayEvaluable({ ...seeds[0], @@ -39,6 +45,7 @@ describe('pathway-schema', () => { const result = buildPathwayResult('none', courseName, seeds); expect(result.triggered).toBe(false); expect(result.winningPathwayId).toBeNull(); + expect(result.triggerSnapshot).toBeNull(); expect(result.responseText).toBeNull(); expect(result.ctas).toEqual([]); }); @@ -47,11 +54,25 @@ describe('pathway-schema', () => { const result = buildPathwayResult('mental-health-crisis', courseName, seeds); expect(result.triggered).toBe(true); expect(result.winningPathwayId).toBe('mental-health-crisis'); + expect(result.triggerSnapshot).toEqual({ + pathwayId: 'mental-health-crisis', + pathwayTitle: 'Mental health crisis', + notifyInstructorOnTrigger: true, + }); expect(result.responseText).toContain(courseName); expect(result.ctas.length).toBeGreaterThan(0); expect(result.ctas[0].label).toContain('9-8-8'); }); + it('snapshots notification disabled without disabling the pathway response', () => { + const pathway = { ...seeds[1], notifyInstructorOnTrigger: false }; + const result = buildPathwayResult(pathway.id, courseName, [pathway]); + + expect(result.triggered).toBe(true); + expect(result.responseText).toBeTruthy(); + expect(result.triggerSnapshot?.notifyInstructorOnTrigger).toBe(false); + }); + it('maps inappropriate-content and off-topic', () => { expect(buildPathwayResult('inappropriate-content', courseName, seeds).triggered).toBe(true); expect(buildPathwayResult('off-topic', 'CHBE 241', seeds).responseText).toContain('CHBE 241'); diff --git a/src/guided-pathways/pathway-alert-persistence.ts b/src/guided-pathways/pathway-alert-persistence.ts new file mode 100644 index 00000000..d6ff807a --- /dev/null +++ b/src/guided-pathways/pathway-alert-persistence.ts @@ -0,0 +1,82 @@ +/** + * Guided Pathway alert persistence boundary + * + * Keeps optional alert creation isolated from the student-facing pathway response. + * A failed alert write is reported as data instead of throwing into the chat route. + * + * @author: EngE-AI Team + * @date: 2026-08-08 + * @version: 1.0.0 + * @description: Failure-isolated persistence helper for Guided Pathway trigger alerts. + */ + +import type { + CreateGuidedPathwayFlagInput, +} from '../db/mongo/guided-pathway-flag-mongo'; +import type { PathwayTriggerSnapshot } from './pathway-schema'; + +/** Minimal persistence contract used by the chat route. */ +export interface GuidedPathwayFlagWriter { + createGuidedPathwayFlag(input: CreateGuidedPathwayFlagInput): Promise; +} + +/** Context needed to decide whether an automatic alert should be written. */ +export interface PersistGuidedPathwayAlertInput { + writer: GuidedPathwayFlagWriter; + trigger: PathwayTriggerSnapshot | null; + courseId?: string; + courseName: string; + messageText: string; + studentUserId: string; + chatId: string; + clientMessageId: string; + isEligibleStudent: boolean; +} + +/** Safe outcome returned to the route without carrying the original database error. */ +export type PersistGuidedPathwayAlertResult = + | { status: 'skipped' } + | { status: 'created' } + | { status: 'failed'; errorCode?: string | number }; + +function safeErrorCode(error: unknown): string | number | undefined { + if (!error || typeof error !== 'object') return undefined; + const code = (error as { code?: unknown }).code; + return typeof code === 'string' || typeof code === 'number' ? code : undefined; +} + +/** + * Persist an alert when the winning pathway and user are eligible. + * + * This function never throws for a flag-write failure. That separation ensures + * the student still receives the pathway's predefined response when MongoDB is + * temporarily unable to store the instructor alert. + */ +export async function persistGuidedPathwayAlertSafely( + input: PersistGuidedPathwayAlertInput +): Promise { + const { trigger, courseId } = input; + if ( + trigger?.notifyInstructorOnTrigger !== true || + !courseId || + !input.isEligibleStudent + ) { + return { status: 'skipped' }; + } + + try { + await input.writer.createGuidedPathwayFlag({ + courseId, + courseName: input.courseName, + pathwayId: trigger.pathwayId, + pathwayTitle: trigger.pathwayTitle, + messageText: input.messageText, + studentUserId: input.studentUserId, + chatId: input.chatId, + clientMessageId: input.clientMessageId, + }); + return { status: 'created' }; + } catch (error) { + return { status: 'failed', errorCode: safeErrorCode(error) }; + } +} diff --git a/src/guided-pathways/pathway-schema.ts b/src/guided-pathways/pathway-schema.ts index 346246f3..5e850477 100644 --- a/src/guided-pathways/pathway-schema.ts +++ b/src/guided-pathways/pathway-schema.ts @@ -13,10 +13,18 @@ import { z } from 'zod'; import type { GuidedPathway, PathwayCta } from '../types/shared'; +/** Backend-only snapshot of the winning pathway used when creating an instructor alert. */ +export interface PathwayTriggerSnapshot { + pathwayId: string; + pathwayTitle: string; + notifyInstructorOnTrigger: boolean; +} + /** Result returned to chat-app after evaluation and server-side resolution. */ export interface PathwayEvaluationResult { triggered: boolean; winningPathwayId: string | null; + triggerSnapshot: PathwayTriggerSnapshot | null; responseText: string | null; ctas: PathwayCta[]; } @@ -24,6 +32,7 @@ export interface PathwayEvaluationResult { const NO_TRIGGER_RESULT: PathwayEvaluationResult = { triggered: false, winningPathwayId: null, + triggerSnapshot: null, responseText: null, ctas: [], }; @@ -95,6 +104,11 @@ export function buildPathwayResult( return { triggered: true, winningPathwayId: definition.id, + triggerSnapshot: { + pathwayId: definition.id, + pathwayTitle: definition.title, + notifyInstructorOnTrigger: definition.notifyInstructorOnTrigger, + }, responseText: formatPathwayResponse(definition.assistantResponse, courseName), ctas: definition.ctas.map((c) => ({ ...c })), }; diff --git a/src/guided-pathways/pathway-seed.ts b/src/guided-pathways/pathway-seed.ts index 55d048ba..fcd863e5 100644 --- a/src/guided-pathways/pathway-seed.ts +++ b/src/guided-pathways/pathway-seed.ts @@ -27,6 +27,7 @@ export function buildPlatformPathwaySeeds(now: number = Date.now()): GuidedPathw order: 0, title: 'Mental health crisis', enabled: true, + notifyInstructorOnTrigger: true, triggerDescription: 'Detects if the user message expresses suicidal ideation, thoughts of self-harm, severe hopelessness, or a mental health crisis.', assistantResponse: `Thank you for telling me this — it sounds like a genuinely hard moment, and I want to take it seriously rather than brush past it. @@ -63,6 +64,7 @@ You don't have to handle this on your own. I'll be here for the course whenever order: 1, title: 'Inappropriate content', enabled: true, + notifyInstructorOnTrigger: true, triggerDescription: 'Detects if the user message contains harassment, hate speech, explicit content, threats, or abusive language.', assistantResponse: `I'm not able to respond to that. EngE-AI is here to support your learning in {courseName}, and I need to keep our conversation focused and respectful to do that well. @@ -76,6 +78,7 @@ If there's an actual question about course material, an assignment, or an engine order: 2, title: 'Off-topic', enabled: true, + notifyInstructorOnTrigger: true, triggerDescription: 'Detects if the user message is unrelated to the course material. This includes requests for help with a completely different subject, personal questions, or general-purpose queries that have no connection to the course.', assistantResponse: `That's outside what I can help with — I'm scoped specifically to {courseName} Engineering coursework, not general topics. diff --git a/src/helpers/__tests__/course-backup-path.test.ts b/src/helpers/__tests__/course-backup-path.test.ts index da1bedca..e83a390a 100644 --- a/src/helpers/__tests__/course-backup-path.test.ts +++ b/src/helpers/__tests__/course-backup-path.test.ts @@ -19,6 +19,7 @@ describe('course-backup-path', () => { expect(names.flags).toBe(`${slug}_flag.json`); expect(names.scheduledTasks).toBe(`${slug}_scheduled_tasks.json`); expect(names.users).toBe(`${slug}_users.json`); - expect(names.memoryAgent).toBe(`${slug}_memory_agent.json`); - }); -}); + expect(names.memoryAgent).toBe(`${slug}_memory_agent.json`); + expect(names.guidedPathwayFlags).toBe(`${slug}_guided_pathway_flags.json`); + }); +}); diff --git a/src/helpers/course-backup-path.ts b/src/helpers/course-backup-path.ts index 0aa6ecd5..9ab6c0b9 100644 --- a/src/helpers/course-backup-path.ts +++ b/src/helpers/course-backup-path.ts @@ -20,20 +20,22 @@ export function courseMongoBackupFilenameSlug(courseName: string): string { } /** - * Five backup JSON filenames (flat under the archive root folder). + * Six backup JSON filenames (flat under the archive root folder). */ export function buildCourseMongoBackupJsonFilenames(courseNameSlug: string): { activeCourseList: string; flags: string; scheduledTasks: string; - users: string; - memoryAgent: string; -} { + users: string; + memoryAgent: string; + guidedPathwayFlags: string; +} { return { activeCourseList: `active-courselist_${courseNameSlug}.json`, flags: `${courseNameSlug}_flag.json`, scheduledTasks: `${courseNameSlug}_scheduled_tasks.json`, users: `${courseNameSlug}_users.json`, - memoryAgent: `${courseNameSlug}_memory_agent.json` - }; -} + memoryAgent: `${courseNameSlug}_memory_agent.json`, + guidedPathwayFlags: `${courseNameSlug}_guided_pathway_flags.json` + }; +} diff --git a/src/middleware/__tests__/require-course-role.test.ts b/src/middleware/__tests__/require-course-role.test.ts index c1d80845..82d743ab 100644 --- a/src/middleware/__tests__/require-course-role.test.ts +++ b/src/middleware/__tests__/require-course-role.test.ts @@ -1,8 +1,9 @@ import type { Request, Response, NextFunction } from 'express'; -import { - requireAdminForCourseAPI, - requireInstructorForCourseAPI -} from '../require-course-role'; +import { + requireAdminForCourseAPI, + requireInstructorForCourseAPI, + requireInstructorOrAdminForCourseAPI +} from '../require-course-role'; jest.mock('../../db/enge-ai-mongodb', () => ({ EngEAI_MongoDB: { @@ -85,7 +86,7 @@ describe('require-course-role admin', () => { }); }); - describe('requireAdminForCourseAPI', () => { + describe('requireAdminForCourseAPI', () => { it('allows platform admin', async () => { (EngEAI_MongoDB.getInstance as jest.Mock).mockResolvedValue({ findGlobalUserByPUID: jest.fn().mockResolvedValue(platformAdmin), @@ -110,6 +111,55 @@ describe('require-course-role admin', () => { expect(next).not.toHaveBeenCalled(); expect(res.status).toHaveBeenCalledWith(403); expect(res.json).toHaveBeenCalledWith({ error: 'Admin access required' }); - }); - }); -}); + }); + }); + + describe('requireInstructorOrAdminForCourseAPI', () => { + it('allows a listed faculty instructor', async () => { + (EngEAI_MongoDB.getInstance as jest.Mock).mockResolvedValue({ + findGlobalUserByPUID: jest.fn().mockResolvedValue(facultyInstructor), + getActiveCourse: jest.fn().mockResolvedValue(course) + }); + + const { req, res, next } = mockReqResNext(); + await requireInstructorOrAdminForCourseAPI(['params'])(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + it('allows a platform administrator who is not listed on the course', async () => { + (EngEAI_MongoDB.getInstance as jest.Mock).mockResolvedValue({ + findGlobalUserByPUID: jest.fn().mockResolvedValue(platformAdmin), + getActiveCourse: jest.fn().mockResolvedValue(course) + }); + + const { req, res, next } = mockReqResNext(); + await requireInstructorOrAdminForCourseAPI(['params'])(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + it('denies a teaching assistant', async () => { + (EngEAI_MongoDB.getInstance as jest.Mock).mockResolvedValue({ + findGlobalUserByPUID: jest.fn().mockResolvedValue({ + userId: 'user-ta', + affiliation: 'student', + isAdmin: false + }), + getActiveCourse: jest.fn().mockResolvedValue({ + ...course, + teachingAssistants: [{ userId: 'user-ta', name: 'TA' }] + }) + }); + + const { req, res, next } = mockReqResNext(); + await requireInstructorOrAdminForCourseAPI(['params'])(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + error: 'Instructor or administrator access required' + }); + }); + }); +}); diff --git a/src/middleware/require-course-role.ts b/src/middleware/require-course-role.ts index 4e49fbc6..f0f2313d 100644 --- a/src/middleware/require-course-role.ts +++ b/src/middleware/require-course-role.ts @@ -108,6 +108,40 @@ export function requireInstructorForCourseAPI(sources: CourseIdSource[] = ['para }; } +/** + * requireInstructorOrAdminForCourseAPI - Requires course-owner review permission. + * + * Platform administrators and faculty listed in `course.instructors` pass. + * Teaching assistants remain course staff for other features but are denied here. + * + * @param sources - Ordered request locations used to resolve the course id + * @returns Express middleware with JSON 401/403/404 failures + */ +export function requireInstructorOrAdminForCourseAPI( + sources: CourseIdSource[] = ['params', 'paramsId', 'body', 'session'] +) { + return async (req: Request, res: Response, next: NextFunction) => { + try { + const ctx = await loadCourseContext(req, sources); + if (!ctx.ok) { + return res.status(ctx.status).json({ error: ctx.error }); + } + + if (!canManageCourseRoster(ctx.course, ctx.globalUser)) { + appLogger.log( + `[RBAC] User ${ctx.globalUser.userId} denied instructor/admin API access for course ${ctx.courseId}` + ); + return res.status(403).json({ error: 'Instructor or administrator access required' }); + } + + next(); + } catch (error) { + appLogger.error('[RBAC] Error in requireInstructorOrAdminForCourseAPI:', error); + res.status(500).json({ error: 'Internal server error' }); + } + }; +} + /** * Middleware: Require student role for course-scoped API endpoints * Returns 403 JSON if user is not enrolled or is course staff. diff --git a/src/routes/__tests__/guided-pathway-flag-admin-routes.test.ts b/src/routes/__tests__/guided-pathway-flag-admin-routes.test.ts new file mode 100644 index 00000000..49847e96 --- /dev/null +++ b/src/routes/__tests__/guided-pathway-flag-admin-routes.test.ts @@ -0,0 +1,84 @@ +import express, { type NextFunction, type Request, type Response } from 'express'; +import request from 'supertest'; + +jest.mock('../../db/enge-ai-mongodb', () => ({ + EngEAI_MongoDB: { getInstance: jest.fn() } +})); + +jest.mock('../../middleware/async-handler', () => ({ + asyncHandlerWithAuth: (handler: (req: Request, res: Response, next: NextFunction) => unknown) => + (req: Request, res: Response, next: NextFunction) => + Promise.resolve(handler(req, res, next)).catch(next) +})); + +jest.mock('../../middleware/require-course-role', () => ({ + requireAdminGlobal: (_req: Request, _res: Response, next: NextFunction) => next() +})); + +jest.mock('../../utils/logger', () => ({ + appLogger: { error: jest.fn() } +})); + +import { EngEAI_MongoDB } from '../../db/enge-ai-mongodb'; +import adminGuidedPathwayFlagRoutes from '../mongo/admin-guided-pathway-flag-routes'; + +describe('administrator Guided Pathway flag list API', () => { + it('returns server-provided safe facets and requests facet-wide Mongo queries', async () => { + const data = { + items: [], + page: 1, + pageSize: 20, + total: 0, + facets: { + pathways: [{ pathwayId: 'pathway-1', pathwayTitle: 'Support' }], + reviewers: ['Instructor A'] + } + }; + const listGuidedPathwayFlags = jest.fn().mockResolvedValue(data); + (EngEAI_MongoDB.getInstance as jest.Mock).mockResolvedValue({ + listGuidedPathwayFlags + }); + + const app = express(); + app.use(express.json()); + app.use('/', adminGuidedPathwayFlagRoutes); + + const response = await request(app).get( + '/?page=1&pageSize=20&status=escalated&pathwayId=pathway-1&reviewer=Instructor%20A' + ); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ success: true, data }); + expect(listGuidedPathwayFlags).toHaveBeenCalledWith(expect.objectContaining({ + page: 1, + pageSize: 20, + status: 'escalated', + pathwayId: 'pathway-1', + reviewer: 'Instructor A', + escalatedFirst: true, + includeFacets: true + })); + expect(response.body.data.facets).not.toHaveProperty('messageText'); + expect(response.body.data.facets).not.toHaveProperty('studentUserId'); + }); + + it('rejects review-state filters combined with a non-escalated decision', async () => { + const listGuidedPathwayFlags = jest.fn(); + (EngEAI_MongoDB.getInstance as jest.Mock).mockResolvedValue({ + listGuidedPathwayFlags + }); + + const app = express(); + app.use(express.json()); + app.use('/', adminGuidedPathwayFlagRoutes); + + const response = await request(app).get('/?status=dismissed&reviewState=needs-review'); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ + success: false, + error: 'Admin review filters apply only to escalated alerts' + }); + expect(listGuidedPathwayFlags).not.toHaveBeenCalled(); + }); +}); diff --git a/src/routes/__tests__/guided-pathway-flag-date-filter.test.ts b/src/routes/__tests__/guided-pathway-flag-date-filter.test.ts new file mode 100644 index 00000000..f8086eea --- /dev/null +++ b/src/routes/__tests__/guided-pathway-flag-date-filter.test.ts @@ -0,0 +1,34 @@ +import { parseGuidedPathwayFlagDateQuery } from '../mongo/admin-guided-pathway-flag-routes'; + +describe('Guided Pathway administrator date filters', () => { + it('covers the full Vancouver summer day instead of ending at UTC midnight', () => { + expect(parseGuidedPathwayFlagDateQuery('2026-08-08', false)?.toISOString()).toBe( + '2026-08-08T07:00:00.000Z' + ); + expect(parseGuidedPathwayFlagDateQuery('2026-08-08', true)?.toISOString()).toBe( + '2026-08-09T06:59:59.999Z' + ); + }); + + it('uses the winter UTC offset for Vancouver date boundaries', () => { + expect(parseGuidedPathwayFlagDateQuery('2026-01-08', false)?.toISOString()).toBe( + '2026-01-08T08:00:00.000Z' + ); + expect(parseGuidedPathwayFlagDateQuery('2026-01-08', true)?.toISOString()).toBe( + '2026-01-09T07:59:59.999Z' + ); + }); + + it('handles a daylight-saving transition day without losing its evening', () => { + expect(parseGuidedPathwayFlagDateQuery('2026-03-08', false)?.toISOString()).toBe( + '2026-03-08T08:00:00.000Z' + ); + expect(parseGuidedPathwayFlagDateQuery('2026-03-08', true)?.toISOString()).toBe( + '2026-03-09T06:59:59.999Z' + ); + }); + + it('rejects impossible date-only values', () => { + expect(parseGuidedPathwayFlagDateQuery('2026-02-30', true)).toBeNull(); + }); +}); diff --git a/src/routes/mongo/admin-course-routes.ts b/src/routes/mongo/admin-course-routes.ts index 258c8ef4..61749517 100644 --- a/src/routes/mongo/admin-course-routes.ts +++ b/src/routes/mongo/admin-course-routes.ts @@ -33,8 +33,11 @@ router.get( asyncHandlerWithAuth(async (_req: Request, res: Response) => { const mongo = await EngEAI_MongoDB.getInstance(); const defaultPeriodId = await mongo.getDefaultAcademicPeriodId(); - const periods = await mongo.listAcademicPeriods(); - const courses = await mongo.getAllActiveCourses(); + const [periods, courses, guidedPathwayEscalationsAwaitingReview] = await Promise.all([ + mongo.listAcademicPeriods(), + mongo.getAllActiveCourses(), + mongo.countGuidedPathwayFlagsAwaitingAdminReview() + ]); const coursesByPeriod = new Map(); for (const period of periods) { @@ -67,7 +70,8 @@ router.get( data: { periods: payload, defaultPeriodId, - defaultPeriodTitle: DEFAULT_ACADEMIC_PERIOD_TITLE + defaultPeriodTitle: DEFAULT_ACADEMIC_PERIOD_TITLE, + guidedPathwayEscalationsAwaitingReview } }); }) diff --git a/src/routes/mongo/admin-guided-pathway-flag-routes.ts b/src/routes/mongo/admin-guided-pathway-flag-routes.ts new file mode 100644 index 00000000..f550f067 --- /dev/null +++ b/src/routes/mongo/admin-guided-pathway-flag-routes.ts @@ -0,0 +1,263 @@ +/** + * Administrator Guided Pathway flag routes + * + * Provides the global anonymous queue, persistent review action, and explicit + * audited identity reveal. Every response delegates through a safe DTO boundary. + * + * @author: EngE-AI Team + * @date: 2026-08-08 + * @version: 1.0.0 + * @description: Platform-admin APIs for cross-course Guided Pathway alerts. + */ + +import { Router, type Request, type Response } from 'express'; +import { EngEAI_MongoDB } from '../../db/enge-ai-mongodb'; +import { + GuidedPathwayFlagConflictError, + GuidedPathwayFlagIdentityUnavailableError, + GuidedPathwayFlagNotFoundError, + type GuidedPathwayFlagActor +} from '../../db/mongo/guided-pathway-flag-mongo'; +import { routeParam } from '../../helpers/route-params'; +import { asyncHandlerWithAuth } from '../../middleware/async-handler'; +import { requireAdminGlobal } from '../../middleware/require-course-role'; +import type { + GlobalUser, + GuidedPathwayFlagReviewState, + GuidedPathwayFlagStatus +} from '../../types/shared'; +import { appLogger } from '../../utils/logger'; + +const router = Router(); +const VALID_STATUSES: GuidedPathwayFlagStatus[] = ['pending', 'escalated', 'dismissed']; +const VALID_REVIEW_STATES: GuidedPathwayFlagReviewState[] = ['needs-review', 'reviewed', 'all']; +const VANCOUVER_TIME_ZONE = 'America/Vancouver'; +const VANCOUVER_DATE_TIME_PARTS = new Intl.DateTimeFormat('en-CA', { + timeZone: VANCOUVER_TIME_ZONE, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23' +}); + +function stringQuery(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +function positiveIntegerQuery(value: unknown, fallback: number): number | null { + if (value === undefined) return fallback; + if (typeof value !== 'string' || !/^\d+$/.test(value)) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null; +} + +function vancouverMidnight(year: number, month: number, day: number): Date { + const targetAsUtc = Date.UTC(year, month - 1, day); + let instant = targetAsUtc; + + // Iteratively align the formatted Vancouver civil time with the requested midnight. + for (let attempt = 0; attempt < 4; attempt += 1) { + const parts = VANCOUVER_DATE_TIME_PARTS.formatToParts(new Date(instant)); + const pick = (type: Intl.DateTimeFormatPart['type']): number => + Number(parts.find((part) => part.type === type)?.value ?? 0); + const representedAsUtc = Date.UTC( + pick('year'), + pick('month') - 1, + pick('day'), + pick('hour'), + pick('minute'), + pick('second') + ); + const adjustment = targetAsUtc - representedAsUtc; + instant += adjustment; + if (adjustment === 0) break; + } + return new Date(instant); +} + +/** Parses an admin date filter; date-only values use DST-aware Vancouver day boundaries. */ +export function parseGuidedPathwayFlagDateQuery( + value: unknown, + endOfDay: boolean +): Date | null | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'string' || !value.trim()) return null; + + const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (dateOnly) { + const year = Number(dateOnly[1]); + const month = Number(dateOnly[2]); + const day = Number(dateOnly[3]); + const calendarCheck = new Date(Date.UTC(year, month - 1, day)); + if ( + calendarCheck.getUTCFullYear() !== year || + calendarCheck.getUTCMonth() !== month - 1 || + calendarCheck.getUTCDate() !== day + ) { + return null; + } + if (!endOfDay) return vancouverMidnight(year, month, day); + + const nextCalendarDay = new Date(Date.UTC(year, month - 1, day + 1)); + return new Date(vancouverMidnight( + nextCalendarDay.getUTCFullYear(), + nextCalendarDay.getUTCMonth() + 1, + nextCalendarDay.getUTCDate() + ).getTime() - 1); + } + + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function actorFromSession(req: Request): GuidedPathwayFlagActor { + const actor = (req.session as any)?.globalUser as GlobalUser | undefined; + if (!actor?.userId || !actor.name) { + throw new Error('Authenticated administrator identity is unavailable'); + } + return { userId: actor.userId, name: actor.name }; +} + +function safeErrorMetadata(error: unknown): { errorName: string; errorCode?: string | number } { + const errorName = error instanceof Error ? error.name : typeof error; + const code = error && typeof error === 'object' ? (error as { code?: unknown }).code : undefined; + return { + errorName, + ...(typeof code === 'string' || typeof code === 'number' ? { errorCode: code } : {}) + }; +} + +function handleMutationError(res: Response, error: unknown): boolean { + if (error instanceof GuidedPathwayFlagNotFoundError) { + res.status(404).json({ success: false, error: error.message }); + return true; + } + if (error instanceof GuidedPathwayFlagConflictError) { + res.status(409).json({ success: false, error: error.message }); + return true; + } + if (error instanceof GuidedPathwayFlagIdentityUnavailableError) { + res.status(404).json({ success: false, error: error.message }); + return true; + } + return false; +} + +router.get( + '/', + requireAdminGlobal, + asyncHandlerWithAuth(async (req: Request, res: Response) => { + const page = positiveIntegerQuery(req.query.page, 1); + const pageSize = positiveIntegerQuery(req.query.pageSize, 50); + const status = stringQuery(req.query.status); + const reviewState = stringQuery(req.query.reviewState) ?? 'all'; + const dateFrom = parseGuidedPathwayFlagDateQuery(req.query.dateFrom, false); + const dateTo = parseGuidedPathwayFlagDateQuery(req.query.dateTo, true); + + if (page === null || pageSize === null) { + return res.status(400).json({ + success: false, + error: 'page and pageSize must be positive integers' + }); + } + if (status && !VALID_STATUSES.includes(status as GuidedPathwayFlagStatus)) { + return res.status(400).json({ success: false, error: 'Invalid status filter' }); + } + if (!VALID_REVIEW_STATES.includes(reviewState as GuidedPathwayFlagReviewState)) { + return res.status(400).json({ success: false, error: 'Invalid reviewState filter' }); + } + if (reviewState !== 'all' && status && status !== 'escalated') { + return res.status(400).json({ + success: false, + error: 'Admin review filters apply only to escalated alerts' + }); + } + if (dateFrom === null || dateTo === null || (dateFrom && dateTo && dateFrom > dateTo)) { + return res.status(400).json({ success: false, error: 'Invalid date range' }); + } + + try { + const mongo = await EngEAI_MongoDB.getInstance(); + const academicPeriodId = stringQuery(req.query.academicPeriodId); + let courseIds: string[] | undefined; + if (academicPeriodId) { + const period = await mongo.getAcademicPeriodById(academicPeriodId); + if (!period) { + return res.status(404).json({ success: false, error: 'Academic period not found' }); + } + courseIds = period.courseIds; + } + + const data = await mongo.listGuidedPathwayFlags({ + page, + pageSize, + status: status as GuidedPathwayFlagStatus | undefined, + reviewState: reviewState as GuidedPathwayFlagReviewState, + courseId: stringQuery(req.query.courseId), + courseIds, + pathwayId: stringQuery(req.query.pathwayId), + reviewer: stringQuery(req.query.reviewer), + dateFrom, + dateTo, + escalatedFirst: true, + includeFacets: true + }); + res.json({ success: true, data }); + } catch (error) { + appLogger.error( + '[guided-pathway-flags] Failed to list administrator alerts', + safeErrorMetadata(error) + ); + res.status(500).json({ success: false, error: 'Failed to load Guided Pathway alerts' }); + } + }) +); + +router.patch( + '/:flagId/review', + requireAdminGlobal, + asyncHandlerWithAuth(async (req: Request, res: Response) => { + try { + const mongo = await EngEAI_MongoDB.getInstance(); + const data = await mongo.markGuidedPathwayFlagAdminReviewed( + routeParam(req.params, 'flagId'), + actorFromSession(req) + ); + res.json({ success: true, data }); + } catch (error) { + if (handleMutationError(res, error)) return; + appLogger.error( + '[guided-pathway-flags] Failed to mark administrator review', + safeErrorMetadata(error) + ); + res.status(500).json({ success: false, error: 'Failed to review Guided Pathway alert' }); + } + }) +); + +router.post( + '/:flagId/reveal-identity', + requireAdminGlobal, + asyncHandlerWithAuth(async (req: Request, res: Response) => { + try { + const mongo = await EngEAI_MongoDB.getInstance(); + const data = await mongo.revealGuidedPathwayFlagIdentity( + routeParam(req.params, 'flagId'), + actorFromSession(req) + ); + res.json({ success: true, data }); + } catch (error) { + if (handleMutationError(res, error)) return; + appLogger.error( + '[guided-pathway-flags] Failed to reveal audited identity', + safeErrorMetadata(error) + ); + res.status(500).json({ success: false, error: 'Failed to reveal student identity' }); + } + }) +); + +export default router; diff --git a/src/routes/mongo/guided-pathway-flag-routes.ts b/src/routes/mongo/guided-pathway-flag-routes.ts new file mode 100644 index 00000000..6aac5507 --- /dev/null +++ b/src/routes/mongo/guided-pathway-flag-routes.ts @@ -0,0 +1,143 @@ +/** + * Guided Pathway flag course routes + * + * Provides the anonymous course queue and immutable instructor decision API. + * Persistence and privacy projection remain in the Mongo delegate. + * + * @author: EngE-AI Team + * @date: 2026-08-08 + * @version: 1.0.0 + * @description: Instructor/admin course APIs for Guided Pathway trigger alerts. + */ + +import type { Request, Response, Router } from 'express'; +import { EngEAI_MongoDB } from '../../db/enge-ai-mongodb'; +import { + GuidedPathwayFlagConflictError, + GuidedPathwayFlagNotFoundError, + type GuidedPathwayFlagActor +} from '../../db/mongo/guided-pathway-flag-mongo'; +import { normalizeRouteParams } from '../../helpers/route-params'; +import { asyncHandlerWithAuth } from '../../middleware/async-handler'; +import { requireInstructorOrAdminForCourseAPI } from '../../middleware/require-course-role'; +import type { + GlobalUser, + GuidedPathwayFlagDecision, + GuidedPathwayFlagStatus +} from '../../types/shared'; +import { appLogger } from '../../utils/logger'; + +const VALID_STATUSES: GuidedPathwayFlagStatus[] = ['pending', 'escalated', 'dismissed']; +const VALID_DECISIONS: GuidedPathwayFlagDecision[] = ['escalate', 'dismiss']; + +function parsePositiveInteger(value: unknown, fallback: number): number | null { + if (value === undefined) return fallback; + if (typeof value !== 'string' || !/^\d+$/.test(value)) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null; +} + +function actorFromSession(req: Request): GuidedPathwayFlagActor { + const actor = (req.session as any)?.globalUser as GlobalUser | undefined; + if (!actor?.userId || !actor.name) { + throw new Error('Authenticated staff identity is unavailable'); + } + return { userId: actor.userId, name: actor.name }; +} + +function safeErrorMetadata(error: unknown): { errorName: string; errorCode?: string | number } { + const errorName = error instanceof Error ? error.name : typeof error; + const code = error && typeof error === 'object' ? (error as { code?: unknown }).code : undefined; + return { + errorName, + ...(typeof code === 'string' || typeof code === 'number' ? { errorCode: code } : {}) + }; +} + +function handleMutationError(res: Response, error: unknown): boolean { + if (error instanceof GuidedPathwayFlagNotFoundError) { + res.status(404).json({ success: false, error: error.message }); + return true; + } + if (error instanceof GuidedPathwayFlagConflictError) { + res.status(409).json({ success: false, error: error.message }); + return true; + } + return false; +} + +/** Registers course-scoped anonymous Guided Pathway alert routes. */ +export function mountGuidedPathwayFlagRoutes(router: Router): void { + router.get( + '/:courseId/guided-pathway-flags', + requireInstructorOrAdminForCourseAPI(['params']), + asyncHandlerWithAuth(async (req: Request, res: Response) => { + const { courseId } = normalizeRouteParams(req.params); + const page = parsePositiveInteger(req.query.page, 1); + const pageSize = parsePositiveInteger(req.query.pageSize, 50); + const rawStatus = req.query.status; + if (page === null || pageSize === null) { + return res.status(400).json({ + success: false, + error: 'page and pageSize must be positive integers' + }); + } + if ( + rawStatus !== undefined && + (typeof rawStatus !== 'string' || !VALID_STATUSES.includes(rawStatus as GuidedPathwayFlagStatus)) + ) { + return res.status(400).json({ success: false, error: 'Invalid status filter' }); + } + + try { + const mongo = await EngEAI_MongoDB.getInstance(); + const data = await mongo.listGuidedPathwayFlags({ + courseId, + page, + pageSize, + status: rawStatus as GuidedPathwayFlagStatus | undefined + }); + res.json({ success: true, data }); + } catch (error) { + appLogger.error( + '[guided-pathway-flags] Failed to list course alerts', + safeErrorMetadata(error) + ); + res.status(500).json({ success: false, error: 'Failed to load Guided Pathway alerts' }); + } + }) + ); + + router.patch( + '/:courseId/guided-pathway-flags/:flagId/decision', + requireInstructorOrAdminForCourseAPI(['params']), + asyncHandlerWithAuth(async (req: Request, res: Response) => { + const { courseId, flagId } = normalizeRouteParams(req.params); + const decision = req.body?.decision; + if (typeof decision !== 'string' || !VALID_DECISIONS.includes(decision as GuidedPathwayFlagDecision)) { + return res.status(400).json({ + success: false, + error: 'decision must be escalate or dismiss' + }); + } + + try { + const mongo = await EngEAI_MongoDB.getInstance(); + const data = await mongo.decideGuidedPathwayFlag( + courseId, + flagId, + decision as GuidedPathwayFlagDecision, + actorFromSession(req) + ); + res.json({ success: true, data }); + } catch (error) { + if (handleMutationError(res, error)) return; + appLogger.error( + '[guided-pathway-flags] Failed to record instructor decision', + safeErrorMetadata(error) + ); + res.status(500).json({ success: false, error: 'Failed to record Guided Pathway alert decision' }); + } + }) + ); +} diff --git a/src/routes/mongo/pathways-routes.ts b/src/routes/mongo/pathways-routes.ts index 5b351561..783fd9bb 100644 --- a/src/routes/mongo/pathways-routes.ts +++ b/src/routes/mongo/pathways-routes.ts @@ -11,13 +11,16 @@ import { Router, Request, Response } from 'express'; import { asyncHandlerWithAuth } from '../../middleware/async-handler'; -import { requireCourseFeatureAPI, requireInstructorForCourseAPI } from '../../middleware/require-course-role'; +import { + requireCourseFeatureAPI, + requireInstructorOrAdminForCourseAPI, +} from '../../middleware/require-course-role'; import { EngEAI_MongoDB } from '../../db/enge-ai-mongodb'; import { normalizeRouteParams } from '../../helpers/route-params'; -/** Staff pathway APIs require instructor role and Guided Pathway capability. */ +/** Pathway configuration requires a course instructor/admin and the Guided Pathway capability. */ const pathwayGates = [ - requireInstructorForCourseAPI(['params']), + requireInstructorOrAdminForCourseAPI(['params']), requireCourseFeatureAPI('guidedPathway', ['params']) ]; @@ -56,6 +59,10 @@ export function mountPathwaysRoutes(router: Router): void { triggerDescription: typeof body.triggerDescription === 'string' ? body.triggerDescription : '', assistantResponse: typeof body.assistantResponse === 'string' ? body.assistantResponse : '', enabled: typeof body.enabled === 'boolean' ? body.enabled : true, + notifyInstructorOnTrigger: + typeof body.notifyInstructorOnTrigger === 'boolean' + ? body.notifyInstructorOnTrigger + : true, ctas: Array.isArray(body.ctas) ? body.ctas : [], }); res.status(201).json({ success: true, data }); @@ -124,6 +131,9 @@ export function mountPathwaysRoutes(router: Router): void { if (typeof body.triggerDescription === 'string') patch.triggerDescription = body.triggerDescription; if (typeof body.assistantResponse === 'string') patch.assistantResponse = body.assistantResponse; if (typeof body.enabled === 'boolean') patch.enabled = body.enabled; + if (typeof body.notifyInstructorOnTrigger === 'boolean') { + patch.notifyInstructorOnTrigger = body.notifyInstructorOnTrigger; + } if (Array.isArray(body.ctas)) patch.ctas = body.ctas; const data = await instance.updatePathway(courseName, pathwayId, patch); diff --git a/src/routes/route-chat-app.ts b/src/routes/route-chat-app.ts index b5bb4f11..5d1bbb10 100644 --- a/src/routes/route-chat-app.ts +++ b/src/routes/route-chat-app.ts @@ -17,7 +17,9 @@ import { EngEAI_MongoDB } from '../db/enge-ai-mongodb'; import { ChatApp, RETIRED_CONVERSATION_MODE_MESSAGE, DEBUG_MODE_FORBIDDEN } from '../chat/chat-app'; import { conversationModePrompts } from '../chat/compose-system-prompt'; import { isAdminUser } from '../utils/admin'; +import { isCourseStaff } from '../utils/course-staff'; import { isDebugToggleMessage } from '../chat/system-prompts/debug-mode-prompt'; +import { persistGuidedPathwayAlertSafely } from '../guided-pathways/pathway-alert-persistence'; import { getRandomNoResponse } from '../memory-agent/unstruggle-responses'; import { memoryAgent } from '../memory-agent/memory-agent'; @@ -36,6 +38,18 @@ const appConfig = loadConfig(); const chatApp = new ChatApp(appConfig); +function safeOperationalErrorMetadata(error: unknown): { + errorName: string; + errorCode?: string | number; +} { + const errorName = error instanceof Error ? error.name : typeof error; + const code = error && typeof error === 'object' ? (error as { code?: unknown }).code : undefined; + return { + errorName, + ...(typeof code === 'string' || typeof code === 'number' ? { errorCode: code } : {}) + }; +} + /** * SIGTERM signal handler * Cleans up chat timers on server shutdown @@ -557,6 +571,7 @@ router.post('/:chatId/dismiss-unstruggle', asyncHandlerWithAuth(async (req: Requ * @route POST /api/chat/:chatId * @param {string} chatId - Chat ID (path param) * @param {string} message - User message text (body) + * @param {string} clientMessageId - Opaque client-generated id reused for transport retries (body) * @param {string} [userId] - User ID, optional; session/MongoDB used as source of truth (body) * @param {string} [conversationMode] - Selected teaching mode used to finalize undeclared chats * @returns {object} { success: boolean, userMessage?: object, assistantMessage?: object, error?: string } @@ -569,7 +584,7 @@ router.post('/:chatId/dismiss-unstruggle', asyncHandlerWithAuth(async (req: Requ router.post('/:chatId', asyncHandlerWithAuth(async (req: Request, res: Response) => { try { const { chatId } = normalizeRouteParams(req.params); - const { message, userId: userIdFromBody, conversationMode } = req.body; // Rename to avoid conflict + const { message, clientMessageId, conversationMode } = req.body; // Get user from session const user = (req as any).user; @@ -592,20 +607,8 @@ router.post('/:chatId', asyncHandlerWithAuth(async (req: Request, res: Response) } const userId = globalUserFromDB.userId; // Use this consistently throughout - //START DEBUG LOG : DEBUG-CODE(SEND-MSG-001) - appLogger.log('\n💬 SENDING MESSAGE:'); - appLogger.log('='.repeat(50)); - appLogger.log(`Chat ID: ${chatId}`); - appLogger.log(`User ID (from body): ${userIdFromBody}`); - appLogger.log(`User ID (from MongoDB): ${userId}`); - appLogger.log(`PUID: ${puid}`); - appLogger.log(`Course: ${courseName}`); - appLogger.log(`Message: ${message.substring(0, 100)}...`); - appLogger.log('='.repeat(50)); - //END DEBUG LOG : DEBUG-CODE(SEND-MSG-001) - // Validate input - if (!message) { + if (typeof message !== 'string' || message.trim().length === 0) { //START DEBUG LOG : DEBUG-CODE(SEND-MSG-002) appLogger.log('❌ VALIDATION FAILED: Missing message'); //END DEBUG LOG : DEBUG-CODE(SEND-MSG-002) @@ -615,6 +618,18 @@ router.post('/:chatId', asyncHandlerWithAuth(async (req: Request, res: Response) }); } + if ( + typeof clientMessageId !== 'string' || + clientMessageId.length < 16 || + clientMessageId.length > 128 || + !/^[A-Za-z0-9._:-]+$/.test(clientMessageId) + ) { + return res.status(400).json({ + success: false, + error: 'A valid clientMessageId is required' + }); + } + if (!puid) { //START DEBUG LOG : DEBUG-CODE(SEND-MSG-003) appLogger.log('❌ VALIDATION FAILED: PUID not found in session'); @@ -720,8 +735,6 @@ router.post('/:chatId', asyncHandlerWithAuth(async (req: Request, res: Response) //START DEBUG LOG : DEBUG-CODE(UNSTRUGGLE-001) appLogger.log(`\n🔄 PROCESSING UNSTRUGGLE RESPONSE:`); - appLogger.log(`Message: ${message}`); - appLogger.log(`Topic: ${topic}`); appLogger.log(`Response: ${isConfident ? 'Yes (confident)' : 'No (needs practice)'}`); //END DEBUG LOG : DEBUG-CODE(UNSTRUGGLE-001) @@ -829,8 +842,6 @@ router.post('/:chatId', asyncHandlerWithAuth(async (req: Request, res: Response) //START DEBUG LOG : DEBUG-CODE(UNSTRUGGLE-003) appLogger.log('⚠️ Unstruggle pattern detected but validation failed:'); appLogger.log(` Has unstruggle tag: ${hasUnstruggleTag}`); - appLogger.log(` Previous topic: ${prevTopic || 'none'}`); - appLogger.log(` User topic: ${topic}`); appLogger.log(' Treating as regular message.'); //END DEBUG LOG : DEBUG-CODE(UNSTRUGGLE-003) } @@ -861,7 +872,7 @@ router.post('/:chatId', asyncHandlerWithAuth(async (req: Request, res: Response) }); } - const assistantMessage = await chatApp.sendUserMessage( + const { assistantMessage, pathwayTrigger } = await chatApp.sendUserMessage( message, chatId, userId.toString(), // Use userId from MongoDB (consistent source) @@ -891,8 +902,6 @@ router.post('/:chatId', asyncHandlerWithAuth(async (req: Request, res: Response) // Save both messages to MongoDB // userId already fetched from MongoDB above (consistent source) - appLogger.log(`[SEND-MSG] Using userId from MongoDB: ${puid} -> ${userId}`); - try { // Save user message await mongoDB.addMessageToChat(courseName, userId, chatId, userMessage); @@ -900,7 +909,6 @@ router.post('/:chatId', asyncHandlerWithAuth(async (req: Request, res: Response) //START DEBUG LOG : DEBUG-CODE(SEND-MSG-007) appLogger.log('✅ User message saved to MongoDB'); appLogger.log(' User message ID:', userMessage.id); - appLogger.log(' Text:', userMessage.text.substring(0, 50) + '...'); //END DEBUG LOG : DEBUG-CODE(SEND-MSG-007) // Save assistant message @@ -909,7 +917,6 @@ router.post('/:chatId', asyncHandlerWithAuth(async (req: Request, res: Response) //START DEBUG LOG : DEBUG-CODE(SEND-MSG-008) appLogger.log('✅ Assistant message saved to MongoDB'); appLogger.log(' Assistant message ID:', assistantMessage.id); - appLogger.log(' Text:', assistantMessage.text.substring(0, 50) + '...'); //END DEBUG LOG : DEBUG-CODE(SEND-MSG-008) // Check if chat title needs updating (first user-AI exchange) @@ -920,12 +927,46 @@ router.post('/:chatId', asyncHandlerWithAuth(async (req: Request, res: Response) } catch (dbError) { //START DEBUG LOG : DEBUG-CODE(SEND-MSG-009) - appLogger.error('⚠️ WARNING: Failed to save messages to MongoDB:', { error: dbError }); + appLogger.error( + '⚠️ WARNING: Failed to save messages to MongoDB', + safeOperationalErrorMetadata(dbError) + ); appLogger.log('Messages in memory but not persisted to database'); //END DEBUG LOG : DEBUG-CODE(SEND-MSG-009) // Continue execution - messages are still in memory } + // Persist an anonymous alert only for enrolled students and notification-enabled triggers. + const courseId = courseForFeatures?.id; + const isEnrolledStudent = Boolean( + courseForFeatures && + courseId && + globalUserFromDB.coursesEnrolled.includes(courseId) && + !isCourseStaff(courseForFeatures, globalUserFromDB) + ); + + const flagResult = await persistGuidedPathwayAlertSafely({ + writer: mongoDB, + trigger: pathwayTrigger, + courseId, + courseName, + messageText: message, + studentUserId: userId, + chatId, + clientMessageId, + isEligibleStudent: isEnrolledStudent, + }); + if (flagResult.status === 'failed') { + appLogger.error( + '[GUIDED-PATHWAY-FLAGS] Alert persistence failed; returning pathway response', + { + courseId, + pathwayId: pathwayTrigger?.pathwayId, + errorCode: flagResult.errorCode, + } + ); + } + // Return the complete response (no streaming) res.json({ success: true, @@ -936,7 +977,7 @@ router.post('/:chatId', asyncHandlerWithAuth(async (req: Request, res: Response) } catch (aiError) { //START DEBUG LOG : DEBUG-CODE(SEND-MSG-010) - appLogger.error('❌ AI Communication Error:', { error: aiError }); + appLogger.error('❌ AI Communication Error', safeOperationalErrorMetadata(aiError)); //END DEBUG LOG : DEBUG-CODE(SEND-MSG-010) if (aiError instanceof Error && aiError.message === DEBUG_MODE_FORBIDDEN) { @@ -954,7 +995,7 @@ router.post('/:chatId', asyncHandlerWithAuth(async (req: Request, res: Response) } catch (error) { //START DEBUG LOG : DEBUG-CODE(SEND-MSG-011) - appLogger.error('❌ ERROR IN SEND MESSAGE ENDPOINT:', { error }); + appLogger.error('❌ ERROR IN SEND MESSAGE ENDPOINT', safeOperationalErrorMetadata(error)); //END DEBUG LOG : DEBUG-CODE(SEND-MSG-011) res.status(500).json({ success: false, diff --git a/src/routes/route-course.ts b/src/routes/route-course.ts index 811dd4b4..cbe01db9 100644 --- a/src/routes/route-course.ts +++ b/src/routes/route-course.ts @@ -10,7 +10,7 @@ import path from 'path'; import { appLogger } from '../utils/logger'; import { asyncHandlerWithAuth } from '../middleware/async-handler'; import { EngEAI_MongoDB } from '../db/enge-ai-mongodb'; -import { isCourseStaff } from '../utils/course-staff'; +import { canManageCourseRoster, isCourseStaff } from '../utils/course-staff'; import { isAdminUser } from '../utils/admin'; import { normalizeRouteParams } from '../helpers/route-params'; // @rdschrs: Implemented the capability-gated Writing Feedback instructor page. @@ -55,6 +55,10 @@ async function validateCourseAccess(req: Request, res: Response, next: express.N // Verify user is enrolled or is course staff (faculty instructor, TA, or platform admin) const isInstructor = isCourseStaff(course as import('../types/shared').activeCourse, globalUser); const isEnrolled = globalUser.coursesEnrolled.includes(courseId); + const canManageCourse = canManageCourseRoster( + course as import('../types/shared').activeCourse, + globalUser + ); if (!isInstructor && !isEnrolled) { appLogger.log(`[COURSE-ROUTES] User ${user.puid} not authorized for course ${courseId}, serving error page`); @@ -67,7 +71,8 @@ async function validateCourseAccess(req: Request, res: Response, next: express.N courseName: course.courseName, course: course, isInstructor, - isEnrolled + isEnrolled, + canManageCourse }; // Update session if needed @@ -100,6 +105,25 @@ function requireInstructorForCourse(req: Request, res: Response, next: express.N next(); } +/** + * Middleware: Require faculty-instructor or platform-admin access for a course page. + * + * Runs after {@link validateCourseAccess}. Teaching assistants remain course staff for + * the shared instructor shell, but cannot open configuration pages such as Pathway Library. + */ +function requireInstructorOrAdminForCourse( + req: Request, + res: Response, + next: express.NextFunction +) { + const ctx = (req as any).courseContext; + if (!ctx?.canManageCourse) { + const { courseId } = normalizeRouteParams(req.params); + return res.redirect(`/course/${courseId}/instructor/dashboard`); + } + next(); +} + /** * requireCourseFeaturePage — redirects to dashboard when a course capability is off. * @@ -358,6 +382,7 @@ router.get( '/course/:courseId/instructor/pathway-library', validateCourseAccess, requireInstructorForCourse, + requireInstructorOrAdminForCourse, requireCourseFeaturePage('guidedPathway'), serveInstructorShell() ); diff --git a/src/routes/route-mongo.ts b/src/routes/route-mongo.ts index 96febbcf..7049f8d7 100644 --- a/src/routes/route-mongo.ts +++ b/src/routes/route-mongo.ts @@ -33,7 +33,15 @@ import express, { Request, Response } from 'express'; import archiver from 'archiver'; import { asyncHandler, asyncHandlerWithAuth } from '../middleware/async-handler'; -import { requireAdminForCourseAPI, requireCourseFeatureAPI, requireInstructorForCourseAPI, requireInstructorGlobal, requirePostPeriodAnalyticsAPI, requireRosterManageAPI } from '../middleware/require-course-role'; +import { + requireAdminForCourseAPI, + requireCourseFeatureAPI, + requireInstructorForCourseAPI, + requireInstructorGlobal, + requireInstructorOrAdminForCourseAPI, + requirePostPeriodAnalyticsAPI, + requireRosterManageAPI +} from '../middleware/require-course-role'; import { EngEAI_MongoDB } from '../db/enge-ai-mongodb'; import { InvalidInstructorStruggleTopicReorderError, @@ -101,6 +109,7 @@ import type { ConversationZipExportRow } from '../db/mongo/conversation-export-m import { mountSystemPromptConfigRoutes } from './mongo/system-prompt-config-routes'; import { mountScenarioQuestionRoutes } from './mongo/scenario-questions-routes'; import { mountPathwaysRoutes } from './mongo/pathways-routes'; +import { mountGuidedPathwayFlagRoutes } from './mongo/guided-pathway-flag-routes'; const router = express.Router(); export default router; @@ -1303,7 +1312,7 @@ router.post('/:courseId/instructors', requireInstructorForCourseAPI(['params']), * @response 404 - Course not found * @response 500 - Failed to restart onboarding */ -router.delete('/:id/restart-onboarding', requireInstructorForCourseAPI(['paramsId']), asyncHandlerWithAuth(async (req: Request, res: Response) => { +router.delete('/:id/restart-onboarding', requireInstructorOrAdminForCourseAPI(['paramsId']), asyncHandlerWithAuth(async (req: Request, res: Response) => { const instance = await EngEAI_MongoDB.getInstance(); try { @@ -1321,6 +1330,9 @@ router.delete('/:id/restart-onboarding', requireInstructorForCourseAPI(['paramsI // Get collection names before deleting the course (to use stored names if available) const collectionNames = await instance.getCollectionNames(courseName); + + // Remove rows from the global Guided Pathway alert collection before replacing the course id. + await instance.deleteGuidedPathwayFlagsForCourse(course.id); // Remove course from active-course-list await instance.deleteActiveCourse(course); @@ -1517,7 +1529,7 @@ router.delete('/:id/remove', requireInstructorForCourseAPI(['paramsId']), asyncH * @response 403 - Instructor access required for course * @response 404 - Course not found */ -router.delete('/:id', requireInstructorForCourseAPI(['paramsId']), asyncHandlerWithAuth(async (req: Request, res: Response) => { +router.delete('/:id', requireInstructorOrAdminForCourseAPI(['paramsId']), asyncHandlerWithAuth(async (req: Request, res: Response) => { const instance = await EngEAI_MongoDB.getInstance(); // First check if course exists @@ -1529,7 +1541,10 @@ router.delete('/:id', requireInstructorForCourseAPI(['paramsId']), asyncHandlerW }); } - // Delete the course + // Remove rows owned by this course from the global Guided Pathway alert collection. + await instance.deleteGuidedPathwayFlagsForCourse(existingCourse.id); + + // Delete the course catalog row. await instance.deleteActiveCourse(existingCourse as unknown as activeCourse); res.status(200).json({ @@ -4459,7 +4474,7 @@ router.get( /** * GET /:courseId/course-backup.zip - * Instructor-only ZIP: `{CourseName} - Backup/` with five EJSON files (catalog row + four per-course collections). + * Admin-only ZIP with the catalog row, four per-course collections, and anonymous Guided Pathway alerts. * * @route GET /api/courses/:courseId/course-backup.zip */ @@ -4506,7 +4521,8 @@ router.get( [`${rootPrefix}${names.flags}`, payloads.flagsJson], [`${rootPrefix}${names.scheduledTasks}`, payloads.scheduledTasksJson], [`${rootPrefix}${names.users}`, payloads.usersJson], - [`${rootPrefix}${names.memoryAgent}`, payloads.memoryAgentJson] + [`${rootPrefix}${names.memoryAgent}`, payloads.memoryAgentJson], + [`${rootPrefix}${names.guidedPathwayFlags}`, payloads.guidedPathwayFlagsJson] ]; for (const [path, body] of entries) { archive.append(Buffer.from(`${body}\n`, 'utf-8'), { name: path }); @@ -4800,3 +4816,8 @@ mountScenarioQuestionRoutes(router); // ========= GUIDED PATHWAY LIBRARY API ===== // =========================================== mountPathwaysRoutes(router); + +// =========================================== +// ========= GUIDED PATHWAY ALERTS API ====== +// =========================================== +mountGuidedPathwayFlagRoutes(router); diff --git a/src/server.ts b/src/server.ts index 8ca7562c..4f5a4b0e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -20,6 +20,7 @@ import courseRoutes from './routes/route-course'; // Import course routes import { sendHtmlPageWithBuildComment } from './utils/build-info'; import academicPeriodRoutes from './routes/mongo/academic-period-routes'; import adminCourseRoutes from './routes/mongo/admin-course-routes'; +import adminGuidedPathwayFlagRoutes from './routes/mongo/admin-guided-pathway-flag-routes'; // Import SAML authentication middleware import sessionMiddleware from './middleware/session'; @@ -267,6 +268,7 @@ app.use('/api/courses', mongodbRoutes); // Course management routes app.use('/api/courses', writingFeedbackRoutes); app.use('/api/academic-periods', academicPeriodRoutes); app.use('/api/admin', adminCourseRoutes); +app.use('/api/admin/guided-pathway-flags', adminGuidedPathwayFlagRoutes); app.use('/api/course', courseEntryRoutes); // Course entry routes app.use('/api/user', userManagementRoutes); // User management routes app.use('/api/health', healthRoutes); // Health check routes diff --git a/src/types/shared.ts b/src/types/shared.ts index fc3113f6..d8864f99 100644 --- a/src/types/shared.ts +++ b/src/types/shared.ts @@ -92,6 +92,7 @@ export interface GuidedPathway { order: number; // library list position (ascending) title: string; // instructor-facing card title (library UI) enabled: boolean; // on for this course; false = listed but not evaluated + notifyInstructorOnTrigger: boolean; // creates an anonymous instructor alert when this active pathway wins triggerDescription: string; // fed into the dynamic evaluator prompt assistantResponse: string; // markdown reply; empty => cannot intercept ctas: PathwayCta[]; // resource buttons shown with the predetermined reply @@ -633,6 +634,57 @@ export interface FlagReport { updatedAt: Date; } +/** Lifecycle state for an automatic alert created by a Guided Pathway trigger. */ +export type GuidedPathwayFlagStatus = 'pending' | 'escalated' | 'dismissed'; + +/** Instructor decision accepted by the Guided Pathway alert review API. */ +export type GuidedPathwayFlagDecision = 'escalate' | 'dismiss'; + +/** Admin queue view selector for escalated alerts that still need platform review. */ +export type GuidedPathwayFlagReviewState = 'needs-review' | 'reviewed' | 'all'; + +/** + * Anonymous Guided Pathway alert returned to instructor and admin interfaces. + * + * This is an explicit safe projection of the internal Mongo record. Student identity, + * deduplication data, chat/request identifiers, and identity-reveal audit events are excluded. + */ +export interface GuidedPathwayFlagView { + id: string; // stable alert id used for decision and review actions + courseId: string; // owning course id for course and admin filtering + courseName: string; // course-name snapshot captured when the pathway triggered + pathwayId: string; // winning pathway id for filtering + pathwayTitle: string; // winning pathway title snapshot shown to reviewers + messageText: string; // exact student-authored message; may contain self-identifying text + status: GuidedPathwayFlagStatus; // instructor review lifecycle + triggeredAt: string; // ISO timestamp for the pathway trigger + decidedAt?: string; // ISO timestamp for Escalate or Dismiss + decidedByName?: string; // staff display-name snapshot for admin reviewer filtering + adminReviewedAt?: string; // ISO timestamp for platform-admin review + adminReviewedByName?: string; // platform-admin display-name snapshot +} + +/** One safe Guided Pathway choice returned for administrator queue filtering. */ +export interface GuidedPathwayFlagPathwayFacet { + pathwayId: string; // stable winning-pathway id used by the list filter + pathwayTitle: string; // instructor-facing title snapshot; contains no student data +} + +/** Full-queue filter choices returned with the administrator alert list. */ +export interface GuidedPathwayFlagFacets { + pathways: GuidedPathwayFlagPathwayFacet[]; // choices matching every active filter except pathwayId + reviewers: string[]; // staff display names matching every active filter except reviewer +} + +/** Paginated anonymous result returned by Guided Pathway alert list APIs. */ +export interface GuidedPathwayFlagListPage { + items: GuidedPathwayFlagView[]; // safe alert rows for the current page + page: number; // one-based page number + pageSize: number; // bounded number of rows requested per page + total: number; // total rows matching the supplied filters + facets?: GuidedPathwayFlagFacets; // global-admin full-queue choices; omitted by course list APIs +} + /** * Global user registry From 8ddf12b04cd4851a2993a4e8dccc4a28080150db Mon Sep 17 00:00:00 2001 From: Christopher Rodas Date: Wed, 12 Aug 2026 13:46:32 -0700 Subject: [PATCH 2/7] feat: isolate guided pathway flags by course --- documents/DATA_MIGRATIONS.md | 36 + documents/ENDPOINT_ARCHITECTURE.md | 15 +- documents/MONGO_DATA_LAYER.md | 10 +- package-lock.json | 4 +- package.json | 2 +- public/components/report/flag-instructor.html | 86 +- public/pages/admin-course-selection.html | 13 + .../scripts/api/guided-pathway-flags-api.ts | 12 +- .../scripts/entry/admin-course-selection.ts | 68 ++ .../feature/admin-guided-pathway-flags.ts | 814 +++++++++++------- .../scripts/feature/guided-pathway-flags.ts | 9 +- public/scripts/types.ts | 2 + public/scripts/ui/modal-overlay.ts | 109 ++- public/styles/admin-guided-pathway-flags.css | 54 +- public/styles/course-selection.css | 81 +- src/db/enge-ai-mongodb.ts | 26 +- .../__tests__/course-backup-mongo.test.ts | 46 +- ...ided-pathway-flag-collection-mongo.test.ts | 207 +++++ .../guided-pathway-flag-mongo.test.ts | 281 +++--- .../__tests__/memory-agent-mongo.test.ts | 3 +- .../mongo/__tests__/mongo-collections.test.ts | 2 +- .../report-fixture-seed-mongo.test.ts | 1 + .../__tests__/scenario-progress-mongo.test.ts | 5 +- .../scenario-suggestions-mongo.test.ts | 2 + src/db/mongo/collection-registry-mongo.ts | 9 +- src/db/mongo/course-mongo.ts | 24 +- .../guided-pathway-flag-collection-mongo.ts | 346 ++++++++ src/db/mongo/guided-pathway-flag-mongo.ts | 499 ++++++----- src/db/mongo/mongo-collections.ts | 6 +- src/db/mongo/mongo-constants.ts | 2 +- src/db/mongo/mongo-context.ts | 4 +- .../guided-pathway-flag-admin-routes.test.ts | 52 +- .../mongo/admin-guided-pathway-flag-routes.ts | 8 +- .../mongo/guided-pathway-flag-routes.ts | 3 +- src/routes/route-mongo.ts | 4 +- src/server.ts | 8 + src/types/shared.ts | 2 + 37 files changed, 1980 insertions(+), 875 deletions(-) create mode 100644 src/db/mongo/__tests__/guided-pathway-flag-collection-mongo.test.ts create mode 100644 src/db/mongo/guided-pathway-flag-collection-mongo.ts diff --git a/documents/DATA_MIGRATIONS.md b/documents/DATA_MIGRATIONS.md index 6c422a04..fa03d9db 100644 --- a/documents/DATA_MIGRATIONS.md +++ b/documents/DATA_MIGRATIONS.md @@ -27,11 +27,47 @@ Operational startup migrations (OB-001) are documented here but are **not** tied | **AP-001** | Course `academicPeriodId` backfill | Lazy (request) | `lazyMigrateCourseAcademicPeriod` in `src/db/mongo/academic-period-mongo.ts` via `getActiveCourse` / `getAllActiveCourses` | missing `academicPeriodId` → default `2025W2` period; `$addToSet` on period `courseIds` | **Remove by 2026-06-30** — see [AP-001](#ap-001-academic-period-lazy-link) | | **IPA-001** | Instructor allow-list period scope | Startup (once) | `migrateInstructorAllowances` in `src/helpers/migrate-instructor-allowances.ts` | `instructor-allowed-courses` → `instructor-period-allowances` for `2025W2` | Operational after first successful run | | **ADM-001** | Platform admin `isAdmin` backfill | Startup | `migratePlatformAdmins` in `src/helpers/migrate-platform-admins.ts` | GlobalUsers matching `CHARISMA_RUSDIYANTO_PUID` / `RICHARD_TAPE_PUID` → `isAdmin: true` | Operational — keep unless product changes | +| **GPF-001** | Guided Pathway alert course isolation | Startup + operation gate | `migrateGuidedPathwayFlagsToCourseCollections` in `src/db/mongo/guided-pathway-flag-collection-mongo.ts` | shared `guided-pathway-flags` rows → deterministic course-owned collections | Operational — retain until every environment has no recoverable legacy rows | | **SQ-001** | Scenario Questions collection backfill | Lazy (first API call) | `ensureScenarioQuestionsCollection` in `src/db/mongo/scenario-questions-mongo.ts` | missing `activeCourse.collections.scenarioQuestions` → creates `{courseName}_scenario_questions` + `$set` the field | Keep while any pre-feature course document may lack `collections.scenarioQuestions` | | **SQ-004** | Scenario Progress collection backfill | Lazy (first progress API call) | `ensureScenarioProgressCollection` in `src/db/mongo/scenario-progress-mongo.ts` | missing `activeCourse.collections.scenarioProgress` → creates `{courseName}_scenario_progress` + `$set` the field | Keep while any course may lack `collections.scenarioProgress` | --- +## GPF-001: Guided Pathway alert course isolation + +**Status:** Active (startup migration with operation-level gate) + +**Collections:** legacy `guided-pathway-flags`, `active-course-list`, and one `guided-pathway-flags-course-` collection per course id + +### Behavior + +1. Derive each active course namespace from a 96-bit SHA-256 prefix of its stable `courseId`, persist it as `activeCourse.collections.guidedPathwayFlags`, and ensure the alert indexes. +2. Read distinct string `courseId` values from the legacy shared collection. A legacy course id missing from the active catalog receives its own isolated destination so it cannot merge with another course's rows. +3. Copy at most 200 records at a time with `_id`-keyed replacement upserts. Verify every source `_id` exists in the destination before deleting that exact source batch. +4. Drop the legacy collection when empty. Retain malformed rows without a usable string `courseId` and log their count for manual recovery. + +Startup invokes the migration after academic-period initialization. Every Guided Pathway persistence operation also awaits the memoized migration, so requests cannot race ahead of it. A failed migration promise is discarded and the next call retries from the last verified batch. + +### Idempotency and failure safety + +Destination writes are upserts by Mongo `_id`; retrying after copy but before source deletion does not duplicate an alert. Source deletion never runs for an unverified batch. Concurrent migrators accept a batch already removed by another instance only after confirming no source `_id` remains. Per-course unique alert-id and deduplication indexes remain the runtime guards after migration. + +### Verification (Mongo shell) + +```js +db.getCollection('guided-pathway-flags').countDocuments({ + courseId: { $type: 'string' } +}) +``` + +Target after a successful deployment: `0`. If the legacy collection remains, inspect malformed retained rows before removing it manually. + +### Rollback + +Restore the database from the pre-deployment backup. Do not merge course collections back into a shared namespace while this application version is running because runtime reads and lifecycle cleanup intentionally resolve one course-owned collection. + +--- + ## SP-001: System prompt v1 → v2 **Status:** Active (lazy migrate + lazy unset) diff --git a/documents/ENDPOINT_ARCHITECTURE.md b/documents/ENDPOINT_ARCHITECTURE.md index e746b3b9..022ce95f 100644 --- a/documents/ENDPOINT_ARCHITECTURE.md +++ b/documents/ENDPOINT_ARCHITECTURE.md @@ -318,8 +318,8 @@ records where the field is missing. | GET | `/api/courses/:courseId/guided-pathway-flags` | Yes | Faculty instructor or **Admin** | Paginated anonymous course alert list; optional `status` | | PATCH | `/api/courses/:courseId/guided-pathway-flags/:flagId/decision` | Yes | Faculty instructor or **Admin** | Atomic pending decision; body `{ decision: 'escalate' | 'dismiss' }` | | GET | `/api/admin/guided-pathway-flags` | Yes | **Admin** | Cross-course anonymous queue with period/course/pathway/status/reviewer/date filters | -| PATCH | `/api/admin/guided-pathway-flags/:flagId/review` | Yes | **Admin** | Mark an escalated item reviewed without deleting it | -| POST | `/api/admin/guided-pathway-flags/:flagId/reveal-identity` | Yes | **Admin** | Audit an escalated-item reveal, then return only the current roster display name | +| PATCH | `/api/admin/guided-pathway-flags/:courseId/:flagId/review` | Yes | **Admin** | Mark an escalated item reviewed in its owning course without deleting it | +| POST | `/api/admin/guided-pathway-flags/:courseId/:flagId/reveal-identity` | Yes | **Admin** | Audit an escalated-item reveal in its owning course, then return only the current roster display name | List and action responses use an explicit anonymous projection: pathway/course snapshots, exact student message, trigger/decision/review times, state, and staff reviewer display names. They never @@ -334,10 +334,17 @@ only on escalated records, requires confirmation in the client, is re-masked aft fails closed when the audit write fails. Students and teaching assistants cannot call these APIs; automatic alerts never enter Student Flag History. +Each course stores automatic alerts in its own deterministic Mongo collection. Course routes resolve +only that collection, while the platform-admin queue aggregates canonical active-course collections +server-side. Including `courseId` in admin action paths makes equal alert ids in different courses +unambiguous. Existing rows in the former shared collection are moved by GPF-001; see +[DATA_MIGRATIONS.md](DATA_MIGRATIONS.md#gpf-001-guided-pathway-alert-course-isolation). + `GET /api/admin/course-selection` also returns `data.guidedPathwayEscalationsAwaitingReview`, counting escalated records with no admin review time. -The dashboard refreshes this count on page load and after review actions; there is no polling, live -popup, email, or external notification. +The course-selection dashboard renders that count as a bell badge between the welcome text and logout. +Clicking the bell opens the same anonymous admin queue, prefiltered to escalated items needing review; +the badge refreshes after review actions. There is no polling, email, or external notification. #### Monitor (instructor roster; post-period analytics) diff --git a/documents/MONGO_DATA_LAYER.md b/documents/MONGO_DATA_LAYER.md index 8a9d3011..ef4b7dcd 100644 --- a/documents/MONGO_DATA_LAYER.md +++ b/documents/MONGO_DATA_LAYER.md @@ -39,13 +39,15 @@ - **Lazy migration (SP-001)** — `ensureSystemPromptConfig` maps legacy `collectionOfSystemPromptItems` → `systemPromptConfig`, then `$unset` the legacy field on access; no startup batch scan. Registry and sunset: [DATA_MIGRATIONS.md](DATA_MIGRATIONS.md#sp-001-system-prompt-v1--v2) (remove SP-001 code by **2026-06-30**). - **Runtime assembly** — chat uses JSON defaults when `usePlatformDefault: true`; learning objectives are injected into the `course main intro` module at compose time via `{{course_learning_objectives}}` (not stored in instructor config). - **Chat threads** (`chat-mongo.ts` on `{courseName}_users.chats[]`) — conversation-level starring has been retired. New records and API responses omit `isPinned`; legacy embedded values are ignored on reads and may remain inert in MongoDB without a destructive migration. Optional `pinnedMessageId` continues to represent the separate message-level pin feature. -- **Guided Pathway alerts** (`guided-pathway-flag-mongo.ts` on global `guided-pathway-flags`): - - One collection serves every course; every row carries `courseId` plus course/pathway title snapshots. It is intentionally separate from manual `{courseName}_flags` and is never queried by Student Flag History or `/flags/with-names`. +- **Guided Pathway alerts** (`guided-pathway-flag-mongo.ts` + `guided-pathway-flag-collection-mongo.ts`): + - Each course owns one deterministic physical collection, `guided-pathway-flags-course-`, registered in `activeCourse.collections.guidedPathwayFlags`. The stable course id, rather than the display name, prevents a rename from changing ownership. Alerts remain separate from manual `{courseName}_flags` and are never queried by Student Flag History or `/flags/with-names`. + - **Startup/operation migration (GPF-001)** copies legacy rows from the shared `guided-pathway-flags` collection into course collections in verified batches, including isolated collections for orphan course ids. Source rows are deleted only after all `_id` values are present in the destination; malformed rows are retained for manual recovery. See [DATA_MIGRATIONS.md](DATA_MIGRATIONS.md#gpf-001-guided-pathway-alert-course-isolation). - Internal rows store the exact message, restricted `studentUserId`, opaque deduplication hash, decision/review actors and times, and append-only identity-reveal audit events. PUID and raw client/chat identifiers are never stored. - Instructor/admin list delegates use inclusion projections and map to `GuidedPathwayFlagView`; student identity, deduplication data, and reveal events cannot reach normal API responses. Admin reveal first atomically appends its audit event, then resolves and returns only the current course-roster display name. Audit failure returns no name. - - A unique deduplication index makes transport retries an idempotent no-op. Additional indexes cover course/status/date, course/pathway/status/date, and escalated/unreviewed admin queries. There is no TTL because completed decisions remain viewable. + - A unique deduplication index makes transport retries an idempotent no-op. Additional per-course indexes cover status/date, pathway/status/date, and escalated/unreviewed admin queries. There is no TTL because completed decisions remain viewable. - Instructor decisions are atomic `pending` to `escalated`/`dismissed` transitions. Admin review is a soft completion marker; neither workflow hard-deletes rows. - - Course backup includes an anonymous, course-filtered projection from this global collection. Restarting onboarding or deleting a course removes rows by `courseId` so global records do not become orphaned. + - Course reads and mutations resolve exactly one owned collection. Platform-admin listing and pending-count operations build a server-owned `$unionWith` pipeline over canonical active-course collections; request input never supplies a physical namespace. + - Course backup reads the anonymous projection from that course's collection. Restarting onboarding or deleting a course drops the owned alert collection after counting its rows. - **Topic/week embedded content** (`topic-week-mongo.ts` on `active-course-list`): - **`learningObjectives[]`** per `items[]` — instructor CRUD; flattened via `getAllLearningObjectives` for system-prompt injection. - **`instructorStruggleTopics[]`** per `items[]` — instructor CRUD (`/struggle-topics` API); gated by `features.memoryAgent`; flattened via `getAllInstructorStruggleTopics` for memory-agent catalog only (not main chat system prompt). diff --git a/package-lock.json b/package-lock.json index cab77710..3b8cc2a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tlef-EngE-AI", - "version": "1.8.0", + "version": "1.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tlef-EngE-AI", - "version": "1.8.0", + "version": "1.9.0", "license": "ISC", "dependencies": { "@qdrant/js-client-rest": "^1.15.1", diff --git a/package.json b/package.json index 617e76c4..0f61a4aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tlef-EngE-AI", - "version": "1.8.0", + "version": "1.9.0", "description": "", "main": "dist/server.js", "scripts": { diff --git a/public/components/report/flag-instructor.html b/public/components/report/flag-instructor.html index 65ff90d6..bca35a10 100644 --- a/public/components/report/flag-instructor.html +++ b/public/components/report/flag-instructor.html @@ -207,91 +207,7 @@

Guided Pathway Alerts

- +
diff --git a/public/pages/admin-course-selection.html b/public/pages/admin-course-selection.html index 9bfbe6c6..7011bb35 100644 --- a/public/pages/admin-course-selection.html +++ b/public/pages/admin-course-selection.html @@ -7,6 +7,7 @@ + @@ -18,6 +19,18 @@
Welcome, + ` + : ''; + + return ` +
+
+ ${menuButton} +
+

Guided Pathway Alerts

+ All courses +
+
+ +
+ + + +
+
+ +

Filters

+
+ + + + + + + + +
+ + +
+
+ +

+
+ + `; } -function populateCourseOptions(): void { - const periodId = byId('admin-guided-alert-period')?.value ?? ''; - const visiblePeriods = periodId ? periods.filter((period) => period.id === periodId) : periods; - const courses = visiblePeriods - .flatMap((period) => period.courses) - .filter((course, index, all) => all.findIndex((candidate) => candidate.id === course.id) === index) - .sort((a, b) => a.courseName.localeCompare(b.courseName)); - replaceSelectOptions( - byId('admin-guided-alert-course'), - 'All courses', - courses.map((course) => ({ value: course.id, label: course.courseName })) - ); -} +/** Owns one root-scoped administrator Guided Pathway queue instance. */ +export class AdminGuidedPathwayFlagsController { + private periods: AdminGuidedPathwayPeriodOption[] = []; + private currentPage = 1; + private pageData: GuidedPathwayFlagListPage | null = null; + private queueLoaded = false; + private loadGeneration = 0; + private readonly listeners = new AbortController(); + + constructor( + private readonly root: HTMLElement, + private readonly options: AdminGuidedPathwayFlagsOptions = {} + ) {} + + private element(role: string): T { + const element = this.root.querySelector(`[data-admin-guided-role="${role}"]`); + if (!element) throw new Error(`Missing Guided Pathway queue control: ${role}`); + return element; + } -function refreshFacetOptions(facets: GuidedPathwayFlagFacets | undefined): void { - if (!facets) return; - const pathways = facets.pathways - .map((pathway) => ({ value: pathway.pathwayId, label: pathway.pathwayTitle })) - .sort((a, b) => a.label.localeCompare(b.label)); - replaceSelectOptions(byId('admin-guided-alert-pathway'), 'All pathways', pathways); - - replaceSelectOptions( - byId('admin-guided-alert-reviewer'), - 'All reviewers', - [...facets.reviewers].sort().map((name) => ({ value: name, label: name })) - ); -} + /** + * initialize - Renders controls and loads filter context without fetching queue rows. + * + * @returns When period/course choices and the initial awaiting-review count are ready + */ + public async initialize(): Promise { + this.root.classList.add('admin-guided-alerts'); + this.root.innerHTML = queueMarkup(this.options.showMobileMenuButton === true); + this.bindControls(); + + let context: AdminGuidedPathwayContextPayload | undefined; + if (!this.options.periods || this.options.initialAwaitingReviewCount === undefined) { + try { + context = await loadAdminQueueContext(); + } catch (error) { + this.setQueueStatus(error instanceof Error ? error.message : 'Unable to load course filters.'); + } + } -function currentFilters(): AdminGuidedPathwayFlagFilters { - const status = byId('admin-guided-alert-status-filter')?.value; - const reviewState = byId('admin-guided-alert-review-state')?.value; - return { - page: currentPage, - pageSize: PAGE_SIZE, - status: status ? (status as GuidedPathwayFlagStatus) : undefined, - reviewState: (reviewState || 'all') as GuidedPathwayFlagReviewState, - academicPeriodId: byId('admin-guided-alert-period')?.value || undefined, - courseId: byId('admin-guided-alert-course')?.value || undefined, - pathwayId: byId('admin-guided-alert-pathway')?.value || undefined, - reviewer: byId('admin-guided-alert-reviewer')?.value || undefined, - dateFrom: byId('admin-guided-alert-date-from')?.value || undefined, - dateTo: byId('admin-guided-alert-date-to')?.value || undefined, - }; -} + const sourcePeriods = this.options.periods ?? context?.periods ?? []; + this.periods = sourcePeriods.map((period) => ({ + id: period.id, + title: period.title, + courses: period.courses.map(({ id, courseName }) => ({ id, courseName })), + })); + this.populatePeriodOptions(); + this.applyInitialFilters(); + this.renderQueue(); + + const initialCount = this.options.initialAwaitingReviewCount + ?? context?.guidedPathwayEscalationsAwaitingReview; + if (initialCount !== undefined) this.publishAwaitingReviewCount(initialCount); + replaceFeatherIcons(); + } -async function loadQueue(): Promise { - setQueueBusy(true); - setQueueStatus('Loading Guided Pathway alerts...'); - try { - pageData = await listAdminGuidedPathwayFlags(currentFilters()); - refreshFacetOptions(pageData.facets); - renderQueue(); - setQueueStatus(`${pageData.total} ${pageData.total === 1 ? 'alert' : 'alerts'}`); - } catch (error) { - pageData = null; - renderQueue('Alerts could not be loaded. Use Refresh to try again.'); - setQueueStatus(error instanceof Error ? error.message : 'Unable to load Guided Pathway alerts.'); - } finally { - setQueueBusy(false); + /** Loads the queue once when its tab or modal first becomes visible. */ + public async activate(): Promise { + if (this.queueLoaded) return; + this.queueLoaded = true; + await this.loadQueue(); + } + + /** Reloads queue rows and the external awaiting-review badge. */ + public async refresh(): Promise { + await Promise.all([this.loadQueue(), this.refreshAwaitingReviewCount()]); + } + + /** Detaches persistent control listeners and invalidates in-flight queue renders. */ + public destroy(): void { + this.loadGeneration += 1; + this.listeners.abort(); + } + + private applyInitialFilters(): void { + const filters = this.options.initialFilters; + if (!filters) return; + if (filters.status) this.element('status-filter').value = filters.status; + if (filters.reviewState) this.element('review-state').value = filters.reviewState; + } + + private setQueueStatus(message: string): void { + this.element('status').textContent = message; } -} -async function refreshAwaitingReviewCount(): Promise { - try { - const page = await listAdminGuidedPathwayFlags({ - page: 1, - pageSize: 1, - status: 'escalated', - reviewState: 'needs-review', + private setQueueBusy(busy: boolean): void { + this.element('list').setAttribute('aria-busy', String(busy)); + this.root.querySelectorAll( + 'button, select, input' + ).forEach((control) => { + control.disabled = busy; }); - setAwaitingReviewCount(page.total); - } catch { - // Keep the last known count; the queue itself reports actionable load errors. + if (!busy) this.renderPagination(); } -} -function metadataItem(label: string, value: string): HTMLElement { - const item = document.createElement('span'); - const strong = document.createElement('strong'); - strong.textContent = `${label}: `; - item.append(strong, document.createTextNode(value)); - return item; -} + private publishAwaitingReviewCount(count: number): void { + const safeCount = Number.isFinite(count) && count > 0 ? Math.floor(count) : 0; + this.options.onAwaitingReviewCountChange?.(safeCount); + } -function createRevealControl(flag: GuidedPathwayFlagView): HTMLElement { - const wrapper = document.createElement('div'); - wrapper.className = 'admin-guided-alert-card__identity'; - const label = document.createElement('label'); - label.className = 'admin-guided-alert-card__identity-toggle'; - const checkbox = document.createElement('input'); - checkbox.type = 'checkbox'; - const labelText = document.createElement('span'); - labelText.textContent = 'Reveal student identity'; - const revealed = document.createElement('span'); - revealed.className = 'admin-guided-alert-card__revealed-name'; - revealed.setAttribute('role', 'status'); - revealed.setAttribute('aria-live', 'polite'); - label.append(checkbox, labelText); - wrapper.append(label, revealed); - - checkbox.addEventListener('change', async () => { - if (!checkbox.checked) { - revealed.textContent = ''; - return; - } + private populatePeriodOptions(): void { + replaceSelectOptions( + this.element('period'), + 'All periods', + this.periods.map((period) => ({ value: period.id, label: period.title })) + ); + this.populateCourseOptions(); + } - const confirmation = await showConfirmModal( - 'Reveal student identity', - 'This access is restricted to escalated alerts and will be recorded with your administrator account and the current time.', - 'Reveal identity', - 'Cancel', - 'danger' + private populateCourseOptions(): void { + const periodId = this.element('period').value; + const visiblePeriods = periodId + ? this.periods.filter((period) => period.id === periodId) + : this.periods; + const courses = visiblePeriods + .flatMap((period) => period.courses) + .filter((course, index, all) => all.findIndex((candidate) => candidate.id === course.id) === index) + .sort((a, b) => a.courseName.localeCompare(b.courseName)); + replaceSelectOptions( + this.element('course'), + 'All courses', + courses.map((course) => ({ value: course.id, label: course.courseName })) + ); + } + + private refreshFacetOptions(facets: GuidedPathwayFlagFacets | undefined): void { + if (!facets) return; + replaceSelectOptions( + this.element('pathway'), + 'All pathways', + facets.pathways + .map((pathway) => ({ value: pathway.pathwayId, label: pathway.pathwayTitle })) + .sort((a, b) => a.label.localeCompare(b.label)) + ); + replaceSelectOptions( + this.element('reviewer'), + 'All reviewers', + [...facets.reviewers].sort().map((name) => ({ value: name, label: name })) ); - if (confirmation.action !== 'reveal-identity') { - checkbox.checked = false; - return; + } + + private currentFilters(): AdminGuidedPathwayFlagFilters { + const status = this.element('status-filter').value; + const reviewState = this.element('review-state').value; + return { + page: this.currentPage, + pageSize: PAGE_SIZE, + status: status ? status as GuidedPathwayFlagStatus : undefined, + reviewState: (reviewState || 'all') as GuidedPathwayFlagReviewState, + academicPeriodId: this.element('period').value || undefined, + courseId: this.element('course').value || undefined, + pathwayId: this.element('pathway').value || undefined, + reviewer: this.element('reviewer').value || undefined, + dateFrom: this.element('date-from').value || undefined, + dateTo: this.element('date-to').value || undefined, + }; + } + + private async loadQueue(): Promise { + const generation = ++this.loadGeneration; + this.setQueueBusy(true); + this.setQueueStatus('Loading Guided Pathway alerts...'); + try { + const page = await listAdminGuidedPathwayFlags(this.currentFilters()); + if (generation !== this.loadGeneration) return; + this.pageData = page; + this.refreshFacetOptions(page.facets); + this.renderQueue(); + this.setQueueStatus(`${page.total} ${page.total === 1 ? 'alert' : 'alerts'}`); + } catch (error) { + if (generation !== this.loadGeneration) return; + this.pageData = null; + this.renderQueue('Alerts could not be loaded. Use Refresh to try again.'); + this.setQueueStatus(error instanceof Error ? error.message : 'Unable to load Guided Pathway alerts.'); + } finally { + if (generation === this.loadGeneration) this.setQueueBusy(false); + } + } + + private async refreshAwaitingReviewCount(): Promise { + try { + const page = await listAdminGuidedPathwayFlags({ + page: 1, + pageSize: 1, + status: 'escalated', + reviewState: 'needs-review', + }); + this.publishAwaitingReviewCount(page.total); + } catch { + // Keep the last known badge value; the queue reports actionable request failures. } + } + + private metadataItem(label: string, value: string): HTMLElement { + const item = document.createElement('span'); + const strong = document.createElement('strong'); + strong.textContent = `${label}: `; + item.append(strong, document.createTextNode(value)); + return item; + } + + private createRevealControl(flag: GuidedPathwayFlagView): HTMLElement { + const wrapper = document.createElement('div'); + wrapper.className = 'admin-guided-alert-card__identity'; + const label = document.createElement('label'); + label.className = 'admin-guided-alert-card__identity-toggle'; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + const labelText = document.createElement('span'); + labelText.textContent = 'Reveal student identity'; + const revealed = document.createElement('span'); + revealed.className = 'admin-guided-alert-card__revealed-name'; + revealed.setAttribute('role', 'status'); + revealed.setAttribute('aria-live', 'polite'); + label.append(checkbox, labelText); + wrapper.append(label, revealed); + + checkbox.addEventListener('change', async () => { + if (!checkbox.checked) { + revealed.textContent = ''; + return; + } + + const confirmation = await showConfirmModal( + 'Reveal student identity', + 'This access is restricted to escalated alerts and will be recorded with your administrator account and the current time.', + 'Reveal identity', + 'Cancel', + 'danger' + ); + if (confirmation.action !== 'reveal-identity') { + checkbox.checked = false; + return; + } + + checkbox.disabled = true; + try { + const identity = await revealAdminGuidedPathwayFlagIdentity(flag.courseId, flag.id); + revealed.textContent = `Student: ${identity.studentName}`; + } catch (error) { + checkbox.checked = false; + revealed.textContent = ''; + await showErrorModal( + 'Unable to reveal identity', + error instanceof Error ? error.message : 'The identity could not be revealed.' + ); + } finally { + checkbox.disabled = false; + } + }, { signal: this.listeners.signal }); + return wrapper; + } - checkbox.disabled = true; + private async markReviewed(flag: GuidedPathwayFlagView, button: HTMLButtonElement): Promise { + button.disabled = true; + button.setAttribute('aria-busy', 'true'); try { - const identity = await revealAdminGuidedPathwayFlagIdentity(flag.id); - revealed.textContent = `Student: ${identity.studentName}`; + await reviewAdminGuidedPathwayFlag(flag.courseId, flag.id); + await Promise.all([this.loadQueue(), this.refreshAwaitingReviewCount()]); } catch (error) { - checkbox.checked = false; - revealed.textContent = ''; + if (button.isConnected) button.disabled = false; await showErrorModal( - 'Unable to reveal identity', - error instanceof Error ? error.message : 'The identity could not be revealed.' + 'Unable to mark alert reviewed', + error instanceof Error ? error.message : 'The review could not be saved.' ); } finally { - checkbox.disabled = false; + button.removeAttribute('aria-busy'); } - }); - return wrapper; -} + } -async function markReviewed(flag: GuidedPathwayFlagView, button: HTMLButtonElement): Promise { - button.disabled = true; - button.setAttribute('aria-busy', 'true'); - try { - await reviewAdminGuidedPathwayFlag(flag.id); - await Promise.all([loadQueue(), refreshAwaitingReviewCount()]); - } catch (error) { - button.disabled = false; - await showErrorModal( - 'Unable to mark alert reviewed', - error instanceof Error ? error.message : 'The review could not be saved.' + private createAlertCard(flag: GuidedPathwayFlagView): HTMLElement { + const card = document.createElement('article'); + card.className = `admin-guided-alert-card admin-guided-alert-card--${flag.status}`; + + const header = document.createElement('div'); + header.className = 'admin-guided-alert-card__header'; + const title = document.createElement('h2'); + title.textContent = flag.pathwayTitle; + const status = document.createElement('span'); + status.className = `admin-guided-alert-card__status admin-guided-alert-card__status--${flag.status}`; + status.textContent = STATUS_LABELS[flag.status]; + header.append(title, status); + + const metadata = document.createElement('div'); + metadata.className = 'admin-guided-alert-card__metadata'; + metadata.append( + this.metadataItem('Course', flag.courseName), + this.metadataItem('Triggered', formatDate(flag.triggeredAt)) ); - } finally { - button.removeAttribute('aria-busy'); + if (flag.decidedAt) metadata.append(this.metadataItem('Decision', formatDate(flag.decidedAt))); + if (flag.decidedByName) metadata.append(this.metadataItem('Decision by', flag.decidedByName)); + if (flag.adminReviewedAt) metadata.append(this.metadataItem('Admin review', formatDate(flag.adminReviewedAt))); + if (flag.adminReviewedByName) metadata.append(this.metadataItem('Reviewed by', flag.adminReviewedByName)); + + const messageLabel = document.createElement('h3'); + messageLabel.textContent = 'Student message'; + const message = document.createElement('p'); + message.className = 'admin-guided-alert-card__message'; + message.textContent = flag.messageText; + card.append(header, metadata, messageLabel, message); + + if (flag.status === 'escalated') { + card.appendChild(this.createRevealControl(flag)); + if (!flag.adminReviewedAt) { + const actions = document.createElement('div'); + actions.className = 'admin-guided-alert-card__actions'; + const review = document.createElement('button'); + review.type = 'button'; + review.textContent = 'Mark reviewed'; + review.addEventListener('click', () => void this.markReviewed(flag, review), { + signal: this.listeners.signal + }); + actions.appendChild(review); + card.appendChild(actions); + } + } + + return card; } -} -function createAlertCard(flag: GuidedPathwayFlagView): HTMLElement { - const card = document.createElement('article'); - card.className = `admin-guided-alert-card admin-guided-alert-card--${flag.status}`; - - const header = document.createElement('div'); - header.className = 'admin-guided-alert-card__header'; - const title = document.createElement('h2'); - title.textContent = flag.pathwayTitle; - const status = document.createElement('span'); - status.className = `admin-guided-alert-card__status admin-guided-alert-card__status--${flag.status}`; - status.textContent = statusLabel(flag.status); - header.append(title, status); - - const metadata = document.createElement('div'); - metadata.className = 'admin-guided-alert-card__metadata'; - metadata.append( - metadataItem('Course', flag.courseName), - metadataItem('Triggered', formatDate(flag.triggeredAt)) - ); - if (flag.decidedAt) metadata.append(metadataItem('Decision', formatDate(flag.decidedAt))); - if (flag.decidedByName) metadata.append(metadataItem('Decision by', flag.decidedByName)); - if (flag.adminReviewedAt) metadata.append(metadataItem('Admin review', formatDate(flag.adminReviewedAt))); - if (flag.adminReviewedByName) metadata.append(metadataItem('Reviewed by', flag.adminReviewedByName)); - - const messageLabel = document.createElement('h3'); - messageLabel.textContent = 'Student message'; - const message = document.createElement('p'); - message.className = 'admin-guided-alert-card__message'; - message.textContent = flag.messageText; - - card.append(header, metadata, messageLabel, message); - - if (flag.status === 'escalated') { - card.appendChild(createRevealControl(flag)); - if (!flag.adminReviewedAt) { - const actions = document.createElement('div'); - actions.className = 'admin-guided-alert-card__actions'; - const review = document.createElement('button'); - review.type = 'button'; - review.textContent = 'Mark reviewed'; - review.addEventListener('click', () => void markReviewed(flag, review)); - actions.appendChild(review); - card.appendChild(actions); + private renderQueue(errorMessage?: string): void { + const list = this.element('list'); + list.replaceChildren(); + const items = this.pageData?.items ?? []; + if (errorMessage) { + const error = document.createElement('p'); + error.className = 'admin-guided-alerts__empty admin-guided-alerts__empty--error'; + error.textContent = errorMessage; + list.appendChild(error); + } else if (items.length === 0) { + const empty = document.createElement('p'); + empty.className = 'admin-guided-alerts__empty'; + empty.textContent = 'No Guided Pathway alerts match these filters.'; + list.appendChild(empty); + } else { + for (const item of items) list.appendChild(this.createAlertCard(item)); } + this.renderPagination(); + replaceFeatherIcons(); } - return card; -} + private renderPagination(): void { + const page = this.pageData?.page ?? this.currentPage; + const total = this.pageData?.total ?? 0; + const pageSize = this.pageData?.pageSize ?? PAGE_SIZE; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + this.element('page-summary').textContent = `Page ${page} of ${totalPages}`; + this.element('previous').disabled = page <= 1; + this.element('next').disabled = page >= totalPages; + } -function renderQueue(errorMessage?: string): void { - const list = byId('admin-guided-alerts-list'); - if (!list) return; - list.replaceChildren(); - const items = pageData?.items ?? []; - if (errorMessage) { - const error = document.createElement('p'); - error.className = 'admin-guided-alerts__empty admin-guided-alerts__empty--error'; - error.textContent = errorMessage; - list.appendChild(error); - } else if (items.length === 0) { - const empty = document.createElement('p'); - empty.className = 'admin-guided-alerts__empty'; - empty.textContent = 'No Guided Pathway alerts match these filters.'; - list.appendChild(empty); - } else { - items.forEach((item) => list.appendChild(createAlertCard(item))); + private clearFilters(): void { + this.element('filters').reset(); + this.populateCourseOptions(); + this.currentPage = 1; + void this.loadQueue(); } - const page = pageData?.page ?? currentPage; - const total = pageData?.total ?? 0; - const pageSize = pageData?.pageSize ?? PAGE_SIZE; - const totalPages = Math.max(1, Math.ceil(total / pageSize)); - const summary = byId('admin-guided-alerts-page-summary'); - const previous = byId('admin-guided-alerts-previous'); - const next = byId('admin-guided-alerts-next'); - if (summary) summary.textContent = `Page ${page} of ${totalPages}`; - if (previous) previous.disabled = page <= 1; - if (next) next.disabled = page >= totalPages; + private bindControls(): void { + const signal = this.listeners.signal; + this.element('refresh').addEventListener('click', () => void this.refresh(), { signal }); + this.element('period').addEventListener('change', () => { + this.element('course').value = ''; + this.populateCourseOptions(); + }, { signal }); + this.element('review-state').addEventListener('change', (event) => { + const reviewState = (event.currentTarget as HTMLSelectElement).value; + if (reviewState !== 'all') this.element('status-filter').value = 'escalated'; + }, { signal }); + this.element('status-filter').addEventListener('change', (event) => { + const status = (event.currentTarget as HTMLSelectElement).value; + if (status !== 'escalated') this.element('review-state').value = 'all'; + }, { signal }); + this.element('filters').addEventListener('submit', (event) => { + event.preventDefault(); + this.currentPage = 1; + void this.loadQueue(); + }, { signal }); + this.element('clear').addEventListener('click', () => this.clearFilters(), { signal }); + this.element('previous').addEventListener('click', () => { + if (this.currentPage <= 1) return; + this.currentPage -= 1; + void this.loadQueue(); + }, { signal }); + this.element('next').addEventListener('click', () => { + this.currentPage += 1; + void this.loadQueue(); + }, { signal }); + } } -function clearFilters(): void { - const form = byId('admin-guided-alert-filters'); - form?.reset(); - populateCourseOptions(); - currentPage = 1; - void loadQueue(); -} +let embeddedController: AdminGuidedPathwayFlagsController | null = null; -function bindControls(): void { - byId('admin-guided-alerts-refresh')?.addEventListener('click', () => { - void Promise.all([loadQueue(), refreshAwaitingReviewCount()]); - }); - byId('admin-guided-alert-period')?.addEventListener('change', () => { - const course = byId('admin-guided-alert-course'); - if (course) course.value = ''; - populateCourseOptions(); - }); - byId('admin-guided-alert-review-state')?.addEventListener('change', (event) => { - const reviewState = (event.currentTarget as HTMLSelectElement).value; - const status = byId('admin-guided-alert-status-filter'); - if (reviewState !== 'all' && status) status.value = 'escalated'; - }); - byId('admin-guided-alert-status-filter')?.addEventListener('change', (event) => { - const status = (event.currentTarget as HTMLSelectElement).value; - const reviewState = byId('admin-guided-alert-review-state'); - if (status !== 'escalated' && reviewState) reviewState.value = 'all'; - }); - byId('admin-guided-alert-filters')?.addEventListener('submit', (event) => { - event.preventDefault(); - currentPage = 1; - void loadQueue(); - }); - byId('admin-guided-alert-filters-clear')?.addEventListener('click', clearFilters); - byId('admin-guided-alerts-previous')?.addEventListener('click', () => { - if (currentPage <= 1) return; - currentPage -= 1; - void loadQueue(); - }); - byId('admin-guided-alerts-next')?.addEventListener('click', () => { - currentPage += 1; - void loadQueue(); - }); +function updateEmbeddedAwaitingReviewCount(count: number): void { + const badge = document.getElementById('admin-guided-alert-count'); + if (!badge) return; + badge.textContent = String(count); + badge.setAttribute('aria-label', `${count} awaiting review`); } -/** Initialize the embedded admin queue, its course filters, and awaiting-review count. */ +/** Initialize the reusable queue inside the existing shared Flags tab. */ export async function initializeAdminGuidedPathwayFlags(): Promise { - currentPage = 1; - pageData = null; - queueLoaded = false; - - try { - const context = await loadAdminQueueContext(); - periods = context.periods.map((period) => ({ - id: period.id, - title: period.title, - courses: period.courses.map((course) => ({ - id: course.id, - courseName: course.courseName, - })), - })); - setAwaitingReviewCount(context.guidedPathwayEscalationsAwaitingReview ?? 0); - } catch (error) { - periods = []; - setAwaitingReviewCount(0); - setQueueStatus(error instanceof Error ? error.message : 'Unable to load course filters.'); - } - - populatePeriodOptions(); - const controlsRoot = byId('admin-guided-alert-filters'); - if (controlsRoot && controlsRoot !== boundControlsRoot) { - bindControls(); - boundControlsRoot = controlsRoot; - } - renderQueue(); + const root = document.getElementById('admin-guided-pathway-alerts-content'); + if (!root) return; + embeddedController?.destroy(); + embeddedController = new AdminGuidedPathwayFlagsController(root, { + showMobileMenuButton: true, + onAwaitingReviewCountChange: updateEmbeddedAwaitingReviewCount, + }); + await embeddedController.initialize(); } -/** Load the global queue the first time an administrator opens its Flags tab. */ +/** Load the embedded queue the first time an administrator opens its Flags tab. */ export function activateAdminGuidedPathwayFlags(): void { - if (queueLoaded) return; - queueLoaded = true; - void loadQueue(); + void embeddedController?.activate(); } diff --git a/public/scripts/feature/guided-pathway-flags.ts b/public/scripts/feature/guided-pathway-flags.ts index 5c872911..401e858a 100644 --- a/public/scripts/feature/guided-pathway-flags.ts +++ b/public/scripts/feature/guided-pathway-flags.ts @@ -27,6 +27,11 @@ import { import { showErrorToast, showSuccessToast } from '../ui/toast-notification.js'; const PAGE_SIZE = 20; +const STATUS_LABELS: Record = { + pending: 'Pending review', + escalated: 'Escalated to LTIC', + dismissed: 'Dismissed', +}; let activeCourseId = ''; let activeStatus: GuidedPathwayFlagStatus = 'pending'; @@ -46,9 +51,7 @@ function formatDate(value: string | undefined): string { } function statusLabel(status: GuidedPathwayFlagStatus): string { - if (status === 'escalated') return 'Escalated to LTIC'; - if (status === 'dismissed') return 'Dismissed'; - return 'Pending review'; + return STATUS_LABELS[status]; } function setDomainTab(domain: 'manual' | 'guided'): void { diff --git a/public/scripts/types.ts b/public/scripts/types.ts index da120c8e..83d9cab4 100644 --- a/public/scripts/types.ts +++ b/public/scripts/types.ts @@ -282,6 +282,8 @@ export interface activeCourse { scenarioQuestions?: string; /** Per-course Guided Pathway Library; lazy-provisions on existing courses */ pathways?: string; + /** Course-owned automatic Guided Pathway alerts; derived from stable course id */ + guidedPathwayFlags?: string; }; collectionOfInitialAssistantPrompts?: InitialAssistantPrompt[]; /** @deprecated v2 uses systemPromptConfig; retained for lazy migration reads only */ diff --git a/public/scripts/ui/modal-overlay.ts b/public/scripts/ui/modal-overlay.ts index fd6b791c..c76f8e52 100644 --- a/public/scripts/ui/modal-overlay.ts +++ b/public/scripts/ui/modal-overlay.ts @@ -9,13 +9,14 @@ * - Multiple modal types (error, warning, success, info, disclaimer, custom) * - Keyboard navigation support (ESC to close, Tab navigation) * - Focus management and accessibility + * - Stack-safe nested modal focus, keyboard, and body-scroll handling * - Responsive design * - Animation support * - Promise-based API for user interactions * * @author: gatahcha * @date: 2025-01-27 - * @version: 1.0.0 + * @version: 1.1.0 */ import type { @@ -30,6 +31,10 @@ import { openCatalogEditModal, type CatalogEditModalOptions, } from './catalog-edit-modal.js'; + +let nextModalId = 1; +const visibleModalStack: ModalOverlay[] = []; +let bodyOverflowBeforeModalStack = ''; export { openDivisionReorderModal, type DivisionReorderModalOptions, @@ -119,6 +124,7 @@ export class ModalOverlay { public isVisible = false; private focusableElements: HTMLElement[] = []; private lastFocusedElement: HTMLElement | null = null; + private readonly titleId = `modal-title-${nextModalId++}`; /** * Creates and shows a modal with the specified configuration @@ -150,7 +156,7 @@ export class ModalOverlay { this.overlay.className = 'modal-overlay'; this.overlay.setAttribute('role', 'dialog'); this.overlay.setAttribute('aria-modal', 'true'); - this.overlay.setAttribute('aria-labelledby', 'modal-title'); + this.overlay.setAttribute('aria-labelledby', this.titleId); // Create container this.container = document.createElement('div'); @@ -178,17 +184,9 @@ export class ModalOverlay { this.container.appendChild(footer); } - // Custom body controls (e.g. choice cards) must join the tab trap - this.container.querySelectorAll( - '.modal-body button, .modal-body [href], .modal-body input, .modal-body select, .modal-body textarea' - ).forEach((el) => { - if (!this.focusableElements.includes(el)) { - this.focusableElements.push(el); - } - }); - this.overlay.appendChild(this.container); document.body.appendChild(this.overlay); + this.refreshFocusableElements(); // Set up event listeners this.setupEventListeners(config); @@ -205,7 +203,7 @@ export class ModalOverlay { header.className = 'modal-header'; const title = document.createElement('h2'); - title.id = 'modal-title'; + title.id = this.titleId; title.className = 'modal-title'; title.textContent = config.title; @@ -297,7 +295,7 @@ export class ModalOverlay { // Overlay click to close if (config.closeOnOverlayClick !== false) { this.overlay.addEventListener('click', (e) => { - if (e.target === this.overlay) { + if (e.target === this.overlay && this.isTopmostModal()) { this.close('overlay'); } }); @@ -305,14 +303,7 @@ export class ModalOverlay { // Escape key to close if (config.closeOnEscape !== false) { - const escapeHandler = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - this.close('escape'); - } - }; - - document.addEventListener('keydown', escapeHandler); - this.overlay.setAttribute('data-escape-handler', 'true'); + document.addEventListener('keydown', this.handleEscapeKey); } // Tab navigation (overlay-level) @@ -346,6 +337,8 @@ export class ModalOverlay { * @param e - Keyboard event */ private handleTabNavigation(e: KeyboardEvent): void { + if (!this.isTopmostModal()) return; + this.refreshFocusableElements(); if (this.focusableElements.length === 0) return; const firstElement = this.focusableElements[0]; @@ -373,7 +366,8 @@ export class ModalOverlay { */ private handleEnterKey = (e: KeyboardEvent): void => { // Only handle Enter when this modal is visible - if (!this.isVisible || e.key !== 'Enter') return; + if (!this.isVisible || !this.isTopmostModal() || e.key !== 'Enter') return; + this.refreshFocusableElements(); // Don't handle Enter if user is typing in an input field (except buttons) const activeElement = document.activeElement; @@ -385,7 +379,7 @@ export class ModalOverlay { // Prevent default behavior (form submission, etc.) and stop propagation e.preventDefault(); - e.stopPropagation(); + e.stopImmediatePropagation(); // Modal-specific Enter key handling based on modal type if (!this.container) return; @@ -514,6 +508,15 @@ export class ModalOverlay { if (!this.overlay) return; this.isVisible = true; + const previousTop = visibleModalStack[visibleModalStack.length - 1]; + if (visibleModalStack.length === 0) { + bodyOverflowBeforeModalStack = document.body.style.overflow; + } + if (previousTop?.overlay) { + previousTop.overlay.setAttribute('aria-hidden', 'true'); + previousTop.overlay.inert = true; + } + visibleModalStack.push(this); document.body.style.overflow = 'hidden'; // Double rAF so the initial opacity/transform paint before .show (CSS transition needs it) @@ -523,6 +526,7 @@ export class ModalOverlay { }); }); + this.refreshFocusableElements(); if (this.focusableElements.length > 0) { this.focusableElements[0].focus(); } else { @@ -539,23 +543,31 @@ export class ModalOverlay { if (!this.overlay || !this.isVisible) return; this.isVisible = false; - - // Remove escape key listener - const escapeHandler = this.overlay.getAttribute('data-escape-handler'); - if (escapeHandler) { - document.removeEventListener('keydown', this.handleEscapeKey); - } + const stackIndex = visibleModalStack.indexOf(this); + const wasTopmost = stackIndex === visibleModalStack.length - 1; + if (stackIndex >= 0) visibleModalStack.splice(stackIndex, 1); + document.removeEventListener('keydown', this.handleEscapeKey); + document.removeEventListener('keydown', this.handleEnterKey); // Hide modal with animation this.overlay.classList.remove('show'); this.overlay.classList.add('hide'); - // Restore body scroll - document.body.style.overflow = ''; + // Keep the page locked until the final stacked modal closes. + const nextTop = visibleModalStack[visibleModalStack.length - 1]; + if (nextTop?.overlay) { + nextTop.overlay.removeAttribute('aria-hidden'); + nextTop.overlay.inert = false; + } + if (visibleModalStack.length === 0) { + document.body.style.overflow = bodyOverflowBeforeModalStack; + } - // Restore focus - if (this.lastFocusedElement) { + // Restore focus only when this was the interactive top layer. + if (wasTopmost && this.lastFocusedElement?.isConnected) { this.lastFocusedElement.focus(); + } else if (wasTopmost && nextTop) { + nextTop.focusFirstElement(); } // Clean up after animation @@ -576,11 +588,40 @@ export class ModalOverlay { * @param e - Keyboard event */ private handleEscapeKey = (e: KeyboardEvent): void => { - if (e.key === 'Escape') { + if (e.key === 'Escape' && this.isTopmostModal()) { + e.preventDefault(); + e.stopImmediatePropagation(); this.close('escape'); } }; + private isTopmostModal(): boolean { + return visibleModalStack[visibleModalStack.length - 1] === this; + } + + private refreshFocusableElements(): void { + if (!this.container) { + this.focusableElements = []; + return; + } + const selector = [ + 'button:not([disabled])', + '[href]', + 'input:not([disabled]):not([type="hidden"])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', + '[contenteditable="true"]' + ].join(','); + this.focusableElements = [...this.container.querySelectorAll(selector)] + .filter((element) => !element.closest('[hidden]') && element.getAttribute('aria-hidden') !== 'true'); + } + + private focusFirstElement(): void { + this.refreshFocusableElements(); + (this.focusableElements[0] ?? this.overlay)?.focus(); + } + /** * Cleans up modal resources */ diff --git a/public/styles/admin-guided-pathway-flags.css b/public/styles/admin-guided-pathway-flags.css index 5a5046a8..068286c9 100644 --- a/public/styles/admin-guided-pathway-flags.css +++ b/public/styles/admin-guided-pathway-flags.css @@ -1,4 +1,4 @@ -/* Platform-admin Guided Pathway queue embedded in the shared Flags view. */ +/* Reusable platform-admin Guided Pathway queue for Flags and dashboard modal. */ .admin-guided-alert-count { display: inline-flex; @@ -284,12 +284,45 @@ padding: 1rem 1.1rem; border: 1px solid var(--admin-guided-border); border-inline-start: 4px solid #aab1a7; - border-radius: 10px; + border-radius: 8px; background: var(--chat-bg, #fff); box-shadow: 0 2px 8px rgba(35, 45, 30, 0.07); transition: border-color 0.18s ease, box-shadow 0.18s ease; } +.admin-guided-alerts-modal { + width: min(1120px, 94vw); + max-height: 92vh; +} + +.admin-guided-alerts-modal .modal-body { + min-height: 0; + overflow-y: auto; + scrollbar-gutter: stable; + scrollbar-color: rgba(0, 0, 0, 0.22) transparent; +} + +.admin-guided-alerts-modal .modal-content { + width: 100%; +} + +.admin-guided-alerts-modal .admin-guided-alerts { + margin-bottom: 0; +} + +.admin-guided-alerts-modal .modal-body::-webkit-scrollbar { + background: transparent; +} + +.admin-guided-alerts-modal .modal-body::-webkit-scrollbar-track { + background: transparent; +} + +.admin-guided-alerts-modal .modal-body::-webkit-scrollbar-thumb { + border-radius: 4px; + background: rgba(0, 0, 0, 0.22); +} + .admin-guided-alert-card--pending { border-inline-start-color: #d3a12d; } @@ -483,39 +516,44 @@ position: static; } - .admin-guided-alerts__header .instructor-mobile-hamburger-btn { + #main-content-area .admin-guided-alerts__header .instructor-mobile-hamburger-btn { display: inline-flex; flex-shrink: 0; color: var(--color-chbe-green); } - .admin-guided-alerts__header h1 { + #main-content-area .admin-guided-alerts__header h1 { color: var(--color-chbe-green); } - .admin-guided-alerts__scope { + #main-content-area .admin-guided-alerts__scope { border-color: rgba(77, 122, 47, 0.28); background: rgba(77, 122, 47, 0.1); color: var(--color-chbe-green); } - .admin-guided-alerts__refresh { + #main-content-area .admin-guided-alerts__refresh { align-self: flex-start; border-color: var(--color-chbe-green); background: #fff; color: var(--color-chbe-green); } - .admin-guided-alerts__refresh:hover { + #main-content-area .admin-guided-alerts__refresh:hover { background: var(--admin-guided-soft-green); } - .admin-guided-alerts__refresh:focus-visible { + #main-content-area .admin-guided-alerts__refresh:focus-visible { outline-color: var(--color-chbe-green); } } @media (max-width: 560px) { + .admin-guided-alerts-modal { + width: calc(100vw - 1rem); + max-height: calc(100vh - 1rem); + } + .admin-guided-alert-filters { grid-template-columns: 1fr; padding: 0.85rem; diff --git a/public/styles/course-selection.css b/public/styles/course-selection.css index 3062093b..3926ea5d 100644 --- a/public/styles/course-selection.css +++ b/public/styles/course-selection.css @@ -30,6 +30,60 @@ font-weight: 500; } +.admin-notification-btn { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + padding: 0; + border: 1px solid rgba(255, 255, 255, 0.62); + border-radius: 6px; + background: transparent; + color: #fff; + cursor: pointer; + transition: background-color 0.2s ease, border-color 0.2s ease; +} + +.admin-notification-btn:hover, +.admin-notification-btn[aria-expanded='true'] { + border-color: #fff; + background: rgba(255, 255, 255, 0.14); +} + +.admin-notification-btn:focus-visible { + outline: 2px solid #fff; + outline-offset: 2px; +} + +.admin-notification-btn i, +.admin-notification-btn .feather { + width: 1.2rem; + height: 1.2rem; + stroke: currentColor; +} + +.admin-notification-count { + position: absolute; + top: -0.45rem; + right: -0.5rem; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.25rem; + height: 1.25rem; + padding: 0 0.25rem; + border: 2px solid var(--color-chbe-green); + border-radius: 999px; + background: var(--color-eng-red); + color: #fff; + font-size: 0.7rem; + font-weight: 700; + line-height: 1; + box-sizing: border-box; +} + .logout-btn { background-color: var(--color-eng-red); color: white; @@ -749,6 +803,12 @@ gap: 0.5rem; margin-right: 2.5rem; /* Space for absolutely positioned logout */ } + + .admin-notification-btn { + width: 2.25rem; + height: 2.25rem; + flex: 0 0 2.25rem; + } .welcome-text { font-size: 0.9rem; @@ -842,6 +902,7 @@ .welcome-text { font-size: 0.85rem; + margin: 0; } .course-selection-header { @@ -860,7 +921,7 @@ min-width: 24px; min-height: 24px; } - + .download-database-btn, .remove-all-users-btn { width: 100%; @@ -1301,3 +1362,21 @@ color: #6b7280; padding: 0 0 0 1rem; } + +@media (max-width: 480px) { + .period-header-left { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: start; + gap: 0.3rem 0.5rem; + } + + .period-header-left .period-collapse-btn { + grid-row: 1 / span 2; + } + + .period-header-left .period-count-pill { + grid-column: 2; + justify-self: start; + } +} diff --git a/src/db/enge-ai-mongodb.ts b/src/db/enge-ai-mongodb.ts index 07124c4f..0ee5eb1e 100644 --- a/src/db/enge-ai-mongodb.ts +++ b/src/db/enge-ai-mongodb.ts @@ -747,13 +747,19 @@ export class EngEAI_MongoDB { * Guided Pathway trigger alerts - guided-pathway-flag-mongo.ts * ######################################################### */ - /** Creates or deduplicates one global Guided Pathway trigger alert and returns its safe view. */ + /** Creates or deduplicates one course-owned Guided Pathway trigger alert and returns its safe view. */ public createGuidedPathwayFlag = async (input: GuidedPathwayFlagMongo.CreateGuidedPathwayFlagInput) => GuidedPathwayFlagMongo.createGuidedPathwayFlag(this.ctx(), input); - /** Lists a paginated, explicitly anonymous Guided Pathway alert queue. */ - public listGuidedPathwayFlags = async (filters: GuidedPathwayFlagMongo.GuidedPathwayFlagListFilters) => - GuidedPathwayFlagMongo.listGuidedPathwayFlags(this.ctx(), filters); + /** Lists one course's paginated, explicitly anonymous Guided Pathway alert queue. */ + public listGuidedPathwayFlagsForCourse = async ( + courseId: string, + filters: Pick + ) => GuidedPathwayFlagMongo.listGuidedPathwayFlagsForCourse(this.ctx(), courseId, filters); + + /** Aggregates active course collections into the platform administrator queue. */ + public listGuidedPathwayFlagsForAdmin = async (filters: GuidedPathwayFlagMongo.GuidedPathwayFlagListFilters) => + GuidedPathwayFlagMongo.listGuidedPathwayFlagsForAdmin(this.ctx(), filters); /** Records an immutable course instructor Escalate or Dismiss decision. */ public decideGuidedPathwayFlag = async ( @@ -765,24 +771,30 @@ export class EngEAI_MongoDB { /** Marks one escalated alert reviewed by a platform administrator. */ public markGuidedPathwayFlagAdminReviewed = async ( + courseId: string, flagId: string, actor: GuidedPathwayFlagMongo.GuidedPathwayFlagActor - ) => GuidedPathwayFlagMongo.markGuidedPathwayFlagAdminReviewed(this.ctx(), flagId, actor); + ) => GuidedPathwayFlagMongo.markGuidedPathwayFlagAdminReviewed(this.ctx(), courseId, flagId, actor); /** Audits an administrator reveal and returns only the current course-roster display name. */ public revealGuidedPathwayFlagIdentity = async ( + courseId: string, flagId: string, actor: GuidedPathwayFlagMongo.GuidedPathwayFlagActor - ) => GuidedPathwayFlagMongo.revealGuidedPathwayFlagIdentity(this.ctx(), flagId, actor); + ) => GuidedPathwayFlagMongo.revealGuidedPathwayFlagIdentity(this.ctx(), courseId, flagId, actor); /** Counts escalated alerts that still require platform administrator review. */ public countGuidedPathwayFlagsAwaitingAdminReview = async () => GuidedPathwayFlagMongo.countGuidedPathwayFlagsAwaitingAdminReview(this.ctx()); - /** Removes all global Guided Pathway alerts owned by one course lifecycle. */ + /** Drops the physical Guided Pathway alert collection owned by one course lifecycle. */ public deleteGuidedPathwayFlagsForCourse = async (courseId: string) => GuidedPathwayFlagMongo.deleteGuidedPathwayFlagsForCourse(this.ctx(), courseId); + /** Runs the idempotent GPF-001 shared-to-course collection migration. */ + public migrateGuidedPathwayFlagsToCourseCollections = async () => + GuidedPathwayFlagMongo.migrateGuidedPathwayFlagsToCourseCollections(this.ctx()); + /** * ######################################################### * Scenario Questions (Practice Scenarios) — scenario-questions-mongo.ts diff --git a/src/db/mongo/__tests__/course-backup-mongo.test.ts b/src/db/mongo/__tests__/course-backup-mongo.test.ts index 55faaefd..7046490c 100644 --- a/src/db/mongo/__tests__/course-backup-mongo.test.ts +++ b/src/db/mongo/__tests__/course-backup-mongo.test.ts @@ -9,35 +9,45 @@ jest.mock('../collection-registry-mongo', () => ({ flags: 'TestCourse_flags', memoryAgent: 'TestCourse_memory-agent', scheduledTasks: 'TestCourse_scheduled_tasks', - scenarioQuestions: 'TestCourse_scenario_questions', - scenarioProgress: 'TestCourse_scenario_progress', - pathways: 'TestCourse_pathways', - }) -})); - + scenarioQuestions: 'TestCourse_scenario_questions', + scenarioProgress: 'TestCourse_scenario_progress', + pathways: 'TestCourse_pathways', + guidedPathwayFlags: 'resolved-by-guided-pathway-owner', + }) +})); + jest.mock('../mongo-collections', () => ({ activeCourseListCollection: jest.fn(() => ({ + find: jest.fn(() => ({ + toArray: jest.fn().mockResolvedValue([{ + id: 'course-id-1', + courseName: 'TestCourse' + }]) + })), findOne: jest.fn().mockResolvedValue({ id: 'course-id-1', courseName: 'TestCourse', _id: new ObjectId() - }) + }), + updateOne: jest.fn().mockResolvedValue({ matchedCount: 1 }) })), guidedPathwayFlagsCollection: jest.fn((db) => db.collection('guided-pathway-flags')) })); - -import { getCollectionNames } from '../collection-registry-mongo'; -import { activeCourseListCollection } from '../mongo-collections'; - -describe('course-backup-mongo loadCourseMongoBackupPayloads', () => { - it('queries catalog and four per-course collections; EJSON round-trips ObjectIds', async () => { - const oid = new ObjectId(); - const rows: Record = { + +import { getCollectionNames } from '../collection-registry-mongo'; +import { guidedPathwayFlagCollectionNameForCourse } from '../guided-pathway-flag-collection-mongo'; +import { activeCourseListCollection } from '../mongo-collections'; + +describe('course-backup-mongo loadCourseMongoBackupPayloads', () => { + it('queries catalog and course-owned collections; EJSON round-trips ObjectIds', async () => { + const oid = new ObjectId(); + const guidedPathwayCollection = guidedPathwayFlagCollectionNameForCourse('course-id-1'); + const rows: Record = { TestCourse_users: [{ _id: oid, userId: 'student-1' }], TestCourse_flags: [{ id: 'f1' }], TestCourse_scheduled_tasks: [], 'TestCourse_memory-agent': [{ userId: 'student-1', struggleTopics: ['a'] }], - 'guided-pathway-flags': [{ + [guidedPathwayCollection]: [{ id: 'gpf-1', courseId: 'course-id-1', courseName: 'TestCourse', @@ -54,6 +64,10 @@ describe('course-backup-mongo loadCourseMongoBackupPayloads', () => { const mockDb = { collection: (name: string) => ({ + createIndex: jest.fn().mockResolvedValue('index-name'), + distinct: jest.fn().mockResolvedValue([]), + countDocuments: jest.fn().mockResolvedValue((rows[name] ?? []).length), + drop: jest.fn().mockResolvedValue(true), find: (filter: { courseId?: string } = {}) => { const matching = (rows[name] ?? []).filter((row: any) => !filter.courseId || row.courseId === filter.courseId diff --git a/src/db/mongo/__tests__/guided-pathway-flag-collection-mongo.test.ts b/src/db/mongo/__tests__/guided-pathway-flag-collection-mongo.test.ts new file mode 100644 index 00000000..c970a653 --- /dev/null +++ b/src/db/mongo/__tests__/guided-pathway-flag-collection-mongo.test.ts @@ -0,0 +1,207 @@ +/** Tests for deterministic course ownership and the idempotent GPF-001 migration. */ + +import type { MongoDalContext } from '../mongo-context'; + +jest.mock('../mongo-collections', () => ({ + activeCourseListCollection: jest.fn(), + guidedPathwayFlagsCollection: jest.fn() +})); + +jest.mock('../../../utils/logger', () => ({ + appLogger: { warn: jest.fn() } +})); + +import { activeCourseListCollection, guidedPathwayFlagsCollection } from '../mongo-collections'; +import { + guidedPathwayFlagCollectionNameForCourse, + migrateGuidedPathwayFlagsToCourseCollections +} from '../guided-pathway-flag-collection-mongo'; + +function context(db: Record): MongoDalContext { + return { + db: db as unknown as MongoDalContext['db'], + idGenerator: {} as MongoDalContext['idGenerator'], + collectionNamesCache: new Map([['Existing Course', {} as any]]), + scheduledTasksIndexesEnsured: new Set() + }; +} + +function migrationCursor(rows: unknown[]) { + let delivered = false; + const cursor: any = { + sort: jest.fn(), + limit: jest.fn(), + toArray: jest.fn().mockImplementation(async () => { + if (delivered) return []; + delivered = true; + return rows; + }) + }; + cursor.sort.mockReturnValue(cursor); + cursor.limit.mockReturnValue(cursor); + return cursor; +} + +function targetCollection() { + return { + createIndex: jest.fn().mockResolvedValue('ok'), + bulkWrite: jest.fn().mockResolvedValue({}), + countDocuments: jest.fn().mockImplementation(async (filter: any) => filter._id?.$in?.length ?? 0) + }; +} + +describe('Guided Pathway course collection ownership', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('derives a stable Mongo-safe namespace from course id rather than display name', () => { + const first = guidedPathwayFlagCollectionNameForCourse('course-stable-id'); + const afterRename = guidedPathwayFlagCollectionNameForCourse('course-stable-id'); + const otherCourse = guidedPathwayFlagCollectionNameForCourse('another-course-id'); + + expect(first).toBe(afterRename); + expect(first).toMatch(/^guided-pathway-flags-course-[a-f0-9]{24}$/); + expect(otherCourse).not.toBe(first); + }); + + it('copies active and orphan rows, verifies each batch, then removes the empty legacy collection', async () => { + const activeCourseId = 'course-1'; + const orphanCourseId = 'deleted-course'; + const activeName = guidedPathwayFlagCollectionNameForCourse(activeCourseId); + const orphanName = guidedPathwayFlagCollectionNameForCourse(orphanCourseId); + const activeRows = [{ _id: 'mongo-1', id: 'flag-1', courseId: activeCourseId }]; + const orphanRows = [{ _id: 'mongo-2', id: 'flag-2', courseId: orphanCourseId }]; + + const catalog = { + find: jest.fn().mockReturnValue({ + toArray: jest.fn().mockResolvedValue([{ + id: activeCourseId, + courseName: 'Existing Course', + collections: { + users: 'users', + flags: 'flags', + memoryAgent: 'memory' + } + }]) + }), + updateOne: jest.fn().mockResolvedValue({ modifiedCount: 1 }) + }; + const sourceCursors = new Map([ + [activeCourseId, migrationCursor(activeRows)], + [orphanCourseId, migrationCursor(orphanRows)] + ]); + const source = { + distinct: jest.fn().mockResolvedValue([activeCourseId, orphanCourseId]), + find: jest.fn().mockImplementation(({ courseId }: { courseId: string }) => sourceCursors.get(courseId)), + deleteMany: jest.fn().mockImplementation(async (filter: any) => ({ + deletedCount: filter._id.$in.length + })), + countDocuments: jest.fn().mockResolvedValue(0), + drop: jest.fn().mockResolvedValue(true) + }; + const targets = new Map([ + [activeName, targetCollection()], + [orphanName, targetCollection()] + ]); + const db = { + createCollection: jest.fn().mockResolvedValue({}), + collection: jest.fn().mockImplementation((name: string) => targets.get(name)) + }; + (activeCourseListCollection as jest.Mock).mockReturnValue(catalog); + (guidedPathwayFlagsCollection as jest.Mock).mockReturnValue(source); + + const result = await migrateGuidedPathwayFlagsToCourseCollections(context(db)); + + expect(result).toEqual({ + registeredCourseCollections: 1, + migratedRows: 2, + orphanCourseCollections: 1, + retainedLegacyRows: 0 + }); + expect(catalog.updateOne).toHaveBeenCalledWith( + { id: activeCourseId }, + { $set: { 'collections.guidedPathwayFlags': activeName } } + ); + expect(targets.get(activeName)?.bulkWrite).toHaveBeenCalledWith([ + { + replaceOne: { + filter: { _id: 'mongo-1' }, + replacement: activeRows[0], + upsert: true + } + } + ], { ordered: true }); + expect(targets.get(orphanName)?.bulkWrite).toHaveBeenCalledTimes(1); + expect(targets.get(activeName)?.countDocuments.mock.invocationCallOrder[0]).toBeLessThan( + source.deleteMany.mock.invocationCallOrder[0] + ); + expect(source.drop).toHaveBeenCalledTimes(1); + }); + + it('keeps the source batch when destination verification fails', async () => { + const courseId = 'course-verification'; + const collectionName = guidedPathwayFlagCollectionNameForCourse(courseId); + const row = { _id: 'mongo-unverified', id: 'flag-unverified', courseId }; + const catalog = { + find: jest.fn().mockReturnValue({ toArray: jest.fn().mockResolvedValue([]) }), + updateOne: jest.fn() + }; + const source = { + distinct: jest.fn().mockResolvedValue([courseId]), + find: jest.fn().mockReturnValue(migrationCursor([row])), + deleteMany: jest.fn(), + countDocuments: jest.fn(), + drop: jest.fn() + }; + const target = { + ...targetCollection(), + countDocuments: jest.fn().mockResolvedValue(0) + }; + const db = { + createCollection: jest.fn().mockResolvedValue({}), + collection: jest.fn().mockImplementation((name: string) => name === collectionName ? target : undefined) + }; + (activeCourseListCollection as jest.Mock).mockReturnValue(catalog); + (guidedPathwayFlagsCollection as jest.Mock).mockReturnValue(source); + + await expect(migrateGuidedPathwayFlagsToCourseCollections(context(db))).rejects.toThrow( + 'GPF-001 verification failed' + ); + expect(source.deleteMany).not.toHaveBeenCalled(); + expect(source.drop).not.toHaveBeenCalled(); + }); + + it('accepts a verified source batch already deleted by a concurrent migrator', async () => { + const courseId = 'course-concurrent'; + const collectionName = guidedPathwayFlagCollectionNameForCourse(courseId); + const row = { _id: 'mongo-concurrent', id: 'flag-concurrent', courseId }; + const catalog = { + find: jest.fn().mockReturnValue({ toArray: jest.fn().mockResolvedValue([]) }), + updateOne: jest.fn() + }; + const source = { + distinct: jest.fn().mockResolvedValue([courseId]), + find: jest.fn().mockReturnValue(migrationCursor([row])), + deleteMany: jest.fn().mockResolvedValue({ deletedCount: 0 }), + countDocuments: jest.fn().mockResolvedValue(0), + drop: jest.fn().mockResolvedValue(true) + }; + const target = targetCollection(); + const db = { + createCollection: jest.fn().mockResolvedValue({}), + collection: jest.fn().mockImplementation((name: string) => name === collectionName ? target : undefined) + }; + (activeCourseListCollection as jest.Mock).mockReturnValue(catalog); + (guidedPathwayFlagsCollection as jest.Mock).mockReturnValue(source); + + await expect(migrateGuidedPathwayFlagsToCourseCollections(context(db))).resolves.toEqual({ + registeredCourseCollections: 0, + migratedRows: 0, + orphanCourseCollections: 1, + retainedLegacyRows: 0 + }); + expect(source.countDocuments).toHaveBeenCalledWith({ _id: { $in: ['mongo-concurrent'] } }); + expect(source.drop).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/db/mongo/__tests__/guided-pathway-flag-mongo.test.ts b/src/db/mongo/__tests__/guided-pathway-flag-mongo.test.ts index 16d2b2a6..25f1622f 100644 --- a/src/db/mongo/__tests__/guided-pathway-flag-mongo.test.ts +++ b/src/db/mongo/__tests__/guided-pathway-flag-mongo.test.ts @@ -1,30 +1,47 @@ -/** Focused persistence and privacy tests for the global Guided Pathway alert collection. */ +/** Focused persistence and privacy tests for course-owned Guided Pathway alert collections. */ import type { MongoDalContext } from '../mongo-context'; -jest.mock('../mongo-collections', () => ({ - guidedPathwayFlagsCollection: jest.fn() +jest.mock('../guided-pathway-flag-collection-mongo', () => ({ + GuidedPathwayFlagCourseNotFoundError: class GuidedPathwayFlagCourseNotFoundError extends Error {}, + getGuidedPathwayFlagCourseScope: jest.fn(), + guidedPathwayFlagCourseCollection: jest.fn(), + listGuidedPathwayFlagCourseScopes: jest.fn(), + migrateGuidedPathwayFlagsToCourseCollections: jest.fn() })); jest.mock('../course-user-mongo', () => ({ getCourseUsersMongoCollection: jest.fn() })); -import { guidedPathwayFlagsCollection } from '../mongo-collections'; import { getCourseUsersMongoCollection } from '../course-user-mongo'; +import { + GuidedPathwayFlagCourseNotFoundError, + getGuidedPathwayFlagCourseScope, + guidedPathwayFlagCourseCollection, + listGuidedPathwayFlagCourseScopes +} from '../guided-pathway-flag-collection-mongo'; import { countGuidedPathwayFlagsAwaitingAdminReview, createGuidedPathwayFlag, decideGuidedPathwayFlag, deleteGuidedPathwayFlagsForCourse, - listGuidedPathwayFlags, + GuidedPathwayFlagNotFoundError, + listGuidedPathwayFlagsForAdmin, + listGuidedPathwayFlagsForCourse, markGuidedPathwayFlagAdminReviewed, revealGuidedPathwayFlagIdentity } from '../guided-pathway-flag-mongo'; -function context(): MongoDalContext { +const courseScope = { + courseId: 'course-1', + courseName: 'Test Course', + collectionName: 'guided-pathway-flags-course-one' +}; + +function context(dbOverrides: Record = {}): MongoDalContext { return { - db: {} as MongoDalContext['db'], + db: { collection: jest.fn(), ...dbOverrides } as unknown as MongoDalContext['db'], idGenerator: {} as MongoDalContext['idGenerator'], collectionNamesCache: new Map(), scheduledTasksIndexesEnsured: new Set() @@ -67,20 +84,19 @@ function cursorFor(rows: unknown[]) { function collection(overrides: Record = {}) { return { - createIndex: jest.fn().mockResolvedValue('ok'), insertOne: jest.fn().mockResolvedValue({ insertedId: 'mongo-id' }), findOne: jest.fn().mockResolvedValue(null), find: jest.fn().mockReturnValue(cursorFor([])), countDocuments: jest.fn().mockResolvedValue(0), findOneAndUpdate: jest.fn().mockResolvedValue(null), - deleteMany: jest.fn().mockResolvedValue({ deletedCount: 0 }), + drop: jest.fn().mockResolvedValue(true), ...overrides } as any; } const createInput = { courseId: 'course-1', - courseName: 'Test Course', + courseName: 'Caller Course Snapshot', pathwayId: 'pathway-1', pathwayTitle: 'Support', messageText: 'I need help', @@ -93,32 +109,33 @@ const createInput = { describe('guided-pathway-flag-mongo', () => { beforeEach(() => { jest.clearAllMocks(); + (getGuidedPathwayFlagCourseScope as jest.Mock).mockResolvedValue(courseScope); + (listGuidedPathwayFlagCourseScopes as jest.Mock).mockResolvedValue([courseScope]); }); - it('stores only an opaque message-bound dedupe key and returns an anonymous view', async () => { + it('stores only an opaque message-bound dedupe key in the resolved course collection', async () => { const coll = collection(); - (guidedPathwayFlagsCollection as jest.Mock).mockReturnValue(coll); - const ctx = context(); + (guidedPathwayFlagCourseCollection as jest.Mock).mockReturnValue(coll); - const first = await createGuidedPathwayFlag(ctx, createInput); - await createGuidedPathwayFlag(ctx, { ...createInput, messageText: 'A different message' }); + const first = await createGuidedPathwayFlag(context(), createInput); + await createGuidedPathwayFlag(context(), { ...createInput, messageText: 'A different message' }); expect(first.created).toBe(true); expect(first.flag).toMatchObject({ courseId: 'course-1', + courseName: 'Test Course', pathwayTitle: 'Support', messageText: 'I need help', status: 'pending' }); expect(first.flag).not.toHaveProperty('studentUserId'); expect(first.flag).not.toHaveProperty('chatId'); - expect(first.flag).not.toHaveProperty('clientMessageId'); const firstDoc = coll.insertOne.mock.calls[0][0]; const secondDoc = coll.insertOne.mock.calls[1][0]; expect(firstDoc.dedupeKey).toMatch(/^[a-f0-9]{64}$/); expect(firstDoc.dedupeKey).not.toBe(secondDoc.dedupeKey); - expect(firstDoc).not.toHaveProperty('chatId'); + expect(firstDoc.courseName).toBe('Test Course'); expect(firstDoc).not.toHaveProperty('clientMessageId'); }); @@ -127,7 +144,7 @@ describe('guided-pathway-flag-mongo', () => { insertOne: jest.fn().mockRejectedValue({ code: 11000 }), findOne: jest.fn().mockResolvedValue(rawFlag()) }); - (guidedPathwayFlagsCollection as jest.Mock).mockReturnValue(coll); + (guidedPathwayFlagCourseCollection as jest.Mock).mockReturnValue(coll); const result = await createGuidedPathwayFlag(context(), createInput); @@ -135,21 +152,20 @@ describe('guided-pathway-flag-mongo', () => { expect(result.flag.id).toBe('flag-1'); expect(result.flag).not.toHaveProperty('studentUserId'); expect(coll.findOne).toHaveBeenCalledWith( - expect.objectContaining({ dedupeKey: expect.any(String) }), + expect.objectContaining({ courseId: 'course-1', dedupeKey: expect.any(String) }), expect.objectContaining({ projection: expect.objectContaining({ id: 1, messageText: 1 }) }) ); }); - it('double-enforces the safe allowlist when listing rows', async () => { + it('double-enforces the safe allowlist when listing one course', async () => { const cursor = cursorFor([rawFlag()]); const coll = collection({ find: jest.fn().mockReturnValue(cursor), countDocuments: jest.fn().mockResolvedValue(1) }); - (guidedPathwayFlagsCollection as jest.Mock).mockReturnValue(coll); + (guidedPathwayFlagCourseCollection as jest.Mock).mockReturnValue(coll); - const page = await listGuidedPathwayFlags(context(), { - courseId: 'course-1', + const page = await listGuidedPathwayFlagsForCourse(context(), 'course-1', { page: 1, pageSize: 20 }); @@ -164,77 +180,65 @@ describe('guided-pathway-flag-mongo', () => { ); }); - it('returns full-scope safe admin facets while excluding each facet own active filter', async () => { - const pageCursor = cursorFor([rawFlag({ pathwayId: 'pathway-1' })]); - const pathwayCursor = cursorFor([ - rawFlag({ pathwayId: 'pathway-1', pathwayTitle: 'Newest Support' }), - rawFlag({ pathwayId: 'pathway-1', pathwayTitle: 'Older Support' }), - rawFlag({ pathwayId: 'pathway-2', pathwayTitle: 'Academic Help' }) - ]); - const reviewerCursor = cursorFor([ - { decidedByName: 'Instructor B', messageText: 'must not be returned' }, - { decidedByName: 'Instructor A', adminReviewedByName: 'Admin C', studentUserId: 'restricted' }, - { adminReviewedByName: 'Admin C' } - ]); - const find = jest.fn() - .mockReturnValueOnce(pageCursor) - .mockReturnValueOnce(pathwayCursor) - .mockReturnValueOnce(reviewerCursor); - const coll = collection({ - find, - countDocuments: jest.fn().mockResolvedValue(1) - }); - (guidedPathwayFlagsCollection as jest.Mock).mockReturnValue(coll); + it('maps a missing active course to the public not-found contract', async () => { + (getGuidedPathwayFlagCourseScope as jest.Mock).mockRejectedValue( + new GuidedPathwayFlagCourseNotFoundError() + ); - const page = await listGuidedPathwayFlags(context(), { - courseId: 'course-1', + await expect(listGuidedPathwayFlagsForCourse(context(), 'missing-course', { + page: 1, + pageSize: 20 + })).rejects.toBeInstanceOf(GuidedPathwayFlagNotFoundError); + }); + + it('builds a canonical cross-course union and returns only safe admin facets', async () => { + const secondScope = { + courseId: 'course-2', + courseName: 'Second Course', + collectionName: 'guided-pathway-flags-course-two' + }; + (listGuidedPathwayFlagCourseScopes as jest.Mock).mockResolvedValue([courseScope, secondScope]); + const toArray = jest.fn().mockResolvedValue([{ + items: [rawFlag()], + totals: [{ value: 1 }], + pathways: [{ pathwayId: 'pathway-1', pathwayTitle: 'Support' }], + reviewers: [{ name: 'Instructor A' }, { name: 'Admin B' }] + }]); + const aggregate = jest.fn().mockReturnValue({ toArray }); + const dbCollection = jest.fn().mockReturnValue({ aggregate }); + + const page = await listGuidedPathwayFlagsForAdmin(context({ collection: dbCollection }), { status: 'escalated', - pathwayId: 'pathway-1', - reviewer: 'Instructor A', + reviewState: 'needs-review', includeFacets: true, escalatedFirst: true }); + expect(page.total).toBe(1); expect(page.facets).toEqual({ - pathways: [ - { pathwayId: 'pathway-2', pathwayTitle: 'Academic Help' }, - { pathwayId: 'pathway-1', pathwayTitle: 'Newest Support' } - ], - reviewers: ['Admin C', 'Instructor A', 'Instructor B'] - }); - - const pageFilter = find.mock.calls[0][0]; - const pathwayFacetFilter = find.mock.calls[1][0]; - const reviewerFacetFilter = find.mock.calls[2][0]; - expect(pageFilter).toMatchObject({ - courseId: 'course-1', - status: 'escalated', - pathwayId: 'pathway-1', - $or: [{ decidedByName: 'Instructor A' }, { adminReviewedByName: 'Instructor A' }] - }); - expect(pathwayFacetFilter).not.toHaveProperty('pathwayId'); - expect(pathwayFacetFilter.$or).toBeDefined(); - expect(reviewerFacetFilter.pathwayId).toBe('pathway-1'); - expect(reviewerFacetFilter).not.toHaveProperty('$or'); - - const pathwayProjection = find.mock.calls[1][1].projection; - const reviewerProjection = find.mock.calls[2][1].projection; - expect(pathwayProjection).toEqual({ - _id: 0, - pathwayId: 1, - pathwayTitle: 1, - triggeredAt: 1 + pathways: [{ pathwayId: 'pathway-1', pathwayTitle: 'Support' }], + reviewers: ['Instructor A', 'Admin B'] }); - expect(reviewerProjection).toEqual({ - _id: 0, - decidedByName: 1, - adminReviewedByName: 1 + expect(page.items[0]).not.toHaveProperty('studentUserId'); + expect(dbCollection).toHaveBeenCalledWith(courseScope.collectionName); + + const pipeline = aggregate.mock.calls[0][0]; + expect(pipeline[0]).toEqual({ $match: { courseId: 'course-1' } }); + expect(pipeline[1]).toEqual({ + $unionWith: { + coll: secondScope.collectionName, + pipeline: [{ $match: { courseId: 'course-2' } }] + } }); - expect(pathwayProjection).not.toHaveProperty('messageText'); - expect(reviewerProjection).not.toHaveProperty('studentUserId'); + const facet = pipeline[2].$facet; + const itemProjection = facet.items.at(-1).$project; + expect(itemProjection).toEqual(expect.objectContaining({ id: 1, messageText: 1 })); + expect(itemProjection).not.toHaveProperty('studentUserId'); + expect(facet.pathways.some((stage: any) => stage.$project?.messageText)).toBe(false); + expect(facet.reviewers.some((stage: any) => stage.$project?.studentUserId)).toBe(false); }); - it('atomically records an instructor escalation and returns a safe view', async () => { + it('atomically records an instructor escalation within the requested course', async () => { const coll = collection({ findOneAndUpdate: jest.fn().mockResolvedValue(rawFlag({ status: 'escalated', @@ -242,7 +246,7 @@ describe('guided-pathway-flag-mongo', () => { decidedByName: 'Instructor' })) }); - (guidedPathwayFlagsCollection as jest.Mock).mockReturnValue(coll); + (guidedPathwayFlagCourseCollection as jest.Mock).mockReturnValue(coll); const result = await decideGuidedPathwayFlag( context(), @@ -267,30 +271,46 @@ describe('guided-pathway-flag-mongo', () => { expect(result).not.toHaveProperty('decidedByUserId'); }); - it('rejects a competing decision after another reviewer completed the pending transition', async () => { - const coll = collection({ - findOneAndUpdate: jest.fn().mockResolvedValue(null), - findOne: jest.fn().mockResolvedValue(rawFlag({ status: 'dismissed' })) + it('does not merge equal alert ids across two course collections', async () => { + const secondScope = { + courseId: 'course-2', + courseName: 'Second Course', + collectionName: 'guided-pathway-flags-course-two' + }; + const firstCollection = collection(); + const secondCollection = collection({ + findOneAndUpdate: jest.fn().mockResolvedValue(rawFlag({ + courseId: 'course-2', + courseName: 'Second Course', + status: 'dismissed' + })) }); - (guidedPathwayFlagsCollection as jest.Mock).mockReturnValue(coll); + (getGuidedPathwayFlagCourseScope as jest.Mock).mockImplementation( + async (_ctx: MongoDalContext, courseId: string) => courseId === 'course-1' ? courseScope : secondScope + ); + (guidedPathwayFlagCourseCollection as jest.Mock).mockImplementation( + (_ctx: MongoDalContext, scope: typeof courseScope) => + scope.courseId === 'course-1' ? firstCollection : secondCollection + ); - await expect(decideGuidedPathwayFlag( + const result = await decideGuidedPathwayFlag( context(), - 'course-1', + 'course-2', 'flag-1', - 'escalate', - { userId: 'instructor-1', name: 'Instructor' } - )).rejects.toMatchObject({ - name: 'GuidedPathwayFlagConflictError' - }); - expect(coll.findOneAndUpdate.mock.calls[0][0]).toEqual({ - id: 'flag-1', - courseId: 'course-1', - status: 'pending' - }); + 'dismiss', + { userId: 'instructor-2', name: 'Instructor Two' } + ); + + expect(result.courseId).toBe('course-2'); + expect(secondCollection.findOneAndUpdate).toHaveBeenCalledWith( + expect.objectContaining({ id: 'flag-1', courseId: 'course-2' }), + expect.any(Object), + expect.any(Object) + ); + expect(firstCollection.findOneAndUpdate).not.toHaveBeenCalled(); }); - it('records platform review once using an atomic escalated-only filter', async () => { + it('records platform review once using course and lifecycle predicates', async () => { const coll = collection({ findOneAndUpdate: jest.fn().mockResolvedValue(rawFlag({ status: 'escalated', @@ -298,16 +318,18 @@ describe('guided-pathway-flag-mongo', () => { adminReviewedByName: 'Admin' })) }); - (guidedPathwayFlagsCollection as jest.Mock).mockReturnValue(coll); + (guidedPathwayFlagCourseCollection as jest.Mock).mockReturnValue(coll); const result = await markGuidedPathwayFlagAdminReviewed( context(), + 'course-1', 'flag-1', { userId: 'admin-1', name: 'Admin' } ); expect(coll.findOneAndUpdate.mock.calls[0][0]).toEqual({ id: 'flag-1', + courseId: 'course-1', status: 'escalated', adminReviewedAt: { $exists: false } }); @@ -317,36 +339,29 @@ describe('guided-pathway-flag-mongo', () => { it('appends the reveal audit before returning only the current roster display name', async () => { const coll = collection({ - findOneAndUpdate: jest.fn().mockResolvedValue({ - courseName: 'Test Course', - studentUserId: 'student-1' - }) + findOneAndUpdate: jest.fn().mockResolvedValue({ studentUserId: 'student-1' }) }); - const roster = { - findOne: jest.fn().mockResolvedValue({ name: 'Current Roster Name' }) - }; - (guidedPathwayFlagsCollection as jest.Mock).mockReturnValue(coll); + const roster = { findOne: jest.fn().mockResolvedValue({ name: 'Current Roster Name' }) }; + (guidedPathwayFlagCourseCollection as jest.Mock).mockReturnValue(coll); (getCourseUsersMongoCollection as jest.Mock).mockResolvedValue(roster); const result = await revealGuidedPathwayFlagIdentity( context(), + 'course-1', 'flag-1', { userId: 'admin-1', name: 'Admin' } ); expect(result).toEqual({ studentName: 'Current Roster Name' }); - expect(coll.findOneAndUpdate.mock.calls[0][1]).toEqual(expect.objectContaining({ - $push: { - identityRevealEvents: expect.objectContaining({ adminUserId: 'admin-1' }) - } - })); + expect(coll.findOneAndUpdate.mock.calls[0][0]).toEqual({ + id: 'flag-1', + courseId: 'course-1', + status: 'escalated' + }); expect(coll.findOneAndUpdate.mock.invocationCallOrder[0]).toBeLessThan( roster.findOne.mock.invocationCallOrder[0] ); - expect(roster.findOne).toHaveBeenCalledWith( - { userId: 'student-1' }, - { projection: { _id: 0, name: 1 } } - ); + expect(getCourseUsersMongoCollection).toHaveBeenCalledWith(expect.anything(), 'Test Course'); }); it('fails closed without reading the roster when the reveal audit write fails', async () => { @@ -354,11 +369,12 @@ describe('guided-pathway-flag-mongo', () => { findOneAndUpdate: jest.fn().mockRejectedValue(new Error('audit write failed')) }); const roster = { findOne: jest.fn() }; - (guidedPathwayFlagsCollection as jest.Mock).mockReturnValue(coll); + (guidedPathwayFlagCourseCollection as jest.Mock).mockReturnValue(coll); (getCourseUsersMongoCollection as jest.Mock).mockResolvedValue(roster); await expect(revealGuidedPathwayFlagIdentity( context(), + 'course-1', 'flag-1', { userId: 'admin-1', name: 'Admin' } )).rejects.toThrow('audit write failed'); @@ -366,20 +382,23 @@ describe('guided-pathway-flag-mongo', () => { expect(roster.findOne).not.toHaveBeenCalled(); }); - it('counts awaiting admin reviews and cleans global rows by course id', async () => { - const coll = collection({ - countDocuments: jest.fn().mockResolvedValue(3), - deleteMany: jest.fn().mockResolvedValue({ deletedCount: 2 }) + it('counts active-course escalations and drops one course-owned collection', async () => { + const aggregate = jest.fn().mockReturnValue({ + toArray: jest.fn().mockResolvedValue([{ value: 3 }]) }); - (guidedPathwayFlagsCollection as jest.Mock).mockReturnValue(coll); - const ctx = context(); + const coll = collection({ countDocuments: jest.fn().mockResolvedValue(2) }); + (guidedPathwayFlagCourseCollection as jest.Mock).mockReturnValue(coll); + const dbCollection = jest.fn().mockReturnValue({ aggregate }); + const ctx = context({ collection: dbCollection }); await expect(countGuidedPathwayFlagsAwaitingAdminReview(ctx)).resolves.toBe(3); await expect(deleteGuidedPathwayFlagsForCourse(ctx, 'course-1')).resolves.toBe(2); - expect(coll.countDocuments).toHaveBeenCalledWith({ - status: 'escalated', - adminReviewedAt: { $exists: false } + + const pipeline = aggregate.mock.calls[0][0]; + expect(pipeline.at(-2)).toEqual({ + $match: { status: 'escalated', adminReviewedAt: { $exists: false } } }); - expect(coll.deleteMany).toHaveBeenCalledWith({ courseId: 'course-1' }); + expect(coll.countDocuments).toHaveBeenCalledWith({ courseId: 'course-1' }); + expect(coll.drop).toHaveBeenCalledTimes(1); }); }); diff --git a/src/db/mongo/__tests__/memory-agent-mongo.test.ts b/src/db/mongo/__tests__/memory-agent-mongo.test.ts index 67d5d2e6..f75434ff 100644 --- a/src/db/mongo/__tests__/memory-agent-mongo.test.ts +++ b/src/db/mongo/__tests__/memory-agent-mongo.test.ts @@ -80,7 +80,8 @@ function makeCtx(docs: MemoryDoc[], courseName = 'TestCourse'): MongoDalContext scheduledTasks: `${courseName}_scheduled-tasks`, scenarioQuestions: `${courseName}_scenario_questions`, scenarioProgress: `${courseName}_scenario_progress`, - pathways: `${courseName}_pathways` + pathways: `${courseName}_pathways`, + guidedPathwayFlags: `${courseName}_guided-pathway-flags` } ] ]), diff --git a/src/db/mongo/__tests__/mongo-collections.test.ts b/src/db/mongo/__tests__/mongo-collections.test.ts index 3184512d..2cd4b2c7 100644 --- a/src/db/mongo/__tests__/mongo-collections.test.ts +++ b/src/db/mongo/__tests__/mongo-collections.test.ts @@ -29,7 +29,7 @@ describe('mongo collections helpers', () => { expect(activeUsersMongoCollection(mockDb()).collectionName).toBe(ACTIVE_USERS_COLLECTION); }); - it('guidedPathwayFlagsCollection uses the single global canonical name', () => { + it('guidedPathwayFlagsCollection resolves the legacy GPF-001 migration source', () => { expect(guidedPathwayFlagsCollection(mockDb()).collectionName).toBe( GUIDED_PATHWAY_FLAGS_COLLECTION ); diff --git a/src/db/mongo/__tests__/report-fixture-seed-mongo.test.ts b/src/db/mongo/__tests__/report-fixture-seed-mongo.test.ts index 081f9c9b..c6f606f2 100644 --- a/src/db/mongo/__tests__/report-fixture-seed-mongo.test.ts +++ b/src/db/mongo/__tests__/report-fixture-seed-mongo.test.ts @@ -199,6 +199,7 @@ function makeCtx(state: { scenarioQuestions: `${REPORT_FIXTURE_TARGET_COURSE_NAME}_scenario_questions`, scenarioProgress: `${REPORT_FIXTURE_TARGET_COURSE_NAME}_scenario_progress`, pathways: `${REPORT_FIXTURE_TARGET_COURSE_NAME}_pathways`, + guidedPathwayFlags: `${REPORT_FIXTURE_TARGET_COURSE_NAME}_guided-pathway-flags`, } ] ]), diff --git a/src/db/mongo/__tests__/scenario-progress-mongo.test.ts b/src/db/mongo/__tests__/scenario-progress-mongo.test.ts index d2484cae..7557a724 100644 --- a/src/db/mongo/__tests__/scenario-progress-mongo.test.ts +++ b/src/db/mongo/__tests__/scenario-progress-mongo.test.ts @@ -76,8 +76,9 @@ function makeCtx(docs: ProgressDoc[], courseName = 'TestCourse'): MongoDalContex memoryAgent: `${courseName}_memory-agent`, scheduledTasks: `${courseName}_scheduled_tasks`, scenarioQuestions: `${courseName}_scenario_questions`, - scenarioProgress: progressCollectionName, - pathways: `${courseName}_pathways`, + scenarioProgress: progressCollectionName, + pathways: `${courseName}_pathways`, + guidedPathwayFlags: `${courseName}_guided-pathway-flags`, }, ], ]), diff --git a/src/db/mongo/__tests__/scenario-suggestions-mongo.test.ts b/src/db/mongo/__tests__/scenario-suggestions-mongo.test.ts index 64ccf7f1..6a69bb45 100644 --- a/src/db/mongo/__tests__/scenario-suggestions-mongo.test.ts +++ b/src/db/mongo/__tests__/scenario-suggestions-mongo.test.ts @@ -50,6 +50,7 @@ function makeCtx(docs: ScenarioQuestion[], courseName = 'TestCourse'): MongoDalC scenarioQuestions: collectionName, scenarioProgress: `${courseName}_scenario_progress`, pathways: `${courseName}_pathways`, + guidedPathwayFlags: `${courseName}_guided-pathway-flags`, }, ], ]), @@ -153,6 +154,7 @@ function makeCtxForTexts(docs: ScenarioQuestion[], courseName = 'TestCourse'): M scenarioQuestions: collectionName, scenarioProgress: `${courseName}_scenario_progress`, pathways: `${courseName}_pathways`, + guidedPathwayFlags: `${courseName}_guided-pathway-flags`, }, ], ]), diff --git a/src/db/mongo/collection-registry-mongo.ts b/src/db/mongo/collection-registry-mongo.ts index fc224d94..768bffe0 100644 --- a/src/db/mongo/collection-registry-mongo.ts +++ b/src/db/mongo/collection-registry-mongo.ts @@ -9,18 +9,19 @@ import type { activeCourse } from '../../types/shared'; import { fetchActiveCourseDocByCourseName } from './active-course-queries-mongo'; +import { guidedPathwayFlagCollectionNameForCourse } from './guided-pathway-flag-collection-mongo'; import type { MongoDalContext } from './mongo-context'; import { appLogger } from '../../utils/logger'; /** * getCollectionNames * - * Returns the four per-course collection identifiers and **memoizes** them on `ctx.collectionNamesCache`. + * Returns the per-course collection identifiers and **memoizes** them on `ctx.collectionNamesCache`. * * @param ctx - MongoDalContext — provides `db`, `collectionNamesCache`, and related singleton state * @param courseName - string — logical course name (matches `activeCourse.courseName` and namespace prefix) * - * @returns Promise<{ users, flags, memoryAgent, scheduledTasks }> + * @returns Names for each physical collection owned by the course * */ export async function getCollectionNames( @@ -34,6 +35,7 @@ export async function getCollectionNames( scenarioQuestions: string; scenarioProgress: string; pathways: string; + guidedPathwayFlags: string; }> { if (ctx.collectionNamesCache.has(courseName)) { return ctx.collectionNamesCache.get(courseName)!; @@ -54,6 +56,7 @@ export async function getCollectionNames( const scenarioQuestions = c.collections.scenarioQuestions ?? `${courseName}_scenario_questions`; const scenarioProgress = c.collections.scenarioProgress ?? `${courseName}_scenario_progress`; const pathways = c.collections.pathways ?? `${courseName}_pathways`; + const guidedPathwayFlags = guidedPathwayFlagCollectionNameForCourse(c.id); const collectionNames = { users: c.collections.users, flags: c.collections.flags, @@ -62,6 +65,7 @@ export async function getCollectionNames( scenarioQuestions, scenarioProgress, pathways, + guidedPathwayFlags, }; ctx.collectionNamesCache.set(courseName, collectionNames); return collectionNames; @@ -81,6 +85,7 @@ export async function getCollectionNames( scenarioQuestions: `${courseName}_scenario_questions`, scenarioProgress: `${courseName}_scenario_progress`, pathways: `${courseName}_pathways`, + guidedPathwayFlags: guidedPathwayFlagCollectionNameForCourse(courseName), }; ctx.collectionNamesCache.set(courseName, computedNames); return computedNames; diff --git a/src/db/mongo/course-mongo.ts b/src/db/mongo/course-mongo.ts index bb14087c..8fcf7199 100644 --- a/src/db/mongo/course-mongo.ts +++ b/src/db/mongo/course-mongo.ts @@ -10,8 +10,9 @@ import type { activeCourse } from '../../types/shared'; import { fetchActiveCourseDocByCourseName, fetchActiveCourseDocById } from './active-course-queries-mongo'; import { lazyMigrateCourseAcademicPeriod } from './academic-period-mongo'; -import { createFlagIndexes } from './flag-mongo'; -import type { MongoDalContext } from './mongo-context'; +import { createFlagIndexes } from './flag-mongo'; +import { guidedPathwayFlagCollectionNameForCourse } from './guided-pathway-flag-collection-mongo'; +import type { MongoDalContext } from './mongo-context'; import { activeCourseListCollection, activeUsersMongoCollection } from './mongo-collections'; import { seedPathwaysForNewCourse } from './pathways-mongo'; import { appLogger } from '../../utils/logger'; @@ -76,17 +77,19 @@ export async function postActiveCourse(ctx: MongoDalContext, course: activeCours const scheduledTasksCollection = `${courseName}_scheduled_tasks`; // SQ-001: created eagerly for new courses; existing courses get it lazily via // ensureScenarioQuestionsCollection on first scenario-questions API call (scenario-questions-mongo.ts). - const scenarioQuestionsCollection = `${courseName}_scenario_questions`; - const pathwaysCollection = `${courseName}_pathways`; + const scenarioQuestionsCollection = `${courseName}_scenario_questions`; + const pathwaysCollection = `${courseName}_pathways`; + const guidedPathwayFlagsCollection = guidedPathwayFlagCollectionNameForCourse(course.id); for (const colName of [ userCollection, flagsCollection, memoryAgentCollection, scheduledTasksCollection, - scenarioQuestionsCollection, - pathwaysCollection, - ]) { + scenarioQuestionsCollection, + pathwaysCollection, + guidedPathwayFlagsCollection, + ]) { try { await ctx.db.createCollection(colName); } catch (error: any) { @@ -102,9 +105,10 @@ export async function postActiveCourse(ctx: MongoDalContext, course: activeCours flags: flagsCollection, memoryAgent: memoryAgentCollection, scheduledTasks: scheduledTasksCollection, - scenarioQuestions: scenarioQuestionsCollection, - pathways: pathwaysCollection, - } + scenarioQuestions: scenarioQuestionsCollection, + pathways: pathwaysCollection, + guidedPathwayFlags: guidedPathwayFlagsCollection, + } }; await activeCourseListCollection(ctx.db).insertOne(courseWithCollections as any); diff --git a/src/db/mongo/guided-pathway-flag-collection-mongo.ts b/src/db/mongo/guided-pathway-flag-collection-mongo.ts new file mode 100644 index 00000000..a4e2e7c3 --- /dev/null +++ b/src/db/mongo/guided-pathway-flag-collection-mongo.ts @@ -0,0 +1,346 @@ +/** + * Guided Pathway flag collection ownership + * + * Resolves one deterministic physical alert collection per course, provisions + * its indexes, and migrates rows out of the legacy shared collection. The + * migration deletes a source batch only after every Mongo `_id` is verified in + * its destination, making retries idempotent after a partial process failure. + * + * @author: EngE-AI Team + * @date: 2026-08-12 + * @version: 1.0.0 + * @description: Course collection resolution and GPF-001 storage migration. + */ + +import { createHash } from 'crypto'; +import type { AnyBulkWriteOperation, Collection, Document } from 'mongodb'; +import type { activeCourse } from '../../types/shared'; +import { appLogger } from '../../utils/logger'; +import { activeCourseListCollection, guidedPathwayFlagsCollection } from './mongo-collections'; +import type { MongoDalContext } from './mongo-context'; + +const COLLECTION_PREFIX = 'guided-pathway-flags-course-'; +const MIGRATION_BATCH_SIZE = 200; + +/** Canonical Mongo ownership information for one active course alert collection. */ +export interface GuidedPathwayFlagCourseScope { + courseId: string; + courseName: string; + collectionName: string; +} + +/** Aggregate result from the idempotent GPF-001 shared-to-course migration. */ +export interface GuidedPathwayFlagMigrationResult { + registeredCourseCollections: number; + migratedRows: number; + orphanCourseCollections: number; + retainedLegacyRows: number; +} + +/** Raised when an operation targets a course that is absent from the active catalog. */ +export class GuidedPathwayFlagCourseNotFoundError extends Error { + constructor() { + super('Course not found for Guided Pathway alert storage'); + this.name = 'GuidedPathwayFlagCourseNotFoundError'; + } +} + +const indexPromises = new WeakMap>>(); +const migrationPromises = new WeakMap>(); + +function namespaceAlreadyExists(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const mongoError = error as { code?: number; codeName?: string }; + return mongoError.code === 48 || mongoError.codeName === 'NamespaceExists'; +} + +function namespaceNotFound(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const mongoError = error as { code?: number; codeName?: string }; + return mongoError.code === 26 || mongoError.codeName === 'NamespaceNotFound'; +} + +/** + * guidedPathwayFlagCollectionNameForCourse - Derives a Mongo-safe name from a stable course id. + * + * The display name is deliberately excluded so a course rename cannot change + * ownership or strand its existing alerts. + * + * @param courseId - Stable course catalog id + * @returns Deterministic physical collection name with a 96-bit hash suffix + */ +export function guidedPathwayFlagCollectionNameForCourse(courseId: string): string { + const suffix = createHash('sha256').update(courseId).digest('hex').slice(0, 24); + return `${COLLECTION_PREFIX}${suffix}`; +} + +async function createCollectionIfMissing(ctx: MongoDalContext, collectionName: string): Promise { + try { + await ctx.db.createCollection(collectionName); + } catch (error) { + if (!namespaceAlreadyExists(error)) throw error; + } +} + +async function persistCanonicalCollectionName( + ctx: MongoDalContext, + course: activeCourse +): Promise { + const collectionName = guidedPathwayFlagCollectionNameForCourse(course.id); + + // The derived value is authoritative; never trust a catalog value as an arbitrary namespace. + if (course.collections?.guidedPathwayFlags !== collectionName) { + await activeCourseListCollection(ctx.db).updateOne( + { id: course.id }, + { $set: { 'collections.guidedPathwayFlags': collectionName } } + ); + ctx.collectionNamesCache.delete(course.courseName); + } + + return { courseId: course.id, courseName: course.courseName, collectionName }; +} + +/** + * ensureGuidedPathwayFlagCollectionIndexes - Creates indexes for one course-owned alert collection. + * + * Promise memoization is keyed by both database and physical collection. A + * failed attempt is removed so the next request can retry safely. + * + * @param ctx - Connected Mongo data-layer context + * @param collectionName - Canonical physical course collection + * @returns When dedupe, lifecycle, filtering, and review indexes are ready + */ +export async function ensureGuidedPathwayFlagCollectionIndexes( + ctx: MongoDalContext, + collectionName: string +): Promise { + const databaseKey = ctx.db as object; + let databasePromises = indexPromises.get(databaseKey); + if (!databasePromises) { + databasePromises = new Map(); + indexPromises.set(databaseKey, databasePromises); + } + + let pending = databasePromises.get(collectionName); + if (!pending) { + const collection = ctx.db.collection(collectionName); + pending = Promise.all([ + collection.createIndex({ id: 1 }, { unique: true, name: 'guided_pathway_flag_id_unique' }), + collection.createIndex({ dedupeKey: 1 }, { unique: true, name: 'guided_pathway_flag_dedupe_unique' }), + collection.createIndex( + { courseId: 1, status: 1, triggeredAt: -1 }, + { name: 'guided_pathway_flag_course_status_time' } + ), + collection.createIndex( + { courseId: 1, pathwayId: 1, status: 1, triggeredAt: -1 }, + { name: 'guided_pathway_flag_course_pathway_status_time' } + ), + collection.createIndex( + { courseId: 1, status: 1, adminReviewedAt: 1, adminSortPriority: 1, triggeredAt: -1 }, + { name: 'guided_pathway_flag_admin_review_queue' } + ) + ]).then(() => undefined); + databasePromises.set(collectionName, pending); + } + + try { + await pending; + } catch (error) { + databasePromises.delete(collectionName); + throw error; + } +} + +async function migrateLegacyCourseRows( + ctx: MongoDalContext, + courseId: string, + collectionName: string +): Promise { + const source = guidedPathwayFlagsCollection(ctx.db); + const destination = ctx.db.collection(collectionName); + let migratedRows = 0; + + await createCollectionIfMissing(ctx, collectionName); + await ensureGuidedPathwayFlagCollectionIndexes(ctx, collectionName); + + while (true) { + const batch = await source + .find({ courseId }) + .sort({ _id: 1 }) + .limit(MIGRATION_BATCH_SIZE) + .toArray(); + if (batch.length === 0) break; + + // Upsert by Mongo identity so a retry after copying but before deletion is harmless. + const operations: AnyBulkWriteOperation[] = batch.map((document) => ({ + replaceOne: { + filter: { _id: document._id }, + replacement: document, + upsert: true + } + })); + await destination.bulkWrite(operations, { ordered: true }); + + // Delete only the exact source records proven to exist in the destination. + const sourceIds = batch.map((document) => document._id); + const verified = await destination.countDocuments({ _id: { $in: sourceIds } }); + if (verified !== sourceIds.length) { + throw new Error(`GPF-001 verification failed for course ${courseId}`); + } + const deleted = await source.deleteMany({ _id: { $in: sourceIds } }); + if (deleted.deletedCount !== sourceIds.length) { + // Another app instance may have migrated the same verified batch concurrently. + const remaining = await source.countDocuments({ _id: { $in: sourceIds } }); + if (remaining !== 0) { + throw new Error(`GPF-001 source cleanup was incomplete for course ${courseId}`); + } + } + migratedRows += deleted.deletedCount; + } + + return migratedRows; +} + +async function runGuidedPathwayFlagMigration( + ctx: MongoDalContext +): Promise { + const catalog = activeCourseListCollection(ctx.db); + const courses = await catalog.find({}).toArray() as unknown as activeCourse[]; + const scopes = new Map(); + let registeredCourseCollections = 0; + + // Register and provision every active course before any shared rows move. + for (const course of courses) { + const expectedName = guidedPathwayFlagCollectionNameForCourse(course.id); + if (course.collections?.guidedPathwayFlags !== expectedName) { + registeredCourseCollections += 1; + } + const scope = await persistCanonicalCollectionName(ctx, course); + await ensureGuidedPathwayFlagCollectionIndexes(ctx, scope.collectionName); + scopes.set(course.id, scope); + } + + const source = guidedPathwayFlagsCollection(ctx.db); + const legacyCourseIds = await source.distinct('courseId', { courseId: { $type: 'string' } }); + let migratedRows = 0; + let orphanCourseCollections = 0; + + // Each legacy course is copied independently, including rows whose catalog entry was already removed. + for (const value of legacyCourseIds) { + if (typeof value !== 'string' || !value) continue; + const activeScope = scopes.get(value); + const collectionName = activeScope?.collectionName ?? guidedPathwayFlagCollectionNameForCourse(value); + if (!activeScope) orphanCourseCollections += 1; + migratedRows += await migrateLegacyCourseRows(ctx, value, collectionName); + } + + // Malformed legacy records remain untouched for manual recovery; an empty namespace is removed. + const retainedLegacyRows = await source.countDocuments({}); + if (retainedLegacyRows === 0) { + try { + await source.drop(); + } catch (error) { + if (!namespaceNotFound(error)) throw error; + } + } else { + appLogger.warn('[guided-pathway-flags] GPF-001 retained malformed legacy rows', { + retainedLegacyRows + }); + } + + return { + registeredCourseCollections, + migratedRows, + orphanCourseCollections, + retainedLegacyRows + }; +} + +/** + * migrateGuidedPathwayFlagsToCourseCollections - Runs and memoizes GPF-001 for this database. + * + * All alert operations await this promise. If migration fails, the promise is + * discarded so a later request can retry from the last verified batch. + * + * @param ctx - Connected Mongo data-layer context + * @returns Registration, migration, orphan, and retained-row counts + */ +export async function migrateGuidedPathwayFlagsToCourseCollections( + ctx: MongoDalContext +): Promise { + const key = ctx.db as object; + let pending = migrationPromises.get(key); + if (!pending) { + pending = runGuidedPathwayFlagMigration(ctx); + migrationPromises.set(key, pending); + } + + try { + return await pending; + } catch (error) { + migrationPromises.delete(key); + throw error; + } +} + +/** + * getGuidedPathwayFlagCourseScope - Resolves and provisions one active course collection. + * + * @param ctx - Connected Mongo data-layer context + * @param courseId - Stable active-course id + * @returns Canonical course and physical collection metadata + * @throws Error when the active course no longer exists + */ +export async function getGuidedPathwayFlagCourseScope( + ctx: MongoDalContext, + courseId: string +): Promise { + await migrateGuidedPathwayFlagsToCourseCollections(ctx); + const course = await activeCourseListCollection(ctx.db).findOne({ id: courseId }) as activeCourse | null; + if (!course) throw new GuidedPathwayFlagCourseNotFoundError(); + + const scope = await persistCanonicalCollectionName(ctx, course); + await ensureGuidedPathwayFlagCollectionIndexes(ctx, scope.collectionName); + return scope; +} + +/** + * listGuidedPathwayFlagCourseScopes - Resolves active collections for an admin query. + * + * Physical namespaces come only from canonical derivation of catalog ids. The + * optional filters narrow which course collections participate in aggregation. + * + * @param ctx - Connected Mongo data-layer context + * @param filters - Optional exact course and approved course-id set + * @returns Canonical active-course scopes ordered by course id + */ +export async function listGuidedPathwayFlagCourseScopes( + ctx: MongoDalContext, + filters: { courseId?: string; courseIds?: string[] } = {} +): Promise { + await migrateGuidedPathwayFlagsToCourseCollections(ctx); + if (filters.courseId && filters.courseIds && !filters.courseIds.includes(filters.courseId)) return []; + + const permittedIds = filters.courseId ? [filters.courseId] : filters.courseIds; + const query = permittedIds ? { id: { $in: permittedIds } } : {}; + const courses = await activeCourseListCollection(ctx.db) + .find(query) + .sort({ id: 1 }) + .toArray() as unknown as activeCourse[]; + + const scopes: GuidedPathwayFlagCourseScope[] = []; + for (const course of courses) { + const scope = await persistCanonicalCollectionName(ctx, course); + await ensureGuidedPathwayFlagCollectionIndexes(ctx, scope.collectionName); + scopes.push(scope); + } + return scopes; +} + +/** Returns a typed Mongo collection handle for a canonical course scope. */ +export function guidedPathwayFlagCourseCollection( + ctx: MongoDalContext, + scope: GuidedPathwayFlagCourseScope +): Collection { + return ctx.db.collection(scope.collectionName); +} diff --git a/src/db/mongo/guided-pathway-flag-mongo.ts b/src/db/mongo/guided-pathway-flag-mongo.ts index 75c449ef..be5ba31c 100644 --- a/src/db/mongo/guided-pathway-flag-mongo.ts +++ b/src/db/mongo/guided-pathway-flag-mongo.ts @@ -1,18 +1,19 @@ /** * Guided Pathway flag Mongo delegate * - * Owns the single global `guided-pathway-flags` collection, including atomic - * trigger deduplication, instructor decisions, platform review, and audited - * identity reveal. Public reads always use an allowlisted anonymous projection. + * Owns anonymous alert CRUD inside course-specific collections. Course reads + * address exactly one collection; platform-admin reads build a server-owned + * `$unionWith` pipeline over canonical active-course namespaces. Every public + * projection excludes student identity, dedupe material, and reveal audit data. * * @author: EngE-AI Team * @date: 2026-08-08 - * @version: 1.0.0 - * @description: Privacy-bounded persistence for Guided Pathway trigger alerts. + * @version: 2.0.0 + * @description: Privacy-bounded persistence for course-isolated Guided Pathway alerts. */ import { createHash, randomUUID } from 'crypto'; -import type { Collection, Filter } from 'mongodb'; +import type { Collection, Document, Filter } from 'mongodb'; import type { GuidedPathwayFlagDecision, GuidedPathwayFlagFacets, @@ -22,7 +23,14 @@ import type { GuidedPathwayFlagView } from '../../types/shared'; import { getCourseUsersMongoCollection } from './course-user-mongo'; -import { guidedPathwayFlagsCollection } from './mongo-collections'; +import { + GuidedPathwayFlagCourseNotFoundError, + getGuidedPathwayFlagCourseScope, + guidedPathwayFlagCourseCollection, + listGuidedPathwayFlagCourseScopes, + migrateGuidedPathwayFlagsToCourseCollections, + type GuidedPathwayFlagCourseScope +} from './guided-pathway-flag-collection-mongo'; import type { MongoDalContext } from './mongo-context'; /** Server-owned actor snapshot used for decisions, review, and reveal audit. */ @@ -44,7 +52,7 @@ export interface CreateGuidedPathwayFlagInput { triggeredAt?: Date; } -/** Filters shared by course and global administrator queues. */ +/** Filters supported by the platform-wide administrator queue. */ export interface GuidedPathwayFlagListFilters { page?: number; pageSize?: number; @@ -94,7 +102,14 @@ interface GuidedPathwayFlagDocument { updatedAt: Date; } -/** Raised when an alert id is absent from the required scope. */ +interface AdminAggregationResult { + items: Partial[]; + totals: Array<{ value: number }>; + pathways?: Array<{ pathwayId: string; pathwayTitle: string }>; + reviewers?: Array<{ name: string }>; +} + +/** Raised when an alert id is absent from the required course scope. */ export class GuidedPathwayFlagNotFoundError extends Error { constructor(message = 'Guided Pathway alert not found') { super(message); @@ -120,6 +135,11 @@ export class GuidedPathwayFlagIdentityUnavailableError extends Error { const DEFAULT_PAGE_SIZE = 50; const MAX_PAGE_SIZE = 200; +const STATUS_PRIORITY: Record = { + escalated: 0, + pending: 1, + dismissed: 2 +}; /** Inclusion-only projection used by every queue and backup read. */ const SAFE_FLAG_PROJECTION = { @@ -138,10 +158,25 @@ const SAFE_FLAG_PROJECTION = { adminReviewedByName: 1 } as const; -const indexPromises = new WeakMap>(); +function collectionFor( + ctx: MongoDalContext, + scope: GuidedPathwayFlagCourseScope +): Collection { + return guidedPathwayFlagCourseCollection(ctx, scope); +} -function flags(ctx: MongoDalContext): Collection { - return guidedPathwayFlagsCollection(ctx.db) as unknown as Collection; +async function requireCourseScope( + ctx: MongoDalContext, + courseId: string +): Promise { + try { + return await getGuidedPathwayFlagCourseScope(ctx, courseId); + } catch (error) { + if (error instanceof GuidedPathwayFlagCourseNotFoundError) { + throw new GuidedPathwayFlagNotFoundError('Guided Pathway alert course not found'); + } + throw error; + } } function asIso(value: Date | string): string { @@ -182,63 +217,129 @@ function isDuplicateKeyError(error: unknown): boolean { return Boolean(error && typeof error === 'object' && (error as { code?: number }).code === 11000); } -function statusPriority(status: GuidedPathwayFlagStatus): number { - if (status === 'escalated') return 0; - if (status === 'pending') return 1; - return 2; +function normalizedPagination(filters: GuidedPathwayFlagListFilters): { page: number; pageSize: number } { + return { + page: Math.max(1, Math.floor(filters.page ?? 1)), + pageSize: Math.min(MAX_PAGE_SIZE, Math.max(1, Math.floor(filters.pageSize ?? DEFAULT_PAGE_SIZE))) + }; } async function findSafeFlag( - ctx: MongoDalContext, + collection: Collection, filter: Filter ): Promise { - const doc = await flags(ctx).findOne(filter, { projection: SAFE_FLAG_PROJECTION }); + const doc = await collection.findOne(filter, { projection: SAFE_FLAG_PROJECTION }); return doc ? toSafeView(doc) : null; } -/** - * ensureGuidedPathwayFlagIndexes - Installs global dedupe, queue, and review indexes. - * - * A per-database shared promise prevents concurrent first-use callers from racing - * index installation. Failed attempts are removed so a later call can retry. - * - * @param ctx - Connected Mongo data-layer context - * @returns When all collection indexes are available - */ -export async function ensureGuidedPathwayFlagIndexes(ctx: MongoDalContext): Promise { - const key = ctx.db as object; - let pending = indexPromises.get(key); - if (!pending) { - const collection = flags(ctx); - pending = Promise.all([ - collection.createIndex({ id: 1 }, { unique: true, name: 'guided_pathway_flag_id_unique' }), - collection.createIndex({ dedupeKey: 1 }, { unique: true, name: 'guided_pathway_flag_dedupe_unique' }), - collection.createIndex( - { courseId: 1, status: 1, triggeredAt: -1 }, - { name: 'guided_pathway_flag_course_status_time' } - ), - collection.createIndex( - { status: 1, adminReviewedAt: 1, adminSortPriority: 1, triggeredAt: -1 }, - { name: 'guided_pathway_flag_admin_review_queue' } - ), - collection.createIndex( - { courseId: 1, pathwayId: 1, status: 1, triggeredAt: -1 }, - { name: 'guided_pathway_flag_course_pathway_status_time' } - ), - collection.createIndex( - { adminSortPriority: 1, triggeredAt: -1 }, - { name: 'guided_pathway_flag_admin_order' } - ) - ]).then(() => undefined); - indexPromises.set(key, pending); +function applyReviewState( + query: Filter, + filters: GuidedPathwayFlagListFilters +): void { + if (!filters.reviewState || filters.reviewState === 'all') return; + + // Review state only exists on escalations; incompatible status pairs intentionally match nothing. + query.status = filters.status && filters.status !== 'escalated' ? { $in: [] } : 'escalated'; + query.adminReviewedAt = { $exists: filters.reviewState === 'reviewed' }; +} + +function buildListFilter( + filters: GuidedPathwayFlagListFilters, + omitOwnFacet?: 'pathwayId' | 'reviewer' +): Filter { + const query: Filter = {}; + + if (filters.courseId) { + query.courseId = filters.courseIds && !filters.courseIds.includes(filters.courseId) + ? { $in: [] } + : filters.courseId; + } else if (filters.courseIds) { + query.courseId = { $in: filters.courseIds }; } - try { - await pending; - } catch (error) { - indexPromises.delete(key); - throw error; + if (filters.status) query.status = filters.status; + if (filters.pathwayId && omitOwnFacet !== 'pathwayId') query.pathwayId = filters.pathwayId; + applyReviewState(query, filters); + + if (filters.reviewer && omitOwnFacet !== 'reviewer') { + query.$or = [ + { decidedByName: filters.reviewer }, + { adminReviewedByName: filters.reviewer } + ]; + } + + if (filters.dateFrom || filters.dateTo) { + const triggeredAt: { $gte?: Date; $lte?: Date } = {}; + if (filters.dateFrom) triggeredAt.$gte = filters.dateFrom; + if (filters.dateTo) triggeredAt.$lte = filters.dateTo; + query.triggeredAt = triggeredAt; } + + return query; +} + +function unionCourseCollections(scopes: GuidedPathwayFlagCourseScope[]): Document[] { + const [first, ...remaining] = scopes; + const pipeline: Document[] = [{ $match: { courseId: first.courseId } }]; + for (const scope of remaining) { + pipeline.push({ + $unionWith: { + coll: scope.collectionName, + pipeline: [{ $match: { courseId: scope.courseId } }] + } + }); + } + return pipeline; +} + +function adminFacetPipeline( + filters: GuidedPathwayFlagListFilters, + page: number, + pageSize: number +): Document { + const sort = filters.escalatedFirst + ? { adminSortPriority: 1, triggeredAt: -1 } + : { triggeredAt: -1 }; + const facet: Record = { + items: [ + { $match: buildListFilter(filters) }, + { $sort: sort }, + { $skip: (page - 1) * pageSize }, + { $limit: pageSize }, + { $project: SAFE_FLAG_PROJECTION } + ], + totals: [ + { $match: buildListFilter(filters) }, + { $count: 'value' } + ] + }; + + if (filters.includeFacets) { + facet.pathways = [ + { $match: buildListFilter(filters, 'pathwayId') }, + { $sort: { triggeredAt: -1 } }, + { + $group: { + _id: '$pathwayId', + pathwayTitle: { $first: '$pathwayTitle' } + } + }, + { $match: { _id: { $type: 'string', $ne: '' }, pathwayTitle: { $type: 'string', $ne: '' } } }, + { $project: { _id: 0, pathwayId: '$_id', pathwayTitle: 1 } }, + { $sort: { pathwayTitle: 1, pathwayId: 1 } } + ]; + facet.reviewers = [ + { $match: buildListFilter(filters, 'reviewer') }, + { $project: { names: ['$decidedByName', '$adminReviewedByName'] } }, + { $unwind: '$names' }, + { $match: { names: { $type: 'string', $regex: /\S/ } } }, + { $group: { _id: '$names' } }, + { $sort: { _id: 1 } }, + { $project: { _id: 0, name: '$_id' } } + ]; + } + + return { $facet: facet }; } /** @@ -255,23 +356,24 @@ export async function createGuidedPathwayFlag( ctx: MongoDalContext, input: CreateGuidedPathwayFlagInput ): Promise { - await ensureGuidedPathwayFlagIndexes(ctx); if (!input.clientMessageId || !input.chatId) { throw new Error('chatId and clientMessageId are required for Guided Pathway alert deduplication'); } + const scope = await requireCourseScope(ctx, input.courseId); + const collection = collectionFor(ctx, scope); const now = input.triggeredAt ?? new Date(); const doc: GuidedPathwayFlagDocument = { id: randomUUID(), - courseId: input.courseId, - courseName: input.courseName, + courseId: scope.courseId, + courseName: scope.courseName, pathwayId: input.pathwayId, pathwayTitle: input.pathwayTitle, messageText: input.messageText, studentUserId: input.studentUserId, dedupeKey: dedupeKeyFor(input), status: 'pending', - adminSortPriority: statusPriority('pending'), + adminSortPriority: STATUS_PRIORITY.pending, triggeredAt: now, identityRevealEvents: [], createdAt: now, @@ -279,156 +381,99 @@ export async function createGuidedPathwayFlag( }; try { - await flags(ctx).insertOne(doc); + await collection.insertOne(doc); return { created: true, flag: toSafeView(doc) }; } catch (error) { if (!isDuplicateKeyError(error)) throw error; - const existing = await findSafeFlag(ctx, { dedupeKey: doc.dedupeKey }); + const existing = await findSafeFlag(collection, { dedupeKey: doc.dedupeKey, courseId: scope.courseId }); if (!existing) throw error; return { created: false, flag: existing }; } } -function buildListFilter( - filters: GuidedPathwayFlagListFilters, - omitOwnFacet?: 'pathwayId' | 'reviewer' -): Filter { - const query: Filter = {}; - - if (filters.courseId) { - if (filters.courseIds && !filters.courseIds.includes(filters.courseId)) { - query.courseId = { $in: [] }; - } else { - query.courseId = filters.courseId; - } - } else if (filters.courseIds) { - query.courseId = { $in: filters.courseIds }; - } - - if (filters.status) query.status = filters.status; - if (filters.pathwayId && omitOwnFacet !== 'pathwayId') query.pathwayId = filters.pathwayId; - - if (filters.reviewState === 'needs-review') { - query.status = filters.status && filters.status !== 'escalated' - ? { $in: [] } - : 'escalated'; - query.adminReviewedAt = { $exists: false }; - } else if (filters.reviewState === 'reviewed') { - query.status = filters.status && filters.status !== 'escalated' - ? { $in: [] } - : 'escalated'; - query.adminReviewedAt = { $exists: true }; - } - - if (filters.reviewer && omitOwnFacet !== 'reviewer') { - query.$or = [ - { decidedByName: filters.reviewer }, - { adminReviewedByName: filters.reviewer } - ]; - } - - if (filters.dateFrom || filters.dateTo) { - query.triggeredAt = {}; - if (filters.dateFrom) query.triggeredAt.$gte = filters.dateFrom; - if (filters.dateTo) query.triggeredAt.$lte = filters.dateTo; - } - - return query; -} - -async function loadSafeFacets( +/** + * listGuidedPathwayFlagsForCourse - Returns one course's paginated anonymous queue. + * + * @param ctx - Connected Mongo data-layer context + * @param courseId - Required course ownership boundary + * @param filters - Status and pagination controls + * @returns Safe page with total matching count + */ +export async function listGuidedPathwayFlagsForCourse( ctx: MongoDalContext, - filters: GuidedPathwayFlagListFilters -): Promise { - const collection = flags(ctx); - const pathwayFilter = buildListFilter(filters, 'pathwayId'); - const reviewerFilter = buildListFilter(filters, 'reviewer'); - - // Fetch only the non-student fields needed to build full-queue filter choices. - const pathwayCursor = collection.find(pathwayFilter, { - projection: { _id: 0, pathwayId: 1, pathwayTitle: 1, triggeredAt: 1 } - }); - pathwayCursor.sort({ triggeredAt: -1 }); - const reviewerCursor = collection.find(reviewerFilter, { - projection: { _id: 0, decidedByName: 1, adminReviewedByName: 1 } - }); - const [pathwayDocs, reviewerDocs] = await Promise.all([ - pathwayCursor.toArray(), - reviewerCursor.toArray() - ]); - - // Keep the newest title snapshot for each stable pathway id. - const pathwayById = new Map(); - for (const doc of pathwayDocs) { - if ( - typeof doc.pathwayId === 'string' && doc.pathwayId && - typeof doc.pathwayTitle === 'string' && doc.pathwayTitle && - !pathwayById.has(doc.pathwayId) - ) { - pathwayById.set(doc.pathwayId, doc.pathwayTitle); - } - } + courseId: string, + filters: Pick +): Promise { + const scope = await requireCourseScope(ctx, courseId); + const collection = collectionFor(ctx, scope); + const pagination = normalizedPagination(filters); + const query = buildListFilter({ ...filters, courseId }); + const cursor = collection.find(query, { projection: SAFE_FLAG_PROJECTION }).sort({ triggeredAt: -1 }); - const reviewers = new Set(); - for (const doc of reviewerDocs) { - if (typeof doc.decidedByName === 'string' && doc.decidedByName.trim()) { - reviewers.add(doc.decidedByName); - } - if (typeof doc.adminReviewedByName === 'string' && doc.adminReviewedByName.trim()) { - reviewers.add(doc.adminReviewedByName); - } - } + const [docs, total] = await Promise.all([ + cursor + .skip((pagination.page - 1) * pagination.pageSize) + .limit(pagination.pageSize) + .toArray(), + collection.countDocuments(query) + ]); return { - pathways: [...pathwayById.entries()] - .map(([pathwayId, pathwayTitle]) => ({ pathwayId, pathwayTitle })) - .sort((a, b) => a.pathwayTitle.localeCompare(b.pathwayTitle) || a.pathwayId.localeCompare(b.pathwayId)), - reviewers: [...reviewers].sort((a, b) => a.localeCompare(b)) + items: docs.map((doc) => toSafeView(doc)), + page: pagination.page, + pageSize: pagination.pageSize, + total }; } /** - * listGuidedPathwayFlags - Returns one paginated anonymous queue page. + * listGuidedPathwayFlagsForAdmin - Aggregates active course collections into one safe queue. * - * The Mongo projection is inclusion-only and the mapper repeats the allowlist, - * preventing identity fields from leaking if the stored schema grows later. + * Collection names come from canonical course scopes, never request input. + * `$facet` computes rows, total, and optional filter choices from one consistent + * cross-course snapshot while projecting only allowlisted fields to Node. * * @param ctx - Connected Mongo data-layer context - * @param filters - Course/admin filters and pagination - * @returns Safe page with total matching count + * @param filters - Administrator filters and pagination + * @returns Safe cross-course page and optional facets */ -export async function listGuidedPathwayFlags( +export async function listGuidedPathwayFlagsForAdmin( ctx: MongoDalContext, filters: GuidedPathwayFlagListFilters ): Promise { - await ensureGuidedPathwayFlagIndexes(ctx); - const page = Math.max(1, Math.floor(filters.page ?? 1)); - const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Math.floor(filters.pageSize ?? DEFAULT_PAGE_SIZE))); - const query = buildListFilter(filters); - const collection = flags(ctx); - const cursor = collection.find(query, { projection: SAFE_FLAG_PROJECTION }); - if (filters.escalatedFirst) { - cursor.sort({ adminSortPriority: 1, triggeredAt: -1 }); - } else { - cursor.sort({ triggeredAt: -1 }); + const pagination = normalizedPagination(filters); + const scopes = await listGuidedPathwayFlagCourseScopes(ctx, filters); + if (scopes.length === 0) { + return { + items: [], + page: pagination.page, + pageSize: pagination.pageSize, + total: 0, + ...(filters.includeFacets ? { facets: { pathways: [], reviewers: [] } } : {}) + }; } - const [docs, total, facets] = await Promise.all([ - cursor - .skip((page - 1) * pageSize) - .limit(pageSize) - .toArray(), - collection.countDocuments(query), - filters.includeFacets ? loadSafeFacets(ctx, filters) : Promise.resolve(undefined) - ]); + const pipeline = [ + ...unionCourseCollections(scopes), + adminFacetPipeline(filters, pagination.page, pagination.pageSize) + ]; + const [aggregation] = await ctx.db + .collection(scopes[0].collectionName) + .aggregate(pipeline, { allowDiskUse: true }) + .toArray(); const result: GuidedPathwayFlagListPage = { - items: docs.map((doc) => toSafeView(doc)), - page, - pageSize, - total + items: (aggregation?.items ?? []).map((doc) => toSafeView(doc)), + page: pagination.page, + pageSize: pagination.pageSize, + total: aggregation?.totals?.[0]?.value ?? 0 }; - if (facets) result.facets = facets; + if (filters.includeFacets) { + result.facets = { + pathways: aggregation?.pathways ?? [], + reviewers: (aggregation?.reviewers ?? []).map(({ name }) => name) + }; + } return result; } @@ -452,15 +497,18 @@ export async function decideGuidedPathwayFlag( decision: GuidedPathwayFlagDecision, actor: GuidedPathwayFlagActor ): Promise { - await ensureGuidedPathwayFlagIndexes(ctx); + const scope = await requireCourseScope(ctx, courseId); + const collection = collectionFor(ctx, scope); const nextStatus: GuidedPathwayFlagStatus = decision === 'escalate' ? 'escalated' : 'dismissed'; const now = new Date(); - const updated = await flags(ctx).findOneAndUpdate( + + // The lifecycle predicate and write share one BSON command, preventing competing decisions. + const updated = await collection.findOneAndUpdate( { id: flagId, courseId, status: 'pending' }, { $set: { status: nextStatus, - adminSortPriority: statusPriority(nextStatus), + adminSortPriority: STATUS_PRIORITY[nextStatus], decidedAt: now, decidedByUserId: actor.userId, decidedByName: actor.name, @@ -471,32 +519,32 @@ export async function decideGuidedPathwayFlag( ); if (updated) return toSafeView(updated); - const existing = await findSafeFlag(ctx, { id: flagId, courseId }); + const existing = await findSafeFlag(collection, { id: flagId, courseId }); if (!existing) throw new GuidedPathwayFlagNotFoundError(); if (existing.status === nextStatus) return existing; throw new GuidedPathwayFlagConflictError('Guided Pathway alert already has a different decision'); } /** - * markGuidedPathwayFlagAdminReviewed - Marks an escalated alert reviewed once. - * - * Repeated review calls return the original completed record without replacing - * its first-review actor or timestamp. + * markGuidedPathwayFlagAdminReviewed - Marks an escalated course alert reviewed once. * * @param ctx - Connected Mongo data-layer context + * @param courseId - Required physical ownership boundary * @param flagId - Escalated alert id * @param actor - Server-owned platform administrator snapshot * @returns Updated safe anonymous alert */ export async function markGuidedPathwayFlagAdminReviewed( ctx: MongoDalContext, + courseId: string, flagId: string, actor: GuidedPathwayFlagActor ): Promise { - await ensureGuidedPathwayFlagIndexes(ctx); + const scope = await requireCourseScope(ctx, courseId); + const collection = collectionFor(ctx, scope); const now = new Date(); - const updated = await flags(ctx).findOneAndUpdate( - { id: flagId, status: 'escalated', adminReviewedAt: { $exists: false } }, + const updated = await collection.findOneAndUpdate( + { id: flagId, courseId, status: 'escalated', adminReviewedAt: { $exists: false } }, { $set: { adminReviewedAt: now, @@ -509,7 +557,7 @@ export async function markGuidedPathwayFlagAdminReviewed( ); if (updated) return toSafeView(updated); - const existing = await findSafeFlag(ctx, { id: flagId }); + const existing = await findSafeFlag(collection, { id: flagId, courseId }); if (!existing) throw new GuidedPathwayFlagNotFoundError(); if (existing.status !== 'escalated') { throw new GuidedPathwayFlagConflictError('Only escalated alerts can be marked reviewed'); @@ -525,19 +573,22 @@ export async function markGuidedPathwayFlagAdminReviewed( * only a display name and never exposes the stored student user id or a PUID. * * @param ctx - Connected Mongo data-layer context + * @param courseId - Required physical ownership boundary * @param flagId - Escalated alert whose author is being revealed * @param actor - Platform administrator performing the reveal * @returns Current course-roster display name */ export async function revealGuidedPathwayFlagIdentity( ctx: MongoDalContext, + courseId: string, flagId: string, actor: GuidedPathwayFlagActor ): Promise<{ studentName: string }> { - await ensureGuidedPathwayFlagIndexes(ctx); + const scope = await requireCourseScope(ctx, courseId); + const collection = collectionFor(ctx, scope); const revealedAt = new Date(); - const audited = await flags(ctx).findOneAndUpdate( - { id: flagId, status: 'escalated' }, + const audited = await collection.findOneAndUpdate( + { id: flagId, courseId, status: 'escalated' }, { $push: { identityRevealEvents: { @@ -549,21 +600,21 @@ export async function revealGuidedPathwayFlagIdentity( }, { returnDocument: 'after', - projection: { _id: 0, courseName: 1, studentUserId: 1 } + projection: { _id: 0, studentUserId: 1 } } - ) as Pick | null; + ) as Pick | null; if (!audited) { - const existing = await flags(ctx).findOne( - { id: flagId }, + const existing = await collection.findOne( + { id: flagId, courseId }, { projection: { _id: 0, status: 1 } } ); if (!existing) throw new GuidedPathwayFlagNotFoundError(); throw new GuidedPathwayFlagConflictError('Identity can be revealed only for escalated alerts'); } - // Read only the current display name from the course roster after the audit succeeds. - const roster = await getCourseUsersMongoCollection(ctx, audited.courseName); + // Read the roster only after the append-only audit event has persisted. + const roster = await getCourseUsersMongoCollection(ctx, scope.courseName); const student = await roster.findOne( { userId: audited.studentUserId }, { projection: { _id: 0, name: 1 } } @@ -575,21 +626,29 @@ export async function revealGuidedPathwayFlagIdentity( } /** - * countGuidedPathwayFlagsAwaitingAdminReview - Counts escalations without platform review. + * countGuidedPathwayFlagsAwaitingAdminReview - Counts unreviewed escalations across active courses. * * @param ctx - Connected Mongo data-layer context * @returns Persistent administrator dashboard count */ export async function countGuidedPathwayFlagsAwaitingAdminReview(ctx: MongoDalContext): Promise { - await ensureGuidedPathwayFlagIndexes(ctx); - return flags(ctx).countDocuments({ status: 'escalated', adminReviewedAt: { $exists: false } }); + const scopes = await listGuidedPathwayFlagCourseScopes(ctx); + if (scopes.length === 0) return 0; + + const pipeline = [ + ...unionCourseCollections(scopes), + { $match: { status: 'escalated', adminReviewedAt: { $exists: false } } }, + { $count: 'value' } + ]; + const [result] = await ctx.db + .collection(scopes[0].collectionName) + .aggregate<{ value: number }>(pipeline, { allowDiskUse: true }) + .toArray(); + return result?.value ?? 0; } /** - * listGuidedPathwayFlagsForBackup - Loads an anonymous course-scoped backup slice. - * - * Restricted identity, opaque dedupe material, request identifiers, and reveal - * audit events are excluded by the same allowlist used for interface reads. + * listGuidedPathwayFlagsForBackup - Loads an anonymous course-owned backup slice. * * @param ctx - Connected Mongo data-layer context * @param courseId - Course whose alerts are being exported @@ -599,7 +658,8 @@ export async function listGuidedPathwayFlagsForBackup( ctx: MongoDalContext, courseId: string ): Promise { - const docs = await flags(ctx) + const scope = await requireCourseScope(ctx, courseId); + const docs = await collectionFor(ctx, scope) .find({ courseId }, { projection: SAFE_FLAG_PROJECTION }) .sort({ triggeredAt: -1 }) .toArray(); @@ -607,16 +667,21 @@ export async function listGuidedPathwayFlagsForBackup( } /** - * deleteGuidedPathwayFlagsForCourse - Removes global alert rows for a deleted/reset course. + * deleteGuidedPathwayFlagsForCourse - Drops the collection owned by a deleted/reset course. * * @param ctx - Connected Mongo data-layer context * @param courseId - Course lifecycle boundary - * @returns Number of global alert rows removed + * @returns Number of alert rows removed with the collection */ export async function deleteGuidedPathwayFlagsForCourse( ctx: MongoDalContext, courseId: string ): Promise { - const result = await flags(ctx).deleteMany({ courseId }); - return result.deletedCount; + const scope = await requireCourseScope(ctx, courseId); + const collection = collectionFor(ctx, scope); + const removed = await collection.countDocuments({ courseId }); + await collection.drop(); + return removed; } + +export { migrateGuidedPathwayFlagsToCourseCollections }; diff --git a/src/db/mongo/mongo-collections.ts b/src/db/mongo/mongo-collections.ts index ff7849ea..677b94da 100644 --- a/src/db/mongo/mongo-collections.ts +++ b/src/db/mongo/mongo-collections.ts @@ -63,11 +63,11 @@ export function instructorPeriodAllowancesCollection(db: Db): Collection { /** * guidedPathwayFlagsCollection * - * Returns the global alert collection. Every query must still apply an explicit - * course or administrator scope and must project identity fields deliberately. + * Returns the legacy shared alert collection used only as the GPF-001 migration source. + * Runtime alert reads and writes use deterministic course-owned collections. * * @param db - Connected Mongo database handle - * @returns `Collection` - `guided-pathway-flags` + * @returns `Collection` - legacy `guided-pathway-flags` migration source */ export function guidedPathwayFlagsCollection(db: Db): Collection { return db.collection(GUIDED_PATHWAY_FLAGS_COLLECTION); diff --git a/src/db/mongo/mongo-constants.ts b/src/db/mongo/mongo-constants.ts index f1ab9acb..0dd4e205 100644 --- a/src/db/mongo/mongo-constants.ts +++ b/src/db/mongo/mongo-constants.ts @@ -19,5 +19,5 @@ export const ACADEMIC_PERIODS_COLLECTION = 'academic-periods'; /** MongoDB collection name for period-scoped instructor course allow-lists. */ export const INSTRUCTOR_PERIOD_ALLOWANCES_COLLECTION = 'instructor-period-allowances'; -/** MongoDB collection name for anonymous Guided Pathway trigger alerts across all courses. */ +/** Legacy shared Guided Pathway collection retained as the GPF-001 migration source. */ export const GUIDED_PATHWAY_FLAGS_COLLECTION = 'guided-pathway-flags'; diff --git a/src/db/mongo/mongo-context.ts b/src/db/mongo/mongo-context.ts index 18d8fba8..71d8ae6e 100644 --- a/src/db/mongo/mongo-context.ts +++ b/src/db/mongo/mongo-context.ts @@ -23,7 +23,9 @@ export interface CourseCollectionNames { /** `{courseName}_scenario_progress` — SQ-004 lazy-provisioned, see `scenario-progress-mongo.ts`. */ scenarioProgress: string; /** `{courseName}_pathways` — lazy-provisioned, see `pathways-mongo.ts`. */ - pathways: string; + pathways: string; + /** Stable-id-derived collection for automatic Guided Pathway trigger alerts. */ + guidedPathwayFlags: string; } /** diff --git a/src/routes/__tests__/guided-pathway-flag-admin-routes.test.ts b/src/routes/__tests__/guided-pathway-flag-admin-routes.test.ts index 49847e96..6ae53c3a 100644 --- a/src/routes/__tests__/guided-pathway-flag-admin-routes.test.ts +++ b/src/routes/__tests__/guided-pathway-flag-admin-routes.test.ts @@ -23,6 +23,10 @@ import { EngEAI_MongoDB } from '../../db/enge-ai-mongodb'; import adminGuidedPathwayFlagRoutes from '../mongo/admin-guided-pathway-flag-routes'; describe('administrator Guided Pathway flag list API', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('returns server-provided safe facets and requests facet-wide Mongo queries', async () => { const data = { items: [], @@ -34,9 +38,9 @@ describe('administrator Guided Pathway flag list API', () => { reviewers: ['Instructor A'] } }; - const listGuidedPathwayFlags = jest.fn().mockResolvedValue(data); + const listGuidedPathwayFlagsForAdmin = jest.fn().mockResolvedValue(data); (EngEAI_MongoDB.getInstance as jest.Mock).mockResolvedValue({ - listGuidedPathwayFlags + listGuidedPathwayFlagsForAdmin }); const app = express(); @@ -49,7 +53,7 @@ describe('administrator Guided Pathway flag list API', () => { expect(response.status).toBe(200); expect(response.body).toEqual({ success: true, data }); - expect(listGuidedPathwayFlags).toHaveBeenCalledWith(expect.objectContaining({ + expect(listGuidedPathwayFlagsForAdmin).toHaveBeenCalledWith(expect.objectContaining({ page: 1, pageSize: 20, status: 'escalated', @@ -63,9 +67,9 @@ describe('administrator Guided Pathway flag list API', () => { }); it('rejects review-state filters combined with a non-escalated decision', async () => { - const listGuidedPathwayFlags = jest.fn(); + const listGuidedPathwayFlagsForAdmin = jest.fn(); (EngEAI_MongoDB.getInstance as jest.Mock).mockResolvedValue({ - listGuidedPathwayFlags + listGuidedPathwayFlagsForAdmin }); const app = express(); @@ -79,6 +83,42 @@ describe('administrator Guided Pathway flag list API', () => { success: false, error: 'Admin review filters apply only to escalated alerts' }); - expect(listGuidedPathwayFlags).not.toHaveBeenCalled(); + expect(listGuidedPathwayFlagsForAdmin).not.toHaveBeenCalled(); + }); + + it('uses both course id and flag id for administrator review and reveal mutations', async () => { + const reviewed = { id: 'shared-flag', courseId: 'course-2', status: 'escalated' }; + const markGuidedPathwayFlagAdminReviewed = jest.fn().mockResolvedValue(reviewed); + const revealGuidedPathwayFlagIdentity = jest.fn().mockResolvedValue({ studentName: 'Student' }); + (EngEAI_MongoDB.getInstance as jest.Mock).mockResolvedValue({ + markGuidedPathwayFlagAdminReviewed, + revealGuidedPathwayFlagIdentity + }); + + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).session = { + globalUser: { userId: 'admin-1', name: 'Admin' } + }; + next(); + }); + app.use('/', adminGuidedPathwayFlagRoutes); + + const reviewResponse = await request(app).patch('/course-2/shared-flag/review'); + const revealResponse = await request(app).post('/course-2/shared-flag/reveal-identity'); + + expect(reviewResponse.status).toBe(200); + expect(revealResponse.status).toBe(200); + expect(markGuidedPathwayFlagAdminReviewed).toHaveBeenCalledWith( + 'course-2', + 'shared-flag', + { userId: 'admin-1', name: 'Admin' } + ); + expect(revealGuidedPathwayFlagIdentity).toHaveBeenCalledWith( + 'course-2', + 'shared-flag', + { userId: 'admin-1', name: 'Admin' } + ); }); }); diff --git a/src/routes/mongo/admin-guided-pathway-flag-routes.ts b/src/routes/mongo/admin-guided-pathway-flag-routes.ts index f550f067..decd7610 100644 --- a/src/routes/mongo/admin-guided-pathway-flag-routes.ts +++ b/src/routes/mongo/admin-guided-pathway-flag-routes.ts @@ -191,7 +191,7 @@ router.get( courseIds = period.courseIds; } - const data = await mongo.listGuidedPathwayFlags({ + const data = await mongo.listGuidedPathwayFlagsForAdmin({ page, pageSize, status: status as GuidedPathwayFlagStatus | undefined, @@ -217,12 +217,13 @@ router.get( ); router.patch( - '/:flagId/review', + '/:courseId/:flagId/review', requireAdminGlobal, asyncHandlerWithAuth(async (req: Request, res: Response) => { try { const mongo = await EngEAI_MongoDB.getInstance(); const data = await mongo.markGuidedPathwayFlagAdminReviewed( + routeParam(req.params, 'courseId'), routeParam(req.params, 'flagId'), actorFromSession(req) ); @@ -239,12 +240,13 @@ router.patch( ); router.post( - '/:flagId/reveal-identity', + '/:courseId/:flagId/reveal-identity', requireAdminGlobal, asyncHandlerWithAuth(async (req: Request, res: Response) => { try { const mongo = await EngEAI_MongoDB.getInstance(); const data = await mongo.revealGuidedPathwayFlagIdentity( + routeParam(req.params, 'courseId'), routeParam(req.params, 'flagId'), actorFromSession(req) ); diff --git a/src/routes/mongo/guided-pathway-flag-routes.ts b/src/routes/mongo/guided-pathway-flag-routes.ts index 6aac5507..290a7f35 100644 --- a/src/routes/mongo/guided-pathway-flag-routes.ts +++ b/src/routes/mongo/guided-pathway-flag-routes.ts @@ -91,8 +91,7 @@ export function mountGuidedPathwayFlagRoutes(router: Router): void { try { const mongo = await EngEAI_MongoDB.getInstance(); - const data = await mongo.listGuidedPathwayFlags({ - courseId, + const data = await mongo.listGuidedPathwayFlagsForCourse(courseId, { page, pageSize, status: rawStatus as GuidedPathwayFlagStatus | undefined diff --git a/src/routes/route-mongo.ts b/src/routes/route-mongo.ts index 7049f8d7..64c48bc5 100644 --- a/src/routes/route-mongo.ts +++ b/src/routes/route-mongo.ts @@ -1331,7 +1331,7 @@ router.delete('/:id/restart-onboarding', requireInstructorOrAdminForCourseAPI([' // Get collection names before deleting the course (to use stored names if available) const collectionNames = await instance.getCollectionNames(courseName); - // Remove rows from the global Guided Pathway alert collection before replacing the course id. + // Drop the course-owned Guided Pathway alert collection before replacing the course id. await instance.deleteGuidedPathwayFlagsForCourse(course.id); // Remove course from active-course-list @@ -1541,7 +1541,7 @@ router.delete('/:id', requireInstructorOrAdminForCourseAPI(['paramsId']), asyncH }); } - // Remove rows owned by this course from the global Guided Pathway alert collection. + // Drop the physical Guided Pathway alert collection owned by this course. await instance.deleteGuidedPathwayFlagsForCourse(existingCourse.id); // Delete the course catalog row. diff --git a/src/server.ts b/src/server.ts index 4f5a4b0e..b23c1c48 100644 --- a/src/server.ts +++ b/src/server.ts @@ -298,6 +298,14 @@ app.listen(port, async () => { logger.error('Failed to initialize academic periods:', err as any); } + try { + const mongo = await EngEAI_MongoDB.getInstance(); + const migration = await mongo.migrateGuidedPathwayFlagsToCourseCollections(); + logger.info('Guided Pathway GPF-001 storage migration complete', migration); + } catch (err) { + logger.error('Guided Pathway GPF-001 storage migration failed:', err as any); + } + try { await migrateInstructorAllowances(); } catch (err) { diff --git a/src/types/shared.ts b/src/types/shared.ts index d8864f99..663a9dd8 100644 --- a/src/types/shared.ts +++ b/src/types/shared.ts @@ -332,6 +332,8 @@ export interface activeCourse { scenarioProgress?: string; /** Per-course Guided Pathway Library (e.g. `${courseName}_pathways`); lazy-provisions on existing courses */ pathways?: string; + /** Course-owned automatic Guided Pathway alerts; derived from stable course id by GPF-001 */ + guidedPathwayFlags?: string; }; collectionOfInitialAssistantPrompts?: InitialAssistantPrompt[]; /** @deprecated v2 uses systemPromptConfig; retained for lazy migration reads only */ From 30f6129f125af14ddd946603916806ccdc5dd770 Mon Sep 17 00:00:00 2001 From: Christopher Rodas Date: Mon, 17 Aug 2026 20:20:57 -0700 Subject: [PATCH 3/7] feat: align guided pathway flag storage with active-course registry --- documents/DATA_MIGRATIONS.md | 88 +- documents/ENDPOINT_ARCHITECTURE.md | 82 +- documents/FLAG_ARCHITECTURE.md | 59 ++ documents/MONGO_DATA_LAYER.md | 16 +- package-lock.json | 4 +- package.json | 2 +- public/components/report/flag-instructor.html | 7 +- .../scripts/feature/guided-pathway-flags.ts | 62 +- public/scripts/types.ts | 8 +- .../instructor-components/flag-instructor.css | 19 + src/db/enge-ai-mongodb.ts | 22 +- .../collection-registry-mongo.test.ts | 59 ++ .../__tests__/course-backup-mongo.test.ts | 63 +- src/db/mongo/__tests__/course-mongo.test.ts | 106 +++ src/db/mongo/__tests__/flag-mongo.test.ts | 19 - ...ided-pathway-flag-collection-mongo.test.ts | 623 ++++++++++---- .../guided-pathway-flag-mongo.test.ts | 209 ++++- .../mongo/__tests__/mongo-collections.test.ts | 2 +- src/db/mongo/__tests__/pathways-mongo.test.ts | 21 + src/db/mongo/collection-registry-mongo.ts | 5 +- src/db/mongo/course-mongo.ts | 76 +- src/db/mongo/flag-mongo.ts | 74 +- .../guided-pathway-flag-collection-mongo.ts | 762 ++++++++++++++---- src/db/mongo/guided-pathway-flag-mongo.ts | 258 +++--- src/db/mongo/mongo-collections.ts | 19 +- src/db/mongo/mongo-constants.ts | 5 +- src/db/mongo/mongo-context.ts | 2 +- src/flags/README.md | 33 + .../guided-pathway-flag-policy.test.ts | 77 ++ .../guided-pathway-flag-service.test.ts} | 49 +- .../__tests__/manual-flag-policy.test.ts | 31 + src/flags/guided-pathway-flag-contracts.ts | 70 ++ src/flags/guided-pathway-flag-errors.ts | 35 + src/flags/guided-pathway-flag-policy.ts | 53 ++ src/flags/guided-pathway-flag-service.ts | 80 ++ src/flags/manual-flag-policy.ts | 72 ++ .../pathway-alert-persistence.ts | 82 -- .../__tests__/require-course-role.test.ts | 68 +- src/middleware/require-course-role.ts | 43 + .../manual-flag-routes-contract.test.ts | 146 ++++ .../mongo/admin-guided-pathway-flag-routes.ts | 8 +- .../mongo/guided-pathway-flag-routes.ts | 8 +- src/routes/route-chat-app.ts | 19 +- src/routes/route-mongo.ts | 209 ++--- src/server.ts | 4 +- src/types/shared.ts | 8 +- 46 files changed, 2949 insertions(+), 818 deletions(-) create mode 100644 documents/FLAG_ARCHITECTURE.md create mode 100644 src/db/mongo/__tests__/collection-registry-mongo.test.ts create mode 100644 src/db/mongo/__tests__/course-mongo.test.ts delete mode 100644 src/db/mongo/__tests__/flag-mongo.test.ts create mode 100644 src/flags/README.md create mode 100644 src/flags/__tests__/guided-pathway-flag-policy.test.ts rename src/{guided-pathways/__tests__/pathway-alert-persistence.test.ts => flags/__tests__/guided-pathway-flag-service.test.ts} (53%) create mode 100644 src/flags/__tests__/manual-flag-policy.test.ts create mode 100644 src/flags/guided-pathway-flag-contracts.ts create mode 100644 src/flags/guided-pathway-flag-errors.ts create mode 100644 src/flags/guided-pathway-flag-policy.ts create mode 100644 src/flags/guided-pathway-flag-service.ts create mode 100644 src/flags/manual-flag-policy.ts delete mode 100644 src/guided-pathways/pathway-alert-persistence.ts create mode 100644 src/routes/__tests__/manual-flag-routes-contract.test.ts diff --git a/documents/DATA_MIGRATIONS.md b/documents/DATA_MIGRATIONS.md index fa03d9db..d9dfa71e 100644 --- a/documents/DATA_MIGRATIONS.md +++ b/documents/DATA_MIGRATIONS.md @@ -27,7 +27,8 @@ Operational startup migrations (OB-001) are documented here but are **not** tied | **AP-001** | Course `academicPeriodId` backfill | Lazy (request) | `lazyMigrateCourseAcademicPeriod` in `src/db/mongo/academic-period-mongo.ts` via `getActiveCourse` / `getAllActiveCourses` | missing `academicPeriodId` → default `2025W2` period; `$addToSet` on period `courseIds` | **Remove by 2026-06-30** — see [AP-001](#ap-001-academic-period-lazy-link) | | **IPA-001** | Instructor allow-list period scope | Startup (once) | `migrateInstructorAllowances` in `src/helpers/migrate-instructor-allowances.ts` | `instructor-allowed-courses` → `instructor-period-allowances` for `2025W2` | Operational after first successful run | | **ADM-001** | Platform admin `isAdmin` backfill | Startup | `migratePlatformAdmins` in `src/helpers/migrate-platform-admins.ts` | GlobalUsers matching `CHARISMA_RUSDIYANTO_PUID` / `RICHARD_TAPE_PUID` → `isAdmin: true` | Operational — keep unless product changes | -| **GPF-001** | Guided Pathway alert course isolation | Startup + operation gate | `migrateGuidedPathwayFlagsToCourseCollections` in `src/db/mongo/guided-pathway-flag-collection-mongo.ts` | shared `guided-pathway-flags` rows → deterministic course-owned collections | Operational — retain until every environment has no recoverable legacy rows | +| **GPF-001** | Guided Pathway alert course isolation | Superseded | Historical implementation in `guided-pathway-flag-collection-mongo.ts` | shared `guided-pathway-flags` rows → hashed per-course collections | Superseded by GPF-002; hash recognition remains migration-only | +| **GPF-002** | Guided Pathway registered collection normalization | Startup + operation gate | `migrateGuidedPathwayFlagsToCourseCollections` in `src/db/mongo/guided-pathway-flag-collection-mongo.ts` | shared and hashed rows → readable `activeCourse.collections.guidedPathwayFlags` targets; lease/result → `application-migrations` | Operational — retain until every environment passes the GPF-002 postchecks and retained legacy data is resolved | | **SQ-001** | Scenario Questions collection backfill | Lazy (first API call) | `ensureScenarioQuestionsCollection` in `src/db/mongo/scenario-questions-mongo.ts` | missing `activeCourse.collections.scenarioQuestions` → creates `{courseName}_scenario_questions` + `$set` the field | Keep while any pre-feature course document may lack `collections.scenarioQuestions` | | **SQ-004** | Scenario Progress collection backfill | Lazy (first progress API call) | `ensureScenarioProgressCollection` in `src/db/mongo/scenario-progress-mongo.ts` | missing `activeCourse.collections.scenarioProgress` → creates `{courseName}_scenario_progress` + `$set` the field | Keep while any course may lack `collections.scenarioProgress` | @@ -35,22 +36,56 @@ Operational startup migrations (OB-001) are documented here but are **not** tied ## GPF-001: Guided Pathway alert course isolation +**Status:** Superseded by GPF-002 + +GPF-001 moved the original global `guided-pathway-flags` rows into +`guided-pathway-flags-course-` namespaces. It also made the hash authoritative +instead of reading `activeCourse.collections.guidedPathwayFlags`. Some environments may +already contain those namespaces, so GPF-002 still recognizes them as migration sources. +GPF-001 must not run independently again. + +--- + +## GPF-002: Guided Pathway registered collection normalization + **Status:** Active (startup migration with operation-level gate) -**Collections:** legacy `guided-pathway-flags`, `active-course-list`, and one `guided-pathway-flags-course-` collection per course id +**Collections:** legacy `guided-pathway-flags`, legacy `guided-pathway-flags-course-`, `active-course-list`, durable migration state in `application-migrations`, and readable registered course targets such as `{courseName}_guided-pathway-flags` ### Behavior -1. Derive each active course namespace from a 96-bit SHA-256 prefix of its stable `courseId`, persist it as `activeCourse.collections.guidedPathwayFlags`, and ensure the alert indexes. -2. Read distinct string `courseId` values from the legacy shared collection. A legacy course id missing from the active catalog receives its own isolated destination so it cannot merge with another course's rows. -3. Copy at most 200 records at a time with `_id`-keyed replacement upserts. Verify every source `_id` exists in the destination before deleting that exact source batch. -4. Drop the legacy collection when empty. Retain malformed rows without a usable string `courseId` and log their count for manual recovery. +1. Create/verify a partial unique index on the non-empty string `activeCourse.collections.guidedPathwayFlags` field. This is the cross-process catalog guard that prevents two active courses from owning the same automatic-alert namespace. +2. Treat a valid non-hash registered value as authoritative. When the field is missing or still points at a GPF-001 hash, choose `{courseName}_guided-pathway-flags` and persist it only after source rows are copied and verified. A later course rename does not recompute the stored name. +3. Preflight every target. Reject protected names, collisions with another registered course collection, duplicate Guided Pathway registrations, and a physical target containing any row whose `courseId` differs from the target course. The checks and logs use counts/identity metadata only and never emit alert content. +4. Copy matching rows from the course's GPF-001 hash namespace first, then the older shared collection, in 200-row `_id` batches. Each destination operation is an upsert with `$setOnInsert`, so the per-course source wins a duplicate legacy `_id` while an existing readable-target document remains authoritative. Verify every destination `_id` with its owning `courseId` before switching the catalog field. +5. Compare-and-set `collections.guidedPathwayFlags`, invalidate the course collection-name cache only after success, and then verify that the catalog still owns the target. Re-verify exact source `_id` batches before deleting them. Drop only namespaces that are empty; a missing namespace is an idempotent cleanup result. +6. Retain malformed global rows and non-empty hash namespaces without an active catalog owner for manual recovery. Never guess ownership or attach orphan data to a current course. +7. Do not register or create storage for an untouched existing course with no registration, no shared/hash source, and no pre-existing readable namespace. Alert creation uses the provisioning resolver when storage is first needed. Course/admin list, count, backup, and cross-course aggregation use read-only resolution and include only existing registered collections. + +Startup invokes GPF-002 after academic-period initialization. Guided Pathway operations also await the migration gate. The exported method retains its historical name for façade compatibility. -Startup invokes the migration after academic-period initialization. Every Guided Pathway persistence operation also awaits the memoized migration, so requests cannot race ahead of it. A failed migration promise is discarded and the next call retries from the last verified batch. +### Cross-process coordination + +GPF-002 stores one record at `application-migrations._id = 'GPF-002'`: + +- The owner acquires `state: 'running'` with a random `ownerId` and a renewable five-minute `leaseUntil`. +- Other application instances poll the durable record. A failed record, an expired lease, or a running record with no lease can be claimed for retry. +- The owner renews the lease between bounded migration/copy/cleanup phases. Losing the lease fails the attempt before it can report completion. +- Success persists `state: 'complete'`, the count-only result, and `completedAt`, then removes owner/lease fields. Later processes and restarts return that persisted result without rerunning data movement. +- Failure marks the record `failed` and removes the lease so a later operation can retry. A process-local promise coalesces callers only within one application instance and is discarded on failure. ### Idempotency and failure safety -Destination writes are upserts by Mongo `_id`; retrying after copy but before source deletion does not duplicate an alert. Source deletion never runs for an unverified batch. Concurrent migrators accept a batch already removed by another instance only after confirming no source `_id` remains. Per-course unique alert-id and deduplication indexes remain the runtime guards after migration. +Destination writes are insert-only upserts by Mongo `_id`; retrying after a partial copy does not duplicate an alert and does not overwrite a newer target decision, admin review, or reveal-audit history with a stale legacy snapshot. A source row is never deleted before target verification, compare-and-set catalog registration, and a second ownership check. Per-course unique alert-id and deduplication indexes remain the runtime guards. + +GPF-001 application instances ignore the registered field and can continue writing hashes. Because a completed GPF-002 record makes later runs a no-op, any old process writing after completion would strand new rows in a migration-only source. Run GPF-002 only after old instances stop accepting chat traffic; a rolling mixed-version migration is unsupported. + +### Deployment preconditions + +1. Take and validate a recoverable full database backup. +2. Stop every pre-GPF-002 application instance from accepting chat/write traffic before a new instance starts the migration. +3. Ensure the deployment identity can read/write `application-migrations`, create the catalog and per-course indexes, create target collections, update `active-course-list`, and delete/drop only verified legacy sources. +4. Do not manually mark the migration complete. If startup fails, inspect the count-only migration error/result and retry the same build after correcting the cause. ### Verification (Mongo shell) @@ -58,13 +93,46 @@ Destination writes are upserts by Mongo `_id`; retrying after copy but before so db.getCollection('guided-pathway-flags').countDocuments({ courseId: { $type: 'string' } }) + +db.getCollection('active-course-list').countDocuments({ + 'collections.guidedPathwayFlags': /^guided-pathway-flags-course-[a-f0-9]{24}$/ +}) + +db.getCollection('active-course-list').aggregate([ + { $match: { 'collections.guidedPathwayFlags': { $type: 'string' } } }, + { $group: { _id: '$collections.guidedPathwayFlags', owners: { $addToSet: '$id' }, count: { $sum: 1 } } }, + { $match: { count: { $gt: 1 } } } +]) + +db.getCollection('application-migrations').findOne( + { _id: 'GPF-002' }, + { _id: 1, state: 1, completedAt: 1, result: 1 } +) + +db.getCollection('active-course-list').getIndexes().filter( + ({ name }) => name === 'guided_pathway_flag_collection_unique' +) ``` -Target after a successful deployment: `0`. If the legacy collection remains, inspect malformed retained rows before removing it manually. +The first two counts and the duplicate-registration aggregation should be empty/zero for migratable active-course data. The migration record must be `state: 'complete'`, and the named partial unique catalog index must exist. Review the count-only `result.retainedLegacyRows`, `result.retainedHashedCollections`, and `result.orphanCourseCollections` values. Non-empty global or hash sources require manual ownership review; do not delete them merely to satisfy the count. + +For every active course with a non-empty registration, also verify operationally that the named collection exists and contains no row with a different `courseId`. Perform that check with counts/projections only; do not print messages, user identifiers, deduplication keys, or reveal events. ### Rollback -Restore the database from the pre-deployment backup. Do not merge course collections back into a shared namespace while this application version is running because runtime reads and lifecycle cleanup intentionally resolve one course-owned collection. +Rollback requires restoring the pre-deployment database backup together with the pre-GPF-002 application build. Redeploying the old build alone is unsafe because new writes use readable registered targets and verified legacy rows may already have been removed. If an attempt fails before completion, prefer correcting the cause and retrying the same GPF-002 build; do not edit the lease/result record or move rows manually without a separate recovery plan. + +### Sunset criteria + +Keep the operation gate, shared/hash source discovery, and durable migration record handling until every deployed environment has: + +- a `complete` GPF-002 record from the current migration implementation; +- zero migratable active-course rows in the shared source and zero hash registrations; +- no duplicate/unsafe registered namespaces and the partial unique registry index present; +- reviewed and resolved every retained malformed/orphan source; and +- passed an agreed rollback-support window with no pre-GPF-002 application version eligible for redeployment. + +After those conditions are documented, a separate change may remove global/hash discovery and the operation-level migration gate. Keep registry-authoritative resolution, the unique catalog index, and read-only versus provisioning resolution after migration code is retired. --- diff --git a/documents/ENDPOINT_ARCHITECTURE.md b/documents/ENDPOINT_ARCHITECTURE.md index 022ce95f..d6fd3a71 100644 --- a/documents/ENDPOINT_ARCHITECTURE.md +++ b/documents/ENDPOINT_ARCHITECTURE.md @@ -288,24 +288,38 @@ Live Canvas OAuth routes are intentionally absent from this table until the priv | PUT | `/api/courses/:courseId/topic-or-week-instances/:topicOrWeekId/items/:itemId/struggle-topics/:struggleTopicId` | Yes | Instructor | Update struggle topic (response includes `changed`) | | DELETE | `/api/courses/:courseId/topic-or-week-instances/:topicOrWeekId/items/:itemId/struggle-topics/:struggleTopicId` | Yes | Instructor | Delete struggle topic (response includes `changed`) | -#### Flags (student creates; instructor manages) +#### Manual flags (explicit report; instructor manages) | Method | Path | Auth | Role | Description | |--------|------|------|------|-------------| | POST | `/api/courses/:courseId/flags` | Yes | Student or Instructor | Create flag (shared) | | GET | `/api/courses/:courseId/flags` | Yes | Instructor | List flags | | GET | `/api/courses/:courseId/flags/with-names` | Yes | Instructor | List flags with names | +| GET | `/api/courses/:courseId/flags/validate` | Yes | Instructor | Validate flag collection integrity | +| GET | `/api/courses/:courseId/flags/statistics` | Yes | Instructor | Flag counts for the course | +| GET | `/api/courses/:courseId/flags/student/:userId` | Yes | **Record owner or course staff** | One student's flag history | | GET | `/api/courses/:courseId/flags/:flagId` | Yes | Instructor | Get flag report | | PUT | `/api/courses/:courseId/flags/:flagId` | Yes | Instructor | Update flag | | PATCH | `/api/courses/:courseId/flags/:flagId/response` | Yes | Instructor | Update response | +`GET /flags/student/:userId` is student-facing — a student reads their own history — so it uses +`requireSelfOrInstructorForCourseAPI` rather than an instructor-only guard: the record owner passes, +course staff pass, and every other authenticated caller receives `403`. The target user id arrives in +the path and is untrusted, so course scope alone is not sufficient authorization. + +The literal `/flags/validate`, `/flags/statistics`, `/flags/with-names`, and `/flags/student/:userId` +routes must stay declared **above** `/flags/:flagId`. Express matches in declaration order, so a +literal route registered after the capture is shadowed and never runs. + #### Guided Pathway Library and automatic alerts Guided Pathway configuration is separate from manual student-created flags. Faculty instructors and platform admins may configure pathways; teaching assistants cannot. `enabled` controls whether a pathway can trigger. The independent `notifyInstructorOnTrigger` setting controls whether a successful trigger creates an automatic alert, and defaults to `true` for new, seeded, and legacy -records where the field is missing. +records where the field is missing. Manually created and seeded pathways use the same evaluator. +When a listed faculty instructor exercises a notification-enabled pathway in normal instructor chat, +the server records a course-local `instructor-test` alert; the client cannot request or forge test mode. | Method | Path | Auth | Role | Description | |--------|------|------|------|-------------| @@ -315,36 +329,56 @@ records where the field is missing. | PUT | `/api/courses/:courseId/pathways/:pathwayId` | Yes | Faculty instructor or **Admin** | Update configuration, including either independent switch | | DELETE | `/api/courses/:courseId/pathways/:pathwayId` | Yes | Faculty instructor or **Admin** | Delete a pathway definition | | POST | `/api/courses/:courseId/pathways/reset` | Yes | Faculty instructor or **Admin** | Restore platform defaults with notification on | -| GET | `/api/courses/:courseId/guided-pathway-flags` | Yes | Faculty instructor or **Admin** | Paginated anonymous course alert list; optional `status` | -| PATCH | `/api/courses/:courseId/guided-pathway-flags/:flagId/decision` | Yes | Faculty instructor or **Admin** | Atomic pending decision; body `{ decision: 'escalate' | 'dismiss' }` | -| GET | `/api/admin/guided-pathway-flags` | Yes | **Admin** | Cross-course anonymous queue with period/course/pathway/status/reviewer/date filters | -| PATCH | `/api/admin/guided-pathway-flags/:courseId/:flagId/review` | Yes | **Admin** | Mark an escalated item reviewed in its owning course without deleting it | -| POST | `/api/admin/guided-pathway-flags/:courseId/:flagId/reveal-identity` | Yes | **Admin** | Audit an escalated-item reveal in its owning course, then return only the current roster display name | - -List and action responses use an explicit anonymous projection: pathway/course snapshots, exact -student message, trigger/decision/review times, state, and staff reviewer display names. They never -include the student's name or user ID, PUID, chat/request identifiers, deduplication key, or reveal +| GET | `/api/courses/:courseId/guided-pathway-flags` | Yes | Faculty instructor or **Admin** | Paginated anonymous owning-course alert list, including labelled instructor tests; optional `status` | +| PATCH | `/api/courses/:courseId/guided-pathway-flags/:flagId/decision` | Yes | Faculty instructor or **Admin** | Atomic pending decision; student body `{ decision: 'escalate' \| 'dismiss' }`; instructor tests permit `dismiss` only | +| GET | `/api/admin/guided-pathway-flags` | Yes | **Admin** | Cross-course anonymous student-alert queue with period/course/pathway/status/reviewer/date filters; instructor tests excluded | +| PATCH | `/api/admin/guided-pathway-flags/:courseId/:flagId/review` | Yes | **Admin** | Mark an escalated student alert reviewed in its owning course without deleting it; tests rejected | +| POST | `/api/admin/guided-pathway-flags/:courseId/:flagId/reveal-identity` | Yes | **Admin** | Audit an escalated student-alert reveal in its owning course, then return only the current roster display name; tests rejected | + +List and action responses use an explicit anonymous projection: `origin`, pathway/course snapshots, +exact message, trigger/decision/review times, state, and staff reviewer display names. They never +include a student or tester user ID, PUID, chat/request identifiers, deduplication key, or reveal audit events. The exact message is not automatically redacted and can still identify its author if -the student writes personal information in it. +the author writes personal information in it. Existing rows with no `origin` are returned as +`origin: 'student'`. -Automatic alerts have `pending`, `escalated`, and `dismissed` states. Instructor decisions are -final in this version, and completed records remain viewable. Escalation is an internal decision: +Production student alerts have `pending`, `escalated`, and `dismissed` states. Instructor decisions +are final in this version, and completed records remain viewable. Escalation is an internal decision: EngE-AI surfaces it to platform admins but does not contact LTIC. Admin identity reveal is available -only on escalated records, requires confirmation in the client, is re-masked after refresh, and -fails closed when the audit write fails. Students and teaching assistants cannot call these APIs; -automatic alerts never enter Student Flag History. - -Each course stores automatic alerts in its own deterministic Mongo collection. Course routes resolve -only that collection, while the platform-admin queue aggregates canonical active-course collections -server-side. Including `courseId` in admin action paths makes equal alert ids in different courses -unambiguous. Existing rows in the former shared collection are moved by GPF-001; see -[DATA_MIGRATIONS.md](DATA_MIGRATIONS.md#gpf-001-guided-pathway-alert-course-isolation). +only on escalated student records, requires confirmation in the client, is re-masked after refresh, +and fails closed when the audit write fails. Student records retain a restricted internal +`studentUserId` only for this audited reveal path. + +At creation, instructor-test records store neither `studentUserId` nor a separate raw trigger-actor +identity. A listed instructor's ID may participate in the opaque deduplication digest but is not +returned as trigger identity. A later dismissal retains the ordinary authorized decision-actor audit +fields; those describe who made the decision, not who originally triggered the test. +Tests are visible only in the owning course, show a `Test` label and `Instructor test message`, and +offer only `Mark test complete` (the dismiss transition). Server guards reject test escalation, +admin review, and identity reveal with `409` before mutation, audit, or roster access. TA membership, +platform-admin +privilege without explicit instructor listing, outsiders, and missing course/user context do not +create tests. Students and teaching assistants cannot call these APIs; automatic alerts never enter +Student Flag History. + +Each course stores automatic alerts separately from manual flags in the physical collection named by +`activeCourse.collections.guidedPathwayFlags`. New registrations default to the readable +`${courseName}_guided-pathway-flags` name, but the stored registry value remains authoritative after +a rename. Course routes resolve only that registered collection, while the platform-admin queue +aggregates existing registered active-course collections server-side. Alert creation may provision a +missing legacy-course target; list, count, backup, and admin aggregation paths do not create empty +collections. Including `courseId` in admin action paths makes equal alert ids in different courses +unambiguous. GPF-001 hash namespaces are migration inputs only; GPF-002 moves shared/hash rows to +registered targets under a Mongo-backed lease. See +[DATA_MIGRATIONS.md](DATA_MIGRATIONS.md#gpf-002-guided-pathway-registered-collection-normalization). `GET /api/admin/course-selection` also returns `data.guidedPathwayEscalationsAwaitingReview`, counting escalated records with no admin review time. The course-selection dashboard renders that count as a bell badge between the welcome text and logout. Clicking the bell opens the same anonymous admin queue, prefiltered to escalated items needing review; -the badge refreshes after review actions. There is no polling, email, or external notification. +the badge refreshes after review actions. Instructor tests are excluded from the queue, all filter +facets and totals, reviewer facets, and this bell count. There is no polling, email, or external +notification. #### Monitor (instructor roster; post-period analytics) diff --git a/documents/FLAG_ARCHITECTURE.md b/documents/FLAG_ARCHITECTURE.md new file mode 100644 index 00000000..265ec2b2 --- /dev/null +++ b/documents/FLAG_ARCHITECTURE.md @@ -0,0 +1,59 @@ +# Flag architecture + +EngE-AI has two course-scoped flag workflows. They share a discoverable domain folder but intentionally retain separate storage schemas, privacy boundaries, and lifecycles. + +## Boundaries + +| Layer | Responsibility | +| --- | --- | +| `src/flags` | Persistence-neutral contracts, trigger and transition policy, and failure-isolated orchestration | +| `src/db/mongo` | Registered collection resolution, indexes, CRUD, safe projections, and migration behavior | +| `src/routes` | HTTP validation, course RBAC, session-owned actors, and response mapping | +| `src/guided-pathways` | Pathway configuration, prompt/classifier behavior, and winning-trigger selection | + +Routes call the `EngEAI_MongoDB` facade. Domain services depend on small writer contracts rather than importing Mongo implementation types. + +## Workflow comparison + +| Concern | Manual flag | Guided Pathway flag | +| --- | --- | --- | +| Creation | Explicit report action | Automatic persistence after a notification-enabled winning pathway | +| Course registry | `collections.flags` | `collections.guidedPathwayFlags` | +| State | `unresolved` / `resolved` | Student: `pending` / `escalated` / `dismissed`; instructor test: `pending` / `dismissed` | +| Identity | Reporter identity supports student history and instructor enrichment | Student identity is restricted to audited reveal; instructor tests store no raw trigger identity | +| Cross-course admin workflow | None | Student alerts only; instructor tests are excluded | + +## Manual flags + +Manual flags are submitted explicitly from chat and stored in the course collection registered as `activeCourse.collections.flags`. They carry the reporting user's course-local identifier, appear in student history, may be enriched with roster names for authorized instructors, and transition between `unresolved` and `resolved`. + +The accepted categories and lifecycle transition policy live in `src/flags/manual-flag-policy.ts`; persistence remains in `src/db/mongo/flag-mongo.ts`. The legacy HTTP handlers remain in `src/routes/route-mongo.ts`. Moving that large endpoint family into `src/routes/mongo/manual-flag-routes.ts` is deferred until it has dedicated route-contract coverage. + +## Guided Pathway flags + +Guided Pathway flags are automatic records created only after the real evaluator selects a winning pathway whose `notifyInstructorOnTrigger` value is true. The persistence attempt is failure-isolated: a Mongo failure must not replace or suppress the pathway's predefined response. Manually created and seeded pathways use the same evaluator and persistence path. + +### Server-derived origin + +The chat route resolves the actor from the current `activeCourse` and `GlobalUser`; request fields never select an origin. + +- An enrolled non-staff user becomes `origin: 'student'`. +- A faculty user explicitly listed in `course.instructors` becomes `origin: 'instructor-test'`. This check runs before enrollment, so a dual-role instructor remains a test actor; a listed instructor who is also a platform admin also qualifies. +- A TA, an admin who is not a listed instructor, an outsider, or a request without valid course/user context does not create an automatic alert. +- A legacy document with no `origin` is normalized to `student` at the safe-view boundary. + +Student rows retain internal `studentUserId` for the existing audited reveal workflow. At creation, instructor-test rows omit `studentUserId` and do not persist a separate trigger-actor identity field. The trigger actor ID may contribute to the opaque deduplication digest, which also includes origin so equivalent student and test triggers cannot collide, but it is never returned as trigger identity. A later dismissal retains the ordinary authorized decision-actor audit fields; those describe the decision, not the original trigger. + +### Visibility and lifecycle + +Course queue reads use inclusion-only anonymous projections. They expose the origin, pathway/course snapshots, exact message, status, and relevant decision timestamps/names, but exclude user IDs, raw chat/request identifiers, deduplication material, and reveal audit events. Exact user-authored text can still be self-identifying. + +Student alerts retain the production `pending` → `escalated` or `dismissed` lifecycle. Instructor tests appear only in the owning course queue, are labelled as tests, and can be marked complete only through the dismiss transition. Server-side guards reject test escalation, administrator review, and identity reveal before an audit or roster read. Every global admin list, total, facet, reviewer filter, and bell-count query includes only `origin: 'student'` or a legacy missing origin. + +### Collection authority and isolation + +Automatic alerts do not share the manual-flag collection. Each course owns a collection registered in `activeCourse.collections.guidedPathwayFlags`. New registrations default to `${courseName}_guided-pathway-flags`; once stored, the registry value is authoritative and survives course renames. A partial unique catalog index prevents two active courses from registering the same non-empty automatic-alert namespace. Resolution also rejects protected names, collisions with other registered course collections, and physical targets containing another course's rows. + +Alert creation uses a provisioning resolver that can register, create, and index storage. Course/admin list, count, backup, and cross-course aggregation paths use existing registered namespaces only, so reading an untouched legacy course does not create an empty collection. Queries retain a `courseId` predicate inside the selected collection as defense in depth. Generic course updates strip browser-provided `collections` values; registry changes belong to server provisioning and migration code. + +GPF-002 moves data from the former shared collection and GPF-001 hash collections into the registered target. A Mongo-backed lease serializes application instances, insert-only `_id` upserts preserve any newer target lifecycle state, source rows are deleted only after target and catalog verification, and malformed/orphan data is retained for recovery. See [DATA_MIGRATIONS.md](DATA_MIGRATIONS.md#gpf-002-guided-pathway-registered-collection-normalization). diff --git a/documents/MONGO_DATA_LAYER.md b/documents/MONGO_DATA_LAYER.md index ef4b7dcd..881493bc 100644 --- a/documents/MONGO_DATA_LAYER.md +++ b/documents/MONGO_DATA_LAYER.md @@ -40,14 +40,16 @@ - **Runtime assembly** — chat uses JSON defaults when `usePlatformDefault: true`; learning objectives are injected into the `course main intro` module at compose time via `{{course_learning_objectives}}` (not stored in instructor config). - **Chat threads** (`chat-mongo.ts` on `{courseName}_users.chats[]`) — conversation-level starring has been retired. New records and API responses omit `isPinned`; legacy embedded values are ignored on reads and may remain inert in MongoDB without a destructive migration. Optional `pinnedMessageId` continues to represent the separate message-level pin feature. - **Guided Pathway alerts** (`guided-pathway-flag-mongo.ts` + `guided-pathway-flag-collection-mongo.ts`): - - Each course owns one deterministic physical collection, `guided-pathway-flags-course-`, registered in `activeCourse.collections.guidedPathwayFlags`. The stable course id, rather than the display name, prevents a rename from changing ownership. Alerts remain separate from manual `{courseName}_flags` and are never queried by Student Flag History or `/flags/with-names`. - - **Startup/operation migration (GPF-001)** copies legacy rows from the shared `guided-pathway-flags` collection into course collections in verified batches, including isolated collections for orphan course ids. Source rows are deleted only after all `_id` values are present in the destination; malformed rows are retained for manual recovery. See [DATA_MIGRATIONS.md](DATA_MIGRATIONS.md#gpf-001-guided-pathway-alert-course-isolation). - - Internal rows store the exact message, restricted `studentUserId`, opaque deduplication hash, decision/review actors and times, and append-only identity-reveal audit events. PUID and raw client/chat identifiers are never stored. - - Instructor/admin list delegates use inclusion projections and map to `GuidedPathwayFlagView`; student identity, deduplication data, and reveal events cannot reach normal API responses. Admin reveal first atomically appends its audit event, then resolves and returns only the current course-roster display name. Audit failure returns no name. + - Each course owns a separate collection registered in `activeCourse.collections.guidedPathwayFlags`. New registrations default to the readable `{courseName}_guided-pathway-flags` name. The stored value, not a recomputed name, remains authoritative after a course rename. GPF-001 `guided-pathway-flags-course-` names are migration sources only. Automatic alerts stay separate from `activeCourse.collections.flags` manual-flag storage and are never queried by Student Flag History or `/flags/with-names`. + - A partial unique index on non-empty string `activeCourse.collections.guidedPathwayFlags` registrations enforces one catalog owner per physical namespace across processes. Provisioning also rejects protected names, collisions with any other registered course collection, and physical collections containing rows for another `courseId`. Generic course updates strip client-provided `collections`; only server-owned create/provision/migration paths can change registry entries. + - **Startup/operation migration (GPF-002)** copies rows from both the former global `guided-pathway-flags` collection and GPF-001 hashed collections into the registered readable target. A durable `application-migrations` record with `_id: 'GPF-002'` provides a renewable cross-process lease and persisted completion state; a process-local promise only coalesces callers within one application instance. Operations await this gate until migration completion. + - GPF-002 uses 200-row, `_id`-keyed, insert-only `$setOnInsert` upserts. Existing target documents are not replaced, so a newer decision, admin review, or reveal audit cannot be reverted by a stale legacy snapshot. The migration verifies target ownership and every copied `_id`, compare-and-set switches the catalog, rechecks catalog ownership, and only then deletes verified source rows. It drops only empty legacy namespaces and retains malformed/orphan data for manual recovery. See [DATA_MIGRATIONS.md](DATA_MIGRATIONS.md#gpf-002-guided-pathway-registered-collection-normalization). + - Every new row has explicit `origin: 'student' | 'instructor-test'`; safe reads normalize a missing legacy origin to `student`. Student rows store the exact message, restricted `studentUserId`, opaque deduplication hash, decision/review actors and times, and append-only identity-reveal audit events. At creation, instructor-test rows omit `studentUserId` and any separate trigger-actor identity field; the trigger actor ID participates only in the opaque deduplication digest. A later dismissal may add the ordinary authorized decision-actor audit fields. PUID and raw client/chat identifiers are never stored. + - Instructor/admin list delegates use inclusion projections and map to `GuidedPathwayFlagView`; student/tester identity, deduplication data, and reveal events cannot reach normal API responses. Admin reveal first atomically appends its audit event, then resolves and returns only the current course-roster display name. Audit failure returns no name. - A unique deduplication index makes transport retries an idempotent no-op. Additional per-course indexes cover status/date, pathway/status/date, and escalated/unreviewed admin queries. There is no TTL because completed decisions remain viewable. - - Instructor decisions are atomic `pending` to `escalated`/`dismissed` transitions. Admin review is a soft completion marker; neither workflow hard-deletes rows. - - Course reads and mutations resolve exactly one owned collection. Platform-admin listing and pending-count operations build a server-owned `$unionWith` pipeline over canonical active-course collections; request input never supplies a physical namespace. - - Course backup reads the anonymous projection from that course's collection. Restarting onboarding or deleting a course drops the owned alert collection after counting its rows. + - Production student decisions are atomic `pending` to `escalated`/`dismissed` transitions. An instructor test is course-only and can transition from `pending` to `dismissed`; escalation, admin review, and identity reveal reject it before any mutation, audit write, or roster read. Global admin rows, totals, facets, reviewer facets, and awaiting-review counts apply a student-or-missing-origin filter, so tests never enter the global workflow. Admin review remains a soft completion marker; neither workflow hard-deletes alert rows. + - Alert creation uses a provisioning resolver that may register, create, and index missing legacy-course storage. Course/admin list, count, backup, and aggregation paths use read-only resolution and do not create or index empty collections. Platform-admin listing and pending-count operations build a server-owned `$unionWith` pipeline over existing registered active-course collections; request input never supplies a physical namespace. + - Course backup reads the anonymous projection, including safe `origin`, from that course's existing registered collection. Restarting onboarding or deleting a course drops only the registered owned alert collection after counting its rows; a missing namespace is an idempotent no-op and invalidates its process-local index memo. - **Topic/week embedded content** (`topic-week-mongo.ts` on `active-course-list`): - **`learningObjectives[]`** per `items[]` — instructor CRUD; flattened via `getAllLearningObjectives` for system-prompt injection. - **`instructorStruggleTopics[]`** per `items[]` — instructor CRUD (`/struggle-topics` API); gated by `features.memoryAgent`; flattened via `getAllInstructorStruggleTopics` for memory-agent catalog only (not main chat system prompt). diff --git a/package-lock.json b/package-lock.json index 3b8cc2a3..67791684 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tlef-EngE-AI", - "version": "1.9.0", + "version": "1.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tlef-EngE-AI", - "version": "1.9.0", + "version": "1.9.1", "license": "ISC", "dependencies": { "@qdrant/js-client-rest": "^1.15.1", diff --git a/package.json b/package.json index 0f61a4aa..48aca335 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tlef-EngE-AI", - "version": "1.9.0", + "version": "1.9.1", "description": "", "main": "dist/server.js", "scripts": { diff --git a/public/components/report/flag-instructor.html b/public/components/report/flag-instructor.html index bca35a10..41d08544 100644 --- a/public/components/report/flag-instructor.html +++ b/public/components/report/flag-instructor.html @@ -185,9 +185,10 @@

Filter by flag type

Guided Pathway Alerts

- Review anonymous messages that triggered an active pathway. Escalating records - your decision for platform administrators; EngE-AI does not contact LTIC. A message - may still identify its author if the student included personal information in its text. + Review anonymous messages that triggered an active pathway. Messages sent from an + instructor chat are labelled Test and cannot be escalated. For student alerts, + escalating records your decision for platform administrators; EngE-AI does not contact + LTIC. A message may still identify its author if personal information appears in its text.

diff --git a/public/scripts/feature/guided-pathway-flags.ts b/public/scripts/feature/guided-pathway-flags.ts index 401e858a..43bafcf1 100644 --- a/public/scripts/feature/guided-pathway-flags.ts +++ b/public/scripts/feature/guided-pathway-flags.ts @@ -54,6 +54,13 @@ function statusLabel(status: GuidedPathwayFlagStatus): string { return STATUS_LABELS[status]; } +function cardStatusLabel(flag: GuidedPathwayFlagView): string { + if (flag.origin === 'instructor-test' && flag.status === 'dismissed') { + return 'Test complete'; + } + return statusLabel(flag.status); +} + function setDomainTab(domain: 'manual' | 'guided'): void { const manualTab = document.getElementById('manual-flags-tab'); const guidedTab = document.getElementById('guided-pathway-alerts-tab'); @@ -152,10 +159,19 @@ function createGuidedAlertCard(flag: GuidedPathwayFlagView): HTMLElement { const title = document.createElement('h3'); title.className = 'guided-pathway-alert-card__title'; title.textContent = flag.pathwayTitle; + const titleGroup = document.createElement('div'); + titleGroup.className = 'guided-pathway-alert-card__title-group'; + titleGroup.appendChild(title); + if (flag.origin === 'instructor-test') { + const testBadge = document.createElement('span'); + testBadge.className = 'guided-pathway-alert-card__test-badge'; + testBadge.textContent = 'Test'; + titleGroup.appendChild(testBadge); + } const status = document.createElement('span'); status.className = `guided-pathway-alert-card__status guided-pathway-alert-card__status--${flag.status}`; - status.textContent = statusLabel(flag.status); - header.append(title, status); + status.textContent = cardStatusLabel(flag); + header.append(titleGroup, status); const metadata = document.createElement('div'); metadata.className = 'guided-pathway-alert-card__metadata'; @@ -164,7 +180,9 @@ function createGuidedAlertCard(flag: GuidedPathwayFlagView): HTMLElement { const messageLabel = document.createElement('h4'); messageLabel.className = 'guided-pathway-alert-card__message-label'; - messageLabel.textContent = 'Student message'; + messageLabel.textContent = flag.origin === 'instructor-test' + ? 'Instructor test message' + : 'Student message'; const message = document.createElement('p'); message.className = 'guided-pathway-alert-card__message'; message.textContent = flag.messageText; @@ -174,20 +192,29 @@ function createGuidedAlertCard(flag: GuidedPathwayFlagView): HTMLElement { if (flag.status === 'pending') { const actions = document.createElement('div'); actions.className = 'guided-pathway-alert-card__actions'; - actions.append( - createDecisionButton( + if (flag.origin === 'instructor-test') { + actions.append(createDecisionButton( flag, 'dismiss', - 'Dismiss', + 'Mark test complete', 'guided-pathway-alert-card__action--secondary' - ), - createDecisionButton( - flag, - 'escalate', - 'Escalate to LTIC', - 'guided-pathway-alert-card__action--primary' - ) - ); + )); + } else { + actions.append( + createDecisionButton( + flag, + 'dismiss', + 'Dismiss', + 'guided-pathway-alert-card__action--secondary' + ), + createDecisionButton( + flag, + 'escalate', + 'Escalate to LTIC', + 'guided-pathway-alert-card__action--primary' + ) + ); + } card.appendChild(actions); } @@ -208,7 +235,12 @@ async function submitDecision( try { await decideGuidedPathwayFlag(activeCourseId, flag.id, decision); - showSuccessToast(decision === 'escalate' ? 'Escalation decision recorded.' : 'Alert dismissed.'); + const successMessage = flag.origin === 'instructor-test' + ? 'Instructor test marked complete.' + : decision === 'escalate' + ? 'Escalation decision recorded.' + : 'Alert dismissed.'; + showSuccessToast(successMessage); await loadGuidedAlerts(); } catch (error) { showErrorToast(error instanceof Error ? error.message : 'Unable to save this decision.'); diff --git a/public/scripts/types.ts b/public/scripts/types.ts index 83d9cab4..b6381619 100644 --- a/public/scripts/types.ts +++ b/public/scripts/types.ts @@ -61,6 +61,9 @@ export interface GuidedPathway { /** Must match src/types/shared.ts. Automatic Guided Pathway alert lifecycle. */ export type GuidedPathwayFlagStatus = 'pending' | 'escalated' | 'dismissed'; +/** Must match src/types/shared.ts. Server-owned trigger origin. */ +export type GuidedPathwayFlagOrigin = 'student' | 'instructor-test'; + /** Must match src/types/shared.ts. Instructor decision request value. */ export type GuidedPathwayFlagDecision = 'escalate' | 'dismiss'; @@ -77,7 +80,8 @@ export interface GuidedPathwayFlagView { courseName: string; // course-name snapshot at trigger time pathwayId: string; // winning pathway id pathwayTitle: string; // winning pathway title snapshot - messageText: string; // exact student-authored message + messageText: string; // exact triggering chat message + origin: GuidedPathwayFlagOrigin; // production student alert or non-escalatable instructor test status: GuidedPathwayFlagStatus; // instructor decision lifecycle triggeredAt: string; // ISO trigger timestamp decidedAt?: string; // ISO instructor-decision timestamp @@ -282,7 +286,7 @@ export interface activeCourse { scenarioQuestions?: string; /** Per-course Guided Pathway Library; lazy-provisions on existing courses */ pathways?: string; - /** Course-owned automatic Guided Pathway alerts; derived from stable course id */ + /** Registered course-owned collection for automatic Guided Pathway alerts. */ guidedPathwayFlags?: string; }; collectionOfInitialAssistantPrompts?: InitialAssistantPrompt[]; diff --git a/public/styles/instructor-components/flag-instructor.css b/public/styles/instructor-components/flag-instructor.css index da954022..a3cb0203 100644 --- a/public/styles/instructor-components/flag-instructor.css +++ b/public/styles/instructor-components/flag-instructor.css @@ -1076,6 +1076,25 @@ overflow-wrap: anywhere; } +.guided-pathway-alert-card__title-group { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.45rem; + min-width: 0; +} + +.guided-pathway-alert-card__test-badge { + flex-shrink: 0; + padding: 0.16rem 0.48rem; + border: 1px solid rgba(0, 92, 77, 0.3); + border-radius: 999px; + background: #e5f3ef; + color: var(--color-chbe-green); + font-size: 0.75rem; + font-weight: 700; +} + .guided-pathway-alert-card__status { flex-shrink: 0; padding: 0.25rem 0.55rem; diff --git a/src/db/enge-ai-mongodb.ts b/src/db/enge-ai-mongodb.ts index 0ee5eb1e..b2b422ec 100644 --- a/src/db/enge-ai-mongodb.ts +++ b/src/db/enge-ai-mongodb.ts @@ -28,6 +28,12 @@ import { } from '../types/shared'; import { IDGenerator } from '../utils/unique-id-generator'; import { appLogger } from '../utils/logger'; +import type { + CreateGuidedPathwayFlagInput, + GuidedPathwayFlagListFilters, + GuidedPathwayFlagReviewActor +} from '../flags/guided-pathway-flag-contracts'; +import { validateManualFlagStatusTransition } from '../flags/manual-flag-policy'; import type { MongoDalContext, CourseCollectionNames } from './mongo/mongo-context'; import * as ChatMongo from './mongo/chat-mongo'; @@ -720,7 +726,7 @@ export class EngEAI_MongoDB { public deleteAllFlagReports = async (courseName: string) => FlagMongo.deleteAllFlagReports(this.ctx(), courseName); - public validateStatusTransition = FlagMongo.validateStatusTransition; + public validateStatusTransition = validateManualFlagStatusTransition; public updateFlagStatus = async ( courseName: string, @@ -748,17 +754,17 @@ export class EngEAI_MongoDB { * ######################################################### */ /** Creates or deduplicates one course-owned Guided Pathway trigger alert and returns its safe view. */ - public createGuidedPathwayFlag = async (input: GuidedPathwayFlagMongo.CreateGuidedPathwayFlagInput) => + public createGuidedPathwayFlag = async (input: CreateGuidedPathwayFlagInput) => GuidedPathwayFlagMongo.createGuidedPathwayFlag(this.ctx(), input); /** Lists one course's paginated, explicitly anonymous Guided Pathway alert queue. */ public listGuidedPathwayFlagsForCourse = async ( courseId: string, - filters: Pick + filters: Pick ) => GuidedPathwayFlagMongo.listGuidedPathwayFlagsForCourse(this.ctx(), courseId, filters); /** Aggregates active course collections into the platform administrator queue. */ - public listGuidedPathwayFlagsForAdmin = async (filters: GuidedPathwayFlagMongo.GuidedPathwayFlagListFilters) => + public listGuidedPathwayFlagsForAdmin = async (filters: GuidedPathwayFlagListFilters) => GuidedPathwayFlagMongo.listGuidedPathwayFlagsForAdmin(this.ctx(), filters); /** Records an immutable course instructor Escalate or Dismiss decision. */ @@ -766,21 +772,21 @@ export class EngEAI_MongoDB { courseId: string, flagId: string, decision: import('../types/shared').GuidedPathwayFlagDecision, - actor: GuidedPathwayFlagMongo.GuidedPathwayFlagActor + actor: GuidedPathwayFlagReviewActor ) => GuidedPathwayFlagMongo.decideGuidedPathwayFlag(this.ctx(), courseId, flagId, decision, actor); /** Marks one escalated alert reviewed by a platform administrator. */ public markGuidedPathwayFlagAdminReviewed = async ( courseId: string, flagId: string, - actor: GuidedPathwayFlagMongo.GuidedPathwayFlagActor + actor: GuidedPathwayFlagReviewActor ) => GuidedPathwayFlagMongo.markGuidedPathwayFlagAdminReviewed(this.ctx(), courseId, flagId, actor); /** Audits an administrator reveal and returns only the current course-roster display name. */ public revealGuidedPathwayFlagIdentity = async ( courseId: string, flagId: string, - actor: GuidedPathwayFlagMongo.GuidedPathwayFlagActor + actor: GuidedPathwayFlagReviewActor ) => GuidedPathwayFlagMongo.revealGuidedPathwayFlagIdentity(this.ctx(), courseId, flagId, actor); /** Counts escalated alerts that still require platform administrator review. */ @@ -791,7 +797,7 @@ export class EngEAI_MongoDB { public deleteGuidedPathwayFlagsForCourse = async (courseId: string) => GuidedPathwayFlagMongo.deleteGuidedPathwayFlagsForCourse(this.ctx(), courseId); - /** Runs the idempotent GPF-001 shared-to-course collection migration. */ + /** Runs the idempotent GPF-002 shared-to-course collection migration. */ public migrateGuidedPathwayFlagsToCourseCollections = async () => GuidedPathwayFlagMongo.migrateGuidedPathwayFlagsToCourseCollections(this.ctx()); diff --git a/src/db/mongo/__tests__/collection-registry-mongo.test.ts b/src/db/mongo/__tests__/collection-registry-mongo.test.ts new file mode 100644 index 00000000..f619f011 --- /dev/null +++ b/src/db/mongo/__tests__/collection-registry-mongo.test.ts @@ -0,0 +1,59 @@ +/** Regression tests for active-course collection-name authority. */ + +import type { MongoDalContext } from '../mongo-context'; + +jest.mock('../active-course-queries-mongo', () => ({ + fetchActiveCourseDocByCourseName: jest.fn() +})); + +jest.mock('../../../utils/logger', () => ({ + appLogger: { warn: jest.fn() } +})); + +import { fetchActiveCourseDocByCourseName } from '../active-course-queries-mongo'; +import { getCollectionNames } from '../collection-registry-mongo'; + +function context(): MongoDalContext { + return { + db: {} as MongoDalContext['db'], + idGenerator: {} as MongoDalContext['idGenerator'], + collectionNamesCache: new Map(), + scheduledTasksIndexesEnsured: new Set() + }; +} + +function catalogCourse(guidedPathwayFlags?: string) { + return { + id: 'course-1', + courseName: 'Renamed Course', + collections: { + users: 'Old Course_users', + flags: 'Old Course_flags', + memoryAgent: 'Old Course_memory-agent', + ...(guidedPathwayFlags ? { guidedPathwayFlags } : {}) + } + }; +} + +describe('getCollectionNames Guided Pathway registration', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns the stored registered value without recomputing it after a course rename', async () => { + (fetchActiveCourseDocByCourseName as jest.Mock).mockResolvedValue( + catalogCourse('Old Course_guided-pathway-flags') + ); + + const names = await getCollectionNames(context(), 'Renamed Course'); + + expect(names.guidedPathwayFlags).toBe('Old Course_guided-pathway-flags'); + }); + + it('uses a readable course-name fallback for an unprovisioned legacy course', async () => { + (fetchActiveCourseDocByCourseName as jest.Mock).mockResolvedValue(catalogCourse()); + + const names = await getCollectionNames(context(), 'Renamed Course'); + + expect(names.guidedPathwayFlags).toBe('Renamed Course_guided-pathway-flags'); + expect(names.guidedPathwayFlags).not.toMatch(/^guided-pathway-flags-course-/); + }); +}); diff --git a/src/db/mongo/__tests__/course-backup-mongo.test.ts b/src/db/mongo/__tests__/course-backup-mongo.test.ts index 7046490c..b697f6a2 100644 --- a/src/db/mongo/__tests__/course-backup-mongo.test.ts +++ b/src/db/mongo/__tests__/course-backup-mongo.test.ts @@ -17,31 +17,58 @@ jest.mock('../collection-registry-mongo', () => ({ })); jest.mock('../mongo-collections', () => ({ - activeCourseListCollection: jest.fn(() => ({ - find: jest.fn(() => ({ - toArray: jest.fn().mockResolvedValue([{ - id: 'course-id-1', - courseName: 'TestCourse' - }]) - })), - findOne: jest.fn().mockResolvedValue({ + activeCourseListCollection: jest.fn(() => { + const course = { id: 'course-id-1', courseName: 'TestCourse', - _id: new ObjectId() - }), - updateOne: jest.fn().mockResolvedValue({ matchedCount: 1 }) + collections: { + users: 'TestCourse_users', + flags: 'TestCourse_flags', + memoryAgent: 'TestCourse_memory-agent', + guidedPathwayFlags: 'resolved-by-guided-pathway-owner' + } + }; + return { + createIndex: jest.fn().mockResolvedValue('guided_pathway_flag_collection_unique'), + find: jest.fn(() => ({ + toArray: jest.fn().mockResolvedValue([course]) + })), + findOne: jest.fn().mockImplementation((filter: any) => { + if (filter.id && typeof filter.id === 'object' && '$ne' in filter.id) return null; + return Promise.resolve({ + ...course, + _id: new ObjectId() + }); + }), + updateOne: jest.fn().mockResolvedValue({ matchedCount: 1 }) + }; + }), + applicationMigrationsCollection: jest.fn(() => ({ + findOne: jest.fn().mockResolvedValue({ + _id: 'GPF-002', + state: 'complete', + result: { + registeredCourseCollections: 0, + migratedRows: 0, + migratedGlobalRows: 0, + migratedHashedRows: 0, + droppedHashedCollections: 0, + retainedLegacyRows: 0, + retainedHashedCollections: 0, + orphanCourseCollections: 0 + } + }) })), guidedPathwayFlagsCollection: jest.fn((db) => db.collection('guided-pathway-flags')) })); import { getCollectionNames } from '../collection-registry-mongo'; -import { guidedPathwayFlagCollectionNameForCourse } from '../guided-pathway-flag-collection-mongo'; import { activeCourseListCollection } from '../mongo-collections'; describe('course-backup-mongo loadCourseMongoBackupPayloads', () => { it('queries catalog and course-owned collections; EJSON round-trips ObjectIds', async () => { const oid = new ObjectId(); - const guidedPathwayCollection = guidedPathwayFlagCollectionNameForCourse('course-id-1'); + const guidedPathwayCollection = 'resolved-by-guided-pathway-owner'; const rows: Record = { TestCourse_users: [{ _id: oid, userId: 'student-1' }], TestCourse_flags: [{ id: 'f1' }], @@ -63,11 +90,18 @@ describe('course-backup-mongo loadCourseMongoBackupPayloads', () => { }; const mockDb = { + listCollections: () => ({ + toArray: async () => Object.keys(rows).map((name) => ({ name })) + }), + createCollection: jest.fn().mockRejectedValue({ codeName: 'NamespaceExists' }), collection: (name: string) => ({ createIndex: jest.fn().mockResolvedValue('index-name'), distinct: jest.fn().mockResolvedValue([]), countDocuments: jest.fn().mockResolvedValue((rows[name] ?? []).length), drop: jest.fn().mockResolvedValue(true), + findOne: jest.fn().mockImplementation((filter: any) => Promise.resolve( + (rows[name] ?? []).find((row: any) => row.courseId !== filter.courseId?.$ne) ?? null + )), find: (filter: { courseId?: string } = {}) => { const matching = (rows[name] ?? []).filter((row: any) => !filter.courseId || row.courseId === filter.courseId @@ -95,7 +129,7 @@ describe('course-backup-mongo loadCourseMongoBackupPayloads', () => { const payloads = await loadCourseMongoBackupPayloads(ctx, course); - expect(getCollectionNames).toHaveBeenCalledWith(ctx, 'TestCourse'); + expect(getCollectionNames).toHaveBeenCalledWith(ctx, 'TestCourse'); expect(activeCourseListCollection).toHaveBeenCalledWith(ctx.db); const users = EJSON.parse(payloads.usersJson, { relaxed: false }) as { _id: ObjectId }[]; @@ -122,5 +156,6 @@ describe('course-backup-mongo loadCourseMongoBackupPayloads', () => { expect(pathwayFlags[0]).not.toHaveProperty('studentUserId'); expect(pathwayFlags[0]).not.toHaveProperty('dedupeKey'); expect(pathwayFlags[0]).not.toHaveProperty('identityRevealEvents'); + expect(mockDb.createCollection).not.toHaveBeenCalled(); }); }); diff --git a/src/db/mongo/__tests__/course-mongo.test.ts b/src/db/mongo/__tests__/course-mongo.test.ts new file mode 100644 index 00000000..b3cb7e16 --- /dev/null +++ b/src/db/mongo/__tests__/course-mongo.test.ts @@ -0,0 +1,106 @@ +/** Regression tests for server-owned active-course collection registrations. */ + +import type { activeCourse } from '../../../types/shared'; +import type { MongoDalContext } from '../mongo-context'; + +jest.mock('../active-course-queries-mongo', () => ({ + fetchActiveCourseDocByCourseName: jest.fn(), + fetchActiveCourseDocById: jest.fn() +})); + +jest.mock('../academic-period-mongo', () => ({ + lazyMigrateCourseAcademicPeriod: jest.fn() +})); + +jest.mock('../flag-mongo', () => ({ + createFlagIndexes: jest.fn() +})); + +jest.mock('../guided-pathway-flag-collection-mongo', () => ({ + assertGuidedPathwayFlagCollectionAvailable: jest.fn(), + ensureGuidedPathwayFlagCollectionIndexes: jest.fn(), + guidedPathwayFlagCollectionNameForCourse: jest.fn(), + invalidateGuidedPathwayFlagCollectionIndexes: jest.fn() +})); + +jest.mock('../mongo-collections', () => ({ + activeCourseListCollection: jest.fn(), + activeUsersMongoCollection: jest.fn() +})); + +jest.mock('../pathways-mongo', () => ({ + seedPathwaysForNewCourse: jest.fn() +})); + +jest.mock('../../../utils/logger', () => ({ + appLogger: { + error: jest.fn(), + log: jest.fn(), + warn: jest.fn() + } +})); + +import { activeCourseListCollection } from '../mongo-collections'; +import { updateActiveCourse } from '../course-mongo'; + +function context(): MongoDalContext { + return { + db: {} as MongoDalContext['db'], + idGenerator: {} as MongoDalContext['idGenerator'], + collectionNamesCache: new Map(), + scheduledTasksIndexesEnsured: new Set() + }; +} + +describe('updateActiveCourse collection registration ownership', () => { + beforeEach(() => jest.clearAllMocks()); + + it('strips untrusted identifiers and stale collections without changing the target course', async () => { + const catalogDocument = { + id: 'course-1', + courseName: 'Current Course Name', + collections: { + users: 'Current Course_users', + flags: 'Current Course_flags', + memoryAgent: 'Current Course_memory-agent', + guidedPathwayFlags: 'current-guided-pathway-flags' + } + } as activeCourse; + const findOneAndUpdate = jest.fn(async (_filter, update) => { + Object.assign(catalogDocument, update.$set); + return catalogDocument; + }); + (activeCourseListCollection as jest.Mock).mockReturnValue({ findOneAndUpdate }); + + const result = await updateActiveCourse(context(), 'course-1', { + id: 'attacker-selected-course', + _id: 'attacker-selected-mongo-document', + courseName: 'Updated Course Name', + collections: { + users: 'Stale Course_users', + flags: 'Stale Course_flags', + memoryAgent: 'Stale Course_memory-agent', + guidedPathwayFlags: 'stale-guided-pathway-flags' + }, + 'collections.guidedPathwayFlags': 'dotted-stale-guided-pathway-flags' + } as Partial); + + expect(findOneAndUpdate).toHaveBeenCalledWith( + { id: 'course-1' }, + { + $set: { + courseName: 'Updated Course Name', + updatedAt: expect.any(String) + } + }, + { returnDocument: 'after' } + ); + expect(findOneAndUpdate.mock.calls[0][1].$set) + .not.toHaveProperty(['collections.guidedPathwayFlags']); + expect(findOneAndUpdate.mock.calls[0][1].$set).not.toHaveProperty('id'); + expect(findOneAndUpdate.mock.calls[0][1].$set).not.toHaveProperty('_id'); + expect(result?.id).toBe('course-1'); + expect(result?.collections?.guidedPathwayFlags).toBe('current-guided-pathway-flags'); + expect(result?.collections?.users).toBe('Current Course_users'); + }); +}); diff --git a/src/db/mongo/__tests__/flag-mongo.test.ts b/src/db/mongo/__tests__/flag-mongo.test.ts deleted file mode 100644 index 3f7c4430..00000000 --- a/src/db/mongo/__tests__/flag-mongo.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { validateStatusTransition } from '../flag-mongo'; - -describe('flag-mongo validateStatusTransition', () => { - it('allows unresolved → resolved', () => { - expect(validateStatusTransition('unresolved', 'resolved')).toEqual({ isValid: true }); - }); - - it('allows resolved → unresolved', () => { - expect(validateStatusTransition('resolved', 'unresolved')).toEqual({ isValid: true }); - }); - - it('rejects unresolved → unresolved', () => { - expect(validateStatusTransition('unresolved', 'unresolved').isValid).toBe(false); - }); - - it('rejects invalid new status', () => { - expect(validateStatusTransition('unresolved', 'pending').isValid).toBe(false); - }); -}); diff --git a/src/db/mongo/__tests__/guided-pathway-flag-collection-mongo.test.ts b/src/db/mongo/__tests__/guided-pathway-flag-collection-mongo.test.ts index c970a653..c10fc7c5 100644 --- a/src/db/mongo/__tests__/guided-pathway-flag-collection-mongo.test.ts +++ b/src/db/mongo/__tests__/guided-pathway-flag-collection-mongo.test.ts @@ -1,9 +1,12 @@ -/** Tests for deterministic course ownership and the idempotent GPF-001 migration. */ +/** Tests for active-course collection authority and the idempotent GPF-002 migration. */ +import { createHash } from 'crypto'; +import type { activeCourse } from '../../../types/shared'; import type { MongoDalContext } from '../mongo-context'; jest.mock('../mongo-collections', () => ({ activeCourseListCollection: jest.fn(), + applicationMigrationsCollection: jest.fn(), guidedPathwayFlagsCollection: jest.fn() })); @@ -11,197 +14,511 @@ jest.mock('../../../utils/logger', () => ({ appLogger: { warn: jest.fn() } })); -import { activeCourseListCollection, guidedPathwayFlagsCollection } from '../mongo-collections'; import { + activeCourseListCollection, + applicationMigrationsCollection, + guidedPathwayFlagsCollection +} from '../mongo-collections'; +import { + assertGuidedPathwayFlagCollectionAvailable, + ensureGuidedPathwayFlagCollectionIndexes, + ensureGuidedPathwayFlagRegistryIndex, + getExistingGuidedPathwayFlagCourseScope, + getGuidedPathwayFlagCourseScope, guidedPathwayFlagCollectionNameForCourse, + invalidateGuidedPathwayFlagCollectionIndexes, + listGuidedPathwayFlagCourseScopes, migrateGuidedPathwayFlagsToCourseCollections } from '../guided-pathway-flag-collection-mongo'; -function context(db: Record): MongoDalContext { - return { - db: db as unknown as MongoDalContext['db'], - idGenerator: {} as MongoDalContext['idGenerator'], - collectionNamesCache: new Map([['Existing Course', {} as any]]), - scheduledTasksIndexesEnsured: new Set() +type Row = Record; + +function legacyHash(courseId: string): string { + return `guided-pathway-flags-course-${createHash('sha256').update(courseId).digest('hex').slice(0, 24)}`; +} + +function matches(row: Row, filter: Row = {}): boolean { + return Object.entries(filter).every(([key, expected]) => { + if (key === '$or') return (expected as Row[]).some((part) => matches(row, part)); + const actual = key.split('.').reduce((value: any, part) => value?.[part], row); + if (expected && typeof expected === 'object' && !Array.isArray(expected)) { + if ('$gt' in expected && !(actual > expected.$gt)) return false; + if ('$lte' in expected && !(actual <= expected.$lte)) return false; + if ('$in' in expected && !expected.$in.includes(actual)) return false; + if ('$ne' in expected && actual === expected.$ne) return false; + if ('$exists' in expected && (actual !== undefined) !== expected.$exists) return false; + if ('$type' in expected && expected.$type === 'string' && typeof actual !== 'string') return false; + return true; + } + return actual === expected; + }); +} + +function memoryCollection(name: string, initial: Row[], physical: Set) { + let rows = initial.map((row) => ({ ...row })); + const api: any = { + collectionName: name, + createIndex: jest.fn().mockResolvedValue('ok'), + find: jest.fn((filter: Row = {}) => { + let limit = Number.POSITIVE_INFINITY; + const cursor: any = { + sort: jest.fn().mockReturnThis(), + limit: jest.fn((value: number) => { + limit = value; + return cursor; + }), + toArray: jest.fn(async () => rows.filter((row) => matches(row, filter)).slice(0, limit).map((row) => ({ ...row }))) + }; + return cursor; + }), + distinct: jest.fn(async (field: string, filter: Row = {}) => [ + ...new Set(rows.filter((row) => matches(row, filter)).map((row) => row[field])) + ]), + findOne: jest.fn(async (filter: Row = {}) => rows.find((row) => matches(row, filter)) ?? null), + insertOne: jest.fn(async (document: Row) => { + if (rows.some((row) => row._id === document._id)) throw { code: 11000 }; + physical.add(name); + rows.push({ ...document }); + return { insertedId: document._id }; + }), + findOneAndUpdate: jest.fn(async (filter: Row, update: Row) => { + const row = rows.find((candidate) => matches(candidate, filter)); + if (!row) return null; + Object.assign(row, update.$set ?? {}); + for (const key of Object.keys(update.$unset ?? {})) delete row[key]; + return { ...row }; + }), + updateOne: jest.fn(async (filter: Row, update: Row) => { + const row = rows.find((candidate) => matches(candidate, filter)); + if (!row) return { matchedCount: 0, modifiedCount: 0 }; + Object.assign(row, update.$set ?? {}); + for (const key of Object.keys(update.$unset ?? {})) delete row[key]; + return { matchedCount: 1, modifiedCount: 1 }; + }), + bulkWrite: jest.fn(async (operations: any[]) => { + for (const operation of operations) { + const spec = operation.updateOne; + const existing = rows.find((row) => row._id === spec.filter._id); + if (!existing) { + rows.push({ _id: spec.filter._id, ...(spec.update.$setOnInsert ?? {}) }); + } + } + return {}; + }), + countDocuments: jest.fn(async (filter: Row = {}) => rows.filter((row) => matches(row, filter)).length), + deleteMany: jest.fn(async (filter: Row) => { + const before = rows.length; + rows = rows.filter((row) => !matches(row, filter)); + return { deletedCount: before - rows.length }; + }), + drop: jest.fn(async () => { + physical.delete(name); + rows = []; + return true; + }), + snapshot: () => rows.map((row) => ({ ...row })) }; + return api; } -function migrationCursor(rows: unknown[]) { - let delivered = false; - const cursor: any = { - sort: jest.fn(), - limit: jest.fn(), - toArray: jest.fn().mockImplementation(async () => { - if (delivered) return []; - delivered = true; - return rows; +function harness(courses: activeCourse[], sourceRows: Row[] = [], hashed: Record = {}) { + const physical = new Set(['guided-pathway-flags', ...Object.keys(hashed)]); + const collections = new Map>(); + const global = memoryCollection('guided-pathway-flags', sourceRows, physical); + collections.set('guided-pathway-flags', global); + for (const [name, rows] of Object.entries(hashed)) collections.set(name, memoryCollection(name, rows, physical)); + + const catalog = { + createIndex: jest.fn().mockResolvedValue('guided_pathway_flag_collection_unique'), + find: jest.fn(() => { + const cursor: any = { + sort: jest.fn().mockReturnThis(), + toArray: jest.fn(async () => courses) + }; + return cursor; + }), + findOne: jest.fn(async (filter: Row) => courses.find((candidate) => matches(candidate as Row, filter)) ?? null), + updateOne: jest.fn(async (filter: Row, update: Row) => { + const course = courses.find((candidate) => candidate.id === filter.id && matches(candidate as any, filter)); + if (!course) return { matchedCount: 0, modifiedCount: 0 }; + course.collections = { + users: course.collections?.users ?? `${course.courseName}_users`, + flags: course.collections?.flags ?? `${course.courseName}_flags`, + memoryAgent: course.collections?.memoryAgent ?? `${course.courseName}_memory-agent`, + ...course.collections, + guidedPathwayFlags: update.$set['collections.guidedPathwayFlags'] + }; + return { matchedCount: 1, modifiedCount: 1 }; + }) + }; + const migrationState = memoryCollection('application-migrations', [], physical); + const db: any = { + listCollections: jest.fn(() => ({ + toArray: jest.fn(async () => [...physical].map((name) => ({ name }))) + })), + createCollection: jest.fn(async (name: string) => { + if (physical.has(name)) throw { codeName: 'NamespaceExists' }; + physical.add(name); + if (!collections.has(name)) collections.set(name, memoryCollection(name, [], physical)); + return collections.get(name); + }), + collection: jest.fn((name: string) => { + if (!collections.has(name)) collections.set(name, memoryCollection(name, [], physical)); + return collections.get(name); }) }; - cursor.sort.mockReturnValue(cursor); - cursor.limit.mockReturnValue(cursor); - return cursor; + const ctx: MongoDalContext = { + db, + idGenerator: {} as MongoDalContext['idGenerator'], + collectionNamesCache: new Map([['Test Course', {} as any]]), + scheduledTasksIndexesEnsured: new Set() + }; + (activeCourseListCollection as jest.Mock).mockReturnValue(catalog); + (applicationMigrationsCollection as jest.Mock).mockReturnValue(migrationState); + (guidedPathwayFlagsCollection as jest.Mock).mockReturnValue(global); + return { ctx, catalog, db, global, migrationState, collections, physical }; } -function targetCollection() { +function course(overrides: Partial = {}): activeCourse { return { - createIndex: jest.fn().mockResolvedValue('ok'), - bulkWrite: jest.fn().mockResolvedValue({}), - countDocuments: jest.fn().mockImplementation(async (filter: any) => filter._id?.$in?.length ?? 0) + id: 'course-1', + date: new Date(), + courseName: 'Test Course', + courseSetup: true, + contentSetup: true, + flagSetup: true, + monitorSetup: true, + instructors: [], + teachingAssistants: [], + frameType: 'byTopic', + tilesNumber: 0, + topicOrWeekInstances: [], + collections: { + users: 'Test Course_users', + flags: 'Test Course_flags', + memoryAgent: 'Test Course_memory-agent' + }, + ...overrides }; } -describe('Guided Pathway course collection ownership', () => { - beforeEach(() => { - jest.clearAllMocks(); +describe('Guided Pathway registered collection ownership', () => { + beforeEach(() => jest.clearAllMocks()); + + it('uses a readable course name only for initial registration', () => { + expect(guidedPathwayFlagCollectionNameForCourse('APSC 101')).toBe('APSC 101_guided-pathway-flags'); }); - it('derives a stable Mongo-safe namespace from course id rather than display name', () => { - const first = guidedPathwayFlagCollectionNameForCourse('course-stable-id'); - const afterRename = guidedPathwayFlagCollectionNameForCourse('course-stable-id'); - const otherCourse = guidedPathwayFlagCollectionNameForCourse('another-course-id'); + it('rejects surrounding whitespace instead of silently changing a stored namespace', async () => { + const active = course({ + collections: { + users: 'Test Course_users', + flags: 'Test Course_flags', + memoryAgent: 'Test Course_memory-agent', + guidedPathwayFlags: ' Test Course_guided-pathway-flags ' + } + }); + const h = harness([active]); - expect(first).toBe(afterRename); - expect(first).toMatch(/^guided-pathway-flags-course-[a-f0-9]{24}$/); - expect(otherCourse).not.toBe(first); + await expect(migrateGuidedPathwayFlagsToCourseCollections(h.ctx)).rejects.toThrow( + 'Invalid Guided Pathway alert collection registration' + ); + expect(h.catalog.updateOne).not.toHaveBeenCalled(); + expect(h.db.createCollection).not.toHaveBeenCalled(); }); - it('copies active and orphan rows, verifies each batch, then removes the empty legacy collection', async () => { - const activeCourseId = 'course-1'; - const orphanCourseId = 'deleted-course'; - const activeName = guidedPathwayFlagCollectionNameForCourse(activeCourseId); - const orphanName = guidedPathwayFlagCollectionNameForCourse(orphanCourseId); - const activeRows = [{ _id: 'mongo-1', id: 'flag-1', courseId: activeCourseId }]; - const orphanRows = [{ _id: 'mongo-2', id: 'flag-2', courseId: orphanCourseId }]; - - const catalog = { - find: jest.fn().mockReturnValue({ - toArray: jest.fn().mockResolvedValue([{ - id: activeCourseId, - courseName: 'Existing Course', - collections: { - users: 'users', - flags: 'flags', - memoryAgent: 'memory' - } - }]) - }), - updateOne: jest.fn().mockResolvedValue({ modifiedCount: 1 }) - }; - const sourceCursors = new Map([ - [activeCourseId, migrationCursor(activeRows)], - [orphanCourseId, migrationCursor(orphanRows)] - ]); - const source = { - distinct: jest.fn().mockResolvedValue([activeCourseId, orphanCourseId]), - find: jest.fn().mockImplementation(({ courseId }: { courseId: string }) => sourceCursors.get(courseId)), - deleteMany: jest.fn().mockImplementation(async (filter: any) => ({ - deletedCount: filter._id.$in.length - })), - countDocuments: jest.fn().mockResolvedValue(0), - drop: jest.fn().mockResolvedValue(true) - }; - const targets = new Map([ - [activeName, targetCollection()], - [orphanName, targetCollection()] - ]); - const db = { - createCollection: jest.fn().mockResolvedValue({}), - collection: jest.fn().mockImplementation((name: string) => targets.get(name)) - }; - (activeCourseListCollection as jest.Mock).mockReturnValue(catalog); - (guidedPathwayFlagsCollection as jest.Mock).mockReturnValue(source); + it('moves global and hashed rows before switching the catalog and dropping verified sources', async () => { + const hashedName = legacyHash('course-1'); + const active = course({ + collections: { + users: 'Test Course_users', + flags: 'Test Course_flags', + memoryAgent: 'Test Course_memory-agent', + guidedPathwayFlags: hashedName + } + }); + const h = harness( + [active], + [{ _id: 'global-1', id: 'flag-global', courseId: 'course-1' }], + { [hashedName]: [{ _id: 'hash-1', id: 'flag-hash', courseId: 'course-1' }] } + ); - const result = await migrateGuidedPathwayFlagsToCourseCollections(context(db)); + const result = await migrateGuidedPathwayFlagsToCourseCollections(h.ctx); expect(result).toEqual({ registeredCourseCollections: 1, migratedRows: 2, - orphanCourseCollections: 1, - retainedLegacyRows: 0 + migratedGlobalRows: 1, + migratedHashedRows: 1, + droppedHashedCollections: 1, + retainedLegacyRows: 0, + retainedHashedCollections: 0, + orphanCourseCollections: 0 }); - expect(catalog.updateOne).toHaveBeenCalledWith( - { id: activeCourseId }, - { $set: { 'collections.guidedPathwayFlags': activeName } } - ); - expect(targets.get(activeName)?.bulkWrite).toHaveBeenCalledWith([ + expect(active.collections?.guidedPathwayFlags).toBe('Test Course_guided-pathway-flags'); + expect(h.collections.get('Test Course_guided-pathway-flags')?.snapshot()).toHaveLength(2); + expect(h.physical.has(hashedName)).toBe(false); + expect(h.ctx.collectionNamesCache.has('Test Course')).toBe(false); + }); + + it('does not overwrite a newer destination row while copying an older legacy snapshot', async () => { + const target = 'Test Course_guided-pathway-flags'; + const active = course(); + const h = harness( + [active], + [{ + _id: 'shared-mongo-id', + id: 'flag-1', + courseId: 'course-1', + status: 'pending', + updatedAt: new Date('2026-08-01T00:00:00.000Z') + }], { - replaceOne: { - filter: { _id: 'mongo-1' }, - replacement: activeRows[0], - upsert: true - } + [target]: [{ + _id: 'shared-mongo-id', + id: 'flag-1', + courseId: 'course-1', + status: 'escalated', + adminReviewedByName: 'Current Reviewer', + updatedAt: new Date('2026-08-17T00:00:00.000Z') + }] } - ], { ordered: true }); - expect(targets.get(orphanName)?.bulkWrite).toHaveBeenCalledTimes(1); - expect(targets.get(activeName)?.countDocuments.mock.invocationCallOrder[0]).toBeLessThan( - source.deleteMany.mock.invocationCallOrder[0] ); - expect(source.drop).toHaveBeenCalledTimes(1); + + await migrateGuidedPathwayFlagsToCourseCollections(h.ctx); + + expect(h.collections.get(target)?.snapshot()).toEqual([expect.objectContaining({ + _id: 'shared-mongo-id', + status: 'escalated', + adminReviewedByName: 'Current Reviewer', + updatedAt: new Date('2026-08-17T00:00:00.000Z') + })]); + expect(h.global.snapshot()).toEqual([]); + expect(h.collections.get(target)?.bulkWrite).toHaveBeenCalledWith( + [expect.objectContaining({ + updateOne: expect.objectContaining({ + filter: { _id: 'shared-mongo-id' }, + update: { $setOnInsert: expect.objectContaining({ status: 'pending' }) }, + upsert: true + }) + })], + { ordered: true } + ); }); - it('keeps the source batch when destination verification fails', async () => { - const courseId = 'course-verification'; - const collectionName = guidedPathwayFlagCollectionNameForCourse(courseId); - const row = { _id: 'mongo-unverified', id: 'flag-unverified', courseId }; - const catalog = { - find: jest.fn().mockReturnValue({ toArray: jest.fn().mockResolvedValue([]) }), - updateOne: jest.fn() - }; - const source = { - distinct: jest.fn().mockResolvedValue([courseId]), - find: jest.fn().mockReturnValue(migrationCursor([row])), - deleteMany: jest.fn(), - countDocuments: jest.fn(), - drop: jest.fn() + it('uses target then hashed then global precedence for duplicate legacy Mongo identities', async () => { + const hashedName = legacyHash('course-1'); + const target = 'Test Course_guided-pathway-flags'; + const active = course({ + collections: { + users: 'Test Course_users', + flags: 'Test Course_flags', + memoryAgent: 'Test Course_memory-agent', + guidedPathwayFlags: hashedName + } + }); + const globalRow = { + _id: 'shared-precedence-id', + id: 'flag-precedence', + courseId: 'course-1', + status: 'pending', + updatedAt: new Date('2026-08-01T00:00:00.000Z') }; - const target = { - ...targetCollection(), - countDocuments: jest.fn().mockResolvedValue(0) + const hashedRow = { + ...globalRow, + status: 'escalated', + decidedByName: 'Current Instructor', + updatedAt: new Date('2026-08-10T00:00:00.000Z') }; - const db = { - createCollection: jest.fn().mockResolvedValue({}), - collection: jest.fn().mockImplementation((name: string) => name === collectionName ? target : undefined) + const withoutTarget = harness( + [active], + [globalRow], + { [hashedName]: [hashedRow] } + ); + + await migrateGuidedPathwayFlagsToCourseCollections(withoutTarget.ctx); + + expect(withoutTarget.collections.get(target)?.snapshot()).toEqual([ + expect.objectContaining({ + _id: 'shared-precedence-id', + status: 'escalated', + decidedByName: 'Current Instructor', + updatedAt: new Date('2026-08-10T00:00:00.000Z') + }) + ]); + + const activeWithTarget = course({ + collections: { + users: 'Test Course_users', + flags: 'Test Course_flags', + memoryAgent: 'Test Course_memory-agent', + guidedPathwayFlags: hashedName + } + }); + const authoritativeTargetRow = { + ...hashedRow, + status: 'dismissed', + decidedByName: 'Latest Instructor', + updatedAt: new Date('2026-08-17T00:00:00.000Z') }; - (activeCourseListCollection as jest.Mock).mockReturnValue(catalog); - (guidedPathwayFlagsCollection as jest.Mock).mockReturnValue(source); + const withTarget = harness( + [activeWithTarget], + [globalRow], + { + [hashedName]: [hashedRow], + [target]: [authoritativeTargetRow] + } + ); + + await migrateGuidedPathwayFlagsToCourseCollections(withTarget.ctx); + + expect(withTarget.collections.get(target)?.snapshot()).toEqual([ + expect.objectContaining({ + _id: 'shared-precedence-id', + status: 'dismissed', + decidedByName: 'Latest Instructor', + updatedAt: new Date('2026-08-17T00:00:00.000Z') + }) + ]); + }); + + it('does not register or create storage for an untouched empty legacy course', async () => { + const active = course(); + const h = harness([active]); + + const result = await migrateGuidedPathwayFlagsToCourseCollections(h.ctx); + + expect(result.registeredCourseCollections).toBe(0); + expect(active.collections?.guidedPathwayFlags).toBeUndefined(); + expect(h.db.createCollection).not.toHaveBeenCalled(); + expect(h.catalog.updateOne).not.toHaveBeenCalled(); + }); + + it('honours a stored readable name after a course rename and keeps read-only resolution side-effect free', async () => { + const active = course({ + courseName: 'Renamed Course', + collections: { + users: 'Old Course_users', + flags: 'Old Course_flags', + memoryAgent: 'Old Course_memory-agent', + guidedPathwayFlags: 'Old Course_guided-pathway-flags' + } + }); + const h = harness([active], [], { 'Old Course_guided-pathway-flags': [] }); + + const existing = await getExistingGuidedPathwayFlagCourseScope(h.ctx, 'course-1'); + const adminScopes = await listGuidedPathwayFlagCourseScopes(h.ctx); + + expect(existing?.collectionName).toBe('Old Course_guided-pathway-flags'); + expect(adminScopes[0]?.collectionName).toBe('Old Course_guided-pathway-flags'); + expect(h.catalog.updateOne).not.toHaveBeenCalled(); + expect(h.db.createCollection).not.toHaveBeenCalled(); + expect(h.collections.get('Old Course_guided-pathway-flags')?.createIndex).not.toHaveBeenCalled(); + }); + + it('lazily registers, creates, and indexes storage only on the provisioning resolver', async () => { + const active = course(); + const h = harness([active]); + + await expect(getExistingGuidedPathwayFlagCourseScope(h.ctx, 'course-1')).resolves.toBeNull(); + const scope = await getGuidedPathwayFlagCourseScope(h.ctx, 'course-1'); + + expect(scope.collectionName).toBe('Test Course_guided-pathway-flags'); + expect(active.collections?.guidedPathwayFlags).toBe(scope.collectionName); + expect(h.db.createCollection).toHaveBeenCalledWith(scope.collectionName); + expect(h.collections.get(scope.collectionName)?.createIndex).toHaveBeenCalledTimes(5); + }); + + it('builds the unique registry index once for one database context', async () => { + const h = harness([course()]); + + await ensureGuidedPathwayFlagRegistryIndex(h.ctx); + await ensureGuidedPathwayFlagRegistryIndex(h.ctx); + + expect(h.catalog.createIndex).toHaveBeenCalledTimes(1); + expect(h.catalog.createIndex).toHaveBeenCalledWith( + { 'collections.guidedPathwayFlags': 1 }, + { + unique: true, + name: 'guided_pathway_flag_collection_unique', + partialFilterExpression: { + 'collections.guidedPathwayFlags': { $type: 'string', $gt: '' } + } + } + ); + }); + + it('rebuilds collection indexes after a dropped namespace invalidates the memo', async () => { + const h = harness([course()]); + const target = 'Test Course_guided-pathway-flags'; + const targetCollection = h.db.collection(target); + + await ensureGuidedPathwayFlagCollectionIndexes(h.ctx, target); + await ensureGuidedPathwayFlagCollectionIndexes(h.ctx, target); + expect(targetCollection.createIndex).toHaveBeenCalledTimes(5); + + invalidateGuidedPathwayFlagCollectionIndexes(h.ctx, target); + await ensureGuidedPathwayFlagCollectionIndexes(h.ctx, target); + + expect(targetCollection.createIndex).toHaveBeenCalledTimes(10); + }); + + it('retains orphan hashed data rather than assigning it to an active course', async () => { + const orphan = legacyHash('deleted-course'); + const h = harness([], [], { + [orphan]: [{ _id: 'orphan-1', id: 'flag-orphan', courseId: 'deleted-course' }] + }); + + const result = await migrateGuidedPathwayFlagsToCourseCollections(h.ctx); - await expect(migrateGuidedPathwayFlagsToCourseCollections(context(db))).rejects.toThrow( - 'GPF-001 verification failed' + expect(result.retainedHashedCollections).toBe(1); + expect(result.orphanCourseCollections).toBe(1); + expect(h.physical.has(orphan)).toBe(true); + }); + + it('rejects a readable target that already contains another course\'s rows', async () => { + const active = course(); + const target = 'Test Course_guided-pathway-flags'; + const h = harness([active], [], { + [target]: [{ _id: 'foreign-1', id: 'foreign-flag', courseId: 'course-2' }] + }); + + await expect(migrateGuidedPathwayFlagsToCourseCollections(h.ctx)).rejects.toThrow( + 'contains rows owned by another course' ); - expect(source.deleteMany).not.toHaveBeenCalled(); - expect(source.drop).not.toHaveBeenCalled(); + expect(h.catalog.updateOne).not.toHaveBeenCalled(); + expect(active.collections?.guidedPathwayFlags).toBeUndefined(); }); - it('accepts a verified source batch already deleted by a concurrent migrator', async () => { - const courseId = 'course-concurrent'; - const collectionName = guidedPathwayFlagCollectionNameForCourse(courseId); - const row = { _id: 'mongo-concurrent', id: 'flag-concurrent', courseId }; - const catalog = { - find: jest.fn().mockReturnValue({ toArray: jest.fn().mockResolvedValue([]) }), - updateOne: jest.fn() - }; - const source = { - distinct: jest.fn().mockResolvedValue([courseId]), - find: jest.fn().mockReturnValue(migrationCursor([row])), - deleteMany: jest.fn().mockResolvedValue({ deletedCount: 0 }), - countDocuments: jest.fn().mockResolvedValue(0), - drop: jest.fn().mockResolvedValue(true) - }; - const target = targetCollection(); - const db = { - createCollection: jest.fn().mockResolvedValue({}), - collection: jest.fn().mockImplementation((name: string) => name === collectionName ? target : undefined) - }; - (activeCourseListCollection as jest.Mock).mockReturnValue(catalog); - (guidedPathwayFlagsCollection as jest.Mock).mockReturnValue(source); - - await expect(migrateGuidedPathwayFlagsToCourseCollections(context(db))).resolves.toEqual({ - registeredCourseCollections: 0, - migratedRows: 0, - orphanCourseCollections: 1, - retainedLegacyRows: 0 + it('rejects reuse of a renamed course\'s registered old collection name', async () => { + const oldTarget = 'Original Course_guided-pathway-flags'; + const renamed = course({ + id: 'course-old', + courseName: 'Renamed Course', + collections: { + users: 'Original Course_users', + flags: 'Original Course_flags', + memoryAgent: 'Original Course_memory-agent', + guidedPathwayFlags: oldTarget + } + }); + const replacement = course({ + id: 'course-new', + courseName: 'Original Course', + collections: { + users: 'Replacement_users', + flags: 'Replacement_flags', + memoryAgent: 'Replacement_memory-agent' + } }); - expect(source.countDocuments).toHaveBeenCalledWith({ _id: { $in: ['mongo-concurrent'] } }); - expect(source.drop).toHaveBeenCalledTimes(1); + const h = harness([renamed, replacement], [], { [oldTarget]: [] }); + + await expect(assertGuidedPathwayFlagCollectionAvailable( + h.ctx, + replacement.id, + oldTarget + )).rejects.toThrow('is already registered to another course'); + expect(h.catalog.createIndex).toHaveBeenCalledTimes(1); }); + }); diff --git a/src/db/mongo/__tests__/guided-pathway-flag-mongo.test.ts b/src/db/mongo/__tests__/guided-pathway-flag-mongo.test.ts index 25f1622f..7c15e573 100644 --- a/src/db/mongo/__tests__/guided-pathway-flag-mongo.test.ts +++ b/src/db/mongo/__tests__/guided-pathway-flag-mongo.test.ts @@ -4,8 +4,10 @@ import type { MongoDalContext } from '../mongo-context'; jest.mock('../guided-pathway-flag-collection-mongo', () => ({ GuidedPathwayFlagCourseNotFoundError: class GuidedPathwayFlagCourseNotFoundError extends Error {}, + getExistingGuidedPathwayFlagCourseScope: jest.fn(), getGuidedPathwayFlagCourseScope: jest.fn(), guidedPathwayFlagCourseCollection: jest.fn(), + invalidateGuidedPathwayFlagCollectionIndexes: jest.fn(), listGuidedPathwayFlagCourseScopes: jest.fn(), migrateGuidedPathwayFlagsToCourseCollections: jest.fn() })); @@ -17,8 +19,10 @@ jest.mock('../course-user-mongo', () => ({ import { getCourseUsersMongoCollection } from '../course-user-mongo'; import { GuidedPathwayFlagCourseNotFoundError, + getExistingGuidedPathwayFlagCourseScope, getGuidedPathwayFlagCourseScope, guidedPathwayFlagCourseCollection, + invalidateGuidedPathwayFlagCollectionIndexes, listGuidedPathwayFlagCourseScopes } from '../guided-pathway-flag-collection-mongo'; import { @@ -26,12 +30,13 @@ import { createGuidedPathwayFlag, decideGuidedPathwayFlag, deleteGuidedPathwayFlagsForCourse, - GuidedPathwayFlagNotFoundError, listGuidedPathwayFlagsForAdmin, + listGuidedPathwayFlagsForBackup, listGuidedPathwayFlagsForCourse, markGuidedPathwayFlagAdminReviewed, revealGuidedPathwayFlagIdentity } from '../guided-pathway-flag-mongo'; +import { GuidedPathwayFlagNotFoundError } from '../../../flags/guided-pathway-flag-errors'; const courseScope = { courseId: 'course-1', @@ -100,7 +105,7 @@ const createInput = { pathwayId: 'pathway-1', pathwayTitle: 'Support', messageText: 'I need help', - studentUserId: 'student-1', + actor: { origin: 'student' as const, userId: 'student-1' }, chatId: 'chat-1', clientMessageId: 'client-message-1', triggeredAt: new Date('2026-08-08T12:00:00.000Z') @@ -109,6 +114,7 @@ const createInput = { describe('guided-pathway-flag-mongo', () => { beforeEach(() => { jest.clearAllMocks(); + (getExistingGuidedPathwayFlagCourseScope as jest.Mock).mockResolvedValue(courseScope); (getGuidedPathwayFlagCourseScope as jest.Mock).mockResolvedValue(courseScope); (listGuidedPathwayFlagCourseScopes as jest.Mock).mockResolvedValue([courseScope]); }); @@ -126,6 +132,7 @@ describe('guided-pathway-flag-mongo', () => { courseName: 'Test Course', pathwayTitle: 'Support', messageText: 'I need help', + origin: 'student', status: 'pending' }); expect(first.flag).not.toHaveProperty('studentUserId'); @@ -136,9 +143,42 @@ describe('guided-pathway-flag-mongo', () => { expect(firstDoc.dedupeKey).toMatch(/^[a-f0-9]{64}$/); expect(firstDoc.dedupeKey).not.toBe(secondDoc.dedupeKey); expect(firstDoc.courseName).toBe('Test Course'); + expect(firstDoc.studentUserId).toBe('student-1'); expect(firstDoc).not.toHaveProperty('clientMessageId'); }); + it('stores an instructor test without persisting tester or student identity', async () => { + const coll = collection(); + (guidedPathwayFlagCourseCollection as jest.Mock).mockReturnValue(coll); + + const result = await createGuidedPathwayFlag(context(), { + ...createInput, + actor: { origin: 'instructor-test', userId: 'instructor-1' } + }); + + expect(result.flag).toMatchObject({ origin: 'instructor-test', status: 'pending' }); + const stored = coll.insertOne.mock.calls[0][0]; + expect(stored.origin).toBe('instructor-test'); + expect(stored).not.toHaveProperty('studentUserId'); + expect(stored).not.toHaveProperty('testerUserId'); + expect(stored).not.toHaveProperty('actor'); + expect(JSON.stringify(stored)).not.toContain('instructor-1'); + }); + + it('includes origin in opaque deduplication material', async () => { + const coll = collection(); + (guidedPathwayFlagCourseCollection as jest.Mock).mockReturnValue(coll); + + await createGuidedPathwayFlag(context(), createInput); + await createGuidedPathwayFlag(context(), { + ...createInput, + actor: { origin: 'instructor-test', userId: 'student-1' } + }); + + expect(coll.insertOne.mock.calls[0][0].dedupeKey) + .not.toBe(coll.insertOne.mock.calls[1][0].dedupeKey); + }); + it('returns the existing safe alert after an atomic duplicate-key collision', async () => { const coll = collection({ insertOne: jest.fn().mockRejectedValue({ code: 11000 }), @@ -171,6 +211,7 @@ describe('guided-pathway-flag-mongo', () => { }); expect(page.total).toBe(1); + expect(page.items[0].origin).toBe('student'); expect(page.items[0]).not.toHaveProperty('studentUserId'); expect(page.items[0]).not.toHaveProperty('dedupeKey'); expect(page.items[0]).not.toHaveProperty('identityRevealEvents'); @@ -180,8 +221,19 @@ describe('guided-pathway-flag-mongo', () => { ); }); + it('returns an empty course page without provisioning missing flag storage', async () => { + (getExistingGuidedPathwayFlagCourseScope as jest.Mock).mockResolvedValue(null); + + await expect(listGuidedPathwayFlagsForCourse(context(), 'course-1', { + page: 2, + pageSize: 20 + })).resolves.toEqual({ items: [], page: 2, pageSize: 20, total: 0 }); + expect(getGuidedPathwayFlagCourseScope).not.toHaveBeenCalled(); + expect(guidedPathwayFlagCourseCollection).not.toHaveBeenCalled(); + }); + it('maps a missing active course to the public not-found contract', async () => { - (getGuidedPathwayFlagCourseScope as jest.Mock).mockRejectedValue( + (getExistingGuidedPathwayFlagCourseScope as jest.Mock).mockRejectedValue( new GuidedPathwayFlagCourseNotFoundError() ); @@ -231,8 +283,15 @@ describe('guided-pathway-flag-mongo', () => { } }); const facet = pipeline[2].$facet; + const studentOriginFilter = { + $or: [{ origin: 'student' }, { origin: { $exists: false } }] + }; + expect(facet.items[0].$match.$and).toContainEqual(studentOriginFilter); + expect(facet.totals[0].$match.$and).toContainEqual(studentOriginFilter); + expect(facet.pathways[0].$match.$and).toContainEqual(studentOriginFilter); + expect(facet.reviewers[0].$match.$and).toContainEqual(studentOriginFilter); const itemProjection = facet.items.at(-1).$project; - expect(itemProjection).toEqual(expect.objectContaining({ id: 1, messageText: 1 })); + expect(itemProjection).toEqual(expect.objectContaining({ id: 1, messageText: 1, origin: 1 })); expect(itemProjection).not.toHaveProperty('studentUserId'); expect(facet.pathways.some((stage: any) => stage.$project?.messageText)).toBe(false); expect(facet.reviewers.some((stage: any) => stage.$project?.studentUserId)).toBe(false); @@ -257,7 +316,12 @@ describe('guided-pathway-flag-mongo', () => { ); expect(coll.findOneAndUpdate).toHaveBeenCalledWith( - { id: 'flag-1', courseId: 'course-1', status: 'pending' }, + { + id: 'flag-1', + courseId: 'course-1', + status: 'pending', + $and: [{ $or: [{ origin: 'student' }, { origin: { $exists: false } }] }] + }, expect.objectContaining({ $set: expect.objectContaining({ status: 'escalated', @@ -271,6 +335,43 @@ describe('guided-pathway-flag-mongo', () => { expect(result).not.toHaveProperty('decidedByUserId'); }); + it('rejects escalation for an instructor test without changing its lifecycle', async () => { + const coll = collection({ + findOne: jest.fn().mockResolvedValue(rawFlag({ origin: 'instructor-test' })) + }); + (guidedPathwayFlagCourseCollection as jest.Mock).mockReturnValue(coll); + + await expect(decideGuidedPathwayFlag( + context(), + 'course-1', + 'flag-1', + 'escalate', + { userId: 'instructor-1', name: 'Instructor' } + )).rejects.toThrow('Instructor test flags cannot be escalated'); + expect(coll.findOneAndUpdate).not.toHaveBeenCalled(); + }); + + it('allows an instructor test to be dismissed as complete', async () => { + const coll = collection({ + findOneAndUpdate: jest.fn().mockResolvedValue(rawFlag({ + origin: 'instructor-test', + status: 'dismissed' + })) + }); + (guidedPathwayFlagCourseCollection as jest.Mock).mockReturnValue(coll); + + const result = await decideGuidedPathwayFlag( + context(), + 'course-1', + 'flag-1', + 'dismiss', + { userId: 'instructor-1', name: 'Instructor' } + ); + + expect(result).toMatchObject({ origin: 'instructor-test', status: 'dismissed' }); + expect(coll.findOneAndUpdate.mock.calls[0][0]).not.toHaveProperty('$and'); + }); + it('does not merge equal alert ids across two course collections', async () => { const secondScope = { courseId: 'course-2', @@ -285,7 +386,7 @@ describe('guided-pathway-flag-mongo', () => { status: 'dismissed' })) }); - (getGuidedPathwayFlagCourseScope as jest.Mock).mockImplementation( + (getExistingGuidedPathwayFlagCourseScope as jest.Mock).mockImplementation( async (_ctx: MongoDalContext, courseId: string) => courseId === 'course-1' ? courseScope : secondScope ); (guidedPathwayFlagCourseCollection as jest.Mock).mockImplementation( @@ -331,12 +432,31 @@ describe('guided-pathway-flag-mongo', () => { id: 'flag-1', courseId: 'course-1', status: 'escalated', - adminReviewedAt: { $exists: false } + adminReviewedAt: { $exists: false }, + $and: [{ $or: [{ origin: 'student' }, { origin: { $exists: false } }] }] }); expect(result.adminReviewedByName).toBe('Admin'); expect(result).not.toHaveProperty('adminReviewedByUserId'); }); + it('rejects administrator review for an instructor test', async () => { + const coll = collection({ + findOne: jest.fn().mockResolvedValue(rawFlag({ + origin: 'instructor-test', + status: 'escalated' + })) + }); + (guidedPathwayFlagCourseCollection as jest.Mock).mockReturnValue(coll); + + await expect(markGuidedPathwayFlagAdminReviewed( + context(), + 'course-1', + 'flag-1', + { userId: 'admin-1', name: 'Admin' } + )).rejects.toThrow('Instructor test flags do not enter administrator review'); + expect(coll.findOneAndUpdate).not.toHaveBeenCalled(); + }); + it('appends the reveal audit before returning only the current roster display name', async () => { const coll = collection({ findOneAndUpdate: jest.fn().mockResolvedValue({ studentUserId: 'student-1' }) @@ -356,7 +476,8 @@ describe('guided-pathway-flag-mongo', () => { expect(coll.findOneAndUpdate.mock.calls[0][0]).toEqual({ id: 'flag-1', courseId: 'course-1', - status: 'escalated' + status: 'escalated', + $and: [{ $or: [{ origin: 'student' }, { origin: { $exists: false } }] }] }); expect(coll.findOneAndUpdate.mock.invocationCallOrder[0]).toBeLessThan( roster.findOne.mock.invocationCallOrder[0] @@ -364,6 +485,28 @@ describe('guided-pathway-flag-mongo', () => { expect(getCourseUsersMongoCollection).toHaveBeenCalledWith(expect.anything(), 'Test Course'); }); + it('rejects identity reveal for an instructor test before audit or roster access', async () => { + const coll = collection({ + findOne: jest.fn().mockResolvedValue(rawFlag({ + origin: 'instructor-test', + status: 'escalated' + })) + }); + const roster = { findOne: jest.fn() }; + (guidedPathwayFlagCourseCollection as jest.Mock).mockReturnValue(coll); + (getCourseUsersMongoCollection as jest.Mock).mockResolvedValue(roster); + + await expect(revealGuidedPathwayFlagIdentity( + context(), + 'course-1', + 'flag-1', + { userId: 'admin-1', name: 'Admin' } + )).rejects.toThrow('Instructor test flags have no student identity to reveal'); + expect(coll.findOneAndUpdate).not.toHaveBeenCalled(); + expect(getCourseUsersMongoCollection).not.toHaveBeenCalled(); + expect(roster.findOne).not.toHaveBeenCalled(); + }); + it('fails closed without reading the roster when the reveal audit write fails', async () => { const coll = collection({ findOneAndUpdate: jest.fn().mockRejectedValue(new Error('audit write failed')) @@ -396,9 +539,57 @@ describe('guided-pathway-flag-mongo', () => { const pipeline = aggregate.mock.calls[0][0]; expect(pipeline.at(-2)).toEqual({ - $match: { status: 'escalated', adminReviewedAt: { $exists: false } } + $match: { + status: 'escalated', + adminReviewedAt: { $exists: false }, + $and: [{ $or: [{ origin: 'student' }, { origin: { $exists: false } }] }] + } }); expect(coll.countDocuments).toHaveBeenCalledWith({ courseId: 'course-1' }); expect(coll.drop).toHaveBeenCalledTimes(1); + expect(invalidateGuidedPathwayFlagCollectionIndexes).toHaveBeenCalledWith( + ctx, + courseScope.collectionName + ); + }); + + it('treats a concurrently missing collection as an idempotent delete', async () => { + const coll = collection({ + countDocuments: jest.fn().mockResolvedValue(2), + drop: jest.fn().mockRejectedValue({ code: 26, codeName: 'NamespaceNotFound' }) + }); + (guidedPathwayFlagCourseCollection as jest.Mock).mockReturnValue(coll); + const ctx = context(); + + await expect(deleteGuidedPathwayFlagsForCourse(ctx, 'course-1')).resolves.toBe(2); + + expect(coll.countDocuments).toHaveBeenCalledWith({ courseId: 'course-1' }); + expect(coll.drop).toHaveBeenCalledTimes(1); + expect(invalidateGuidedPathwayFlagCollectionIndexes).toHaveBeenCalledWith( + ctx, + courseScope.collectionName + ); + }); + + it('returns empty backup/delete results without provisioning absent storage', async () => { + (getExistingGuidedPathwayFlagCourseScope as jest.Mock).mockResolvedValue(null); + + await expect(listGuidedPathwayFlagsForBackup(context(), 'course-1')).resolves.toEqual([]); + await expect(deleteGuidedPathwayFlagsForCourse(context(), 'course-1')).resolves.toBe(0); + expect(getGuidedPathwayFlagCourseScope).not.toHaveBeenCalled(); + expect(guidedPathwayFlagCourseCollection).not.toHaveBeenCalled(); + }); + + it('returns not-found for a targeted mutation when no collection exists', async () => { + (getExistingGuidedPathwayFlagCourseScope as jest.Mock).mockResolvedValue(null); + + await expect(decideGuidedPathwayFlag( + context(), + 'course-1', + 'flag-1', + 'dismiss', + { userId: 'instructor-1', name: 'Instructor' } + )).rejects.toBeInstanceOf(GuidedPathwayFlagNotFoundError); + expect(getGuidedPathwayFlagCourseScope).not.toHaveBeenCalled(); }); }); diff --git a/src/db/mongo/__tests__/mongo-collections.test.ts b/src/db/mongo/__tests__/mongo-collections.test.ts index 2cd4b2c7..2eda2976 100644 --- a/src/db/mongo/__tests__/mongo-collections.test.ts +++ b/src/db/mongo/__tests__/mongo-collections.test.ts @@ -29,7 +29,7 @@ describe('mongo collections helpers', () => { expect(activeUsersMongoCollection(mockDb()).collectionName).toBe(ACTIVE_USERS_COLLECTION); }); - it('guidedPathwayFlagsCollection resolves the legacy GPF-001 migration source', () => { + it('guidedPathwayFlagsCollection resolves the legacy GPF-002 migration source', () => { expect(guidedPathwayFlagsCollection(mockDb()).collectionName).toBe( GUIDED_PATHWAY_FLAGS_COLLECTION ); diff --git a/src/db/mongo/__tests__/pathways-mongo.test.ts b/src/db/mongo/__tests__/pathways-mongo.test.ts index f82878f0..d9c4297a 100644 --- a/src/db/mongo/__tests__/pathways-mongo.test.ts +++ b/src/db/mongo/__tests__/pathways-mongo.test.ts @@ -7,6 +7,7 @@ import type { MongoDalContext } from '../mongo-context'; import { seedPathwaysIfEmpty, listPathways, + listPathwaysForEvaluation, createPathway, updatePathway, deletePathway, @@ -186,6 +187,26 @@ describe('pathways-mongo', () => { expect(store).toHaveLength(0); }); + it('returns an instructor-created pathway through the chat evaluation list', async () => { + const created = await createPathway(ctx, 'Test', { + title: 'Instructor support route', + triggerDescription: 'Detect a request for instructor support', + assistantResponse: 'Please use these support options.', + enabled: true, + notifyInstructorOnTrigger: true, + ctas: [] + }); + + const evaluable = await listPathwaysForEvaluation(ctx, 'Test'); + + expect(evaluable).toEqual([expect.objectContaining({ + id: created.id, + title: 'Instructor support route', + enabled: true, + notifyInstructorOnTrigger: true + })]); + }); + it('reorderPathways rewrites order', async () => { store.push( { diff --git a/src/db/mongo/collection-registry-mongo.ts b/src/db/mongo/collection-registry-mongo.ts index 768bffe0..c5c868d9 100644 --- a/src/db/mongo/collection-registry-mongo.ts +++ b/src/db/mongo/collection-registry-mongo.ts @@ -56,7 +56,10 @@ export async function getCollectionNames( const scenarioQuestions = c.collections.scenarioQuestions ?? `${courseName}_scenario_questions`; const scenarioProgress = c.collections.scenarioProgress ?? `${courseName}_scenario_progress`; const pathways = c.collections.pathways ?? `${courseName}_pathways`; - const guidedPathwayFlags = guidedPathwayFlagCollectionNameForCourse(c.id); + // GPF-002: the catalog is authoritative; the readable fallback is used only + // until a legacy course is lazily provisioned and the name is persisted. + const guidedPathwayFlags = c.collections.guidedPathwayFlags + ?? guidedPathwayFlagCollectionNameForCourse(courseName); const collectionNames = { users: c.collections.users, flags: c.collections.flags, diff --git a/src/db/mongo/course-mongo.ts b/src/db/mongo/course-mongo.ts index 8fcf7199..a2f27b91 100644 --- a/src/db/mongo/course-mongo.ts +++ b/src/db/mongo/course-mongo.ts @@ -11,7 +11,13 @@ import type { activeCourse } from '../../types/shared'; import { fetchActiveCourseDocByCourseName, fetchActiveCourseDocById } from './active-course-queries-mongo'; import { lazyMigrateCourseAcademicPeriod } from './academic-period-mongo'; import { createFlagIndexes } from './flag-mongo'; -import { guidedPathwayFlagCollectionNameForCourse } from './guided-pathway-flag-collection-mongo'; +import { + assertGuidedPathwayFlagCollectionAvailable, + ensureGuidedPathwayFlagCollectionIndexes, + guidedPathwayFlagCollectionNameForCourse, + invalidateGuidedPathwayFlagCollectionIndexes, + migrateGuidedPathwayFlagsToCourseCollections +} from './guided-pathway-flag-collection-mongo'; import type { MongoDalContext } from './mongo-context'; import { activeCourseListCollection, activeUsersMongoCollection } from './mongo-collections'; import { seedPathwaysForNewCourse } from './pathways-mongo'; @@ -27,8 +33,9 @@ import { appLogger } from '../../utils/logger'; * * @returns Promise * - * Actions: - * - Exit early when `course.id` already exists in `active-course-list`. + * Actions: + * - Wait for GPF-002 so course creation cannot claim a namespace reserved by migration. + * - Exit early when `course.id` already exists in `active-course-list`. * - Derive **`courseCode`**: reuse provided code or retry `idGenerator.courseCodeID` against uniqueness (bounded attempts). * - `createCollection` for `{courseName}_users`, `_flags`, `_memory-agent`, `_scheduled_tasks` (ignore NamespaceExists). * - `insertOne` course including `collections` map. @@ -37,9 +44,13 @@ import { appLogger } from '../../utils/logger'; * Notes: * - Throws on unexpected errors so API surfaces creation failures. */ -export async function postActiveCourse(ctx: MongoDalContext, course: activeCourse): Promise { - try { - const existingCourse = await getActiveCourse(ctx, course.id); +export async function postActiveCourse(ctx: MongoDalContext, course: activeCourse): Promise { + try { + // Course creation can claim a readable namespace that GPF-002 predicted for + // an existing legacy course, so wait for the cross-process migration first. + await migrateGuidedPathwayFlagsToCourseCollections(ctx); + + const existingCourse = await getActiveCourse(ctx, course.id); if (existingCourse) { appLogger.log(`⚠️ Course with id ${course.id} already exists, skipping creation`); return; @@ -79,9 +90,16 @@ export async function postActiveCourse(ctx: MongoDalContext, course: activeCours // ensureScenarioQuestionsCollection on first scenario-questions API call (scenario-questions-mongo.ts). const scenarioQuestionsCollection = `${courseName}_scenario_questions`; const pathwaysCollection = `${courseName}_pathways`; - const guidedPathwayFlagsCollection = guidedPathwayFlagCollectionNameForCourse(course.id); - - for (const colName of [ + const guidedPathwayFlagsCollection = guidedPathwayFlagCollectionNameForCourse(courseName); + + // Reserve automatic-alert ownership before accepting an inherited NamespaceExists result. + await assertGuidedPathwayFlagCollectionAvailable( + ctx, + course.id, + guidedPathwayFlagsCollection + ); + + for (const colName of [ userCollection, flagsCollection, memoryAgentCollection, @@ -90,9 +108,12 @@ export async function postActiveCourse(ctx: MongoDalContext, course: activeCours pathwaysCollection, guidedPathwayFlagsCollection, ]) { - try { - await ctx.db.createCollection(colName); - } catch (error: any) { + try { + await ctx.db.createCollection(colName); + if (colName === guidedPathwayFlagsCollection) { + invalidateGuidedPathwayFlagCollectionIndexes(ctx, colName); + } + } catch (error: any) { if (error.codeName !== 'NamespaceExists') throw error; } } @@ -111,7 +132,14 @@ export async function postActiveCourse(ctx: MongoDalContext, course: activeCours } }; - await activeCourseListCollection(ctx.db).insertOne(courseWithCollections as any); + await activeCourseListCollection(ctx.db).insertOne(courseWithCollections as any); + + try { + await ensureGuidedPathwayFlagCollectionIndexes(ctx, guidedPathwayFlagsCollection); + } catch (indexError) { + // The first alert operation retries index provisioning before it writes. + appLogger.error(`❌ Error creating Guided Pathway alert indexes for ${courseName}:`, indexError); + } try { await seedPathwaysForNewCourse(ctx, courseName); @@ -220,16 +248,26 @@ export async function getAllActiveCourses(ctx: MongoDalContext) { * @returns Updated `activeCourse` document after the write, or `null` when not found * * Actions: - * - `$set` merges `updateData` with `updatedAt: Date.now().toString()`. + * - Strip immutable ids, the server-owned `collections` object, and dotted `collections.*` paths. + * - `$set` merges the remaining fields with `updatedAt: Date.now().toString()`. */ -export async function updateActiveCourse( +export async function updateActiveCourse( ctx: MongoDalContext, id: string, updateData: Partial -): Promise { - const result = await activeCourseListCollection(ctx.db).findOneAndUpdate( - { id: id }, - { $set: { ...updateData, updatedAt: Date.now().toString() } }, +): Promise { + // Physical collection registrations are mutated only by dedicated provisioning/CAS paths. + const safeUpdateData = Object.fromEntries( + Object.entries(updateData).filter(([key]) => ( + key !== 'id' + && key !== '_id' + && key !== 'collections' + && !key.startsWith('collections.') + )) + ) as Partial; + const result = await activeCourseListCollection(ctx.db).findOneAndUpdate( + { id: id }, + { $set: { ...safeUpdateData, updatedAt: Date.now().toString() } }, { returnDocument: 'after' } ); return (result as activeCourse | null) ?? null; diff --git a/src/db/mongo/flag-mongo.ts b/src/db/mongo/flag-mongo.ts index 9d782b21..82103b17 100644 --- a/src/db/mongo/flag-mongo.ts +++ b/src/db/mongo/flag-mongo.ts @@ -5,8 +5,12 @@ * @description Moderation **flags** stored in each course’s `{courseName}_flags` collection — CRUD, validation, analytics, and enrichment with roster names. */ -import type { Collection } from 'mongodb'; -import type { FlagReport } from '../../types/shared'; +import type { Collection } from 'mongodb'; +import type { FlagReport } from '../../types/shared'; +import { + isManualFlagType, + validateManualFlagStatusTransition +} from '../../flags/manual-flag-policy'; import { batchFindUsersByUserIds } from './course-user-mongo'; import { getCollectionNames } from './collection-registry-mongo'; import type { MongoDalContext } from './mongo-context'; @@ -61,19 +65,9 @@ function validateFlagDocument(flagDocument: any): { isValid: boolean; issues: st if (flagDocument.status && !['unresolved', 'resolved'].includes(flagDocument.status)) { issues.push('Field "status" must be "unresolved" or "resolved"'); } - if ( - flagDocument.flagType && - ![ - 'innacurate_response', - 'harassment', - 'inappropriate', - 'dishonesty', - 'interface bug', - 'other' - ].includes(flagDocument.flagType) - ) { - issues.push('Field "flagType" has invalid value'); - } + if (flagDocument.flagType && !isManualFlagType(flagDocument.flagType)) { + issues.push('Field "flagType" has invalid value'); + } if (flagDocument.date && !(flagDocument.date instanceof Date)) { issues.push('Field "date" must be a Date object'); } @@ -86,52 +80,6 @@ function validateFlagDocument(flagDocument: any): { isValid: boolean; issues: st return { isValid: issues.length === 0, issues }; } -/** - * validateStatusTransition - * - * Pure validator for moderator workflows — unresolved ↔︎ resolved only. - * - * @param currentStatus - string — existing value on the flag (`unresolved` | `resolved`) - * @param newStatus - string — requested next state - * - * @returns `{ isValid: true }` or `{ isValid: false, error: string }` - * - * Actions: - * - Confirm both statuses are members of `{ unresolved, resolved }`. - * - Allow unresolved→resolved and resolved→unresolved only. - */ -export function validateStatusTransition( - currentStatus: string, - newStatus: string -): { isValid: boolean; error?: string } { - appLogger.log(`[MONGODB] 🔄 Validating status transition: ${currentStatus} -> ${newStatus}`); - const validStatuses = ['unresolved', 'resolved']; - if (!validStatuses.includes(newStatus)) { - return { - isValid: false, - error: `Invalid status: ${newStatus}. Must be one of: ${validStatuses.join(', ')}` - }; - } - if (!validStatuses.includes(currentStatus)) { - return { - isValid: false, - error: `Invalid current status: ${currentStatus}. Must be one of: ${validStatuses.join(', ')}` - }; - } - const validTransitions: { [key: string]: string[] } = { - unresolved: ['resolved'], - resolved: ['unresolved'] - }; - if (!validTransitions[currentStatus].includes(newStatus)) { - return { - isValid: false, - error: `Invalid transition: ${currentStatus} -> ${newStatus}. Valid transitions: ${validTransitions[currentStatus].join(', ')}` - }; - } - appLogger.log(`[MONGODB] ✅ Status transition validated: ${currentStatus} -> ${newStatus}`); - return { isValid: true }; -} - /** * createFlagReport * @@ -282,7 +230,7 @@ export async function deleteAllFlagReports(ctx: MongoDalContext, courseName: str * @returns Updated `FlagReport` or throws if missing / invalid * * Actions: - * - Load current doc, run `validateStatusTransition`. + * - Load current doc, run `validateManualFlagStatusTransition`. * - `$set` status, timestamps, optional response / audit metadata. */ export async function updateFlagStatus( @@ -300,7 +248,7 @@ export async function updateFlagStatus( if (!currentFlag) { throw new Error(`Flag not found: ${flagId}`); } - const validation = validateStatusTransition((currentFlag as any).status, newStatus); + const validation = validateManualFlagStatusTransition((currentFlag as any).status, newStatus); if (!validation.isValid) { throw new Error(validation.error); } diff --git a/src/db/mongo/guided-pathway-flag-collection-mongo.ts b/src/db/mongo/guided-pathway-flag-collection-mongo.ts index a4e2e7c3..7710ff19 100644 --- a/src/db/mongo/guided-pathway-flag-collection-mongo.ts +++ b/src/db/mongo/guided-pathway-flag-collection-mongo.ts @@ -1,26 +1,52 @@ /** * Guided Pathway flag collection ownership * - * Resolves one deterministic physical alert collection per course, provisions - * its indexes, and migrates rows out of the legacy shared collection. The - * migration deletes a source batch only after every Mongo `_id` is verified in - * its destination, making retries idempotent after a partial process failure. + * Resolves the server-owned collection registered on `active-course-list`, + * provisions missing legacy-course storage lazily, and runs GPF-002. GPF-002 + * moves rows from the former global and hashed namespaces into readable, + * course-registered collections without deleting an unverified source row. * * @author: EngE-AI Team * @date: 2026-08-12 - * @version: 1.0.0 - * @description: Course collection resolution and GPF-001 storage migration. + * @version: 2.0.0 + * @description: Registered course collection resolution and GPF-002 migration. */ -import { createHash } from 'crypto'; +import { createHash, randomUUID } from 'crypto'; import type { AnyBulkWriteOperation, Collection, Document } from 'mongodb'; import type { activeCourse } from '../../types/shared'; import { appLogger } from '../../utils/logger'; -import { activeCourseListCollection, guidedPathwayFlagsCollection } from './mongo-collections'; +import { + activeCourseListCollection, + applicationMigrationsCollection, + guidedPathwayFlagsCollection +} from './mongo-collections'; +import { + ACADEMIC_PERIODS_COLLECTION, + ACTIVE_COURSE_LIST_COLLECTION, + ACTIVE_USERS_COLLECTION, + APPLICATION_MIGRATIONS_COLLECTION, + GUIDED_PATHWAY_FLAGS_COLLECTION, + INSTRUCTOR_PERIOD_ALLOWANCES_COLLECTION +} from './mongo-constants'; import type { MongoDalContext } from './mongo-context'; -const COLLECTION_PREFIX = 'guided-pathway-flags-course-'; +const LEGACY_HASHED_COLLECTION_PREFIX = 'guided-pathway-flags-course-'; +const LEGACY_HASHED_COLLECTION_PATTERN = /^guided-pathway-flags-course-[a-f0-9]{24}$/; +const REGISTERED_COLLECTION_SUFFIX = '_guided-pathway-flags'; const MIGRATION_BATCH_SIZE = 200; +const MIGRATION_KEY = 'GPF-002'; +const MIGRATION_LEASE_MS = 5 * 60 * 1000; +const MIGRATION_POLL_MS = 250; +const REGISTRY_INDEX_NAME = 'guided_pathway_flag_collection_unique'; +const RESERVED_COLLECTION_NAMES = new Set([ + ACTIVE_COURSE_LIST_COLLECTION, + ACTIVE_USERS_COLLECTION, + ACADEMIC_PERIODS_COLLECTION, + INSTRUCTOR_PERIOD_ALLOWANCES_COLLECTION, + APPLICATION_MIGRATIONS_COLLECTION, + GUIDED_PATHWAY_FLAGS_COLLECTION +]); /** Canonical Mongo ownership information for one active course alert collection. */ export interface GuidedPathwayFlagCourseScope { @@ -29,12 +55,27 @@ export interface GuidedPathwayFlagCourseScope { collectionName: string; } -/** Aggregate result from the idempotent GPF-001 shared-to-course migration. */ +/** Aggregate result from the idempotent GPF-002 registered-collection migration. */ export interface GuidedPathwayFlagMigrationResult { registeredCourseCollections: number; migratedRows: number; - orphanCourseCollections: number; + migratedGlobalRows: number; + migratedHashedRows: number; + droppedHashedCollections: number; retainedLegacyRows: number; + retainedHashedCollections: number; + /** Compatibility metric: retained hash namespaces without an active catalog owner. */ + orphanCourseCollections: number; +} + +interface GuidedPathwayFlagMigrationLease { + _id: typeof MIGRATION_KEY; + state: 'running' | 'complete' | 'failed'; + ownerId?: string; + leaseUntil?: Date; + result?: GuidedPathwayFlagMigrationResult; + updatedAt: Date; + completedAt?: Date; } /** Raised when an operation targets a course that is absent from the active catalog. */ @@ -47,6 +88,7 @@ export class GuidedPathwayFlagCourseNotFoundError extends Error { const indexPromises = new WeakMap>>(); const migrationPromises = new WeakMap>(); +const registryIndexPromises = new WeakMap>(); function namespaceAlreadyExists(error: unknown): boolean { if (!error || typeof error !== 'object') return false; @@ -60,56 +102,260 @@ function namespaceNotFound(error: unknown): boolean { return mongoError.code === 26 || mongoError.codeName === 'NamespaceNotFound'; } +function duplicateKey(error: unknown): boolean { + return Boolean(error && typeof error === 'object' && (error as { code?: number }).code === 11000); +} + +function waitForMigrationPoll(): Promise { + return new Promise((resolve) => setTimeout(resolve, MIGRATION_POLL_MS)); +} + +function storedCollectionName(course: activeCourse): string | undefined { + const value = course.collections?.guidedPathwayFlags; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function isLegacyHashedCollectionName(value: string | undefined): value is string { + return Boolean(value && LEGACY_HASHED_COLLECTION_PATTERN.test(value)); +} + /** - * guidedPathwayFlagCollectionNameForCourse - Derives a Mongo-safe name from a stable course id. + * guidedPathwayFlagCollectionNameForCourse - Builds the initial readable course namespace. * - * The display name is deliberately excluded so a course rename cannot change - * ownership or strand its existing alerts. + * This helper is used only when registering a collection for the first time. + * Subsequent reads use `activeCourse.collections.guidedPathwayFlags`, so a + * course rename does not move or strand existing data. * - * @param courseId - Stable course catalog id - * @returns Deterministic physical collection name with a 96-bit hash suffix + * @param courseName - Course display name used by inherited per-course namespaces + * @returns Readable default physical collection name */ -export function guidedPathwayFlagCollectionNameForCourse(courseId: string): string { +export function guidedPathwayFlagCollectionNameForCourse(courseName: string): string { + return `${courseName}${REGISTERED_COLLECTION_SUFFIX}`; +} + +function legacyHashedCollectionNameForCourseId(courseId: string): string { const suffix = createHash('sha256').update(courseId).digest('hex').slice(0, 24); - return `${COLLECTION_PREFIX}${suffix}`; + return `${LEGACY_HASHED_COLLECTION_PREFIX}${suffix}`; +} + +function targetCollectionName(course: activeCourse): string { + const registered = storedCollectionName(course); + return registered && !isLegacyHashedCollectionName(registered) + ? registered + : guidedPathwayFlagCollectionNameForCourse(course.courseName); +} + +function assertSafeRegisteredCollectionName(course: activeCourse, collectionName: string): void { + if ( + !collectionName + || collectionName.trim() !== collectionName + || collectionName.includes('\0') + || collectionName.startsWith('system.') + ) { + throw new Error(`Invalid Guided Pathway alert collection registration for course ${course.id}`); + } + if (RESERVED_COLLECTION_NAMES.has(collectionName)) { + throw new Error(`Guided Pathway alert collection for course ${course.id} uses a reserved namespace`); + } + const otherOwnedNames = Object.entries(course.collections ?? {}) + .filter(([key]) => key !== 'guidedPathwayFlags') + .map(([, value]) => value) + .filter((value): value is string => typeof value === 'string'); + if (otherOwnedNames.includes(collectionName)) { + throw new Error(`Guided Pathway alert collection for course ${course.id} collides with another course collection`); + } +} + +function assertUniqueMigrationTargets(courses: activeCourse[]): void { + const owners = new Map(); + const otherOwnedNames = new Map(); + for (const course of courses) { + for (const [key, value] of Object.entries(course.collections ?? {})) { + if (key !== 'guidedPathwayFlags' && typeof value === 'string') otherOwnedNames.set(value, course.id); + } + } + for (const course of courses) { + const target = targetCollectionName(course); + assertSafeRegisteredCollectionName(course, target); + const existingOwner = owners.get(target); + if (existingOwner && existingOwner !== course.id) { + throw new Error(`Guided Pathway alert collection ${target} is registered to multiple courses`); + } + if (otherOwnedNames.has(target)) { + throw new Error(`Guided Pathway alert collection ${target} collides with a registered course namespace`); + } + owners.set(target, course.id); + } +} + +async function physicalCollectionNames(ctx: MongoDalContext): Promise> { + const infos = await ctx.db.listCollections({}, { nameOnly: true }).toArray(); + return new Set(infos.map(({ name }) => name)); +} + +/** + * invalidateGuidedPathwayFlagCollectionIndexes - Forgets one namespace's process-local index promise. + * + * A Mongo collection drop also drops its indexes. Lifecycle and migration code + * must invalidate this memo before the same physical name can be provisioned again. + * + * @param ctx - Connected Mongo data-layer context + * @param collectionName - Physical namespace that disappeared or was freshly recreated + * @returns Nothing + */ +export function invalidateGuidedPathwayFlagCollectionIndexes( + ctx: MongoDalContext, + collectionName: string +): void { + indexPromises.get(ctx.db as object)?.delete(collectionName); } -async function createCollectionIfMissing(ctx: MongoDalContext, collectionName: string): Promise { +async function createCollectionIfMissing(ctx: MongoDalContext, collectionName: string): Promise { try { await ctx.db.createCollection(collectionName); + invalidateGuidedPathwayFlagCollectionIndexes(ctx, collectionName); + return true; } catch (error) { if (!namespaceAlreadyExists(error)) throw error; + return false; } } -async function persistCanonicalCollectionName( +async function assertPhysicalTargetOwnership( ctx: MongoDalContext, - course: activeCourse -): Promise { - const collectionName = guidedPathwayFlagCollectionNameForCourse(course.id); - - // The derived value is authoritative; never trust a catalog value as an arbitrary namespace. - if (course.collections?.guidedPathwayFlags !== collectionName) { - await activeCourseListCollection(ctx.db).updateOne( - { id: course.id }, - { $set: { 'collections.guidedPathwayFlags': collectionName } } - ); - ctx.collectionNamesCache.delete(course.courseName); + courseId: string, + collectionName: string, + existingNames: Set +): Promise { + if (!existingNames.has(collectionName)) return; + const conflicting = await ctx.db.collection(collectionName).findOne( + { courseId: { $ne: courseId } }, + { projection: { _id: 1 } } + ); + if (conflicting) { + throw new Error(`Guided Pathway alert target ${collectionName} contains rows owned by another course`); } +} - return { courseId: course.id, courseName: course.courseName, collectionName }; +/** + * ensureGuidedPathwayFlagRegistryIndex - Enforces one catalog owner per physical alert namespace. + * + * The partial unique index ignores missing/empty legacy registrations while + * protecting every non-empty string registration across processes. + * + * @param ctx - Connected Mongo data-layer context + * @returns When the catalog uniqueness index is ready + * @throws Mongo errors, including pre-existing duplicate registrations + */ +export async function ensureGuidedPathwayFlagRegistryIndex(ctx: MongoDalContext): Promise { + const key = ctx.db as object; + let pending = registryIndexPromises.get(key); + if (!pending) { + pending = activeCourseListCollection(ctx.db).createIndex( + { 'collections.guidedPathwayFlags': 1 }, + { + unique: true, + name: REGISTRY_INDEX_NAME, + partialFilterExpression: { + 'collections.guidedPathwayFlags': { $type: 'string', $gt: '' } + } + } + ).then(() => undefined); + registryIndexPromises.set(key, pending); + } + try { + await pending; + } catch (error) { + registryIndexPromises.delete(key); + throw error; + } } /** - * ensureGuidedPathwayFlagCollectionIndexes - Creates indexes for one course-owned alert collection. + * assertGuidedPathwayFlagCollectionAvailable - Preflights a new course registration. * - * Promise memoization is keyed by both database and physical collection. A - * failed attempt is removed so the next request can retry safely. + * The check rejects catalog ownership by any course collection and physical + * rows owned by another course. The unique registry index closes concurrent + * same-name creation races after this human-readable preflight. * * @param ctx - Connected Mongo data-layer context - * @param collectionName - Canonical physical course collection - * @returns When dedupe, lifecycle, filtering, and review indexes are ready + * @param courseId - New course attempting to reserve the namespace + * @param collectionName - Proposed readable Guided Pathway collection + * @returns When the name is safe to register + * @throws Error when another course or foreign physical data owns the name */ +export async function assertGuidedPathwayFlagCollectionAvailable( + ctx: MongoDalContext, + courseId: string, + collectionName: string +): Promise { + await ensureGuidedPathwayFlagRegistryIndex(ctx); + const owner = await activeCourseListCollection(ctx.db).findOne({ + id: { $ne: courseId }, + $or: [ + { 'collections.users': collectionName }, + { 'collections.flags': collectionName }, + { 'collections.memoryAgent': collectionName }, + { 'collections.scheduledTasks': collectionName }, + { 'collections.scenarioQuestions': collectionName }, + { 'collections.scenarioProgress': collectionName }, + { 'collections.pathways': collectionName }, + { 'collections.guidedPathwayFlags': collectionName } + ] + }); + if (owner) { + throw new Error(`Guided Pathway alert collection ${collectionName} is already registered to another course`); + } + const existingNames = await physicalCollectionNames(ctx); + await assertPhysicalTargetOwnership(ctx, courseId, collectionName, existingNames); +} + +async function registerCollectionName( + ctx: MongoDalContext, + course: activeCourse, + collectionName: string +): Promise { + const current = storedCollectionName(course); + if (current === collectionName) return false; + const filter: Record = { id: course.id }; + if (current) { + filter['collections.guidedPathwayFlags'] = current; + } else { + filter.$or = [ + { 'collections.guidedPathwayFlags': { $exists: false } }, + { 'collections.guidedPathwayFlags': null }, + { 'collections.guidedPathwayFlags': '' } + ]; + } + const catalog = activeCourseListCollection(ctx.db); + const result = await catalog.updateOne(filter, { + $set: { 'collections.guidedPathwayFlags': collectionName } + }); + if (result.matchedCount === 0) { + const latest = await catalog.findOne({ id: course.id }) as activeCourse | null; + if (!latest || storedCollectionName(latest) !== collectionName) { + throw new Error(`Guided Pathway alert collection registration changed concurrently for course ${course.id}`); + } + } + ctx.collectionNamesCache.delete(course.courseName); + return true; +} + +async function assertCatalogOwnsCollection( + ctx: MongoDalContext, + courseId: string, + collectionName: string +): Promise { + const owner = await activeCourseListCollection(ctx.db).findOne({ + id: courseId, + 'collections.guidedPathwayFlags': collectionName + }); + if (!owner) { + throw new Error(`Guided Pathway alert collection registration changed for course ${courseId}`); + } +} + +/** Creates the unique and queue indexes for one registered alert collection. */ export async function ensureGuidedPathwayFlagCollectionIndexes( ctx: MongoDalContext, collectionName: string @@ -120,17 +366,13 @@ export async function ensureGuidedPathwayFlagCollectionIndexes( databasePromises = new Map(); indexPromises.set(databaseKey, databasePromises); } - let pending = databasePromises.get(collectionName); if (!pending) { const collection = ctx.db.collection(collectionName); pending = Promise.all([ collection.createIndex({ id: 1 }, { unique: true, name: 'guided_pathway_flag_id_unique' }), collection.createIndex({ dedupeKey: 1 }, { unique: true, name: 'guided_pathway_flag_dedupe_unique' }), - collection.createIndex( - { courseId: 1, status: 1, triggeredAt: -1 }, - { name: 'guided_pathway_flag_course_status_time' } - ), + collection.createIndex({ courseId: 1, status: 1, triggeredAt: -1 }, { name: 'guided_pathway_flag_course_status_time' }), collection.createIndex( { courseId: 1, pathwayId: 1, status: 1, triggeredAt: -1 }, { name: 'guided_pathway_flag_course_pathway_status_time' } @@ -142,7 +384,6 @@ export async function ensureGuidedPathwayFlagCollectionIndexes( ]).then(() => undefined); databasePromises.set(collectionName, pending); } - try { await pending; } catch (error) { @@ -151,119 +392,331 @@ export async function ensureGuidedPathwayFlagCollectionIndexes( } } -async function migrateLegacyCourseRows( +async function copyCourseRows( ctx: MongoDalContext, + source: Collection, courseId: string, - collectionName: string + targetName: string, + renewLease: () => Promise ): Promise { - const source = guidedPathwayFlagsCollection(ctx.db); - const destination = ctx.db.collection(collectionName); - let migratedRows = 0; - - await createCollectionIfMissing(ctx, collectionName); - await ensureGuidedPathwayFlagCollectionIndexes(ctx, collectionName); - + const destination = ctx.db.collection(targetName); + let copied = 0; + let lastId: unknown; + let targetReady = false; while (true) { - const batch = await source - .find({ courseId }) - .sort({ _id: 1 }) - .limit(MIGRATION_BATCH_SIZE) - .toArray(); + await renewLease(); + const filter: Record = { courseId }; + if (lastId !== undefined) filter._id = { $gt: lastId }; + const batch = await source.find(filter).sort({ _id: 1 }).limit(MIGRATION_BATCH_SIZE).toArray(); if (batch.length === 0) break; - - // Upsert by Mongo identity so a retry after copying but before deletion is harmless. - const operations: AnyBulkWriteOperation[] = batch.map((document) => ({ - replaceOne: { - filter: { _id: document._id }, - replacement: document, - upsert: true - } - })); + if (!targetReady) { + await createCollectionIfMissing(ctx, targetName); + await ensureGuidedPathwayFlagCollectionIndexes(ctx, targetName); + targetReady = true; + } + // Existing destination rows may contain newer decisions/reveal audits from another process. + // Insert legacy snapshots only when the Mongo identity is still absent. + const operations: AnyBulkWriteOperation[] = batch.map((document) => { + const { _id, ...insertFields } = document; + return { + updateOne: { + filter: { _id }, + update: { $setOnInsert: insertFields }, + upsert: true + } + }; + }); await destination.bulkWrite(operations, { ordered: true }); + const sourceIds = batch.map((document) => document._id); + const verified = await destination.countDocuments({ courseId, _id: { $in: sourceIds } }); + if (verified !== sourceIds.length) throw new Error(`GPF-002 verification failed for course ${courseId}`); + copied += batch.length; + lastId = batch[batch.length - 1]._id; + } + return copied; +} - // Delete only the exact source records proven to exist in the destination. +async function deleteVerifiedCourseRows( + ctx: MongoDalContext, + source: Collection, + courseId: string, + targetName: string, + renewLease: () => Promise +): Promise { + const destination = ctx.db.collection(targetName); + while (true) { + await renewLease(); + const batch = await source.find({ courseId }).sort({ _id: 1 }).limit(MIGRATION_BATCH_SIZE).toArray(); + if (batch.length === 0) return; const sourceIds = batch.map((document) => document._id); - const verified = await destination.countDocuments({ _id: { $in: sourceIds } }); - if (verified !== sourceIds.length) { - throw new Error(`GPF-001 verification failed for course ${courseId}`); - } - const deleted = await source.deleteMany({ _id: { $in: sourceIds } }); + const verified = await destination.countDocuments({ courseId, _id: { $in: sourceIds } }); + if (verified !== sourceIds.length) throw new Error(`GPF-002 cleanup verification failed for course ${courseId}`); + const deleted = await source.deleteMany({ courseId, _id: { $in: sourceIds } }); if (deleted.deletedCount !== sourceIds.length) { - // Another app instance may have migrated the same verified batch concurrently. - const remaining = await source.countDocuments({ _id: { $in: sourceIds } }); - if (remaining !== 0) { - throw new Error(`GPF-001 source cleanup was incomplete for course ${courseId}`); - } + const remaining = await source.countDocuments({ courseId, _id: { $in: sourceIds } }); + if (remaining !== 0) throw new Error(`GPF-002 source cleanup was incomplete for course ${courseId}`); } - migratedRows += deleted.deletedCount; } +} - return migratedRows; +async function dropEmptyCollection( + ctx: MongoDalContext, + collection: Collection +): Promise { + if (await collection.countDocuments({}) !== 0) return false; + try { + await collection.drop(); + invalidateGuidedPathwayFlagCollectionIndexes(ctx, collection.collectionName); + return true; + } catch (error) { + if (namespaceNotFound(error)) { + invalidateGuidedPathwayFlagCollectionIndexes(ctx, collection.collectionName); + return false; + } + throw error; + } } async function runGuidedPathwayFlagMigration( - ctx: MongoDalContext + ctx: MongoDalContext, + renewLease: () => Promise ): Promise { + await renewLease(); const catalog = activeCourseListCollection(ctx.db); const courses = await catalog.find({}).toArray() as unknown as activeCourse[]; - const scopes = new Map(); + assertUniqueMigrationTargets(courses); + const initialPhysicalNames = await physicalCollectionNames(ctx); + for (const course of courses) { + await assertPhysicalTargetOwnership(ctx, course.id, targetCollectionName(course), initialPhysicalNames); + } + await ensureGuidedPathwayFlagRegistryIndex(ctx); + const globalSource = guidedPathwayFlagsCollection(ctx.db) as Collection; + const globalCourseIds = new Set( + (await globalSource.distinct('courseId', { courseId: { $type: 'string' } })) + .filter((value): value is string => typeof value === 'string' && Boolean(value)) + ); + const activeCourseIds = new Set(courses.map(({ id }) => id)); let registeredCourseCollections = 0; + let migratedGlobalRows = 0; + let migratedHashedRows = 0; + let droppedHashedCollections = 0; - // Register and provision every active course before any shared rows move. for (const course of courses) { - const expectedName = guidedPathwayFlagCollectionNameForCourse(course.id); - if (course.collections?.guidedPathwayFlags !== expectedName) { - registeredCourseCollections += 1; + await renewLease(); + const current = storedCollectionName(course); + const targetName = targetCollectionName(course); + // The per-course hash was the most recent legacy write target, so let it + // win over an older shared-row snapshot when both contain the same `_id`. + // A row already present in the readable target remains authoritative over both. + const hashedSources = new Set(); + if (isLegacyHashedCollectionName(current)) hashedSources.add(current); + hashedSources.add(legacyHashedCollectionNameForCourseId(course.id)); + const hasHashNamespace = [...hashedSources].some((name) => initialPhysicalNames.has(name)); + const hasReadableNamespace = initialPhysicalNames.has(targetName); + const hasGlobalRows = globalCourseIds.has(course.id); + + // Do not create empty collections for untouched legacy courses. + if (!current && !hasHashNamespace && !hasReadableNamespace && !hasGlobalRows) continue; + for (const sourceName of hashedSources) { + if (!initialPhysicalNames.has(sourceName) || sourceName === targetName) continue; + migratedHashedRows += await copyCourseRows( + ctx, + ctx.db.collection(sourceName) as Collection, + course.id, + targetName, + renewLease + ); + } + if (hasGlobalRows) { + migratedGlobalRows += await copyCourseRows(ctx, globalSource, course.id, targetName, renewLease); } - const scope = await persistCanonicalCollectionName(ctx, course); - await ensureGuidedPathwayFlagCollectionIndexes(ctx, scope.collectionName); - scopes.set(course.id, scope); - } - - const source = guidedPathwayFlagsCollection(ctx.db); - const legacyCourseIds = await source.distinct('courseId', { courseId: { $type: 'string' } }); - let migratedRows = 0; - let orphanCourseCollections = 0; - // Each legacy course is copied independently, including rows whose catalog entry was already removed. - for (const value of legacyCourseIds) { - if (typeof value !== 'string' || !value) continue; - const activeScope = scopes.get(value); - const collectionName = activeScope?.collectionName ?? guidedPathwayFlagCollectionNameForCourse(value); - if (!activeScope) orphanCourseCollections += 1; - migratedRows += await migrateLegacyCourseRows(ctx, value, collectionName); + if ((!current || isLegacyHashedCollectionName(current)) && await registerCollectionName(ctx, course, targetName)) { + registeredCourseCollections += 1; + } + await renewLease(); + await assertCatalogOwnsCollection(ctx, course.id, targetName); + if (hasGlobalRows) { + await deleteVerifiedCourseRows(ctx, globalSource, course.id, targetName, renewLease); + } + for (const sourceName of hashedSources) { + if (!initialPhysicalNames.has(sourceName) || sourceName === targetName) continue; + const source = ctx.db.collection(sourceName) as Collection; + await deleteVerifiedCourseRows(ctx, source, course.id, targetName, renewLease); + if (await dropEmptyCollection(ctx, source)) droppedHashedCollections += 1; + } } - // Malformed legacy records remain untouched for manual recovery; an empty namespace is removed. - const retainedLegacyRows = await source.countDocuments({}); + await renewLease(); + const retainedLegacyRows = await globalSource.countDocuments({}); if (retainedLegacyRows === 0) { try { - await source.drop(); + await globalSource.drop(); + invalidateGuidedPathwayFlagCollectionIndexes(ctx, globalSource.collectionName); } catch (error) { if (!namespaceNotFound(error)) throw error; + invalidateGuidedPathwayFlagCollectionIndexes(ctx, globalSource.collectionName); } } else { - appLogger.warn('[guided-pathway-flags] GPF-001 retained malformed legacy rows', { - retainedLegacyRows - }); + appLogger.warn('[guided-pathway-flags] GPF-002 retained global rows for manual recovery', { retainedLegacyRows }); } + const remainingPhysicalNames = await physicalCollectionNames(ctx); + const remainingHashedNames = [...remainingPhysicalNames].filter((name) => isLegacyHashedCollectionName(name)); + let orphanCourseCollections = 0; + for (const name of remainingHashedNames) { + const owners = await (ctx.db.collection(name) as Collection).distinct('courseId', { + courseId: { $type: 'string' } + }); + if (!owners.some((value) => typeof value === 'string' && activeCourseIds.has(value))) { + orphanCourseCollections += 1; + } + } + if (remainingHashedNames.length > 0) { + appLogger.warn('[guided-pathway-flags] GPF-002 retained hashed collections for manual recovery', { + retainedHashedCollections: remainingHashedNames.length, + orphanCourseCollections + }); + } return { registeredCourseCollections, - migratedRows, - orphanCourseCollections, - retainedLegacyRows + migratedRows: migratedGlobalRows + migratedHashedRows, + migratedGlobalRows, + migratedHashedRows, + droppedHashedCollections, + retainedLegacyRows, + retainedHashedCollections: remainingHashedNames.length, + orphanCourseCollections }; } +async function acquireMigrationLease( + ctx: MongoDalContext +): Promise<{ ownerId: string } | { completed: GuidedPathwayFlagMigrationResult }> { + const collection = applicationMigrationsCollection(ctx.db); + const ownerId = randomUUID(); + + while (true) { + const current = await collection.findOne({ _id: MIGRATION_KEY }); + if (current?.state === 'complete') { + if (!current.result) throw new Error('GPF-002 completion record is missing its result'); + return { completed: current.result }; + } + + const now = new Date(); + const leaseUntil = new Date(now.getTime() + MIGRATION_LEASE_MS); + if (!current) { + try { + await collection.insertOne({ + _id: MIGRATION_KEY, + state: 'running', + ownerId, + leaseUntil, + updatedAt: now + }); + return { ownerId }; + } catch (error) { + if (!duplicateKey(error)) throw error; + } + } else { + const claimed = await collection.findOneAndUpdate( + { + _id: MIGRATION_KEY, + $or: [ + { state: 'failed' }, + { state: 'running', leaseUntil: { $lte: now } }, + { state: 'running', leaseUntil: { $exists: false } } + ] + }, + { + $set: { state: 'running', ownerId, leaseUntil, updatedAt: now }, + $unset: { result: '', completedAt: '' } + }, + { returnDocument: 'after' } + ); + if (claimed?.ownerId === ownerId) return { ownerId }; + } + + await waitForMigrationPoll(); + } +} + +async function renewMigrationLease(ctx: MongoDalContext, ownerId: string): Promise { + const now = new Date(); + const result = await applicationMigrationsCollection(ctx.db).updateOne( + { _id: MIGRATION_KEY, state: 'running', ownerId }, + { $set: { leaseUntil: new Date(now.getTime() + MIGRATION_LEASE_MS), updatedAt: now } } + ); + if (result.matchedCount !== 1) { + throw new Error('GPF-002 migration lease was lost'); + } +} + +async function completeMigrationLease( + ctx: MongoDalContext, + ownerId: string, + result: GuidedPathwayFlagMigrationResult +): Promise { + const now = new Date(); + const update = await applicationMigrationsCollection(ctx.db).updateOne( + { _id: MIGRATION_KEY, state: 'running', ownerId }, + { + $set: { state: 'complete', result, completedAt: now, updatedAt: now }, + $unset: { ownerId: '', leaseUntil: '' } + } + ); + if (update.matchedCount !== 1) { + throw new Error('GPF-002 migration lease was lost before completion'); + } +} + +async function releaseFailedMigrationLease(ctx: MongoDalContext, ownerId: string): Promise { + const now = new Date(); + await applicationMigrationsCollection(ctx.db).updateOne( + { _id: MIGRATION_KEY, state: 'running', ownerId }, + { + $set: { state: 'failed', updatedAt: now }, + $unset: { ownerId: '', leaseUntil: '', result: '', completedAt: '' } + } + ); +} + +async function runGuidedPathwayFlagMigrationWithLease( + ctx: MongoDalContext +): Promise { + const lease = await acquireMigrationLease(ctx); + if ('completed' in lease) { + await ensureGuidedPathwayFlagRegistryIndex(ctx); + return lease.completed; + } + + const renewLease = () => renewMigrationLease(ctx, lease.ownerId); + try { + const result = await runGuidedPathwayFlagMigration(ctx, renewLease); + await completeMigrationLease(ctx, lease.ownerId, result); + return result; + } catch (error) { + try { + await releaseFailedMigrationLease(ctx, lease.ownerId); + } catch (releaseError) { + appLogger.warn('[guided-pathway-flags] GPF-002 failed lease could not be released', { + errorName: releaseError instanceof Error ? releaseError.name : typeof releaseError + }); + } + throw error; + } +} + /** - * migrateGuidedPathwayFlagsToCourseCollections - Runs and memoizes GPF-001 for this database. + * migrateGuidedPathwayFlagsToCourseCollections - Runs GPF-002 once per database. * - * All alert operations await this promise. If migration fails, the promise is - * discarded so a later request can retry from the last verified batch. + * A Mongo-backed lease serializes application instances; a process-local promise + * coalesces callers inside one instance. Failed attempts release the lease for a + * later retry, while a persisted completion result makes restarts a no-op. * * @param ctx - Connected Mongo data-layer context - * @returns Registration, migration, orphan, and retained-row counts + * @returns Registration, migration, cleanup, and retained-data counts + * @throws Mongo or validation failures; no unverified source row is deleted */ export async function migrateGuidedPathwayFlagsToCourseCollections( ctx: MongoDalContext @@ -271,10 +724,9 @@ export async function migrateGuidedPathwayFlagsToCourseCollections( const key = ctx.db as object; let pending = migrationPromises.get(key); if (!pending) { - pending = runGuidedPathwayFlagMigration(ctx); + pending = runGuidedPathwayFlagMigrationWithLease(ctx); migrationPromises.set(key, pending); } - try { return await pending; } catch (error) { @@ -283,61 +735,91 @@ export async function migrateGuidedPathwayFlagsToCourseCollections( } } -/** - * getGuidedPathwayFlagCourseScope - Resolves and provisions one active course collection. - * - * @param ctx - Connected Mongo data-layer context - * @param courseId - Stable active-course id - * @returns Canonical course and physical collection metadata - * @throws Error when the active course no longer exists - */ +/** Resolves and lazily provisions one active course's registered alert collection. */ export async function getGuidedPathwayFlagCourseScope( ctx: MongoDalContext, courseId: string ): Promise { await migrateGuidedPathwayFlagsToCourseCollections(ctx); - const course = await activeCourseListCollection(ctx.db).findOne({ id: courseId }) as activeCourse | null; + const catalog = activeCourseListCollection(ctx.db); + const course = await catalog.findOne({ id: courseId }) as activeCourse | null; if (!course) throw new GuidedPathwayFlagCourseNotFoundError(); - - const scope = await persistCanonicalCollectionName(ctx, course); - await ensureGuidedPathwayFlagCollectionIndexes(ctx, scope.collectionName); - return scope; + const collectionName = targetCollectionName(course); + assertSafeRegisteredCollectionName(course, collectionName); + const duplicate = await catalog.findOne({ + id: { $ne: course.id }, + 'collections.guidedPathwayFlags': collectionName + }); + if (duplicate) throw new Error(`Guided Pathway alert collection ${collectionName} is registered to multiple courses`); + await registerCollectionName(ctx, course, collectionName); + await createCollectionIfMissing(ctx, collectionName); + await ensureGuidedPathwayFlagCollectionIndexes(ctx, collectionName); + return { courseId: course.id, courseName: course.courseName, collectionName }; } /** - * listGuidedPathwayFlagCourseScopes - Resolves active collections for an admin query. + * getExistingGuidedPathwayFlagCourseScope - Resolves existing storage without provisioning it. * - * Physical namespaces come only from canonical derivation of catalog ids. The - * optional filters narrow which course collections participate in aggregation. + * Read-only list/count/backup paths use this variant so viewing an empty legacy + * course does not register a name, create a namespace, or build indexes. * * @param ctx - Connected Mongo data-layer context - * @param filters - Optional exact course and approved course-id set - * @returns Canonical active-course scopes ordered by course id + * @param courseId - Stable active-course id + * @returns Existing registered scope, or `null` when no physical collection exists + * @throws Error when the course is absent or the stored registration is unsafe */ +export async function getExistingGuidedPathwayFlagCourseScope( + ctx: MongoDalContext, + courseId: string +): Promise { + await migrateGuidedPathwayFlagsToCourseCollections(ctx); + const catalog = activeCourseListCollection(ctx.db); + const course = await catalog.findOne({ id: courseId }) as activeCourse | null; + if (!course) throw new GuidedPathwayFlagCourseNotFoundError(); + const collectionName = storedCollectionName(course); + if (!collectionName || isLegacyHashedCollectionName(collectionName)) return null; + assertSafeRegisteredCollectionName(course, collectionName); + const duplicate = await catalog.findOne({ + id: { $ne: course.id }, + 'collections.guidedPathwayFlags': collectionName + }); + if (duplicate) throw new Error(`Guided Pathway alert collection ${collectionName} is registered to multiple courses`); + const existingNames = await physicalCollectionNames(ctx); + if (!existingNames.has(collectionName)) return null; + return { courseId: course.id, courseName: course.courseName, collectionName }; +} + +/** Lists existing registered collections for cross-course administrator aggregation. */ export async function listGuidedPathwayFlagCourseScopes( ctx: MongoDalContext, filters: { courseId?: string; courseIds?: string[] } = {} ): Promise { await migrateGuidedPathwayFlagsToCourseCollections(ctx); if (filters.courseId && filters.courseIds && !filters.courseIds.includes(filters.courseId)) return []; - const permittedIds = filters.courseId ? [filters.courseId] : filters.courseIds; const query = permittedIds ? { id: { $in: permittedIds } } : {}; const courses = await activeCourseListCollection(ctx.db) .find(query) .sort({ id: 1 }) .toArray() as unknown as activeCourse[]; - + const existingNames = await physicalCollectionNames(ctx); + const owners = new Map(); const scopes: GuidedPathwayFlagCourseScope[] = []; for (const course of courses) { - const scope = await persistCanonicalCollectionName(ctx, course); - await ensureGuidedPathwayFlagCollectionIndexes(ctx, scope.collectionName); - scopes.push(scope); + const collectionName = storedCollectionName(course); + if (!collectionName || isLegacyHashedCollectionName(collectionName) || !existingNames.has(collectionName)) continue; + assertSafeRegisteredCollectionName(course, collectionName); + const existingOwner = owners.get(collectionName); + if (existingOwner && existingOwner !== course.id) { + throw new Error(`Guided Pathway alert collection ${collectionName} is registered to multiple courses`); + } + owners.set(collectionName, course.id); + scopes.push({ courseId: course.id, courseName: course.courseName, collectionName }); } return scopes; } -/** Returns a typed Mongo collection handle for a canonical course scope. */ +/** Returns a typed Mongo collection handle for a validated registered course scope. */ export function guidedPathwayFlagCourseCollection( ctx: MongoDalContext, scope: GuidedPathwayFlagCourseScope diff --git a/src/db/mongo/guided-pathway-flag-mongo.ts b/src/db/mongo/guided-pathway-flag-mongo.ts index be5ba31c..a6e5cbe9 100644 --- a/src/db/mongo/guided-pathway-flag-mongo.ts +++ b/src/db/mongo/guided-pathway-flag-mongo.ts @@ -18,62 +18,34 @@ import type { GuidedPathwayFlagDecision, GuidedPathwayFlagFacets, GuidedPathwayFlagListPage, - GuidedPathwayFlagReviewState, + GuidedPathwayFlagOrigin, GuidedPathwayFlagStatus, GuidedPathwayFlagView } from '../../types/shared'; +import type { + CreateGuidedPathwayFlagInput, + CreateGuidedPathwayFlagResult, + GuidedPathwayFlagListFilters, + GuidedPathwayFlagReviewActor +} from '../../flags/guided-pathway-flag-contracts'; +import { + GuidedPathwayFlagConflictError, + GuidedPathwayFlagIdentityUnavailableError, + GuidedPathwayFlagNotFoundError +} from '../../flags/guided-pathway-flag-errors'; import { getCourseUsersMongoCollection } from './course-user-mongo'; import { GuidedPathwayFlagCourseNotFoundError, + getExistingGuidedPathwayFlagCourseScope, getGuidedPathwayFlagCourseScope, guidedPathwayFlagCourseCollection, + invalidateGuidedPathwayFlagCollectionIndexes, listGuidedPathwayFlagCourseScopes, migrateGuidedPathwayFlagsToCourseCollections, type GuidedPathwayFlagCourseScope } from './guided-pathway-flag-collection-mongo'; import type { MongoDalContext } from './mongo-context'; -/** Server-owned actor snapshot used for decisions, review, and reveal audit. */ -export interface GuidedPathwayFlagActor { - userId: string; - name: string; -} - -/** Input from the chat trigger path. Chat/request identifiers are hashed, never stored verbatim. */ -export interface CreateGuidedPathwayFlagInput { - courseId: string; - courseName: string; - pathwayId: string; - pathwayTitle: string; - messageText: string; - studentUserId: string; - chatId: string; - clientMessageId: string; - triggeredAt?: Date; -} - -/** Filters supported by the platform-wide administrator queue. */ -export interface GuidedPathwayFlagListFilters { - page?: number; - pageSize?: number; - status?: GuidedPathwayFlagStatus; - reviewState?: GuidedPathwayFlagReviewState; - courseId?: string; - courseIds?: string[]; - pathwayId?: string; - reviewer?: string; - dateFrom?: Date; - dateTo?: Date; - escalatedFirst?: boolean; - includeFacets?: boolean; -} - -/** Result of an idempotent trigger insert. */ -export interface CreateGuidedPathwayFlagResult { - created: boolean; - flag: GuidedPathwayFlagView; -} - interface GuidedPathwayIdentityRevealEvent { adminUserId: string; revealedAt: Date; @@ -86,7 +58,8 @@ interface GuidedPathwayFlagDocument { pathwayId: string; pathwayTitle: string; messageText: string; - studentUserId: string; + origin?: GuidedPathwayFlagOrigin; + studentUserId?: string; dedupeKey: string; status: GuidedPathwayFlagStatus; adminSortPriority: number; @@ -109,30 +82,6 @@ interface AdminAggregationResult { reviewers?: Array<{ name: string }>; } -/** Raised when an alert id is absent from the required course scope. */ -export class GuidedPathwayFlagNotFoundError extends Error { - constructor(message = 'Guided Pathway alert not found') { - super(message); - this.name = 'GuidedPathwayFlagNotFoundError'; - } -} - -/** Raised when an action conflicts with the alert's completed lifecycle state. */ -export class GuidedPathwayFlagConflictError extends Error { - constructor(message: string) { - super(message); - this.name = 'GuidedPathwayFlagConflictError'; - } -} - -/** Raised after a successful reveal audit when the current roster name no longer exists. */ -export class GuidedPathwayFlagIdentityUnavailableError extends Error { - constructor() { - super('Student identity is unavailable in the current course roster'); - this.name = 'GuidedPathwayFlagIdentityUnavailableError'; - } -} - const DEFAULT_PAGE_SIZE = 50; const MAX_PAGE_SIZE = 200; const STATUS_PRIORITY: Record = { @@ -150,6 +99,7 @@ const SAFE_FLAG_PROJECTION = { pathwayId: 1, pathwayTitle: 1, messageText: 1, + origin: 1, status: 1, triggeredAt: 1, decidedAt: 1, @@ -165,7 +115,7 @@ function collectionFor( return guidedPathwayFlagCourseCollection(ctx, scope); } -async function requireCourseScope( +async function requireWritableCourseScope( ctx: MongoDalContext, courseId: string ): Promise { @@ -179,6 +129,29 @@ async function requireCourseScope( } } +async function existingCourseScope( + ctx: MongoDalContext, + courseId: string +): Promise { + try { + return await getExistingGuidedPathwayFlagCourseScope(ctx, courseId); + } catch (error) { + if (error instanceof GuidedPathwayFlagCourseNotFoundError) { + throw new GuidedPathwayFlagNotFoundError('Guided Pathway alert course not found'); + } + throw error; + } +} + +async function requireExistingCourseScope( + ctx: MongoDalContext, + courseId: string +): Promise { + const scope = await existingCourseScope(ctx, courseId); + if (!scope) throw new GuidedPathwayFlagNotFoundError(); + return scope; +} + function asIso(value: Date | string): string { return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); } @@ -191,6 +164,7 @@ function toSafeView(doc: Partial): GuidedPathwayFlagV pathwayId: String(doc.pathwayId), pathwayTitle: String(doc.pathwayTitle), messageText: String(doc.messageText), + origin: doc.origin === 'instructor-test' ? 'instructor-test' : 'student', status: doc.status as GuidedPathwayFlagStatus, triggeredAt: asIso(doc.triggeredAt as Date) }; @@ -205,7 +179,8 @@ function dedupeKeyFor(input: CreateGuidedPathwayFlagInput): string { return createHash('sha256') .update(JSON.stringify([ input.courseId, - input.studentUserId, + input.actor.origin, + input.actor.userId, input.chatId, input.clientMessageId, input.messageText @@ -217,6 +192,12 @@ function isDuplicateKeyError(error: unknown): boolean { return Boolean(error && typeof error === 'object' && (error as { code?: number }).code === 11000); } +function isNamespaceNotFoundError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const mongoError = error as { code?: number; codeName?: string }; + return mongoError.code === 26 || mongoError.codeName === 'NamespaceNotFound'; +} + function normalizedPagination(filters: GuidedPathwayFlagListFilters): { page: number; pageSize: number } { return { page: Math.max(1, Math.floor(filters.page ?? 1)), @@ -278,6 +259,23 @@ function buildListFilter( return query; } +/** Legacy rows have no origin and are treated as production student alerts. */ +const STUDENT_ORIGIN_FILTER: Filter = { + $or: [ + { origin: 'student' }, + { origin: { $exists: false } } + ] +}; + +function buildAdminListFilter( + filters: GuidedPathwayFlagListFilters, + omitOwnFacet?: 'pathwayId' | 'reviewer' +): Filter { + const query = buildListFilter(filters, omitOwnFacet); + query.$and = [...(query.$and ?? []), STUDENT_ORIGIN_FILTER]; + return query; +} + function unionCourseCollections(scopes: GuidedPathwayFlagCourseScope[]): Document[] { const [first, ...remaining] = scopes; const pipeline: Document[] = [{ $match: { courseId: first.courseId } }]; @@ -302,21 +300,21 @@ function adminFacetPipeline( : { triggeredAt: -1 }; const facet: Record = { items: [ - { $match: buildListFilter(filters) }, + { $match: buildAdminListFilter(filters) }, { $sort: sort }, { $skip: (page - 1) * pageSize }, { $limit: pageSize }, { $project: SAFE_FLAG_PROJECTION } ], totals: [ - { $match: buildListFilter(filters) }, + { $match: buildAdminListFilter(filters) }, { $count: 'value' } ] }; if (filters.includeFacets) { facet.pathways = [ - { $match: buildListFilter(filters, 'pathwayId') }, + { $match: buildAdminListFilter(filters, 'pathwayId') }, { $sort: { triggeredAt: -1 } }, { $group: { @@ -329,7 +327,7 @@ function adminFacetPipeline( { $sort: { pathwayTitle: 1, pathwayId: 1 } } ]; facet.reviewers = [ - { $match: buildListFilter(filters, 'reviewer') }, + { $match: buildAdminListFilter(filters, 'reviewer') }, { $project: { names: ['$decidedByName', '$adminReviewedByName'] } }, { $unwind: '$names' }, { $match: { names: { $type: 'string', $regex: /\S/ } } }, @@ -345,8 +343,8 @@ function adminFacetPipeline( /** * createGuidedPathwayFlag - Atomically creates one alert per processed client message. * - * The opaque unique dedupe key includes course, student, chat, and client message - * identity. A duplicate insert returns the already stored anonymous alert. + * The opaque unique dedupe key includes course, actor origin/id, chat, and client + * message identity. Instructor-test actors never persist an identity field. * * @param ctx - Connected Mongo data-layer context * @param input - Trigger context from the chat pipeline @@ -360,7 +358,7 @@ export async function createGuidedPathwayFlag( throw new Error('chatId and clientMessageId are required for Guided Pathway alert deduplication'); } - const scope = await requireCourseScope(ctx, input.courseId); + const scope = await requireWritableCourseScope(ctx, input.courseId); const collection = collectionFor(ctx, scope); const now = input.triggeredAt ?? new Date(); const doc: GuidedPathwayFlagDocument = { @@ -370,7 +368,8 @@ export async function createGuidedPathwayFlag( pathwayId: input.pathwayId, pathwayTitle: input.pathwayTitle, messageText: input.messageText, - studentUserId: input.studentUserId, + origin: input.actor.origin, + ...(input.actor.origin === 'student' ? { studentUserId: input.actor.userId } : {}), dedupeKey: dedupeKeyFor(input), status: 'pending', adminSortPriority: STATUS_PRIORITY.pending, @@ -404,9 +403,18 @@ export async function listGuidedPathwayFlagsForCourse( courseId: string, filters: Pick ): Promise { - const scope = await requireCourseScope(ctx, courseId); - const collection = collectionFor(ctx, scope); const pagination = normalizedPagination(filters); + const scope = await existingCourseScope(ctx, courseId); + if (!scope) { + return { + items: [], + page: pagination.page, + pageSize: pagination.pageSize, + total: 0 + }; + } + + const collection = collectionFor(ctx, scope); const query = buildListFilter({ ...filters, courseId }); const cursor = collection.find(query, { projection: SAFE_FLAG_PROJECTION }).sort({ triggeredAt: -1 }); @@ -495,16 +503,28 @@ export async function decideGuidedPathwayFlag( courseId: string, flagId: string, decision: GuidedPathwayFlagDecision, - actor: GuidedPathwayFlagActor + actor: GuidedPathwayFlagReviewActor ): Promise { - const scope = await requireCourseScope(ctx, courseId); + const scope = await requireExistingCourseScope(ctx, courseId); const collection = collectionFor(ctx, scope); const nextStatus: GuidedPathwayFlagStatus = decision === 'escalate' ? 'escalated' : 'dismissed'; const now = new Date(); + const transitionFilter: Filter = { + id: flagId, + courseId, + status: 'pending' + }; + if (decision === 'escalate') { + const candidate = await findSafeFlag(collection, { id: flagId, courseId }); + if (candidate?.origin === 'instructor-test') { + throw new GuidedPathwayFlagConflictError('Instructor test flags cannot be escalated'); + } + transitionFilter.$and = [STUDENT_ORIGIN_FILTER]; + } // The lifecycle predicate and write share one BSON command, preventing competing decisions. const updated = await collection.findOneAndUpdate( - { id: flagId, courseId, status: 'pending' }, + transitionFilter, { $set: { status: nextStatus, @@ -521,6 +541,9 @@ export async function decideGuidedPathwayFlag( const existing = await findSafeFlag(collection, { id: flagId, courseId }); if (!existing) throw new GuidedPathwayFlagNotFoundError(); + if (existing.origin === 'instructor-test' && decision === 'escalate') { + throw new GuidedPathwayFlagConflictError('Instructor test flags cannot be escalated'); + } if (existing.status === nextStatus) return existing; throw new GuidedPathwayFlagConflictError('Guided Pathway alert already has a different decision'); } @@ -538,13 +561,23 @@ export async function markGuidedPathwayFlagAdminReviewed( ctx: MongoDalContext, courseId: string, flagId: string, - actor: GuidedPathwayFlagActor + actor: GuidedPathwayFlagReviewActor ): Promise { - const scope = await requireCourseScope(ctx, courseId); + const scope = await requireExistingCourseScope(ctx, courseId); const collection = collectionFor(ctx, scope); + const candidate = await findSafeFlag(collection, { id: flagId, courseId }); + if (candidate?.origin === 'instructor-test') { + throw new GuidedPathwayFlagConflictError('Instructor test flags do not enter administrator review'); + } const now = new Date(); const updated = await collection.findOneAndUpdate( - { id: flagId, courseId, status: 'escalated', adminReviewedAt: { $exists: false } }, + { + id: flagId, + courseId, + status: 'escalated', + adminReviewedAt: { $exists: false }, + $and: [STUDENT_ORIGIN_FILTER] + }, { $set: { adminReviewedAt: now, @@ -559,6 +592,9 @@ export async function markGuidedPathwayFlagAdminReviewed( const existing = await findSafeFlag(collection, { id: flagId, courseId }); if (!existing) throw new GuidedPathwayFlagNotFoundError(); + if (existing.origin === 'instructor-test') { + throw new GuidedPathwayFlagConflictError('Instructor test flags do not enter administrator review'); + } if (existing.status !== 'escalated') { throw new GuidedPathwayFlagConflictError('Only escalated alerts can be marked reviewed'); } @@ -582,13 +618,22 @@ export async function revealGuidedPathwayFlagIdentity( ctx: MongoDalContext, courseId: string, flagId: string, - actor: GuidedPathwayFlagActor + actor: GuidedPathwayFlagReviewActor ): Promise<{ studentName: string }> { - const scope = await requireCourseScope(ctx, courseId); + const scope = await requireExistingCourseScope(ctx, courseId); const collection = collectionFor(ctx, scope); + const candidate = await findSafeFlag(collection, { id: flagId, courseId }); + if (candidate?.origin === 'instructor-test') { + throw new GuidedPathwayFlagConflictError('Instructor test flags have no student identity to reveal'); + } const revealedAt = new Date(); const audited = await collection.findOneAndUpdate( - { id: flagId, courseId, status: 'escalated' }, + { + id: flagId, + courseId, + status: 'escalated', + $and: [STUDENT_ORIGIN_FILTER] + }, { $push: { identityRevealEvents: { @@ -607,12 +652,19 @@ export async function revealGuidedPathwayFlagIdentity( if (!audited) { const existing = await collection.findOne( { id: flagId, courseId }, - { projection: { _id: 0, status: 1 } } + { projection: { _id: 0, origin: 1, status: 1 } } ); if (!existing) throw new GuidedPathwayFlagNotFoundError(); + if (existing.origin === 'instructor-test') { + throw new GuidedPathwayFlagConflictError('Instructor test flags have no student identity to reveal'); + } throw new GuidedPathwayFlagConflictError('Identity can be revealed only for escalated alerts'); } + if (typeof audited.studentUserId !== 'string' || !audited.studentUserId) { + throw new GuidedPathwayFlagIdentityUnavailableError(); + } + // Read the roster only after the append-only audit event has persisted. const roster = await getCourseUsersMongoCollection(ctx, scope.courseName); const student = await roster.findOne( @@ -637,7 +689,13 @@ export async function countGuidedPathwayFlagsAwaitingAdminReview(ctx: MongoDalCo const pipeline = [ ...unionCourseCollections(scopes), - { $match: { status: 'escalated', adminReviewedAt: { $exists: false } } }, + { + $match: { + status: 'escalated', + adminReviewedAt: { $exists: false }, + $and: [STUDENT_ORIGIN_FILTER] + } + }, { $count: 'value' } ]; const [result] = await ctx.db @@ -658,7 +716,8 @@ export async function listGuidedPathwayFlagsForBackup( ctx: MongoDalContext, courseId: string ): Promise { - const scope = await requireCourseScope(ctx, courseId); + const scope = await existingCourseScope(ctx, courseId); + if (!scope) return []; const docs = await collectionFor(ctx, scope) .find({ courseId }, { projection: SAFE_FLAG_PROJECTION }) .sort({ triggeredAt: -1 }) @@ -677,11 +736,20 @@ export async function deleteGuidedPathwayFlagsForCourse( ctx: MongoDalContext, courseId: string ): Promise { - const scope = await requireCourseScope(ctx, courseId); + const scope = await existingCourseScope(ctx, courseId); + if (!scope) return 0; const collection = collectionFor(ctx, scope); - const removed = await collection.countDocuments({ courseId }); - await collection.drop(); - return removed; + let removed = 0; + try { + removed = await collection.countDocuments({ courseId }); + await collection.drop(); + invalidateGuidedPathwayFlagCollectionIndexes(ctx, scope.collectionName); + return removed; + } catch (error) { + if (!isNamespaceNotFoundError(error)) throw error; + invalidateGuidedPathwayFlagCollectionIndexes(ctx, scope.collectionName); + return removed; + } } export { migrateGuidedPathwayFlagsToCourseCollections }; diff --git a/src/db/mongo/mongo-collections.ts b/src/db/mongo/mongo-collections.ts index 677b94da..153a4cde 100644 --- a/src/db/mongo/mongo-collections.ts +++ b/src/db/mongo/mongo-collections.ts @@ -7,11 +7,12 @@ * Prefer these over string literals so renames stay localized. */ -import type { Db, Collection } from 'mongodb'; -import { +import type { Db, Collection, Document } from 'mongodb'; +import { ACADEMIC_PERIODS_COLLECTION, ACTIVE_COURSE_LIST_COLLECTION, ACTIVE_USERS_COLLECTION, + APPLICATION_MIGRATIONS_COLLECTION, GUIDED_PATHWAY_FLAGS_COLLECTION, INSTRUCTOR_PERIOD_ALLOWANCES_COLLECTION } from './mongo-constants'; @@ -60,11 +61,21 @@ export function instructorPeriodAllowancesCollection(db: Db): Collection { return db.collection(INSTRUCTOR_PERIOD_ALLOWANCES_COLLECTION); } +/** + * applicationMigrationsCollection - Returns the cross-process migration-state collection. + * + * @param db - Connected Mongo database handle + * @returns Fixed `application-migrations` collection + */ +export function applicationMigrationsCollection(db: Db): Collection { + return db.collection(APPLICATION_MIGRATIONS_COLLECTION); +} + /** * guidedPathwayFlagsCollection * - * Returns the legacy shared alert collection used only as the GPF-001 migration source. - * Runtime alert reads and writes use deterministic course-owned collections. + * Returns the legacy shared alert collection used only as a GPF-002 migration source. + * Runtime reads and writes use the collection registered on the active course. * * @param db - Connected Mongo database handle * @returns `Collection` - legacy `guided-pathway-flags` migration source diff --git a/src/db/mongo/mongo-constants.ts b/src/db/mongo/mongo-constants.ts index 0dd4e205..0be50d14 100644 --- a/src/db/mongo/mongo-constants.ts +++ b/src/db/mongo/mongo-constants.ts @@ -19,5 +19,8 @@ export const ACADEMIC_PERIODS_COLLECTION = 'academic-periods'; /** MongoDB collection name for period-scoped instructor course allow-lists. */ export const INSTRUCTOR_PERIOD_ALLOWANCES_COLLECTION = 'instructor-period-allowances'; -/** Legacy shared Guided Pathway collection retained as the GPF-001 migration source. */ +/** MongoDB collection for cross-process operational migration leases and completion records. */ +export const APPLICATION_MIGRATIONS_COLLECTION = 'application-migrations'; + +/** Legacy shared Guided Pathway collection retained as a GPF-002 migration source. */ export const GUIDED_PATHWAY_FLAGS_COLLECTION = 'guided-pathway-flags'; diff --git a/src/db/mongo/mongo-context.ts b/src/db/mongo/mongo-context.ts index 71d8ae6e..e0523f70 100644 --- a/src/db/mongo/mongo-context.ts +++ b/src/db/mongo/mongo-context.ts @@ -24,7 +24,7 @@ export interface CourseCollectionNames { scenarioProgress: string; /** `{courseName}_pathways` — lazy-provisioned, see `pathways-mongo.ts`. */ pathways: string; - /** Stable-id-derived collection for automatic Guided Pathway trigger alerts. */ + /** Server-registered per-course collection for automatic Guided Pathway trigger alerts. */ guidedPathwayFlags: string; } diff --git a/src/flags/README.md b/src/flags/README.md new file mode 100644 index 00000000..c95f8de7 --- /dev/null +++ b/src/flags/README.md @@ -0,0 +1,33 @@ +# Flag domain + +`src/flags` is the discoverable policy and orchestration boundary for both EngE-AI flag workflows. It does not combine their schemas, collections, or privacy rules. + +| Concern | Manual flag | Guided Pathway flag | +| --- | --- | --- | +| Trigger | A user explicitly reports a chat response | A notification-enabled winning Guided Pathway is selected for an eligible chat actor | +| Registry key | `activeCourse.collections.flags` | `activeCourse.collections.guidedPathwayFlags` | +| Visibility | Student history and identity-enriched instructor view | Anonymous course view for student alerts; course-only test view for instructor tests; student alerts only in global admin views | +| Lifecycle | `unresolved` / `resolved` | Student: `pending` → `escalated` or `dismissed`; instructor test: `pending` → `dismissed` only | +| Retry behavior | Existing insert behavior | Unique opaque deduplication key includes actor origin | +| Persistence | `src/db/mongo/flag-mongo.ts` | `src/db/mongo/guided-pathway-flag-mongo.ts` | + +## Module ownership + +- `src/flags` owns persistence-neutral contracts, trigger-actor policy, manual-flag policy, and failure-isolated automatic-alert orchestration. +- `src/db/mongo` owns physical collection resolution, indexes, projections, CRUD, and GPF migration behavior. +- `src/routes` owns HTTP parsing, RBAC, and response mapping. +- `src/guided-pathways` owns pathway schemas, prompts, classification, and winner selection. + +The legacy manual-flag endpoint family still lives in `src/routes/route-mongo.ts`. Extracting it into `src/routes/mongo/manual-flag-routes.ts` was deferred because moving that large route block safely needs dedicated route-contract coverage; this does not change the `src/flags` domain ownership above. + +## Guided Pathway origin and identity + +The server derives `origin` from database-backed course and user records; the browser cannot request test mode. An enrolled non-staff user produces a `student` alert. A faculty user explicitly listed in `course.instructors` produces an `instructor-test` alert, including when that instructor is also enrolled or has platform-admin privilege. TA membership, platform-admin privilege alone, and unrelated users do not produce an alert. + +Student records retain a restricted internal `studentUserId` for the audited administrator reveal workflow. At creation, instructor-test records store no student identity or raw trigger-actor identity; the instructor user ID participates only in the opaque deduplication digest. A later course decision retains the ordinary authorized decision-actor audit fields, not an identity link to the original trigger. Test records are visible only in the owning course, can only be marked complete through dismissal, and are excluded from every global admin item, total, facet, reviewer filter, bell count, review, escalation, and identity-reveal path. A legacy row with no `origin` is read as `student`. + +## Registered storage + +Automatic alerts remain separate from manual flags. New courses default to the readable `${courseName}_guided-pathway-flags` collection, but the stored `activeCourse.collections.guidedPathwayFlags` value is the runtime authority after registration. Course renames therefore do not move or recompute the namespace, and GPF-001 hash names are migration inputs only. + +Alert creation uses an explicit provisioning resolver. Course/admin lists, counts, backup, and aggregation use read-only resolution and do not create an empty collection for an untouched legacy course. GPF-002 normalizes global and hashed legacy sources under a Mongo-backed lease; see [`documents/DATA_MIGRATIONS.md`](../../documents/DATA_MIGRATIONS.md#gpf-002-guided-pathway-registered-collection-normalization). diff --git a/src/flags/__tests__/guided-pathway-flag-policy.test.ts b/src/flags/__tests__/guided-pathway-flag-policy.test.ts new file mode 100644 index 00000000..a0b5e82b --- /dev/null +++ b/src/flags/__tests__/guided-pathway-flag-policy.test.ts @@ -0,0 +1,77 @@ +import type { GlobalUser, activeCourse } from '../../types/shared'; +import { resolveGuidedPathwayFlagTriggerActor } from '../guided-pathway-flag-policy'; + +const course = { + id: 'course-1', + courseName: 'Course One', + instructors: [{ userId: 'instructor-1', name: 'Instructor' }], + teachingAssistants: [{ userId: 'ta-1', name: 'TA' }], +} as activeCourse; + +function user(overrides: Partial): GlobalUser { + return { + name: 'Test User', + puid: 'test-puid', + userId: 'user-1', + coursesEnrolled: [], + affiliation: 'student', + status: 'active', + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +describe('resolveGuidedPathwayFlagTriggerActor', () => { + it('classifies a listed faculty instructor as an instructor test before enrollment', () => { + const actor = resolveGuidedPathwayFlagTriggerActor(course, user({ + userId: 'instructor-1', + affiliation: 'faculty', + coursesEnrolled: ['course-1'], + })); + + expect(actor).toEqual({ origin: 'instructor-test', userId: 'instructor-1' }); + }); + + it('keeps an admin who is also an explicitly listed faculty instructor eligible as a test', () => { + const actor = resolveGuidedPathwayFlagTriggerActor(course, user({ + userId: 'instructor-1', + affiliation: 'faculty', + coursesEnrolled: ['course-1'], + isAdmin: true, + })); + + expect(actor).toEqual({ origin: 'instructor-test', userId: 'instructor-1' }); + }); + + it('classifies an enrolled non-staff user as a production student', () => { + const actor = resolveGuidedPathwayFlagTriggerActor(course, user({ + userId: 'student-1', + coursesEnrolled: ['course-1'], + })); + + expect(actor).toEqual({ origin: 'student', userId: 'student-1' }); + }); + + it.each([ + ['teaching assistant', user({ userId: 'ta-1', affiliation: 'staff', coursesEnrolled: ['course-1'] })], + ['admin-only user', user({ userId: 'admin-1', affiliation: 'staff', coursesEnrolled: ['course-1'], isAdmin: true })], + ['unenrolled outsider', user({ userId: 'outsider-1' })], + ])('skips an ineligible %s', (_label, candidate) => { + expect(resolveGuidedPathwayFlagTriggerActor(course, candidate)).toBeNull(); + }); + + it('safely skips a legacy user whose enrollment array is missing', () => { + const malformedUser = { + ...user({ userId: 'legacy-user' }), + coursesEnrolled: undefined, + } as unknown as GlobalUser; + + expect(resolveGuidedPathwayFlagTriggerActor(course, malformedUser)).toBeNull(); + }); + + it('skips when course or user context is missing', () => { + expect(resolveGuidedPathwayFlagTriggerActor(null, user({}))).toBeNull(); + expect(resolveGuidedPathwayFlagTriggerActor(course, null)).toBeNull(); + }); +}); diff --git a/src/guided-pathways/__tests__/pathway-alert-persistence.test.ts b/src/flags/__tests__/guided-pathway-flag-service.test.ts similarity index 53% rename from src/guided-pathways/__tests__/pathway-alert-persistence.test.ts rename to src/flags/__tests__/guided-pathway-flag-service.test.ts index cb59b05c..88799295 100644 --- a/src/guided-pathways/__tests__/pathway-alert-persistence.test.ts +++ b/src/flags/__tests__/guided-pathway-flag-service.test.ts @@ -1,16 +1,5 @@ -/** - * Tests for failure-isolated Guided Pathway alert persistence. - * - * @author: EngE-AI Team - * @date: 2026-08-08 - * @version: 1.0.0 - * @description: Verifies eligibility, exact-message forwarding, and safe write failure behavior. - */ - -import { - persistGuidedPathwayAlertSafely, - type GuidedPathwayFlagWriter, -} from '../pathway-alert-persistence'; +import type { GuidedPathwayFlagWriter } from '../guided-pathway-flag-contracts'; +import { persistGuidedPathwayFlagSafely } from '../guided-pathway-flag-service'; const trigger = { pathwayId: 'pathway-1', @@ -25,33 +14,47 @@ function baseInput(writer: GuidedPathwayFlagWriter) { courseId: 'course-1', courseName: 'Course One', messageText: ' Keep my exact spacing. ', - studentUserId: 'student-1', + actor: { origin: 'student' as const, userId: 'student-1' }, chatId: 'chat-1', clientMessageId: 'client-message-1', - isEligibleStudent: true, }; } -describe('persistGuidedPathwayAlertSafely', () => { - it('writes one alert with the exact message for an eligible notification-enabled trigger', async () => { +describe('persistGuidedPathwayFlagSafely', () => { + it('writes one flag with the exact message for an eligible notification-enabled trigger', async () => { const writer: GuidedPathwayFlagWriter = { createGuidedPathwayFlag: jest.fn().mockResolvedValue({ created: true }), }; - await expect(persistGuidedPathwayAlertSafely(baseInput(writer))).resolves.toEqual({ + await expect(persistGuidedPathwayFlagSafely(baseInput(writer))).resolves.toEqual({ status: 'created', }); expect(writer.createGuidedPathwayFlag).toHaveBeenCalledWith(expect.objectContaining({ courseId: 'course-1', pathwayId: 'pathway-1', messageText: ' Keep my exact spacing. ', - studentUserId: 'student-1', + actor: { origin: 'student', userId: 'student-1' }, chatId: 'chat-1', clientMessageId: 'client-message-1', })); }); - it('returns failed instead of throwing when alert storage rejects', async () => { + it('forwards an explicit instructor-test actor without changing the exact message', async () => { + const writer: GuidedPathwayFlagWriter = { + createGuidedPathwayFlag: jest.fn().mockResolvedValue({ created: true }), + }; + + await expect(persistGuidedPathwayFlagSafely({ + ...baseInput(writer), + actor: { origin: 'instructor-test', userId: 'instructor-1' }, + })).resolves.toEqual({ status: 'created' }); + expect(writer.createGuidedPathwayFlag).toHaveBeenCalledWith(expect.objectContaining({ + messageText: ' Keep my exact spacing. ', + actor: { origin: 'instructor-test', userId: 'instructor-1' }, + })); + }); + + it('returns failed instead of throwing when flag storage rejects', async () => { const writer: GuidedPathwayFlagWriter = { createGuidedPathwayFlag: jest.fn().mockRejectedValue({ code: 91, @@ -59,7 +62,7 @@ describe('persistGuidedPathwayAlertSafely', () => { }), }; - await expect(persistGuidedPathwayAlertSafely(baseInput(writer))).resolves.toEqual({ + await expect(persistGuidedPathwayFlagSafely(baseInput(writer))).resolves.toEqual({ status: 'failed', errorCode: 91, }); @@ -67,14 +70,14 @@ describe('persistGuidedPathwayAlertSafely', () => { it.each([ ['notification is disabled', { trigger: { ...trigger, notifyInstructorOnTrigger: false } }], - ['the sender is not an eligible student', { isEligibleStudent: false }], + ['the sender has no eligible server-derived actor', { actor: null }], ['the course id is unavailable', { courseId: undefined }], ])('skips storage when %s', async (_label, overrides) => { const writer: GuidedPathwayFlagWriter = { createGuidedPathwayFlag: jest.fn(), }; - await expect(persistGuidedPathwayAlertSafely({ + await expect(persistGuidedPathwayFlagSafely({ ...baseInput(writer), ...overrides, })).resolves.toEqual({ status: 'skipped' }); diff --git a/src/flags/__tests__/manual-flag-policy.test.ts b/src/flags/__tests__/manual-flag-policy.test.ts new file mode 100644 index 00000000..25ef8730 --- /dev/null +++ b/src/flags/__tests__/manual-flag-policy.test.ts @@ -0,0 +1,31 @@ +import { + isManualFlagType, + MANUAL_FLAG_TYPES, + validateManualFlagStatusTransition +} from '../manual-flag-policy'; + +describe('manual flag policy', () => { + it('recognizes every inherited manual flag category', () => { + for (const flagType of MANUAL_FLAG_TYPES) { + expect(isManualFlagType(flagType)).toBe(true); + } + expect(isManualFlagType('guided-pathway')).toBe(false); + expect(isManualFlagType(null)).toBe(false); + }); + + it('allows unresolved to resolved', () => { + expect(validateManualFlagStatusTransition('unresolved', 'resolved')).toEqual({ isValid: true }); + }); + + it('allows resolved to unresolved', () => { + expect(validateManualFlagStatusTransition('resolved', 'unresolved')).toEqual({ isValid: true }); + }); + + it('rejects same-status transitions', () => { + expect(validateManualFlagStatusTransition('unresolved', 'unresolved').isValid).toBe(false); + }); + + it('rejects unknown statuses', () => { + expect(validateManualFlagStatusTransition('unresolved', 'pending').isValid).toBe(false); + }); +}); diff --git a/src/flags/guided-pathway-flag-contracts.ts b/src/flags/guided-pathway-flag-contracts.ts new file mode 100644 index 00000000..53dc1f74 --- /dev/null +++ b/src/flags/guided-pathway-flag-contracts.ts @@ -0,0 +1,70 @@ +/** + * Guided Pathway flag contracts + * + * HTTP handlers, chat orchestration, the Mongo facade, and Mongo delegates share + * these contracts without importing types from the persistence implementation. + * + * @author: EngE-AI Team + * @date: 2026-08-17 + * @version: 1.0.0 + * @description: Persistence-neutral contracts for automatic Guided Pathway flags. + */ + +import type { + GuidedPathwayFlagOrigin, + GuidedPathwayFlagReviewState, + GuidedPathwayFlagStatus, + GuidedPathwayFlagView +} from '../types/shared'; + +/** Server-owned staff identity snapshot used for decisions, review, and reveal audit. */ +export interface GuidedPathwayFlagReviewActor { + userId: string; + name: string; +} + +/** Server-derived chat participant allowed to create an automatic flag. */ +export interface GuidedPathwayFlagTriggerActor { + origin: GuidedPathwayFlagOrigin; + userId: string; +} + +/** Input from the chat trigger path. Chat/request identifiers are hashed, never stored verbatim. */ +export interface CreateGuidedPathwayFlagInput { + courseId: string; + courseName: string; + pathwayId: string; + pathwayTitle: string; + messageText: string; + actor: GuidedPathwayFlagTriggerActor; + chatId: string; + clientMessageId: string; + triggeredAt?: Date; +} + +/** Filters supported by the platform-wide administrator queue. */ +export interface GuidedPathwayFlagListFilters { + page?: number; + pageSize?: number; + status?: GuidedPathwayFlagStatus; + reviewState?: GuidedPathwayFlagReviewState; + courseId?: string; + courseIds?: string[]; + pathwayId?: string; + reviewer?: string; + dateFrom?: Date; + dateTo?: Date; + escalatedFirst?: boolean; + includeFacets?: boolean; +} + +/** Result of an idempotent trigger insert. */ +export interface CreateGuidedPathwayFlagResult { + created: boolean; + flag: GuidedPathwayFlagView; +} + +/** Minimal persistence port used by failure-isolated chat orchestration. */ +export interface GuidedPathwayFlagWriter { + createGuidedPathwayFlag(input: CreateGuidedPathwayFlagInput): Promise; +} diff --git a/src/flags/guided-pathway-flag-errors.ts b/src/flags/guided-pathway-flag-errors.ts new file mode 100644 index 00000000..a5c37f51 --- /dev/null +++ b/src/flags/guided-pathway-flag-errors.ts @@ -0,0 +1,35 @@ +/** + * Guided Pathway flag errors + * + * Domain errors shared by automatic-flag persistence and HTTP adapters so + * transport status mapping does not depend on the Mongo implementation. + * + * @author: EngE-AI Team + * @date: 2026-08-17 + * @version: 1.0.0 + * @description: Persistence-neutral lifecycle and identity error contracts. + */ + +/** Raised when an alert id is absent from the required course scope. */ +export class GuidedPathwayFlagNotFoundError extends Error { + constructor(message = 'Guided Pathway alert not found') { + super(message); + this.name = 'GuidedPathwayFlagNotFoundError'; + } +} + +/** Raised when an action conflicts with the alert's completed lifecycle state. */ +export class GuidedPathwayFlagConflictError extends Error { + constructor(message: string) { + super(message); + this.name = 'GuidedPathwayFlagConflictError'; + } +} + +/** Raised after a successful reveal audit when the current roster name no longer exists. */ +export class GuidedPathwayFlagIdentityUnavailableError extends Error { + constructor() { + super('Student identity is unavailable in the current course roster'); + this.name = 'GuidedPathwayFlagIdentityUnavailableError'; + } +} diff --git a/src/flags/guided-pathway-flag-policy.ts b/src/flags/guided-pathway-flag-policy.ts new file mode 100644 index 00000000..fde23414 --- /dev/null +++ b/src/flags/guided-pathway-flag-policy.ts @@ -0,0 +1,53 @@ +/** + * Guided Pathway flag trigger-actor policy + * + * Resolves a database-backed course/user pair into either a production student + * actor or an instructor-test actor. HTTP request fields never select origin. + * + * @author: EngE-AI Team + * @date: 2026-08-17 + * @version: 1.0.0 + * @description: Server-owned role policy for automatic Guided Pathway flag creation. + */ + +import type { GlobalUser, activeCourse } from '../types/shared'; +import { isCourseStaff, isInCourseInstructors } from '../utils/course-staff'; +import { isAdminUser } from '../utils/admin'; +import type { GuidedPathwayFlagTriggerActor } from './guided-pathway-flag-contracts'; + +/** + * resolveGuidedPathwayFlagTriggerActor - Classifies an authenticated chat sender. + * + * Listed faculty instructors are checked before enrollment so dual-role records + * remain tests, including a listed instructor who also has platform-admin + * privilege. TAs, admin-only users, outsiders, and missing context are skipped. + * + * @param course - Current active-course record loaded by the server + * @param user - Current global-user record loaded by PUID + * @returns Explicit student/instructor-test actor, or null when persistence must be skipped + */ +export function resolveGuidedPathwayFlagTriggerActor( + course: activeCourse | null | undefined, + user: GlobalUser | null | undefined +): GuidedPathwayFlagTriggerActor | null { + if (!course?.id || !user?.userId) { + return null; + } + + // Resolve listed faculty instructors before enrollment to avoid production-student misclassification. + if (user.affiliation === 'faculty' && isInCourseInstructors(course, user.userId)) { + return { origin: 'instructor-test', userId: user.userId }; + } + + // Platform-admin privilege alone does not make a user a course instructor test actor. + if (isAdminUser(user)) { + return null; + } + + // Only enrolled non-staff users create production student flags. + if (user.coursesEnrolled?.includes(course.id) === true && !isCourseStaff(course, user)) { + return { origin: 'student', userId: user.userId }; + } + + return null; +} diff --git a/src/flags/guided-pathway-flag-service.ts b/src/flags/guided-pathway-flag-service.ts new file mode 100644 index 00000000..89121995 --- /dev/null +++ b/src/flags/guided-pathway-flag-service.ts @@ -0,0 +1,80 @@ +/** + * Guided Pathway flag service + * + * A failed optional flag write is returned as data instead of interrupting the + * predefined pathway response shown to the chat sender. + * + * @author: EngE-AI Team + * @date: 2026-08-17 + * @version: 1.0.0 + * @description: Failure-isolated orchestration for automatic pathway-trigger flags. + */ + +import type { + GuidedPathwayFlagTriggerActor, + GuidedPathwayFlagWriter, +} from './guided-pathway-flag-contracts'; +import type { PathwayTriggerSnapshot } from '../guided-pathways/pathway-schema'; + +/** Trigger context used to decide whether an automatic flag should be written. */ +export interface PersistGuidedPathwayFlagInput { + writer: GuidedPathwayFlagWriter; + trigger: PathwayTriggerSnapshot | null; + courseId?: string; + courseName: string; + messageText: string; + actor: GuidedPathwayFlagTriggerActor | null; + chatId: string; + clientMessageId: string; +} + +/** Safe outcome returned to the chat route without carrying the original database error. */ +export type PersistGuidedPathwayFlagResult = + | { status: 'skipped' } + | { status: 'created' } + | { status: 'failed'; errorCode?: string | number }; + +function safeErrorCode(error: unknown): string | number | undefined { + if (!error || typeof error !== 'object') return undefined; + const code = (error as { code?: unknown }).code; + return typeof code === 'string' || typeof code === 'number' ? code : undefined; +} + +/** + * persistGuidedPathwayFlagSafely - Persists a notification-enabled automatic flag. + * + * Both production students and listed course instructors use the same failure- + * isolated write path. The actor origin remains explicit so an instructor test + * can never be mistaken for a student-authored escalation. + * + * @param input - Winning pathway snapshot, server-derived actor, transport identity, and writer port + * @returns `skipped`, `created`, or a sanitized `failed` result; never throws a storage error + */ +export async function persistGuidedPathwayFlagSafely( + input: PersistGuidedPathwayFlagInput +): Promise { + const { trigger, courseId } = input; + if ( + trigger?.notifyInstructorOnTrigger !== true || + !courseId || + !input.actor + ) { + return { status: 'skipped' }; + } + + try { + await input.writer.createGuidedPathwayFlag({ + courseId, + courseName: input.courseName, + pathwayId: trigger.pathwayId, + pathwayTitle: trigger.pathwayTitle, + messageText: input.messageText, + actor: input.actor, + chatId: input.chatId, + clientMessageId: input.clientMessageId, + }); + return { status: 'created' }; + } catch (error) { + return { status: 'failed', errorCode: safeErrorCode(error) }; + } +} diff --git a/src/flags/manual-flag-policy.ts b/src/flags/manual-flag-policy.ts new file mode 100644 index 00000000..dfd9c8a1 --- /dev/null +++ b/src/flags/manual-flag-policy.ts @@ -0,0 +1,72 @@ +/** + * Manual flag policy + * + * Defines the inherited manual-report categories and lifecycle validation used + * by both HTTP and Mongo adapters without performing persistence or logging. + * + * @author: EngE-AI Team + * @date: 2026-08-17 + * @version: 1.0.0 + * @description: Pure category and status-transition policy for manual flags. + */ + +import type { FlagReport } from '../types/shared'; + +/** Manual/student-reported categories accepted by the existing flag workflow. */ +export const MANUAL_FLAG_TYPES = [ + 'innacurate_response', + 'harassment', + 'inappropriate', + 'dishonesty', + 'interface bug', + 'other' +] as const satisfies readonly FlagReport['flagType'][]; + +/** + * isManualFlagType - Narrows untrusted input to an inherited manual flag category. + * + * @param value - Candidate request or document value + * @returns Whether the value is one of the supported manual report categories + */ +export function isManualFlagType(value: unknown): value is FlagReport['flagType'] { + return typeof value === 'string' && (MANUAL_FLAG_TYPES as readonly string[]).includes(value); +} + +/** + * validateManualFlagStatusTransition - Validates the inherited two-state lifecycle. + * + * Same-state and unknown-state transitions fail with a stable explanatory error. + * + * @param currentStatus - Existing stored status + * @param newStatus - Requested next status + * @returns A success discriminator or the validation error for the caller + */ +export function validateManualFlagStatusTransition( + currentStatus: string, + newStatus: string +): { isValid: boolean; error?: string } { + const validStatuses = ['unresolved', 'resolved']; + if (!validStatuses.includes(newStatus)) { + return { + isValid: false, + error: `Invalid status: ${newStatus}. Must be one of: ${validStatuses.join(', ')}` + }; + } + if (!validStatuses.includes(currentStatus)) { + return { + isValid: false, + error: `Invalid current status: ${currentStatus}. Must be one of: ${validStatuses.join(', ')}` + }; + } + const validTransitions: Record = { + unresolved: ['resolved'], + resolved: ['unresolved'] + }; + if (!validTransitions[currentStatus].includes(newStatus)) { + return { + isValid: false, + error: `Invalid transition: ${currentStatus} -> ${newStatus}. Valid transitions: ${validTransitions[currentStatus].join(', ')}` + }; + } + return { isValid: true }; +} diff --git a/src/guided-pathways/pathway-alert-persistence.ts b/src/guided-pathways/pathway-alert-persistence.ts deleted file mode 100644 index d6ff807a..00000000 --- a/src/guided-pathways/pathway-alert-persistence.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Guided Pathway alert persistence boundary - * - * Keeps optional alert creation isolated from the student-facing pathway response. - * A failed alert write is reported as data instead of throwing into the chat route. - * - * @author: EngE-AI Team - * @date: 2026-08-08 - * @version: 1.0.0 - * @description: Failure-isolated persistence helper for Guided Pathway trigger alerts. - */ - -import type { - CreateGuidedPathwayFlagInput, -} from '../db/mongo/guided-pathway-flag-mongo'; -import type { PathwayTriggerSnapshot } from './pathway-schema'; - -/** Minimal persistence contract used by the chat route. */ -export interface GuidedPathwayFlagWriter { - createGuidedPathwayFlag(input: CreateGuidedPathwayFlagInput): Promise; -} - -/** Context needed to decide whether an automatic alert should be written. */ -export interface PersistGuidedPathwayAlertInput { - writer: GuidedPathwayFlagWriter; - trigger: PathwayTriggerSnapshot | null; - courseId?: string; - courseName: string; - messageText: string; - studentUserId: string; - chatId: string; - clientMessageId: string; - isEligibleStudent: boolean; -} - -/** Safe outcome returned to the route without carrying the original database error. */ -export type PersistGuidedPathwayAlertResult = - | { status: 'skipped' } - | { status: 'created' } - | { status: 'failed'; errorCode?: string | number }; - -function safeErrorCode(error: unknown): string | number | undefined { - if (!error || typeof error !== 'object') return undefined; - const code = (error as { code?: unknown }).code; - return typeof code === 'string' || typeof code === 'number' ? code : undefined; -} - -/** - * Persist an alert when the winning pathway and user are eligible. - * - * This function never throws for a flag-write failure. That separation ensures - * the student still receives the pathway's predefined response when MongoDB is - * temporarily unable to store the instructor alert. - */ -export async function persistGuidedPathwayAlertSafely( - input: PersistGuidedPathwayAlertInput -): Promise { - const { trigger, courseId } = input; - if ( - trigger?.notifyInstructorOnTrigger !== true || - !courseId || - !input.isEligibleStudent - ) { - return { status: 'skipped' }; - } - - try { - await input.writer.createGuidedPathwayFlag({ - courseId, - courseName: input.courseName, - pathwayId: trigger.pathwayId, - pathwayTitle: trigger.pathwayTitle, - messageText: input.messageText, - studentUserId: input.studentUserId, - chatId: input.chatId, - clientMessageId: input.clientMessageId, - }); - return { status: 'created' }; - } catch (error) { - return { status: 'failed', errorCode: safeErrorCode(error) }; - } -} diff --git a/src/middleware/__tests__/require-course-role.test.ts b/src/middleware/__tests__/require-course-role.test.ts index 82d743ab..e23d65b6 100644 --- a/src/middleware/__tests__/require-course-role.test.ts +++ b/src/middleware/__tests__/require-course-role.test.ts @@ -2,7 +2,8 @@ import type { Request, Response, NextFunction } from 'express'; import { requireAdminForCourseAPI, requireInstructorForCourseAPI, - requireInstructorOrAdminForCourseAPI + requireInstructorOrAdminForCourseAPI, + requireSelfOrInstructorForCourseAPI } from '../require-course-role'; jest.mock('../../db/enge-ai-mongodb', () => ({ @@ -163,3 +164,68 @@ describe('require-course-role admin', () => { }); }); }); + +describe('require-course-role self-or-instructor', () => { + const course = { + id: 'course-1', + instructors: [{ userId: 'user-inst', name: 'Inst' }], + teachingAssistants: [{ userId: 'user-ta', name: 'TA' }] + }; + + /** Wires the middleware's two Mongo lookups for one authenticated caller. */ + function mockCaller(globalUser: Record) { + (EngEAI_MongoDB.getInstance as jest.Mock).mockResolvedValue({ + findGlobalUserByPUID: jest.fn().mockResolvedValue(globalUser), + getActiveCourse: jest.fn().mockResolvedValue(course) + }); + } + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('allows a student to read their own record', async () => { + mockCaller({ userId: 'user-student', affiliation: 'student', isAdmin: false }); + + const { req, res, next } = mockReqResNext({ + params: { courseId: 'course-1', userId: 'user-student' } + }); + await requireSelfOrInstructorForCourseAPI('userId', ['params'])(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + it('denies a student reading a different student record', async () => { + mockCaller({ userId: 'user-student', affiliation: 'student', isAdmin: false }); + + const { req, res, next } = mockReqResNext({ + params: { courseId: 'course-1', userId: 'user-other' } + }); + await requireSelfOrInstructorForCourseAPI('userId', ['params'])(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('allows course staff to read any student record', async () => { + mockCaller({ userId: 'user-ta', affiliation: 'student', isAdmin: false }); + + const { req, res, next } = mockReqResNext({ + params: { courseId: 'course-1', userId: 'user-other' } + }); + await requireSelfOrInstructorForCourseAPI('userId', ['params'])(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + it('compares numeric and string user ids as the same identity', async () => { + mockCaller({ userId: 12345, affiliation: 'student', isAdmin: false }); + + const { req, res, next } = mockReqResNext({ + params: { courseId: 'course-1', userId: '12345' } + }); + await requireSelfOrInstructorForCourseAPI('userId', ['params'])(req, res, next); + + expect(next).toHaveBeenCalled(); + }); +}); diff --git a/src/middleware/require-course-role.ts b/src/middleware/require-course-role.ts index f0f2313d..437dd216 100644 --- a/src/middleware/require-course-role.ts +++ b/src/middleware/require-course-role.ts @@ -169,6 +169,49 @@ export function requireStudentForCourseAPI(sources: CourseIdSource[] = ['params' }; } +/** + * requireSelfOrInstructorForCourseAPI - Restricts a per-student record to its owner or course staff. + * + * Used by endpoints that carry a target user id in the path and serve two + * audiences: a student reading their own record, and staff reviewing any + * student in the course. Authentication alone is not sufficient, because the + * target user id is caller-supplied and would otherwise expose other students. + * + * @param userIdParam - Route parameter naming the record owner (for example `userId`) + * @param sources - Ordered request locations used to resolve the course id + * @returns Express middleware with JSON 401/403/404 failures + */ +export function requireSelfOrInstructorForCourseAPI( + userIdParam: string, + sources: CourseIdSource[] = ['params', 'paramsId', 'body', 'session'] +) { + return async (req: Request, res: Response, next: NextFunction) => { + try { + const ctx = await loadCourseContext(req, sources); + if (!ctx.ok) { + return res.status(ctx.status).json({ error: ctx.error }); + } + + // Stored user ids are numeric for some records and string for others, + // while a path parameter is always a string; compare on the string form. + const targetUserId = String((req.params as Record)?.[userIdParam] ?? ''); + const isSelf = targetUserId !== '' && String(ctx.globalUser.userId) === targetUserId; + + if (!isSelf && !isCourseStaff(ctx.course, ctx.globalUser)) { + appLogger.log( + `[RBAC] User ${ctx.globalUser.userId} denied self-or-instructor API access for course ${ctx.courseId}` + ); + return res.status(403).json({ error: 'Instructor access required' }); + } + + next(); + } catch (error) { + appLogger.error('[RBAC] Error in requireSelfOrInstructorForCourseAPI:', error); + res.status(500).json({ error: 'Internal server error' }); + } + }; +} + /** * Middleware: Require roster manage permission (faculty instructor or platform admin). * TAs are course staff but cannot promote/demote. diff --git a/src/routes/__tests__/manual-flag-routes-contract.test.ts b/src/routes/__tests__/manual-flag-routes-contract.test.ts new file mode 100644 index 00000000..83d6a9b3 --- /dev/null +++ b/src/routes/__tests__/manual-flag-routes-contract.test.ts @@ -0,0 +1,146 @@ +/** + * Manual flag route contract tests + * + * Pins course-scoped authorization and route-matching order for the inherited + * manual flag endpoint family in `route-mongo.ts`. These contracts must hold + * before the family is extracted into its own module. + * + * @author: EngE-AI Team + * @date: 2026-08-17 + * @version: 1.0.0 + * @description: RBAC and route-order regression coverage for manual flag endpoints. + */ + +import express, { type NextFunction, type Request, type Response } from 'express'; +import request from 'supertest'; + +/** + * Toggles the stubbed course guards between deny and pass-through. + * + * Authorization logic lives in `middleware/__tests__/require-course-role.test.ts`. + * Denying proves a guard is mounted at all; passing through lets a test observe + * which handler Express actually selected. + */ +const guardState = { deny: true }; + +jest.mock('../../middleware/async-handler', () => ({ + asyncHandler: (handler: (req: Request, res: Response, next: NextFunction) => unknown) => + (req: Request, res: Response, next: NextFunction) => + Promise.resolve(handler(req, res, next)).catch(next), + asyncHandlerWithAuth: (handler: (req: Request, res: Response, next: NextFunction) => unknown) => + (req: Request, res: Response, next: NextFunction) => + Promise.resolve(handler(req, res, next)).catch(next) +})); + +jest.mock('../../middleware/require-course-role', () => { + const courseGuard = () => (_req: Request, res: Response, next: NextFunction) => { + if (guardState.deny) { + return res.status(403).json({ success: false, error: 'Course access required' }); + } + return next(); + }; + const passThroughGuard = () => (_req: Request, _res: Response, next: NextFunction) => next(); + return { + requireAdminForCourseAPI: passThroughGuard, + requireCourseFeatureAPI: passThroughGuard, + requireInstructorForCourseAPI: courseGuard, + requireSelfOrInstructorForCourseAPI: courseGuard, + requireInstructorGlobal: passThroughGuard(), + requireInstructorOrAdminForCourseAPI: passThroughGuard, + requirePostPeriodAnalyticsAPI: passThroughGuard, + requireRosterManageAPI: passThroughGuard, + requireAdminGlobal: passThroughGuard() + }; +}); + +jest.mock('../../db/enge-ai-mongodb', () => ({ + EngEAI_MongoDB: { getInstance: jest.fn() } +})); + +jest.mock('../../utils/logger', () => ({ + appLogger: { log: jest.fn(), warn: jest.fn(), error: jest.fn() } +})); + +jest.mock('../../rag/rag-app', () => ({ RAGApp: { getInstance: jest.fn() } })); +jest.mock('../../memory-agent/memory-agent', () => ({ memoryAgent: {} })); +jest.mock('../../jobs/scheduled-publish-audit', () => ({ scheduledPublishAudit: { record: jest.fn() } })); + +// `scenario-service` builds a live LLM provider at module load, and `route-mongo` +// imports it transitively when it mounts the scenario-question routes. Stubbing the +// module keeps that import side effect out of a pure route-contract test. +jest.mock('../../scenario-generation/scenario-service', () => ({ + generateScenarioQuestions: jest.fn(), + submitScenarioStudentResponse: jest.fn(), + submitScenarioExam: jest.fn(), + getScenarioService: jest.fn() +})); + +import { EngEAI_MongoDB } from '../../db/enge-ai-mongodb'; +import mongodbRoutes from '../route-mongo'; + +function buildApp() { + const app = express(); + app.use(express.json()); + app.use('/api/courses', mongodbRoutes); + return app; +} + +/** Stubs the singleton so each test only supplies the delegates its route needs. */ +function mockMongo(delegates: Record) { + (EngEAI_MongoDB.getInstance as jest.Mock).mockResolvedValue({ + getActiveCourse: jest.fn().mockResolvedValue({ id: 'course-1', courseName: 'CHBE 241' }), + ...delegates + }); +} + +describe('manual flag route contracts', () => { + beforeEach(() => { + jest.clearAllMocks(); + guardState.deny = true; + }); + + it('guards one student flag history behind course-scoped authorization', async () => { + const getAllFlagReports = jest.fn(); + mockMongo({ getAllFlagReports }); + + const response = await request(buildApp()).get('/api/courses/course-1/flags/student/student-9'); + + expect(response.status).toBe(403); + expect(getAllFlagReports).not.toHaveBeenCalled(); + }); + + it('guards flag statistics behind course-scoped authorization', async () => { + const getFlagStatistics = jest.fn(); + mockMongo({ getFlagStatistics }); + + const response = await request(buildApp()).get('/api/courses/course-1/flags/statistics'); + + expect(response.status).toBe(403); + expect(getFlagStatistics).not.toHaveBeenCalled(); + }); + + it('routes flags/statistics to the statistics handler rather than the flag-id handler', async () => { + guardState.deny = false; + const getFlagStatistics = jest.fn().mockResolvedValue({ total: 0 }); + const getFlagReportById = jest.fn(); + mockMongo({ getFlagStatistics, getFlagReportById }); + + await request(buildApp()).get('/api/courses/course-1/flags/statistics'); + + // `/:courseId/flags/:flagId` also matches this URL, so declaration order decides. + expect(getFlagReportById).not.toHaveBeenCalled(); + expect(getFlagStatistics).toHaveBeenCalledWith('CHBE 241'); + }); + + it('routes flags/validate to the validation handler rather than the flag-id handler', async () => { + guardState.deny = false; + const validateFlagCollection = jest.fn().mockResolvedValue({ valid: true }); + const getFlagReportById = jest.fn(); + mockMongo({ validateFlagCollection, getFlagReportById }); + + await request(buildApp()).get('/api/courses/course-1/flags/validate'); + + expect(getFlagReportById).not.toHaveBeenCalled(); + expect(validateFlagCollection).toHaveBeenCalled(); + }); +}); diff --git a/src/routes/mongo/admin-guided-pathway-flag-routes.ts b/src/routes/mongo/admin-guided-pathway-flag-routes.ts index decd7610..e7ba5b81 100644 --- a/src/routes/mongo/admin-guided-pathway-flag-routes.ts +++ b/src/routes/mongo/admin-guided-pathway-flag-routes.ts @@ -12,12 +12,12 @@ import { Router, type Request, type Response } from 'express'; import { EngEAI_MongoDB } from '../../db/enge-ai-mongodb'; +import type { GuidedPathwayFlagReviewActor } from '../../flags/guided-pathway-flag-contracts'; import { GuidedPathwayFlagConflictError, GuidedPathwayFlagIdentityUnavailableError, - GuidedPathwayFlagNotFoundError, - type GuidedPathwayFlagActor -} from '../../db/mongo/guided-pathway-flag-mongo'; + GuidedPathwayFlagNotFoundError +} from '../../flags/guided-pathway-flag-errors'; import { routeParam } from '../../helpers/route-params'; import { asyncHandlerWithAuth } from '../../middleware/async-handler'; import { requireAdminGlobal } from '../../middleware/require-course-role'; @@ -113,7 +113,7 @@ export function parseGuidedPathwayFlagDateQuery( return Number.isNaN(parsed.getTime()) ? null : parsed; } -function actorFromSession(req: Request): GuidedPathwayFlagActor { +function actorFromSession(req: Request): GuidedPathwayFlagReviewActor { const actor = (req.session as any)?.globalUser as GlobalUser | undefined; if (!actor?.userId || !actor.name) { throw new Error('Authenticated administrator identity is unavailable'); diff --git a/src/routes/mongo/guided-pathway-flag-routes.ts b/src/routes/mongo/guided-pathway-flag-routes.ts index 290a7f35..2edc24b2 100644 --- a/src/routes/mongo/guided-pathway-flag-routes.ts +++ b/src/routes/mongo/guided-pathway-flag-routes.ts @@ -12,11 +12,11 @@ import type { Request, Response, Router } from 'express'; import { EngEAI_MongoDB } from '../../db/enge-ai-mongodb'; +import type { GuidedPathwayFlagReviewActor } from '../../flags/guided-pathway-flag-contracts'; import { GuidedPathwayFlagConflictError, - GuidedPathwayFlagNotFoundError, - type GuidedPathwayFlagActor -} from '../../db/mongo/guided-pathway-flag-mongo'; + GuidedPathwayFlagNotFoundError +} from '../../flags/guided-pathway-flag-errors'; import { normalizeRouteParams } from '../../helpers/route-params'; import { asyncHandlerWithAuth } from '../../middleware/async-handler'; import { requireInstructorOrAdminForCourseAPI } from '../../middleware/require-course-role'; @@ -37,7 +37,7 @@ function parsePositiveInteger(value: unknown, fallback: number): number | null { return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null; } -function actorFromSession(req: Request): GuidedPathwayFlagActor { +function actorFromSession(req: Request): GuidedPathwayFlagReviewActor { const actor = (req.session as any)?.globalUser as GlobalUser | undefined; if (!actor?.userId || !actor.name) { throw new Error('Authenticated staff identity is unavailable'); diff --git a/src/routes/route-chat-app.ts b/src/routes/route-chat-app.ts index 5d1bbb10..d2e5cdc5 100644 --- a/src/routes/route-chat-app.ts +++ b/src/routes/route-chat-app.ts @@ -17,9 +17,9 @@ import { EngEAI_MongoDB } from '../db/enge-ai-mongodb'; import { ChatApp, RETIRED_CONVERSATION_MODE_MESSAGE, DEBUG_MODE_FORBIDDEN } from '../chat/chat-app'; import { conversationModePrompts } from '../chat/compose-system-prompt'; import { isAdminUser } from '../utils/admin'; -import { isCourseStaff } from '../utils/course-staff'; import { isDebugToggleMessage } from '../chat/system-prompts/debug-mode-prompt'; -import { persistGuidedPathwayAlertSafely } from '../guided-pathways/pathway-alert-persistence'; +import { persistGuidedPathwayFlagSafely } from '../flags/guided-pathway-flag-service'; +import { resolveGuidedPathwayFlagTriggerActor } from '../flags/guided-pathway-flag-policy'; import { getRandomNoResponse } from '../memory-agent/unstruggle-responses'; import { memoryAgent } from '../memory-agent/memory-agent'; @@ -936,25 +936,22 @@ router.post('/:chatId', asyncHandlerWithAuth(async (req: Request, res: Response) // Continue execution - messages are still in memory } - // Persist an anonymous alert only for enrolled students and notification-enabled triggers. + // Derive the flag origin from server-owned course roles; request fields never select test mode. const courseId = courseForFeatures?.id; - const isEnrolledStudent = Boolean( - courseForFeatures && - courseId && - globalUserFromDB.coursesEnrolled.includes(courseId) && - !isCourseStaff(courseForFeatures, globalUserFromDB) + const flagActor = resolveGuidedPathwayFlagTriggerActor( + courseForFeatures, + globalUserFromDB ); - const flagResult = await persistGuidedPathwayAlertSafely({ + const flagResult = await persistGuidedPathwayFlagSafely({ writer: mongoDB, trigger: pathwayTrigger, courseId, courseName, messageText: message, - studentUserId: userId, + actor: flagActor, chatId, clientMessageId, - isEligibleStudent: isEnrolledStudent, }); if (flagResult.status === 'failed') { appLogger.error( diff --git a/src/routes/route-mongo.ts b/src/routes/route-mongo.ts index 64c48bc5..5034e5bc 100644 --- a/src/routes/route-mongo.ts +++ b/src/routes/route-mongo.ts @@ -40,7 +40,8 @@ import { requireInstructorGlobal, requireInstructorOrAdminForCourseAPI, requirePostPeriodAnalyticsAPI, - requireRosterManageAPI + requireRosterManageAPI, + requireSelfOrInstructorForCourseAPI } from '../middleware/require-course-role'; import { EngEAI_MongoDB } from '../db/enge-ai-mongodb'; import { @@ -110,6 +111,7 @@ import { mountSystemPromptConfigRoutes } from './mongo/system-prompt-config-rout import { mountScenarioQuestionRoutes } from './mongo/scenario-questions-routes'; import { mountPathwaysRoutes } from './mongo/pathways-routes'; import { mountGuidedPathwayFlagRoutes } from './mongo/guided-pathway-flag-routes'; +import { isManualFlagType, MANUAL_FLAG_TYPES } from '../flags/manual-flag-policy'; const router = express.Router(); export default router; @@ -1180,8 +1182,16 @@ router.put('/:id', requireInstructorForCourseAPI(['paramsId']), asyncHandlerWith }); } - // Strip capabilities so this generic instructor update cannot bypass the roster-manager gate. - const { features: _ignoredFeatures, ...updateData } = req.body ?? {}; + // Keep capabilities, immutable ids, and physical collection registrations server-owned. + const updateData = Object.fromEntries( + Object.entries(req.body ?? {}).filter(([key]) => ( + key !== 'features' + && key !== 'id' + && key !== '_id' + && key !== 'collections' + && !key.startsWith('collections.') + )) + ); const updatedCourse = await instance.updateActiveCourse(routeParam(req.params, 'id'), updateData); res.status(200).json({ @@ -2275,11 +2285,10 @@ router.post('/:courseId/flags', asyncHandlerWithAuth(async (req: Request, res: R } // Validate flagType - const validFlagTypes = ['innacurate_response', 'harassment', 'inappropriate', 'dishonesty', 'interface bug', 'other']; - if (!validFlagTypes.includes(flagType)) { + if (!isManualFlagType(flagType)) { return res.status(400).json({ success: false, - error: 'Invalid flagType. Must be one of: ' + validFlagTypes.join(', ') + error: 'Invalid flagType. Must be one of: ' + MANUAL_FLAG_TYPES.join(', ') }); } @@ -2701,6 +2710,101 @@ router.get('/:courseId/flags/with-names', requireInstructorForCourseAPI(['params } })); +// Literal `/flags/...` routes must be declared before the `/:courseId/flags/:flagId` +// capture below; Express matches in declaration order, so a later literal route would +// be swallowed by `:flagId` and never run. + +/** + * GET /:courseId/flags/validate + * Validate flag collection integrity. Instructors only. + * + * @route GET /api/courses/:courseId/flags/validate + * @param {string} courseId - Course ID (path param) + * @returns {object} { success: boolean, data?: object, error?: string } + * @response 200 - Validation result + * @response 401 - User not authenticated + * @response 403 - Instructor access required for course + * @response 404 - Course not found + * @response 500 - Failed to validate flag collection + */ +router.get('/:courseId/flags/validate', requireInstructorForCourseAPI(['params']), asyncHandlerWithAuth(async (req: Request, res: Response) => { + try { + const instance = await EngEAI_MongoDB.getInstance(); + const { courseId } = normalizeRouteParams(req.params); + + // Get course to get course name + const course = await instance.getActiveCourse(courseId); + if (!course) { + return res.status(404).json({ + success: false, + error: 'Course not found' + }); + } + + //START DEBUG LOG : DEBUG-CODE(VALIDATE-COLLECTION-API) + appLogger.log('🔍 Validating flag collection for course:', course.courseName); + //END DEBUG LOG : DEBUG-CODE(VALIDATE-COLLECTION-API) + + const validation = await instance.validateFlagCollection(course.courseName); + + res.json({ + success: true, + data: validation + }); + } catch (error) { + appLogger.error('Error validating flag collection:', { error }); + res.status(500).json({ + success: false, + error: 'Failed to validate flag collection' + }); + } +})); + +/** + * GET /:courseId/flags/statistics + * Get flag statistics for a course. + * + * @route GET /api/courses/:courseId/flags/statistics + * @param {string} courseId - Course ID (path param) + * @returns {object} { success: boolean, data?: object, error?: string } + * @response 200 - Success + * @response 401 - User not authenticated + * @response 404 - Course not found + * @response 500 - Failed to get flag statistics + */ +router.get('/:courseId/flags/statistics', requireInstructorForCourseAPI(['params']), asyncHandlerWithAuth(async (req: Request, res: Response) => { + try { + const instance = await EngEAI_MongoDB.getInstance(); + const { courseId } = normalizeRouteParams(req.params); + + // Get course to get course name + const course = await instance.getActiveCourse(courseId); + if (!course) { + return res.status(404).json({ + success: false, + error: 'Course not found' + }); + } + + //START DEBUG LOG : DEBUG-CODE(GET-STATISTICS-API) + appLogger.log('📊 Getting flag statistics for course:', course.courseName); + //END DEBUG LOG : DEBUG-CODE(GET-STATISTICS-API) + + const statistics = await instance.getFlagStatistics(course.courseName); + + res.json({ + success: true, + data: statistics + }); + } catch (error) { + appLogger.error('Error getting flag statistics:', { error }); + res.status(500).json({ + success: false, + error: 'Failed to get flag statistics' + }); + } +})); + /** * GET /:courseId/flags/:flagId * Get a specific flag report by ID. Instructors only. @@ -3073,96 +3177,6 @@ router.post('/:courseId/flags/create-indexes', requireInstructorForCourseAPI(['p } })); -/** - * GET /:courseId/flags/validate - * Validate flag collection integrity. Instructors only. - * - * @route GET /api/courses/:courseId/flags/validate - * @param {string} courseId - Course ID (path param) - * @returns {object} { success: boolean, data?: object, error?: string } - * @response 200 - Validation result - * @response 401 - User not authenticated - * @response 403 - Instructor access required for course - * @response 404 - Course not found - * @response 500 - Failed to validate flag collection - */ -router.get('/:courseId/flags/validate', requireInstructorForCourseAPI(['params']), asyncHandlerWithAuth(async (req: Request, res: Response) => { - try { - const instance = await EngEAI_MongoDB.getInstance(); - const { courseId } = normalizeRouteParams(req.params); - - // Get course to get course name - const course = await instance.getActiveCourse(courseId); - if (!course) { - return res.status(404).json({ - success: false, - error: 'Course not found' - }); - } - - //START DEBUG LOG : DEBUG-CODE(VALIDATE-COLLECTION-API) - appLogger.log('🔍 Validating flag collection for course:', course.courseName); - //END DEBUG LOG : DEBUG-CODE(VALIDATE-COLLECTION-API) - - const validation = await instance.validateFlagCollection(course.courseName); - - res.json({ - success: true, - data: validation - }); - } catch (error) { - appLogger.error('Error validating flag collection:', { error }); - res.status(500).json({ - success: false, - error: 'Failed to validate flag collection' - }); - } -})); - -/** - * GET /:courseId/flags/statistics - * Get flag statistics for a course. - * - * @route GET /api/courses/:courseId/flags/statistics - * @param {string} courseId - Course ID (path param) - * @returns {object} { success: boolean, data?: object, error?: string } - * @response 200 - Success - * @response 401 - User not authenticated - * @response 404 - Course not found - * @response 500 - Failed to get flag statistics - */ -router.get('/:courseId/flags/statistics', asyncHandlerWithAuth(async (req: Request, res: Response) => { - try { - const instance = await EngEAI_MongoDB.getInstance(); - const { courseId } = normalizeRouteParams(req.params); - - // Get course to get course name - const course = await instance.getActiveCourse(courseId); - if (!course) { - return res.status(404).json({ - success: false, - error: 'Course not found' - }); - } - - //START DEBUG LOG : DEBUG-CODE(GET-STATISTICS-API) - appLogger.log('📊 Getting flag statistics for course:', course.courseName); - //END DEBUG LOG : DEBUG-CODE(GET-STATISTICS-API) - - const statistics = await instance.getFlagStatistics(course.courseName); - - res.json({ - success: true, - data: statistics - }); - } catch (error) { - appLogger.error('Error getting flag statistics:', { error }); - res.status(500).json({ - success: false, - error: 'Failed to get flag statistics' - }); - } -})); /** * GET /:courseId/flags/student/:userId @@ -3177,7 +3191,8 @@ router.get('/:courseId/flags/statistics', asyncHandlerWithAuth(async (req: Reque * @response 404 - Course not found * @response 500 - Failed to get student flag reports */ -router.get('/:courseId/flags/student/:userId', asyncHandlerWithAuth(async (req: Request, res: Response) => { +// A student may read their own flag history; anyone else needs course staff authority. +router.get('/:courseId/flags/student/:userId', requireSelfOrInstructorForCourseAPI('userId', ['params']), asyncHandlerWithAuth(async (req: Request, res: Response) => { try { const instance = await EngEAI_MongoDB.getInstance(); const { courseId, userId } = normalizeRouteParams(req.params); diff --git a/src/server.ts b/src/server.ts index b23c1c48..8280a013 100644 --- a/src/server.ts +++ b/src/server.ts @@ -301,9 +301,9 @@ app.listen(port, async () => { try { const mongo = await EngEAI_MongoDB.getInstance(); const migration = await mongo.migrateGuidedPathwayFlagsToCourseCollections(); - logger.info('Guided Pathway GPF-001 storage migration complete', migration); + logger.info('Guided Pathway GPF-002 storage migration complete', migration); } catch (err) { - logger.error('Guided Pathway GPF-001 storage migration failed:', err as any); + logger.error('Guided Pathway GPF-002 storage migration failed:', err as any); } try { diff --git a/src/types/shared.ts b/src/types/shared.ts index 663a9dd8..68de4217 100644 --- a/src/types/shared.ts +++ b/src/types/shared.ts @@ -332,7 +332,7 @@ export interface activeCourse { scenarioProgress?: string; /** Per-course Guided Pathway Library (e.g. `${courseName}_pathways`); lazy-provisions on existing courses */ pathways?: string; - /** Course-owned automatic Guided Pathway alerts; derived from stable course id by GPF-001 */ + /** Registered course-owned collection for automatic Guided Pathway alerts (GPF-002). */ guidedPathwayFlags?: string; }; collectionOfInitialAssistantPrompts?: InitialAssistantPrompt[]; @@ -639,6 +639,9 @@ export interface FlagReport { /** Lifecycle state for an automatic alert created by a Guided Pathway trigger. */ export type GuidedPathwayFlagStatus = 'pending' | 'escalated' | 'dismissed'; +/** Server-owned origin separating production student alerts from instructor tests. */ +export type GuidedPathwayFlagOrigin = 'student' | 'instructor-test'; + /** Instructor decision accepted by the Guided Pathway alert review API. */ export type GuidedPathwayFlagDecision = 'escalate' | 'dismiss'; @@ -657,7 +660,8 @@ export interface GuidedPathwayFlagView { courseName: string; // course-name snapshot captured when the pathway triggered pathwayId: string; // winning pathway id for filtering pathwayTitle: string; // winning pathway title snapshot shown to reviewers - messageText: string; // exact student-authored message; may contain self-identifying text + messageText: string; // exact triggering chat message; may contain self-identifying text + origin: GuidedPathwayFlagOrigin; // production student alert or non-escalatable instructor test status: GuidedPathwayFlagStatus; // instructor review lifecycle triggeredAt: string; // ISO timestamp for the pathway trigger decidedAt?: string; // ISO timestamp for Escalate or Dismiss From 5678d8862ff10860e3adb3dedeb34ae5db842781 Mon Sep 17 00:00:00 2001 From: Charisma Pramudya Rusdiyanto Date: Tue, 25 Aug 2026 18:32:22 -0700 Subject: [PATCH 4/7] revamp the UI on instructor's page and the flag system --- documents/ENDPOINT_ARCHITECTURE.md | 18 +- documents/MONGO_DATA_LAYER.md | 3 +- documents/RESPONSIVE_DESIGN.md | 56 +++ jest.config.cjs | 2 +- package.json | 2 +- .../assistant-prompts-instructor.html | 6 +- .../documents/documents-instructor.html | 6 +- .../monitor/monitor-instructor.html | 6 +- .../pathways/pathway-library-instructor.html | 9 +- public/components/report/flag-instructor.html | 251 ++++------ .../scenarios/scenario-questions-grid.html | 7 +- .../scenario-questions-instructor.html | 2 +- .../system-prompts-instructor.html | 8 +- .../writing-feedback/writing-feedback.html | 6 +- public/pages/instructor-mode.html | 1 + public/scripts/entry/instructor-mode.ts | 11 +- .../feature/admin-guided-pathway-flags.ts | 259 ++++++++-- public/scripts/types.ts | 102 ++++ public/styles/assistant-prompts.css | 57 --- .../instructor-components/documents.css | 53 -- .../instructor-components/flag-instructor.css | 453 +++++++++++------- .../monitor-instructor.css | 56 +-- .../instructor-components/pathway-library.css | 40 +- .../scenario-questions-instructor.css | 85 +--- .../writing-feedback.css | 56 +-- public/styles/instructor-mode.css | 16 +- public/styles/system-prompts.css | 29 +- src/db/enge-ai-mongodb.ts | 19 + .../guided-pathway-flag-mongo.test.ts | 27 ++ src/db/mongo/flag-mongo.ts | 211 +++++++- src/db/mongo/guided-pathway-flag-mongo.ts | 16 +- src/db/mongo/topic-week-mongo.ts | 8 + src/routes/route-mongo.ts | 50 ++ src/server.ts | 2 + src/types/shared.ts | 35 +- tsconfig.jest.json | 16 +- 36 files changed, 1206 insertions(+), 778 deletions(-) diff --git a/documents/ENDPOINT_ARCHITECTURE.md b/documents/ENDPOINT_ARCHITECTURE.md index d6fd3a71..637dbf85 100644 --- a/documents/ENDPOINT_ARCHITECTURE.md +++ b/documents/ENDPOINT_ARCHITECTURE.md @@ -299,7 +299,8 @@ Live Canvas OAuth routes are intentionally absent from this table until the priv | GET | `/api/courses/:courseId/flags/statistics` | Yes | Instructor | Flag counts for the course | | GET | `/api/courses/:courseId/flags/student/:userId` | Yes | **Record owner or course staff** | One student's flag history | | GET | `/api/courses/:courseId/flags/:flagId` | Yes | Instructor | Get flag report | -| PUT | `/api/courses/:courseId/flags/:flagId` | Yes | Instructor | Update flag | +| PUT | `/api/courses/:courseId/flags/:flagId` | Yes | Instructor (faculty, TA, admin) | Update flag (`unresolved` / `resolved` only; blocked when `escalated`) | +| PATCH | `/api/courses/:courseId/flags/:flagId/escalate` | Yes | Instructor (faculty, TA, admin) | Escalate unresolved manual flag to platform admins | | PATCH | `/api/courses/:courseId/flags/:flagId/response` | Yes | Instructor | Update response | `GET /flags/student/:userId` is student-facing — a student reads their own history — so it uses @@ -335,6 +336,19 @@ the server records a course-local `instructor-test` alert; the client cannot req | PATCH | `/api/admin/guided-pathway-flags/:courseId/:flagId/review` | Yes | **Admin** | Mark an escalated student alert reviewed in its owning course without deleting it; tests rejected | | POST | `/api/admin/guided-pathway-flags/:courseId/:flagId/reveal-identity` | Yes | **Admin** | Audit an escalated student-alert reveal in its owning course, then return only the current roster display name; tests rejected | +#### Manual flag escalations (platform admin) + +| Method | Path | Auth | Role | Description | +|--------|------|------|------|-------------| +| GET | `/api/admin/manual-flags` | Yes | **Admin** | Cross-course escalated manual flag queue; optional `reviewState`, period/course/date filters | +| PATCH | `/api/admin/manual-flags/:courseId/:flagId/review` | Yes | **Admin** | Mark an escalated manual flag reviewed | + +**Unified instructor Flag Management UI** merges manual flags and course-scoped Guided Pathway alerts client-side. RBAC split: TAs may list/resolve/escalate manual flags (`requireInstructorForCourseAPI`) but cannot access Guided Pathway alert APIs (`requireInstructorOrAdminForCourseAPI`). + +Automatic alerts are created when an enabled pathway with `notifyInstructorOnTrigger` wins for any **enrolled user or course staff** sender (students, TAs, faculty instructors, platform admins). TAs may trigger alerts while testing chat but still cannot list or act on GP flags in Flag Management. The stored `studentUserId` field holds the triggering user's id; admin identity reveal resolves the display name from the course roster, then `active-users` when the sender is staff not on the roster. + +**Guided Pathway category filters** (faculty/admin Flag Management UI only) are client-side: checkboxes mirror the current Pathway Library; each GP flag is classified by its persisted `pathwayId` against that library. Flags whose `pathwayId` is missing or no longer in the library appear under **Others** — never by title inference. + List and action responses use an explicit anonymous projection: `origin`, pathway/course snapshots, exact message, trigger/decision/review times, state, and staff reviewer display names. They never include a student or tester user ID, PUID, chat/request identifiers, deduplication key, or reveal @@ -667,7 +681,7 @@ and exact message before hashing it; a unique Mongo key prevents duplicate autom Before RAG, an enabled Guided Pathway may intercept the message and return its predefined response. When its independent notification setting is on, the chat route attempts to create one anonymous -alert in a separate failure boundary. An alert-write failure never blocks the predefined safety or +alert in a separate failure boundary for enrolled users and course staff senders. An alert-write failure never blocks the predefined safety or redirection response. Trigger metadata remains backend-only and is not stored on `ChatMessage` or returned to the student. diff --git a/documents/MONGO_DATA_LAYER.md b/documents/MONGO_DATA_LAYER.md index 881493bc..22cc2552 100644 --- a/documents/MONGO_DATA_LAYER.md +++ b/documents/MONGO_DATA_LAYER.md @@ -41,11 +41,12 @@ - **Chat threads** (`chat-mongo.ts` on `{courseName}_users.chats[]`) — conversation-level starring has been retired. New records and API responses omit `isPinned`; legacy embedded values are ignored on reads and may remain inert in MongoDB without a destructive migration. Optional `pinnedMessageId` continues to represent the separate message-level pin feature. - **Guided Pathway alerts** (`guided-pathway-flag-mongo.ts` + `guided-pathway-flag-collection-mongo.ts`): - Each course owns a separate collection registered in `activeCourse.collections.guidedPathwayFlags`. New registrations default to the readable `{courseName}_guided-pathway-flags` name. The stored value, not a recomputed name, remains authoritative after a course rename. GPF-001 `guided-pathway-flags-course-` names are migration sources only. Automatic alerts stay separate from `activeCourse.collections.flags` manual-flag storage and are never queried by Student Flag History or `/flags/with-names`. + - Manual `{courseName}_flags` documents use `FlagReport.status` of `unresolved`, `resolved`, or `escalated`. Escalation stores optional `escalatedAt` / `escalatedBy`; platform-admin review stores `adminReviewedAt` / `adminReviewedBy`. No migration required for existing `unresolved` / `resolved` rows. - A partial unique index on non-empty string `activeCourse.collections.guidedPathwayFlags` registrations enforces one catalog owner per physical namespace across processes. Provisioning also rejects protected names, collisions with any other registered course collection, and physical collections containing rows for another `courseId`. Generic course updates strip client-provided `collections`; only server-owned create/provision/migration paths can change registry entries. - **Startup/operation migration (GPF-002)** copies rows from both the former global `guided-pathway-flags` collection and GPF-001 hashed collections into the registered readable target. A durable `application-migrations` record with `_id: 'GPF-002'` provides a renewable cross-process lease and persisted completion state; a process-local promise only coalesces callers within one application instance. Operations await this gate until migration completion. - GPF-002 uses 200-row, `_id`-keyed, insert-only `$setOnInsert` upserts. Existing target documents are not replaced, so a newer decision, admin review, or reveal audit cannot be reverted by a stale legacy snapshot. The migration verifies target ownership and every copied `_id`, compare-and-set switches the catalog, rechecks catalog ownership, and only then deletes verified source rows. It drops only empty legacy namespaces and retains malformed/orphan data for manual recovery. See [DATA_MIGRATIONS.md](DATA_MIGRATIONS.md#gpf-002-guided-pathway-registered-collection-normalization). - Every new row has explicit `origin: 'student' | 'instructor-test'`; safe reads normalize a missing legacy origin to `student`. Student rows store the exact message, restricted `studentUserId`, opaque deduplication hash, decision/review actors and times, and append-only identity-reveal audit events. At creation, instructor-test rows omit `studentUserId` and any separate trigger-actor identity field; the trigger actor ID participates only in the opaque deduplication digest. A later dismissal may add the ordinary authorized decision-actor audit fields. PUID and raw client/chat identifiers are never stored. - - Instructor/admin list delegates use inclusion projections and map to `GuidedPathwayFlagView`; student/tester identity, deduplication data, and reveal events cannot reach normal API responses. Admin reveal first atomically appends its audit event, then resolves and returns only the current course-roster display name. Audit failure returns no name. + - Instructor/admin list delegates use inclusion projections and map to `GuidedPathwayFlagView`; student/tester identity, deduplication data, and reveal events cannot reach normal API responses. Admin reveal first atomically appends its audit event, then resolves and returns only the current course-roster display name, falling back to `active-users` when the sender is staff not on the roster. Audit failure returns no name. - A unique deduplication index makes transport retries an idempotent no-op. Additional per-course indexes cover status/date, pathway/status/date, and escalated/unreviewed admin queries. There is no TTL because completed decisions remain viewable. - Production student decisions are atomic `pending` to `escalated`/`dismissed` transitions. An instructor test is course-only and can transition from `pending` to `dismissed`; escalation, admin review, and identity reveal reject it before any mutation, audit write, or roster read. Global admin rows, totals, facets, reviewer facets, and awaiting-review counts apply a student-or-missing-origin filter, so tests never enter the global workflow. Admin review remains a soft completion marker; neither workflow hard-deletes alert rows. - Alert creation uses a provisioning resolver that may register, create, and index missing legacy-course storage. Course/admin list, count, backup, and aggregation paths use read-only resolution and do not create or index empty collections. Platform-admin listing and pending-count operations build a server-owned `$unionWith` pipeline over existing registered active-course collections; request input never supplies a physical namespace. diff --git a/documents/RESPONSIVE_DESIGN.md b/documents/RESPONSIVE_DESIGN.md index 3dfd1520..ebdd4d47 100644 --- a/documents/RESPONSIVE_DESIGN.md +++ b/documents/RESPONSIVE_DESIGN.md @@ -188,6 +188,14 @@ The student mode uses a consistent mobile header pattern across welcome screen, --- +## Instructor Flag Management + +**Flag Management** (`flag-instructor.html`, `flag-instructor.css`) uses the instructor mobile header pattern (hamburger + title + workflow nav tiles) at **768px**. + +**Filters** sit in page content, first below the header (not in the header, not a modal). Source, category, and period controls apply with Clear/Apply. Custom date fields stack to one column at ≤768px. + +--- + ## Instructor Dashboard The instructor dashboard (`dashboard-instructor.html`, `dashboard.css`) uses a two-column topbar on desktop and stacks on mobile. `.dashboard-page-header` is `position: sticky` inside `.dashboard-grid-view` (the scrollport), with opaque `var(--chat-bg)` chrome and negative margins matching grid padding so cards cannot peek beside it. @@ -210,6 +218,54 @@ Course Information was removed from the instructor sidebar footer; course code l --- +## Page shell (reusable layout) + +Generic scrollport + sticky header module: [`public/styles/page-shell.css`](public/styles/page-shell.css). Loaded from `instructor-mode.html` for instructor features today; student/admin can link the same file later without renaming classes. + +**Exceptions:** Dashboard uses `.dashboard-content-area` / `.dashboard-grid-view` (`dashboard.css`). Flags keeps its own header styles. + +### Structure + +```text +.page-frame optional full-height outer wrapper + .page-shell scrollport (1200px cap, 3rem / 1rem gutters) + .page-header optional sticky title row + bleed + (feature content) +``` + +Modifiers: `.page-shell--wide` (1680px, Writing Feedback), `.page-shell--column` + `.page-shell-body` (System Prompts editor column). + +### Tokens (on `.page-shell`, overridable per instance) + +| Token | Desktop | Mobile | +|-------|---------|--------| +| `--page-shell-max-width` | `1200px` | — | +| `--page-shell-pad-x` | `3rem` | `1rem` | +| `--page-shell-pad-bottom` | `20px` | shell uses `2rem` bottom pad | +| `--page-header-pad-top` | `1.5rem` | `1.25rem` | +| `--page-header-pad-bottom` | `1rem` | `0.75rem` | +| `--page-header-margin-bottom` | `1.75rem` | — | +| `--page-header-title-size` | `2rem` | title `1.25rem` via instructor mobile rules | +| `--page-header-title-weight` | `700` | — | + +### Sticky header (`.page-header`) + +- Bleed: `margin` / `padding` use `--page-shell-pad-x` so the title aligns with body content. +- `background: var(--chat-bg)` so scrolled content does not peek beside the sticky title. +- Enter animation: `page-header-in` (`0.45s ease-out`, ends at `transform: none` for sticky). + +Instructor hamburger styling stays in [`instructor-mode.css`](public/styles/instructor-mode.css) (`#main-content-area .page-header.mobile-header-bar`). + +### Adopting on a new page + +1. Link `/styles/page-shell.css`. +2. Wrap content: `page-frame` > `page-shell` > `page-header` + body. +3. Override tokens on `.page-shell` if needed (e.g. `--page-shell-max-width: none`). + +### `prefers-reduced-motion` + +- Header enter animation collapses to `0.01ms` in `page-shell.css`. + --- ## Marketing homepage (`/` and `/team`) diff --git a/jest.config.cjs b/jest.config.cjs index c0399c13..a2c283c6 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -2,7 +2,7 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', - roots: ['/src'], + roots: ['/src', '/public/scripts'], testMatch: ['**/__tests__/**/*.test.ts'], moduleFileExtensions: ['ts', 'js', 'json'], clearMocks: true, diff --git a/package.json b/package.json index 48aca335..636c0f0f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tlef-EngE-AI", - "version": "1.9.1", + "version": "1.11.0", "description": "", "main": "dist/server.js", "scripts": { diff --git a/public/components/assistant-prompts/assistant-prompts-instructor.html b/public/components/assistant-prompts/assistant-prompts-instructor.html index 0c7dde3a..058dbe0e 100644 --- a/public/components/assistant-prompts/assistant-prompts-instructor.html +++ b/public/components/assistant-prompts/assistant-prompts-instructor.html @@ -1,7 +1,7 @@ -
+
-
-
+
+