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
3 changes: 3 additions & 0 deletions .husky/post-checkout
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/bin/sh
command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-checkout' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; }
git lfs post-checkout "$@"
Comment on lines +1 to +3
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Load Husky shim before running the hook

Each Husky hook must source husky.sh; otherwise the standard skip logic (HUSKY=0, HUSKY_SKIP_HOOKS), ENV tweaks, and debug features never run. That means this script will execute even in CI environments that intentionally disable hooks, causing avoidable failures when git-lfs isn’t installed. Please restore the husky shim (same applies to the sibling hooks in this PR).

 #!/bin/sh
+. "$(dirname -- "$0")/_/husky.sh"
 command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-checkout' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; }
 git lfs post-checkout "$@"
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#!/bin/sh
command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-checkout' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; }
git lfs post-checkout "$@"
#!/bin/sh
. "$(dirname -- "$0")/_/husky.sh"
command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-checkout' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; }
git lfs post-checkout "$@"
πŸ€– Prompt for AI Agents
In .husky/post-checkout lines 1-3, the hook runs git-lfs directly without
sourcing the husky shim so Husky skip logic and env handling are bypassed; fix
by sourcing the Husky shim immediately after the shebang (e.g. add a line to
source the husky.sh in the repo's .husky/_/husky.sh using dirname "$0" to locate
it) before any other commands, then leave the existing git-lfs detection and git
lfs post-checkout invocation unchanged so the hook respects
HUSKY/HUSKY_SKIP_HOOKS and CI behavior.

3 changes: 3 additions & 0 deletions .husky/post-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/bin/sh
command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-commit' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; }
git lfs post-commit "$@"
Comment on lines +1 to +3
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Load Husky shim before running the hook

The Husky shim is absent here as well, so Husky’s native skip/disable features won’t work and the hook will always run. Add the shim first thing in the script to re-enable expected behavior.

 #!/bin/sh
+. "$(dirname -- "$0")/_/husky.sh"
 command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-commit' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; }
 git lfs post-commit "$@"
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#!/bin/sh
command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-commit' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; }
git lfs post-commit "$@"
#!/bin/sh
. "$(dirname -- "$0")/_/husky.sh"
command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-commit' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; }
git lfs post-commit "$@"
πŸ€– Prompt for AI Agents
In .husky/post-commit around lines 1 to 3, the script never loads the Husky shim
so Husky skip/disable features won't work; fix by sourcing the Husky shim as the
first action in the hook (e.g. source or dot the .husky/shim file if it exists),
then keep the existing git-lfs presence check and git lfs post-commit call
unchanged; ensure the shim sourcing is conditional if the shim file may be
missing and that the script remains executable.

3 changes: 3 additions & 0 deletions .husky/post-merge
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/bin/sh
command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-merge' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; }
git lfs post-merge "$@"
Comment on lines +1 to +3
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Load Husky shim before running the hook

Same issue here: without sourcing husky.sh, Husky’s opt-out/skip mechanisms are bypassed and the hook still fires in contexts that expect it to be silent. Please add the shim before any logic so the environment is set up correctly.

 #!/bin/sh
+. "$(dirname -- "$0")/_/husky.sh"
 command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-merge' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; }
 git lfs post-merge "$@"
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#!/bin/sh
command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-merge' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; }
git lfs post-merge "$@"
#!/bin/sh
. "$(dirname -- "$0")/_/husky.sh"
command -v git-lfs >/dev/null 2>&1 || { \
printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-merge' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; \
exit 2; \
}
git lfs post-merge "$@"
πŸ€– Prompt for AI Agents
In .husky/post-merge lines 1-3, the hook runs Git LFS logic without sourcing the
Husky shim, so Husky's opt-out/skip mechanisms are bypassed; fix this by
sourcing the Husky shim at the very top of the file (before any logic) from the
repository's husky helper (usually .husky/_/husky.sh) so the environment and
skip flags are initialized, then retain the existing shebang and Git LFS checks
below.

3 changes: 3 additions & 0 deletions .husky/pre-push
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/bin/sh
command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'pre-push' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; }
git lfs pre-push "$@"
Comment on lines +1 to +3
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Load Husky shim before running the hook

