Backend API for My Ulo, a PropTech platform that helps Nigerians find verified rental and purchase properties through identity-verified agents and landlords, location-aware property data, secure payments, and transparent property reviews.
- Prerequisites
- Tech Stack
- Current API Surface
- Project Structure
- Getting Started
- Environment Variables
- Available Scripts
- Development Workflow
- Testing
- Git Workflow and Commit Convention
- Before You Push - Sync Your Env
Before you begin, ensure you have the following installed:
| Tool | Notes |
|---|---|
| Node.js | Current LTS version recommended |
| npm | Default package manager for this project |
| PostgreSQL | Database server |
| PostGIS | PostgreSQL spatial extension |
Do not mix package managers. This project uses npm.
- Runtime — Node.js
- Framework — Express.js
- Language — TypeScript
- Database — PostgreSQL
- ORM — Drizzle ORM
- Spatial Database — PostGIS
- Validation — Zod
- Authentication — Passwordless (email OTP) + Google Sign-In + JWT (Access & Refresh tokens), Argon2 for hashing login codes
- File Storage — Cloudinary(via Multer)
- Identity Verification — Dojah (synchronous NIN/CAC lookup)
- Payments — Paystack (recurring monthly subscription)
- Logging — Pino + pino-http
- Realtime — Socket.IO (JWT-authenticated, used for Live Chat)
- Security — Helmet, CORS, Cookie Parser, Rate Limiter
- API Documentation — Swagger (OpenAPI), deployed alongside the API on Azure for frontend consumption
- Git Hooks — Husky + Commitlint
Note on Identity Verification: Dojah's synchronous lookup was the best fit driven by cost and integreation complexity for a 4-week MVP.
All routes are mounted under /api/v1, plus a root-level health check.
- GET /health
Properties
- GET /properties — search/filter/paginate (public; auth-aware for isSaved and view tracking when a valid token is present)
- GET /properties/recommended
- GET /properties/:id — includes Trek Check, trust score, owner info (contact gated by premium status), isSaved
- POST /properties — agent/landlord only
- PATCH /properties/:id
- PATCH /properties/:id/publish — requires owner KYC verified
- DELETE /properties/:id
- POST /properties/:id/media — photo/video upload via Cloudinary; videos are probed with ffprobe and rejected if under 5s, over 5min, or unreadable/corrupted (Multer's 20MB size check alone doesn't catch this)
- POST /properties/:id/report — reason, description, evidence upload
- POST /properties/:id/save, DELETE /properties/:id/save
- POST /properties/:id/inspections — schedule a viewing (tenant-facing; date/time based, distinct from Inquiries)
Inspections
- GET /inspections/me — tenant's own scheduled inspections
- GET /inspections/agent/me — inspections booked against the agent/landlord's listings
- PATCH /inspections/:id/status — agent can confirm/complete/cancel; tenant can only cancel their own
Saved
- GET /saved — supports ?listingPurpose=rent|sale
- GET /saved/counts
Saved Filters
- GET /saved-filters, POST /saved-filters, DELETE /saved-filters/:id
Reviews
- GET /properties/:propertyId/reviews — public, paginated, split into verifiedResident / communityTip
- POST /properties/:propertyId/reviews — GPS-gated, photo upload supported, limited to once per property per rolling 30-day window
- PATCH /properties/:propertyId/reviews/:reviewId — edit ratings/text only (GPS/location cannot be changed after submission)
- DELETE /properties/:propertyId/reviews/:reviewId
Inquiries
- POST /properties/:id/inquiries
- GET /inquiries
Amenities
- GET /amenities — filter by type, optional proximity search
KYC
- POST /kyc/verify-nin
- POST /kyc/verify-cac
- GET /kyc/status
Payments
- POST /payments/subscribe
- POST /payments/webhook
- GET /payments/history
- GET /payments/subscription
- POST /payments/cancel
Referrals
- GET /referrals/me
- POST /referrals/apply
Users
- GET /users/me
- GET /users/me/stats — saved/viewed/inquiries counts for the Profile screen's Account Overview
- GET /users/me/activity — unified "Viewed / Saved / Reviewed" feed for the dashboard (chronological merge of existing view/save/review events, not a separate table)
- PATCH /users/me/avatar
- PATCH /users/me/profile — Settings > Profile (dateOfBirth, gender, city, country)
- PATCH /users/me/account — Settings > Account (language, timezone, dateFormat)
- GET /users/me/notification-preferences, PATCH /users/me/notification-preferences — Settings > Notifications, per-category email/push toggles
- POST /users/me/email/request-change, POST /users/me/email/verify-change — Settings > Security, two-step OTP email change
- POST /users/me/recent-searches, GET /users/me/recent-searches, DELETE /users/me/recent-searches — dashboard "Recent Searches" stat
- DELETE /users/me
- GET /users/:id/profile — public agent/landlord profile
Conversations (Live Chat)
- POST /conversations — start (or reuse) a thread with another user, with an initial message
- GET /conversations — list threads with last message + unread count
- GET /conversations/unread-count — dashboard "Messages: N" stat
- GET /conversations/:id/messages, POST /conversations/:id/messages
- PATCH /conversations/:id/read
- Realtime: connect a Socket.IO client with
auth: { token: <accessToken> }; listens formessage:new,message:read,conversation:typingevents on the caller's own room
Contact
- POST /contact — public, rate-limited (3/hour per IP); notifies SUPPORT_EMAIL and stores the message
Auth
- POST /auth/request-code, POST /auth/verify-code, POST /auth/google, PATCH /auth/complete-profile, POST /auth/refresh, POST /auth/logout
Admin (requires admin role — bootstrapped manually, no self-registration)
- GET /admin/reports — supports ?status=open|under_review|resolved|dismissed and ?propertyId=
- PATCH /admin/reports/:id/status
- PATCH /admin/properties/:id/status
- GET /admin/kyc/review-needed
- PATCH /admin/kyc/:id/resolve
- PATCH /admin/users/:id/blacklist — also revokes all active sessions and unpublishes all of that user's listings
.
├── .github/
├── .husky/
├── scripts/
├── src/
│ ├── app.ts
│ ├── server.ts
│ ├── config/
│ ├── constants/
│ ├── controllers/
│ ├── db/
│ │ ├── schema/
│ │ ├── migrations/
│ │ ├── index.ts
| | ├── seed.ts
| | ├── seed-production.ts
│ │ └── cleanup-dev-seed.ts
│ ├── docs/
│ ├── errors/
│ ├── lib/
│ ├── middlewares/
│ ├── routes/
│ ├── services/
│ ├── templates/
| ├── tests/
│ ├── types/
│ ├── utils/
│ └── validations/
├── .env.example
├── .oxlintignore
├── commitlint.config.cjs
├── drizzle.config.ts
├── lint-staged.config.js
├── oxlintrc.json
├── package-lock.json
├── package.json
├── README.md
├── tsconfig.build.json
├── tsconfig.json
├── tsdownconfig.json
└── vitest.config.ts
config/env validation and database connection setup.controllers/handles incoming HTTP requests.services/contains business logic.routes/defines API endpoints.db/manages database configuration, migrations, seeding, and schema definitions.middlewares/contains reusable Express middleware.validations/stores Zod validation schemas.lib/contains thin wrappers around third-pary APIs.docs/contains Swagger/OpenAPI documentation.
git clone https://github.com/Group-2-Build-SZN/node-backend.git
cd node-backendnpm installcp .env.example .envUpdate the values in .env.
CREATE EXTENSION IF NOT EXISTS postgisnpm run migratenpm run seednpm run devGET http://localhost:5000/healthThe application uses environment variables stored in .env.
Common variables include:
| Variable | Description |
|---|---|
| NODE_ENV | Application environment |
| PORT | Server port |
| DATABASE_URL | PostgreSQL connection string |
| ALLOWED_ORIGINS | Comma-separated list |
| JWT_SECRET | JWT signing secret |
| CLOUDINARY_CLOUD_NAME | Cloudinary configuration |
| CLOUDINARY_API_KEY | Cloudinary configuration |
| CLOUDINARY_API_SECRET | Cloudinary configuration |
| PAYSTACK_SECRET_KEY | Paystack Secret Key |
| PAYSTACK_PLAN_CODE | Pystack plan code |
| DOHAH_APP_ID | Dojah KYC - App ID |
| DOJAH_SECRET_KEY | Dojah KYC - Secret key |
| DOJAH_BASE_URL | Dojah API Base URL |
| EMAIL_HOST/PORT/USER/PASS | SMTP config for sending login codes |
| OTP_EXPIRY_MINUTES | Login code expiry window |
| OTP_CODE_LENGTH | Digits in the emailed login code |
| GOOGLE_CLIENT_ID | Google Sign-In token verification |
| SUPPORT_EMAIL | Address that receives /contact |
Never commit your .env file.
| Script | Description |
|---|---|
| npm run dev | Start development server |
| npm run build | Build the application |
| npm start | Run production build |
| npm run type-check | Run TypeScript checks |
| npm run lint | Run linting |
| npm run format | Format source files |
| npm run generate | Generate Drizzle migrations |
| npm run migrate | Run database migrations |
| npm run seed | Seed the database with reference/test data (refuses to run if NODE_ENV=production) |
| npm run seed-production | Seed a small set of richer demo listings/reviews into whatever DATABASE_URL points at; requires SEED_CONFIRM=yes-seed-production |
| npm run cleanup-dev-seed | removes the dummy seed.ts test data from whatever DATABASE_URL points at; also requires SEED_CONFIRM=yes-seed-production |
| npx tsx scripts/reset-db.ts | Drop all tables/types in dev — clean slate before re-migrating (refuses to run if NODE_ENV=production) |
| npm run test | Run the automated test suite (Vitest + Supertest) against the test database |
| npm run test:watch | Run tests in watch mode during development |
| npm run prepare | Install Git hooks |
- Pull the latest changes from the
devbranch. - Create a feature branch.
Example:
feat/authentication
feat/property-module
fix/payment-webhook
- Implement your feature.
- Run:
npm run lint
npm run type-check- Commit using Conventional Commits.
- Push your branch.
- Open a Pull Request into
dev.
Automated tests are set up using Vitest + Supertest, run against a dedicated Neon test branch (never against production or shared dev data).
- Create a
testbranch on Neon (Neon console → Branches → Create branch). - Copy its connection string into a local
.env.test(see.env.teststructure — not committed, same as .env). - Run migrations against it once:
DATABASE_URL="your-test-branch-url" npx drizzle-kit migrate.
bash npm run test # single run npm run test:watch # watch mode
Third-party integrations (Dojah, Cloudinary, email sending) are mocked in tests (src/tests/mocks/integrations.ts) — no real network calls, no real emails sent, no sandbox API usage during test runs.
Current coverage (critical paths, not full endpoint coverage — a deliberate scope decision given the sprint timeline)
- Auth: new user creation on first login, wrong-code rejection, unauthenticated route rejection
- Property publish gating: rejects publishing without at least one photo and one video
- Payments: Paystack webhook signature verification (valid + invalid + missing signature), isPremium activation/deactivation lifecycle
- KYC: name-match outcomes (verified / review_needed / rejected), and the blacklist re-registration block
- User settings: profile/account/notification preference updates, full email-change OTP flow (including wrong-code and already-taken-email rejection)
- Recent searches: record/list/clear
- Inspections: scheduling, past-date rejection, agent-vs-tenant status permissions
- Activity feed: merges viewed/saved/reviewed events correctly
- Messaging: conversation start/reuse, unread counts, read receipts, non-participant rejection
- Video duration validation: rejects too-short and corrupted uploads, accepts valid ones
- Properties CRUD beyond publish gating
- Reviews (GPS-gating logic, cooldown, edit/delete)
- Amenities, saved properties/filters, inquiries, referrals
- Admin endpoints (report resolution, blacklist cascade)
CI runs the full suite automatically on every push/PR via a throwaway PostGIS-enabled Postgres service container — see .github/workflows/ci.yml.
Two dedicated scripts exist for working with a deployed database directly, both gated behind SEED_CONFIRM=yes-seed-production so they can't run by accident:
bash
SEED_CONFIRM=yes-seed-production DATABASE_URL="your-production-connection-string" npm run seed-production
SEED_CONFIRM=yes-seed-production DATABASE_URL="your-production-connection-string" npm run cleanup-dev-seed
npm run seed itself still refuses to run when NODE_ENV=production by design — do not override that guard to seed a deployed database. Use seed-production instead, which is meant for exactly this and doesn't touch anything outside its own dedicated demo user's data.
GitHub Actions runs automatically on every push and pull request to dev/main:
- CI (
.github/workflows/ci.yml) — install, lint, type-check, build, and run the automated test suite against a throwawaypostgis/postgisPostgres service container (spun up fresh per run, not the Neon test branch used locally) - Commit Lint (
.github/workflows/commitlint.yml) — validates every commit in a PR follows Conventional Commits - Build and deploy to Azure (
.github/workflows/dev_myulo-api.yml) — on every push todev, builds and deploys to themyulo-apiAzure Web App. This workflow only builds/deploys; it does not run tests (that's CI's job — running the integration suite here would need production secrets it doesn't have). Aconcurrencygroup ensures a new push waits for an in-flight deploy to finish rather than racing it.
A PR with failing lint, type-check, build, tests, or commit-message checks should not be merged.
This repository follows Conventional Commits.
feat(auth): implement login endpoint
feat(property): add property creation endpoint
fix(payment): handle failed webhook verification
refactor(review): simplify review service
docs: update README
chore: initialize backend project
Whenever a new environment variable is introduced:
- Update your local
.env - Update
.env.example - Never commit secrets
- Verify all required keys are documented
Authentication is complete: passwordless email OTP, Google Sign-In, and JWT access tokens paired with DB-backed, rotating refresh tokens (refresh_tokens table) — not stateless JWT refresh tokens. Key behaviors worth knowing:
-
Refresh tokens are opaque random strings, stored hashed (SHA-256) in the database, and rotated on every use — the old one is revoked the moment a new one is issued, so a stolen-but-unused refresh token becomes worthless as soon as the real owner refreshes again.
-
authenticate(strict) vs.attachUserIfPresent(soft, doesn't reject unauthenticated requests) are both implemented — used on public-but-auth-aware routes likeGET /properties. -
Blacklisted users are rejected at login/refresh time. Admin blacklisting also calls
revokeAllSessions()to immediately kill a user's ability to refresh, without waiting for their short-lived (15 min) access token to expire naturally.