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
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ dist
.env.local
*.log
coverage
test-results
playwright-report
.DS_Store
review-agent-app
${userHome}
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ next-env.d.ts
dist/
deployment-result.json
coverage/
test-results/
playwright-report/
.DS_Store
*.log

Expand Down
32 changes: 20 additions & 12 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,21 +180,29 @@ Policy Copilot uses one interface for both regimes. Taxi-specific questions and

An active `ModuleVersion` can define:

- public description and before-you-start guidance;
- application types;
- form sections and fields;
- conditional field and document rules;
- document requirements and verification status;
- workflow stages, order, SLA days, reminders, and visibility;
- review checklist;
- fees and payment mode;
- owning team and submission mailbox;
- eligibility and retention settings;
- decision and notification templates;
- feature flags and application availability.

Use the administrator module builder for demonstration and controlled configuration. For production, define review, approval, promotion, rollback, and audit processes for module changes.

### Creating and publishing modules

Open **Modules > Create module** at `/admin/modules/new`. Start blank, use a standard or inspection starter, copy an existing module, or import a module JSON package. Configure details, application types, questions, conditional sections, evidence, workflow targets, officer checks and fees. The permanent module key must be unique; `new` is reserved for the creation route.

The builder supports choice lists, repeatable groups, earlier-question conditions, reorder/duplicate controls and undo/redo. **Preview** uses the applicant form renderer without submitting an application or uploading evidence. Check both desktop and mobile layouts before publication.

**Save draft** creates an inactive version. New modules remain disabled and closed to applications; drafting changes to an existing module does not replace its published version. **Review & publish** validates the configuration and requires explicit confirmation of audience, enablement and application availability. Version activation, enablement and audit logging are transactional. Existing applications retain their original module version when resumed.

Unsaved configuration has a user-scoped browser recovery copy and can be exported as a version 1 JSON package. Imports and version-history restoration change only the editor; they do not publish automatically. A stale editor cannot overwrite a newer save: export unsaved work, then reload before retrying. Recovery storage is not a server save.

Supported new payment flows are no fee, manual reference and receipt upload. Choosing receipt upload adds a required receipt requirement. The incomplete external-gateway and payment-API modes cannot be newly published. Existing advanced settings and fee bands are preserved, but the payment service uses the base amount. File uploads belong in document requirements, where allowed MIME types and maximum size are enforced alongside existing upload security checks. The platform maximum remains `MAX_FILE_SIZE_MB`.

The builder is administrator-only, uses same-origin mutation checks and bounds JSON requests to 1 MiB. It requires no database migration or reseeding. Existing deployments receive it only after their web image is updated. Specialised deployments must retain their existing intake restrictions and custom form types rather than replacing them with a generic image.

### Module builder tests

- `npm run test:modules` runs definition tests. Add `MODULE_BUILDER_INTEGRATION=1` and a `DATABASE_URL` targeting a disposable local `dpp_module_builder_*` database to include database lifecycle, concurrency and rollback tests. Apply existing migrations to that disposable database first. Never point integration tests at a deployed database.
- `npx playwright install chromium` installs the browser used by `npm run test:modules:e2e`. Point `MODULE_BUILDER_URL` at an isolated local app and set `MODULE_BUILDER_PASSWORD` to its seeded demo password. Defaults are `http://localhost:3107` and the synthetic development password `password123`.
- Browser tests cover create/preview/publish, a full applicant renewal, preserved historical versions, evidence rejection, access control, recovery, failed saves, import/export, concurrent edits, version restoration, registry toggles, keyboard navigation, and desktop/mobile layouts. They create synthetic fixtures and reject non-local URLs. Screenshots and traces are ignored under `test-results/module-builder/`.

## Licence document templates