Please source husky.sh so that Husky’s own skip logic is honored; otherwise the hook can’t be disabled in CI or ad-hoc scenarios, leading to unexpected exits when git-lfs isn’t present.

 #!/bin/sh
+. "$(dirname -- "$0")/_/husky.sh"
 command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'pre-push' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; }
 git lfs pre-push "$@"
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#!/bin/sh
command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'pre-push' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; }
git lfs pre-push "$@"
#!/bin/sh
. "$(dirname -- "$0")/_/husky.sh"
command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'pre-push' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; }
git lfs pre-push "$@"
πŸ€– Prompt for AI Agents
In .husky/pre-push around lines 1 to 3, the script runs git-lfs checks directly
and therefore bypasses Husky’s skip/disable logic; modify the script to source
the Husky shim (husky.sh) at the top before any checks so Husky’s SKIP_HOOKS and
CI handling are honored, then proceed with the existing git-lfs existence check
and git lfs pre-push invocation.

8 changes: 7 additions & 1 deletion src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { UsersModule } from './modules/users/users.module';
import { NotificationsModule } from './modules/notifications/notifications.module';
import { OrdersModule } from './modules/orders/orders.module';
import { BuyerRequestsModule } from './modules/buyer-requests/buyer-requests.module';
import { OffersModule } from './modules/offers/offers.module';
import { OffersModule } from './src/modules/offers/offers.module';
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | πŸ”΄ Critical

Fix the OffersModule import path

src/app.module.ts already lives under src/, so importing OffersModule via './src/modules/offers/offers.module' points to a non-existent src/src/... directory. Nest will fail to compile with Cannot find module './src/modules/offers/offers.module'. Please revert to the original relative path.

-import { OffersModule } from './src/modules/offers/offers.module';
+import { OffersModule } from './modules/offers/offers.module';
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { OffersModule } from './src/modules/offers/offers.module';
import { OffersModule } from './modules/offers/offers.module';
πŸ€– Prompt for AI Agents
In src/app.module.ts around line 18, the import uses
'./src/modules/offers/offers.module' which resolves to src/src/... and fails;
change the import to the correct relative path (e.g.
'./modules/offers/offers.module') so it points to the actual OffersModule file
under src/modules.

import { SupabaseModule } from './modules/supabase/supabase.module';

// Entities
Expand All @@ -36,6 +36,9 @@ import { CouponUsage } from './modules/coupons/entities/coupon-usage.entity';
import { BuyerRequest } from './modules/buyer-requests/entities/buyer-request.entity';
import { Offer } from './modules/offers/entities/offer.entity';
import { OfferAttachment } from './modules/offers/entities/offer-attachment.entity';
import { Escrow } from './modules/escrows/entities/escrow.entity';
import { Milestone } from './modules/escrows/entities/milestone.entity';
import { EscrowsModule } from './modules/escrows/escrows.module';

@Module({
imports: [
Expand Down Expand Up @@ -63,6 +66,8 @@ import { OfferAttachment } from './modules/offers/entities/offer-attachment.enti
BuyerRequest,
Offer,
OfferAttachment,
Escrow,
Milestone,
],
synchronize: process.env.NODE_ENV !== 'production',
logging: process.env.NODE_ENV === 'development',
Expand All @@ -81,6 +86,7 @@ import { OfferAttachment } from './modules/offers/entities/offer-attachment.enti
BuyerRequestsModule,
OffersModule,
SupabaseModule,
EscrowsModule,
],
})
export class AppModule {}
62 changes: 62 additions & 0 deletions src/modules/escrows/controllers/escrow.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Controller, Patch, Param, UseGuards, Request, Body, Get, Query, Post } from '@nestjs/common';
import { EscrowService } from '../services/escrow.service';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../../auth/guards/roles.guard';
import { Roles } from '../../auth/decorators/roles.decorator';
import { Role } from '@/types/role';
import { AuthRequest } from '@/modules/wishlist/common/types/auth-request.type';
import { UpdateMilestoneStatusDto } from '../dto/update-milestone-status.dto';
import { MilestoneStatus } from '../entities/milestone.entity';
import { GetEscrowsQueryDto } from '../dto/get-escrows-query.dto';
import { MultipleEscrowBalancesDto } from '../dto/multiple-balances.dto';

