SaveMate is a full‑stack “deals marketplace” application built as a single pnpm monorepo.
At a high level:
- Backend: Express + TypeScript + Prisma (MongoDB)
- Frontend: React (Vite) + TypeScript
- Contract: Zod schemas as the single source of truth → OpenAPI generation → typed API client
The product is role-based:
- USER: browse deals and view deal details
- BUSINESS: create/manage your own deals
- ADMIN: moderate deals and manage categories
pnpm install
cp .env.example .env
pnpm db:up
pnpm -C apps/backend exec prisma db push
pnpm -C apps/backend exec prisma db seed
pnpm dev- Frontend: http://localhost:5173
- Backend: http://localhost:4000
- Health check: http://localhost:4000/health
- Deals feed with pagination (
page+limit) - Filters: keyword search, category, city, voivodeship, price range, discount minimum, tags, date range, sorting
- Deal details page
- Public categories list
- Register + Login
- Access token returned from backend and attached as
Authorization: Bearer ... - Refresh token stored as an
httpOnlycookie (rotated on refresh) - Frontend Axios interceptor automatically refreshes the access token on 401 (except logout)
- Create a deal
- Update a deal
- Delete a deal
- List “my deals” with pagination and filters
- Pending deals queue
- Approve a deal
- Reject a deal (requires a reason)
- “All deals” page: list all deals with pagination and status management
- Categories CRUD (create / edit / delete)
- Audit logs created for admin actions (deal moderation + category changes)
- Node.js + Express
- Prisma ORM (MongoDB)
- Zod validation middleware for query/body/params
- Helmet + CORS + rate limiting
- Pino HTTP logging + request IDs (
x-request-id) - Local static uploads served from
/uploads
- React + Vite + TypeScript
- TanStack React Query for data fetching + caching
- Axios client with auth/refresh interceptor
- UI primitives via Tailwind + Radix (dialogs/dropdowns)
@savemate/shared-validation: Zod schemas + OpenAPI registry@savemate/api-client: generated OpenAPI TypeScript types@savemate/config: shared ESLint/TS/Prettier configuration
apps/
backend/ # Express API + Prisma schema/seed
prisma/
src/
frontend/ # React UI (Vite)
src/
packages/
shared-validation/ # Zod schemas (shared by backend + frontend)
api-client/ # OpenAPI generated TS types
config/ # Shared tooling configs
Most routes follow the same flow:
HTTP request
-> route handler (apps/backend/src/routes/*)
-> validateQuery / validateBody / validateParams (Zod)
-> service layer (apps/backend/src/services/*)
-> repository layer (apps/backend/src/repositories/*)
-> Prisma (MongoDB)
-> consistent JSON response
This separation keeps:
- Routes focused on HTTP concerns (auth, validation, status codes)
- Services focused on business rules (status transitions, permissions)
- Repositories focused on database queries (filters, pagination, selects)
The public deals browsing flow is representative:
URL search params
-> parsed into typed query state (filters/pagination)
-> useDealsFeed(searchKey, query)
-> api/deals.ts wrapper (Axios)
-> backend /deals
-> React Query cache keyed by ["deals", searchKey]
-> UI renders cards + pagination
Key points:
- Filters are reflected in the URL, so you can share links like
/deals?categoryId=...&page=2. - React Query uses
keepPreviousDatato reduce UI flicker during pagination.
This repo reads env vars from the root .env file.
| Variable | Example | Purpose |
|---|---|---|
PORT |
4000 |
Backend HTTP port |
DATABASE_URL |
mongodb://root:root@localhost:27017/savemate?authSource=admin |
Prisma Mongo connection string |
JWT_ACCESS_SECRET |
change-me |
HMAC secret for access tokens |
JWT_REFRESH_SECRET |
change-me |
HMAC secret for refresh tokens |
ACCESS_TOKEN_TTL_MIN |
15 |
Access token TTL in minutes |
REFRESH_TOKEN_TTL_DAYS |
14 |
Refresh token TTL in days |
CORS_ORIGIN |
http://localhost:5173 |
Comma-separated allowed origins |
| Variable | Example | Purpose |
|---|---|---|
VITE_API_URL |
http://localhost:4000 |
Backend base URL used by Axios |
| Variable | Example | Purpose |
|---|---|---|
SEED_ADMIN_EMAIL |
admin@savemate.local |
Admin user email created/updated by seed |
SEED_ADMIN_PASSWORD |
admin123456-change-me |
Admin user password |
SEED_BUSINESS_EMAIL |
business@savemate.local |
Business user email |
SEED_BUSINESS_PASSWORD |
business123456-change-me |
Business user password |
SaveMate uses a typical “short‑lived access token + long‑lived refresh token” model.
POST /auth/loginandPOST /auth/registerreturn{ accessToken }.- They also set a refresh cookie:
- cookie name:
refreshToken httpOnly: truesameSite: lax- cookie path:
/auth/refresh
- cookie name:
- The frontend stores the access token in a lightweight auth store.
- All API requests attach
Authorization: Bearer <token>. - On a 401:
- Axios calls
POST /auth/refresh(cookie is automatically sent viawithCredentials: true). - If refresh succeeds, the request is retried with the new access token.
- If refresh fails, the user is redirected to
/login.
- Axios calls
This keeps refresh tokens out of JavaScript (httpOnly cookie), while access tokens remain easy to attach.
All handled backend errors return a consistent JSON shape:
{
"error": {
"code": "UNAUTHORIZED|FORBIDDEN|NOT_FOUND|CONFLICT|VALIDATION_ERROR|INTERNAL",
"message": "Human readable message",
"details": {},
"requestId": "..."
}
}- Backend assigns or propagates
x-request-id. - The frontend also tries to surface
requestIdfrom errors (helpful for debugging logs).
Backend applies a basic rate limit (per minute) to reduce abuse during development.
- Backend serves static files from
/uploads. - Images referenced by deals typically use paths like
/uploads/seed/.... - The frontend resolves images against
VITE_API_URL, e.g.:http://localhost:4000+/uploads/seed/deal-food-01.svg
Dev note:
- Frontend and backend run on different origins in dev. The backend sets
Cross-Origin-Resource-Policy: cross-originfor/uploadsto prevent the browser from blocking images.
List endpoints return:
{
"items": [
/* ... */
],
"page": {
"page": 1,
"limit": 10,
"total": 123,
"totalPages": 13
}
}GET /health→{ ok: true }GET /categories→{ items: Category[] }GET /deals- supports filters like
q,categoryId,city,voivodeship,minPrice,maxPrice,discountMin,tags,dateFrom,dateTo,sort, pluspage/limit
- supports filters like
GET /deals/:id→ deal details
POST /auth/registerPOST /auth/loginPOST /auth/refresh(uses refresh cookie)POST /auth/logout(clears refresh cookie)POST /auth/forgot(dev may return a reset token)POST /auth/reset
GET /business/deals(lists only the authenticated business’s deals)POST /business/deals(create)PATCH /business/deals/:id(update)DELETE /business/deals/:id(delete)
GET /admin/deals/pending(pending queue)POST /admin/deals/:id/approve(only allowed for PENDING)POST /admin/deals/:id/reject(only allowed for PENDING; requiresreason)GET /admin/deals(all deals; optional status filter)PATCH /admin/deals/:id/status(status update; REJECTED requiresreason)GET /admin/categoriesPOST /admin/categoriesPATCH /admin/categories/:idDELETE /admin/categories/:id
requireAuthreads theAuthorization: Bearer ...header and verifies the access token.requireRole("ADMIN")/requireRole("BUSINESS")enforce role restrictions.
Business convenience behavior:
- If a user is
BUSINESSbut the token payload doesn’t include abusinessId, the backend resolves it from the database and attaches it to the request.
Core entities:
- User (
ADMIN | USER | BUSINESS) - BusinessProfile (attached to a business user)
- Category (name + slug)
- Deal (belongs to a business + category)
- AuditLog (records admin moderation/category actions)
Deal status lifecycle:
DRAFT→PENDING→APPROVEDREJECTED(admin requires a reason)EXPIRED(used for past deals)
- Node.js 18+ (or newer)
pnpm(workspace uses pnpm)- Docker (for MongoDB)
pnpm installCreate a local .env in the repo root:
cp .env.example .envpnpm db:upWhy replica set?
- The docker compose file starts MongoDB with
--replSet rs0and runs an init container to callrs.initiate(...). - This matches common Prisma MongoDB requirements when transactions are needed.
pnpm -C apps/backend exec prisma db push
pnpm -C apps/backend exec prisma db seedThe seed creates:
- Categories
- An admin user and a few business users
- A set of realistic deals with all major fields filled
- Seed images in
apps/backend/uploads/seed
pnpm devDev note:
- Backend dev command is
tsx src/server.tsand does not provide hot reload by default. - If you change backend code and don’t see it reflected, restart the backend dev process.
This repo treats Zod schemas as the canonical contract.
Generation pipeline:
- Backend generates OpenAPI JSON from Zod
- Frontend/client regenerates OpenAPI TypeScript types
Run the entire pipeline:
pnpm contract:syncIndividual steps:
pnpm openapi:generate
pnpm openapi:typesWhere artifacts live:
- Backend:
apps/backend/openapi.json - Types:
packages/api-client/src/generated/*
- Add/extend Zod schemas in
packages/shared-validation - Use schemas in backend route validation
- Implement service/repository logic
- Regenerate contract + types:
pnpm contract:sync - Update frontend API wrappers + UI
- Update the shared
DealsQuerySchema(and types) - Update backend repository filter builder
- Update frontend URL parsing +
useDealsFeedmapping
From repo root:
pnpm dev
pnpm lint
pnpm typecheck
pnpm format
pnpm build
pnpm db:up
pnpm db:downBackend-only:
pnpm -C apps/backend dev
pnpm -C apps/backend openapi:generate
pnpm -C apps/backend exec prisma db push
pnpm -C apps/backend exec prisma db seedFrontend-only:
pnpm -C apps/frontend dev
pnpm -C apps/frontend build
pnpm -C apps/frontend previewNote about “build”:
- Frontend build produces a Vite production bundle.
- Backend
buildcurrently runs TypeScript typechecking (tsc --noEmit). If you plan to deploy, you’ll likely want a real backend build step that emits JS.
- Backend serves images at
/uploads. - Ensure
VITE_API_URLpoints to the backend. - Ensure deal
imageUrlvalues look like/uploads/....
- Backend reads
CORS_ORIGIN(comma-separated list). - For local dev it should include
http://localhost:5173.
- Prisma + MongoDB commonly needs a replica set.
- Use
pnpm db:up(docker compose includes a replica set init container).
- The backend dev script uses
tsxwithout hot reload. - Restart the backend process.
Internal / project-specific. Add a license if you plan to open-source.