Skip to content

Repository files navigation

Inventory Info — Next.js rewrite

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

⚠️ This is a reference copy — it will not build or run

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 build fails at prisma generate — there is no schema to generate from
  • npm run typecheck fails — 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.


Getting started

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:seed

Then open http://localhost:3000.

First boot takes a couple of minutes (image build + npm install). Watch it with docker compose logs -f web.

Demo accounts

All use the password password:

Email 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, …).


Common commands

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.sh

End-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 spec

Specs 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 inventory

How it is organised

prisma/
  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

Six things worth reading before adding features

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) or requireAccess(capability)
  • server actions re-check with assertCapability / assertSameCompany from src/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.


Deployment

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.


Migration status

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

Dropped from the plan

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.

Known gaps

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.

Carried over deliberately differently

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

Two defects in the original, not carried forward

  • routes/api.php referenced Api\AuthController and Api\EquipmentController, neither of which exists — those routes 500 in the Laravel app today.
  • The v1_0_0 API had its auth:sanctum middleware commented out, leaving equipment add/remove endpoints unauthenticated. Phase 6 authenticates every API route against api_tokens.

About

Multi-tenant equipment inventory system built with Next.js 15, TypeScript, Prisma and Auth.js v5. Asset records with custom fields and media, project-scoped permissions, booking approvals, spreadsheet import with column mapping, and XLSX/PDF/ZIP export. Public reference copy — the database layer is withheld, so it does not build.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages