diff --git a/src/app.module.ts b/src/app.module.ts index cacc0ae..c8e42a9 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,3 +1,8 @@ +import { WorkflowsModule } from './workflows/workflows.module'; +import { AuthModule } from './auth/auth.module'; +import { SponsorsModule } from './sponsors/sponsors.module'; +import { CatalogsModule } from './catalogs/catalogs.module'; +import { PeriodsModule } from './periods/periods.module'; import { Module } from '@nestjs/common'; import { ApplicationsModule } from './applications/applications.module'; import { SharedModule } from './shared/shared.module'; @@ -6,6 +11,17 @@ import { ProjectsModule } from './projects/projects.module'; import { EventsModule } from './events/events.module'; @Module({ - imports: [SharedModule, ApplicationsModule, MembersModule, ProjectsModule, EventsModule], + imports: [ + AuthModule, + WorkflowsModule, + SponsorsModule, + CatalogsModule, + PeriodsModule, + SharedModule, + ApplicationsModule, + MembersModule, + ProjectsModule, + EventsModule, + ], }) export class AppModule {} diff --git a/src/applications/applications.controller.ts b/src/applications/applications.controller.ts index 1623ca3..3acec62 100644 --- a/src/applications/applications.controller.ts +++ b/src/applications/applications.controller.ts @@ -1,16 +1,29 @@ -import { Body, Controller, Get, Post, Res, HttpStatus } from '@nestjs/common'; -import { Response } from 'express'; +import { Public, Permissions } from '../auth/iam.service'; +import { Body, Controller, Get, Post, Query } from '@nestjs/common'; import { ApplicationsService } from './applications.service'; -//import { created, fail } from './response/http-response'; @Controller('applications') export class ApplicationsController { - constructor(private readonly applicationsService: ApplicationsService){} - @Post() - create(@Body() body: any){ - return this.applicationsService.create(body); - } - @Get() - findAll() { - return this.applicationsService.findAll(); - } + constructor(private readonly applicationsService: ApplicationsService) {} + @Public() + @Post() + create(@Body() body: unknown) { + return this.applicationsService.create(body); + } + @Permissions('applications.read') + @Get() + findAll( + @Query('limit') limit?: string, + @Query('offset') offset?: string, + @Query('estado') state?: string, + @Query('periodo_id') period?: string, + @Query('search') search?: string, + ) { + return this.applicationsService.findAll( + limit, + offset, + state, + period, + search, + ); + } } diff --git a/src/applications/applications.module.ts b/src/applications/applications.module.ts index 4f559e5..e82a7f8 100644 --- a/src/applications/applications.module.ts +++ b/src/applications/applications.module.ts @@ -1,9 +1,11 @@ +import { PeriodsModule } from '../periods/periods.module'; import { Module } from '@nestjs/common'; import { ApplicationsController } from './applications.controller'; import { ApplicationsService } from './applications.service'; @Module({ + imports: [PeriodsModule], controllers: [ApplicationsController], - providers: [ApplicationsService] + providers: [ApplicationsService], }) export class ApplicationsModule {} diff --git a/src/applications/applications.service.spec.ts b/src/applications/applications.service.spec.ts index 0376258..8dfb7b3 100644 --- a/src/applications/applications.service.spec.ts +++ b/src/applications/applications.service.spec.ts @@ -7,8 +7,11 @@ describe('ApplicationsService with encrypted RUT', () => { const previousKey = process.env.RUT_ENCRYPTION_KEY_V1; const key = Buffer.alloc(32, 7); // Test-only key, never used outside tests. const body = { - nombre_completo: 'Persona de prueba', rut: '12.345.678-5', - correo_institucional: 'TEST@utem.cl', carrera: 'Computación', area_interes1: 'Backend', + nombre_completo: 'Persona de prueba', + rut: '12.345.678-5', + correo_institucional: 'TEST@utem.cl', + carrera: 'Computación', + area_interes1: 'Backend', }; let client: any; let pool: any; @@ -16,37 +19,66 @@ describe('ApplicationsService with encrypted RUT', () => { beforeEach(() => { process.env.RUT_ENCRYPTION_KEY_VERSION = '1'; process.env.RUT_ENCRYPTION_KEY_V1 = key.toString('base64'); - client = { query: jest.fn(async (sql: string) => { - if (sql.includes('SELECT id')) return { rows: [{ id: '2' }] }; - if (sql.includes('INSERT INTO')) return { rows: [{ id: '9007199254740993' }] }; - return { rows: [] }; - }), release: jest.fn() }; + client = { + query: jest.fn(async (sql: string) => { + if (sql.includes('SELECT id')) return { rows: [{ id: '2' }] }; + if (sql.includes('INSERT INTO')) + return { rows: [{ id: '9007199254740993' }] }; + return { rows: [] }; + }), + release: jest.fn(), + }; pool = { connect: jest.fn(async () => client) }; service = new ApplicationsService(pool); }); afterAll(() => { - if (previousVersion === undefined) delete process.env.RUT_ENCRYPTION_KEY_VERSION; + if (previousVersion === undefined) + delete process.env.RUT_ENCRYPTION_KEY_VERSION; else process.env.RUT_ENCRYPTION_KEY_VERSION = previousVersion; if (previousKey === undefined) delete process.env.RUT_ENCRYPTION_KEY_V1; else process.env.RUT_ENCRYPTION_KEY_V1 = previousKey; }); it('lists applications without selecting RUT or encryption metadata', async () => { - const rows = [{ id: '1', periodo_id: '2', nombre_completo: 'Persona de prueba' }]; - pool.query = jest.fn().mockResolvedValue({ rows }); - await expect(service.findAll()).resolves.toEqual({ totalPostulaciones: 1, postulaciones: rows }); + const rows = [ + { id: '1', periodo_id: '2', nombre_completo: 'Persona de prueba' }, + ]; + pool.query = jest + .fn() + .mockResolvedValue({ rows: [{ total: 1, postulaciones: rows }] }); + await expect(service.findAll()).resolves.toEqual({ + totalPostulaciones: 1, + postulaciones: rows, + limit: 10, + offset: 0, + hasMore: false, + }); const sql = pool.query.mock.calls[0][0]; expect(sql).not.toMatch(/rut|SELECT\s+\*/i); expect(sql).toContain('estado_postulacion'); expect(sql).toContain('ORDER BY created_at DESC, id DESC'); }); it('returns an empty collection when there are no applications', async () => { - pool.query = jest.fn().mockResolvedValue({ rows: [] }); - await expect(service.findAll()).resolves.toEqual({ totalPostulaciones: 0, postulaciones: [] }); + pool.query = jest + .fn() + .mockResolvedValue({ rows: [{ total: 0, postulaciones: [] }] }); + await expect(service.findAll()).resolves.toEqual({ + totalPostulaciones: 0, + postulaciones: [], + limit: 10, + offset: 0, + hasMore: false, + }); }); it('does not expose database errors from the listing', async () => { - pool.query = jest.fn().mockRejectedValue(new Error('private database detail')); + pool.query = jest + .fn() + .mockRejectedValue(new Error('private database detail')); await expect(service.findAll()).rejects.toMatchObject({ - status: 500, response: { responseCode: 'E003', message: 'No se pudieron consultar las postulaciones' }, + status: 500, + response: { + responseCode: 'E003', + message: 'No se pudieron consultar las postulaciones', + }, }); }); it('validates the RUT check digit', () => { @@ -56,15 +88,26 @@ describe('ApplicationsService with encrypted RUT', () => { it('encrypts with randomized authenticated encryption', () => { const first = encryptRut('12345678-5').ciphertext; expect(first.equals(encryptRut('12345678-5').ciphertext)).toBe(false); - const decipher = createDecipheriv('aes-256-gcm', key, first.subarray(0, 12)); + const decipher = createDecipheriv( + 'aes-256-gcm', + key, + first.subarray(0, 12), + ); decipher.setAAD(Buffer.from('exdev:postulaciones:rut:v1')); decipher.setAuthTag(first.subarray(12, 28)); - expect(Buffer.concat([decipher.update(first.subarray(28)), decipher.final()]).toString()).toBe('12345678-5'); + expect( + Buffer.concat([ + decipher.update(first.subarray(28)), + decipher.final(), + ]).toString(), + ).toBe('12345678-5'); }); it('saves only encrypted RUT and selects the period on the server', async () => { const result = await service.create({ ...body, periodo_id: 999 }); expect(result.idPostulacion).toBe('9007199254740993'); - const insert = client.query.mock.calls.find(([sql]) => sql.includes('INSERT INTO')); + const insert = client.query.mock.calls.find(([sql]) => + sql.includes('INSERT INTO'), + ); expect(insert[1][0]).toBe('2'); expect(Buffer.isBuffer(insert[1][1])).toBe(true); expect(insert[1]).not.toContain('12345678-5'); @@ -73,7 +116,9 @@ describe('ApplicationsService with encrypted RUT', () => { expect(client.release).toHaveBeenCalledWith(false); }); it('rejects invalid RUT before connecting', async () => { - await expect(service.create({ ...body, rut: '12345678-0' })).rejects.toMatchObject({ status: 400 }); + await expect( + service.create({ ...body, rut: '12345678-0' }), + ).rejects.toMatchObject({ status: 400 }); expect(pool.connect).not.toHaveBeenCalled(); }); it('fails closed without a key', async () => { @@ -87,17 +132,24 @@ describe('ApplicationsService with encrypted RUT', () => { expect(client.query).toHaveBeenCalledWith('ROLLBACK'); }); it('rejects outside the date window after locking', async () => { - client.query.mockImplementation(async (sql: string) => ({ rows: sql.includes('FOR UPDATE') ? [{ id: '2' }] : [] })); + client.query.mockImplementation(async (sql: string) => ({ + rows: sql.includes('FOR UPDATE') ? [{ id: '2' }] : [], + })); await expect(service.create(body)).rejects.toMatchObject({ status: 409 }); - expect(client.query.mock.calls.some(([sql]) => sql.includes('INSERT INTO'))).toBe(false); + expect( + client.query.mock.calls.some(([sql]) => sql.includes('INSERT INTO')), + ).toBe(false); }); it('does not expose database details on duplicate email', async () => { client.query.mockImplementation(async (sql: string) => { - if (sql.includes('INSERT INTO')) throw { code: '23505', detail: 'PRIVATE DATA' }; + if (sql.includes('INSERT INTO')) + throw { code: '23505', detail: 'PRIVATE DATA' }; return { rows: [{ id: '2' }] }; }); - try { await service.create(body); throw new Error('Expected failure'); } - catch (error) { + try { + await service.create(body); + throw new Error('Expected failure'); + } catch (error) { expect(error.getStatus()).toBe(409); expect(JSON.stringify(error.getResponse())).not.toContain('PRIVATE DATA'); } diff --git a/src/applications/applications.service.ts b/src/applications/applications.service.ts index 6c44f28..d3fd34f 100644 --- a/src/applications/applications.service.ts +++ b/src/applications/applications.service.ts @@ -1,6 +1,14 @@ -import { HttpException, Inject, Injectable } from '@nestjs/common'; +import { + BadRequestException, + HttpException, + Inject, + Injectable, + Optional, +} from '@nestjs/common'; import { Pool, PoolClient } from 'pg'; import { PG_POOL } from '../shared/connections/database.module'; +import { PeriodsService } from '../periods/periods.service'; +import { id } from '../common/admin-data'; import { encryptRut, normalizeRut } from './rut-encryption'; function fail(status: number, responseCode: string, message: string): never { @@ -14,37 +22,60 @@ function validate(body: unknown): Record { const input = body as Record; const data: Record = {}; const texts: [string, number, boolean][] = [ - ['nombre_completo', 200, true], ['correo_institucional', 150, true], - ['campus', 50, false], ['carrera', 100, true], ['area_interes1', 80, true], - ['area_interes2', 80, false], ['area_interes3', 80, false], - ['ayudantias', 1000, false], ['motivo_postulacion', 5000, false], - ['proyecto_idea', 5000, false], ['portafolio', 500, false], - ['postulacion_conjunta', 500, false], ['pitch', 10000, false], ['apodo', 100, false], + ['nombre_completo', 200, true], + ['correo_institucional', 150, true], + ['campus', 50, false], + ['carrera', 100, true], + ['area_interes1', 80, true], + ['area_interes2', 80, false], + ['area_interes3', 80, false], + ['ayudantias', 1000, false], + ['motivo_postulacion', 5000, false], + ['proyecto_idea', 5000, false], + ['portafolio', 500, false], + ['postulacion_conjunta', 500, false], + ['pitch', 10000, false], + ['apodo', 100, false], ]; for (const [field, max, required] of texts) { const value = input[field]; - if (value != null && typeof value !== 'string') fail(400, 'E002', `Campo inválido: ${field}`); + if (value != null && typeof value !== 'string') + fail(400, 'E002', `Campo inválido: ${field}`); const text = (value as string | undefined)?.trim() || null; - if ((required && !text) || (text && text.length > max)) fail(400, 'E002', `Campo inválido: ${field}`); + if ((required && !text) || (text && text.length > max)) + fail(400, 'E002', `Campo inválido: ${field}`); data[field] = text; } const email = (data.correo_institucional as string).toLowerCase(); - if (!/^[^\s@]+@utem\.cl$/.test(email)) fail(400, 'E002', 'Correo institucional inválido'); + if (!/^[^\s@]+@utem\.cl$/.test(email)) + fail(400, 'E002', 'Correo institucional inválido'); data.correo_institucional = email; - const currentYear = Number(new Intl.DateTimeFormat('en', { - year: 'numeric', timeZone: 'America/Santiago', - }).format(new Date())); + const currentYear = Number( + new Intl.DateTimeFormat('en', { + year: 'numeric', + timeZone: 'America/Santiago', + }).format(new Date()), + ); for (const [field, min, max] of [ - ['edad', 16, 99], ['anio_ingreso', currentYear - 10, currentYear], - ['anio_actual', 1, 2147483647], ['horas_disponibles_semanales', 0, 2147483647], + ['edad', 16, 99], + ['anio_ingreso', currentYear - 10, currentYear], + ['anio_actual', 1, 2147483647], + ['horas_disponibles_semanales', 0, 2147483647], ] as [string, number, number][]) { const raw = input[field]; - if (raw == null || raw === '') { data[field] = null; continue; } - if (typeof raw !== 'number' && !(typeof raw === 'string' && /^\d+$/.test(raw))) { + if (raw == null || raw === '') { + data[field] = null; + continue; + } + if ( + typeof raw !== 'number' && + !(typeof raw === 'string' && /^\d+$/.test(raw)) + ) { fail(400, 'E002', `Campo inválido: ${field}`); } const value = Number(raw); - if (!Number.isInteger(value) || value < min || value > max) fail(400, 'E002', `Campo inválido: ${field}`); + if (!Number.isInteger(value) || value < min || value > max) + fail(400, 'E002', `Campo inválido: ${field}`); data[field] = value; } const rut = normalizeRut(input.rut); @@ -55,10 +86,14 @@ function validate(body: unknown): Record { @Injectable() export class ApplicationsService { - constructor(@Inject(PG_POOL) private readonly pool: Pool) {} + constructor( + @Inject(PG_POOL) private readonly pool: Pool, + @Optional() private readonly periods?: PeriodsService, + ) {} async create(body: unknown) { const data = validate(body); + await this.periods?.synchronize(); let client: PoolClient | undefined; let transaction = false; let discardConnection = false; @@ -74,30 +109,57 @@ export class ApplicationsService { WHERE estado_periodo = 'habilitado' FOR UPDATE `); - if (period.rows.length !== 1) fail(409, 'E004', 'No hay un período de postulaciones abierto'); + if (period.rows.length !== 1) + fail(409, 'E004', 'No hay un período de postulaciones abierto'); const periodId = period.rows[0].id; // Read the clock AFTER acquiring the lock, not the transaction start time. - const window = await client.query(` + const window = await client.query( + ` SELECT id FROM public.periodos_postulacion WHERE id = $1 AND estado_periodo = 'habilitado' AND fecha_apertura <= clock_timestamp() AND clock_timestamp() < fecha_cierre - `, [periodId]); - if (window.rows.length !== 1) fail(409, 'E004', 'No hay un período de postulaciones abierto'); + `, + [periodId], + ); + if (window.rows.length !== 1) + fail(409, 'E004', 'No hay un período de postulaciones abierto'); const fields = [ - 'nombre_completo', 'edad', 'correo_institucional', 'campus', 'carrera', - 'anio_ingreso', 'anio_actual', 'area_interes1', 'area_interes2', 'area_interes3', - 'ayudantias', 'horas_disponibles_semanales', 'motivo_postulacion', - 'proyecto_idea', 'portafolio', 'postulacion_conjunta', 'pitch', 'apodo', + 'nombre_completo', + 'edad', + 'correo_institucional', + 'campus', + 'carrera', + 'anio_ingreso', + 'anio_actual', + 'area_interes1', + 'area_interes2', + 'area_interes3', + 'ayudantias', + 'horas_disponibles_semanales', + 'motivo_postulacion', + 'proyecto_idea', + 'portafolio', + 'postulacion_conjunta', + 'pitch', + 'apodo', + ]; + const values = [ + periodId, + encrypted.ciphertext, + encrypted.keyVersion, + ...fields.map((field) => data[field]), ]; - const values = [periodId, encrypted.ciphertext, encrypted.keyVersion, ...fields.map(field => data[field])]; - const result = await client.query(` + const result = await client.query( + ` INSERT INTO public.postulaciones ( periodo_id, rut_cifrado, rut_clave_version, ${fields.join(', ')} ) VALUES (${values.map((_, i) => `$${i + 1}`).join(', ')}) RETURNING id - `, values); + `, + values, + ); await client.query('COMMIT'); transaction = false; return { @@ -108,12 +170,18 @@ export class ApplicationsService { }; } catch (error: unknown) { if (client && transaction) { - try { await client.query('ROLLBACK'); } catch { discardConnection = true; } + try { + await client.query('ROLLBACK'); + } catch { + discardConnection = true; + } } if (error instanceof HttpException) throw error; const code = (error as { code?: string })?.code; - if (code === '23505') fail(409, 'E001', 'El correo ya tiene una postulación en este período'); - if (['23514', '23502', '22P02', '22001', '22003'].includes(code)) fail(400, 'E002', 'Datos inválidos'); + if (code === '23505') + fail(409, 'E001', 'El correo ya tiene una postulación en este período'); + if (['23514', '23502', '22P02', '22001', '22003'].includes(code)) + fail(400, 'E002', 'Datos inválidos'); // Do not expose pg details, request data, ciphertext, keys or exception causes. fail(500, 'E003', 'No se pudo registrar la postulación'); } finally { @@ -121,24 +189,66 @@ export class ApplicationsService { } } - async findAll() { - // TODO(IAM): protect this administrative listing. RUT and encryption - // metadata are deliberately excluded; never replace this with SELECT *. + async findAll( + rawLimit = '10', + rawOffset = '0', + state?: string, + periodId?: string, + search?: string, + ) { + if (!/^\d+$/.test(rawLimit) || !/^\d+$/.test(rawOffset)) + throw new BadRequestException('Paginación inválida'); + const limit = Number(rawLimit), + offset = Number(rawOffset); + if ( + ![10, 30, 50, 100].includes(limit) || + !Number.isSafeInteger(offset) || + offset > 2147483647 + ) + throw new BadRequestException( + 'limit debe ser 10, 30, 50 o 100; offset debe ser un entero no negativo', + ); + if ( + state && + !['pendiente', 'aceptada', 'rechazada', 'retirada'].includes(state) + ) + throw new BadRequestException('Estado inválido'); + const period = periodId ? id(periodId, 'periodo_id') : null; + if (search && search.length > 200) + throw new BadRequestException('La búsqueda es demasiado larga'); try { - const { rows } = await this.pool.query(` + const result = await this.pool.query( + `WITH filtered AS ( SELECT id, periodo_id, nombre_completo, edad, correo_institucional, - campus, carrera, anio_ingreso, anio_actual, - area_interes1, area_interes2, area_interes3, ayudantias, - horas_disponibles_semanales, motivo_postulacion, proyecto_idea, - portafolio, postulacion_conjunta, pitch, apodo, - estado_postulacion, resuelta_en, resuelta_por, created_at, updated_at + campus, carrera, anio_ingreso, anio_actual, area_interes1, area_interes2, area_interes3, ayudantias, + horas_disponibles_semanales, motivo_postulacion, proyecto_idea, portafolio, postulacion_conjunta, + pitch, apodo, estado_postulacion, resuelta_en, resuelta_por, created_at, updated_at FROM public.postulaciones - ORDER BY created_at DESC, id DESC - `); - return { totalPostulaciones: rows.length, postulaciones: rows }; + WHERE ($3::text IS NULL OR estado_postulacion=$3) + AND ($4::bigint IS NULL OR periodo_id=$4) + AND ($5::text IS NULL OR position(lower($5) in lower(nombre_completo || ' ' || correo_institucional))>0) + ), page AS (SELECT id,periodo_id,nombre_completo,edad,correo_institucional,campus,carrera,anio_ingreso,anio_actual,area_interes1,area_interes2,area_interes3,ayudantias,horas_disponibles_semanales,motivo_postulacion,proyecto_idea,portafolio,postulacion_conjunta,pitch,apodo,estado_postulacion,resuelta_en,resuelta_por,created_at,updated_at FROM filtered ORDER BY created_at DESC, id DESC LIMIT $1 OFFSET $2) + SELECT (SELECT count(*)::integer FROM filtered) AS total, + COALESCE((SELECT json_agg(json_build_object( + 'id',id::text,'periodo_id',periodo_id::text,'nombre_completo',nombre_completo,'edad',edad, + 'correo_institucional',correo_institucional,'campus',campus,'carrera',carrera,'anio_ingreso',anio_ingreso, + 'anio_actual',anio_actual,'area_interes1',area_interes1,'area_interes2',area_interes2,'area_interes3',area_interes3, + 'ayudantias',ayudantias,'horas_disponibles_semanales',horas_disponibles_semanales,'motivo_postulacion',motivo_postulacion, + 'proyecto_idea',proyecto_idea,'portafolio',portafolio,'postulacion_conjunta',postulacion_conjunta,'pitch',pitch,'apodo',apodo, + 'estado_postulacion',estado_postulacion,'resuelta_en',resuelta_en,'resuelta_por',resuelta_por::text, + 'created_at',created_at,'updated_at',updated_at) ORDER BY created_at DESC,id DESC) FROM page),'[]'::json) AS postulaciones`, + [limit, offset, state || null, period, search?.trim() || null], + ); + const row = result.rows[0]; + return { + totalPostulaciones: row.total, + postulaciones: row.postulaciones, + limit, + offset, + hasMore: offset + row.postulaciones.length < row.total, + }; } catch { fail(500, 'E003', 'No se pudieron consultar las postulaciones'); } } - } diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts new file mode 100644 index 0000000..6568cdf --- /dev/null +++ b/src/auth/auth.module.ts @@ -0,0 +1,18 @@ +import { OutboxService } from './outbox.service'; +import { Global, Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; +import { DatabaseModule } from '../shared/connections/database.module'; +import { IamGuard, IamService } from './iam.service'; + +@Global() +@Module({ + imports: [DatabaseModule], + providers: [ + OutboxService, + IamService, + IamGuard, + { provide: APP_GUARD, useExisting: IamGuard }, + ], + exports: [IamService], +}) +export class AuthModule {} diff --git a/src/auth/iam.service.ts b/src/auth/iam.service.ts new file mode 100644 index 0000000..0cf8475 --- /dev/null +++ b/src/auth/iam.service.ts @@ -0,0 +1,156 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Inject, + Injectable, + ServiceUnavailableException, + SetMetadata, + UnauthorizedException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Pool, PoolClient } from 'pg'; +import { Request } from 'express'; +import { PG_POOL } from '../shared/connections/database.module'; + +export const Public = () => SetMetadata('iam.public', true); +export const Permissions = (...permissions: string[]) => + SetMetadata('iam.permissions', permissions); +export interface Actor { + isAdministrator?: boolean; + userId: string; + memberId: string; + permissions: string[]; + token: string; +} +export type AuthRequest = Request & { actor: Actor }; + +@Injectable() +export class IamService { + constructor(@Inject(PG_POOL) private readonly pool: Pool) {} + async validate( + token: string, + mutation?: { csrf?: string; origin?: string }, + ): Promise { + const base = process.env.IAM_BASE_URL; + const secret = process.env.IAM_SERVICE_TOKEN; + if (!base || !secret || secret.length < 43) + throw new ServiceUnavailableException('IAM_NOT_CONFIGURED'); + let response: Response; + try { + const url = new URL(base); + if ( + url.username || + url.password || + (url.protocol !== 'https:' && + !( + process.env.NODE_ENV !== 'production' && + url.protocol === 'http:' && + ['localhost', '127.0.0.1'].includes(url.hostname) + )) + ) + throw new Error(); + response = await fetch( + `${base.replace(/\/$/, '')}/internal/sessions/validate`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${secret}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + token, + ...(mutation ? { mutation: true, ...mutation } : {}), + }), + signal: AbortSignal.timeout(5000), + redirect: 'error', + }, + ); + } catch { + throw new ServiceUnavailableException('IAM_UNAVAILABLE'); + } + if (response.status === 401) + throw new UnauthorizedException('SESSION_INVALID'); + if (response.status === 403) + throw new ForbiddenException('ACCESS_OR_CSRF_DENIED'); + if (!response.ok) throw new ServiceUnavailableException('IAM_UNAVAILABLE'); + const value = await response.json().catch(() => null); + if ( + value?.application !== (process.env.IAM_APPLICATION_CODE || 'rafael') || + value?.access !== 'enabled' || + typeof value?.user?.id !== 'string' || + !/^[0-9a-f-]{36}$/.test(value.user.id) || + !Array.isArray(value.permissions) || + !value.permissions.every((p: unknown) => typeof p === 'string') + ) + throw new ServiceUnavailableException('IAM_INVALID_RESPONSE'); + const { rows } = await this.pool.query( + "SELECT id::text FROM public.miembros WHERE iam_subject=$1 AND estado='activo'", + [value.user.id], + ); + if (!rows[0] || rows[0].id !== value.member?.id) + throw new ForbiddenException('MEMBER_NOT_ACTIVE'); + return { + isAdministrator: + Array.isArray(value.roles) && + value.roles.includes('administrador_rafael'), + userId: value.user.id, + memberId: rows[0].id, + permissions: value.permissions, + token, + }; + } + require(actor: Actor, ...permissions: string[]) { + if (!permissions.every((p) => actor.permissions.includes(p))) + throw new ForbiddenException('PERMISSION_REQUIRED'); + } + async revalidate(client: PoolClient, actor: Actor, ...permissions: string[]) { + const fresh = await this.validate(actor.token); + if (fresh.userId !== actor.userId || fresh.memberId !== actor.memberId) + throw new ForbiddenException('IDENTITY_CHANGED'); + this.require(fresh, ...permissions); + const result = await client.query( + "SELECT id FROM public.miembros WHERE id=$1 AND iam_subject=$2 AND estado='activo' FOR SHARE", + [actor.memberId, actor.userId], + ); + if (!result.rowCount) throw new ForbiddenException('MEMBER_NOT_ACTIVE'); + } +} + +@Injectable() +export class IamGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly iam: IamService, + ) {} + async canActivate(context: ExecutionContext) { + const targets = [context.getHandler(), context.getClass()]; + if (this.reflector.getAllAndOverride('iam.public', targets)) + return true; + const required = this.reflector.getAllAndOverride( + 'iam.permissions', + targets, + ); + if (!required) throw new ForbiddenException('ROUTE_NOT_AUTHORIZED'); + const request = context.switchToHttp().getRequest(); + const cookieName = + process.env.IAM_SESSION_COOKIE || '__Host-exdev_rafael_session'; + const cookies = (request.headers.cookie || '') + .split(';') + .map((v) => v.trim()) + .filter((v) => v.startsWith(`${cookieName}=`)); + const token = + cookies.length === 1 ? cookies[0].slice(cookieName.length + 1) : ''; + if (!/^[A-Za-z0-9_-]{43}$/.test(token)) + throw new UnauthorizedException('SESSION_REQUIRED'); + const mutation = !['GET', 'HEAD', 'OPTIONS'].includes(request.method); + request.actor = await this.iam.validate( + token, + mutation + ? { csrf: request.get('X-CSRF-Token'), origin: request.get('Origin') } + : undefined, + ); + this.iam.require(request.actor, ...required); + return true; + } +} diff --git a/src/auth/iam.spec.ts b/src/auth/iam.spec.ts new file mode 100644 index 0000000..20245b1 --- /dev/null +++ b/src/auth/iam.spec.ts @@ -0,0 +1,96 @@ +import { ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { IamGuard, IamService } from './iam.service'; +import { MembersService } from '../members/members.service'; +const actor = { + userId: '1236638c-55ab-441b-8e06-49d185573c35', + memberId: '1', + permissions: ['members.read'], + token: 'a'.repeat(43), +}; +describe('IAM guard and boundary', () => { + const original = process.env.IAM_SESSION_COOKIE; + afterEach(() => { + process.env.IAM_SESSION_COOKIE = original; + }); + function setup( + publicRoute = false, + permissions: string[] | undefined = ['members.read'], + cookie = 'exdev_rafael_session=' + actor.token, + method = 'GET', + ) { + process.env.IAM_SESSION_COOKIE = 'exdev_rafael_session'; + const request: any = { + headers: { cookie }, + method, + get: (name: string) => + name === 'Origin' ? 'http://localhost:3000' : 'csrf', + }; + const reflector = { + getAllAndOverride: (key: string) => + key === 'iam.public' ? publicRoute : permissions, + }; + const iam = { + validate: jest.fn().mockResolvedValue(actor), + require: jest.fn((a, p) => { + if (!a.permissions.includes(p)) throw new ForbiddenException(); + }), + }; + const context = { + getHandler: () => {}, + getClass: () => {}, + switchToHttp: () => ({ getRequest: () => request }), + } as unknown as ExecutionContext; + return { + guard: new IamGuard( + reflector as unknown as Reflector, + iam as unknown as IamService, + ), + iam, + request, + context, + }; + } + it('preserves public routes without contacting IAM', async () => { + const s = setup(true); + expect(await s.guard.canActivate(s.context)).toBe(true); + expect(s.iam.validate).not.toHaveBeenCalled(); + }); + it('denies routes without an explicit policy', async () => { + const s = setup(); + (s.guard as any).reflector.getAllAndOverride = () => undefined; + await expect(s.guard.canActivate(s.context)).rejects.toThrow( + 'ROUTE_NOT_AUTHORIZED', + ); + }); + it('requires session and rejects duplicate cookies', async () => { + for (const cookie of [ + '', + `exdev_rafael_session=${actor.token}; exdev_rafael_session=${actor.token}`, + ]) { + const s = setup(false, [], cookie); + await expect(s.guard.canActivate(s.context)).rejects.toThrow( + 'SESSION_REQUIRED', + ); + } + }); + it('passes CSRF and origin for mutation and derives actor from IAM', async () => { + const s = setup(false, ['members.read'], undefined, 'PATCH'); + await s.guard.canActivate(s.context); + expect(s.iam.validate).toHaveBeenCalledWith(actor.token, { + csrf: 'csrf', + origin: 'http://localhost:3000', + }); + expect(s.request.actor.memberId).toBe('1'); + }); + it('rejects missing permissions', async () => { + const s = setup(false, ['members.manage']); + await expect(s.guard.canActivate(s.context)).rejects.toThrow(); + }); + it('rejects identity assignment through normal member POST', () => { + const members = new MembersService({} as any); + expect(() => + members.create({ nombre: 'X', iamSubject: actor.userId }), + ).toThrow('campos no permitidos'); + }); +}); diff --git a/src/auth/outbox.service.ts b/src/auth/outbox.service.ts new file mode 100644 index 0000000..af09d69 --- /dev/null +++ b/src/auth/outbox.service.ts @@ -0,0 +1,86 @@ +import { + Inject, + Injectable, + Logger, + OnModuleDestroy, + OnModuleInit, +} from '@nestjs/common'; +import { Pool } from 'pg'; +import { randomUUID } from 'node:crypto'; +import { PG_POOL } from '../shared/connections/database.module'; +@Injectable() +export class OutboxService implements OnModuleInit, OnModuleDestroy { + constructor(@Inject(PG_POOL) private readonly pool: Pool) {} + private timer?: ReturnType; + private running = false; + private readonly logger = new Logger(OutboxService.name); + onModuleInit() { + this.timer = setInterval(() => void this.tick(), 30000); + this.timer.unref(); + } + onModuleDestroy() { + if (this.timer) clearInterval(this.timer); + } + async tick() { + if ( + this.running || + !process.env.IAM_BASE_URL || + !process.env.IAM_SERVICE_TOKEN + ) + return; + this.running = true; + try { + const base = new URL(process.env.IAM_BASE_URL); + if ( + base.username || + base.password || + (base.protocol !== 'https:' && + !( + process.env.NODE_ENV !== 'production' && + base.protocol === 'http:' && + ['localhost', '127.0.0.1'].includes(base.hostname) + )) + ) + throw new Error(); + const token = randomUUID(); + const { rows } = await this.pool.query( + `WITH candidate AS (SELECT id FROM public.iam_access_outbox WHERE processed_at IS NULL AND available_at<=clock_timestamp() AND (locked_until IS NULL OR locked_until { + let app: INestApplication; + let workflows: WorkflowsService; + const iam = { + validate: jest.fn().mockResolvedValue({ + userId: 'user', + memberId: '1', + permissions: [], + token: 'a'.repeat(43), + }), + require: IamService.prototype.require, + }; + beforeAll(async () => { + const client = { + query: jest.fn().mockResolvedValue({ rows: [] }), + release: jest.fn(), + }; + const module = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(PG_POOL) + .useValue({ ...client, connect: async () => client }) + .overrideProvider(IamService) + .useValue(iam) + .compile(); + workflows = module.get(WorkflowsService); + app = module.createNestApplication(); + await app.init(); + }); + afterAll(async () => { + await app.close(); + }); + it.each([ + '/members/admin', + '/members/me', + '/projects/admin', + '/events/admin', + '/sponsors/admin', + '/roles', + '/specialties', + '/periods', + '/applications', + '/announcements/admin', + '/applications/1/my-vote', + '/applications/1/votes', + ])('GET %s requires a real session', async (path) => { + await request(app.getHttpServer()).get(path).expect(401); + }); + it.each([ + '/members', + '/projects', + '/events', + '/sponsors', + '/roles', + '/specialties', + '/periods', + '/announcements', + '/applications/1/my-vote', + '/applications/1/resolution', + '/applications/1/member-conversion', + ])('POST %s requires a real session', async (path) => { + await request(app.getHttpServer()).post(path).send({}).expect(401); + }); + it.each([ + '/members/1', + '/members/me', + '/projects/1', + '/events/1', + '/sponsors/1', + '/roles/1', + '/specialties/1', + '/periods/1', + '/announcements/1', + '/announcements/1/publication', + '/applications/1/my-vote', + ])('PATCH %s requires a real session', async (path) => { + await request(app.getHttpServer()).patch(path).send({}).expect(401); + }); + it.each(['/members', '/projects', '/events', '/sponsors'])( + 'GET %s stays public', + async (path) => { + await request(app.getHttpServer()).get(path).expect(200); + }, + ); + it('GET /announcements?limit=3 works without a cookie or IAM validation', async () => { + iam.validate.mockClear(); + const list = jest.spyOn(workflows, 'announcements'); + try { + const response = await request(app.getHttpServer()) + .get('/announcements?limit=3') + .expect(200); + expect(response.body).toEqual({ data: [], hasMore: false }); + expect(list).toHaveBeenCalledWith(false, '3', undefined); + expect(iam.validate).not.toHaveBeenCalled(); + } finally { + list.mockRestore(); + } + }); + it.each([ + '/projects/admin', + '/events/admin', + '/members/admin', + '/announcements/admin', + ])('authenticated member without permissions cannot GET %s', async (path) => { + const name = + process.env.IAM_SESSION_COOKIE || '__Host-exdev_rafael_session'; + await request(app.getHttpServer()) + .get(path) + .set('Cookie', `${name}=${'a'.repeat(43)}`) + .expect(403); + }); + it.each(['/members', '/projects', '/events', '/announcements'])( + 'does not infer management permission for POST %s from an authenticated member', + async (path) => { + const name = + process.env.IAM_SESSION_COOKIE || '__Host-exdev_rafael_session'; + await request(app.getHttpServer()) + .post(path) + .set('Cookie', `${name}=${'a'.repeat(43)}`) + .send({}) + .expect(403); + }, + ); +}); diff --git a/src/catalogs/catalogs.controller.ts b/src/catalogs/catalogs.controller.ts new file mode 100644 index 0000000..c489295 --- /dev/null +++ b/src/catalogs/catalogs.controller.ts @@ -0,0 +1,41 @@ +import { Permissions } from '../auth/iam.service'; +import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; +import { CatalogsService } from './catalogs.service'; +@Controller('roles') +export class RolesController { + constructor(private readonly catalogs: CatalogsService) {} + @Permissions('club_roles.read') + @Get() + findAll() { + return this.catalogs.findAll('roles'); + } + @Permissions('club_roles.manage') + @Post() + create(@Body() body: unknown) { + return this.catalogs.save('roles', body); + } + @Permissions('club_roles.manage') + @Patch(':id') + update(@Param('id') id: string, @Body() body: unknown) { + return this.catalogs.save('roles', body, id); + } +} +@Controller('specialties') +export class SpecialtiesController { + constructor(private readonly catalogs: CatalogsService) {} + @Permissions('specialties.read') + @Get() + findAll() { + return this.catalogs.findAll('specialties'); + } + @Permissions('specialties.manage') + @Post() + create(@Body() body: unknown) { + return this.catalogs.save('specialties', body); + } + @Permissions('specialties.manage') + @Patch(':id') + update(@Param('id') id: string, @Body() body: unknown) { + return this.catalogs.save('specialties', body, id); + } +} diff --git a/src/catalogs/catalogs.module.ts b/src/catalogs/catalogs.module.ts new file mode 100644 index 0000000..5852d9e --- /dev/null +++ b/src/catalogs/catalogs.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { SharedModule } from '../shared/shared.module'; +import { RolesController, SpecialtiesController } from './catalogs.controller'; +import { CatalogsService } from './catalogs.service'; +@Module({ + imports: [SharedModule], + controllers: [RolesController, SpecialtiesController], + providers: [CatalogsService], +}) +export class CatalogsModule {} diff --git a/src/catalogs/catalogs.service.ts b/src/catalogs/catalogs.service.ts new file mode 100644 index 0000000..b5ef822 --- /dev/null +++ b/src/catalogs/catalogs.service.ts @@ -0,0 +1,37 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Pool } from 'pg'; +import { PG_POOL } from '../shared/connections/database.module'; +import { + choice, + optionalText, + requiredText, + RecordSpec, + saveRecord, +} from '../common/admin-data'; +export type CatalogKind = 'roles' | 'specialties'; +const tables: Record = { + roles: 'roles_club', + specialties: 'especialidades', +}; +@Injectable() +export class CatalogsService { + constructor(@Inject(PG_POOL) private readonly pool: Pool) {} + async findAll(kind: CatalogKind) { + const { rows } = await this.pool.query( + `SELECT id,nombre,descripcion,estado FROM public.${tables[kind]} ORDER BY nombre,id`, + ); + return { data: rows }; + } + save(kind: CatalogKind, body: unknown, id?: string) { + const spec: RecordSpec = { + table: tables[kind], + fields: { + nombre: requiredText, + descripcion: optionalText, + estado: choice(['activo', 'inactivo']), + }, + defaults: { descripcion: null, estado: 'activo' }, + }; + return saveRecord(this.pool, spec, body, id); + } +} diff --git a/src/common/admin-data.spec.ts b/src/common/admin-data.spec.ts new file mode 100644 index 0000000..f8bda8c --- /dev/null +++ b/src/common/admin-data.spec.ts @@ -0,0 +1,58 @@ +import { + bodyObject, + date, + id, + ids, + timestamp, + url, + transaction, +} from './admin-data'; +describe('Administrative input validation', () => { + it('preserves bigint IDs and rejects unsafe numbers', () => { + expect(id('9007199254740993')).toBe('9007199254740993'); + expect(() => id(9007199254740993)).toThrow(); + expect(() => id('9223372036854775808')).toThrow(); + }); + it.each(['2026-02-30', '2026-13-01', '2026-00-01', '2026-2-1'])( + 'rejects invalid calendar date %s', + (value) => expect(() => date(value, 'date')).toThrow(), + ); + it('accepts leap days only when valid', () => { + expect(date('2024-02-29', 'date')).toBe('2024-02-29'); + expect(() => date('2025-02-29', 'date')).toThrow(); + }); + it('requires an explicit timezone', () => { + expect(() => timestamp('2026-10-01T10:00', 'time')).toThrow(); + expect(timestamp('2026-10-01T10:00:00-03:00', 'time')).toBe( + '2026-10-01T13:00:00.000Z', + ); + }); + it.each(['javascript:alert(1)', '//example.com', '/\\example.com'])( + 'rejects unsafe URL %s', + (value) => expect(() => url(value, 'url')).toThrow(), + ); + it('allows the club application path', () => + expect(url('/apply', 'url')).toBe('/apply')); + it('rejects empty patches and unknown fields', () => { + expect(() => bodyObject({}, ['nombre'], true)).toThrow(); + expect(() => bodyObject({ admin: true }, ['nombre'])).toThrow(); + }); + it('detects duplicate IDs across numeric and string representations', () => + expect(() => ids([1, '1'], 'ids')).toThrow()); + it('rolls back and discards a broken connection without leaking database errors', async () => { + const client = { + query: jest + .fn() + .mockResolvedValueOnce({}) + .mockRejectedValueOnce(new Error('private')), + release: jest.fn(), + }; + const pool = { connect: jest.fn().mockResolvedValue(client) }; + await expect( + transaction(pool as any, async () => { + throw new Error('private'); + }), + ).rejects.toThrow('No fue posible'); + expect(client.release).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/common/admin-data.ts b/src/common/admin-data.ts new file mode 100644 index 0000000..01dd809 --- /dev/null +++ b/src/common/admin-data.ts @@ -0,0 +1,230 @@ +import { + BadRequestException, + ConflictException, + HttpException, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import { Pool, PoolClient } from 'pg'; + +export type Data = Record; +export type Parser = (value: unknown, field: string) => unknown; +export function bodyObject( + raw: unknown, + allowed: string[], + partial = false, +): Data { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) + throw new BadRequestException('El cuerpo debe ser un objeto'); + const body = raw as Data; + if (Object.keys(body).some((key) => !allowed.includes(key))) + throw new BadRequestException('El cuerpo contiene campos no permitidos'); + if (partial && !Object.keys(body).length) + throw new BadRequestException('Debes enviar al menos un campo'); + return body; +} +export function id(value: unknown, field = 'id'): string { + const text = + typeof value === 'number' && Number.isSafeInteger(value) + ? String(value) + : typeof value === 'string' + ? value + : ''; + if (!/^[1-9]\d*$/.test(text) || BigInt(text) > 9223372036854775807n) + throw new BadRequestException(`${field} inválido`); + return text; +} +export function requiredText(value: unknown, field: string): string { + if (typeof value !== 'string' || !value.trim()) + throw new BadRequestException(`${field} es obligatorio`); + return value.trim(); +} +export function optionalText(value: unknown, field: string): string | null { + if (value == null) return null; + if (typeof value !== 'string') + throw new BadRequestException(`${field} debe ser texto`); + return value.trim() || null; +} +export function boolean(value: unknown, field: string): boolean { + if (typeof value !== 'boolean') + throw new BadRequestException(`${field} debe ser booleano`); + return value; +} +export const choice = + (values: string[]): Parser => + (value, field) => { + if (typeof value !== 'string' || !values.includes(value)) + throw new BadRequestException(`${field} inválido`); + return value; + }; +export function integer(value: unknown, field: string): number { + if ( + typeof value !== 'number' || + !Number.isInteger(value) || + value < -2147483648 || + value > 2147483647 + ) + throw new BadRequestException(`${field} debe ser un entero válido`); + return value; +} +export function date(value: unknown, field: string): string { + if ( + typeof value !== 'string' || + !/^\d{4}-\d{2}-\d{2}$/.test(value) || + !Number.isFinite(Date.parse(`${value}T00:00:00Z`)) || + new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) !== value + ) + throw new BadRequestException( + `${field} debe ser una fecha válida YYYY-MM-DD`, + ); + return value; +} +export const optionalDate: Parser = (value, field) => + value == null || value === '' ? null : date(value, field); +export function timestamp(value: unknown, field: string): string { + if ( + typeof value !== 'string' || + !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2})$/.test( + value, + ) || + !Number.isFinite(Date.parse(value)) + ) + throw new BadRequestException( + `${field} debe incluir fecha, hora y zona horaria`, + ); + date(value.slice(0, 10), field); + return new Date(value).toISOString(); +} +export const url: Parser = (value, field) => { + const text = optionalText(value, field); + if (!text) return null; + if (/^\/(?![\/\\])/.test(text) && !text.includes('\\')) return text; + try { + if (['https:', 'http:'].includes(new URL(text).protocol)) return text; + } catch { + /* invalid URL */ + } + throw new BadRequestException( + `${field} debe ser una ruta interna o URL HTTP/HTTPS`, + ); +}; +export function dateRange(start: unknown, end: unknown) { + if (start && end && String(end) < String(start)) + throw new BadRequestException( + 'La fecha de término no puede ser anterior al inicio', + ); +} +export function ids(value: unknown, field: string): string[] { + if (!Array.isArray(value)) + throw new BadRequestException(`${field} debe ser un arreglo`); + const result = value.map((item) => id(item, field)); + if (new Set(result).size !== result.length) + throw new BadRequestException(`${field} contiene IDs repetidos`); + return result; +} +export function databaseError(error: unknown): never { + if (error instanceof HttpException) throw error; + const code = (error as { code?: string })?.code; + if (['23505', '23P01'].includes(code)) + throw new ConflictException( + 'El registro se duplica o se superpone con otro existente', + ); + if ( + [ + '23503', + '23514', + '23502', + '22P02', + '22007', + '22008', + '22003', + '22001', + ].includes(code) + ) + throw new BadRequestException('Los datos o las relaciones no son válidos'); + throw new InternalServerErrorException( + 'No fue posible completar la operación', + ); +} +export async function transaction( + pool: Pool, + operation: (client: PoolClient) => Promise, +): Promise { + let client: PoolClient | undefined; + let discard = false; + try { + client = await pool.connect(); + await client.query('BEGIN'); + const value = await operation(client); + await client.query('COMMIT'); + return value; + } catch (error) { + if (client) { + try { + await client.query('ROLLBACK'); + } catch { + discard = true; + } + } + databaseError(error); + } finally { + client?.release(discard); + } +} +export interface RecordSpec { + table: string; + fields: Record; + defaults: Data; + validate?: (data: Data) => void; +} +// Table and column names are supplied exclusively by code-owned specifications. +export async function saveRecord( + pool: Pool, + spec: RecordSpec, + raw: unknown, + rawId?: string, +) { + const patch = rawId !== undefined; + const body = bodyObject(raw, Object.keys(spec.fields), patch); + const recordId = patch ? id(rawId) : undefined; + return transaction(pool, async (client) => { + let current = spec.defaults; + if (patch) { + const columns = Object.keys(spec.fields).map((key) => + [date, optionalDate].includes(spec.fields[key]) + ? `to_char(${key},'YYYY-MM-DD') AS ${key}` + : key, + ); + const result = await client.query( + `SELECT ${columns.join(', ')} FROM public.${spec.table} WHERE id = $1 FOR UPDATE`, + [recordId], + ); + if (!result.rows.length) + throw new NotFoundException('Registro no encontrado'); + current = result.rows[0]; + for (const key of Object.keys(current)) + if (current[key] instanceof Date) + current[key] = (current[key] as Date).toISOString(); + } + const merged = { ...current, ...body }; + const data = Object.fromEntries( + Object.entries(spec.fields).map(([key, parse]) => [ + key, + parse(merged[key], key), + ]), + ); + spec.validate?.(data); + const keys = Object.keys(data); + const values = Object.values(data); + const result = patch + ? await client.query( + `UPDATE public.${spec.table} SET ${keys.map((key, index) => `${key} = $${index + 1}`).join(', ')} WHERE id = $${keys.length + 1} RETURNING id`, + [...values, recordId], + ) + : await client.query( + `INSERT INTO public.${spec.table} (${keys.join(', ')}) VALUES (${keys.map((_, index) => `$${index + 1}`).join(', ')}) RETURNING id`, + values, + ); + return { id: result.rows[0].id }; + }); +} diff --git a/src/common/admin-routes.spec.ts b/src/common/admin-routes.spec.ts new file mode 100644 index 0000000..fef45d3 --- /dev/null +++ b/src/common/admin-routes.spec.ts @@ -0,0 +1,83 @@ +import { IamGuard } from '../auth/iam.service'; +import { Test } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from '../app.module'; +import { PG_POOL } from '../shared/connections/database.module'; + +describe('Administrative HTTP routes (isolated database double)', () => { + let app: INestApplication; + const query = jest.fn(async (sql: string) => { + if (sql.includes('SELECT EXISTS')) return { rows: [{ ready: true }] }; + if (sql.includes('WITH filtered AS')) + return { rows: [{ total: 0, postulaciones: [] }] }; + if (sql.startsWith('INSERT')) return { rows: [{ id: '9007199254740993' }] }; + return { rows: [] }; + }); + beforeAll(async () => { + const client = { query, release: jest.fn() }; + const module = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(IamGuard) + .useValue({ + canActivate: (context: any) => { + context.switchToHttp().getRequest().actor = { memberId: '1' }; + return true; + }, + }) + .overrideProvider(PG_POOL) + .useValue({ query, connect: async () => client }) + .compile(); + app = module.createNestApplication(); + await app.init(); + }); + afterAll(async () => { + await app?.close(); + }); + it.each([ + '/members/admin', + '/projects/admin', + '/events/admin', + '/sponsors/admin', + '/roles', + '/specialties', + '/periods', + ])('registers GET %s with the collection envelope', async (path) => { + const response = await request(app.getHttpServer()).get(path).expect(200); + expect(response.body).toEqual({ data: [] }); + }); + it('forwards pagination query parameters', async () => { + const response = await request(app.getHttpServer()) + .get('/applications?limit=30&offset=60') + .expect(200); + expect(response.body).toMatchObject({ + limit: 30, + offset: 60, + postulaciones: [], + totalPostulaciones: 0, + }); + }); + it.each(['/events', '/sponsors', '/roles', '/specialties'])( + 'registers and validates POST %s', + async (path) => { + await request(app.getHttpServer()).post(path).send({}).expect(400); + }, + ); + it('creates a role and preserves its bigint ID', async () => { + const response = await request(app.getHttpServer()) + .post('/roles') + .send({ nombre: 'Contract test' }) + .expect(201); + expect(response.body.id).toBe('9007199254740993'); + }); + it.each([ + '/members/1', + '/projects/1', + '/events/1', + '/sponsors/1', + '/roles/1', + '/specialties/1', + '/periods/1', + ])('registers PATCH %s and rejects empty payloads', async (path) => { + await request(app.getHttpServer()).patch(path).send({}).expect(400); + }); +}); diff --git a/src/common/admin-services.spec.ts b/src/common/admin-services.spec.ts new file mode 100644 index 0000000..6259500 --- /dev/null +++ b/src/common/admin-services.spec.ts @@ -0,0 +1,234 @@ +import { MembersService } from '../members/members.service'; +import { ProjectsService } from '../projects/projects.service'; +import { EventsService } from '../events/events.service'; +import { SponsorsService } from '../sponsors/sponsors.service'; +import { CatalogsService } from '../catalogs/catalogs.service'; +import { ApplicationsService } from '../applications/applications.service'; +const member = { + nombre: 'Existing', + correoInstitucional: null, + carrera: 'Computación', + anioIngresoCarrera: 2005, + estado: 'activo', + perfilPublico: true, + fotoPublica: true, +}; +const project = { + nombre: 'Existing', + descripcionBreve: null, + descripcion: null, + estado: 'activo', + fechaInicio: '2026-01-01', + fechaFin: null, + publicado: true, + destacado: true, + orden: 0, +}; +describe('Administrative CRUD contracts', () => { + let client: any, pool: any; + beforeEach(() => { + client = { + query: jest.fn().mockResolvedValue({ rows: [] }), + release: jest.fn(), + }; + pool = { + query: jest.fn().mockResolvedValue({ rows: [] }), + connect: jest.fn().mockResolvedValue(client), + }; + }); + it('administrative lists preserve the data envelope and do not filter visibility or membership', async () => { + for (const Service of [MembersService, ProjectsService, EventsService]) { + pool.query.mockClear(); + expect(await new Service(pool).findAdmin()).toEqual({ data: [] }); + const sql = pool.query.mock.calls[0][0]; + expect(sql).not.toMatch( + /WHERE\s+(m\.estado|p\.publicado|publicado)|AND\s+m\.perfil_publico/, + ); + } + }); + it('public members remain active and public', async () => { + await new MembersService(pool).findPublic(); + expect(pool.query.mock.calls[0][0]).toContain("m.estado = 'activo'"); + expect(pool.query.mock.calls[0][0]).toContain('m.perfil_publico = true'); + }); + it('public projects retain the publication filter', async () => { + await new ProjectsService(pool).findPublic(); + expect(pool.query.mock.calls[0][0]).toContain('p.publicado = true'); + }); + it('patching one member field preserves all omitted fields and associations', async () => { + client.query.mockImplementation(async (sql: string) => ({ + rows: sql.includes('SELECT iam_subject, nombre') ? [member] : [], + })); + await new MembersService(pool).update('9007199254740993', { + nombre: 'Updated', + }); + const call = client.query.mock.calls.find(([sql]) => + sql.startsWith('UPDATE public.miembros'), + ); + expect(call[1]).toEqual([ + 'Updated', + null, + 'Computación', + 2005, + 'activo', + true, + true, + '9007199254740993', + ]); + expect( + client.query.mock.calls.some(([sql]) => sql.includes('miembro_roles')), + ).toBe(false); + }); + it('rejects hiding a member while retaining a public photo', async () => { + client.query.mockImplementation(async (sql: string) => ({ + rows: sql.includes('SELECT iam_subject, nombre') ? [member] : [], + })); + await expect( + new MembersService(pool).update('1', { perfilPublico: false }), + ).rejects.toMatchObject({ status: 400 }); + expect(client.query).toHaveBeenCalledWith('ROLLBACK'); + }); + it('retiring roles finalizes assignments rather than deleting history', async () => { + client.query.mockImplementation(async (sql: string) => ({ + rows: sql.includes('SELECT iam_subject, nombre') + ? [member] + : sql.includes('SELECT rol_id') + ? [{ id: '2' }] + : [], + })); + await new MembersService(pool).update('1', { roleIds: [] }); + const sql = client.query.mock.calls.map(([sql]) => sql).join('\n'); + expect(sql).toContain('finalizado_en=clock_timestamp()'); + expect(sql).not.toContain('DELETE FROM public.miembro_roles'); + }); + it('rejects unpublishing a featured project without clearing featured', async () => { + client.query.mockImplementation(async (sql: string) => ({ + rows: sql.includes('SELECT nombre') ? [project] : [], + })); + await expect( + new ProjectsService(pool).update('1', { publicado: false }), + ).rejects.toMatchObject({ status: 400 }); + }); + it('rejects unknown member IDs before connecting', () => { + expect(() => + new MembersService(pool).update('0', { nombre: 'X' }), + ).toThrow(); + expect(pool.connect).not.toHaveBeenCalled(); + }); + it('returns 404 for a missing project', async () => { + await expect( + new ProjectsService(pool).update('1', { nombre: 'X' }), + ).rejects.toMatchObject({ status: 404 }); + }); + it('event creation enforces action URLs', async () => { + await expect( + new EventsService(pool).create({ + titulo_evento: 'Event', + tipo_evento: 'charla', + fecha_inicio: '2026-10-01', + tipo_accion: 'inscripcion', + }), + ).rejects.toMatchObject({ status: 400 }); + expect( + client.query.mock.calls.some(([sql]) => sql.startsWith('INSERT')), + ).toBe(false); + }); + it('event partial patches keep dates as date-only values', async () => { + const event = { + tipo_evento: 'charla', + titulo_evento: 'Event', + descripcion: null, + fecha_inicio: '2026-10-01', + fecha_fin: null, + fecha_texto: null, + ubicacion: null, + url_evento: null, + tipo_accion: null, + url_accion: null, + estado: 'programado', + publicado: false, + }; + client.query.mockImplementation(async (sql: string) => ({ + rows: sql.startsWith('SELECT') + ? [event] + : sql.startsWith('UPDATE') + ? [{ id: '1' }] + : [], + })); + await expect( + new EventsService(pool).update('1', { publicado: true }), + ).resolves.toEqual({ id: '1' }); + expect( + client.query.mock.calls.find(([sql]) => sql.startsWith('SELECT'))[0], + ).toContain("to_char(fecha_inicio,'YYYY-MM-DD')"); + }); + it('public sponsors filter both publication and current sponsorship', async () => { + await new SponsorsService(pool).findAll(); + expect(pool.query.mock.calls[0][0]).toContain( + "WHERE publicado=true AND estado_patrocinador='activo'", + ); + pool.query.mockClear(); + await new SponsorsService(pool).findAll(true); + expect(pool.query.mock.calls[0][0]).not.toContain('WHERE'); + }); + it('catalog duplicate names return conflict without internal details', async () => { + client.query.mockImplementation(async (sql: string) => { + if (sql.startsWith('INSERT')) throw { code: '23505', detail: 'PRIVATE' }; + return { rows: [] }; + }); + await expect( + new CatalogsService(pool).save('roles', { nombre: 'Existing' }), + ).rejects.toMatchObject({ status: 409 }); + }); + it.each(['10', '30', '50', '100'])( + 'supports application page size %s', + async (limit) => { + pool.query.mockResolvedValue({ + rows: [{ total: 120, postulaciones: [{ id: '9007199254740993' }] }], + }); + const result = await new ApplicationsService(pool).findAll( + limit, + '10', + 'pendiente', + '5', + 'Ana', + ); + expect(result).toMatchObject({ + limit: Number(limit), + offset: 10, + totalPostulaciones: 120, + hasMore: true, + }); + expect(pool.query.mock.calls[0][1]).toEqual([ + Number(limit), + 10, + 'pendiente', + '5', + 'Ana', + ]); + expect(pool.query.mock.calls[0][0]).not.toMatch(/rut|SELECT\s+\*/i); + }, + ); + it.each([ + ['0', '0'], + ['11', '0'], + ['10', '-1'], + ['100', '1.5'], + ['100', '2147483648'], + ])('rejects invalid pagination %s/%s', async (limit, offset) => { + await expect( + new ApplicationsService(pool).findAll(limit, offset), + ).rejects.toMatchObject({ status: 400 }); + expect(pool.query).not.toHaveBeenCalled(); + }); + it('retains the total on an offset past the last page', async () => { + pool.query.mockResolvedValue({ rows: [{ total: 3, postulaciones: [] }] }); + expect( + await new ApplicationsService(pool).findAll('10', '20'), + ).toMatchObject({ + totalPostulaciones: 3, + postulaciones: [], + hasMore: false, + }); + }); +}); diff --git a/src/events/events.controller.ts b/src/events/events.controller.ts index c33ec5c..5778841 100644 --- a/src/events/events.controller.ts +++ b/src/events/events.controller.ts @@ -1,11 +1,40 @@ -import { Controller, Get, Query } from '@nestjs/common'; +import { Public, Permissions } from '../auth/iam.service'; +import { + Body, + Controller, + Get, + Query, + Post, + Patch, + Param, +} from '@nestjs/common'; import { EventsService } from './events.service'; @Controller('events') export class EventsController { constructor(private readonly events: EventsService) {} + @Permissions('events.read') + @Get('admin') + findAdmin() { + return this.events.findAdmin(); + } + @Permissions('events.manage') + @Post() + create(@Body() body: unknown) { + return this.events.create(body); + } + @Permissions('events.manage') + @Patch(':id') + update(@Param('id') id: string, @Body() body: unknown) { + return this.events.update(id, body); + } + @Public() @Get() - findPublic(@Query('limit') limit?: string, @Query('upcoming') upcoming?: string, @Query('offset') offset?: string) { + findPublic( + @Query('limit') limit?: string, + @Query('upcoming') upcoming?: string, + @Query('offset') offset?: string, + ) { return this.events.findPublic(limit, upcoming, offset); } } diff --git a/src/events/events.service.ts b/src/events/events.service.ts index 981e6a8..3081e4e 100644 --- a/src/events/events.service.ts +++ b/src/events/events.service.ts @@ -1,17 +1,100 @@ -import { BadRequestException, Inject, Injectable, InternalServerErrorException } from '@nestjs/common'; +import { + BadRequestException, + Inject, + Injectable, + InternalServerErrorException, +} from '@nestjs/common'; import { Pool } from 'pg'; import { PG_POOL } from '../shared/connections/database.module'; +import { + boolean, + choice, + date, + dateRange, + optionalDate, + optionalText, + requiredText, + saveRecord, + url, + RecordSpec, +} from '../common/admin-data'; + +const eventSpec: RecordSpec = { + table: 'eventos', + fields: { + tipo_evento: requiredText, + titulo_evento: requiredText, + descripcion: optionalText, + fecha_inicio: date, + fecha_fin: optionalDate, + fecha_texto: optionalText, + ubicacion: optionalText, + url_evento: url, + tipo_accion: (value, field) => + value == null + ? null + : choice(['inscripcion', 'postulacion', 'acceso_libre'])(value, field), + url_accion: url, + estado: choice(['programado', 'cancelado', 'finalizado']), + publicado: boolean, + }, + defaults: { + descripcion: null, + fecha_fin: null, + fecha_texto: null, + ubicacion: null, + url_evento: null, + tipo_accion: null, + url_accion: null, + estado: 'programado', + publicado: false, + }, + validate(data) { + dateRange(data.fecha_inicio, data.fecha_fin); + const needsUrl = ['inscripcion', 'postulacion'].includes( + String(data.tipo_accion), + ); + if (needsUrl ? !data.url_accion : data.url_accion !== null) + throw new BadRequestException( + 'El enlace debe corresponder a la acción del evento', + ); + }, +}; @Injectable() export class EventsService { constructor(@Inject(PG_POOL) private readonly pool: Pool) {} + async findAdmin() { + const { rows } = await this.pool + .query(`SELECT id,tipo_evento,titulo_evento,descripcion, + to_char(fecha_inicio,'YYYY-MM-DD') AS fecha_inicio,to_char(fecha_fin,'YYYY-MM-DD') AS fecha_fin, + fecha_texto,ubicacion,url_evento,tipo_accion,url_accion,estado,publicado FROM public.eventos ORDER BY fecha_inicio DESC,id DESC`); + return { data: rows }; + } + create(body: unknown) { + return saveRecord(this.pool, eventSpec, body); + } + update(id: string, body: unknown) { + return saveRecord(this.pool, eventSpec, body, id); + } async findPublic(rawLimit = '20', rawUpcoming = 'true', rawOffset = '0') { - if (!/^\d+$/.test(rawLimit) || !/^\d+$/.test(rawOffset)) throw new BadRequestException('Paginación inválida'); - const limit = Number(rawLimit), offset = Number(rawOffset); - if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 || !Number.isSafeInteger(offset) || offset > 1000000) throw new BadRequestException('Paginación inválida'); - if (!['true', 'false'].includes(rawUpcoming)) throw new BadRequestException('upcoming debe ser true o false'); + if (!/^\d+$/.test(rawLimit) || !/^\d+$/.test(rawOffset)) + throw new BadRequestException('Paginación inválida'); + const limit = Number(rawLimit), + offset = Number(rawOffset); + if ( + !Number.isSafeInteger(limit) || + limit < 1 || + limit > 100 || + !Number.isSafeInteger(offset) || + offset > 1000000 + ) + throw new BadRequestException('Paginación inválida'); + if (!['true', 'false'].includes(rawUpcoming)) + throw new BadRequestException('upcoming debe ser true o false'); try { - const { rows } = await this.pool.query(` + const { rows } = await this.pool.query( + ` SELECT id, tipo_evento, titulo_evento, descripcion, to_char(fecha_inicio, 'YYYY-MM-DD') AS fecha_inicio, to_char(fecha_fin, 'YYYY-MM-DD') AS fecha_fin, @@ -27,10 +110,14 @@ export class EventsService { CASE WHEN COALESCE(fecha_fin, fecha_inicio) >= (CURRENT_TIMESTAMP AT TIME ZONE 'America/Santiago')::date THEN fecha_inicio END ASC, fecha_inicio DESC, id ASC LIMIT $1 OFFSET $3 - `, [limit + 1, rawUpcoming === 'true', offset]); + `, + [limit + 1, rawUpcoming === 'true', offset], + ); return { data: rows.slice(0, limit), hasMore: rows.length > limit }; } catch { - throw new InternalServerErrorException('No fue posible cargar los eventos'); + throw new InternalServerErrorException( + 'No fue posible cargar los eventos', + ); } } } diff --git a/src/main.ts b/src/main.ts index 0a59a06..ac59dfb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,6 +17,10 @@ async function bootstrap() { 'http://127.0.0.1:3000', 'https://tomas.exdev.cl', 'https://www.tomas.exdev.cl', + 'https://rafael.exdev.cl', + 'https://www.rafael.exdev.cl', + 'https://dev-rafael.exdev.cl', + 'https://www.dev-rafael.exdev.cl', ]; app.use((req, res, next) => { res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); @@ -29,9 +33,15 @@ async function bootstrap() { if (!origin) return cb(null, true); cb(null, WEB_ORIGINS.includes(origin)); }, - methods: ['GET', 'POST', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization'], - credentials: false, + methods: ['GET', 'POST', 'PATCH', 'OPTIONS'], + allowedHeaders: [ + 'Content-Type', + 'Authorization', + 'X-CSRF-Token', + 'If-Match', + 'Idempotency-Key', + ], + credentials: true, optionsSuccessStatus: 204, }); diff --git a/src/members/access-levels.spec.ts b/src/members/access-levels.spec.ts new file mode 100644 index 0000000..22b1836 --- /dev/null +++ b/src/members/access-levels.spec.ts @@ -0,0 +1,210 @@ +import { MembersService } from './members.service'; +import { MembersController } from './members.controller'; +import { IamService, Actor } from '../auth/iam.service'; +import { Pool } from 'pg'; + +const representative: Actor = { + userId: '1236638c-55ab-441b-8e06-49d185573c35', + memberId: '1', + token: 'a'.repeat(43), + permissions: ['members.manage', 'access_levels.manage'], + isAdministrator: false, +}; +const profile = { + nombre: 'Member', + correoInstitucional: 'member@utem.cl', + carrera: 'Computación', + estado: 'activo', + perfilPublico: false, + fotoPublica: false, +}; +function fixture(previous: string | null = null, administrator = false) { + const actor = { ...representative, isAdministrator: administrator }; + const query = jest.fn(async (sql: string) => ({ + rows: sql.includes('SELECT iam_subject, nombre') + ? [{ ...profile, iam_subject: null, accessLevel: previous }] + : sql.includes('INSERT INTO public.miembros') + ? [{ id: '2' }] + : sql.includes('FROM public.roles_club') + ? [{ id: '42' }] + : [], + rowCount: 1, + })); + const client = { query, release: jest.fn() }; + const pool = { connect: async () => client } as unknown as Pool; + const iam = { + require: IamService.prototype.require, + validate: jest.fn().mockResolvedValue(actor), + }; + const service = new MembersService(pool, iam as unknown as IamService); + return { service, query, iam, actor }; +} +describe('Member access level authorization', () => { + it.each(['trainee', 'miembro'])( + 'representative can create a member with %s before IAM linking', + async (level) => { + const f = fixture(); + await f.service.create({ ...profile, accessLevel: level }, f.actor); + expect(f.query).toHaveBeenCalledWith( + 'UPDATE public.miembros SET rafael_access_level=$1 WHERE id=$2', + [level, '2'], + ); + expect( + f.query.mock.calls.some(([sql]) => + sql.includes('rafael_access_level_audit'), + ), + ).toBe(true); + }, + ); + it('promotes trainee to member and audits the previous level in the same transaction', async () => { + const f = fixture('trainee'); + await f.service.update( + '2', + { accessLevel: 'miembro' }, + f.actor.memberId, + f.actor, + ); + const audit = f.query.mock.calls.find(([sql]) => + sql.includes('INSERT INTO public.rafael_access_level_audit'), + ); + expect(audit).toBeDefined(); + expect(f.query).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO public.rafael_access_level_audit'), + ['2', f.actor.userId, 'trainee', 'miembro'], + ); + expect(f.query).toHaveBeenCalledWith('COMMIT'); + }); + it.each([ + ['trainee', 'representante'], + ['representante', 'miembro'], + ['representante', null], + ])('representative cannot change %s to %s', async (previous, next) => { + const f = fixture(previous); + await expect( + f.service.update('2', { accessLevel: next }, '1', f.actor), + ).rejects.toThrow('ADMINISTRATOR_REQUIRED'); + expect(f.query).toHaveBeenCalledWith('ROLLBACK'); + }); + it('representative cannot create a representative by forging the payload', async () => { + const f = fixture(); + await expect( + f.service.create({ ...profile, accessLevel: 'representante' }, f.actor), + ).rejects.toThrow('ADMINISTRATOR_REQUIRED'); + }); + it('administrator can assign representative', async () => { + const f = fixture('miembro', true); + await f.service.update('2', { accessLevel: 'representante' }, '1', f.actor); + expect(f.query).toHaveBeenCalledWith( + 'UPDATE public.miembros SET rafael_access_level=$1 WHERE id=$2', + ['representante', '2'], + ); + }); + it('no member form can assign administrator', async () => { + const f = fixture('miembro', true); + await expect( + f.service.update( + '2', + { accessLevel: 'administrador_rafael' }, + '1', + f.actor, + ), + ).rejects.toThrow(); + }); + it('rechecks authorization after session permissions were revoked', async () => { + const f = fixture('trainee'); + f.iam.validate.mockResolvedValue({ ...f.actor, permissions: [] }); + await expect( + f.service.update('2', { accessLevel: 'miembro' }, '1', f.actor), + ).rejects.toThrow('PERMISSION_REQUIRED'); + }); + it('preserves level when an edit omits it', async () => { + const f = fixture('representante'); + await f.service.update('2', { nombre: 'Updated' }, '1', f.actor); + expect( + f.query.mock.calls.some(([sql]) => + sql.includes('SET rafael_access_level'), + ), + ).toBe(false); + }); + it('self-profile cannot change access level', () => { + const f = fixture('trainee'); + expect(() => + new MembersController(f.service).updateSelf({ actor: f.actor } as any, { + accessLevel: 'representante', + }), + ).toThrow('campos no permitidos'); + }); +}); + +describe('Unified member type', () => { + it.each([ + ['trainee', 'trainee', ['trainee']], + ['miembro', 'miembro', ['miembro', 'miembro activo']], + ['titulado', 'miembro', ['titulado']], + ['representante', 'representante', ['representante']], + ])( + 'saves %s with its club role and %s access atomically', + async (type, level, names) => { + const f = fixture(null, true); + await f.service.create({ ...profile, memberType: type }, f.actor); + expect(f.query).toHaveBeenCalledWith( + expect.stringContaining('lower(btrim(nombre))=ANY'), + [names], + ); + expect(f.query).toHaveBeenCalledWith( + 'UPDATE public.miembros SET rafael_access_level=$1 WHERE id=$2', + [level, '2'], + ); + expect(f.query).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO public.miembro_roles'), + ['2', '42'], + ); + expect(f.query).toHaveBeenCalledWith('COMMIT'); + }, + ); + it('changes member to graduate without changing member permissions', async () => { + const f = fixture('miembro'); + await f.service.update('2', { memberType: 'titulado' }, '1', f.actor); + expect(f.iam.validate).toHaveBeenCalled(); + expect(f.query).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO public.miembro_roles'), + ['2', '42'], + ); + expect( + f.query.mock.calls.some(([sql]) => + sql.includes('SET rafael_access_level'), + ), + ).toBe(false); + }); + it.each([ + [null, 'representante'], + ['representante', 'miembro'], + ])('requires administrator for %s to %s', async (previous, next) => { + const f = fixture(previous); + await expect( + f.service.update('2', { memberType: next }, '1', f.actor), + ).rejects.toThrow('ADMINISTRATOR_REQUIRED'); + expect(f.query).toHaveBeenCalledWith('ROLLBACK'); + }); + it('rejects conflicting legacy selection fields', () => { + const f = fixture(); + expect(() => + f.service.create( + { ...profile, memberType: 'titulado', roleIds: ['1'] }, + f.actor, + ), + ).toThrow('Envía solo memberType'); + expect(f.query).not.toHaveBeenCalled(); + }); + it('rolls back when the matching catalog role is missing', async () => { + const f = fixture(); + f.query.mockImplementation(async () => ({ rows: [], rowCount: 0 })); + await expect( + f.service.create({ ...profile, memberType: 'titulado' }, f.actor), + ).rejects.toThrow('único rol activo'); + expect(f.query).toHaveBeenCalledWith('ROLLBACK'); + expect( + f.query.mock.calls.some(([sql]) => sql.includes('INSERT INTO')), + ).toBe(false); + }); +}); diff --git a/src/members/members.controller.ts b/src/members/members.controller.ts index 3296d33..c946d12 100644 --- a/src/members/members.controller.ts +++ b/src/members/members.controller.ts @@ -1,18 +1,74 @@ -import { Body, Controller, Get, Post, Query } from '@nestjs/common'; +import { bodyObject } from '../common/admin-data'; +import { Public, Permissions, AuthRequest } from '../auth/iam.service'; +import { + Body, + Controller, + Get, + Post, + Query, + Patch, + Param, + Req, +} from '@nestjs/common'; import { MembersService } from './members.service'; @Controller('members') export class MembersController { constructor(private readonly membersService: MembersService) {} + @Permissions() + @Get('me') + async self(@Req() req: AuthRequest) { + const result = await this.membersService.findAdmin(req.actor.memberId); + return { data: result.data[0] }; + } + @Permissions('profile.write_own') + @Patch('me') + updateSelf(@Req() req: AuthRequest, @Body() raw: unknown) { + const body = bodyObject( + raw, + [ + 'nombre', + 'carrera', + 'anioIngresoCarrera', + 'perfilPublico', + 'fotoPublica', + 'specialtyIds', + ], + true, + ); + return this.membersService.update( + req.actor.memberId, + body, + req.actor.memberId, + ); + } + + @Permissions('members.read') + @Get('admin') + findAdmin() { + return this.membersService.findAdmin(); + } + + @Permissions('members.manage') + @Patch(':id') + update( + @Param('id') id: string, + @Body() body: unknown, + @Req() req: AuthRequest, + ) { + return this.membersService.update(id, body, req.actor.memberId, req.actor); + } + + @Public() @Get() findPublic(@Query('limit') limit?: string) { return this.membersService.findPublic(limit); } - // TODO(IAM): proteger este endpoint antes de habilitarlo en producción. + @Permissions('members.manage') @Post() - create(@Body() body: unknown) { - return this.membersService.create(body); + create(@Body() body: unknown, @Req() req: AuthRequest) { + return this.membersService.create(body, req.actor); } } diff --git a/src/members/members.service.ts b/src/members/members.service.ts index 7446d17..6bf2796 100644 --- a/src/members/members.service.ts +++ b/src/members/members.service.ts @@ -1,10 +1,33 @@ -import { BadRequestException, ConflictException, Inject, Injectable, InternalServerErrorException } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import { Actor, IamService } from '../auth/iam.service'; +import { + BadRequestException, + ForbiddenException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { Pool } from 'pg'; +import { + bodyObject, + boolean, + choice, + id, + ids, + integer, + optionalText, + requiredText, + transaction, + Data, +} from '../common/admin-data'; import { PG_POOL } from '../shared/connections/database.module'; @Injectable() export class MembersService { - constructor(@Inject(PG_POOL) private readonly pool: Pool) {} + constructor( + @Inject(PG_POOL) private readonly pool: Pool, + private readonly iam?: IamService, + ) {} async findPublic(rawLimit?: string) { const limit = this.parseLimit(rawLimit); @@ -43,91 +66,277 @@ export class MembersService { return { data: rows }; } - async create(rawBody: unknown) { - const body = this.validateCreateBody(rawBody); - const client = await this.pool.connect(); - try { - await client.query('BEGIN'); - const { rows } = await client.query( - `INSERT INTO public.miembros - (iam_subject, nombre, correo_institucional, carrera, anio_ingreso_carrera, - estado, perfil_publico, foto_publica) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8) - RETURNING id;`, - [body.iamSubject, body.nombre, body.correoInstitucional, body.carrera, - body.anioIngresoCarrera, body.estado, body.perfilPublico, body.fotoPublica], - ); - const memberId = rows[0].id; + async findAdmin(memberId?: string) { + const { rows } = await this.pool.query( + ` + SELECT m.id, m.nombre, m.correo_institucional, m.carrera, m.anio_ingreso_carrera, + m.estado, m.perfil_publico, m.foto_publica, m.rafael_access_level, + (m.iam_subject IS NOT NULL) AS iam_linked, + CASE WHEN m.rafael_access_level='miembro' AND EXISTS ( + SELECT 1 FROM public.miembro_roles mr JOIN public.roles_club r ON r.id=mr.rol_id + WHERE mr.miembro_id=m.id AND mr.estado='activo' AND lower(btrim(r.nombre))='titulado' + ) THEN 'titulado' ELSE m.rafael_access_level END AS member_type, + COALESCE((SELECT json_agg(r.nombre ORDER BY r.nombre) FROM public.miembro_roles mr + JOIN public.roles_club r ON r.id = mr.rol_id WHERE mr.miembro_id = m.id AND mr.estado = 'activo'), '[]') AS roles, + COALESCE((SELECT json_agg(mr.rol_id::text ORDER BY mr.rol_id) FROM public.miembro_roles mr + WHERE mr.miembro_id = m.id AND mr.estado = 'activo'), '[]') AS role_ids, + COALESCE((SELECT json_agg(e.nombre ORDER BY e.nombre) FROM public.miembro_especialidades me + JOIN public.especialidades e ON e.id = me.especialidad_id WHERE me.miembro_id = m.id), '[]') AS especialidades, + COALESCE((SELECT json_agg(me.especialidad_id::text ORDER BY me.especialidad_id) FROM public.miembro_especialidades me + WHERE me.miembro_id = m.id), '[]') AS specialty_ids + FROM public.miembros m WHERE ($1::bigint IS NULL OR m.id=$1) ORDER BY m.nombre, m.id`, + [memberId ?? null], + ); + return { data: rows }; + } - for (const roleId of body.roleIds) { + create(body: unknown, actor?: Actor) { + return this.save(body, undefined, actor?.memberId, actor); + } + update(memberId: string, body: unknown, actorId?: string, actor?: Actor) { + return this.save(body, id(memberId), actorId, actor); + } + + private save( + raw: unknown, + memberId?: string, + actorId?: string, + actor?: Actor, + ) { + const fields = [ + 'nombre', + 'correoInstitucional', + 'carrera', + 'anioIngresoCarrera', + 'estado', + 'perfilPublico', + 'fotoPublica', + 'roleIds', + 'specialtyIds', + 'accessLevel', + 'memberType', + ]; + const body = bodyObject(raw, fields, !!memberId); + if ( + body.memberType !== undefined && + (body.accessLevel !== undefined || body.roleIds !== undefined) + ) + throw new BadRequestException( + 'Envía solo memberType para seleccionar el tipo de miembro', + ); + const memberType = + body.memberType === undefined + ? undefined + : choice(['trainee', 'miembro', 'titulado', 'representante'])( + body.memberType, + 'memberType', + ); + if (memberType !== undefined) + body.accessLevel = memberType === 'titulado' ? 'miembro' : memberType; + return transaction(this.pool, async (client) => { + let previous: Data = { + estado: 'activo', + perfilPublico: false, + fotoPublica: false, + correoInstitucional: null, + anioIngresoCarrera: null, + accessLevel: null, + }; + if (memberId) { + const result = await client.query( + `SELECT iam_subject, nombre, correo_institucional AS "correoInstitucional", carrera, rafael_access_level AS "accessLevel", + anio_ingreso_carrera AS "anioIngresoCarrera", estado, perfil_publico AS "perfilPublico", foto_publica AS "fotoPublica" + FROM public.miembros WHERE id = $1 FOR UPDATE`, + [memberId], + ); + if (!result.rows.length) + throw new NotFoundException('Miembro no encontrado'); + previous = result.rows[0]; + } + const nextLevel = + body.accessLevel === undefined + ? (previous.accessLevel ?? null) + : body.accessLevel === null + ? null + : choice(['trainee', 'miembro', 'representante'])( + body.accessLevel, + 'accessLevel', + ); + const changeLevel = nextLevel !== (previous.accessLevel ?? null); + if (body.accessLevel !== undefined) { + if (!actor || !this.iam) + throw new ForbiddenException('PERMISSION_REQUIRED'); + this.iam.require(actor, 'access_levels.manage'); + if (changeLevel || memberType !== undefined) { + const fresh = await this.iam.validate(actor.token); + this.iam.require(fresh, 'members.manage', 'access_levels.manage'); + if ( + fresh.userId !== actor.userId || + fresh.memberId !== actor.memberId + ) + throw new ForbiddenException('IDENTITY_CHANGED'); + if ( + (nextLevel === 'representante' || + previous.accessLevel === 'representante') && + !fresh.isAdministrator + ) + throw new ForbiddenException('ADMINISTRATOR_REQUIRED'); + } + } + const data = { ...previous, ...body }; + if (memberType !== undefined) { + const names = + memberType === 'miembro' + ? ['miembro', 'miembro activo'] + : [memberType]; + const target = await client.query( + `SELECT id::text FROM public.roles_club WHERE lower(btrim(nombre))=ANY($1::text[]) AND estado='activo' FOR SHARE`, + [names], + ); + if (target.rows.length !== 1) + throw new BadRequestException( + 'El tipo debe tener un único rol activo correspondiente en el catálogo del club', + ); + const otherRoles = memberId + ? await client.query( + `SELECT mr.rol_id::text AS id FROM public.miembro_roles mr JOIN public.roles_club r ON r.id=mr.rol_id + WHERE mr.miembro_id=$1 AND mr.estado='activo' + AND lower(btrim(r.nombre)) NOT IN ('trainee','miembro','miembro activo','titulado','representante')`, + [memberId], + ) + : { rows: [] }; + body.roleIds = [ + ...otherRoles.rows.map((row) => row.id), + target.rows[0].id, + ]; + } + const nombre = requiredText(data.nombre, 'nombre'); + const carrera = requiredText(data.carrera, 'carrera'); + const email = + optionalText( + data.correoInstitucional, + 'correoInstitucional', + )?.toLowerCase() ?? null; + if (email && !/^[^\s@]+@utem\.cl$/.test(email)) + throw new BadRequestException('El correo debe pertenecer a @utem.cl'); + const year = + data.anioIngresoCarrera == null + ? null + : integer(data.anioIngresoCarrera, 'anioIngresoCarrera'); + if (year !== null && (year < 1900 || year > 2100)) + throw new BadRequestException('Año de ingreso inválido'); + const estado = choice(['activo', 'inactivo'])(data.estado, 'estado'); + if (memberId && previous.iam_subject && estado !== previous.estado) { + if (memberId === actorId && estado === 'inactivo') + throw new BadRequestException( + 'No puedes desactivar tu propia cuenta', + ); + if (estado === 'activo') { + const pending = await client.query( + 'SELECT id FROM public.iam_access_outbox WHERE miembro_id=$1 AND processed_at IS NULL LIMIT 1', + [memberId], + ); + if (pending.rows.length) + throw new BadRequestException( + 'La suspensión IAM sigue pendiente; vuelve a intentar después', + ); + } else + await client.query( + 'INSERT INTO public.iam_access_outbox(id,miembro_id,iam_user_id,application_code) VALUES($1,$2,$3,$4)', + [ + randomUUID(), + memberId, + previous.iam_subject, + process.env.IAM_APPLICATION_CODE || 'rafael', + ], + ); + } + const profile = boolean(data.perfilPublico, 'perfilPublico'); + const photo = boolean(data.fotoPublica, 'fotoPublica'); + if (photo && !profile) + throw new BadRequestException( + 'La foto pública requiere un perfil público', + ); + const values = [nombre, email, carrera, year, estado, profile, photo]; + if (memberId) + await client.query( + `UPDATE public.miembros SET nombre=$1, correo_institucional=$2, carrera=$3, anio_ingreso_carrera=$4, estado=$5, perfil_publico=$6, foto_publica=$7 WHERE id=$8`, + [...values, memberId], + ); + else { + const result = await client.query( + `INSERT INTO public.miembros (nombre, correo_institucional, carrera, anio_ingreso_carrera, estado, perfil_publico, foto_publica) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`, + values, + ); + memberId = String(result.rows[0].id); + } + if (changeLevel) { + await client.query( + 'UPDATE public.miembros SET rafael_access_level=$1 WHERE id=$2', + [nextLevel, memberId], + ); await client.query( - `INSERT INTO public.miembro_roles (miembro_id, rol_id, estado) - VALUES ($1, $2, 'activo');`, - [memberId, roleId], + `INSERT INTO public.rafael_access_level_audit(member_id,actor_iam_subject,previous_level,next_level) + VALUES($1,$2,$3,$4)`, + [memberId, actor!.userId, previous.accessLevel ?? null, nextLevel], ); } - for (const specialtyId of body.specialtyIds) { + if (body.roleIds !== undefined) { + const roleIds = ids(body.roleIds, 'roleIds'); + const existing = await client.query( + `SELECT rol_id::text AS id FROM public.miembro_roles WHERE miembro_id=$1 AND estado='activo'`, + [memberId], + ); + const old = new Set(existing.rows.map((row) => String(row.id))); + for (const roleId of roleIds.filter((value) => !old.has(value))) { + const role = await client.query( + `SELECT id FROM public.roles_club WHERE id=$1 AND estado='activo' FOR SHARE`, + [roleId], + ); + if (!role.rows.length) + throw new BadRequestException( + 'Solo puedes asignar roles activos existentes', + ); + await client.query( + `INSERT INTO public.miembro_roles (miembro_id, rol_id) VALUES ($1,$2)`, + [memberId, roleId], + ); + } await client.query( - `INSERT INTO public.miembro_especialidades (miembro_id, especialidad_id) - VALUES ($1, $2);`, - [memberId, specialtyId], + `UPDATE public.miembro_roles SET estado='inactivo', finalizado_en=clock_timestamp() WHERE miembro_id=$1 AND estado='activo' AND NOT (rol_id=ANY($2::bigint[]))`, + [memberId, roleIds], ); } - - await client.query('COMMIT'); - return { message: 'Miembro creado correctamente', id: memberId }; - } catch (error: any) { - await client.query('ROLLBACK'); - if (error?.code === '23505') throw new ConflictException('El miembro o una de sus asignaciones ya existe'); - if (['23503', '23514', '22P02'].includes(error?.code)) throw new BadRequestException('Los datos del miembro no son válidos'); - throw new InternalServerErrorException('No fue posible crear el miembro'); - } finally { - client.release(); - } - } - - private validateCreateBody(rawBody: unknown) { - if (!rawBody || typeof rawBody !== 'object') throw new BadRequestException('El cuerpo es obligatorio'); - const body = rawBody as Record; - const nombre = this.requiredText(body.nombre, 'nombre'); - const correoInstitucional = this.requiredText(body.correoInstitucional, 'correoInstitucional').toLowerCase(); - if (!/^[^\s@]+@utem\.cl$/i.test(correoInstitucional)) { - throw new BadRequestException('correoInstitucional debe pertenecer a @utem.cl'); - } - const carrera = this.requiredText(body.carrera, 'carrera'); - const anioIngresoCarrera = Number(body.anioIngresoCarrera); - if (!Number.isInteger(anioIngresoCarrera) || anioIngresoCarrera < 1900 || anioIngresoCarrera > 2100) { - throw new BadRequestException('anioIngresoCarrera no es válido'); - } - const estado = body.estado ?? 'activo'; - if (estado !== 'activo' && estado !== 'inactivo') throw new BadRequestException('estado no es válido'); - const perfilPublico = body.perfilPublico ?? false; - const fotoPublica = body.fotoPublica ?? false; - if (typeof perfilPublico !== 'boolean' || typeof fotoPublica !== 'boolean' || (fotoPublica && !perfilPublico)) { - throw new BadRequestException('La configuración de visibilidad no es válida'); - } - return { - iamSubject: body.iamSubject === undefined || body.iamSubject === null ? null : this.requiredText(body.iamSubject, 'iamSubject'), - nombre, correoInstitucional, carrera, anioIngresoCarrera, estado, - perfilPublico, fotoPublica, - roleIds: this.idArray(body.roleIds, 'roleIds'), - specialtyIds: this.idArray(body.specialtyIds, 'specialtyIds'), - }; - } - - private requiredText(value: unknown, field: string): string { - if (typeof value !== 'string' || !value.trim()) throw new BadRequestException(`${field} es obligatorio`); - return value.trim(); - } - - private idArray(value: unknown, field: string): number[] { - if (value === undefined) return []; - if (!Array.isArray(value)) throw new BadRequestException(`${field} debe ser un arreglo`); - const ids = value.map(Number); - if (ids.some((id) => !Number.isSafeInteger(id) || id < 1) || new Set(ids).size !== ids.length) { - throw new BadRequestException(`${field} contiene IDs inválidos o repetidos`); - } - return ids; + if (body.specialtyIds !== undefined) { + const specialtyIds = ids(body.specialtyIds, 'specialtyIds'); + const existing = await client.query( + `SELECT especialidad_id::text AS id FROM public.miembro_especialidades WHERE miembro_id=$1`, + [memberId], + ); + const old = new Set(existing.rows.map((row) => String(row.id))); + for (const specialtyId of specialtyIds.filter( + (value) => !old.has(value), + )) { + const specialty = await client.query( + `SELECT id FROM public.especialidades WHERE id=$1 AND estado='activo' FOR SHARE`, + [specialtyId], + ); + if (!specialty.rows.length) + throw new BadRequestException( + 'Solo puedes asignar especialidades activas existentes', + ); + await client.query( + `INSERT INTO public.miembro_especialidades (miembro_id, especialidad_id) VALUES ($1,$2)`, + [memberId, specialtyId], + ); + } + await client.query( + `DELETE FROM public.miembro_especialidades WHERE miembro_id=$1 AND NOT (especialidad_id=ANY($2::bigint[]))`, + [memberId, specialtyIds], + ); + } + return { id: memberId }; + }); } private parseLimit(rawLimit?: string): number { diff --git a/src/periods/periods.controller.ts b/src/periods/periods.controller.ts new file mode 100644 index 0000000..7737323 --- /dev/null +++ b/src/periods/periods.controller.ts @@ -0,0 +1,22 @@ +import { Permissions } from '../auth/iam.service'; +import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; +import { PeriodsService } from './periods.service'; +@Controller('periods') +export class PeriodsController { + constructor(private readonly periods: PeriodsService) {} + @Permissions('application_periods.read') + @Get() + findAll() { + return this.periods.findAll(); + } + @Permissions('application_periods.manage') + @Post() + create(@Body() body: unknown) { + return this.periods.create(body); + } + @Permissions('application_periods.manage') + @Patch(':id') + update(@Param('id') id: string, @Body() body: unknown) { + return this.periods.update(id, body); + } +} diff --git a/src/periods/periods.module.ts b/src/periods/periods.module.ts new file mode 100644 index 0000000..34a1619 --- /dev/null +++ b/src/periods/periods.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { SharedModule } from '../shared/shared.module'; +import { PeriodsController } from './periods.controller'; +import { PeriodsService } from './periods.service'; +@Module({ + imports: [SharedModule], + controllers: [PeriodsController], + providers: [PeriodsService], + exports: [PeriodsService], +}) +export class PeriodsModule {} diff --git a/src/periods/periods.service.spec.ts b/src/periods/periods.service.spec.ts new file mode 100644 index 0000000..2b307f8 --- /dev/null +++ b/src/periods/periods.service.spec.ts @@ -0,0 +1,104 @@ +import { PeriodsService } from './periods.service'; +const scheduled = { + nombre_periodo: 'Convocatoria', + descripcion: null, + fecha_apertura: '2030-10-01T13:00:00.000Z', + fecha_cierre: '2030-10-10T13:00:00.000Z', + fecha_cierre_votacion: '2030-10-12T13:00:00.000Z', + estado_periodo: 'creado', +}; +describe('Scheduled periods', () => { + let client: any, pool: any, service: PeriodsService; + beforeEach(() => { + client = { + query: jest.fn(async (sql: string) => { + if (sql.includes('SELECT EXISTS')) return { rows: [{ ready: true }] }; + if (sql.includes('SELECT clock_timestamp')) + return { rows: [{ instant: '2030-09-01T12:00:00.000Z' }] }; + if (sql.includes('FOR UPDATE')) return { rows: [scheduled] }; + if (sql.startsWith('INSERT')) return { rows: [{ id: '7' }] }; + return { rows: [] }; + }), + release: jest.fn(), + }; + pool = { + connect: jest.fn().mockResolvedValue(client), + query: jest.fn().mockResolvedValue({ rows: [] }), + }; + service = new PeriodsService(pool); + }); + it('closes expired periods before opening eligible ones, under a transaction lock', async () => { + await service.synchronize(); + const calls = client.query.mock.calls.map(([sql]) => sql); + const closed = calls.findIndex((sql) => + sql.includes("SET estado_periodo='cerrado'"), + ), + opened = calls.findIndex((sql) => + sql.includes("SET estado_periodo='habilitado'"), + ); + expect(calls[1]).toContain('pg_advisory_xact_lock'); + expect(closed).toBeLessThan(opened); + expect(calls[opened]).toContain("estado_periodo='creado'"); + expect(calls[opened]).toContain('fecha_apertura<=clock_timestamp()'); + expect(calls[opened]).toContain('clock_timestamp() { + expect(await service.create(scheduled)).toEqual({ id: '7' }); + expect( + client.query.mock.calls.find(([sql]) => sql.startsWith('INSERT'))[1][5], + ).toBe('creado'); + }); + it('rejects invalid reception and voting windows', async () => { + await expect( + service.create({ ...scheduled, fecha_cierre: scheduled.fecha_apertura }), + ).rejects.toMatchObject({ status: 400 }); + await expect( + service.create({ + ...scheduled, + fecha_cierre_votacion: scheduled.fecha_apertura, + }), + ).rejects.toMatchObject({ status: 400 }); + }); + it('rejects a new period opening in the past', async () => { + await expect( + service.create({ ...scheduled, fecha_apertura: '2030-08-01T13:00:00Z' }), + ).rejects.toMatchObject({ status: 400 }); + }); + it('prevents manual early activation', async () => { + await expect( + service.update('7', { estado_periodo: 'habilitado' }), + ).rejects.toMatchObject({ status: 400 }); + }); + it('allows cancellation of a scheduled period', async () => { + expect(await service.update('7', { estado_periodo: 'cancelado' })).toEqual({ + id: '7', + }); + expect( + client.query.mock.calls.find( + ([sql]) => sql.startsWith('UPDATE') && sql.includes('nombre_periodo'), + )[1][5], + ).toBe('cancelado'); + }); + it('reports a missing migration before writing', async () => { + client.query.mockImplementation(async (sql: string) => ({ + rows: sql.includes('SELECT EXISTS') ? [{ ready: false }] : [], + })); + await expect(service.create(scheduled)).rejects.toMatchObject({ + status: 409, + }); + expect( + client.query.mock.calls.some(([sql]) => sql.startsWith('INSERT')), + ).toBe(false); + }); + it('maps overlapping intervals to conflict and rolls back', async () => { + const original = client.query.getMockImplementation(); + client.query.mockImplementation(async (sql: string) => { + if (sql.startsWith('INSERT')) throw { code: '23P01' }; + return original(sql); + }); + await expect(service.create(scheduled)).rejects.toMatchObject({ + status: 409, + }); + expect(client.query).toHaveBeenCalledWith('ROLLBACK'); + }); +}); diff --git a/src/periods/periods.service.ts b/src/periods/periods.service.ts new file mode 100644 index 0000000..68f4933 --- /dev/null +++ b/src/periods/periods.service.ts @@ -0,0 +1,212 @@ +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + Logger, + NotFoundException, + OnModuleDestroy, + OnModuleInit, +} from '@nestjs/common'; +import { Pool, PoolClient } from 'pg'; +import { PG_POOL } from '../shared/connections/database.module'; +import { + bodyObject, + choice, + id, + optionalText, + requiredText, + timestamp, + transaction, +} from '../common/admin-data'; + +@Injectable() +export class PeriodsService implements OnModuleInit, OnModuleDestroy { + constructor(@Inject(PG_POOL) private readonly pool: Pool) {} + private timer?: ReturnType; + private running = false; + private readonly logger = new Logger(PeriodsService.name); + private warned = false; + onModuleInit() { + void this.tick(); + this.timer = setInterval(() => void this.tick(), 30000); + this.timer.unref(); + } + onModuleDestroy() { + if (this.timer) clearInterval(this.timer); + } + private async tick() { + if (this.running) return; + this.running = true; + try { + await this.synchronize(); + } catch { + if (!this.warned) { + this.logger.warn( + 'No se pudieron actualizar los períodos. Revisa la conexión y la migración de programación.', + ); + this.warned = true; + } + } finally { + this.running = false; + } + } + private async hasMigration(client: PoolClient) { + const result = await client.query( + `SELECT EXISTS(SELECT 1 FROM pg_constraint WHERE conrelid=to_regclass('public.periodos_postulacion') AND conname='periodos_recepcion_sin_superposicion') AS ready`, + ); + return result.rows[0]?.ready === true; + } + private async lockAndSync(client: PoolClient, required = true) { + // All API writers serialize period changes across processes, then acquire row locks. + await client.query('SELECT pg_advisory_xact_lock(74102931)'); + if (!(await this.hasMigration(client))) { + if (required) + throw new ConflictException( + 'Falta aplicar la migración de programación de períodos', + ); + if (!this.warned) { + this.logger.warn( + 'Programación automática pendiente: falta la migración de períodos.', + ); + this.warned = true; + } + return false; + } + await client.query( + `UPDATE public.periodos_postulacion SET estado_periodo='cerrado' WHERE estado_periodo IN ('creado','habilitado') AND fecha_cierre<=clock_timestamp()`, + ); + await client.query( + `UPDATE public.periodos_postulacion SET estado_periodo='habilitado' WHERE estado_periodo='creado' AND fecha_apertura<=clock_timestamp() AND clock_timestamp() this.lockAndSync(client, false)); + } + async findAll() { + await this.synchronize(); + const { rows } = await this.pool.query( + `SELECT id,nombre_periodo,descripcion,fecha_apertura,fecha_cierre,fecha_cierre_votacion,estado_periodo FROM public.periodos_postulacion ORDER BY fecha_apertura DESC,id DESC`, + ); + return { data: rows }; + } + create(body: unknown) { + return this.save(body); + } + update(periodId: string, body: unknown) { + return this.save(body, id(periodId)); + } + private save(raw: unknown, periodId?: string) { + const body = bodyObject( + raw, + [ + 'nombre_periodo', + 'descripcion', + 'fecha_apertura', + 'fecha_cierre', + 'fecha_cierre_votacion', + 'estado_periodo', + ], + !!periodId, + ); + return transaction(this.pool, async (client) => { + await this.lockAndSync(client); + let previous: Record = { + descripcion: null, + estado_periodo: 'creado', + }; + if (periodId) { + const result = await client.query( + `SELECT nombre_periodo,descripcion,fecha_apertura,fecha_cierre,fecha_cierre_votacion,estado_periodo FROM public.periodos_postulacion WHERE id=$1 FOR UPDATE`, + [periodId], + ); + if (!result.rows.length) + throw new NotFoundException('Período no encontrado'); + previous = result.rows[0]; + for (const key of [ + 'fecha_apertura', + 'fecha_cierre', + 'fecha_cierre_votacion', + ]) + if (previous[key] instanceof Date) + previous[key] = (previous[key] as Date).toISOString(); + } + const merged = { ...previous, ...body }; + const opening = timestamp(merged.fecha_apertura, 'fecha_apertura'), + closing = timestamp(merged.fecha_cierre, 'fecha_cierre'), + voting = timestamp( + merged.fecha_cierre_votacion, + 'fecha_cierre_votacion', + ); + if (closing <= opening || voting < closing) + throw new BadRequestException( + 'El cierre debe ser posterior a la apertura y la votación no puede cerrar antes de la recepción', + ); + const state = choice(['creado', 'habilitado', 'cerrado', 'cancelado'])( + merged.estado_periodo, + 'estado_periodo', + ); + const clock = await client.query('SELECT clock_timestamp() AS instant'); + const now = new Date(clock.rows[0].instant).toISOString(); + if (!periodId && (state !== 'creado' || opening <= now)) + throw new BadRequestException( + 'El nuevo período debe programarse con una apertura futura y estado creado', + ); + if (periodId) { + const old = previous.estado_periodo; + const allowed = + old === 'creado' + ? ['creado', 'cancelado'] + : old === 'habilitado' + ? ['habilitado', 'cerrado', 'cancelado'] + : [old]; + if (!allowed.includes(state)) + throw new BadRequestException( + 'La transición de estado no está permitida', + ); + if ( + old !== 'creado' && + (opening !== previous.fecha_apertura || + (closing !== previous.fecha_cierre && old !== 'habilitado')) + ) + throw new BadRequestException( + 'No se pueden cambiar las fechas de recepción de este período', + ); + if (old === 'creado' && state === 'creado' && opening <= now) + throw new BadRequestException( + 'La apertura programada debe ser futura', + ); + if ( + ['cerrado', 'cancelado'].includes(String(old)) && + voting !== previous.fecha_cierre_votacion + ) + throw new BadRequestException( + 'No se puede reprogramar la votación de un período terminado', + ); + } + const values = [ + requiredText(merged.nombre_periodo, 'nombre_periodo'), + optionalText(merged.descripcion, 'descripcion'), + opening, + closing, + voting, + state, + ]; + if (periodId) + await client.query( + `UPDATE public.periodos_postulacion SET nombre_periodo=$1,descripcion=$2,fecha_apertura=$3,fecha_cierre=$4,fecha_cierre_votacion=$5,estado_periodo=$6 WHERE id=$7`, + [...values, periodId], + ); + else { + const result = await client.query( + `INSERT INTO public.periodos_postulacion (nombre_periodo,descripcion,fecha_apertura,fecha_cierre,fecha_cierre_votacion,estado_periodo) VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`, + values, + ); + periodId = String(result.rows[0].id); + } + await this.lockAndSync(client); + return { id: periodId }; + }); + } +} diff --git a/src/projects/projects.controller.ts b/src/projects/projects.controller.ts index 7a4468c..b5e90f0 100644 --- a/src/projects/projects.controller.ts +++ b/src/projects/projects.controller.ts @@ -1,16 +1,41 @@ -import { Body, Controller, Get, Post, Query } from '@nestjs/common'; +import { Public, Permissions } from '../auth/iam.service'; +import { + Body, + Controller, + Get, + Post, + Query, + Patch, + Param, +} from '@nestjs/common'; import { ProjectsService } from './projects.service'; @Controller('projects') export class ProjectsController { constructor(private readonly projectsService: ProjectsService) {} + @Permissions('projects.read') + @Get('admin') + findAdmin() { + return this.projectsService.findAdmin(); + } + + @Permissions('projects.manage') + @Patch(':id') + update(@Param('id') id: string, @Body() body: unknown) { + return this.projectsService.update(id, body); + } + + @Public() @Get() - findPublic(@Query('limit') limit?: string, @Query('featured') featured?: string) { + findPublic( + @Query('limit') limit?: string, + @Query('featured') featured?: string, + ) { return this.projectsService.findPublic(limit, featured); } - // TODO(IAM): proteger este endpoint antes de habilitarlo en producción. + @Permissions('projects.manage') @Post() create(@Body() body: unknown) { return this.projectsService.create(body); diff --git a/src/projects/projects.service.ts b/src/projects/projects.service.ts index ebc393b..7837318 100644 --- a/src/projects/projects.service.ts +++ b/src/projects/projects.service.ts @@ -1,5 +1,24 @@ -import { BadRequestException, ConflictException, Inject, Injectable, InternalServerErrorException } from '@nestjs/common'; +import { + BadRequestException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { Pool } from 'pg'; +import { + bodyObject, + boolean, + choice, + dateRange, + id, + ids, + integer, + optionalDate, + optionalText, + requiredText, + transaction, + Data, +} from '../common/admin-data'; import { PG_POOL } from '../shared/connections/database.module'; @Injectable() @@ -43,104 +62,144 @@ export class ProjectsService { return { data: rows }; } - async create(rawBody: unknown) { - const body = this.validateCreateBody(rawBody); - const client = await this.pool.connect(); - try { - await client.query('BEGIN'); - const { rows } = await client.query( - `INSERT INTO public.proyectos - (nombre, descripcion_breve, descripcion, estado, fecha_inicio, fecha_fin, - publicado, destacado, orden) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) - RETURNING id;`, - [body.nombre, body.descripcionBreve, body.descripcion, body.estado, - body.fechaInicio, body.fechaFin, body.publicado, body.destacado, body.orden], - ); - const projectId = rows[0].id; - - for (const member of body.members) { - await client.query( - `INSERT INTO public.proyecto_miembros - (proyecto_id, miembro_id, funcion, fecha_inicio, fecha_fin) - VALUES ($1,$2,$3,$4,$5);`, - [projectId, member.memberId, member.functionName, member.startDate, member.endDate], - ); - } - - await client.query('COMMIT'); - return { message: 'Proyecto creado correctamente', id: projectId }; - } catch (error: any) { - await client.query('ROLLBACK'); - if (error?.code === '23505') throw new ConflictException('El proyecto o uno de sus miembros ya existe'); - if (['23503', '23514', '22P02', '22007'].includes(error?.code)) throw new BadRequestException('Los datos del proyecto no son válidos'); - throw new InternalServerErrorException('No fue posible crear el proyecto'); - } finally { - client.release(); - } - } - - private validateCreateBody(rawBody: unknown) { - if (!rawBody || typeof rawBody !== 'object') throw new BadRequestException('El cuerpo es obligatorio'); - const body = rawBody as Record; - const estado = body.estado ?? 'planificacion'; - const validStates = ['planificacion', 'activo', 'bloqueado', 'pausado', 'completado', 'cancelado']; - if (typeof estado !== 'string' || !validStates.includes(estado)) throw new BadRequestException('estado no es válido'); - const publicado = body.publicado ?? false; - const destacado = body.destacado ?? false; - if (typeof publicado !== 'boolean' || typeof destacado !== 'boolean' || (destacado && !publicado)) { - throw new BadRequestException('La configuración de publicación no es válida'); - } - const orden = body.orden ?? 0; - if (!Number.isSafeInteger(orden)) throw new BadRequestException('orden debe ser un entero'); - const members = body.members ?? []; - if (!Array.isArray(members)) throw new BadRequestException('members debe ser un arreglo'); - const parsedMembers = members.map((rawMember) => this.parseMember(rawMember)); - const memberIds = parsedMembers.map((member) => member.memberId); - if (new Set(memberIds).size !== memberIds.length) throw new BadRequestException('Un miembro no puede repetirse en el proyecto'); - - return { - nombre: this.requiredText(body.nombre, 'nombre'), - descripcionBreve: this.optionalText(body.descripcionBreve, 'descripcionBreve'), - descripcion: this.optionalText(body.descripcion, 'descripcion'), - estado, - fechaInicio: this.optionalDate(body.fechaInicio, 'fechaInicio'), - fechaFin: this.optionalDate(body.fechaFin, 'fechaFin'), - publicado, destacado, orden, - members: parsedMembers, - }; - } - - private parseMember(rawMember: unknown) { - if (!rawMember || typeof rawMember !== 'object') throw new BadRequestException('Cada miembro del proyecto debe ser un objeto'); - const member = rawMember as Record; - const memberId = Number(member.memberId); - if (!Number.isSafeInteger(memberId) || memberId < 1) throw new BadRequestException('memberId no es válido'); - return { - memberId, - functionName: this.optionalText(member.functionName, 'functionName'), - startDate: this.optionalDate(member.startDate, 'startDate'), - endDate: this.optionalDate(member.endDate, 'endDate'), - }; + async findAdmin() { + const { rows } = await this.pool + .query(`SELECT p.id, p.nombre, p.descripcion_breve, p.descripcion, + p.estado, to_char(p.fecha_inicio,'YYYY-MM-DD') AS fecha_inicio, to_char(p.fecha_fin,'YYYY-MM-DD') AS fecha_fin, + p.publicado, p.destacado, p.orden, + COALESCE((SELECT json_agg(json_build_object('id',m.id::text,'nombre',m.nombre,'funcion',pm.funcion, + 'fecha_inicio',to_char(pm.fecha_inicio,'YYYY-MM-DD'),'fecha_fin',to_char(pm.fecha_fin,'YYYY-MM-DD')) ORDER BY m.nombre,m.id) + FROM public.proyecto_miembros pm JOIN public.miembros m ON m.id=pm.miembro_id WHERE pm.proyecto_id=p.id),'[]') AS miembros + FROM public.proyectos p ORDER BY p.orden,p.created_at DESC,p.id DESC`); + return { data: rows }; } - - private requiredText(value: unknown, field: string): string { - if (typeof value !== 'string' || !value.trim()) throw new BadRequestException(`${field} es obligatorio`); - return value.trim(); + create(body: unknown) { + return this.save(body); } - - private optionalText(value: unknown, field: string): string | null { - if (value === undefined || value === null || value === '') return null; - if (typeof value !== 'string') throw new BadRequestException(`${field} debe ser texto`); - return value.trim() || null; + update(projectId: string, body: unknown) { + return this.save(body, id(projectId)); } - - private optionalDate(value: unknown, field: string): string | null { - if (value === undefined || value === null || value === '') return null; - if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) { - throw new BadRequestException(`${field} debe usar el formato YYYY-MM-DD`); - } - return value; + private save(raw: unknown, projectId?: string) { + const body = bodyObject( + raw, + [ + 'nombre', + 'descripcionBreve', + 'descripcion', + 'estado', + 'fechaInicio', + 'fechaFin', + 'publicado', + 'destacado', + 'orden', + 'members', + ], + !!projectId, + ); + return transaction(this.pool, async (client) => { + let previous: Data = { + descripcionBreve: null, + descripcion: null, + estado: 'planificacion', + fechaInicio: null, + fechaFin: null, + publicado: false, + destacado: false, + orden: 0, + }; + if (projectId) { + const result = await client.query( + `SELECT nombre, descripcion_breve AS "descripcionBreve", descripcion, estado, + to_char(fecha_inicio,'YYYY-MM-DD') AS "fechaInicio", to_char(fecha_fin,'YYYY-MM-DD') AS "fechaFin", publicado, destacado, orden + FROM public.proyectos WHERE id=$1 FOR UPDATE`, + [projectId], + ); + if (!result.rows.length) + throw new NotFoundException('Proyecto no encontrado'); + previous = result.rows[0]; + } + const data = { ...previous, ...body }; + const start = optionalDate(data.fechaInicio, 'fechaInicio'), + end = optionalDate(data.fechaFin, 'fechaFin'); + dateRange(start, end); + const published = boolean(data.publicado, 'publicado'), + featured = boolean(data.destacado, 'destacado'); + if (featured && !published) + throw new BadRequestException('Destacado requiere publicación'); + const values = [ + requiredText(data.nombre, 'nombre'), + optionalText(data.descripcionBreve, 'descripcionBreve'), + optionalText(data.descripcion, 'descripcion'), + choice([ + 'planificacion', + 'activo', + 'bloqueado', + 'pausado', + 'completado', + 'cancelado', + ])(data.estado, 'estado'), + start, + end, + published, + featured, + integer(data.orden, 'orden'), + ]; + if (projectId) + await client.query( + `UPDATE public.proyectos SET nombre=$1,descripcion_breve=$2,descripcion=$3,estado=$4,fecha_inicio=$5,fecha_fin=$6,publicado=$7,destacado=$8,orden=$9 WHERE id=$10`, + [...values, projectId], + ); + else { + const result = await client.query( + `INSERT INTO public.proyectos (nombre,descripcion_breve,descripcion,estado,fecha_inicio,fecha_fin,publicado,destacado,orden) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`, + values, + ); + projectId = String(result.rows[0].id); + } + if (body.members !== undefined) { + if (!Array.isArray(body.members)) + throw new BadRequestException('members debe ser un arreglo'); + const members = body.members.map((rawMember) => { + const m = bodyObject(rawMember, [ + 'memberId', + 'functionName', + 'startDate', + 'endDate', + ]); + const startDate = optionalDate(m.startDate, 'startDate'), + endDate = optionalDate(m.endDate, 'endDate'); + dateRange(startDate, endDate); + return { + memberId: id(m.memberId, 'memberId'), + functionName: optionalText(m.functionName, 'functionName'), + startDate, + endDate, + }; + }); + const memberIds = ids( + members.map((m) => m.memberId), + 'members', + ); + for (const member of members) { + await client.query( + `INSERT INTO public.proyecto_miembros (proyecto_id,miembro_id,funcion,fecha_inicio,fecha_fin) VALUES ($1,$2,$3,$4,$5) + ON CONFLICT (proyecto_id,miembro_id) DO UPDATE SET funcion=EXCLUDED.funcion,fecha_inicio=EXCLUDED.fecha_inicio,fecha_fin=EXCLUDED.fecha_fin`, + [ + projectId, + member.memberId, + member.functionName, + member.startDate, + member.endDate, + ], + ); + } + await client.query( + `DELETE FROM public.proyecto_miembros WHERE proyecto_id=$1 AND NOT (miembro_id=ANY($2::bigint[]))`, + [projectId, memberIds], + ); + } + return { id: projectId }; + }); } private parseLimit(rawLimit?: string): number { diff --git a/src/sponsors/sponsors.controller.ts b/src/sponsors/sponsors.controller.ts new file mode 100644 index 0000000..20b48ed --- /dev/null +++ b/src/sponsors/sponsors.controller.ts @@ -0,0 +1,27 @@ +import { Public, Permissions } from '../auth/iam.service'; +import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; +import { SponsorsService } from './sponsors.service'; +@Controller('sponsors') +export class SponsorsController { + constructor(private readonly sponsors: SponsorsService) {} + @Public() + @Get() + findPublic() { + return this.sponsors.findAll(); + } + @Permissions('sponsors.read') + @Get('admin') + findAdmin() { + return this.sponsors.findAll(true); + } + @Permissions('sponsors.manage') + @Post() + create(@Body() body: unknown) { + return this.sponsors.create(body); + } + @Permissions('sponsors.manage') + @Patch(':id') + update(@Param('id') id: string, @Body() body: unknown) { + return this.sponsors.update(id, body); + } +} diff --git a/src/sponsors/sponsors.module.ts b/src/sponsors/sponsors.module.ts new file mode 100644 index 0000000..905b0b5 --- /dev/null +++ b/src/sponsors/sponsors.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { SharedModule } from '../shared/shared.module'; +import { SponsorsController } from './sponsors.controller'; +import { SponsorsService } from './sponsors.service'; +@Module({ + imports: [SharedModule], + controllers: [SponsorsController], + providers: [SponsorsService], +}) +export class SponsorsModule {} diff --git a/src/sponsors/sponsors.service.ts b/src/sponsors/sponsors.service.ts new file mode 100644 index 0000000..6471ad2 --- /dev/null +++ b/src/sponsors/sponsors.service.ts @@ -0,0 +1,44 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Pool } from 'pg'; +import { PG_POOL } from '../shared/connections/database.module'; +import { + boolean, + choice, + integer, + optionalText, + requiredText, + RecordSpec, + saveRecord, +} from '../common/admin-data'; +const spec: RecordSpec = { + table: 'patrocinadores', + fields: { + nombre_patrocinador: requiredText, + descripcion_patrocinador: optionalText, + estado_patrocinador: choice(['activo', 'inactivo']), + publicado: boolean, + orden: integer, + }, + defaults: { + descripcion_patrocinador: null, + estado_patrocinador: 'activo', + publicado: false, + orden: 0, + }, +}; +@Injectable() +export class SponsorsService { + constructor(@Inject(PG_POOL) private readonly pool: Pool) {} + async findAll(admin = false) { + const { rows } = await this.pool.query( + `SELECT id,nombre_patrocinador,descripcion_patrocinador,estado_patrocinador,publicado,orden FROM public.patrocinadores ${admin ? '' : "WHERE publicado=true AND estado_patrocinador='activo'"} ORDER BY orden,id`, + ); + return { data: rows }; + } + create(body: unknown) { + return saveRecord(this.pool, spec, body); + } + update(id: string, body: unknown) { + return saveRecord(this.pool, spec, body, id); + } +} diff --git a/src/workflows/workflows.controller.ts b/src/workflows/workflows.controller.ts new file mode 100644 index 0000000..f4925f8 --- /dev/null +++ b/src/workflows/workflows.controller.ts @@ -0,0 +1,108 @@ +import { + Body, + Controller, + Get, + Headers, + Param, + Patch, + Post, + Query, + Req, +} from '@nestjs/common'; +import { AuthRequest, Permissions, Public } from '../auth/iam.service'; +import { WorkflowsService } from './workflows.service'; + +@Controller('announcements') +export class AnnouncementsController { + constructor(private readonly service: WorkflowsService) {} + @Permissions('announcements.read_all') @Get('admin') admin( + @Query('limit') limit?: string, + @Query('offset') offset?: string, + ) { + return this.service.announcements(true, limit, offset); + } + @Public() + @Get() + list( + @Query('limit') limit?: string, + @Query('offset') offset?: string, + ) { + return this.service.announcements(false, limit, offset); + } + @Permissions('announcements.create') @Post() create( + @Req() req: AuthRequest, + @Body() body: unknown, + ) { + return this.service.saveAnnouncement(req.actor, body); + } + @Permissions() @Patch(':id') edit( + @Req() req: AuthRequest, + @Param('id') id: string, + @Body() body: unknown, + @Headers('if-match') version?: string, + ) { + return this.service.saveAnnouncement(req.actor, body, id, version); + } + @Permissions('announcements.publish') @Patch(':id/publication') publish( + @Req() req: AuthRequest, + @Param('id') id: string, + @Body() body: unknown, + @Headers('if-match') version?: string, + ) { + return this.service.saveAnnouncement(req.actor, body, id, version, true); + } +} + +@Controller('applications') +export class ReviewController { + constructor(private readonly service: WorkflowsService) {} + @Permissions() + @Get('voting-results') + overview(@Query('offset') offset?: string) { + return this.service.resultsOverview(offset); + } + @Permissions('votes.read_own') @Get(':id/my-vote') own( + @Req() req: AuthRequest, + @Param('id') id: string, + ) { + return this.service.myVote(req.actor, id); + } + @Permissions('votes.write_own') @Post(':id/my-vote') vote( + @Req() req: AuthRequest, + @Param('id') id: string, + @Body() body: unknown, + ) { + return this.service.vote(req.actor, id, body, false); + } + @Permissions('votes.write_own') @Patch(':id/my-vote') editVote( + @Req() req: AuthRequest, + @Param('id') id: string, + @Body() body: unknown, + @Headers('if-match') version?: string, + ) { + return this.service.vote(req.actor, id, body, true, version); + } + @Permissions() + @Get(':id/votes') + results(@Param('id') id: string) { + return this.service.results(id); + } + @Permissions('applications.resolve') @Post(':id/resolution') resolve( + @Req() req: AuthRequest, + @Param('id') id: string, + @Body() body: unknown, + @Headers('idempotency-key') key?: string, + ) { + return this.service.resolve(req.actor, id, body, key); + } + @Permissions('applications.convert', 'members.manage') + @Post(':id/member-conversion') + convert( + @Req() req: AuthRequest, + @Param('id') id: string, + @Body() body: unknown, + @Headers('idempotency-key') key?: string, + ) { + return this.service.convert(req.actor, id, body, key); + } +} diff --git a/src/workflows/workflows.module.ts b/src/workflows/workflows.module.ts new file mode 100644 index 0000000..8778ab8 --- /dev/null +++ b/src/workflows/workflows.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from '../shared/connections/database.module'; +import { WorkflowsService } from './workflows.service'; +import { + AnnouncementsController, + ReviewController, +} from './workflows.controller'; +@Module({ + imports: [DatabaseModule], + providers: [WorkflowsService], + controllers: [AnnouncementsController, ReviewController], +}) +export class WorkflowsModule {} diff --git a/src/workflows/workflows.service.ts b/src/workflows/workflows.service.ts new file mode 100644 index 0000000..1c1c934 --- /dev/null +++ b/src/workflows/workflows.service.ts @@ -0,0 +1,488 @@ +import { + BadRequestException, + ConflictException, + HttpException, + Inject, + Injectable, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; +import { createHash, randomUUID } from 'node:crypto'; +import { Pool, PoolClient } from 'pg'; +import { PG_POOL } from '../shared/connections/database.module'; +import { Actor, IamService } from '../auth/iam.service'; +import { + bodyObject, + boolean, + choice, + date, + dateRange, + id, + optionalDate, + optionalText, + requiredText, + transaction, +} from '../common/admin-data'; + +const versionColumn = + "to_char(updated_at AT TIME ZONE 'UTC','YYYY-MM-DD\"T\"HH24:MI:SS.US') AS version"; +function match(actual: string, supplied?: string) { + if (!supplied) + throw new HttpException( + 'Falta la versión del registro; vuelve a cargarlo', + 428, + ); + if (supplied !== actual) + throw new HttpException( + 'El registro cambió; vuelve a cargarlo antes de editar', + 412, + ); +} +function key(value?: string) { + if ( + !value || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + value, + ) + ) + throw new BadRequestException('Idempotency-Key debe ser UUID'); + return value.toLowerCase(); +} +const fingerprint = (data: object) => + createHash('sha256').update(JSON.stringify(data)).digest(); + +@Injectable() +export class WorkflowsService { + constructor( + @Inject(PG_POOL) private readonly pool: Pool, + private readonly iam: IamService, + ) {} + private async ready(client: PoolClient, conversion = false) { + const { rows } = await client.query( + "SELECT to_regclass('public.business_audit_events') IS NOT NULL AND ($1::boolean=false OR to_regclass('public.postulacion_miembro_conversiones') IS NOT NULL) AS ready", + [conversion], + ); + if (!rows[0]?.ready) + throw new ServiceUnavailableException( + 'Falta aplicar 005_rafael_auditoria_conversiones.sql', + ); + } + private async audit( + client: PoolClient, + actor: Actor, + action: string, + resource: string, + result: object, + detail: object = {}, + idem?: string, + hash?: Buffer, + ) { + await client.query( + `INSERT INTO public.business_audit_events(actor_iam_subject,actor_miembro_id,action,resource_type,resource_id,request_id,idempotency_key,request_fingerprint,result,detail) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, + [ + actor.userId, + actor.memberId, + action, + action.startsWith('announcement') ? 'anuncio' : 'postulacion', + resource, + randomUUID(), + idem || null, + hash || null, + result, + detail, + ], + ); + } + async announcements(admin: boolean, rawLimit = '30', rawOffset = '0') { + if ( + !/^\d+$/.test(rawLimit) || + !/^\d+$/.test(rawOffset) || + +rawLimit < 1 || + +rawLimit > 100 || + !Number.isSafeInteger(+rawOffset) || + +rawOffset > 2147483647 + ) + throw new BadRequestException('Paginación inválida'); + const { rows } = await this.pool.query( + `SELECT a.id::text,a.titulo,a.contenido,a.autor_id::text,m.nombre AS autor_nombre,to_char(a.fecha_inicio,'YYYY-MM-DD') AS fecha_inicio,to_char(a.fecha_fin,'YYYY-MM-DD') AS fecha_fin,a.publicado,to_char(a.updated_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS.US') AS version FROM public.anuncios a JOIN public.miembros m ON m.id=a.autor_id WHERE ($1::boolean OR (a.publicado AND a.fecha_inicio<=(clock_timestamp() AT TIME ZONE 'America/Santiago')::date AND (a.fecha_fin IS NULL OR a.fecha_fin>=(clock_timestamp() AT TIME ZONE 'America/Santiago')::date))) ORDER BY a.fecha_inicio DESC,a.id DESC LIMIT $2 OFFSET $3`, + [admin, +rawLimit + 1, +rawOffset], + ); + return { data: rows.slice(0, +rawLimit), hasMore: rows.length > +rawLimit }; + } + saveAnnouncement( + actor: Actor, + raw: unknown, + rawId?: string, + version?: string, + publication = false, + ) { + const announcementId = rawId ? id(rawId) : undefined; + const body = bodyObject( + raw, + publication + ? ['publicado'] + : ['titulo', 'contenido', 'fecha_inicio', 'fecha_fin'], + !!announcementId, + ); + return transaction(this.pool, async (client) => { + await this.ready(client); + let previous: any = { fecha_fin: null, publicado: false }; + const permissions = announcementId ? [] : ['announcements.create']; + if (announcementId) { + const result = await client.query( + `SELECT id,autor_id::text,titulo,contenido,to_char(fecha_inicio,'YYYY-MM-DD') AS fecha_inicio,to_char(fecha_fin,'YYYY-MM-DD') AS fecha_fin,publicado,${versionColumn} FROM public.anuncios WHERE id=$1 FOR UPDATE`, + [announcementId], + ); + previous = result.rows[0]; + if (!previous) throw new NotFoundException('Anuncio no encontrado'); + permissions.push( + previous.autor_id === actor.memberId + ? 'announcements.edit_own' + : 'announcements.edit_any', + ); + if (publication || previous.publicado) + permissions.push('announcements.publish'); + this.iam.require(actor, ...permissions); + match(previous.version, version); + } + const merged = { ...previous, ...body }; + const title = requiredText(merged.titulo, 'titulo'); + if ([...title].length > 200) + throw new BadRequestException('Título demasiado largo'); + const content = requiredText(merged.contenido, 'contenido'); + const start = date(merged.fecha_inicio, 'fecha_inicio'); + const end = optionalDate(merged.fecha_fin, 'fecha_fin'); + dateRange(start, end); + const published = publication + ? boolean(body.publicado, 'publicado') + : previous.publicado; + await this.iam.revalidate(client, actor, ...permissions); + const result = announcementId + ? await client.query( + 'UPDATE public.anuncios SET titulo=$1,contenido=$2,fecha_inicio=$3,fecha_fin=$4,publicado=$5 WHERE id=$6 RETURNING id::text', + [title, content, start, end, published, announcementId], + ) + : await client.query( + 'INSERT INTO public.anuncios(titulo,contenido,fecha_inicio,fecha_fin,autor_id) VALUES($1,$2,$3,$4,$5) RETURNING id::text', + [title, content, start, end, actor.memberId], + ); + const saved = { id: result.rows[0].id }; + await this.audit( + client, + actor, + publication + ? 'announcement.publication' + : announcementId + ? 'announcement.updated' + : 'announcement.created', + saved.id, + saved, + ); + return saved; + }); + } + private async lockApplication(client: PoolClient, applicationId: string) { + await client.query('SELECT pg_advisory_xact_lock(74102931)'); + const lookup = await client.query( + 'SELECT periodo_id::text FROM public.postulaciones WHERE id=$1', + [applicationId], + ); + if (!lookup.rows[0]) + throw new NotFoundException('Postulación no encontrada'); + const period = await client.query( + 'SELECT * FROM public.periodos_postulacion WHERE id=$1 FOR UPDATE', + [lookup.rows[0].periodo_id], + ); + const application = await client.query( + 'SELECT id::text,estado_postulacion FROM public.postulaciones WHERE id=$1 FOR UPDATE', + [applicationId], + ); + return { period: period.rows[0], application: application.rows[0] }; + } + async myVote(actor: Actor, rawId: string) { + const applicationId = id(rawId); + const exists = await this.pool.query( + 'SELECT id FROM public.postulaciones WHERE id=$1', + [applicationId], + ); + if (!exists.rowCount) + throw new NotFoundException('Postulación no encontrada'); + const result = await this.pool.query( + `SELECT id::text,voto,comentario,${versionColumn} FROM public.postulacion_votos WHERE postulacion_id=$1 AND miembro_id=$2`, + [applicationId, actor.memberId], + ); + return { data: result.rows[0] || null }; + } + vote( + actor: Actor, + rawId: string, + raw: unknown, + patch: boolean, + version?: string, + ) { + const applicationId = id(rawId), + body = bodyObject(raw, ['voto', 'comentario'], patch); + return transaction(this.pool, async (client) => { + await this.ready(client); + const { application, period } = await this.lockApplication( + client, + applicationId, + ); + if ( + application.estado_postulacion !== 'pendiente' || + period.estado_periodo === 'cancelado' + ) + throw new ConflictException('La postulación no admite votos'); + const found = await client.query( + `SELECT id::text,voto,comentario,${versionColumn} FROM public.postulacion_votos WHERE postulacion_id=$1 AND miembro_id=$2 FOR UPDATE`, + [applicationId, actor.memberId], + ); + const previous = found.rows[0]; + if (patch && !previous) + throw new NotFoundException('Todavía no has votado'); + if (!patch && previous) + throw new ConflictException('Ya emitiste un voto'); + if (patch) match(previous.version, version); + const merged = { comentario: null, ...previous, ...body }; + const vote = choice(['a_favor', 'en_contra'])(merged.voto, 'voto'); + const comment = optionalText(merged.comentario, 'comentario'); + await this.iam.revalidate(client, actor, 'votes.write_own'); + const window = await client.query( + 'SELECT clock_timestamp()>=$1::timestamptz AND clock_timestamp()<$2::timestamptz AS open', + [period.fecha_apertura, period.fecha_cierre_votacion], + ); + if (!window.rows[0].open) + throw new ConflictException('Fuera del plazo de votación'); + const saved = patch + ? await client.query( + 'UPDATE public.postulacion_votos SET voto=$1,comentario=$2 WHERE id=$3 RETURNING id::text', + [vote, comment, previous.id], + ) + : await client.query( + 'INSERT INTO public.postulacion_votos(postulacion_id,miembro_id,voto,comentario) VALUES($1,$2,$3,$4) RETURNING id::text', + [applicationId, actor.memberId, vote, comment], + ); + const result = { id: saved.rows[0].id }; + await this.audit( + client, + actor, + patch ? 'vote.updated' : 'vote.created', + applicationId, + result, + ); + return result; + }); + } + async resultsOverview(rawOffset = '0') { + if ( + !/^\d+$/.test(rawOffset) || + !Number.isSafeInteger(+rawOffset) || + +rawOffset > 2147483647 + ) + throw new BadRequestException('Paginación inválida'); + const { rows } = await this.pool.query( + `SELECT p.id::text,p.nombre_completo,p.estado_postulacion,periodo.nombre_periodo FROM public.postulaciones p JOIN public.periodos_postulacion periodo ON periodo.id=p.periodo_id ORDER BY p.created_at DESC,p.id DESC LIMIT 31 OFFSET $1`, + [+rawOffset], + ); + return { data: rows.slice(0, 30), hasMore: rows.length > 30 }; + } + async results(rawId: string) { + const applicationId = id(rawId); + const exists = await this.pool.query( + 'SELECT id FROM public.postulaciones WHERE id=$1', + [applicationId], + ); + if (!exists.rowCount) + throw new NotFoundException('Postulación no encontrada'); + const { rows } = await this.pool.query( + 'SELECT v.id::text,v.miembro_id::text,m.nombre,v.voto,v.comentario,v.updated_at FROM public.postulacion_votos v JOIN public.miembros m ON m.id=v.miembro_id WHERE v.postulacion_id=$1 ORDER BY m.nombre,v.id', + [applicationId], + ); + return { + data: rows, + a_favor: rows.filter((v) => v.voto === 'a_favor').length, + en_contra: rows.filter((v) => v.voto === 'en_contra').length, + }; + } + resolve(actor: Actor, rawId: string, raw: unknown, rawKey?: string) { + const applicationId = id(rawId), + idem = key(rawKey), + body = bodyObject(raw, ['decision', 'motivo']); + const decision = choice(['aceptada', 'rechazada'])( + body.decision, + 'decision', + ), + reason = requiredText(body.motivo, 'motivo'); + const hash = fingerprint({ decision, reason }); + return transaction(this.pool, async (client) => { + await this.ready(client); + const { application, period } = await this.lockApplication( + client, + applicationId, + ); + await this.iam.revalidate(client, actor, 'applications.resolve'); + const replay = await client.query( + "SELECT request_fingerprint,result FROM public.business_audit_events WHERE actor_iam_subject=$1 AND action='application.resolved' AND resource_type='postulacion' AND resource_id=$2 AND idempotency_key=$3", + [actor.userId, applicationId, idem], + ); + if (replay.rows[0]) { + if (!replay.rows[0].request_fingerprint.equals(hash)) + throw new ConflictException('La clave ya se usó con otros datos'); + return replay.rows[0].result; + } + if ( + application.estado_postulacion !== 'pendiente' || + period.estado_periodo === 'cancelado' + ) + throw new ConflictException( + 'La postulación ya está resuelta o no admite resolución', + ); + const time = await client.query( + 'SELECT clock_timestamp()>=$1::timestamptz AS open', + [period.fecha_apertura], + ); + if (!time.rows[0].open) + throw new ConflictException('El período aún no comienza'); + await client.query( + 'UPDATE public.postulaciones SET estado_postulacion=$1,resuelta_en=clock_timestamp(),resuelta_por=$2 WHERE id=$3', + [decision, actor.memberId, applicationId], + ); + const result = { id: applicationId, estado_postulacion: decision }; + await this.audit( + client, + actor, + 'application.resolved', + applicationId, + result, + { motivo: reason }, + idem, + hash, + ); + return result; + }); + } + convert(actor: Actor, rawId: string, raw: unknown, rawKey?: string) { + const applicationId = id(rawId), + idem = key(rawKey), + body = bodyObject(raw, [ + 'mode', + 'memberId', + 'nombre', + 'correoInstitucional', + 'carrera', + 'anioIngresoCarrera', + 'confirmado', + ]); + const mode = choice(['crear', 'asociar_existente'])(body.mode, 'mode'); + if (body.confirmado !== true) + throw new BadRequestException('Debes confirmar la revisión de identidad'); + let data: any; + if (mode === 'asociar_existente') { + if ( + Object.keys(body).some( + (k) => !['mode', 'memberId', 'confirmado'].includes(k), + ) + ) + throw new BadRequestException('Datos incompatibles con asociación'); + data = { mode, memberId: id(body.memberId) }; + } else { + if ('memberId' in body) + throw new BadRequestException('No enviar memberId al crear'); + const email = requiredText( + body.correoInstitucional, + 'correoInstitucional', + ).toLowerCase(); + if (!/^[^\s@]+@utem\.cl$/.test(email)) + throw new BadRequestException('Correo institucional inválido'); + const year = body.anioIngresoCarrera ?? null; + if ( + year !== null && + (typeof year !== 'number' || + !Number.isInteger(year) || + year < 1900 || + year > 2100) + ) + throw new BadRequestException('Año inválido'); + data = { + mode, + nombre: requiredText(body.nombre, 'nombre'), + correoInstitucional: email, + carrera: requiredText(body.carrera, 'carrera'), + anioIngresoCarrera: year, + }; + } + const hash = fingerprint(data); + return transaction(this.pool, async (client) => { + await this.ready(client, true); + const { application } = await this.lockApplication(client, applicationId); + await this.iam.revalidate( + client, + actor, + 'applications.convert', + 'members.manage', + ); + const replay = await client.query( + 'SELECT miembro_id::text,request_fingerprint FROM public.postulacion_miembro_conversiones WHERE postulacion_id=$1', + [applicationId], + ); + if (replay.rows[0]) { + if (!replay.rows[0].request_fingerprint.equals(hash)) + throw new ConflictException( + 'Esta postulación ya fue convertida con otros datos', + ); + return { id: replay.rows[0].miembro_id }; + } + if (application.estado_postulacion !== 'aceptada') + throw new ConflictException( + 'Solo puedes convertir postulaciones aceptadas', + ); + let memberId: string; + if (mode === 'asociar_existente') { + const member = await client.query( + "SELECT id::text FROM public.miembros WHERE id=$1 AND estado='activo' FOR SHARE", + [data.memberId], + ); + if (!member.rows[0]) + throw new ConflictException( + 'Miembro no disponible; no se reactiva automáticamente', + ); + memberId = member.rows[0].id; + } else { + const member = await client.query( + "INSERT INTO public.miembros(nombre,correo_institucional,carrera,anio_ingreso_carrera,estado,perfil_publico,foto_publica) VALUES($1,$2,$3,$4,'activo',false,false) RETURNING id::text", + [ + data.nombre, + data.correoInstitucional, + data.carrera, + data.anioIngresoCarrera, + ], + ); + memberId = member.rows[0].id; + } + await client.query( + 'INSERT INTO public.postulacion_miembro_conversiones(postulacion_id,miembro_id,converted_by,actor_iam_subject,mode,idempotency_key,request_fingerprint) VALUES($1,$2,$3,$4,$5,$6,$7)', + [ + applicationId, + memberId, + actor.memberId, + actor.userId, + mode, + idem, + hash, + ], + ); + const result = { id: memberId }; + await this.audit( + client, + actor, + 'application.converted', + applicationId, + result, + {}, + idem, + hash, + ); + return result; + }); + } +} diff --git a/src/workflows/workflows.spec.ts b/src/workflows/workflows.spec.ts new file mode 100644 index 0000000..af41f48 --- /dev/null +++ b/src/workflows/workflows.spec.ts @@ -0,0 +1,142 @@ +import { WorkflowsService } from './workflows.service'; +const actor = { + userId: '1236638c-55ab-441b-8e06-49d185573c35', + memberId: '1', + permissions: [], + token: 'a'.repeat(43), +}; +const idem = 'd7c13b56-0618-4219-a37d-83ae1727d7de'; +describe('Business workflow protection', () => { + function setup(state = 'pendiente', open = true, existingVote: any = null) { + const query = jest.fn(async (sql: string) => { + if (sql.includes('to_regclass')) return { rows: [{ ready: true }] }; + if (sql.startsWith('SELECT periodo_id')) + return { rows: [{ periodo_id: '1' }] }; + if (sql.startsWith('SELECT * FROM public.periodos')) + return { + rows: [ + { + estado_periodo: 'habilitado', + fecha_apertura: '2026-01-01', + fecha_cierre_votacion: '2026-12-01', + }, + ], + }; + if (sql.startsWith('SELECT id::text,estado')) + return { rows: [{ id: '2', estado_postulacion: state }] }; + if (sql.includes('SELECT clock_timestamp()')) return { rows: [{ open }] }; + if ( + sql.includes('FROM public.postulacion_votos') && + sql.includes('FOR UPDATE') + ) + return { rows: existingVote ? [existingVote] : [] }; + if (sql.startsWith('INSERT INTO public.postulacion_votos')) + return { rows: [{ id: '90' }] }; + return { rows: [], rowCount: 0 }; + }); + const client = { query, release: jest.fn() }, + pool = { connect: async () => client, query }; + const iam = { + revalidate: jest.fn().mockResolvedValue(undefined), + require: jest.fn(), + }; + return { + service: new WorkflowsService(pool as any, iam as any), + query, + iam, + }; + } + it.each(['autor_id', 'miembro_id', 'resuelta_por'])( + 'rejects supplied actor %s', + (field) => { + const s = setup(); + expect(() => + s.service.saveAnnouncement(actor, { [field]: '2' }), + ).toThrow(); + expect(() => + s.service.vote(actor, '2', { voto: 'a_favor', [field]: '2' }, false), + ).toThrow(); + expect(() => + s.service.resolve( + actor, + '2', + { decision: 'aceptada', motivo: 'x', [field]: '2' }, + idem, + ), + ).toThrow(); + }, + ); + it('does not write a vote on a resolved application', async () => { + const s = setup('aceptada'); + await expect( + s.service.vote(actor, '2', { voto: 'a_favor' }, false), + ).rejects.toThrow('no admite votos'); + expect( + s.query.mock.calls.some(([sql]) => + sql.startsWith('INSERT INTO public.postulacion_votos'), + ), + ).toBe(false); + }); + it('checks voting deadline after actor revalidation', async () => { + const s = setup('pendiente', false); + await expect( + s.service.vote(actor, '2', { voto: 'a_favor' }, false), + ).rejects.toThrow('Fuera del plazo'); + expect(s.iam.revalidate).toHaveBeenCalled(); + }); + it('writes actor-owned vote and audit in same transaction', async () => { + const s = setup(); + await expect( + s.service.vote(actor, '2', { voto: 'a_favor' }, false), + ).resolves.toEqual({ id: '90' }); + expect( + s.query.mock.calls.some(([sql]) => + sql.startsWith('INSERT INTO public.business_audit_events'), + ), + ).toBe(true); + expect(s.query.mock.calls.at(-1)?.[0]).toBe('COMMIT'); + }); + it('requires current version when editing', async () => { + const s = setup('pendiente', true, { + id: '90', + version: 'current', + voto: 'a_favor', + }); + await expect( + s.service.vote(actor, '2', { voto: 'en_contra' }, true, 'old'), + ).rejects.toThrow('registro cambió'); + }); + it('allows resolution during reception, independent of voting close', async () => { + const s = setup(); + await expect( + s.service.resolve( + actor, + '2', + { decision: 'aceptada', motivo: 'Revisión' }, + idem, + ), + ).resolves.toMatchObject({ estado_postulacion: 'aceptada' }); + }); + it('rejects a second final resolution', async () => { + const s = setup('rechazada'); + await expect( + s.service.resolve( + actor, + '2', + { decision: 'aceptada', motivo: 'Cambio' }, + idem, + ), + ).rejects.toThrow('ya está resuelta'); + }); + it('requires identity review for conversion', () => { + const s = setup(); + expect(() => + s.service.convert( + actor, + '2', + { mode: 'asociar_existente', memberId: '3' }, + idem, + ), + ).toThrow('confirmar'); + }); +});