@Controller('escrows')
export class EscrowController {
constructor(private readonly escrowService: EscrowService) {}

@Patch(':escrowId/milestones/:milestoneId/approve')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.BUYER)
approve(
@Param('escrowId') escrowId: string,
@Param('milestoneId') milestoneId: string,
@Request() req: AuthRequest
) {
return this.escrowService.approveMilestone(escrowId, milestoneId, Number(req.user.id));
}

@Patch(':escrowId/milestones/:milestoneId/status')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.SELLER)
changeStatus(
@Param('escrowId') escrowId: string,
@Param('milestoneId') milestoneId: string,
@Body() body: UpdateMilestoneStatusDto,
@Request() req: AuthRequest
) {
return this.escrowService.changeMilestoneStatus(
escrowId,
milestoneId,
Number(req.user.id),
body.status as MilestoneStatus
);
}

// GET /escrows?role=buyer|seller (defaults to both roles for signer)
@Get()
@UseGuards(JwtAuthGuard)
list(@Query() query: GetEscrowsQueryDto, @Request() req: AuthRequest) {
const userId = Number(req.user.id);
if (query.role) {
return this.escrowService.getEscrowsByRole(userId, query.role);
}
return this.escrowService.getEscrowsBySigner(userId);
}

// POST /escrows/balances { ids: [...] }
@Post('balances')
@UseGuards(JwtAuthGuard)
balances(@Body() body: MultipleEscrowBalancesDto) {
return this.escrowService.getMultipleEscrowBalances(body.ids);
}
}
7 changes: 7 additions & 0 deletions src/modules/escrows/dto/approve-milestone.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { IsOptional, IsString } from 'class-validator';

export class ApproveMilestoneDto {
@IsOptional()
@IsString()
type?: string; // placeholder if future variations required
}
7 changes: 7 additions & 0 deletions src/modules/escrows/dto/get-escrows-query.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { IsOptional, IsIn } from 'class-validator';

export class GetEscrowsQueryDto {
@IsOptional()
@IsIn(['buyer', 'seller'])
role?: 'buyer' | 'seller';
}
8 changes: 8 additions & 0 deletions src/modules/escrows/dto/multiple-balances.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { ArrayNotEmpty, IsArray, IsUUID } from 'class-validator';

export class MultipleEscrowBalancesDto {
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
ids: string[];
}
8 changes: 8 additions & 0 deletions src/modules/escrows/dto/update-milestone-status.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { IsEnum } from 'class-validator';
import { MilestoneStatus } from '../entities/milestone.entity';

// Seller-changeable statuses (not including APPROVED which is buyer action)
export class UpdateMilestoneStatusDto {
@IsEnum(MilestoneStatus, { message: 'Invalid milestone status' })
status: MilestoneStatus; // Expect READY | IN_PROGRESS | DELIVERED
}
53 changes: 53 additions & 0 deletions src/modules/escrows/entities/escrow.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany, CreateDateColumn, UpdateDateColumn, JoinColumn, Check } from 'typeorm';
import { Offer } from '../../offers/entities/offer.entity';
import { User } from '../../users/entities/user.entity';
import { Milestone } from './milestone.entity';

export enum EscrowStatus {
PENDING = 'pending', // Has milestones not yet approved
IN_PROGRESS = 'in_progress', // At least one milestone approved but not all
COMPLETED = 'completed', // All milestones approved
}

@Entity('escrows')
@Check('"totalAmount" >= 0')
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | πŸ”΄ Critical

Fix the CHECK constraint column reference.

The CHECK clause targets "totalAmount", but the column is persisted as total_amount. Postgres will fail the migration (column "totalAmount" does not exist), so the table won’t be created. Update the constraint to reference the actual column name.

