Implement soft-delete for client targets and balance corrections
Deleting a target or correction sets deletedAt instead of hard-deleting. Creating a target for a user+client that has a soft-deleted record reactivates it (clears deletedAt, applies new weeklyHours/startDate) rather than failing the unique constraint. All reads filter deletedAt = null on the target, its corrections, and the parent client.
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable: add deleted_at column to client_targets
|
||||||
|
ALTER TABLE "client_targets" ADD COLUMN "deleted_at" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- AlterTable: add deleted_at column to balance_corrections
|
||||||
|
ALTER TABLE "balance_corrections" ADD COLUMN "deleted_at" TIMESTAMP(3);
|
||||||
@@ -100,11 +100,12 @@ model OngoingTimer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model ClientTarget {
|
model ClientTarget {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
weeklyHours Float @map("weekly_hours")
|
weeklyHours Float @map("weekly_hours")
|
||||||
startDate DateTime @map("start_date") @db.Date // Always a Monday
|
startDate DateTime @map("start_date") @db.Date // Always a Monday
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
|
||||||
userId String @map("user_id") @db.VarChar(255)
|
userId String @map("user_id") @db.VarChar(255)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
@@ -120,12 +121,13 @@ model ClientTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model BalanceCorrection {
|
model BalanceCorrection {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
date DateTime @map("date") @db.Date
|
date DateTime @map("date") @db.Date
|
||||||
hours Float
|
hours Float
|
||||||
description String? @db.VarChar(255)
|
description String? @db.VarChar(255)
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
|
||||||
clientTargetId String @map("client_target_id")
|
clientTargetId String @map("client_target_id")
|
||||||
clientTarget ClientTarget @relation(fields: [clientTargetId], references: [id], onDelete: Cascade)
|
clientTarget ClientTarget @relation(fields: [clientTargetId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
@@ -68,10 +68,10 @@ export interface ClientTargetWithBalance {
|
|||||||
export class ClientTargetService {
|
export class ClientTargetService {
|
||||||
async findAll(userId: string): Promise<ClientTargetWithBalance[]> {
|
async findAll(userId: string): Promise<ClientTargetWithBalance[]> {
|
||||||
const targets = await prisma.clientTarget.findMany({
|
const targets = await prisma.clientTarget.findMany({
|
||||||
where: { userId, client: { deletedAt: null } },
|
where: { userId, deletedAt: null, client: { deletedAt: null } },
|
||||||
include: {
|
include: {
|
||||||
client: { select: { id: true, name: true } },
|
client: { select: { id: true, name: true } },
|
||||||
corrections: { orderBy: { date: 'asc' } },
|
corrections: { where: { deletedAt: null }, orderBy: { date: 'asc' } },
|
||||||
},
|
},
|
||||||
orderBy: { client: { name: 'asc' } },
|
orderBy: { client: { name: 'asc' } },
|
||||||
});
|
});
|
||||||
@@ -81,10 +81,10 @@ export class ClientTargetService {
|
|||||||
|
|
||||||
async findById(id: string, userId: string) {
|
async findById(id: string, userId: string) {
|
||||||
return prisma.clientTarget.findFirst({
|
return prisma.clientTarget.findFirst({
|
||||||
where: { id, userId, client: { deletedAt: null } },
|
where: { id, userId, deletedAt: null, client: { deletedAt: null } },
|
||||||
include: {
|
include: {
|
||||||
client: { select: { id: true, name: true } },
|
client: { select: { id: true, name: true } },
|
||||||
corrections: { orderBy: { date: 'asc' } },
|
corrections: { where: { deletedAt: null }, orderBy: { date: 'asc' } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -106,6 +106,18 @@ export class ClientTargetService {
|
|||||||
// Check for existing target (unique per user+client)
|
// Check for existing target (unique per user+client)
|
||||||
const existing = await prisma.clientTarget.findFirst({ where: { userId, clientId: data.clientId } });
|
const existing = await prisma.clientTarget.findFirst({ where: { userId, clientId: data.clientId } });
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
if (existing.deletedAt !== null) {
|
||||||
|
// Reactivate the soft-deleted target with the new settings
|
||||||
|
const reactivated = await prisma.clientTarget.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { deletedAt: null, weeklyHours: data.weeklyHours, startDate },
|
||||||
|
include: {
|
||||||
|
client: { select: { id: true, name: true } },
|
||||||
|
corrections: { where: { deletedAt: null }, orderBy: { date: 'asc' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.computeBalance(reactivated);
|
||||||
|
}
|
||||||
throw new BadRequestError('A target already exists for this client. Delete the existing one first or update it.');
|
throw new BadRequestError('A target already exists for this client. Delete the existing one first or update it.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +130,7 @@ export class ClientTargetService {
|
|||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
client: { select: { id: true, name: true } },
|
client: { select: { id: true, name: true } },
|
||||||
corrections: { orderBy: { date: 'asc' } },
|
corrections: { where: { deletedAt: null }, orderBy: { date: 'asc' } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -148,7 +160,7 @@ export class ClientTargetService {
|
|||||||
data: updateData,
|
data: updateData,
|
||||||
include: {
|
include: {
|
||||||
client: { select: { id: true, name: true } },
|
client: { select: { id: true, name: true } },
|
||||||
corrections: { orderBy: { date: 'asc' } },
|
corrections: { where: { deletedAt: null }, orderBy: { date: 'asc' } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -158,7 +170,10 @@ export class ClientTargetService {
|
|||||||
async delete(id: string, userId: string): Promise<void> {
|
async delete(id: string, userId: string): Promise<void> {
|
||||||
const existing = await this.findById(id, userId);
|
const existing = await this.findById(id, userId);
|
||||||
if (!existing) throw new NotFoundError('Client target not found');
|
if (!existing) throw new NotFoundError('Client target not found');
|
||||||
await prisma.clientTarget.delete({ where: { id } });
|
await prisma.clientTarget.update({
|
||||||
|
where: { id },
|
||||||
|
data: { deletedAt: new Date() },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async addCorrection(targetId: string, userId: string, data: CreateCorrectionInput) {
|
async addCorrection(targetId: string, userId: string, data: CreateCorrectionInput) {
|
||||||
@@ -188,11 +203,14 @@ export class ClientTargetService {
|
|||||||
if (!target) throw new NotFoundError('Client target not found');
|
if (!target) throw new NotFoundError('Client target not found');
|
||||||
|
|
||||||
const correction = await prisma.balanceCorrection.findFirst({
|
const correction = await prisma.balanceCorrection.findFirst({
|
||||||
where: { id: correctionId, clientTargetId: targetId },
|
where: { id: correctionId, clientTargetId: targetId, deletedAt: null },
|
||||||
});
|
});
|
||||||
if (!correction) throw new NotFoundError('Correction not found');
|
if (!correction) throw new NotFoundError('Correction not found');
|
||||||
|
|
||||||
await prisma.balanceCorrection.delete({ where: { id: correctionId } });
|
await prisma.balanceCorrection.update({
|
||||||
|
where: { id: correctionId },
|
||||||
|
data: { deletedAt: new Date() },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async computeBalance(target: {
|
private async computeBalance(target: {
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ export interface BalanceCorrection {
|
|||||||
hours: number;
|
hours: number;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WeekBalance {
|
export interface WeekBalance {
|
||||||
|
|||||||
Reference in New Issue
Block a user