Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/Glemynart/SaaS/llms.txt

Use this file to discover all available pages before exploring further.

La Oficina Nítida is organized as a pnpm monorepo managed with Turborepo. The workspace contains two applications — apps/api (NestJS REST API) and apps/web (Next.js 15 frontend) — plus a packages/types shared library (@saas/types) that exports canonical TypeScript types, enums, and utility functions (such as formatNombreCompleto()) consumed by both apps. All packages are listed in pnpm-workspace.yaml and build tasks are orchestrated through turbo.json with proper dependency ordering (^build chains).

Tech stack

Next.js 15

React framework for the frontend application (apps/web). Uses the App Router, shadcn/ui component library, TanStack Query for server-state management, React Hook Form + Zod for validated forms, and a BFF (Backend-for-Frontend) route layer under app/api/ that proxies requests to the NestJS API.

NestJS

Modular Node.js framework for the REST API (apps/api). Each domain area is a dedicated NestJS module: AuthModule, EmployeesModule, ContractsModule, ExpedienteDocsModule, AlertsModule, BillingModule, AccountingModule, and more. Controllers stay thin; all business logic lives in services.

PostgreSQL + Prisma

PostgreSQL 16 is the single source of truth. Prisma ORM handles all schema migrations (versioned under apps/api/prisma/migrations/), type-safe query building, and $transaction support for atomic operations. The Prisma client is regenerated on every schema change.

BullMQ + Redis

BullMQ job queues backed by Redis handle all work that must not block the HTTP request cycle: bulk contract generation, DOCX-to-PDF conversion, outbound emails, and scheduled expiration-alert generation.

Cloudflare R2

Object storage for all binary assets: generated contract PDFs, uploaded expediente documents, invoice PDFs, and XML files. The StorageModule in NestJS abstracts all R2 operations behind a consistent interface.

Resend

Transactional email delivery via the Resend SDK. The global EmailModule wraps Resend with a graceful degraded mode: if RESEND_API_KEY is absent, errors are logged but the application does not crash. Failed email attempts are recorded in AuditLog as EMAIL_FAILED.

Railway

Cloud hosting for both the NestJS API and the Next.js frontend. Railway also hosts the managed PostgreSQL and Redis instances used in production. Environment variables are injected at runtime.

Turborepo

Build orchestration for the monorepo. turbo.json defines build, lint, typecheck, and dev tasks with correct inter-package dependency chains. The .env file is declared as a globalDependency so changes always invalidate the cache.

Multi-tenancy

Every single table in the database includes a tenantId foreign key. This is the cornerstone of the platform’s data isolation model.
Every Prisma query that reads or mutates data must include where: { tenantId }. There are no exceptions. Omitting the tenantId filter is treated as a critical bug.The tenantId value is always sourced from the validated JWT payload — never from the request body, never from URL parameters, and never from query strings. This means a user cannot forge or inject a different tenant’s ID by manipulating the HTTP request.
// Correct — tenantId from JWT, not from request body
const employees = await this.prisma.empleado.findMany({
  where: { tenantId: currentUser.tenantId },
});
Because tenantId flows exclusively from the JWT, a user authenticated to Tenant A has no mechanism to read, write, or enumerate data belonging to Tenant B — even if they know UUIDs of entities in Tenant B. Cross-tenant lookups return 404 Not Found by design rather than 403 Forbidden, to avoid leaking the existence of records.Tenant registration is handled atomically: the Tenant record and the first ADMIN user are created in a single prisma.$transaction. If either insert fails, neither record is committed.
Educational operators commonly manage multiple school campuses. The Sede entity is the official branch abstraction. Employees are linked to a branch via sedeId; contracts and generated documents inherit the branch context. Document templates expose a {{SEDE}} variable that resolves to employee.sedeRef?.nombre at generation time.The data hierarchy is: Tenant → Sede → Empleado → Contrato → ExpedienteDoc.

Security model

Authentication issues two tokens on every successful login:
TokenLifetimePurpose
accessToken15 minutesAuthorizes all API requests via Authorization: Bearer header
refreshToken7 daysOpaque base64(tokenId:secret) string used to obtain a new token pair at POST /auth/refresh
The refresh token secret is never stored in the database. Only a bcrypt hash of the secret is persisted. On refresh, the presented secret is verified against the stored hash. If a previously revoked refresh token is presented, all refresh tokens for that user are immediately revoked (token-reuse detection).All refresh tokens are also revoked when a password reset is completed.
The ThrottlerModule enforces independent rate limits on sensitive endpoints:
EndpointLimitWindow
POST /auth/login5 requests60 seconds
POST /auth/register3 requests60 seconds
POST /auth/refresh10 requests60 seconds
POST /auth/forgot-password3 requests1 hour
POST /auth/reset-password5 requests15 minutes
Limits are applied per IP address. Exceeding a limit returns 429 Too Many Requests.
The browser-supplied MIME type is explicitly not trusted. Every uploaded file is validated against three independent checks before being accepted:
  1. Magic numbers — the file’s leading bytes are inspected to confirm the actual binary format.
  2. Allowed extensions — only explicitly whitelisted extensions are accepted per upload context.
  3. Maximum file size — enforced at the NestJS layer before the file reaches storage.
