A multi-tenant equipment-inventory application: asset records with custom fields and media, project-scoped access grants, booking requests and approvals, spreadsheet import with column mapping and validation, and XLSX/PDF/ZIP export.
Stack: Next.js 15 (App Router) · TypeScript · Prisma · MySQL 8.4 · Auth.js v5 · Tailwind CSS v4
The database layer has been deliberately withheld from this public repository.
prisma/schema.prisma, the migration history and the seed script are not
included, so:
npm run buildfails atprisma generate— there is no schema to generate fromnpm run typecheckfails — there are no generated Prisma model types- there is no way to create the database, and no seed accounts to sign in with
This is intentional, not an oversight or a missing commit. Please do not open issues asking for the schema; it is not going to be published. The repository is here so the architecture, UI layer and code style can be read, not so the application can be deployed.
See LICENSE — all rights reserved. Reading this code does not grant a licence to use it.
Data: any records referenced in the documentation are entirely fictional. There has never been production data in this project.
Requires Docker. No host Node or MySQL install is needed.
cd web
cp .env.example .env
docker compose up -d # installs deps, applies migrations, starts dev server
docker compose run --rm web npm run db:seedThen open http://localhost:3000.
First boot takes a couple of minutes (image build + npm install). Watch it
with docker compose logs -f web.
All use the password password:
| Role | Sees | |
|---|---|---|
admin@inventory.test |
Super Admin | companies, email templates and site settings — no equipment |
admin@northwind.test |
Administrator | everything in Northwind Plant Hire |
owner@northwind.test |
Owner | same list; may only edit equipment they own |
user@northwind.test |
User | only projects they hold a grant on |
Start with the Administrator to see the product. The Super Admin runs the
platform and belongs to no company, so /equipment, /projects and the rest
return 404 for it — by design (navAccess in src/lib/permissions.ts reads
equipment: !superAdmin), not because they are missing. The sign-in screen
spells this out for each role.
Companies 2 and 3 follow the same pattern
(admin@meridian.test, owner1@harbour.test, …).
Everything runs inside the container:
docker compose run --rm web npm run typecheck # tsc --noEmit
docker compose run --rm web npm run build # production build
docker compose run --rm web npm run db:seed # wipe + regenerate demo data
docker compose run --rm web npm run db:migrate # create a migration after schema edits
docker compose run --rm web npm run db:studio # Prisma Studio on :5555
docker compose logs -f web # dev server output
# build the Hostinger bundle, then smoke-test it the way Hostinger runs it.
# Safe to run while the dev server is up: it builds into .next-build, not .next.
docker compose run --rm web npm run build:hostinger
docker compose run --rm web bash scripts/verify-bundle.shEnd-to-end acceptance (drives a real browser against the dev server, reseeds first, so never point it at data you care about):
bash scripts/e2e.sh # every spec
bash scripts/e2e.sh projects # one specSpecs live in e2e/ and screenshots land in e2e/artifacts/. They resolve
row ids from MySQL at run time rather than hardcoding them — reseeding advances
the auto-increment counters, so any fixed id goes stale on the next run. Each
spec prints how many checks it ran and fails loudly if that is zero, because a
spec that silently executes nothing otherwise reads as a pass.
Currently 221 checks: 15 projects, 35 Phase 2, 35 Phase 3, 55 Phase 4, 35 Phase 5, 19 settings, 27 email templates.
Direct MySQL access:
docker exec -it inventory-db mysql -uinventory -pinventory inventoryprisma/
schema.prisma data model, ported from the Laravel migrations
seed.ts all demo data
src/
auth.config.ts edge-safe Auth.js config (used by middleware)
auth.ts Credentials provider — Node runtime only
middleware.ts route protection
lib/
permissions.ts THE authority on who can see and do what
equipment-status.ts derived equipment status
session.ts requireUser() / requirePermissions()
queries/ scoped data access
app/
login/ unauthenticated
(app)/ everything behind auth, wrapped in the shell
api/files/ authorised media serving
api/equipment/ QR generation (rendered per request, never stored)
components/
ui/ primitives
shell/ sidebar, topbar, theme toggle
docker/ local dev image + MySQL init
scripts/ Hostinger bundle assembly
1. Permissions live in exactly one place.
The Laravel app duplicated its equipment logic across TenantAdmin/,
TenantOwner/ and TenantUser/ controller trees — 4,300 lines with 78%
identical content. That is collapsed into
src/lib/permissions.ts. Every list query must
spread equipmentScope(perms) into its where; every mutation must go through
canEditEquipment / canDeleteEquipment. Do not reintroduce role branching in
page components.
Authorisation is enforced in two places, and both are required:
- pages call
requirePermissions(capability)orrequireAccess(capability) - server actions re-check with
assertCapability/assertSameCompanyfromsrc/lib/guards.ts
A server action is its own endpoint. Guarding the page that renders a form does nothing for the action that form posts to, so actions never trust the page they came from — they re-derive the caller and re-check tenancy on the submitted id.
2. Uploads are never served from public/.
Photos and documents are written to UPLOADS_DIR (default var/uploads) and
served by /api/files/image/[id] and /api/files/document/[id], which resolve
the disk path from the database row and check the caller may see the owning
equipment. No path from a request ever reaches the filesystem, so traversal is
unrepresentable rather than merely filtered. Documents are always sent as
attachments. See src/lib/storage.ts.
3. Equipment status is derived, never stored.
There is no status column. An item's status is computed from the
equipment_requests rows overlapping the current instant, resolved by priority
(decommissioned > maintenance > reserved-approved > reserved-pending >
available). See src/lib/equipment-status.ts.
Adding a status column would put two sources of truth in conflict — the old app
deliberately avoided it too.
4. Bookings decide availability; status only reports it.
There is still no status column. Reserving, scheduling maintenance and
decommissioning all write equipment_requests rows through
src/lib/reservations.ts, and
equipment-status.ts derives what the item is from them. Keep it that way:
the rules about overlap, the decommission lock and who may approve are one code
path, and a second source of truth would immediately disagree with it.
Three rules that look like UI but are not — all enforced server-side and all
covered by e2e/phase5.py:
- a reservation may not overlap anything live; maintenance may, but only after
the server has asked and been answered (
ActionState.confirm) - a decommissioned item accepts no further bookings of any kind
- an issue may only be reported by whoever has the item booked out right now, or by someone who manages its bookings
5. A setting that changes nothing does not belong in SETTING_DEFINITIONS.
Every key in src/lib/settings.ts is read somewhere:
the site name in the shell, tab title and sign-in screen; the support email on
the sign-in screen; the auto-approve threshold in decide(); the page size on
the equipment list. e2e/settings.py changes each one and asserts the
consequence elsewhere, so an inert setting fails the suite. Defaults live in
code as well as the database, so a fresh or truncated table still behaves.
6. The export formats are written by hand, with no library.
.xlsx, .zip and .pdf are produced by src/lib/export/:
a ZIP writer (zip.ts), a workbook writer on top of it (xlsx.ts, since an
xlsx is a zip of XML), and a table PDF writer (pdf.ts) that uses only the
standard Helvetica metrics, which are inlined there. The reason is the target
platform: the obvious dependency for PDF reads font metrics from .afm files at
runtime, which Next's standalone tracer does not follow, and shared hosting
bills in disk and inodes. Nothing here is embedded and nothing has to be traced.
Because they are hand-rolled, the only meaningful test is a real parser
accepting the bytes — e2e/phase4.py downloads all four formats from the
running app and opens them with openpyxl, pypdf and zipfile. Change these
files and run that spec.
Production is Hostinger Business Hosting via its Node.js Web App feature, with Hostinger's managed MySQL. Docker is not used in production.
See DEPLOYMENT.md.
| Phase | Scope | State |
|---|---|---|
| 1 | Scaffold, schema, auth + RBAC, seed, shell, equipment list | done |
| 2 | Projects, locations, categories, custom fields, users, companies | done |
| 3 | Equipment CRUD: images, documents, QR, groups, bulk ops | done |
| 4 | CSV/XLSX import pipeline, Excel/PDF/ZIP export | done |
| 5 | Reservations, approvals, reported issues, favorites, dashboards | done |
| 6 | Site settings | done |
The public REST API, the PWA and the QR scanner were cut by decision, not
deferred. api_tokens and api_logs remain in the schema and seed but nothing
reads them.
Not built, and nothing in the app pretends otherwise:
| Gap | Consequence today |
|---|---|
| No email is ever sent | No SMTP transport is configured. Approving, rejecting or reporting notifies nobody — people find out by revisiting the page. The templates themselves are authored and previewed at /email-templates; only delivery is missing. |
| No password reset | /forgot-password and /reset-password are allowlisted in auth.config.ts and password_reset_tokens exists, but neither page does. Someone locked out needs an administrator. The sign-in screen offers the support_email setting for exactly this reason. |
| No scheduled jobs | Pending requests are never auto-approved or chased, and abandoned import batches and their uploaded files are never cleaned up. |
| No user invitations | users.invitation_status exists; users are created directly with a password. Depends on email. |
| No test send | The template editor's Send test email button is present but disabled — there is no transport behind it. |
Every nav destination is a real screen. Nothing in the app resolves to a phase placeholder.
Three of the six templates describe moments that genuinely occur (a reservation awaiting approval, maintenance cancelling a booking, a decommission). The other three — invitation, password reset, export ready — describe features that were never built, so the list marks them Never fires rather than letting them imply working plumbing.
| Laravel | Here | Why |
|---|---|---|
equipment.custom_field JSON blob, searched with JSON_SEARCH |
equipment_custom_field_values table |
indexable and joinable |
user_permissions.permission_lists JSON map |
field_permissions table |
same |
equipment.owner_name varchar holding a user id |
equipment.owner_id FK |
it was already a foreign key in disguise |
equipment.uploaded_docs longText array |
equipment_documents table |
real rows, real metadata |
tmp_equipment + jobs |
import_batches / import_batch_rows |
no queue daemon on shared hosting |
Sanctum personal_access_tokens |
api_tokens |
stores a hash, not the token |
equipment.qr_image PNG written at create time |
generated per request | the stored file went stale when the site URL changed and orphaned on delete |
uploads under public/ |
UPLOADS_DIR + authorised routes |
tenant files must not be readable by URL guessing |
| Reports hard-deleted on "solve" | status = RESOLVED + resolved_at |
deleting the audit trail lost the history |
| Rejection reason discarded after emailing it | stored on the request, shown to the requester | the reason only existed in an inbox, so the same request came back |
changeEquipmentStatus: ~280 lines of validation, conflict checks, mail and persistence in one controller method |
lib/reservations.ts (rules) + one server action (authorisation and writes) |
the rules are testable on their own |
| Import resolved lookups while inserting | validate every row first, then commit | an unknown category was written as NULL and an unknown location aborted the run half way |
| Import mapping held in the PHP session | stored on import_batches |
the import was lost when the session rolled over |
incrementWithLeadingZeros = (int)$value + 1 |
increment the trailing digit run | the original evaluated EQ-0009 to 0 and returned 1, so sequential codes only ever worked for all-digit values |
routes/api.phpreferencedApi\AuthControllerandApi\EquipmentController, neither of which exists — those routes 500 in the Laravel app today.- The
v1_0_0API had itsauth:sanctummiddleware commented out, leaving equipment add/remove endpoints unauthenticated. Phase 6 authenticates every API route againstapi_tokens.