La Oficina Nítida is organized as a pnpm monorepo managed with Turborepo. The workspace contains two applications —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.
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 atenantId foreign key. This is the cornerstone of the platform’s data isolation model.
The tenantId rule
The tenantId rule
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.Tenant isolation guarantee
Tenant isolation guarantee
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.Multi-sede (multi-branch) support
Multi-sede (multi-branch) support
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
JWT token lifecycle
JWT token lifecycle
Authentication issues two tokens on every successful login:
The refresh token secret is never stored in the database. Only a
| Token | Lifetime | Purpose |
|---|---|---|
accessToken | 15 minutes | Authorizes all API requests via Authorization: Bearer header |
refreshToken | 7 days | Opaque base64(tokenId:secret) string used to obtain a new token pair at POST /auth/refresh |
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.Rate limiting
Rate limiting
The
Limits are applied per IP address. Exceeding a limit returns
ThrottlerModule enforces independent rate limits on sensitive endpoints:| Endpoint | Limit | Window |
|---|---|---|
POST /auth/login | 5 requests | 60 seconds |
POST /auth/register | 3 requests | 60 seconds |
POST /auth/refresh | 10 requests | 60 seconds |
POST /auth/forgot-password | 3 requests | 1 hour |
POST /auth/reset-password | 5 requests | 15 minutes |
429 Too Many Requests.File upload validation
File upload validation
The browser-supplied MIME type is explicitly not trusted. Every uploaded file is validated against three independent checks before being accepted:
- Magic numbers — the file’s leading bytes are inspected to confirm the actual binary format.
- Allowed extensions — only explicitly whitelisted extensions are accepted per upload context.
- Maximum file size — enforced at the NestJS layer before the file reaches storage.
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’stenantId.
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.Bulk contract generation
Bulk contract generation
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.
DOCX-to-PDF conversion
DOCX-to-PDF conversion
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.Email delivery
Email delivery
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.Expiration alert generation
Expiration alert generation
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 theStorageModule. Files are never served from the NestJS process directly in production.
The official storage key convention for employee expediente documents is:
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 theAuditLog table. Log entries are immutable — they are append-only and are never updated or deleted. The following action types are audited:
| Action | Trigger |
|---|---|
LOGIN | Successful user authentication |
CREATE | Creation of any core entity (employee, contract, document, invoice, etc.) |
UPDATE | Modification of any core entity |
DELETE | Deletion or logical deactivation of any core entity |
PASSWORD_RESET_REQUESTED | User initiates a password reset flow |
PASSWORD_RESET_COMPLETED | Password reset token is successfully consumed |
EMAIL_FAILED | Transactional email delivery fails |
PERIODO_CONTABLE_REABIERTO | An accounting period is reopened (includes motivo in metadata) |
AuditLog record carries: tenantId, userId, action, entityType, entityId (when applicable), metadata (JSON), and a server-side createdAt timestamp.