Skip to content
Open
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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 30 additions & 17 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,26 +103,39 @@ model PremisesProfile {
}

model AuditTemplate {
id String @id @default(cuid())
version String // e.g. "2025-09"
publishedAt DateTime
sections Json // Array of { id, number, title, items: [{id, question, guidance, responseType, tags}] }
isActive Boolean @default(false)
audits Audit[]
id String @id @default(cuid())
version String // e.g. "2025-09" — annual/questionnaire release
revision Int @default(1) // patch within a release
changeType AuditTemplateChangeType @default(RELEASE) // RELEASE | PATCH
description String?
publishedAt DateTime
sections Json // Array of sections/items
isActive Boolean @default(false) // template used for new audits
supersededAt DateTime?
audits Audit[]

@@unique([version, revision])
}

// Each premises may have multiple audits over time. There is no one-audit-per-year rule.
// Draft audits follow the latest revision of the active template.
// Completed audits store a sectionsSnapshot so template patches do not rewrite history.

model Audit {
id String @id @default(cuid())
premisesId String
premises Premises @relation(fields: [premisesId], references: [id])
templateId String
template AuditTemplate @relation(fields: [templateId], references: [id])
status AuditStatus // DRAFT | COMPLETE
startedBy String // userId
startedAt DateTime @default(now())
completedAt DateTime?
responses AuditResponse[]
sections AuditSection[]
id String @id @default(cuid())
premisesId String
premises Premises @relation(fields: [premisesId], references: [id])
templateId String
template AuditTemplate @relation(fields: [templateId], references: [id])
templateRevision Int // pinned at start; updated for drafts on template sync
auditDate DateTime @db.Date // date of the inspection/review (not enforced annually)
sectionsSnapshot Json? // frozen template JSON when status becomes COMPLETE
status AuditStatus // DRAFT | COMPLETE
startedBy String // userId
startedAt DateTime @default(now())
completedAt DateTime?
responses AuditResponse[]
sections AuditSection[]
}

model AuditSection {
Expand Down
10 changes: 9 additions & 1 deletion app/(app)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,15 @@ export default function AppLayout({
<Link href="/" className="text-lg font-semibold text-slate-900">
ScoutBase
</Link>
<span className="text-sm text-slate-500">Premises compliance</span>
<nav className="flex items-center gap-4 text-sm">
<Link
href="/templates"
className="font-medium text-slate-600 hover:text-slate-900"
>
Templates
</Link>
<span className="text-slate-400">Premises compliance</span>
</nav>
</div>
</header>
<main className="mx-auto max-w-6xl px-4 py-8 sm:px-6">{children}</main>
Expand Down
46 changes: 46 additions & 0 deletions app/(app)/premises/[id]/actions/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Suspense } from "react";
import { notFound, redirect } from "next/navigation";
import { ActionTracker } from "@/components/actions/ActionTracker";
import { PremisesPageHeader } from "@/components/premises/PremisesPageHeader";
import { requireAuthContext } from "@/lib/auth";
import { getPremisesForOrganisation } from "@/lib/premises";

type PageProps = {
params: { id: string };
};

export default async function PremisesActionsPage({ params }: PageProps) {
let auth;
try {
auth = await requireAuthContext();
} catch {
redirect("/");
}

const premises = await getPremisesForOrganisation(
params.id,
auth.organisationId,
);

if (!premises) {
notFound();
}

return (
<div>
<PremisesPageHeader
premisesId={premises.id}
premisesName={premises.name}
address={premises.address}
eyebrow="Actions tracker"
description="View and manage follow-up actions raised during audits. Filter by status, priority, and due date."
/>

<div className="mt-8">
<Suspense fallback={<p className="text-sm text-slate-500">Loading actions…</p>}>
<ActionTracker premisesId={premises.id} />
</Suspense>
</div>
</div>
);
}
22 changes: 16 additions & 6 deletions app/(app)/premises/[id]/audit/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { notFound, redirect } from "next/navigation";
import { AuditStatus } from "@prisma/client";
import { AuditStartPanel } from "@/components/audit/AuditStartPanel";
import { AuditsManager } from "@/components/audit/AuditsManager";
import { PremisesPageHeader } from "@/components/premises/PremisesPageHeader";
import { requireAuthContext } from "@/lib/auth";
import { getPremisesForOrganisation } from "@/lib/premises";
import { prisma } from "@/lib/prisma";
Expand Down Expand Up @@ -28,21 +29,30 @@ export default async function PremisesAuditPage({ params }: PageProps) {
premisesId: premises.id,
status: AuditStatus.DRAFT,
},
select: { id: true },
select: { id: true, auditDate: true },
});