Administrators manage generated Word documents at `/admin/licence-management`. The built-in standard DOCX template is available to every configured licence and permit type, including disabled modules. Administrators can also upload any number of tailored templates and assign each upload to one, several, or all licence types. Assignment changes do not alter documents already generated for a case.
Expand Down
43 changes: 43 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
"lint": "biome lint .",
"test": "tsx --test tests/*.test.ts",
"test:watch": "tsx --test --watch tests/*.test.ts",
"test:modules": "tsx --test tests/module-builder.test.ts tests/module-builder.integration.test.ts",
"test:modules:e2e": "playwright test tests/module-builder.spec.ts --workers=1 --reporter=line --output=test-results/module-builder",
"docs:check": "node scripts/check-markdown-links.mjs",
"audit:allowlist": "node scripts/audit-with-allowlist.mjs --audit-level low",
"validate:release": "node scripts/validate-public-release.mjs",
Expand Down Expand Up @@ -82,6 +84,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.5.6",
"@playwright/test": "^1.63.0",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.20.1",
"@types/pdf-parse": "^1.1.5",
Expand Down
37 changes: 30 additions & 7 deletions src/app/admin/modules/[moduleKey]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,30 @@ import { redirect, notFound } from "next/navigation";
import { GovHeader, getNavigationForRole } from "@/components/ui/header";
import { GovFooter } from "@/components/ui/footer";
import { requireRole } from "@/lib/permissions";
import { getModuleByKey } from "@/lib/modules/registry";
import { getModuleBuilderOptions, getModuleForBuilder } from "@/lib/modules/registry";
import { toModuleDefinition } from "@/lib/modules/definition";
import { ModuleBuilder } from "@/components/admin/module-builder";

export const dynamic = "force-dynamic";

