Skip to content
Draft
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
21 changes: 21 additions & 0 deletions app/(app)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Link from "next/link";

export default function AppLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<div className="min-h-screen bg-slate-50">
<header className="border-b border-slate-200 bg-white">
<div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-4">
<Link href="/" className="text-lg font-semibold text-slate-900">
ScoutBase
</Link>
<span className="text-sm text-slate-500">Premises compliance</span>
</div>
</header>
<main className="mx-auto max-w-3xl px-4 py-8">{children}</main>
</div>
);
}
78 changes: 78 additions & 0 deletions app/(app)/premises/[id]/profile/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { notFound, redirect } from "next/navigation";
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 type { ProfileFormData } from "@/lib/profile-labels";
import type { BuildingAgeBand, FloodRiskZone, OwnershipType } from "@prisma/client";

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

function toFormData(profile: {
ownershipType: OwnershipType;
buildingAgeBand: BuildingAgeBand;
hasGas: boolean;
floodRiskZone: FloodRiskZone | null;
hasCateringKitchen: boolean;
hasGrounds: boolean;
hasSleeping: boolean;
hasVehicles: boolean;
hasPlantMachinery: boolean;
hasThirdPartyUsers: boolean;
}): ProfileFormData {
return {
ownershipType: profile.ownershipType,
buildingAgeBand: profile.buildingAgeBand,
hasGas: profile.hasGas,
floodRiskZone: profile.floodRiskZone,
hasCateringKitchen: profile.hasCateringKitchen,
hasGrounds: profile.hasGrounds,
hasSleeping: profile.hasSleeping,
hasVehicles: profile.hasVehicles,
hasPlantMachinery: profile.hasPlantMachinery,
hasThirdPartyUsers: profile.hasThirdPartyUsers,
};
}

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

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

if (!premises) {
notFound();
}

const initialProfile = premises.profile
? toFormData(premises.profile)
: EMPTY_PROFILE_FORM;

const initialApplicableSections = premises.profile?.applicableSections ?? [];

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>
<div className="mt-8">
<ProfileWizard
premisesId={premises.id}
initialProfile={initialProfile}
initialApplicableSections={initialApplicableSections}
/>
</div>
</div>
);
}
203 changes: 203 additions & 0 deletions app/api/premises/[id]/profile/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { NextResponse } from "next/server";
import type { BuildingAgeBand, FloodRiskZone, OwnershipType } from "@prisma/client";
import { requireAuthContext } from "@/lib/auth";
import { getPremisesForOrganisation } from "@/lib/premises";
import { prisma } from "@/lib/prisma";
import { computeApplicableSections } from "@/lib/sections";
import { EMPTY_PROFILE_FORM, type ProfileFormData } from "@/lib/profile-labels";

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

function toFormData(profile: {
ownershipType: OwnershipType;
buildingAgeBand: BuildingAgeBand;
hasGas: boolean;
floodRiskZone: FloodRiskZone | null;
hasCateringKitchen: boolean;
hasGrounds: boolean;
hasSleeping: boolean;
hasVehicles: boolean;
hasPlantMachinery: boolean;
hasThirdPartyUsers: boolean;
}): ProfileFormData {
return {
ownershipType: profile.ownershipType,
buildingAgeBand: profile.buildingAgeBand,
hasGas: profile.hasGas,
floodRiskZone: profile.floodRiskZone,
hasCateringKitchen: profile.hasCateringKitchen,
hasGrounds: profile.hasGrounds,
hasSleeping: profile.hasSleeping,
hasVehicles: profile.hasVehicles,
hasPlantMachinery: profile.hasPlantMachinery,
hasThirdPartyUsers: profile.hasThirdPartyUsers,
};
}

function parseProfileBody(body: unknown): ProfileFormData | null {
if (!body || typeof body !== "object") {
return null;
}

const data = body as Record<string, unknown>;

return {
ownershipType: (data.ownershipType as OwnershipType) ?? "",
buildingAgeBand: (data.buildingAgeBand as BuildingAgeBand) ?? "",
hasGas: Boolean(data.hasGas),
floodRiskZone:
data.floodRiskZone === null || data.floodRiskZone === ""
? null
: (data.floodRiskZone as FloodRiskZone),
hasCateringKitchen: Boolean(data.hasCateringKitchen),
hasGrounds: Boolean(data.hasGrounds),
hasSleeping: Boolean(data.hasSleeping),
hasVehicles: Boolean(data.hasVehicles),
hasPlantMachinery: Boolean(data.hasPlantMachinery),
hasThirdPartyUsers: Boolean(data.hasThirdPartyUsers),
};
}

function validateProfileForm(form: ProfileFormData): string | null {
if (!form.ownershipType) {
return "Ownership type is required";
}
if (!form.buildingAgeBand) {
return "Building age is required";
}
return null;
}

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