return (
<div>
<p className="text-sm font-medium text-emerald-700">Annual safety audit</p>
<h1 className="mt-1 text-2xl font-semibold text-slate-900">{premises.name}</h1>
<p className="mt-1 text-slate-600">{premises.address}</p>
<PremisesPageHeader
premisesId={premises.id}
premisesName={premises.name}
address={premises.address}
eyebrow="Audits"
description="View past audits, start a new one against a chosen template, or continue a draft."
/>

<div className="mt-8">
<AuditStartPanel
<AuditsManager
premisesId={premises.id}
premisesName={premises.name}
hasProfile={Boolean(premises.profile)}
draftAuditId={draft?.id ?? null}
draftAuditDate={
draft?.auditDate
? draft.auditDate.toISOString().slice(0, 10)
: null
}
/>
</div>
</div>
Expand Down
16 changes: 8 additions & 8 deletions app/(app)/premises/[id]/profile/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { requireAuthContext } from "@/lib/auth";
import { getPremisesForOrganisation } from "@/lib/premises";
import { EMPTY_PROFILE_FORM } from "@/lib/profile-labels";
import { ProfileWizard } from "@/components/profile/ProfileWizard";
import { PremisesPageHeader } from "@/components/premises/PremisesPageHeader";
import type { ProfileFormData } from "@/lib/profile-labels";
import type { BuildingAgeBand, FloodRiskZone, OwnershipType } from "@prisma/client";

Expand Down Expand Up @@ -59,14 +60,13 @@ export default async function PremisesProfilePage({ params }: PageProps) {

return (
<div>
<p className="text-sm font-medium text-emerald-700">Premises profile</p>
<h1 className="mt-1 text-2xl font-semibold text-slate-900">{premises.name}</h1>
<p className="mt-1 text-slate-600">{premises.address}</p>
<p className="mt-4 text-sm text-slate-600">
Answer a few questions about your premises. We&apos;ll tailor the annual
safety audit to what applies to your building — usually about five
minutes.
</p>
<PremisesPageHeader
premisesId={premises.id}
premisesName={premises.name}
address={premises.address}
eyebrow="Premises profile"
description="Answer a few questions about your premises. We'll tailor the annual safety audit to what applies to your building — usually about five minutes."
/>
{premises.profile ? (
<Link
href={`/premises/${premises.id}/audit`}
Expand Down
21 changes: 21 additions & 0 deletions app/(app)/templates/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { TemplateManager } from "@/components/templates/TemplateManager";

export default function TemplatesPage() {
return (
<div>
<p className="text-sm font-medium text-emerald-700">Template library</p>
<h1 className="mt-1 text-2xl font-semibold text-slate-900">
Audit templates
</h1>
<p className="mt-2 max-w-2xl text-slate-600">
Upload new questionnaire releases and manage which template version is
active by default. Each audit is pinned to the template used when it was
started.
</p>

<div className="mt-8">
<TemplateManager />
</div>
</div>
);
}
39 changes: 25 additions & 14 deletions app/api/actions/[actionId]/route.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
import { NextResponse } from "next/server";
import { Priority } from "@prisma/client";
import { ActionStatus, Priority } from "@prisma/client";
import { requireAuthContext } from "@/lib/auth";
import { serializeLinkedAction, updateAuditAction, deleteAuditAction } from "@/lib/audit";
import { prisma } from "@/lib/prisma";
import {
deleteAction,
serializeActionListItem,
updateAction,
} from "@/lib/actions";

type RouteParams = { params: { actionId: string } };

const VALID_PRIORITIES = new Set<string>(Object.values(Priority));
const VALID_STATUSES = new Set<string>(Object.values(ActionStatus));

export async function GET(_request: Request, { params }: RouteParams) {
try {
const auth = await requireAuthContext();

const action = await prisma.action.findFirst({
where: {
id: params.actionId,
premises: { organisationId: auth.organisationId },
},
});
const { getActionForOrganisation } = await import("@/lib/actions");
const action = await getActionForOrganisation(
params.actionId,
auth.organisationId,
);

if (!action) {
return NextResponse.json(
Expand All @@ -27,7 +29,7 @@ export async function GET(_request: Request, { params }: RouteParams) {
}

return NextResponse.json({
data: serializeLinkedAction(action),
data: serializeActionListItem(action),
error: null,
});
} catch (error) {
Expand All @@ -46,6 +48,7 @@ export async function PATCH(request: Request, { params }: RouteParams) {
const title = body?.title as string | undefined;
const description = body?.description as string | undefined;
const priority = body?.priority as string | undefined;
const status = body?.status as string | undefined;
const dueDate = body?.dueDate as string | null | undefined;

if (!title || !priority) {
Expand All @@ -62,15 +65,23 @@ export async function PATCH(request: Request, { params }: RouteParams) {
);
}

const action = await updateAuditAction(params.actionId, auth.organisationId, {
if (status && !VALID_STATUSES.has(status)) {
return NextResponse.json(
{ data: null, error: "Invalid status value" },
{ status: 400 },
);
}

const action = await updateAction(params.actionId, auth.organisationId, {
title,
description,
priority: priority as Priority,
dueDate,
status: status as ActionStatus | undefined,
});

return NextResponse.json({
data: serializeLinkedAction(action),
data: serializeActionListItem(action),
error: null,
});
} catch (error) {
Expand All @@ -90,7 +101,7 @@ export async function PATCH(request: Request, { params }: RouteParams) {
export async function DELETE(_request: Request, { params }: RouteParams) {
try {
const auth = await requireAuthContext();
await deleteAuditAction(params.actionId, auth.organisationId);
await deleteAction(params.actionId, auth.organisationId);

return NextResponse.json({ data: { deleted: true }, error: null });
} catch (error) {
Expand Down
25 changes: 25 additions & 0 deletions app/api/audit-templates/[templateId]/activate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { requireAuthContext } from "@/lib/auth";
import { activateAuditTemplate } from "@/lib/audit-templates";

type RouteParams = { params: { templateId: string } };

export async function POST(_request: Request, { params }: RouteParams) {
try {
await requireAuthContext();
const template = await activateAuditTemplate(params.templateId);

return NextResponse.json({ data: { template }, error: null });
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to activate template";
const status =
message === "Unauthorized"
? 401
: message === "Template not found"
? 404
: 500;

return NextResponse.json({ data: null, error: message }, { status });
}
}
61 changes: 61 additions & 0 deletions app/api/audit-templates/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { NextResponse } from "next/server";
import { requireAuthContext } from "@/lib/auth";
import {
importAuditTemplateRelease,
listAuditTemplateCatalog,
listSelectableAuditTemplates,
} from "@/lib/audit-templates";
import { parseUploadedTemplatePayload } from "@/lib/audit-template-import";

export async function GET(request: Request) {
try {
await requireAuthContext();
const url = new URL(request.url);
const mode = url.searchParams.get("mode");

const templates =
mode === "selectable"
? await listSelectableAuditTemplates()
: await listAuditTemplateCatalog();

return NextResponse.json({ data: { templates }, error: null });
} catch (error) {
return NextResponse.json(
{
data: null,
error:
error instanceof Error ? error.message : "Failed to load templates",
},
{ status: error instanceof Error && error.message === "Unauthorized" ? 401 : 500 },
);
}
}

export async function POST(request: Request) {
try {
await requireAuthContext();
const body = await request.json();
const input = parseUploadedTemplatePayload(body);

const template = await importAuditTemplateRelease({
version: input.version,
publishedAt: input.publishedAt,
description: input.description,
sections: input.sections,
setActive: input.setActive,
});

return NextResponse.json({ data: { template }, error: null }, { status: 201 });
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to import template";
const status =
message === "Unauthorized"
? 401
: message.includes("already exists") || message.includes("must")
? 400
: 500;

return NextResponse.json({ data: null, error: message }, { status });
}
}
Loading