Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a889999
docs: plan the demo on the showcase site before going public
bpstack Oct 5, 2026
e332b8a
docs: settle the demo design and the local install
bpstack Oct 5, 2026
5789e83
feat(auth): restrict the public demo account with an is_demo flag
bpstack Oct 5, 2026
a306d36
docs(db): mark the is_demo migration as applied on Aiven
bpstack Oct 5, 2026
8553963
fix(auth): answer 403 to blocked demo requests without a JSON body
bpstack Oct 5, 2026
7507d58
feat(auth): enter the public demo with one button, without a password
bpstack Oct 5, 2026
d8c23af
feat(auth): rate limit the demo account's writes and schedule generation
bpstack Oct 5, 2026
db8b356
feat(demo): reset the demo data daily and from Settings
bpstack Oct 5, 2026
a2395a7
docs(db): mark the demo reset log migration as applied on Aiven
bpstack Oct 5, 2026
7e7ed39
test(auth): mock the demo reset in the login timing test
bpstack Oct 5, 2026
ee9cbc0
fix(db): load the mock data on a database with a single admin
bpstack Oct 5, 2026
a8882c4
feat(db): install a local database from scratch with pnpm setup:local
bpstack Oct 5, 2026
9336716
feat(db): give the local install a documented admin password
bpstack Oct 5, 2026
e3a642c
fix(db): group the backoffice monthly summary view by its period
bpstack Oct 5, 2026
9991d79
test(security): check the CSRF defence and the session cookies by beh…
bpstack Oct 5, 2026
4449b7a
fix(checklist): render unsafe links in step notes as plain text
bpstack Oct 5, 2026
143882d
refactor(auth): retire the demo-admin role
bpstack Oct 5, 2026
4884e78
docs: describe the public demo account instead of demo-admin
bpstack Oct 5, 2026
fa744b8
docs: record the demo as built and what is left before going public
bpstack Oct 5, 2026
4c9238e
docs: record the scheduling base and the demo-admin role removal
bpstack Oct 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 41 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,51 @@ Full documentation lives in [`docs/`](docs/general/README.md):
- [Blacklist](docs/blacklist/README.md)
- [Conciliation](docs/conciliation/README.md)

## Local development
## Try it

A public demo runs on the project site: **Try our live demo** on the login
page signs you in as an administrator, with fictitious data that resets every
day. User management and settings are locked there. To see everything, run it
locally.

## Run it locally

You need Node ≥ 22.16, pnpm, Python 3.11+ (for the scheduling solver) and
MySQL 8, either installed or through Docker. `frontend/` and `backend/` are
independent pnpm projects, each with its own lockfile.

```bash
cd backend && pnpm install && pnpm dev:aiven # API against Aiven; frontend looks for it on :4000
cd frontend && pnpm install && pnpm dev # http://localhost:3000
# 1. Settings: copy and fill in (LOCAL_DB_USER=root, a LOCAL_DB_PASSWORD of
# your choice, a long random SECRET_JWT_KEY)
cp backend/.env.example backend/.env
cp frontend/.env.example frontend/.env.local

# 2. MySQL in Docker (skip it if you already run MySQL 8; if that one uses
# port 3306, set LOCAL_DB_PORT=3307 for Docker)
docker compose --env-file backend/.env up -d

# 3. Database: tables, departments, the local admin and fictitious data.
# It creates hotel_db and refuses to touch one that already has tables
# (add -- --force to replace it)
cd backend && pnpm install && pnpm setup:local

# 4. Scheduling solver (once)
cd scheduling-solver && python -m venv venv
venv/Scripts/pip install -r requirements.txt # Linux/macOS: venv/bin/pip
cd ..

# 5. Run
pnpm dev:local # API on :4000
cd ../frontend && pnpm install && pnpm dev # http://localhost:3000
```

Node ≥ 22.16, pnpm. `frontend/` and `backend/` are independent pnpm projects,
each with its own lockfile.
Sign in as **`admin`** / **`fourpoints-local`**: a full administrator,
users, roles and settings included. The password is public on purpose:
`setup:local` only runs against a local database, so this account never
exists anywhere else. Change it in Profile if you like.

Files (photos, PDFs) need a Cloudinary account in `backend/.env`; without it
everything else works.

## License

Expand Down
6 changes: 4 additions & 2 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ NODE_ENV=development
LOG_LEVEL=
# Extra allowed CORS origin, on top of the ones in index.ts (optional)
FRONTEND_URL=
# Public demo: "true" enables POST /api/auth/demo (entry without password)
DEMO_MODE=