-@Check('"totalAmount" >= 0')
+@Check('"total_amount" >= 0')
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Check('"totalAmount" >= 0')
@Check('"total_amount" >= 0')
πŸ€– Prompt for AI Agents
In src/modules/escrows/entities/escrow.entity.ts around line 13, the CHECK
constraint references "totalAmount" but the actual DB column is total_amount;
update the CHECK clause to reference total_amount (e.g., CHECK("total_amount" >=
0) or without quotes depending on naming) so Postgres can find the column and
the migration will succeed.

export class Escrow {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column({ type: 'uuid', name: 'offer_id' })
offerId: string;

@ManyToOne(() => Offer, { nullable: false })
@JoinColumn({ name: 'offer_id' })
offer: Offer;

@Column({ name: 'buyer_id' })
buyerId: number;

@ManyToOne(() => User, { nullable: false })
@JoinColumn({ name: 'buyer_id' })
buyer: User;

@Column({ name: 'seller_id' })
sellerId: number;

@ManyToOne(() => User, { nullable: false })
@JoinColumn({ name: 'seller_id' })
seller: User;

@Column({ type: 'decimal', precision: 12, scale: 2, name: 'total_amount' })
totalAmount: number;

@Column({ type: 'enum', enum: EscrowStatus, default: EscrowStatus.PENDING })
status: EscrowStatus;

@OneToMany(() => Milestone, (m) => m.escrow, { cascade: true })
milestones: Milestone[];

@CreateDateColumn({ name: 'created_at' })
createdAt: Date;

@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
48 changes: 48 additions & 0 deletions src/modules/escrows/entities/milestone.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, CreateDateColumn, UpdateDateColumn, JoinColumn, Check } from 'typeorm';
import { Escrow } from './escrow.entity';

export enum MilestoneStatus {
PENDING = 'pending',
APPROVED = 'approved',
READY = 'ready', // Seller marked as ready to start
IN_PROGRESS = 'in_progress', // Work is in progress
DELIVERED = 'delivered', // Seller delivered work for buyer approval
}

@Entity('escrow_milestones')
@Check('"amount" >= 0')
export class Milestone {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column({ type: 'uuid', name: 'escrow_id' })
escrowId: string;

@ManyToOne(() => Escrow, (e) => e.milestones, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'escrow_id' })
escrow: Escrow;

@Column({ type: 'int' })
sequence: number; // order of milestone

@Column({ length: 120 })
title: string;

@Column({ type: 'text', nullable: true })
description?: string;

@Column({ type: 'decimal', precision: 12, scale: 2 })
amount: number;

@Column({ type: 'enum', enum: MilestoneStatus, default: MilestoneStatus.PENDING })
status: MilestoneStatus;

@Column({ type: 'timestamp', name: 'approved_at', nullable: true })
approvedAt?: Date;

@CreateDateColumn({ name: 'created_at' })
createdAt: Date;

@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
14 changes: 14 additions & 0 deletions src/modules/escrows/escrows.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Escrow } from './entities/escrow.entity';
import { Milestone } from './entities/milestone.entity';
import { EscrowService } from './services/escrow.service';
import { EscrowController } from './controllers/escrow.controller';

