diff --git a/scripts/migrations/2026-11-10-creditors-module.py b/scripts/migrations/2026-11-10-creditors-module.py new file mode 100644 index 0000000..02cb2e5 --- /dev/null +++ b/scripts/migrations/2026-11-10-creditors-module.py @@ -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) diff --git a/scripts/migrations/test_2026_11_10_creditors_module.py b/scripts/migrations/test_2026_11_10_creditors_module.py new file mode 100644 index 0000000..6ac7d29 --- /dev/null +++ b/scripts/migrations/test_2026_11_10_creditors_module.py @@ -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() diff --git a/src/app/api/creditors/invoices/[id]/route.ts b/src/app/api/creditors/invoices/[id]/route.ts index e455c8f..f586d8d 100644 --- a/src/app/api/creditors/invoices/[id]/route.ts +++ b/src/app/api/creditors/invoices/[id]/route.ts @@ -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; diff --git a/src/app/api/creditors/invoices/route.ts b/src/app/api/creditors/invoices/route.ts index c88eff4..20baaaa 100644 --- a/src/app/api/creditors/invoices/route.ts +++ b/src/app/api/creditors/invoices/route.ts @@ -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 ?? ""; } diff --git a/src/app/api/creditors/statements/[id]/route.ts b/src/app/api/creditors/statements/[id]/route.ts index e5f513c..7312ae8 100644 --- a/src/app/api/creditors/statements/[id]/route.ts +++ b/src/app/api/creditors/statements/[id]/route.ts @@ -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; diff --git a/src/app/api/creditors/statements/route.ts b/src/app/api/creditors/statements/route.ts index eb2660f..30050b4 100644 --- a/src/app/api/creditors/statements/route.ts +++ b/src/app/api/creditors/statements/route.ts @@ -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 ?? ""; } diff --git a/src/app/api/pb-files/[collection]/[recordId]/[...filename]/route.ts b/src/app/api/pb-files/[collection]/[recordId]/[...filename]/route.ts index 8c6657e..d9025de 100644 --- a/src/app/api/pb-files/[collection]/[recordId]/[...filename]/route.ts +++ b/src/app/api/pb-files/[collection]/[recordId]/[...filename]/route.ts @@ -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; diff --git a/src/app/dashboard/settings/users/users-view.tsx b/src/app/dashboard/settings/users/users-view.tsx index d547313..6720077 100644 --- a/src/app/dashboard/settings/users/users-view.tsx +++ b/src/app/dashboard/settings/users/users-view.tsx @@ -423,7 +423,7 @@ function UserDialog({ open, onOpenChange, user, sites, isAdmin, onWarning }: { {role === "office_user" && <>