← Back
003 OIKORA

OIKORA

Business, in sync

ActiveAdvancedUpdated Oct 2026View Live
Role
Solo build
Type
Personal · Live
Systems
Next.js · Prisma · PostgreSQL
Scale
Multi-tenant, org-isolated, 12 phases shipped
01

Objective

Products, Purchases, and Sales are three connected views onto one thing: a movement ledger that's the single source of truth for every quantity in the system. That's what the name is about — one system, three sides of it, always in sync.

Every business table carries an organizationId, and every company ("Organization") gets fully isolated Products, Purchases, and Sales on the same deployment — a user can belong to more than one organization and switch between them from the nav without signing out, with a per-membership role (OWNER / ADMIN / STOREKEEPER / STAFF) so the same person can own one company and be plain staff at another.

Around that core it has grown into a full operations suite: stock counts with discrepancy adjustments, product bundles, partial purchase-order receiving, sales and purchase returns, draft-order stock reservation, an inventory valuation report, supplier on-time-delivery tracking, reorder and inter-location transfer suggestions, global search, audit-log and failed-login trails, optional TOTP two-factor, read-only API keys, in-app notifications with per-event preferences, and daily or weekly digest emails.

Why this is hard: multi-tenancy isn't a feature you bolt on — it's a property every single query has to uphold, forever. One route that forgets a where clause is a data breach between two paying customers, not a bug ticket. The isolation guarantee has to be enforced structurally, not by developer discipline alone.

02

Decisions

Multi-tenancy enforced at the query layer, not by convention

A Prisma Client Extension (scopedDb()) auto-injects the current organizationId into every read/update/delete issued through it, so one organization's data can't leak into another's just because a route forgot a where clause. A scripted audit (npm run audit:org-scoping, also run in CI) checks that every API route touching a tenant-scoped model uses the scoped client rather than the raw one — and a dedicated integration suite fires many concurrent cross-org reads/writes at it to prove the isolation holds under real load, not just in a single request.

Race-safe stock mutations

Receive/issue/transfer flows are atomic and race-safe: concurrent stock-out requests can't oversell, enforced via a conditional updateMany (UPDATE ... WHERE quantity >= ? RETURNING) rather than a read-then-write check that two simultaneous requests could both pass. The same claim/guard idiom is reused for the background-job queue's claim query.

A background job queue with no Redis

src/lib/job-worker.ts implements a minimal, Postgres-backed job queue (a Job table with PENDING/RUNNING/DONE/FAILED status) instead of standing up Redis or a queue service. CSV import/export enqueue a Job and return immediately; the UI polls GET /api/jobs/[id] instead of blocking on a request a large file could time out. A worker endpoint claims pending jobs with the same race-safe conditional updateMany used for stock, so concurrent invocations never double-process a job, and runs them in batches of 20.

Order and stock changes lock first, then apply relative deltas

A production-scale pass found the read-then-write shape was still racy in places. Fulfilling, receiving, returning, status changes and stock counts now lock the order row and apply relative changes (quantity = quantity + n) instead of writing back a value read earlier, so two requests on the same order can't both succeed and drift stock. A concurrency-safety integration suite races them against each other to prove it.

Membership and role re-checked on every request

A JWT alone would keep working after someone was removed from an organization or demoted. Every request now re-checks the membership and its role, and a password reset ends existing sessions, so access changes take effect immediately instead of whenever the token expires. A session-revocation test covers it.

Pre-aggregated analytics

A daily rollup job pre-aggregates each day's stock and sales activity into summary tables, so dashboard analytics reads a bounded rollup for historical data and only falls back to a live query for today's not-yet-rolled-up activity — the query cost doesn't grow with how much transaction history accumulates. Lists, exports and deep product pages use keyset paging and SQL-side aggregation rather than offset scans or loading rows into memory.

03

Architecture

Request path

Client → Auth.js session → App Router route → scopedDb() extension → PostgreSQL (org-scoped)

  • Every tenant-scoped model is registered in ORG_SCOPED_MODELS so the extension auto-scopes it — a route can't opt out by accident.
  • requireAdmin and a canManage flag enforce the same OWNER/ADMIN-only boundary on both the API route and the UI, so STOREKEEPER/STAFF see a read-only view instead of a form that would just fail server-side.

Background job pipeline

Route enqueues Job (Postgres) → Queue runs right after the response → Worker claims it (race-safe) → UI polls for DONE/FAILED

  • Vercel's Hobby plan only allows daily crons, so the route that enqueues a job runs that organization's queue as soon as its response is sent, and the UI can also trigger it on demand. The daily sweep is the fallback: it re-queues jobs whose worker died mid-run, fails them after three attempts, and deletes finished jobs older than seven days.
  • Imports run in batches and exports in chunks, so a large file is many short invocations instead of one that can time out.
04

Screens

Location and employee names anonymized — this is live production data.

Dashboard — stock value, low-stock count, top-moved products and customers, 7-day trends
Products — searchable/filterable list with low-stock badges and CSV export/import
Stock — receive, issue, and transfer across locations, with current levels below
Reorder Suggestions — products below their reorder point
Dark mode — theme toggle and account menu
05

Result

A live, deployed multi-tenant inventory system covering the full Products / Purchases / Sales cycle — org switching, role-scoped permissions, purchase and sales order lifecycles, CSV import/export, stock counts, returns, bundles, valuation, two-factor, API keys, digests, and a real-time-feeling dashboard backed by pre-aggregated rollups. Org isolation and stock correctness are proven under concurrent load by dedicated integration suites, not just asserted by code review. Twelve roadmap phases are done; the remaining plan (packaging hierarchy, sales and purchasing documents, warehouse operations, light manufacturing, integrations) is scoped but not built.

06

By the numbers

4
USER ROLES
242
UNIT TESTS
12
INTEGRATION SUITES
6
CRON JOBS
07

Stack

Next.js, TypeScript, Tailwind, Prisma, PostgreSQL, Auth.js, Vitest, Playwright

08

Lessons learned

  • The org-scoping guarantee needed more than a code review to trust — a scripted audit plus a dedicated integration suite firing concurrent cross-org reads/writes was necessary to actually prove isolation holds under real load, not just in a single request.
  • An early CSV import ran synchronously on the request, risking timeouts on large files. Moving it to a Postgres-backed job queue with UI polling avoided pulling in Redis just to make one import path non-blocking.
  • The primary hosted Postgres instance was repeatedly unreachable mid-build — the fallback became a documented local-Postgres workaround (fresh scratch database, replay every migration, reseed, run the integration suite twice back to back to confirm zero residue) rather than skipping verification when the database was down.
  • Features-first building left real races behind: several flows that looked atomic read a value, checked it, then wrote it back. A deliberate hardening pass, locking rows and moving to relative updates with integration tests that race the requests, caught what unit tests never would. I now treat "passes sequentially" as the weaker half of correctness for anything that touches stock.