Bulk employee imports are capped at 500 rows per Excel file, validated atomically before any rows are committed to the database.

Data model

The core entity graph flows from the tenant root downward. Each arrow represents a foreign-key relationship where the child always carries the parent’s tenantId.
Tenant
 ├── Sede            (branch / campus)
 │    └── Empleado   (employee — carries sedeId)
 │         ├── Contrato        (employment contract — snapshots cargo + salario)
 │         │    └── ExpedienteDoc   (documents in category CONTRATO)
 │         └── ExpedienteDoc   (all other document categories)
 ├── User            (platform users with role ADMIN or OPERADOR)
 ├── DocumentTemplate (DOCX templates with dynamic variables)
 ├── GeneratedDocument (PDF output linked to Empleado + ExpedienteDoc)
 ├── Alert           (expiration alerts linked to Contrato + ExpedienteDoc)
 ├── Customer        (invoicing customers)
 ├── Product         (invoicing products/services)
 ├── Invoice         (electronic invoice linked to Customer)
 └── AuditLog        (immutable event log for all critical actions)
Contracts preserve historical snapshots. The Contrato model stores the cargo (job title) and salario (salary) values at the moment of contract creation. If an employee’s position or salary changes later, the historical contract record is not modified — this is the “variables snapshot” pattern, enforced by convention across the codebase.

Async processing

Long-running operations are delegated to BullMQ job queues so the HTTP response is returned immediately and the heavy work runs in a background worker.
When an operator triggers a bulk contract run, the API validates the batch (max 500 employees), enqueues one job per employee, and returns a job reference immediately. Workers process DOCX template rendering and PDF conversion in parallel. The final result is a downloadable ZIP archive.
PDF generation uses Puppeteer for HTML-to-PDF rendering. The conversion step runs inside a BullMQ worker to avoid blocking the API event loop. The resulting PDF is stored in Cloudflare R2 and linked to an ExpedienteDoc record in a single prisma.$transaction — ensuring that pdfUrl and the ExpedienteDoc row are always committed atomically or not at all.
Transactional emails (password reset links, contract generation notifications, expiration alert summaries) are dispatched via the EmailModule. In the MVP, email sending is synchronous and handled by Resend. Email failures are recorded in AuditLog as EMAIL_FAILED but do not interrupt the primary business flow.
A scheduled job queries all Contrato records with estado IN (VIGENTE, RENOVADO) and fechaFin within the next 30, 15, or 7 days. For each match, an Alert record is created (or deduplicated if one already exists for that contract + severity combination). Alerts are linked to the most recent ExpedienteDoc of category CONTRATO for the employee.

Storage

All binary files are stored in Cloudflare R2 via the StorageModule. Files are never served from the NestJS process directly in production. The official storage key convention for employee expediente documents is:
{tenantId}/expediente/{empleadoId}/{categoria}/{id}_v{version}.{ext}
For documents generated by the document engine (PDFs produced from DOCX templates), the key follows a specific prefix:
{tenantId}/expediente/{empleadoId}/{categoria}/gen_{generatedDocId}_v1.pdf
The categoria segment maps to the ExpedienteCategoria enum values: CONTRATO, CERTIFICADO, HOJA_DE_VIDA, OTRO, and others defined in the Prisma schema. This convention makes keys predictable, enables per-tenant and per-employee prefix scans, and ensures that a retry of a failed PDF generation overwrites the previous file (deterministic filename) rather than creating orphaned objects.

Audit log

Every critical action in the platform is recorded in the AuditLog table. Log entries are immutable — they are append-only and are never updated or deleted. The following action types are audited:
ActionTrigger
LOGINSuccessful user authentication
CREATECreation of any core entity (employee, contract, document, invoice, etc.)
UPDATEModification of any core entity
DELETEDeletion or logical deactivation of any core entity
PASSWORD_RESET_REQUESTEDUser initiates a password reset flow
PASSWORD_RESET_COMPLETEDPassword reset token is successfully consumed
EMAIL_FAILEDTransactional email delivery fails
PERIODO_CONTABLE_REABIERTOAn accounting period is reopened (includes motivo in metadata)
Each AuditLog record carries: tenantId, userId, action, entityType, entityId (when applicable), metadata (JSON), and a server-side createdAt timestamp.

Build docs developers (and LLMs) love