No description
  • TypeScript 98.9%
  • JavaScript 0.7%
  • Dockerfile 0.4%
Find a file
Smekhov Aleksandr cccb6bc7ba
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
Fix audit
Reviewed-on: #2
2026-09-06 20:10:48 +00:00
.forgejo Small fixes. 2026-08-26 21:12:32 +03:00
prisma Fix pre-commit. 2026-08-28 01:34:19 +03:00
scripts Add import ordering. 2026-08-20 22:47:35 +03:00
src Small fixes. 2026-08-26 21:12:32 +03:00
test More refactoring. 2026-08-24 20:35:01 +03:00
.dockerignore Add tests to ignore. 2026-08-21 12:38:31 +03:00
.env.example More refactoring. 2026-08-24 20:35:01 +03:00
.env.test Add api generation and huge refactoring. 2026-08-20 21:35:50 +03:00
.gitignore Update dependencies. 2026-08-17 18:52:07 +03:00
.pre-commit-config.yaml Add import ordering. 2026-08-20 22:47:35 +03:00
.prettierignore Add api generation and huge refactoring. 2026-08-20 21:35:50 +03:00
.prettierrc Huge refactoring. 2026-08-19 18:20:03 +03:00
docker-compose.test.yaml Refactoring and improvement. 2026-08-21 15:53:07 +03:00
docker-compose.yaml Small fixes. 2026-08-26 21:12:32 +03:00
Dockerfile Refactoring. 2026-08-22 15:42:27 +03:00
eslint.config.mjs Refactoring and improvement. 2026-08-21 15:53:07 +03:00
nest-cli.json Huge refactoring. 2026-08-19 18:20:03 +03:00
openapi.json Small fixes. 2026-08-26 21:12:32 +03:00
package.json Fix audit. 2026-09-06 22:54:14 +03:00
prisma.config.ts Huge refactoring. 2026-08-19 18:20:03 +03:00
README.es.md Small fixes. 2026-08-26 21:12:32 +03:00
README.md Small fixes. 2026-08-26 21:12:32 +03:00
README.ru.md Small fixes. 2026-08-26 21:12:32 +03:00
tsconfig.build.json Add api generation and huge refactoring. 2026-08-20 21:35:50 +03:00
tsconfig.json More refactoring. 2026-08-24 20:35:01 +03:00
yarn.lock Fix audit. 2026-09-06 22:54:14 +03:00

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

  • 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 httpOnly cookie. Every login creates a Session row, 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 lifetimeSESSION_MAX_LIFETIME is measured from session creation, so uninterrupted rotation cannot keep a stolen token alive forever.
  • Instant global revocationUser.tokenVersion invalidates 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 offending field + constraint pairs.
  • 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/health touches neither the database nor the schema; /api/health/ready verifies 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-id carried through AsyncLocalStorage.
  • 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 when MODE is not PROD.
  • 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.yml in 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/profile requires currentPassword whenever password or email changes. Both operations bump tokenVersion, revoke every session and clear the refresh cookie in the response — the user re-authenticates everywhere.
  • Endpoints that accept a day treat 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-DD strings, 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) 110
Work / break interval 11440 minutes
Time block duration 11440 minutes
Round secondsLeft 086400
Email / name length 254 / 255
Password length 6128
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:

  1. Revoking one session (POST /auth/logout) marks that row revoked — the device whose cookie was presented signs out, and the others stay signed in.
  2. Bumping tokenVersion invalidates 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 set TRUST_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.