@Module({
imports: [TypeOrmModule.forFeature([Escrow, Milestone])],
controllers: [EscrowController],
providers: [EscrowService],
exports: [EscrowService],
})
export class EscrowsModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class ExtendMilestoneStatusEnum1752190000000 implements MigrationInterface {
name = 'ExtendMilestoneStatusEnum1752190000000';

public async up(queryRunner: QueryRunner): Promise<void> {
// Postgres enum alteration strategy: rename old type, create new, alter column, drop old
await queryRunner.query(`ALTER TYPE "public"."escrow_milestones_status_enum" RENAME TO "escrow_milestones_status_enum_old"`);
await queryRunner.query(`CREATE TYPE "public"."escrow_milestones_status_enum" AS ENUM('pending','approved','ready','in_progress','delivered')`);
await queryRunner.query(`ALTER TABLE "escrow_milestones" ALTER COLUMN "status" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "escrow_milestones" ALTER COLUMN "status" TYPE "public"."escrow_milestones_status_enum" USING "status"::text::"public"."escrow_milestones_status_enum"`);
await queryRunner.query(`ALTER TABLE "escrow_milestones" ALTER COLUMN "status" SET DEFAULT 'pending'`);
await queryRunner.query(`DROP TYPE "public"."escrow_milestones_status_enum_old"`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TYPE "public"."escrow_milestones_status_enum" RENAME TO "escrow_milestones_status_enum_old"`);
await queryRunner.query(`CREATE TYPE "public"."escrow_milestones_status_enum" AS ENUM('pending','approved')`);
await queryRunner.query(`ALTER TABLE "escrow_milestones" ALTER COLUMN "status" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "escrow_milestones" ALTER COLUMN "status" TYPE "public"."escrow_milestones_status_enum" USING "status"::text::"public"."escrow_milestones_status_enum"`);
await queryRunner.query(`ALTER TABLE "escrow_milestones" ALTER COLUMN "status" SET DEFAULT 'pending'`);
Comment on lines +17 to +21
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Ensure down migration copes with the new statuses

As soon as any row is saved with ready, in_progress, or delivered, the down migration will crash when it tries to coerce those values into the reduced enum. Please normalise the data back to the legacy domain before altering the column type.

Apply this diff inside the down method:

   public async down(queryRunner: QueryRunner): Promise<void> {
     await queryRunner.query(`ALTER TYPE "public"."escrow_milestones_status_enum" RENAME TO "escrow_milestones_status_enum_old"`);
+    await queryRunner.query(`UPDATE "escrow_milestones" SET "status" = 'pending' WHERE "status"::text NOT IN ('pending','approved')`);
     await queryRunner.query(`CREATE TYPE "public"."escrow_milestones_status_enum" AS ENUM('pending','approved')`);
     await queryRunner.query(`ALTER TABLE "escrow_milestones" ALTER COLUMN "status" DROP DEFAULT`);
     await queryRunner.query(`ALTER TABLE "escrow_milestones" ALTER COLUMN "status" TYPE "public"."escrow_milestones_status_enum" USING "status"::text::"public"."escrow_milestones_status_enum"`);
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await queryRunner.query(`ALTER TYPE "public"."escrow_milestones_status_enum" RENAME TO "escrow_milestones_status_enum_old"`);
await queryRunner.query(`CREATE TYPE "public"."escrow_milestones_status_enum" AS ENUM('pending','approved')`);
await queryRunner.query(`ALTER TABLE "escrow_milestones" ALTER COLUMN "status" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "escrow_milestones" ALTER COLUMN "status" TYPE "public"."escrow_milestones_status_enum" USING "status"::text::"public"."escrow_milestones_status_enum"`);
await queryRunner.query(`ALTER TABLE "escrow_milestones" ALTER COLUMN "status" SET DEFAULT 'pending'`);
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TYPE "public"."escrow_milestones_status_enum" RENAME TO "escrow_milestones_status_enum_old"`);
await queryRunner.query(`UPDATE "escrow_milestones" SET "status" = 'pending' WHERE "status"::text NOT IN ('pending','approved')`);
await queryRunner.query(`CREATE TYPE "public"."escrow_milestones_status_enum" AS ENUM('pending','approved')`);
await queryRunner.query(`ALTER TABLE "escrow_milestones" ALTER COLUMN "status" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "escrow_milestones" ALTER COLUMN "status" TYPE "public"."escrow_milestones_status_enum" USING "status"::text::"public"."escrow_milestones_status_enum"`);
await queryRunner.query(`ALTER TABLE "escrow_milestones" ALTER COLUMN "status" SET DEFAULT 'pending'`);
}

await queryRunner.query(`DROP TYPE "public"."escrow_milestones_status_enum_old"`);
}
}
93 changes: 93 additions & 0 deletions src/modules/escrows/services/escrow.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { Test, TestingModule } from '@nestjs/testing';
import { EscrowService } from './escrow.service';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { Escrow } from '../entities/escrow.entity';
import { Milestone, MilestoneStatus } from '../entities/milestone.entity';
import { ForbiddenException, NotFoundException, BadRequestException } from '@nestjs/common';

