Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
3 changes: 3 additions & 0 deletions stackable-app/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
24 changes: 24 additions & 0 deletions stackable-app/.tmp-lane-a/inspect.ts
Original file line number Diff line number Diff line change
@@ -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); });
16 changes: 16 additions & 0 deletions stackable-app/.tmp-lane-a/inspect2.ts
Original file line number Diff line number Diff line change
@@ -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); });
14 changes: 14 additions & 0 deletions stackable-app/.tmp-lane-a/inspect3.ts
Original file line number Diff line number Diff line change
@@ -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); });
51 changes: 51 additions & 0 deletions stackable-app/.tmp-smoke/smoke.tsx
Original file line number Diff line number Diff line change
@@ -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<string, () => React.ReactNode> = {
ChildCardFull: () => <ChildCardFull child={child} />,
ChildCardFullRate: () => <ChildCardFull child={{ ...child, attendanceRate: 92, averagePct: 71, averageGrade: "B", latestGrade: null }} />,
ChildRow: () => <ChildRow child={child} />,
ChildRowRate: () => <ChildRow child={{ ...child, attendanceRate: 60, latestGrade: null }} />,
Subjects: () => <SubjectsGrid cards={buildSubjectCards([{ id: "1", name: "Maths", teacherName: null, averagePct: null, latestGrade: "A" }], grades)} />,
SubjectsNoLink: () => <SubjectsGrid cards={[]} linkFor={() => null} allHref={null} />,
Latest: () => <LatestGrades grades={sortNewestFirst(grades)} epochMinute={0} />,
Matrix: () => <TermMatrix rows={grades} terms={["Term 1", "Term 2"]} />,
Mix: () => <GradeMixBlock mix={gradeMix(grades)} overall="A" />,
MixEmpty: () => <GradeMixBlock mix={gradeMix([])} overall={null} />,
Attendance0: () => <AttendanceBlock attendance={att0} />,
Insights: () => <GradeInsights rows={grades} />,
Upcoming0: () => <UpcomingPanel today={{ date: "", weekday: "", lessons: [] }} nowMin={null} />,
Progress: () => <ProgressPanel summary={summarizeGrades(grades)} mix={gradeMix(grades)} attendance={att0} />,
Skeletons: () => (<><StudentHomeSkeleton /><GradesSkeleton /><FamilyHomeSkeleton /><ChildProfileSkeleton /><ChildrenListSkeleton /></>),
};
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);
19 changes: 19 additions & 0 deletions stackable-app/app/(developer)/dev/audit/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-6">
<DevIntro subtitle="A record of every impersonation: who viewed as whom, why, and each request made while viewing." />
<Suspense fallback={<TableSkeleton columns={6} />}>
<AuditView />
</Suspense>
</div>
);
}
8 changes: 8 additions & 0 deletions stackable-app/app/(developer)/dev/integrations/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <IntegrationsView />;
}
9 changes: 9 additions & 0 deletions stackable-app/app/(developer)/dev/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <h1>; the view starts with a greeting <h2>.
export default function DeveloperOverviewPage() {
return <OverviewView />;
}
14 changes: 14 additions & 0 deletions stackable-app/app/(developer)/dev/schools/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-6">
<DevIntro subtitle="Every school on the platform. Select a row for its details and a shortcut to its users." />
<SchoolsView />
</div>
);
}
8 changes: 8 additions & 0 deletions stackable-app/app/(developer)/dev/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <SettingsView />;
}
8 changes: 8 additions & 0 deletions stackable-app/app/(developer)/dev/sitemap/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <SitemapView />;
}
19 changes: 19 additions & 0 deletions stackable-app/app/(developer)/dev/users/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-6">
<DevIntro subtitle="Find people across every school and, when you need to, view the app as them. Each session is recorded." />
<Suspense fallback={<TableSkeleton />}>
<UsersView />
</Suspense>
</div>
);
}
160 changes: 8 additions & 152 deletions stackable-app/app/(developer)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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: <UserCog className={iconClassName} strokeWidth={iconStrokeWidth} />,
},
],
},
{
key: "main",
ariaLabel: "Main navigation",
items: [
{
type: "link",
href: "/dev",
label: "Platform Overview",
icon: <Home className={iconClassName} strokeWidth={iconStrokeWidth} />,
},
{
type: "link",
href: "/dev/schools",
label: "Schools",
icon: <Building2 className={iconClassName} strokeWidth={iconStrokeWidth} />,
},
{
type: "link",
href: "/dev/users",
label: "Cross-School Users",
icon: <Users className={iconClassName} strokeWidth={iconStrokeWidth} />,
},
{
type: "link",
href: "/dev/audit",
label: "Audit Log",
icon: <ScrollText className={iconClassName} strokeWidth={iconStrokeWidth} />,
},
{
type: "link",
href: "/dev/integrations",
label: "Integrations",
icon: <Blocks className={iconClassName} strokeWidth={iconStrokeWidth} />,
},
{
type: "link",
href: "/dev/feature-flags",
label: "Feature Flags",
icon: <Zap className={iconClassName} strokeWidth={iconStrokeWidth} />,
},
],
},
];

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 (
<div className="min-h-screen w-full bg-[#F6F6F6] text-black">
<Sidebar
isCollapsed={isSidebarCollapsed}
isMobileOpen={isMobileSidebarOpen}
onCloseMobile={() => setIsMobileSidebarOpen(false)}
onToggleCollapse={() => setIsSidebarCollapsed((current) => !current)}
sections={DEV_SIDEBAR_SECTIONS}
/>

<div
className={clsx(
"min-h-screen overflow-x-hidden transition-[padding] duration-300",
isSidebarCollapsed
? DESKTOP_SIDEBAR_COLLAPSED_OFFSET
: DESKTOP_SIDEBAR_EXPANDED_OFFSET,
)}
>
<Navbar
isMobileSidebarOpen={isMobileSidebarOpen}
isSidebarCollapsed={isSidebarCollapsed}
onToggleMobileSidebar={() =>
setIsMobileSidebarOpen((current) => !current)
}
/>

<main className="px-4 pb-10 pt-52 sm:px-5 sm:pt-48 xl:px-6 xl:pt-32">
<div className="mb-4">
<BreadCrumb />
</div>
{children}
</main>
</div>
</div>
);
};

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 <DashboardShell portal="developer">{children}</DashboardShell>;
}
Loading
Loading