# =============================
# Security
Expand All @@ -20,9 +22,9 @@ SECRET_JWT_KEY=change-me-to-a-random-string-of-32-or-more-characters
SALT_ROUNDS=10

# =============================
# MySQL: which preset config/db.ts uses (local | aiven, default: local)
# MySQL: which preset config/db-config.ts uses (local | aiven, default: local)
# =============================
DB_ENVIRONMENT=aiven
DB_ENVIRONMENT=local

# Local MySQL
LOCAL_DB_HOST=localhost
Expand Down
55 changes: 55 additions & 0 deletions backend/config/cors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// config/cors.ts
/**
* CORS, which is also the CSRF defence: a request whose Origin is not one of
* ours is answered 403 before any route runs, including simple requests that
* a browser sends without a preflight (a form POST, a multipart upload).
* Requests without Origin pass: browsers always send it on cross-site writes,
* and server-to-server calls or curl do not carry cookies from a victim.
*/

import cors from 'cors'
import type { Request, Response, NextFunction, RequestHandler } from 'express'
import { logger } from './logger.js'

export function allowedOrigins(
nodeEnv = process.env.NODE_ENV,
frontendUrl = process.env.FRONTEND_URL
): string[] {
return [
...(nodeEnv === 'production' ? [] : ['http://localhost:3000']),
'https://four-points.stackbp.es',
'https://four-points.vercel.app',
'https://api.four-points.stackbp.es',
'https://four-points.onrender.com',
frontendUrl, // URL adicional si es necesario
].filter(Boolean) as string[]
}

export function corsMiddleware(origins = allowedOrigins()): RequestHandler {
const handler = cors({
origin: (origin, callback) => {
// Permitir requests sin origin (Postman, server-to-server, mobile apps)
if (!origin || origins.includes(origin)) {
callback(null, true)
return
}
logger.warn({ origin }, '[CORS] Blocked origin')
// Must stay an error: callback(null, false) would let simple requests reach the routes
callback(new Error('Origen no permitido'))
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'Cookie'],
exposedHeaders: ['Set-Cookie'],
})

return (req: Request, res: Response, next: NextFunction) => {
handler(req, res, (err?: unknown) => {
if (err) {
res.status(403).json({ error: 'Origen no permitido' })
return
}
next()
})
}
}
85 changes: 85 additions & 0 deletions backend/config/db-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// config/db-config.ts
// Connection settings only, without opening anything: scripts that create the
// database (scripts/setup-local.ts) read them before it exists. config/db.ts
// opens the pool with them.

import dotenv from 'dotenv'
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'

dotenv.config()

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)

// Detectar entorno
const isDevelopment = process.env.NODE_ENV === 'development'

// ========================================
// 🔧 INTERFAZ DE CONFIGURACIÓN
// ========================================
interface DBConfig {
// ← AÑADIDO
host: string
port: number
user: string
password: string | undefined
database: string
ssl?: any

Check warning on line 29 in backend/config/db-config.ts

View workflow job for this annotation

GitHub Actions / backend

Unexpected any. Specify a different type

Check warning on line 29 in backend/config/db-config.ts

View workflow job for this annotation

GitHub Actions / backend

Unexpected any. Specify a different type
}

// ========================================
// 🎯 PRESETS DE CONFIGURACIÓN
// ========================================
const presets: Record<string, DBConfig> = {
// ← MODIFICADO (añadido tipo)
local: {
host: process.env.LOCAL_DB_HOST || 'localhost',
port: parseInt(process.env.LOCAL_DB_PORT || '3306'), // ← MODIFICADO (añadido fallback)
user: process.env.LOCAL_DB_USER || 'root',
password: process.env.LOCAL_DB_PASSWORD,
database: process.env.LOCAL_DB_NAME || 'hotel_db',
},
aiven: {
host: process.env.AIVEN_DB_HOST || '', // ← MODIFICADO (añadido fallback)
port: parseInt(process.env.AIVEN_DB_PORT || ''),
user: process.env.AIVEN_DB_USER || '',
password: process.env.AIVEN_PASSWORD,
database: process.env.AIVEN_DB_NAME || 'hotel_db',
ssl: isDevelopment
? { rejectUnauthorized: false }
: {
ca: fs.readFileSync(path.join(__dirname, 'certs', 'ca-certificate.pem')),
rejectUnauthorized: true,
},
},
}

// ========================================
// 🔍 VALIDACIÓN Y SELECCIÓN DE ENTORNO
// ========================================
export const environment = process.env.DB_ENVIRONMENT || 'local'
const config = presets[environment]

