A full-stack web application for managing property maintenance requests across three user roles: Tenants, Property Managers, and Technicians.
| Role | Password | |
|---|---|---|
| Property Manager | manager@propcare.demo |
Demo@123 |
| Tenant | tenant1@propcare.demo |
Demo@123 |
| Tenant | tenant2@propcare.demo |
Demo@123 |
| Technician | tech1@propcare.demo |
Demo@123 |
| Technician | tech2@propcare.demo |
Demo@123 |
Run
npm run seedin the server directory to populate demo data.
Property managers handle dozens of maintenance requests daily — leaking pipes, broken ACs, faulty wiring. Without a system, requests get lost in text messages, tenants don't know the status, and technicians lack clear assignments.
PropCare solves this by providing a structured workflow where tenants submit tickets, managers triage and assign them, and technicians update progress — all with full audit trails and notifications.
This system was scoped as a focused development effort. The priority is correctness, clarity, and architectural soundness — not feature sprawl. Every feature included is functional end-to-end, from database schema to API to UI.
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ React Client │──────▶│ Express API │──────▶│ PostgreSQL │
│ (Vite, :5173) │ HTTP │ (Node.js, :3001)│Prisma │ (Port 5432) │
└──────────────────┘ └──────────────────┘ └──────────────────┘
- Client → Server: REST API calls over HTTP with JWT in
Authorizationheader - Server → DB: Prisma ORM with type-safe queries, migrations, and seed scripts
- File uploads: Stored on disk via Multer, served as static assets at
/uploads/ - Auth flow:
POST /api/auth/login→ JWT issued → all subsequent requests carry the token
PropCare/
├── server/ # Backend API
│ ├── prisma/
│ │ ├── schema.prisma # Database schema (5 models, 4 enums)
│ │ └── seed.ts # Demo data seeder
│ └── src/
│ ├── config/ # Environment variables, CORS config
│ ├── middleware/ # auth, role, upload, validate, errorHandler
│ ├── modules/
│ │ ├── auth/ # Register, login, JWT issuance
│ │ ├── user/ # User listing, technician lookup
│ │ ├── ticket/ # CRUD, status transitions, assignment
│ │ ├── activity/ # Per-ticket audit trail
│ │ └── notification/ # In-app notification system
│ ├── utils/ # JWT helpers, password hashing, response format
│ └── index.ts # Express app entry point
├── client/ # Frontend SPA
│ └── src/
│ ├── api/ # Axios instance + endpoint wrappers
│ ├── context/ # AuthContext (login state management)
│ ├── guards/ # ProtectedRoute (redirect if unauthenticated)
│ ├── components/
│ │ ├── layout/ # AppShell (sidebar, mobile nav, notification badge)
│ │ └── tickets/ # TicketCard
│ ├── pages/ # Login, Dashboard, Tickets, NewTicket, TicketDetail, Notifications
│ └── styles/ # Global CSS (dark theme design system)
├── docker-compose.yml
├── Dockerfile.server
└── Dockerfile.client
| Layer | Technology | Version | Purpose |
|---|---|---|---|
| Runtime | Node.js | 18+ | Server runtime |
| Backend Framework | Express | 5.x | REST API routing and middleware |
| Language | TypeScript | 5.9 | Type safety across the backend |
| ORM | Prisma | 6.16 | Schema management, migrations, type-safe DB queries |
| Database | PostgreSQL | 15+ | Relational data storage |
| Auth | jsonwebtoken | 9.x | JWT token issuance and verification |
| Password Hashing | bcryptjs | 3.x | Secure password storage |
| Validation | Zod | 4.x | Request body/params validation |
| File Upload | Multer | 2.x | Disk-based image upload handling |
| Frontend | React | 19.x | Component-based UI |
| Build Tool | Vite | 7.x | Fast HMR and production bundling |
| HTTP Client | Axios | 1.x | API calls with interceptors |
| Routing | React Router | 7.x | Client-side navigation |
| Icons | Lucide React | 0.563 | Consistent icon set |
| CSS | Vanilla CSS | — | Custom dark theme design system (no framework) |
Every API route is protected by two middleware layers:
authenticate— Verifies the JWT token from theAuthorization: Bearer <token>header. Attaches{ userId, role }toreq.user.authorize(...roles)— Checks ifreq.user.roleis in the allowed list. Returns 403 if not.
| Action | Tenant | Manager | Technician |
|---|---|---|---|
| Submit ticket | ✅ | ❌ | ❌ |
| View own tickets | ✅ | — | — |
| View all tickets | ❌ | ✅ | ❌ |
| View assigned tickets | ❌ | — | ✅ |
| Assign technician | ❌ | ✅ | ❌ |
| Change priority | ❌ | ✅ | ❌ |
| Update ticket status | ❌ | ✅ | ✅ |
| View dashboard stats | Own tickets | All tickets | Assigned tasks |
| Receive notifications | Ticket updates | New tickets, status changes | New assignments |
// Routes are protected by chaining middleware:
router.post(
"/",
authenticate,
authorize("TENANT"),
upload.array("images", 5),
validate(createTicketSchema),
create,
);
router.patch(
"/:id/assign",
authenticate,
authorize("MANAGER"),
validate(assignSchema),
assign,
);Tickets follow a strict state machine. Invalid transitions are rejected server-side.
┌──────┐ assign ┌──────────┐ start work ┌─────────────┐ complete ┌──────┐
│ OPEN │─────────────▶│ ASSIGNED │───────────────▶│ IN_PROGRESS │────────────▶│ DONE │
└──────┘ └──────────┘ └─────────────┘ └──────┘
│ ▲ │ ▲
│ │ reassign │ │
│ └────────────────────────┘ │
│ unassign │
└──────────────────────────────────┘
(back to OPEN)
Valid transitions enforced in ticket.service.ts:
const VALID_TRANSITIONS: Record<TicketStatus, TicketStatus[]> = {
OPEN: ["ASSIGNED"],
ASSIGNED: ["IN_PROGRESS", "OPEN"],
IN_PROGRESS: ["DONE", "ASSIGNED"],
DONE: [],
};What happens on each transition:
- OPEN → ASSIGNED: Manager assigns a technician. Technician receives notification.
- ASSIGNED → IN_PROGRESS: Technician starts work. Tenant + managers notified.
- IN_PROGRESS → DONE: Technician marks complete. Tenant + managers notified.
- ASSIGNED → OPEN / IN_PROGRESS → ASSIGNED: Reassignment/unassignment paths.
Every transition creates an Activity record for the audit trail.
- Submit maintenance tickets with title, description, category, building, unit, priority
- Upload up to 5 images per ticket (JPEG, PNG, WebP — max 5MB each)
- Track ticket status in real-time
- Receive notifications when status changes
- Dashboard with ticket counts by status (Open, Assigned, In Progress, Done)
- Filter tickets by status and priority
- Assign technicians to tickets
- Change ticket priority (Low / Medium / High / Urgent)
- View full activity timeline per ticket
- View only assigned tasks
- Update status: Start Work (→ In Progress), Mark Complete (→ Done)
- Receive notifications on new assignments
- JWT authentication with 24-hour token expiry
- In-app notification bell with unread count (polls every 30 seconds)
- Activity log per ticket (who did what, when)
- Mobile-first responsive UI with dark theme
- Role-specific accent colors (Blue: Tenant, Purple: Manager, Green: Technician)
- Demo account quick-fill on the login page
User (1) ──────▶ (N) Ticket (as creator)
User (1) ──────▶ (N) Ticket (as assignee, optional)
User (1) ──────▶ (N) Activity (as performer)
User (1) ──────▶ (N) Notification
Ticket (1) ────▶ (N) TicketImage
Ticket (1) ────▶ (N) Activity
| Model | Key Fields | Notes |
|---|---|---|
| User | email (unique), password (hashed), name, role, phone? | Indexed on role |
| Ticket | title, description, status, priority, category?, building?, unit? | Indexed on status, creatorId, assigneeId |
| TicketImage | url, filename, mimeType, size | Cascades on ticket delete |
| Activity | type, message, metadata (JSON) | Audit trail — cascades on ticket delete |
| Notification | title, message, read, link? | Indexed on [userId, read] for fast unread queries |
- UUIDs as primary keys — avoids sequential ID guessing, works well in distributed setups
- Composite index
[userId, read]on notifications — optimizes the most frequent query (fetching unread notifications for a user) - JSON metadata on activities — flexible key-value storage for transition details without adding columns
- Cascade deletes on images, activities, notifications — when a ticket is deleted, all related records are cleaned up
- Node.js 18+
- PostgreSQL 15+ (running locally or via Docker)
# Option A: Local PostgreSQL
psql -U postgres -c "CREATE USER propcare WITH PASSWORD 'propcare123' CREATEDB;"
psql -U postgres -c "CREATE DATABASE propcare_db OWNER propcare;"
# Option B: Docker
docker run -d --name propcare-db \
-e POSTGRES_USER=propcare \
-e POSTGRES_PASSWORD=propcare123 \
-e POSTGRES_DB=propcare_db \
-p 5432:5432 postgres:15-alpinecd server
cp .env.example .env # Edit DATABASE_URL if needed
npm install
npx prisma migrate dev --name init
npm run seed # Populates demo users + tickets
npm run dev # Starts on http://localhost:3001cd client
npm install
npm run dev # Starts on http://localhost:5173Navigate to http://localhost:5173 and log in with a demo account.
docker-compose up --build
# Frontend: http://localhost:5173
# Backend: http://localhost:3001| Variable | Default | Description |
|---|---|---|
DATABASE_URL |
postgresql://propcare:propcare123@localhost:5432/propcare_db |
PostgreSQL connection string |
JWT_SECRET |
propcare-super-secret-... |
JWT signing secret |
PORT |
3001 |
API server port |
UPLOAD_DIR |
./uploads |
Directory for uploaded images |
MAX_FILE_SIZE |
5242880 |
Max upload size in bytes (5MB) |
CORS_ORIGIN |
http://localhost:5173 |
Allowed CORS origin |
The backend includes integration tests covering auth, CRUD, the full ticket workflow, authorization, and notifications:
cd server
npm test # Runs 19 integration tests via Jest + SupertestTest coverage:
| Suite | Tests | What's verified |
|---|---|---|
| Auth | 5 | Login (valid/invalid), registration forced to TENANT, /me auth + unauth |
| Tickets | 5 | Tenant creates, manager can't create, tenant sees only own, manager sees all, tech can't see unassigned |
| Workflow | 7 | Assign → In Progress → Done, invalid transition rejected, DONE is terminal, audit trail, priority change |
| Notifications | 2 | Fetch notifications, mark all as read |
# Health check
curl http://localhost:3001/api/health
# Login
curl -X POST http://localhost:3001/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"manager@propcare.demo","password":"Demo@123"}'
# List tickets (use token from login response)
curl http://localhost:3001/api/tickets \
-H "Authorization: Bearer <token>"
# Dashboard stats
curl http://localhost:3001/api/tickets/dashboard/stats \
-H "Authorization: Bearer <token>"# Frontend
cd client && npm run build # Vite production build
# Backend
cd server && npx tsc --noEmit # TypeScript type checking| Decision | Rationale |
|---|---|
| Express 5 over Nest/Fastify | Minimal overhead, direct control, widely understood — appropriate for the scope |
| Prisma over raw SQL/Sequelize | Type-safe queries generated from schema, auto-migrations, clean relation syntax |
| Zod for validation | Composable schemas, TypeScript-native inference, better error messages than Joi |
| JWT over sessions | Stateless auth — no server-side session store needed, works cleanly with REST API |
| Multer with local disk | Simple file handling for the current scale; easily swappable to S3 via storage engine |
| Vanilla CSS over Tailwind | Full control over the dark theme design system, no build-time dependency |
| Polling for notifications | Simpler than WebSockets for the current scope; 30s interval is acceptable latency |
| State machine for tickets | Prevents invalid transitions (e.g., OPEN → DONE), enforced server-side |
| Modular folder structure | Each module (auth, ticket, etc.) is self-contained with its own routes, controller, service, and schema |
- Real-time updates: Replace notification polling with WebSocket/SSE for instant pushes
- Comments on tickets: Allow tenants and technicians to communicate directly on a ticket
- Email/SMS notifications: Integrate Nodemailer or Twilio for external notifications
- Image storage on S3: Replace local disk uploads with AWS S3 or Cloudinary
- Pagination: Add cursor-based pagination for ticket lists at scale
- Search: Full-text search on ticket title/description using PostgreSQL
tsvector - Analytics dashboard: Avg resolution time, tickets per building, technician workload
- Mobile app: React Native client reusing the same API layer
- CI/CD pipeline: GitHub Actions for lint, test, build, and deploy
This implementation focuses on:
- Backend architecture through modular layering and service-driven logic
- Database integrity via explicit schema constraints, indexes, transactions, and cascade rules
- Auth & role management with strict middleware enforcement, rate limiting on auth routes, and registration restricted to tenants
- Workflow logic via a validated state machine with atomic transitions and audit trails
- Clean, usable UI optimized for role-specific workflows
- Code quality with TypeScript, Zod validation, and 19 integration tests covering auth, CRUD, workflow, and notifications
- Clear deployment and documentation practices with Docker, healthchecks, and environment isolation
The system is intentionally built with a thin, modular Express API rather than a monolithic framework. Each domain concept (tickets, auth, notifications) is isolated in its own module with a clear service → controller → route boundary. This makes it straightforward to:
- Test in isolation — each service function can be unit tested without HTTP concerns
- Swap implementations — replacing Multer's local storage with S3 requires changing one file
- Onboard developers — the folder structure maps directly to the domain language
The state machine pattern for ticket status is a deliberate choice over a simple status update endpoint. By defining valid transitions explicitly, the system prevents impossible states (a ticket can't go from OPEN to DONE without assignment and work) — this is enforced at the service layer, not just the UI.
The notification system uses database-backed polling rather than WebSockets. For a property management context where latency of a few seconds is acceptable, this avoids the complexity of persistent connections and works reliably behind load balancers and proxies.