if (!premises) {
return NextResponse.json(
{ data: null, error: "Premises not found" },
{ status: 404 },
);
}

const form = premises.profile
? toFormData(premises.profile)
: EMPTY_PROFILE_FORM;

const applicableSections = premises.profile
? premises.profile.applicableSections
: [];

return NextResponse.json({
data: {
premises: {
id: premises.id,
name: premises.name,
address: premises.address,
},
profile: form,
applicableSections,
},
error: null,
});
} catch (error) {
return NextResponse.json(
{
data: null,
error: error instanceof Error ? error.message : "Failed to load profile",
},
{ status: error instanceof Error && error.message === "Unauthorized" ? 401 : 500 },
);
}
}

export async function PUT(request: Request, { params }: RouteParams) {
try {
const auth = await requireAuthContext();
const premises = await getPremisesForOrganisation(params.id, auth.organisationId);

if (!premises) {
return NextResponse.json(
{ data: null, error: "Premises not found" },
{ status: 404 },
);
}

const body = await request.json();
const form = parseProfileBody(body);

if (!form) {
return NextResponse.json(
{ data: null, error: "Invalid request body" },
{ status: 400 },
);
}

const validationError = validateProfileForm(form);
if (validationError) {
return NextResponse.json(
{ data: null, error: validationError },
{ status: 400 },
);
}

const applicableSections = computeApplicableSections({
buildingAgeBand: form.buildingAgeBand as BuildingAgeBand,
hasGas: form.hasGas,
hasSleeping: form.hasSleeping,
hasCateringKitchen: form.hasCateringKitchen,
hasGrounds: form.hasGrounds,
hasVehicles: form.hasVehicles,
hasPlantMachinery: form.hasPlantMachinery,
hasThirdPartyUsers: form.hasThirdPartyUsers,
floodRiskZone: form.floodRiskZone as FloodRiskZone | null,
});

const profile = await prisma.premisesProfile.upsert({
where: { premisesId: premises.id },
create: {
premisesId: premises.id,
ownershipType: form.ownershipType as OwnershipType,
buildingAgeBand: form.buildingAgeBand as BuildingAgeBand,
hasGas: form.hasGas,
floodRiskZone: form.floodRiskZone as FloodRiskZone | null,
hasCateringKitchen: form.hasCateringKitchen,
hasGrounds: form.hasGrounds,
hasSleeping: form.hasSleeping,
hasVehicles: form.hasVehicles,
hasPlantMachinery: form.hasPlantMachinery,
hasThirdPartyUsers: form.hasThirdPartyUsers,
applicableSections,
},
update: {
ownershipType: form.ownershipType as OwnershipType,
buildingAgeBand: form.buildingAgeBand as BuildingAgeBand,
hasGas: form.hasGas,
floodRiskZone: form.floodRiskZone as FloodRiskZone | null,
hasCateringKitchen: form.hasCateringKitchen,
hasGrounds: form.hasGrounds,
hasSleeping: form.hasSleeping,
hasVehicles: form.hasVehicles,
hasPlantMachinery: form.hasPlantMachinery,
hasThirdPartyUsers: form.hasThirdPartyUsers,
applicableSections,
},
});

return NextResponse.json({
data: {
profile: toFormData(profile),
applicableSections: profile.applicableSections,
},
error: null,
});
} catch (error) {
return NextResponse.json(
{
data: null,
error: error instanceof Error ? error.message : "Failed to save profile",
},
{ status: error instanceof Error && error.message === "Unauthorized" ? 401 : 500 },
);
}
}
4 changes: 2 additions & 2 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
title: "Vigil",
description: "Compliance management for volunteer-run community buildings",
title: "ScoutBase",
description: "Premises compliance for volunteer-run community buildings",
};

export default function RootLayout({
Expand Down
27 changes: 24 additions & 3 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,31 @@
export default function Home() {
import Link from "next/link";
import { prisma } from "@/lib/prisma";

export default async function Home() {
const premises = await prisma.premises.findFirst({
orderBy: { createdAt: "asc" },
select: { id: true, name: true },
});

return (
<main className="flex min-h-screen flex-col items-center justify-center p-8">
<h1 className="text-3xl font-semibold">Vigil</h1>
<p className="mt-2 text-gray-600">
<h1 className="text-3xl font-semibold text-slate-900">ScoutBase</h1>
<p className="mt-2 max-w-md text-center text-slate-600">
Compliance management for volunteer-run community buildings
</p>
{premises ? (
<Link
href={`/premises/${premises.id}/profile`}
className="mt-8 rounded-lg bg-emerald-700 px-5 py-2.5 text-sm font-medium text-white hover:bg-emerald-800"
>
Set up {premises.name} profile
</Link>
) : (
<p className="mt-8 text-sm text-slate-500">
Run <code className="rounded bg-slate-100 px-1">npm run db:seed</code>{" "}
to create a demo premises.
</p>
)}
</main>
);
}
Loading