diff --git a/stackable-app/.gitignore b/stackable-app/.gitignore index fcd02c2..51f1f5a 100644 --- a/stackable-app/.gitignore +++ b/stackable-app/.gitignore @@ -42,3 +42,6 @@ next-env.d.ts # prisma generated client (regenerated via `pnpm db:generate`) /lib/generated + +# Sentry Config File +.env.sentry-build-plugin diff --git a/stackable-app/.tmp-lane-a/inspect.ts b/stackable-app/.tmp-lane-a/inspect.ts new file mode 100644 index 0000000..bed9a97 --- /dev/null +++ b/stackable-app/.tmp-lane-a/inspect.ts @@ -0,0 +1,24 @@ +// throwaway read-only inspection: prints ONLY schema metadata +import { Client } from "pg"; +process.loadEnvFile(".env.local"); +async function main() { + const c = new Client({ connectionString: process.env.DATABASE_URL }); + await c.connect(); + const q = async (label: string, sql: string, params: unknown[] = []) => { + const r = await c.query(sql, params); + console.log("== " + label); + console.log(r.rows.map((x) => JSON.stringify(x)).join("\n")); + }; + await q("views/tables named school_usage%", "select table_schema, table_name, table_type from information_schema.tables where table_name like 'school_usage%'"); + await q("schools columns", "select column_name, data_type from information_schema.columns where table_schema='public' and table_name='schools' order by ordinal_position"); + await q("school_profiles columns", "select column_name, data_type from information_schema.columns where table_schema='public' and table_name='school_profiles' order by ordinal_position"); + await q("school_security_codes columns", "select column_name, data_type from information_schema.columns where table_schema='public' and table_name='school_security_codes' order by ordinal_position"); + await q("audit_logs columns", "select column_name, data_type, is_nullable from information_schema.columns where table_schema='public' and table_name='audit_logs' order by ordinal_position"); + await q("counts", "select (select count(*) from schools) schools, (select count(*) from school_profiles) profiles, (select count(*) from school_security_codes) codes, (select count(*) from users) users"); + await q("students/teachers/parent/users role distribution", "select role, count(*) from users group by role order by role"); + await q("triggers on schools/users", "select event_object_table, trigger_name from information_schema.triggers where event_object_table in ('schools','users','school_profiles')"); + await q("schools status/subscription values", "select status, subscription_status, subscription_package, count(*) from schools group by 1,2,3"); + await q("students columns school", "select column_name, data_type from information_schema.columns where table_schema='public' and table_name in ('students','teachers','parent') and column_name in ('school_id','school_name','status','user_id') order by table_name, column_name"); + await c.end(); +} +main().catch((e) => { console.error("ERR", e instanceof Error ? e.message : String(e)); process.exit(1); }); diff --git a/stackable-app/.tmp-lane-a/inspect2.ts b/stackable-app/.tmp-lane-a/inspect2.ts new file mode 100644 index 0000000..886fee3 --- /dev/null +++ b/stackable-app/.tmp-lane-a/inspect2.ts @@ -0,0 +1,16 @@ +import { Client } from "pg"; +process.loadEnvFile(".env.local"); +async function main() { + const c = new Client({ connectionString: process.env.DATABASE_URL }); + await c.connect(); + const r1 = await c.query("select no::text no, school_id::text school_id, code_change_count, length(phone_1::text) phone_len, (email is null) email_null, (logo is null) logo_null, status, subscription_status from schools order by no"); + console.log(r1.rows.map((x) => JSON.stringify(x)).join("\n")); + const r2 = await c.query("select conname, contype from pg_constraint where conrelid in ('schools'::regclass,'users'::regclass,'school_profiles'::regclass) and contype in ('c','u','p')"); + console.log(r2.rows.map((x) => JSON.stringify(x)).join("\n")); + const r3 = await c.query("select pg_get_constraintdef(oid) d from pg_constraint where conrelid='schools'::regclass and contype='c'"); + console.log(r3.rows.map((x) => x.d).join("\n")); + const r4 = await c.query("select pg_get_constraintdef(oid) d from pg_constraint where conrelid='users'::regclass and contype='c'"); + console.log(r4.rows.map((x) => x.d).join("\n")); + await c.end(); +} +main().catch((e) => { console.error("ERR", e instanceof Error ? e.message : String(e)); process.exit(1); }); diff --git a/stackable-app/.tmp-lane-a/inspect3.ts b/stackable-app/.tmp-lane-a/inspect3.ts new file mode 100644 index 0000000..f02feb6 --- /dev/null +++ b/stackable-app/.tmp-lane-a/inspect3.ts @@ -0,0 +1,14 @@ +import { Client } from "pg"; +process.loadEnvFile(".env.local"); +async function main() { + const c = new Client({ connectionString: process.env.DATABASE_URL }); + await c.connect(); + const r1 = await c.query("select table_name from information_schema.tables where table_schema='public' and table_type='BASE TABLE' order by 1"); + console.log(r1.rows.map((x) => x.table_name).join(", ")); + const r2 = await c.query("select split_part(split_part(logo,'://',2),'/',1) host, substring(logo from '^[a-z]+') scheme, count(*) from schools where logo is not null group by 1,2"); + console.log(r2.rows.map((x) => JSON.stringify(x)).join("\n")); + const r3 = await c.query("select count(*) filter (where role='super-admin') sa, (select count(*) from audit_logs) audits from users"); + console.log(JSON.stringify(r3.rows[0])); + await c.end(); +} +main().catch((e) => { console.error("ERR", e instanceof Error ? e.message : String(e)); process.exit(1); }); diff --git a/stackable-app/.tmp-smoke/smoke.tsx b/stackable-app/.tmp-smoke/smoke.tsx new file mode 100644 index 0000000..d40fadc --- /dev/null +++ b/stackable-app/.tmp-smoke/smoke.tsx @@ -0,0 +1,51 @@ +import { renderToString } from "react-dom/server"; +import type { ChildCard, GradeRow, ChildOverview, StudentDashboardData } from "@/lib/repositories/portal-types"; +import { ChildCardFull, ChildRow } from "@/components/portal/parent/ChildCardView"; +import { SubjectsGrid } from "@/components/portal/student/SubjectsGrid"; +import { LatestGrades } from "@/components/portal/student/LatestGrades"; +import { TermMatrix } from "@/components/portal/student/TermMatrix"; +import { GradeMixBlock, AttendanceBlock } from "@/components/portal/student/parts"; +import { GradeInsights } from "@/components/portal/student/GradeInsights"; +import { UpcomingPanel } from "@/components/portal/student/UpcomingPanel"; +import { ProgressPanel } from "@/components/portal/student/ProgressPanel"; +import { StudentHomeSkeleton } from "@/components/portal/student/StudentHomeSkeleton"; +import { GradesSkeleton } from "@/components/portal/student/GradesSkeleton"; +import { FamilyHomeSkeleton, ChildProfileSkeleton, ChildrenListSkeleton } from "@/components/portal/parent/FamilySkeletons"; +import { buildSubjectCards, gradeMix, summarizeGrades, sortNewestFirst } from "@/components/portal/student/helpers"; +import { normalizeStudentDashboard, normalizeChildCards, normalizeChildOverview } from "@/components/portal/student/normalize"; + +const g = (subject: string, term: string, grade: string, createdAt: string | null): GradeRow => ({ subject, term, grade, rawScore: null, normalizedPct: null, createdAt }); +const grades = [g("Maths", "Term 1", "B", "2026-02-01T00:00:00Z"), g("Maths", "Term 2", "A", "2026-05-01T00:00:00Z"), g("English", "Term 2", "C", "2026-05-02T00:00:00Z"), g("Science", "Term 2", "D", "2026-05-03T00:00:00Z")]; +const child: ChildCard = { studentId: "s1", firstName: "Amina", lastName: "Otieno", admissionNo: "A1", className: "Grade 5", profilePicture: null, averageGrade: null, relationship: "mother", isPrimary: true, subjectCount: 6, averagePct: null, attendanceRate: null, latestGrade: { subject: "Maths", term: "Term 2", grade: "A", normalizedPct: null } }; +const att0 = { present: 0, late: 0, absent: 0, total: 0, rate: 0 }; + +const parts: Record React.ReactNode> = { + ChildCardFull: () => , + ChildCardFullRate: () => , + ChildRow: () => , + ChildRowRate: () => , + Subjects: () => , + SubjectsNoLink: () => null} allHref={null} />, + Latest: () => , + Matrix: () => , + Mix: () => , + MixEmpty: () => , + Attendance0: () => , + Insights: () => , + Upcoming0: () => , + Progress: () => , + Skeletons: () => (<>), +}; +let failed = 0; +for (const [name, render] of Object.entries(parts)) { + try { const html = renderToString(render() as React.ReactElement); console.log("ok ", name, html.length); } + catch (e) { failed++; console.log("FAIL", name, (e as Error).message); } +} +// Normalisers on hostile input (older backend / nulls). +try { + normalizeStudentDashboard({ student: { id: "x", lastName: "L" } }); + normalizeChildCards([{ studentId: "a" }, {}, null]); + normalizeChildOverview({ student: { id: "x" }, grades: [{ subject: "M" }] }); + console.log("ok normalizers"); +} catch (e) { failed++; console.log("FAIL normalizers", (e as Error).message); } +process.exit(failed ? 1 : 0); diff --git a/stackable-app/app/(developer)/dev/audit/page.tsx b/stackable-app/app/(developer)/dev/audit/page.tsx new file mode 100644 index 0000000..69ebfee --- /dev/null +++ b/stackable-app/app/(developer)/dev/audit/page.tsx @@ -0,0 +1,19 @@ +import { Suspense } from "react"; +import type { Metadata } from "next"; +import { AuditView } from "@/components/dev/AuditView"; +import { DevIntro } from "@/components/dev/DevIntro"; +import { TableSkeleton } from "@/components/dev/skeletons"; + +export const metadata: Metadata = { title: "Audit log" }; + +// AuditView reads its filters from the query string (useSearchParams), which needs a Suspense boundary. +export default function DeveloperAuditPage() { + return ( +
+ + }> + + +
+ ); +} diff --git a/stackable-app/app/(developer)/dev/integrations/page.tsx b/stackable-app/app/(developer)/dev/integrations/page.tsx new file mode 100644 index 0000000..42a0ad2 --- /dev/null +++ b/stackable-app/app/(developer)/dev/integrations/page.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from "next"; +import { IntegrationsView } from "@/components/dev/IntegrationsView"; + +export const metadata: Metadata = { title: "Integrations" }; + +export default function DeveloperIntegrationsPage() { + return ; +} diff --git a/stackable-app/app/(developer)/dev/page.tsx b/stackable-app/app/(developer)/dev/page.tsx new file mode 100644 index 0000000..90dd612 --- /dev/null +++ b/stackable-app/app/(developer)/dev/page.tsx @@ -0,0 +1,9 @@ +import type { Metadata } from "next"; +import { OverviewView } from "@/components/dev/OverviewView"; + +export const metadata: Metadata = { title: "Platform overview" }; + +// The Navbar in DashboardShell renders the page

; the view starts with a greeting

. +export default function DeveloperOverviewPage() { + return ; +} diff --git a/stackable-app/app/(developer)/dev/schools/page.tsx b/stackable-app/app/(developer)/dev/schools/page.tsx new file mode 100644 index 0000000..409a1b0 --- /dev/null +++ b/stackable-app/app/(developer)/dev/schools/page.tsx @@ -0,0 +1,14 @@ +import type { Metadata } from "next"; +import { DevIntro } from "@/components/dev/DevIntro"; +import { SchoolsView } from "@/components/dev/SchoolsView"; + +export const metadata: Metadata = { title: "Schools" }; + +export default function DeveloperSchoolsPage() { + return ( +
+ + +
+ ); +} diff --git a/stackable-app/app/(developer)/dev/settings/page.tsx b/stackable-app/app/(developer)/dev/settings/page.tsx new file mode 100644 index 0000000..24f94f3 --- /dev/null +++ b/stackable-app/app/(developer)/dev/settings/page.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from "next"; +import { SettingsView } from "@/components/dev/SettingsView"; + +export const metadata: Metadata = { title: "Settings" }; + +export default function DeveloperSettingsPage() { + return ; +} diff --git a/stackable-app/app/(developer)/dev/sitemap/page.tsx b/stackable-app/app/(developer)/dev/sitemap/page.tsx new file mode 100644 index 0000000..6eb136d --- /dev/null +++ b/stackable-app/app/(developer)/dev/sitemap/page.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from "next"; +import { SitemapView } from "@/components/dev/SitemapView"; + +export const metadata: Metadata = { title: "Sitemap" }; + +export default function DeveloperSitemapPage() { + return ; +} diff --git a/stackable-app/app/(developer)/dev/users/page.tsx b/stackable-app/app/(developer)/dev/users/page.tsx new file mode 100644 index 0000000..e8a0818 --- /dev/null +++ b/stackable-app/app/(developer)/dev/users/page.tsx @@ -0,0 +1,19 @@ +import { Suspense } from "react"; +import type { Metadata } from "next"; +import { DevIntro } from "@/components/dev/DevIntro"; +import { TableSkeleton } from "@/components/dev/skeletons"; +import { UsersView } from "@/components/dev/UsersView"; + +export const metadata: Metadata = { title: "Users" }; + +// UsersView reads its filters from the query string (useSearchParams), which needs a Suspense boundary. +export default function DeveloperUsersPage() { + return ( +
+ + }> + + +
+ ); +} diff --git a/stackable-app/app/(developer)/layout.tsx b/stackable-app/app/(developer)/layout.tsx index 675f2ea..fbf5e9b 100644 --- a/stackable-app/app/(developer)/layout.tsx +++ b/stackable-app/app/(developer)/layout.tsx @@ -1,152 +1,8 @@ -"use client"; - -import React, { useEffect, useState } from "react"; -import clsx from "clsx"; -import Sidebar, { type SidebarSection } from "@/components/sidebar/sidebar"; -import Navbar from "@/components/header/dashboard-navbar"; -import BreadCrumb from "@/components/breadcrumb/bread"; -import { - Blocks, - Building2, - Home, - ScrollText, - UserCog, - Users, - Zap, -} from "lucide-react"; - -const DESKTOP_SIDEBAR_EXPANDED_OFFSET = "lg:pl-[17rem]"; -const DESKTOP_SIDEBAR_COLLAPSED_OFFSET = "lg:pl-28"; - -const iconClassName = "h-5 w-5 shrink-0"; -const iconStrokeWidth = 1.75; - -const DEV_SIDEBAR_SECTIONS: SidebarSection[] = [ - { - key: "settings", - ariaLabel: "Settings navigation", - items: [ - { - type: "link", - href: "/dev/settings", - label: "Platform Settings", - variant: "top", - icon: , - }, - ], - }, - { - key: "main", - ariaLabel: "Main navigation", - items: [ - { - type: "link", - href: "/dev", - label: "Platform Overview", - icon: , - }, - { - type: "link", - href: "/dev/schools", - label: "Schools", - icon: , - }, - { - type: "link", - href: "/dev/users", - label: "Cross-School Users", - icon: , - }, - { - type: "link", - href: "/dev/audit", - label: "Audit Log", - icon: , - }, - { - type: "link", - href: "/dev/integrations", - label: "Integrations", - icon: , - }, - { - type: "link", - href: "/dev/feature-flags", - label: "Feature Flags", - icon: , - }, - ], - }, -]; - -const DeveloperLayout = ({ children }: { children: React.ReactNode }) => { - const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); - const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); - - useEffect(() => { - const handleResize = () => { - if (window.innerWidth >= 1024) { - setIsMobileSidebarOpen(false); - } - }; - - handleResize(); - window.addEventListener("resize", handleResize); - - return () => { - window.removeEventListener("resize", handleResize); - }; - }, []); - - useEffect(() => { - const originalOverflow = document.body.style.overflow; - - if (isMobileSidebarOpen) { - document.body.style.overflow = "hidden"; - } else { - document.body.style.overflow = originalOverflow; - } - - return () => { - document.body.style.overflow = originalOverflow; - }; - }, [isMobileSidebarOpen]); - - return ( -
- setIsMobileSidebarOpen(false)} - onToggleCollapse={() => setIsSidebarCollapsed((current) => !current)} - sections={DEV_SIDEBAR_SECTIONS} - /> - -
- - setIsMobileSidebarOpen((current) => !current) - } - /> - -
-
- -
- {children} -
-
-
- ); -}; - -export default DeveloperLayout; +import { requireRole } from "@/lib/api/guard"; +import { DashboardShell } from "@/components/dashboard/DashboardShell"; + +// Developer / platform console (/dev/*, super-admin only). Menu: lib/nav.ts -> NAV.developer. +export default async function DeveloperLayout({ children }: { children: React.ReactNode }) { + await requireRole("developer"); + return {children}; +} diff --git a/stackable-app/app/(parent)/family/children/[studentId]/page.tsx b/stackable-app/app/(parent)/family/children/[studentId]/page.tsx index ef4fb95..58b3a8b 100644 --- a/stackable-app/app/(parent)/family/children/[studentId]/page.tsx +++ b/stackable-app/app/(parent)/family/children/[studentId]/page.tsx @@ -1,254 +1,12 @@ "use client"; -import React, { useState } from "react"; -import Link from "next/link"; import { useParams } from "next/navigation"; -import { ArrowLeft, CheckCircle2, Clock, XCircle, BarChart2 } from "lucide-react"; -import { useChildOverview } from "@/hooks/useChildOverview"; -import StatCard from "@/components/cards/card"; -import type { ChildOverview } from "@/lib/repositories/parent.repo"; - -// ─── helpers ────────────────────────────────────────────────────────────────── - -function gradeColor(grade: string): string { - const g = grade.toUpperCase(); - if (g.startsWith("A")) return "bg-green-100 text-green-700"; - if (g.startsWith("B")) return "bg-teal-100 text-teal-700"; - if (g.startsWith("C")) return "bg-yellow-100 text-yellow-700"; - if (g.startsWith("D")) return "bg-orange-100 text-orange-700"; - if (g.startsWith("F")) return "bg-red-100 text-red-700"; - return "bg-gray-100 text-gray-600"; -} - -function statusBadge(status: string) { - const map: Record = { - active: "bg-green-100 text-green-700", - inactive: "bg-red-100 text-red-700", - suspended: "bg-orange-100 text-orange-700", - }; - return map[status.toLowerCase()] ?? "bg-gray-100 text-gray-600"; -} - -// ─── grades table ───────────────────────────────────────────────────────────── - -type GradeRow = ChildOverview["grades"][number]; - -function GradesTable({ grades }: { grades: GradeRow[] }) { - const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); - - const sorted = [...grades].sort((a, b) => { - const cmp = a.subject.localeCompare(b.subject); - return sortDir === "asc" ? cmp : -cmp; - }); - - if (grades.length === 0) { - return ( -

- No grade reports available yet. -

- ); - } - - return ( -
- - - - - - - - - - - - {sorted.map((row, i) => ( - - - - - - - - ))} - -
setSortDir((d) => (d === "asc" ? "desc" : "asc"))} - > - Subject {sortDir === "asc" ? "↑" : "↓"} - TermGradeRaw Score%
{row.subject}{row.term} - - {row.grade} - - - {row.rawScore !== null ? row.rawScore : "—"} - - {row.normalizedPct !== null ? `${row.normalizedPct}%` : "—"} -
-
- ); -} - -// ─── loading skeleton ───────────────────────────────────────────────────────── - -function LoadingSkeleton() { - return ( -
-
-
-
-
-
-
-
-
- {[...Array(4)].map((_, i) => ( -
- ))} -
-
-
- ); -} - -// ─── page ───────────────────────────────────────────────────────────────────── +import ChildProfile from "@/components/portal/parent/ChildProfile"; +// One child's profile. The shell renders the page title ("My children"); ChildProfile owns +// the rest (header, quick facts, tabs, and the not-your-child state for a 403/404). export default function ChildDetailPage() { const params = useParams(); - const studentId = - typeof params?.studentId === "string" ? params.studentId : undefined; - - const { data, isLoading, isError, error } = useChildOverview(studentId); - - const fullName = data - ? [data.student.firstName, data.student.lastName].filter(Boolean).join(" ") - : ""; - - return ( -
- {/* Back link */} - - - Back to My Children - - -

- {fullName || "Child Overview"} -

- - {isLoading && } - - {isError && ( -
-

Failed to load student data

-

- {error instanceof Error ? error.message : "An unexpected error occurred."} -

- -
- )} - - {!isLoading && !isError && data && ( -
- {/* Profile header */} -
- {data.student.profilePicture ? ( - // eslint-disable-next-line @next/next/no-img-element - {fullName} - ) : ( -
- {`${data.student.firstName ? data.student.firstName[0] : ""}${data.student.lastName[0] ?? ""}`.toUpperCase()} -
- )} - -
-
-

{fullName}

- - {data.student.status} - -
-

- {data.student.className ?? "No class assigned"} ·{" "} - {data.student.admissionNo} -

-
-
- - {/* Attendance stat cards */} -
- } - value={String(data.attendance.present)} - label="Days Present" - delta={`of ${data.attendance.total}`} - iconBg="bg-green-200/60" - iconColor="text-green-700" - deltaStatus="positive" - /> - } - value={String(data.attendance.late)} - label="Days Late" - delta={`of ${data.attendance.total}`} - iconBg="bg-yellow-200/60" - iconColor="text-yellow-700" - deltaStatus="neutral" - /> - } - value={String(data.attendance.absent)} - label="Days Absent" - delta={`of ${data.attendance.total}`} - iconBg="bg-red-200/60" - iconColor="text-red-700" - deltaStatus="negative" - /> - } - value={`${data.attendance.rate}%`} - label="Attendance Rate" - delta={data.attendance.rate >= 80 ? "Good" : "Needs attention"} - iconBg="bg-blue-200/60" - iconColor="text-blue-700" - deltaStatus={ - data.attendance.rate >= 80 - ? "positive" - : data.attendance.rate >= 60 - ? "neutral" - : "negative" - } - /> -
- - {/* Grades table */} -
-

Grade Reports

- -
-
- )} -
- ); + const studentId = typeof params?.studentId === "string" ? params.studentId : undefined; + return ; } diff --git a/stackable-app/app/(parent)/family/children/page.tsx b/stackable-app/app/(parent)/family/children/page.tsx index 15cf7eb..27861bf 100644 --- a/stackable-app/app/(parent)/family/children/page.tsx +++ b/stackable-app/app/(parent)/family/children/page.tsx @@ -1,163 +1,6 @@ -"use client"; - -import React from "react"; -import Link from "next/link"; -import { Users } from "lucide-react"; -import { useParentChildren } from "@/hooks/useParentChildren"; -import type { ChildCard } from "@/lib/repositories/parent.repo"; - -// ─── helpers ────────────────────────────────────────────────────────────────── - -function gradeColor(grade: string | null): string { - if (!grade) return "bg-gray-100 text-gray-500"; - const g = grade.toUpperCase(); - if (g.startsWith("A")) return "bg-green-100 text-green-700"; - if (g.startsWith("B")) return "bg-teal-100 text-teal-700"; - if (g.startsWith("C")) return "bg-yellow-100 text-yellow-700"; - if (g.startsWith("D")) return "bg-orange-100 text-orange-700"; - if (g.startsWith("F")) return "bg-red-100 text-red-700"; - return "bg-gray-100 text-gray-500"; -} - -function initials(first: string | null, last: string): string { - return `${first ? first[0] : ""}${last[0] ?? ""}`.toUpperCase(); -} - -// ─── child card ─────────────────────────────────────────────────────────────── - -function ChildCardItem({ child }: { child: ChildCard }) { - const fullName = [child.firstName, child.lastName].filter(Boolean).join(" "); - - return ( - -
- {/* Avatar + name */} -
- {child.profilePicture ? ( - // eslint-disable-next-line @next/next/no-img-element - {fullName} - ) : ( -
- {initials(child.firstName, child.lastName)} -
- )} - -
-

{fullName}

- {child.className && ( -

{child.className}

- )} -
-
- - {/* Badges row */} -
- {/* Grade badge */} - - {child.averageGrade ? `Grade ${child.averageGrade}` : "N/A"} - - - {/* Relationship tag */} - - {child.relationship} - -
- - {/* Admission number */} -

{child.admissionNo}

-
- - ); -} - -// ─── skeleton ───────────────────────────────────────────────────────────────── - -function SkeletonCard() { - return ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
- ); -} - -// ─── page ───────────────────────────────────────────────────────────────────── +import ChildrenView from "@/components/portal/parent/ChildrenView"; +// The children linked to the signed-in parent. The shell renders the page title. export default function AllChildrenPage() { - const { data: children, isLoading, isError, error } = useParentChildren(); - - return ( -
-

All Children

- - {/* Loading state */} - {isLoading && ( -
- - - -
- )} - - {/* Error state */} - {isError && ( -
-

Failed to load children

-

- {error instanceof Error ? error.message : "An unexpected error occurred."} -

- -
- )} - - {/* Empty state */} - {!isLoading && !isError && children && children.length === 0 && ( -
-
- -
-

No children linked yet

-

- No children are currently linked to your account. Please contact the school - administration for assistance. -

-
- )} - - {/* Children grid */} - {!isLoading && !isError && children && children.length > 0 && ( -
- {children.map((child) => ( - - ))} -
- )} -
- ); + return ; } diff --git a/stackable-app/app/(parent)/family/page.tsx b/stackable-app/app/(parent)/family/page.tsx index edb4c48..f916c28 100644 --- a/stackable-app/app/(parent)/family/page.tsx +++ b/stackable-app/app/(parent)/family/page.tsx @@ -1,161 +1,6 @@ -"use client"; - -import React from "react"; -import Link from "next/link"; -import { useParentChildren } from "@/hooks/useParentChildren"; -import type { ChildCard } from "@/lib/repositories/parent.repo"; - -// ─── helpers ────────────────────────────────────────────────────────────────── - -function gradeColor(grade: string | null): string { - if (!grade) return "bg-gray-100 text-gray-500"; - const g = grade.toUpperCase(); - if (g.startsWith("A")) return "bg-green-100 text-green-700"; - if (g.startsWith("B")) return "bg-teal-100 text-teal-700"; - if (g.startsWith("C")) return "bg-yellow-100 text-yellow-700"; - if (g.startsWith("D")) return "bg-orange-100 text-orange-700"; - if (g.startsWith("F")) return "bg-red-100 text-red-700"; - return "bg-gray-100 text-gray-500"; -} - -function initials(first: string | null, last: string): string { - return `${first ? first[0] : ""}${last[0] ?? ""}`.toUpperCase(); -} - -// ─── child card component ────────────────────────────────────────────────────── - -function ChildCardItem({ child }: { child: ChildCard }) { - const fullName = [child.firstName, child.lastName].filter(Boolean).join(" "); - - return ( -
- {/* Avatar + name row */} -
- {child.profilePicture ? ( - // eslint-disable-next-line @next/next/no-img-element - {fullName} - ) : ( -
- {initials(child.firstName, child.lastName)} -
- )} - -
-

{fullName}

-

{child.admissionNo}

-
-
- - {/* Class + badges */} -
- {child.className && ( - - {child.className} - - )} - - {child.averageGrade && ( - - Grade {child.averageGrade} - - )} - - - {child.relationship} - -
- - {/* CTA */} - - View Details - -
- ); -} - -// ─── skeleton ───────────────────────────────────────────────────────────────── - -function SkeletonCard() { - return ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
- ); -} - -// ─── page ───────────────────────────────────────────────────────────────────── +import FamilyHome from "@/components/portal/parent/FamilyHome"; +// Parent home. The shell (layout) renders the page title; all UI lives in FamilyHome. export default function FamilyPage() { - const { data: children, isLoading, isError, error } = useParentChildren(); - - return ( -
-

My Children

- - {isLoading && ( -
- - - -
- )} - - {isError && ( -
-

Failed to load children

-

- {error instanceof Error ? error.message : "An unexpected error occurred."} -

- -
- )} - - {!isLoading && !isError && children && children.length === 0 && ( -
-
- 👨‍👩‍👧 -
-

No children linked yet

-

- No children are currently linked to your account. Please contact the school - administration for assistance. -

-
- )} - - {!isLoading && !isError && children && children.length > 0 && ( -
- {children.map((child) => ( - - ))} -
- )} -
- ); + return ; } diff --git a/stackable-app/app/(parent)/family/settings/page.tsx b/stackable-app/app/(parent)/family/settings/page.tsx new file mode 100644 index 0000000..d3787f0 --- /dev/null +++ b/stackable-app/app/(parent)/family/settings/page.tsx @@ -0,0 +1,11 @@ +import { ComingSoon } from "@/components/ui"; + +export default function ParentSettingsPage() { + return ( + + ); +} diff --git a/stackable-app/app/(parent)/layout.tsx b/stackable-app/app/(parent)/layout.tsx index e757353..92b9ecc 100644 --- a/stackable-app/app/(parent)/layout.tsx +++ b/stackable-app/app/(parent)/layout.tsx @@ -1,149 +1,8 @@ -"use client"; - -import React, { useEffect, useState } from "react"; -import clsx from "clsx"; -import Sidebar, { type SidebarSection } from "@/components/sidebar/sidebar"; -import Navbar from "@/components/header/dashboard-navbar"; -import BreadCrumb from "@/components/breadcrumb/bread"; -import { - Bell, - Home, - MessagesSquare, - UsersRound, - UserCog, - Wallet, -} from "lucide-react"; - -const DESKTOP_SIDEBAR_EXPANDED_OFFSET = "lg:pl-[17rem]"; -const DESKTOP_SIDEBAR_COLLAPSED_OFFSET = "lg:pl-28"; - -const iconClassName = "h-5 w-5 shrink-0"; -const iconStrokeWidth = 1.75; - -const PARENT_SECTIONS: SidebarSection[] = [ - { - key: "settings", - ariaLabel: "Settings navigation", - items: [ - { - type: "link", - href: "/family/settings", - label: "My Settings", - variant: "top", - icon: , - }, - ], - }, - { - key: "main", - ariaLabel: "Main navigation", - items: [ - { - type: "link", - href: "/family", - label: "Home", - icon: , - }, - { - type: "link", - href: "/family", - label: "My Children", - icon: ( - - ), - }, - { - type: "link", - href: "/family/fees", - label: "School Fees", - icon: , - }, - { - type: "link", - href: "/family/notices", - label: "Notices", - icon: , - }, - { - type: "link", - href: "/family/inbox", - label: "Inbox", - icon: ( - - ), - }, - ], - }, -]; - -const ParentLayout = ({ children }: { children: React.ReactNode }) => { - const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); - const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); - - useEffect(() => { - const handleResize = () => { - if (window.innerWidth >= 1024) { - setIsMobileSidebarOpen(false); - } - }; - - handleResize(); - window.addEventListener("resize", handleResize); - - return () => { - window.removeEventListener("resize", handleResize); - }; - }, []); - - useEffect(() => { - const originalOverflow = document.body.style.overflow; - - if (isMobileSidebarOpen) { - document.body.style.overflow = "hidden"; - } else { - document.body.style.overflow = originalOverflow; - } - - return () => { - document.body.style.overflow = originalOverflow; - }; - }, [isMobileSidebarOpen]); - - return ( -
- setIsMobileSidebarOpen(false)} - onToggleCollapse={() => setIsSidebarCollapsed((current) => !current)} - sections={PARENT_SECTIONS} - /> - -
- - setIsMobileSidebarOpen((current) => !current) - } - /> - -
-
- -
- {children} -
-
-
- ); -}; - -export default ParentLayout; +import { requireRole } from "@/lib/api/guard"; +import { DashboardShell } from "@/components/dashboard/DashboardShell"; + +// Parent / guardian portal (/family/*). Menu: lib/nav.ts -> NAV.parent. +export default async function ParentLayout({ children }: { children: React.ReactNode }) { + await requireRole("parent"); + return {children}; +} diff --git a/stackable-app/app/(principal)/admin/page.tsx b/stackable-app/app/(principal)/admin/page.tsx index d157076..599c50a 100644 --- a/stackable-app/app/(principal)/admin/page.tsx +++ b/stackable-app/app/(principal)/admin/page.tsx @@ -1,100 +1,6 @@ -"use client"; - -import Link from "next/link"; -import { - GraduationCap, - Users, - BriefcaseBusiness, - UserCheck, -} from "lucide-react"; -import StatCard from "@/components/cards/card"; - -// TODO: Replace stub values with real API data when the principal stats endpoint is ready. -// Expected shape: { totalStudents: number; totalTeachers: number; totalStaff: number; totalParents: number } -const STUB_STATS = { - totalStudents: 0, - totalTeachers: 0, - totalStaff: 0, - totalParents: 0, -}; - -const QUICK_LINKS = [ - { label: "Students", href: "/admin/students" }, - { label: "Teachers", href: "/admin/teachers" }, - { label: "Subjects", href: "/admin/academics" }, - { label: "Classes", href: "/admin/academics" }, - { label: "Payments", href: "/admin/finance" }, - { label: "Settings", href: "/admin/settings" }, -]; +import AdminOverview from "@/components/dashboard/AdminOverview"; +// Principal / manager overview. Identical to /dashboard: both render the shared AdminOverview. export default function AdminOverviewPage() { - const { totalStudents, totalTeachers, totalStaff, totalParents } = STUB_STATS; - - return ( -
- {/* Page heading */} -
-

School Overview

-

- A snapshot of your school at a glance. -

-
- - {/* Stat cards */} -
- } - value={String(totalStudents)} - label="Total Students" - delta="Enrolled" - iconBg="bg-green-100" - iconColor="text-[#108548]" - deltaStatus="positive" - /> - } - value={String(totalTeachers)} - label="Teachers" - delta="Active" - iconBg="bg-blue-100" - iconColor="text-blue-600" - deltaStatus="neutral" - /> - } - value={String(totalStaff)} - label="Staff" - delta="Active" - iconBg="bg-purple-100" - iconColor="text-purple-600" - deltaStatus="neutral" - /> - } - value={String(totalParents)} - label="Parents" - delta="Registered" - iconBg="bg-amber-100" - iconColor="text-amber-600" - deltaStatus="neutral" - /> -
- - {/* Quick links */} -
-

Quick Links

-
- {QUICK_LINKS.map(({ label, href }) => ( - - {label} - - ))} -
-
-
- ); + return ; } diff --git a/stackable-app/app/(principal)/layout.tsx b/stackable-app/app/(principal)/layout.tsx index c7f7536..918eca4 100644 --- a/stackable-app/app/(principal)/layout.tsx +++ b/stackable-app/app/(principal)/layout.tsx @@ -1,263 +1,8 @@ -"use client"; - -import React, { useEffect, useState } from "react"; -import clsx from "clsx"; -import Sidebar, { type SidebarSection } from "@/components/sidebar/sidebar"; -import Navbar from "@/components/header/dashboard-navbar"; -import BreadCrumb from "@/components/breadcrumb/bread"; -import { - Bell, - Blocks, - BookOpenText, - BriefcaseBusiness, - CalendarDays, - FileSpreadsheet, - GraduationCap, - Home, - LibraryBig, - MessagesSquare, - ShieldUser, - UserCog, - Users, - UsersRound, - Wallet, - Zap, -} from "lucide-react"; - -const DESKTOP_SIDEBAR_EXPANDED_OFFSET = "lg:pl-[17rem]"; -const DESKTOP_SIDEBAR_COLLAPSED_OFFSET = "lg:pl-28"; - -const iconClassName = "h-5 w-5 shrink-0"; -const iconStrokeWidth = 1.75; - -const PRINCIPAL_SECTIONS: SidebarSection[] = [ - { - key: "settings", - ariaLabel: "Settings navigation", - items: [ - { - type: "link", - href: "/admin/settings", - label: "My Settings", - variant: "top", - icon: , - }, - ], - }, - { - key: "main", - ariaLabel: "Main navigation", - items: [ - { - type: "link", - href: "/admin", - label: "Overview", - icon: , - }, - { - type: "link", - href: "/admin/notifications", - label: "Notifications", - icon: , - }, - { - type: "link", - href: "/admin/inbox", - label: "Inbox", - icon: ( - - ), - }, - { - type: "link", - href: "/admin/calendar", - label: "Calendar", - icon: ( - - ), - }, - ], - }, - { - key: "people", - ariaLabel: "People", - items: [ - { - type: "group", - key: "students", - label: "Students", - icon: ( - - ), - children: [ - { href: "/dashboard/students", label: "All Students" }, - { href: "/dashboard/students/allocation", label: "Allocation" }, - ], - }, - { - type: "group", - key: "teachers", - label: "Teachers", - icon: ( - - ), - children: [ - { href: "/dashboard/teachers", label: "All Teachers" }, - ], - }, - { - type: "group", - key: "staff", - label: "Staff", - icon: ( - - ), - children: [ - { href: "/dashboard/staff", label: "All Staff" }, - ], - }, - { - type: "group", - key: "parents", - label: "Parents/Guardians", - icon: , - children: [ - { href: "/dashboard/parents", label: "All Parents" }, - ], - }, - { - type: "link", - href: "/dashboard/admin-role", - label: "Admin Roles", - icon: ( - - ), - }, - ], - }, - { - key: "academics", - ariaLabel: "Academics", - items: [ - { - type: "link", - href: "/dashboard/classes", - label: "Classes", - icon: , - }, - { - type: "link", - href: "/dashboard/subjects", - label: "Subjects", - icon: ( - - ), - }, - { - type: "link", - href: "/dashboard/grades-reports", - label: "Grades & Reports", - icon: ( - - ), - }, - { - type: "link", - href: "/dashboard/library", - label: "Library", - icon: ( - - ), - }, - ], - }, - { - key: "finance", - ariaLabel: "Finance", - items: [ - { - type: "group", - key: "payments", - label: "Payments", - icon: , - children: [ - { href: "/dashboard/fees", label: "School Fees" }, - { href: "/dashboard/salaries", label: "Salaries" }, - { href: "/dashboard/attendance", label: "Events" }, - ], - }, - ], - }, -]; - -const PrincipalLayout = ({ children }: { children: React.ReactNode }) => { - const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); - const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); - - useEffect(() => { - const handleResize = () => { - if (window.innerWidth >= 1024) { - setIsMobileSidebarOpen(false); - } - }; - - handleResize(); - window.addEventListener("resize", handleResize); - - return () => { - window.removeEventListener("resize", handleResize); - }; - }, []); - - useEffect(() => { - const originalOverflow = document.body.style.overflow; - - if (isMobileSidebarOpen) { - document.body.style.overflow = "hidden"; - } else { - document.body.style.overflow = originalOverflow; - } - - return () => { - document.body.style.overflow = originalOverflow; - }; - }, [isMobileSidebarOpen]); - - return ( -
- setIsMobileSidebarOpen(false)} - onToggleCollapse={() => setIsSidebarCollapsed((current) => !current)} - sections={PRINCIPAL_SECTIONS} - /> - -
- - setIsMobileSidebarOpen((current) => !current) - } - /> - -
-
- -
- {children} -
-
-
- ); -}; - -export default PrincipalLayout; +import { requireRole } from "@/lib/api/guard"; +import { DashboardShell } from "@/components/dashboard/DashboardShell"; + +// Principal / manager portal (/admin). Menu: lib/nav.ts -> NAV.principal. +export default async function PrincipalLayout({ children }: { children: React.ReactNode }) { + await requireRole("principal"); + return {children}; +} diff --git a/stackable-app/app/(student)/layout.tsx b/stackable-app/app/(student)/layout.tsx index b00c49b..373c376 100644 --- a/stackable-app/app/(student)/layout.tsx +++ b/stackable-app/app/(student)/layout.tsx @@ -1,150 +1,8 @@ -"use client"; - -import React, { useEffect, useState } from "react"; -import clsx from "clsx"; -import Sidebar, { type SidebarSection } from "@/components/sidebar/sidebar"; -import Navbar from "@/components/header/dashboard-navbar"; -import BreadCrumb from "@/components/breadcrumb/bread"; -import { - BookOpenText, - FileSpreadsheet, - Home, - LibraryBig, - MessagesSquare, - NotebookPen, - UserCog, -} from "lucide-react"; - -const DESKTOP_SIDEBAR_EXPANDED_OFFSET = "lg:pl-[17rem]"; -const DESKTOP_SIDEBAR_COLLAPSED_OFFSET = "lg:pl-28"; - -const iconClassName = "h-5 w-5 shrink-0"; -const iconStrokeWidth = 1.75; - -const STUDENT_SECTIONS: SidebarSection[] = [ - { - key: "settings", - ariaLabel: "Settings navigation", - items: [ - { - type: "link", - href: "/learn/settings", - label: "My Settings", - variant: "top", - icon: , - }, - ], - }, - { - key: "main", - ariaLabel: "Main navigation", - items: [ - { - type: "link", - href: "/learn", - label: "Home", - icon: , - }, - { - type: "link", - href: "/learn/subjects", - label: "My Subjects", - icon: , - }, - { - type: "link", - href: "/learn/grades", - label: "My Grades", - icon: , - }, - { - type: "link", - href: "/learn/homework", - label: "Homework", - icon: , - }, - { - type: "link", - href: "/learn/library", - label: "Library", - icon: , - }, - { - type: "link", - href: "/learn/inbox", - label: "Inbox", - icon: , - }, - ], - }, -]; - -const StudentLayout = ({ children }: { children: React.ReactNode }) => { - const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); - const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); - - useEffect(() => { - const handleResize = () => { - if (window.innerWidth >= 1024) { - setIsMobileSidebarOpen(false); - } - }; - - handleResize(); - window.addEventListener("resize", handleResize); - - return () => { - window.removeEventListener("resize", handleResize); - }; - }, []); - - useEffect(() => { - const originalOverflow = document.body.style.overflow; - - if (isMobileSidebarOpen) { - document.body.style.overflow = "hidden"; - } else { - document.body.style.overflow = originalOverflow; - } - - return () => { - document.body.style.overflow = originalOverflow; - }; - }, [isMobileSidebarOpen]); - - return ( -
- setIsMobileSidebarOpen(false)} - onToggleCollapse={() => setIsSidebarCollapsed((current) => !current)} - /> - -
- setIsMobileSidebarOpen((current) => !current)} - /> - -
-
- -
- {children} -
-
-
- ); -}; - -export default StudentLayout; +import { requireRole } from "@/lib/api/guard"; +import { DashboardShell } from "@/components/dashboard/DashboardShell"; + +// Student / pupil portal (/learn/*). Menu: lib/nav.ts -> NAV.student. +export default async function StudentLayout({ children }: { children: React.ReactNode }) { + await requireRole("student"); + return {children}; +} diff --git a/stackable-app/app/(student)/learn/grades/page.tsx b/stackable-app/app/(student)/learn/grades/page.tsx index 9fc837a..c255a4f 100644 --- a/stackable-app/app/(student)/learn/grades/page.tsx +++ b/stackable-app/app/(student)/learn/grades/page.tsx @@ -1,106 +1,14 @@ -"use client"; +import { Suspense } from "react"; +import GradesView from "@/components/portal/student/GradesView"; +import { GradesSkeleton } from "@/components/portal/student/GradesSkeleton"; -import { useStudentGrades } from "@/hooks/useStudentGrades"; - -// ── Grade badge ─────────────────────────────────────────────────────────────── -function GradeBadge({ grade }: { grade: string }) { - const upper = grade.toUpperCase(); - const colorMap: Record = { - A: "bg-green-100 text-green-700", - B: "bg-teal-100 text-teal-700", - C: "bg-yellow-100 text-yellow-700", - D: "bg-orange-100 text-orange-700", - F: "bg-red-100 text-red-700", - }; - const firstLetter = upper[0] ?? "F"; - const colorClass = colorMap[firstLetter] ?? "bg-gray-100 text-gray-700"; - return ( - - {grade} - - ); -} - -// ── Skeleton ────────────────────────────────────────────────────────────────── -function Skeleton({ className }: { className?: string }) { - return
; -} - -function GradesSkeleton() { - return ( -
- - -
- ); -} - -// ── Error card ──────────────────────────────────────────────────────────────── -function ErrorCard({ message }: { message: string }) { - return ( -
-

Failed to load grades

-

{message}

-
- ); -} - -// ── Main page ───────────────────────────────────────────────────────────────── +// The signed-in student's grades. The shell renders the page title. GradesView reads the +// `?subject=` deep-link (from the home page's subject tiles) via useSearchParams, +// so it sits inside a Suspense boundary whose fallback is the page's own skeleton. export default function StudentGradesPage() { - const { data, isLoading, isError, error } = useStudentGrades(); - - if (isLoading) return ; - - if (isError) { - const msg = error instanceof Error ? error.message : "Unknown error"; - return ; - } - - const grades = data ?? []; - return ( -
-

My Grades

- -
- {grades.length === 0 ? ( -
-

No grades recorded yet.

-

Your grades will appear here once teachers submit reports.

-
- ) : ( -
- - - - - - - - - - - - {grades.map((g, idx) => ( - - - - - - - - ))} - -
SubjectTermGradeRaw Score%
{g.subject}{g.term} - - - {g.rawScore != null ? g.rawScore : "—"} - - {g.normalizedPct != null ? `${g.normalizedPct}%` : "—"} -
-
- )} -
-
+ }> + + ); } diff --git a/stackable-app/app/(student)/learn/inbox/page.tsx b/stackable-app/app/(student)/learn/inbox/page.tsx new file mode 100644 index 0000000..7c9380f --- /dev/null +++ b/stackable-app/app/(student)/learn/inbox/page.tsx @@ -0,0 +1,11 @@ +import { ComingSoon } from "@/components/ui"; + +export default function StudentInboxPage() { + return ( + + ); +} diff --git a/stackable-app/app/(student)/learn/library/page.tsx b/stackable-app/app/(student)/learn/library/page.tsx new file mode 100644 index 0000000..8edb52b --- /dev/null +++ b/stackable-app/app/(student)/learn/library/page.tsx @@ -0,0 +1,11 @@ +import { ComingSoon } from "@/components/ui"; + +export default function StudentLibraryPage() { + return ( + + ); +} diff --git a/stackable-app/app/(student)/learn/page.tsx b/stackable-app/app/(student)/learn/page.tsx index 625d789..24ccc3c 100644 --- a/stackable-app/app/(student)/learn/page.tsx +++ b/stackable-app/app/(student)/learn/page.tsx @@ -1,170 +1,6 @@ -"use client"; +import StudentHome from "@/components/portal/student/StudentHome"; -import { BookOpenText, CalendarCheck, CalendarX, TrendingUp } from "lucide-react"; -import StatCard from "@/components/cards/card"; -import { useStudentDashboard } from "@/hooks/useStudentDashboard"; - -// ── Grade badge ───────────────────────────────────────────────────────────── -function GradeBadge({ grade }: { grade: string }) { - const upper = grade.toUpperCase(); - const colorMap: Record = { - A: "bg-green-100 text-green-700", - B: "bg-teal-100 text-teal-700", - C: "bg-yellow-100 text-yellow-700", - D: "bg-orange-100 text-orange-700", - F: "bg-red-100 text-red-700", - }; - const firstLetter = upper[0] ?? "F"; - const colorClass = colorMap[firstLetter] ?? "bg-gray-100 text-gray-700"; - return ( - - {grade} - - ); -} - -// ── Skeleton ───────────────────────────────────────────────────────────────── -function Skeleton({ className }: { className?: string }) { - return
; -} - -function DashboardSkeleton() { - return ( -
- -
- {[...Array(4)].map((_, i) => ( - - ))} -
- -
- ); -} - -// ── Error card ──────────────────────────────────────────────────────────────── -function ErrorCard({ message }: { message: string }) { - return ( -
-

Failed to load dashboard

-

{message}

-
- ); -} - -// ── Main page ───────────────────────────────────────────────────────────────── +// Student home. The shell (layout) renders the page title; all UI lives in StudentHome. export default function StudentDashboardPage() { - const { data, isLoading, isError, error } = useStudentDashboard(); - - if (isLoading) return ; - - if (isError) { - const msg = error instanceof Error ? error.message : "Unknown error"; - return ; - } - - if (!data) return null; - - const { student, subjectCount, grades, attendance } = data; - const recentGrades = grades.slice(0, 5); - - return ( -
- {/* Page heading */} -

My Dashboard

- - {/* Welcome banner */} -
-
-

- Welcome back, {student.firstName ?? student.lastName}! -

- {student.className && ( -

Class: {student.className}

- )} -
- {student.averageGrade && ( -
- Average Grade - -
- )} -
- - {/* Stat cards */} -
- } - value={String(subjectCount)} - label="Subjects" - delta="Enrolled" - iconBg="bg-blue-100/60" - iconColor="text-blue-600" - deltaStatus="neutral" - /> - } - value={String(attendance.present)} - label="Present Days" - delta={`${attendance.rate}% rate`} - iconBg="bg-green-100/60" - iconColor="text-green-600" - deltaStatus="positive" - /> - } - value={String(attendance.absent)} - label="Absent Days" - delta={attendance.absent === 0 ? "Perfect!" : "Missed"} - iconBg="bg-red-100/60" - iconColor="text-red-500" - deltaStatus={attendance.absent === 0 ? "positive" : "negative"} - /> - } - value={`${attendance.rate}%`} - label="Attendance Rate" - delta={attendance.rate >= 80 ? "Good" : "Low"} - iconBg="bg-teal-100/60" - iconColor="text-teal-600" - deltaStatus={attendance.rate >= 80 ? "positive" : "negative"} - /> -
- - {/* Recent grades table */} -
-

Recent Grades

- {recentGrades.length === 0 ? ( -

No grades recorded yet.

- ) : ( -
- - - - - - - - - - - {recentGrades.map((g, idx) => ( - - - - - - - ))} - -
SubjectTermGradeScore
{g.subject}{g.term} - - - {g.rawScore != null ? g.rawScore : "—"} -
-
- )} -
-
- ); + return ; } diff --git a/stackable-app/app/(student)/learn/settings/page.tsx b/stackable-app/app/(student)/learn/settings/page.tsx new file mode 100644 index 0000000..f21c1ae --- /dev/null +++ b/stackable-app/app/(student)/learn/settings/page.tsx @@ -0,0 +1,11 @@ +import { ComingSoon } from "@/components/ui"; + +export default function StudentSettingsPage() { + return ( + + ); +} diff --git a/stackable-app/app/(teacher)/layout.tsx b/stackable-app/app/(teacher)/layout.tsx index 082ab3a..7343d16 100644 --- a/stackable-app/app/(teacher)/layout.tsx +++ b/stackable-app/app/(teacher)/layout.tsx @@ -1,181 +1,8 @@ -"use client"; - -import React, { useEffect, useState } from "react"; -import clsx from "clsx"; -import { - Blocks, - BookOpenText, - CalendarDays, - ClipboardList, - FileSpreadsheet, - GraduationCap, - Home, - MessagesSquare, - UserCog, -} from "lucide-react"; -import Sidebar, { type SidebarSection } from "@/components/sidebar/sidebar"; -import Navbar from "@/components/header/dashboard-navbar"; -import BreadCrumb from "@/components/breadcrumb/bread"; - -const DESKTOP_SIDEBAR_EXPANDED_OFFSET = "lg:pl-[17rem]"; -const DESKTOP_SIDEBAR_COLLAPSED_OFFSET = "lg:pl-28"; - -const iconClassName = "h-5 w-5 shrink-0"; -const iconStrokeWidth = 1.75; - -const TEACHER_SECTIONS: SidebarSection[] = [ - { - key: "settings", - ariaLabel: "Settings navigation", - items: [ - { - type: "link", - href: "/teach/settings", - label: "My Settings", - variant: "top", - icon: , - }, - ], - }, - { - key: "main", - ariaLabel: "Main navigation", - items: [ - { - type: "link", - href: "/teach", - label: "Home", - icon: , - }, - { - type: "link", - href: "/teach/classes", - label: "My Classes", - icon: , - }, - { - type: "link", - href: "/teach/students", - label: "My Students", - icon: ( - - ), - }, - { - type: "link", - href: "/teach/subjects", - label: "My Subjects", - icon: ( - - ), - }, - { - type: "link", - href: "/teach/attendance", - label: "Attendance", - icon: ( - - ), - }, - { - type: "link", - href: "/teach/grading", - label: "Grading", - icon: ( - - ), - }, - { - type: "link", - href: "/teach/timetable", - label: "Timetable", - icon: ( - - ), - }, - { - type: "link", - href: "/teach/inbox", - label: "Inbox", - icon: ( - - ), - }, - ], - }, -]; - -const TeacherLayout = ({ children }: { children: React.ReactNode }) => { - const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); - const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); - - useEffect(() => { - const handleResize = () => { - if (window.innerWidth >= 1024) { - setIsMobileSidebarOpen(false); - } - }; - - handleResize(); - window.addEventListener("resize", handleResize); - - return () => { - window.removeEventListener("resize", handleResize); - }; - }, []); - - useEffect(() => { - const originalOverflow = document.body.style.overflow; - - if (isMobileSidebarOpen) { - document.body.style.overflow = "hidden"; - } else { - document.body.style.overflow = originalOverflow; - } - - return () => { - document.body.style.overflow = originalOverflow; - }; - }, [isMobileSidebarOpen]); - - return ( -
- setIsMobileSidebarOpen(false)} - onToggleCollapse={() => setIsSidebarCollapsed((current) => !current)} - sections={TEACHER_SECTIONS} - /> - -
- - setIsMobileSidebarOpen((current) => !current) - } - /> - -
-
- -
- {children} -
-
-
- ); -}; - -export default TeacherLayout; +import { requireRole } from "@/lib/api/guard"; +import { DashboardShell } from "@/components/dashboard/DashboardShell"; + +// Teacher / staff portal (/teach/*). Menu: lib/nav.ts -> NAV.teacher. +export default async function TeacherLayout({ children }: { children: React.ReactNode }) { + await requireRole("teacher"); + return {children}; +} diff --git a/stackable-app/app/(teacher)/teach/attendance/page.tsx b/stackable-app/app/(teacher)/teach/attendance/page.tsx new file mode 100644 index 0000000..aafb64e --- /dev/null +++ b/stackable-app/app/(teacher)/teach/attendance/page.tsx @@ -0,0 +1,11 @@ +import { ComingSoon } from "@/components/ui"; + +export default function TeacherAttendancePage() { + return ( + + ); +} diff --git a/stackable-app/app/(teacher)/teach/grading/page.tsx b/stackable-app/app/(teacher)/teach/grading/page.tsx new file mode 100644 index 0000000..f8fe277 --- /dev/null +++ b/stackable-app/app/(teacher)/teach/grading/page.tsx @@ -0,0 +1,11 @@ +import { ComingSoon } from "@/components/ui"; + +export default function TeacherGradingPage() { + return ( + + ); +} diff --git a/stackable-app/app/(teacher)/teach/inbox/page.tsx b/stackable-app/app/(teacher)/teach/inbox/page.tsx new file mode 100644 index 0000000..2538011 --- /dev/null +++ b/stackable-app/app/(teacher)/teach/inbox/page.tsx @@ -0,0 +1,11 @@ +import { ComingSoon } from "@/components/ui"; + +export default function TeacherInboxPage() { + return ( + + ); +} diff --git a/stackable-app/app/(teacher)/teach/page.tsx b/stackable-app/app/(teacher)/teach/page.tsx index 37cf09c..bf62ccf 100644 --- a/stackable-app/app/(teacher)/teach/page.tsx +++ b/stackable-app/app/(teacher)/teach/page.tsx @@ -1,124 +1,6 @@ -"use client"; - -import { Blocks, BookOpenText, GraduationCap } from "lucide-react"; -import StatCard from "@/components/cards/card"; -import { useTeacherPortal } from "@/hooks/useTeacherPortal"; +import TeacherHome from "@/components/portal/teacher/TeacherHome"; +// Teacher home. The shell (layout) renders the page title; all UI lives in TeacherHome. export default function TeacherDashboardPage() { - const { data, isLoading, isError, error } = useTeacherPortal(); - - if (isLoading) { - return ( -
-
-
- ); - } - - if (isError) { - return ( -
-

- {error instanceof Error ? error.message : "Failed to load dashboard data."} -

-
- ); - } - - const { teacher, classes, subjects, studentCount } = data!; - - return ( -
- {/* Heading */} -
-

Teacher Dashboard

-

- Welcome back, {teacher.name} -

-
- - {/* Stat Cards */} -
- } - value={String(classes.length)} - label="My Classes" - delta="Active" - iconBg="bg-green-100" - iconColor="text-[#108548]" - deltaStatus="positive" - /> - } - value={String(studentCount)} - label="My Students" - delta="Assigned" - iconBg="bg-blue-100" - iconColor="text-blue-600" - deltaStatus="neutral" - /> - } - value={String(subjects.length)} - label="My Subjects" - delta="Teaching" - iconBg="bg-yellow-100" - iconColor="text-[#ffc300]" - deltaStatus="neutral" - /> -
- - {/* My Classes */} -
-

My Classes

- {classes.length === 0 ? ( -

No classes assigned yet.

- ) : ( -
- - - - - - - - - - {classes.map((cls) => ( - - - - - - ))} - -
ClassStreamStudents
{cls.name} - {cls.stream ?? "—"} - - {cls.totalStudents ?? "—"} -
-
- )} -
- - {/* My Subjects */} -
-

My Subjects

- {subjects.length === 0 ? ( -

No subjects assigned yet.

- ) : ( -
- {subjects.map((sub) => ( - - {sub.subjectName} - - ))} -
- )} -
-
- ); + return ; } diff --git a/stackable-app/app/(teacher)/teach/settings/page.tsx b/stackable-app/app/(teacher)/teach/settings/page.tsx new file mode 100644 index 0000000..974d677 --- /dev/null +++ b/stackable-app/app/(teacher)/teach/settings/page.tsx @@ -0,0 +1,11 @@ +import { ComingSoon } from "@/components/ui"; + +export default function TeacherSettingsPage() { + return ( + + ); +} diff --git a/stackable-app/app/(teacher)/teach/students/page.tsx b/stackable-app/app/(teacher)/teach/students/page.tsx index d06c48a..f2073aa 100644 --- a/stackable-app/app/(teacher)/teach/students/page.tsx +++ b/stackable-app/app/(teacher)/teach/students/page.tsx @@ -1,95 +1,21 @@ -"use client"; - -import { useTeacherStudents } from "@/hooks/useTeacherStudents"; - -function StatusBadge({ status }: { status: string }) { - const isActive = status === "active"; - return ( - - {status} - - ); -} +import { Suspense } from "react"; +import StudentsView from "@/components/portal/teacher/StudentsView"; +import { Skeleton } from "@/components/ui"; +// Students the signed-in teacher is assigned to. The shell renders the page title. +// StudentsView reads the `?student=` deep-link via useSearchParams, so it sits +// inside a Suspense boundary (the fallback is just a quiet table-shaped skeleton). export default function MyStudentsPage() { - const { data, isLoading, isError, error } = useTeacherStudents(); - - if (isLoading) { - return ( -
-
-
- ); - } - - if (isError) { - return ( -
-

- {error instanceof Error ? error.message : "Failed to load students."} -

-
- ); - } - - const { students, total } = data!; - return ( -
-
-

My Students

-

{total} student{total !== 1 ? "s" : ""} assigned to you

-
- -
- {students.length === 0 ? ( -

- No students assigned yet. -

- ) : ( -
- - - - - - - - - - - {students.map((student) => ( - - - - - - - ))} - -
Student NameAdmission NoClassStatus
- {[student.firstName, student.lastName] - .filter(Boolean) - .join(" ") || "—"} - - {student.admissionNo} - - {student.className ?? "—"} - - -
-
- )} -
-
+ + + +
+ } + > + + ); } diff --git a/stackable-app/app/(teacher)/teach/timetable/page.tsx b/stackable-app/app/(teacher)/teach/timetable/page.tsx new file mode 100644 index 0000000..ff0ff83 --- /dev/null +++ b/stackable-app/app/(teacher)/teach/timetable/page.tsx @@ -0,0 +1,11 @@ +import { ComingSoon } from "@/components/ui"; + +export default function TeacherTimetablePage() { + return ( + + ); +} diff --git a/stackable-app/app/api/admin/overview/route.ts b/stackable-app/app/api/admin/overview/route.ts new file mode 100644 index 0000000..5421b0c --- /dev/null +++ b/stackable-app/app/api/admin/overview/route.ts @@ -0,0 +1,31 @@ +// ============================================================================= +// GET /api/admin/overview — the school principal's home page numbers. +// ----------------------------------------------------------------------------- +// Scoped to the signed-in user's school (from the session). The answer is the same +// for every admin of that school, so it is cached per school for 60 seconds. +// ============================================================================= + +import { NextRequest, NextResponse } from "next/server"; +import { requireAuth } from "@/lib/api/guard"; +import { toErrorResponse } from "@/lib/api/errors"; +import { cached } from "@/lib/cache"; +import { getAdminOverview } from "@/lib/repositories/admin-overview.repo"; + +export const dynamic = "force-dynamic"; + +const OVERVIEW_CACHE_SECONDS = 60; // dashboards may be up to a minute behind + +export async function GET(request: NextRequest) { + try { + const auth = await requireAuth(request, { + roles: ["admin", "manager", "super-admin"], + rateLimit: "read", + }); + const data = await cached(auth.schoolId, "admin-overview", OVERVIEW_CACHE_SECONDS, () => + getAdminOverview(auth.schoolId), + ); + return NextResponse.json({ ok: true, data }); + } catch (err) { + return toErrorResponse(err); + } +} diff --git a/stackable-app/app/api/admin/schools/route.ts b/stackable-app/app/api/admin/schools/route.ts index 9ba0b5f..919dff1 100644 --- a/stackable-app/app/api/admin/schools/route.ts +++ b/stackable-app/app/api/admin/schools/route.ts @@ -1,38 +1,20 @@ import { NextRequest, NextResponse } from "next/server"; -import { createClient } from "@supabase/supabase-js"; import { requireAuth } from "@/lib/api/guard"; import { toErrorResponse } from "@/lib/api/errors"; import { listSchools } from "@/lib/repositories/school.repo"; -const supabase = createClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.SUPABASE_SERVICE_ROLE_KEY!, - { auth: { persistSession: false } } -); - export async function GET(request: NextRequest) { try { await requireAuth(request, { roles: ["admin", "super-admin"], rateLimit: "read" }); - } catch (err) { return toErrorResponse(err); } - - if (process.env.DATA_BACKEND === "prisma") { - try { - const schools = await listSchools(); - return NextResponse.json({ schools }); - } catch (error) { - console.error("admin schools list route error (prisma)", error); - return NextResponse.json({ error: "Unexpected server error." }, { status: 500 }); - } + } catch (err) { + return toErrorResponse(err); } - const { data, error } = await supabase - .from("schools") - .select("id, name, code, created_at") - .order("name", { ascending: true }); - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }); + try { + const schools = await listSchools(); + return NextResponse.json({ schools }); + } catch (error) { + console.error("admin schools list route error", error); + return NextResponse.json({ error: "Unexpected server error." }, { status: 500 }); } - - return NextResponse.json({ schools: data ?? [] }); -} \ No newline at end of file +} diff --git a/stackable-app/app/api/admin/users/route.ts b/stackable-app/app/api/admin/users/route.ts index 45d508c..e837c86 100644 --- a/stackable-app/app/api/admin/users/route.ts +++ b/stackable-app/app/api/admin/users/route.ts @@ -1,46 +1,25 @@ import { NextRequest, NextResponse } from "next/server"; -import { createClient } from "@supabase/supabase-js"; import { requireAuth } from "@/lib/api/guard"; +import { buildAuditContext } from "@/lib/api/audit-context"; import { toErrorResponse } from "@/lib/api/errors"; -import type { Role } from "@/lib/validation/shared"; -import { listUsersWithPermissions } from "@/lib/repositories/user.repo"; - -// Only admins manage other users. -const USER_ADMIN_ROLES: Role[] = ["admin", "super-admin"]; - -const supabase = createClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.SUPABASE_SERVICE_ROLE_KEY!, - { auth: { persistSession: false } } -); - -const ALLOWED_ROLES = ["manager", "admin", "super-admin", "teacher", "student"] as const; -const ALLOWED_STATUSES = ["active", "suspended", "pending"] as const; - -const PAGE_KEYS = [ - "dashboard", - "users", - "students", - "teachers", - "parents", - "classes", - "subjects", - "payments", - "library", - "exams", - "assignments", - "reports", - "settings", - "notifications", -] as const; - -function isAllowedRole(value: string) { - return ALLOWED_ROLES.includes(value as (typeof ALLOWED_ROLES)[number]); -} - -function isAllowedStatus(value: string) { - return ALLOWED_STATUSES.includes(value as (typeof ALLOWED_STATUSES)[number]); -} +import { parseJson } from "@/lib/api/validate"; +import { + createUserRecord, + deleteUserRecord, + findUserTarget, + listUsersWithPermissions, + updateUserRecord, +} from "@/lib/repositories/user.repo"; +import { PAGE_KEYS } from "@/lib/validation/shared"; +import { + checkUserCreate, + checkUserDelete, + checkUserUpdate, + createUserSchema, + deleteUserQuerySchema, + updateUserSchema, + USER_ADMIN_ROLES, +} from "@/lib/validation/user-admin"; export async function GET(request: NextRequest) { try { @@ -56,350 +35,86 @@ export async function GET(request: NextRequest) { const status = searchParams.get("status") ?? "all"; const schoolId = searchParams.get("schoolId") ?? "all"; - if (process.env.DATA_BACKEND === "prisma") { - const users = await listUsersWithPermissions({ schoolId, role, status, search }); - return NextResponse.json({ users, pageKeys: PAGE_KEYS }); - } - - let query = supabase - .from("users") - .select(` - id, - created_at, - updated_at, - school_id, - school_code, - school_adm, - email, - phone, - phone_2, - role, - status, - first_name, - last_name, - must_change_password, - schools:school_id ( - id, - name, - code - ) - `) - .order("created_at", { ascending: false }); - - if (role !== "all") query = query.eq("role", role); - if (status !== "all") query = query.eq("status", status); - if (schoolId !== "all") query = query.eq("school_id", schoolId); - - const { data: users, error } = await query; - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }); - } - - const filtered = (users ?? []).filter((user) => { - if (!search) return true; - const fullName = `${user.first_name ?? ""} ${user.last_name ?? ""}`.toLowerCase(); - const email = (user.email ?? "").toLowerCase(); - const phone = String(user.phone ?? "").toLowerCase(); - const phone2 = String(user.phone_2 ?? "").toLowerCase(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const schoolName = ((user as any).schools?.name ?? "").toLowerCase(); - const roleValue = (user.role ?? "").toLowerCase(); - - return ( - fullName.includes(search.toLowerCase()) || - email.includes(search.toLowerCase()) || - phone.includes(search.toLowerCase()) || - phone2.includes(search.toLowerCase()) || - schoolName.includes(search.toLowerCase()) || - roleValue.includes(search.toLowerCase()) - ); - }); - - const userIds = filtered.map((item) => item.id); - - let permissionsMap: Record = {}; - - if (userIds.length) { - const { data: permissions } = await supabase - .from("user_page_permissions") - .select("user_id, page_key, can_access") - .in("user_id", userIds); - - permissionsMap = (permissions ?? []).reduce((acc, item) => { - if (!acc[item.user_id]) acc[item.user_id] = []; - acc[item.user_id].push({ - page_key: item.page_key, - can_access: item.can_access, - }); - return acc; - }, {} as Record); - } - - return NextResponse.json({ - users: filtered.map((user) => ({ - ...user, - permissions: permissionsMap[user.id] ?? [], - })), - pageKeys: PAGE_KEYS, - }); + const users = await listUsersWithPermissions({ schoolId, role, status, search }); + return NextResponse.json({ users, pageKeys: PAGE_KEYS }); } catch (error) { - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unknown server error" }, - { status: 500 } - ); + console.error("admin users list route error", error); + return NextResponse.json({ error: "Unexpected server error." }, { status: 500 }); } } export async function POST(request: NextRequest) { + let auth; try { - await requireAuth(request, { roles: USER_ADMIN_ROLES, rateLimit: "mutation" }); + auth = await requireAuth(request, { roles: USER_ADMIN_ROLES, rateLimit: "mutation", denyImpersonation: true }); } catch (err) { return toErrorResponse(err); } try { - const body = await request.json(); - - const { - first_name, - last_name, - email, - phone, - phone_2, - school_id, - role, - photo_url, - permissions = [], - } = body; - - if (!first_name || !last_name || !school_id || !role) { - return NextResponse.json( - { error: "first_name, last_name, school_id and role are required." }, - { status: 400 } - ); - } - - if (!isAllowedRole(role)) { - return NextResponse.json({ error: "Invalid role." }, { status: 400 }); - } - - const { data: insertedUser, error: insertError } = await supabase - .from("users") - .insert({ - first_name, - last_name, - email: email || null, - phone: phone || null, - phone_2: phone_2 || null, - school_id, - role, - status: "pending", - must_change_password: true, - }) - .select(` - id, - created_at, - updated_at, - school_id, - school_code, - school_adm, - email, - phone, - phone_2, - role, - status, - first_name, - last_name, - must_change_password, - schools:school_id ( - id, - name, - code - ) - `) - .single(); - - if (insertError) { - return NextResponse.json({ error: insertError.message }, { status: 500 }); - } - - if (photo_url) { - await supabase - .from("user_profiles") - .upsert( - { - user_id: insertedUser.id, - photo_url, - }, - { onConflict: "user_id" } - ); - } - - const sanitizedPermissions = Array.isArray(permissions) - ? permissions - .filter((item) => PAGE_KEYS.includes(item.page_key)) - .map((item) => ({ - user_id: insertedUser.id, - page_key: item.page_key, - can_access: !!item.can_access, - })) - : []; + const body = await parseJson(request, createUserSchema); - if (sanitizedPermissions.length) { - const { error: permissionsError } = await supabase - .from("user_page_permissions") - .upsert(sanitizedPermissions, { - onConflict: "user_id,page_key", - }); + const denial = checkUserCreate({ id: auth.userId, role: auth.role, schoolId: auth.schoolId }, body); + if (denial) return NextResponse.json({ error: denial.message, code: denial.code }, { status: denial.status }); - if (permissionsError) { - return NextResponse.json({ error: permissionsError.message }, { status: 500 }); - } - } - - return NextResponse.json({ user: insertedUser }, { status: 201 }); + const audit = buildAuditContext(request, auth); + const user = await createUserRecord(body, audit); + return NextResponse.json({ user }, { status: 201 }); } catch (error) { - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unknown server error" }, - { status: 500 } - ); + return toErrorResponse(error); } } export async function PATCH(request: NextRequest) { + let auth; try { - await requireAuth(request, { roles: USER_ADMIN_ROLES, rateLimit: "mutation" }); + auth = await requireAuth(request, { roles: USER_ADMIN_ROLES, rateLimit: "mutation", denyImpersonation: true }); } catch (err) { return toErrorResponse(err); } try { - const body = await request.json(); + const body = await parseJson(request, updateUserSchema); - const { - id, - email, - status, - role, - must_change_password, - clear_history, - permissions, - } = body; + const target = await findUserTarget(body.id); + if (!target) return NextResponse.json({ error: "User not found." }, { status: 404 }); - if (!id) { - return NextResponse.json({ error: "User id is required." }, { status: 400 }); - } - - const updatePayload: Record = {}; - - if (typeof email !== "undefined") updatePayload.email = email || null; - if (typeof status !== "undefined") { - if (!isAllowedStatus(status)) { - return NextResponse.json({ error: "Invalid status." }, { status: 400 }); - } - updatePayload.status = status; - } - - if (typeof role !== "undefined") { - if (!isAllowedRole(role)) { - return NextResponse.json({ error: "Invalid role." }, { status: 400 }); - } - updatePayload.role = role; - } - - if (typeof must_change_password !== "undefined") { - updatePayload.must_change_password = !!must_change_password; - } - - const { error: updateError } = await supabase - .from("users") - .update(updatePayload) - .eq("id", id); - - if (updateError) { - return NextResponse.json({ error: updateError.message }, { status: 500 }); - } - - if (Array.isArray(permissions)) { - const { error: deleteOldPermissionsError } = await supabase - .from("user_page_permissions") - .delete() - .eq("user_id", id); - - if (deleteOldPermissionsError) { - return NextResponse.json( - { error: deleteOldPermissionsError.message }, - { status: 500 } - ); - } - - const nextPermissions = permissions - .filter((item) => PAGE_KEYS.includes(item.page_key)) - .map((item) => ({ - user_id: id, - page_key: item.page_key, - can_access: !!item.can_access, - })); - - if (nextPermissions.length) { - const { error: insertPermissionsError } = await supabase - .from("user_page_permissions") - .insert(nextPermissions); - - if (insertPermissionsError) { - return NextResponse.json( - { error: insertPermissionsError.message }, - { status: 500 } - ); - } - } - } - - if (clear_history) { - // Replace these with your real audit/history tables. - await Promise.allSettled([ - supabase.from("activity_logs").delete().eq("user_id", id), - supabase.from("login_history").delete().eq("user_id", id), - supabase.from("notifications").delete().eq("user_id", id), - ]); - } + const denial = checkUserUpdate({ id: auth.userId, role: auth.role, schoolId: auth.schoolId }, target, body); + if (denial) return NextResponse.json({ error: denial.message, code: denial.code }, { status: denial.status }); + const audit = buildAuditContext(request, auth); + await updateUserRecord(body, audit); return NextResponse.json({ ok: true }); } catch (error) { - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unknown server error" }, - { status: 500 } - ); + return toErrorResponse(error); } } export async function DELETE(request: NextRequest) { + let auth; try { - await requireAuth(request, { roles: ["super-admin"], rateLimit: "mutation" }); + auth = await requireAuth(request, { roles: ["super-admin"], rateLimit: "mutation", denyImpersonation: true }); } catch (err) { return toErrorResponse(err); } try { const { searchParams } = new URL(request.url); - const id = searchParams.get("id"); - - if (!id) { + const parsed = deleteUserQuerySchema.safeParse({ id: searchParams.get("id") ?? "" }); + if (!parsed.success) { return NextResponse.json({ error: "User id is required." }, { status: 400 }); } - const { error } = await supabase.from("users").delete().eq("id", id); + const target = await findUserTarget(parsed.data.id); + if (!target) return NextResponse.json({ error: "User not found." }, { status: 404 }); - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }); - } + const denial = checkUserDelete({ id: auth.userId, role: auth.role, schoolId: auth.schoolId }, target); + if (denial) return NextResponse.json({ error: denial.message, code: denial.code }, { status: denial.status }); + const audit = buildAuditContext(request, auth); + await deleteUserRecord(parsed.data.id, audit); return NextResponse.json({ ok: true }); } catch (error) { - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unknown server error" }, - { status: 500 } - ); + return toErrorResponse(error); } -} \ No newline at end of file +} diff --git a/stackable-app/app/api/assessments/route.ts b/stackable-app/app/api/assessments/route.ts index 62a6ca7..4a2d991 100644 --- a/stackable-app/app/api/assessments/route.ts +++ b/stackable-app/app/api/assessments/route.ts @@ -1,133 +1,104 @@ +// ============================================================================= +// /api/assessments — assessments for one subject offering, addressed by +// `school_subject_id` (query for GET, body for POST). Same data and rules as +// /api/subjects/[id]/assessments; the offering must belong to the caller's school +// (another school's id is a 404), and reads/writes share the subjects cache. +// ============================================================================= + import { NextRequest, NextResponse } from "next/server"; -import { getSchoolSubjectOrThrow, getSubjectAssessmentsData } from "@/lib/subjects-server"; -import { supabaseAdmin } from "@/lib/supabase/admin"; import { requireAuth } from "@/lib/api/guard"; -import { toErrorResponse } from "@/lib/api/errors"; - -function asText(value: unknown) { - const text = String(value ?? "").trim(); - return text || null; -} - -function asNumber(value: unknown) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : null; -} - +import { badRequest, toErrorResponse } from "@/lib/api/errors"; +import { parse } from "@/lib/api/validate"; +import { bumpCache, cached } from "@/lib/cache"; +import { createAssessment } from "@/lib/subjects-mutations"; +import { + SUBJECT_ASSESSMENTS_TTL_SECONDS, + SUBJECT_CACHE_ENTITY, + SUBJECT_READ_ROLES, + SUBJECT_WRITE_ROLES, + getSchoolSubjectOrThrow, + getSubjectAssessmentsData, + requireSubjectId, +} from "@/lib/subjects-server"; +import { createAssessmentSchema } from "@/lib/validation/subjects"; + +export const dynamic = "force-dynamic"; + +/** GET ?school_subject_id= -> { ok: true, data: SubjectAssessmentsPayload }. */ export async function GET(request: NextRequest) { + let auth; try { - await requireAuth(request, { pageKey: "exams", rateLimit: "read" }); - } catch (err) { return toErrorResponse(err); } + auth = await requireAuth(request, { + roles: SUBJECT_READ_ROLES, + pageKey: "exams", + rateLimit: "read", + }); + } catch (err) { + return toErrorResponse(err); + } try { - const schoolSubjectId = request.nextUrl.searchParams.get("school_subject_id"); - if (!schoolSubjectId) { - return NextResponse.json({ error: "school_subject_id is required." }, { status: 400 }); - } - - const data = await getSubjectAssessmentsData(schoolSubjectId); + const rawId = request.nextUrl.searchParams.get("school_subject_id"); + if (!rawId) throw badRequest("school_subject_id is required."); + + const id = requireSubjectId(rawId); + const { schoolId } = auth; + const data = await cached( + schoolId, + SUBJECT_CACHE_ENTITY, + SUBJECT_ASSESSMENTS_TTL_SECONDS, + () => getSubjectAssessmentsData(id, schoolId), + `assessments:${id}`, + ); return NextResponse.json({ ok: true, data }); } catch (error) { - console.error("shared assessments route error", error); - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unexpected server error." }, - { status: 500 }, - ); + return toErrorResponse(error); } } +/** + * POST JSON { school_subject_id, type, title, target_class_ids, description?, term?, + * total_marks_raw?, duration_minutes?, scheduled_start_at?, scheduled_end_at?, teacher_id? }. + * Response 201: { ok: true, data: SubjectAssessmentsPayload }. + */ export async function POST(request: NextRequest) { + let auth; try { - await requireAuth(request, { roles: ["admin", "super-admin", "manager", "teacher"], rateLimit: "mutation" }); - } catch (err) { return toErrorResponse(err); } + auth = await requireAuth(request, { + roles: SUBJECT_WRITE_ROLES, + pageKey: "exams", + rateLimit: "mutation", + }); + } catch (err) { + return toErrorResponse(err); + } try { - const body = (await request.json()) as { - school_subject_id?: string | null; - type?: string | null; - title?: string | null; - description?: string | null; - term?: string | null; - total_marks_raw?: number | null; - duration_minutes?: number | null; - scheduled_start_at?: string | null; - scheduled_end_at?: string | null; - target_class_ids?: string[]; - teacher_id?: string | null; - created_by?: string | null; - }; - - const schoolSubjectId = asText(body.school_subject_id); - const title = asText(body.title); - const type = asText(body.type); - const targetClassIds = Array.isArray(body.target_class_ids) ? body.target_class_ids : []; - - if (!schoolSubjectId || !title || !type || targetClassIds.length === 0) { - return NextResponse.json( - { error: "school_subject_id, title, type, and target classes are required." }, - { status: 400 }, - ); - } + const { schoolId, userId } = auth; - const offering = await getSchoolSubjectOrThrow(schoolSubjectId); - - const assessmentInsert = await supabaseAdmin - .from("assessments") - .insert({ - school_id: offering.school_id, - school_subject_id: schoolSubjectId, - type, - title, - description: asText(body.description), - term: asText(body.term), - total_marks_raw: asNumber(body.total_marks_raw), - duration_minutes: asNumber(body.duration_minutes), - scheduled_start_at: asText(body.scheduled_start_at), - scheduled_end_at: asText(body.scheduled_end_at), - status: "scheduled", - created_by: asText(body.created_by), - }) - .select("id") - .single(); - - if (assessmentInsert.error || !assessmentInsert.data) { - return NextResponse.json( - { error: assessmentInsert.error?.message ?? "Failed to create assessment." }, - { status: 500 }, - ); + let body: unknown; + try { + body = await request.json(); + } catch { + throw badRequest("Request body must be valid JSON."); } - const classLinks = await supabaseAdmin - .from("school_subject_classes") - .select("id, class_id") - .eq("school_subject_id", schoolSubjectId) - .in("class_id", targetClassIds); + const rawId = String((body as { school_subject_id?: unknown } | null)?.school_subject_id ?? "").trim(); + const input = parse(createAssessmentSchema, body); - if (classLinks.error) { - return NextResponse.json({ error: classLinks.error.message }, { status: 500 }); + if (!rawId || !input.title || !input.type || input.target_class_ids.length === 0) { + throw badRequest("school_subject_id, title, type, and target classes are required."); } - const targetsInsert = await supabaseAdmin.from("assessment_targets").insert( - (classLinks.data ?? []).map((item) => ({ - assessment_id: assessmentInsert.data.id, - school_subject_class_id: item.id, - class_id: item.class_id, - teacher_id: asText(body.teacher_id), - status: "scheduled", - })), - ); + const id = requireSubjectId(rawId); + const offering = await getSchoolSubjectOrThrow(id, schoolId); - if (targetsInsert.error) { - return NextResponse.json({ error: targetsInsert.error.message }, { status: 500 }); - } + await createAssessment(offering, { ...input, title: input.title, type: input.type }, { schoolId, userId }); + await bumpCache(schoolId, SUBJECT_CACHE_ENTITY); - const data = await getSubjectAssessmentsData(schoolSubjectId); + const data = await getSubjectAssessmentsData(id, schoolId); return NextResponse.json({ ok: true, data }, { status: 201 }); } catch (error) { - console.error("shared assessments create route error", error); - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unexpected server error." }, - { status: 500 }, - ); + return toErrorResponse(error); } } diff --git a/stackable-app/app/api/auth/forgot-password/route.ts b/stackable-app/app/api/auth/forgot-password/route.ts new file mode 100644 index 0000000..7c5f50d --- /dev/null +++ b/stackable-app/app/api/auth/forgot-password/route.ts @@ -0,0 +1,70 @@ +import { NextResponse } from 'next/server'; +import { generateOtp, getClientIp, getUserAgent, resolveSmsPhone } from '@/lib/auth-utils'; +import { enqueueJob } from '@/lib/qeue/jobs'; +import { toErrorResponse } from '@/lib/api/errors'; +import { OTP_TTL_MS, authLimit, hashCode } from '@/lib/auth/otp'; +import { findActiveUsersByEmail, issueResetCode, listResetTimesSince } from '@/lib/repositories/auth.repo'; +import { forgotPasswordSchema } from '@/lib/validation/auth'; + +const RESEND_GAP_MS = 60 * 1000; +const MAX_CODES_PER_HOUR = 5; + +// Same answer whether or not the email exists, so this can't be used to find accounts. +const GENERIC = { ok: true, message: 'If that email is registered, a 6-digit code is on its way.' }; + +export async function POST(req: Request) { + try { + const parsed = forgotPasswordSchema.safeParse(await req.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json({ error: 'Enter a valid email address.' }, { status: 400 }); + } + const email = parsed.data.email.trim().toLowerCase(); + + await authLimit(`forgot:${email}`); + + // Only a single, active account gets a code (an email shared by two schools is ambiguous). + const users = await findActiveUsersByEmail(email); + const user = users.length === 1 && users[0].status === 'active' && users[0].email ? users[0] : null; + if (!user) return NextResponse.json(GENERIC); + + const recent = await listResetTimesSince(user.id, new Date(Date.now() - 60 * 60 * 1000)); + if (recent.length > 0) { + const sinceLast = Date.now() - recent[0].getTime(); + if (sinceLast < RESEND_GAP_MS || recent.length >= MAX_CODES_PER_HOUR) { + return NextResponse.json(GENERIC); + } + } + + const code = generateOtp(6); + try { + await issueResetCode({ + userId: user.id, + codeHash: hashCode('reset', user.id, code), // keyed hash, never the digits + expiresAt: new Date(Date.now() + OTP_TTL_MS), + ip: getClientIp(req), + userAgent: getUserAgent(req), + }); + } catch (err) { + console.error('forgot-password insert failed', err); + return NextResponse.json(GENERIC); + } + + // Queued (not awaited-and-sent inline), so an unknown email and a real one take the same + // time either way, and delivery is retried by QStash if it fails. + try { + await enqueueJob('send-otp', { + email: user.email!, + firstName: user.first_name, + otpCode: code, + purpose: 'reset', + phone: resolveSmsPhone(user), + }); + } catch (err) { + console.error('forgot-password enqueue failed', err); + } + + return NextResponse.json(GENERIC); + } catch (error) { + return toErrorResponse(error); + } +} diff --git a/stackable-app/app/api/auth/login/route.ts b/stackable-app/app/api/auth/login/route.ts index 861bdc5..9325d39 100644 --- a/stackable-app/app/api/auth/login/route.ts +++ b/stackable-app/app/api/auth/login/route.ts @@ -1,15 +1,22 @@ +import { cookies } from 'next/headers'; import { NextResponse } from 'next/server'; import bcrypt from 'bcryptjs'; -//import { Resend } from 'resend'; -import { createSupabaseClient } from '@/lib/supabase/supabase-admin'; -import { generateOtp, getClientIp, getUserAgent, maskEmail } from '@/lib/auth-utils'; -import { qstash } from "@/lib/qeue/qstash"; -//import { pingram } from "@/lib/pingram/pingram"; -//import { infobip } from "@/lib/infobib/infobib"; - - -//const resend = new Resend(process.env.RESEND_API_KEY); - +import { generateOtp, getClientIp, getUserAgent, maskEmail, resolveSmsPhone } from '@/lib/auth-utils'; +import { enqueueJob } from '@/lib/qeue/jobs'; +import { toErrorResponse } from '@/lib/api/errors'; +import { + OTP_CHALLENGE_COOKIE, + OTP_TTL_MS, + MAX_OTP_SENDS, + authLimit, + challengeCookieOptions, + hashCode, + signChallenge, +} from '@/lib/auth/otp'; +import { countLoginOtpsSince, findUsersByEmail, issueLoginOtp, type AuthUser } from '@/lib/repositories/auth.repo'; + +// Compared against when the email is unknown, so "no such user" costs the same as "wrong password". +const DUMMY_HASH = bcrypt.hashSync('not-a-real-password', 10); export async function POST(req: Request) { try { @@ -21,16 +28,19 @@ export async function POST(req: Request) { return NextResponse.json({ error: 'Email and password are required.' }, { status: 400 }); } - const { data: user, error } = await createSupabaseClient - .from('users') - .select('id, school_id, school_code, email, phone, phone_2, password_hash, first_name, last_name, role, status, must_change_password') - .eq('email', email) - .maybeSingle(); + await authLimit(`login:${email}`); - if (error) { - return NextResponse.json({ error: 'Database error while reading user.' }, { status: 500 }); + // The same email can belong to accounts at different schools: try the password against each + // (at most 3, always all of them, so timing does not reveal how many exist). + const candidates = await findUsersByEmail(email); + let user: AuthUser | null = null; + if (candidates.length === 0) await bcrypt.compare(password, DUMMY_HASH); + for (const candidate of candidates) { + const ok = await bcrypt.compare(password, candidate.password_hash || DUMMY_HASH); + if (ok && candidate.password_hash && !user) user = candidate; } + // Check the password BEFORE the status, so an inactive account can't be discovered without its password. if (!user) { return NextResponse.json({ error: 'Invalid email or password.' }, { status: 401 }); } @@ -39,124 +49,56 @@ export async function POST(req: Request) { return NextResponse.json({ error: 'Your account is not active yet.' }, { status: 403 }); } - const passwordOk = await bcrypt.compare(password, user.password_hash); + if (!user.email) { + return NextResponse.json( + { error: 'This account has no email on file to send a verification code to. Contact your school admin.' }, + { status: 400 }, + ); + } - if (!passwordOk) { - return NextResponse.json({ error: 'Invalid email or password.' }, { status: 401 }); + // Cap how many codes one account can request in a window (login + resends). + const recentSends = await countLoginOtpsSince(user.id, new Date(Date.now() - OTP_TTL_MS)); + if (recentSends >= MAX_OTP_SENDS) { + return NextResponse.json( + { error: 'Too many verification codes requested. Please wait a few minutes and try again.' }, + { status: 429 }, + ); } const otpCode = generateOtp(5); - const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString(); - const ipAddress = getClientIp(req); - const userAgent = getUserAgent(req); - - await createSupabaseClient - .from('user_otps') - .update({ consumed_at: new Date().toISOString() }) - .eq('user_id', user.id) - .eq('school_code', user.school_code) - .is('consumed_at', null); - const { error: otpInsertError } = await createSupabaseClient.from('user_otps').insert({ - user_id: user.id, - school_code: user.school_code, - otp_code: otpCode, - channel: 'email', - expires_at: expiresAt, - ip_address: ipAddress, - user_agent: userAgent, + const otpRow = await issueLoginOtp({ + userId: user.id, + schoolCode: user.school_code, + codeHash: hashCode('login', user.id, otpCode), // keyed hash, never the digits + expiresAt: new Date(Date.now() + OTP_TTL_MS), + ip: getClientIp(req), + userAgent: getUserAgent(req), }); - if (otpInsertError) { - return NextResponse.json({ error: 'Failed to create OTP.' }, { status: 500 }); - } - - await qstash.publishJSON({ - url: `${process.env.APP_BASE_URL}/api/jobs/send-otp-email`, - body: { - email: user.email, - firstName: user.first_name, - otpCode, - }, -}); - -const rawPhone = String(user.phone ?? user.phone_2 ?? "").trim(); - -// Remove everything except digits -let normalizedPhone = rawPhone.replace(/\D/g, ""); - -// Convert local formats to 2547XXXXXXXX / 2541XXXXXXXX -if (normalizedPhone.startsWith("0")) { - normalizedPhone = "254" + normalizedPhone.slice(1); -} - -// Convert 7XXXXXXXX or 1XXXXXXXX to 2547XXXXXXXX / 2541XXXXXXXX -if (/^[71]\d{8}$/.test(normalizedPhone)) { - normalizedPhone = "254" + normalizedPhone; -} - -// Final validation for Kenya mobile numbers -if (!/^254[17]\d{8}$/.test(normalizedPhone)) { - return NextResponse.json( - { error: "Invalid or missing phone number." }, - { status: 400 } - ); -} - -const phoneNumber = `${normalizedPhone}`; - -/*await pingram.send({ - type: 'stackable_security_code', - to: { - id:String(user.school_id ?? user.id), - number:`+254${user.phone ?? user.phone_2}`, // Replace with your phone number, use format [+][country code][area code][local number] - }, - sms: { - message: `Hi ${user.first_name}, Your verification code is: ${otpCode}. Reply STOP to opt-out.` - } -}); - -try { - const response = await infobip.channels.sms.send({ - messages: [ - { - sender: process.env.INFOBIP_SENDER || "ServiceSMS", - destinations: [{ to: phoneNumber}], - content: { - text: `Hi ${user.first_name}, your verification code is ${otpCode}. If you did not request it, please ignore this message.`, - }, - }, - ], - headers: { - Authorization: `App ${process.env.INFOBIP_API_KEY}`, - "Content-Type": "application/json", - Accept: "application/json", - }, - }); + // Email is required, SMS is a bonus channel when the account has a usable phone on file - + // either way, a delivery problem never fails the login (the job retries on its own). + await enqueueJob('send-otp', { + email: user.email, + firstName: user.first_name, + otpCode, + purpose: 'login', + phone: resolveSmsPhone(user), + }); - console.log("Infobip SMS response:", { - status: response?.messages?.[0]?.status, - to: response?.messages?.[0]?.to, - messageId: response?.messages?.[0]?.messageId, -}); -} catch (smsError) { - console.error("Infobip SMS failed:", smsError); - // Do not fail login if email OTP is already queued -}*/ + // Bind the next step (verify-otp / resend-otp) to this exact code and this browser. + (await cookies()).set(OTP_CHALLENGE_COOKIE, signChallenge(otpRow.id), challengeCookieOptions()); return NextResponse.json({ ok: true, - userId: user.id, - schoolId: user.school_id, - schoolCode: user.school_code, email: user.email, maskedEmail: maskEmail(user.email), firstName: user.first_name, - mustChangePassword: !!user.must_change_password, + mustChangePassword: user.must_change_password, message: 'Verification code is being sent.', }); } catch (error) { console.error('login route error', error); - return NextResponse.json({ error: 'Unexpected server error.' }, { status: 500 }); + return toErrorResponse(error); } } diff --git a/stackable-app/app/api/auth/logout/route.ts b/stackable-app/app/api/auth/logout/route.ts index a9985fa..c7ea0aa 100644 --- a/stackable-app/app/api/auth/logout/route.ts +++ b/stackable-app/app/api/auth/logout/route.ts @@ -1,18 +1,19 @@ import { cookies } from 'next/headers'; import { NextResponse } from 'next/server'; -import { createSupabaseClient } from '@/lib/supabase/supabase-admin'; import { hashToken } from '@/lib/auth-utils'; +import { revokeSessionByTokenHash } from '@/lib/repositories/auth.repo'; export async function POST() { const cookieStore = await cookies(); const rawToken = cookieStore.get('stackable_session')?.value; if (rawToken) { - await createSupabaseClient - .from('user_sessions') - .update({ revoked_at: new Date().toISOString() }) - .eq('refresh_token_hash', hashToken(rawToken)) - .is('revoked_at', null); + try { + await revokeSessionByTokenHash(hashToken(rawToken)); + } catch (err) { + // Still clear the cookie below; the session row simply expires on its own. + console.error('logout: could not revoke session', err); + } } cookieStore.delete('stackable_session'); diff --git a/stackable-app/app/api/auth/me/route.ts b/stackable-app/app/api/auth/me/route.ts new file mode 100644 index 0000000..7a2f7eb --- /dev/null +++ b/stackable-app/app/api/auth/me/route.ts @@ -0,0 +1,72 @@ +// ============================================================================= +// GET /api/auth/me — who is signed in right now. +// ----------------------------------------------------------------------------- +// The dashboard shell (navbar name, role chip, school name and the impersonation +// banner) reads this through the useMe() hook. Any signed-in user may call it; it +// only ever returns THEIR OWN details, taken from the validated session (never +// from the request), and never returns password hashes or any other column. +// +// Data comes from PostgreSQL through Prisma (lib/repositories/me.repo.ts). +// ============================================================================= + +import { NextResponse } from "next/server"; +import { requireAuth } from "@/lib/api/guard"; +import { forbidden, toErrorResponse, unauthorized } from "@/lib/api/errors"; +import { findMeProfile } from "@/lib/repositories/me.repo"; +import { ROLE_DASHBOARD, ROLE_HOME } from "@/lib/validation/shared"; + +// Session-dependent and must never be prerendered or served from a cache. +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const NO_STORE = { "Cache-Control": "no-store" } as const; + +export async function GET(request: Request) { + try { + // No `roles`: every signed-in role may read its own identity. + const ctx = await requireAuth(request); + + // A role we do not recognise has no portal to land in. + const home = ROLE_HOME[ctx.role] as string | undefined; + const portal = ROLE_DASHBOARD[ctx.role] as string | undefined; + if (!home || !portal) throw forbidden("Your account has no valid role."); + + const profile = await findMeProfile(ctx.userId, ctx.schoolId); + + // The session was valid a moment ago but the user row is gone: treat as signed out. + if (!profile) throw unauthorized(); + + return NextResponse.json( + { + id: ctx.userId, + firstName: profile.firstName, + lastName: profile.lastName, + email: profile.email, + role: ctx.role, + schoolId: ctx.schoolId, + schoolCode: ctx.schoolCode, + schoolName: profile.schoolName, + portal, + home, + // While a super-admin is viewing as this user, ctx is the TARGET's (so id, role, + // name and school above are the target's) and this says who is really behind it. + // Only display fields: the impersonation row id is never sent to the browser. + impersonatedBy: ctx.impersonatedBy + ? { + id: ctx.impersonatedBy.id, + name: ctx.impersonatedBy.name, + role: ctx.impersonatedBy.role, + reason: ctx.impersonatedBy.reason, + expiresAt: ctx.impersonatedBy.expiresAt, + } + : null, + }, + { headers: NO_STORE }, + ); + } catch (error) { + // A cached 401/403 would keep a signed-in user looking signed out. + const res = toErrorResponse(error); + res.headers.set("Cache-Control", "no-store"); + return res; + } +} diff --git a/stackable-app/app/api/auth/resend-otp/route.ts b/stackable-app/app/api/auth/resend-otp/route.ts index cd98a5d..bafa4e7 100644 --- a/stackable-app/app/api/auth/resend-otp/route.ts +++ b/stackable-app/app/api/auth/resend-otp/route.ts @@ -1,27 +1,59 @@ +import { cookies } from 'next/headers'; import { NextResponse } from 'next/server'; -import { Resend } from 'resend'; -import { createSupabaseClient } from '@/lib/supabase/supabase-admin'; -import { generateOtp, getClientIp, getUserAgent, maskEmail } from '@/lib/auth-utils'; -import OtpEmail from '@/components/email/otp-email'; - -const resend = new Resend(process.env.RESEND_API_KEY); +import { generateOtp, getClientIp, getUserAgent, maskEmail, resolveSmsPhone } from '@/lib/auth-utils'; +import { enqueueJob } from '@/lib/qeue/jobs'; +import { + MAX_OTP_ATTEMPTS, + MAX_OTP_SENDS, + MIN_RESEND_GAP_MS, + OTP_CHALLENGE_COOKIE, + OTP_TTL_MS, + challengeCookieOptions, + hashCode, + readChallenge, + signChallenge, +} from '@/lib/auth/otp'; +import { countLoginOtpsSince, findUserById, getOtp, issueLoginOtp } from '@/lib/repositories/auth.repo'; export async function POST(req: Request) { try { - const body = await req.json(); - const userId = String(body?.userId ?? ''); + // Which code is being resent comes from the signed cookie set by /login, not from the body. + const store = await cookies(); + const otpId = readChallenge(store.get(OTP_CHALLENGE_COOKIE)?.value); + if (!otpId) { + return NextResponse.json({ error: 'Your verification session expired. Please sign in again.' }, { status: 401 }); + } + + const current = await getOtp(otpId); + + if (!current || current.purpose !== 'login' || current.consumed_at || current.expires_at.getTime() <= Date.now()) { + return NextResponse.json({ error: 'Your verification session expired. Please sign in again.' }, { status: 401 }); + } + + if (current.attempts >= MAX_OTP_ATTEMPTS) { + return NextResponse.json({ error: 'Too many wrong codes. Please sign in again.' }, { status: 401 }); + } - if (!userId) { - return NextResponse.json({ error: 'Missing userId.' }, { status: 400 }); + // Server-side throttle (the page's cooldown is only cosmetic). + const sinceLast = Date.now() - current.created_at.getTime(); + if (sinceLast < MIN_RESEND_GAP_MS) { + const retryAfter = Math.ceil((MIN_RESEND_GAP_MS - sinceLast) / 1000); + return NextResponse.json( + { error: `Please wait ${retryAfter}s before requesting another code.`, retryAfter }, + { status: 429 }, + ); } - const { data: user, error } = await createSupabaseClient - .from('users') - .select('id, email, school_code, first_name, status') - .eq('id', userId) - .maybeSingle(); + const recentSends = await countLoginOtpsSince(current.user_id, new Date(Date.now() - OTP_TTL_MS)); + if (recentSends >= MAX_OTP_SENDS) { + return NextResponse.json( + { error: 'Too many verification codes requested. Please sign in again in a few minutes.' }, + { status: 429 }, + ); + } - if (error || !user) { + const user = await findUserById(current.user_id); + if (!user || !user.email) { return NextResponse.json({ error: 'User not found.' }, { status: 404 }); } @@ -29,50 +61,31 @@ export async function POST(req: Request) { return NextResponse.json({ error: 'Your account is not active yet.' }, { status: 403 }); } - await createSupabaseClient - .from('user_otps') - .update({ consumed_at: new Date().toISOString() }) - .eq('user_id', user.id) - .eq('school_code', user.school_code) - .is('consumed_at', null); - const otpCode = generateOtp(5); - const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString(); - - const { error: otpInsertError } = await createSupabaseClient.from('user_otps').insert({ - user_id: user.id, - school_code: user.school_code, - otp_code: otpCode, - channel: 'email', - expires_at: expiresAt, - ip_address: getClientIp(req), - user_agent: getUserAgent(req), + const otpRow = await issueLoginOtp({ + userId: user.id, + schoolCode: user.school_code, + codeHash: hashCode('login', user.id, otpCode), + expiresAt: new Date(Date.now() + OTP_TTL_MS), + ip: getClientIp(req), + userAgent: getUserAgent(req), }); - if (otpInsertError) { - return NextResponse.json({ error: 'Failed to create OTP.' }, { status: 500 }); - } - - const fromEmail = process.env.RESEND_FROM_EMAIL; - if (!fromEmail) { - return NextResponse.json({ error: 'Missing Credentials.' }, { status: 500 }); - } - - const { error: sendError } = await resend.emails.send({ - from: fromEmail, - to: user.email, - subject: 'Your Stackable verification code', - react: OtpEmail({ firstName: user.first_name, otpCode }), + await enqueueJob('send-otp', { + email: user.email, + firstName: user.first_name, + otpCode, + purpose: 'login', + phone: resolveSmsPhone(user), }); - if (sendError) { - return NextResponse.json({ error: 'OTP email failed to send.' }, { status: 500 }); - } + store.set(OTP_CHALLENGE_COOKIE, signChallenge(otpRow.id), challengeCookieOptions()); return NextResponse.json({ ok: true, maskedEmail: maskEmail(user.email), message: 'A new OTP has been sent.', + retryAfter: MIN_RESEND_GAP_MS / 1000, }); } catch (error) { console.error('resend otp route error', error); diff --git a/stackable-app/app/api/auth/reset-password/route.ts b/stackable-app/app/api/auth/reset-password/route.ts new file mode 100644 index 0000000..97cefa2 --- /dev/null +++ b/stackable-app/app/api/auth/reset-password/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from 'next/server'; +import bcrypt from 'bcryptjs'; +import { hashToken } from '@/lib/auth-utils'; +import { toErrorResponse } from '@/lib/api/errors'; +import { claimResetToken, completePasswordReset, findUserById } from '@/lib/repositories/auth.repo'; +import { resetPasswordSchema } from '@/lib/validation/auth'; + +export async function POST(req: Request) { + try { + const parsed = resetPasswordSchema.safeParse(await req.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues[0]?.message ?? 'Check your new password and try again.' }, + { status: 400 }, + ); + } + const { token, newPassword } = parsed.data; + + // Claim the token. Clearing it in the same conditional update makes it single-use: two + // requests with the same token cannot both succeed. + const userId = await claimResetToken(hashToken(token), new Date()); + if (!userId) { + return NextResponse.json( + { error: 'This reset session has expired. Please start again.' }, + { status: 401 }, + ); + } + + const user = await findUserById(userId); + if (!user || user.status !== 'active') { + return NextResponse.json({ error: 'Your account is not active yet.' }, { status: 403 }); + } + + const passwordHash = await bcrypt.hash(newPassword, 12); + + try { + // New password + sign out everywhere + kill leftover codes, all in one transaction. + await completePasswordReset(userId, passwordHash); + } catch (err) { + console.error('reset-password update failed', err); + return NextResponse.json({ error: 'Could not update your password. Please try again.' }, { status: 500 }); + } + + return NextResponse.json({ ok: true, message: 'Password updated. Please sign in.' }); + } catch (error) { + return toErrorResponse(error); + } +} diff --git a/stackable-app/app/api/auth/verify-otp/route.ts b/stackable-app/app/api/auth/verify-otp/route.ts index a7812a1..bbe9da6 100644 --- a/stackable-app/app/api/auth/verify-otp/route.ts +++ b/stackable-app/app/api/auth/verify-otp/route.ts @@ -1,92 +1,112 @@ import { cookies } from 'next/headers'; import { NextResponse } from 'next/server'; -import { createSupabaseClient } from '@/lib/supabase/supabase-admin'; import { generateSessionToken, getClientIp, getUserAgent, hashToken } from '@/lib/auth-utils'; +import { + MAX_OTP_ATTEMPTS, + OTP_CHALLENGE_COOKIE, + hashCode, + readChallenge, + safeEqual, +} from '@/lib/auth/otp'; +import { + consumeOtp, + createSession, + findUserById, + getOtp, + reserveOtpAttempt, +} from '@/lib/repositories/auth.repo'; +import { ROLE_HOME, type Role } from '@/lib/validation/shared'; + +const DAY_MS = 24 * 60 * 60 * 1000; export async function POST(req: Request) { try { const body = await req.json(); - const userId = String(body?.userId ?? ''); const otpCode = String(body?.otp ?? '').trim(); + const remember = body?.remember === true; - if (!userId || !otpCode) { - return NextResponse.json({ error: 'Missing userId or otp.' }, { status: 400 }); + if (!/^\d{5}$/.test(otpCode)) { + return NextResponse.json({ error: 'Enter the 5-digit code.' }, { status: 400 }); } - const { data: user, error: userError } = await createSupabaseClient - .from('users') - .select('id, school_id, school_code, first_name, last_name, email, role, must_change_password, status') - .eq('id', userId) - .maybeSingle(); + // The OTP row comes from the signed cookie set by /login - never from the request body. + const store = await cookies(); + const otpId = readChallenge(store.get(OTP_CHALLENGE_COOKIE)?.value); + if (!otpId) { + return NextResponse.json({ error: 'Your verification session expired. Please sign in again.' }, { status: 401 }); + } - if (userError || !user) { - return NextResponse.json({ error: 'User not found.' }, { status: 404 }); + const row = await getOtp(otpId); + + if (!row || row.purpose !== 'login' || row.consumed_at || row.expires_at.getTime() <= Date.now()) { + return NextResponse.json({ error: 'Invalid or expired OTP. Please sign in again.' }, { status: 401 }); } - if (user.status !== 'active') { - return NextResponse.json({ error: 'Your account is not active yet.' }, { status: 403 }); + if (row.attempts >= MAX_OTP_ATTEMPTS) { + return NextResponse.json({ error: 'Too many wrong codes. Please sign in again.' }, { status: 401 }); } - const nowIso = new Date().toISOString(); - - const { data: otpRow, error: otpError } = await createSupabaseClient - .from('user_otps') - .select('id, otp_code, expires_at, consumed_at') - .eq('user_id', user.id) - .eq('school_code', user.school_code) - .eq('otp_code', otpCode) - .is('consumed_at', null) - .gt('expires_at', nowIso) - .order('created_at', { ascending: false }) - .limit(1) - .maybeSingle(); - - if (otpError) { - return NextResponse.json({ error: 'Failed to verify OTP.' }, { status: 500 }); + // Reserve an attempt BEFORE checking the code. The compare-and-set means parallel guesses + // cannot all pass: only one request wins each slot, so 5 is a hard cap. + if (!(await reserveOtpAttempt(row.id, row.attempts))) { + return NextResponse.json({ error: 'Please wait a moment and try again.' }, { status: 429 }); } - if (!otpRow) { - return NextResponse.json({ error: 'Invalid or expired OTP.' }, { status: 401 }); + if (!safeEqual(hashCode('login', row.user_id, otpCode), row.otp_code)) { + const attemptsLeft = MAX_OTP_ATTEMPTS - (row.attempts + 1); + if (attemptsLeft <= 0) await consumeOtp(row.id); + return NextResponse.json( + { + error: + attemptsLeft > 0 + ? `That code isn't right. ${attemptsLeft} ${attemptsLeft === 1 ? 'attempt' : 'attempts'} left.` + : 'Too many wrong codes. Please sign in again.', + attemptsLeft, + }, + { status: 401 }, + ); } - const consumedAt = new Date().toISOString(); + // Single use: only one request can flip the code from live to consumed. + if (!(await consumeOtp(row.id))) { + return NextResponse.json({ error: 'Invalid or expired OTP. Please sign in again.' }, { status: 401 }); + } - const { error: consumeError } = await createSupabaseClient - .from('user_otps') - .update({ consumed_at: consumedAt }) - .eq('id', otpRow.id) - .is('consumed_at', null); + const user = await findUserById(row.user_id); + if (!user) { + return NextResponse.json({ error: 'User not found.' }, { status: 404 }); + } - if (consumeError) { - return NextResponse.json({ error: 'Failed to consume OTP.' }, { status: 500 }); + if (user.status !== 'active') { + return NextResponse.json({ error: 'Your account is not active yet.' }, { status: 403 }); } const rawSessionToken = generateSessionToken(); - const refreshTokenHash = hashToken(rawSessionToken); - const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); - - const { error: sessionError } = await createSupabaseClient.from('user_sessions').insert({ - user_id: user.id, - school_id: user.school_id, - session_type: 'web', - refresh_token_hash: refreshTokenHash, - ip_address: getClientIp(req), - user_agent: getUserAgent(req), - expires_at: expiresAt, + // Remember me: 30-day persistent cookie. Otherwise a browser-session cookie, capped at 24h server-side. + const expiresAt = new Date(Date.now() + (remember ? 30 * DAY_MS : DAY_MS)); + + await createSession({ + userId: user.id, + schoolId: user.school_id, + tokenHash: hashToken(rawSessionToken), + expiresAt, + ip: getClientIp(req), + userAgent: getUserAgent(req), }); - if (sessionError) { - return NextResponse.json({ error: 'OTP valid, but failed to create session.' }, { status: 500 }); - } - - const cookieStore = await cookies(); - cookieStore.set('stackable_session', rawSessionToken, { + store.set('stackable_session', rawSessionToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', - expires: new Date(expiresAt), + ...(remember ? { expires: expiresAt } : {}), }); + store.delete({ name: OTP_CHALLENGE_COOKIE, path: '/api/auth' }); + + // First-login / admin-forced password change goes through the same reset flow. + const redirectTo = user.must_change_password + ? `/forgot-password?reason=change&email=${encodeURIComponent(user.email ?? '')}` + : ROLE_HOME[user.role as Role] ?? '/login'; return NextResponse.json({ ok: true, @@ -98,9 +118,9 @@ export async function POST(req: Request) { lastName: user.last_name, email: user.email, role: user.role, - mustChangePassword: !!user.must_change_password, + mustChangePassword: user.must_change_password, }, - redirectTo: user.must_change_password ? '/change-password' : '/dashboard', + redirectTo, }); } catch (error) { console.error('verify otp route error', error); diff --git a/stackable-app/app/api/auth/verify-reset-code/route.ts b/stackable-app/app/api/auth/verify-reset-code/route.ts new file mode 100644 index 0000000..6e9d785 --- /dev/null +++ b/stackable-app/app/api/auth/verify-reset-code/route.ts @@ -0,0 +1,58 @@ +import crypto from 'crypto'; +import { NextResponse } from 'next/server'; +import { hashToken } from '@/lib/auth-utils'; +import { toErrorResponse } from '@/lib/api/errors'; +import { MAX_OTP_ATTEMPTS, OTP_TTL_MS, authLimit, hashCode, safeEqual } from '@/lib/auth/otp'; +import { + findActiveUsersByEmail, + findLiveReset, + issueResetToken, + killReset, + reserveResetAttempt, +} from '@/lib/repositories/auth.repo'; +import { verifyResetCodeSchema } from '@/lib/validation/auth'; + +// One message for every failure (unknown email, wrong code, expired, locked out), +// so the response never reveals whether an account exists. +const FAIL = 'That code is not right or has expired. Request a new one if you need to.'; + +export async function POST(req: Request) { + try { + const parsed = verifyResetCodeSchema.safeParse(await req.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json({ error: 'Enter the 6-digit code.' }, { status: 400 }); + } + const email = parsed.data.email.trim().toLowerCase(); + const code = parsed.data.code; + + await authLimit(`resetverify:${email}`); + + const users = await findActiveUsersByEmail(email); + const user = users.length === 1 && users[0].status === 'active' ? users[0] : null; + if (!user) return NextResponse.json({ error: FAIL }, { status: 401 }); + + const row = await findLiveReset(user.id, new Date()); + if (!row || row.attempts >= MAX_OTP_ATTEMPTS) { + return NextResponse.json({ error: FAIL }, { status: 401 }); + } + + // Reserve an attempt first (compare-and-set) so parallel guesses can't exceed the cap. + if (!(await reserveResetAttempt(row.id, row.attempts))) { + return NextResponse.json({ error: 'Please wait a moment and try again.' }, { status: 429 }); + } + + if (!safeEqual(hashCode('reset', user.id, code), row.code_hash)) { + if (row.attempts + 1 >= MAX_OTP_ATTEMPTS) await killReset(row.id); + return NextResponse.json({ error: FAIL }, { status: 401 }); + } + + // Correct: burn the code and hand back a short-lived, single-use reset token. + const resetToken = crypto.randomBytes(32).toString('hex'); + const issued = await issueResetToken(row.id, hashToken(resetToken), new Date(Date.now() + OTP_TTL_MS)); + if (!issued) return NextResponse.json({ error: FAIL }, { status: 401 }); + + return NextResponse.json({ ok: true, resetToken }); + } catch (error) { + return toErrorResponse(error); + } +} diff --git a/stackable-app/app/api/dev/audit/route.ts b/stackable-app/app/api/dev/audit/route.ts new file mode 100644 index 0000000..84a28f3 --- /dev/null +++ b/stackable-app/app/api/dev/audit/route.ts @@ -0,0 +1,25 @@ +// GET /api/dev/audit?actor=&target=&action=&from=&to=&page=&pageSize= -> { ok: true, data: DevPage } +// Real super-admin only. `action` is a prefix; from/to are inclusive ISO dates. Answers 503 +// with a clear message when the database migrations have not been run (audit table missing). + +import { + auditQuerySchema, + devError, + devJson, + listDevAudit, + parseDevQuery, + requireDevAuth, +} from "@/lib/dev-server"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function GET(request: Request) { + try { + await requireDevAuth(request); + const query = parseDevQuery(auditQuerySchema, request.url); + return devJson(await listDevAudit(query)); + } catch (err) { + return devError(err); + } +} diff --git a/stackable-app/app/api/dev/impersonate/start/route.ts b/stackable-app/app/api/dev/impersonate/start/route.ts new file mode 100644 index 0000000..9a3caef --- /dev/null +++ b/stackable-app/app/api/dev/impersonate/start/route.ts @@ -0,0 +1,46 @@ +// POST /api/dev/impersonate/start { targetUserId, reason } -> { ok: true, redirectTo } +// Begin viewing as another user. SECURITY-CRITICAL, in this order: +// same-origin -> REAL session (never the impersonated one) -> real super-admin +// -> rate limit -> body -> flow (lib/dev-server.ts startImpersonation) -> cookie. +// The cookie is set ONLY after the audit row is written (fail closed). + +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; +import { parseJson } from "@/lib/api/validate"; +import { + IMPERSONATION_COOKIE, + impersonationCookieOptions, +} from "@/lib/api/impersonation"; +import { + assertSameOrigin, + impersonationError, + requireRealSuperAdmin, + startBodySchema, + startImpersonation, +} from "@/lib/dev-server"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function POST(request: Request) { + try { + assertSameOrigin(request); + const actor = await requireRealSuperAdmin(); + const body = await parseJson(request, startBodySchema); + + const store = await cookies(); + const { token, redirectTo } = await startImpersonation({ + actor, + targetUserId: body.targetUserId, + reason: body.reason, + existingCookie: store.get(IMPERSONATION_COOKIE)?.value, + request, + }); + + // Reached only when the row exists AND the audit record was written. + store.set(IMPERSONATION_COOKIE, token, impersonationCookieOptions()); + return NextResponse.json({ ok: true, redirectTo }, { headers: { "Cache-Control": "no-store" } }); + } catch (err) { + return impersonationError(err); + } +} diff --git a/stackable-app/app/api/dev/impersonate/stop/route.ts b/stackable-app/app/api/dev/impersonate/stop/route.ts new file mode 100644 index 0000000..7e468e1 --- /dev/null +++ b/stackable-app/app/api/dev/impersonate/stop/route.ts @@ -0,0 +1,36 @@ +// POST /api/dev/impersonate/stop -> { ok: true, redirectTo: "/dev" } +// Stop viewing as another user. Works WHILE impersonating, so it uses the REAL session. +// The cookie is ALWAYS cleared (even on 401 or a database outage): a developer must never be +// trapped in someone else's account. Idempotent: nothing to stop is still a success. + +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; +import { unauthorized } from "@/lib/api/errors"; +import { resolveRealSession } from "@/lib/api/guard"; +import { + IMPERSONATION_COOKIE, + clearedImpersonationCookieOptions, +} from "@/lib/api/impersonation"; +import { assertSameOrigin, impersonationError, stopImpersonation } from "@/lib/dev-server"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function POST(request: Request) { + try { + assertSameOrigin(request); + + // Clear first: whatever happens next, the browser drops the impersonation cookie. + const store = await cookies(); + const cookieToken = store.get(IMPERSONATION_COOKIE)?.value; + store.set(IMPERSONATION_COOKIE, "", clearedImpersonationCookieOptions()); + + const actor = await resolveRealSession(); + if (!actor) throw unauthorized(); + + await stopImpersonation({ actor, cookieToken, request }); + return NextResponse.json({ ok: true, redirectTo: "/dev" }, { headers: { "Cache-Control": "no-store" } }); + } catch (err) { + return impersonationError(err); + } +} diff --git a/stackable-app/app/api/dev/overview/route.ts b/stackable-app/app/api/dev/overview/route.ts new file mode 100644 index 0000000..cc93a8d --- /dev/null +++ b/stackable-app/app/api/dev/overview/route.ts @@ -0,0 +1,17 @@ +// GET /api/dev/overview -> { ok: true, data: DevOverview } +// Real super-admin only (never while viewing as another user). Counts + service health, +// cached 30 s. Logic lives in lib/dev-server.ts; a route file may only export HTTP methods. + +import { devError, devJson, getDevOverview, requireDevAuth } from "@/lib/dev-server"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function GET(request: Request) { + try { + await requireDevAuth(request); + return devJson(await getDevOverview()); + } catch (err) { + return devError(err); + } +} diff --git a/stackable-app/app/api/dev/schools/route.ts b/stackable-app/app/api/dev/schools/route.ts new file mode 100644 index 0000000..72c4473 --- /dev/null +++ b/stackable-app/app/api/dev/schools/route.ts @@ -0,0 +1,24 @@ +// GET /api/dev/schools?q=&page=&pageSize= -> { ok: true, data: DevPage } +// Real super-admin only. Search matches name, code and e-mail; pageSize is clamped to 1..100. + +import { + devError, + devJson, + listDevSchools, + parseDevQuery, + requireDevAuth, + schoolsQuerySchema, +} from "@/lib/dev-server"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function GET(request: Request) { + try { + await requireDevAuth(request); + const query = parseDevQuery(schoolsQuerySchema, request.url); + return devJson(await listDevSchools(query)); + } catch (err) { + return devError(err); + } +} diff --git a/stackable-app/app/api/dev/users/route.ts b/stackable-app/app/api/dev/users/route.ts new file mode 100644 index 0000000..d739d1c --- /dev/null +++ b/stackable-app/app/api/dev/users/route.ts @@ -0,0 +1,25 @@ +// GET /api/dev/users?q=&role=&schoolId=&status=&page=&pageSize= -> { ok: true, data: DevPage } +// Real super-admin only. role/status are checked against the known lists, schoolId must be an +// id, and the search text is sanitised before it reaches the database filter. + +import { + devError, + devJson, + listDevUsers, + parseDevQuery, + requireDevAuth, + usersQuerySchema, +} from "@/lib/dev-server"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function GET(request: Request) { + try { + await requireDevAuth(request); + const query = parseDevQuery(usersQuerySchema, request.url); + return devJson(await listDevUsers(query)); + } catch (err) { + return devError(err); + } +} diff --git a/stackable-app/app/api/jobs/[job]/route.ts b/stackable-app/app/api/jobs/[job]/route.ts new file mode 100644 index 0000000..21bc8f3 --- /dev/null +++ b/stackable-app/app/api/jobs/[job]/route.ts @@ -0,0 +1,36 @@ +// ============================================================================= +// POST /api/jobs/[job] — QStash delivers background jobs here. +// ----------------------------------------------------------------------------- +// Order matters: +// 1. Verify the QStash signature FIRST. A caller without a valid signature gets +// 401 and learns nothing, not even which job names exist. +// 2. Unknown job name -> 404. +// 3. Run the handler. If it throws we answer 500 so QStash retries later. +// +// The handlers live in lib/qeue/registry.ts. Do NOT export them from this file: +// Next.js rejects any export here that is not an HTTP method or route config. +// ============================================================================= + +import { NextResponse } from "next/server"; +import { notFound, toErrorResponse } from "@/lib/api/errors"; +import { verifyQStashRequest } from "@/lib/qeue/jobs"; +import { findJobHandler } from "@/lib/qeue/registry"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function POST(req: Request, ctx: { params: Promise<{ job: string }> }) { + try { + const { body } = await verifyQStashRequest(req); + + const { job } = await ctx.params; + const handler = findJobHandler(job); + if (!handler) throw notFound("Unknown job."); + + await handler(body); + return NextResponse.json({ ok: true }); + } catch (error) { + // ApiError keeps its status (401/404/...); anything else is logged and becomes 500. + return toErrorResponse(error); + } +} diff --git a/stackable-app/app/api/jobs/send-otp-email/route.ts b/stackable-app/app/api/jobs/send-otp-email/route.ts deleted file mode 100644 index 7e64b1a..0000000 --- a/stackable-app/app/api/jobs/send-otp-email/route.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { NextResponse } from "next/server"; -import { Resend } from "resend"; -import { Receiver } from "@upstash/qstash"; -import OtpEmail from "@/components/email/otp-email"; - -const resend = new Resend(process.env.RESEND_API_KEY); - -const receiver = new Receiver({ - currentSigningKey: process.env.QSTASH_CURRENT_SIGNING_KEY!, - nextSigningKey: process.env.QSTASH_NEXT_SIGNING_KEY!, -}); - -export async function POST(req: Request) { - try { - const bodyText = await req.text(); - - const signature = req.headers.get("upstash-signature"); - if (!signature) { - return NextResponse.json({ error: "Missing signature." }, { status: 401 }); - } - - const isValid = await receiver.verify({ - signature, - body: bodyText, - }); - - if (!isValid) { - return NextResponse.json({ error: "Invalid signature." }, { status: 401 }); - } - - const body = JSON.parse(bodyText) as { - email: string; - firstName: string; - otpCode: string; - }; - - const fromEmail = process.env.RESEND_FROM_EMAIL; - if (!fromEmail) { - return NextResponse.json({ error: "Missing RESEND_FROM_EMAIL." }, { status: 500 }); - } - - const { error } = await resend.emails.send({ - from: fromEmail, - to: body.email, - subject: "Your Stackable verification code", - react: OtpEmail({ - firstName: body.firstName, - otpCode: body.otpCode, - }), - }); - - if (error) { - return NextResponse.json({ error: "Failed to send OTP email." }, { status: 500 }); - } - - return NextResponse.json({ ok: true }); - } catch (error) { - console.error("send-otp-email job error", error); - return NextResponse.json({ error: "Unexpected server error." }, { status: 500 }); - } -} \ No newline at end of file diff --git a/stackable-app/app/api/parent/children/[studentId]/route.ts b/stackable-app/app/api/parent/children/[studentId]/route.ts index d568087..24e108f 100644 --- a/stackable-app/app/api/parent/children/[studentId]/route.ts +++ b/stackable-app/app/api/parent/children/[studentId]/route.ts @@ -1,10 +1,14 @@ import { NextRequest, NextResponse } from "next/server"; import { requireAuth } from "@/lib/api/guard"; -import { toErrorResponse } from "@/lib/api/errors"; +import { badRequest, toErrorResponse } from "@/lib/api/errors"; import { getChildOverview } from "@/lib/repositories/parent.repo"; +import { isUuid } from "@/lib/repositories/portal-shared"; -// GET /api/parent/children/[studentId] — grades + attendance for ONE child. -// The repository refuses if the child isn't linked to this parent (403). +export const dynamic = "force-dynamic"; + +// GET /api/parent/children/[studentId] — ChildOverview (grades, attendance, subjects) for ONE child. +// The repository refuses if the child isn't linked to this parent (403). Not cached: the +// link check must run on every request. export async function GET( request: NextRequest, { params }: { params: Promise<{ studentId: string }> }, @@ -12,6 +16,9 @@ export async function GET( try { const auth = await requireAuth(request, { roles: ["parent"], rateLimit: "read" }); const { studentId } = await params; + // Validate at the gate: a malformed id must not reach a uuid column (that would be a 500). + if (!isUuid(studentId)) throw badRequest("Invalid student id."); + const overview = await getChildOverview(auth.userId, studentId); return NextResponse.json({ ok: true, data: overview }); } catch (err) { diff --git a/stackable-app/app/api/parent/children/route.ts b/stackable-app/app/api/parent/children/route.ts index d135ad3..7c0d426 100644 --- a/stackable-app/app/api/parent/children/route.ts +++ b/stackable-app/app/api/parent/children/route.ts @@ -1,13 +1,24 @@ import { NextRequest, NextResponse } from "next/server"; import { requireAuth } from "@/lib/api/guard"; import { toErrorResponse } from "@/lib/api/errors"; +import { cached } from "@/lib/cache"; import { getParentChildren } from "@/lib/repositories/parent.repo"; -// GET /api/parent/children — the signed-in parent's children. +export const dynamic = "force-dynamic"; + +const CHILDREN_CACHE_SECONDS = 30; // short: a parent should see new grades within seconds + +// GET /api/parent/children — the signed-in parent's children (ChildCard[]). export async function GET(request: NextRequest) { try { const auth = await requireAuth(request, { roles: ["parent"], rateLimit: "read" }); - const children = await getParentChildren(auth.userId); + const children = await cached( + auth.schoolId, + "parent-children", + CHILDREN_CACHE_SECONDS, + () => getParentChildren(auth.userId), + auth.userId, // per-user data: the user id MUST be in the key + ); return NextResponse.json({ ok: true, data: children }); } catch (err) { return toErrorResponse(err); diff --git a/stackable-app/app/api/school/[id]/actions/route.ts b/stackable-app/app/api/school/[id]/actions/route.ts index 85aacec..b3bc841 100644 --- a/stackable-app/app/api/school/[id]/actions/route.ts +++ b/stackable-app/app/api/school/[id]/actions/route.ts @@ -1,176 +1,52 @@ import { NextRequest, NextResponse } from "next/server"; -import { supabaseAdmin } from "@/lib/supabase/admin"; -import { generateSchoolCode } from "@/lib/school-security"; import { requireAuth } from "@/lib/api/guard"; -import { toErrorResponse } from "@/lib/api/errors"; - -type Context = { - params: Promise<{ id: string }>; -}; - -async function generateUniqueSchoolCode(name: string) { - for (let attempt = 0; attempt < 10; attempt += 1) { - const code = generateSchoolCode(name); - - const { data, error } = await supabaseAdmin - .from("schools") - .select("id") - .eq("code", code) - .maybeSingle(); - - if (error) { - throw new Error(error.message); - } - - if (!data) { - return code; - } - } - - throw new Error("Failed to generate a unique school code."); -} +import { buildAuditContext } from "@/lib/api/audit-context"; +import { forbidden, notFound, toErrorResponse } from "@/lib/api/errors"; +import { parseJson } from "@/lib/api/validate"; +import { + increaseSchoolCapacity, + regenerateSchoolCode, + setSchoolStatus, +} from "@/lib/repositories/school-admin.repo"; +import { canAccessSchool, canRunSchoolAction, schoolActionSchema } from "@/lib/validation/school-admin"; + +type Context = { params: Promise<{ id: string }> }; export async function POST(req: NextRequest, context: Context) { + let auth; try { - await requireAuth(req, { roles: ["admin", "super-admin"], rateLimit: "mutation" }); - } catch (err) { return toErrorResponse(err); } + auth = await requireAuth(req, { roles: ["admin", "super-admin"], rateLimit: "mutation", denyImpersonation: true }); + } catch (err) { + return toErrorResponse(err); + } try { const { id } = await context.params; - const body = (await req.json()) as { action?: string }; - const action = String(body.action ?? "").trim(); + if (!canAccessSchool(auth, id)) throw notFound("School not found."); - if (!action) { - return NextResponse.json({ error: "Missing action." }, { status: 400 }); + const { action } = await parseJson(req, schoolActionSchema); + if (!canRunSchoolAction(auth.role, action)) { + throw forbidden("Only a super-admin can perform this action."); } - const { data: school, error } = await supabaseAdmin - .from("school_usage_overview") - .select(` - id, - name, - status, - code_change_count, - expected_users, - expected_students, - expected_parents, - expected_teachers, - expected_admins, - expected_staff - `) - .eq("id", id) - .single(); - - if (error || !school) { - return NextResponse.json({ error: "School not found." }, { status: 404 }); - } - - const now = new Date().toISOString(); + const audit = buildAuditContext(req, auth); if (action === "activate") { - const { error: updateError } = await supabaseAdmin - .from("schools") - .update({ - status: "active", - pending_status_change_at: null, - updated_at: now, - }) - .eq("id", id); - - if (updateError) { - return NextResponse.json({ error: updateError.message }, { status: 500 }); - } - - return NextResponse.json({ - ok: true, - message: "School activated successfully.", - }); + await setSchoolStatus(id, "active", audit); + return NextResponse.json({ ok: true, message: "School activated successfully." }); } - if (action === "suspend") { - const { error: updateError } = await supabaseAdmin - .from("schools") - .update({ - status: "suspended", - pending_status_change_at: new Date( - Date.now() + 30 * 60 * 1000, - ).toISOString(), - updated_at: now, - }) - .eq("id", id); - - if (updateError) { - return NextResponse.json({ error: updateError.message }, { status: 500 }); - } - - return NextResponse.json({ - ok: true, - message: "School suspension has been queued.", - }); + await setSchoolStatus(id, "suspended", audit); + return NextResponse.json({ ok: true, message: "School suspended successfully." }); } - if (action === "increase_capacity_50") { - const { error: updateError } = await supabaseAdmin - .from("schools") - .update({ - expected_users: Number(school.expected_users ?? 0) + 50, - expected_students: Number(school.expected_students ?? 0) + 10, - expected_parents: Number(school.expected_parents ?? 0) + 10, - expected_teachers: Number(school.expected_teachers ?? 0) + 10, - expected_admins: Number(school.expected_admins ?? 0) + 10, - expected_staff: Number(school.expected_staff ?? 0) + 10, - updated_at: now, - }) - .eq("id", id); - - if (updateError) { - return NextResponse.json({ error: updateError.message }, { status: 500 }); - } - - return NextResponse.json({ - ok: true, - message: "School capacity increased by 50 users.", - }); + await increaseSchoolCapacity(id, audit); + return NextResponse.json({ ok: true, message: "School capacity increased by 50 users." }); } - - if (action === "regenerate_code") { - if (Number(school.code_change_count ?? 0) >= 3) { - return NextResponse.json( - { error: "This school has already used all 3 code regeneration attempts." }, - { status: 400 }, - ); - } - - const nextCode = await generateUniqueSchoolCode(String(school.name ?? "school")); - - const { error: updateError } = await supabaseAdmin - .from("schools") - .update({ - code: nextCode, - code_change_count: Number(school.code_change_count ?? 0) + 1, - pending_code_change_at: new Date( - Date.now() + 14 * 24 * 60 * 60 * 1000, - ).toISOString(), - updated_at: now, - }) - .eq("id", id); - - if (updateError) { - return NextResponse.json({ error: updateError.message }, { status: 500 }); - } - - return NextResponse.json({ - ok: true, - message: "School code regeneration queued successfully.", - }); - } - - return NextResponse.json({ error: "Unsupported action." }, { status: 400 }); + // action === "regenerate_code" (schoolActionSchema only allows these four values) + await regenerateSchoolCode(id, audit); + return NextResponse.json({ ok: true, message: "School code regenerated successfully." }); } catch (error) { - console.error("school action route error", error); - return NextResponse.json( - { error: "Unexpected server error." }, - { status: 500 }, - ); + return toErrorResponse(error); } } diff --git a/stackable-app/app/api/school/[id]/confirm-email/route.ts b/stackable-app/app/api/school/[id]/confirm-email/route.ts index 6f01685..3fe0eae 100644 --- a/stackable-app/app/api/school/[id]/confirm-email/route.ts +++ b/stackable-app/app/api/school/[id]/confirm-email/route.ts @@ -1,10 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; -import { supabaseAdmin } from "@/lib/supabase/admin"; +import { confirmPendingSchool, findSchoolForConfirmation } from "@/lib/repositories/school-admin.repo"; import { verifySchoolEmailConfirmationToken } from "@/lib/school-security"; -type Context = { - params: Promise<{ id: string }>; -}; +type Context = { params: Promise<{ id: string }> }; function renderHtml(title: string, message: string, success: boolean) { const accent = success ? "#047857" : "#dc2626"; @@ -34,93 +32,60 @@ function renderHtml(title: string, message: string, success: boolean) { `; } +function page(title: string, message: string, success: boolean, status: number) { + return new NextResponse(renderHtml(title, message, success), { + status, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); +} + export async function GET(req: NextRequest, context: Context) { const { id } = await context.params; const token = req.nextUrl.searchParams.get("token") ?? ""; const payload = verifySchoolEmailConfirmationToken(token); if (!payload || payload.schoolId !== id) { - return new NextResponse( - renderHtml( - "Invalid confirmation link", - "This school confirmation link is invalid or expired. Ask an administrator to create the school again or resend confirmation.", - false, - ), - { - status: 400, - headers: { "Content-Type": "text/html; charset=utf-8" }, - }, + return page( + "Invalid confirmation link", + "This school confirmation link is invalid or expired. Ask an administrator to create the school again or resend confirmation.", + false, + 400, ); } - const { data: school, error } = await supabaseAdmin - .from("schools") - .select("id, name, email, status") - .eq("id", id) - .maybeSingle(); - - if (error || !school) { - return new NextResponse( - renderHtml( - "School not found", - "We could not find this school record anymore. Please contact your Stackable administrator.", - false, - ), - { - status: 404, - headers: { "Content-Type": "text/html; charset=utf-8" }, - }, + const school = await findSchoolForConfirmation(id); + if (!school) { + return page( + "School not found", + "We could not find this school record anymore. Please contact your Stackable administrator.", + false, + 404, ); } if ((school.email ?? "").toLowerCase() !== payload.email.toLowerCase()) { - return new NextResponse( - renderHtml( - "Email mismatch", - "This confirmation link no longer matches the current school email address.", - false, - ), - { - status: 400, - headers: { "Content-Type": "text/html; charset=utf-8" }, - }, + return page( + "Email mismatch", + "This confirmation link no longer matches the current school email address.", + false, + 400, ); } - if (school.status !== "active") { - const { error: updateError } = await supabaseAdmin - .from("schools") - .update({ - status: "active", - pending_status_change_at: null, - updated_at: new Date().toISOString(), - }) - .eq("id", id); - - if (updateError) { - return new NextResponse( - renderHtml( - "Could not confirm email", - "The email was valid but Stackable could not update the school status right now. Please try again later.", - false, - ), - { - status: 500, - headers: { "Content-Type": "text/html; charset=utf-8" }, - }, - ); - } + const outcome = await confirmPendingSchool(id); + if (outcome === "blocked") { + return page( + "Could not confirm email", + "This school is suspended, so an old confirmation link can no longer activate it. Please contact your Stackable administrator.", + false, + 409, + ); } - return new NextResponse( - renderHtml( - "School email confirmed", - `${school.name} is now confirmed and can move forward as an active school in Stackable.`, - true, - ), - { - status: 200, - headers: { "Content-Type": "text/html; charset=utf-8" }, - }, + return page( + "School email confirmed", + `${school.name} is now confirmed and can move forward as an active school in Stackable.`, + true, + 200, ); } diff --git a/stackable-app/app/api/school/[id]/route.ts b/stackable-app/app/api/school/[id]/route.ts index 3c551e7..943c9a6 100644 --- a/stackable-app/app/api/school/[id]/route.ts +++ b/stackable-app/app/api/school/[id]/route.ts @@ -1,102 +1,92 @@ import { NextRequest, NextResponse } from "next/server"; -import { supabaseAdmin } from "@/lib/supabase/admin"; import { requireAuth } from "@/lib/api/guard"; -import { toErrorResponse } from "@/lib/api/errors"; - -type Context = { - params: Promise<{ id: string }>; -}; +import { buildAuditContext } from "@/lib/api/audit-context"; +import { forbidden, notFound, toErrorResponse } from "@/lib/api/errors"; +import { parseJson } from "@/lib/api/validate"; +import { + deleteSchoolRecord, + findSchoolDetail, + findSchoolSensitiveFields, + updateSchoolRecord, +} from "@/lib/repositories/school-admin.repo"; +import { + canAccessSchool, + findSensitiveSchoolChanges, + isPackageAllowed, + SENSITIVE_DENIED_MESSAGE, + updateSchoolSchema, +} from "@/lib/validation/school-admin"; + +type Context = { params: Promise<{ id: string }> }; export async function GET(request: NextRequest, context: Context) { try { - await requireAuth(request, { roles: ["admin", "super-admin"], rateLimit: "read" }); - } catch (err) { return toErrorResponse(err); } - - const { id } = await context.params; - - const { data, error } = await supabaseAdmin - .from("school_usage_overview") - .select("*") - .eq("id", id) - .single(); - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }); + const auth = await requireAuth(request, { roles: ["admin", "super-admin"], rateLimit: "read" }); + const { id } = await context.params; + if (!canAccessSchool(auth, id)) throw notFound("School not found."); + + const data = await findSchoolDetail(id); + if (!data) throw notFound("School not found."); + return NextResponse.json({ data }); + } catch (err) { + return toErrorResponse(err); } - - return NextResponse.json({ data }); } export async function PATCH(req: NextRequest, context: Context) { + let auth; try { - await requireAuth(req, { roles: ["admin", "super-admin"], rateLimit: "mutation" }); - } catch (err) { return toErrorResponse(err); } - - const { id } = await context.params; - const body = await req.json(); - - const payload = { - name: body.name, - email: body.email, - phone_1: body.phone_1, - phone_2: body.phone_2 ?? null, - phone_3: body.phone_3 ?? null, - logo: body.logo, - status: body.status, - subscription_package: body.subscription_package, - subscription_status: body.subscription_status, - subscription_started_at: body.subscription_started_at, - subscription_expires_at: body.subscription_expires_at, - updated_at: new Date().toISOString(), - }; - - const { error } = await supabaseAdmin - .from("schools") - .update(payload) - .eq("id", id); - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }); + auth = await requireAuth(req, { roles: ["admin", "super-admin"], rateLimit: "mutation" }); + } catch (err) { + return toErrorResponse(err); } - if (body.head_name || body.owner_name) { - await supabaseAdmin - .from("school_profiles") - .upsert({ - school_id: id, - head_name: body.head_name ?? null, - owner_name: body.owner_name ?? null, - location: body.location ?? null, - updated_at: new Date().toISOString(), - }); - } else if (body.phone_2 || body.phone_3 || body.location) { - await supabaseAdmin - .from("school_profiles") - .upsert({ - school_id: id, - location: body.location ?? null, - updated_at: new Date().toISOString(), - }); + try { + const { id } = await context.params; + if (!canAccessSchool(auth, id)) throw notFound("School not found."); + + const body = await parseJson(req, updateSchoolSchema); + + if (auth.role !== "super-admin") { + const current = await findSchoolSensitiveFields(id); + if (!current) throw notFound("School not found."); + if (body.subscription_package !== undefined && !isPackageAllowed(body.subscription_package, current.subscription_package)) { + throw forbidden(SENSITIVE_DENIED_MESSAGE); + } + const changed = findSensitiveSchoolChanges(current, body); + if (changed.length > 0) throw forbidden(SENSITIVE_DENIED_MESSAGE); + } + + const { head_name, owner_name, location, ...schoolFields } = body; + const audit = buildAuditContext(req, auth); + await updateSchoolRecord( + id, + schoolFields, + { head_name, owner_name, location }, + audit, + { fields: Object.keys(body) }, + ); + + return NextResponse.json({ ok: true }); + } catch (error) { + return toErrorResponse(error); } - - return NextResponse.json({ ok: true }); } export async function DELETE(request: NextRequest, context: Context) { + let auth; try { - await requireAuth(request, { roles: ["super-admin"], rateLimit: "mutation" }); - } catch (err) { return toErrorResponse(err); } - - const { id } = await context.params; - - const { error } = await supabaseAdmin - .from("schools") - .delete() - .eq("id", id); - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }); + auth = await requireAuth(request, { roles: ["super-admin"], rateLimit: "mutation", denyImpersonation: true }); + } catch (err) { + return toErrorResponse(err); } - return NextResponse.json({ ok: true }); + try { + const { id } = await context.params; + const audit = buildAuditContext(request, auth); + await deleteSchoolRecord(id, audit); + return NextResponse.json({ ok: true }); + } catch (error) { + return toErrorResponse(error); + } } diff --git a/stackable-app/app/api/school/[id]/security-codes/route.ts b/stackable-app/app/api/school/[id]/security-codes/route.ts index 9d1ad8a..ee65d72 100644 --- a/stackable-app/app/api/school/[id]/security-codes/route.ts +++ b/stackable-app/app/api/school/[id]/security-codes/route.ts @@ -1,7 +1,11 @@ import { NextRequest, NextResponse } from "next/server"; -import { supabaseAdmin } from "@/lib/supabase/admin"; import { requireAuth } from "@/lib/api/guard"; -import { toErrorResponse, notFound } from "@/lib/api/errors"; +import { notFound, toErrorResponse } from "@/lib/api/errors"; +import { + findSchoolForCodes, + insertMissingSecurityCodes, + listSecurityCodeRows, +} from "@/lib/repositories/school-admin.repo"; import { buildSchoolSecurityCodeEntries, buildSchoolSecurityCodeRows, @@ -11,16 +15,7 @@ import { type SchoolSecurityCodeLabel, } from "@/lib/school-security"; -type Context = { - params: Promise<{ id: string }>; -}; - -type SchoolSecurityCodeRow = { - code_hash: string; - code_label: SchoolSecurityCodeLabel; - is_active: boolean; - used_count: number; -}; +type Context = { params: Promise<{ id: string }> }; function safeFileName(value: string) { return value @@ -31,72 +26,39 @@ function safeFileName(value: string) { } async function ensureSchoolSecurityRows(schoolId: string) { - const { data: existingRows, error } = await supabaseAdmin - .from("school_security_codes") - .select("code_hash, code_label, is_active, used_count") - .eq("school_id", schoolId); + const existingRows = await listSecurityCodeRows(schoolId); + const existingLabels = new Set(existingRows.map((row) => row.code_label)); + const missingLabels = SCHOOL_SECURITY_CODE_LABELS.filter((label) => !existingLabels.has(label)); - if (error) { - throw new Error(error.message); - } + if (missingLabels.length === 0) return existingRows; - const typedRows = (existingRows ?? []) as SchoolSecurityCodeRow[]; - const existingLabels = new Set(typedRows.map((row) => row.code_label)); - const missingLabels = SCHOOL_SECURITY_CODE_LABELS.filter( - (label) => !existingLabels.has(label), + const rows = buildSchoolSecurityCodeRows(schoolId).filter((row) => + missingLabels.includes(row.code_label as SchoolSecurityCodeLabel), ); - - if (missingLabels.length > 0) { - const rows = buildSchoolSecurityCodeRows(schoolId).filter((row) => - missingLabels.includes(row.code_label), - ); - - const { error: insertError } = await supabaseAdmin - .from("school_security_codes") - .insert(rows); - - if (insertError) { - throw new Error(insertError.message); - } - - return [ - ...typedRows, - ...rows.map((row) => ({ - code_hash: row.code_hash, - code_label: row.code_label, - is_active: row.is_active, - used_count: row.used_count, - })), - ]; - } - - return typedRows; + await insertMissingSecurityCodes(rows); + return [...existingRows, ...rows]; } export async function GET(request: NextRequest, context: Context) { let ctx; try { - ctx = await requireAuth(request, { roles: ["super-admin"], rateLimit: "read" }); - } catch (err) { return toErrorResponse(err); } + ctx = await requireAuth(request, { roles: ["super-admin"], rateLimit: "read", denyImpersonation: true }); + } catch (err) { + return toErrorResponse(err); + } try { const { id } = await context.params; - // Cross-tenant guard: super-admins can access any school's codes. - // Non-super-admins are already blocked by the role check above. - // Extra defence: even super-admins must request a real, existing school (handled below). - // Return 404 (not 403) if a non-owner somehow reaches this point to avoid leaking existence. + // Cross-tenant guard: super-admins can access any school's codes. Non-super-admins are + // already blocked by the role check above. Return 404 (not 403) so a stray id never + // confirms a school's existence to someone who should not be here. if (ctx.role !== "super-admin" && id !== ctx.schoolId) { return toErrorResponse(notFound()); } - const { data: school, error } = await supabaseAdmin - .from("schools") - .select("id, name, code, email") - .eq("id", id) - .maybeSingle(); - - if (error || !school) { + const school = await findSchoolForCodes(id); + if (!school) { return NextResponse.json({ error: "School not found." }, { status: 404 }); } @@ -104,34 +66,20 @@ export async function GET(request: NextRequest, context: Context) { const activeRows = rows.filter((row) => row.is_active); if (activeRows.length === 0) { - return NextResponse.json( - { error: "No active security codes found for this school." }, - { status: 404 }, - ); + return NextResponse.json({ error: "No active security codes found for this school." }, { status: 404 }); } const generatedEntries = buildSchoolSecurityCodeEntries(school.id); - const generatedByLabel = new Map( - generatedEntries.map((entry) => [entry.label, entry.code]), - ); + const generatedByLabel = new Map(generatedEntries.map((entry) => [entry.label, entry.code])); const codes = activeRows.map((row) => { - const code = generatedByLabel.get(row.code_label); - if (!code) { - throw new Error(`Missing generator for label ${row.code_label}.`); + const label = row.code_label as SchoolSecurityCodeLabel; + const code = generatedByLabel.get(label); + if (!code) throw new Error(`Missing generator for label ${label}.`); + if (hashSchoolSecurityCode(code) !== row.code_hash) { + throw new Error(`Stored security code hash did not match the generated code for ${label}.`); } - - const matchesHash = hashSchoolSecurityCode(code) === row.code_hash; - if (!matchesHash) { - throw new Error( - `Stored security code hash did not match the generated code for ${row.code_label}.`, - ); - } - - return { - label: row.code_label, - code, - }; + return { label, code }; }); const pdf = buildSchoolSecurityCodesPdf({ @@ -158,12 +106,7 @@ export async function GET(request: NextRequest, context: Context) { } catch (error) { console.error("school security codes route error", error); return NextResponse.json( - { - error: - error instanceof Error - ? error.message - : "Unexpected server error.", - }, + { error: error instanceof Error ? error.message : "Unexpected server error." }, { status: 500 }, ); } diff --git a/stackable-app/app/api/school/logo/route.ts b/stackable-app/app/api/school/logo/route.ts index e7c0ebf..e3a173a 100644 --- a/stackable-app/app/api/school/logo/route.ts +++ b/stackable-app/app/api/school/logo/route.ts @@ -1,13 +1,19 @@ import crypto from "crypto"; import { NextRequest, NextResponse } from "next/server"; -import { supabaseAdmin } from "@/lib/supabase/admin"; import { requireAuth } from "@/lib/api/guard"; import { toErrorResponse } from "@/lib/api/errors"; +import { getPublicUrl, uploadFile } from "@/lib/storage"; +import { sniffImageType } from "@/lib/teacher-photo"; +import { MAX_LOGO_BYTES } from "@/lib/validation/school-admin"; + +const SCHOOL_LOGOS_BUCKET = "school_logos"; export async function POST(req: NextRequest) { try { await requireAuth(req, { roles: ["admin", "super-admin"], rateLimit: "mutation" }); - } catch (err) { return toErrorResponse(err); } + } catch (err) { + return toErrorResponse(err); + } try { const formData = await req.formData(); @@ -16,38 +22,36 @@ export async function POST(req: NextRequest) { if (!(file instanceof File)) { return NextResponse.json({ error: "Missing logo file." }, { status: 400 }); } + if (file.size === 0) { + return NextResponse.json({ error: "Logo file is empty." }, { status: 400 }); + } + if (file.size > MAX_LOGO_BYTES) { + return NextResponse.json({ error: "Logo must be 4MB or smaller." }, { status: 400 }); + } - const extension = file.name.split(".").pop()?.toLowerCase() || "png"; - const filePath = `schools/${Date.now()}-${crypto.randomUUID()}.${extension}`; - const arrayBuffer = await file.arrayBuffer(); + const bytes = await file.arrayBuffer(); + // Trust the bytes, not the browser's claimed content type. + const kind = sniffImageType(new Uint8Array(bytes)); + if (!kind) { + return NextResponse.json({ error: "Logo must be a JPEG, PNG, WebP or GIF image." }, { status: 400 }); + } - const { error: uploadError } = await supabaseAdmin.storage - .from("school_logos") - .upload(filePath, arrayBuffer, { - contentType: file.type || "application/octet-stream", - upsert: false, - }); + const filePath = `schools/${Date.now()}-${crypto.randomUUID()}.${kind.ext}`; + const { error } = await uploadFile(SCHOOL_LOGOS_BUCKET, filePath, bytes, { + contentType: kind.mime, + ensureBucket: { public: true, fileSizeLimit: MAX_LOGO_BYTES }, + }); - if (uploadError) { - return NextResponse.json({ error: uploadError.message }, { status: 500 }); + if (error) { + return NextResponse.json({ error }, { status: 500 }); } - const { - data: { publicUrl }, - } = supabaseAdmin.storage.from("school_logos").getPublicUrl(filePath); - return NextResponse.json({ ok: true, - data: { - path: filePath, - url: publicUrl, - }, + data: { path: filePath, url: getPublicUrl(SCHOOL_LOGOS_BUCKET, filePath) }, }); } catch (error) { console.error("school logo upload route error", error); - return NextResponse.json( - { error: "Unexpected server error." }, - { status: 500 }, - ); + return NextResponse.json({ error: "Unexpected server error." }, { status: 500 }); } } diff --git a/stackable-app/app/api/school/route.ts b/stackable-app/app/api/school/route.ts index 2fdbf47..c9c8171 100644 --- a/stackable-app/app/api/school/route.ts +++ b/stackable-app/app/api/school/route.ts @@ -1,264 +1,114 @@ import crypto from "crypto"; import { NextRequest, NextResponse } from "next/server"; import { Resend } from "resend"; -import { supabaseAdmin } from "@/lib/supabase/admin"; import { requireAuth } from "@/lib/api/guard"; +import { buildAuditContext } from "@/lib/api/audit-context"; import { toErrorResponse } from "@/lib/api/errors"; +import { parseJson } from "@/lib/api/validate"; +import { + createSchoolRecord, + discardNewSchool, + listSchoolOverviews, +} from "@/lib/repositories/school-admin.repo"; import { buildSchoolSecurityCodeRows, createSchoolEmailConfirmationToken, - generateSchoolCode, } from "@/lib/school-security"; +import { createSchoolSchema } from "@/lib/validation/school-admin"; import SchoolConfirmationEmail from "@/components/email/school-confirmation-email"; const resend = new Resend(process.env.RESEND_API_KEY); -type CreateSchoolBody = { - name?: string; - email?: string; - phone_1?: string; - phone_2?: string; - phone_3?: string; - head_name?: string; - owner_name?: string; - location?: string; - logo?: string; - subscription_package?: string; - subscription_status?: string; - subscription_started_at?: string | null; - subscription_expires_at?: string | null; - expected_users?: number; - expected_students?: number; - expected_parents?: number; - expected_teachers?: number; - expected_admins?: number; - expected_staff?: number; -}; - -function asText(value: unknown) { - const text = String(value ?? "").trim(); - return text || null; -} - -function asNumber(value: unknown, fallback = 0) { - const parsed = Number(value); - return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed) : fallback; -} - -async function getNextSchoolNumber() { - const { data, error } = await supabaseAdmin - .from("schools") - .select("school_id") - .order("school_id", { ascending: false }) - .limit(1) - .maybeSingle(); - - if (error) { - throw new Error(error.message); - } - - return (data?.school_id ?? 0) + 1; -} - -async function generateUniqueSchoolCode(name: string) { - for (let attempt = 0; attempt < 10; attempt += 1) { - const code = generateSchoolCode(name); - - const { data, error } = await supabaseAdmin - .from("schools") - .select("id") - .eq("code", code) - .maybeSingle(); - - if (error) { - throw new Error(error.message); - } - - if (!data) { - return code; - } - } - - throw new Error("Failed to generate a unique school code."); -} - export async function GET(request: NextRequest) { try { - await requireAuth(request, { roles: ["admin", "super-admin"], rateLimit: "read" }); - } catch (err) { return toErrorResponse(err); } - - const { data, error } = await supabaseAdmin - .from("school_usage_overview") - .select("*") - .order("name", { ascending: true }); - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }); + const auth = await requireAuth(request, { roles: ["admin", "super-admin"], rateLimit: "read" }); + // A school admin only ever sees their own school; a super-admin sees every school. + const onlyId = auth.role === "super-admin" ? undefined : auth.schoolId; + const data = await listSchoolOverviews({ onlyId }); + return NextResponse.json({ data }); + } catch (err) { + return toErrorResponse(err); } - - return NextResponse.json({ data }); } export async function POST(req: NextRequest) { + let auth; try { - await requireAuth(req, { roles: ["admin", "super-admin"], rateLimit: "mutation" }); - } catch (err) { return toErrorResponse(err); } + auth = await requireAuth(req, { roles: ["admin", "super-admin"], rateLimit: "mutation" }); + } catch (err) { + return toErrorResponse(err); + } try { - const body = (await req.json()) as CreateSchoolBody; - - const name = asText(body.name); - const email = asText(body.email); - const phone1 = asText(body.phone_1); - - if (!name || !email || !phone1) { - return NextResponse.json( - { error: "School name, school email, and primary phone are required." }, - { status: 400 }, - ); - } - - const subscriptionPackage = asText(body.subscription_package) ?? "Seedling"; - const subscriptionStatus = asText(body.subscription_status) ?? "trial"; - const expectedStudents = asNumber(body.expected_students, 0); - const expectedParents = asNumber(body.expected_parents, 0); - const expectedTeachers = asNumber(body.expected_teachers, 0); - const expectedAdmins = asNumber(body.expected_admins, 0); - const expectedStaff = asNumber(body.expected_staff, 0); - const expectedUsers = - asNumber(body.expected_users, 0) || - expectedStudents + - expectedParents + - expectedTeachers + - expectedAdmins + - expectedStaff; + const body = await parseJson(req, createSchoolSchema); + const audit = buildAuditContext(req, auth); const schoolId = crypto.randomUUID(); - const schoolNumber = await getNextSchoolNumber(); - const schoolCode = await generateUniqueSchoolCode(name); - const confirmationExpiresAt = new Date( - Date.now() + 1000 * 60 * 60 * 24 * 3, - ).toISOString(); + const now = new Date(); + const confirmationExpiresAt = new Date(now.getTime() + 1000 * 60 * 60 * 24 * 3); const token = createSchoolEmailConfirmationToken({ schoolId, - email, - expiresAt: confirmationExpiresAt, + email: body.email, + expiresAt: confirmationExpiresAt.toISOString(), }); - const appBaseUrl = - process.env.APP_BASE_URL || - process.env.NEXT_PUBLIC_APP_URL || - req.nextUrl.origin; - + const appBaseUrl = process.env.APP_BASE_URL || process.env.NEXT_PUBLIC_APP_URL || req.nextUrl.origin; const confirmationUrl = `${appBaseUrl}/api/school/${schoolId}/confirm-email?token=${encodeURIComponent(token)}`; - const now = new Date().toISOString(); - - const { error: schoolError } = await supabaseAdmin.from("schools").insert({ - id: schoolId, - school_id: schoolNumber, - name, - code: schoolCode, - logo: asText(body.logo), - email, - phone_1: phone1, - phone_2: asText(body.phone_2), - phone_3: asText(body.phone_3), - status: "pending", - subscription_package: subscriptionPackage, - subscription_status: subscriptionStatus, - subscription_started_at: asText(body.subscription_started_at) ?? now, - subscription_expires_at: asText(body.subscription_expires_at), - expected_users: expectedUsers, - expected_students: expectedStudents, - expected_parents: expectedParents, - expected_teachers: expectedTeachers, - expected_admins: expectedAdmins, - expected_staff: expectedStaff, - updated_at: now, - }); - - if (schoolError) { - return NextResponse.json({ error: schoolError.message }, { status: 500 }); - } - - const { error: profileError } = await supabaseAdmin - .from("school_profiles") - .upsert({ - school_id: schoolId, - head_name: asText(body.head_name), - owner_name: asText(body.owner_name), - location: asText(body.location), - updated_at: now, - }); - if (profileError) { - await supabaseAdmin.from("schools").delete().eq("id", schoolId); - return NextResponse.json({ error: profileError.message }, { status: 500 }); - } - - const { error: securityCodeError } = await supabaseAdmin - .from("school_security_codes") - .insert(buildSchoolSecurityCodeRows(schoolId)); - - if (securityCodeError) { - await supabaseAdmin.from("school_profiles").delete().eq("school_id", schoolId); - await supabaseAdmin.from("schools").delete().eq("id", schoolId); - return NextResponse.json( - { error: securityCodeError.message }, - { status: 500 }, - ); - } + const created = await createSchoolRecord( + { + id: schoolId, + name: body.name, + email: body.email, + phone_1: body.phone_1, + phone_2: body.phone_2 ?? null, + phone_3: body.phone_3 ?? null, + head_name: body.head_name ?? null, + owner_name: body.owner_name ?? null, + location: body.location ?? null, + logo: body.logo ?? null, + subscription_package: body.subscription_package ?? "Seedling", + subscription_status: body.subscription_status ?? "trial", + subscription_started_at: body.subscription_started_at ?? now, + subscription_expires_at: body.subscription_expires_at ?? null, + expected_users: body.expected_users ?? 0, + expected_students: body.expected_students ?? 0, + expected_parents: body.expected_parents ?? 0, + expected_teachers: body.expected_teachers ?? 0, + expected_admins: body.expected_admins ?? 0, + expected_staff: body.expected_staff ?? 0, + }, + buildSchoolSecurityCodeRows(schoolId), + audit, + ); const fromEmail = process.env.RESEND_FROM_EMAIL; if (!fromEmail) { - await supabaseAdmin.from("school_profiles").delete().eq("school_id", schoolId); - await supabaseAdmin.from("schools").delete().eq("id", schoolId); - return NextResponse.json( - { error: "Missing RESEND_FROM_EMAIL." }, - { status: 500 }, - ); + await discardNewSchool(schoolId, audit); + return NextResponse.json({ error: "Missing RESEND_FROM_EMAIL." }, { status: 500 }); } const { error: sendError } = await resend.emails.send({ from: fromEmail, - to: email, - subject: `Confirm ${name} on Stackable`, + to: body.email, + subject: `Confirm ${body.name} on Stackable`, react: SchoolConfirmationEmail({ - schoolName: name, - schoolCode: schoolCode, + schoolName: body.name, + schoolCode: created.code, confirmationUrl, }), }); if (sendError) { - await supabaseAdmin.from("school_profiles").delete().eq("school_id", schoolId); - await supabaseAdmin.from("schools").delete().eq("id", schoolId); - return NextResponse.json( - { error: "School created could not send confirmation email." }, - { status: 500 }, - ); + await discardNewSchool(schoolId, audit); + return NextResponse.json({ error: "School created but could not send confirmation email." }, { status: 500 }); } return NextResponse.json( - { - ok: true, - data: { - id: schoolId, - school_id: schoolNumber, - name, - code: schoolCode, - email, - status: "pending", - }, - message: "School created and confirmation email sent.", - }, + { ok: true, data: created, message: "School created and confirmation email sent." }, { status: 201 }, ); } catch (error) { - console.error("create school route error", error); - return NextResponse.json( - { error: "Unexpected server error." }, - { status: 500 }, - ); + return toErrorResponse(error); } } diff --git a/stackable-app/app/api/sentry-example-api/route.ts b/stackable-app/app/api/sentry-example-api/route.ts new file mode 100644 index 0000000..af56395 --- /dev/null +++ b/stackable-app/app/api/sentry-example-api/route.ts @@ -0,0 +1,18 @@ +import * as Sentry from "@sentry/nextjs"; +import { connection } from "next/server"; +class SentryExampleAPIError extends Error { + constructor(message: string | undefined) { + super(message); + this.name = "SentryExampleAPIError"; + } +} + +// A faulty API route to test Sentry's error monitoring +export async function GET() { + // Opt out of prerendering so the error is thrown at request time + await connection(); + Sentry.logger.info("Sentry example API called"); + throw new SentryExampleAPIError( + "This error is raised on the backend called by the example page.", + ); +} diff --git a/stackable-app/app/api/student/dashboard/route.ts b/stackable-app/app/api/student/dashboard/route.ts index ef9f9b6..9875864 100644 --- a/stackable-app/app/api/student/dashboard/route.ts +++ b/stackable-app/app/api/student/dashboard/route.ts @@ -1,12 +1,24 @@ import { NextRequest, NextResponse } from "next/server"; import { requireAuth } from "@/lib/api/guard"; import { toErrorResponse } from "@/lib/api/errors"; +import { cached } from "@/lib/cache"; import { getStudentDashboard } from "@/lib/repositories/student.repo"; +export const dynamic = "force-dynamic"; + +const DASHBOARD_CACHE_SECONDS = 30; // short: attendance and new grades should show up quickly + +// GET /api/student/dashboard — StudentDashboardData for the signed-in student. export async function GET(request: NextRequest) { try { const auth = await requireAuth(request, { roles: ["student", "pupil"], rateLimit: "read" }); - const data = await getStudentDashboard(auth.userId); + const data = await cached( + auth.schoolId, + "student-dashboard", + DASHBOARD_CACHE_SECONDS, + () => getStudentDashboard(auth.userId), + auth.userId, // per-user data: the user id MUST be in the key + ); return NextResponse.json({ ok: true, data }); } catch (err) { return toErrorResponse(err); diff --git a/stackable-app/app/api/student/grades/route.ts b/stackable-app/app/api/student/grades/route.ts index e097fa9..d5febcc 100644 --- a/stackable-app/app/api/student/grades/route.ts +++ b/stackable-app/app/api/student/grades/route.ts @@ -3,6 +3,9 @@ import { requireAuth } from "@/lib/api/guard"; import { toErrorResponse } from "@/lib/api/errors"; import { getStudentGrades } from "@/lib/repositories/student.repo"; +export const dynamic = "force-dynamic"; + +// GET /api/student/grades — GradeRow[] for the signed-in student. export async function GET(request: NextRequest) { try { const auth = await requireAuth(request, { roles: ["student", "pupil"], rateLimit: "read" }); diff --git a/stackable-app/app/api/students/[id]/route.ts b/stackable-app/app/api/students/[id]/route.ts new file mode 100644 index 0000000..855629a --- /dev/null +++ b/stackable-app/app/api/students/[id]/route.ts @@ -0,0 +1,55 @@ +// ============================================================================= +// /api/students/[id] — one student of the caller's school. +// GET -> { ok, data: StudentProfile } +// PATCH -> { ok, data: { student: StudentListItem } } (body: StudentUpdateInput) +// DELETE -> { ok, data: { id } } +// ----------------------------------------------------------------------------- +// Every operation is scoped by the session's school: an id from another school is +// a plain 404. Writes need role admin, manager or super-admin. +// ============================================================================= + +import { NextRequest, NextResponse } from "next/server"; +import { requireAuth } from "@/lib/api/guard"; +import { toErrorResponse } from "@/lib/api/errors"; +import { parseJson } from "@/lib/api/validate"; +import { studentAdmin } from "@/lib/services/student-admin.api"; +import { ADMIN_ROLES, assertUuid } from "@/lib/validation/admin-common"; +import { studentUpdateSchema } from "@/lib/validation/students"; + +export const dynamic = "force-dynamic"; + +type Context = { params: Promise<{ id: string }> }; + +export async function GET(request: NextRequest, context: Context) { + try { + const auth = await requireAuth(request, { roles: ADMIN_ROLES, pageKey: "students", rateLimit: "read" }); + const id = assertUuid((await context.params).id); + const data = await studentAdmin.profile(auth.schoolId, id); + return NextResponse.json({ ok: true, data }); + } catch (error) { + return toErrorResponse(error); + } +} + +export async function PATCH(request: NextRequest, context: Context) { + try { + const auth = await requireAuth(request, { roles: ADMIN_ROLES, pageKey: "students", rateLimit: "mutation" }); + const id = assertUuid((await context.params).id); + const input = await parseJson(request, studentUpdateSchema); + const data = await studentAdmin.update(auth.schoolId, id, input); + return NextResponse.json({ ok: true, data }); + } catch (error) { + return toErrorResponse(error); + } +} + +export async function DELETE(request: NextRequest, context: Context) { + try { + const auth = await requireAuth(request, { roles: ADMIN_ROLES, pageKey: "students", rateLimit: "mutation" }); + const id = assertUuid((await context.params).id); + const data = await studentAdmin.remove(auth.schoolId, id); + return NextResponse.json({ ok: true, data }); + } catch (error) { + return toErrorResponse(error); + } +} diff --git a/stackable-app/app/api/students/form-options/route.ts b/stackable-app/app/api/students/form-options/route.ts new file mode 100644 index 0000000..c8e9cc1 --- /dev/null +++ b/stackable-app/app/api/students/form-options/route.ts @@ -0,0 +1,22 @@ +// ============================================================================= +// GET /api/students/form-options — classes, school and statuses for filters and +// the student edit form. Response: { ok: true, data: StudentFormOptions }. +// ============================================================================= + +import { NextRequest, NextResponse } from "next/server"; +import { requireAuth } from "@/lib/api/guard"; +import { toErrorResponse } from "@/lib/api/errors"; +import { studentAdmin } from "@/lib/services/student-admin.api"; +import { ADMIN_ROLES } from "@/lib/validation/admin-common"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + try { + const auth = await requireAuth(request, { roles: ADMIN_ROLES, pageKey: "students", rateLimit: "read" }); + const data = await studentAdmin.formOptions(auth.schoolId); + return NextResponse.json({ ok: true, data }); + } catch (error) { + return toErrorResponse(error); + } +} diff --git a/stackable-app/app/api/students/route.ts b/stackable-app/app/api/students/route.ts new file mode 100644 index 0000000..3c57ac3 --- /dev/null +++ b/stackable-app/app/api/students/route.ts @@ -0,0 +1,25 @@ +// ============================================================================= +// GET /api/students — the school's student register for the admin workspace. +// ----------------------------------------------------------------------------- +// Replaces the old page's direct browser database read. Scoped to the caller's +// school (from the session), max 5000 rows, with status counts and the class +// filter list. Response: { ok: true, data: StudentListData }. +// ============================================================================= + +import { NextRequest, NextResponse } from "next/server"; +import { requireAuth } from "@/lib/api/guard"; +import { toErrorResponse } from "@/lib/api/errors"; +import { studentAdmin } from "@/lib/services/student-admin.api"; +import { ADMIN_ROLES } from "@/lib/validation/admin-common"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + try { + const auth = await requireAuth(request, { roles: ADMIN_ROLES, pageKey: "students", rateLimit: "read" }); + const data = await studentAdmin.list(auth.schoolId); + return NextResponse.json({ ok: true, data }); + } catch (error) { + return toErrorResponse(error); + } +} diff --git a/stackable-app/app/api/subjects/[id]/assessments/route.ts b/stackable-app/app/api/subjects/[id]/assessments/route.ts index ed7d76c..2aac6ed 100644 --- a/stackable-app/app/api/subjects/[id]/assessments/route.ts +++ b/stackable-app/app/api/subjects/[id]/assessments/route.ts @@ -1,140 +1,91 @@ +// ============================================================================= +// /api/subjects/[id]/assessments +// GET -> upcoming + past assessments for the subject offering +// POST -> schedule an assessment and target it at some of the offering's classes +// The subject offering must belong to the caller's school (else 404). +// ============================================================================= + import { NextRequest, NextResponse } from "next/server"; -import { getSubjectAssessmentsData, getSchoolSubjectOrThrow } from "@/lib/subjects-server"; -import { supabaseAdmin } from "@/lib/supabase/admin"; import { requireAuth } from "@/lib/api/guard"; -import { toErrorResponse } from "@/lib/api/errors"; +import { badRequest, toErrorResponse } from "@/lib/api/errors"; +import { parseJson } from "@/lib/api/validate"; +import { bumpCache, cached } from "@/lib/cache"; +import { createAssessment } from "@/lib/subjects-mutations"; +import { + SUBJECT_ASSESSMENTS_TTL_SECONDS, + SUBJECT_CACHE_ENTITY, + SUBJECT_READ_ROLES, + SUBJECT_WRITE_ROLES, + getSchoolSubjectOrThrow, + getSubjectAssessmentsData, + requireSubjectId, +} from "@/lib/subjects-server"; +import { createAssessmentSchema } from "@/lib/validation/subjects"; + +export const dynamic = "force-dynamic"; type Context = { params: Promise<{ id: string }>; }; -type AssessmentCreatePayload = { - type?: string | null; - title?: string | null; - description?: string | null; - term?: string | null; - total_marks_raw?: number | null; - duration_minutes?: number | null; - scheduled_start_at?: string | null; - scheduled_end_at?: string | null; - target_class_ids?: string[]; - teacher_id?: string | null; - created_by?: string | null; -}; - -function asText(value: unknown) { - const text = String(value ?? "").trim(); - return text || null; -} - -function asNumber(value: unknown) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : null; -} - +/** Response: { ok: true, data: SubjectAssessmentsPayload }. */ export async function GET(request: NextRequest, context: Context) { + let auth; try { - await requireAuth(request, { pageKey: "subjects", rateLimit: "read" }); - } catch (err) { return toErrorResponse(err); } + auth = await requireAuth(request, { roles: SUBJECT_READ_ROLES, pageKey: "subjects", rateLimit: "read" }); + } catch (err) { + return toErrorResponse(err); + } try { - const { id } = await context.params; - const data = await getSubjectAssessmentsData(id); + const id = requireSubjectId((await context.params).id); + const { schoolId } = auth; + const data = await cached( + schoolId, + SUBJECT_CACHE_ENTITY, + SUBJECT_ASSESSMENTS_TTL_SECONDS, + () => getSubjectAssessmentsData(id, schoolId), + `assessments:${id}`, + ); return NextResponse.json({ ok: true, data }); } catch (error) { - console.error("subject assessments route error", error); - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unexpected server error." }, - { status: 500 }, - ); + return toErrorResponse(error); } } +/** + * Body (JSON): { type, title, target_class_ids, description?, term?, total_marks_raw?, + * duration_minutes?, scheduled_start_at?, scheduled_end_at?, teacher_id? }. + * Response 201: { ok: true, data: SubjectAssessmentsPayload } (the refreshed list). + */ export async function POST(request: NextRequest, context: Context) { + let auth; try { - await requireAuth(request, { roles: ["admin", "super-admin", "manager", "teacher"], rateLimit: "mutation" }); - } catch (err) { return toErrorResponse(err); } + auth = await requireAuth(request, { + roles: SUBJECT_WRITE_ROLES, + pageKey: "subjects", + rateLimit: "mutation", + }); + } catch (err) { + return toErrorResponse(err); + } try { - const { id } = await context.params; - const offering = await getSchoolSubjectOrThrow(id); - const body = (await request.json()) as AssessmentCreatePayload; - const title = asText(body.title); - const type = asText(body.type); - const targetClassIds = Array.isArray(body.target_class_ids) - ? body.target_class_ids.filter(Boolean) - : []; + const id = requireSubjectId((await context.params).id); + const { schoolId, userId } = auth; + const input = await parseJson(request, createAssessmentSchema); + const offering = await getSchoolSubjectOrThrow(id, schoolId); - if (!title || !type || targetClassIds.length === 0) { - return NextResponse.json( - { error: "Assessment title, type, and target classes are required." }, - { status: 400 }, - ); + if (!input.title || !input.type || input.target_class_ids.length === 0) { + throw badRequest("Assessment title, type, and target classes are required."); } - const assessmentInsert = await supabaseAdmin - .from("assessments") - .insert({ - school_id: offering.school_id, - school_subject_id: id, - type, - title, - description: asText(body.description), - term: asText(body.term), - total_marks_raw: asNumber(body.total_marks_raw), - duration_minutes: asNumber(body.duration_minutes), - scheduled_start_at: asText(body.scheduled_start_at), - scheduled_end_at: asText(body.scheduled_end_at), - status: "scheduled", - created_by: asText(body.created_by), - }) - .select("id") - .single(); + await createAssessment(offering, { ...input, title: input.title, type: input.type }, { schoolId, userId }); + await bumpCache(schoolId, SUBJECT_CACHE_ENTITY); - if (assessmentInsert.error || !assessmentInsert.data) { - return NextResponse.json( - { - error: - assessmentInsert.error?.message ?? "Failed to create subject assessment.", - }, - { status: 500 }, - ); - } - - const schoolSubjectClasses = await supabaseAdmin - .from("school_subject_classes") - .select("id, class_id") - .eq("school_subject_id", id) - .in("class_id", targetClassIds); - - if (schoolSubjectClasses.error) { - return NextResponse.json( - { error: schoolSubjectClasses.error.message }, - { status: 500 }, - ); - } - - const targetsInsert = await supabaseAdmin.from("assessment_targets").insert( - (schoolSubjectClasses.data ?? []).map((item) => ({ - assessment_id: assessmentInsert.data.id, - school_subject_class_id: item.id, - class_id: item.class_id, - teacher_id: asText(body.teacher_id), - status: "scheduled", - })), - ); - - if (targetsInsert.error) { - return NextResponse.json({ error: targetsInsert.error.message }, { status: 500 }); - } - - const data = await getSubjectAssessmentsData(id); + const data = await getSubjectAssessmentsData(id, schoolId); return NextResponse.json({ ok: true, data }, { status: 201 }); } catch (error) { - console.error("create subject assessment route error", error); - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unexpected server error." }, - { status: 500 }, - ); + return toErrorResponse(error); } } diff --git a/stackable-app/app/api/subjects/[id]/coursework/route.ts b/stackable-app/app/api/subjects/[id]/coursework/route.ts index 37d8cfa..f2c12ea 100644 --- a/stackable-app/app/api/subjects/[id]/coursework/route.ts +++ b/stackable-app/app/api/subjects/[id]/coursework/route.ts @@ -1,256 +1,190 @@ +// ============================================================================= +// /api/subjects/[id]/coursework +// GET -> class offerings, syllabus progress, curriculum tree and resources +// POST -> multipart, action = create_topic | create_resource (file upload) +// PATCH -> JSON, action = update_progress | toggle_visibility +// The subject offering, and every class offering / topic / resource id sent in a +// request, must belong to the caller's school (else 404 / 400). +// ============================================================================= + import { NextRequest, NextResponse } from "next/server"; +import { requireAuth } from "@/lib/api/guard"; +import { badRequest, toErrorResponse } from "@/lib/api/errors"; +import { parse, parseForm } from "@/lib/api/validate"; +import { bumpCache, cached } from "@/lib/cache"; import { - buildSubjectResourcePath, - ensureSubjectResourcesBucket, + createCurriculumNode, + createResource, + saveClassProgress, + setResourceVisibility, +} from "@/lib/subjects-mutations"; +import { + SUBJECT_CACHE_ENTITY, + SUBJECT_COURSEWORK_TTL_SECONDS, + SUBJECT_READ_ROLES, + SUBJECT_WRITE_ROLES, getSchoolSubjectOrThrow, getSubjectCourseworkData, + requireSubjectId, } from "@/lib/subjects-server"; -import { supabaseAdmin } from "@/lib/supabase/admin"; -import { requireAuth } from "@/lib/api/guard"; -import { toErrorResponse } from "@/lib/api/errors"; +import { + courseworkQuerySchema, + createResourceSchema, + createTopicSchema, + toggleVisibilitySchema, + updateProgressSchema, +} from "@/lib/validation/subjects"; + +export const dynamic = "force-dynamic"; type Context = { params: Promise<{ id: string }>; }; -type CourseworkPatchBody = - | { - action: "toggle_visibility"; - resource_id: string; - next_visibility: string; - changed_by?: string | null; - } - | { - action: "update_progress"; - school_subject_class_id: string; - current_node_id?: string | null; - syllabus_progress_pct?: number | null; - }; - -function asText(value: FormDataEntryValue | null | undefined) { - const text = String(value ?? "").trim(); - return text || null; -} - -function asNumber(value: FormDataEntryValue | null | undefined) { - const parsed = Number(asText(value)); - return Number.isFinite(parsed) ? parsed : null; -} +const UNSUPPORTED_ACTION = "Unsupported coursework action."; +/** + * Optional query: ?class_offering_id= returns only that class's + * curriculum tree and resources (classOfferings still lists every class). + * Response: { ok: true, data: SubjectCourseworkPayload }. + */ export async function GET(request: NextRequest, context: Context) { + let auth; try { - await requireAuth(request, { pageKey: "subjects", rateLimit: "read" }); - } catch (err) { return toErrorResponse(err); } + auth = await requireAuth(request, { roles: SUBJECT_READ_ROLES, pageKey: "subjects", rateLimit: "read" }); + } catch (err) { + return toErrorResponse(err); + } try { - const { id } = await context.params; - const data = await getSubjectCourseworkData(id); + const id = requireSubjectId((await context.params).id); + const { schoolId } = auth; + const query = parse(courseworkQuerySchema, { + class_offering_id: request.nextUrl.searchParams.get("class_offering_id"), + }); + const classOfferingId = query.class_offering_id; + + const data = await cached( + schoolId, + SUBJECT_CACHE_ENTITY, + SUBJECT_COURSEWORK_TTL_SECONDS, + () => getSubjectCourseworkData(id, schoolId, classOfferingId), + `coursework:${id}:${classOfferingId ?? "all"}`, + ); return NextResponse.json({ ok: true, data }); } catch (error) { - console.error("subject coursework route error", error); - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unexpected server error." }, - { status: 500 }, - ); + return toErrorResponse(error); } } +/** + * Multipart form. + * action=create_topic: school_subject_class_id, title, node_type?, sort_order?, parent_id? + * action=create_resource: school_subject_class_id, title, resource_type?, visibility?, + * curriculum_node_id?, short_description?, author_name?, + * cover_image_url?, source_url?, file? (a file or a source_url is required) + * Response 201: { ok: true, data: SubjectCourseworkPayload }. + */ export async function POST(request: NextRequest, context: Context) { + let auth; try { - await requireAuth(request, { roles: ["admin", "super-admin", "manager", "teacher"], rateLimit: "mutation" }); - } catch (err) { return toErrorResponse(err); } + auth = await requireAuth(request, { + roles: SUBJECT_WRITE_ROLES, + pageKey: "subjects", + rateLimit: "mutation", + }); + } catch (err) { + return toErrorResponse(err); + } try { - const { id } = await context.params; - const offering = await getSchoolSubjectOrThrow(id); - const formData = await request.formData(); - const action = asText(formData.get("action")); - - if (action === "create_topic") { - const schoolSubjectClassId = asText(formData.get("school_subject_class_id")); - const title = asText(formData.get("title")); - const parentId = asText(formData.get("parent_id")); - const nodeType = asText(formData.get("node_type")) ?? "topic"; - const sortOrder = asNumber(formData.get("sort_order")) ?? 0; + const id = requireSubjectId((await context.params).id); + const { schoolId, userId } = auth; + const offering = await getSchoolSubjectOrThrow(id, schoolId); - if (!schoolSubjectClassId || !title) { - return NextResponse.json( - { error: "Class offering and topic title are required." }, - { status: 400 }, - ); - } + const form = await request.formData(); + const action = String(form.get("action") ?? "").trim(); - let depth = 0; - if (parentId) { - const parentRes = await supabaseAdmin - .from("subject_curriculum_nodes") - .select("depth") - .eq("id", parentId) - .maybeSingle(); - if (parentRes.error) { - return NextResponse.json({ error: parentRes.error.message }, { status: 500 }); - } - depth = Number(parentRes.data?.depth ?? 0) + 1; + if (action === "create_topic") { + const input = parseForm(form, createTopicSchema); + if (!input.school_subject_class_id || !input.title) { + throw badRequest("Class offering and topic title are required."); } - - const insertRes = await supabaseAdmin.from("subject_curriculum_nodes").insert({ - school_subject_class_id: schoolSubjectClassId, - parent_id: parentId, - title, - node_type: nodeType, - sort_order: sortOrder, - depth, + await createCurriculumNode(offering, { + ...input, + school_subject_class_id: input.school_subject_class_id, + title: input.title, }); - - if (insertRes.error) { - return NextResponse.json({ error: insertRes.error.message }, { status: 500 }); - } } else if (action === "create_resource") { - const schoolSubjectClassId = asText(formData.get("school_subject_class_id")); - const title = asText(formData.get("title")); - const resourceType = asText(formData.get("resource_type")) ?? "document"; - const visibility = asText(formData.get("visibility")) ?? "private"; - const file = formData.get("file"); - const sourceUrl = asText(formData.get("source_url")); - - if (!schoolSubjectClassId || !title) { - return NextResponse.json( - { error: "Class offering and resource title are required." }, - { status: 400 }, - ); - } - - const classLinkRes = await supabaseAdmin - .from("school_subject_classes") - .select("id, class_id") - .eq("id", schoolSubjectClassId) - .maybeSingle(); - - if (classLinkRes.error || !classLinkRes.data) { - return NextResponse.json( - { error: classLinkRes.error?.message ?? "Class offering not found." }, - { status: 404 }, - ); - } - - let storagePath: string | null = null; - - if (file instanceof File && file.size > 0) { - await ensureSubjectResourcesBucket(); - storagePath = buildSubjectResourcePath({ - schoolId: offering.school_id, - subjectId: offering.subject_id, - classId: classLinkRes.data.class_id, - filename: file.name, - }); - const uploadRes = await supabaseAdmin.storage - .from("subject_resources") - .upload(storagePath, await file.arrayBuffer(), { - contentType: file.type || "application/octet-stream", - upsert: false, - }); - - if (uploadRes.error) { - return NextResponse.json({ error: uploadRes.error.message }, { status: 500 }); - } - } - - const insertRes = await supabaseAdmin.from("subject_resources").insert({ - school_subject_class_id: schoolSubjectClassId, - curriculum_node_id: asText(formData.get("curriculum_node_id")), - resource_type: resourceType, - title, - short_description: asText(formData.get("short_description")), - author_name: asText(formData.get("author_name")), - cover_image_url: asText(formData.get("cover_image_url")), - storage_path: storagePath, - source_url: sourceUrl, - visibility, - uploaded_by: asText(formData.get("uploaded_by")), - uploaded_at: new Date().toISOString(), - }); - - if (insertRes.error) { - return NextResponse.json({ error: insertRes.error.message }, { status: 500 }); + const input = parseForm(form, createResourceSchema); + if (!input.school_subject_class_id || !input.title) { + throw badRequest("Class offering and resource title are required."); } + const upload = form.get("file"); + await createResource( + offering, + { ...input, school_subject_class_id: input.school_subject_class_id, title: input.title }, + upload instanceof File && upload.size > 0 ? upload : null, + { schoolId, userId }, + ); } else { - return NextResponse.json({ error: "Unsupported coursework action." }, { status: 400 }); + throw badRequest(UNSUPPORTED_ACTION); } - const data = await getSubjectCourseworkData(id); + await bumpCache(schoolId, SUBJECT_CACHE_ENTITY); + const data = await getSubjectCourseworkData(id, schoolId); return NextResponse.json({ ok: true, data }, { status: 201 }); } catch (error) { - console.error("subject coursework create route error", error); - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unexpected server error." }, - { status: 500 }, - ); + return toErrorResponse(error); } } +/** + * JSON body. + * { action: "update_progress", school_subject_class_id, current_node_id?, syllabus_progress_pct? } + * { action: "toggle_visibility", resource_id, next_visibility: "private" | "public" } + * Response: { ok: true, data: SubjectCourseworkPayload }. + */ export async function PATCH(request: NextRequest, context: Context) { + let auth; try { - await requireAuth(request, { roles: ["admin", "super-admin", "manager", "teacher"], rateLimit: "mutation" }); - } catch (err) { return toErrorResponse(err); } + auth = await requireAuth(request, { + roles: SUBJECT_WRITE_ROLES, + pageKey: "subjects", + rateLimit: "mutation", + }); + } catch (err) { + return toErrorResponse(err); + } try { - const { id } = await context.params; - const body = (await request.json()) as CourseworkPatchBody; - - if (body.action === "toggle_visibility") { - const currentResource = await supabaseAdmin - .from("subject_resources") - .select("id, visibility") - .eq("id", body.resource_id) - .maybeSingle(); - - if (currentResource.error || !currentResource.data) { - return NextResponse.json( - { error: currentResource.error?.message ?? "Resource not found." }, - { status: 404 }, - ); - } - - const updateRes = await supabaseAdmin - .from("subject_resources") - .update({ visibility: body.next_visibility }) - .eq("id", body.resource_id); - - if (updateRes.error) { - return NextResponse.json({ error: updateRes.error.message }, { status: 500 }); - } - - await supabaseAdmin.from("subject_resource_visibility_events").insert({ - resource_id: body.resource_id, - previous_visibility: currentResource.data.visibility, - next_visibility: body.next_visibility, - changed_by: body.changed_by ?? null, - }); + const id = requireSubjectId((await context.params).id); + const { schoolId, userId } = auth; + + let body: unknown; + try { + body = await request.json(); + } catch { + throw badRequest("Request body must be valid JSON."); } + const action = typeof (body as { action?: unknown } | null)?.action === "string" + ? (body as { action: string }).action + : ""; - if (body.action === "update_progress") { - const upsertRes = await supabaseAdmin.from("subject_class_progress").upsert( - { - school_subject_class_id: body.school_subject_class_id, - current_node_id: body.current_node_id ?? null, - syllabus_progress_pct: body.syllabus_progress_pct ?? null, - updated_at: new Date().toISOString(), - }, - { onConflict: "school_subject_class_id" }, - ); + const offering = await getSchoolSubjectOrThrow(id, schoolId); - if (upsertRes.error) { - return NextResponse.json({ error: upsertRes.error.message }, { status: 500 }); - } + if (action === "toggle_visibility") { + await setResourceVisibility(offering, parse(toggleVisibilitySchema, body), { schoolId, userId }); + } else if (action === "update_progress") { + await saveClassProgress(offering, parse(updateProgressSchema, body)); + } else { + throw badRequest(UNSUPPORTED_ACTION); } - const data = await getSubjectCourseworkData(id); + await bumpCache(schoolId, SUBJECT_CACHE_ENTITY); + const data = await getSubjectCourseworkData(id, schoolId); return NextResponse.json({ ok: true, data }); } catch (error) { - console.error("subject coursework patch route error", error); - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unexpected server error." }, - { status: 500 }, - ); + return toErrorResponse(error); } } diff --git a/stackable-app/app/api/subjects/[id]/route.ts b/stackable-app/app/api/subjects/[id]/route.ts index 371d53d..24cffd2 100644 --- a/stackable-app/app/api/subjects/[id]/route.ts +++ b/stackable-app/app/api/subjects/[id]/route.ts @@ -1,34 +1,47 @@ +// ============================================================================= +// GET /api/subjects/[id] — detail for one subject offering of the caller's school. +// A subject id from another school answers 404, exactly like an id that doesn't exist. +// ============================================================================= + import { NextRequest, NextResponse } from "next/server"; -import { getSubjectDetailData, getSchoolSubjectOrThrow } from "@/lib/subjects-server"; import { requireAuth } from "@/lib/api/guard"; -import { toErrorResponse, notFound } from "@/lib/api/errors"; +import { toErrorResponse } from "@/lib/api/errors"; +import { cached } from "@/lib/cache"; +import { + SUBJECT_CACHE_ENTITY, + SUBJECT_DETAIL_TTL_SECONDS, + SUBJECT_READ_ROLES, + getSubjectDetailData, + requireSubjectId, +} from "@/lib/subjects-server"; + +export const dynamic = "force-dynamic"; type Context = { params: Promise<{ id: string }>; }; +/** Response: { ok: true, data: SubjectDetailPayload }. */ export async function GET(request: NextRequest, context: Context) { - let ctx; + let auth; try { - ctx = await requireAuth(request, { pageKey: "subjects", rateLimit: "read" }); - } catch (err) { return toErrorResponse(err); } + auth = await requireAuth(request, { roles: SUBJECT_READ_ROLES, pageKey: "subjects", rateLimit: "read" }); + } catch (err) { + return toErrorResponse(err); + } try { - const { id } = await context.params; - - // Cross-tenant guard: verify the school_subject belongs to the caller's school. - const offering = await getSchoolSubjectOrThrow(id); - if (offering.school_id !== ctx.schoolId) { - return toErrorResponse(notFound()); - } - - const data = await getSubjectDetailData(id); + const id = requireSubjectId((await context.params).id); + const { schoolId } = auth; + const data = await cached( + schoolId, + SUBJECT_CACHE_ENTITY, + SUBJECT_DETAIL_TTL_SECONDS, + () => getSubjectDetailData(id, schoolId), + `detail:${id}`, + ); return NextResponse.json({ ok: true, data }); } catch (error) { - console.error("subject detail route error", error); - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unexpected server error." }, - { status: 500 }, - ); + return toErrorResponse(error); } } diff --git a/stackable-app/app/api/subjects/resource/route.ts b/stackable-app/app/api/subjects/resource/route.ts index af13e23..b5a1116 100644 --- a/stackable-app/app/api/subjects/resource/route.ts +++ b/stackable-app/app/api/subjects/resource/route.ts @@ -1,44 +1,57 @@ +// ============================================================================= +// GET /api/subjects/resource?path= +// Redirects to a short-lived signed URL for a coursework file. Only files that +// belong to the caller's own school can be signed: the path must have the exact +// shape we generate and start with the session's school id. Staff (SUBJECT_READ_ROLES) +// can open any of their school's files; every other role only PUBLIC resources. +// ============================================================================= + import { NextRequest, NextResponse } from "next/server"; -import { supabaseAdmin } from "@/lib/supabase/admin"; import { requireAuth } from "@/lib/api/guard"; -import { toErrorResponse, notFound } from "@/lib/api/errors"; +import { badRequest, notFound, toErrorResponse } from "@/lib/api/errors"; +import { createSignedUrl } from "@/lib/storage"; +import { SUBJECT_RESOURCES_BUCKET } from "@/lib/subjects"; +import { + SUBJECT_READ_ROLES, + getResourceVisibilityByPath, + isOwnResourcePath, +} from "@/lib/subjects-server"; + +export const dynamic = "force-dynamic"; + +const SIGNED_URL_SECONDS = 60 * 10; // long enough to start a download or stream, short enough to not be shareable export async function GET(request: NextRequest) { let ctx; try { ctx = await requireAuth(request, { rateLimit: "read" }); - } catch (err) { return toErrorResponse(err); } + } catch (err) { + return toErrorResponse(err); + } try { const path = request.nextUrl.searchParams.get("path"); - if (!path) { - return NextResponse.json({ error: "path is required." }, { status: 400 }); - } + if (!path) throw badRequest("path is required."); - // Storage paths are: {schoolId}/{subjectId}/{classId}/... - // The first segment is the school UUID — verify it matches the caller's school. - const pathSchoolId = path.split("/")[0]; - if (!pathSchoolId || pathSchoolId !== ctx.schoolId) { - return toErrorResponse(notFound()); - } + // Same 404 for "not yours" and "does not exist" so paths can't be probed. + if (!isOwnResourcePath(path, ctx.schoolId)) throw notFound(); - const signed = await supabaseAdmin.storage - .from("subject_resources") - .createSignedUrl(path, 60 * 10); + // The file must belong to a resource row of THIS school; non-staff roles only get public ones. + const visibility = await getResourceVisibilityByPath(path, ctx.schoolId); + if (!visibility) throw notFound(); + if (!SUBJECT_READ_ROLES.includes(ctx.role) && visibility !== "public") throw notFound(); - if (signed.error || !signed.data?.signedUrl) { - return NextResponse.json( - { error: signed.error?.message ?? "Could not create a signed resource URL." }, - { status: 500 }, - ); + const signed = await createSignedUrl(SUBJECT_RESOURCES_BUCKET, path, SIGNED_URL_SECONDS); + if (signed.error || !signed.url) { + // Most often the object is gone; the provider's message stays in the server log. + console.error("subject resource signing failed:", signed.error); + throw notFound("Resource file not found."); } - return NextResponse.redirect(signed.data.signedUrl); + const response = NextResponse.redirect(signed.url); + response.headers.set("Cache-Control", "private, no-store"); // the signed link is per-user and expires + return response; } catch (error) { - console.error("subject resource proxy route error", error); - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unexpected server error." }, - { status: 500 }, - ); + return toErrorResponse(error); } } diff --git a/stackable-app/app/api/subjects/route.ts b/stackable-app/app/api/subjects/route.ts index 78abf41..5324738 100644 --- a/stackable-app/app/api/subjects/route.ts +++ b/stackable-app/app/api/subjects/route.ts @@ -1,348 +1,109 @@ -import crypto from "crypto"; +// ============================================================================= +// /api/subjects +// GET -> the school's subject directory (cards + filter/form options) +// POST -> create (or link) a subject and its school offering +// ----------------------------------------------------------------------------- +// The school always comes from the session. GET is cached per school and every +// successful POST bumps that cache, so "add a subject, then see it" always works. +// ============================================================================= + import { NextRequest, NextResponse } from "next/server"; -import { SUBJECT_BACKGROUND_BUCKET } from "@/lib/subjects"; -import { getSubjectDirectoryData, getSubjectFormOptions } from "@/lib/subjects-server"; -import { supabaseAdmin } from "@/lib/supabase/admin"; import { requireAuth } from "@/lib/api/guard"; -import { toErrorResponse } from "@/lib/api/errors"; -import { listSubjectsWithMeta } from "@/lib/repositories/subject.repo"; - -type SubjectCreatePayload = { - existing_subject_id?: number | null; - school_id?: string | null; - class_ids?: string[]; - teacher_assignments?: Array<{ - teacher_id: string; - assignment_role: string; - is_primary?: boolean; - }>; - subject_name?: string | null; - subject_code?: string | null; - acronym?: string | null; - short_name?: string | null; - strapline?: string | null; - description?: string | null; - department?: string | null; - category?: string | null; - subject_type?: string | null; - education_level?: string | null; - requires_lab?: boolean; - has_coursework?: boolean; - has_assessments?: boolean; - is_elective?: boolean; - is_active?: boolean; - default_sequence?: number | null; - theme_token?: string | null; - abstract_image_url?: string | null; -}; - -function asText(value: unknown) { - const text = String(value ?? "").trim(); - return text || null; -} - -function asBool(value: unknown, fallback: boolean) { - return typeof value === "boolean" ? value : fallback; -} - -function asNumber(value: unknown) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : null; -} - -async function ensureSubjectBackgroundBucket() { - const bucket = await supabaseAdmin.storage.getBucket(SUBJECT_BACKGROUND_BUCKET); - if (!bucket.error && bucket.data) return; - - const created = await supabaseAdmin.storage.createBucket(SUBJECT_BACKGROUND_BUCKET, { - public: true, - fileSizeLimit: 8 * 1024 * 1024, - }); - - if (created.error && !created.error.message.toLowerCase().includes("already exists")) { - throw new Error(created.error.message); - } -} - +import { badRequest, toErrorResponse } from "@/lib/api/errors"; +import { parse } from "@/lib/api/validate"; +import { bumpCache, cached } from "@/lib/cache"; +import { getSubjectDirectory, getSubjectFormOptions } from "@/lib/repositories/subject.repo"; +import { createSubjectOffering } from "@/lib/subjects-mutations"; +import { + SUBJECT_CACHE_ENTITY, + SUBJECT_LIST_TTL_SECONDS, + SUBJECT_READ_ROLES, + SUBJECT_WRITE_ROLES, +} from "@/lib/subjects-server"; +import { createSubjectSchema } from "@/lib/validation/subjects"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/subjects — the directory for the signed-in user's school. + * Response: { ok: true, data: SubjectDirectoryPayload }. + */ export async function GET(request: NextRequest) { let auth; try { - auth = await requireAuth(request, { pageKey: "subjects", rateLimit: "read" }); - } catch (err) { return toErrorResponse(err); } - - if (process.env.DATA_BACKEND === "prisma") { - try { - const data = await listSubjectsWithMeta({ schoolId: auth.schoolId }); - return NextResponse.json({ ok: true, data }); - } catch (error) { - console.error("subjects directory route error (prisma)", error); - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unexpected server error." }, - { status: 500 }, - ); - } + auth = await requireAuth(request, { roles: SUBJECT_READ_ROLES, pageKey: "subjects", rateLimit: "read" }); + } catch (err) { + return toErrorResponse(err); } try { - // Pass schoolId so the Supabase fallback is scoped to this school (tenant safety). - const data = await getSubjectDirectoryData({ schoolId: auth.schoolId }); + const { schoolId } = auth; + const data = await cached( + schoolId, + SUBJECT_CACHE_ENTITY, + SUBJECT_LIST_TTL_SECONDS, + () => getSubjectDirectory({ schoolId }), + "list", + ); return NextResponse.json({ ok: true, data }); } catch (error) { - console.error("subjects directory route error", error); - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unexpected server error." }, - { status: 500 }, - ); + return toErrorResponse(error); } } +/** + * POST /api/subjects — create a subject offering for the signed-in user's school. + * Body: multipart with `payload` (JSON string) and optional `background_image` file, + * or a plain JSON body with the same fields as `payload`. + * Response 201: { ok: true, data: { id, subject_id, school_id, options } }. + */ export async function POST(request: NextRequest) { - let authCtx; + let auth; try { - authCtx = await requireAuth(request, { roles: ["admin", "super-admin", "manager", "teacher"], rateLimit: "mutation" }); - } catch (err) { return toErrorResponse(err); } + auth = await requireAuth(request, { + roles: SUBJECT_WRITE_ROLES, + pageKey: "subjects", + rateLimit: "mutation", + }); + } catch (err) { + return toErrorResponse(err); + } try { - let body: SubjectCreatePayload = {}; - let backgroundImage: File | null = null; - const contentType = request.headers.get("content-type") ?? ""; - - if (contentType.includes("multipart/form-data")) { - const formData = await request.formData(); - const payload = asText(formData.get("payload")); - body = payload ? (JSON.parse(payload) as SubjectCreatePayload) : {}; - const uploaded = formData.get("background_image"); - backgroundImage = uploaded instanceof File && uploaded.size > 0 ? uploaded : null; - } else { - body = (await request.json()) as SubjectCreatePayload; - } - // schoolId comes from the session — never trust the request body for tenancy. - const schoolId = authCtx.schoolId; - const classIds = Array.isArray(body.class_ids) ? body.class_ids.filter(Boolean) : []; - const teacherAssignments = Array.isArray(body.teacher_assignments) - ? body.teacher_assignments.filter((item) => item?.teacher_id) - : []; - - if (!schoolId) { - return NextResponse.json({ error: "School is required." }, { status: 400 }); - } - - let subjectId = asNumber(body.existing_subject_id); - let backgroundImageUrl = asText(body.abstract_image_url); - - if (backgroundImage) { - if (!backgroundImage.type.startsWith("image/")) { - return NextResponse.json( - { error: "Subject background must be an image file." }, - { status: 400 }, - ); - } - - if (backgroundImage.size > 8 * 1024 * 1024) { - return NextResponse.json( - { error: "Subject background must be 8MB or smaller." }, - { status: 400 }, - ); - } - - await ensureSubjectBackgroundBucket(); - const safeExtension = - backgroundImage.name.split(".").pop()?.replace(/[^a-zA-Z0-9]/g, "").toLowerCase() || - "jpg"; - const uploadedBackgroundPath = `subjects/${subjectId ?? "draft"}/${Date.now()}-${crypto.randomUUID()}.${safeExtension}`; - - const uploaded = await supabaseAdmin.storage - .from(SUBJECT_BACKGROUND_BUCKET) - .upload(uploadedBackgroundPath, await backgroundImage.arrayBuffer(), { - contentType: backgroundImage.type || "application/octet-stream", - upsert: false, - }); - - if (uploaded.error) { - return NextResponse.json({ error: uploaded.error.message }, { status: 500 }); - } + const { schoolId, userId } = auth; + if (!schoolId) throw badRequest("School is required."); - const { - data: { publicUrl }, - } = supabaseAdmin.storage - .from(SUBJECT_BACKGROUND_BUCKET) - .getPublicUrl(uploadedBackgroundPath); - backgroundImageUrl = publicUrl; - } - - if (!subjectId) { - const subjectName = asText(body.subject_name); - if (!subjectName) { - return NextResponse.json( - { error: "Either an existing subject or a subject name is required." }, - { status: 400 }, - ); - } - - const subjectInsert = await supabaseAdmin - .from("subjects") - .insert({ - subject_name: subjectName, - subject_code: asText(body.subject_code), - acronym: asText(body.acronym), - short_name: asText(body.short_name), - strapline: asText(body.strapline), - description: asText(body.description), - department: asText(body.department), - category: asText(body.category) ?? "core", - subject_type: asText(body.subject_type) ?? "general", - education_level: asText(body.education_level) ?? "secondary", - requires_lab: asBool(body.requires_lab, false), - has_coursework: asBool(body.has_coursework, true), - has_assessments: asBool(body.has_assessments, true), - is_elective: asBool(body.is_elective, false), - is_active: asBool(body.is_active, true), - default_sequence: asNumber(body.default_sequence), - theme_token: asText(body.theme_token), - abstract_image_url: backgroundImageUrl, - }) - .select("id") - .single(); - - if (subjectInsert.error || !subjectInsert.data) { - return NextResponse.json( - { error: subjectInsert.error?.message ?? "Failed to create subject." }, - { status: 500 }, - ); - } - - subjectId = Number(subjectInsert.data.id); - } else if (backgroundImageUrl != null) { - const subjectUpdate = await supabaseAdmin - .from("subjects") - .update({ abstract_image_url: backgroundImageUrl }) - .eq("id", subjectId); - - if (subjectUpdate.error) { - return NextResponse.json({ error: subjectUpdate.error.message }, { status: 500 }); - } - } - - const schoolSubjectLookup = await supabaseAdmin - .from("school_subjects") - .select("id, school_id, subject_id") - .eq("school_id", schoolId) - .eq("subject_id", subjectId) - .maybeSingle(); - - if (schoolSubjectLookup.error) { - return NextResponse.json({ error: schoolSubjectLookup.error.message }, { status: 500 }); - } - - let schoolSubjectId = schoolSubjectLookup.data?.id as string | undefined; - - if (!schoolSubjectId) { - const schoolSubjectInsert = await supabaseAdmin - .from("school_subjects") - .insert({ school_id: schoolId, subject_id: subjectId }) - .select("id") - .single(); - - if (schoolSubjectInsert.error || !schoolSubjectInsert.data) { - return NextResponse.json( - { - error: - schoolSubjectInsert.error?.message ?? - "Failed to create school subject offering.", - }, - { status: 500 }, - ); - } - - schoolSubjectId = schoolSubjectInsert.data.id as string; - } - - if (classIds.length > 0) { - const classesCheck = await supabaseAdmin - .from("classes") - .select("id, school_id") - .in("id", classIds); - - if (classesCheck.error) { - return NextResponse.json({ error: classesCheck.error.message }, { status: 500 }); - } - - const validClassIds = (classesCheck.data ?? []) - .filter((item) => item.school_id === schoolId) - .map((item) => item.id); - - if (validClassIds.length > 0) { - const classUpsert = await supabaseAdmin.from("school_subject_classes").upsert( - validClassIds.map((classId, index) => ({ - school_subject_id: schoolSubjectId, - class_id: classId, - display_order: index, - })), - { onConflict: "school_subject_id,class_id" }, - ); + let rawPayload: unknown = {}; + let backgroundImage: File | null = null; - if (classUpsert.error) { - return NextResponse.json({ error: classUpsert.error.message }, { status: 500 }); + if ((request.headers.get("content-type") ?? "").includes("multipart/form-data")) { + const form = await request.formData(); + const payloadText = form.get("payload"); + if (typeof payloadText === "string" && payloadText.trim()) { + try { + rawPayload = JSON.parse(payloadText); + } catch { + throw badRequest("Subject payload must be valid JSON."); } } - } - - if (teacherAssignments.length > 0) { - const teacherIds = teacherAssignments.map((item) => item.teacher_id); - const teachersCheck = await supabaseAdmin - .from("teachers") - .select("id, school_id") - .in("id", teacherIds); - - if (teachersCheck.error) { - return NextResponse.json({ error: teachersCheck.error.message }, { status: 500 }); - } - - const validTeachers = new Set( - (teachersCheck.data ?? []) - .filter((item) => item.school_id === schoolId) - .map((item) => item.id), - ); - - const teacherUpsert = await supabaseAdmin.from("teacher_subjects").upsert( - teacherAssignments - .filter((item) => validTeachers.has(item.teacher_id)) - .map((item) => ({ - teacher_id: item.teacher_id, - subject_id: subjectId, - school_id: schoolId, - school_subject_id: schoolSubjectId, - assignment_role: item.assignment_role || "teacher", - is_primary: Boolean(item.is_primary), - })), - { onConflict: "teacher_id,subject_id,school_id" }, - ); - - if (teacherUpsert.error) { - return NextResponse.json({ error: teacherUpsert.error.message }, { status: 500 }); + const uploaded = form.get("background_image"); + backgroundImage = uploaded instanceof File && uploaded.size > 0 ? uploaded : null; + } else { + try { + rawPayload = await request.json(); + } catch { + throw badRequest("Request body must be valid JSON."); } } - const options = await getSubjectFormOptions(); + const input = parse(createSubjectSchema, rawPayload); + const created = await createSubjectOffering(input, { schoolId, userId }, backgroundImage); + await bumpCache(schoolId, SUBJECT_CACHE_ENTITY); - return NextResponse.json( - { - ok: true, - data: { - id: schoolSubjectId, - subject_id: subjectId, - school_id: schoolId, - options, - }, - }, - { status: 201 }, - ); + const options = await getSubjectFormOptions({ schoolId }); + return NextResponse.json({ ok: true, data: { ...created, options } }, { status: 201 }); } catch (error) { - console.error("create subject route error", error); - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unexpected server error." }, - { status: 500 }, - ); + return toErrorResponse(error); } } diff --git a/stackable-app/app/api/teach/portal-data/route.ts b/stackable-app/app/api/teach/portal-data/route.ts index 1999e07..b29014e 100644 --- a/stackable-app/app/api/teach/portal-data/route.ts +++ b/stackable-app/app/api/teach/portal-data/route.ts @@ -1,15 +1,20 @@ // ============================================================================= -// GET /api/teach/portal-data — Teacher portal dashboard data +// GET /api/teach/portal-data — Teacher portal home data (TeacherPortalData) // ----------------------------------------------------------------------------- // Scoped to the logged-in teacher. Because the `teachers` table has no user_id -// FK, we resolve the teacher by looking up the user's email in `users` and then -// finding the teacher row with that email (teachers.email is unique). +// FK, the repository resolves the teacher from the user's email (see +// teacher-portal.repo.ts). Per-user answer, so the cache key includes the user id. // ============================================================================= import { NextResponse } from "next/server"; import { requireAuth } from "@/lib/api/guard"; -import { toErrorResponse, notFound } from "@/lib/api/errors"; -import { prisma } from "@/lib/db/prisma"; +import { toErrorResponse } from "@/lib/api/errors"; +import { cached } from "@/lib/cache"; +import { getTeacherPortalData } from "@/lib/repositories/teacher-portal.repo"; + +export const dynamic = "force-dynamic"; + +const PORTAL_CACHE_SECONDS = 30; // keeps quick page revisits cheap without hiding new grades for long export async function GET(request: Request) { try { @@ -17,109 +22,14 @@ export async function GET(request: Request) { roles: ["teacher", "staff", "super-admin"], rateLimit: "read", }); - - // Resolve teacher row via email (teachers has no user_id FK to users). - const user = await prisma.users.findUnique({ - where: { id: auth.userId }, - select: { email: true }, - }); - - if (!user?.email) { - throw notFound("User account has no email — cannot resolve teacher profile."); - } - - const teacher = await prisma.teachers.findUnique({ - where: { email: user.email }, - select: { id: true, name: true, email: true, status: true, school_id: true }, - }); - - if (!teacher) { - throw notFound("No teacher profile found for this account."); - } - - // Fetch all portal data in parallel. - const [timetableRows, studentAssignments, teacherSubjects] = await Promise.all([ - // Timetable → unique class_ids this teacher is scheduled for - prisma.teacher_timetables.findMany({ - where: { teacher_id: teacher.id }, - select: { class_id: true }, - distinct: ["class_id"], - }), - - // Students assigned to this teacher - prisma.teacher_student_assignments.findMany({ - where: { teacher_id: teacher.id }, - select: { - student_id: true, - students: { - select: { - id: true, - first_name: true, - last_name: true, - }, - }, - }, - distinct: ["student_id"], - }), - - // Subjects assigned to this teacher - prisma.teacher_subjects.findMany({ - where: { teacher_id: teacher.id }, - select: { - subject_id: true, - subjects: { - select: { - id: true, - subject_name: true, - }, - }, - }, - distinct: ["subject_id"], - }), - ]); - - // Resolve class details for the timetable class_ids - const classIds = timetableRows - .map((r) => r.class_id) - .filter((id): id is string => id !== null); - - const classes = - classIds.length > 0 - ? await prisma.classes.findMany({ - where: { id: { in: classIds } }, - select: { - id: true, - class_name: true, - stream: true, - total_students: true, - }, - }) - : []; - - const subjects = teacherSubjects.map((ts) => ({ - id: String(ts.subjects.id), - subjectName: ts.subjects.subject_name, - })); - - return NextResponse.json({ - ok: true, - data: { - teacher: { - id: teacher.id, - name: teacher.name, - email: teacher.email, - status: teacher.status, - }, - classes: classes.map((c) => ({ - id: c.id, - name: c.class_name, - stream: c.stream, - totalStudents: c.total_students, - })), - subjects, - studentCount: studentAssignments.length, - }, - }); + const data = await cached( + auth.schoolId, + "teach-portal-data", + PORTAL_CACHE_SECONDS, + () => getTeacherPortalData(auth.userId, auth.schoolId), + auth.userId, // per-user data: the user id MUST be in the key + ); + return NextResponse.json({ ok: true, data }); } catch (err) { return toErrorResponse(err); } diff --git a/stackable-app/app/api/teach/students/[studentId]/route.ts b/stackable-app/app/api/teach/students/[studentId]/route.ts new file mode 100644 index 0000000..dafa5dc --- /dev/null +++ b/stackable-app/app/api/teach/students/[studentId]/route.ts @@ -0,0 +1,34 @@ +// ============================================================================= +// GET /api/teach/students/[studentId] — one assigned student's profile +// ----------------------------------------------------------------------------- +// Returns TeacherStudentProfile. Allowed ONLY for students assigned to the +// signed-in teacher; anyone else gets a plain 404 so ids cannot be probed. +// ============================================================================= + +import { NextResponse } from "next/server"; +import { requireAuth } from "@/lib/api/guard"; +import { badRequest, toErrorResponse } from "@/lib/api/errors"; +import { getTeacherStudentProfile } from "@/lib/repositories/teacher-portal.repo"; +import { isUuid } from "@/lib/repositories/portal-shared"; + +export const dynamic = "force-dynamic"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ studentId: string }> }, +) { + try { + const auth = await requireAuth(request, { + roles: ["teacher", "staff", "super-admin"], + rateLimit: "read", + }); + const { studentId } = await params; + // Validate at the gate: a malformed id must not reach a uuid column (that would be a 500). + if (!isUuid(studentId)) throw badRequest("Invalid student id."); + + const data = await getTeacherStudentProfile(auth.userId, auth.schoolId, studentId); + return NextResponse.json({ ok: true, data }); + } catch (err) { + return toErrorResponse(err); + } +} diff --git a/stackable-app/app/api/teach/students/route.ts b/stackable-app/app/api/teach/students/route.ts index be3f60e..c28d6a9 100644 --- a/stackable-app/app/api/teach/students/route.ts +++ b/stackable-app/app/api/teach/students/route.ts @@ -1,11 +1,14 @@ // ============================================================================= // GET /api/teach/students — Students assigned to the signed-in teacher +// Returns { students: TeacherStudent[]; total: number } (see portal-types.ts). // ============================================================================= import { NextResponse } from "next/server"; import { requireAuth } from "@/lib/api/guard"; -import { toErrorResponse, notFound } from "@/lib/api/errors"; -import { prisma } from "@/lib/db/prisma"; +import { toErrorResponse } from "@/lib/api/errors"; +import { listTeacherStudents } from "@/lib/repositories/teacher-portal.repo"; + +export const dynamic = "force-dynamic"; export async function GET(request: Request) { try { @@ -13,73 +16,8 @@ export async function GET(request: Request) { roles: ["teacher", "staff", "super-admin"], rateLimit: "read", }); - - // Resolve teacher via user email (no user_id FK on teachers table). - const user = await prisma.users.findUnique({ - where: { id: auth.userId }, - select: { email: true }, - }); - - if (!user?.email) { - throw notFound("User account has no email — cannot resolve teacher profile."); - } - - const teacher = await prisma.teachers.findUnique({ - where: { email: user.email }, - select: { id: true }, - }); - - if (!teacher) { - throw notFound("No teacher profile found for this account."); - } - - const assignments = await prisma.teacher_student_assignments.findMany({ - where: { teacher_id: teacher.id }, - select: { - student_id: true, - students: { - select: { - id: true, - first_name: true, - last_name: true, - admission_no: true, - status: true, - class_id: true, - classes: { - select: { - class_name: true, - stream: true, - }, - }, - }, - }, - }, - distinct: ["student_id"], - orderBy: { - students: { - last_name: "asc", - }, - }, - }); - - const students = assignments.map((a) => { - const s = a.students; - const classLabel = s.classes - ? [s.classes.class_name, s.classes.stream].filter(Boolean).join(" ") - : null; - - return { - id: s.id, - firstName: s.first_name ?? "", - lastName: s.last_name, - admissionNo: s.admission_no, - status: s.status, - classId: s.class_id, - className: classLabel, - }; - }); - - return NextResponse.json({ ok: true, data: { students, total: students.length } }); + const data = await listTeacherStudents(auth.userId, auth.schoolId); + return NextResponse.json({ ok: true, data }); } catch (err) { return toErrorResponse(err); } diff --git a/stackable-app/app/api/teachers/[id]/attendance/route.ts b/stackable-app/app/api/teachers/[id]/attendance/route.ts new file mode 100644 index 0000000..cbc808d --- /dev/null +++ b/stackable-app/app/api/teachers/[id]/attendance/route.ts @@ -0,0 +1,31 @@ +// ============================================================================= +// GET /api/teachers/[id]/attendance?limit=200 — the teacher's clock-in history. +// Counts (present / late / absent / total) cover ALL records; `records` are the +// newest `limit` (default 200, max 500). This is what the old +// /dashboard/teachers/[id]/students page showed. +// Response: { ok: true, data: TeacherAttendanceData }. +// ============================================================================= + +import { NextRequest, NextResponse } from "next/server"; +import { requireAuth } from "@/lib/api/guard"; +import { toErrorResponse } from "@/lib/api/errors"; +import { parse } from "@/lib/api/validate"; +import { teacherAdmin } from "@/lib/services/teacher-admin.api"; +import { ADMIN_ROLES, assertUuid } from "@/lib/validation/admin-common"; +import { attendanceQuerySchema } from "@/lib/validation/teachers"; + +export const dynamic = "force-dynamic"; + +type Context = { params: Promise<{ id: string }> }; + +export async function GET(request: NextRequest, context: Context) { + try { + const auth = await requireAuth(request, { roles: ADMIN_ROLES, pageKey: "teachers", rateLimit: "read" }); + const id = assertUuid((await context.params).id); + const { limit } = parse(attendanceQuerySchema, Object.fromEntries(request.nextUrl.searchParams)); + const data = await teacherAdmin.attendance(auth.schoolId, id, limit); + return NextResponse.json({ ok: true, data }); + } catch (error) { + return toErrorResponse(error); + } +} diff --git a/stackable-app/app/api/teachers/[id]/edit-data/route.ts b/stackable-app/app/api/teachers/[id]/edit-data/route.ts new file mode 100644 index 0000000..7c69b0c --- /dev/null +++ b/stackable-app/app/api/teachers/[id]/edit-data/route.ts @@ -0,0 +1,27 @@ +// ============================================================================= +// GET /api/teachers/[id]/edit-data — everything the teacher edit page loads in one +// call: the teacher, the subject catalogue, the school's classes, current subject / +// class assignments and the weekly timetable. +// Response: { ok: true, data: TeacherEditData }. 404 for an id outside the caller's school. +// ============================================================================= + +import { NextRequest, NextResponse } from "next/server"; +import { requireAuth } from "@/lib/api/guard"; +import { toErrorResponse } from "@/lib/api/errors"; +import { teacherAdmin } from "@/lib/services/teacher-admin.api"; +import { ADMIN_ROLES, assertUuid } from "@/lib/validation/admin-common"; + +export const dynamic = "force-dynamic"; + +type Context = { params: Promise<{ id: string }> }; + +export async function GET(request: NextRequest, context: Context) { + try { + const auth = await requireAuth(request, { roles: ADMIN_ROLES, pageKey: "teachers", rateLimit: "read" }); + const id = assertUuid((await context.params).id); + const data = await teacherAdmin.editData(auth.schoolId, id); + return NextResponse.json({ ok: true, data }); + } catch (error) { + return toErrorResponse(error); + } +} diff --git a/stackable-app/app/api/teachers/[id]/route.ts b/stackable-app/app/api/teachers/[id]/route.ts index 9519493..609d52e 100644 --- a/stackable-app/app/api/teachers/[id]/route.ts +++ b/stackable-app/app/api/teachers/[id]/route.ts @@ -1,286 +1,57 @@ +// ============================================================================= +// /api/teachers/[id] — one teacher of the caller's school. +// GET -> { ok, data: TeacherProfileData } teacher + pupils of their classes +// PATCH -> { ok, data: TeacherUpdateResult } save the edit form (JSON, or multipart with +// a `payload` JSON field and an optional `photo`); everything applies together or not at all +// DELETE -> { ok, data: { id } } 409 TEACHER_HAS_GRADING_REPORTS unless ?force=true +// ----------------------------------------------------------------------------- +// Roles: admin, manager, super-admin. Every operation is scoped by the session's school: +// an id from another school is a plain 404. +// ============================================================================= + import { NextRequest, NextResponse } from "next/server"; -import { supabaseAdmin } from "@/lib/supabase/admin"; -import { formatClassLabel, numericScore, scoreToGrade } from "@/lib/teachers"; import { requireAuth } from "@/lib/api/guard"; -import { toErrorResponse, notFound } from "@/lib/api/errors"; - -type Context = { - params: Promise<{ id: string }>; -}; - -type TeacherRow = { - id: string; - name: string; - email: string | null; - phone: string | null; - admission_number: string; - subject_id: number | null; - school_id: string; - profile_photo: string | null; - status: string; - created_at: string | null; - days_present: number | null; - total_school_days: number | null; - attendance_percentage: number | string | null; - class_teacher: boolean | null; -}; - -type TeacherSubjectBridgeRow = { - subject_id: number; -}; - -type SubjectRow = { - id: number; - subject_name: string; -}; +import { toErrorResponse } from "@/lib/api/errors"; +import { parse } from "@/lib/api/validate"; +import { teacherAdmin } from "@/lib/services/teacher-admin.api"; +import { ADMIN_ROLES, assertUuid } from "@/lib/validation/admin-common"; +import { readTeacherUpdateRequest, teacherDeleteQuerySchema } from "@/lib/validation/teachers"; -type ClassRow = { - id: string; - class_name: string; - stream: string | null; - class_teacher_id: string | null; -}; +export const dynamic = "force-dynamic"; -type TeacherTimetableRow = { - class_id: string | null; -}; - -type SchoolRow = { - id: string; - name: string; -}; - -type StudentRow = { - id: string; - admission_no: string; - class_id: string | null; - phone: string | null; - phone2: string | null; - status: string; - first_name: string | null; - last_name: string | null; - profile_picture: string | null; -}; - -type StudentAverageGradeRow = { - student_id: string; - avg_score: number | string | null; -}; - -function getStudentName(student: StudentRow) { - const fullName = `${student.first_name ?? ""} ${student.last_name ?? ""}`.trim(); - return fullName || "Unnamed student"; -} +type Context = { params: Promise<{ id: string }> }; export async function GET(request: NextRequest, context: Context) { - let ctx; try { - ctx = await requireAuth(request, { pageKey: "teachers", rateLimit: "read" }); - } catch (err) { return toErrorResponse(err); } + const auth = await requireAuth(request, { roles: ADMIN_ROLES, pageKey: "teachers", rateLimit: "read" }); + const id = assertUuid((await context.params).id); + const data = await teacherAdmin.profile(auth.schoolId, id); + return NextResponse.json({ ok: true, data }); + } catch (error) { + return toErrorResponse(error); + } +} +export async function PATCH(request: NextRequest, context: Context) { try { - const { id } = await context.params; - - const teacherRes = await supabaseAdmin - .from("teachers") - .select( - ` - id, - name, - email, - phone, - admission_number, - subject_id, - school_id, - profile_photo, - status, - created_at, - days_present, - total_school_days, - attendance_percentage, - class_teacher - `, - ) - .eq("id", id) - .maybeSingle(); - - if (teacherRes.error) { - return NextResponse.json({ error: teacherRes.error.message }, { status: 500 }); - } - - if (!teacherRes.data) { - return NextResponse.json({ error: "Teacher not found." }, { status: 404 }); - } - - const teacher = teacherRes.data as TeacherRow; - - // Cross-tenant guard: return 404 (not 403) to avoid leaking existence. - if (teacher.school_id !== ctx.schoolId) { - return toErrorResponse(notFound()); - } - - const [schoolRes, bridgeRes, ownedClassesRes, timetableRes] = - await Promise.all([ - supabaseAdmin - .from("schools") - .select("id, name") - .eq("id", teacher.school_id) - .maybeSingle(), - supabaseAdmin - .from("teacher_subjects") - .select("subject_id") - .eq("teacher_id", id), - supabaseAdmin - .from("classes") - .select("id, class_name, stream, class_teacher_id") - .eq("class_teacher_id", id), - supabaseAdmin - .from("teacher_timetables") - .select("class_id") - .eq("teacher_id", id), - ]); - - const bootstrapError = - schoolRes.error ?? bridgeRes.error ?? ownedClassesRes.error ?? timetableRes.error; - - if (bootstrapError) { - return NextResponse.json({ error: bootstrapError.message }, { status: 500 }); - } - - const subjectIds = Array.from( - new Set( - [ - teacher.subject_id, - ...(((bridgeRes.data as TeacherSubjectBridgeRow[]) ?? []).map((row) => - Number(row.subject_id), - ) ?? []), - ].filter((value): value is number => Number.isFinite(value)), - ), - ); - - const ownedClasses = (ownedClassesRes.data as ClassRow[]) ?? []; - const timetableRows = (timetableRes.data as TeacherTimetableRow[]) ?? []; - const classIds = Array.from( - new Set( - [ - ...ownedClasses.map((classItem) => classItem.id), - ...timetableRows - .map((row) => row.class_id) - .filter((value): value is string => Boolean(value)), - ], - ), - ); - - const [subjectsRes, classesRes, studentsRes] = await Promise.all([ - subjectIds.length - ? supabaseAdmin - .from("subjects") - .select("id, subject_name") - .in("id", subjectIds) - : Promise.resolve({ data: [], error: null }), - classIds.length - ? supabaseAdmin - .from("classes") - .select("id, class_name, stream, class_teacher_id") - .in("id", classIds) - : Promise.resolve({ data: [], error: null }), - classIds.length - ? supabaseAdmin - .from("students") - .select( - ` - id, - admission_no, - class_id, - phone, - phone2, - status, - first_name, - last_name, - profile_picture - `, - ) - .in("class_id", classIds) - : Promise.resolve({ data: [], error: null }), - ]); - - const relatedError = subjectsRes.error ?? classesRes.error ?? studentsRes.error; - - if (relatedError) { - return NextResponse.json({ error: relatedError.message }, { status: 500 }); - } - - const students = (studentsRes.data as StudentRow[]) ?? []; - const studentIds = students.map((student) => student.id); - - const averagesRes = studentIds.length - ? await supabaseAdmin - .from("student_average_grade") - .select("student_id, avg_score") - .in("student_id", studentIds) - : { data: [], error: null }; - - if (averagesRes.error) { - return NextResponse.json({ error: averagesRes.error.message }, { status: 500 }); - } - - const school = (schoolRes.data as SchoolRow | null) ?? null; - const subjects = (subjectsRes.data as SubjectRow[]) ?? []; - const classes = (classesRes.data as ClassRow[]) ?? []; - const averages = (averagesRes.data as StudentAverageGradeRow[]) ?? []; - - const classMap = new Map(classes.map((classItem) => [classItem.id, classItem])); - const averageMap = new Map( - averages.map((row) => [row.student_id, numericScore(row.avg_score)]), - ); - - const payloadStudents = students - .map((student) => { - const avgScore = averageMap.get(student.id) ?? null; - - return { - id: student.id, - full_name: getStudentName(student), - admission_no: student.admission_no, - class_id: student.class_id, - class_name: formatClassLabel( - student.class_id ? classMap.get(student.class_id) : null, - ), - phone: student.phone, - phone2: student.phone2, - status: student.status, - profile_picture: student.profile_picture, - avg_score: avgScore, - grade: scoreToGrade(avgScore), - }; - }) - .sort((left, right) => { - const scoreGap = (right.avg_score ?? -1) - (left.avg_score ?? -1); - if (scoreGap !== 0) return scoreGap; - return left.full_name.localeCompare(right.full_name); - }); + const auth = await requireAuth(request, { roles: ADMIN_ROLES, pageKey: "teachers", rateLimit: "mutation" }); + const id = assertUuid((await context.params).id); + const { input, photo } = await readTeacherUpdateRequest(request); + const data = await teacherAdmin.update(auth.schoolId, id, input, photo); + return NextResponse.json({ ok: true, data }); + } catch (error) { + return toErrorResponse(error); + } +} - return NextResponse.json({ - ok: true, - data: { - teacher: { - ...teacher, - school_name: school?.name ?? null, - subjects, - classes: classes.map((classItem) => ({ - id: classItem.id, - label: formatClassLabel(classItem), - })), - students_count: payloadStudents.length, - }, - students: payloadStudents, - }, - }); +export async function DELETE(request: NextRequest, context: Context) { + try { + const auth = await requireAuth(request, { roles: ADMIN_ROLES, pageKey: "teachers", rateLimit: "mutation" }); + const id = assertUuid((await context.params).id); + const { force } = parse(teacherDeleteQuerySchema, Object.fromEntries(request.nextUrl.searchParams)); + const data = await teacherAdmin.remove(auth.schoolId, id, force); + return NextResponse.json({ ok: true, data }); } catch (error) { - console.error("teacher detail route error", error); - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unexpected server error." }, - { status: 500 }, - ); + return toErrorResponse(error); } } diff --git a/stackable-app/app/api/teachers/[id]/timetable/route.ts b/stackable-app/app/api/teachers/[id]/timetable/route.ts new file mode 100644 index 0000000..ca94d20 --- /dev/null +++ b/stackable-app/app/api/teachers/[id]/timetable/route.ts @@ -0,0 +1,42 @@ +// ============================================================================= +// /api/teachers/[id]/timetable — a teacher's weekly timetable. +// GET -> { ok, data: TeacherTimetableData } (slots + the school's classes + subject catalogue) +// PUT -> { ok, data: { slots, conflicts } } body { slots: TimetableSlotInput[] } +// replaces the WHOLE timetable atomically; overlaps are returned as warnings. +// Scoped to the caller's school; writes need role admin, manager or super-admin. +// ============================================================================= + +import { NextRequest, NextResponse } from "next/server"; +import { requireAuth } from "@/lib/api/guard"; +import { toErrorResponse } from "@/lib/api/errors"; +import { parseJson } from "@/lib/api/validate"; +import { teacherAdmin } from "@/lib/services/teacher-admin.api"; +import { ADMIN_ROLES, assertUuid } from "@/lib/validation/admin-common"; +import { timetableSaveSchema } from "@/lib/validation/teachers"; + +export const dynamic = "force-dynamic"; + +type Context = { params: Promise<{ id: string }> }; + +export async function GET(request: NextRequest, context: Context) { + try { + const auth = await requireAuth(request, { roles: ADMIN_ROLES, pageKey: "teachers", rateLimit: "read" }); + const id = assertUuid((await context.params).id); + const data = await teacherAdmin.timetable(auth.schoolId, id); + return NextResponse.json({ ok: true, data }); + } catch (error) { + return toErrorResponse(error); + } +} + +export async function PUT(request: NextRequest, context: Context) { + try { + const auth = await requireAuth(request, { roles: ADMIN_ROLES, pageKey: "teachers", rateLimit: "mutation" }); + const id = assertUuid((await context.params).id); + const { slots } = await parseJson(request, timetableSaveSchema); + const data = await teacherAdmin.saveTimetable(auth.schoolId, id, slots); + return NextResponse.json({ ok: true, data }); + } catch (error) { + return toErrorResponse(error); + } +} diff --git a/stackable-app/app/api/teachers/form-options/route.ts b/stackable-app/app/api/teachers/form-options/route.ts index 4ca4545..dbf8d0a 100644 --- a/stackable-app/app/api/teachers/form-options/route.ts +++ b/stackable-app/app/api/teachers/form-options/route.ts @@ -1,54 +1,23 @@ +// ============================================================================= +// GET /api/teachers/form-options — the caller's school, its classes (with the current +// class teacher id) and the subject catalogue, for the create-teacher form. +// Response: { ok: true, data: { schools, classes, subjects } }. +// ============================================================================= + import { NextRequest, NextResponse } from "next/server"; -import { supabaseAdmin } from "@/lib/supabase/admin"; import { requireAuth } from "@/lib/api/guard"; import { toErrorResponse } from "@/lib/api/errors"; +import { teacherAdmin } from "@/lib/services/teacher-admin.api"; +import { ADMIN_ROLES } from "@/lib/validation/admin-common"; -export async function GET(request: NextRequest) { - let auth; - try { - auth = await requireAuth(request, { rateLimit: "read" }); - } catch (err) { return toErrorResponse(err); } +export const dynamic = "force-dynamic"; +export async function GET(request: NextRequest) { try { - const [schoolsRes, classesRes, subjectsRes] = await Promise.all([ - // Only return the school the caller belongs to — never all schools. - supabaseAdmin - .from("schools") - .select("id, name") - .eq("id", auth.schoolId) - .order("name", { ascending: true }), - // Classes scoped to the caller's school to prevent cross-tenant enumeration. - supabaseAdmin - .from("classes") - .select("id, school_id, class_name, stream, class_teacher_id") - .eq("school_id", auth.schoolId) - .order("class_name", { ascending: true }), - // Subjects are a global catalog — no school scope needed. - supabaseAdmin - .from("subjects") - .select("id, subject_name") - .order("subject_name", { ascending: true }), - ]); - - const firstError = schoolsRes.error ?? classesRes.error ?? subjectsRes.error; - - if (firstError) { - return NextResponse.json({ error: firstError.message }, { status: 500 }); - } - - return NextResponse.json({ - ok: true, - data: { - schools: schoolsRes.data ?? [], - classes: classesRes.data ?? [], - subjects: subjectsRes.data ?? [], - }, - }); + const auth = await requireAuth(request, { roles: ADMIN_ROLES, pageKey: "teachers", rateLimit: "read" }); + const data = await teacherAdmin.formOptions(auth.schoolId); + return NextResponse.json({ ok: true, data }); } catch (error) { - console.error("teacher form options route error", error); - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unexpected server error." }, - { status: 500 }, - ); + return toErrorResponse(error); } } diff --git a/stackable-app/app/api/teachers/photo/route.ts b/stackable-app/app/api/teachers/photo/route.ts index 15788d2..eeac448 100644 --- a/stackable-app/app/api/teachers/photo/route.ts +++ b/stackable-app/app/api/teachers/photo/route.ts @@ -1,67 +1,41 @@ +// ============================================================================= +// GET /api/teachers/photo?path=teachers/{teacherId}/{file} — serves a teacher's photo. +// ----------------------------------------------------------------------------- +// Any signed-in user of the SAME school may read it (avatars appear in every portal). +// The path must be exactly teachers/{uuid}/{file}, and the teacher in it must belong +// to the caller's school; anything else is a 404. The bytes come from lib/storage.ts, +// and the content type is always a real image type, never an arbitrary stored one. +// ============================================================================= + import { NextRequest, NextResponse } from "next/server"; -import { supabaseAdmin } from "@/lib/supabase/admin"; -import { TEACHERS_PROFILE_BUCKET } from "@/lib/teachers"; import { requireAuth } from "@/lib/api/guard"; -import { toErrorResponse, notFound } from "@/lib/api/errors"; +import { badRequest, notFound, toErrorResponse } from "@/lib/api/errors"; +import { teacherAdmin } from "@/lib/services/teacher-admin.api"; +import { parsePhotoPath, readStoredTeacherPhoto } from "@/lib/teacher-photo"; + +export const dynamic = "force-dynamic"; export async function GET(request: NextRequest) { - let ctx; try { - ctx = await requireAuth(request, { rateLimit: "read" }); - } catch (err) { return toErrorResponse(err); } + const auth = await requireAuth(request, { rateLimit: "read" }); - try { const filePath = request.nextUrl.searchParams.get("path")?.trim(); + if (!filePath) throw badRequest("Missing teacher photo path."); - if (!filePath) { - return NextResponse.json( - { error: "Missing teacher photo path." }, - { status: 400 }, - ); - } - - // Storage paths are: teachers/{teacherId}/... - // Extract the teacher ID and verify it belongs to the caller's school. - const pathParts = filePath.split("/"); - const teacherId = pathParts[1]; // index 0 = "teachers", index 1 = UUID - if (!teacherId) { - return toErrorResponse(notFound()); - } - - const { data: teacherRow, error: teacherError } = await supabaseAdmin - .from("teachers") - .select("school_id") - .eq("id", teacherId) - .maybeSingle(); - - if (teacherError || !teacherRow || teacherRow.school_id !== ctx.schoolId) { - return toErrorResponse(notFound()); - } - - const { data, error } = await supabaseAdmin.storage - .from(TEACHERS_PROFILE_BUCKET) - .download(filePath); - - if (error || !data) { - return NextResponse.json( - { error: error?.message ?? "Teacher photo not found." }, - { status: 404 }, - ); - } + const parsed = parsePhotoPath(filePath); + if (!parsed || !(await teacherAdmin.exists(auth.schoolId, parsed.teacherId))) throw notFound(); - const arrayBuffer = await data.arrayBuffer(); + const photo = await readStoredTeacherPhoto(filePath); + if (!photo) throw notFound("Teacher photo not found."); - return new NextResponse(arrayBuffer, { + return new NextResponse(photo.data, { headers: { - "Content-Type": data.type || "application/octet-stream", + "Content-Type": photo.contentType, "Cache-Control": "private, max-age=300", + "X-Content-Type-Options": "nosniff", }, }); } catch (error) { - console.error("teacher photo route error", error); - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unexpected server error." }, - { status: 500 }, - ); + return toErrorResponse(error); } } diff --git a/stackable-app/app/api/teachers/route.ts b/stackable-app/app/api/teachers/route.ts index cc87930..fcfc7b3 100644 --- a/stackable-app/app/api/teachers/route.ts +++ b/stackable-app/app/api/teachers/route.ts @@ -1,487 +1,39 @@ -import crypto from "crypto"; +// ============================================================================= +// /api/teachers — the school's teacher register. +// GET -> { ok, data: TeacherListItem[] } every teacher of the caller's school +// POST -> 201 { ok, data: { id, teacher } } create a teacher (multipart, photo required) +// ----------------------------------------------------------------------------- +// Scoped to the caller's school (from the session, never from the body). Roles: admin, +// manager, super-admin. POST fields: name, email, phone, admission_number, class_id, +// subject_id, photo. A `school_id` field, if sent, is ignored. +// ============================================================================= + import { NextRequest, NextResponse } from "next/server"; -import { supabaseAdmin } from "@/lib/supabase/admin"; -import { - buildTeacherPhotoUrl, - formatClassLabel, - TEACHERS_PROFILE_BUCKET, -} from "@/lib/teachers"; -import { listTeachersWithMeta } from "@/lib/repositories/teacher.repo"; import { requireAuth } from "@/lib/api/guard"; import { toErrorResponse } from "@/lib/api/errors"; +import { teacherAdmin } from "@/lib/services/teacher-admin.api"; +import { ADMIN_ROLES } from "@/lib/validation/admin-common"; +import { readTeacherCreateRequest } from "@/lib/validation/teachers"; -type TeacherRow = { - id: string; - name: string; - email: string | null; - phone: string | null; - admission_number: string; - subject_id: number | null; - school_id: string; - profile_photo: string | null; - status: string; - created_at: string | null; - days_present: number | null; - total_school_days: number | null; - attendance_percentage: number | string | null; - class_teacher: boolean | null; -}; - -type SchoolRow = { - id: string; - name: string; -}; - -type SubjectRow = { - id: number; - subject_name: string; -}; - -type ClassRow = { - id: string; - class_name: string; - stream: string | null; - class_teacher_id: string | null; -}; - -type TeacherTimetableRow = { - teacher_id: string; - class_id: string | null; -}; - -function asText(value: FormDataEntryValue | null) { - const text = String(value ?? "").trim(); - return text || null; -} - -function asNumberValue(value: FormDataEntryValue | null) { - const parsed = Number(asText(value)); - return Number.isFinite(parsed) ? parsed : null; -} - -async function ensureTeacherProfileBucket() { - const { data, error } = await supabaseAdmin.storage.getBucket( - TEACHERS_PROFILE_BUCKET, - ); - - if (!error && data) return; - - const { error: createError } = await supabaseAdmin.storage.createBucket( - TEACHERS_PROFILE_BUCKET, - { - public: false, - fileSizeLimit: 5 * 1024 * 1024, - }, - ); - - if ( - createError && - !createError.message.toLowerCase().includes("already exists") - ) { - throw new Error(createError.message); - } -} - -async function removeUploadedTeacherPhoto(filePath: string | null) { - if (!filePath) return; - - await supabaseAdmin.storage.from(TEACHERS_PROFILE_BUCKET).remove([filePath]); -} - -async function rollbackTeacherCreate({ - teacherId, - classId, - filePath, -}: { - teacherId: string; - classId: string | null; - filePath: string | null; -}) { - await supabaseAdmin.from("teacher_subjects").delete().eq("teacher_id", teacherId); - - if (classId) { - await supabaseAdmin - .from("classes") - .update({ class_teacher_id: null }) - .eq("id", classId) - .eq("class_teacher_id", teacherId); - } - - await supabaseAdmin.from("teachers").delete().eq("id", teacherId); - await removeUploadedTeacherPhoto(filePath); -} +export const dynamic = "force-dynamic"; export async function GET(request: NextRequest) { - // 1. Must be signed in (closes the old "anyone can call this" hole). - let auth; try { - auth = await requireAuth(request, { pageKey: "teachers", rateLimit: "read" }); - } catch (err) { - return toErrorResponse(err); - } - - // 2. New path: read from Prisma/Neon when the switch is flipped, scoped to the - // signed-in user's school. Same JSON shape as before. - if (process.env.DATA_BACKEND === "prisma") { - try { - const data = await listTeachersWithMeta({ schoolId: auth.schoolId }); - return NextResponse.json({ ok: true, data }); - } catch (error) { - console.error("teachers list route error (prisma)", error); - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unexpected server error." }, - { status: 500 }, - ); - } - } - - try { - const [teachersRes, schoolsRes, subjectsRes, classesRes, timetableRes] = - await Promise.all([ - supabaseAdmin - .from("teachers") - .select( - ` - id, - name, - email, - phone, - admission_number, - subject_id, - school_id, - profile_photo, - status, - created_at, - days_present, - total_school_days, - attendance_percentage, - class_teacher - `, - ) - .eq("school_id", auth.schoolId) - .order("created_at", { ascending: false }), - supabaseAdmin.from("schools").select("id, name").eq("id", auth.schoolId), - supabaseAdmin.from("subjects").select("id, subject_name"), - supabaseAdmin - .from("classes") - .select("id, class_name, stream, class_teacher_id") - .eq("school_id", auth.schoolId), - supabaseAdmin - .from("teacher_timetables") - .select("teacher_id, class_id"), - ]); - - const firstError = - teachersRes.error ?? - schoolsRes.error ?? - subjectsRes.error ?? - classesRes.error ?? - timetableRes.error; - - if (firstError) { - return NextResponse.json({ error: firstError.message }, { status: 500 }); - } - - const teachers = (teachersRes.data as TeacherRow[]) ?? []; - const schools = (schoolsRes.data as SchoolRow[]) ?? []; - const subjects = (subjectsRes.data as SubjectRow[]) ?? []; - const classes = (classesRes.data as ClassRow[]) ?? []; - const timetableRows = (timetableRes.data as TeacherTimetableRow[]) ?? []; - - const schoolMap = new Map(schools.map((school) => [school.id, school.name])); - const subjectMap = new Map( - subjects.map((subject) => [String(subject.id), subject.subject_name]), - ); - const classMap = new Map(classes.map((classItem) => [classItem.id, classItem])); - const teacherClassIds = new Map>(); - - for (const classItem of classes) { - if (!classItem.class_teacher_id) continue; - const classIds = teacherClassIds.get(classItem.class_teacher_id) ?? new Set(); - classIds.add(classItem.id); - teacherClassIds.set(classItem.class_teacher_id, classIds); - } - - for (const row of timetableRows) { - if (!row.class_id) continue; - const classIds = teacherClassIds.get(row.teacher_id) ?? new Set(); - classIds.add(row.class_id); - teacherClassIds.set(row.teacher_id, classIds); - } - - return NextResponse.json({ - ok: true, - data: teachers.map((teacher) => { - const classLabels = Array.from(teacherClassIds.get(teacher.id) ?? []).map( - (classId) => formatClassLabel(classMap.get(classId)), - ); - - return { - ...teacher, - school_name: schoolMap.get(teacher.school_id) ?? null, - subject_name: teacher.subject_id - ? subjectMap.get(String(teacher.subject_id)) ?? null - : null, - class_labels: classLabels, - }; - }), - }); + const auth = await requireAuth(request, { roles: ADMIN_ROLES, pageKey: "teachers", rateLimit: "read" }); + const data = await teacherAdmin.list(auth.schoolId); + return NextResponse.json({ ok: true, data }); } catch (error) { - console.error("teachers list route error", error); - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unexpected server error." }, - { status: 500 }, - ); + return toErrorResponse(error); } } export async function POST(request: NextRequest) { - let auth; - try { - auth = await requireAuth(request, { roles: ["admin", "super-admin", "manager"], rateLimit: "mutation" }); - } catch (err) { return toErrorResponse(err); } - - let uploadedFilePath: string | null = null; - let createdTeacherId: string | null = null; - let assignedClassId: string | null = null; - try { - const formData = await request.formData(); - - const name = asText(formData.get("name")); - const email = asText(formData.get("email")); - const phone = asText(formData.get("phone")); - const admissionNumber = asText(formData.get("admission_number")); - // schoolId comes from the session — never trust the request body for tenancy. - const schoolId = auth.schoolId; - const classId = asText(formData.get("class_id")); - const subjectId = asNumberValue(formData.get("subject_id")); - const photo = formData.get("photo"); - - if ( - !name || - !email || - !phone || - !admissionNumber || - !schoolId || - !classId || - !subjectId - ) { - return NextResponse.json( - { - error: - "Name, email, phone number, teacher ID, school, class, and subject are required.", - }, - { status: 400 }, - ); - } - - if (!(photo instanceof File) || photo.size === 0) { - return NextResponse.json( - { error: "Teacher photo is required." }, - { status: 400 }, - ); - } - - if (!photo.type.startsWith("image/")) { - return NextResponse.json( - { error: "Teacher photo must be an image file." }, - { status: 400 }, - ); - } - - if (photo.size > 5 * 1024 * 1024) { - return NextResponse.json( - { error: "Teacher photo must be 5MB or smaller." }, - { status: 400 }, - ); - } - - const [schoolRes, classRes, subjectRes, duplicateTeacherRes] = - await Promise.all([ - supabaseAdmin - .from("schools") - .select("id, name") - .eq("id", schoolId) - .maybeSingle(), - supabaseAdmin - .from("classes") - .select("id, school_id, class_name, stream, class_teacher_id") - .eq("id", classId) - .maybeSingle(), - supabaseAdmin - .from("subjects") - .select("id, subject_name") - .eq("id", subjectId) - .maybeSingle(), - supabaseAdmin - .from("teachers") - .select("id") - .eq("admission_number", admissionNumber) - .maybeSingle(), - ]); - - const lookupError = - schoolRes.error ?? - classRes.error ?? - subjectRes.error ?? - duplicateTeacherRes.error; - - if (lookupError) { - return NextResponse.json({ error: lookupError.message }, { status: 500 }); - } - - if (!schoolRes.data) { - return NextResponse.json({ error: "School not found." }, { status: 404 }); - } - - if (!classRes.data || classRes.data.school_id !== schoolId) { - return NextResponse.json( - { error: "Selected class does not belong to the chosen school." }, - { status: 400 }, - ); - } - - if (!subjectRes.data) { - return NextResponse.json({ error: "Subject not found." }, { status: 404 }); - } - - if (duplicateTeacherRes.data) { - return NextResponse.json( - { error: "A teacher with that ID already exists." }, - { status: 409 }, - ); - } - - if (classRes.data.class_teacher_id) { - return NextResponse.json( - { error: "That class is already assigned to another teacher." }, - { status: 409 }, - ); - } - - await ensureTeacherProfileBucket(); - - const safeExtension = - photo.name.split(".").pop()?.replace(/[^a-zA-Z0-9]/g, "").toLowerCase() || - "jpg"; - - createdTeacherId = crypto.randomUUID(); - assignedClassId = classId; - uploadedFilePath = `teachers/${createdTeacherId}/${Date.now()}-${crypto.randomUUID()}.${safeExtension}`; - - const arrayBuffer = await photo.arrayBuffer(); - - const { error: uploadError } = await supabaseAdmin.storage - .from(TEACHERS_PROFILE_BUCKET) - .upload(uploadedFilePath, arrayBuffer, { - contentType: photo.type || "application/octet-stream", - upsert: false, - }); - - if (uploadError) { - return NextResponse.json({ error: uploadError.message }, { status: 500 }); - } - - const profilePhotoUrl = buildTeacherPhotoUrl(uploadedFilePath); - - const { error: insertTeacherError } = await supabaseAdmin.from("teachers").insert({ - id: createdTeacherId, - name, - email, - phone, - admission_number: admissionNumber, - school_id: schoolId, - profile_photo: profilePhotoUrl, - status: "active", - class_teacher: true, - subject_id: subjectId, - }); - - if (insertTeacherError) { - await removeUploadedTeacherPhoto(uploadedFilePath); - return NextResponse.json( - { error: insertTeacherError.message }, - { status: 500 }, - ); - } - - const { error: classAssignError } = await supabaseAdmin - .from("classes") - .update({ class_teacher_id: createdTeacherId }) - .eq("id", classId) - .is("class_teacher_id", null); - - if (classAssignError) { - await rollbackTeacherCreate({ - teacherId: createdTeacherId, - classId, - filePath: uploadedFilePath, - }); - return NextResponse.json( - { error: classAssignError.message }, - { status: 500 }, - ); - } - - const { error: teacherSubjectError } = await supabaseAdmin - .from("teacher_subjects") - .insert({ - teacher_id: createdTeacherId, - subject_id: subjectId, - }); - - if (teacherSubjectError) { - await rollbackTeacherCreate({ - teacherId: createdTeacherId, - classId, - filePath: uploadedFilePath, - }); - return NextResponse.json( - { error: teacherSubjectError.message }, - { status: 500 }, - ); - } - - return NextResponse.json( - { - ok: true, - data: { - id: createdTeacherId, - teacher: { - id: createdTeacherId, - name, - email, - phone, - admission_number: admissionNumber, - school_id: schoolId, - school_name: schoolRes.data.name, - subject_id: subjectId, - subject_name: subjectRes.data.subject_name, - profile_photo: profilePhotoUrl, - class_teacher: true, - class_labels: [formatClassLabel(classRes.data)], - status: "active", - }, - }, - }, - { status: 201 }, - ); + const auth = await requireAuth(request, { roles: ADMIN_ROLES, pageKey: "teachers", rateLimit: "mutation" }); + const { input, photo } = await readTeacherCreateRequest(request); + const data = await teacherAdmin.create(auth.schoolId, input, photo); + return NextResponse.json({ ok: true, data }, { status: 201 }); } catch (error) { - if (createdTeacherId) { - await rollbackTeacherCreate({ - teacherId: createdTeacherId, - classId: assignedClassId, - filePath: uploadedFilePath, - }); - } else { - await removeUploadedTeacherPhoto(uploadedFilePath); - } - - console.error("create teacher route error", error); - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unexpected server error." }, - { status: 500 }, - ); + return toErrorResponse(error); } } diff --git a/stackable-app/app/dashboard/admin-role/page.tsx b/stackable-app/app/dashboard/admin-role/page.tsx index 9f6890d..cf8dd7b 100644 --- a/stackable-app/app/dashboard/admin-role/page.tsx +++ b/stackable-app/app/dashboard/admin-role/page.tsx @@ -1,1613 +1,173 @@ "use client"; -import Image from "next/image"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { useConfirmation } from "@/components/confirmation/ConfirmationProvider"; +// ============================================================================= +// Users & permissions (/dashboard/admin-role) +// ----------------------------------------------------------------------------- +// A small orchestrating page: data comes from hooks/useAdminUsers.ts, the +// pieces live in components/admin/users/**. The shell already renders the page +//

, so this page has none of its own. +// +// Who can do what (the API is the real enforcement point): +// - list / create / edit: admin and super-admin (a manager gets a 403 and sees +// the "restricted" state below) +// - delete: super-admin only (the button is hidden for everyone else) +// ============================================================================= + +import { useEffect, useState } from "react"; +import type { SortingState } from "@tanstack/react-table"; import { useToast } from "@/components/toast/ToastProvider"; - -type UserRole = "manager" | "admin" | "super-admin" | "teacher" | "student"; -type UserStatus = "active" | "suspended" | "pending"; - -type SchoolOption = { - id: string; - name: string; - code?: string; -}; - -type PermissionItem = { - page_key: string; - can_access: boolean; -}; - -type UserRow = { - id: string; - created_at: string; - updated_at: string; - school_id: string; - school_code: string; - school_adm: number | null; - email: string | null; - phone: number | null; - phone_2: number | null; - role: UserRole; - status: UserStatus; - first_name: string; - last_name: string; - must_change_password: boolean; - schools?: { - id: string; - name: string; - code?: string; - } | null; - permissions?: PermissionItem[]; -}; - -const PAGE_SIZE_OPTIONS = [10, 20, 50]; - -function cn(...classes: Array) { - return classes.filter(Boolean).join(" "); -} - -function formatDate(value: string | null | undefined) { - if (!value) return "—"; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return "—"; - return new Intl.DateTimeFormat("en-GB", { - day: "2-digit", - month: "short", - year: "numeric", - }).format(date); -} - -function getFullName(user: Pick) { - return `${user.first_name ?? ""} ${user.last_name ?? ""}`.trim() || "Unnamed user"; -} - -function getInitials(name: string) { - const parts = name.split(" ").filter(Boolean); - if (!parts.length) return "US"; - return `${parts[0]?.[0] ?? ""}${parts[1]?.[0] ?? ""}`.toUpperCase(); -} - -function roleBadge(role: UserRole) { - switch (role) { - case "super-admin": - return "bg-purple-50 text-purple-700 border-purple-200"; - case "admin": - return "bg-blue-50 text-blue-700 border-blue-200"; - case "manager": - return "bg-indigo-50 text-indigo-700 border-indigo-200"; - case "teacher": - return "bg-emerald-50 text-emerald-700 border-emerald-200"; - case "student": - return "bg-amber-50 text-amber-700 border-amber-200"; - default: - return "bg-gray-50 text-gray-700 border-gray-200"; - } -} - -function statusBadge(status: UserStatus) { - switch (status) { - case "active": - return "bg-green-50 text-green-700 border-green-200"; - case "pending": - return "bg-yellow-50 text-yellow-700 border-yellow-200"; - case "suspended": - return "bg-red-50 text-red-700 border-red-200"; - default: - return "bg-gray-50 text-gray-700 border-gray-200"; - } -} - -function EmptyState({ message }: { message: string }) { - return ( -
-
- - - - - - -
-

No users found

-

{message}

-
- ); -} - -function UsersSkeleton() { - return ( -
-
-

Loading users...

-
- ); -} - -function ActionIcon({ - title, - className, - onClick, - children, - disabled, -}: { - title: string; - className: string; - onClick: () => void; - children: React.ReactNode; - disabled?: boolean; -}) { - return ( - - ); -} - -function SlideOver({ - open, - title, - subtitle, - onClose, - children, - widthClass = "max-w-[760px]", -}: { - open: boolean; - title: string; - subtitle?: string; - onClose: () => void; - children: React.ReactNode; - widthClass?: string; -}) { - return ( - <> -
-
-
-
-
-
-

{title}

- {subtitle ?

{subtitle}

: null} -
- - -
- -
{children}
-
-
-
- - ); -} - -function SearchableSchoolSelect({ - schools, - value, - onChange, -}: { - schools: SchoolOption[]; - value: string; - onChange: (value: string) => void; -}) { - const [open, setOpen] = useState(false); - const [query, setQuery] = useState(""); - const containerRef = useRef(null); - - const selected = schools.find((school) => school.id === value); - - const filtered = useMemo(() => { - const q = query.trim().toLowerCase(); - if (!q) return schools; - return schools.filter((school) => school.name.toLowerCase().includes(q)); - }, [schools, query]); - - useEffect(() => { - function onClickOutside(event: MouseEvent) { - if (!containerRef.current) return; - if (!containerRef.current.contains(event.target as Node)) { - setOpen(false); - } - } - document.addEventListener("mousedown", onClickOutside); - return () => document.removeEventListener("mousedown", onClickOutside); - }, []); - - return ( -
- - - {open ? ( -
-
- setQuery(e.target.value)} - placeholder="Search schools..." - className="w-full rounded-[16px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm text-gray-700 outline-none" - /> - -
- -
- {filtered.length ? ( - filtered.map((school) => ( - - )) - ) : ( -
No schools match your search.
- )} -
-
- ) : null} -
- ); -} - -function RolePermissionEditor({ - role, - pageKeys, - permissions, - onChange, -}: { - role: UserRole; - pageKeys: string[]; - permissions: PermissionItem[]; - onChange: (next: PermissionItem[]) => void; -}) { - if (!["admin", "super-admin"].includes(role)) return null; - - const permissionMap = new Map(permissions.map((item) => [item.page_key, item.can_access])); - - function togglePermission(page_key: string, can_access: boolean) { - const next = [...permissions.filter((item) => item.page_key !== page_key), { page_key, can_access }]; - onChange(next); - } - - return ( -
-
-

Page access

-

- This only applies to admin-level roles. Manager, teacher and student do not use this block. -

-
- -
- {pageKeys.map((pageKey) => { - const allowed = permissionMap.get(pageKey) ?? true; - - return ( -
-
-
-

- {pageKey.replaceAll("_", " ")} -

-

- Define whether this role holder can access this page. -

-
- -
- - -
-
-
- ); - })} -
-
- ); -} +import { Button, EmptyState } from "@/components/ui"; +import { CreateUserDrawer } from "@/components/admin/users/CreateUserDrawer"; +import { EMPTY_FILTERS, UserFilters, hasActiveFilters, type UserFilterState } from "@/components/admin/users/UserFilters"; +import { UserDrawer } from "@/components/admin/users/UserDrawer"; +import { UsersTable } from "@/components/admin/users/UsersTable"; +import { useUserActions } from "@/components/admin/users/useUserActions"; +import { useAdminSchoolOptions, useAdminUsers, type AdminUser } from "@/hooks/useAdminUsers"; +import { useMe } from "@/hooks/useMe"; + +// Newest first, same as the old default. Header clicks change it. +const DEFAULT_SORTING: SortingState = [{ id: "joined", desc: true }]; export default function UsersPage() { - const { confirm } = useConfirmation(); const { showToast } = useToast(); - const [users, setUsers] = useState([]); - const [schools, setSchools] = useState([]); - const [pageKeys, setPageKeys] = useState([]); - const [loading, setLoading] = useState(true); - const [busyId, setBusyId] = useState(null); - - const [search, setSearch] = useState(""); - const [roleFilter, setRoleFilter] = useState("all"); - const [statusFilter, setStatusFilter] = useState("all"); - const [schoolFilter, setSchoolFilter] = useState("all"); - const [sortBy, setSortBy] = useState("newest"); - const [pageSize, setPageSize] = useState(10); - const [page, setPage] = useState(1); - - const [viewUser, setViewUser] = useState(null); - const [editUser, setEditUser] = useState(null); - const [createOpen, setCreateOpen] = useState(false); - - const [createForm, setCreateForm] = useState({ - first_name: "", - last_name: "", - email: "", - phone: "", - phone_2: "", - school_id: "", - role: "manager" as UserRole, - photo_url: "", - permissions: [] as PermissionItem[], - }); - - function showErrorToast(title: string, description?: string) { - showToast({ type: "error", title, description }); - } - - function showSuccessToast(title: string, description?: string) { - showToast({ type: "success", title, description }); - } - - const [editForm, setEditForm] = useState({ - id: "", - email: "", - status: "pending" as UserStatus, - role: "manager" as UserRole, - must_change_password: true, - permissions: [] as PermissionItem[], - }); - - async function fetchUsers() { - setLoading(true); - const params = new URLSearchParams({ - search, - role: roleFilter, - status: statusFilter, - schoolId: schoolFilter, - }); - - const res = await fetch(`/api/admin/users?${params.toString()}`, { cache: "no-store" }); - const json = await res.json(); - - if (!res.ok) { - showErrorToast("Users load failed", json.error || "Failed to load users."); - setLoading(false); - return; - } - - setUsers(json.users ?? []); - setPageKeys(json.pageKeys ?? []); - setLoading(false); - } - - async function fetchSchools() { - const res = await fetch("/api/admin/schools", { cache: "no-store" }); - const json = await res.json(); - if (res.ok) { - setSchools(json.schools ?? []); - } else { - showErrorToast("Schools load failed", json.error || "Failed to load schools."); - } - } - + const me = useMe(); + const actions = useUserActions(); + + const [filters, setFilters] = useState(EMPTY_FILTERS); + const [sorting, setSorting] = useState(DEFAULT_SORTING); + // `n` bumps on every open so each drawer starts from a fresh snapshot / empty form. + const [detail, setDetail] = useState<{ user: AdminUser | null; open: boolean; n: number }>({ user: null, open: false, n: 0 }); + const [create, setCreate] = useState({ open: false, n: 0 }); + + const users = useAdminUsers(filters); + const schools = useAdminSchoolOptions(); + + const rows = users.data?.users ?? []; + const pageKeys = users.data?.pageKeys ?? []; + const forbidden = users.error?.status === 403; + const isSuperAdmin = me.data?.role === "super-admin"; + const viewer = { id: me.data?.id, role: me.data?.role }; + + // Same load-failure toasts as before (the inline states below are the main feedback). + const usersError = users.error; + const schoolsError = schools.error; useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - void fetchSchools(); - }, []); - - useEffect(() => { - const timeout = setTimeout(() => { - void fetchUsers(); - }, 250); - - return () => clearTimeout(timeout); - }, [search, roleFilter, statusFilter, schoolFilter]); - - const filteredUsers = useMemo(() => { - const next = [...users]; - - next.sort((a, b) => { - switch (sortBy) { - case "name-asc": - return getFullName(a).localeCompare(getFullName(b)); - case "name-desc": - return getFullName(b).localeCompare(getFullName(a)); - case "role-asc": - return a.role.localeCompare(b.role); - case "role-desc": - return b.role.localeCompare(a.role); - case "newest": - return b.created_at.localeCompare(a.created_at); - case "oldest": - return a.created_at.localeCompare(b.created_at); - default: - return 0; - } - }); - - return next; - }, [users, sortBy]); - - const totalEntries = filteredUsers.length; - const totalPages = Math.max(1, Math.ceil(totalEntries / pageSize)); - const pageStart = totalEntries === 0 ? 0 : (page - 1) * pageSize + 1; - const pageEnd = Math.min(page * pageSize, totalEntries); - - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - setPage(1); - }, [search, roleFilter, statusFilter, schoolFilter, sortBy, pageSize]); - + if (usersError && usersError.status !== 403) { + showToast({ type: "error", title: "Users load failed", description: usersError.message }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- showToast is not stable; toast once per error + }, [usersError]); useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - if (page > totalPages) setPage(totalPages); - }, [page, totalPages]); - - const paginatedUsers = useMemo(() => { - const start = (page - 1) * pageSize; - return filteredUsers.slice(start, start + pageSize); - }, [filteredUsers, page, pageSize]); - - function openViewModal(user: UserRow) { - setViewUser(user); - setEditUser(null); - } - - function openEditModal(user: UserRow) { - setEditUser(user); - setViewUser(null); - setEditForm({ - id: user.id, - email: user.email ?? "", - status: user.status, - role: user.role, - must_change_password: user.must_change_password, - permissions: user.permissions ?? [], - }); - } - - function openCreateModal() { - setCreateOpen(true); - setViewUser(null); - setEditUser(null); - setCreateForm({ - first_name: "", - last_name: "", - email: "", - phone: "", - phone_2: "", - school_id: "", - role: "manager", - photo_url: "", - permissions: [], - }); - } - - async function handleSuspendToggle(user: UserRow) { - setBusyId(user.id); - - const nextStatus = user.status === "suspended" ? "active" : "suspended"; - - const res = await fetch("/api/admin/users", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - id: user.id, - status: nextStatus, - }), - }); - - const json = await res.json(); - - if (!res.ok) { - showErrorToast("Status update failed", json.error || "Failed to update status."); - setBusyId(null); - return; + if (schoolsError && schoolsError.status !== 403) { + showToast({ type: "error", title: "Schools load failed", description: schoolsError.message }); } + // eslint-disable-next-line react-hooks/exhaustive-deps -- see above + }, [schoolsError]); - setUsers((prev) => - prev.map((item) => (item.id === user.id ? { ...item, status: nextStatus } : item)) - ); - setBusyId(null); - showSuccessToast( - nextStatus === "suspended" ? "User suspended" : "User activated", - `${getFullName(user)} is now ${nextStatus}.`, - ); - } - - async function handleDelete(userId: string) { - const user = users.find((item) => item.id === userId) ?? viewUser ?? editUser; - const ok = await confirm({ - title: "Delete user?", - message: `Delete ${user ? getFullName(user) : "this user"}? This action is permanent.`, - confirmLabel: "Delete user", - cancelLabel: "Cancel", - tone: "danger", - }); - - if (!ok) return; - - setBusyId(userId); - - const res = await fetch(`/api/admin/users?id=${userId}`, { - method: "DELETE", - }); - - const json = await res.json(); - - if (!res.ok) { - showErrorToast("Delete failed", json.error || "Failed to delete user."); - setBusyId(null); - return; - } + const openUser = (user: AdminUser) => setDetail((d) => ({ user, open: true, n: d.n + 1 })); + const openCreate = () => setCreate((c) => ({ open: true, n: c.n + 1 })); - setUsers((prev) => prev.filter((item) => item.id !== userId)); - setBusyId(null); - if (viewUser?.id === userId) setViewUser(null); - if (editUser?.id === userId) setEditUser(null); - showSuccessToast( - "User deleted", - user ? `${getFullName(user)} was removed.` : "The user was removed.", + if (forbidden) { + return ( + ); } - async function handleCreateUser() { - const required = ["first_name", "last_name", "school_id", "role"] as const; - for (const key of required) { - if (!createForm[key]) { - showErrorToast("Missing fields", "Fill all required fields."); - return; - } - } - - const res = await fetch("/api/admin/users", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(createForm), - }); - - const json = await res.json(); - - if (!res.ok) { - showErrorToast("Create user failed", json.error || "Failed to create user."); - return; - } - - setCreateOpen(false); - await fetchUsers(); - showSuccessToast( - "User created", - `${createForm.first_name} ${createForm.last_name}`.trim() || "The user was created.", + // Shown instead of the empty table: a load error, "nothing matches", or "no users yet". + let emptyState; + if (users.isError && !users.data) { + emptyState = ( + void users.refetch()}> + Try again + + } + /> ); - } - - async function handleUpdateUser(clearHistory = false) { - const res = await fetch("/api/admin/users", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - ...editForm, - clear_history: clearHistory, - }), - }); - - const json = await res.json(); - - if (!res.ok) { - showErrorToast("Update failed", json.error || "Failed to update user."); - return; - } - - await fetchUsers(); - - const nextUser = users.find((item) => item.id === editForm.id); - if (nextUser) { - setEditUser({ - ...nextUser, - email: editForm.email, - status: editForm.status, - role: editForm.role, - must_change_password: editForm.must_change_password, - permissions: editForm.permissions, - }); - } - - if (clearHistory) { - showSuccessToast("History cleared", "User history was cleared."); - } else { - showSuccessToast("User updated", "The user changes were saved."); - } - } - - function renderPagination() { - if (totalPages <= 1) { - return ( - - ); - } - - const items: Array = []; - const maxButtons = 5; - - if (totalPages <= maxButtons) { - for (let i = 1; i <= totalPages; i += 1) items.push(i); - } else { - items.push(1); - if (page > 3) items.push("..."); - const start = Math.max(2, page - 1); - const end = Math.min(totalPages - 1, page + 1); - for (let i = start; i <= end; i += 1) items.push(i); - if (page < totalPages - 2) items.push("..."); - items.push(totalPages); - } - - return ( -
- - - {items.map((item, index) => - item === "..." ? ( - - ... - - ) : ( - - ) - )} - - -
+ } else if (rows.length === 0 && !users.isPending) { + emptyState = hasActiveFilters(filters) ? ( + setFilters(EMPTY_FILTERS)}> + Clear filters + + } + /> + ) : ( + + Add user + + } + /> ); } return ( -
-
-
-
-

- User Management -

-

- Central user register for managers, admins, super-admins, teachers and students. -

-
- - -
- -
-
-
- -
- - - - - setSearch(e.target.value)} - placeholder="Search name, email, school, phone or role" - className="w-full bg-transparent px-3 py-3 text-sm text-gray-700 outline-none" - /> -
-
- -
- - -
- -
- - -
- -
- - -
-
- -
-
- - -
-
-
-
- -
- {loading ? ( - - ) : totalEntries === 0 ? ( - - ) : ( -
-
- - - - - - - - - - - - - - - - {paginatedUsers.map((user) => { - const fullName = getFullName(user); - - return ( - - - - - - - - - - - - - - - - - ); - })} - -
UserSchoolRoleEmailPhoneStatusMust Change PasswordAction
-
-
- {getInitials(fullName)} -
- -
- -
- {formatDate(user.created_at)} -
-
-
-
{user.schools?.name ?? "—"} - - {user.role} - - {user.email ?? "—"}{user.phone ?? "—"} - - {user.status} - - - - {user.must_change_password ? "Required" : "Cleared"} - - -
- handleSuspendToggle(user)} - disabled={busyId === user.id} - className={ - user.status === "suspended" - ? "border-green-200 bg-green-50 text-green-700 hover:bg-green-100" - : "border-amber-200 bg-amber-50 text-amber-700 hover:bg-amber-100" - } - > - {user.status === "suspended" ? ( - - - - ) : ( - - - - - - )} - - - openViewModal(user)} - className="border-[#dbe8ff] bg-[#EEF4FF] text-[#4F6EF7] hover:bg-[#E4ECFF]" - > - - - - - - - handleDelete(user.id)} - disabled={busyId === user.id} - className="border-red-200 bg-red-50 text-red-600 hover:bg-red-100" - > - - - - - - - - -
-
-
-
- )} - -
-
- Showing {pageStart} to{" "} - {pageEnd} of{" "} - {totalEntries} entries -
- -
- - - {renderPagination()} -
-
+
+
+

+ Central user register for managers, admins, super-admins, teachers and students. +

+
- setViewUser(null)} - title={viewUser ? getFullName(viewUser) : "User details"} - subtitle="Profile details and administrative controls" - > - {viewUser ? ( -
-
-
-
-
-
- {getInitials(getFullName(viewUser))} -
-
- -
-

{getFullName(viewUser)}

-

{viewUser.email ?? "No email set"}

-
- - {viewUser.role} - - - {viewUser.status} - -
-
-
- - -
-
- -
- {[ - ["School", viewUser.schools?.name ?? "—"], - ["School Code", viewUser.school_code || "—"], - ["Phone", viewUser.phone ?? "—"], - ["Alternative Phone", viewUser.phone_2 ?? "—"], - ["Must Change Password", viewUser.must_change_password ? "Yes" : "No"], - ["Created", formatDate(viewUser.created_at)], - ["Updated", formatDate(viewUser.updated_at)], - ["School ADM", viewUser.school_adm ?? "—"], - ].map(([label, value]) => ( -
-

{label}

-

{String(value)}

-
- ))} -
- - {["admin", "super-admin"].includes(viewUser.role) ? ( -
-

Current page permissions

-
- {pageKeys.map((key) => { - const permission = viewUser.permissions?.find((item) => item.page_key === key); - const allowed = permission ? permission.can_access : true; - - return ( -
- - {key.replaceAll("_", " ")} - - - {allowed ? "Allowed" : "Blocked"} - -
- ); - })} -
-
- ) : null} -
- ) : null} -
- - setEditUser(null)} - title={editUser ? `Edit ${getFullName(editUser)}` : "Edit user"} - subtitle="Update core administrative settings" - > - {editUser ? ( -
-
-
-
- - setEditForm((prev) => ({ ...prev, email: e.target.value }))} - placeholder="Enter email" - className="w-full rounded-[18px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm text-gray-700 outline-none" - /> -
- -
- - -
- -
- - -
- -
- -
- - -
-
-
-
- - setEditForm((prev) => ({ ...prev, permissions: next }))} - /> - -
- - - - - -
-
- ) : null} -
- - setCreateOpen(false)} - title="Add user" - subtitle="Create a new user and assign the school and role" - > -
-
-
-
- {createForm.photo_url ? ( - Profile preview - ) : ( -
- - - - - - -
- )} - -
- - {createForm.photo_url ? ( - <> - - - - ) : ( - <> - - - )} - -
-
- - - setCreateForm((prev) => ({ - ...prev, - photo_url: e.target.value, - })) - } - placeholder="Paste uploaded bucket image URL" - className="mt-4 w-full rounded-[18px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm text-gray-700 outline-none" - /> -

- Use your bucket upload flow here. Do not upload with service role in the browser. -

-
- -
-
- - - setCreateForm((prev) => ({ ...prev, first_name: e.target.value })) - } - placeholder="First name" - className="w-full rounded-[18px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm text-gray-700 outline-none" - /> -
- -
- - - setCreateForm((prev) => ({ ...prev, last_name: e.target.value })) - } - placeholder="Last name" - className="w-full rounded-[18px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm text-gray-700 outline-none" - /> -
- -
- - - setCreateForm((prev) => ({ ...prev, email: e.target.value })) - } - placeholder="Email address" - className="w-full rounded-[18px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm text-gray-700 outline-none" - /> -
- -
- - - setCreateForm((prev) => ({ ...prev, phone: e.target.value })) - } - placeholder="Phone number" - className="w-full rounded-[18px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm text-gray-700 outline-none" - /> -
- -
- - - setCreateForm((prev) => ({ ...prev, phone_2: e.target.value })) - } - placeholder="Alternative phone" - className="w-full rounded-[18px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm text-gray-700 outline-none" - /> -
- -
- - setCreateForm((prev) => ({ ...prev, school_id: value }))} - /> -
- -
- - -
-
-
- - setCreateForm((prev) => ({ ...prev, permissions: next }))} - /> + } + emptyState={emptyState} + sorting={sorting} + onSortingChange={setSorting} + canDelete={isSuperAdmin} + viewer={viewer} + busyId={actions.busyId} + onOpen={openUser} + onToggleSuspend={(user) => void actions.toggleSuspend(user)} + onDelete={(user) => void actions.deleteUser(user)} + /> -
- + {detail.user ? ( + setDetail((d) => ({ ...d, open: false }))} + pageKeys={pageKeys} + viewer={viewer} + canDelete={isSuperAdmin} + /> + ) : null} - -
-
-
+ {create.n > 0 ? ( + setCreate((c) => ({ ...c, open: false }))} + schools={schools.data?.schools ?? []} + schoolsLoading={schools.isPending} + pageKeys={pageKeys} + canGrantSuperAdmin={isSuperAdmin} + /> + ) : null}
); } diff --git a/stackable-app/app/dashboard/layout.tsx b/stackable-app/app/dashboard/layout.tsx index f29c3ce..3b52c0e 100644 --- a/stackable-app/app/dashboard/layout.tsx +++ b/stackable-app/app/dashboard/layout.tsx @@ -1,83 +1,8 @@ -"use client"; - -import React, { useEffect, useState } from "react"; -import clsx from "clsx"; -import Sidebar from "@/components/sidebar/sidebar"; -import Navbar from "@/components/header/dashboard-navbar"; -import BreadCrumb from "@/components/breadcrumb/bread"; - -const DESKTOP_SIDEBAR_EXPANDED_OFFSET = "lg:pl-[17rem]"; -const DESKTOP_SIDEBAR_COLLAPSED_OFFSET = "lg:pl-28"; - -const DashboardLayout = ({ children }: { children: React.ReactNode }) => { - const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); - const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); - - useEffect(() => { - const handleResize = () => { - if (window.innerWidth >= 1024) { - setIsMobileSidebarOpen(false); - } - }; - - handleResize(); - window.addEventListener("resize", handleResize); - - return () => { - window.removeEventListener("resize", handleResize); - }; - }, []); - - useEffect(() => { - const originalOverflow = document.body.style.overflow; - - if (isMobileSidebarOpen) { - document.body.style.overflow = "hidden"; - } else { - document.body.style.overflow = originalOverflow; - } - - return () => { - document.body.style.overflow = originalOverflow; - }; - }, [isMobileSidebarOpen]); - - return ( -
- setIsMobileSidebarOpen(false)} - onToggleCollapse={() => - setIsSidebarCollapsed((current) => !current) - } - /> - -
- - setIsMobileSidebarOpen((current) => !current) - } - /> - -
-
- -
- {children} -
-
-
- ); -}; - -export default DashboardLayout; +import { requireRole } from "@/lib/api/guard"; +import { DashboardShell } from "@/components/dashboard/DashboardShell"; + +// School admin workspace (/dashboard/*). Menu: lib/nav.ts -> NAV.dashboard. +export default async function DashboardLayout({ children }: { children: React.ReactNode }) { + await requireRole("dashboard"); + return {children}; +} diff --git a/stackable-app/app/dashboard/page.tsx b/stackable-app/app/dashboard/page.tsx index 3baa95b..060f40d 100644 --- a/stackable-app/app/dashboard/page.tsx +++ b/stackable-app/app/dashboard/page.tsx @@ -1,68 +1,6 @@ -import React from 'react'; -import Card from '@/components/cards/card'; -import { School, Wallet, Users, BarChart2 } from "lucide-react"; -import Script from 'next/script'; -import CalendarSummary from '@/components/Calender/widget'; -import AttendanceChartCard from '@/components/charts/AttendanceChart'; +import AdminOverview from "@/components/dashboard/AdminOverview"; - - -export default function DashboardPage(){ - return ( - <> -
- -

Dashboard Overview

-
- - } - value="1,578" - label="Active Students" - delta="No Change" - iconBg="bg-yellow-200/60" - iconColor="text-yellow-700" - /> - - } - value="KES 2,005,472" - label="Fees Collected" - delta="+2.1%" - iconBg="bg-cyan-200/60" - deltaStatus="positive" - iconColor="text-cyan-700" - /> - - } - value="1,400" - label="Attendance Today" - delta="-2.3%" - iconBg="bg-purple-200/60" - deltaStatus="negative" - iconColor="text-purple-700" - /> - - } - value="64.6" - label="Mean Grade" - delta="-0.8%" - deltaStatus="negative" - iconBg="bg-blue-200/60" - iconColor="text-blue-700" - /> - -
-
- {/* Chart Card */} - - -{/*Calender*/} - -
-
- - ) +// School workspace overview. Identical to /admin: both render the shared AdminOverview. +export default function DashboardPage() { + return ; } diff --git a/stackable-app/app/dashboard/schools/page.tsx b/stackable-app/app/dashboard/schools/page.tsx index 9fec482..2101efc 100644 --- a/stackable-app/app/dashboard/schools/page.tsx +++ b/stackable-app/app/dashboard/schools/page.tsx @@ -1,2151 +1,252 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import Image from "next/image"; +/** + * Schools (/dashboard/schools) - school portfolio, subscription health, + * capacity utilisation and high-risk actions. + * + * This page only orchestrates; the pieces live in components/admin/schools/** + * and the data layer in hooks/useSchools.ts (TanStack Query, no raw fetch). + * SchoolFilters search + filters + sort (shared by both views) + * SchoolsTable list view (DataTable) SchoolsGrid card view + * SchoolDrawer detail with tabs SchoolForm create / edit drawer + * useSchoolActions confirm -> mutate -> toast for suspend, capacity, code, delete, PDF + * + * The dashboard navbar already renders the page

, so this header has none. + */ +import { useState } from "react"; +import type { SortingState } from "@tanstack/react-table"; +import { Button, EmptyState, Tabs } from "@/components/ui"; +import { useMe } from "@/hooks/useMe"; +import { useSchools, type SchoolRow } from "@/hooks/useSchools"; +import { HttpError } from "@/lib/api/http"; import { useToast } from "@/components/toast/ToastProvider"; +import { SchoolDrawer } from "@/components/admin/schools/SchoolDrawer"; +import { SchoolFilters } from "@/components/admin/schools/SchoolFilters"; +import { SchoolForm } from "@/components/admin/schools/SchoolForm"; +import { SchoolsGrid } from "@/components/admin/schools/SchoolsGrid"; +import { SchoolsTable } from "@/components/admin/schools/SchoolsTable"; +import type { FormMode } from "@/components/admin/schools/formState"; +import { schoolPermissions } from "@/components/admin/schools/permissions"; +import { useErrorToast } from "@/components/admin/schools/useErrorToast"; +import { useSchoolActions } from "@/components/admin/schools/useSchoolActions"; import { - useConfirmation, - type ConfirmationRequestOptions, -} from "@/components/confirmation/ConfirmationProvider"; + DEFAULT_SORTING, + EMPTY_FILTERS, + SORT_OPTIONS, + filterSchools, + hasActiveFilters, + sortingToOption, + type SchoolFilters as Filters, +} from "@/components/admin/schools/utils"; type ViewMode = "list" | "grid"; -type SchoolStatus = "active" | "pending" | "suspended"; -type SubscriptionStatus = "inactive" | "active" | "expired" | "suspended" | "trial"; -type SchoolRow = { - id: string; - school_id: number; - name: string; - code: string; - logo: string | null; - email: string | null; - phone_1: string | null; - head_name: string | null; - owner_name: string | null; - status: SchoolStatus; - subscription_package: string; - subscription_status: SubscriptionStatus; - subscription_started_at: string | null; - subscription_expires_at: string | null; - - expected_users: number; - expected_teachers: number; - expected_admins: number; - expected_students: number; - expected_parents: number; - expected_staff: number; - - no_of_users: number; - no_of_teachers: number; - no_of_admins: number; - no_of_students: number; - no_of_parents: number; - no_of_staff: number; - - code_change_count: number; - pending_code_change_at: string | null; - pending_status_change_at: string | null; -}; - -type SchoolDetails = SchoolRow & { - phone_2?: string | null; - phone_3?: string | null; - location?: string | null; -}; - -type SchoolCreateForm = { - name: string; - email: string; - phone_1: string; - phone_2: string; - phone_3: string; - head_name: string; - owner_name: string; - location: string; - logo: string; - subscription_package: string; - subscription_status: SubscriptionStatus; - subscription_started_at: string; - subscription_expires_at: string; - expected_users: string; - expected_students: string; - expected_parents: string; - expected_teachers: string; - expected_admins: string; - expected_staff: string; -}; - -const PAGE_SIZE_OPTIONS = [10, 20, 50]; - -function createInitialSchoolForm(): SchoolCreateForm { - return { - name: "", - email: "", - phone_1: "", - phone_2: "", - phone_3: "", - head_name: "", - owner_name: "", - location: "", - logo: "", - subscription_package: "Seedling", - subscription_status: "trial", - subscription_started_at: new Date().toISOString().slice(0, 10), - subscription_expires_at: "", - expected_users: "50", - expected_students: "10", - expected_parents: "10", - expected_teachers: "10", - expected_admins: "10", - expected_staff: "10", - }; -} - -function formatDate(value: string | null) { - if (!value) return "—"; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return "—"; - return new Intl.DateTimeFormat("en-GB", { - day: "2-digit", - month: "short", - year: "numeric", - }).format(date); -} - -function getInitials(name: string) { - const parts = name.trim().split(/\s+/).filter(Boolean); - if (!parts.length) return "SC"; - return `${parts[0]?.[0] ?? ""}${parts[1]?.[0] ?? ""}`.toUpperCase(); -} - -function schoolStatusClasses(status: SchoolStatus) { - switch (status) { - case "active": - return "bg-green-50 text-green-700 border-green-200"; - case "suspended": - return "bg-red-50 text-red-700 border-red-200"; - case "pending": - return "bg-yellow-50 text-yellow-700 border-yellow-200"; - default: - return "bg-gray-100 text-gray-700 border-gray-200"; - } -} - -function subscriptionStatusClasses(status: SubscriptionStatus) { - switch (status) { - case "active": - return "bg-green-50 text-green-700 border-green-200"; - case "trial": - return "bg-blue-50 text-blue-700 border-blue-200"; - case "expired": - return "bg-orange-50 text-orange-700 border-orange-200"; - case "suspended": - return "bg-red-50 text-red-700 border-red-200"; - case "inactive": - return "bg-gray-100 text-gray-700 border-gray-200"; - default: - return "bg-gray-100 text-gray-700 border-gray-200"; - } -} - -function toPercent(actual: number, expected: number) { - if (!expected || expected <= 0) return 0; - return (actual / expected) * 100; -} - -function capacityTextClass(actual: number, expected: number) { - if (!expected || expected <= 0) return "text-gray-400"; - const pct = toPercent(actual, expected); - - if (pct <= 50) return "text-green-700"; - if (pct <= 70) return "text-gray-900"; - if (pct <= 85) return "text-yellow-600"; - if (pct <= 90) return "text-orange-600"; - return "text-red-600"; -} - -function capacityWarning(actual: number, expected: number) { - if (!expected || expected <= 0) return false; - return toPercent(actual, expected) > 90; -} - -function CapacityCell({ - actual, - expected, -}: { - actual: number; - expected: number; -}) { - const warn = capacityWarning(actual, expected); - const pct = Math.round(toPercent(actual, expected)); - - return ( -
- - {warn ? ( - - - - - - ) : null} - {actual} - -
- / {expected} · {pct}% -
-
- ); -} - -function EmptyState({ message }: { message: string }) { - return ( -
-
- - - - - -
-

No schools found

-

{message}

-
- ); -} - -function Pagination({ - page, - totalPages, - setPage, -}: { - page: number; - totalPages: number; - setPage: React.Dispatch>; -}) { - function renderPagination() { - if (totalPages <= 1) { - return ( -
- -
- ); - } - - const items: (number | "...")[] = []; - const maxButtons = 5; - - if (totalPages <= maxButtons) { - for (let i = 1; i <= totalPages; i += 1) items.push(i); - } else { - items.push(1); - - if (page > 3) items.push("..."); - - const start = Math.max(2, page - 1); - const end = Math.min(totalPages - 1, page + 1); - - for (let i = start; i <= end; i += 1) items.push(i); - - if (page < totalPages - 2) items.push("..."); - - items.push(totalPages); - } - - return ( -
- - - {items.map((item, index) => - item === "..." ? ( - - ... - - ) : ( - - ), - )} - - -
- ); - } - - return renderPagination(); -} +/** The create / edit drawer. `nonce` remounts the form so every open starts clean. */ +type FormState = { open: boolean; mode: FormMode; schoolId?: string; nonce: number }; export default function SchoolsPage() { const { showToast } = useToast(); - const { confirm } = useConfirmation(); + const me = useMe(); + const permissions = schoolPermissions(me.data?.role); - const [schools, setSchools] = useState([]); - const [loading, setLoading] = useState(true); - const [busyId, setBusyId] = useState(null); + const schoolsQuery = useSchools(); + useErrorToast(schoolsQuery.error, "Schools load failed", "Failed to fetch schools."); + const schools: SchoolRow[] = schoolsQuery.data?.data ?? []; const [viewMode, setViewMode] = useState("list"); - const [search, setSearch] = useState(""); - const [statusFilter, setStatusFilter] = useState("all"); - const [subscriptionFilter, setSubscriptionFilter] = useState("all"); - const [packageFilter, setPackageFilter] = useState("all"); - const [sortBy, setSortBy] = useState("name-asc"); + const [filters, setFilters] = useState(EMPTY_FILTERS); + const [sorting, setSorting] = useState(DEFAULT_SORTING); const [pageSize, setPageSize] = useState(10); - const [page, setPage] = useState(1); + const [capacityColumns, setCapacityColumns] = useState(false); - const [selectedSchool, setSelectedSchool] = useState(null); - const [detailsOpen, setDetailsOpen] = useState(false); - const [saving, setSaving] = useState(false); - const [createOpen, setCreateOpen] = useState(false); - const [createForm, setCreateForm] = useState( - createInitialSchoolForm(), - ); - const [creating, setCreating] = useState(false); - const [uploadingLogoTarget, setUploadingLogoTarget] = useState< - "create" | "edit" | null - >(null); + // Detail drawer: the id is kept after closing so the exit animation still shows the content. + const [detail, setDetail] = useState<{ id: string | null; open: boolean }>({ id: null, open: false }); + const [form, setForm] = useState(null); - const createLogoInputRef = useRef(null); - const editLogoInputRef = useRef(null); - - const showErrorToast = useCallback( - (title: string, description?: string) => { - showToast({ type: "error", title, description }); - }, - [showToast], - ); - - const showSuccessToast = useCallback( - (title: string, description?: string) => { - showToast({ type: "success", title, description }); - }, - [showToast], - ); + const actions = useSchoolActions({ + onDeleted: (id) => setDetail((current) => (current.id === id ? { ...current, open: false } : current)), + }); - const confirmAndRun = useCallback( - async ( - options: ConfirmationRequestOptions, - action: () => Promise | void, - ) => { - const accepted = await confirm(options); - if (!accepted) return; + const filtered = filterSchools(schools, filters); + const packages = Array.from(new Set(schools.map((school) => school.subscription_package))).sort(); - try { - await action(); - } catch (error) { - console.error(error); - showErrorToast( - "Action failed", - error instanceof Error ? error.message : "Something went wrong.", - ); - } - }, - [confirm, showErrorToast], - ); - - const getErrorMessage = useCallback( - async (res: Response, fallback: string) => { - try { - const data = await res.json(); - return data?.error || data?.message || fallback; - } catch { - return fallback; - } - }, - [], - ); - - const fetchSchools = useCallback(async () => { - setLoading(true); - try { - const res = await fetch("/api/school", { cache: "no-store" }); - if (!res.ok) { - throw new Error(await getErrorMessage(res, "Failed to fetch schools.")); - } - const data = await res.json(); - setSchools(data.data ?? []); - } catch (error) { - console.error(error); - showErrorToast( - "Schools load failed", - error instanceof Error ? error.message : "Failed to fetch schools.", - ); - } finally { - setLoading(false); - } - }, [getErrorMessage, showErrorToast]); - - useEffect(() => { - void fetchSchools(); - }, [fetchSchools]); - - const packageOptions = useMemo(() => { - return Array.from(new Set(schools.map((item) => item.subscription_package))).sort(); - }, [schools]); - - const filteredSchools = useMemo(() => { - const q = search.trim().toLowerCase(); - - let data = schools.filter((school) => { - const matchesSearch = - !q || - school.name.toLowerCase().includes(q) || - school.code.toLowerCase().includes(q) || - (school.email ?? "").toLowerCase().includes(q) || - (school.phone_1 ?? "").toLowerCase().includes(q) || - (school.head_name ?? "").toLowerCase().includes(q) || - (school.owner_name ?? "").toLowerCase().includes(q); - - const matchesStatus = - statusFilter === "all" || school.status === statusFilter; - - const matchesSubscription = - subscriptionFilter === "all" || - school.subscription_status === subscriptionFilter; - - const matchesPackage = - packageFilter === "all" || - school.subscription_package === packageFilter; - - return ( - matchesSearch && - matchesStatus && - matchesSubscription && - matchesPackage - ); - }); - - data = [...data].sort((a, b) => { - switch (sortBy) { - case "name-asc": - return a.name.localeCompare(b.name); - case "name-desc": - return b.name.localeCompare(a.name); - case "users-high": - return b.no_of_users - a.no_of_users; - case "users-low": - return a.no_of_users - b.no_of_users; - case "expiry-asc": - return (a.subscription_expires_at ?? "").localeCompare( - b.subscription_expires_at ?? "", - ); - case "expiry-desc": - return (b.subscription_expires_at ?? "").localeCompare( - a.subscription_expires_at ?? "", - ); - default: - return 0; - } - }); - - return data; - }, [schools, search, statusFilter, subscriptionFilter, packageFilter, sortBy]); - - const totalEntries = filteredSchools.length; - const totalPages = Math.max(1, Math.ceil(totalEntries / pageSize)); - - useEffect(() => { - setPage(1); - }, [search, statusFilter, subscriptionFilter, packageFilter, sortBy, pageSize]); - - useEffect(() => { - if (page > totalPages) setPage(totalPages); - }, [page, totalPages]); - - const paginatedSchools = useMemo(() => { - const start = (page - 1) * pageSize; - return filteredSchools.slice(start, start + pageSize); - }, [filteredSchools, page, pageSize]); - - const pageStart = totalEntries === 0 ? 0 : (page - 1) * pageSize + 1; - const pageEnd = Math.min(page * pageSize, totalEntries); - - function openCreatePanel() { - setCreateForm(createInitialSchoolForm()); - setCreateOpen(true); - setDetailsOpen(false); - setSelectedSchool(null); - } - - function closeCreatePanel() { - if (creating || uploadingLogoTarget === "create") return; - setCreateOpen(false); - } - - async function openDetails(id: string) { - if (!id) { - console.error("Missing school id:", id); - showErrorToast( - "Missing school id", - "This school record has no id. Fix the school_usage_overview view.", - ); - return; - } - - try { - const res = await fetch(`/api/school/${id}`, { cache: "no-store" }); - if (!res.ok) { - throw new Error( - await getErrorMessage(res, "Failed to fetch school details."), - ); - } - const data = await res.json(); - setSelectedSchool(data.data); - setDetailsOpen(true); - setCreateOpen(false); - } catch (error) { - console.error(error); - showErrorToast( - "Details load failed", - error instanceof Error ? error.message : "Failed to load school details.", - ); - } - } - - async function performDelete(id: string) { - setBusyId(id); - try { - const res = await fetch(`/api/school/${id}`, { - method: "DELETE", + function openDetails(school: SchoolRow) { + if (!school.id) { + showToast({ + type: "error", + title: "Missing school id", + description: "This school record has no id. Fix the school_usage_overview view.", }); - - if (!res.ok) { - throw new Error(await getErrorMessage(res, "Failed to delete school.")); - } - - setSchools((prev) => prev.filter((item) => item.id !== id)); - if (selectedSchool?.id === id) { - setSelectedSchool(null); - setDetailsOpen(false); - } - showSuccessToast("School deleted", "The school record was removed."); - } catch (error) { - console.error(error); - showErrorToast( - "Delete failed", - error instanceof Error ? error.message : "Failed to delete school.", - ); - } finally { - setBusyId(null); - } - } - - async function performSuspendActivate(school: SchoolRow) { - const action = school.status === "suspended" ? "activate" : "suspend"; - setBusyId(school.id); - - try { - const res = await fetch(`/api/school/${school.id}/actions`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action }), - }); - - if (!res.ok) { - throw new Error( - await getErrorMessage(res, "Failed to update school status."), - ); - } - await fetchSchools(); - showSuccessToast( - action === "suspend" ? "School suspended" : "School activated", - action === "suspend" - ? "The school status update has been queued." - : "The school is now active.", - ); - } catch (error) { - console.error(error); - showErrorToast( - "Status update failed", - error instanceof Error - ? error.message - : "Failed to update school status.", - ); - } finally { - setBusyId(null); - } - } - - async function performIncreaseUsers(id: string) { - setBusyId(id); - - try { - const res = await fetch(`/api/school/${id}/actions`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "increase_capacity_50" }), - }); - - if (!res.ok) { - throw new Error( - await getErrorMessage(res, "Failed to increase school capacity."), - ); - } - await fetchSchools(); - - if (selectedSchool?.id === id) { - await openDetails(id); - } - showSuccessToast( - "Capacity updated", - "The school capacity increased by 50 users.", - ); - } catch (error) { - console.error(error); - showErrorToast( - "Capacity update failed", - error instanceof Error ? error.message : "Failed to increase users.", - ); - } finally { - setBusyId(null); - } - } - - async function performRegenerateCode(id: string) { - setBusyId(id); - - try { - const res = await fetch(`/api/school/${id}/actions`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "regenerate_code" }), - }); - - const data = await res.json(); - - if (!res.ok) { - throw new Error( - data?.error || "Failed to queue school code regeneration", - ); - } - - await fetchSchools(); - - if (selectedSchool?.id === id) { - await openDetails(id); - } - showSuccessToast( - "Code regeneration queued", - data?.message || "The school code update was queued successfully.", - ); - } catch (error) { - console.error(error); - showErrorToast( - "Code regeneration failed", - error instanceof Error - ? error.message - : "Failed to queue code regeneration.", - ); - } finally { - setBusyId(null); + return; } + setDetail({ id: school.id, open: true }); } - async function handleDownloadSecurityCodes(id: string, name: string) { - setBusyId(id); - - try { - const res = await fetch(`/api/school/${id}/security-codes`); - if (!res.ok) { - throw new Error( - await getErrorMessage(res, "Failed to download security codes."), - ); - } - - const blob = await res.blob(); - const url = window.URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = `${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-security-codes.pdf`; - document.body.appendChild(link); - link.click(); - link.remove(); - window.URL.revokeObjectURL(url); - - showSuccessToast( - "PDF downloaded", - "Your school security codes PDF has been downloaded.", - ); - } catch (error) { - console.error(error); - showErrorToast( - "Download failed", - error instanceof Error - ? error.message - : "Failed to download security codes.", - ); - } finally { - setBusyId(null); - } + function openForm(mode: FormMode, schoolId?: string) { + setForm((current) => ({ open: true, mode, schoolId, nonce: (current?.nonce ?? 0) + 1 })); } - async function uploadLogo(file: File, target: "create" | "edit") { - setUploadingLogoTarget(target); - - try { - const formData = new FormData(); - formData.append("file", file); - - const res = await fetch("/api/school/logo", { - method: "POST", - body: formData, - }); - - if (!res.ok) { - throw new Error(await getErrorMessage(res, "Failed to upload logo.")); - } - - const data = await res.json(); - const logoUrl = String(data?.data?.url ?? ""); - - if (!logoUrl) { - throw new Error("Logo upload did not return a usable URL."); - } - - if (target === "create") { - setCreateForm((prev) => ({ ...prev, logo: logoUrl })); - } else { - setSelectedSchool((prev) => (prev ? { ...prev, logo: logoUrl } : prev)); - } - - showSuccessToast("Logo uploaded", "School logo uploaded successfully."); - } catch (error) { - console.error(error); - showErrorToast( - "Logo upload failed", - error instanceof Error ? error.message : "Failed to upload logo.", - ); - } finally { - setUploadingLogoTarget(null); - - if (target === "create" && createLogoInputRef.current) { - createLogoInputRef.current.value = ""; - } - - if (target === "edit" && editLogoInputRef.current) { - editLogoInputRef.current.value = ""; - } - } + function openEdit(school: SchoolRow) { + if (!school.id) return openDetails(school); // shows the "missing id" toast + openForm("edit", school.id); } - async function performCreateSchool() { - setCreating(true); - - try { - if (!createForm.name.trim() || !createForm.email.trim() || !createForm.phone_1.trim()) { - throw new Error("School name, school email, and primary phone are required."); - } - - const res = await fetch("/api/school", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - ...createForm, - expected_users: Number(createForm.expected_users || 0), - expected_students: Number(createForm.expected_students || 0), - expected_parents: Number(createForm.expected_parents || 0), - expected_teachers: Number(createForm.expected_teachers || 0), - expected_admins: Number(createForm.expected_admins || 0), - expected_staff: Number(createForm.expected_staff || 0), - }), - }); - - const data = await res.json(); - - if (!res.ok) { - throw new Error(data?.error || "Failed to create school."); - } - - await fetchSchools(); - setCreateOpen(false); - setCreateForm(createInitialSchoolForm()); - - showSuccessToast( - "School created", - "The school was created and the confirmation email was sent.", - ); - - const shouldDownloadCodes = await confirm({ - title: "School created successfully", - message: `${data?.data?.name ?? "The school"} was created with pending status. A confirmation email was sent to ${data?.data?.email ?? "the school email"}. Download the security codes PDF and keep it safely.`, - confirmLabel: "Download PDF", - cancelLabel: "Close", - tone: "success", - variant: "success", - }); - - if (shouldDownloadCodes) { - await handleDownloadSecurityCodes( - String(data?.data?.id ?? ""), - String(data?.data?.name ?? "school"), - ); - } - } catch (error) { - console.error(error); - showErrorToast( - "Create school failed", - error instanceof Error ? error.message : "Failed to create school.", - ); - } finally { - setCreating(false); - } + function openCreate() { + setDetail((current) => ({ ...current, open: false })); + openForm("create"); } - async function performSaveDetails() { - if (!selectedSchool) return; - - setSaving(true); - try { - const res = await fetch(`/api/school/${selectedSchool.id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: selectedSchool.name, - head_name: selectedSchool.head_name, - owner_name: selectedSchool.owner_name, - email: selectedSchool.email, - phone_1: selectedSchool.phone_1, - phone_2: selectedSchool.phone_2, - phone_3: selectedSchool.phone_3, - location: selectedSchool.location, - logo: selectedSchool.logo, - status: selectedSchool.status, - subscription_package: selectedSchool.subscription_package, - subscription_status: selectedSchool.subscription_status, - subscription_started_at: selectedSchool.subscription_started_at, - subscription_expires_at: selectedSchool.subscription_expires_at, - }), - }); + const noAccess = schoolsQuery.error instanceof HttpError && schoolsQuery.error.status === 403; + + const emptyState = hasActiveFilters(filters) ? ( + setFilters(EMPTY_FILTERS)}> + Clear filters + + } + /> + ) : ( + + Add school + + ) : undefined + } + /> + ); - if (!res.ok) { - throw new Error(await getErrorMessage(res, "Failed to update school.")); - } - await fetchSchools(); - await openDetails(selectedSchool.id); - showSuccessToast( - "School updated", - "The school changes were saved successfully.", - ); - } catch (error) { - console.error(error); - showErrorToast( - "Save failed", - error instanceof Error ? error.message : "Failed to save school details.", - ); - } finally { - setSaving(false); - } - } + // Filters remount the list / grid so paging starts from page 1 again. + const resetKey = JSON.stringify(filters); return ( -
-
-
-
-

- Schools -

-

- School portfolio, subscription health, capacity utilization, and high-risk actions. -

-
- -
-
- - - -
- - -
-
- -
-
-
- -
- setSearch(e.target.value)} - placeholder="Search school, head, owner, code, email or phone" - className="w-full bg-transparent px-3 py-3 text-sm text-gray-700 outline-none" - /> -
-
- -
- - -
- -
- - -
- -
- - -
- -
- - -
-
-
-
- -
- {loading ? ( -
-
-

Loading schools...

-
- ) : totalEntries === 0 ? ( - - ) : viewMode === "list" ? ( -
-
- - - - - - - - - - - - - - - - - - - - - - - - {paginatedSchools.map((school) => ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - ))} - -
SchoolHead / OwnerEmailPhonePackageUsersStudentsParentsTeachersAdminsStaffSchool CodeSchool StatusSub StatusExpiryAction
-
- {school.logo ? ( - {school.name} - ) : ( -
- {getInitials(school.name)} -
- )} -
- -
- #{school.school_id} -
-
-
-
-
-
{school.head_name || "—"}
-
{school.owner_name || "—"}
-
-
{school.email || "—"}{school.phone_1 || "—"} -
- {school.subscription_package} -
-
- {formatDate(school.subscription_started_at)} -
-
- - - - - - - - - - - - -
{school.code}
-
- Changes: {school.code_change_count}/3 -
-
- - {school.status} - - {school.pending_status_change_at ? ( -
- queued {formatDate(school.pending_status_change_at)} -
- ) : null} -
- - {school.subscription_status} - - -
- {formatDate(school.subscription_expires_at)} -
- {school.pending_code_change_at ? ( -
- code queued {formatDate(school.pending_code_change_at)} -
- ) : null} -
-
- - - - - - - - - - - -
-
-
-
- ) : ( -
- {paginatedSchools.map((school) => ( -
-
-
- {school.logo ? ( - {school.name} - ) : ( -
- {getInitials(school.name)} -
- )} - -
- -

{school.code}

-
-
- - - {school.status} - -
- -
-
-

Head

-

{school.head_name || "—"}

-
- -
-

Owner

-

{school.owner_name || "—"}

-
- -
-

Phone

-

{school.phone_1 || "—"}

-
- -
-

Email

-

{school.email || "—"}

-
- -
-

Package

-

{school.subscription_package}

-
- -
-

Expiry

-

{formatDate(school.subscription_expires_at)}

-
- -
-

Users

- -
- -
-

Students

- -
- -
-

Parents

- -
- -
-

Teachers

- -
- -
-

Admins

- -
- -
-

Staff

- -
-
- -
- - - - - - - - - - - -
-
- ))} -
- )} - -
-
- Showing {pageStart} to{" "} - {pageEnd} of{" "} - {totalEntries} entries -
- -
- - - -
+
+
+

+ School portfolio, subscription health, capacity utilisation and high-risk actions. +

+
+ setViewMode(id as ViewMode)} + items={[ + { id: "list", label: "List", icon: "solar:list-linear" }, + { id: "grid", label: "Grid", icon: "solar:widget-2-linear" }, + ]} + /> + {permissions.canWrite ? ( + + ) : null}
- {detailsOpen && selectedSchool ? ( -
- -
-
- -
-
- {selectedSchool.logo ? ( - {selectedSchool.name} - ) : ( -
- {getInitials(selectedSchool.name)} -
- )} - -
-
{selectedSchool.name}
-
- Code: {selectedSchool.code} -
-
-
- -
-
- - setSelectedSchool((prev) => prev ? { ...prev, name: e.target.value } : prev)} - className="w-full rounded-[16px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm outline-none" - /> -
- -
- - setSelectedSchool((prev) => prev ? { ...prev, head_name: e.target.value } : prev)} - className="w-full rounded-[16px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm outline-none" - /> -
- -
- - setSelectedSchool((prev) => prev ? { ...prev, owner_name: e.target.value } : prev)} - className="w-full rounded-[16px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm outline-none" - /> -
- -
- - setSelectedSchool((prev) => prev ? { ...prev, email: e.target.value } : prev)} - className="w-full rounded-[16px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm outline-none" - /> -
- -
- - setSelectedSchool((prev) => prev ? { ...prev, phone_1: e.target.value } : prev)} - className="w-full rounded-[16px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm outline-none" - /> -
- -
- -
- setSelectedSchool((prev) => prev ? { ...prev, logo: e.target.value } : prev)} - className="flex-1 rounded-[16px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm outline-none" - /> - - { - const file = e.target.files?.[0]; - if (file) { - void uploadLogo(file, "edit"); - } - }} - /> -
-
- -
- - setSelectedSchool((prev) => prev ? { ...prev, phone_2: e.target.value } : prev)} - className="w-full rounded-[16px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm outline-none" - /> -
- -
- - setSelectedSchool((prev) => prev ? { ...prev, phone_3: e.target.value } : prev)} - className="w-full rounded-[16px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm outline-none" - /> -
- -
- - setSelectedSchool((prev) => prev ? { ...prev, location: e.target.value } : prev)} - className="w-full rounded-[16px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm outline-none" - /> -
- -
- - -
- -
- - -
- -
- - -
- -
- - setSelectedSchool((prev) => prev ? { ...prev, subscription_started_at: e.target.value || null } : prev)} - className="w-full rounded-[16px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm outline-none" - /> -
- -
- - setSelectedSchool((prev) => prev ? { ...prev, subscription_expires_at: e.target.value || null } : prev)} - className="w-full rounded-[16px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm outline-none" - /> -
-
- -
-
-

Users

- -
-
-

Students

- -
-
-

Parents

- -
-
-

Teachers

- -
-
-

Admins

- -
-
-

Staff

- -
-
- -
- - - - - - - -
- -
-
Code changes used: {selectedSchool.code_change_count}/3
-
Pending code change: {formatDate(selectedSchool.pending_code_change_at)}
-
Pending status change: {formatDate(selectedSchool.pending_status_change_at)}
-
-
-
- ) : null} - - {createOpen ? ( -
- -
-
- -
-
-
-
-

Pending until email confirmation

-

- New schools stay in pending status until the school email confirms the verification link. -

-
- - Pending - -
-
- -
-
-
- {createForm.logo ? ( - School logo preview - ) : ( -
- {getInitials(createForm.name || "School")} -
- )} -
- -
- - { - const file = e.target.files?.[0]; - if (file) { - void uploadLogo(file, "create"); - } - }} - /> -
- - setCreateForm((prev) => ({ ...prev, logo: e.target.value }))} - placeholder="Uploaded logo URL will appear here" - className="mt-4 w-full rounded-[16px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm outline-none" - /> -
- -
-
- - setCreateForm((prev) => ({ ...prev, name: e.target.value }))} - className="w-full rounded-[16px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm outline-none" - /> -
-
- - setCreateForm((prev) => ({ ...prev, email: e.target.value }))} - className="w-full rounded-[16px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm outline-none" - /> -
- -
- - setCreateForm((prev) => ({ ...prev, phone_1: e.target.value }))} - className="w-full rounded-[16px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm outline-none" - /> -
- -
- - setCreateForm((prev) => ({ ...prev, phone_2: e.target.value }))} - className="w-full rounded-[16px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm outline-none" - /> -
- -
- - setCreateForm((prev) => ({ ...prev, phone_3: e.target.value }))} - className="w-full rounded-[16px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm outline-none" - /> -
- -
- - setCreateForm((prev) => ({ ...prev, head_name: e.target.value }))} - className="w-full rounded-[16px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm outline-none" - /> -
- -
- - setCreateForm((prev) => ({ ...prev, owner_name: e.target.value }))} - className="w-full rounded-[16px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm outline-none" - /> -
- -
- - setCreateForm((prev) => ({ ...prev, location: e.target.value }))} - className="w-full rounded-[16px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm outline-none" - /> -
- -
- - -
- -
- - -
- -
- - setCreateForm((prev) => ({ ...prev, subscription_started_at: e.target.value }))} - className="w-full rounded-[16px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm outline-none" - /> -
- -
- - setCreateForm((prev) => ({ ...prev, subscription_expires_at: e.target.value }))} - className="w-full rounded-[16px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm outline-none" - /> -
-
-
- -
-

Capacity Setup

-

- Define the expected capacity values for this school at creation. -

- -
- {[ - ["Expected Users", "expected_users"], - ["Students", "expected_students"], - ["Parents", "expected_parents"], - ["Teachers", "expected_teachers"], - ["Admins", "expected_admins"], - ["Staff", "expected_staff"], - ].map(([label, key]) => ( -
- - - setCreateForm((prev) => ({ - ...prev, - [key]: e.target.value, - })) - } - className="w-full rounded-[16px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm outline-none" - /> -
- ))} -
-
- -
- - - -
+ {schoolsQuery.isError ? ( +
+ void schoolsQuery.refetch()}> + Try again + + } + />
-
-
+ ) : viewMode === "list" ? ( + + ) : ( + + )} + + )} + + setDetail((current) => ({ ...current, open: false }))} + onEdit={(id) => openForm("edit", id)} + permissions={permissions} + actions={actions} + /> + + {form ? ( + setForm((current) => (current ? { ...current, open: false } : current))} + permissions={permissions} + actions={actions} + /> ) : null} -

); } diff --git a/stackable-app/app/dashboard/students/[id]/page.tsx b/stackable-app/app/dashboard/students/[id]/page.tsx index a1f09d8..a168981 100644 --- a/stackable-app/app/dashboard/students/[id]/page.tsx +++ b/stackable-app/app/dashboard/students/[id]/page.tsx @@ -1,165 +1,193 @@ -import Image from "next/image"; -import Link from "next/link"; -import { createClient } from "@supabase/supabase-js"; -import { notFound } from "next/navigation"; - -const supabase = createClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, -); - -function formatDate(value: string | null) { - if (!value) return "—"; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return "—"; - return new Intl.DateTimeFormat("en-GB", { - day: "2-digit", - month: "long", - year: "numeric", - }).format(date); +"use client"; + +/** + * Student profile (/dashboard/students/[id]) - full record for one student: + * identity, class placement, contacts, welfare notes, guardians, subjects, + * recent grades and attendance. + * + * Converted from a server component with a direct Supabase query to a client + * page on GET /api/students/[id] via useStudent(id) (hooks/useStudents.ts, + * already built - Prisma/Neon backed). Guardians, subjects, recent grades and + * attendance are new here: the old page only showed the student row plus one + * average-grade lookup. + */ +import { useParams } from "next/navigation"; +import { Avatar, Badge, Button, DataTable, EmptyState, Skeleton } from "@/components/ui"; +import { useStudent, type StudentGrade } from "@/hooks/useStudents"; +import { StudentStatusBadge } from "@/components/admin/students/StatusBadge"; +import { formatDate } from "@/components/admin/students/utils"; + +function Fact({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value || "—"}

+
+ ); } -function getStatusClasses(status: string) { - switch (status) { - case "active": - return "bg-green-50 text-green-700 border-green-200"; - case "suspended": - return "bg-red-50 text-red-700 border-red-200"; - case "pending": - return "bg-yellow-50 text-yellow-700 border-yellow-200"; - case "graduated": - return "bg-blue-50 text-blue-700 border-blue-200"; - default: - return "bg-gray-100 text-gray-700 border-gray-200"; +export default function StudentProfilePage() { + const params = useParams(); + const id = params?.id as string; + const { data, isLoading, isError, error, refetch } = useStudent(id); + + if (isLoading) { + return ( +
+ +
+ + +
+
+ ); } -} - -function scoreToGrade(score: number | null | undefined) { - if (score == null) return "—"; - if (score >= 11.5) return "A"; - if (score >= 10.5) return "B+"; - if (score >= 9.5) return "B"; - if (score >= 8.5) return "B-"; - if (score >= 7.5) return "C+"; - if (score >= 6.5) return "C"; - if (score >= 5.5) return "C-"; - if (score >= 4.5) return "D+"; - if (score >= 3.5) return "D"; - if (score >= 2.5) return "D-"; - if (score >= 1.5) return "E"; - return "F"; -} - -export default async function StudentDetailPage({ - params, -}: { - params: Promise<{ id: string }>; -}) { - const { id } = await params; - - const { data, error } = await supabase - .from("students") - .select(` - *, - users!students_user_fk ( - first_name, - last_name, - email, - phone - ) - `) - .eq("id", id) - .single(); - - if (error || !data) notFound(); - - const { data: averageGradeRow } = await supabase - .from("student_average_grade") - .select("avg_score") - .eq("student_id", id) - .single(); - - const averageGrade = scoreToGrade(averageGradeRow?.avg_score); + if (isError || !data) { + return ( +
+ void refetch()}> + Try again + + } + /> +
+ ); + } - const fullName = - `${data.first_name ?? ""} ${data.last_name ?? ""}`.trim() || - "Unnamed student"; + const { student, average, guardians, subjects, recent_grades, attendance } = data; return ( -
-
-
-
- - ← Back to students - -

{fullName}

-

- Full student profile and operational details. -

-
- - - {data.status} - -
+
+
+ + +
-
-
+
+
+
- {data.profile_picture ? ( - {fullName} - ) : ( -
- {fullName.slice(0, 2).toUpperCase()} + +

{student.full_name}

+

{student.admission_no}

+
+
+

Average grade

+

{average.grade ?? "—"}

- )} - -

{fullName}

-

{data.admission_no}

+
+

Attendance

+

{attendance.rate}%

+
+
-
- {[ - ["School", data.school_name || "—"], - ["Class", data.class_id || "Unassigned"], - ["Date of Birth", formatDate(data.date_of_birth)], - ["Parent Contact 1", data.phone || "—"], - ["Parent Contact 2", data.phone2 || "—"], - ["Email", data.email || data.users?.email || "—"], - ["Location", data.location || "—"], - ["Home Address", data.home_address || "—"], - ["Emergency Contact", data.emergency_contact || "—"], - ["Health Status", data.health_status || "—"], - ["Average Grade", averageGrade || "—"], - ["Other Info", data.other_info || "—"], - ].map(([label, value]) => ( -
-

{label}

-

{value}

-
- ))} +
+

Guardians

+ {guardians.length === 0 ? ( +

No guardians linked yet.

+ ) : ( +
    + {guardians.map((guardian) => ( +
  • +
    +

    {guardian.name}

    + {guardian.is_primary ? Primary : null} +
    +

    {guardian.relationship}

    +

    {guardian.phone || guardian.email || "No contact on file"}

    +
  • + ))} +
+ )}
+ +
+
+

School details

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

Contact & welfare

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

Subjects

+ {subjects.length === 0 ? ( +

No subjects assigned yet.

+ ) : ( +
+ {subjects.map((subject) => ( + + {subject.subject_name} + {subject.teacher_name ? ` · ${subject.teacher_name}` : ""} + + ))} +
+ )} +
+ +
+
+

Recent grades

+
+ + caption="Recent grades" + searchable={false} + getRowId={(g) => `${g.subject_id}-${g.term}-${g.created_at}`} + columns={[ + { accessorKey: "subject_name", header: "Subject" }, + { accessorKey: "term", header: "Term" }, + { accessorKey: "grade", header: "Grade", meta: { className: "font-semibold text-ink" } }, + { + id: "score", + header: "Score", + accessorFn: (g) => g.normalized_pct ?? g.raw_score ?? null, + cell: (c) => (c.getValue() != null ? `${c.getValue()}%` : "—"), + }, + { + id: "date", + header: "Recorded", + accessorFn: (g) => g.created_at ?? "", + cell: (c) => formatDate(c.getValue() || null), + }, + ]} + data={recent_grades} + emptyState={ + + } + /> +
+
); -} \ No newline at end of file +} diff --git a/stackable-app/app/dashboard/students/page.tsx b/stackable-app/app/dashboard/students/page.tsx index 079ff60..3c10b8d 100644 --- a/stackable-app/app/dashboard/students/page.tsx +++ b/stackable-app/app/dashboard/students/page.tsx @@ -1,1044 +1,161 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import Link from "next/link"; -import Image from "next/image"; -import { createClient } from "@supabase/supabase-js"; -import { useConfirmation } from "@/components/confirmation/ConfirmationProvider"; -import { useToast } from "@/components/toast/ToastProvider"; - -const supabase = createClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, -); - -type StudentRow = { - id: string; - user_id: string; - school_id: string; - school_name: string; - admission_no: string; - class_id: string | null; - date_of_birth: string | null; - phone: string | null; - phone2: string | null; - profile_picture: string | null; - status: "active" | "suspended" | "pending" | "removed" | "graduated"; - created_at: string; - first_name: string | null; - last_name: string | null; -}; - -type SchoolOption = { - id: string; - name: string; -}; - -type Classes = { - id: string; - class_name: string; - stream: string | null; -}; - -const PAGE_SIZE_OPTIONS = [10, 20, 50]; - -function formatDate(value: string | null) { - if (!value) return "—"; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return "—"; - return new Intl.DateTimeFormat("en-GB", { - day: "2-digit", - month: "short", - year: "numeric", - }).format(date); -} - -function getStudentName(student: StudentRow) { - const first = student.first_name?.trim() ?? ""; - const last = student.last_name?.trim() ?? ""; - const full = `${first} ${last}`.trim(); - return full || "Unnamed student"; -} - -function formatClassName(classItem: Classes | null | undefined) { - if (!classItem) return "—"; - return classItem.stream - ? `${classItem.class_name} ${classItem.stream}` - : classItem.class_name; -} - -function getInitials(student: StudentRow) { - const name = getStudentName(student); - const parts = name.split(" ").filter(Boolean); - if (!parts.length) return "ST"; - return `${parts[0][0] ?? ""}${parts[1]?.[0] ?? ""}`.toUpperCase(); -} - -function formatClassLabel(classId: string | null) { - if (!classId) return "Unassigned"; - // TODO: Replace this with a real join to your classes table. - return classId.length > 14 ? `Class • ${classId.slice(0, 8)}` : classId; -} - -function statusClasses(status: StudentRow["status"]) { - switch (status) { - case "active": - return "bg-green-50 text-green-700 border-green-200"; - case "suspended": - return "bg-red-50 text-red-700 border-red-200"; - case "pending": - return "bg-yellow-50 text-yellow-700 border-yellow-200"; - case "graduated": - return "bg-blue-50 text-blue-700 border-blue-200"; - case "removed": - return "bg-gray-100 text-gray-700 border-gray-200"; - default: - return "bg-gray-100 text-gray-700 border-gray-200"; - } -} - -function EmptyState({ message }: { message: string }) { - return ( -
-
- - - - -
-

No students found

-

{message}

-
- ); -} +/** + * Students (/dashboard/students) - the school's student register: search / + * filter, list or grid view, quick suspend / activate, edit and delete. + * + * This page only orchestrates; the pieces live in components/admin/students/** + * and the data layer in hooks/useStudents.ts (TanStack Query, Prisma-backed - + * the direct Supabase browser client this page used to import is gone). + * + * The dashboard navbar already renders the page

, so this header has none. + */ +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import type { SortingState } from "@tanstack/react-table"; +import { Button, EmptyState, Tabs } from "@/components/ui"; +import { useStudents, type StudentListItem } from "@/hooks/useStudents"; +import { StudentDrawer } from "@/components/admin/students/StudentDrawer"; +import { StudentFilters } from "@/components/admin/students/StudentFilters"; +import { StudentsGrid } from "@/components/admin/students/StudentsGrid"; +import { StudentsTable } from "@/components/admin/students/StudentsTable"; +import { useStudentActions } from "@/components/admin/students/useStudentActions"; +import { EMPTY_FILTERS, filterStudents, hasActiveFilters, type StudentFilterState } from "@/components/admin/students/utils"; + +type ViewMode = "list" | "grid"; + +const DEFAULT_SORTING: SortingState = [{ id: "student", desc: false }]; export default function StudentsPage() { - const { confirm } = useConfirmation(); - const { showToast } = useToast(); - const [students, setStudents] = useState([]); - const [schools, setSchools] = useState([]); - const [loading, setLoading] = useState(true); - const [busyId, setBusyId] = useState(null); - const [classes, setClasses] = useState([]); - - const [viewMode, setViewMode] = useState<"list" | "grid">("list"); - const [search, setSearch] = useState(""); - const [schoolFilter, setSchoolFilter] = useState("all"); - const [classFilter, setClassFilter] = useState("all"); - const [statusFilter, setStatusFilter] = useState("all"); - const [sortBy, setSortBy] = useState("name-asc"); - const [pageSize, setPageSize] = useState(10); - const [page, setPage] = useState(1); - - const showErrorToast = useCallback( - (title: string, description?: string) => { - showToast({ type: "error", title, description }); - }, - [showToast], - ); - - const showSuccessToast = useCallback( - (title: string, description?: string) => { - showToast({ type: "success", title, description }); - }, - [showToast], - ); - - const fetchPageData = useCallback(async () => { - setLoading(true); - - const [studentsRes, schoolsRes, classesRes] = await Promise.all([ - supabase - .from("students") - .select(` - id, - user_id, - school_id, - school_name, - admission_no, - class_id, - date_of_birth, - phone, - phone2, - profile_picture, - status, - created_at, - first_name, - last_name - `) - .order("created_at", { ascending: false }), - supabase.from("schools").select("id, name").order("name", { ascending: true }), - supabase - .from("classes") - .select("id, class_name, stream"), - ]); - - if (studentsRes.error) { - console.error("Students fetch failed:", studentsRes.error); - } else { - setStudents((studentsRes.data as StudentRow[]) ?? []); - } - - if (classesRes.error) { - console.error("Classes fetch failed:", classesRes.error); - } else { - setClasses((classesRes.data as Classes[]) ?? []); - } - - if (schoolsRes.error) { - console.error("Schools fetch failed:", schoolsRes.error); - } else { - setSchools((schoolsRes.data as SchoolOption[]) ?? []); - } - - setLoading(false); - }, []); - - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - void fetchPageData(); - }, [fetchPageData]); - - const classOptions = useMemo(() => { - const unique = Array.from( - new Set(students.map((item) => item.class_id).filter(Boolean) as string[]), - ); - return unique.sort((a, b) => a.localeCompare(b)); - }, [students]); - - const filteredStudents = useMemo(() => { - const q = search.trim().toLowerCase(); - - let data = students.filter((student) => { - const studentName = getStudentName(student).toLowerCase(); - const admission = student.admission_no.toLowerCase(); - const school = (student.school_name ?? "").toLowerCase(); - const phoneA = (student.phone ?? "").toLowerCase(); - const phoneB = (student.phone2 ?? "").toLowerCase(); - const classText = (student.class_id ?? "").toLowerCase(); - - const matchesSearch = - !q || - studentName.includes(q) || - admission.includes(q) || - school.includes(q) || - phoneA.includes(q) || - phoneB.includes(q) || - classText.includes(q); - - const matchesSchool = - schoolFilter === "all" || student.school_id === schoolFilter; - - const matchesClass = - classFilter === "all" || student.class_id === classFilter; - - const matchesStatus = - statusFilter === "all" || student.status === statusFilter; - - return matchesSearch && matchesSchool && matchesClass && matchesStatus; - }); - - data = [...data].sort((a, b) => { - switch (sortBy) { - case "name-asc": - return getStudentName(a).localeCompare(getStudentName(b)); - case "name-desc": - return getStudentName(b).localeCompare(getStudentName(a)); - case "admission-asc": - return a.admission_no.localeCompare(b.admission_no); - case "admission-desc": - return b.admission_no.localeCompare(a.admission_no); - case "dob-asc": - return (a.date_of_birth ?? "").localeCompare(b.date_of_birth ?? ""); - case "dob-desc": - return (b.date_of_birth ?? "").localeCompare(a.date_of_birth ?? ""); - case "newest": - return b.created_at.localeCompare(a.created_at); - case "oldest": - return a.created_at.localeCompare(b.created_at); - default: - return 0; - } - }); - - return data; - }, [students, search, schoolFilter, classFilter, statusFilter, sortBy]); - - const totalEntries = filteredStudents.length; - const totalPages = Math.max(1, Math.ceil(totalEntries / pageSize)); - - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - setPage(1); - }, [search, schoolFilter, classFilter, statusFilter, sortBy, pageSize]); - - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - if (page > totalPages) setPage(totalPages); - }, [page, totalPages]); - - const paginatedStudents = useMemo(() => { - const start = (page - 1) * pageSize; - return filteredStudents.slice(start, start + pageSize); - }, [filteredStudents, page, pageSize]); - - const classMap = useMemo( - () => new Map(classes.map((item) => [item.id, item])), - [classes], - ); - - const pageStart = totalEntries === 0 ? 0 : (page - 1) * pageSize + 1; - const pageEnd = Math.min(page * pageSize, totalEntries); + const router = useRouter(); + const studentsQuery = useStudents(); + const actions = useStudentActions(); - async function handleFreeze(student: StudentRow) { - const nextStatus = student.status === "suspended" ? "active" : "suspended"; - setBusyId(student.id); + const students: StudentListItem[] = studentsQuery.data?.students ?? []; + const classes = studentsQuery.data?.classes ?? []; - const { error } = await supabase - .from("students") - .update({ status: nextStatus }) - .eq("id", student.id); + const [viewMode, setViewMode] = useState("list"); + const [filters, setFilters] = useState(EMPTY_FILTERS); + const [sorting, setSorting] = useState(DEFAULT_SORTING); + const [pageSize, setPageSize] = useState(10); + const [edit, setEdit] = useState<{ student: StudentListItem | null; open: boolean; n: number }>({ + student: null, + open: false, + n: 0, + }); - if (error) { - console.error("Freeze toggle failed:", error); - showErrorToast( - "Status update failed", - `Could not update ${getStudentName(student)}.`, - ); - } else { - setStudents((prev) => - prev.map((item) => - item.id === student.id ? { ...item, status: nextStatus } : item, - ), - ); - showSuccessToast( - nextStatus === "suspended" ? "Student suspended" : "Student activated", - `${getStudentName(student)} is now ${nextStatus}.`, - ); - } + const filtered = filterStudents(students, filters, classes); - setBusyId(null); + function openProfile(student: StudentListItem) { + router.push(`/dashboard/students/${student.id}`); } - async function handleDelete(student: StudentRow) { - const ok = await confirm({ - title: "Delete student?", - message: `Delete ${getStudentName(student)}? This action cannot be undone.`, - confirmLabel: "Delete student", - cancelLabel: "Cancel", - tone: "danger", - }); - - if (!ok) return; - - setBusyId(student.id); - - const { error } = await supabase.from("students").delete().eq("id", student.id); - - if (error) { - console.error("Delete failed:", error); - showErrorToast( - "Delete failed", - `Could not delete ${getStudentName(student)}.`, - ); - } else { - setStudents((prev) => prev.filter((item) => item.id !== student.id)); - showSuccessToast( - "Student deleted", - `${getStudentName(student)} was removed successfully.`, - ); - } - - setBusyId(null); + function openEdit(student: StudentListItem) { + setEdit((current) => ({ student, open: true, n: current.n + 1 })); } - function renderPagination() { - if (totalPages <= 1) { - return ( -
- -
- ); - } - - const items: (number | "...")[] = []; - const maxButtons = 5; - - if (totalPages <= maxButtons) { - for (let i = 1; i <= totalPages; i += 1) items.push(i); - } else { - items.push(1); - - if (page > 3) items.push("..."); - - const start = Math.max(2, page - 1); - const end = Math.min(totalPages - 1, page + 1); - - for (let i = start; i <= end; i += 1) { - items.push(i); + const emptyState = hasActiveFilters(filters) ? ( + setFilters(EMPTY_FILTERS)}> + Clear filters + } + /> + ) : ( + + ); - if (page < totalPages - 2) items.push("..."); - - items.push(totalPages); - } - - return ( -
- - - {items.map((item, index) => - item === "..." ? ( - - ... - - ) : ( - - ), - )} + const errorState = ( + void studentsQuery.refetch()}> + Try again + + } + /> + ); - -
- ); - } + // Filters remount the list / grid so paging starts from page 1 again. + const resetKey = JSON.stringify(filters); return ( -
-
-
-
-

- Students -

-

- Student register, quick actions, filtering, and direct access to - each student detail page. -

-
- -
-
- - - -
- - - - - - Add Student - -
-
- -
-
-
- -
- - - - - setSearch(e.target.value)} - placeholder="Search name, admission, school or contact" - className="w-full bg-transparent px-3 py-3 text-sm text-gray-700 outline-none" - /> -
-
- -
- - -
- -
- - -
- -
- - -
- -
- - -
-
-
-
- -
- - {loading ? ( -
-
-

Loading students...

-
- ) : totalEntries === 0 ? ( - - ) : viewMode === "list" ? ( -
-
- - - - - - - - - - - - - - - - {paginatedStudents.map((student) => ( - - - - - - - - - - - - - - - - - - ))} - -
StudentAdmissionSchoolClassParent ContactStatusDate of BirthAction
-
- {student.profile_picture ? ( - {getStudentName(student)} - ) : ( -
- {getInitials(student)} -
- )} -
- - {getStudentName(student)} - -
-
-
- {student.admission_no} - {student.school_name}{formatClassName( - student.class_id ? classMap.get(student.class_id) : null, - )} -
-
- {student.phone || "—"} -
-
- {student.phone2 || "—"} -
-
-
- - {student.status} - - {formatDate(student.date_of_birth)} -
- - - - - - - - - - - - - - - - - -
-
-
-
- ) : ( -
- {paginatedStudents.map((student) => ( -
-
-
- {student.profile_picture ? ( - {getStudentName(student)} - ) : ( -
- {getInitials(student)} -
- )} - -
- - {getStudentName(student)} - -

{student.admission_no}

-
-
- - - {student.status} - -
- -
-
-

School

-

{student.school_name}

-
- -
-

Class

-

- {formatClassName( - student.class_id ? classMap.get(student.class_id) : null, - )} -

-
- -
-

- Parent Contact 1 -

-

{student.phone || "—"}

-
- -
-

- Parent Contact 2 -

-

{student.phone2 || "—"}

-
- -
-

- Date of Birth -

-

- {formatDate(student.date_of_birth)} -

-
-
- -
- - - - - - - - - - - - - - - - - -
-
- ))} -
- )} - -
-
- Showing {pageStart} to{" "} - {pageEnd} of{" "} - {totalEntries} entries -
- -
- +
+
+

+ Student register, quick actions, filtering and full profiles for every learner at your school. +

+ setViewMode(id as ViewMode)} + items={[ + { id: "list", label: "List", icon: "solar:list-linear" }, + { id: "grid", label: "Grid", icon: "solar:widget-2-linear" }, + ]} + /> +
- {renderPagination()} -
-
-
+ + + {studentsQuery.isError ? ( +
{errorState}
+ ) : viewMode === "list" ? ( + void actions.toggleStatus(student)} + onDelete={(student) => void actions.deleteStudent(student)} + /> + ) : ( + void actions.toggleStatus(student)} + onDelete={(student) => void actions.deleteStudent(student)} + emptyState={emptyState} + /> + )} + + {edit.student ? ( + setEdit((current) => ({ ...current, open: false }))} + classes={classes} + actions={actions} + /> + ) : null}
); } diff --git a/stackable-app/app/dashboard/teachers/[id]/edit/page.tsx b/stackable-app/app/dashboard/teachers/[id]/edit/page.tsx index d8d4997..b456b56 100644 --- a/stackable-app/app/dashboard/teachers/[id]/edit/page.tsx +++ b/stackable-app/app/dashboard/teachers/[id]/edit/page.tsx @@ -1,1370 +1,374 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; -import Image from "next/image"; -import Link from "next/link"; -import { useParams, useRouter, useSearchParams } from "next/navigation"; -import { createClient } from "@supabase/supabase-js"; +/** + * Edit teacher (/dashboard/teachers/[id]/edit) - profile fields, subjects, + * class-teacher ownership and the weekly timetable, saved together in one + * PATCH. Converted from direct Supabase reads/writes across teachers, + * classes, teacher_subjects and teacher_timetables (plus a direct storage + * upload for the photo) to useTeacherEditData(id) + useUpdateTeacher() + * (hooks/useTeachers.ts, Prisma/Neon backed, atomic on the server). + * + * Not carried over: the old page's "Verify email" / "Verify phone" buttons + * called /api/teachers/[id]/send-email-verification and + * .../send-phone-verification, routes that do not exist anywhere in this + * codebase (grepped) - they always 404'd. The read-only "Switch to view" + * toggle is also gone: the profile page (/dashboard/teachers/[id]) is now a + * richer read view than that toggle ever was, so this page is edit-only, + * matching how create/edit works everywhere else in the admin workspace. + */ +import { useState } from "react"; +import { useParams, useRouter } from "next/navigation"; import { useConfirmation } from "@/components/confirmation/ConfirmationProvider"; import { useToast } from "@/components/toast/ToastProvider"; - -const supabase = createClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, -); - -type TeacherStatus = - | "active" - | "suspended" - | "on_leave" - | "retired" - | "terminated"; - -type Teacher = { - id: string; +import { Avatar, Badge, Button, EmptyState, Field, Input, Select, Skeleton } from "@/components/ui"; +import { + TEACHER_STATUSES, + useTeacherEditData, + useUpdateTeacher, + type TeacherStatus, + type TimetableSlot, + type TimetableSlotInput, +} from "@/hooks/useTeachers"; +import { TeacherStatusBadge } from "@/components/admin/teachers/StatusBadge"; +import { TimetableEditor } from "@/components/admin/teachers/TimetableEditor"; +import { STATUS_LABEL, classLabel, percentage } from "@/components/admin/teachers/utils"; + +type Draft = { name: string; email: string; - phone: string | null; + phone: string; admission_number: string; - school_id: string; - profile_photo: string | null; - class_teacher: boolean; status: TeacherStatus; - subject_id: number | null; - created_at: string | null; - days_present: number | null; - total_school_days: number | null; - attendance_percentage: number | string | null; - class_id?: string | null; -}; - -type Subject = { - id: number; - subject_name: string; -}; - -type ClassRow = { - id: string; - class_name: string; - school_id: string; - stream: string | null; - class_teacher_id?: string | null; -}; - -type TimetableItemType = "class" | "event" | "duty" | "task"; - -type TimetableRow = { - id: string; - teacher_id: string; - class_id: string | null; - subject_id: number | null; - day_of_week: string | null; - start_time: string | null; - end_time: string | null; - room: string | null; - item_type?: TimetableItemType | null; - title?: string | null; - notes?: string | null; - created_at?: string | null; -}; - -type TeacherSubjectRow = { - teacher_id: string; - subject_id: number; -}; - -const STATUS_META: Record< - TeacherStatus, - { label: string; active: string; idle: string } -> = { - active: { - label: "Active", - active: "border-green-300 bg-green-50 text-green-700", - idle: "border-gray-200 bg-gray-50 text-gray-500", - }, - suspended: { - label: "Suspended", - active: "border-red-300 bg-red-50 text-red-700", - idle: "border-gray-200 bg-gray-50 text-gray-500", - }, - on_leave: { - label: "On Leave", - active: "border-amber-300 bg-amber-50 text-amber-700", - idle: "border-gray-200 bg-gray-50 text-gray-500", - }, - retired: { - label: "Retired", - active: "border-blue-300 bg-blue-50 text-blue-700", - idle: "border-gray-200 bg-gray-50 text-gray-500", - }, - terminated: { - label: "Terminated", - active: "border-gray-300 bg-gray-200 text-gray-700", - idle: "border-gray-200 bg-gray-50 text-gray-500", - }, + class_teacher: boolean; + class_teacher_class_id: string; + subject_ids: number[]; + timetable: TimetableSlot[]; }; -const DAYS = [ - "monday", - "tuesday", - "wednesday", - "thursday", - "friday", - "saturday", - "sunday", -] as const; - -const SLOT_STARTS = [ - "07:00", - "08:00", - "09:00", - "10:00", - "11:00", - "12:00", - "13:00", - "14:00", - "15:00", - "16:00", - "17:00", -]; - -function initials(name: string) { - const parts = name.trim().split(/\s+/).filter(Boolean); - if (!parts.length) return "TR"; - return `${parts[0]?.[0] ?? ""}${parts[1]?.[0] ?? ""}`.toUpperCase(); -} - -function percent(value: number | string | null | undefined) { - const n = Number(value ?? 0); - return Number.isFinite(n) ? Math.round(n * 100) / 100 : 0; -} - -function formatClassName(cls: ClassRow | null | undefined) { - if (!cls) return "—"; - return cls.stream ? `${cls.class_name} ${cls.stream}` : cls.class_name; -} - -function labelDay(day: string | null | undefined) { - const safe = String(day ?? "").trim(); - if (!safe) return "Unknown"; - return safe.charAt(0).toUpperCase() + safe.slice(1).toLowerCase(); +function toSlotInput(slot: TimetableSlot): TimetableSlotInput { + return { + class_id: slot.class_id, + subject_id: slot.subject_id, + day_of_week: slot.day_of_week, + start_time: slot.start_time, + end_time: slot.end_time, + room: slot.room, + item_type: slot.item_type, + title: slot.title, + notes: slot.notes, + }; } -function timeToMinutes(time: string | null | undefined) { - if (!time) return 0; - const [h, m] = time.split(":").map(Number); - return (h || 0) * 60 + (m || 0); -} - -function sortTimetable(rows: TimetableRow[]) { - return [...rows].sort((a, b) => { - const dayA = DAYS.indexOf( - String(a.day_of_week ?? "").toLowerCase() as (typeof DAYS)[number], - ); - const dayB = DAYS.indexOf( - String(b.day_of_week ?? "").toLowerCase() as (typeof DAYS)[number], - ); - const safeA = dayA === -1 ? 999 : dayA; - const safeB = dayB === -1 ? 999 : dayB; - if (safeA !== safeB) return safeA - safeB; - return timeToMinutes(a.start_time) - timeToMinutes(b.start_time); - }); -} - -function tileStyle(type: TimetableItemType | null | undefined) { - switch (type) { - case "event": - return "border-purple-200 bg-purple-50 text-purple-700"; - case "duty": - return "border-amber-200 bg-amber-50 text-amber-700"; - case "task": - return "border-cyan-200 bg-cyan-50 text-cyan-700"; - default: - return "border-[#dbe8cc] bg-[#F7F9E2] text-[#007146]"; - } -} +const errorText = (error: unknown, fallback: string) => (error instanceof Error && error.message ? error.message : fallback); -export default function TeacherEditSlugPage() { +export default function TeacherEditPage() { const params = useParams(); - const searchParams = useSearchParams(); const router = useRouter(); + const id = params?.id as string; const { confirm } = useConfirmation(); const { showToast } = useToast(); - const teacherId = params?.id as string; - const initialMode = searchParams?.get("mode") === "view" ? "view" : "edit"; - - const [mode, setMode] = useState<"view" | "edit">(initialMode); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [verifying, setVerifying] = useState<"email" | "phone" | null>(null); - - const [teacher, setTeacher] = useState(null); - const [form, setForm] = useState({ - name: "", - email: "", - phone: "", - admission_number: "", - school_id: "", - class_teacher: false, - status: "active" as TeacherStatus, - profile_photo: "", - }); - - const [subjects, setSubjects] = useState([]); - const [classes, setClasses] = useState([]); - const [assignedSubjectIds, setAssignedSubjectIds] = useState([]); - const [assignedClassIds, setAssignedClassIds] = useState([]); - const [classTeacherForClassId, setClassTeacherForClassId] = useState(""); - const [timetable, setTimetable] = useState([]); - const [draggingId, setDraggingId] = useState(null); - - const [showSlotModal, setShowSlotModal] = useState(false); - const [editingSlot, setEditingSlot] = useState(null); - const [slotForm, setSlotForm] = useState({ - item_type: "class" as TimetableItemType, - title: "", - class_id: "", - subject_id: "", - day_of_week: "monday", - start_time: "08:00", - end_time: "09:00", - room: "", - notes: "", - }); - - const fileRef = useRef(null); - - function showErrorToast(title: string, description?: string) { - showToast({ type: "error", title, description }); + const query = useTeacherEditData(id); + const update = useUpdateTeacher(); + + const [seededFor, setSeededFor] = useState(null); + const [initial, setInitial] = useState(null); + const [draft, setDraft] = useState(null); + const [photo, setPhoto] = useState(null); + const [photoPreview, setPhotoPreview] = useState(null); + + if (query.data && seededFor !== id) { + const d = query.data; + const seeded: Draft = { + name: d.teacher.name, + email: d.teacher.email ?? "", + phone: d.teacher.phone ?? "", + admission_number: d.teacher.admission_number, + status: d.teacher.status as TeacherStatus, + class_teacher: d.teacher.class_teacher, + class_teacher_class_id: d.class_teacher_class_id ?? "", + subject_ids: d.assigned_subject_ids, + timetable: d.timetable, + }; + setSeededFor(id); + setInitial(seeded); + setDraft(seeded); } - function showSuccessToast(title: string, description?: string) { - showToast({ type: "success", title, description }); + if (query.isLoading || !draft || !initial) { + return ( +
+ + + +
+ ); } - useEffect(() => { - async function fetchData() { - if (!teacherId) { - setLoading(false); - return; - } - - setLoading(true); - - // Fetch teacher first to get school_id for classes query - const teacherRes = await supabase - .from("teachers") - .select(` - id, - name, - email, - phone, - admission_number, - school_id, - profile_photo, - class_teacher, - status, - subject_id, - created_at, - days_present, - total_school_days, - attendance_percentage - `) - .eq("id", teacherId) - .maybeSingle(); - - if (teacherRes.error) { - console.error("Teacher fetch failed:", teacherRes.error); - setLoading(false); - return; - } - - if (!teacherRes.data) { - setTeacher(null); - setLoading(false); - return; - } - - const teacherData = teacherRes.data as Teacher; - - const [subjectsRes, classesRes, timetableRes, teacherSubjectsRes] = - await Promise.all([ - supabase - .from("subjects") - .select("id, subject_name") - .order("subject_name", { ascending: true }), - supabase - .from("classes") - .select("id, school_id, class_name, stream, class_teacher_id") - .eq("school_id", teacherData.school_id) - .order("class_name", { ascending: true }), - supabase - .from("teacher_timetables") - .select(` - id, - teacher_id, - class_id, - subject_id, - day_of_week, - start_time, - end_time, - room, - item_type, - title, - notes, - created_at - `) - .eq("teacher_id", teacherId), - supabase - .from("teacher_subjects") - .select("teacher_id, subject_id") - .eq("teacher_id", teacherId), - ]); - - const allowedClassIds = ((classesRes.data as ClassRow[]) ?? []) - .filter((cls) => cls.class_teacher_id === teacherData.id) - .map((cls) => cls.id); - setTeacher(teacherData); - setForm({ - name: teacherData.name ?? "", - email: teacherData.email ?? "", - phone: teacherData.phone ?? "", - admission_number: teacherData.admission_number ?? "", - school_id: teacherData.school_id ?? "", - class_teacher: !!teacherData.class_teacher, - status: teacherData.status ?? "active", - profile_photo: teacherData.profile_photo ?? "", - }); - - setSubjects((subjectsRes.data as Subject[]) ?? []); - const classRows = (classesRes.data as ClassRow[]) ?? []; - setClasses(classRows); - setTimetable(sortTimetable((timetableRes.data as TimetableRow[]) ?? [])); - - const fromBridge = ((teacherSubjectsRes.data as TeacherSubjectRow[]) ?? []).map( - (row) => Number(row.subject_id), - ); - - const mergedSubjects = Array.from( - new Set([ - ...fromBridge, - ...(teacherData.subject_id ? [Number(teacherData.subject_id)] : []), - ]), - ).filter(Boolean); - - setAssignedSubjectIds(mergedSubjects); - - const classTeacherClass = classRows.find( - (cls) => cls.class_teacher_id === teacherData.id, - ); - setClassTeacherForClassId(classTeacherClass?.id ?? ""); - - const timetableClassIds = ((timetableRes.data as TimetableRow[]) ?? []) - .map((row) => row.class_id) - .filter(Boolean) as string[]; - - const classTeacherIds = classTeacherClass?.id ? [classTeacherClass.id] : []; - - setAssignedClassIds(Array.from(new Set([...timetableClassIds, ...classTeacherIds]))); - setLoading(false); - } - - void fetchData(); - }, [teacherId]); - - const subjectMap = useMemo( - () => new Map(subjects.map((item) => [String(item.id), item.subject_name])), - [subjects], - ); + if (query.isError || !query.data) { + return ( +
+ void query.refetch()}> + Try again + + } + /> +
+ ); + } - const classMap = useMemo( - () => new Map(classes.map((item) => [item.id, item])), - [classes], - ); + const { teacher, subjects, classes } = query.data; - const groupedByDay = useMemo(() => { - const groups: Record = {}; - for (const day of DAYS) groups[day] = []; - for (const row of timetable) { - const key = String(row.day_of_week ?? "").toLowerCase(); - if (!groups[key]) groups[key] = []; - groups[key].push(row); - } - for (const key of Object.keys(groups)) { - groups[key] = groups[key].sort( - (a, b) => timeToMinutes(a.start_time) - timeToMinutes(b.start_time), - ); - } - return groups; - }, [timetable]); + const dirty = + JSON.stringify(draft) !== JSON.stringify(initial) || photo !== null; - function updateForm(key: K, value: (typeof form)[K]) { - setForm((prev) => ({ ...prev, [key]: value })); + function patch(change: Partial) { + setDraft((d) => (d ? { ...d, ...change } : d)); } function toggleSubject(subjectId: number) { - if (mode === "view") return; - setAssignedSubjectIds((prev) => - prev.includes(subjectId) - ? prev.filter((id) => id !== subjectId) - : [...prev, subjectId], - ); - } - - function toggleAssignedClass(classId: string) { - if (mode === "view") return; - setAssignedClassIds((prev) => - prev.includes(classId) - ? prev.filter((id) => id !== classId) - : [...prev, classId], - ); + patch({ + subject_ids: draft!.subject_ids.includes(subjectId) + ? draft!.subject_ids.filter((sid) => sid !== subjectId) + : [...draft!.subject_ids, subjectId], + }); } async function requestStatusChange(next: TeacherStatus) { - if (mode === "view") return; - if (next === form.status) return; - + if (next === draft!.status) return; const accepted = await confirm({ title: "Confirm status change", - message: `Change teacher status from ${STATUS_META[form.status].label} to ${STATUS_META[next].label}?`, + message: `Change teacher status from ${STATUS_LABEL[draft!.status]} to ${STATUS_LABEL[next]}?`, confirmLabel: "Change status", cancelLabel: "Cancel", - tone: - next === "suspended" || next === "terminated" ? "danger" : "primary", + tone: next === "suspended" || next === "terminated" ? "danger" : "primary", }); - if (!accepted) return; - updateForm("status", next); + patch({ status: next }); } - function openNewSlot(day?: string, time?: string) { - if (mode === "view") return; - setEditingSlot(null); - setSlotForm({ - item_type: "class", - title: "", - class_id: assignedClassIds[0] ?? "", - subject_id: assignedSubjectIds[0] ? String(assignedSubjectIds[0]) : "", - day_of_week: day ?? "monday", - start_time: time ?? "08:00", - end_time: "09:00", - room: "", - notes: "", + function handlePhotoFile(file: File) { + setPhoto(file); + setPhotoPreview((prev) => { + if (prev) URL.revokeObjectURL(prev); + return URL.createObjectURL(file); }); - setShowSlotModal(true); } - function openEditSlot(slot: TimetableRow) { - if (mode === "view") return; - setEditingSlot(slot); - setSlotForm({ - item_type: (slot.item_type ?? "class") as TimetableItemType, - title: slot.title ?? "", - class_id: slot.class_id ?? "", - subject_id: slot.subject_id ? String(slot.subject_id) : "", - day_of_week: slot.day_of_week ?? "monday", - start_time: slot.start_time ?? "08:00", - end_time: slot.end_time ?? "09:00", - room: slot.room ?? "", - notes: slot.notes ?? "", - }); - setShowSlotModal(true); - } - - function closeSlotModal() { - setShowSlotModal(false); - setEditingSlot(null); - } - - function saveSlotLocal() { - const row: TimetableRow = { - id: editingSlot?.id ?? `temp-${Date.now()}`, - teacher_id: teacherId, - class_id: slotForm.item_type === "class" ? slotForm.class_id || null : null, - subject_id: - slotForm.item_type === "class" && slotForm.subject_id - ? Number(slotForm.subject_id) - : null, - day_of_week: slotForm.day_of_week, - start_time: slotForm.start_time, - end_time: slotForm.end_time, - room: slotForm.room || null, - item_type: slotForm.item_type, - title: slotForm.item_type === "class" ? null : slotForm.title, - notes: slotForm.notes || null, - }; - - setTimetable((prev) => { - const next = editingSlot - ? prev.map((item) => (item.id === editingSlot.id ? row : item)) - : [...prev, row]; - return sortTimetable(next); - }); - - if (row.class_id && !assignedClassIds.includes(row.class_id)) { - setAssignedClassIds((prev) => [...prev, row.class_id!]); - } - - closeSlotModal(); - } - - async function deleteSlotLocal(id: string) { - if (mode === "view") return; - - const ok = await confirm({ - title: "Delete timetable item?", - message: "Delete this timetable item from the weekly schedule?", - confirmLabel: "Delete item", - cancelLabel: "Cancel", - tone: "danger", - }); - - if (!ok) return; - - setTimetable((prev) => prev.filter((item) => item.id !== id)); - closeSlotModal(); - showSuccessToast("Timetable item deleted", "The schedule item was removed."); - } - - function onDragStart(slotId: string) { - if (mode === "view") return; - setDraggingId(slotId); - } - - function onDrop(day: string, startTime: string) { - if (mode === "view" || !draggingId) return; - setTimetable((prev) => - sortTimetable( - prev.map((item) => - item.id === draggingId - ? { ...item, day_of_week: day, start_time: startTime } - : item, - ), - ), - ); - setDraggingId(null); - } - - async function handlePhotoPicked(file: File) { - if (!teacherId) return; - const ext = file.name.split(".").pop() || "jpg"; - const path = `teachers/${teacherId}-${Date.now()}.${ext}`; - - const { error: uploadError } = await supabase.storage - .from("teacher-profile-photos") - .upload(path, file, { upsert: true }); - - if (uploadError) { - console.error("Photo upload failed:", uploadError); - showErrorToast("Photo upload failed", "Failed to upload photo."); - return; - } - - const { data } = supabase.storage - .from("teacher-profile-photos") - .getPublicUrl(path); - - updateForm("profile_photo", data.publicUrl); - showSuccessToast("Photo updated", "Teacher profile photo uploaded successfully."); - } - - async function handleVerify(kind: "email" | "phone") { - setVerifying(kind); + async function handleSave() { try { - const endpoint = - kind === "email" - ? `/api/teachers/${teacherId}/send-email-verification` - : `/api/teachers/${teacherId}/send-phone-verification`; - - const payload = - kind === "email" - ? { email: form.email } - : { phone: form.phone }; - - const res = await fetch(endpoint, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), + const result = await update.mutateAsync({ + id, + changes: { + name: draft!.name, + email: draft!.email, + phone: draft!.phone || null, + admission_number: draft!.admission_number, + status: draft!.status, + class_teacher: draft!.class_teacher, + class_teacher_class_id: draft!.class_teacher ? draft!.class_teacher_class_id || null : null, + subject_ids: draft!.subject_ids, + timetable: draft!.timetable.map(toSlotInput), + }, + photo, }); - - if (!res.ok) throw new Error(`Verification request failed: ${res.status}`); - showSuccessToast( - kind === "email" ? "Verification email sent" : "Verification text sent", - kind === "email" - ? "The email verification message was sent." - : "The phone verification message was sent.", - ); - } catch (error) { - console.error(error); - showErrorToast( - "Verification failed", - `Failed to send ${kind} verification.`, - ); - } finally { - setVerifying(null); - } - } - - async function handleSaveAll() { - if (!teacherId || !teacher) return; - setSaving(true); - - try { - const primarySubjectId = assignedSubjectIds[0] ?? null; - - const teacherUpdate = await supabase - .from("teachers") - .update({ - name: form.name, - email: form.email, - phone: form.phone || null, - admission_number: form.admission_number, - school_id: form.school_id, - profile_photo: form.profile_photo || null, - class_teacher: form.class_teacher, - status: form.status, - subject_id: primarySubjectId, - }) - .eq("id", teacherId); - - if (teacherUpdate.error) throw teacherUpdate.error; - - const currentClassTeacherClassIds = classes - .filter((cls) => cls.class_teacher_id === teacherId) - .map((cls) => cls.id); - - if (currentClassTeacherClassIds.length) { - const clearRes = await supabase - .from("classes") - .update({ class_teacher_id: null }) - .in("id", currentClassTeacherClassIds); - if (clearRes.error) throw clearRes.error; - } - - if (form.class_teacher && classTeacherForClassId) { - const assignRes = await supabase - .from("classes") - .update({ class_teacher_id: teacherId }) - .eq("id", classTeacherForClassId); - if (assignRes.error) throw assignRes.error; - } - - const bridgeDelete = await supabase - .from("teacher_subjects") - .delete() - .eq("teacher_id", teacherId); - - if (bridgeDelete.error) { - console.error("Teacher subjects clear failed:", bridgeDelete.error); - } - - if (assignedSubjectIds.length) { - const bridgeInsert = await supabase.from("teacher_subjects").insert( - assignedSubjectIds.map((subjectId) => ({ - teacher_id: teacherId, - subject_id: subjectId, - })), - ); - if (bridgeInsert.error) { - console.error("Teacher subjects save failed:", bridgeInsert.error); - } - } - - const existingRealIds = timetable - .filter((row) => !String(row.id).startsWith("temp-")) - .map((row) => row.id); - - if (existingRealIds.length) { - const deleteOld = await supabase - .from("teacher_timetables") - .delete() - .eq("teacher_id", teacherId); - if (deleteOld.error) throw deleteOld.error; - } - - if (timetable.length) { - const insertRes = await supabase.from("teacher_timetables").insert( - timetable.map((row) => ({ - teacher_id: teacherId, - class_id: row.class_id, - subject_id: row.subject_id, - day_of_week: row.day_of_week, - start_time: row.start_time, - end_time: row.end_time, - room: row.room, - item_type: row.item_type ?? "class", - title: row.title, - notes: row.notes, - })), - ); - if (insertRes.error) throw insertRes.error; - } - - showSuccessToast( - "Teacher updated", - "Teacher profile and timetable changes were saved successfully.", - ); - router.refresh(); + showToast({ + type: "success", + title: "Teacher updated", + description: + result.timetable_conflicts.length > 0 + ? `Saved with ${result.timetable_conflicts.length} overlapping timetable slot${result.timetable_conflicts.length === 1 ? "" : "s"}.` + : "Teacher profile and timetable changes were saved successfully.", + }); + router.push(`/dashboard/teachers/${id}`); } catch (error) { - console.error("Save failed:", error); - showErrorToast( - "Save failed", - "Failed to save teacher changes.", - ); - } finally { - setSaving(false); + showToast({ type: "error", title: "Save failed", description: errorText(error, "Failed to save teacher changes.") }); } } - if (loading) { - return ( -
-
-
-

Loading teacher workspace...

-
-
- ); - } - - if (!teacher) { - return ( -
-
-

Teacher not found

-
-
- ); - } + // Classes available for "class teacher for": unowned, or already owned by this teacher. + const availableClassTeacherClasses = classes.filter((c) => !c.class_teacher_id || c.class_teacher_id === id); + const photoSrc = photoPreview ?? teacher.profile_photo; return ( -
-
-
-

- {mode === "edit" ? "Edit Teacher" : "Teacher Details"} -

-

- Full teacher profile, subject assignment, class ownership, and default timetable operations. -

-
- -
- - - - View Pupils - - - - Timetable Page - - - {mode === "edit" && ( - - )} +
+
+ +
+ {dirty ? ( + + ) : null} +
-
-
-
+
+
+
- {form.profile_photo ? ( - {form.name} - ) : ( -
- {initials(form.name || teacher.name)} -
- )} - -
-

{form.name || "Unnamed Teacher"}

-

{form.email || "No email"}

-

{form.phone || "No phone"}

- - {mode === "edit" && ( - <> - { - const file = e.target.files?.[0]; - if (file) void handlePhotoPicked(file); - }} - /> - - - )} + +
+

{draft.name || "Unnamed teacher"}

+

{draft.email || "No email"}

+
-
-
-

Status

-

- {form.status.replace("_", " ")} -

-
-
-

Attendance

-

- {percent(teacher.attendance_percentage)}% -

-
-
-

Days Present

-

{teacher.days_present ?? 0}

+
+
+

Attendance

+

{percentage(teacher.attendance_percentage)}%

-
-

School Days

-

{teacher.total_school_days ?? 0}

+
+

Days present

+

{teacher.days_present ?? 0}

-
-

Status Control

-

- Choose one teacher lifecycle state. Each change requires confirmation. -

- -
- {(Object.keys(STATUS_META) as TeacherStatus[]).map((status) => { - const selected = form.status === status; +
+

Status

+

Each change asks for confirmation before it applies.

+
+ {TEACHER_STATUSES.map((status) => { + const selected = draft.status === status; return ( ); })}
- -
-

Verification Actions

-

- Trigger verification when email or phone is changed. -

-
- - -
-
-
-
-

Teacher Details

-
-
- - updateForm("name", e.target.value)} - disabled={mode === "view"} - className="w-full rounded-[16px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm outline-none" - /> -
- -
- - updateForm("admission_number", e.target.value)} - disabled={mode === "view"} - className="w-full rounded-[16px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm outline-none" - /> -
- -
- - updateForm("email", e.target.value)} - disabled={mode === "view"} - className="w-full rounded-[16px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm outline-none" - /> -
- -
- - updateForm("phone", e.target.value)} - disabled={mode === "view"} - className="w-full rounded-[16px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm outline-none" - /> -
+
+
+

Teacher details

+
+ + patch({ name: e.target.value })} /> + + + patch({ admission_number: e.target.value })} /> + + + patch({ email: e.target.value })} /> + + + patch({ phone: e.target.value })} /> +
-
- -
-

Subjects

-

- Assign one or more subjects. First selected subject becomes the default primary subject. -

+ -
+
+

Subjects

+

The first subject selected becomes the primary subject.

+
{subjects.map((subject) => { - const selected = assignedSubjectIds.includes(subject.id); + const index = draft.subject_ids.indexOf(subject.id); + const selected = index !== -1; return ( ); })}
-
- -
-

Class Assignment

-

- Select classes this teacher handles and define the class they officially lead. -

- -
- {classes.map((cls) => { - const selected = assignedClassIds.includes(cls.id); - return ( - - ); - })} -
- -
- - - - -
-
- -
-
-
-

Default Weekly Timetable

-

- Drag items between days, click tiles to edit, or add new class, event, duty, or task blocks. -

-
- - {mode === "edit" && ( - - )} -
- -
-
-
- {DAYS.map((day) => ( -
- {labelDay(day)} -
- ))} - - {SLOT_STARTS.map((slotStart) => ( - <> -
- {slotStart} -
- - {DAYS.map((day) => { - const items = groupedByDay[day]?.filter( - (row) => row.start_time === slotStart, - ) ?? []; - - return ( -
e.preventDefault()} - onDrop={() => onDrop(day, slotStart)} - onDoubleClick={() => openNewSlot(day, slotStart)} - className="min-h-[96px] rounded-[18px] border border-dashed border-gray-200 bg-[#FCFCFC] p-2" - > -
- {items.map((item) => ( - - ))} - - {mode === "edit" && items.length === 0 && ( - - )} -
-
- ); - })} - - ))} -
-
- -
-

Assigned Class Overview

-
- {assignedClassIds.length === 0 ? ( - No classes assigned yet. - ) : ( - assignedClassIds.map((id) => ( - - {formatClassName(classMap.get(id))} - - )) - )} -
-
-
-
-
- - {showSlotModal && ( -
-
-
-

- {editingSlot ? "Edit Schedule Item" : "Add Schedule Item"} -

- -
- -
-
- - -
- -
- - patch({ class_teacher: e.target.checked })} + className="size-4 rounded border-ghost accent-[var(--primary)]" + /> + Mark this teacher as a class teacher + +
+ + -
- -
- - - setSlotForm((prev) => ({ ...prev, start_time: e.target.value })) - } - className="w-full rounded-[16px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm outline-none" - /> -
- -
- - - setSlotForm((prev) => ({ ...prev, end_time: e.target.value })) - } - className="w-full rounded-[16px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm outline-none" - /> -
- - {slotForm.item_type === "class" ? ( - <> -
- - -
- -
- - -
- - ) : ( -
- - - setSlotForm((prev) => ({ ...prev, title: e.target.value })) - } - placeholder="e.g. Assembly Duty, Parent Meeting, Marking Task" - className="w-full rounded-[16px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm outline-none" - /> -
- )} - -
- - - setSlotForm((prev) => ({ ...prev, room: e.target.value })) - } - className="w-full rounded-[16px] border border-gray-200 bg-[#FCFCFC] px-4 py-3 text-sm outline-none" - /> -
- -
- -