Initial commit: Horux Despachos project
This commit is contained in:
772
docs/superpowers/plans/2026-04-17-plan2a-schema-auth.md
Normal file
772
docs/superpowers/plans/2026-04-17-plan2a-schema-auth.md
Normal file
@@ -0,0 +1,772 @@
|
||||
# Plan 2A: Schema + Auth para Horux Despachos — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Evolucionar el schema de BD central y tenant para soportar el modelo de despachos (verticalProfile, dbMode, entidades gestionadas, contribuyentes, carteras), agregar roles de despacho (supervisor, cliente), refactorear el migration runner para tracking por scope, y crear un endpoint de signup básico para despachos.
|
||||
|
||||
**Architecture:** Se evoluciona el modelo `Tenant` existente (no se renombra — un despacho ES un tenant con campos adicionales). Se agregan 5 tenant migrations SQL (tracking table + entidades + carteras + contribuyentes + cliente_accesos). El migration runner se extiende para soportar tabla de tracking (`tenant_migrations`). Se agregan tipos nuevos a `@horux/shared`.
|
||||
|
||||
**Tech Stack:** Prisma 5.22, PostgreSQL 16, TypeScript 5, Express 4.21, pnpm workspaces.
|
||||
|
||||
**Validation:** `pnpm --filter @horux/api typecheck` (57 pre-existing errors baseline — verify no NEW errors). `pnpm --filter @horux/shared typecheck` (0 errors baseline).
|
||||
|
||||
**Git:** Commits locales, sin push. Un commit por task.
|
||||
|
||||
**Pre-existing codebase context (Plan 2A engineer MUST know):**
|
||||
- Prisma schema: `apps/api/prisma/schema.prisma` — modelos Tenant, User, TenantMembership, Rol, FielCredential, Subscription, etc.
|
||||
- Current roles table (Rol): id=1 owner, id=2 contador, id=3 visor, id=7 cfo, id=8 auxiliar.
|
||||
- Tenant migrations: `apps/api/src/migrations/tenant/001-005.sql` — flat numbered, applied lazily by `TenantConnectionManager.getPool()` via `migrate()` in `config/tenant-migrations.ts`.
|
||||
- Auth JWT payload: `{ userId, email, role, tenantId, platformRoles?, tokenVersion? }` from `@horux/shared`.
|
||||
- Config env: `apps/api/src/config/env.ts` — Zod-validated, includes DATABASE_URL, JWT_SECRET, FIEL_ENCRYPTION_KEY.
|
||||
- Imports in apps/api use `.js` extension (NodeNext module resolution).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**New files:**
|
||||
- `apps/api/prisma/migrations/YYYYMMDD_despacho_fields/migration.sql` — Prisma migration (auto-generated)
|
||||
- `apps/api/src/migrations/tenant/006_tenant_migrations_tracking.sql` — tracking table for scope-based migrations
|
||||
- `apps/api/src/migrations/tenant/007_entidades_gestionadas.sql` — core: base entity table
|
||||
- `apps/api/src/migrations/tenant/008_carteras.sql` — core: portfolios + assignments
|
||||
- `apps/api/src/migrations/tenant/009_cliente_accesos.sql` — core: client-viewer access
|
||||
- `apps/api/src/migrations/tenant/010_contribuyentes.sql` — vertical-contable: taxpayer subtype
|
||||
- `packages/shared/src/types/despacho.ts` — DespachoRole, VerticalProfile, DbMode types
|
||||
- `apps/api/src/controllers/despacho.controller.ts` — signup endpoint
|
||||
- `apps/api/src/services/despacho.service.ts` — signup business logic
|
||||
- `apps/api/src/routes/despacho.routes.ts` — route mounting
|
||||
|
||||
**Modified files:**
|
||||
- `apps/api/prisma/schema.prisma` — add fields to Tenant, add enums
|
||||
- `apps/api/prisma/seed.ts` — add 'supervisor' and 'cliente' roles
|
||||
- `apps/api/src/config/tenant-migrations.ts` — support tracking table
|
||||
- `apps/api/src/app.ts` — mount despacho routes
|
||||
- `packages/shared/src/types/auth.ts` — add DespachoRole to exports
|
||||
- `packages/shared/src/index.ts` — re-export despacho types
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: Prisma migration — add despacho fields to Tenant
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/api/prisma/schema.prisma`
|
||||
- Create: auto-generated migration via `prisma migrate dev`
|
||||
|
||||
- [ ] **Step 1: Add new enums and fields to Prisma schema**
|
||||
|
||||
Open `apps/api/prisma/schema.prisma` and add the following:
|
||||
|
||||
After the existing `enum Plan { ... }`:
|
||||
```prisma
|
||||
enum VerticalProfile {
|
||||
CONTABLE
|
||||
JURIDICO
|
||||
ARQUITECTURA
|
||||
}
|
||||
|
||||
enum DbMode {
|
||||
BYO
|
||||
MANAGED
|
||||
}
|
||||
```
|
||||
|
||||
In the `model Tenant { ... }`, add AFTER the `telefono` field (before the relations block):
|
||||
```prisma
|
||||
// === Despacho fields (Plan 2A) ===
|
||||
verticalProfile VerticalProfile? @map("vertical_profile")
|
||||
dbMode DbMode? @map("db_mode")
|
||||
dbConnectionEnc String? @map("db_connection_enc")
|
||||
dbConnectionIv String? @map("db_connection_iv")
|
||||
dbSchemaVersion Int @default(0) @map("db_schema_version")
|
||||
connectorTokenEnc String? @map("connector_token_enc")
|
||||
connectorTunnelHostname String? @map("connector_tunnel_hostname")
|
||||
connectorLastSeen DateTime? @map("connector_last_seen")
|
||||
connectorVersion String? @map("connector_version") @db.VarChar(20)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Generate and apply Prisma migration**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd apps/api && npx prisma migrate dev --name despacho_fields
|
||||
```
|
||||
|
||||
Expected: migration SQL generated in `prisma/migrations/YYYYMMDD_despacho_fields/`. Since all new fields are nullable or have defaults, this is safe for existing data.
|
||||
|
||||
If the command fails because there's no DB connection, create the migration without applying:
|
||||
```bash
|
||||
cd apps/api && npx prisma migrate dev --name despacho_fields --create-only
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Generate Prisma client**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd apps/api && npx prisma generate
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify typecheck**
|
||||
|
||||
Run: `pnpm --filter @horux/api typecheck`
|
||||
Expected: same 57 pre-existing errors, no new ones.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/api/prisma/
|
||||
git commit -m "feat(schema): add despacho fields to Tenant model (verticalProfile, dbMode, connector)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Seed new roles (supervisor, cliente)
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/api/prisma/seed.ts`
|
||||
|
||||
- [ ] **Step 1: Read current seed.ts to understand the roles seeding pattern**
|
||||
|
||||
Open `apps/api/prisma/seed.ts` and find where roles are upserted. The current roles are:
|
||||
```
|
||||
id=1: owner, id=2: contador, id=3: visor, id=7: cfo, id=8: auxiliar
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add supervisor and cliente roles to the seed**
|
||||
|
||||
Add to the roles upsert section:
|
||||
```typescript
|
||||
await prisma.rol.upsert({
|
||||
where: { nombre: 'supervisor' },
|
||||
update: {},
|
||||
create: { id: 9, nombre: 'supervisor', descripcion: 'Supervisor de despacho — titular de RFCs, crea carteras' },
|
||||
});
|
||||
|
||||
await prisma.rol.upsert({
|
||||
where: { nombre: 'cliente' },
|
||||
update: {},
|
||||
create: { id: 10, nombre: 'cliente', descripcion: 'Cliente visor externo — acceso read-only a sus RFCs' },
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run seed (if DB is available)**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd apps/api && npx prisma db seed
|
||||
```
|
||||
|
||||
If no DB connection, skip — the seed will run at next deploy. The migration and seed are idempotent.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/api/prisma/seed.ts
|
||||
git commit -m "feat(seed): add supervisor and cliente roles for despachos"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add despacho types to @horux/shared
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/shared/src/types/despacho.ts`
|
||||
- Modify: `packages/shared/src/index.ts` (or wherever types are re-exported)
|
||||
|
||||
- [ ] **Step 1: Create despacho types file**
|
||||
|
||||
Create `packages/shared/src/types/despacho.ts`:
|
||||
|
||||
```typescript
|
||||
export type DespachoRole = 'owner' | 'supervisor' | 'auxiliar' | 'cliente';
|
||||
|
||||
export type VerticalProfile = 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
|
||||
|
||||
export type DbMode = 'BYO' | 'MANAGED';
|
||||
|
||||
export interface DespachoInfo {
|
||||
id: string;
|
||||
nombre: string;
|
||||
rfc: string;
|
||||
verticalProfile: VerticalProfile;
|
||||
dbMode: DbMode | null;
|
||||
plan: string;
|
||||
}
|
||||
|
||||
export interface DespachoSignupRequest {
|
||||
despacho: {
|
||||
nombre: string;
|
||||
rfc: string;
|
||||
regimenFiscal?: string;
|
||||
codigoPostal?: string;
|
||||
verticalProfile: VerticalProfile;
|
||||
};
|
||||
owner: {
|
||||
nombre: string;
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContribuyenteInfo {
|
||||
id: string;
|
||||
rfc: string;
|
||||
razonSocial: string;
|
||||
regimenFiscal: string;
|
||||
codigoPostal?: string;
|
||||
supervisorUserId?: string;
|
||||
active: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Find and update the barrel export**
|
||||
|
||||
Read `packages/shared/src/index.ts` to see how types are exported. Add:
|
||||
|
||||
```typescript
|
||||
export * from './types/despacho';
|
||||
```
|
||||
|
||||
If the barrel uses a different pattern (e.g., explicit re-exports), follow that pattern.
|
||||
|
||||
- [ ] **Step 3: Verify typecheck**
|
||||
|
||||
Run: `pnpm --filter @horux/shared typecheck`
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/shared/
|
||||
git commit -m "feat(shared): add DespachoRole, VerticalProfile, DbMode types"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Tenant migration — tracking table + entidades_gestionadas
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/api/src/migrations/tenant/006_tenant_migrations_tracking.sql`
|
||||
- Create: `apps/api/src/migrations/tenant/007_entidades_gestionadas.sql`
|
||||
|
||||
- [ ] **Step 1: Create migration 006 — tracking table**
|
||||
|
||||
Create `apps/api/src/migrations/tenant/006_tenant_migrations_tracking.sql`:
|
||||
|
||||
```sql
|
||||
-- Tracking table for scope-based migrations.
|
||||
-- Allows checking which migrations have been applied and which are pending.
|
||||
-- For now, all existing migrations (001-005) are considered "legacy" scope
|
||||
-- and are tracked by the existing file-based runner. This table tracks
|
||||
-- only NEW migrations going forward (007+).
|
||||
CREATE TABLE IF NOT EXISTS tenant_migrations (
|
||||
scope varchar(50) NOT NULL,
|
||||
version int NOT NULL,
|
||||
name varchar(255),
|
||||
applied_at timestamptz DEFAULT now(),
|
||||
PRIMARY KEY (scope, version)
|
||||
);
|
||||
|
||||
-- Mark 001-005 as already applied under "legacy" scope
|
||||
-- so the runner doesn't try to re-apply them.
|
||||
INSERT INTO tenant_migrations (scope, version, name)
|
||||
VALUES
|
||||
('legacy', 1, '001_initial_schema'),
|
||||
('legacy', 2, '002_create_opiniones_cumplimiento'),
|
||||
('legacy', 3, '003_create_declaraciones_provisionales'),
|
||||
('legacy', 4, '004_declaraciones_liga_pago_pdf'),
|
||||
('legacy', 5, '005_create_constancias_situacion_fiscal'),
|
||||
('legacy', 6, '006_tenant_migrations_tracking')
|
||||
ON CONFLICT (scope, version) DO NOTHING;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create migration 007 — entidades_gestionadas**
|
||||
|
||||
Create `apps/api/src/migrations/tenant/007_entidades_gestionadas.sql`:
|
||||
|
||||
```sql
|
||||
-- Core table: base entity managed by the despacho.
|
||||
-- Subtyped by vertical (e.g., contribuyentes for CONTABLE).
|
||||
-- Carteras and client access operate on this table (vertical-agnostic).
|
||||
CREATE TABLE IF NOT EXISTS entidades_gestionadas (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tipo varchar(20) NOT NULL,
|
||||
nombre text NOT NULL,
|
||||
identificador text,
|
||||
supervisor_user_id uuid,
|
||||
active boolean DEFAULT true,
|
||||
created_at timestamptz DEFAULT now(),
|
||||
updated_at timestamptz DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_entidades_supervisor ON entidades_gestionadas(supervisor_user_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_entidades_tipo ON entidades_gestionadas(tipo, active);
|
||||
CREATE INDEX IF NOT EXISTS ix_entidades_identificador ON entidades_gestionadas(identificador);
|
||||
|
||||
INSERT INTO tenant_migrations (scope, version, name)
|
||||
VALUES ('core', 7, '007_entidades_gestionadas')
|
||||
ON CONFLICT (scope, version) DO NOTHING;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify SQL syntax**
|
||||
|
||||
Read both files back to confirm no typos. The SQL should be idempotent (`IF NOT EXISTS`, `ON CONFLICT DO NOTHING`).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/api/src/migrations/tenant/006_tenant_migrations_tracking.sql apps/api/src/migrations/tenant/007_entidades_gestionadas.sql
|
||||
git commit -m "feat(migrations): add tenant_migrations tracking + entidades_gestionadas table"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Tenant migrations — carteras + cliente_accesos + contribuyentes
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/api/src/migrations/tenant/008_carteras.sql`
|
||||
- Create: `apps/api/src/migrations/tenant/009_cliente_accesos.sql`
|
||||
- Create: `apps/api/src/migrations/tenant/010_contribuyentes.sql`
|
||||
|
||||
- [ ] **Step 1: Create migration 008 — carteras**
|
||||
|
||||
Create `apps/api/src/migrations/tenant/008_carteras.sql`:
|
||||
|
||||
```sql
|
||||
-- Core: supervisor portfolios. A supervisor groups entities into carteras
|
||||
-- and assigns auxiliares to them. Cascading: if supervisor loses an entity,
|
||||
-- it's removed from all their carteras automatically (via JOIN, not trigger).
|
||||
CREATE TABLE IF NOT EXISTS carteras (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
supervisor_user_id uuid NOT NULL,
|
||||
nombre text NOT NULL,
|
||||
descripcion text,
|
||||
created_at timestamptz DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_carteras_supervisor ON carteras(supervisor_user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cartera_entidades (
|
||||
cartera_id uuid NOT NULL REFERENCES carteras(id) ON DELETE CASCADE,
|
||||
entidad_id uuid NOT NULL REFERENCES entidades_gestionadas(id) ON DELETE CASCADE,
|
||||
added_at timestamptz DEFAULT now(),
|
||||
PRIMARY KEY (cartera_id, entidad_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cartera_auxiliares (
|
||||
cartera_id uuid NOT NULL REFERENCES carteras(id) ON DELETE CASCADE,
|
||||
auxiliar_user_id uuid NOT NULL,
|
||||
added_at timestamptz DEFAULT now(),
|
||||
PRIMARY KEY (cartera_id, auxiliar_user_id)
|
||||
);
|
||||
|
||||
INSERT INTO tenant_migrations (scope, version, name)
|
||||
VALUES ('core', 8, '008_carteras')
|
||||
ON CONFLICT (scope, version) DO NOTHING;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create migration 009 — cliente_accesos**
|
||||
|
||||
Create `apps/api/src/migrations/tenant/009_cliente_accesos.sql`:
|
||||
|
||||
```sql
|
||||
-- Core: direct access grants for external client-viewers.
|
||||
-- A client user can see specific entities (not via carteras).
|
||||
CREATE TABLE IF NOT EXISTS cliente_accesos (
|
||||
user_id uuid NOT NULL,
|
||||
entidad_id uuid NOT NULL REFERENCES entidades_gestionadas(id) ON DELETE CASCADE,
|
||||
granted_at timestamptz DEFAULT now(),
|
||||
PRIMARY KEY (user_id, entidad_id)
|
||||
);
|
||||
|
||||
INSERT INTO tenant_migrations (scope, version, name)
|
||||
VALUES ('core', 9, '009_cliente_accesos')
|
||||
ON CONFLICT (scope, version) DO NOTHING;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create migration 010 — contribuyentes (vertical contable)**
|
||||
|
||||
Create `apps/api/src/migrations/tenant/010_contribuyentes.sql`:
|
||||
|
||||
```sql
|
||||
-- Vertical CONTABLE: taxpayer subtype of entidades_gestionadas.
|
||||
-- Uses single-PK inheritance: contribuyentes.entidad_id = entidades_gestionadas.id.
|
||||
-- CFDI and other fiscal tables will FK to this table (via entidad_id, not a separate id).
|
||||
CREATE TABLE IF NOT EXISTS contribuyentes (
|
||||
entidad_id uuid PRIMARY KEY REFERENCES entidades_gestionadas(id) ON DELETE CASCADE,
|
||||
rfc varchar(13) NOT NULL UNIQUE,
|
||||
regimen_fiscal varchar(3),
|
||||
codigo_postal varchar(5),
|
||||
domicilio jsonb
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_contribuyentes_rfc ON contribuyentes(rfc);
|
||||
|
||||
INSERT INTO tenant_migrations (scope, version, name)
|
||||
VALUES ('vertical-contable', 10, '010_contribuyentes')
|
||||
ON CONFLICT (scope, version) DO NOTHING;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/api/src/migrations/tenant/008_carteras.sql apps/api/src/migrations/tenant/009_cliente_accesos.sql apps/api/src/migrations/tenant/010_contribuyentes.sql
|
||||
git commit -m "feat(migrations): add carteras, cliente_accesos, contribuyentes tables"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Despacho signup service + controller + route
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/api/src/services/despacho.service.ts`
|
||||
- Create: `apps/api/src/controllers/despacho.controller.ts`
|
||||
- Create: `apps/api/src/routes/despacho.routes.ts`
|
||||
- Modify: `apps/api/src/app.ts` (mount route)
|
||||
|
||||
- [ ] **Step 1: Create despacho service**
|
||||
|
||||
Create `apps/api/src/services/despacho.service.ts`:
|
||||
|
||||
```typescript
|
||||
import { prisma, tenantDb } from '../config/database.js';
|
||||
import { hashPassword } from '../auth/passwords.js';
|
||||
import { generateAccessToken, generateRefreshToken } from '../auth/tokens.js';
|
||||
import type { DespachoSignupRequest, VerticalProfile } from '@horux/shared';
|
||||
import type { JWTPayload, Role } from '@horux/shared';
|
||||
|
||||
export async function signupDespacho(data: DespachoSignupRequest) {
|
||||
const { despacho, owner } = data;
|
||||
|
||||
// Validate uniqueness
|
||||
const existingTenant = await prisma.tenant.findUnique({ where: { rfc: despacho.rfc } });
|
||||
if (existingTenant) {
|
||||
throw new Error('Ya existe una empresa registrada con este RFC');
|
||||
}
|
||||
|
||||
const existingUser = await prisma.user.findUnique({ where: { email: owner.email } });
|
||||
if (existingUser) {
|
||||
throw new Error('Ya existe un usuario con este email');
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(owner.password);
|
||||
|
||||
// Create tenant + user + membership in transaction
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// 1. Create tenant as despacho
|
||||
const databaseName = `horux_${despacho.rfc.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
|
||||
|
||||
const tenant = await tx.tenant.create({
|
||||
data: {
|
||||
nombre: despacho.nombre,
|
||||
rfc: despacho.rfc.toUpperCase(),
|
||||
plan: 'starter',
|
||||
databaseName,
|
||||
cfdiLimit: 0,
|
||||
usersLimit: 3,
|
||||
verticalProfile: despacho.verticalProfile as any,
|
||||
dbMode: 'MANAGED' as any,
|
||||
dbSchemaVersion: 0,
|
||||
trialEndsAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
|
||||
codigoPostal: despacho.codigoPostal,
|
||||
},
|
||||
});
|
||||
|
||||
// 2. Create user
|
||||
const user = await tx.user.create({
|
||||
data: {
|
||||
email: owner.email.toLowerCase(),
|
||||
passwordHash,
|
||||
nombre: owner.nombre,
|
||||
lastTenantId: tenant.id,
|
||||
},
|
||||
});
|
||||
|
||||
// 3. Create membership as owner
|
||||
const ownerRole = await tx.rol.findUnique({ where: { nombre: 'owner' } });
|
||||
if (!ownerRole) throw new Error('Rol owner no encontrado en BD');
|
||||
|
||||
await tx.tenantMembership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
tenantId: tenant.id,
|
||||
rolId: ownerRole.id,
|
||||
isOwner: true,
|
||||
},
|
||||
});
|
||||
|
||||
return { tenant, user };
|
||||
});
|
||||
|
||||
// 4. Provision tenant database (outside transaction — creates actual DB)
|
||||
try {
|
||||
await tenantDb.provisionDatabase(despacho.rfc);
|
||||
} catch (err: any) {
|
||||
// If DB provisioning fails, delete the tenant (rollback)
|
||||
await prisma.tenant.delete({ where: { id: result.tenant.id } });
|
||||
await prisma.user.delete({ where: { id: result.user.id } });
|
||||
throw new Error(`Error al crear base de datos del despacho: ${err.message}`);
|
||||
}
|
||||
|
||||
// 5. Generate JWT pair
|
||||
const payload: Omit<JWTPayload, 'iat' | 'exp'> = {
|
||||
userId: result.user.id,
|
||||
email: result.user.email,
|
||||
role: 'owner' as Role,
|
||||
tenantId: result.tenant.id,
|
||||
tokenVersion: 0,
|
||||
};
|
||||
|
||||
const accessToken = generateAccessToken(payload);
|
||||
const refreshToken = generateRefreshToken(payload);
|
||||
|
||||
// 6. Store refresh token
|
||||
await prisma.refreshToken.create({
|
||||
data: {
|
||||
userId: result.user.id,
|
||||
token: refreshToken,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: {
|
||||
id: result.user.id,
|
||||
email: result.user.email,
|
||||
nombre: result.user.nombre,
|
||||
role: 'owner' as Role,
|
||||
tenantId: result.tenant.id,
|
||||
tenantName: result.tenant.nombre,
|
||||
tenantRfc: result.tenant.rfc,
|
||||
plan: result.tenant.plan,
|
||||
tenants: [{
|
||||
id: result.tenant.id,
|
||||
nombre: result.tenant.nombre,
|
||||
rfc: result.tenant.rfc,
|
||||
plan: result.tenant.plan,
|
||||
role: 'owner' as Role,
|
||||
isOwner: true,
|
||||
}],
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create despacho controller**
|
||||
|
||||
Create `apps/api/src/controllers/despacho.controller.ts`:
|
||||
|
||||
```typescript
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { signupDespacho } from '../services/despacho.service.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
|
||||
const signupSchema = z.object({
|
||||
despacho: z.object({
|
||||
nombre: z.string().min(2, 'Nombre del despacho requerido'),
|
||||
rfc: z.string().regex(/^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/i, 'RFC inválido'),
|
||||
regimenFiscal: z.string().optional(),
|
||||
codigoPostal: z.string().regex(/^\d{5}$/, 'Código postal inválido').optional(),
|
||||
verticalProfile: z.enum(['CONTABLE', 'JURIDICO', 'ARQUITECTURA']),
|
||||
}),
|
||||
owner: z.object({
|
||||
nombre: z.string().min(2, 'Nombre del owner requerido'),
|
||||
email: z.string().email('Email inválido'),
|
||||
password: z.string().min(10, 'La contraseña debe tener al menos 10 caracteres'),
|
||||
}),
|
||||
});
|
||||
|
||||
export async function signup(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = signupSchema.parse(req.body);
|
||||
const result = await signupDespacho(data);
|
||||
return res.status(201).json(result);
|
||||
} catch (error: any) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return next(new AppError(400, error.errors[0].message));
|
||||
}
|
||||
if (error.message?.includes('Ya existe')) {
|
||||
return next(new AppError(409, error.message));
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create despacho routes**
|
||||
|
||||
Create `apps/api/src/routes/despacho.routes.ts`:
|
||||
|
||||
```typescript
|
||||
import { Router } from 'express';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { signup } from '../controllers/despacho.controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const signupLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: 5,
|
||||
message: { message: 'Demasiados intentos de registro. Intenta en una hora.' },
|
||||
});
|
||||
|
||||
router.post('/signup', signupLimiter, signup);
|
||||
|
||||
export default router;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Mount route in app.ts**
|
||||
|
||||
Open `apps/api/src/app.ts`. Find the block where routes are mounted (look for `app.use('/api/auth'`). Add:
|
||||
|
||||
```typescript
|
||||
import despachoRoutes from './routes/despacho.routes.js';
|
||||
// ... in the routes section:
|
||||
app.use('/api/despachos', despachoRoutes);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify typecheck**
|
||||
|
||||
Run: `pnpm --filter @horux/api typecheck`
|
||||
Expected: same pre-existing errors, no new ones. If there are new errors from the despacho files, fix them (likely import paths or type mismatches with Prisma generated types).
|
||||
|
||||
Common issue: Prisma might not know about `VerticalProfile` and `DbMode` enums yet if the migration wasn't applied. If typecheck fails on `verticalProfile: despacho.verticalProfile as any`, the `as any` cast handles it. If stricter typing is needed, verify Prisma client was regenerated (Step 3 of Task 1).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/api/src/services/despacho.service.ts apps/api/src/controllers/despacho.controller.ts apps/api/src/routes/despacho.routes.ts apps/api/src/app.ts
|
||||
git commit -m "feat(api): add POST /api/despachos/signup endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Validation + smoke test
|
||||
|
||||
**Files:** None (verification only)
|
||||
|
||||
- [ ] **Step 1: Verify all packages typecheck**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
pnpm --filter @horux/shared typecheck
|
||||
pnpm --filter @horux/core typecheck
|
||||
pnpm --filter @horux/shared-ui typecheck
|
||||
pnpm --filter @horux/api typecheck
|
||||
```
|
||||
|
||||
Expected: shared/core/shared-ui = 0 errors. api = same pre-existing errors only.
|
||||
|
||||
- [ ] **Step 2: Verify migration files exist and are numbered correctly**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
ls -la apps/api/src/migrations/tenant/
|
||||
```
|
||||
|
||||
Expected: files 001-010 in order. Verify 006-010 are our new ones.
|
||||
|
||||
- [ ] **Step 3: Verify Prisma schema has new fields**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -n "verticalProfile\|dbMode\|dbSchemaVersion\|connectorTokenEnc" apps/api/prisma/schema.prisma
|
||||
```
|
||||
|
||||
Expected: all 4 fields present.
|
||||
|
||||
- [ ] **Step 4: Verify commit history**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git log --oneline -10
|
||||
```
|
||||
|
||||
Expected: 6 new commits from this plan on top of the Plan 1 refactor commits.
|
||||
|
||||
- [ ] **Step 5: Start dev server and test signup endpoint (MANUAL)**
|
||||
|
||||
Run: `pnpm dev`
|
||||
|
||||
Test with curl (or user in browser):
|
||||
```bash
|
||||
curl -X POST http://localhost:4000/api/despachos/signup \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"despacho": {
|
||||
"nombre": "Despacho Test",
|
||||
"rfc": "DTE250101AAA",
|
||||
"verticalProfile": "CONTABLE"
|
||||
},
|
||||
"owner": {
|
||||
"nombre": "Test Owner",
|
||||
"email": "test@despacho.com",
|
||||
"password": "testpassword123"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
Expected: 201 with `{ accessToken, refreshToken, user: { ... } }`.
|
||||
|
||||
If no DB connection, this step is deferred. The typecheck validation is sufficient for the plan.
|
||||
|
||||
- [ ] **Step 6: Final commit if any fixes were needed**
|
||||
|
||||
```bash
|
||||
git add -A && git status
|
||||
# Only commit if there are changes
|
||||
git commit -m "fix: Plan 2A validation fixes" || true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
### Spec coverage (vs spec §3-§5, §11, §15-Phase1)
|
||||
|
||||
| Spec requirement | Task | Status |
|
||||
|------------------|------|--------|
|
||||
| Tenant evolves to support despacho (verticalProfile, dbMode, connector fields) | Task 1 | ✅ |
|
||||
| New roles: supervisor, cliente | Task 2 | ✅ |
|
||||
| Shared types: DespachoRole, VerticalProfile, DbMode, DespachoSignupRequest, ContribuyenteInfo | Task 3 | ✅ |
|
||||
| Tenant migration: tenant_migrations tracking table | Task 4 | ✅ |
|
||||
| Tenant migration: entidades_gestionadas (core) | Task 4 | ✅ |
|
||||
| Tenant migration: carteras + cartera_entidades + cartera_auxiliares (core) | Task 5 | ✅ |
|
||||
| Tenant migration: cliente_accesos (core) | Task 5 | ✅ |
|
||||
| Tenant migration: contribuyentes (vertical-contable, single-PK inheritance) | Task 5 | ✅ |
|
||||
| Signup endpoint: POST /despachos/signup | Task 6 | ✅ |
|
||||
| Trial 30 days | Task 6 (trialEndsAt) | ✅ |
|
||||
| Managed DB provisioned at signup | Task 6 (provisionDatabase) | ✅ |
|
||||
| JWT + refresh token on signup | Task 6 | ✅ |
|
||||
|
||||
**Deferred to Plan 2B:**
|
||||
- CRUD contribuyentes endpoints (add/update/delete RFC within despacho)
|
||||
- FIEL/CSD assignment to contribuyente (not tenant)
|
||||
- CFDI emission with contribuyente_id FK
|
||||
- Metrics tables (metricas_mensuales etc.)
|
||||
- Magic link auth flow
|
||||
|
||||
**Deferred to Plan 2C:**
|
||||
- Frontend signup page
|
||||
- Dashboard adapted for despacho
|
||||
- Contribuyente selector UI
|
||||
- Onboarding wizard
|
||||
|
||||
### Placeholder scan
|
||||
|
||||
- No "TBD", "TODO", "implement later" found.
|
||||
- All code blocks contain complete, copy-paste-ready code.
|
||||
- Types referenced (DespachoSignupRequest, JWTPayload, Role, etc.) are all defined in tasks.
|
||||
|
||||
### Type consistency
|
||||
|
||||
- `DespachoRole` = `'owner' | 'supervisor' | 'auxiliar' | 'cliente'` — consistent with spec §5.
|
||||
- `VerticalProfile` = `'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA'` — matches Prisma enum.
|
||||
- `DbMode` = `'BYO' | 'MANAGED'` — matches Prisma enum.
|
||||
- `signupDespacho()` accepts `DespachoSignupRequest` and returns `LoginResponse`-compatible shape.
|
||||
- SQL migrations use `gen_random_uuid()`, `timestamptz`, `varchar` — consistent with existing migrations.
|
||||
- `tenant_migrations` table uses `(scope, version)` PK — matches spec §12.
|
||||
Reference in New Issue
Block a user