From bbd5590d0b236c8524f60656f1ca00c433743baa Mon Sep 17 00:00:00 2001 From: Olivier JM Maniraho Date: Sun, 19 Apr 2026 17:16:46 +0200 Subject: [PATCH 01/18] feat: client-side media swap for resource view (no full reload on related item click) - Fix DB connection: replace per-call MongoClient with global singleton pattern - Split MediaContentView into MediaViewer + MediaDetails (pure display components) - Add MediaContentPlayer client component: owns active media state, intercepts related item clicks, fetches full metadata in background, syncs URL via router.replace without page navigation - Add onSelect/activeMediaId props to RelatedMediaContentList for client-driven click interception with active item highlight - Add fetchRelatedMediaClient for client-side related media re-fetch after swap - Add docs/plans/05-database-migrations.md: versioned migration system plan - Add docs/plans/01-04: resource view, admin panel, DB model, issues triage plans --- docs/plans/01-resource-view-redesign.md | 135 +++++++++ docs/plans/02-admin-panel-improvements.md | 136 +++++++++ docs/plans/03-database-model-review.md | 218 ++++++++++++++ docs/plans/04-github-issues-triage.md | 181 ++++++++++++ docs/plans/05-database-migrations.md | 266 ++++++++++++++++++ src/app/api/lib/db/index.ts | 32 +-- src/app/library/media/[id]/page.tsx | 8 +- src/components/library/MediaContentPlayer.tsx | 57 ++++ src/components/library/MediaDetails.tsx | 90 ++++++ .../{MediaContentView.tsx => MediaViewer.tsx} | 108 +------ .../library/RelatedMediaContentList.tsx | 74 ++++- src/fetchers/library/fetchRelatedMedia.tsx | 21 ++ 12 files changed, 1183 insertions(+), 143 deletions(-) create mode 100644 docs/plans/01-resource-view-redesign.md create mode 100644 docs/plans/02-admin-panel-improvements.md create mode 100644 docs/plans/03-database-model-review.md create mode 100644 docs/plans/04-github-issues-triage.md create mode 100644 docs/plans/05-database-migrations.md create mode 100644 src/components/library/MediaContentPlayer.tsx create mode 100644 src/components/library/MediaDetails.tsx rename src/components/library/{MediaContentView.tsx => MediaViewer.tsx} (50%) diff --git a/docs/plans/01-resource-view-redesign.md b/docs/plans/01-resource-view-redesign.md new file mode 100644 index 00000000..43a6f319 --- /dev/null +++ b/docs/plans/01-resource-view-redesign.md @@ -0,0 +1,135 @@ +# Plan: Resource View Page Redesign (YouTube-style, no full reload) + +**Closes:** #437 +**Goal:** Clicking a related media item updates only the player/details section — no full page navigation. URL stays in sync for shareability. Layout (sidebar, related list) stays mounted. + +--- + +## Problem + +`/library/media/[id]/page.tsx` is a Next.js **server component**. `RelatedMediaContentList` uses `` which triggers a full server-side render and page transition. The skeleton flash is visible even with App Router soft navigation because the entire route re-fetches and remounts. + +--- + +## Architecture + +Convert the media view page into a **client-driven experience**: + +1. Server page component (`page.tsx`) still handles initial load (SSR, metadata, SEO) +2. A new client component `MediaContentPlayer` takes initial data as props and owns all subsequent state +3. Related item clicks fire a client-side fetch (SWR) and swap state — no navigation +4. URL is updated via `router.replace` (no scroll, no history entry) so deep links still work +5. The related list itself re-fetches when the active media changes + +--- + +## Files to Change + +### 1. `src/app/library/media/[id]/page.tsx` +- Keep server component, keep SSR + metadata generation +- Pass `initialMediaContent` and `initialRelatedMedia` to new client component + +### 2. `src/components/library/MediaContentPlayer.tsx` (NEW) +``` +'use client' +- Accept: initialMediaContent: T_RawMediaContentFields, initialRelatedMedia: T_RawMediaContentFields[] | null +- State: activeMedia (starts from initialMediaContent), relatedMedia (starts from initialRelatedMedia) +- On related item click: + a. Immediately set activeMedia to clicked item (instant render, no flash) + b. Background-fetch full metadata for clicked item via /api/media-content?mediaContentId=...&withMetaData=true + c. Once fetched, replace activeMedia with full data + d. Re-fetch related media for new active item + e. Call router.replace(`/library/media/${item._id}`, { scroll: false }) to update URL +- Renders: MediaViewer + MediaDetails (below player) + RelatedMediaContentList (side) +``` + +### 3. `src/components/library/MediaContentView.tsx` +- Split into two smaller components: + - `MediaViewer` — renders the actual file (video/pdf/image/iframe) + - `MediaDetails` — title, metadata tags, reaction buttons, view count +- Both accept `mediaContent` prop and are pure display components +- Remove state/effects from here — move all to `MediaContentPlayer` + +### 4. `src/components/library/RelatedMediaContentList.tsx` +- Add `onSelect?: (item: T_RawMediaContentFields) => void` prop +- When `onSelect` is provided, intercept click (prevent default navigation), call `onSelect(item)` +- When not provided, behave as before (plain ``) +- Add visual indicator for currently active item (highlight/bold) + +### 5. `src/fetchers/library/fetchRelatedMedia.tsx` +- Keep server-side version for SSR +- Add client-side helper: `fetchRelatedMediaClient(mediaContent)` — same logic, uses `fetch()` directly, no `cache()` + +--- + +## Data Flow + +``` +Initial load (SSR): + page.tsx → getMediaContent(id) → fetchRelatedMedia(media) → MediaContentPlayer (props) + +On related item click (client): + click → setActiveMedia(item) [instant] + → fetch /api/media-content?...&withMetaData=true [background] + → setActiveMedia(fullData) [once ready] + → fetch related for new item [background] + → router.replace(new URL) [URL sync] +``` + +--- + +## Performance Improvements in This Feature + +- **No skeleton flash** — related item click instantly shows media name + basic info from list data while full metadata loads +- **Preload thumbnails** — add `loading="eager"` on visible thumbnails in related list +- **View counter debounce** — already has 45s delay, keep it; make sure timer resets on media swap +- **Aggregation pipeline** (`p_fetchMediaContentWithMetaData`) — currently default `limit: 1000` even for single-item fetch. For `fetchMediaContentById_`, pass `limit: 1` to the pipeline instead of slicing post-fetch. +- **DB connection** — `dbClient()` creates a new `MongoClient` on every call in production. Fix: reuse the `mongoClientPromise` pattern (already used in dev mode) in production too. One client per process. + +--- + +## URL / SEO Considerations + +- On initial load: full SSR with correct metadata (title, thumbnail, description) — works today +- On client navigation: `router.replace` updates URL without triggering SSR — no metadata update, but that's acceptable (same tradeoff YouTube makes) +- For bots/crawlers: always hits SSR path → correct OG tags + +--- + +## Steps + +1. Create `MediaViewer` component (extract from `MediaContentView.tsx`) +2. Create `MediaDetails` component (extract from `MediaContentView.tsx`) +3. Add `onSelect` prop to `RelatedMediaContentList` +4. Add `fetchRelatedMediaClient` to `src/fetchers/library/fetchRelatedMedia.tsx` +5. Create `MediaContentPlayer` client component +6. Update `page.tsx` to render `MediaContentPlayer` instead of `MediaContentView` +7. Delete `MediaContentView.tsx` (now split into player + viewer + details) +8. Fix `dbClient()` connection reuse bug (production creates new client per request) +9. Fix `p_fetchMediaContentWithMetaData` — pass `limit: 1` when fetching by ID +10. Verify: click related item → no skeleton, URL updates, browser back works + +--- + +## General Performance Issues (Fix alongside this) + +### DB Connection (`src/app/api/lib/db/index.ts`) +Current production code creates a new `MongoClient` on every `dbClient()` call: +```ts +// BROKEN in production: +client = new MongoClient(uri, options); +mongoClientPromise = client.connect(); // new connection every call! +``` +Fix: apply the same `global._mongoClientPromise` pattern for production, or use a module-level singleton. + +### In-memory cache (`src/app/api/media-content/index.ts`) +The `cache: Record` object is process-local. On serverless (Vercel), each cold start loses it. Use Next.js `unstable_cache` or route-level `revalidate` (already set to 360s on the page) instead of rolling your own. + +### Pipeline default limit +`p_fetchMediaContentWithMetaData` defaults `limit: 1000` — fine for list views, bad for single-item lookups. Always pass explicit limit. + +### MongoDB indexes to add (via migration) +- `media_content`: `{ grade_id: 1 }`, `{ subject_id: 1 }`, `{ unit_id: 1 }`, `{ topic_id: 1 }`, `{ name: 1 }` +- `users`: `{ email: 1 }` (unique) +- `user_role_mappings`: `{ user_id: 1 }` (unique) +- `media_reactions`: `{ media_content_id: 1, user_id: 1 }` (unique compound) diff --git a/docs/plans/02-admin-panel-improvements.md b/docs/plans/02-admin-panel-improvements.md new file mode 100644 index 00000000..ddf26ae5 --- /dev/null +++ b/docs/plans/02-admin-panel-improvements.md @@ -0,0 +1,136 @@ +# Plan: Admin Panel Improvements + +**Goal:** Make the admin panel functional, well-organized, and ready for institution-scoped management. Focus on real missing functionality, not cosmetic changes. + +--- + +## Current State + +The admin panel provides basic CRUD for: Schools, Programs, Courses, Units, Topics, Subjects, Grades, Media Content, Users, Roles, Settings (pages, page actions), and Institutions (page exists, likely not in nav). + +### Identified Gaps + +| Gap | Impact | +|-----|--------| +| Role permissions not enforced (content-manager can't manage content) | #412 | +| User update fails with 500 | #427 | +| No institution management in sidebar nav | Institutions feature blocked | +| Stats dashboard shows counts only — no trends, no recent activity | Low insight | +| No pagination UI on list pages | All data loaded at once | +| No search/filter in admin tables | Hard to find items at scale | +| No audit log | No accountability | +| Settings page incomplete | #119 | +| No setup/onboarding flow | #380 | +| `withMetaData` flag inconsistently used | Extra DB load | + +--- + +## Priority 1: Fix Broken Things + +### 1a. Fix user update 500 error (#427) +**File:** `src/app/api/users/edit.ts` (line in `edit.ts`) +**Likely cause:** `zfd.formData()` parsing a JSON body. `zfd` expects `FormData`; the handler uses `request.json()` then passes to `zfd.formData().parse()`. Mismatch causes a parse error → uncaught → 500. +**Fix:** Use `z.object()` instead of `zfd.formData()` for JSON body parsing, or parse raw JSON and validate with plain zod. + +### 1b. Enforce content-manager role (#412) +**Current state:** `withAuthorization(RootLayout, { requireAdmin: true })` — any non-admin is blocked from admin panel. +**Fix:** +- Extend `withAuthorization` HOC to accept `{ requireAdmin?: boolean; allowedRoles?: string[] }` +- Route-level: admin-destructive pages (delete users, manage roles) require `Admin` role +- Content pages (media-content, units, topics) also allow `Content Manager` +- `src/app/admin/layout.tsx` stays `requireAdmin` (broad gate) +- Individual pages that content managers should access use a more permissive check +- Add role check in sidebar — hide nav items the user can't access + +### 1c. Add Institutions to admin sidebar nav +**File:** `src/hooks/useNavigation/constants.ts` (or wherever admin menu items are defined) +Add Institutions entry pointing to `/admin/institutions` + +--- + +## Priority 2: Usability + +### 2a. Pagination in admin tables +- All list pages currently fetch with `limit`/`skip` but no UI pagination +- Add a `Pagination` component using flowbite-react `Pagination` +- Wire to existing `limit`/`skip` query params in each list hook +- Start with: Users, Media Content (largest tables) + +### 2b. Search/filter in admin tables +- Add a debounced search input to `AdminTable` component +- Uses existing `findByName` endpoints (already exist for media content, users) +- Standardize the `findByName` pattern across all entities that lack it (programs, courses, etc.) + +### 2c. Admin dashboard improvements +- Add recent uploads (last 5 media items with links) +- Add recent user registrations (last 5 users) +- Add quick action cards: "Upload content", "Add user", "Create course" +- Keep existing stat count cards + +### 2d. Bulk operations +- `AdminTable` already has selection checkboxes — extend to support bulk status changes +- Add "bulk publish/unpublish" for media content (requires `is_visible` field on `media_content`) + +--- + +## Priority 3: Completeness + +### 3a. Settings page (#119) +The settings page (`/admin/settings`) exists but is minimal. Build: +- **Site settings:** site name, logo, contact email (stored in `settings` collection) +- **Feature flags:** enable/disable features (search, reactions, bookmarks) +- **Default role:** which role to assign to new users +- All settings read/written via `/api/config` endpoint (already exists, check `src/app/api/config`) + +### 3b. Setup/onboarding page (#380) +First-run wizard for new deployments: +1. Create admin account +2. Set site name +3. Create first institution +4. Seed default roles (migration already exists: `addDefaultRoles`) +5. Upload first content +Show checklist on dashboard until all steps complete. Track completion in `settings` collection key `setup_complete`. + +### 3c. Audit log +- New collection: `audit_log` — fields: `action`, `entity`, `entity_id`, `actor_id`, `timestamp`, `metadata` +- Write log entry after every create/update/delete in API handlers +- Add `/admin/audit` page showing filterable log + +--- + +## File Map + +``` +src/ + app/ + admin/ + institutions/page.tsx — already exists, wire up + audit/page.tsx — NEW + settings/page.tsx — expand + components/ + admin/ + AdminTable/AdminTable.tsx — add pagination + search + AdminTable/Pagination.tsx — NEW + institutions/ — NEW (list, create, edit views) + hooks/ + useNavigation/constants.ts — add Institutions nav item + useInstitution/ — already exists, verify completeness + app/ + api/ + audit/route.ts — NEW + users/edit.ts — fix zfd/JSON mismatch +``` + +--- + +## Steps (in order) + +1. Fix user update 500 (`src/app/api/users/edit.ts`) +2. Add Institutions to sidebar nav +3. Fix role enforcement — extend `withAuthorization` + hide inaccessible nav items +4. Add pagination to `AdminTable` + wire to Users and Media Content pages +5. Add search input to `AdminTable` +6. Complete Settings page (site config, feature flags) +7. Build setup/onboarding wizard +8. Add audit log (collection + API + admin page) +9. Improve dashboard with recent activity diff --git a/docs/plans/03-database-model-review.md b/docs/plans/03-database-model-review.md new file mode 100644 index 00000000..f28c319f --- /dev/null +++ b/docs/plans/03-database-model-review.md @@ -0,0 +1,218 @@ +# Plan: Database Model Review + Institution Readiness + +**Goal:** Fix schema bugs, standardize conventions, add missing indexes, and prepare all tables for institution-scoped multi-tenancy without breaking current functionality. + +--- + +## Current Collections + +| Collection | Status | +|---|---| +| `users` | Missing `institution_id`, field naming inconsistent | +| `schools` | Legacy — being migrated to `institutions` | +| `institutions` | New — schema looks good, not fully wired | +| `programs` | Has `school_id` — should get `institution_id` | +| `courses` | Has `institution_id` in TS but `institutions_id` in schema (typo) | +| `units` | No institution link | +| `subjects` | No institution link | +| `grades` | No institution link | +| `topics` | No institution link | +| `media_content` | No `institution_id`, no `is_visible` flag | +| `user_roles` | `permission_ids` typed as `user_permissions[]` but should be `ObjectId[]` | +| `user_role_mappings` | Good — bridges users and roles | +| `user_permissions` | Exists but not enforced anywhere | +| `settings` | Typo: `upated_at` instead of `updated_at` | +| `preferences` | `updated_by_id` typed as `Date` instead of `ObjectId` | +| `bookmarks` | Schema exists — not wired to any UI | +| `media_reactions` | Good | +| `page_views` | Good | +| `searches` | Good | +| `content_categories` | In collections, no schema defined | +| `feed_back` | All fields optional including `_id` — risky | +| `languages` | All optional — should have required `name` | +| `page_links` | Uses `page-links` (hyphen) in DB name — inconsistent with `page_actions` using `page-actions` | + +--- + +## Bug Fixes (do now) + +### 1. `settings` schema typo +```ts +// src/app/api/lib/db/schema.ts line ~244 +upated_at: 'date?', // BUG +updated_at: 'date?', // FIX +``` +Also fix the TypeScript type: `upated_at?: Date` → `updated_at?: Date` + +### 2. `preferences` wrong type +```ts +updated_by_id?: Date; // BUG — should be ObjectId +updated_by_id?: ObjectId; // FIX +``` + +### 3. `courses` schema inconsistency +TypeScript type has `institution_id?: ObjectId` but Realm schema has `institutions_id: 'objectId?'` +```ts +// Fix schema key name: +institution_id: 'objectId?', // not institutions_id +``` + +### 4. `user_roles.permission_ids` type +Currently typed `permission_ids: user_permissions[]` — embeds full objects. +In MongoDB this is stored as references (ObjectIds). Fix the TS type: +```ts +permission_ids: ObjectId[]; // references, not embedded +``` + +### 5. `feed_back` — `_id` should be required +```ts +_id: ObjectId; // not optional +``` + +### 6. `p_fetchRandomMediaContent` — unprotected `$unwind` +In `pipelines.ts`, the `$unwind: '$user'` has no `preserveNullAndEmptyArrays: true`. Any media without a `created_by_id` user will be silently dropped from random results. Add `preserveNullAndEmptyArrays: true`. + +--- + +## Institution Readiness (schema additions — non-breaking) + +### Strategy +- Add `institution_id` as **optional** field to all content tables +- When `institution_id` is null/absent → global content (visible to all) +- When set → content scoped to that institution only +- This is the simplest approach: additive, backward-compatible, no migration needed for existing data + +### Fields to add + +| Collection | Field to add | +|---|---| +| `media_content` | `institution_id?: ObjectId` | +| `programs` | `institution_id?: ObjectId` (prefer over `school_id` long term) | +| `courses` | already has it (fix typo first) | +| `units` | `institution_id?: ObjectId` | +| `subjects` | `institution_id?: ObjectId` | +| `grades` | `institution_id?: ObjectId` | +| `topics` | `institution_id?: ObjectId` | +| `users` | `institution_id?: ObjectId` (primary affiliation) | + +### New collection: `institution_memberships` +For users belonging to multiple institutions: +```ts +type institution_memberships = { + _id: ObjectId; + user_id: ObjectId; + institution_id: ObjectId; + role: 'admin' | 'member'; // institution-level role + joined_at: Date; + invited_by_id?: ObjectId; +} +``` +Index: `{ user_id: 1, institution_id: 1 }` unique compound + +### New collection: `institution_invites` +For invite-based onboarding: +```ts +type institution_invites = { + _id: ObjectId; + institution_id: ObjectId; + email: string; + token: string; // hashed invite token + invited_by_id: ObjectId; + expires_at: Date; + used_at?: Date; +} +``` +Index: `{ token: 1 }` unique, `{ email: 1, institution_id: 1 }` + +--- + +## MongoDB Indexes to Add + +Add a migration file: `src/app/api/lib/db/migrations/addIndexes.ts` + +```ts +// media_content +db.collection('media_content').createIndex({ grade_id: 1 }) +db.collection('media_content').createIndex({ subject_id: 1 }) +db.collection('media_content').createIndex({ unit_id: 1 }) +db.collection('media_content').createIndex({ topic_id: 1 }) +db.collection('media_content').createIndex({ institution_id: 1 }) +db.collection('media_content').createIndex({ name: 'text' }) // for text search + +// users +db.collection('users').createIndex({ email: 1 }, { unique: true }) +db.collection('users').createIndex({ institution_id: 1 }) + +// user_role_mappings +db.collection('user-role-mappings').createIndex({ user_id: 1 }, { unique: true }) + +// media_reactions +db.collection('media_reactions').createIndex({ media_content_id: 1, user_id: 1 }, { unique: true }) + +// institution_memberships +db.collection('institution_memberships').createIndex({ user_id: 1, institution_id: 1 }, { unique: true }) +db.collection('institution_memberships').createIndex({ institution_id: 1 }) + +// institution_invites +db.collection('institution_invites').createIndex({ token: 1 }, { unique: true }) +db.collection('institution_invites').createIndex({ email: 1, institution_id: 1 }) + +// programs / courses +db.collection('programs').createIndex({ institution_id: 1 }) +db.collection('courses').createIndex({ institution_id: 1 }) +``` + +--- + +## DB Connection Fix (`src/app/api/lib/db/index.ts`) + +Current production code creates a fresh `MongoClient` on every `dbClient()` call. Fix: + +```ts +// Use module-level singleton for production too +if (!global._mongoClientPromise) { + const client = new MongoClient(uri, options); + global._mongoClientPromise = client.connect(); +} +const mongoClientPromise = global._mongoClientPromise; + +export const dbClient = async () => { + const client = await mongoClientPromise; + return client.db(process.env.MONGODB_DB); +}; +``` + +Remove the try/catch that returns `null` from `dbClient()` — callers already handle null, but a real connection error should throw/surface, not silently return null and cause confusing downstream errors. + +--- + +## TypeScript Types to Update + +**`src/types/media-content/index.ts`:** +Add `institution_id?: string` and `external_url?: string` to `T_RawMediaContentFields` + +**`src/app/api/lib/db/schema.ts`:** +- Fix all bugs listed above +- Add `institution_id` to relevant schemas +- Add `institution_memberships` and `institution_invites` schemas + +**`src/app/api/lib/db/collections.ts`:** +- Add `institution_memberships` and `institution_invites` entries +- Fix `page_links` label (currently says "Media Content" — copy-paste bug) + +**`src/app/api/lib/db/types.ts`:** +- Add `institution_memberships` and `institution_invites` to `T_dbCollection` + +--- + +## Steps (in order) + +1. Fix schema bugs (settings typo, preferences type, courses inconsistency, user_roles type, feed_back _id) +2. Fix `p_fetchRandomMediaContent` unwind bug +3. Fix DB connection singleton +4. Add `institution_id` (optional) to all content collections' TS types and schemas +5. Add `institution_memberships` and `institution_invites` schemas + collections +6. Write `addIndexes` migration +7. Update `T_dbCollection` type and `dbCollections` object +8. Fix `page_links` label copy-paste bug in collections +9. Update API query handlers to filter by `institution_id` when present in session (future step — just wire the optional filter for now) diff --git a/docs/plans/04-github-issues-triage.md b/docs/plans/04-github-issues-triage.md new file mode 100644 index 00000000..b7eab17e --- /dev/null +++ b/docs/plans/04-github-issues-triage.md @@ -0,0 +1,181 @@ +# Plan: GitHub Issues Triage + +**Repo:** SparkEdUAB/sparked-next +**Date reviewed:** 2026-04-19 + +--- + +## Issues to Fix + +### #437 — Improve Resource view layout +**Status:** Fix +**Plan:** See `01-resource-view-redesign.md`. Convert to client-driven media swap, no full reload on related item click. This is the most impactful UX fix. + +--- + +### #430 — Replace eslint and fix all lint errors & warnings +**Status:** Fix +**What to do:** +- Project already uses `oxlint` (`"lint": "npx oxlint --fix"` in package.json) +- ESLint 8.57.1 is still installed as a dependency — remove it and its config once oxlint covers all cases +- Run `npx oxlint --fix` across the codebase +- Fix remaining warnings manually (unused vars, missing deps in useEffect, etc.) +- Remove `eslint`, `eslint-config-next` from `package.json` after confirming oxlint handles everything +- Note: `eslint-config-next` has Next.js-specific rules (no-img-element, etc.) — ensure oxlint or a custom rule covers these + +--- + +### #427 — Error 500 when updating a user from the dashboard +**Status:** Fix +**Root cause:** `src/app/api/users/edit.ts` uses `zfd.formData()` (which parses FormData) but the handler does `request.json()` and passes JSON object to the zfd schema. This causes a parse failure → unhandled → 500. +**Fix:** Replace `zfd.formData({ ... })` with `z.object({ ... })` and validate with `schema.parse(formBody)` directly. + +--- + +### #412 — Revisit user roles (content-manager) +**Status:** Fix +**Plan:** See `02-admin-panel-improvements.md` section 1b. Extend `withAuthorization` HOC to support `allowedRoles`. Content Manager role allows CRUD on media, units, topics, subjects, grades, programs — but not user management or role management. Default roles already seeded via `addDefaultRoles` migration. + +--- + +### #411 — Login not working as expected in production +**Status:** Fix +**What to do:** +- Issue: login redirects to URL with params instead of completing login flow +- Investigate `src/app/api/authentication` and `next-auth` config +- Likely cause: `callbackUrl` is being set to the login page URL with query params, creating a redirect loop +- Check `pages.signIn` config and `redirect` callback in NextAuth options +- Ensure `NEXTAUTH_URL` env var is set correctly in production +- Check if `withAuthorization` HOC's redirect logic passes the full URL (including search params) as `callbackUrl` — if so, strip search params before redirecting + +--- + +### #382 — Provide list of existing higher level institutions on login +**Status:** Fix (partially, when institution feature is built) +**What to do:** +- On login/signup page, add institution selector dropdown +- Fetch institutions list via public endpoint `/api/institution?limit=50` +- User selects their institution during registration → stored as `institution_id` on user +- Gate: only show if institutions exist (skip for solo deployments) +- Blocked by: institution management being complete (#381 foundation) + +--- + +### #381 — Every institution should depend on a higher level organisation +**Status:** Keep open — future roadmap +**Context:** This is about institutional hierarchy (e.g., school → district → ministry). Not needed for v1 of institution support. The current `institutions` schema supports a flat model. Add `parent_institution_id?: ObjectId` to `institutions` schema as optional now so the field exists when needed. + +--- + +### #380 — Add setup page +**Status:** Fix +**Plan:** See `02-admin-panel-improvements.md` section 3b. First-run wizard: create admin, set site name, create institution, seed roles, upload first content. + +--- + +### #378 — Serve resources based on the institution +**Status:** Fix (when institution_id added to content) +**What to do:** +- After `03-database-model-review.md` adds `institution_id` to content tables: +- API handlers for `fetchMediaContent_` add optional filter: if user's session has `institution_id`, add `{ institution_id: new BSON.ObjectId(session.institution_id) }` to query (or include null/absent docs as global content) +- Library layout reads institution context from session +- No UI changes needed — filtering is transparent + +--- + +### #331 — Add more actions to content view +**Status:** Fix +**What to do:** +- Download button (if file_url is a direct file) +- Share button (copy link to clipboard) +- Report button (submits to `feed_back` collection) +- Bookmark button (schema already exists in `bookmarks` collection — not wired yet → see #329) +- Add these to `MediaDetails` component (see resource view plan) + +--- + +### #329 — Save reading materials for later access (bookmarks) +**Status:** Fix +**What to do:** +- `bookmarks` collection already defined in schema +- Add `POST /api/bookmarks` — create bookmark for `resource_id` + `user_id` +- Add `GET /api/bookmarks` — fetch user's bookmarks +- Add `DELETE /api/bookmarks/:id` +- Add bookmark icon button to `MediaDetails` (filled = bookmarked, outline = not) +- Add `/library/bookmarks` page listing saved items +- Auth required (hide button if not logged in) + +--- + +### #168 — Setup tests (unit & e2e) +**Status:** Fix +**What to do:** +- Vitest is already configured (`vitest.config.ts`, `vitest.setup.tsx`) — write unit tests +- Playwright is configured (`playwright.config.ts`) — write e2e tests +- Priority unit tests: + - `determineFileType` helper + - `fetchRelatedMedia` fetcher (mock fetch) + - `MediaContentPlayer` component (test state updates on related item click) + - API handlers (mock `dbClient`) +- Priority e2e tests: + - Library page loads and lists content + - Click media item → view page loads + - Click related item → content swaps without full reload + - Admin login flow + - Create media content flow + +--- + +### #119 — Settings page for configuring different things +**Status:** Fix +**Plan:** See `02-admin-panel-improvements.md` section 3a. + +--- + +### #72 — Implement a global search +**Status:** Fix +**What to do:** +- Search UI already exists at `/library/search` with `SearchMediaContentList` +- Backend `findMediaContentByName_` exists +- Gap: search is only in library, not global (doesn't search programs, courses, etc.) +- Fix: extend search to include content hierarchy items +- Add MongoDB text index on `media_content.name` and `media_content.description` +- Add keyboard shortcut (Cmd+K / Ctrl+K) to open search from anywhere +- Debounce already implemented via `use-debounce` hook + +--- + +## Issues to Close + +### #335 — Add automated PDF summary & automated quiz +**Status:** Close (wontfix label already applied) +**Reason:** Out of scope for current project focus. AI summarization requires external service integration. Mark as `wontfix` and close. + +### #330 — Track pages read in a PDF per user +**Status:** Close (wontfix label already applied) +**Reason:** High complexity (browser PDF rendering events are unreliable), low ROI, wontfix label already applied. + +--- + +## Issues to Keep Open (no action now) + +### #130 — Dependency Dashboard +**Status:** Keep open +**Reason:** Automated Renovate bot issue. Self-manages. Do not close. + +--- + +## Recommended Fix Order + +1. #427 — User update 500 (quick bug fix, high impact) +2. #411 — Login redirect bug (production blocker) +3. #437 — Resource view layout (main UX improvement) +4. #412 — User roles enforcement +5. #380 — Setup page +6. #329 — Bookmarks +7. #331 — Content view actions (share, download, report, bookmark) +8. #382 — Institution selector on login +9. #72 — Global search improvements +10. #119 — Settings page +11. #430 — ESLint → oxlint migration +12. #168 — Tests diff --git a/docs/plans/05-database-migrations.md b/docs/plans/05-database-migrations.md new file mode 100644 index 00000000..62ccbaa4 --- /dev/null +++ b/docs/plans/05-database-migrations.md @@ -0,0 +1,266 @@ +# Plan: Database Migration System + +**Goal:** Replace the ad-hoc migration approach (single `addDefaultRoles.ts` run at startup) with a versioned, trackable migration system that is safe to run in production, idempotent, and easy to extend. + +--- + +## Current State + +- One migration file: `src/app/api/lib/db/migrations/addDefaultRoles.ts` +- Invoked in `src/app/api/lib/db/init.ts` via `initializeDatabase()` — called at app startup +- No tracking: no record of which migrations have run, no ordering, no rollback +- If more migrations are added to `initializeDatabase()`, they all run every startup (even if already applied) — only idempotency guards prevent damage +- No CLI tool to run migrations manually or check status + +--- + +## Design + +### Migration Record Collection + +Add a `_migrations` collection to MongoDB. Each applied migration gets one document: + +```ts +type MigrationRecord = { + _id: ObjectId; + name: string; // e.g. "001-add-default-roles" + applied_at: Date; + duration_ms: number; +} +``` + +Index: `{ name: 1 }` unique — prevents double-apply at the DB level. + +### Migration File Convention + +Each migration is a `.ts` file in `src/app/api/lib/db/migrations/`: + +``` +migrations/ + 001-add-default-roles.ts + 002-add-indexes.ts + 003-fix-schema-typos.ts + 004-add-institution-id-fields.ts + 005-add-institution-collections.ts +``` + +Naming: `NNN-kebab-description.ts` where `NNN` is zero-padded integer. Migrations run in filename order. + +Each file exports one async function named `up`: + +```ts +// migrations/002-add-indexes.ts +import { Db } from 'mongodb'; + +export async function up(db: Db): Promise { + await db.collection('media_content').createIndex({ grade_id: 1 }); + // ... more indexes +} + +export const name = '002-add-indexes'; +``` + +### Migration Runner + +`src/app/api/lib/db/migrate.ts` — core runner: + +```ts +import { Db } from 'mongodb'; + +export async function runMigrations(db: Db): Promise { + // 1. Ensure _migrations collection + unique index exist + await db.collection('_migrations').createIndex({ name: 1 }, { unique: true }); + + // 2. Discover all migration modules (statically imported registry) + const migrations = getMigrationRegistry(); // see below + + // 3. For each migration in order: + for (const migration of migrations) { + const already = await db.collection('_migrations').findOne({ name: migration.name }); + if (already) continue; + + const start = Date.now(); + await migration.up(db); + await db.collection('_migrations').insertOne({ + name: migration.name, + applied_at: new Date(), + duration_ms: Date.now() - start, + }); + + console.log(`[migrate] Applied: ${migration.name}`); + } +} +``` + +### Migration Registry + +Since Next.js/Node doesn't support dynamic `fs.readdir` in edge/serverless, use a static registry: + +```ts +// src/app/api/lib/db/migrations/index.ts +import { up as up001, name as name001 } from './001-add-default-roles'; +import { up as up002, name as name002 } from './002-add-indexes'; +// ... add new migrations here + +export const migrations = [ + { name: name001, up: up001 }, + { name: name002, up: up002 }, +]; +``` + +Adding a migration = create the file + add one line to the registry. No magic, no filesystem scanning. + +### Startup Integration + +Update `src/app/api/lib/db/init.ts`: + +```ts +import { dbClient } from '.'; +import { runMigrations } from './migrate'; + +export async function initializeDatabase() { + const db = await dbClient(); + if (!db) return; + await runMigrations(db); +} +``` + +`initializeDatabase()` is already called at startup — no change needed at call site. + +### CLI Script + +Add `src/scripts/migrate.ts` for manual runs (useful for production deploys, debugging): + +```ts +// Run with: npx tsx src/scripts/migrate.ts +import { MongoClient } from 'mongodb'; +import { runMigrations } from '../app/api/lib/db/migrate'; + +async function main() { + const client = new MongoClient(process.env.MONGODB_URI!); + await client.connect(); + const db = client.db(process.env.MONGODB_DB); + await runMigrations(db); + await client.close(); + console.log('[migrate] Done.'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); +``` + +Add to `package.json`: +```json +"migrate": "dotenv -e .env.local -- tsx src/scripts/migrate.ts" +``` + +--- + +## Migrations to Write (from Plan 03) + +### 001-add-default-roles.ts +Rename/refactor `addDefaultRoles.ts` → `001-add-default-roles.ts`. Export `up(db)` + `name`. + +### 002-add-indexes.ts +```ts +export async function up(db: Db) { + // media_content + await db.collection('media_content').createIndex({ grade_id: 1 }); + await db.collection('media_content').createIndex({ subject_id: 1 }); + await db.collection('media_content').createIndex({ unit_id: 1 }); + await db.collection('media_content').createIndex({ topic_id: 1 }); + await db.collection('media_content').createIndex({ name: 'text' }); + + // users + await db.collection('users').createIndex({ email: 1 }, { unique: true }); + + // user_role_mappings + await db.collection('user-role-mappings').createIndex({ user_id: 1 }, { unique: true }); + + // media_reactions + await db.collection('media_reactions').createIndex( + { media_content_id: 1, user_id: 1 }, + { unique: true } + ); +} +export const name = '002-add-indexes'; +``` + +### 003-fix-schema-typos.ts +MongoDB document fixes (rename fields on existing documents): +```ts +export async function up(db: Db) { + // Fix settings.upated_at → updated_at + await db.collection('settings').updateMany( + { upated_at: { $exists: true } }, + [{ $set: { updated_at: '$upated_at' } }, { $unset: 'upated_at' }] + ); + + // Fix courses.institutions_id → institution_id + await db.collection('courses').updateMany( + { institutions_id: { $exists: true } }, + [{ $set: { institution_id: '$institutions_id' } }, { $unset: 'institutions_id' }] + ); +} +export const name = '003-fix-schema-typos'; +``` + +### 004-add-institution-id-fields.ts +Adds `institution_id` index to content collections once the field starts being populated: +```ts +export async function up(db: Db) { + for (const col of ['programs', 'courses', 'units', 'subjects', 'grades', 'topics', 'media_content', 'users']) { + await db.collection(col).createIndex({ institution_id: 1 }); + } +} +export const name = '004-add-institution-id-fields'; +``` + +### 005-add-institution-collections.ts +Create `institution_memberships` and `institution_invites` with their indexes: +```ts +export async function up(db: Db) { + await db.createCollection('institution_memberships'); + await db.collection('institution_memberships').createIndex( + { user_id: 1, institution_id: 1 }, + { unique: true } + ); + await db.collection('institution_memberships').createIndex({ institution_id: 1 }); + + await db.createCollection('institution_invites'); + await db.collection('institution_invites').createIndex({ token: 1 }, { unique: true }); + await db.collection('institution_invites').createIndex({ email: 1, institution_id: 1 }); +} +export const name = '005-add-institution-collections'; +``` + +--- + +## Steps (in order) + +1. Update `dbClient()` to use singleton (Plan 03 prerequisite — needed so runner uses same connection) +2. Create `src/app/api/lib/db/migrate.ts` (runner) +3. Create `src/app/api/lib/db/migrations/index.ts` (registry) +4. Rename `addDefaultRoles.ts` → `001-add-default-roles.ts`, add `up(db)` export + `name` +5. Update `init.ts` to call `runMigrations(db)` instead of `addDefaultRoles()` +6. Write `002-add-indexes.ts` +7. Write `003-fix-schema-typos.ts` +8. Write `004-add-institution-id-fields.ts` +9. Write `005-add-institution-collections.ts` +10. Add all 5 to registry in `migrations/index.ts` +11. Add `migrate` script to `package.json` +12. Write `src/scripts/migrate.ts` +13. Test: run `npm run migrate` locally — confirm `_migrations` collection populated, all 5 applied once, second run is a no-op + +--- + +## Key Properties + +- **Idempotent**: `_migrations` unique index + existence check = safe to run multiple times +- **Ordered**: static registry preserves insertion order +- **Fast startup**: skip already-applied migrations with a single `findOne` +- **No framework dependency**: plain MongoDB driver, no mongoose/prisma +- **No rollback**: MongoDB schema changes are largely additive — rollback files add complexity with little benefit for this stack. Revert via a new forward migration if needed. +- **No env drift**: `npm run migrate` CLI + startup auto-run = same code path either way diff --git a/src/app/api/lib/db/index.ts b/src/app/api/lib/db/index.ts index 0dbfed7a..533f96d8 100644 --- a/src/app/api/lib/db/index.ts +++ b/src/app/api/lib/db/index.ts @@ -7,31 +7,21 @@ if (!process.env.MONGODB_URI) { const uri = process.env.MONGODB_URI || "mongodb://...."; // Just to allow the build to pass const options = {}; -let client: MongoClient; -let mongoClientPromise: Promise; +declare global { + // eslint-disable-next-line no-var + var _mongoClientPromise: Promise | undefined; +} -if (process.env.NODE_ENV === "development") { - //@ts-ignore - if (!global._mongomongoClientPromise) { - client = new MongoClient(uri, options); - //@ts-ignore - global._mongomongoClientPromise = client.connect(); - } - //@ts-ignore - mongoClientPromise = global._mongomongoClientPromise; -} else { - client = new MongoClient(uri, options); - mongoClientPromise = client.connect(); +if (!global._mongoClientPromise) { + const client = new MongoClient(uri, options); + global._mongoClientPromise = client.connect(); } +const mongoClientPromise = global._mongoClientPromise; + export const dbClient = async () => { - try { - const mongoClient = new MongoClient(uri, options); - const dbConnection = await mongoClient.connect(); - return dbConnection.db(process.env.MONGODB_DB); - } catch { - return null; - } + const client = await mongoClientPromise; + return client.db(process.env.MONGODB_DB); }; export default mongoClientPromise; diff --git a/src/app/library/media/[id]/page.tsx b/src/app/library/media/[id]/page.tsx index d2d0a44e..865849e5 100644 --- a/src/app/library/media/[id]/page.tsx +++ b/src/app/library/media/[id]/page.tsx @@ -8,7 +8,7 @@ import { T_RawMediaContentFields } from 'types/media-content'; import { determineFileType } from 'utils/helpers/determineFileType'; import { getMetadataGenerator } from 'utils/helpers/getMetadataGenerator'; import NETWORK_UTILS from 'utils/network'; -import { MediaContentView } from '../../../../components/library/MediaContentView'; +import { MediaContentPlayer } from '../../../../components/library/MediaContentPlayer'; import { fetchRelatedMedia } from '../../../../fetchers/library/fetchRelatedMedia'; // Route-level revalidation replaces per-fetch unstable_cache wrappers removed during Next.js 16 upgrade @@ -57,9 +57,9 @@ export default async function MediaContentPage({ params: paramsPromise }: T_Medi {result instanceof Error ? ( {result.message} ) : ( - (initialMediaContent); + const [relatedMedia, setRelatedMedia] = useState(initialRelatedMedia); + + const handleSelect = useCallback( + async (item: T_RawMediaContentFields) => { + // Instantly swap to basic data from the list — no flash + setActiveMedia(item); + router.replace(`/library/media/${item._id}`, { scroll: false }); + + // Background: fetch full metadata + const fullResult = await fetcher<{ mediaContent: T_RawMediaContentFields }>( + API_LINKS.FETCH_MEDIA_CONTENT_BY_ID + NETWORK_UTILS.formatGetParams({ mediaContentId: item._id, withMetaData: 'true' }), + ); + if (!(fullResult instanceof Error)) { + setActiveMedia(fullResult.mediaContent); + // Background: update related list for new active item + const related = await fetchRelatedMediaClient(fullResult.mediaContent); + setRelatedMedia(related); + } + }, + [router], + ); + + return ( +
+
+ + +
+ +
+ ); +} diff --git a/src/components/library/MediaDetails.tsx b/src/components/library/MediaDetails.tsx new file mode 100644 index 00000000..14963f9f --- /dev/null +++ b/src/components/library/MediaDetails.tsx @@ -0,0 +1,90 @@ +'use client'; + +import Link from 'next/link'; +import { ReactNode, memo, useCallback, useEffect } from 'react'; +import { FaBook, FaBookmark, FaEye } from 'react-icons/fa'; +import { PiStepsFill } from 'react-icons/pi'; +import { MdTopic } from 'react-icons/md'; +import { ImBooks } from 'react-icons/im'; +import { useSession } from 'next-auth/react'; +import { T_RawMediaContentFields } from 'types/media-content'; +import { ReactionButtons } from '@components/atom/ReactionButtons'; +import { useMediaInteractions } from '@hooks/useMediaInteractions'; + +const IconWithLabel = memo(({ icon, label, title }: { label?: string; icon: ReactNode; title: string }) => + label ? ( +
+ {icon} + {label} +
+ ) : null, +); +IconWithLabel.displayName = 'IconWithLabel'; + +const MediaContentMetadata = memo(({ mediaContent }: { mediaContent: T_RawMediaContentFields }) => ( +
+ {mediaContent.grade && ( + + } label={mediaContent.grade.name} /> + + )} + {mediaContent.subject && ( + + } label={mediaContent.subject.name} /> + + )} + {mediaContent.course && ( + + } label={mediaContent.course.name} /> + + )} + {mediaContent.unit && ( + + } label={mediaContent.unit.name} /> + + )} + {mediaContent.topic && ( + + } label={mediaContent.topic.name} /> + + )} +
+)); +MediaContentMetadata.displayName = 'MediaContentMetadata'; + +export function MediaDetails({ mediaContent }: { mediaContent: T_RawMediaContentFields }) { + const { data: session } = useSession(); + const { viewCount, reactionData, isLoadingReactions, recordView, handleReaction, hasRecordedView } = + useMediaInteractions(mediaContent._id); + + const handleReactionCallback = useCallback((type: any) => handleReaction(type, session), [handleReaction, session]); + + useEffect(() => { + const timeoutId = setTimeout(recordView, 45000); + return () => clearTimeout(timeoutId); + }, [mediaContent._id, hasRecordedView, recordView]); + + return ( +
+
+

{mediaContent.name}

+
+
+ +
+ + {viewCount} views +
+ {!session && Please login to react to this content} +
+
+ +
+
+ ); +} diff --git a/src/components/library/MediaContentView.tsx b/src/components/library/MediaViewer.tsx similarity index 50% rename from src/components/library/MediaContentView.tsx rename to src/components/library/MediaViewer.tsx index af167332..4b9b511b 100644 --- a/src/components/library/MediaContentView.tsx +++ b/src/components/library/MediaViewer.tsx @@ -1,21 +1,12 @@ 'use client'; -import Link from 'next/link'; -import { ReactNode, memo, useCallback, useMemo, useEffect } from 'react'; -import { FaBook, FaBookmark, FaEye } from 'react-icons/fa'; -import { PiStepsFill } from 'react-icons/pi'; -import { MdTopic } from 'react-icons/md'; -import { ImBooks } from 'react-icons/im'; import Image from 'next/image'; import dynamic from 'next/dynamic'; -import { useSession } from 'next-auth/react'; +import { useMemo } from 'react'; import { T_RawMediaContentFields } from 'types/media-content'; import { determineFileType } from 'utils/helpers/determineFileType'; import { getFileUrl } from 'utils/helpers/getFileUrl'; import { LibraryErrorMessage } from './LibraryErrorMessage/LibraryErrorMessage'; -import { RelatedMediaContentList } from './RelatedMediaContentList'; -import { ReactionButtons } from '@components/atom/ReactionButtons'; -import { useMediaInteractions } from '@hooks/useMediaInteractions'; import { useScreenDetector } from '@hooks/useScreenDetactor'; const VideoViewer = dynamic(() => import('next-video/player'), { @@ -28,55 +19,7 @@ const PdfViewer = dynamic(() => import('@components/layouts/library/PdfViewer/Pd loading: () =>
, }); -const IconWithLabel = memo(({ icon, label, title }: { label?: string; icon: ReactNode; title: string }) => - label ? ( -
- {icon} - {label} -
- ) : null, -); -IconWithLabel.displayName = 'IconWithLabel'; - -const MediaContentMetadata = memo(({ mediaContent }: { mediaContent: T_RawMediaContentFields }) => ( -
- {mediaContent.grade && ( - - } label={mediaContent.grade.name} /> - - )} - {mediaContent.subject && ( - - } label={mediaContent.subject.name} /> - - )} - {mediaContent.course && ( - - } label={mediaContent.course.name} /> - - )} - {mediaContent.unit && ( - - } label={mediaContent.unit.name} /> - - )} - {mediaContent.topic && ( - - } label={mediaContent.topic.name} /> - - )} -
-)); -MediaContentMetadata.displayName = 'MediaContentMetadata'; - -export function MediaContentView({ - mediaContent, - relatedMediaContent, -}: { - mediaContent: T_RawMediaContentFields; - relatedMediaContent: T_RawMediaContentFields[] | null; -}) { - const { data: session } = useSession(); +export function MediaViewer({ mediaContent }: { mediaContent: T_RawMediaContentFields }) { const { isDeviceMobile } = useScreenDetector(); const fileType = useMemo(() => determineFileType(mediaContent?.file_url || ''), [mediaContent?.file_url]); const fileUrl = useMemo( @@ -85,17 +28,7 @@ export function MediaContentView({ ); const externalUrl = mediaContent.external_url; - const { viewCount, reactionData, isLoadingReactions, recordView, handleReaction, hasRecordedView } = - useMediaInteractions(mediaContent._id); - - const handleReactionCallback = useCallback((type: any) => handleReaction(type, session), [handleReaction, session]); - - useEffect(() => { - const timeoutId = setTimeout(recordView, 45000); - return () => clearTimeout(timeoutId); - }, [hasRecordedView, recordView]); - - const renderMediaContent = useMemo(() => { + return useMemo(() => { if (!fileUrl && !externalUrl) { return ( @@ -110,7 +43,6 @@ export function MediaContentView({ if (youtubeRegex.test(externalUrl)) { const videoId = externalUrl.match( - // eslint-disable-next-line no-useless-escape /(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/, )?.[1]; const embedUrl = `https://www.youtube.com/embed/${videoId}`; @@ -146,13 +78,13 @@ export function MediaContentView({ } return ( -
+
); @@ -192,34 +124,4 @@ export function MediaContentView({ return Could not recognize the file type; } }, [fileUrl, fileType, mediaContent.name, externalUrl, isDeviceMobile]); - - return ( -
-
-
{renderMediaContent}
-
-
-

{mediaContent.name}

-
-
- -
- - {viewCount} views -
- {!session && Please login to react to this content} -
-
-
- -
-
- -
- ); } diff --git a/src/components/library/RelatedMediaContentList.tsx b/src/components/library/RelatedMediaContentList.tsx index 770cdd2f..22e90773 100644 --- a/src/components/library/RelatedMediaContentList.tsx +++ b/src/components/library/RelatedMediaContentList.tsx @@ -1,3 +1,5 @@ +'use client'; + import { List } from 'flowbite-react'; import Image from 'next/image'; import Link from 'next/link'; @@ -8,30 +10,67 @@ const isValidImage = (url: string) => { return url && url.match(/\.(jpeg|jpg|gif|png)$/) !== null; }; -const RelatedMediaItem = memo(({ item }: { item: T_RawMediaContentFields }) => { - const domainName = item.external_url ? new URL(item.external_url).hostname : ''; - const placeholderImage = `https://placehold.co/120x100?text=${domainName || item.name}`; - const thumbnailUrl = isValidImage(item.thumbnail_url as string) ? item.thumbnail_url : placeholderImage; +const RelatedMediaItem = memo( + ({ + item, + isActive, + onSelect, + }: { + item: T_RawMediaContentFields; + isActive: boolean; + onSelect?: (item: T_RawMediaContentFields) => void; + }) => { + const domainName = item.external_url ? new URL(item.external_url).hostname : ''; + const placeholderImage = `https://placehold.co/120x100?text=${domainName || item.name}`; + const thumbnailUrl = isValidImage(item.thumbnail_url as string) ? item.thumbnail_url : placeholderImage; - return ( - - - {item.name} + const content = ( +
+ {item.name}
-

{item.name}

-
{item.description}
+

+ {item.name} +

+
{item.description}
- - - ); -}); +
+ ); + + if (onSelect) { + return ( + + + + ); + } + + return ( + + {content} + + ); + }, +); RelatedMediaItem.displayName = 'RelatedMediaItem'; export function RelatedMediaContentList({ relatedMediaContent, + activeMediaId, + onSelect, }: { relatedMediaContent: T_RawMediaContentFields[] | null; + activeMediaId?: string; + onSelect?: (item: T_RawMediaContentFields) => void; }) { return (
@@ -40,7 +79,12 @@ export function RelatedMediaContentList({

Related Media

{relatedMediaContent.map((item) => ( - + ))} diff --git a/src/fetchers/library/fetchRelatedMedia.tsx b/src/fetchers/library/fetchRelatedMedia.tsx index e96e92f6..b88a6089 100644 --- a/src/fetchers/library/fetchRelatedMedia.tsx +++ b/src/fetchers/library/fetchRelatedMedia.tsx @@ -24,3 +24,24 @@ export async function fetchRelatedMedia(mediaContent: T_RawMediaContentFields) { { next: { revalidate: 60 } }, ); } + +export async function fetchRelatedMediaClient( + mediaContent: T_RawMediaContentFields, +): Promise { + const { _id, grade } = mediaContent; + + const params: Record = { + media_content_id: _id, + limit: '10', + skip: '0', + withMetaData: 'false', + }; + if (grade?._id) params.grade_id = grade._id; + + const result = await fetcher<{ mediaContent: T_RawMediaContentFields[] }>( + API_LINKS.FETCH_RELATED_MEDIA_CONTENT + NETWORK_UTILS.formatGetParams(params), + ); + + if (result instanceof Error) return null; + return result.mediaContent; +} From 1b51ccd5a04a919ce10647085a17ef0f759cc21c Mon Sep 17 00:00:00 2001 From: Olivier JM Maniraho Date: Sun, 19 Apr 2026 17:29:48 +0200 Subject: [PATCH 02/18] fix: restore dbClient null return on connection failure, add retry on rejection --- src/app/api/lib/db/index.ts | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/app/api/lib/db/index.ts b/src/app/api/lib/db/index.ts index 533f96d8..a1877ab0 100644 --- a/src/app/api/lib/db/index.ts +++ b/src/app/api/lib/db/index.ts @@ -12,16 +12,25 @@ declare global { var _mongoClientPromise: Promise | undefined; } -if (!global._mongoClientPromise) { - const client = new MongoClient(uri, options); - global._mongoClientPromise = client.connect(); +function getClientPromise(): Promise { + if (!global._mongoClientPromise) { + const client = new MongoClient(uri, options); + global._mongoClientPromise = client.connect().catch((err) => { + // Reset so the next request gets a fresh attempt + global._mongoClientPromise = undefined; + return Promise.reject(err); + }); + } + return global._mongoClientPromise; } -const mongoClientPromise = global._mongoClientPromise; - export const dbClient = async () => { - const client = await mongoClientPromise; - return client.db(process.env.MONGODB_DB); + try { + const client = await getClientPromise(); + return client.db(process.env.MONGODB_DB); + } catch { + return null; + } }; -export default mongoClientPromise; +export default getClientPromise(); From 3a372914865507025893357e13dcec2dedab2d5a Mon Sep 17 00:00:00 2001 From: Olivier JM Maniraho Date: Sun, 19 Apr 2026 19:29:07 +0200 Subject: [PATCH 03/18] fix media navigation --- src/components/library/MediaContentPlayer.tsx | 28 ++++++++++++------- tailwind.config.js | 6 ++++ 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/components/library/MediaContentPlayer.tsx b/src/components/library/MediaContentPlayer.tsx index 81057efd..5186cbb9 100644 --- a/src/components/library/MediaContentPlayer.tsx +++ b/src/components/library/MediaContentPlayer.tsx @@ -1,7 +1,6 @@ 'use client'; import { useCallback, useState } from 'react'; -import { useRouter } from 'next/navigation'; import { T_RawMediaContentFields } from 'types/media-content'; import { fetcher } from '@hooks/use-swr/fetcher'; import { API_LINKS } from 'app/links'; @@ -17,39 +16,48 @@ type Props = { }; export function MediaContentPlayer({ initialMediaContent, initialRelatedMedia }: Props) { - const router = useRouter(); const [activeMedia, setActiveMedia] = useState(initialMediaContent); const [relatedMedia, setRelatedMedia] = useState(initialRelatedMedia); + const [pendingMediaId, setPendingMediaId] = useState(null); const handleSelect = useCallback( async (item: T_RawMediaContentFields) => { - // Instantly swap to basic data from the list — no flash - setActiveMedia(item); - router.replace(`/library/media/${item._id}`, { scroll: false }); + if (item._id === activeMedia._id) return; - // Background: fetch full metadata + // Highlight selection immediately, keep current content displayed + setPendingMediaId(item._id); + window.history.replaceState(null, '', `/library/media/${item._id}`); + + // Fetch full metadata before swapping viewer const fullResult = await fetcher<{ mediaContent: T_RawMediaContentFields }>( API_LINKS.FETCH_MEDIA_CONTENT_BY_ID + NETWORK_UTILS.formatGetParams({ mediaContentId: item._id, withMetaData: 'true' }), ); if (!(fullResult instanceof Error)) { setActiveMedia(fullResult.mediaContent); - // Background: update related list for new active item const related = await fetchRelatedMediaClient(fullResult.mediaContent); setRelatedMedia(related); } + setPendingMediaId(null); }, - [router], + [activeMedia._id], ); return (
-
+
+ {pendingMediaId && ( +
+
+
+
+
+ )}
diff --git a/tailwind.config.js b/tailwind.config.js index bf8b90d6..f48e6e83 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -8,6 +8,7 @@ module.exports = { animation: { appear: 'appear 0.5s ease-in-out', 'fadeIn': 'fadeIn 0.5s ease-in-out', + 'loading-bar': 'loading-bar 1.2s ease-in-out infinite', }, keyframes: { appear: { @@ -18,6 +19,11 @@ module.exports = { '0%': { opacity: '0', transform: 'translateY(10px)' }, '100%': { opacity: '1', transform: 'translateY(0)' }, }, + 'loading-bar': { + '0%': { transform: 'translateX(-100%)' }, + '50%': { transform: 'translateX(100%)' }, + '100%': { transform: 'translateX(300%)' }, + }, }, }, }, From d02d130a8b1fd59d315f0217e2106bd469cedce4 Mon Sep 17 00:00:00 2001 From: Olivier JM Maniraho Date: Sun, 19 Apr 2026 19:36:00 +0200 Subject: [PATCH 04/18] feat: add versioned MongoDB migration system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create migrate.ts with runMigrations(db) runner that tracks applied migrations in _migrations collection with unique index - Rename addDefaultRoles.ts → migrations/001-add-default-roles.ts, adapting to up(db: Db) signature with exported name constant - Add migrations/index.ts as static registry - Update init.ts to call runMigrations(db) instead of addDefaultRoles() Co-Authored-By: Claude Sonnet 4.6 --- src/app/api/lib/db/init.ts | 7 ++++-- src/app/api/lib/db/migrate.ts | 23 +++++++++++++++++++ ...faultRoles.ts => 001-add-default-roles.ts} | 7 +++--- src/app/api/lib/db/migrations/index.ts | 5 ++++ 4 files changed, 36 insertions(+), 6 deletions(-) create mode 100644 src/app/api/lib/db/migrate.ts rename src/app/api/lib/db/migrations/{addDefaultRoles.ts => 001-add-default-roles.ts} (85%) create mode 100644 src/app/api/lib/db/migrations/index.ts diff --git a/src/app/api/lib/db/init.ts b/src/app/api/lib/db/init.ts index 9e406565..3c722b0c 100644 --- a/src/app/api/lib/db/init.ts +++ b/src/app/api/lib/db/init.ts @@ -1,5 +1,8 @@ -import { addDefaultRoles } from './migrations/addDefaultRoles'; +import { dbClient } from '.'; +import { runMigrations } from './migrate'; export async function initializeDatabase() { - await addDefaultRoles(); + const db = await dbClient(); + if (!db) return; + await runMigrations(db); } diff --git a/src/app/api/lib/db/migrate.ts b/src/app/api/lib/db/migrate.ts new file mode 100644 index 00000000..8474ef72 --- /dev/null +++ b/src/app/api/lib/db/migrate.ts @@ -0,0 +1,23 @@ +import { Db } from 'mongodb'; +import { migrations } from './migrations'; + +export async function runMigrations(db: Db): Promise { + // 1. Ensure _migrations collection + unique index exist + await db.collection('_migrations').createIndex({ name: 1 }, { unique: true }); + + // 2. For each migration in order: + for (const migration of migrations) { + const already = await db.collection('_migrations').findOne({ name: migration.name }); + if (already) continue; + + const start = Date.now(); + await migration.up(db); + await db.collection('_migrations').insertOne({ + name: migration.name, + applied_at: new Date(), + duration_ms: Date.now() - start, + }); + + console.log(`[migrate] Applied: ${migration.name}`); + } +} diff --git a/src/app/api/lib/db/migrations/addDefaultRoles.ts b/src/app/api/lib/db/migrations/001-add-default-roles.ts similarity index 85% rename from src/app/api/lib/db/migrations/addDefaultRoles.ts rename to src/app/api/lib/db/migrations/001-add-default-roles.ts index 7a2059d5..1ae8565f 100644 --- a/src/app/api/lib/db/migrations/addDefaultRoles.ts +++ b/src/app/api/lib/db/migrations/001-add-default-roles.ts @@ -1,4 +1,4 @@ -import { dbClient } from '../'; +import { Db } from 'mongodb'; import { dbCollections } from '../collections'; const DEFAULT_ROLES = [ @@ -20,10 +20,9 @@ const DEFAULT_ROLES = [ }, ]; -export async function addDefaultRoles() { - const db = await dbClient(); - if (!db) return; +export const name = '001-add-default-roles'; +export async function up(db: Db): Promise { for (const role of DEFAULT_ROLES) { const exists = await db.collection(dbCollections.user_roles.name).findOne({ name: role.name }); if (!exists) { diff --git a/src/app/api/lib/db/migrations/index.ts b/src/app/api/lib/db/migrations/index.ts new file mode 100644 index 00000000..f3977cef --- /dev/null +++ b/src/app/api/lib/db/migrations/index.ts @@ -0,0 +1,5 @@ +import { up as up001, name as name001 } from './001-add-default-roles'; + +export const migrations = [ + { name: name001, up: up001 }, +]; From db85f5fb9d59e4f2656f3357fb231cfbb3bcabc8 Mon Sep 17 00:00:00 2001 From: Olivier JM Maniraho Date: Sun, 19 Apr 2026 19:38:11 +0200 Subject: [PATCH 05/18] fix: add Migration type + error logging to migration runner --- src/app/api/lib/db/migrate.ts | 12 +++++++++++- src/app/api/lib/db/migrations/index.ts | 3 ++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/app/api/lib/db/migrate.ts b/src/app/api/lib/db/migrate.ts index 8474ef72..6331fed4 100644 --- a/src/app/api/lib/db/migrate.ts +++ b/src/app/api/lib/db/migrate.ts @@ -1,6 +1,11 @@ import { Db } from 'mongodb'; import { migrations } from './migrations'; +export type Migration = { + name: string; + up: (db: Db) => Promise; +}; + export async function runMigrations(db: Db): Promise { // 1. Ensure _migrations collection + unique index exist await db.collection('_migrations').createIndex({ name: 1 }, { unique: true }); @@ -11,7 +16,12 @@ export async function runMigrations(db: Db): Promise { if (already) continue; const start = Date.now(); - await migration.up(db); + try { + await migration.up(db); + } catch (err) { + console.error(`[migrate] FAILED: ${migration.name}`, err); + throw err; + } await db.collection('_migrations').insertOne({ name: migration.name, applied_at: new Date(), diff --git a/src/app/api/lib/db/migrations/index.ts b/src/app/api/lib/db/migrations/index.ts index f3977cef..d91136a2 100644 --- a/src/app/api/lib/db/migrations/index.ts +++ b/src/app/api/lib/db/migrations/index.ts @@ -1,5 +1,6 @@ +import type { Migration } from '../migrate'; import { up as up001, name as name001 } from './001-add-default-roles'; -export const migrations = [ +export const migrations: Migration[] = [ { name: name001, up: up001 }, ]; From b621dc4c64edd68014526026be7de4e63098a42c Mon Sep 17 00:00:00 2001 From: Olivier JM Maniraho Date: Sun, 19 Apr 2026 19:39:25 +0200 Subject: [PATCH 06/18] feat: add migrations 002-005, CLI script, and npm migrate script - 002: indexes for media_content, users, user-role-mappings, media_reactions - 003: fix schema typos (settings.upated_at, courses.institutions_id) - 004: institution_id indexes across 8 collections - 005: institution_memberships and institution_invites collections with indexes (idempotent via NamespaceExists guard) - src/scripts/migrate.ts: standalone CLI runner using MongoClient + runMigrations - package.json: add "migrate" npm script via dotenv + tsx Co-Authored-By: Claude Sonnet 4.6 --- package.json | 3 ++- .../api/lib/db/migrations/002-add-indexes.ts | 23 ++++++++++++++++++ .../lib/db/migrations/003-fix-schema-typos.ts | 16 +++++++++++++ .../004-add-institution-id-fields.ts | 8 +++++++ .../005-add-institution-collections.ts | 23 ++++++++++++++++++ src/app/api/lib/db/migrations/index.ts | 8 +++++++ src/scripts/migrate.ts | 24 +++++++++++++++++++ 7 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 src/app/api/lib/db/migrations/002-add-indexes.ts create mode 100644 src/app/api/lib/db/migrations/003-fix-schema-typos.ts create mode 100644 src/app/api/lib/db/migrations/004-add-institution-id-fields.ts create mode 100644 src/app/api/lib/db/migrations/005-add-institution-collections.ts create mode 100644 src/scripts/migrate.ts diff --git a/package.json b/package.json index cf0d45c2..5aef28f2 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "test": "vitest run", "test:watch": "vitest", "lint": "npx oxlint --fix", - "pm2": "pm2 start npm --name \"sparked-next\" -- start" + "pm2": "pm2 start npm --name \"sparked-next\" -- start", + "migrate": "dotenv -e .env.local -- tsx src/scripts/migrate.ts" }, "engines": { "node": ">=22" diff --git a/src/app/api/lib/db/migrations/002-add-indexes.ts b/src/app/api/lib/db/migrations/002-add-indexes.ts new file mode 100644 index 00000000..92dc928c --- /dev/null +++ b/src/app/api/lib/db/migrations/002-add-indexes.ts @@ -0,0 +1,23 @@ +import { Db } from 'mongodb'; + +export async function up(db: Db): Promise { + // media_content + await db.collection('media_content').createIndex({ grade_id: 1 }); + await db.collection('media_content').createIndex({ subject_id: 1 }); + await db.collection('media_content').createIndex({ unit_id: 1 }); + await db.collection('media_content').createIndex({ topic_id: 1 }); + await db.collection('media_content').createIndex({ name: 'text' }); + + // users + await db.collection('users').createIndex({ email: 1 }, { unique: true }); + + // user_role_mappings (DB name is 'user-role-mappings' with hyphen) + await db.collection('user-role-mappings').createIndex({ user_id: 1 }, { unique: true }); + + // media_reactions + await db.collection('media_reactions').createIndex( + { media_content_id: 1, user_id: 1 }, + { unique: true } + ); +} +export const name = '002-add-indexes'; diff --git a/src/app/api/lib/db/migrations/003-fix-schema-typos.ts b/src/app/api/lib/db/migrations/003-fix-schema-typos.ts new file mode 100644 index 00000000..75ef8106 --- /dev/null +++ b/src/app/api/lib/db/migrations/003-fix-schema-typos.ts @@ -0,0 +1,16 @@ +import { Db } from 'mongodb'; + +export async function up(db: Db): Promise { + // Fix settings.upated_at → updated_at + await db.collection('settings').updateMany( + { upated_at: { $exists: true } }, + [{ $set: { updated_at: '$upated_at' } }, { $unset: 'upated_at' }] + ); + + // Fix courses.institutions_id → institution_id + await db.collection('courses').updateMany( + { institutions_id: { $exists: true } }, + [{ $set: { institution_id: '$institutions_id' } }, { $unset: 'institutions_id' }] + ); +} +export const name = '003-fix-schema-typos'; diff --git a/src/app/api/lib/db/migrations/004-add-institution-id-fields.ts b/src/app/api/lib/db/migrations/004-add-institution-id-fields.ts new file mode 100644 index 00000000..1bfd6ef2 --- /dev/null +++ b/src/app/api/lib/db/migrations/004-add-institution-id-fields.ts @@ -0,0 +1,8 @@ +import { Db } from 'mongodb'; + +export async function up(db: Db): Promise { + for (const col of ['programs', 'courses', 'units', 'subjects', 'grades', 'topics', 'media_content', 'users']) { + await db.collection(col).createIndex({ institution_id: 1 }); + } +} +export const name = '004-add-institution-id-fields'; diff --git a/src/app/api/lib/db/migrations/005-add-institution-collections.ts b/src/app/api/lib/db/migrations/005-add-institution-collections.ts new file mode 100644 index 00000000..c7d886ee --- /dev/null +++ b/src/app/api/lib/db/migrations/005-add-institution-collections.ts @@ -0,0 +1,23 @@ +import { Db } from 'mongodb'; + +export async function up(db: Db): Promise { + try { + await db.createCollection('institution_memberships'); + } catch (err: any) { + if (err?.code !== 48) throw err; + } + await db.collection('institution_memberships').createIndex( + { user_id: 1, institution_id: 1 }, + { unique: true } + ); + await db.collection('institution_memberships').createIndex({ institution_id: 1 }); + + try { + await db.createCollection('institution_invites'); + } catch (err: any) { + if (err?.code !== 48) throw err; + } + await db.collection('institution_invites').createIndex({ token: 1 }, { unique: true }); + await db.collection('institution_invites').createIndex({ email: 1, institution_id: 1 }); +} +export const name = '005-add-institution-collections'; diff --git a/src/app/api/lib/db/migrations/index.ts b/src/app/api/lib/db/migrations/index.ts index d91136a2..a434a7c2 100644 --- a/src/app/api/lib/db/migrations/index.ts +++ b/src/app/api/lib/db/migrations/index.ts @@ -1,6 +1,14 @@ import type { Migration } from '../migrate'; import { up as up001, name as name001 } from './001-add-default-roles'; +import { up as up002, name as name002 } from './002-add-indexes'; +import { up as up003, name as name003 } from './003-fix-schema-typos'; +import { up as up004, name as name004 } from './004-add-institution-id-fields'; +import { up as up005, name as name005 } from './005-add-institution-collections'; export const migrations: Migration[] = [ { name: name001, up: up001 }, + { name: name002, up: up002 }, + { name: name003, up: up003 }, + { name: name004, up: up004 }, + { name: name005, up: up005 }, ]; diff --git a/src/scripts/migrate.ts b/src/scripts/migrate.ts new file mode 100644 index 00000000..d1b7da85 --- /dev/null +++ b/src/scripts/migrate.ts @@ -0,0 +1,24 @@ +import { MongoClient } from 'mongodb'; +import { runMigrations } from '../app/api/lib/db/migrate'; + +async function main() { + const uri = process.env.MONGODB_URI; + const dbName = process.env.MONGODB_DB; + + if (!uri || !dbName) { + console.error('[migrate] MONGODB_URI and MONGODB_DB must be set'); + process.exit(1); + } + + const client = new MongoClient(uri); + await client.connect(); + const db = client.db(dbName); + await runMigrations(db); + await client.close(); + console.log('[migrate] Done.'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); From 860a8bcd2e2f6fa108c3db5fb7db90ee22207aaa Mon Sep 17 00:00:00 2001 From: Olivier JM Maniraho Date: Sun, 19 Apr 2026 19:41:09 +0200 Subject: [PATCH 07/18] feat: add migrations 002-005, CLI script, and npm migrate script --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5aef28f2..8e18496e 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "test:watch": "vitest", "lint": "npx oxlint --fix", "pm2": "pm2 start npm --name \"sparked-next\" -- start", - "migrate": "dotenv -e .env.local -- tsx src/scripts/migrate.ts" + "migrate": "node --env-file=.env.local -r ts-node/register src/scripts/migrate.ts" }, "engines": { "node": ">=22" From a8acd6a5008f507cc1586d4e6d267e4911398c12 Mon Sep 17 00:00:00 2001 From: Olivier JM Maniraho Date: Sun, 19 Apr 2026 19:43:43 +0200 Subject: [PATCH 08/18] fix: use tsx for migrate script, add try/finally for connection cleanup --- package.json | 3 ++- src/scripts/migrate.ts | 9 ++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 8e18496e..e5e64e24 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "test:watch": "vitest", "lint": "npx oxlint --fix", "pm2": "pm2 start npm --name \"sparked-next\" -- start", - "migrate": "node --env-file=.env.local -r ts-node/register src/scripts/migrate.ts" + "migrate": "tsx --env-file=.env.local src/scripts/migrate.ts" }, "engines": { "node": ">=22" @@ -78,6 +78,7 @@ "postcss": "^8.4.28", "tailwindcss": "^3.3.3", "ts-node": "^10.9.2", + "tsx": "^4.21.0", "vitest": "^4.1.0" }, "resolutions": { diff --git a/src/scripts/migrate.ts b/src/scripts/migrate.ts index d1b7da85..100eba2d 100644 --- a/src/scripts/migrate.ts +++ b/src/scripts/migrate.ts @@ -12,9 +12,12 @@ async function main() { const client = new MongoClient(uri); await client.connect(); - const db = client.db(dbName); - await runMigrations(db); - await client.close(); + try { + const db = client.db(dbName); + await runMigrations(db); + } finally { + await client.close(); + } console.log('[migrate] Done.'); } From 59353aedfc1bbecc9f4b009c15823c683353b7b1 Mon Sep 17 00:00:00 2001 From: Olivier JM Maniraho Date: Sun, 19 Apr 2026 19:44:39 +0200 Subject: [PATCH 09/18] fix: preserve media items with no linked user in p_fetchRandomMediaContent Add preserveNullAndEmptyArrays: true to the $unwind '$user' stage so media content whose created_by_id doesn't match any user is no longer silently dropped from random media results. Co-Authored-By: Claude Sonnet 4.6 --- src/app/api/media-content/pipelines.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/media-content/pipelines.ts b/src/app/api/media-content/pipelines.ts index 0b589c78..9b1cf429 100644 --- a/src/app/api/media-content/pipelines.ts +++ b/src/app/api/media-content/pipelines.ts @@ -184,7 +184,7 @@ export const p_fetchRandomMediaContent = ({ }, }, { - $unwind: '$user', + $unwind: { path: '$user', preserveNullAndEmptyArrays: true }, }, { $lookup: { From 58e1a4713f58c6ae510238ec3c8a9ac544ba0c2f Mon Sep 17 00:00:00 2001 From: Olivier JM Maniraho Date: Sun, 19 Apr 2026 19:44:45 +0200 Subject: [PATCH 10/18] fix: replace zfd.formData with z.object in user edit handler (#427) The schema used zfd.formData() but the body was read with request.json(), causing a parse mismatch and a 500 error. Switched to a plain z.object() schema to match the JSON request body correctly. Co-Authored-By: Claude Sonnet 4.6 --- src/app/api/users/edit.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/app/api/users/edit.ts b/src/app/api/users/edit.ts index c26d468e..30fe51f6 100644 --- a/src/app/api/users/edit.ts +++ b/src/app/api/users/edit.ts @@ -1,20 +1,20 @@ import SPARKED_PROCESS_CODES from 'app/shared/processCodes'; import { BSON } from 'mongodb'; import { Session } from 'next-auth'; -import { zfd } from 'zod-form-data'; +import { z } from 'zod'; import { dbClient } from '../lib/db'; import { dbCollections } from '../lib/db/collections'; import { default as USER_PROCESS_CODES } from './processCodes'; import { HttpStatusCode } from 'axios'; export default async function editUser_(request: Request, session?: Session) { - const schema = zfd.formData({ - _id: zfd.text(), - email: zfd.text(), - firstName: zfd.text(), - lastName: zfd.text(), - phoneNumber: zfd.text(), - role: zfd.text().optional(), + const schema = z.object({ + _id: z.string(), + email: z.string(), + firstName: z.string(), + lastName: z.string(), + phoneNumber: z.string(), + role: z.string().optional(), }); const formBody = await request.json(); From 75c8ccdae370306472e6b4e3f649014ec12f2271 Mon Sep 17 00:00:00 2001 From: Olivier JM Maniraho Date: Sun, 19 Apr 2026 19:45:54 +0200 Subject: [PATCH 11/18] fix: schema bugs in schema.ts, collections.ts, and types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix typo: settings.upated_at → updated_at (type + schema) - Fix wrong type: preferences.updated_by_id Date → ObjectId (type + schema) - Fix key mismatch: coursesSchema.institutions_id → institution_id - Fix wrong embedded type: user_roles.permission_ids user_permissions[] → ObjectId[] - Fix feed_back._id optional → required (type + schema) - Fix page_links label copy-paste bug: 'Media Content' → 'Page Links' - Add institution_memberships and institution_invites types, schemas, collections, and T_dbCollection entries - Add institution_id?: string to T_RawMediaContentFields Co-Authored-By: Claude Sonnet 4.6 --- src/app/api/lib/db/collections.ts | 10 ++++- src/app/api/lib/db/schema.ts | 62 +++++++++++++++++++++++++++---- src/app/api/lib/db/types.ts | 8 ++++ src/types/media-content/index.ts | 1 + 4 files changed, 72 insertions(+), 9 deletions(-) diff --git a/src/app/api/lib/db/collections.ts b/src/app/api/lib/db/collections.ts index df83faba..8350df0b 100644 --- a/src/app/api/lib/db/collections.ts +++ b/src/app/api/lib/db/collections.ts @@ -35,7 +35,7 @@ export const dbCollections: T_dbCollection = { }, page_links: { name: 'page-links', - label: 'Media Content', + label: 'Page Links', }, grades: { name: 'grades', @@ -73,4 +73,12 @@ export const dbCollections: T_dbCollection = { name: 'media_reactions', label: 'Media Reactions', }, + institution_memberships: { + name: 'institution_memberships', + label: 'Institution Memberships', + }, + institution_invites: { + name: 'institution_invites', + label: 'Institution Invites', + }, } as const; diff --git a/src/app/api/lib/db/schema.ts b/src/app/api/lib/db/schema.ts index ec3babc8..e8846cac 100644 --- a/src/app/api/lib/db/schema.ts +++ b/src/app/api/lib/db/schema.ts @@ -72,7 +72,7 @@ export const coursesSchema = { code: 'string', created_at: 'date', created_by_id: 'objectId', - institutions_id: 'objectId?', + institution_id: 'objectId?', is_visible: 'bool?', languages: 'courses_languages []', name: 'string', @@ -97,7 +97,7 @@ export const courses_languagesSchema = { }; export type feed_back = { - _id?: ObjectId; + _id: ObjectId; attachment_link?: string; created_at?: Date; message?: string; @@ -108,7 +108,7 @@ export type feed_back = { export const feed_backSchema = { name: 'feed_back', properties: { - _id: 'objectId?', + _id: 'objectId', attachment_link: 'string?', created_at: 'date?', message: 'string?', @@ -185,6 +185,52 @@ export const institutionsSchema = { primaryKey: '_id', }; +export type institution_memberships = { + _id: ObjectId; + user_id: ObjectId; + institution_id: ObjectId; + role: 'admin' | 'member'; + joined_at: Date; + invited_by_id?: ObjectId; +}; + +export const institution_membershipsSchema = { + name: 'institution_memberships', + properties: { + _id: 'objectId', + user_id: 'objectId', + institution_id: 'objectId', + role: 'string', + joined_at: 'date', + invited_by_id: 'objectId?', + }, + primaryKey: '_id', +}; + +export type institution_invites = { + _id: ObjectId; + institution_id: ObjectId; + email: string; + token: string; + invited_by_id: ObjectId; + expires_at: Date; + used_at?: Date; +}; + +export const institution_invitesSchema = { + name: 'institution_invites', + properties: { + _id: 'objectId', + institution_id: 'objectId', + email: 'string', + token: 'string', + invited_by_id: 'objectId', + expires_at: 'date', + used_at: 'date?', + }, + primaryKey: '_id', +}; + // Keep schools schema for backward compatibility (will be migrated to institutions) export const schoolsSchema = { name: 'schools', @@ -207,7 +253,7 @@ export type preferences = { description?: string; title?: string; updated_at?: Date; - updated_by_id?: Date; + updated_by_id?: ObjectId; }; export const preferencesSchema = { @@ -219,7 +265,7 @@ export const preferencesSchema = { description: 'string?', title: 'string?', updated_at: 'date?', - updated_by_id: 'date?', + updated_by_id: 'objectId?', }, primaryKey: '_id', }; @@ -230,7 +276,7 @@ export type settings = { created_by_id: ObjectId; description?: string; title: string; - upated_at?: Date; + updated_at?: Date; updated_by_id?: ObjectId; }; @@ -242,7 +288,7 @@ export const settingsSchema = { created_by_id: 'objectId', description: 'string?', title: 'string', - upated_at: 'date?', + updated_at: 'date?', updated_by_id: 'objectId?', }, primaryKey: '_id', @@ -281,7 +327,7 @@ export type user_roles = { description?: string; label?: string; name?: string; - permission_ids: user_permissions[]; + permission_ids: ObjectId[]; updated_at?: Date; updated_by_id?: ObjectId; }; diff --git a/src/app/api/lib/db/types.ts b/src/app/api/lib/db/types.ts index 6a065b79..4e8229dc 100644 --- a/src/app/api/lib/db/types.ts +++ b/src/app/api/lib/db/types.ts @@ -72,4 +72,12 @@ export type T_dbCollection = { name: string; label: string; }; + institution_memberships: { + name: string; + label: string; + }; + institution_invites: { + name: string; + label: string; + }; }; diff --git a/src/types/media-content/index.ts b/src/types/media-content/index.ts index d6b109ef..28eb7d46 100644 --- a/src/types/media-content/index.ts +++ b/src/types/media-content/index.ts @@ -41,6 +41,7 @@ export type T_RawMediaContentFields = { created_at: string; updated_at: string; + institution_id?: string; user?: { _id: string; From c3063f6b305a414d9fbc560fe2dcf18d5151742e Mon Sep 17 00:00:00 2001 From: Olivier JM Maniraho Date: Sun, 19 Apr 2026 19:46:59 +0200 Subject: [PATCH 12/18] fix: update user_rolesSchema permission_ids to objectId[] --- src/app/api/lib/db/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/lib/db/schema.ts b/src/app/api/lib/db/schema.ts index e8846cac..01b9022a 100644 --- a/src/app/api/lib/db/schema.ts +++ b/src/app/api/lib/db/schema.ts @@ -341,7 +341,7 @@ export const user_rolesSchema = { description: 'string?', label: 'string?', name: 'string?', - permission_ids: 'user_permissions[]', + permission_ids: 'objectId[]', updated_at: 'date?', updated_by_id: 'objectId?', }, From b8cb4d7616d4e91043abe0ecfa1ea0f6384c1afb Mon Sep 17 00:00:00 2001 From: Olivier JM Maniraho Date: Sun, 19 Apr 2026 20:04:09 +0200 Subject: [PATCH 13/18] fix: add external_url to media types, use safeParse in user edit for 400 on bad input --- src/app/api/users/edit.ts | 9 ++++++++- src/types/media-content/index.ts | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/app/api/users/edit.ts b/src/app/api/users/edit.ts index 30fe51f6..c20e3f43 100644 --- a/src/app/api/users/edit.ts +++ b/src/app/api/users/edit.ts @@ -18,7 +18,14 @@ export default async function editUser_(request: Request, session?: Session) { }); const formBody = await request.json(); - const { _id, email, firstName, lastName, role, phoneNumber } = schema.parse(formBody); + const parsed = schema.safeParse(formBody); + if (!parsed.success) { + return new Response( + JSON.stringify({ isError: true, code: SPARKED_PROCESS_CODES.UNKNOWN_ERROR }), + { status: HttpStatusCode.BadRequest }, + ); + } + const { _id, email, firstName, lastName, role, phoneNumber } = parsed.data; try { const db = await dbClient(); diff --git a/src/types/media-content/index.ts b/src/types/media-content/index.ts index 28eb7d46..490704f9 100644 --- a/src/types/media-content/index.ts +++ b/src/types/media-content/index.ts @@ -42,6 +42,7 @@ export type T_RawMediaContentFields = { created_at: string; updated_at: string; institution_id?: string; + external_url?: string; user?: { _id: string; From af860de1be3b53f6fe8b8917cb519eb687a3049f Mon Sep 17 00:00:00 2001 From: Olivier JM Maniraho Date: Sun, 19 Apr 2026 20:09:31 +0200 Subject: [PATCH 14/18] fix: safety comments for unique indexes, idempotency note, INVALID_INPUT process code --- src/app/api/lib/db/migrate.ts | 2 ++ src/app/api/lib/db/migrations/002-add-indexes.ts | 4 +++- src/app/api/users/edit.ts | 2 +- src/app/api/users/processCodes.ts | 1 + 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/app/api/lib/db/migrate.ts b/src/app/api/lib/db/migrate.ts index 6331fed4..aac69fbf 100644 --- a/src/app/api/lib/db/migrate.ts +++ b/src/app/api/lib/db/migrate.ts @@ -16,6 +16,8 @@ export async function runMigrations(db: Db): Promise { if (already) continue; const start = Date.now(); + // NOTE: up() and insertOne() are not atomic. If up() succeeds but insertOne() fails, + // the migration will re-run on the next startup. Every migration must be idempotent. try { await migration.up(db); } catch (err) { diff --git a/src/app/api/lib/db/migrations/002-add-indexes.ts b/src/app/api/lib/db/migrations/002-add-indexes.ts index 92dc928c..3fffef8d 100644 --- a/src/app/api/lib/db/migrations/002-add-indexes.ts +++ b/src/app/api/lib/db/migrations/002-add-indexes.ts @@ -8,10 +8,12 @@ export async function up(db: Db): Promise { await db.collection('media_content').createIndex({ topic_id: 1 }); await db.collection('media_content').createIndex({ name: 'text' }); - // users + // users — WARNING: will fail if duplicate emails exist in the collection. + // Before running on prod, verify: db.users.aggregate([{$group:{_id:"$email",n:{$sum:1}}},{$match:{n:{$gt:1}}}]) await db.collection('users').createIndex({ email: 1 }, { unique: true }); // user_role_mappings (DB name is 'user-role-mappings' with hyphen) + // WARNING: will fail if duplicate user_id values exist in the collection. await db.collection('user-role-mappings').createIndex({ user_id: 1 }, { unique: true }); // media_reactions diff --git a/src/app/api/users/edit.ts b/src/app/api/users/edit.ts index c20e3f43..1af12b91 100644 --- a/src/app/api/users/edit.ts +++ b/src/app/api/users/edit.ts @@ -21,7 +21,7 @@ export default async function editUser_(request: Request, session?: Session) { const parsed = schema.safeParse(formBody); if (!parsed.success) { return new Response( - JSON.stringify({ isError: true, code: SPARKED_PROCESS_CODES.UNKNOWN_ERROR }), + JSON.stringify({ isError: true, code: USER_PROCESS_CODES.INVALID_INPUT }), { status: HttpStatusCode.BadRequest }, ); } diff --git a/src/app/api/users/processCodes.ts b/src/app/api/users/processCodes.ts index 15c94bc2..fed60885 100644 --- a/src/app/api/users/processCodes.ts +++ b/src/app/api/users/processCodes.ts @@ -8,6 +8,7 @@ const USER_PROCESS_CODES = { USER_CREATED: 6503, USER_DELETED: 6504, INVALID_ROLE: 7005, + INVALID_INPUT: 6505, } satisfies TProcessCode; export default USER_PROCESS_CODES; From 4a0881afd614dfc31f1a1676589e94ad3063e3dd Mon Sep 17 00:00:00 2001 From: Olivier JM Maniraho Date: Sun, 19 Apr 2026 20:15:50 +0200 Subject: [PATCH 15/18] fix: suppress hydration warning for theme, LCP image priority, 5s DB timeout, limit:1 for single-item fetch --- src/app/api/lib/db/index.ts | 5 ++++- src/app/api/media-content/index.ts | 1 + src/app/layout.tsx | 2 +- src/components/logo/index.tsx | 1 + 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/app/api/lib/db/index.ts b/src/app/api/lib/db/index.ts index a1877ab0..d2624373 100644 --- a/src/app/api/lib/db/index.ts +++ b/src/app/api/lib/db/index.ts @@ -5,7 +5,10 @@ if (!process.env.MONGODB_URI) { } const uri = process.env.MONGODB_URI || "mongodb://...."; // Just to allow the build to pass -const options = {}; +const options = { + serverSelectionTimeoutMS: 5000, + connectTimeoutMS: 5000, +}; declare global { // eslint-disable-next-line no-var diff --git a/src/app/api/media-content/index.ts b/src/app/api/media-content/index.ts index 336082b0..935cb84d 100644 --- a/src/app/api/media-content/index.ts +++ b/src/app/api/media-content/index.ts @@ -156,6 +156,7 @@ export async function fetchMediaContentById_(request: any) { query: { _id: new BSON.ObjectId(mediaContentId), }, + limit: 1, }), ) .toArray(); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a0995070..7a526ec0 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -14,7 +14,7 @@ const RootLayout = async ({ children }: { children: ReactNode }) => { const session = await getServerSession(authOptions); return ( - + diff --git a/src/components/logo/index.tsx b/src/components/logo/index.tsx index da6768bb..4e8de2bc 100644 --- a/src/components/logo/index.tsx +++ b/src/components/logo/index.tsx @@ -11,6 +11,7 @@ const AppLogo = ({ scale = 1 }: { scale?: number }) => { src="/alternate-logo.svg" alt="SparkEd Logo" className="admin-logo" + priority /> ); }; From dfe6432052b3c3d7d2d37cb1eaee5b29c51b7754 Mon Sep 17 00:00:00 2001 From: Olivier JM Maniraho Date: Sun, 19 Apr 2026 22:41:06 +0200 Subject: [PATCH 16/18] remove unused files --- docs/plans/01-resource-view-redesign.md | 135 ----------- docs/plans/02-admin-panel-improvements.md | 136 ----------- docs/plans/03-database-model-review.md | 218 ------------------ docs/plans/04-github-issues-triage.md | 181 --------------- docs/plans/05-database-migrations.md | 266 ---------------------- package.json | 2 +- 6 files changed, 1 insertion(+), 937 deletions(-) delete mode 100644 docs/plans/01-resource-view-redesign.md delete mode 100644 docs/plans/02-admin-panel-improvements.md delete mode 100644 docs/plans/03-database-model-review.md delete mode 100644 docs/plans/04-github-issues-triage.md delete mode 100644 docs/plans/05-database-migrations.md diff --git a/docs/plans/01-resource-view-redesign.md b/docs/plans/01-resource-view-redesign.md deleted file mode 100644 index 43a6f319..00000000 --- a/docs/plans/01-resource-view-redesign.md +++ /dev/null @@ -1,135 +0,0 @@ -# Plan: Resource View Page Redesign (YouTube-style, no full reload) - -**Closes:** #437 -**Goal:** Clicking a related media item updates only the player/details section — no full page navigation. URL stays in sync for shareability. Layout (sidebar, related list) stays mounted. - ---- - -## Problem - -`/library/media/[id]/page.tsx` is a Next.js **server component**. `RelatedMediaContentList` uses `` which triggers a full server-side render and page transition. The skeleton flash is visible even with App Router soft navigation because the entire route re-fetches and remounts. - ---- - -## Architecture - -Convert the media view page into a **client-driven experience**: - -1. Server page component (`page.tsx`) still handles initial load (SSR, metadata, SEO) -2. A new client component `MediaContentPlayer` takes initial data as props and owns all subsequent state -3. Related item clicks fire a client-side fetch (SWR) and swap state — no navigation -4. URL is updated via `router.replace` (no scroll, no history entry) so deep links still work -5. The related list itself re-fetches when the active media changes - ---- - -## Files to Change - -### 1. `src/app/library/media/[id]/page.tsx` -- Keep server component, keep SSR + metadata generation -- Pass `initialMediaContent` and `initialRelatedMedia` to new client component - -### 2. `src/components/library/MediaContentPlayer.tsx` (NEW) -``` -'use client' -- Accept: initialMediaContent: T_RawMediaContentFields, initialRelatedMedia: T_RawMediaContentFields[] | null -- State: activeMedia (starts from initialMediaContent), relatedMedia (starts from initialRelatedMedia) -- On related item click: - a. Immediately set activeMedia to clicked item (instant render, no flash) - b. Background-fetch full metadata for clicked item via /api/media-content?mediaContentId=...&withMetaData=true - c. Once fetched, replace activeMedia with full data - d. Re-fetch related media for new active item - e. Call router.replace(`/library/media/${item._id}`, { scroll: false }) to update URL -- Renders: MediaViewer + MediaDetails (below player) + RelatedMediaContentList (side) -``` - -### 3. `src/components/library/MediaContentView.tsx` -- Split into two smaller components: - - `MediaViewer` — renders the actual file (video/pdf/image/iframe) - - `MediaDetails` — title, metadata tags, reaction buttons, view count -- Both accept `mediaContent` prop and are pure display components -- Remove state/effects from here — move all to `MediaContentPlayer` - -### 4. `src/components/library/RelatedMediaContentList.tsx` -- Add `onSelect?: (item: T_RawMediaContentFields) => void` prop -- When `onSelect` is provided, intercept click (prevent default navigation), call `onSelect(item)` -- When not provided, behave as before (plain ``) -- Add visual indicator for currently active item (highlight/bold) - -### 5. `src/fetchers/library/fetchRelatedMedia.tsx` -- Keep server-side version for SSR -- Add client-side helper: `fetchRelatedMediaClient(mediaContent)` — same logic, uses `fetch()` directly, no `cache()` - ---- - -## Data Flow - -``` -Initial load (SSR): - page.tsx → getMediaContent(id) → fetchRelatedMedia(media) → MediaContentPlayer (props) - -On related item click (client): - click → setActiveMedia(item) [instant] - → fetch /api/media-content?...&withMetaData=true [background] - → setActiveMedia(fullData) [once ready] - → fetch related for new item [background] - → router.replace(new URL) [URL sync] -``` - ---- - -## Performance Improvements in This Feature - -- **No skeleton flash** — related item click instantly shows media name + basic info from list data while full metadata loads -- **Preload thumbnails** — add `loading="eager"` on visible thumbnails in related list -- **View counter debounce** — already has 45s delay, keep it; make sure timer resets on media swap -- **Aggregation pipeline** (`p_fetchMediaContentWithMetaData`) — currently default `limit: 1000` even for single-item fetch. For `fetchMediaContentById_`, pass `limit: 1` to the pipeline instead of slicing post-fetch. -- **DB connection** — `dbClient()` creates a new `MongoClient` on every call in production. Fix: reuse the `mongoClientPromise` pattern (already used in dev mode) in production too. One client per process. - ---- - -## URL / SEO Considerations - -- On initial load: full SSR with correct metadata (title, thumbnail, description) — works today -- On client navigation: `router.replace` updates URL without triggering SSR — no metadata update, but that's acceptable (same tradeoff YouTube makes) -- For bots/crawlers: always hits SSR path → correct OG tags - ---- - -## Steps - -1. Create `MediaViewer` component (extract from `MediaContentView.tsx`) -2. Create `MediaDetails` component (extract from `MediaContentView.tsx`) -3. Add `onSelect` prop to `RelatedMediaContentList` -4. Add `fetchRelatedMediaClient` to `src/fetchers/library/fetchRelatedMedia.tsx` -5. Create `MediaContentPlayer` client component -6. Update `page.tsx` to render `MediaContentPlayer` instead of `MediaContentView` -7. Delete `MediaContentView.tsx` (now split into player + viewer + details) -8. Fix `dbClient()` connection reuse bug (production creates new client per request) -9. Fix `p_fetchMediaContentWithMetaData` — pass `limit: 1` when fetching by ID -10. Verify: click related item → no skeleton, URL updates, browser back works - ---- - -## General Performance Issues (Fix alongside this) - -### DB Connection (`src/app/api/lib/db/index.ts`) -Current production code creates a new `MongoClient` on every `dbClient()` call: -```ts -// BROKEN in production: -client = new MongoClient(uri, options); -mongoClientPromise = client.connect(); // new connection every call! -``` -Fix: apply the same `global._mongoClientPromise` pattern for production, or use a module-level singleton. - -### In-memory cache (`src/app/api/media-content/index.ts`) -The `cache: Record` object is process-local. On serverless (Vercel), each cold start loses it. Use Next.js `unstable_cache` or route-level `revalidate` (already set to 360s on the page) instead of rolling your own. - -### Pipeline default limit -`p_fetchMediaContentWithMetaData` defaults `limit: 1000` — fine for list views, bad for single-item lookups. Always pass explicit limit. - -### MongoDB indexes to add (via migration) -- `media_content`: `{ grade_id: 1 }`, `{ subject_id: 1 }`, `{ unit_id: 1 }`, `{ topic_id: 1 }`, `{ name: 1 }` -- `users`: `{ email: 1 }` (unique) -- `user_role_mappings`: `{ user_id: 1 }` (unique) -- `media_reactions`: `{ media_content_id: 1, user_id: 1 }` (unique compound) diff --git a/docs/plans/02-admin-panel-improvements.md b/docs/plans/02-admin-panel-improvements.md deleted file mode 100644 index ddf26ae5..00000000 --- a/docs/plans/02-admin-panel-improvements.md +++ /dev/null @@ -1,136 +0,0 @@ -# Plan: Admin Panel Improvements - -**Goal:** Make the admin panel functional, well-organized, and ready for institution-scoped management. Focus on real missing functionality, not cosmetic changes. - ---- - -## Current State - -The admin panel provides basic CRUD for: Schools, Programs, Courses, Units, Topics, Subjects, Grades, Media Content, Users, Roles, Settings (pages, page actions), and Institutions (page exists, likely not in nav). - -### Identified Gaps - -| Gap | Impact | -|-----|--------| -| Role permissions not enforced (content-manager can't manage content) | #412 | -| User update fails with 500 | #427 | -| No institution management in sidebar nav | Institutions feature blocked | -| Stats dashboard shows counts only — no trends, no recent activity | Low insight | -| No pagination UI on list pages | All data loaded at once | -| No search/filter in admin tables | Hard to find items at scale | -| No audit log | No accountability | -| Settings page incomplete | #119 | -| No setup/onboarding flow | #380 | -| `withMetaData` flag inconsistently used | Extra DB load | - ---- - -## Priority 1: Fix Broken Things - -### 1a. Fix user update 500 error (#427) -**File:** `src/app/api/users/edit.ts` (line in `edit.ts`) -**Likely cause:** `zfd.formData()` parsing a JSON body. `zfd` expects `FormData`; the handler uses `request.json()` then passes to `zfd.formData().parse()`. Mismatch causes a parse error → uncaught → 500. -**Fix:** Use `z.object()` instead of `zfd.formData()` for JSON body parsing, or parse raw JSON and validate with plain zod. - -### 1b. Enforce content-manager role (#412) -**Current state:** `withAuthorization(RootLayout, { requireAdmin: true })` — any non-admin is blocked from admin panel. -**Fix:** -- Extend `withAuthorization` HOC to accept `{ requireAdmin?: boolean; allowedRoles?: string[] }` -- Route-level: admin-destructive pages (delete users, manage roles) require `Admin` role -- Content pages (media-content, units, topics) also allow `Content Manager` -- `src/app/admin/layout.tsx` stays `requireAdmin` (broad gate) -- Individual pages that content managers should access use a more permissive check -- Add role check in sidebar — hide nav items the user can't access - -### 1c. Add Institutions to admin sidebar nav -**File:** `src/hooks/useNavigation/constants.ts` (or wherever admin menu items are defined) -Add Institutions entry pointing to `/admin/institutions` - ---- - -## Priority 2: Usability - -### 2a. Pagination in admin tables -- All list pages currently fetch with `limit`/`skip` but no UI pagination -- Add a `Pagination` component using flowbite-react `Pagination` -- Wire to existing `limit`/`skip` query params in each list hook -- Start with: Users, Media Content (largest tables) - -### 2b. Search/filter in admin tables -- Add a debounced search input to `AdminTable` component -- Uses existing `findByName` endpoints (already exist for media content, users) -- Standardize the `findByName` pattern across all entities that lack it (programs, courses, etc.) - -### 2c. Admin dashboard improvements -- Add recent uploads (last 5 media items with links) -- Add recent user registrations (last 5 users) -- Add quick action cards: "Upload content", "Add user", "Create course" -- Keep existing stat count cards - -### 2d. Bulk operations -- `AdminTable` already has selection checkboxes — extend to support bulk status changes -- Add "bulk publish/unpublish" for media content (requires `is_visible` field on `media_content`) - ---- - -## Priority 3: Completeness - -### 3a. Settings page (#119) -The settings page (`/admin/settings`) exists but is minimal. Build: -- **Site settings:** site name, logo, contact email (stored in `settings` collection) -- **Feature flags:** enable/disable features (search, reactions, bookmarks) -- **Default role:** which role to assign to new users -- All settings read/written via `/api/config` endpoint (already exists, check `src/app/api/config`) - -### 3b. Setup/onboarding page (#380) -First-run wizard for new deployments: -1. Create admin account -2. Set site name -3. Create first institution -4. Seed default roles (migration already exists: `addDefaultRoles`) -5. Upload first content -Show checklist on dashboard until all steps complete. Track completion in `settings` collection key `setup_complete`. - -### 3c. Audit log -- New collection: `audit_log` — fields: `action`, `entity`, `entity_id`, `actor_id`, `timestamp`, `metadata` -- Write log entry after every create/update/delete in API handlers -- Add `/admin/audit` page showing filterable log - ---- - -## File Map - -``` -src/ - app/ - admin/ - institutions/page.tsx — already exists, wire up - audit/page.tsx — NEW - settings/page.tsx — expand - components/ - admin/ - AdminTable/AdminTable.tsx — add pagination + search - AdminTable/Pagination.tsx — NEW - institutions/ — NEW (list, create, edit views) - hooks/ - useNavigation/constants.ts — add Institutions nav item - useInstitution/ — already exists, verify completeness - app/ - api/ - audit/route.ts — NEW - users/edit.ts — fix zfd/JSON mismatch -``` - ---- - -## Steps (in order) - -1. Fix user update 500 (`src/app/api/users/edit.ts`) -2. Add Institutions to sidebar nav -3. Fix role enforcement — extend `withAuthorization` + hide inaccessible nav items -4. Add pagination to `AdminTable` + wire to Users and Media Content pages -5. Add search input to `AdminTable` -6. Complete Settings page (site config, feature flags) -7. Build setup/onboarding wizard -8. Add audit log (collection + API + admin page) -9. Improve dashboard with recent activity diff --git a/docs/plans/03-database-model-review.md b/docs/plans/03-database-model-review.md deleted file mode 100644 index f28c319f..00000000 --- a/docs/plans/03-database-model-review.md +++ /dev/null @@ -1,218 +0,0 @@ -# Plan: Database Model Review + Institution Readiness - -**Goal:** Fix schema bugs, standardize conventions, add missing indexes, and prepare all tables for institution-scoped multi-tenancy without breaking current functionality. - ---- - -## Current Collections - -| Collection | Status | -|---|---| -| `users` | Missing `institution_id`, field naming inconsistent | -| `schools` | Legacy — being migrated to `institutions` | -| `institutions` | New — schema looks good, not fully wired | -| `programs` | Has `school_id` — should get `institution_id` | -| `courses` | Has `institution_id` in TS but `institutions_id` in schema (typo) | -| `units` | No institution link | -| `subjects` | No institution link | -| `grades` | No institution link | -| `topics` | No institution link | -| `media_content` | No `institution_id`, no `is_visible` flag | -| `user_roles` | `permission_ids` typed as `user_permissions[]` but should be `ObjectId[]` | -| `user_role_mappings` | Good — bridges users and roles | -| `user_permissions` | Exists but not enforced anywhere | -| `settings` | Typo: `upated_at` instead of `updated_at` | -| `preferences` | `updated_by_id` typed as `Date` instead of `ObjectId` | -| `bookmarks` | Schema exists — not wired to any UI | -| `media_reactions` | Good | -| `page_views` | Good | -| `searches` | Good | -| `content_categories` | In collections, no schema defined | -| `feed_back` | All fields optional including `_id` — risky | -| `languages` | All optional — should have required `name` | -| `page_links` | Uses `page-links` (hyphen) in DB name — inconsistent with `page_actions` using `page-actions` | - ---- - -## Bug Fixes (do now) - -### 1. `settings` schema typo -```ts -// src/app/api/lib/db/schema.ts line ~244 -upated_at: 'date?', // BUG -updated_at: 'date?', // FIX -``` -Also fix the TypeScript type: `upated_at?: Date` → `updated_at?: Date` - -### 2. `preferences` wrong type -```ts -updated_by_id?: Date; // BUG — should be ObjectId -updated_by_id?: ObjectId; // FIX -``` - -### 3. `courses` schema inconsistency -TypeScript type has `institution_id?: ObjectId` but Realm schema has `institutions_id: 'objectId?'` -```ts -// Fix schema key name: -institution_id: 'objectId?', // not institutions_id -``` - -### 4. `user_roles.permission_ids` type -Currently typed `permission_ids: user_permissions[]` — embeds full objects. -In MongoDB this is stored as references (ObjectIds). Fix the TS type: -```ts -permission_ids: ObjectId[]; // references, not embedded -``` - -### 5. `feed_back` — `_id` should be required -```ts -_id: ObjectId; // not optional -``` - -### 6. `p_fetchRandomMediaContent` — unprotected `$unwind` -In `pipelines.ts`, the `$unwind: '$user'` has no `preserveNullAndEmptyArrays: true`. Any media without a `created_by_id` user will be silently dropped from random results. Add `preserveNullAndEmptyArrays: true`. - ---- - -## Institution Readiness (schema additions — non-breaking) - -### Strategy -- Add `institution_id` as **optional** field to all content tables -- When `institution_id` is null/absent → global content (visible to all) -- When set → content scoped to that institution only -- This is the simplest approach: additive, backward-compatible, no migration needed for existing data - -### Fields to add - -| Collection | Field to add | -|---|---| -| `media_content` | `institution_id?: ObjectId` | -| `programs` | `institution_id?: ObjectId` (prefer over `school_id` long term) | -| `courses` | already has it (fix typo first) | -| `units` | `institution_id?: ObjectId` | -| `subjects` | `institution_id?: ObjectId` | -| `grades` | `institution_id?: ObjectId` | -| `topics` | `institution_id?: ObjectId` | -| `users` | `institution_id?: ObjectId` (primary affiliation) | - -### New collection: `institution_memberships` -For users belonging to multiple institutions: -```ts -type institution_memberships = { - _id: ObjectId; - user_id: ObjectId; - institution_id: ObjectId; - role: 'admin' | 'member'; // institution-level role - joined_at: Date; - invited_by_id?: ObjectId; -} -``` -Index: `{ user_id: 1, institution_id: 1 }` unique compound - -### New collection: `institution_invites` -For invite-based onboarding: -```ts -type institution_invites = { - _id: ObjectId; - institution_id: ObjectId; - email: string; - token: string; // hashed invite token - invited_by_id: ObjectId; - expires_at: Date; - used_at?: Date; -} -``` -Index: `{ token: 1 }` unique, `{ email: 1, institution_id: 1 }` - ---- - -## MongoDB Indexes to Add - -Add a migration file: `src/app/api/lib/db/migrations/addIndexes.ts` - -```ts -// media_content -db.collection('media_content').createIndex({ grade_id: 1 }) -db.collection('media_content').createIndex({ subject_id: 1 }) -db.collection('media_content').createIndex({ unit_id: 1 }) -db.collection('media_content').createIndex({ topic_id: 1 }) -db.collection('media_content').createIndex({ institution_id: 1 }) -db.collection('media_content').createIndex({ name: 'text' }) // for text search - -// users -db.collection('users').createIndex({ email: 1 }, { unique: true }) -db.collection('users').createIndex({ institution_id: 1 }) - -// user_role_mappings -db.collection('user-role-mappings').createIndex({ user_id: 1 }, { unique: true }) - -// media_reactions -db.collection('media_reactions').createIndex({ media_content_id: 1, user_id: 1 }, { unique: true }) - -// institution_memberships -db.collection('institution_memberships').createIndex({ user_id: 1, institution_id: 1 }, { unique: true }) -db.collection('institution_memberships').createIndex({ institution_id: 1 }) - -// institution_invites -db.collection('institution_invites').createIndex({ token: 1 }, { unique: true }) -db.collection('institution_invites').createIndex({ email: 1, institution_id: 1 }) - -// programs / courses -db.collection('programs').createIndex({ institution_id: 1 }) -db.collection('courses').createIndex({ institution_id: 1 }) -``` - ---- - -## DB Connection Fix (`src/app/api/lib/db/index.ts`) - -Current production code creates a fresh `MongoClient` on every `dbClient()` call. Fix: - -```ts -// Use module-level singleton for production too -if (!global._mongoClientPromise) { - const client = new MongoClient(uri, options); - global._mongoClientPromise = client.connect(); -} -const mongoClientPromise = global._mongoClientPromise; - -export const dbClient = async () => { - const client = await mongoClientPromise; - return client.db(process.env.MONGODB_DB); -}; -``` - -Remove the try/catch that returns `null` from `dbClient()` — callers already handle null, but a real connection error should throw/surface, not silently return null and cause confusing downstream errors. - ---- - -## TypeScript Types to Update - -**`src/types/media-content/index.ts`:** -Add `institution_id?: string` and `external_url?: string` to `T_RawMediaContentFields` - -**`src/app/api/lib/db/schema.ts`:** -- Fix all bugs listed above -- Add `institution_id` to relevant schemas -- Add `institution_memberships` and `institution_invites` schemas - -**`src/app/api/lib/db/collections.ts`:** -- Add `institution_memberships` and `institution_invites` entries -- Fix `page_links` label (currently says "Media Content" — copy-paste bug) - -**`src/app/api/lib/db/types.ts`:** -- Add `institution_memberships` and `institution_invites` to `T_dbCollection` - ---- - -## Steps (in order) - -1. Fix schema bugs (settings typo, preferences type, courses inconsistency, user_roles type, feed_back _id) -2. Fix `p_fetchRandomMediaContent` unwind bug -3. Fix DB connection singleton -4. Add `institution_id` (optional) to all content collections' TS types and schemas -5. Add `institution_memberships` and `institution_invites` schemas + collections -6. Write `addIndexes` migration -7. Update `T_dbCollection` type and `dbCollections` object -8. Fix `page_links` label copy-paste bug in collections -9. Update API query handlers to filter by `institution_id` when present in session (future step — just wire the optional filter for now) diff --git a/docs/plans/04-github-issues-triage.md b/docs/plans/04-github-issues-triage.md deleted file mode 100644 index b7eab17e..00000000 --- a/docs/plans/04-github-issues-triage.md +++ /dev/null @@ -1,181 +0,0 @@ -# Plan: GitHub Issues Triage - -**Repo:** SparkEdUAB/sparked-next -**Date reviewed:** 2026-04-19 - ---- - -## Issues to Fix - -### #437 — Improve Resource view layout -**Status:** Fix -**Plan:** See `01-resource-view-redesign.md`. Convert to client-driven media swap, no full reload on related item click. This is the most impactful UX fix. - ---- - -### #430 — Replace eslint and fix all lint errors & warnings -**Status:** Fix -**What to do:** -- Project already uses `oxlint` (`"lint": "npx oxlint --fix"` in package.json) -- ESLint 8.57.1 is still installed as a dependency — remove it and its config once oxlint covers all cases -- Run `npx oxlint --fix` across the codebase -- Fix remaining warnings manually (unused vars, missing deps in useEffect, etc.) -- Remove `eslint`, `eslint-config-next` from `package.json` after confirming oxlint handles everything -- Note: `eslint-config-next` has Next.js-specific rules (no-img-element, etc.) — ensure oxlint or a custom rule covers these - ---- - -### #427 — Error 500 when updating a user from the dashboard -**Status:** Fix -**Root cause:** `src/app/api/users/edit.ts` uses `zfd.formData()` (which parses FormData) but the handler does `request.json()` and passes JSON object to the zfd schema. This causes a parse failure → unhandled → 500. -**Fix:** Replace `zfd.formData({ ... })` with `z.object({ ... })` and validate with `schema.parse(formBody)` directly. - ---- - -### #412 — Revisit user roles (content-manager) -**Status:** Fix -**Plan:** See `02-admin-panel-improvements.md` section 1b. Extend `withAuthorization` HOC to support `allowedRoles`. Content Manager role allows CRUD on media, units, topics, subjects, grades, programs — but not user management or role management. Default roles already seeded via `addDefaultRoles` migration. - ---- - -### #411 — Login not working as expected in production -**Status:** Fix -**What to do:** -- Issue: login redirects to URL with params instead of completing login flow -- Investigate `src/app/api/authentication` and `next-auth` config -- Likely cause: `callbackUrl` is being set to the login page URL with query params, creating a redirect loop -- Check `pages.signIn` config and `redirect` callback in NextAuth options -- Ensure `NEXTAUTH_URL` env var is set correctly in production -- Check if `withAuthorization` HOC's redirect logic passes the full URL (including search params) as `callbackUrl` — if so, strip search params before redirecting - ---- - -### #382 — Provide list of existing higher level institutions on login -**Status:** Fix (partially, when institution feature is built) -**What to do:** -- On login/signup page, add institution selector dropdown -- Fetch institutions list via public endpoint `/api/institution?limit=50` -- User selects their institution during registration → stored as `institution_id` on user -- Gate: only show if institutions exist (skip for solo deployments) -- Blocked by: institution management being complete (#381 foundation) - ---- - -### #381 — Every institution should depend on a higher level organisation -**Status:** Keep open — future roadmap -**Context:** This is about institutional hierarchy (e.g., school → district → ministry). Not needed for v1 of institution support. The current `institutions` schema supports a flat model. Add `parent_institution_id?: ObjectId` to `institutions` schema as optional now so the field exists when needed. - ---- - -### #380 — Add setup page -**Status:** Fix -**Plan:** See `02-admin-panel-improvements.md` section 3b. First-run wizard: create admin, set site name, create institution, seed roles, upload first content. - ---- - -### #378 — Serve resources based on the institution -**Status:** Fix (when institution_id added to content) -**What to do:** -- After `03-database-model-review.md` adds `institution_id` to content tables: -- API handlers for `fetchMediaContent_` add optional filter: if user's session has `institution_id`, add `{ institution_id: new BSON.ObjectId(session.institution_id) }` to query (or include null/absent docs as global content) -- Library layout reads institution context from session -- No UI changes needed — filtering is transparent - ---- - -### #331 — Add more actions to content view -**Status:** Fix -**What to do:** -- Download button (if file_url is a direct file) -- Share button (copy link to clipboard) -- Report button (submits to `feed_back` collection) -- Bookmark button (schema already exists in `bookmarks` collection — not wired yet → see #329) -- Add these to `MediaDetails` component (see resource view plan) - ---- - -### #329 — Save reading materials for later access (bookmarks) -**Status:** Fix -**What to do:** -- `bookmarks` collection already defined in schema -- Add `POST /api/bookmarks` — create bookmark for `resource_id` + `user_id` -- Add `GET /api/bookmarks` — fetch user's bookmarks -- Add `DELETE /api/bookmarks/:id` -- Add bookmark icon button to `MediaDetails` (filled = bookmarked, outline = not) -- Add `/library/bookmarks` page listing saved items -- Auth required (hide button if not logged in) - ---- - -### #168 — Setup tests (unit & e2e) -**Status:** Fix -**What to do:** -- Vitest is already configured (`vitest.config.ts`, `vitest.setup.tsx`) — write unit tests -- Playwright is configured (`playwright.config.ts`) — write e2e tests -- Priority unit tests: - - `determineFileType` helper - - `fetchRelatedMedia` fetcher (mock fetch) - - `MediaContentPlayer` component (test state updates on related item click) - - API handlers (mock `dbClient`) -- Priority e2e tests: - - Library page loads and lists content - - Click media item → view page loads - - Click related item → content swaps without full reload - - Admin login flow - - Create media content flow - ---- - -### #119 — Settings page for configuring different things -**Status:** Fix -**Plan:** See `02-admin-panel-improvements.md` section 3a. - ---- - -### #72 — Implement a global search -**Status:** Fix -**What to do:** -- Search UI already exists at `/library/search` with `SearchMediaContentList` -- Backend `findMediaContentByName_` exists -- Gap: search is only in library, not global (doesn't search programs, courses, etc.) -- Fix: extend search to include content hierarchy items -- Add MongoDB text index on `media_content.name` and `media_content.description` -- Add keyboard shortcut (Cmd+K / Ctrl+K) to open search from anywhere -- Debounce already implemented via `use-debounce` hook - ---- - -## Issues to Close - -### #335 — Add automated PDF summary & automated quiz -**Status:** Close (wontfix label already applied) -**Reason:** Out of scope for current project focus. AI summarization requires external service integration. Mark as `wontfix` and close. - -### #330 — Track pages read in a PDF per user -**Status:** Close (wontfix label already applied) -**Reason:** High complexity (browser PDF rendering events are unreliable), low ROI, wontfix label already applied. - ---- - -## Issues to Keep Open (no action now) - -### #130 — Dependency Dashboard -**Status:** Keep open -**Reason:** Automated Renovate bot issue. Self-manages. Do not close. - ---- - -## Recommended Fix Order - -1. #427 — User update 500 (quick bug fix, high impact) -2. #411 — Login redirect bug (production blocker) -3. #437 — Resource view layout (main UX improvement) -4. #412 — User roles enforcement -5. #380 — Setup page -6. #329 — Bookmarks -7. #331 — Content view actions (share, download, report, bookmark) -8. #382 — Institution selector on login -9. #72 — Global search improvements -10. #119 — Settings page -11. #430 — ESLint → oxlint migration -12. #168 — Tests diff --git a/docs/plans/05-database-migrations.md b/docs/plans/05-database-migrations.md deleted file mode 100644 index 62ccbaa4..00000000 --- a/docs/plans/05-database-migrations.md +++ /dev/null @@ -1,266 +0,0 @@ -# Plan: Database Migration System - -**Goal:** Replace the ad-hoc migration approach (single `addDefaultRoles.ts` run at startup) with a versioned, trackable migration system that is safe to run in production, idempotent, and easy to extend. - ---- - -## Current State - -- One migration file: `src/app/api/lib/db/migrations/addDefaultRoles.ts` -- Invoked in `src/app/api/lib/db/init.ts` via `initializeDatabase()` — called at app startup -- No tracking: no record of which migrations have run, no ordering, no rollback -- If more migrations are added to `initializeDatabase()`, they all run every startup (even if already applied) — only idempotency guards prevent damage -- No CLI tool to run migrations manually or check status - ---- - -## Design - -### Migration Record Collection - -Add a `_migrations` collection to MongoDB. Each applied migration gets one document: - -```ts -type MigrationRecord = { - _id: ObjectId; - name: string; // e.g. "001-add-default-roles" - applied_at: Date; - duration_ms: number; -} -``` - -Index: `{ name: 1 }` unique — prevents double-apply at the DB level. - -### Migration File Convention - -Each migration is a `.ts` file in `src/app/api/lib/db/migrations/`: - -``` -migrations/ - 001-add-default-roles.ts - 002-add-indexes.ts - 003-fix-schema-typos.ts - 004-add-institution-id-fields.ts - 005-add-institution-collections.ts -``` - -Naming: `NNN-kebab-description.ts` where `NNN` is zero-padded integer. Migrations run in filename order. - -Each file exports one async function named `up`: - -```ts -// migrations/002-add-indexes.ts -import { Db } from 'mongodb'; - -export async function up(db: Db): Promise { - await db.collection('media_content').createIndex({ grade_id: 1 }); - // ... more indexes -} - -export const name = '002-add-indexes'; -``` - -### Migration Runner - -`src/app/api/lib/db/migrate.ts` — core runner: - -```ts -import { Db } from 'mongodb'; - -export async function runMigrations(db: Db): Promise { - // 1. Ensure _migrations collection + unique index exist - await db.collection('_migrations').createIndex({ name: 1 }, { unique: true }); - - // 2. Discover all migration modules (statically imported registry) - const migrations = getMigrationRegistry(); // see below - - // 3. For each migration in order: - for (const migration of migrations) { - const already = await db.collection('_migrations').findOne({ name: migration.name }); - if (already) continue; - - const start = Date.now(); - await migration.up(db); - await db.collection('_migrations').insertOne({ - name: migration.name, - applied_at: new Date(), - duration_ms: Date.now() - start, - }); - - console.log(`[migrate] Applied: ${migration.name}`); - } -} -``` - -### Migration Registry - -Since Next.js/Node doesn't support dynamic `fs.readdir` in edge/serverless, use a static registry: - -```ts -// src/app/api/lib/db/migrations/index.ts -import { up as up001, name as name001 } from './001-add-default-roles'; -import { up as up002, name as name002 } from './002-add-indexes'; -// ... add new migrations here - -export const migrations = [ - { name: name001, up: up001 }, - { name: name002, up: up002 }, -]; -``` - -Adding a migration = create the file + add one line to the registry. No magic, no filesystem scanning. - -### Startup Integration - -Update `src/app/api/lib/db/init.ts`: - -```ts -import { dbClient } from '.'; -import { runMigrations } from './migrate'; - -export async function initializeDatabase() { - const db = await dbClient(); - if (!db) return; - await runMigrations(db); -} -``` - -`initializeDatabase()` is already called at startup — no change needed at call site. - -### CLI Script - -Add `src/scripts/migrate.ts` for manual runs (useful for production deploys, debugging): - -```ts -// Run with: npx tsx src/scripts/migrate.ts -import { MongoClient } from 'mongodb'; -import { runMigrations } from '../app/api/lib/db/migrate'; - -async function main() { - const client = new MongoClient(process.env.MONGODB_URI!); - await client.connect(); - const db = client.db(process.env.MONGODB_DB); - await runMigrations(db); - await client.close(); - console.log('[migrate] Done.'); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); -``` - -Add to `package.json`: -```json -"migrate": "dotenv -e .env.local -- tsx src/scripts/migrate.ts" -``` - ---- - -## Migrations to Write (from Plan 03) - -### 001-add-default-roles.ts -Rename/refactor `addDefaultRoles.ts` → `001-add-default-roles.ts`. Export `up(db)` + `name`. - -### 002-add-indexes.ts -```ts -export async function up(db: Db) { - // media_content - await db.collection('media_content').createIndex({ grade_id: 1 }); - await db.collection('media_content').createIndex({ subject_id: 1 }); - await db.collection('media_content').createIndex({ unit_id: 1 }); - await db.collection('media_content').createIndex({ topic_id: 1 }); - await db.collection('media_content').createIndex({ name: 'text' }); - - // users - await db.collection('users').createIndex({ email: 1 }, { unique: true }); - - // user_role_mappings - await db.collection('user-role-mappings').createIndex({ user_id: 1 }, { unique: true }); - - // media_reactions - await db.collection('media_reactions').createIndex( - { media_content_id: 1, user_id: 1 }, - { unique: true } - ); -} -export const name = '002-add-indexes'; -``` - -### 003-fix-schema-typos.ts -MongoDB document fixes (rename fields on existing documents): -```ts -export async function up(db: Db) { - // Fix settings.upated_at → updated_at - await db.collection('settings').updateMany( - { upated_at: { $exists: true } }, - [{ $set: { updated_at: '$upated_at' } }, { $unset: 'upated_at' }] - ); - - // Fix courses.institutions_id → institution_id - await db.collection('courses').updateMany( - { institutions_id: { $exists: true } }, - [{ $set: { institution_id: '$institutions_id' } }, { $unset: 'institutions_id' }] - ); -} -export const name = '003-fix-schema-typos'; -``` - -### 004-add-institution-id-fields.ts -Adds `institution_id` index to content collections once the field starts being populated: -```ts -export async function up(db: Db) { - for (const col of ['programs', 'courses', 'units', 'subjects', 'grades', 'topics', 'media_content', 'users']) { - await db.collection(col).createIndex({ institution_id: 1 }); - } -} -export const name = '004-add-institution-id-fields'; -``` - -### 005-add-institution-collections.ts -Create `institution_memberships` and `institution_invites` with their indexes: -```ts -export async function up(db: Db) { - await db.createCollection('institution_memberships'); - await db.collection('institution_memberships').createIndex( - { user_id: 1, institution_id: 1 }, - { unique: true } - ); - await db.collection('institution_memberships').createIndex({ institution_id: 1 }); - - await db.createCollection('institution_invites'); - await db.collection('institution_invites').createIndex({ token: 1 }, { unique: true }); - await db.collection('institution_invites').createIndex({ email: 1, institution_id: 1 }); -} -export const name = '005-add-institution-collections'; -``` - ---- - -## Steps (in order) - -1. Update `dbClient()` to use singleton (Plan 03 prerequisite — needed so runner uses same connection) -2. Create `src/app/api/lib/db/migrate.ts` (runner) -3. Create `src/app/api/lib/db/migrations/index.ts` (registry) -4. Rename `addDefaultRoles.ts` → `001-add-default-roles.ts`, add `up(db)` export + `name` -5. Update `init.ts` to call `runMigrations(db)` instead of `addDefaultRoles()` -6. Write `002-add-indexes.ts` -7. Write `003-fix-schema-typos.ts` -8. Write `004-add-institution-id-fields.ts` -9. Write `005-add-institution-collections.ts` -10. Add all 5 to registry in `migrations/index.ts` -11. Add `migrate` script to `package.json` -12. Write `src/scripts/migrate.ts` -13. Test: run `npm run migrate` locally — confirm `_migrations` collection populated, all 5 applied once, second run is a no-op - ---- - -## Key Properties - -- **Idempotent**: `_migrations` unique index + existence check = safe to run multiple times -- **Ordered**: static registry preserves insertion order -- **Fast startup**: skip already-applied migrations with a single `findOne` -- **No framework dependency**: plain MongoDB driver, no mongoose/prisma -- **No rollback**: MongoDB schema changes are largely additive — rollback files add complexity with little benefit for this stack. Revert via a new forward migration if needed. -- **No env drift**: `npm run migrate` CLI + startup auto-run = same code path either way diff --git a/package.json b/package.json index e5e64e24..412777eb 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "@wojtekmaj/react-hooks": "^2.0.1", "axios": "^1.5.0", "bcryptjs": "^3.0.2", - "eslint": "8.57.1", + "eslint": "^9.0.0", "eslint-config-next": "^16.2.1", "flowbite": "^3.1.2", "flowbite-react": "^0.9.0", From 5b10acd8a3cb81f0c81e2c3865f6ac2a740880a7 Mon Sep 17 00:00:00 2001 From: Olivier JM Maniraho Date: Sun, 19 Apr 2026 23:42:09 +0200 Subject: [PATCH 17/18] clean up and fix user mappings --- src/app/api/auth/authOptions.ts | 21 +++++++++-- src/app/api/auth/login.ts | 32 ++++++++++++++-- src/app/api/auth/signup.ts | 15 +++++++- src/app/api/lib/db/index.ts | 16 ++++++-- src/app/api/lib/db/migrate.ts | 11 ++++-- .../api/lib/db/migrations/002-add-indexes.ts | 37 ++++++++++++++++--- .../006-backfill-user-role-mappings.ts | 31 ++++++++++++++++ src/app/api/lib/db/migrations/index.ts | 2 + src/hocs/withAuthorization.tsx | 21 ++++++----- src/hooks/useAuth/index.ts | 18 ++++++--- src/types/next-auth.d.ts | 4 ++ 11 files changed, 171 insertions(+), 37 deletions(-) create mode 100644 src/app/api/lib/db/migrations/006-backfill-user-role-mappings.ts diff --git a/src/app/api/auth/authOptions.ts b/src/app/api/auth/authOptions.ts index 17fb49c1..b2d28ec6 100644 --- a/src/app/api/auth/authOptions.ts +++ b/src/app/api/auth/authOptions.ts @@ -12,11 +12,10 @@ export const authOptions: NextAuthOptions = { // @ts-expect-error async authorize(credentials) { // @ts-expect-error - const { jwtToken } = credentials; + const { jwtToken, id, email, role, firstName, lastName, phoneNumber, avatar } = credentials; try { - const user = { ...credentials }; // Extract user details from credentials - return { ...user, token: jwtToken }; + return { id, token: jwtToken, email, role, firstName, lastName, phone: phoneNumber, avatar }; } catch { return null; } @@ -28,10 +27,14 @@ export const authOptions: NextAuthOptions = { }, callbacks: { async session({ session, token }) { - if (token.sub && session.user) { + if (session.user) { session.user.id = token.sub; session.user.role = token.role as string; session.role = token.role as string; + session.user.firstName = token.firstName as string | undefined; + session.user.lastName = token.lastName as string | undefined; + session.user.phone = token.phone as string | undefined; + session.user.avatar = token.avatar as string | undefined; } return session; }, @@ -40,11 +43,21 @@ export const authOptions: NextAuthOptions = { token.id = user.id; token.email = user.email; token.role = user.role ?? token.role; + token.firstName = user.firstName; + token.lastName = user.lastName; + token.phone = user.phone; + token.avatar = user.avatar; } if (token.sub) { const db = await dbClient(); if (db) { + const dbUser = await db + .collection(dbCollections.users.name) + .findOne({ _id: new BSON.ObjectId(token.sub) }, { projection: { _id: 1 } }); + + if (!dbUser) return null; // invalidate session if user no longer exists + const roleMapping = await db.collection(dbCollections.user_role_mappings.name).findOne({ user_id: new BSON.ObjectId(token.sub), }); diff --git a/src/app/api/auth/login.ts b/src/app/api/auth/login.ts index 61c057af..ac44da80 100644 --- a/src/app/api/auth/login.ts +++ b/src/app/api/auth/login.ts @@ -42,6 +42,10 @@ export default async function login_(request: Request) { role: 1, is_verified: 1, password: 1, + firstName: 1, + lastName: 1, + phoneNumber: 1, + avatar: 1, }, }, ); @@ -67,17 +71,33 @@ export default async function login_(request: Request) { }); } - const userRole = await db + let userRoleResult = await db .collection(dbCollections.user_role_mappings.name) .aggregate(p_fetchUserRoleDetails({ userId: `${user._id}` })) .toArray(); + if (!userRoleResult[0]) { + const defaultRole = await db + .collection(dbCollections.user_roles.name) + .findOne({ name: 'student' }); + + if (defaultRole) { + await db.collection(dbCollections.user_role_mappings.name).insertOne({ + user_id: user._id, + role_id: defaultRole._id, + created_at: new Date(), + }); + + userRoleResult = [{ role_details: defaultRole }]; + } + } + let role: null | T_RECORD = null; - if (userRole[0]) { + if (userRoleResult[0]) { role = { - id: userRole[0].role_details._id, - name: userRole[0].role_details.name, + id: userRoleResult[0].role_details._id, + name: userRoleResult[0].role_details.name, }; } @@ -92,6 +112,10 @@ export default async function login_(request: Request) { id: user._id.toString(), email: user.email, role: role?.name, + firstName: user.firstName, + lastName: user.lastName, + phoneNumber: user.phoneNumber, + avatar: user.avatar, }, jwtToken: token, }; diff --git a/src/app/api/auth/signup.ts b/src/app/api/auth/signup.ts index 86cb4f8d..03933f9c 100644 --- a/src/app/api/auth/signup.ts +++ b/src/app/api/auth/signup.ts @@ -87,7 +87,20 @@ export default async function signup_(request: Request) { userDoc.grade = grade; } - await db.collection(dbCollections.users.name).insertOne(userDoc); + const studentRole = await db.collection(dbCollections.user_roles.name).findOne({ name: 'student' }); + if (!studentRole) { + return new Response(JSON.stringify({ isError: true, code: SPARKED_PROCESS_CODES.DB_CONNECTION_FAILED }), { + status: HttpStatusCode.InternalServerError, + }); + } + + const insertResult = await db.collection(dbCollections.users.name).insertOne(userDoc); + + await db.collection(dbCollections.user_role_mappings.name).insertOne({ + user_id: insertResult.insertedId, + role_id: studentRole._id, + created_at: new Date(), + }); await resend.emails.send({ from: 'Sparked Support ', diff --git a/src/app/api/lib/db/index.ts b/src/app/api/lib/db/index.ts index d2624373..a091532a 100644 --- a/src/app/api/lib/db/index.ts +++ b/src/app/api/lib/db/index.ts @@ -1,10 +1,11 @@ -import { MongoClient } from "mongodb"; +import { MongoClient } from 'mongodb'; +import { runMigrations } from './migrate'; if (!process.env.MONGODB_URI) { console.error('Invalid/Missing environment variable: "MONGODB_URI"'); } -const uri = process.env.MONGODB_URI || "mongodb://...."; // Just to allow the build to pass +const uri = process.env.MONGODB_URI || 'mongodb://localhost:27017/sparked'; // Just to allow the build to pass const options = { serverSelectionTimeoutMS: 5000, connectTimeoutMS: 5000, @@ -13,6 +14,8 @@ const options = { declare global { // eslint-disable-next-line no-var var _mongoClientPromise: Promise | undefined; + // eslint-disable-next-line no-var + var _migrationsRun: boolean | undefined; } function getClientPromise(): Promise { @@ -30,7 +33,14 @@ function getClientPromise(): Promise { export const dbClient = async () => { try { const client = await getClientPromise(); - return client.db(process.env.MONGODB_DB); + const db = client.db(process.env.MONGODB_DB); + + if (!global._migrationsRun) { + global._migrationsRun = true; // set before await to prevent concurrent runs + await runMigrations(db); + } + + return db; } catch { return null; } diff --git a/src/app/api/lib/db/migrate.ts b/src/app/api/lib/db/migrate.ts index aac69fbf..68a5ef65 100644 --- a/src/app/api/lib/db/migrate.ts +++ b/src/app/api/lib/db/migrate.ts @@ -20,9 +20,12 @@ export async function runMigrations(db: Db): Promise { // the migration will re-run on the next startup. Every migration must be idempotent. try { await migration.up(db); - } catch (err) { - console.error(`[migrate] FAILED: ${migration.name}`, err); - throw err; + } catch (err: any) { + const detail = err?.errorResponse?.errmsg ?? err?.message ?? String(err); + console.error(`[migrate] FAILED: ${migration.name} — ${detail}`); + // Do not throw — app continues with remaining migrations skipped for this run. + // Fix the migration and restart to retry. + break; } await db.collection('_migrations').insertOne({ name: migration.name, @@ -30,6 +33,6 @@ export async function runMigrations(db: Db): Promise { duration_ms: Date.now() - start, }); - console.log(`[migrate] Applied: ${migration.name}`); + console.log(`[migrate] Applied: ${migration.name} (${Date.now() - start}ms)`); } } diff --git a/src/app/api/lib/db/migrations/002-add-indexes.ts b/src/app/api/lib/db/migrations/002-add-indexes.ts index 3fffef8d..a04c1b10 100644 --- a/src/app/api/lib/db/migrations/002-add-indexes.ts +++ b/src/app/api/lib/db/migrations/002-add-indexes.ts @@ -1,5 +1,29 @@ import { Db } from 'mongodb'; +async function deduplicateBefore( + db: Db, + collection: string, + groupFields: Record, +) { + const group: Record = { count: { $sum: 1 }, ids: { $push: '$_id' } }; + const matchKey: Record = {}; + for (const [alias, field] of Object.entries(groupFields)) { + group[alias] = { $first: `$${field}` }; + matchKey[alias] = `$${field}`; + } + + const duplicates = await db.collection(collection).aggregate([ + { $group: { _id: matchKey, ids: { $push: '$_id' }, count: { $sum: 1 } } }, + { $match: { count: { $gt: 1 } } }, + ]).toArray(); + + for (const dup of duplicates) { + const [, ...toDelete] = dup.ids; // keep oldest, delete the rest + await db.collection(collection).deleteMany({ _id: { $in: toDelete } }); + console.log(`[migrate:002] Removed ${toDelete.length} duplicate(s) from ${collection}`); + } +} + export async function up(db: Db): Promise { // media_content await db.collection('media_content').createIndex({ grade_id: 1 }); @@ -8,18 +32,19 @@ export async function up(db: Db): Promise { await db.collection('media_content').createIndex({ topic_id: 1 }); await db.collection('media_content').createIndex({ name: 'text' }); - // users — WARNING: will fail if duplicate emails exist in the collection. - // Before running on prod, verify: db.users.aggregate([{$group:{_id:"$email",n:{$sum:1}}},{$match:{n:{$gt:1}}}]) + // users — deduplicate by email first, then enforce uniqueness + await deduplicateBefore(db, 'users', { email: 'email' }); await db.collection('users').createIndex({ email: 1 }, { unique: true }); - // user_role_mappings (DB name is 'user-role-mappings' with hyphen) - // WARNING: will fail if duplicate user_id values exist in the collection. + // user-role-mappings — deduplicate by user_id first, then enforce uniqueness + await deduplicateBefore(db, 'user-role-mappings', { user_id: 'user_id' }); await db.collection('user-role-mappings').createIndex({ user_id: 1 }, { unique: true }); - // media_reactions + // media_reactions — deduplicate by compound key first, then enforce uniqueness + await deduplicateBefore(db, 'media_reactions', { media_content_id: 'media_content_id', user_id: 'user_id' }); await db.collection('media_reactions').createIndex( { media_content_id: 1, user_id: 1 }, - { unique: true } + { unique: true }, ); } export const name = '002-add-indexes'; diff --git a/src/app/api/lib/db/migrations/006-backfill-user-role-mappings.ts b/src/app/api/lib/db/migrations/006-backfill-user-role-mappings.ts new file mode 100644 index 00000000..42f2179f --- /dev/null +++ b/src/app/api/lib/db/migrations/006-backfill-user-role-mappings.ts @@ -0,0 +1,31 @@ +import { Db } from 'mongodb'; +import { dbCollections } from '../collections'; + +export const name = '006-backfill-user-role-mappings'; + +export async function up(db: Db): Promise { + const users = await db.collection(dbCollections.users.name).find({}, { projection: { _id: 1, role: 1 } }).toArray(); + + const roles = await db.collection(dbCollections.user_roles.name).find({}).toArray(); + const roleByName = new Map(roles.map((r) => [r.name.toLowerCase(), r])); + + const studentRole = roleByName.get('student'); + if (!studentRole) throw new Error('[006] student role not found in user_roles — run migration 001 first'); + + for (const user of users) { + const existing = await db + .collection(dbCollections.user_role_mappings.name) + .findOne({ user_id: user._id }); + + if (existing) continue; + + const roleName = typeof user.role === 'string' ? user.role.toLowerCase() : 'student'; + const matchedRole = roleByName.get(roleName) ?? studentRole; + + await db.collection(dbCollections.user_role_mappings.name).insertOne({ + user_id: user._id, + role_id: matchedRole._id, + created_at: new Date(), + }); + } +} diff --git a/src/app/api/lib/db/migrations/index.ts b/src/app/api/lib/db/migrations/index.ts index a434a7c2..fce076fe 100644 --- a/src/app/api/lib/db/migrations/index.ts +++ b/src/app/api/lib/db/migrations/index.ts @@ -4,6 +4,7 @@ import { up as up002, name as name002 } from './002-add-indexes'; import { up as up003, name as name003 } from './003-fix-schema-typos'; import { up as up004, name as name004 } from './004-add-institution-id-fields'; import { up as up005, name as name005 } from './005-add-institution-collections'; +import { up as up006, name as name006 } from './006-backfill-user-role-mappings'; export const migrations: Migration[] = [ { name: name001, up: up001 }, @@ -11,4 +12,5 @@ export const migrations: Migration[] = [ { name: name003, up: up003 }, { name: name004, up: up004 }, { name: name005, up: up005 }, + { name: name006, up: up006 }, ]; diff --git a/src/hocs/withAuthorization.tsx b/src/hocs/withAuthorization.tsx index cde7bd48..c2942f13 100644 --- a/src/hocs/withAuthorization.tsx +++ b/src/hocs/withAuthorization.tsx @@ -27,8 +27,6 @@ type ExtendedSession = { }; }; - - export function withAuthorization

( WrappedComponent: ComponentType

, { requireAdmin = false, requireGuest = false }: Options = {}, @@ -41,6 +39,8 @@ export function withAuthorization

( status: 'loading' | 'authenticated' | 'unauthenticated'; }; + console.log(session); + const user = useUser(); const setUser = useSetUser(); const clearUser = useClearUser(); @@ -60,15 +60,17 @@ export function withAuthorization

( setLoading(false); if (status === 'authenticated' && session?.user) { - // Only sync session to store if user is not already set - // This prevents overwriting the correct isAdmin value set during login + const sessionRole = session.user.role?.toLowerCase() as 'student' | 'user' | 'admin'; + const sessionIsAdmin = sessionRole === 'admin'; if (!user) { - const sessionUser = { + setUser({ ...session.user, - role: session.user.role as 'student' | 'user' | 'admin', - isAdmin: session.user.role?.toLowerCase() === 'admin', - }; - setUser(sessionUser); + role: sessionRole, + isAdmin: sessionIsAdmin, + }); + } else if (user.role?.toLowerCase() !== sessionRole || user.isAdmin !== sessionIsAdmin) { + // Role changed (e.g. user was promoted to admin) — sync from session + setUser({ ...user, role: sessionRole, isAdmin: sessionIsAdmin }); } } else if (status === 'unauthenticated') { if (user) { @@ -107,6 +109,7 @@ export function withAuthorization

( return; } + console.log(isAdmin, requireAdmin); // If requires admin and user is not admin, redirect to library if (requireAdmin && !isAdmin) { hasRedirected.current = true; diff --git a/src/hooks/useAuth/index.ts b/src/hooks/useAuth/index.ts index 9bd0a902..8d405505 100644 --- a/src/hooks/useAuth/index.ts +++ b/src/hooks/useAuth/index.ts @@ -90,26 +90,32 @@ const useAuth = () => { const decodedToken: { role?: { name: string } } = jwtDecode(jwtToken); const userRole: typeof decodedToken.role = decodedToken.role || { name: 'student' }; + const { user: loginUser } = responseData; + const singInResp = await signIn('credentials', { redirect: false, jwtToken, + id: loginUser?.id ?? '', email: fields.email, role: (userRole?.name ?? 'user') as 'student' | 'user' | 'admin', + firstName: loginUser?.firstName ?? '', + lastName: loginUser?.lastName ?? '', + phoneNumber: loginUser?.phoneNumber ?? '', + avatar: loginUser?.avatar ?? '', }); - + const isUserAdmin = userRole?.name?.toLowerCase() === 'admin'; if (singInResp?.ok && !singInResp?.error) { - const userData = { email: fields.email, - firstName: responseData.firstName, - lastName: responseData.lastName, - phone: responseData.phoneNumber, + firstName: loginUser?.firstName, + lastName: loginUser?.lastName, + phone: loginUser?.phoneNumber, role: userRole?.name as 'student' | 'user' | 'admin', isAdmin: isUserAdmin, }; - + setUser(userData); } diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts index 0574644d..804f7dc0 100644 --- a/src/types/next-auth.d.ts +++ b/src/types/next-auth.d.ts @@ -34,5 +34,9 @@ declare module 'next-auth/jwt' { id?: string; role?: string; sub?: string; + firstName?: string; + lastName?: string; + phone?: string; + avatar?: string; } } From f4c0e45100387ff1baf660fad52ee94c6f820958 Mon Sep 17 00:00:00 2001 From: Olivier JM Maniraho Date: Sat, 25 Apr 2026 20:38:39 +0200 Subject: [PATCH 18/18] Minor fixes --- package.json | 2 +- src/components/library/MediaContentPlayer.tsx | 62 ++++++++++++------- .../library/RelatedMediaContentList.tsx | 18 +++++- src/hooks/useMediaInteractions.ts | 7 ++- 4 files changed, 62 insertions(+), 27 deletions(-) diff --git a/package.json b/package.json index f50960a8..4bd49266 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "test": "vitest run", "test:watch": "vitest", "lint": "npx oxlint --fix", - "pm2": "pm2 start npm --name \"sparked-next\" -- start", + "pm2": "pm2 start npm --name \"sparked-next\" -- start" }, "engines": { "node": ">=22" diff --git a/src/components/library/MediaContentPlayer.tsx b/src/components/library/MediaContentPlayer.tsx index 81057efd..f883bdc7 100644 --- a/src/components/library/MediaContentPlayer.tsx +++ b/src/components/library/MediaContentPlayer.tsx @@ -1,7 +1,6 @@ 'use client'; -import { useCallback, useState } from 'react'; -import { useRouter } from 'next/navigation'; +import { useCallback, useRef, useState } from 'react'; import { T_RawMediaContentFields } from 'types/media-content'; import { fetcher } from '@hooks/use-swr/fetcher'; import { API_LINKS } from 'app/links'; @@ -16,31 +15,51 @@ type Props = { initialRelatedMedia: T_RawMediaContentFields[] | null; }; +async function fetchFullMedia(id: string) { + return fetcher<{ mediaContent: T_RawMediaContentFields }>( + API_LINKS.FETCH_MEDIA_CONTENT_BY_ID + NETWORK_UTILS.formatGetParams({ mediaContentId: id, withMetaData: 'true' }), + ); +} + export function MediaContentPlayer({ initialMediaContent, initialRelatedMedia }: Props) { - const router = useRouter(); const [activeMedia, setActiveMedia] = useState(initialMediaContent); const [relatedMedia, setRelatedMedia] = useState(initialRelatedMedia); - const handleSelect = useCallback( - async (item: T_RawMediaContentFields) => { - // Instantly swap to basic data from the list — no flash - setActiveMedia(item); - router.replace(`/library/media/${item._id}`, { scroll: false }); - - // Background: fetch full metadata - const fullResult = await fetcher<{ mediaContent: T_RawMediaContentFields }>( - API_LINKS.FETCH_MEDIA_CONTENT_BY_ID + NETWORK_UTILS.formatGetParams({ mediaContentId: item._id, withMetaData: 'true' }), - ); - if (!(fullResult instanceof Error)) { - setActiveMedia(fullResult.mediaContent); - // Background: update related list for new active item - const related = await fetchRelatedMediaClient(fullResult.mediaContent); - setRelatedMedia(related); - } - }, - [router], + // Cache fetched media by ID — re-selecting a visited item is instant + const mediaCache = useRef>( + new Map([[initialMediaContent._id, initialMediaContent]]), ); + const handleSelect = useCallback(async (item: T_RawMediaContentFields) => { + setActiveMedia(item); + window.history.replaceState(null, '', `/library/media/${item._id}`); + + const cached = mediaCache.current.get(item._id); + if (cached) { + setActiveMedia(cached); + const related = await fetchRelatedMediaClient(cached); + setRelatedMedia(related); + return; + } + + const fullResult = await fetchFullMedia(item._id); + if (!(fullResult instanceof Error)) { + mediaCache.current.set(fullResult.mediaContent._id, fullResult.mediaContent); + setActiveMedia(fullResult.mediaContent); + const related = await fetchRelatedMediaClient(fullResult.mediaContent); + setRelatedMedia(related); + } + }, []); + + // Prefetch full metadata on hover so click feels instant + const handlePrefetch = useCallback(async (item: T_RawMediaContentFields) => { + if (mediaCache.current.has(item._id)) return; + const result = await fetchFullMedia(item._id); + if (!(result instanceof Error)) { + mediaCache.current.set(result.mediaContent._id, result.mediaContent); + } + }, []); + return (

@@ -51,6 +70,7 @@ export function MediaContentPlayer({ initialMediaContent, initialRelatedMedia }: relatedMediaContent={relatedMedia} activeMediaId={activeMedia._id} onSelect={handleSelect} + onPrefetch={handlePrefetch} />
); diff --git a/src/components/library/RelatedMediaContentList.tsx b/src/components/library/RelatedMediaContentList.tsx index 02d584db..19215a3f 100644 --- a/src/components/library/RelatedMediaContentList.tsx +++ b/src/components/library/RelatedMediaContentList.tsx @@ -12,12 +12,16 @@ const isValidImage = (url: string) => { const RelatedMediaItem = memo( ({ item, + index, isActive, onSelect, + onPrefetch, }: { item: T_RawMediaContentFields; + index: number; isActive: boolean; onSelect?: (item: T_RawMediaContentFields) => void; + onPrefetch?: (item: T_RawMediaContentFields) => void; }) => { const domainName = item.external_url ? new URL(item.external_url).hostname : ''; const placeholderImage = `https://placehold.co/120x100?text=${domainName || item.name}`; @@ -31,7 +35,7 @@ const RelatedMediaItem = memo( width={120} height={90} className="object-cover rounded" - loading="eager" + loading={index < 3 ? 'eager' : 'lazy'} />

@@ -45,7 +49,11 @@ const RelatedMediaItem = memo( if (onSelect) { return (
  • -
  • @@ -66,10 +74,12 @@ export function RelatedMediaContentList({ relatedMediaContent, activeMediaId, onSelect, + onPrefetch, }: { relatedMediaContent: T_RawMediaContentFields[] | null; activeMediaId?: string; onSelect?: (item: T_RawMediaContentFields) => void; + onPrefetch?: (item: T_RawMediaContentFields) => void; }) { return (
    @@ -77,12 +87,14 @@ export function RelatedMediaContentList({ <>

    Related Media

      - {relatedMediaContent.map((item) => ( + {relatedMediaContent.map((item, index) => ( ))}
    diff --git a/src/hooks/useMediaInteractions.ts b/src/hooks/useMediaInteractions.ts index 2a5ec2e1..1079b2f6 100644 --- a/src/hooks/useMediaInteractions.ts +++ b/src/hooks/useMediaInteractions.ts @@ -3,9 +3,12 @@ import { Session } from 'next-auth'; import { useFetch } from './use-swr'; export function useMediaInteractions(mediaId: string) { - const [hasRecordedView, setHasRecordedView] = useState(false); + // Track which mediaId has been viewed so the flag resets on media switch + const [recordedMediaId, setRecordedMediaId] = useState(null); const [isLoading, setIsLoading] = useState(false); + const hasRecordedView = recordedMediaId === mediaId; + const { data: viewCountData } = useFetch(`/api/media-actions/getViewCount?mediaId=${mediaId}`); const { @@ -30,7 +33,7 @@ export function useMediaInteractions(mediaId: string) { }), }); - setHasRecordedView(true); + setRecordedMediaId(mediaId); } catch (error) { console.error('Error recording view:', error); } finally {