feat(creditors): Creditors is its own user-settings section for office users
Adds "creditors" to the grantable modules (office users only; site managers need it switched on and the settings screen does not offer it). Creditors pages, APIs and statement/invoice files check this section instead of Financial. Migration 2026-11-10-creditors-module.py adds it to users.modules (applied). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
954a92e777
commit
e089cbc000
11 changed files with 162 additions and 17 deletions
90
scripts/migrations/2026-11-10-creditors-module.py
Normal file
90
scripts/migrations/2026-11-10-creditors-module.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""Add "creditors" to the users.modules choices so Office users can be given Creditors. Dry-run by default."""
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
NEW_MODULE = "creditors"
|
||||
|
||||
|
||||
def prepare_fields(collection):
|
||||
if collection.get("type") != "auth":
|
||||
raise RuntimeError("users must be an auth collection")
|
||||
fields = copy.deepcopy(collection["fields"])
|
||||
modules = next((field for field in fields if field["name"] == "modules"), None)
|
||||
if not modules or modules.get("type") != "select":
|
||||
raise RuntimeError("users.modules must be the existing select field (run 2026-10-07-office-access.py first)")
|
||||
if NEW_MODULE not in modules["values"]:
|
||||
modules["values"].append(NEW_MODULE)
|
||||
modules["maxSelect"] = max(modules.get("maxSelect", 0), len(modules["values"]))
|
||||
return fields
|
||||
|
||||
|
||||
def environment(path):
|
||||
values = {}
|
||||
if path.is_file():
|
||||
for raw in path.read_text().splitlines():
|
||||
line = raw.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
key, value = line.split("=", 1)
|
||||
values[key.strip()] = value.strip().strip('"').strip("'")
|
||||
return {**values, **os.environ}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--env-file", type=Path, default=Path(__file__).resolve().parents[2] / ".env.local")
|
||||
parser.add_argument("--apply", action="store_true")
|
||||
parser.add_argument("--confirm-host", help="Exact PocketBase base URL for applying the additive schema update")
|
||||
args = parser.parse_args()
|
||||
env = environment(args.env_file)
|
||||
base = (env.get("POCKETBASE_URL") or env.get("NEXT_PUBLIC_POCKETBASE_URL") or "").rstrip("/")
|
||||
email, password = env.get("POCKETBASE_ADMIN_EMAIL"), env.get("POCKETBASE_ADMIN_PASSWORD")
|
||||
if not base or not email or not password:
|
||||
parser.error("PocketBase URL and superuser credentials are required")
|
||||
if args.apply and args.confirm_host != base:
|
||||
parser.error("--apply requires --confirm-host matching the configured PocketBase URL")
|
||||
|
||||
def api(method, path, token=None, body=None):
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
request = urllib.request.Request(f"{base}{path}", data=data, method=method)
|
||||
if data is not None:
|
||||
request.add_header("Content-Type", "application/json")
|
||||
if token:
|
||||
request.add_header("Authorization", token)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
return json.load(response)
|
||||
except urllib.error.HTTPError as error:
|
||||
raise RuntimeError(f"{method} {path} failed (HTTP {error.code})") from None
|
||||
|
||||
print(f"Target: {base} | {'APPLY' if args.apply else 'DRY RUN'}")
|
||||
auth = api("POST", "/api/collections/_superusers/auth-with-password", body={"identity": email, "password": password})
|
||||
token = auth["token"]
|
||||
users = api("GET", "/api/collections/users", token)
|
||||
fields = prepare_fields(users)
|
||||
if fields == users["fields"]:
|
||||
print("users.modules already offers Creditors. No changes needed.")
|
||||
return
|
||||
print("Add 'creditors' to users.modules; preserve current users, their sections and collection rules.")
|
||||
if not args.apply:
|
||||
print("Dry run complete. Apply this schema before deploying the Creditors section setting.")
|
||||
return
|
||||
api("PATCH", "/api/collections/users", token, {"fields": fields})
|
||||
verified = api("GET", "/api/collections/users", token)
|
||||
if prepare_fields(verified) != verified["fields"]:
|
||||
raise RuntimeError("Creditors module schema verification failed")
|
||||
print("Creditors module schema verified. No user was changed.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (RuntimeError, urllib.error.URLError) as error:
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
39
scripts/migrations/test_2026_11_10_creditors_module.py
Normal file
39
scripts/migrations/test_2026_11_10_creditors_module.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import copy
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
spec = importlib.util.spec_from_file_location("creditors_module", Path(__file__).with_name("2026-11-10-creditors-module.py"))
|
||||
migration = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(migration)
|
||||
|
||||
|
||||
class CreditorsModuleTests(unittest.TestCase):
|
||||
def collection(self):
|
||||
return {"type": "auth", "fields": [
|
||||
{"name": "email", "type": "email"},
|
||||
{"name": "modules", "type": "select", "maxSelect": 5, "values": ["overview", "sites", "workforce", "plant", "financial"]},
|
||||
]}
|
||||
|
||||
def test_adds_the_value_and_widens_max_select(self):
|
||||
original = self.collection()
|
||||
snapshot = copy.deepcopy(original)
|
||||
fields = migration.prepare_fields(original)
|
||||
modules = next(f for f in fields if f["name"] == "modules")
|
||||
self.assertEqual(modules["values"][-1], "creditors")
|
||||
self.assertEqual(modules["maxSelect"], 6)
|
||||
self.assertEqual(fields[0], snapshot["fields"][0])
|
||||
self.assertEqual(original, snapshot)
|
||||
|
||||
def test_second_run_is_a_no_op(self):
|
||||
collection = self.collection()
|
||||
collection["fields"] = migration.prepare_fields(collection)
|
||||
self.assertEqual(migration.prepare_fields(collection), collection["fields"])
|
||||
|
||||
def test_requires_the_modules_field(self):
|
||||
with self.assertRaises(RuntimeError):
|
||||
migration.prepare_fields({"type": "auth", "fields": []})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -8,7 +8,7 @@ import { isSameOrigin } from "@/lib/agent/same-origin";
|
|||
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
if (!isSameOrigin(request)) return NextResponse.json({ error: "Request origin is not allowed" }, { status: 403 });
|
||||
if (authMode() === "users") {
|
||||
const user = await requireApiUser("financial");
|
||||
const user = await requireApiUser("creditors");
|
||||
if (user instanceof Response) return user;
|
||||
}
|
||||
const { id } = await params;
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export async function POST(request: Request) {
|
|||
if (!isSameOrigin(request)) return NextResponse.json({ error: "Request origin is not allowed" }, { status: 403 });
|
||||
let uploadedBy = "";
|
||||
if (authMode() === "users") {
|
||||
const user = await requireApiUser("financial");
|
||||
const user = await requireApiUser("creditors");
|
||||
if (user instanceof Response) return user;
|
||||
uploadedBy = user.email ?? user.id ?? "";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { isSameOrigin } from "@/lib/agent/same-origin";
|
|||
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
if (!isSameOrigin(request)) return NextResponse.json({ error: "Request origin is not allowed" }, { status: 403 });
|
||||
if (authMode() === "users") {
|
||||
const user = await requireApiUser("financial");
|
||||
const user = await requireApiUser("creditors");
|
||||
if (user instanceof Response) return user;
|
||||
}
|
||||
const { id } = await params;
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export async function POST(request: Request) {
|
|||
if (!isSameOrigin(request)) return NextResponse.json({ error: "Request origin is not allowed" }, { status: 403 });
|
||||
let uploadedBy = "";
|
||||
if (authMode() === "users") {
|
||||
const user = await requireApiUser("financial");
|
||||
const user = await requireApiUser("creditors");
|
||||
if (user instanceof Response) return user;
|
||||
uploadedBy = user.email ?? user.id ?? "";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ const ALLOWED_COLLECTIONS = new Set([
|
|||
"creditor_invoices",
|
||||
]);
|
||||
|
||||
/** Creditor documents belong to a supplier, not a site, so Financial access is the whole check. */
|
||||
/** Creditor documents belong to a supplier, not a site, so Creditors access is the whole check. */
|
||||
const NO_SITE_COLLECTIONS = new Set(["creditor_statements", "creditor_invoices"]);
|
||||
|
||||
export async function GET(
|
||||
|
|
@ -53,7 +53,8 @@ export async function GET(
|
|||
params: Promise<{ collection: string; recordId: string; filename: string[] }>;
|
||||
},
|
||||
) {
|
||||
const user = await requireApiUser("financial");
|
||||
const { collection: requested } = await params;
|
||||
const user = await requireApiUser(NO_SITE_COLLECTIONS.has(requested) ? "creditors" : "financial");
|
||||
if (user instanceof Response) return user;
|
||||
const { collection, recordId, filename } = await params;
|
||||
|
||||
|
|
|
|||
|
|
@ -423,7 +423,7 @@ function UserDialog({ open, onOpenChange, user, sites, isAdmin, onWarning }: {
|
|||
{role === "office_user" && <>
|
||||
<fieldset className="min-w-0 rounded-lg border border-border p-3"><legend className="px-1 text-sm font-medium">Sections</legend>
|
||||
<label className="block text-sm">Access preset<select value={modules.length === 1 ? modules[0] : "custom"} onChange={(e) => { if (e.target.value !== "custom") setModules([e.target.value as DashboardModule]); }} className="mt-1 min-h-11 w-full rounded-md border border-input bg-background px-3 py-2">
|
||||
<option value="workforce">Workforce</option><option value="financial">Financial</option><option value="plant">Plant</option><option value="custom">Custom</option>
|
||||
<option value="workforce">Workforce</option><option value="financial">Financial</option><option value="creditors">Creditors</option><option value="plant">Plant</option><option value="custom">Custom</option>
|
||||
{modules.length === 1 && (modules[0] === "overview" || modules[0] === "sites") && <option value={modules[0]}>{MODULE_LABELS[modules[0]]}</option>}
|
||||
</select></label>
|
||||
<div className="mt-2 space-y-1">{DASHBOARD_MODULES.map((module) => <label key={module} className="flex min-h-11 items-center gap-3 py-1 text-sm"><input type="checkbox" className="h-5 w-5 shrink-0 accent-primary" checked={modules.includes(module)} onChange={() => setModules((current) => current.includes(module) ? current.filter((item) => item !== module) : [...current, module])} />{MODULE_LABELS[module]}</label>)}</div>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
export type Role = "admin" | "site_user" | "clock_only" | "office_user";
|
||||
|
||||
export const DASHBOARD_MODULES = ["overview", "sites", "workforce", "plant", "financial"] as const;
|
||||
export const DASHBOARD_MODULES = ["overview", "sites", "workforce", "plant", "financial", "creditors"] as const;
|
||||
export type DashboardModule = typeof DASHBOARD_MODULES[number];
|
||||
// Surveying (site maps) is not a grantable module: everyone who can open
|
||||
// the dashboard sees it, scoped to their own sites.
|
||||
export type AccessArea = "dashboard" | "clock" | "management" | "surveying" | DashboardModule;
|
||||
export const MODULE_LABELS: Record<DashboardModule, string> = {
|
||||
overview: "Overview", sites: "Sites", workforce: "Workforce", plant: "Plant", financial: "Financial",
|
||||
overview: "Overview", sites: "Sites", workforce: "Workforce", plant: "Plant", financial: "Financial", creditors: "Creditors",
|
||||
};
|
||||
export const ROLE_LABELS: Record<Role, string> = {
|
||||
admin: "Admin", site_user: "Site manager", clock_only: "Clock user", office_user: "Office user",
|
||||
|
|
@ -62,10 +62,10 @@ export function canSeeWorkerRates(user: AppUser): boolean {
|
|||
return user.role === "admin" || user.hideRates !== true;
|
||||
}
|
||||
|
||||
/** Sites shows whether a job makes money, so site managers see it only when switched on. */
|
||||
/** Sites shows whether a job makes money, and Creditors shows what the company owes suppliers, so site managers see them only when switched on. */
|
||||
export function canAccessModule(user: AppUser, module: DashboardModule): boolean {
|
||||
if (user.role === "admin") return true;
|
||||
if (user.role === "site_user") return module !== "sites" || user.modules?.includes("sites") === true;
|
||||
if (user.role === "site_user") return (module !== "sites" && module !== "creditors") || user.modules?.includes(module) === true;
|
||||
return user.role === "office_user" && user.modules?.includes(module) === true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,9 +16,6 @@ it.each([
|
|||
["/api/purchase-orders/order/status", true], ["/api/purchase-orders/order/retry", true],
|
||||
["/api/purchase-orders/order/items", true], ["/api/delivery-notes/upload", true],
|
||||
["/api/dn-batches/batch/notes/note/post", true], ["/api/pb-files/purchase_orders/order/document.pdf", true],
|
||||
["/dashboard/creditors", true], ["/dashboard/creditors/suppliers/supplier", true],
|
||||
["/api/creditors/statements", true], ["/api/creditors/invoices/invoice", true],
|
||||
["/api/pb-files/creditor_statements/statement/file.pdf", true],
|
||||
["/api/timesheets/import", false], ["/api/employees/worker/calendar-weather", false],
|
||||
["/dashboard/workforce", false], ["/dashboard/financially-unrelated", false],
|
||||
["/clock", false], ["/api/clock/scan", false], ["/api/clock/vehicle-scan", false],
|
||||
|
|
@ -83,3 +80,19 @@ it("opens Surveying maps to every dashboard user and keeps Sites to admins and o
|
|||
expect(canAccessPath({ ...user, role: "admin" }, path)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/dashboard/creditors", true], ["/dashboard/creditors/suppliers/supplier", true],
|
||||
["/api/creditors/statements", true], ["/api/pb-files/creditor_statements/statement/file.pdf", true],
|
||||
["/dashboard/financial", false], ["/api/suppliers", false],
|
||||
] as const)("Creditors-only office access to %s is %s", (path, allowed) => {
|
||||
expect(canAccessPath({ ...user, modules: ["creditors"] }, path)).toBe(allowed);
|
||||
});
|
||||
|
||||
it("keeps Creditors away from Financial-only office users and from site managers unless switched on", () => {
|
||||
expect(canAccessPath(user, "/dashboard/creditors")).toBe(false);
|
||||
expect(canAccessPath(user, "/api/creditors/statements")).toBe(false);
|
||||
const manager: AppUser = { ...user, role: "site_user", modules: undefined, siteIds: ["a"], allSites: false };
|
||||
expect(canAccessPath(manager, "/dashboard/creditors")).toBe(false);
|
||||
expect(canAccessPath({ ...manager, modules: ["creditors"] }, "/dashboard/creditors")).toBe(true);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,13 +11,15 @@ export function moduleForPath(path: string): DashboardModule | null {
|
|||
}
|
||||
if (within(path, "/dashboard/employees")) return "workforce";
|
||||
if (within(path, "/dashboard/fuel")) return "plant";
|
||||
if (within(path, "/dashboard/purchasing") || within(path, "/dashboard/creditors")) return "financial";
|
||||
if (within(path, "/dashboard/purchasing")) return "financial";
|
||||
if (within(path, "/dashboard/creditors") || within(path, "/api/creditors")) return "creditors";
|
||||
if (/^\/api\/pb-files\/(creditor_statements|creditor_invoices)(\/|$)/.test(path)) return "creditors";
|
||||
if (within(path, "/api/employees") || within(path, "/api/employers") || within(path, "/api/workforce") || within(path, "/api/timesheets") || within(path, "/api/clock/events")) return "workforce";
|
||||
if (["vehicles", "fuel-logs", "fuel-prices", "plant-hours"].some((resource) => within(path, `/api/${resource}`))) return "plant";
|
||||
if (within(path, "/api/sites")) return "sites";
|
||||
if (["suppliers", "creditors", "purchase-orders", "delivery-notes", "dn-batches", "data-import-batches"].some((resource) => within(path, `/api/${resource}`))) return "financial";
|
||||
if (["suppliers", "purchase-orders", "delivery-notes", "dn-batches", "data-import-batches"].some((resource) => within(path, `/api/${resource}`))) return "financial";
|
||||
if (/^\/api\/records\/(purchase_orders|delivery_notes)(\/|$)/.test(path) ||
|
||||
/^\/api\/pb-files\/(purchase_orders|delivery_notes|creditor_statements|creditor_invoices)(\/|$)/.test(path)) return "financial";
|
||||
/^\/api\/pb-files\/(purchase_orders|delivery_notes)(\/|$)/.test(path)) return "financial";
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue