Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,28 +47,29 @@ cp frontend/.env.example frontend/.env.local
# 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)
# 3. Database: tables, the local admin and fictitious data in every module
# (scheduling included). 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
# 5. Run, in two terminals
pnpm dev:local # backend/: API on :4000
cd frontend && pnpm install && pnpm dev # http://localhost:3000
```

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.
Uploading photos and PDFs needs a Cloudinary account in `backend/.env`.
Without it everything else works, and an upload just says storage is not
configured.

## License

Expand Down
977 changes: 977 additions & 0 deletions backend/db-mysql/scheduling-seed.sql

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions backend/middlewares/requireStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// middlewares/requireStorage.ts
// Goes before the upload middleware of every route that stores the file on
// Cloudinary (not F&B, which only reads its PDF). Without the three variables
// (a local install without an account) the upload would fail deep in the
// controller with a 500; this answers first, with a code the frontend
// translates.

import type { Request, Response, NextFunction } from 'express'

export const STORAGE_NOT_CONFIGURED = 'STORAGE_NOT_CONFIGURED'

export function isStorageConfigured(env: NodeJS.ProcessEnv = process.env): boolean {
return Boolean(env.CLOUDINARY_CLOUD_NAME && env.CLOUDINARY_API_KEY && env.CLOUDINARY_API_SECRET)
}

export function requireStorage(_req: Request, res: Response, next: NextFunction): void {
if (!isStorageConfigured()) {
res.status(503).json({
error: 'El almacenamiento de archivos no está configurado (Cloudinary).',
code: STORAGE_NOT_CONFIGURED,
})
return
}
next()
}
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"setup:local": "cross-env DB_ENVIRONMENT=local tsx scripts/setup-local.ts",
"demo:user": "cross-env DB_ENVIRONMENT=local tsx scripts/create-demo-user.ts",
"demo:user:aiven": "cross-env DB_ENVIRONMENT=aiven tsx scripts/create-demo-user.ts",
"seed:scheduling:export": "cross-env DB_ENVIRONMENT=aiven tsx scripts/export-scheduling-seed.ts",
"build": "python3 -m venv scheduling-solver/venv && scheduling-solver/venv/bin/pip install --no-cache-dir -r scheduling-solver/requirements.txt",
"start": "tsx index.ts",
"typecheck": "tsc --noEmit",
Expand Down
2 changes: 2 additions & 0 deletions backend/routes/auth/auth-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
profileUpdateLimiter,
} from '../../middlewares/rateLimiter.js'
import { isAdmin } from '../../middlewares/roleCheck.js'
import { requireStorage } from '../../middlewares/requireStorage.js'

const router: Router = express.Router()

Expand Down Expand Up @@ -62,6 +63,7 @@ router.post(
'/me/avatar',
authenticateToken,
denyDemo,
requireStorage,
singleImage('avatar', 2 * 1024 * 1024),
uploadAvatar
)
Expand Down
10 changes: 9 additions & 1 deletion backend/routes/backoffice/backoffice-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { BackofficeController } from '../../controllers/backoffice/backoffice-co
import { authenticateToken } from '../../middlewares/authenticateToken.js'
import { canAccessBackoffice, isAdmin } from '../../middlewares/roleCheck.js'
import { denyDemo } from '../../middlewares/demoRestriction.js'
import { requireStorage } from '../../middlewares/requireStorage.js'

const router = Router()

Expand Down Expand Up @@ -265,6 +266,7 @@ router.delete('/invoices/:id', isAdmin, BackofficeController.deleteInvoice)
router.post(
'/invoices/:id/pdf',
isAdmin,
requireStorage,
upload.single('pdf'),
BackofficeController.uploadInvoicePdf
)
Expand Down Expand Up @@ -304,7 +306,13 @@ router.get('/assets/:id/file', BackofficeController.getAssetFile)
* @body type (stamp/signature), name, is_default (opcional)
* @file image (multipart/form-data) - PNG o WebP
*/
router.post('/assets', isAdmin, upload.single('image'), BackofficeController.createAsset)
router.post(
'/assets',
isAdmin,
requireStorage,
upload.single('image'),
BackofficeController.createAsset
)

/**
* @route DELETE /api/backoffice/assets/:id
Expand Down
3 changes: 2 additions & 1 deletion backend/routes/blacklist/blacklist-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import multer from 'multer'
import { BlacklistController } from '../../controllers/blacklist/blacklist-controller.js'
import { authenticateToken } from '../../middlewares/authenticateToken.js'
import { excludeMantenimiento } from '../../middlewares/roleCheck.js'
import { requireStorage } from '../../middlewares/requireStorage.js'

const router = Router()

Expand All @@ -35,7 +36,7 @@ router.use(excludeMantenimiento)
* POST /api/blacklist/upload
* Subir imagen a Cloudinary
*/
router.post('/upload', upload.single('image'), BlacklistController.uploadImage)
router.post('/upload', requireStorage, upload.single('image'), BlacklistController.uploadImage)

/**
* GET /api/blacklist/images/:file
Expand Down
8 changes: 7 additions & 1 deletion backend/routes/checklist/checklist-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import express, { Router } from 'express'
import { authenticateToken } from '../../middlewares/authenticateToken.js'
import { excludeMantenimiento, canResetChecklist } from '../../middlewares/roleCheck.js'
import { singleImage } from '../../middlewares/imageUpload.js'
import { requireStorage } from '../../middlewares/requireStorage.js'
import { validateChecklistParams } from '../../middlewares/checklistParams.js'
import {
getRunController,
Expand Down Expand Up @@ -61,7 +62,12 @@ router.delete('/:id/steps/:stepId/comments/:commentId', deleteCommentController)
router.get('/:id/steps/:stepId/attachments', getAttachmentsController)

// POST /api/checklists/:id/steps/:stepId/attachments
router.post('/:id/steps/:stepId/attachments', singleImage('file'), addAttachmentController)
router.post(
'/:id/steps/:stepId/attachments',
requireStorage,
singleImage('file'),
addAttachmentController
)

// DELETE /api/checklists/:id/steps/:stepId/attachments/:attachmentId
router.delete('/:id/steps/:stepId/attachments/:attachmentId', deleteAttachmentController)
Expand Down
3 changes: 2 additions & 1 deletion backend/routes/maintenance/maintenance-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { singleImage } from '../../middlewares/imageUpload.js'
import { validateParams } from '../../middlewares/validateParams.js'
import { MAINTENANCE_PARAM_RULES } from '../../validations/maintenance/schemas.js'
import { canAccessMaintenance } from '../../middlewares/roleCheck.js'
import { requireStorage } from '../../middlewares/requireStorage.js'

const router = Router()

Expand Down Expand Up @@ -136,7 +137,7 @@ router.get('/:id/images/:imageId/file', MaintenanceController.getImageFile)
* @access Private
* @file image (multipart/form-data)
*/
router.post('/:id/images', singleImage('image'), MaintenanceController.uploadImage)
router.post('/:id/images', requireStorage, singleImage('image'), MaintenanceController.uploadImage)

/**
* @route DELETE /api/maintenance/:id/images/:imageId
Expand Down
159 changes: 159 additions & 0 deletions backend/scripts/export-scheduling-seed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* export-scheduling-seed.ts — `pnpm seed:scheduling:export`
*
* Writes db-mysql/scheduling-seed.sql from the demo's scheduling base (the
* demo_snapshot_scheduling_* tables saved with «Guardar horarios actuales como
* base»). pnpm setup:local loads that file, so a local install gets the same
* fictitious employees, contracts and months as the public demo.
*
* Only reads: SELECTs on the database of DB_ENVIRONMENT (aiven by default).
* The employees are users: they are exported with a bcrypt hash of a random
* password nobody knows, so they show in the grid but cannot sign in. Rows of
* people no longer in the grid are left out, and every other reference to a
* user (who created or published a month...) points to the local admin.
*
* Uso:
* pnpm seed:scheduling:export
*/

import bcrypt from 'bcrypt'
import crypto from 'node:crypto'
import { writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import mysql from 'mysql2/promise'
import type { RowDataPacket } from 'mysql2/promise'
import { dbConfig } from '../config/db-config.js'
import { SALT_ROUNDS } from '../config/config.js'
import { SNAPSHOT_TABLES } from '../services/demo/snapshot-tables.js'

const OUT = join(dirname(fileURLToPath(import.meta.url)), '..', 'db-mysql', 'scheduling-seed.sql')

// Columns that hold a user id; the employee ones keep it, the rest become the admin
const USER_COLUMNS = new Set([
'employee_id',
'added_by',
'created_by',
'approved_by',
'changed_by',
'published_by',
'generated_by',
])
const ADMIN = '@seed_admin_id'

function value(v: unknown, column: string, employees: Set<string>): string {
if (USER_COLUMNS.has(column) && typeof v === 'string' && !employees.has(v)) return ADMIN
// JSON columns come back parsed
if (v !== null && typeof v === 'object' && !Buffer.isBuffer(v))
return mysql.escape(JSON.stringify(v))
return mysql.escape(v)
}

async function main(): Promise<void> {
const conn = await mysql.createConnection({
host: dbConfig.host,
port: dbConfig.port,
user: dbConfig.user,
password: dbConfig.password,
database: dbConfig.database,
ssl: dbConfig.ssl,
dateStrings: true,
})
try {
const rows: Record<string, RowDataPacket[]> = {}
for (const table of SNAPSHOT_TABLES) {
const [r] = await conn.query<RowDataPacket[]>(`SELECT * FROM demo_snapshot_${table}`)
rows[table] = r
}
if (rows.scheduling_employees.length === 0) {
throw new Error('No hay base guardada (demo_snapshot_scheduling_employees vacía)')
}

const employees = new Set(rows.scheduling_employees.map((r) => String(r.employee_id)))
// Rows of people no longer in the grid (old contracts...) would point to the admin
const dropped: string[] = []
for (const table of SNAPSHOT_TABLES) {
const kept = rows[table].filter(
(r) =>
!('employee_id' in r) || r.employee_id === null || employees.has(String(r.employee_id))
)
if (kept.length < rows[table].length)
dropped.push(`${table}: ${rows[table].length - kept.length}`)
rows[table] = kept
}
const [users] = await conn.query<RowDataPacket[]>(
`SELECT u.id, u.username, u.email, r.name AS role, u.is_active
FROM users u JOIN roles r ON r.id = u.role_id
WHERE u.id IN (?) ORDER BY u.username`,
[[...employees]]
)
if (users.length !== employees.size) throw new Error('Faltan usuarios de algún empleado')

const hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), SALT_ROUNDS)
const out: string[] = [
'-- =========================================================',
'-- SCHEDULING SEED - Four-Points',
'-- =========================================================',
'-- Base de horarios ficticia de la demo pública: empleados, contratos,',
'-- reglas, turnos, configuración y meses ya generados por el solver.',
'-- La carga pnpm setup:local después de mock-data.sql.',
'--',
'-- BORRA todo el módulo de horarios (scheduling_*) antes de cargarla: solo',
'-- para una BD local recién instalada.',
'--',
'-- Los empleados son usuarios inventados con una contraseña aleatoria que',
'-- nadie conoce: salen en el cuadrante pero no pueden entrar. Lo que en la',
'-- demo apunta a otro usuario (quién creó o publicó un mes) apunta aquí al',
'-- primer admin activo.',
'--',
'-- Generado con: pnpm seed:scheduling:export (scripts/export-scheduling-seed.ts)',
'-- =========================================================',
'',
"SET @seed_admin_id = (SELECT u.id FROM users u JOIN roles r ON r.id = u.role_id WHERE r.name = 'admin' AND u.is_active = 1 AND u.is_demo = 0 ORDER BY u.created_at LIMIT 1);",
'',
'-- Los DELETE vacían tablas enteras',
'SET SQL_SAFE_UPDATES = 0;',
'SET FOREIGN_KEY_CHECKS = 0;',
'START TRANSACTION;',
'',
]
for (const table of [...SNAPSHOT_TABLES].reverse()) out.push(`DELETE FROM ${table};`)
out.push('')

out.push('-- Empleados (usuarios inventados)')
for (const u of users) {
out.push(
`INSERT IGNORE INTO users (id, username, email, password, role_id, is_active) SELECT ${mysql.escape(u.id)}, ${mysql.escape(u.username)}, ${mysql.escape(u.email)}, ${mysql.escape(hash)}, r.id, ${Number(u.is_active)} FROM roles r WHERE r.name = ${mysql.escape(u.role)};`
)
}
out.push('')

for (const table of SNAPSHOT_TABLES) {
const r = rows[table]
if (r.length === 0) continue
const cols = Object.keys(r[0])
out.push(`-- ${table} (${r.length})`)
out.push(`INSERT INTO ${table} (${cols.join(', ')}) VALUES`)
out.push(
r.map((row) => `(${cols.map((c) => value(row[c], c, employees)).join(', ')})`).join(',\n') +
';'
)
out.push('')
}
out.push('COMMIT;', 'SET FOREIGN_KEY_CHECKS = 1;', '')

await writeFile(OUT, out.join('\n'))
console.log(`${OUT}`)
console.log(` empleados: ${users.map((u) => u.username).join(', ')}`)
for (const table of SNAPSHOT_TABLES) console.log(` ${table}: ${rows[table].length}`)
if (dropped.length)
console.log(` fuera (de quien ya no está en el cuadrante): ${dropped.join(', ')}`)
} finally {
await conn.end()
}
}

main().catch((err) => {
console.error(err instanceof Error ? err.message : err)
process.exit(1)
})
15 changes: 10 additions & 5 deletions backend/scripts/setup-local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
* 2. departamentos que usan los módulos y el mock;
* 3. el admin local `admin` / `fourpoints-local`, público y documentado en el
* README: solo puede existir en una BD local;
* 4. datos ficticios (mock-data.sql).
* 4. la base de horarios de la demo (scheduling-seed.sql): empleados, contratos
* y meses ficticios;
* 5. datos ficticios del resto de módulos (mock-data.sql).
*
* MASTER_INSTALL.sql borra y crea `hotel_db`: el script se niega a seguir si
* la BD no es local, si no se llama hotel_db o si ya tiene tablas (salvo
Expand Down Expand Up @@ -120,7 +122,7 @@ async function main(): Promise<void> {
)
}

console.log('1/4 Tablas (MASTER_INSTALL.sql)')
console.log('1/5 Tablas (MASTER_INSTALL.sql)')
await conn.query(await readSqlScript(join(DB_DIR, 'MASTER_INSTALL.sql'), DB_DIR, fixBaseline))
await conn.query(`USE \`${DATABASE}\``)

