From 3b654a34c896648f2ae902327fd7ed65db2444f6 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Sat, 24 Jan 2026 22:09:40 -0600 Subject: [PATCH] [TASK-2026-01-24] docs: Add ET-SAAS-015, ET-SAAS-016, ET-SAAS-017 technical specs --- docs/02-especificaciones/ET-SAAS-015-oauth.md | 758 ++++++++++++++++ .../ET-SAAS-016-analytics.md | 807 ++++++++++++++++++ .../ET-SAAS-017-reports.md | 776 +++++++++++++++++ 3 files changed, 2341 insertions(+) create mode 100644 docs/02-especificaciones/ET-SAAS-015-oauth.md create mode 100644 docs/02-especificaciones/ET-SAAS-016-analytics.md create mode 100644 docs/02-especificaciones/ET-SAAS-017-reports.md diff --git a/docs/02-especificaciones/ET-SAAS-015-oauth.md b/docs/02-especificaciones/ET-SAAS-015-oauth.md new file mode 100644 index 00000000..88991e1c --- /dev/null +++ b/docs/02-especificaciones/ET-SAAS-015-oauth.md @@ -0,0 +1,758 @@ +--- +id: "ET-SAAS-015" +title: "Especificacion Tecnica OAuth 2.0" +type: "TechnicalSpec" +status: "Proposed" +priority: "P1" +module: "oauth" +version: "1.0.0" +created_date: "2026-01-24" +updated_date: "2026-01-24" +story_points: 5 +--- + +# ET-SAAS-015: Especificacion Tecnica - OAuth 2.0 Endpoints + +## Metadata +- **Codigo:** ET-SAAS-015 +- **Modulo:** Auth (extension OAuth) +- **Version:** 1.0.0 +- **Estado:** Propuesto +- **Fecha:** 2026-01-24 +- **Basado en:** RFC 6749, Passport.js strategies + +--- + +## 1. Resumen Ejecutivo + +### 1.1 Estado Actual + +El modulo de autenticacion actual incluye: + +| Componente | Estado | Limitaciones | +|------------|--------|--------------| +| JWT Auth | Implementado | Funcional | +| Refresh Tokens | Implementado | Funcional | +| Email/Password | Implementado | Funcional | +| OAuth Connections (DDL) | Implementado | Tabla existe | +| OAuth Endpoints | NO | Sin implementacion | +| Passport Strategies | NO | Sin implementacion | +| OAuth UI | NO | Sin botones/callbacks | + +### 1.2 Propuesta v1.0 + +Sistema OAuth 2.0 completo con: + +- **4 Proveedores**: Google, Microsoft, GitHub, Apple +- **Passport.js**: Strategies oficiales para cada proveedor +- **Flujos soportados**: Authorization Code con PKCE +- **Vinculacion de cuentas**: Usuarios existentes pueden vincular OAuth +- **CSRF Protection**: State parameter con validacion +- **Token encryption**: Access/refresh tokens encriptados en BD + +--- + +## 2. Analisis Comparativo + +### 2.1 Proveedores OAuth + +| Proveedor | Strategy NPM | Scopes Requeridos | Notas | +|-----------|--------------|-------------------|-------| +| Google | passport-google-oauth20 | email, profile | Mas comun | +| Microsoft | passport-microsoft | user.read | Azure AD | +| GitHub | passport-github2 | user:email | Developers | +| Apple | passport-apple | email, name | iOS users | + +### 2.2 Flujo OAuth Comparado + +| Paso | Google | Microsoft | GitHub | Apple | +|------|--------|-----------|--------|-------| +| Auth URL | accounts.google.com | login.microsoftonline.com | github.com | appleid.apple.com | +| Token URL | oauth2.googleapis.com | login.microsoftonline.com | github.com | appleid.apple.com | +| User Info | userinfo | graph.microsoft.com | api.github.com | Token JWT | +| Refresh | Si | Si | No | Si | + +--- + +## 3. Arquitectura Propuesta + +### 3.1 Diagrama de Componentes + +``` ++------------------+ +-------------------+ +------------------+ +| Frontend | | OAuthController | | Passport.js | +| OAuth Buttons |---->| /auth/oauth/:prov |---->| Strategies | ++------------------+ +-------------------+ +------------------+ + | + +--------------------------------+ + | + v ++------------------+ +-------------------+ +------------------+ +| Google/MS/GH/AP |---->| Callback Handler |---->| OAuthService | +| Auth Servers | | /callback | | (business logic) | ++------------------+ +-------------------+ +------------------+ + | + v + +-------------------+ + | User + Connection | + | (DB operations) | + +-------------------+ +``` + +### 3.2 Flujo Detallado + +``` +INICIO OAUTH: +1. Usuario click "Login con Google" +2. Frontend GET /auth/oauth/google?redirect_uri=... +3. Controller invoca passport.authenticate('google') +4. Passport genera URL con state + code_verifier +5. Redirect 302 a Google + +CALLBACK OAUTH: +6. Usuario autoriza en Google +7. Google redirige a /auth/oauth/google/callback?code=...&state=... +8. Passport valida state (CSRF) +9. Passport intercambia code por tokens +10. Passport obtiene perfil de usuario +11. OAuthService.handleOAuthCallback(profile, tokens) + a. Buscar OAuthConnection por provider_user_id + b. Si existe: obtener User vinculado + c. Si no existe: crear User + Tenant + OAuthConnection +12. Generar JWT tokens de sesion +13. Redirect a frontend con tokens +``` + +--- + +## 4. Modelo de Datos + +### 4.1 Tabla Existente: auth.oauth_connections + +```sql +-- Ya existe en DDL, sin cambios necesarios +CREATE TABLE auth.oauth_connections ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE, + + -- Provider info + provider auth.oauth_provider NOT NULL, + provider_user_id VARCHAR(255) NOT NULL, + + -- Tokens (encriptados) + access_token TEXT, + refresh_token TEXT, + token_expires_at TIMESTAMPTZ, + + -- Profile data + profile_data JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + -- Constraints + UNIQUE(provider, provider_user_id), + UNIQUE(user_id, provider) +); + +-- RLS ya configurado +``` + +### 4.2 Enum Existente + +```sql +CREATE TYPE auth.oauth_provider AS ENUM ( + 'google', + 'microsoft', + 'github', + 'apple' +); +``` + +--- + +## 5. Implementacion Backend + +### 5.1 Estructura de Archivos + +``` +backend/src/modules/auth/ +├── strategies/ +│ ├── google.strategy.ts +│ ├── microsoft.strategy.ts +│ ├── github.strategy.ts +│ └── apple.strategy.ts +├── controllers/ +│ └── oauth.controller.ts +├── services/ +│ └── oauth.service.ts +├── entities/ +│ └── oauth-connection.entity.ts +└── dto/ + ├── oauth-callback.dto.ts + └── oauth-connection.dto.ts +``` + +### 5.2 Entity: OAuthConnection + +```typescript +@Entity('oauth_connections', { schema: 'auth' }) +export class OAuthConnection { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + userId: string; + + @ManyToOne(() => User) + @JoinColumn({ name: 'user_id' }) + user: User; + + @Column({ + type: 'enum', + enum: OAuthProvider, + enumName: 'oauth_provider', + }) + provider: OAuthProvider; + + @Column({ name: 'provider_user_id' }) + providerUserId: string; + + @Column({ name: 'access_token', nullable: true }) + accessToken: string; + + @Column({ name: 'refresh_token', nullable: true }) + refreshToken: string; + + @Column({ name: 'token_expires_at', nullable: true }) + tokenExpiresAt: Date; + + @Column({ name: 'profile_data', type: 'jsonb', default: {} }) + profileData: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} +``` + +### 5.3 Service: OAuthService + +```typescript +@Injectable() +export class OAuthService { + constructor( + @InjectRepository(OAuthConnection) + private readonly oauthRepo: Repository, + private readonly usersService: UsersService, + private readonly tenantsService: TenantsService, + private readonly authService: AuthService, + private readonly encryptionService: EncryptionService, + ) {} + + async handleOAuthCallback( + provider: OAuthProvider, + profile: OAuthProfile, + tokens: OAuthTokens, + ): Promise { + // 1. Buscar conexion existente + let connection = await this.oauthRepo.findOne({ + where: { provider, providerUserId: profile.id }, + relations: ['user'], + }); + + let user: User; + let isNewUser = false; + + if (connection) { + // Usuario existente con OAuth + user = connection.user; + + // Actualizar tokens + await this.updateConnectionTokens(connection, tokens); + } else { + // Nuevo usuario via OAuth + const result = await this.createUserFromOAuth(provider, profile, tokens); + user = result.user; + connection = result.connection; + isNewUser = true; + } + + // Generar JWT + const jwtTokens = await this.authService.generateTokens(user); + + return { + ...jwtTokens, + user: this.usersService.toDto(user), + isNewUser, + }; + } + + async linkOAuthToUser( + userId: string, + provider: OAuthProvider, + profile: OAuthProfile, + tokens: OAuthTokens, + ): Promise { + // Verificar que no existe otra cuenta con ese provider_user_id + const existing = await this.oauthRepo.findOne({ + where: { provider, providerUserId: profile.id }, + }); + + if (existing && existing.userId !== userId) { + throw new ConflictException( + 'This OAuth account is already linked to another user', + ); + } + + // Verificar que el usuario no tiene ya este provider + const userConnection = await this.oauthRepo.findOne({ + where: { userId, provider }, + }); + + if (userConnection) { + throw new ConflictException( + `User already has a ${provider} account linked`, + ); + } + + // Crear conexion + return this.oauthRepo.save({ + userId, + provider, + providerUserId: profile.id, + accessToken: this.encryptionService.encrypt(tokens.accessToken), + refreshToken: tokens.refreshToken + ? this.encryptionService.encrypt(tokens.refreshToken) + : null, + tokenExpiresAt: tokens.expiresAt, + profileData: profile.raw, + }); + } + + async unlinkOAuth(userId: string, provider: OAuthProvider): Promise { + // Verificar que el usuario tiene otra forma de login + const user = await this.usersService.findById(userId); + const connections = await this.oauthRepo.count({ where: { userId } }); + + if (!user.passwordHash && connections <= 1) { + throw new BadRequestException( + 'Cannot unlink last OAuth provider without password set', + ); + } + + await this.oauthRepo.delete({ userId, provider }); + } + + async getConnections(userId: string): Promise { + const connections = await this.oauthRepo.find({ + where: { userId }, + select: ['id', 'provider', 'profileData', 'createdAt'], + }); + + return connections.map((c) => ({ + provider: c.provider, + email: c.profileData.email, + linkedAt: c.createdAt, + })); + } +} +``` + +### 5.4 Strategy: Google + +```typescript +@Injectable() +export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { + constructor(private readonly configService: ConfigService) { + super({ + clientID: configService.get('GOOGLE_CLIENT_ID'), + clientSecret: configService.get('GOOGLE_CLIENT_SECRET'), + callbackURL: configService.get('GOOGLE_CALLBACK_URL'), + scope: ['email', 'profile'], + state: true, + }); + } + + async validate( + accessToken: string, + refreshToken: string, + profile: GoogleProfile, + done: VerifyCallback, + ): Promise { + const oauthProfile: OAuthProfile = { + id: profile.id, + email: profile.emails?.[0]?.value, + name: profile.displayName, + firstName: profile.name?.givenName, + lastName: profile.name?.familyName, + picture: profile.photos?.[0]?.value, + raw: profile._json, + }; + + const tokens: OAuthTokens = { + accessToken, + refreshToken, + expiresAt: new Date(Date.now() + 3600 * 1000), // 1h default + }; + + done(null, { profile: oauthProfile, tokens }); + } +} +``` + +### 5.5 Controller: OAuthController + +```typescript +@Controller('auth/oauth') +@ApiTags('OAuth') +export class OAuthController { + constructor(private readonly oauthService: OAuthService) {} + + @Get(':provider') + @ApiOperation({ summary: 'Initiate OAuth flow' }) + @UseGuards(AuthGuard('google')) // Dynamic based on provider + initiateOAuth(@Param('provider') provider: OAuthProvider): void { + // Passport handles redirect + } + + @Get(':provider/callback') + @ApiOperation({ summary: 'OAuth callback' }) + @UseGuards(AuthGuard('google')) // Dynamic based on provider + async oauthCallback( + @Param('provider') provider: OAuthProvider, + @Req() req: Request, + @Res() res: Response, + ): Promise { + const { profile, tokens } = req.user as OAuthCallbackData; + + const result = await this.oauthService.handleOAuthCallback( + provider, + profile, + tokens, + ); + + // Redirect to frontend with tokens + const frontendUrl = this.configService.get('FRONTEND_URL'); + const params = new URLSearchParams({ + access_token: result.accessToken, + refresh_token: result.refreshToken, + is_new_user: result.isNewUser.toString(), + }); + + res.redirect(`${frontendUrl}/auth/callback?${params}`); + } + + @Post('link/:provider') + @ApiOperation({ summary: 'Link OAuth to existing account' }) + @UseGuards(JwtAuthGuard) + @UseGuards(AuthGuard('google')) // Dynamic + async linkOAuth( + @Param('provider') provider: OAuthProvider, + @GetUser() user: User, + @Req() req: Request, + ): Promise { + const { profile, tokens } = req.user as OAuthCallbackData; + + const connection = await this.oauthService.linkOAuthToUser( + user.id, + provider, + profile, + tokens, + ); + + return this.oauthService.toConnectionDto(connection); + } + + @Delete(':provider') + @ApiOperation({ summary: 'Unlink OAuth provider' }) + @UseGuards(JwtAuthGuard) + async unlinkOAuth( + @Param('provider') provider: OAuthProvider, + @GetUser() user: User, + ): Promise { + await this.oauthService.unlinkOAuth(user.id, provider); + } + + @Get('connections') + @ApiOperation({ summary: 'List OAuth connections' }) + @UseGuards(JwtAuthGuard) + async getConnections(@GetUser() user: User): Promise { + return this.oauthService.getConnections(user.id); + } +} +``` + +--- + +## 6. Implementacion Frontend + +### 6.1 Componente: OAuthButtons + +```tsx +interface OAuthButtonsProps { + mode: 'login' | 'register' | 'link'; + onSuccess?: () => void; +} + +export const OAuthButtons: React.FC = ({ mode, onSuccess }) => { + const { initiateOAuth, isLoading } = useOAuthLogin(); + + const providers = [ + { id: 'google', name: 'Google', icon: GoogleIcon, color: '#DB4437' }, + { id: 'microsoft', name: 'Microsoft', icon: MicrosoftIcon, color: '#00A4EF' }, + { id: 'github', name: 'GitHub', icon: GitHubIcon, color: '#333' }, + { id: 'apple', name: 'Apple', icon: AppleIcon, color: '#000' }, + ]; + + return ( +
+ {providers.map((provider) => ( + + ))} +
+ ); +}; +``` + +### 6.2 Hook: useOAuthLogin + +```tsx +export const useOAuthLogin = () => { + const [isLoading, setIsLoading] = useState(false); + + const initiateOAuth = useCallback((provider: string) => { + setIsLoading(true); + + const currentUrl = window.location.href; + const redirectUri = encodeURIComponent(currentUrl); + + // Redirect to backend OAuth endpoint + window.location.href = `/api/auth/oauth/${provider}?redirect_uri=${redirectUri}`; + }, []); + + return { initiateOAuth, isLoading }; +}; +``` + +### 6.3 Pagina: OAuthCallback + +```tsx +export const OAuthCallbackPage: React.FC = () => { + const navigate = useNavigate(); + const { setAuth } = useAuthStore(); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const accessToken = params.get('access_token'); + const refreshToken = params.get('refresh_token'); + const isNewUser = params.get('is_new_user') === 'true'; + const error = params.get('error'); + + if (error) { + toast.error(`OAuth failed: ${error}`); + navigate('/login'); + return; + } + + if (accessToken && refreshToken) { + setAuth({ accessToken, refreshToken }); + + if (isNewUser) { + navigate('/onboarding'); + } else { + navigate('/dashboard'); + } + } + }, []); + + return ; +}; +``` + +--- + +## 7. Seguridad + +### 7.1 CSRF Protection + +```typescript +// State parameter generado con crypto +const generateState = (): string => { + return crypto.randomBytes(32).toString('hex'); +}; + +// Almacenar en session/cookie +const storeState = (res: Response, state: string): void => { + res.cookie('oauth_state', state, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + maxAge: 600000, // 10 min + sameSite: 'lax', + }); +}; + +// Validar en callback +const validateState = (req: Request, state: string): boolean => { + const storedState = req.cookies.oauth_state; + return storedState === state; +}; +``` + +### 7.2 Token Encryption + +```typescript +@Injectable() +export class EncryptionService { + private readonly algorithm = 'aes-256-gcm'; + private readonly key: Buffer; + + constructor(configService: ConfigService) { + this.key = Buffer.from(configService.get('ENCRYPTION_KEY'), 'hex'); + } + + encrypt(text: string): string { + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(this.algorithm, this.key, iv); + const encrypted = Buffer.concat([cipher.update(text), cipher.final()]); + const tag = cipher.getAuthTag(); + return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('hex')}`; + } + + decrypt(encrypted: string): string { + const [ivHex, tagHex, dataHex] = encrypted.split(':'); + const iv = Buffer.from(ivHex, 'hex'); + const tag = Buffer.from(tagHex, 'hex'); + const data = Buffer.from(dataHex, 'hex'); + const decipher = crypto.createDecipheriv(this.algorithm, this.key, iv); + decipher.setAuthTag(tag); + return decipher.update(data) + decipher.final('utf8'); + } +} +``` + +--- + +## 8. Testing + +### 8.1 Unit Tests + +```typescript +describe('OAuthService', () => { + describe('handleOAuthCallback', () => { + it('should create new user for new OAuth login', async () => { + // ... + }); + + it('should return existing user for known OAuth account', async () => { + // ... + }); + + it('should update tokens on existing connection', async () => { + // ... + }); + }); + + describe('linkOAuthToUser', () => { + it('should link OAuth to existing user', async () => { + // ... + }); + + it('should throw if OAuth already linked to another user', async () => { + // ... + }); + }); + + describe('unlinkOAuth', () => { + it('should unlink OAuth provider', async () => { + // ... + }); + + it('should throw if unlinking last auth method', async () => { + // ... + }); + }); +}); +``` + +### 8.2 E2E Tests + +```typescript +describe('OAuth Flow (E2E)', () => { + it('should complete Google OAuth flow', async () => { + // Mock Google OAuth + // Test full flow + }); + + it('should handle OAuth errors gracefully', async () => { + // Test error scenarios + }); +}); +``` + +--- + +## 9. Criterios de Aceptacion + +- [ ] GET /auth/oauth/:provider redirige correctamente a cada proveedor +- [ ] Callback procesa autorizacion y crea/vincula usuario +- [ ] State parameter previene CSRF +- [ ] Tokens OAuth se encriptan en BD +- [ ] Usuario puede vincular multiples proveedores +- [ ] Usuario puede desvincular proveedor (si tiene alternativa) +- [ ] Frontend muestra botones OAuth en login/register +- [ ] Frontend procesa callback correctamente +- [ ] Tests unitarios con cobertura >70% +- [ ] Tests E2E para flujo completo de cada proveedor + +--- + +## 10. Riesgos y Mitigaciones + +| Riesgo | Probabilidad | Impacto | Mitigacion | +|--------|--------------|---------|------------| +| Proveedor no disponible | Media | Alto | Retry + mensaje amigable | +| Token expirado | Alta | Bajo | Auto-refresh donde soportado | +| Cuenta duplicada | Baja | Medio | Verificar email antes de crear | +| CSRF attack | Baja | Alto | State parameter obligatorio | + +--- + +## 11. Dependencias + +### NPM Packages + +```json +{ + "@nestjs/passport": "^10.0.3", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", + "passport-microsoft": "^1.0.0", + "passport-github2": "^0.1.12", + "passport-apple": "^2.0.2" +} +``` + +### Servicios Externos + +- Google Cloud Console (OAuth credentials) +- Microsoft Azure Portal (App registration) +- GitHub Developer Settings (OAuth App) +- Apple Developer Portal (Sign in with Apple) + +--- + +*ET-SAAS-015 v1.0.0 - Template SaaS* diff --git a/docs/02-especificaciones/ET-SAAS-016-analytics.md b/docs/02-especificaciones/ET-SAAS-016-analytics.md new file mode 100644 index 00000000..290bb73b --- /dev/null +++ b/docs/02-especificaciones/ET-SAAS-016-analytics.md @@ -0,0 +1,807 @@ +--- +id: "ET-SAAS-016" +title: "Especificacion Tecnica Analytics Dashboard" +type: "TechnicalSpec" +status: "Proposed" +priority: "P2" +module: "analytics" +version: "1.0.0" +created_date: "2026-01-24" +updated_date: "2026-01-24" +story_points: 8 +--- + +# ET-SAAS-016: Especificacion Tecnica - Analytics Dashboard + +## Metadata +- **Codigo:** ET-SAAS-016 +- **Modulo:** Analytics +- **Version:** 1.0.0 +- **Estado:** Propuesto +- **Fecha:** 2026-01-24 +- **Basado en:** Metabase patterns, SaaS analytics best practices + +--- + +## 1. Resumen Ejecutivo + +### 1.1 Estado Actual + +No existe modulo de analytics implementado. Los datos para metricas existen en: + +| Fuente | Datos Disponibles | Accesibilidad | +|--------|-------------------|---------------| +| users.users | Total usuarios, fechas creacion | Directa | +| billing.subscriptions | MRR, planes activos | Directa | +| billing.invoices | Revenue por periodo | Directa | +| auth.sessions | Usuarios activos (login) | Indirecta | +| audit.audit_logs | Acciones por usuario | Directa | +| storage.files | Uso de storage | Directa | +| ai.ai_usage | Tokens AI consumidos | Directa | + +### 1.2 Propuesta v1.0 + +Dashboard de analytics con: + +- **KPIs en tiempo real**: Usuarios, MRR, uso de recursos +- **Tendencias temporales**: Graficos de 7d, 30d, 90d, 1y +- **Breakdowns**: Por plan, por tenant, por recurso +- **Cache inteligente**: Metricas pre-calculadas +- **Superadmin view**: Metricas globales agregadas + +--- + +## 2. Metricas Definidas + +### 2.1 Metricas de Usuarios + +| ID | Metrica | Formula | Granularidad | +|----|---------|---------|--------------| +| U1 | total_users | COUNT(users) | Snapshot | +| U2 | active_users_7d | Users con login en 7 dias | Rolling | +| U3 | active_users_30d | Users con login en 30 dias | Rolling | +| U4 | new_users | Users creados en periodo | Period | +| U5 | churned_users | Users inactivos > 30 dias | Period | +| U6 | churn_rate | churned / total * 100 | Period | +| U7 | dau | Users activos hoy | Daily | +| U8 | mau | Users activos 30d | Monthly | + +### 2.2 Metricas de Billing + +| ID | Metrica | Formula | Granularidad | +|----|---------|---------|--------------| +| B1 | mrr | SUM(subscription.price) WHERE active | Snapshot | +| B2 | arr | MRR * 12 | Calculated | +| B3 | revenue_period | SUM(invoices.amount) WHERE paid | Period | +| B4 | arpu | revenue / users | Period | +| B5 | subscriptions_active | COUNT(active subs) | Snapshot | +| B6 | subscriptions_new | Subs creadas en periodo | Period | +| B7 | subscriptions_churned | Subs canceladas en periodo | Period | +| B8 | ltv | ARPU * avg_lifetime | Calculated | + +### 2.3 Metricas de Uso + +| ID | Metrica | Formula | Granularidad | +|----|---------|---------|--------------| +| R1 | api_calls | COUNT(requests) | Period | +| R2 | api_calls_by_endpoint | GROUP BY endpoint | Period | +| R3 | storage_used_gb | SUM(file_size) / 1GB | Snapshot | +| R4 | storage_by_type | GROUP BY mime_type | Snapshot | +| R5 | ai_tokens | SUM(tokens_used) | Period | +| R6 | ai_tokens_by_model | GROUP BY model | Period | +| R7 | notifications_sent | COUNT(sent) | Period | +| R8 | webhooks_delivered | COUNT(delivered) | Period | + +--- + +## 3. Arquitectura + +### 3.1 Diagrama de Componentes + +``` ++------------------+ +-------------------+ +------------------+ +| Data Sources | | MetricsCalculator | | MetricsCache | +| (users, billing |---->| Service |---->| (Redis/DB) | +| audit, etc) | +-------------------+ +------------------+ ++------------------+ | | + v v + +-------------------+ +-------------------+ + | AnalyticsService |<----| Scheduled Jobs | + | (API layer) | | (hourly/daily) | + +-------------------+ +-------------------+ + | + v + +-------------------+ + | AnalyticsController| + | (REST endpoints) | + +-------------------+ + | + v + +-------------------+ + | Frontend Dashboard| + | (React + Recharts)| + +-------------------+ +``` + +### 3.2 Estrategia de Cache + +| Tipo Metrica | Calculo | Cache TTL | Storage | +|--------------|---------|-----------|---------| +| Snapshots | Cada hora | 1 hora | Redis | +| Daily | Medianoche | 24 horas | PostgreSQL | +| Weekly | Domingo | 7 dias | PostgreSQL | +| Monthly | Dia 1 | 30 dias | PostgreSQL | +| Real-time | On-demand | 5 min | Redis | + +--- + +## 4. Modelo de Datos + +### 4.1 Schema: analytics + +```sql +-- Crear schema +CREATE SCHEMA IF NOT EXISTS analytics; + +-- Enum para tipos de metrica +CREATE TYPE analytics.metric_type AS ENUM ( + 'users_total', + 'users_active', + 'users_new', + 'users_churned', + 'mrr', + 'arr', + 'revenue', + 'arpu', + 'subscriptions_active', + 'api_calls', + 'storage_used', + 'ai_tokens', + 'notifications', + 'webhooks' +); + +-- Enum para periodos +CREATE TYPE analytics.period_type AS ENUM ( + 'hourly', + 'daily', + 'weekly', + 'monthly', + 'yearly' +); +``` + +### 4.2 Tabla: metrics_cache + +```sql +CREATE TABLE analytics.metrics_cache ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID REFERENCES tenants.tenants(id) ON DELETE CASCADE, + + -- Metric identification + metric_type analytics.metric_type NOT NULL, + period_type analytics.period_type NOT NULL, + period_start TIMESTAMPTZ NOT NULL, + period_end TIMESTAMPTZ NOT NULL, + + -- Value + value NUMERIC NOT NULL, + metadata JSONB DEFAULT '{}', + + -- Timestamps + calculated_at TIMESTAMPTZ DEFAULT NOW(), + + -- Constraints + UNIQUE(tenant_id, metric_type, period_type, period_start) +); + +-- Indexes +CREATE INDEX idx_metrics_cache_tenant ON analytics.metrics_cache(tenant_id); +CREATE INDEX idx_metrics_cache_type ON analytics.metrics_cache(metric_type); +CREATE INDEX idx_metrics_cache_period ON analytics.metrics_cache(period_start DESC); + +-- RLS +ALTER TABLE analytics.metrics_cache ENABLE ROW LEVEL SECURITY; + +CREATE POLICY metrics_cache_tenant_isolation ON analytics.metrics_cache + USING ( + tenant_id = current_setting('app.current_tenant_id', true)::UUID + OR current_setting('app.is_superadmin', true)::BOOLEAN = true + ); + +COMMENT ON TABLE analytics.metrics_cache IS 'Cache de metricas pre-calculadas por tenant'; +``` + +### 4.3 Tabla: usage_events + +```sql +CREATE TABLE analytics.usage_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + user_id UUID, + + -- Event info + event_type VARCHAR(50) NOT NULL, + event_data JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Partitioning por mes +CREATE INDEX idx_usage_events_tenant_type +ON analytics.usage_events(tenant_id, event_type, created_at); + +-- Auto-purge eventos > 90 dias +CREATE OR REPLACE FUNCTION analytics.purge_old_events() +RETURNS void AS $$ +BEGIN + DELETE FROM analytics.usage_events + WHERE created_at < NOW() - INTERVAL '90 days'; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON TABLE analytics.usage_events IS 'Eventos de uso para tracking detallado'; +``` + +--- + +## 5. Implementacion Backend + +### 5.1 Estructura de Archivos + +``` +backend/src/modules/analytics/ +├── analytics.module.ts +├── controllers/ +│ └── analytics.controller.ts +├── services/ +│ ├── analytics.service.ts +│ ├── metrics-calculator.service.ts +│ └── metrics-cache.service.ts +├── entities/ +│ ├── metrics-cache.entity.ts +│ └── usage-event.entity.ts +├── dto/ +│ ├── analytics-summary.dto.ts +│ ├── user-metrics.dto.ts +│ ├── billing-metrics.dto.ts +│ └── usage-metrics.dto.ts +└── jobs/ + ├── calculate-hourly-metrics.job.ts + └── calculate-daily-metrics.job.ts +``` + +### 5.2 Service: MetricsCalculatorService + +```typescript +@Injectable() +export class MetricsCalculatorService { + constructor( + @InjectRepository(User) private usersRepo: Repository, + @InjectRepository(Subscription) private subsRepo: Repository, + @InjectRepository(Invoice) private invoicesRepo: Repository, + @InjectRepository(Session) private sessionsRepo: Repository, + @InjectRepository(AuditLog) private auditRepo: Repository, + @InjectRepository(File) private filesRepo: Repository, + @InjectRepository(AiUsage) private aiUsageRepo: Repository, + ) {} + + async calculateUserMetrics( + tenantId: string, + startDate: Date, + endDate: Date, + ): Promise { + const [ + totalUsers, + activeUsers7d, + activeUsers30d, + newUsers, + ] = await Promise.all([ + this.usersRepo.count({ where: { tenantId } }), + this.getActiveUsers(tenantId, 7), + this.getActiveUsers(tenantId, 30), + this.usersRepo.count({ + where: { + tenantId, + createdAt: Between(startDate, endDate), + }, + }), + ]); + + const churnedUsers = await this.getChurnedUsers(tenantId, 30); + const churnRate = totalUsers > 0 ? (churnedUsers / totalUsers) * 100 : 0; + + return { + total: totalUsers, + active7d: activeUsers7d, + active30d: activeUsers30d, + new: newUsers, + churned: churnedUsers, + churnRate: Math.round(churnRate * 100) / 100, + }; + } + + async calculateBillingMetrics( + tenantId: string, + startDate: Date, + endDate: Date, + ): Promise { + const activeSubscriptions = await this.subsRepo.find({ + where: { tenantId, status: 'active' }, + relations: ['plan'], + }); + + const mrr = activeSubscriptions.reduce( + (sum, sub) => sum + sub.plan.priceMonthly, + 0, + ); + + const revenue = await this.invoicesRepo + .createQueryBuilder('i') + .select('SUM(i.amount)', 'total') + .where('i.tenant_id = :tenantId', { tenantId }) + .andWhere('i.status = :status', { status: 'paid' }) + .andWhere('i.paid_at BETWEEN :start AND :end', { + start: startDate, + end: endDate, + }) + .getRawOne(); + + const totalUsers = await this.usersRepo.count({ where: { tenantId } }); + const arpu = totalUsers > 0 ? revenue.total / totalUsers : 0; + + return { + mrr, + arr: mrr * 12, + revenue: revenue.total || 0, + arpu: Math.round(arpu * 100) / 100, + subscriptionsActive: activeSubscriptions.length, + }; + } + + async calculateUsageMetrics( + tenantId: string, + startDate: Date, + endDate: Date, + ): Promise { + const [storageUsed, aiTokens] = await Promise.all([ + this.filesRepo + .createQueryBuilder('f') + .select('SUM(f.size)', 'total') + .where('f.tenant_id = :tenantId', { tenantId }) + .getRawOne(), + this.aiUsageRepo + .createQueryBuilder('a') + .select('SUM(a.tokens_used)', 'total') + .where('a.tenant_id = :tenantId', { tenantId }) + .andWhere('a.created_at BETWEEN :start AND :end', { + start: startDate, + end: endDate, + }) + .getRawOne(), + ]); + + return { + storageUsedGb: Math.round((storageUsed.total || 0) / 1024 / 1024 / 1024 * 100) / 100, + aiTokens: aiTokens.total || 0, + }; + } + + private async getActiveUsers(tenantId: string, days: number): Promise { + const since = new Date(); + since.setDate(since.getDate() - days); + + return this.sessionsRepo + .createQueryBuilder('s') + .select('COUNT(DISTINCT s.user_id)', 'count') + .where('s.tenant_id = :tenantId', { tenantId }) + .andWhere('s.created_at > :since', { since }) + .getRawOne() + .then((r) => parseInt(r.count, 10)); + } + + private async getChurnedUsers(tenantId: string, inactiveDays: number): Promise { + const since = new Date(); + since.setDate(since.getDate() - inactiveDays); + + // Users with no session in last N days + return this.usersRepo + .createQueryBuilder('u') + .where('u.tenant_id = :tenantId', { tenantId }) + .andWhere( + `NOT EXISTS ( + SELECT 1 FROM auth.sessions s + WHERE s.user_id = u.id AND s.created_at > :since + )`, + { since }, + ) + .getCount(); + } +} +``` + +### 5.3 Service: AnalyticsService + +```typescript +@Injectable() +export class AnalyticsService { + constructor( + private readonly metricsCalculator: MetricsCalculatorService, + private readonly metricsCache: MetricsCacheService, + ) {} + + async getSummary( + tenantId: string, + period: '7d' | '30d' | '90d' | '1y', + ): Promise { + const { startDate, endDate } = this.getPeriodDates(period); + + // Try cache first + const cached = await this.metricsCache.get(tenantId, 'summary', period); + if (cached && !this.isStale(cached)) { + return cached.value; + } + + // Calculate fresh + const [users, billing, usage] = await Promise.all([ + this.metricsCalculator.calculateUserMetrics(tenantId, startDate, endDate), + this.metricsCalculator.calculateBillingMetrics(tenantId, startDate, endDate), + this.metricsCalculator.calculateUsageMetrics(tenantId, startDate, endDate), + ]); + + // Calculate trends + const previousPeriod = this.getPreviousPeriodDates(period); + const [prevUsers, prevBilling] = await Promise.all([ + this.metricsCalculator.calculateUserMetrics(tenantId, previousPeriod.startDate, previousPeriod.endDate), + this.metricsCalculator.calculateBillingMetrics(tenantId, previousPeriod.startDate, previousPeriod.endDate), + ]); + + const trends = { + usersGrowth: this.calculateGrowth(users.total, prevUsers.total), + mrrGrowth: this.calculateGrowth(billing.mrr, prevBilling.mrr), + }; + + const summary = { period, users, billing, usage, trends }; + + // Cache result + await this.metricsCache.set(tenantId, 'summary', period, summary); + + return summary; + } + + async getTimeseries( + tenantId: string, + metric: string, + period: '7d' | '30d' | '90d' | '1y', + granularity: 'daily' | 'weekly' | 'monthly', + ): Promise { + const { startDate, endDate } = this.getPeriodDates(period); + const intervals = this.generateIntervals(startDate, endDate, granularity); + + const data = await Promise.all( + intervals.map(async (interval) => { + const value = await this.getMetricForInterval( + tenantId, + metric, + interval.start, + interval.end, + ); + return { + date: interval.start.toISOString().split('T')[0], + value, + }; + }), + ); + + return data; + } + + private getPeriodDates(period: string): { startDate: Date; endDate: Date } { + const endDate = new Date(); + const startDate = new Date(); + + switch (period) { + case '7d': + startDate.setDate(startDate.getDate() - 7); + break; + case '30d': + startDate.setDate(startDate.getDate() - 30); + break; + case '90d': + startDate.setDate(startDate.getDate() - 90); + break; + case '1y': + startDate.setFullYear(startDate.getFullYear() - 1); + break; + } + + return { startDate, endDate }; + } + + private calculateGrowth(current: number, previous: number): number { + if (previous === 0) return current > 0 ? 100 : 0; + return Math.round(((current - previous) / previous) * 100 * 10) / 10; + } +} +``` + +### 5.4 Controller: AnalyticsController + +```typescript +@Controller('analytics') +@ApiTags('Analytics') +@UseGuards(JwtAuthGuard) +export class AnalyticsController { + constructor(private readonly analyticsService: AnalyticsService) {} + + @Get('summary') + @ApiOperation({ summary: 'Get analytics summary' }) + async getSummary( + @GetTenant() tenantId: string, + @Query('period') period: '7d' | '30d' | '90d' | '1y' = '30d', + ): Promise { + return this.analyticsService.getSummary(tenantId, period); + } + + @Get('users') + @ApiOperation({ summary: 'Get user metrics' }) + async getUserMetrics( + @GetTenant() tenantId: string, + @Query('period') period: '7d' | '30d' | '90d' | '1y' = '30d', + @Query('granularity') granularity: 'daily' | 'weekly' | 'monthly' = 'daily', + ): Promise { + return this.analyticsService.getUserMetrics(tenantId, period, granularity); + } + + @Get('billing') + @ApiOperation({ summary: 'Get billing metrics' }) + async getBillingMetrics( + @GetTenant() tenantId: string, + @Query('period') period: '7d' | '30d' | '90d' | '1y' = '30d', + ): Promise { + return this.analyticsService.getBillingMetrics(tenantId, period); + } + + @Get('usage') + @ApiOperation({ summary: 'Get usage metrics' }) + async getUsageMetrics( + @GetTenant() tenantId: string, + @Query('period') period: '7d' | '30d' | '90d' | '1y' = '30d', + ): Promise { + return this.analyticsService.getUsageMetrics(tenantId, period); + } + + @Get('trends') + @ApiOperation({ summary: 'Get trends data' }) + async getTrends( + @GetTenant() tenantId: string, + @Query('metrics') metrics: string, + @Query('period') period: '30d' | '90d' | '1y' = '30d', + ): Promise { + const metricsList = metrics.split(','); + return this.analyticsService.getTrends(tenantId, metricsList, period); + } + + @Get('export') + @ApiOperation({ summary: 'Export analytics data' }) + async exportData( + @GetTenant() tenantId: string, + @Query('period') period: '7d' | '30d' | '90d' | '1y' = '30d', + @Query('format') format: 'json' | 'csv' = 'json', + @Res() res: Response, + ): Promise { + const data = await this.analyticsService.exportData(tenantId, period); + + if (format === 'csv') { + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', 'attachment; filename=analytics.csv'); + res.send(this.convertToCsv(data)); + } else { + res.json(data); + } + } +} +``` + +### 5.5 Scheduled Jobs + +```typescript +@Injectable() +export class CalculateMetricsJob { + constructor( + private readonly metricsCalculator: MetricsCalculatorService, + private readonly metricsCache: MetricsCacheService, + private readonly tenantsService: TenantsService, + ) {} + + @Cron('0 * * * *') // Every hour + async calculateHourlyMetrics(): Promise { + const tenants = await this.tenantsService.findAll(); + + for (const tenant of tenants) { + await this.calculateAndCacheMetrics(tenant.id, 'hourly'); + } + } + + @Cron('0 0 * * *') // Every day at midnight + async calculateDailyMetrics(): Promise { + const tenants = await this.tenantsService.findAll(); + + for (const tenant of tenants) { + await this.calculateAndCacheMetrics(tenant.id, 'daily'); + } + } + + private async calculateAndCacheMetrics( + tenantId: string, + periodType: 'hourly' | 'daily', + ): Promise { + const endDate = new Date(); + const startDate = new Date(); + + if (periodType === 'hourly') { + startDate.setHours(startDate.getHours() - 1); + } else { + startDate.setDate(startDate.getDate() - 1); + } + + const metrics = await this.metricsCalculator.calculateAllMetrics( + tenantId, + startDate, + endDate, + ); + + await this.metricsCache.store(tenantId, periodType, startDate, metrics); + } +} +``` + +--- + +## 6. Implementacion Frontend + +### 6.1 Dashboard Component + +```tsx +export const AnalyticsDashboard: React.FC = () => { + const [period, setPeriod] = useState<'7d' | '30d' | '90d' | '1y'>('30d'); + const { data: summary, isLoading } = useAnalyticsSummary(period); + const { data: trends } = useAnalyticsTrends(['users', 'mrr'], period); + + if (isLoading) return ; + + return ( +
+ {/* Period Selector */} +
+

Analytics

+ +
+ + {/* KPI Cards */} +
+ } + /> + } + /> + } + /> +
+ + {/* Trend Chart */} + + + Trends + + + + + + + {/* Usage Breakdown */} +
+ + +
+
+ ); +}; +``` + +### 6.2 TrendChart Component + +```tsx +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; + +interface TrendChartProps { + data: TimeseriesData[]; + height?: number; +} + +export const TrendChart: React.FC = ({ data, height = 300 }) => { + return ( + + + + + + + + + + ); +}; +``` + +### 6.3 Hooks + +```typescript +export const useAnalyticsSummary = (period: string) => { + return useQuery({ + queryKey: ['analytics', 'summary', period], + queryFn: () => analyticsApi.getSummary(period), + staleTime: 5 * 60 * 1000, // 5 minutes + }); +}; + +export const useAnalyticsTrends = (metrics: string[], period: string) => { + return useQuery({ + queryKey: ['analytics', 'trends', metrics, period], + queryFn: () => analyticsApi.getTrends(metrics.join(','), period), + staleTime: 5 * 60 * 1000, + }); +}; +``` + +--- + +## 7. Criterios de Aceptacion + +- [ ] Dashboard muestra KPIs de usuarios, billing, uso +- [ ] Selector de periodo funciona (7d, 30d, 90d, 1y) +- [ ] Graficos de tendencias renderizan con Recharts +- [ ] Cache de metricas reduce queries a BD +- [ ] Jobs programados calculan metricas hourly/daily +- [ ] Superadmin puede ver metricas globales +- [ ] Exportacion de datos funciona (JSON, CSV) +- [ ] Performance < 500ms para dashboard load +- [ ] Tests unitarios con cobertura >70% + +--- + +## 8. Riesgos y Mitigaciones + +| Riesgo | Probabilidad | Impacto | Mitigacion | +|--------|--------------|---------|------------| +| Queries lentas | Alta | Alto | Indexes + cache + jobs | +| Cache stale | Media | Medio | TTLs cortos + invalidation | +| Datos incorrectos | Baja | Alto | Tests + validacion cruzada | +| Alto uso CPU | Media | Medio | Jobs en horarios off-peak | + +--- + +*ET-SAAS-016 v1.0.0 - Template SaaS* diff --git a/docs/02-especificaciones/ET-SAAS-017-reports.md b/docs/02-especificaciones/ET-SAAS-017-reports.md new file mode 100644 index 00000000..f8aaa6f9 --- /dev/null +++ b/docs/02-especificaciones/ET-SAAS-017-reports.md @@ -0,0 +1,776 @@ +--- +id: "ET-SAAS-017" +title: "Especificacion Tecnica Reports Generation" +type: "TechnicalSpec" +status: "Proposed" +priority: "P2" +module: "reports" +version: "1.0.0" +created_date: "2026-01-24" +updated_date: "2026-01-24" +story_points: 5 +--- + +# ET-SAAS-017: Especificacion Tecnica - Sistema de Reportes + +## Metadata +- **Codigo:** ET-SAAS-017 +- **Modulo:** Reports +- **Version:** 1.0.0 +- **Estado:** Propuesto +- **Fecha:** 2026-01-24 +- **Basado en:** PDFKit, ExcelJS best practices + +--- + +## 1. Resumen Ejecutivo + +### 1.1 Estado Actual + +No existe sistema de reportes implementado. + +| Capacidad | Estado | Notas | +|-----------|--------|-------| +| Export PDF | NO | Sin implementacion | +| Export Excel | NO | Sin implementacion | +| Export CSV | NO | Sin implementacion | +| Templates | NO | Sin templates definidos | +| Email delivery | Parcial | Email module existe | + +### 1.2 Propuesta v1.0 + +Sistema de reportes con: + +- **3 Formatos**: PDF (formateado), Excel (tabular), CSV (crudo) +- **6 Tipos de reporte**: Users, Billing, Invoices, Audit, Usage, Subscriptions +- **Templates predefinidos**: Diseño profesional con branding +- **Filtros avanzados**: Fecha, status, plan, usuario +- **Delivery**: Descarga directa + envio por email + +--- + +## 2. Tipos de Reportes + +### 2.1 Catalogo de Reportes + +| ID | Nombre | Formatos | Descripcion | +|----|--------|----------|-------------| +| R01 | Users List | PDF, Excel, CSV | Lista completa de usuarios | +| R02 | Active Users | PDF, Excel | Usuarios activos en periodo | +| R03 | Billing Summary | PDF | Resumen de facturacion | +| R04 | Invoices List | PDF, Excel | Facturas emitidas | +| R05 | Audit Log | CSV | Log de auditoría | +| R06 | Subscriptions | Excel | Estado de suscripciones | +| R07 | Usage Report | Excel | Uso de recursos | +| R08 | Revenue Report | PDF, Excel | Ingresos detallados | + +### 2.2 Campos por Reporte + +**R01 - Users List** +``` +| Name | Email | Role | Plan | Status | Created | Last Login | +``` + +**R03 - Billing Summary** +``` +Period: January 2026 +MRR: $45,000 +Revenue: $52,000 +New Subscriptions: 15 +Canceled: 3 +Top Plan: Pro (65%) +``` + +**R05 - Audit Log** +``` +| Timestamp | User | Action | Resource | IP | Details | +``` + +--- + +## 3. Arquitectura + +### 3.1 Diagrama de Componentes + +``` ++------------------+ +-------------------+ +------------------+ +| Frontend UI | | ReportsController | | ReportsService | +| Report Config |---->| /reports/:type |---->| (orchestration) | ++------------------+ +-------------------+ +------------------+ + | + +--------------------------------+ + | | | + v v v + +-------------+ +-------------+ +-------------+ + | PdfGenerator| |ExcelGenerator| |CsvGenerator | + | (PDFKit) | | (ExcelJS) | | (manual) | + +-------------+ +-------------+ +-------------+ + | | | + v v v + +---------------------------------------------------+ + | Response | + | - Stream (download) or Email (attachment) | + +---------------------------------------------------+ +``` + +### 3.2 Flujo de Generacion + +``` +1. Usuario configura reporte (tipo, filtros, formato) + | +2. Frontend POST /reports/generate + | +3. ReportsService.generate(config) + | +4. DataService.fetchData(type, filters) + | +5. Select generator by format: + |-- PDF: PdfGeneratorService + |-- Excel: ExcelGeneratorService + |-- CSV: CsvGeneratorService + | +6. Generator produces file buffer + | +7. Response: + |-- Download: Stream to client + |-- Email: Queue email with attachment + | +8. AuditLog: Register report generation +``` + +--- + +## 4. Implementacion Backend + +### 4.1 Estructura de Archivos + +``` +backend/src/modules/reports/ +├── reports.module.ts +├── controllers/ +│ └── reports.controller.ts +├── services/ +│ ├── reports.service.ts +│ ├── pdf-generator.service.ts +│ ├── excel-generator.service.ts +│ └── csv-generator.service.ts +├── templates/ +│ ├── pdf/ +│ │ ├── users-report.template.ts +│ │ ├── billing-summary.template.ts +│ │ └── invoices-report.template.ts +│ └── excel/ +│ ├── users-report.template.ts +│ └── usage-report.template.ts +└── dto/ + ├── generate-report.dto.ts + └── report-config.dto.ts +``` + +### 4.2 DTOs + +```typescript +export class GenerateReportDto { + @IsEnum(ReportType) + type: ReportType; + + @IsEnum(ReportFormat) + format: ReportFormat; + + @IsOptional() + @IsDateString() + startDate?: string; + + @IsOptional() + @IsDateString() + endDate?: string; + + @IsOptional() + @IsObject() + filters?: Record; +} + +export class SendReportEmailDto extends GenerateReportDto { + @IsArray() + @IsEmail({}, { each: true }) + recipients: string[]; + + @IsOptional() + @IsString() + subject?: string; + + @IsOptional() + @IsString() + message?: string; +} + +export enum ReportType { + USERS = 'users', + ACTIVE_USERS = 'active_users', + BILLING_SUMMARY = 'billing_summary', + INVOICES = 'invoices', + AUDIT_LOG = 'audit_log', + SUBSCRIPTIONS = 'subscriptions', + USAGE = 'usage', + REVENUE = 'revenue', +} + +export enum ReportFormat { + PDF = 'pdf', + EXCEL = 'excel', + CSV = 'csv', +} +``` + +### 4.3 Service: ReportsService + +```typescript +@Injectable() +export class ReportsService { + constructor( + private readonly pdfGenerator: PdfGeneratorService, + private readonly excelGenerator: ExcelGeneratorService, + private readonly csvGenerator: CsvGeneratorService, + private readonly dataService: ReportDataService, + private readonly emailService: EmailService, + private readonly auditService: AuditService, + ) {} + + async generate( + tenantId: string, + userId: string, + config: GenerateReportDto, + ): Promise { + // 1. Validate limits + await this.validateLimits(tenantId, config); + + // 2. Fetch data + const data = await this.dataService.fetchData(tenantId, config); + + // 3. Generate report + let buffer: Buffer; + let mimeType: string; + let extension: string; + + switch (config.format) { + case ReportFormat.PDF: + buffer = await this.pdfGenerator.generate(config.type, data); + mimeType = 'application/pdf'; + extension = 'pdf'; + break; + case ReportFormat.EXCEL: + buffer = await this.excelGenerator.generate(config.type, data); + mimeType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + extension = 'xlsx'; + break; + case ReportFormat.CSV: + buffer = await this.csvGenerator.generate(config.type, data); + mimeType = 'text/csv'; + extension = 'csv'; + break; + } + + // 4. Audit log + await this.auditService.log({ + tenantId, + userId, + action: 'report_generated', + resource: 'report', + details: { type: config.type, format: config.format, rows: data.length }, + }); + + const filename = this.generateFilename(config.type, extension); + + return { buffer, mimeType, filename }; + } + + async sendByEmail( + tenantId: string, + userId: string, + config: SendReportEmailDto, + ): Promise { + const report = await this.generate(tenantId, userId, config); + + await this.emailService.sendWithAttachment({ + to: config.recipients, + subject: config.subject || `${config.type} Report`, + body: config.message || 'Please find the attached report.', + attachment: { + filename: report.filename, + content: report.buffer, + contentType: report.mimeType, + }, + }); + + await this.auditService.log({ + tenantId, + userId, + action: 'report_emailed', + resource: 'report', + details: { type: config.type, recipients: config.recipients }, + }); + } + + private async validateLimits( + tenantId: string, + config: GenerateReportDto, + ): Promise { + const count = await this.dataService.countRecords(tenantId, config); + + if (count > 10000) { + throw new BadRequestException( + `Report exceeds limit of 10,000 records (found ${count}). Please narrow your filters.`, + ); + } + } + + private generateFilename(type: string, extension: string): string { + const date = new Date().toISOString().split('T')[0]; + return `${type}-report-${date}.${extension}`; + } +} +``` + +### 4.4 Service: PdfGeneratorService + +```typescript +@Injectable() +export class PdfGeneratorService { + async generate(type: ReportType, data: any[]): Promise { + return new Promise((resolve, reject) => { + const doc = new PDFDocument({ margin: 50 }); + const chunks: Buffer[] = []; + + doc.on('data', (chunk) => chunks.push(chunk)); + doc.on('end', () => resolve(Buffer.concat(chunks))); + doc.on('error', reject); + + // Header + this.addHeader(doc, type); + + // Content based on type + switch (type) { + case ReportType.USERS: + this.generateUsersReport(doc, data); + break; + case ReportType.BILLING_SUMMARY: + this.generateBillingSummary(doc, data); + break; + case ReportType.INVOICES: + this.generateInvoicesReport(doc, data); + break; + default: + this.generateGenericTable(doc, data); + } + + // Footer + this.addFooter(doc); + + doc.end(); + }); + } + + private addHeader(doc: PDFKit.PDFDocument, type: ReportType): void { + doc.fontSize(20).text(this.getTitle(type), { align: 'center' }); + doc.moveDown(0.5); + doc.fontSize(10).fillColor('#666').text(`Generated: ${new Date().toLocaleString()}`, { align: 'center' }); + doc.moveDown(2); + doc.fillColor('#000'); + } + + private generateUsersReport(doc: PDFKit.PDFDocument, users: any[]): void { + // Summary + doc.fontSize(14).text('Summary'); + doc.fontSize(12).text(`Total Users: ${users.length}`); + doc.text(`Active: ${users.filter((u) => u.status === 'active').length}`); + doc.moveDown(2); + + // Table + const headers = ['Name', 'Email', 'Role', 'Plan', 'Status', 'Created']; + const rows = users.map((u) => [ + u.name, + u.email, + u.role, + u.plan, + u.status, + new Date(u.createdAt).toLocaleDateString(), + ]); + + this.drawTable(doc, headers, rows); + } + + private generateBillingSummary(doc: PDFKit.PDFDocument, data: any): void { + doc.fontSize(14).text('Billing Summary'); + doc.moveDown(); + + const metrics = [ + ['MRR', `$${data.mrr.toLocaleString()}`], + ['ARR', `$${data.arr.toLocaleString()}`], + ['Revenue (Period)', `$${data.revenue.toLocaleString()}`], + ['Active Subscriptions', data.subscriptionsActive.toString()], + ['New Subscriptions', data.subscriptionsNew.toString()], + ['Churned', data.subscriptionsChurned.toString()], + ]; + + metrics.forEach(([label, value]) => { + doc.fontSize(12).text(`${label}: `, { continued: true }); + doc.fontSize(12).fillColor('#3B82F6').text(value); + doc.fillColor('#000'); + }); + } + + private drawTable( + doc: PDFKit.PDFDocument, + headers: string[], + rows: string[][], + ): void { + const startX = 50; + const startY = doc.y; + const colWidth = (doc.page.width - 100) / headers.length; + const rowHeight = 20; + + // Headers + doc.font('Helvetica-Bold').fontSize(10); + headers.forEach((header, i) => { + doc.text(header, startX + i * colWidth, startY, { width: colWidth }); + }); + + // Rows + doc.font('Helvetica').fontSize(9); + rows.forEach((row, rowIndex) => { + const y = startY + (rowIndex + 1) * rowHeight; + + if (y > doc.page.height - 100) { + doc.addPage(); + } + + row.forEach((cell, colIndex) => { + doc.text(cell || '-', startX + colIndex * colWidth, y, { + width: colWidth, + ellipsis: true, + }); + }); + }); + } + + private addFooter(doc: PDFKit.PDFDocument): void { + const pages = doc.bufferedPageRange(); + for (let i = 0; i < pages.count; i++) { + doc.switchToPage(i); + doc.fontSize(8).fillColor('#999').text( + `Page ${i + 1} of ${pages.count}`, + 50, + doc.page.height - 50, + { align: 'center' }, + ); + } + } + + private getTitle(type: ReportType): string { + const titles: Record = { + [ReportType.USERS]: 'Users Report', + [ReportType.ACTIVE_USERS]: 'Active Users Report', + [ReportType.BILLING_SUMMARY]: 'Billing Summary', + [ReportType.INVOICES]: 'Invoices Report', + [ReportType.AUDIT_LOG]: 'Audit Log', + [ReportType.SUBSCRIPTIONS]: 'Subscriptions Report', + [ReportType.USAGE]: 'Usage Report', + [ReportType.REVENUE]: 'Revenue Report', + }; + return titles[type] || 'Report'; + } +} +``` + +### 4.5 Service: ExcelGeneratorService + +```typescript +@Injectable() +export class ExcelGeneratorService { + async generate(type: ReportType, data: any[]): Promise { + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'Template SaaS'; + workbook.created = new Date(); + + const sheet = workbook.addWorksheet(this.getSheetName(type)); + + switch (type) { + case ReportType.USERS: + this.generateUsersSheet(sheet, data); + break; + case ReportType.INVOICES: + this.generateInvoicesSheet(sheet, data); + break; + case ReportType.USAGE: + this.generateUsageSheet(sheet, data); + break; + default: + this.generateGenericSheet(sheet, data); + } + + return workbook.xlsx.writeBuffer() as Promise; + } + + private generateUsersSheet(sheet: ExcelJS.Worksheet, users: any[]): void { + sheet.columns = [ + { header: 'Name', key: 'name', width: 25 }, + { header: 'Email', key: 'email', width: 30 }, + { header: 'Role', key: 'role', width: 15 }, + { header: 'Plan', key: 'plan', width: 15 }, + { header: 'Status', key: 'status', width: 12 }, + { header: 'Created', key: 'createdAt', width: 15 }, + { header: 'Last Login', key: 'lastLoginAt', width: 15 }, + ]; + + // Style header row + sheet.getRow(1).font = { bold: true }; + sheet.getRow(1).fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FF3B82F6' }, + }; + sheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } }; + + // Add data + users.forEach((user) => { + sheet.addRow({ + name: user.name, + email: user.email, + role: user.role, + plan: user.plan, + status: user.status, + createdAt: new Date(user.createdAt), + lastLoginAt: user.lastLoginAt ? new Date(user.lastLoginAt) : null, + }); + }); + + // Date formatting + sheet.getColumn('createdAt').numFmt = 'yyyy-mm-dd'; + sheet.getColumn('lastLoginAt').numFmt = 'yyyy-mm-dd'; + } + + private generateUsageSheet(sheet: ExcelJS.Worksheet, data: any): void { + sheet.columns = [ + { header: 'Resource', key: 'resource', width: 20 }, + { header: 'Usage', key: 'usage', width: 15 }, + { header: 'Unit', key: 'unit', width: 10 }, + { header: 'Limit', key: 'limit', width: 15 }, + { header: '% Used', key: 'percent', width: 10 }, + ]; + + sheet.getRow(1).font = { bold: true }; + + const resources = [ + { resource: 'API Calls', usage: data.apiCalls, unit: 'calls', limit: data.apiLimit }, + { resource: 'Storage', usage: data.storageGb, unit: 'GB', limit: data.storageLimit }, + { resource: 'AI Tokens', usage: data.aiTokens, unit: 'tokens', limit: data.aiLimit }, + ]; + + resources.forEach((r) => { + sheet.addRow({ + ...r, + percent: r.limit > 0 ? Math.round((r.usage / r.limit) * 100) : 0, + }); + }); + } + + private getSheetName(type: ReportType): string { + return type.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()); + } +} +``` + +### 4.6 Controller: ReportsController + +```typescript +@Controller('reports') +@ApiTags('Reports') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('admin', 'owner') +export class ReportsController { + constructor(private readonly reportsService: ReportsService) {} + + @Post('generate') + @ApiOperation({ summary: 'Generate and download report' }) + async generateReport( + @GetTenant() tenantId: string, + @GetUser() user: User, + @Body() dto: GenerateReportDto, + @Res() res: Response, + ): Promise { + const report = await this.reportsService.generate(tenantId, user.id, dto); + + res.setHeader('Content-Type', report.mimeType); + res.setHeader('Content-Disposition', `attachment; filename="${report.filename}"`); + res.send(report.buffer); + } + + @Post('send-email') + @ApiOperation({ summary: 'Generate and send report by email' }) + async sendReportByEmail( + @GetTenant() tenantId: string, + @GetUser() user: User, + @Body() dto: SendReportEmailDto, + ): Promise<{ message: string }> { + await this.reportsService.sendByEmail(tenantId, user.id, dto); + return { message: 'Report sent successfully' }; + } + + @Get('types') + @ApiOperation({ summary: 'Get available report types' }) + getReportTypes(): ReportTypeInfo[] { + return [ + { type: 'users', name: 'Users List', formats: ['pdf', 'excel', 'csv'] }, + { type: 'active_users', name: 'Active Users', formats: ['pdf', 'excel'] }, + { type: 'billing_summary', name: 'Billing Summary', formats: ['pdf'] }, + { type: 'invoices', name: 'Invoices', formats: ['pdf', 'excel'] }, + { type: 'audit_log', name: 'Audit Log', formats: ['csv'] }, + { type: 'subscriptions', name: 'Subscriptions', formats: ['excel'] }, + { type: 'usage', name: 'Usage Report', formats: ['excel'] }, + { type: 'revenue', name: 'Revenue Report', formats: ['pdf', 'excel'] }, + ]; + } +} +``` + +--- + +## 5. Implementacion Frontend + +### 5.1 ReportGenerator Component + +```tsx +export const ReportGenerator: React.FC = () => { + const [reportType, setReportType] = useState('users'); + const [format, setFormat] = useState('pdf'); + const [dateRange, setDateRange] = useState({ + start: subDays(new Date(), 30), + end: new Date(), + }); + const [isLoading, setIsLoading] = useState(false); + const [showEmailModal, setShowEmailModal] = useState(false); + + const { data: reportTypes } = useReportTypes(); + + const handleDownload = async () => { + setIsLoading(true); + try { + const blob = await reportsApi.generate({ + type: reportType, + format, + startDate: dateRange.start.toISOString(), + endDate: dateRange.end.toISOString(), + }); + + downloadBlob(blob, `${reportType}-report.${format === 'excel' ? 'xlsx' : format}`); + toast.success('Report downloaded'); + } catch (error) { + toast.error('Failed to generate report'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+

Generate Report

+ + + + {/* Report Type */} +
+ + +
+ + {/* Format */} +
+ + +
+ + {/* Date Range */} +
+ + +
+ + {/* Actions */} +
+ + +
+
+
+ + setShowEmailModal(false)} + reportConfig={{ type: reportType, format, dateRange }} + /> +
+ ); +}; +``` + +--- + +## 6. Criterios de Aceptacion + +- [ ] Generar PDF de usuarios con formato profesional +- [ ] Generar Excel de usuarios con datos tabulares +- [ ] Generar CSV de audit log +- [ ] Filtros por fecha funcionan correctamente +- [ ] Limite de 10,000 registros se respeta +- [ ] Envio por email funciona con adjunto +- [ ] Solo admins pueden generar reportes +- [ ] Cada generacion se registra en audit log +- [ ] Performance < 5s para reportes de 5000 registros +- [ ] Tests unitarios con cobertura >70% + +--- + +## 7. Dependencias + +### NPM Packages + +```json +{ + "pdfkit": "^0.15.0", + "exceljs": "^4.4.0" +} +``` + +--- + +*ET-SAAS-017 v1.0.0 - Template SaaS*