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
2 changes: 2 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,11 @@ enum EventType {
ADOPTION_COMPLETED
CUSTODY_STARTED
CUSTODY_RETURNED
CUSTODY_VIOLATION
ESCROW_CREATED
ESCROW_FUNDED
ESCROW_RELEASED
ESCROW_REFUNDED
TRUST_SCORE_UPDATED
}

Expand Down
53 changes: 53 additions & 0 deletions src/custody/custody.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
UseGuards,
HttpCode,
HttpStatus,
Param,
} from '@nestjs/common';
import {
ApiTags,
Expand Down Expand Up @@ -79,4 +80,56 @@ export class CustodyController {
): Promise<CustodyResponseDto> {
return this.custodyService.createCustody(user.userId, createCustodyDto);
}

@Post(':id/return')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@ApiOperation({
summary: 'Return custody agreement',
description:
'End custody agreement successfully. Releases escrow and updates trust score positively',
})
@ApiResponse({
status: 200,
description: 'Custody successfully returned',
type: CustodyResponseDto,
})
@ApiResponse({
status: 400,
description: 'Bad Request - Custody not active',
})
@ApiResponse({
status: 404,
description: 'Not Found - Custody does not exist',
})
async returnCustody(@Param('id') custodyId: string): Promise<CustodyResponseDto> {
return this.custodyService.returnCustody(custodyId);
}

@Post(':id/violation')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@ApiOperation({
summary: 'Mark custody as violation',
description:
'Report custody violation. Refunds escrow and penalizes trust score',
})
@ApiResponse({
status: 200,
description: 'Custody marked as violation',
type: CustodyResponseDto,
})
@ApiResponse({
status: 400,
description: 'Bad Request - Custody not active',
})
@ApiResponse({
status: 404,
description: 'Not Found - Custody does not exist',
})
async violationCustody(@Param('id') custodyId: string): Promise<CustodyResponseDto> {
return this.custodyService.violationCustody(custodyId);
}
}
3 changes: 2 additions & 1 deletion src/custody/custody.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import { CustodyController } from './custody.controller';
import { PrismaModule } from '../prisma/prisma.module';
import { EventsModule } from '../events/events.module';
import { EscrowModule } from '../escrow/escrow.module';
import { UsersModule } from '../users/users.module';