Expand All @@ -132,12 +134,12 @@ async function main(): Promise<void> {
await conn.query(await readSqlScript(join(DB_DIR, 'scripts', file)))
}

console.log('2/4 Departamentos')
console.log('2/5 Departamentos')
await conn.query('INSERT IGNORE INTO departments (name) VALUES ?', [
DEPARTMENTS.map((name) => [name]),
])

console.log('3/4 Admin')
console.log('3/5 Admin')
const [roles] = await conn.query<RowDataPacket[]>("SELECT id FROM roles WHERE name = 'admin'")
await conn.query(
`INSERT INTO users (id, username, email, password, role_id, created_at, is_active)
Expand All @@ -151,7 +153,10 @@ async function main(): Promise<void> {
]
)

console.log('4/4 Datos ficticios (mock-data.sql)')
console.log('4/5 Horarios (scheduling-seed.sql)')
await conn.query(await readSqlScript(join(DB_DIR, 'scheduling-seed.sql')))

console.log('5/5 Datos ficticios (mock-data.sql)')
// MASTER_INSTALL.sql leaves safe updates on for this session; the mock empties whole tables
await conn.query('SET SQL_SAFE_UPDATES = 0')
await conn.query(await readSqlScript(join(DB_DIR, 'mock-data.sql')))
Expand Down
Loading
Loading