export default async function ModuleEditPage({
params,
searchParams,
}: {
params: Promise<{ moduleKey: string }>;
searchParams: Promise<{ tab?: string; saved?: string }>;
}) {
const session = await requireRole("ADMIN").catch(() => null);
if (!session) redirect("/auth/login?callbackUrl=/admin");

const resolvedParams = await params;
const module = await getModuleByKey(resolvedParams.moduleKey);
const [module, options, query] = await Promise.all([
getModuleForBuilder(resolvedParams.moduleKey),
getModuleBuilderOptions(),
searchParams,
]);
if (!module) return notFound();
const version = module.versions[0];

return (
<>
Expand All @@ -43,11 +51,26 @@ export default async function ModuleEditPage({
</nav>

<ModuleBuilder
moduleKey={module.moduleKey}
displayName={module.displayName}
category={module.category}
moduleId={module.id}
version={module.activeVersion}
key={module.id}
userId={session.user.id}
options={{ ...options, uploadLimitMb: Number(process.env.MAX_FILE_SIZE_MB) || 10 }}
initialTab={query.tab}
notice={query.saved === "1" ? "Draft module created. It is disabled and not accepting applications." : undefined}
initial={{
moduleId: module.id,
moduleKey: module.moduleKey,
displayName: module.displayName,
category: module.category,
enabled: module.enabled,
versionId: version.id,
versionNumber: version.version,
definition: toModuleDefinition(version),
liveVersion: module.liveVersion,
history: module.versions.map((entry) => ({
id: entry.id, version: entry.version, visibility: entry.visibility,
isActive: entry.isActive, createdAt: entry.createdAt.toISOString(),
})),
}}
/>
</div>
</main>
Expand Down
161 changes: 6 additions & 155 deletions src/app/admin/modules/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,73 +2,16 @@ import Link from "next/link";
import { redirect } from "next/navigation";
import { GovFooter } from "@/components/ui/footer";
import { GovHeader, getNavigationForRole } from "@/components/ui/header";
import { createLicenceModule } from "@/lib/modules/registry";
import { ModuleBuilder } from "@/components/admin/module-builder";
import { getModuleBuilderOptions } from "@/lib/modules/registry";
import { requireRole } from "@/lib/permissions";

export const dynamic = "force-dynamic";

function normaliseModuleKey(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[\s-]+/g, "_")
.replace(/[^a-z0-9_]/g, "");
}

async function createModuleAction(formData: FormData) {
"use server";
export default async function NewModulePage() {
const session = await requireRole("ADMIN").catch(() => null);
if (!session) redirect("/auth/login?callbackUrl=/admin/modules/new");

const moduleKey = normaliseModuleKey(String(formData.get("moduleKey") || ""));
const displayName = String(formData.get("displayName") || "").trim();
const category = String(formData.get("category") || "").trim();
const publicDescription = String(
formData.get("publicDescription") || "",
).trim();

if (!/^[a-z][a-z0-9_]{2,63}$/.test(moduleKey)) {
redirect("/admin/modules/new?error=invalid-key");
}
if (displayName.length < 3 || displayName.length > 120) {
redirect("/admin/modules/new?error=invalid-name");
}
if (category.length < 2 || category.length > 80) {
redirect("/admin/modules/new?error=invalid-category");
}

let module: Awaited<ReturnType<typeof createLicenceModule>>;
try {
module = await createLicenceModule(
{ moduleKey, displayName, category, publicDescription },
session.user.id,
);
} catch (error) {
if (error instanceof Error && error.message === "MODULE_KEY_EXISTS") {
redirect("/admin/modules/new?error=duplicate-key");
}
throw error;
}

redirect(`/admin/modules/${module.moduleKey}`);
}

const errorMessages: Record<string, string> = {
"invalid-key":
"Module key must start with a letter and contain 3 to 64 lowercase letters, numbers, or underscores.",
"invalid-name": "Display name must contain 3 to 120 characters.",
"invalid-category": "Category must contain 2 to 80 characters.",
"duplicate-key": "A module with that key already exists.",
};

export default async function NewModulePage({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
const session = await requireRole("ADMIN").catch(() => null);
if (!session) redirect("/auth/login?callbackUrl=/admin/modules/new");
const { error } = await searchParams;
const options = await getModuleBuilderOptions();

return (
<>
Expand All @@ -79,7 +22,7 @@ export default async function NewModulePage({
userRole={session.user.role}
/>
<main className="govuk-main-wrapper" id="main-content">
<div className="govuk-container max-w-govuk-two-thirds">
<div className="govuk-container">
<nav className="govuk-breadcrumbs mb-6" aria-label="Breadcrumb">
<ol className="govuk-breadcrumbs__list">
<li className="govuk-breadcrumbs__list-item">
Expand All @@ -89,99 +32,7 @@ export default async function NewModulePage({
</ol>
</nav>

<h1>Create module</h1>
<p className="text-govuk-dark-grey max-w-2xl">
The module starts disabled, in draft, and not accepting applications.
Configure and review its form, evidence, workflow, fees, and content
before publishing and enabling it.
</p>

{error && errorMessages[error] && (
<div className="govuk-warning-text" role="alert">
<strong>There is a problem:</strong> {errorMessages[error]}
</div>
)}

<form action={createModuleAction} className="max-w-2xl mt-6">
<div className="govuk-form-group">
<label className="govuk-label" htmlFor="displayName">
Display name
</label>
<p className="govuk-hint">For example, Market operator permit.</p>
<input
className="govuk-input"
id="displayName"
name="displayName"
minLength={3}
maxLength={120}
required
/>
</div>

<div className="govuk-form-group">
<label className="govuk-label" htmlFor="moduleKey">
Module key
</label>
<p className="govuk-hint">
Stable identifier using lowercase letters, numbers, and
underscores. It cannot be changed after creation.
</p>
<input
className="govuk-input font-mono"
id="moduleKey"
name="moduleKey"
pattern="[A-Za-z][A-Za-z0-9 _]{2,63}"
minLength={3}
maxLength={64}
required
/>
</div>

<div className="govuk-form-group">
<label className="govuk-label" htmlFor="category">
Category
</label>
<p className="govuk-hint">
Use an existing category name where possible.
</p>
<input
className="govuk-input"
id="category"
name="category"
minLength={2}
maxLength={80}
required
/>
</div>

<div className="govuk-form-group">
<label className="govuk-label" htmlFor="publicDescription">
Initial public description
</label>
<p className="govuk-hint">
Optional. You can refine this in the module builder.
</p>
<textarea
className="govuk-textarea"
id="publicDescription"
name="publicDescription"
rows={4}
maxLength={2000}
/>
</div>

<div className="flex flex-wrap gap-3">
<button type="submit" className="govuk-button">
Create draft module
</button>
<Link
href="/admin"
className="govuk-button govuk-button--secondary no-underline"
>
Cancel
</Link>
</div>
</form>
<ModuleBuilder userId={session.user.id} options={{ ...options, uploadLimitMb: Number(process.env.MAX_FILE_SIZE_MB) || 10 }} />
</div>
</main>
<GovFooter />
Expand Down
Loading
Loading