What affects the project as a whole: what it is, who uses it, how it is built
and how information flows. Each functional module has its own folder in docs/.
A hotel's internal operations —what the commercial PMS does not manage— are usually split between spreadsheets, paper and loose messages. Four-Points brings them together in a single web application: logbook, parking, maintenance, groups, cashier, blacklist, staff scheduling, shift checklists, F&B revenue and invoice backoffice. It complements the hotel's PMS (Opera, from which it imports PDF reports), it does not replace it. Each person has their own user and permissions according to their role.
Hotel staff, each with a role:
admin: everything. They are the only one who creates users, manages departments, sees reports and writes in backoffice.recepcionista: operational modules (logbook, parking, cashier, blacklist, conciliation, checklist, F&B, maintenance). They only query groups.group-admin: the same as reception and, additionally, manages groups and receives their notices.mantenimiento: only the maintenance module, plus the common functions (profile, messages, notifications, search).- Public demo account (
users.is_demo = 1, only whereDEMO_MODE=true): anadminentered with the demo button, without a password. It can do everything the daily reset undoes, but not manage users, departments or the scheduling configuration, upload files, or reset the demo. Each blocked attempt is recorded. Details in../demo/README.md.
The side menu only hides backoffice and scheduling links from non-admins. The
backend is what actually decides: each route checks the role and returns 403
if it does not match.
Functional modules — each documented in its folder: logbook, parking,
maintenance, groups, scheduling, checklist, cashier, fnb,
backoffice, blacklist, conciliation.
Functions common to the whole application (documented here):
- Access: login with username and password. There is no public registration:
users are created by an
admin. - Profile (
/dashboard/profile): own data, avatar, username and password change (both ask for the current password). In Settings, administrators manage users, departments and reports. - Internal messaging: direct conversations and groups between users, with
urgent messages that arrive as a notification. It lives inside the profile.
Detail in
messages/. - Notifications: bell in the header, which refreshes every minute. They are
generated by the system (group notices: payments, rooming list, arrivals), an
adminmanually or a new message. - Global search and recent activity on the home panel.
- Language Spanish or English and theme light or dark.
- MySQL 8 on Aiven, a single database (
hotel_db) with test data, used by both the deployment and development. Summary indatabase/; schema, scripts and migration policy inbackend/db-mysql/. - Users and roles: tables
usersandroles. Passwords are stored with bcrypt. Deleting a user does not remove them: they are deactivated and renamed. - Files (avatars, maintenance and blacklist photos, invoice PDF): in Cloudinary, not on the server.
- Time: everything that depends on the day is calculated in
Europe/Madrid.
Session
- On login the backend issues two JWTs in HttpOnly cookies:
access_token(15 min) andrefresh_token(7 days). In production the cookies are valid for all of.four-points.stackbp.es. - When a request receives
401, the client asks for a new token and repeats it. Each renewal also issues a newrefresh_token, so the session is extended as long as it is used. - Sessions are not stored in the database: there is no way to close them from the server before they expire. Changing the password deletes the cookies from the browser where it is done.
Protection
- Request limits per IP: 300 every 15 min across the whole API; 5 login attempts every 15 min per IP and user; 3 password changes per hour.
- Security headers with Helmet on the backend and on Vercel. CORS accepts own
domains, Vercel previews and requests without the
Originheader (tools, server to server). - In production, unhandled errors return a generic message.
Log
- Every
403is logged (access_denied: user, role, method and path, without the query string), and so is every time anadminreads, lists or deletes conversations or messages they are not in (admin_override). - Passwords and tokens are hidden at the first three levels of each entry, and
so is the SQL of a failed query (
err.sql), which carries its values. - Failed logins keep the username that was typed (decided on 2026-10-12, left as is): it is what reveals a brute-force attack, although someone may type their password in that field.
Database
- Every schema change is a new, idempotent script in
backend/db-mysql/scripts/and recorded in itsINDEX.md. The full installation is never run against a database with data.
Scheduled tasks (Madrid time, inside the backend process)
| When | What it does |
|---|---|
| Every day, 06:30 | Resets the day's checklists |
| Every day, 07:00 | Sends scheduled notifications and generates group notices |
| Monday, 04:00 | Deletes checklist event logs older than 7 days |
| 10th, 23:59 | Marks validated invoices from the previous month as paid |
Browser ──────────────► Frontend (Next.js · Vercel)
│ │ server components: serverFetch
│ fetch + cookies ▼
└──────────────────► Backend (Express · Render)
│ routes → controllers → services → repositories
├──► MySQL (Aiven)
├──► Cloudinary (files)
└──► Python solver (scheduling, child process)
- The browser calls the backend directly (
api.four-points.stackbp.es) withcredentials: 'include', throughapp/lib/apiClient.ts, which also renews the token. There is no intermediate proxy: the frontend has noapp/apiroutes (the unused ones were removed on 2026-10-02). - Server components use
app/lib/serverFetch.ts, which forwards the access cookie to the backend. proxy.ts(the Next.js 16 middleware) redirects to login if there are no session cookies. It only checks whether they exist and whether they have expired: the signature is verified by the backend.- On the backend, every request goes through
authenticateToken→ role check → controller → validation with Zod → service → repository → MySQL. - Server state on the client is managed by React Query; Zustand stores local state for some modules (cashier, groups, maintenance, notifications).
- Frontend: Next.js 16 (App Router, Turbopack), React 19, Tailwind CSS 3, NextUI, React Query 5, Zustand 5, next-intl 4 and Zod 3.
- Backend: Express 5 and TypeScript run with
tsx(no compilation), mysql2, Zod 4, pino, node-cron and multer. - Scheduling: Python 3.11 and OR-Tools (CP-SAT), as a persistent process launched by the backend.
- Tests: Vitest on the backend (auth, checklist, F&B, scheduling) and pytest on the solver. The frontend has no tests.
frontend/ and backend/ are two independent pnpm projects, each with its own
lockfile. Node ≥ 22.16.
| Piece | Where | Detail |
|---|---|---|
| Frontend | Vercel | four-points.stackbp.es, region cdg1 (Paris) |
| Backend | Render | api.four-points.stackbp.es. The build creates the solver's Python env |
| DB | Aiven | MySQL with TLS; CA certificate in backend/config/certs/ |
| Files | Cloudinary |
- DNS:
four-points.stackbp.esis a CNAME to Vercel andapi.four-points.stackbp.esa CNAME tofour-points.onrender.com. NODE_ENV=productionis required on the backend. Only then are the session cookiesSecureand scoped to.four-points.stackbp.es, so the frontend and the API share them. Without it, login succeeds but every following request answers401.- Closing every session at once: both tokens are signed with
SECRET_JWT_KEYand the server stores no sessions, so changing that variable on Render and redeploying logs everyone out. - The build and start commands are set in the Render and Vercel dashboards;
there is no
render.yamlin the repository.
A second, complete stack to test a branch in production conditions before it is
merged into main. Created on 2026-10-04 for phase 1c (ADR-027); why it looks
like this is in ADR-030.
| Piece | Where | Detail |
|---|---|---|
| Frontend | Vercel | preview.four-points.stackbp.es, a project domain bound to the branch |
| Backend | Render | service four-points-api-preview (free), api-preview.four-points.stackbp.es |
| DB | Aiven | the production database — every write from preview is real |
- Render: same root directory, build and start commands as
four-points-api, deploys the branch on every commit. Environment variables are a copy of production's plusFRONTEND_URL=https://preview.four-points.stackbp.es, the extra CORS origin read bybackend/index.ts. - Vercel:
NEXT_PUBLIC_API_URL=https://api-preview.four-points.stackbp.es, scoped to Preview and to this branch only. The production value (All Environments) is untouched. - DNS (Hostinger, nameservers
dns-parking.com; there is no Cloudflare account):preview.four-pointsCNAME to the same Vercel target as production,api-preview.four-pointsCNAME tofour-points-api-preview.onrender.com. - Why not the
*.vercel.appURL: the API is on another site, so theSameSite=Laxcookies are not sent and the CORS list rejects it. Underfour-points.stackbp.esboth work without touching code.
Test users (created on 2026-10-04 to verify the pre-release fixes role by role)
| User | Role | |
|---|---|---|
qa_recep |
recepcionista |
qa_recep@qa.invalid |
qa_gadmin |
group-admin |
qa_gadmin@qa.invalid |
qa_mant |
mantenimiento |
qa_mant@qa.invalid |
- They live in the production database, so production sees them too.
- Their random passwords, and the
adminone used for the tests, are only indocs/_archive/verify/creds.envon the main PC (dz). Git ignores that folder (ADR-009); the helperfp.shnext to it logs in with them. - Any data a test creates starts with
QA-.
Side effects to keep in mind
- Shared cookies: both APIs set the same cookie names on
.four-points.stackbp.esand sign them with the sameSECRET_JWT_KEY. Logging in on preview replaces the production session in that browser, and the session is valid on both. - Scheduled tasks run twice while the preview service is awake: both
backends start the same
node-cronjobs against the same database. Checklist reset, log purge and batch payment are idempotent; the 07:00 notices may be duplicated. Free services sleep after 15 minutes without traffic, so it only happens if preview is awake at that time. - Vercel toolbar on preview: Vercel injects its feedback toolbar
(
feedback.js) and the "Vercel Authentication" protection into preview deployments only. They logOPTIONS400 and/.well-known/vercel/jwe503 in the console and some?_rsc=prefetches answer 503. It is not the app: production shows none of it. The toolbar can be turned off in Vercel → Settings → General. - Free limits: the Render workspace has 750 instance hours a month shared by every free service (if they run out, production is suspended too) and 2 custom domains, both now in use.
After a merge into main: keep working on the same branch, so the preview
keeps its domain, variable and Render service. A merge commit has the branch as
a parent, so it only needs a fast-forward:
git switch claude/compassionate-planck-gh6aof && git merge --ff-only origin/main.
PR #7 merged the first round on 2026-10-04 (942fdfa).
Removing it when it is no longer needed: delete the service
four-points-api-preview on Render, the domain preview.four-points.stackbp.es
and the branch-scoped NEXT_PUBLIC_API_URL on Vercel, and both CNAMEs on
Hostinger. Delete or deactivate the qa_* users and the QA- test data, and
docs/_archive/verify/. Nothing in main depends on it.
Installing from scratch (MySQL in Docker or your own, pnpm setup:local, the
local admin admin / fourpoints-local) is in the root
README.md. Day to day:
cd backend && pnpm dev:local # API on :4000 against the local MySQL (dev:aiven: against Aiven)
cd frontend && pnpm dev # http://localhost:3000Backend variables: PORT (4000 locally), NODE_ENV, SECRET_JWT_KEY,
SALT_ROUNDS, DB_ENVIRONMENT (local or aiven), LOCAL_DB_* or
AIVEN_DB_* + AIVEN_PASSWORD, CLOUDINARY_*, LOG_LEVEL, FRONTEND_URL and
DEMO_MODE (true only where the public demo runs). Frontend:
NEXT_PUBLIC_API_URL, NEXT_SERVER_API_URL (optional), NEXT_PUBLIC_APP_URL,
which the user creation route uses to accept the request origin, and
NEXT_PUBLIC_DEMO_MODE, which shows the demo button on the login page.
Tests: pnpm test in backend/ (also test:watch and test:coverage).
Some suites (login, checklist, F&B, scheduling requests) use the configured
database, which is the shared Aiven one: the F&B suite writes to the year 2099
and deletes it afterwards. The solver's own tests are described in
../scheduling/solver.md.
Languages: Spanish (default) and English with next-intl, one JSON file per
module in frontend/messages/es and frontend/messages/en. The language comes
from the NEXT_LOCALE cookie, then the visitor's country (Vercel header), then
the browser, then Spanish.