A multi-tenant REST API for managing material inventory with user management, transaction tracking, and plan-based limits. Built with Node.js, Express, TypeScript, Prisma, and PostgreSQL.
┌──────────────────────────────────────────────────────────────┐
│ Client Application │
└────────────────────────────┬─────────────────────────────────┘
│ HTTP Requests
│ (x-tenant-id header)
▼
┌──────────────────────────────────────────────────────────────┐
│ Express Application │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Middleware Layer │ │
│ │ • resolveTenant - Validates tenant & injects context │ │
│ │ • validateBody - Zod schema validation on body │ │
│ │ • errorHandler - Centralizes error responses │ │
│ └────────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Routes Layer │ │
│ │ /health /tenants /users /materials │ │
│ │ /transactions │ │
│ └────────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Controllers Layer │ │
│ │ • Call service methods │ │
│ │ • Format responses via response helpers │ │
│ │ • Parse pagination query params │ │
│ └────────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Services Layer │ │
│ │ • Business logic │ │
│ │ • Tenant isolation (WHERE tenantId = ?) │ │
│ │ • Plan limits enforcement │ │
│ │ • Paginated queries with total counts │ │
│ └────────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Prisma ORM Layer │ │
│ │ • Type-safe database queries │ │
│ │ • Atomic transaction support │ │
│ │ • Migrations + Seed data │ │
│ └────────────────────────────────────────────────────────┘ │
└────────────────────────────┬─────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ PostgreSQL Database │
│ Tables: tenants, users, materials, transactions │
│ Row-Level Isolation: All data scoped by tenantId │
└──────────────────────────────────────────────────────────────┘
1. Client sends: POST /materials/:id/transactions
Headers: { "x-tenant-id": "abc123", "Content-Type": "application/json" }
Body: { "quantity": 50, "type": "IN" }
2. resolveTenant Middleware:
• Extracts tenant ID from header
• Validates tenant exists in database
• Attaches tenantId to req.tenantId
• Returns 400 if missing, 404 if tenant not found
3. validateBody Middleware (Zod):
• Parses and validates quantity (must be positive number)
• Validates type (must be "IN" or "OUT")
• Returns 400 with detailed message on failure
4. Transaction Controller:
• Calls transactionService.createTransaction()
5. Transaction Service:
• Verifies material belongs to tenant (getMaterialById)
• Checks stock sufficiency for OUT transactions
• Uses prisma.$transaction for atomic operation:
a) Creates Transaction record
b) Updates Material.currentStock (+/- quantity)
• Returns both transaction and updated material
6. Client receives:
{ "success": true, "message": "Transaction created successfully", "data": { transaction, material } }
Every database query includes a tenantId filter:
- Users can only access their own tenant's data
- Cross-tenant access returns
404(not403, to avoid information leakage) resolveTenantmiddleware validates the tenant before any route handler runs
| Feature | FREE Plan | PRO Plan |
|---|---|---|
| Materials | Max 5 | Unlimited |
| Users | Unlimited | Unlimited |
| Transactions | Unlimited | Unlimited |
materialService.createMaterial() checks the tenant's plan before creation and returns 403 when a FREE tenant exceeds 5 materials.
material-inventory-api/
├── src/
│ ├── app.ts # Express app + health check
│ ├── server.ts # HTTP server entry point
│ ├── controllers/
│ │ ├── tenant.controller.ts
│ │ ├── user.controller.ts
│ │ ├── material.controller.ts
│ │ └── transaction.controller.ts
│ ├── services/
│ │ ├── tenant.service.ts # Tenant logic + plan limits
│ │ ├── user.service.ts # User CRUD + email uniqueness
│ │ ├── material.service.ts # Material logic + pagination
│ │ └── transaction.service.ts # Atomic stock management + pagination
│ ├── middleware/
│ │ ├── tenant.ts # resolveTenant middleware
│ │ ├── validate.ts # validateBody(schema) middleware
│ │ └── error.ts # Centralized error handler
│ ├── routes/
│ │ ├── tenant.routes.ts
│ │ ├── user.routes.ts
│ │ ├── material.routes.ts
│ │ └── transaction.routes.ts
│ ├── schemas/ # Zod validation schemas
│ │ ├── tenant.schema.ts
│ │ ├── user.schema.ts
│ │ ├── material.schema.ts
│ │ └── transaction.schema.ts
│ ├── utils/
│ │ ├── response.ts # sendSuccess / sendCreated / sendList / sendMessage
│ │ └── pagination.ts # parsePagination / buildMeta
│ └── db/
│ └── prisma.ts # Prisma client singleton
├── prisma/
│ ├── schema.prisma # Database schema
│ ├── seed.ts # Seed data (2 tenants, 4 users, 6 materials)
│ └── migrations/ # Migration history
├── test-api.sh # Complete test suite (28 tests)
├── package.json
└── tsconfig.json
- Node.js 18+
- PostgreSQL 14+
jq(for running tests):brew install jq
1. Clone and install dependencies:
git clone <repository-url>
cd material-inventory-api
npm install2. Configure environment:
cp .env.example .env
# Edit .env with your database credentialsDATABASE_URL="postgresql://user:password@localhost:5432/material_inventory"
PORT=3000
NODE_ENV=development3. Set up the database:
# Run migrations (also regenerates the Prisma client)
npx prisma migrate dev --name init
# Seed with sample data
npm run db:seed4. Start the development server:
npm run dev
# Server running at http://localhost:3000Running npm run db:seed populates the database with realistic sample data:
| Tenant | Plan | Users | Materials |
|---|---|---|---|
| Acme Corp | FREE | Alice Johnson - [email protected] (ADMIN), Bob Smith - [email protected] (USER) | Steel Rods (kg), Copper Wire (m) |
| Globex Industries | PRO | Carol White - [email protected] (ADMIN), Dave Brown - [email protected] (USER) | Aluminium Sheets (kg), Plastic Pellets (ton), Carbon Fiber (m), Titanium Bolts (units) |
Each material has a realistic IN/OUT transaction history with stock derived from it. The seed is idempotent — re-running it is safe and produces the same result.
Use the seeded tenant IDs (printed after seed completes) as your x-tenant-id header when testing manually.
./test-api.sh28 automated tests covering:
- Health check (with DB probe)
- Tenant creation (FREE & PRO plans)
- User CRUD (create, list, get, update, delete)
- Material CRUD
- Transaction operations (IN/OUT) with stock tracking
- Transaction detail retrieval
- Pagination (
?page=&limit=on all list endpoints) - Multi-tenant isolation (materials & users)
- Plan limit enforcement (FREE max 5)
- Zod validation (invalid email, missing fields, invalid enum, zero quantity)
- Duplicate email → 409 Conflict
- Insufficient stock → 400
- Missing tenant header → 400
- Empty update body → 400
http://localhost:3000
Content-Type: application/json
x-tenant-id: <tenant-id> # Required for all endpoints except /health and /tenants
All responses share a consistent envelope:
Success (single resource):
{ "success": true, "data": { ... }, "message": "Optional message" }Success (list):
{
"success": true,
"data": [ ... ],
"meta": { "total": 25, "page": 1, "limit": 20, "totalPages": 2 }
}Error:
{ "success": false, "error": "Error type", "message": "Details" }All list endpoints support query parameters:
| Param | Default | Max | Description |
|---|---|---|---|
page |
1 |
— | Page number (1-based) |
limit |
20 |
100 |
Items per page |
GET /materials?page=2&limit=10Returns API status and database connectivity.
Response (200 — healthy):
{
"success": true,
"data": {
"status": "ok",
"db": "connected",
"timestamp": "2024-12-10T10:30:00.000Z",
"service": "material-inventory-api"
}
}Response (503 — DB unreachable):
{
"success": true,
"data": {
"status": "degraded",
"db": "disconnected",
"timestamp": "2024-12-10T10:30:00.000Z",
"service": "material-inventory-api"
}
}Create a new tenant.
Request Body:
{
"name": "Acme Corp",
"plan": "FREE"
}| Field | Type | Required | Rules |
|---|---|---|---|
name |
string | Yes | Max 100 chars |
plan |
"FREE" | "PRO" |
No | Defaults to "FREE" |
Response (201):
{
"success": true,
"message": "Tenant created successfully",
"data": {
"id": "cm52abc123",
"name": "Acme Corp",
"plan": "FREE",
"createdAt": "2024-12-10T10:30:00.000Z",
"updatedAt": "2024-12-10T10:30:00.000Z"
}
}Get tenant by ID.
Response (200):
{
"success": true,
"data": {
"id": "cm52abc123",
"name": "Acme Corp",
"plan": "FREE",
"createdAt": "2024-12-10T10:30:00.000Z",
"updatedAt": "2024-12-10T10:30:00.000Z"
}
}Create a new user.
Headers: x-tenant-id required
Request Body:
{
"email": "[email protected]",
"name": "John Doe",
"role": "ADMIN"
}| Field | Type | Required | Rules |
|---|---|---|---|
email |
string | Yes | Valid email format, max 255 chars, globally unique |
name |
string | Yes | Max 100 chars |
role |
"ADMIN" | "USER" |
No | Defaults to "USER" |
Response (201):
{
"success": true,
"message": "User created successfully",
"data": {
"id": "cm53user123",
"email": "[email protected]",
"name": "John Doe",
"role": "ADMIN",
"tenantId": "cm52abc123",
"createdAt": "2024-12-10T10:31:00.000Z",
"updatedAt": "2024-12-10T10:31:00.000Z"
}
}Errors:
400— Validation error (invalid email, missing name)409— Email already exists
List all users for tenant (paginated).
Headers: x-tenant-id required
Query Params: page, limit
Response (200):
{
"success": true,
"data": [
{
"id": "cm53user123",
"email": "[email protected]",
"name": "John Doe",
"role": "ADMIN",
"createdAt": "2024-12-10T10:31:00.000Z"
}
],
"meta": { "total": 2, "page": 1, "limit": 20, "totalPages": 1 }
}Get user by ID.
Headers: x-tenant-id required
Response (200):
{
"success": true,
"data": {
"id": "cm53user123",
"email": "[email protected]",
"name": "John Doe",
"role": "ADMIN",
"tenantId": "cm52abc123",
"createdAt": "2024-12-10T10:31:00.000Z"
}
}Errors: 404 — User not found or belongs to a different tenant
Update user name and/or role. Email is immutable.
Headers: x-tenant-id required
Request Body: (at least one field required)
{
"name": "John Updated",
"role": "ADMIN"
}| Field | Type | Rules |
|---|---|---|
name |
string | Max 100 chars |
role |
"ADMIN" | "USER" |
— |
Response (200):
{
"success": true,
"message": "User updated successfully",
"data": {
"id": "cm53user123",
"email": "[email protected]",
"name": "John Updated",
"role": "ADMIN",
"updatedAt": "2024-12-10T10:35:00.000Z"
}
}Errors:
400— Empty body (at least one field required)404— User not found
Delete user.
Headers: x-tenant-id required
Response (200):
{ "success": true, "message": "User deleted successfully" }Errors: 404 — User not found
Create a material.
Headers: x-tenant-id required
Request Body:
{
"name": "Steel Rods",
"unit": "kg",
"currentStock": 100
}| Field | Type | Required | Rules |
|---|---|---|---|
name |
string | Yes | Max 100 chars, unique per tenant |
unit |
string | Yes | Max 50 chars |
currentStock |
number | No | Non-negative, defaults to 0 |
Response (201):
{
"success": true,
"message": "Material created successfully",
"data": {
"id": "cm53mat123",
"name": "Steel Rods",
"unit": "kg",
"currentStock": 100,
"tenantId": "cm52abc123",
"createdAt": "2024-12-10T10:40:00.000Z",
"updatedAt": "2024-12-10T10:40:00.000Z"
}
}Errors:
400— Validation error403— FREE plan material limit (5) exceeded409— Material name already exists for this tenant
List all materials for tenant (paginated).
Headers: x-tenant-id required
Query Params: page, limit
Response (200):
{
"success": true,
"data": [
{
"id": "cm53mat123",
"name": "Steel Rods",
"unit": "kg",
"currentStock": 450,
"tenantId": "cm52abc123",
"createdAt": "2024-12-10T10:40:00.000Z",
"updatedAt": "2024-12-10T10:50:00.000Z"
}
],
"meta": { "total": 2, "page": 1, "limit": 20, "totalPages": 1 }
}Get material with full transaction history.
Headers: x-tenant-id required
Response (200):
{
"success": true,
"data": {
"id": "cm53mat123",
"name": "Steel Rods",
"unit": "kg",
"currentStock": 450,
"tenantId": "cm52abc123",
"createdAt": "2024-12-10T10:40:00.000Z",
"updatedAt": "2024-12-10T10:50:00.000Z",
"transactions": [
{ "id": "cm54txn456", "quantity": 30, "type": "OUT", "createdAt": "2024-12-10T10:50:00.000Z" },
{ "id": "cm54txn123", "quantity": 500, "type": "IN", "createdAt": "2024-12-10T10:45:00.000Z" }
]
}
}Errors: 404 — Material not found or belongs to a different tenant
Create a stock transaction (IN to add, OUT to remove). Atomically updates material stock.
Headers: x-tenant-id required
Request Body:
{
"quantity": 50,
"type": "IN"
}| Field | Type | Required | Rules |
|---|---|---|---|
quantity |
number | Yes | Must be > 0 |
type |
"IN" | "OUT" |
Yes | — |
Response (201):
{
"success": true,
"message": "Transaction created successfully",
"data": {
"transaction": {
"id": "cm54txn123",
"tenantId": "cm52abc123",
"materialId": "cm53mat123",
"quantity": 50,
"type": "IN",
"createdAt": "2024-12-10T10:45:00.000Z"
},
"material": {
"id": "cm53mat123",
"name": "Steel Rods",
"unit": "kg",
"currentStock": 150,
"tenantId": "cm52abc123",
"createdAt": "2024-12-10T10:40:00.000Z",
"updatedAt": "2024-12-10T10:45:00.000Z"
}
}
}Errors:
400— Validation error or insufficient stock for OUT transaction404— Material not found or belongs to a different tenant
List all transactions for tenant across all materials (paginated).
Headers: x-tenant-id required
Query Params: page, limit
Response (200):
{
"success": true,
"data": [
{
"id": "cm54txn123",
"tenantId": "cm52abc123",
"materialId": "cm53mat123",
"quantity": 50,
"type": "IN",
"createdAt": "2024-12-10T10:45:00.000Z",
"material": { "name": "Steel Rods", "unit": "kg" }
}
],
"meta": { "total": 8, "page": 1, "limit": 20, "totalPages": 1 }
}Get a single transaction with material details.
Headers: x-tenant-id required
Response (200):
{
"success": true,
"data": {
"id": "cm54txn123",
"tenantId": "cm52abc123",
"materialId": "cm53mat123",
"quantity": 50,
"type": "IN",
"createdAt": "2024-12-10T10:45:00.000Z",
"material": {
"id": "cm53mat123",
"name": "Steel Rods",
"unit": "kg",
"currentStock": 150
}
}
}Errors: 404 — Transaction not found or belongs to a different tenant
All request bodies are validated by Zod schemas before reaching controllers. Validation errors return:
{
"success": false,
"error": "Validation error",
"message": "Invalid email format, Name is required and cannot be empty"
}| Schema | Rules |
|---|---|
createTenantSchema |
name max 100, plan enum |
createUserSchema |
email format + max 255, name max 100, role enum |
updateUserSchema |
At least one of name or role required |
createMaterialSchema |
name max 100, unit max 50, currentStock non-negative |
createTransactionSchema |
quantity positive, type enum |
| Status | Meaning |
|---|---|
400 |
Validation error (Zod), business logic failure (insufficient stock) |
403 |
Plan limit exceeded |
404 |
Resource not found or tenant isolation violation |
409 |
Duplicate entry (email, material name per tenant) |
503 |
Health check — database unreachable |
500 |
Unexpected server error |
| Layer | Technology |
|---|---|
| Runtime | Node.js 18+ |
| Framework | Express 4.18 |
| Language | TypeScript 5.3 |
| Validation | Zod 4 |
| ORM | Prisma 5.22 |
| Database | PostgreSQL 14+ |
| Dev server | tsx (hot reload) |
enum Plan { FREE PRO }
enum Role { ADMIN USER }
enum TransactionType { IN OUT }
model Tenant {
id String @id @default(uuid())
name String
plan Plan @default(FREE)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
users User[]
materials Material[]
transactions Transaction[]
@@map("tenants")
}
model User {
id String @id @default(uuid())
tenantId String @map("tenant_id")
email String @unique
name String
role Role @default(USER)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@map("users")
}
model Material {
id String @id @default(uuid())
tenantId String @map("tenant_id")
name String
unit String
currentStock Float @default(0) @map("current_stock")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
transactions Transaction[]
@@index([tenantId])
@@unique([tenantId, name])
@@map("materials")
}
model Transaction {
id String @id @default(uuid())
tenantId String @map("tenant_id")
materialId String @map("material_id")
quantity Float
type TransactionType @default(IN)
createdAt DateTime @default(now()) @map("created_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
material Material @relation(fields: [materialId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@index([materialId])
@@index([createdAt])
@@map("transactions")
}npm run dev # Start server with hot reload
npm run build # Compile TypeScript → dist/
npm start # Run compiled build
npm run db:seed # Seed database with sample data
npm run prisma:migrate # Create and apply a new migration
npm run prisma:generate # Regenerate Prisma client
npm run prisma:studio # Open Prisma Studio (DB GUI)
npm run db:push # Push schema changes without a migrationDatabase connection error:
pg_isready # Check PostgreSQL is running
# Verify DATABASE_URL format: postgresql://user:password@localhost:5432/dbnameReset database and re-seed:
npx prisma migrate reset # WARNING: deletes all data, re-runs migrations and seedPort already in use:
lsof -ti:3000 | xargs kill -9Prisma type errors after schema change:
npx prisma migrate dev --name <migration_name> # runs generate automatically
# or manually:
npx prisma generateClear build and reinstall:
rm -rf node_modules dist && npm install