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
12 changes: 12 additions & 0 deletions src/modules/vouching/dto/vouch.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export enum VouchStatus {
APPROVED = 'approved',
REVOKED = 'revoked',
EXPIRED = 'expired',
DECLINED = 'declined',
}

const STELLAR_ADDRESS_REGEX = /^G[A-Z2-7]{55}$/;
Expand Down Expand Up @@ -37,6 +38,17 @@ export class ApproveVouchDto {
learnerWallet: string;
}

export class DeclineVouchDto {
@ApiProperty({
example: 'GLEARNER1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890AB',
description: 'Learner Stellar wallet address (G... 56 chars)',
})
@IsString()
@Length(56, 56)
@Matches(STELLAR_ADDRESS_REGEX, { message: 'learnerWallet must be a valid Stellar address (G...)' })
learnerWallet: string;
}

export class VouchResponseDto {
@ApiProperty() id: string;
@ApiProperty() mentorWallet: string;
Expand Down
15 changes: 15 additions & 0 deletions src/modules/vouching/vouching.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
import { VouchingService } from './vouching.service';
import {
ApproveVouchDto,
DeclineVouchDto,
RequestVouchDto,
VouchResponseDto,
VouchRequestItemDto,
Expand Down Expand Up @@ -61,6 +62,20 @@ export class VouchingController {
return this.vouchingService.approveVouch(user.wallet, dto);
}

@Post('decline')
@UseGuards(RolesGuard)
@Roles('mentor')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Mentor declines a pending vouch request' })
@ApiResponse({ status: 200, description: 'Vouch declined', type: VouchResponseDto })
@ApiResponse({ status: 404, description: 'No pending vouch found' })
async declineVouch(
@CurrentUser() user: { wallet: string },
@Body() dto: DeclineVouchDto,
): Promise<VouchResponseDto> {
return this.vouchingService.declineVouch(user.wallet, dto);
}

@Get('mine')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Get the current learner's vouches" })
Expand Down
50 changes: 50 additions & 0 deletions src/modules/vouching/vouching.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { SupabaseService } from '../../database/supabase.client';
import {
ApproveVouchDto,
DeclineVouchDto,
RequestVouchDto,
VouchResponseDto,
VouchRequestItemDto,
Expand Down Expand Up @@ -134,6 +135,55 @@ export class VouchingService {
return this.mapToDto(data as VouchRow);
}

async declineVouch(
mentorWallet: string,
dto: DeclineVouchDto,
): Promise<VouchResponseDto> {
const client = this.supabaseService.getServiceRoleClient();

const { data: pending, error: findError } = await client
.from('vouches')
.select('*')
.eq('mentor_wallet', mentorWallet)
.eq('learner_wallet', dto.learnerWallet)
.eq('status', VouchStatus.PENDING)
.maybeSingle();

if (findError) {
this.logger.error(`Failed to find pending vouch: ${findError.message}`);
throw new Error('Failed to find pending vouch.');
}

if (!pending) {
throw new NotFoundException({
code: 'VOUCH_NOT_FOUND',
message: 'No pending vouch request found for this learner.',
});
}

const pendingRow = pending as VouchRow;

const { data, error } = await client
.from('vouches')
.update({
status: VouchStatus.DECLINED,
updated_at: new Date().toISOString(),
})
.eq('id', pendingRow.id)
.select()
.single();

if (error || !data) {
this.logger.error(`Failed to decline vouch ${pendingRow.id}: ${error?.message}`);
throw new Error('Failed to decline vouch.');
}

this.logger.log(
`Vouch declined: id=${pendingRow.id} mentor=${mentorWallet} learner=${dto.learnerWallet}`,
);
return this.mapToDto(data as VouchRow);
}

async getMyVouches(wallet: string): Promise<VouchResponseDto[]> {
const client = this.supabaseService.getClient();

Expand Down
26 changes: 26 additions & 0 deletions test/unit/modules/vouching/vouching.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,30 @@ describe('VouchingService', () => {
await expect(service.revokeVouch(otherMentor, vouchId)).rejects.toThrow(ForbiddenException);
});
});

describe('declineVouch', () => {
const learnerWallet = 'GLEARNER89ABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLM';
const pendingRow = { ...vouchRow, status: VouchStatus.PENDING };

it('declines a pending vouch request for the mentor', async () => {
mockSupabaseClient.from
.mockReturnValueOnce(chain({ data: pendingRow, error: null })) // pending lookup
.mockReturnValueOnce(
chain({ data: { ...pendingRow, status: VouchStatus.DECLINED }, error: null }),
);

const result = await service.declineVouch(mentorWallet, { learnerWallet });

expect(result.status).toBe(VouchStatus.DECLINED);
expect(result.id).toBe(vouchId);
});

it('throws NotFoundException when there is no pending vouch for this learner', async () => {
mockSupabaseClient.from.mockReturnValueOnce(chain({ data: null, error: null }));

await expect(
service.declineVouch(mentorWallet, { learnerWallet }),
).rejects.toThrow(NotFoundException);
});
});
});