// Validar que el entorno existe
if (!config) {
throw new Error(`❌ Entorno no válido: ${environment}`)
}

// Validar credenciales críticas
if (!config.password) {
throw new Error(
`❌ Falta ${environment === 'local' ? 'LOCAL_DB_PASSWORD' : 'AIVEN_PASSWORD'} en .env`
)
}

if (environment === 'aiven' && !config.host) {
throw new Error('❌ Falta AIVEN_DB_HOST en .env')
}

if (environment === 'aiven' && (!config.user || !Number.isInteger(config.port))) {
throw new Error('❌ Faltan AIVEN_DB_USER o AIVEN_DB_PORT en .env')
}

export const dbConfig = config
84 changes: 4 additions & 80 deletions backend/config/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,86 +3,7 @@
import mysql from 'mysql2/promise'
import type { Pool } from 'mysql2/promise' // ← AÑADIDO
import { logger } from './logger.js'
import dotenv from 'dotenv'
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'

// Cargar variables de entorno
dotenv.config()

// Para usar __dirname en ES modules
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)

// Detectar entorno
const isDevelopment = process.env.NODE_ENV === 'development'

// ========================================
// 🔧 INTERFAZ DE CONFIGURACIÓN
// ========================================
interface DBConfig {
// ← AÑADIDO
host: string
port: number
user: string
password: string | undefined
database: string
ssl?: any
}

// ========================================
// 🎯 PRESETS DE CONFIGURACIÓN
// ========================================
const presets: Record<string, DBConfig> = {
// ← MODIFICADO (añadido tipo)
local: {
host: process.env.LOCAL_DB_HOST || 'localhost',
port: parseInt(process.env.LOCAL_DB_PORT || '3306'), // ← MODIFICADO (añadido fallback)
user: process.env.LOCAL_DB_USER || 'root',
password: process.env.LOCAL_DB_PASSWORD,
database: process.env.LOCAL_DB_NAME || 'hotel_db',
},
aiven: {
host: process.env.AIVEN_DB_HOST || '', // ← MODIFICADO (añadido fallback)
port: parseInt(process.env.AIVEN_DB_PORT || ''),
user: process.env.AIVEN_DB_USER || '',
password: process.env.AIVEN_PASSWORD,
database: process.env.AIVEN_DB_NAME || 'hotel_db',
ssl: isDevelopment
? { rejectUnauthorized: false }
: {
ca: fs.readFileSync(path.join(__dirname, 'certs', 'ca-certificate.pem')),
rejectUnauthorized: true,
},
},
}

// ========================================
// 🔍 VALIDACIÓN Y SELECCIÓN DE ENTORNO
// ========================================
const environment = process.env.DB_ENVIRONMENT || 'local'
const config = presets[environment]

// Validar que el entorno existe
if (!config) {
throw new Error(`❌ Entorno no válido: ${environment}`)
}

// Validar credenciales críticas
if (!config.password) {
throw new Error(
`❌ Falta ${environment === 'local' ? 'LOCAL_DB_PASSWORD' : 'AIVEN_PASSWORD'} en .env`
)
}

if (environment === 'aiven' && !config.host) {
throw new Error('❌ Falta AIVEN_DB_HOST en .env')
}

if (environment === 'aiven' && (!config.user || !Number.isInteger(config.port))) {
throw new Error('❌ Faltan AIVEN_DB_USER o AIVEN_DB_PORT en .env')
}
import { dbConfig as config, environment } from './db-config.js'

// ========================================
// 📊 LOG DE CONFIGURACIÓN (sin passwords)
Expand Down Expand Up @@ -122,4 +43,7 @@ async function connectWithRetry(retries = 3, delayMs = 3000): Promise<void> {

connectWithRetry()

// For one-off connections with other options (services/demo: multipleStatements)
export { dbConfig } from './db-config.js'

export default pool
1 change: 1 addition & 0 deletions backend/config/error-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const ERROR_CODES = {
// ============================================================================
AUTH_CREDENTIALS_REQUIRED: 'AUTH_CREDENTIALS_REQUIRED',
AUTH_INVALID_CREDENTIALS: 'AUTH_INVALID_CREDENTIALS',
AUTH_DEMO_UNAVAILABLE: 'AUTH_DEMO_UNAVAILABLE',
AUTH_REFRESH_TOKEN_MISSING: 'AUTH_REFRESH_TOKEN_MISSING',
AUTH_REFRESH_TOKEN_INVALID: 'AUTH_REFRESH_TOKEN_INVALID',
AUTH_USER_NOT_FOUND: 'AUTH_USER_NOT_FOUND',
Expand Down
Loading
Loading