// Simple in-memory mocks
class MockRepo<T extends { id?: any }> {
private entities: T[] = [];
findOne = jest.fn(async (options: any) => {
if (options.where?.id) return this.entities.find(e => e.id === options.where.id) || null;
if (options.where?.id && options.relations) return this.entities.find(e => e.id === options.where.id) || null;
return null;
});
find = jest.fn(async (options: any) => this.entities.filter(e => (options.where?.escrowId ? (e as any).escrowId === options.where.escrowId : true)));
save = jest.fn(async (entity: T) => {
const existingIndex = this.entities.findIndex(e => e.id === entity.id);
if (existingIndex >= 0) this.entities[existingIndex] = entity;
else this.entities.push(entity);
return entity;
});
seed(data: T[]) { this.entities = data; }
}

const mockTransaction = (cb: any) => cb({
findOne: (entity: any, opts: any) => entity === Milestone ? milestoneRepo.findOne(opts) : escrowRepo.findOne(opts),
find: (entity: any, opts: any) => milestoneRepo.find(opts),
save: (entity: any) => Array.isArray(entity) ? Promise.all(entity.map(e => (e instanceof Milestone ? milestoneRepo.save(e) : escrowRepo.save(e)))) : (entity instanceof Milestone ? milestoneRepo.save(entity) : escrowRepo.save(entity))
});

let escrowRepo: MockRepo<Escrow>;
let milestoneRepo: MockRepo<Milestone>;

describe('EscrowService - changeMilestoneStatus', () => {
let service: EscrowService;

beforeEach(async () => {
escrowRepo = new MockRepo<Escrow>();
milestoneRepo = new MockRepo<Milestone>();

const module: TestingModule = await Test.createTestingModule({
providers: [
EscrowService,
{ provide: getRepositoryToken(Escrow), useValue: escrowRepo },
{ provide: getRepositoryToken(Milestone), useValue: milestoneRepo },
{ provide: DataSource, useValue: { transaction: jest.fn(mockTransaction) } },
],
}).compile();

service = module.get<EscrowService>(EscrowService);

// Seed data
escrowRepo.seed([{ id: 'escrow1', sellerId: 10, buyerId: 20 } as any]);
milestoneRepo.seed([
{ id: 'm1', escrowId: 'escrow1', status: MilestoneStatus.PENDING } as any,
]);
});

it('should change status from pending to ready by seller', async () => {
const result = await service.changeMilestoneStatus('escrow1', 'm1', 10, MilestoneStatus.READY);
expect(result.status).toBe(MilestoneStatus.READY);
});

it('should block non-seller from changing status', async () => {
await expect(
service.changeMilestoneStatus('escrow1', 'm1', 999, MilestoneStatus.READY)
).rejects.toBeInstanceOf(ForbiddenException);
});

it('should block backwards transition', async () => {
await service.changeMilestoneStatus('escrow1', 'm1', 10, MilestoneStatus.READY);
await service.changeMilestoneStatus('escrow1', 'm1', 10, MilestoneStatus.IN_PROGRESS);
await expect(
service.changeMilestoneStatus('escrow1', 'm1', 10, MilestoneStatus.READY)
).rejects.toBeInstanceOf(BadRequestException);
});

it('should be idempotent if same status provided', async () => {
await service.changeMilestoneStatus('escrow1', 'm1', 10, MilestoneStatus.READY);
const result = await service.changeMilestoneStatus('escrow1', 'm1', 10, MilestoneStatus.READY);
expect(result.status).toBe(MilestoneStatus.READY);
});

it('should not allow change after approval', async () => {
// Simulate approved milestone
milestoneRepo.seed([{ id: 'm1', escrowId: 'escrow1', status: MilestoneStatus.APPROVED } as any]);
await expect(
service.changeMilestoneStatus('escrow1', 'm1', 10, MilestoneStatus.DELIVERED)
).rejects.toBeInstanceOf(BadRequestException);
});
});
Loading