@Module({
imports: [PrismaModule, EventsModule, EscrowModule],
imports: [PrismaModule, EventsModule, EscrowModule, UsersModule],
controllers: [CustodyController],
providers: [CustodyService],
exports: [CustodyService],
Expand Down
18 changes: 18 additions & 0 deletions src/custody/custody.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { CustodyService } from './custody.service';
import { PrismaService } from '../prisma/prisma.service';
import { EventsService } from '../events/events.service';
import { EscrowService } from '../escrow/escrow.service';
import { UsersService } from '../users/users.service';
import { NotificationQueueService } from '../jobs/services/notification-queue.service';
import { CreateCustodyDto } from './dto/create-custody.dto';

describe('CustodyService', () => {
Expand Down Expand Up @@ -34,6 +36,14 @@ describe('CustodyService', () => {
createEscrow: jest.fn(),
};

const mockUsersService = {
updateTrustScore: jest.fn(),
};

const mockNotificationQueueService = {
addJob: jest.fn(),
};

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
Expand All @@ -50,6 +60,14 @@ describe('CustodyService', () => {
provide: EscrowService,
useValue: mockEscrowService,
},
{
provide: UsersService,
useValue: mockUsersService,
},
{
provide: NotificationQueueService,
useValue: mockNotificationQueueService,
},
],
}).compile();

Expand Down
90 changes: 90 additions & 0 deletions src/custody/custody.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import { PrismaService } from '../prisma/prisma.service';
import { EventsService } from '../events/events.service';
import { EscrowService } from '../escrow/escrow.service';
import { UsersService } from '../users/users.service';
import { CreateCustodyDto } from './dto/create-custody.dto';
import { CustodyResponseDto } from './dto/custody-response.dto';
import { CustodyStatus } from '@prisma/client';
Expand All @@ -18,6 +19,7 @@ export class CustodyService {
private readonly prisma: PrismaService,
private readonly eventsService: EventsService,
private readonly escrowService: EscrowService,
private readonly usersService: UsersService,
@Optional()
private readonly notificationQueueService?: NotificationQueueService,
) {}
Expand Down Expand Up @@ -180,4 +182,92 @@ export class CustodyService {

return custody as CustodyResponseDto;
}

async returnCustody(custodyId: string): Promise<CustodyResponseDto> {
return this.prisma.$transaction(async (tx) => {
const custody = await tx.custody.findUnique({
where: { id: custodyId },
include: { holder: true, pet: true },
});

if (!custody) {
throw new NotFoundException(`Custody with id ${custodyId} not found`);
}

if (custody.status !== CustodyStatus.ACTIVE) {
throw new BadRequestException(
`Custody must be ACTIVE to return (current status: ${custody.status})`,
);
}

const updatedCustody = await tx.custody.update({
where: { id: custodyId },
data: { status: CustodyStatus.RETURNED },
include: { holder: true, pet: true },
});

await this.eventsService.logEvent({
entityType: 'CUSTODY',
entityId: custodyId,
eventType: 'CUSTODY_RETURNED',
actorId: custody.holderId,
payload: {
petId: custody.petId,
holderId: custody.holderId,
},
});

if (custody.escrowId) {
await this.escrowService.releaseEscrow(custody.escrowId);
}

await this.usersService.updateTrustScore(custody.holderId, 5);

return updatedCustody as CustodyResponseDto;
});
}

async violationCustody(custodyId: string): Promise<CustodyResponseDto> {
return this.prisma.$transaction(async (tx) => {
const custody = await tx.custody.findUnique({
where: { id: custodyId },
include: { holder: true, pet: true },
});

if (!custody) {
throw new NotFoundException(`Custody with id ${custodyId} not found`);
}

if (custody.status !== CustodyStatus.ACTIVE) {
throw new BadRequestException(
`Custody must be ACTIVE to mark as violation (current status: ${custody.status})`,
);
}

const updatedCustody = await tx.custody.update({
where: { id: custodyId },
data: { status: CustodyStatus.VIOLATION },
include: { holder: true, pet: true },
});

await this.eventsService.logEvent({
entityType: 'CUSTODY',
entityId: custodyId,
eventType: 'CUSTODY_VIOLATION',
actorId: custody.holderId,
payload: {
petId: custody.petId,
holderId: custody.holderId,
},
});

if (custody.escrowId) {
await this.escrowService.refundEscrow(custody.escrowId);
}

await this.usersService.updateTrustScore(custody.holderId, -15);

return updatedCustody as CustodyResponseDto;
});
}
}
30 changes: 30 additions & 0 deletions src/escrow/escrow.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,34 @@ export class EscrowService {
return updatedEscrow;
});
}

async refundEscrow(escrowId: string, txHash?: string) {
return this.prisma.$transaction(async (tx) => {
const escrow = await tx.escrow.findUnique({
where: { id: escrowId },
});

if (!escrow) {
throw new NotFoundException('Escrow not found');
}

const updatedEscrow = await tx.escrow.update({
where: { id: escrowId },
data: {
status: EscrowStatus.REFUNDED,
refundTxHash: txHash,
},
});

await this.events.logEvent({
entityType: EventEntityType.ESCROW,
entityId: escrowId,
eventType: EventType.ESCROW_REFUNDED,
txHash,
payload: { amount: Number(escrow.amount) },
});

return updatedEscrow;
});
}
}
4 changes: 3 additions & 1 deletion src/users/users.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { PrismaModule } from '../prisma/prisma.module';
import { CloudinaryModule } from '../cloudinary/cloudinary.module';
import { EventsModule } from '../events/events.module';

@Module({
imports: [PrismaModule, CloudinaryModule],
imports: [PrismaModule, CloudinaryModule, EventsModule],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
41 changes: 40 additions & 1 deletion src/users/users.service.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { EventsService } from '../events/events.service';
import { UpdateUserDto } from './dto/update-user.dto';

@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly eventsService: EventsService,
) {}

async getProfile(id: string) {
const user = await this.prisma.user.findUnique({
Expand Down Expand Up @@ -59,4 +63,39 @@ export class UsersService {
},
});
}

async updateTrustScore(userId: string, delta: number) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true, trustScore: true },
});

if (!user) {
throw new NotFoundException('User not found');
}

const newScore = Math.max(0, Math.min(100, user.trustScore + delta));

const updatedUser = await this.prisma.user.update({
where: { id: userId },
data: { trustScore: newScore },
select: {
id: true,
trustScore: true,
},
});

await this.eventsService.logEvent({
entityType: 'USER',
entityId: userId,
eventType: 'TRUST_SCORE_UPDATED',
payload: {
previousScore: user.trustScore,
newScore,
delta,
},
});

return updatedUser;
}
}