Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions apps/api/src/auth/auth.dto.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { plainToInstance } from 'class-transformer';
import { validate, ValidatorOptions } from 'class-validator';
import { RegisterDto } from './dto/auth.dto';

const validatorOptions: ValidatorOptions = {
whitelist: true,
forbidNonWhitelisted: true,
};

describe('RegisterDto', () => {
it('rechaza email vacío / inválido', async () => {
const dto = plainToInstance(RegisterDto, {
email: 'no-es-un-email',
password: '12345678',
perspectiva: 'usuario',
});
const errors = await validate(dto, validatorOptions);
const props = errors.map((e) => e.property);
expect(props).toContain('email');
});

it('rechaza password menor a 8 caracteres', async () => {
const dto = plainToInstance(RegisterDto, {
email: 'user@velar.cr',
password: '1234',
perspectiva: 'usuario',
});
const errors = await validate(dto, validatorOptions);
const props = errors.map((e) => e.property);
expect(props).toContain('password');
});

it('rechaza perspectiva inválida', async () => {
const dto = plainToInstance(RegisterDto, {
email: 'user@velar.cr',
password: '12345678',
perspectiva: 'admin',
});
const errors = await validate(dto, validatorOptions);
const props = errors.map((e) => e.property);
expect(props).toContain('perspectiva');
});

it('rechaza campos desconocidos con forbidNonWhitelisted', async () => {
const dto = plainToInstance(RegisterDto, {
email: 'user@velar.cr',
password: '12345678',
perspectiva: 'usuario',
isAdmin: true,
});
const errors = await validate(dto, validatorOptions);
const props = errors.map((e) => e.property);
expect(props).toContain('isAdmin');
});

it('acepta payload válido — usuario', async () => {
const dto = plainToInstance(RegisterDto, {
email: 'comprador@velar.cr',
password: 'Velar12345!',
perspectiva: 'usuario',
nombres: 'Juan',
apellidos: 'Pérez',
});
const errors = await validate(dto, validatorOptions);
expect(errors).toHaveLength(0);
});

it('acepta payload válido — partido', async () => {
const dto = plainToInstance(RegisterDto, {
email: 'partido@velar.cr',
password: 'Velar12345!',
perspectiva: 'partido',
nombrePartido: 'Partido Velar',
codigo: 'PV',
});
const errors = await validate(dto, validatorOptions);
expect(errors).toHaveLength(0);
});
});
114 changes: 79 additions & 35 deletions apps/api/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { Injectable, BadRequestException, Logger, UnauthorizedException } from '@nestjs/common';
import {
Injectable,
BadRequestException,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { SupabaseService } from '../common/supabase/supabase.service';
import { WalletService } from '../escrow/wallet.service';
import { RegisterDto, LoginDto } from './dto/auth.dto';
import type { LoginRequest, RegisterRequest } from '@velar/types';

export type Perspectiva = RegisterRequest['perspectiva'];
Expand All @@ -25,18 +31,16 @@ export class AuthService {
private wallets: WalletService,
) {}

async login(input: LoginInput) {
if (!input.email || !input.password) {
throw new BadRequestException('email y password son obligatorios');
}

async login(input: LoginDto) {
const { data, error } = await this.supabase.admin.auth.signInWithPassword({
email: input.email,
password: input.password,
});

if (error || !data.session) {
throw new UnauthorizedException(error?.message ?? 'Credenciales inválidas');
throw new UnauthorizedException(
error?.message ?? 'Credenciales inválidas',
);
}

return {
Expand All @@ -48,10 +52,7 @@ export class AuthService {
};
}

async register(input: RegisterInput) {
if (!input.email || !input.password) {
throw new BadRequestException('email y password son obligatorios');
}
async register(input: RegisterDto) {
const db = this.supabase.admin;

// 1) Crear usuario de auth (confirmado, sin email de verificación para la demo).
Expand All @@ -62,7 +63,9 @@ export class AuthService {
user_metadata: { full_name: this.fullName(input) },
});
if (cErr || !created?.user) {
throw new BadRequestException(cErr?.message ?? 'No se pudo crear la cuenta');
throw new BadRequestException(
cErr?.message ?? 'No se pudo crear la cuenta',
);
}
const userId = created.user.id;

Expand All @@ -75,18 +78,31 @@ export class AuthService {
throw new BadRequestException('El partido requiere nombre y código');
}
const full = {
code: input.codigo, name: input.nombrePartido,
code: input.codigo,
name: input.nombrePartido,
representante_legal: input.representanteLegal ?? null,
cedula_juridica: input.cedulaJuridica ?? null,
};
let { data: party, error: pErr } = await db
.from('parties').upsert(full, { onConflict: 'code' }).select().single();
.from('parties')
.upsert(full, { onConflict: 'code' })
.select()
.single();
if (pErr && /column|schema cache/i.test(pErr.message)) {
({ data: party, error: pErr } = await db
.from('parties').upsert({ code: input.codigo, name: input.nombrePartido }, { onConflict: 'code' }).select().single());
.from('parties')
.upsert(
{ code: input.codigo, name: input.nombrePartido },
{ onConflict: 'code' },
)
.select()
.single());
}
if (pErr) throw new BadRequestException(pErr.message);
const partyRow = party as { id: string; stellar_wallet?: string | null };
const partyRow = party as {
id: string;
stellar_wallet?: string | null;
};
partyId = partyRow.id;
partyWallet = partyRow.stellar_wallet ?? null;
}
Expand All @@ -96,10 +112,14 @@ export class AuthService {
let walletStatus: string | null = partyWallet ? 'funded' : null;
let walletNetwork: string | null = partyWallet ? 'testnet' : null;
let walletError: string | null = null;
let walletCreatedAt: string | null = partyWallet ? new Date().toISOString() : null;
let walletCreatedAt: string | null = partyWallet
? new Date().toISOString()
: null;
if (!wallet) {
try {
const createdWallet = await this.wallets.createWalletRecord(input.email);
const createdWallet = await this.wallets.createWalletRecord(
input.email,
);
wallet = createdWallet.publicKey;
walletStatus = createdWallet.status;
walletNetwork = createdWallet.network;
Expand All @@ -113,19 +133,28 @@ export class AuthService {
}
if (partyId && wallet && !partyWallet) {
try {
await db.from('parties').update({
stellar_wallet: wallet,
stellar_wallet_status: walletStatus ?? 'created',
stellar_network: walletNetwork ?? 'testnet',
stellar_created_at: walletCreatedAt,
stellar_wallet_error: walletError,
}).eq('id', partyId);
await db
.from('parties')
.update({
stellar_wallet: wallet,
stellar_wallet_status: walletStatus ?? 'created',
stellar_network: walletNetwork ?? 'testnet',
stellar_created_at: walletCreatedAt,
stellar_wallet_error: walletError,
})
.eq('id', partyId);
} catch {
// Older schemas do not have party wallet metadata yet.
}
}

// 4) Completar el profile (lo creó el trigger handle_new_user) con la info.
const role =
input.perspectiva === 'partido'
? 'emisor'
: input.perspectiva === 'tse'
? 'tse'
: 'comprador';
const role = input.perspectiva === 'partido' ? 'emisor' : 'comprador';
const core = {
role,
Expand All @@ -144,28 +173,43 @@ export class AuthService {
telefono: input.telefono ?? null,
direccion: input.direccion ?? null,
};
let { error: uErr } = await db.from('profiles').update({ ...core, ...extra }).eq('id', userId);
let { error: uErr } = await db
.from('profiles')
.update({ ...core, ...extra })
.eq('id', userId);
if (uErr && /column|schema cache/i.test(uErr.message)) {
// La migración de campos de registro aún no se aplicó: guardamos lo básico.
this.logger.warn('Campos de registro no existen aún (aplicá la migración). Guardo lo básico.');
({ error: uErr } = await db.from('profiles').update({
role,
full_name: this.fullName(input),
party_id: partyId,
stellar_wallet: wallet,
}).eq('id', userId));
this.logger.warn(
'Campos de registro no existen aún (aplicá la migración). Guardo lo básico.',
);
({ error: uErr } = await db
.from('profiles')
.update({
role,
full_name: this.fullName(input),
party_id: partyId,
stellar_wallet: wallet,
})
.eq('id', userId));
}
if (uErr) throw new BadRequestException(uErr.message);

return { id: userId, email: input.email, role, perspectiva: input.perspectiva, partyId, wallet };
return {
id: userId,
email: input.email,
role,
perspectiva: input.perspectiva,
partyId,
wallet,
};
} catch (e) {
// Rollback: si algo falló luego de crear el auth user, lo borramos.
await db.auth.admin.deleteUser(userId).catch(() => undefined);
throw e;
}
}

private fullName(i: RegisterInput): string {
private fullName(i: RegisterDto): string {
if (i.perspectiva === 'partido') return i.nombrePartido ?? i.email;
return [i.nombres, i.apellidos].filter(Boolean).join(' ') || i.email;
}
Expand Down
24 changes: 23 additions & 1 deletion apps/api/src/auth/dto/auth.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,29 @@ import {
IsString,
MinLength,
} from 'class-validator';
import type { LoginInput, Perspectiva, RegisterInput } from '../auth.service';

export type Perspectiva = 'usuario' | 'partido' | 'tse';

interface RegisterInput {
email: string;
password: string;
perspectiva: Perspectiva;
nombres?: string;
apellidos?: string;
identificacion?: string;
telefono?: string;
direccion?: string;
// Partido
nombrePartido?: string;
codigo?: string;
representanteLegal?: string;
cedulaJuridica?: string;
}

interface LoginInput {
email: string;
password: string;
}

export class LoginDto implements LoginInput {
@ApiProperty({ example: 'comprador@velar.cr' })
Expand Down
Loading