- TypeScript 98.9%
- JavaScript 0.7%
- Dockerfile 0.4%
|
All checks were successful
Deploy / pre-commit (push) Successful in 27s
Deploy / lint (push) Successful in 1m14s
Deploy / tests (push) Successful in 1m22s
Deploy / migrations (push) Successful in 33s
Deploy / openapi (push) Successful in 28s
Deploy / audit (push) Successful in 25s
Deploy / deploy (push) Successful in 35s
Reviewed-on: #2 |
||
|---|---|---|
| .forgejo | ||
| prisma | ||
| scripts | ||
| src | ||
| test | ||
| .dockerignore | ||
| .env.example | ||
| .env.test | ||
| .gitignore | ||
| .pre-commit-config.yaml | ||
| .prettierignore | ||
| .prettierrc | ||
| docker-compose.test.yaml | ||
| docker-compose.yaml | ||
| Dockerfile | ||
| eslint.config.mjs | ||
| nest-cli.json | ||
| openapi.json | ||
| package.json | ||
| prisma.config.ts | ||
| README.es.md | ||
| README.md | ||
| README.ru.md | ||
| tsconfig.build.json | ||
| tsconfig.json | ||
| yarn.lock | ||
SA Planner — Backend
Language / Язык / Idioma: English | Русский | Español
REST API for the Planner pet project — a day-and-week planner with tasks, drag-and-drop time blocks and a Pomodoro timer. Built with NestJS and Prisma on PostgreSQL. Authentication is split between a short-lived access token the client keeps in memory and a rotating refresh token in an httpOnly cookie, with every login backed by a revocable server-side session row. The HTTP contract is committed to the repository as openapi.json, and CI fails the build if that file drifts from the code.
The frontend lives in a separate repository: sa_planner-frontend.
Table of Contents
- Features
- Tech Stack
- Getting Started
- Environment Variables
- Commands
- API
- Limits
- Sessions and Token Revocation
- Data Model
- Project Structure
- Testing
- CI/CD
- Deployment
- License
Features
- Session-backed JWT auth — the access token is returned in the response body (the client keeps it in memory only), the refresh token in an
httpOnlycookie. Every login creates aSessionrow, so one device can be signed out without touching the others. - One-time refresh rotation — each exchange issues a new token id. The previous one is accepted for another 15 seconds so two tabs refreshing at once do not evict each other; presented outside that window it counts as theft and revokes the whole session.
- A ceiling on session lifetime —
SESSION_MAX_LIFETIMEis measured from session creation, so uninterrupted rotation cannot keep a stolen token alive forever. - Instant global revocation —
User.tokenVersioninvalidates every issued access and refresh token at once, without waiting for expiry. Changing the password or the email bumps it and revokes all sessions, including the current one. - Tasks — calendar due dates (a day, not a moment), three priorities, a completion flag; capped at 1000 per user.
- Time blocks — user-defined ordering with a transactional bulk reorder that either applies in full or rolls back; capped at 100 per user.
- Pomodoro — one session per user per client calendar day, enforced by a database unique key; rounds are pre-generated from the user's settings and each round persists its remaining seconds.
- Task statistics — total, completed, due today and due within the week, computed by a single SQL aggregate rather than four queries.
- Machine-readable errors — every failure carries a stable
code; validation errors and uniqueness conflicts additionally list the offendingfield+constraintpairs. - Rate limiting — counted per user for authenticated callers and per address for everyone else, with tighter buckets on login and refresh.
- Liveness and readiness probes —
/api/healthtouches neither the database nor the schema;/api/health/readyverifies that the database answers. - Committed OpenAPI contract — Swagger UI is served outside production only; in production the contract is the committed file, and a CI job fails if it goes stale.
- Structured logging — one JSON record per line in production, correlated by an
x-request-idcarried throughAsyncLocalStorage. - Hardened by default — helmet, an explicit CORS allow-list, a 64 KB body limit, and a validation pipe that rejects unknown fields instead of silently dropping them.
Tech Stack
| Layer | Technology |
|---|---|
| Framework | NestJS 11 |
| Language | TypeScript 6 |
| Runtime | Node.js ^20.19 || ^22.12 || >=24 (a Prisma 7 constraint) |
| Database | PostgreSQL 17 |
| ORM | Prisma 7 with the @prisma/adapter-pg driver adapter |
| Auth | @nestjs/jwt, Passport JWT, argon2 password hashing |
| Validation | class-validator + class-transformer |
| Rate limiting | @nestjs/throttler |
| API schema | @nestjs/swagger (OpenAPI 3) |
| HTTP hardening | helmet, cookie-parser, explicit CORS allow-list |
| Testing | Jest 30, Supertest |
| Lint / format | ESLint 10 (typescript-eslint, perfectionist), Prettier, pre-commit |
| Package manager | Yarn |
| Containerization | Docker (multi-stage node:24-alpine) |
| CI/CD | Forgejo Actions |
Getting Started
Prerequisites
- Node.js
^20.19/^22.12/>=24 - Yarn 1.x
- PostgreSQL 17 (or Docker, to run it in a container)
Installation
git clone https://git.smekhov-alex.com/SmehAlex/sa_planner-backend
cd sa_planner-backend
yarn
Configuration
Copy the example environment file and fill it in — see Environment Variables:
cp .env.example .env
Database
The Prisma CLI reads its connection string through prisma.config.ts, which takes it from DATABASE_URL; the application itself connects through the @prisma/adapter-pg driver adapter.
npx prisma generate # generate the client into src/generated/prisma (no database needed)
npx prisma migrate deploy # apply existing migrations
npx prisma migrate dev --name <name> # author a new migration after editing the schema (development)
Run
yarn start:dev # watch mode
yarn build # production build
yarn start:prod # run the build
With the defaults the API is served at http://localhost:3000/api, and Swagger UI at http://localhost:3000/api/docs.
Environment Variables
Read from .env through dotenv in src/const/env.ts. A missing required variable stops the process at startup with the list of what is missing — the app never boots half-configured. See .env.example.
| Variable | Required | Purpose |
|---|---|---|
DATABASE_URL |
yes | PostgreSQL connection string |
ALLOWED_HOSTS |
yes | CORS allow-list, space-separated |
JWT_SECRET |
yes | Signing key for the access token |
JWT_REFRESH_SECRET |
in PROD |
Separate key for the refresh token. Outside production it falls back to JWT_SECRET; in production it is required and must differ, otherwise an access token would pass as a refresh one |
TRUST_PROXY |
in PROD |
How many reverse proxies sit in front of the app; decides whose address counts as the client's when rate limiting |
MODE |
no | PROD switches on production behaviour: JSON logs, secure cookies, no Swagger, the stricter checks above |
JWT_ACCESS_LIFETIME |
no (1h) |
Access token lifetime |
JWT_REFRESH_LIFETIME |
no (7d) |
Refresh token lifetime; the refresh cookie is set to expire with it |
SESSION_MAX_LIFETIME |
no (30d) |
Hard ceiling on session lifetime, counted from creation; rotation cannot extend a session past it |
HOST_PORT |
no (3000) |
Listening port |
DOMAIN_URL |
no | Domain the service cookies are set on |
BASE_URL |
no (empty) | Extra path prefix the whole app is mounted under; the API prefix becomes ${BASE_URL}/api |
Leave DOMAIN_URL empty so the refresh cookie stays host-only. A value like .example.com hands it to every subdomain, and SameSite=Lax stops protecting the token refresh from CSRF.
Commands
| Command | Description |
|---|---|
yarn start:dev |
Development server in watch mode |
yarn start:debug |
Watch mode with the Node inspector attached |
yarn build |
Production build into dist/ |
yarn start:prod |
Run the production build |
yarn lint |
ESLint over src, test and scripts (lint:fix to autofix) |
yarn format |
Prettier over the sources |
yarn test |
Unit tests (test:watch, test:cov for coverage) |
yarn test:e2e |
End-to-end tests against a real database |
yarn generate:openapi |
Regenerate openapi.json from the code |
yarn check:openapi |
Regenerate it and fail if the committed file differs |
API
Everything is mounted under ${BASE_URL}/api. Paths in openapi.json are written without that prefix — the global prefix is applied at runtime, so prepend it yourself.
- Swagger UI —
${BASE_URL}/api/docs, served only whenMODEis notPROD. - Live schema —
${BASE_URL}/api/openapi.json, same condition. - The contract of record — the committed
openapi.json. In production it is a file, not a running application;openapi.ymlin CI fails the pull request if it no longer matches the code.
Request and response bodies are documented in that contract and are not duplicated here. What follows is the map.
Authorization
Every endpoint except the health probes and /auth/* requires a header:
Authorization: Bearer <accessToken>
The refresh token is never sent in a header — it travels in the refreshToken cookie, set httpOnly, sameSite=lax, and secure in production. Because the cookie is lax, the frontend and the backend must live on the same site; a cross-domain setup — which ALLOWED_HOSTS and credentials: true do allow — would additionally need sameSite=none together with secure.
Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/health |
Liveness: touches neither the database nor the schema | |
GET |
/health/ready |
Readiness: 503 unless the database answers |
|
POST |
/auth/register |
Register, seeding the default Pomodoro settings | |
POST |
/auth/login |
Sign in: access token in the body, refresh in the cookie | |
POST |
/auth/login/access_token |
Exchange the refresh cookie for a fresh pair | |
POST |
/auth/logout |
Sign out: clears the cookie and revokes this session | |
GET |
/user/profile |
✓ | Profile and task statistics; optional ?day=YYYY-MM-DD |
PUT |
/user/profile |
✓ | Update the profile and Pomodoro settings |
GET |
/user/tasks |
✓ | Every task, in creation order |
POST |
/user/tasks |
✓ | Create a task |
PUT |
/user/tasks/{id} |
✓ | Partial update |
DELETE |
/user/tasks/{id} |
✓ | Delete a task |
GET |
/user/time-block |
✓ | Time blocks in the user's own order |
POST |
/user/time-block |
✓ | Append a block to the end of the list |
PUT |
/user/time-block/update-order |
✓ | Reorder: the order of the ids is the new order |
PUT |
/user/time-block/{id} |
✓ | Partial update |
DELETE |
/user/time-block/{id} |
✓ | Delete a block |
GET |
/user/timer |
✓ | The session for a client calendar day, or null; optional ?day= |
POST |
/user/timer |
✓ | Create the day's session; calling it again returns the existing one |
PUT |
/user/timer/round/{id} |
✓ | Update a round: seconds left and the completion flag |
PUT |
/user/timer/{id} |
✓ | Update the session |
DELETE |
/user/timer/{id} |
✓ | Reset: deletes the session together with its rounds |
A few behaviours worth knowing before reading the schema:
PUT /user/profilerequirescurrentPasswordwheneverpasswordoremailchanges. Both operations bumptokenVersion, revoke every session and clear the refresh cookie in the response — the user re-authenticates everywhere.- Endpoints that accept a
daytreat it as the client's calendar day. Omit it and the server falls back to its own current day in UTC, which is not the same thing for a user several time zones away. - Unknown fields in a request body are rejected with
VALIDATION_FAILED, not silently dropped. - Days are exchanged as
YYYY-MM-DDstrings, never as timestamps: a task's due date is a day, not a moment.
Error format
Every failure comes back in the same shape, with a machine code the client can branch on:
{ "statusCode": 404, "code": "RECORD_NOT_FOUND", "message": "Record not found" }
A validation failure additionally lists the broken rules:
{
"statusCode": 400,
"code": "VALIDATION_FAILED",
"message": ["password must be at least 6 characters long"],
"errors": [{ "field": "password", "constraint": "minLength" }]
}
The same errors field names the taken fields on a uniqueness conflict. RECORD_ALREADY_EXISTS is shared by every record type, and without this the client could not tell a taken email from a clash on some other key:
{
"statusCode": 409,
"code": "RECORD_ALREADY_EXISTS",
"message": "email already in use",
"errors": [{ "field": "email", "constraint": "unique" }]
}
| Code | When |
|---|---|
VALIDATION_FAILED |
A field broke a rule, or the body carried an unknown field |
TOO_MANY_REQUESTS |
A rate-limit bucket ran out |
LIMIT_REACHED |
The per-user cap on tasks or time blocks was hit |
USER_EXISTS |
The email is already registered |
USER_NOT_FOUND |
No such user |
INVALID_CREDENTIALS |
Wrong email/password pair on sign-in |
INVALID_CURRENT_PASSWORD |
currentPassword did not match when changing the email/password |
REFRESH_TOKEN_NOT_PASSED |
No refresh cookie on the request |
INVALID_REFRESH_TOKEN |
The refresh token is invalid, rotated away, or its session is gone |
USER_SETTINGS_NOT_FOUND |
The user has no Pomodoro settings row |
RECORD_NOT_FOUND |
The record does not exist or belongs to someone else |
RECORD_ALREADY_EXISTS |
A uniqueness constraint was violated |
RELATED_RECORD_NOT_FOUND |
A referenced related record is missing |
Limits
Field-level bounds live in src/const/limits.ts and reach the frontend through the OpenAPI contract, so both sides validate against the same numbers.
| What | Limit |
|---|---|
| Tasks per user | 1000 |
| Time blocks per user | 100 |
Pomodoro intervals (intervalsCount) |
1–10 |
| Work / break interval | 1–1440 minutes |
| Time block duration | 1–1440 minutes |
Round secondsLeft |
0–86400 |
| Email / name length | 254 / 255 |
| Password length | 6–128 |
| Request body | 64 KB |
| Requests per minute (default) | 300 |
| Sign-in and registration, per minute | 5 |
| Refresh exchanges per minute | 30 |
Hitting a cap on create returns LIMIT_REACHED rather than an opaque 500; the counting happens inside a transaction that takes a row lock on the user, so two parallel creates cannot both slip past the last slot.
Sessions and Token Revocation
Every sign-in creates a Session row, and the token pair carries its id (sid) along with the user's tokenVersion.
Two independent kill switches:
- Revoking one session (
POST /auth/logout) marks that row revoked — the device whose cookie was presented signs out, and the others stay signed in. - Bumping
tokenVersioninvalidates every access and refresh token already issued, without waiting for expiry. It happens on a password change and on an email change; both revoke the current session and all the rest, and the response clears the refresh cookie immediately.
Rotation. Each exchange replaces the presented token id with a new one. The previous id is still accepted for a 15-second grace window so that two tabs refreshing simultaneously do not evict each other. Presented outside that window, an old token is treated as stolen and the entire session is revoked.
The ceiling. Rotation extends a session, but not indefinitely: SESSION_MAX_LIFETIME (30 days by default) is counted from creation, and nothing extends a session past it. Without that, a stolen token the thief keeps refreshing would never expire.
Cleanup. Expired rows are removed by a background task — hourly, plus once at startup — rather than on sign-in. Sweeping the whole table in the hot path of login cost writes and locks on every single sign-in attempt.
In-process state
Two things live in the memory of a single instance, so they behave differently across replicas. Both trade-offs are deliberate; moving to a cluster means changing them together:
- Rate limits (
@nestjs/throttler,src/app.module.ts) — the limit is per instance, not per cluster: across N replicas it is effectively multiplied by N. Behind a reverse proxy you must setTRUST_PROXY, otherwise every client arrives from the same address and the shared sign-in budget of 5 attempts per minute is split between all of them at once. - The authentication cache (
SessionService) — saves a database round-trip on every API request and lives for 5 seconds. Revoking a session clears the cache of its own process only, so on neighbouring replicas a revoked access token survives those few seconds.
A shared store (Redis) removes both limitations at once.
Data Model
Defined in prisma/schema.prisma. Every child row cascades on user deletion.
| Model | What it holds |
|---|---|
User |
Email, name, argon2 password hash, interface language (en/ru/es), and tokenVersion — the global kill switch |
Session |
One sign-in: the currently valid refresh token id, the previous one plus rotatedAt for the grace window, an expiry and a revocation timestamp |
UserSettings |
Pomodoro settings — work interval, break, interval count (defaults: 50 / 10 / 7) |
Task |
Name, dueDate as a date column (a calendar day), optional priority, completion flag |
TimeBlock |
Name, optional colour from a fixed palette, duration in minutes, and an explicit order |
PomodoroSession |
A day's session, unique per (user, day) — that pair is the "one session per day" rule |
PomodoroRound |
A round of a session: secondsLeft (what the timer shows when the page is reopened), completion flag, unique order within the session |
Enums: Priority (low/medium/high), Language (en/ru/es), TimeBlockColor (seven fixed colours).
Project Structure
sa_planner-backend/
├── prisma/
│ ├── schema.prisma # Data model
│ └── migrations/ # SQL migrations
├── src/
│ ├── main.ts # Bootstrap; mounts Swagger outside production
│ ├── configure-app.ts # Global prefix, helmet, CORS, cookies, validation pipe, filters
│ ├── app.module.ts # Root module and the global throttler guard
│ ├── openapi.ts # OpenAPI document builder
│ ├── auth/ # Sign-in/up, token issuing and rotation, JWT strategy, refresh cookie
│ ├── session/ # Session lifecycle, auth cache, expired-row cleanup
│ ├── user/ # Profile, settings, statistics
│ ├── task/ # Tasks
│ ├── time-block/ # Time blocks and reordering
│ ├── pomodoro/ # Day sessions and rounds
│ ├── health/ # Liveness and readiness probes
│ ├── const/ # env, error codes, limits, throttler buckets, defaults
│ ├── dto/ # Shared DTOs and Swagger response decorators
│ ├── exceptions/ # Application exceptions, validation exception factory
│ ├── filters/ # Prisma error → HTTP response
│ ├── guards/ # Throttler guard keyed by user or address
│ ├── lib/ # Calendar dates, durations, passwords, row locks, request context
│ ├── logging/ # JSON logger and request logging middleware
│ ├── validators/ # Custom class-validator rules
│ └── generated/prisma/ # Generated Prisma client (committed, not hand-edited)
├── test/ # e2e specs and their bootstrap
├── scripts/generate-openapi.ts # Contract generation
├── .forgejo/ # Forgejo Actions workflows and composite actions
├── openapi.json # The committed contract
└── Dockerfile / docker-compose.yaml / docker-compose.test.yaml
Testing
Two suites, both on Jest, with a 90% coverage floor on statements, branches, functions and lines.
yarn test # unit tests: *.spec.ts next to the code under src/
yarn test:cov # the same, with a coverage report
yarn test:e2e # end-to-end: test/*.e2e-spec.ts against a real database
The e2e suite needs PostgreSQL. docker-compose.test.yaml brings up a throwaway instance on port 5433 with its data directory on tmpfs, and .env.test already points at it:
docker compose -f docker-compose.test.yaml up -d
npx prisma migrate deploy
yarn test:e2e
docker compose -f docker-compose.test.yaml down
The e2e specs cover authentication and rotation, the profile, tasks, time blocks, Pomodoro, throttling and the health probes.
CI/CD
Forgejo Actions. Reusable steps are factored into composite actions under .forgejo/actions/; every workflow caches node_modules on the yarn.lock hash.
| Workflow | Trigger | What it does |
|---|---|---|
lint.yml |
pull request | ESLint, tsc --noEmit, and a build |
tests.yml |
pull request | Unit tests with coverage, then migrations and the e2e suite against a postgres:17 service |
migrations.yml |
pull request | Applies migrations to an empty database and fails if schema.prisma has run ahead of them |
openapi.yml |
pull request | Regenerates the contract and fails if the committed openapi.json is stale |
audit.yml |
pull request | yarn audit; fails on high or critical findings only |
pre-commit.yml |
pull request | Every pre-commit hook across the whole tree |
deploy.yml |
push to main / manual |
Re-runs all six checks, then deploys: sync, render .env, build the image, prisma migrate deploy, restart |
redeploy.yml |
manual | Deploy without any checks — refuses anything but main unless allow_any_branch is set |
Pre-commit hooks
pip install pre-commit
pre-commit install
Runs the standard file hygiene hooks, ESLint with --fix over src, test and scripts, and Prettier. The generated Prisma client and openapi.json are excluded.
Deployment
The production image is a multi-stage Dockerfile on node:24-alpine: dependencies, build, production-only dependencies, then a slim runtime that runs as the unprivileged node user and carries a HEALTHCHECK hitting /api/health/ready. docker-compose.yaml publishes the service on 127.0.0.1:3000, capped at one CPU and 512 MB.
License
A private pet project (UNLICENSED). Source: git.smekhov-alex.com/SmehAlex/sa_planner-backend.