[TASK-2026-01-24] docs: Add ET-SAAS-015, ET-SAAS-016, ET-SAAS-017 technical specs
This commit is contained in:
parent
a6489b0caf
commit
3b654a34c8
758
docs/02-especificaciones/ET-SAAS-015-oauth.md
Normal file
758
docs/02-especificaciones/ET-SAAS-015-oauth.md
Normal file
@ -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<string, any>;
|
||||||
|
|
||||||
|
@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<OAuthConnection>,
|
||||||
|
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<AuthResult> {
|
||||||
|
// 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<OAuthConnection> {
|
||||||
|
// 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<void> {
|
||||||
|
// 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<OAuthConnectionDto[]> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<OAuthConnectionDto> {
|
||||||
|
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<void> {
|
||||||
|
await this.oauthService.unlinkOAuth(user.id, provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('connections')
|
||||||
|
@ApiOperation({ summary: 'List OAuth connections' })
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
async getConnections(@GetUser() user: User): Promise<OAuthConnectionDto[]> {
|
||||||
|
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<OAuthButtonsProps> = ({ 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 (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{providers.map((provider) => (
|
||||||
|
<button
|
||||||
|
key={provider.id}
|
||||||
|
onClick={() => initiateOAuth(provider.id)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex items-center justify-center gap-2 px-4 py-2 border rounded-lg hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<provider.icon className="w-5 h-5" />
|
||||||
|
<span>
|
||||||
|
{mode === 'link' ? 'Link' : 'Continue'} with {provider.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 <LoadingSpinner message="Completing login..." />;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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*
|
||||||
807
docs/02-especificaciones/ET-SAAS-016-analytics.md
Normal file
807
docs/02-especificaciones/ET-SAAS-016-analytics.md
Normal file
@ -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<User>,
|
||||||
|
@InjectRepository(Subscription) private subsRepo: Repository<Subscription>,
|
||||||
|
@InjectRepository(Invoice) private invoicesRepo: Repository<Invoice>,
|
||||||
|
@InjectRepository(Session) private sessionsRepo: Repository<Session>,
|
||||||
|
@InjectRepository(AuditLog) private auditRepo: Repository<AuditLog>,
|
||||||
|
@InjectRepository(File) private filesRepo: Repository<File>,
|
||||||
|
@InjectRepository(AiUsage) private aiUsageRepo: Repository<AiUsage>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async calculateUserMetrics(
|
||||||
|
tenantId: string,
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date,
|
||||||
|
): Promise<UserMetrics> {
|
||||||
|
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<BillingMetrics> {
|
||||||
|
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<UsageMetrics> {
|
||||||
|
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<number> {
|
||||||
|
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<number> {
|
||||||
|
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<AnalyticsSummary> {
|
||||||
|
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<TimeseriesData[]> {
|
||||||
|
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<AnalyticsSummary> {
|
||||||
|
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<UserMetricsResponse> {
|
||||||
|
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<BillingMetricsResponse> {
|
||||||
|
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<UsageMetricsResponse> {
|
||||||
|
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<TrendsResponse> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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 <DashboardSkeleton />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Period Selector */}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h1 className="text-2xl font-bold">Analytics</h1>
|
||||||
|
<PeriodSelector value={period} onChange={setPeriod} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<MetricCard
|
||||||
|
title="Total Users"
|
||||||
|
value={summary.users.total}
|
||||||
|
change={summary.trends.usersGrowth}
|
||||||
|
icon={<UsersIcon />}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title="MRR"
|
||||||
|
value={formatCurrency(summary.billing.mrr)}
|
||||||
|
change={summary.trends.mrrGrowth}
|
||||||
|
icon={<DollarIcon />}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title="API Calls"
|
||||||
|
value={formatNumber(summary.usage.apiCalls)}
|
||||||
|
icon={<ApiIcon />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trend Chart */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Trends</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<TrendChart data={trends} height={300} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Usage Breakdown */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<UsageBreakdownCard
|
||||||
|
title="Users by Plan"
|
||||||
|
data={summary.users.byPlan}
|
||||||
|
/>
|
||||||
|
<UsageBreakdownCard
|
||||||
|
title="Storage by Type"
|
||||||
|
data={summary.usage.storageByType}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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<TrendChartProps> = ({ data, height = 300 }) => {
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={height}>
|
||||||
|
<LineChart data={data}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="date" />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="value"
|
||||||
|
stroke="#3B82F6"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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*
|
||||||
776
docs/02-especificaciones/ET-SAAS-017-reports.md
Normal file
776
docs/02-especificaciones/ET-SAAS-017-reports.md
Normal file
@ -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<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ReportResult> {
|
||||||
|
// 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<Buffer> {
|
||||||
|
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, string> = {
|
||||||
|
[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<Buffer> {
|
||||||
|
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<Buffer>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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<string>('users');
|
||||||
|
const [format, setFormat] = useState<string>('pdf');
|
||||||
|
const [dateRange, setDateRange] = useState<DateRange>({
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold">Generate Report</h1>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-4 pt-6">
|
||||||
|
{/* Report Type */}
|
||||||
|
<div>
|
||||||
|
<Label>Report Type</Label>
|
||||||
|
<Select value={reportType} onValueChange={setReportType}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{reportTypes?.map((rt) => (
|
||||||
|
<SelectItem key={rt.type} value={rt.type}>
|
||||||
|
{rt.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Format */}
|
||||||
|
<div>
|
||||||
|
<Label>Format</Label>
|
||||||
|
<Select value={format} onValueChange={setFormat}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{reportTypes
|
||||||
|
?.find((rt) => rt.type === reportType)
|
||||||
|
?.formats.map((f) => (
|
||||||
|
<SelectItem key={f} value={f}>
|
||||||
|
{f.toUpperCase()}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date Range */}
|
||||||
|
<div>
|
||||||
|
<Label>Date Range</Label>
|
||||||
|
<DateRangePicker value={dateRange} onChange={setDateRange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-2 pt-4">
|
||||||
|
<Button onClick={handleDownload} disabled={isLoading}>
|
||||||
|
{isLoading ? <Spinner /> : <DownloadIcon />}
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => setShowEmailModal(true)}>
|
||||||
|
<MailIcon />
|
||||||
|
Send by Email
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ReportEmailModal
|
||||||
|
open={showEmailModal}
|
||||||
|
onClose={() => setShowEmailModal(false)}
|
||||||
|
reportConfig={{ type: reportType, format, dateRange }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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*
|
||||||
Loading…
Reference in New Issue
Block a user