5 Commits

Author SHA1 Message Date
7dd3873148 Merge branch 'main' into feature/soft-delete 2026-02-23 17:59:29 +01:00
simon.franken
ddb0926dba 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.
2026-02-23 15:48:07 +01:00
simon.franken
1b0f5866a1 Restore onDelete: Cascade on Project->Client and TimeEntry->Project
Direct database deletes should still cascade to avoid orphaned records.
The migration now only adds the three deleted_at columns without touching
the existing FK constraints.
2026-02-23 15:32:31 +01:00
simon.franken
159022ef38 Exclude client targets for soft-deleted clients
findAll and findById filter on client.deletedAt = null so targets
belonging to a soft-deleted client are invisible. The create guard
also rejects soft-deleted clients. The raw SQL balance query now
excludes soft-deleted time entries and projects from tracked totals.
2026-02-23 15:24:58 +01:00
simon.franken
1a7d13d5b9 Implement soft-delete for clients, projects, and time entries
Replace hard deletes with deletedAt timestamp flags on all three entities.
Deleting a client or project only sets its own deletedAt; child records are
excluded implicitly by filtering on parent deletedAt in every read query.
Raw SQL statistics queries also filter out soft-deleted parents.
FK ON DELETE CASCADE removed from Project→Client and TimeEntry→Project.
2026-02-23 15:21:13 +01:00
8 changed files with 132 additions and 59 deletions

View File

@@ -0,0 +1,8 @@
-- AlterTable: add deleted_at column to clients
ALTER TABLE "clients" ADD COLUMN "deleted_at" TIMESTAMP(3);
-- AlterTable: add deleted_at column to projects
ALTER TABLE "projects" ADD COLUMN "deleted_at" TIMESTAMP(3);
-- AlterTable: add deleted_at column to time_entries
ALTER TABLE "time_entries" ADD COLUMN "deleted_at" TIMESTAMP(3);

View File

@@ -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);

View File

@@ -30,6 +30,7 @@ model Client {
description String? @db.Text description String? @db.Text
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)
@@ -47,6 +48,7 @@ model Project {
color String? @db.VarChar(7) // Hex color code color String? @db.VarChar(7) // Hex color code
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)
@@ -69,6 +71,7 @@ model TimeEntry {
description String? @db.Text description String? @db.Text
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)
@@ -102,6 +105,7 @@ model ClientTarget {
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)
@@ -123,6 +127,7 @@ model BalanceCorrection {
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)

View File

@@ -5,14 +5,14 @@ import type { CreateClientInput, UpdateClientInput } from "../types";
export class ClientService { export class ClientService {
async findAll(userId: string) { async findAll(userId: string) {
return prisma.client.findMany({ return prisma.client.findMany({
where: { userId }, where: { userId, deletedAt: null },
orderBy: { name: "asc" }, orderBy: { name: "asc" },
}); });
} }
async findById(id: string, userId: string) { async findById(id: string, userId: string) {
return prisma.client.findFirst({ return prisma.client.findFirst({
where: { id, userId }, where: { id, userId, deletedAt: null },
}); });
} }
@@ -43,8 +43,9 @@ export class ClientService {
throw new NotFoundError("Client not found"); throw new NotFoundError("Client not found");
} }
await prisma.client.delete({ await prisma.client.update({
where: { id }, where: { id },
data: { deletedAt: new Date() },
}); });
} }
} }

View File

@@ -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 }, 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 }, 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' } },
}, },
}); });
} }
@@ -97,8 +97,8 @@ export class ClientTargetService {
throw new BadRequestError('startDate must be a Monday'); throw new BadRequestError('startDate must be a Monday');
} }
// Ensure the client belongs to this user // Ensure the client belongs to this user and is not soft-deleted
const client = await prisma.client.findFirst({ where: { id: data.clientId, userId } }); const client = await prisma.client.findFirst({ where: { id: data.clientId, userId, deletedAt: null } });
if (!client) { if (!client) {
throw new NotFoundError('Client not found'); throw new NotFoundError('Client not found');
} }
@@ -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: {
@@ -229,6 +247,8 @@ export class ClientTargetService {
AND p.client_id = ${target.clientId} AND p.client_id = ${target.clientId}
AND te.start_time >= ${periodStart} AND te.start_time >= ${periodStart}
AND te.start_time <= ${periodEnd} AND te.start_time <= ${periodEnd}
AND te.deleted_at IS NULL
AND p.deleted_at IS NULL
GROUP BY DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC') GROUP BY DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC')
`); `);

View File

@@ -7,6 +7,8 @@ export class ProjectService {
return prisma.project.findMany({ return prisma.project.findMany({
where: { where: {
userId, userId,
deletedAt: null,
client: { deletedAt: null },
...(clientId && { clientId }), ...(clientId && { clientId }),
}, },
orderBy: { name: "asc" }, orderBy: { name: "asc" },
@@ -23,7 +25,12 @@ export class ProjectService {
async findById(id: string, userId: string) { async findById(id: string, userId: string) {
return prisma.project.findFirst({ return prisma.project.findFirst({
where: { id, userId }, where: {
id,
userId,
deletedAt: null,
client: { deletedAt: null },
},
include: { include: {
client: { client: {
select: { select: {
@@ -36,9 +43,9 @@ export class ProjectService {
} }
async create(userId: string, data: CreateProjectInput) { async create(userId: string, data: CreateProjectInput) {
// Verify the client belongs to the user // Verify the client belongs to the user and is not soft-deleted
const client = await prisma.client.findFirst({ const client = await prisma.client.findFirst({
where: { id: data.clientId, userId }, where: { id: data.clientId, userId, deletedAt: null },
}); });
if (!client) { if (!client) {
@@ -70,10 +77,10 @@ export class ProjectService {
throw new NotFoundError("Project not found"); throw new NotFoundError("Project not found");
} }
// If clientId is being updated, verify it belongs to the user // If clientId is being updated, verify it belongs to the user and is not soft-deleted
if (data.clientId) { if (data.clientId) {
const client = await prisma.client.findFirst({ const client = await prisma.client.findFirst({
where: { id: data.clientId, userId }, where: { id: data.clientId, userId, deletedAt: null },
}); });
if (!client) { if (!client) {
@@ -108,8 +115,9 @@ export class ProjectService {
throw new NotFoundError("Project not found"); throw new NotFoundError("Project not found");
} }
await prisma.project.delete({ await prisma.project.update({
where: { id }, where: { id },
data: { deletedAt: new Date() },
}); });
} }
} }

View File

@@ -46,7 +46,11 @@ export class TimeEntryService {
COUNT(te.id)::bigint AS entry_count COUNT(te.id)::bigint AS entry_count
FROM time_entries te FROM time_entries te
JOIN projects p ON p.id = te.project_id JOIN projects p ON p.id = te.project_id
JOIN clients c ON c.id = p.client_id
WHERE te.user_id = ${userId} WHERE te.user_id = ${userId}
AND te.deleted_at IS NULL
AND p.deleted_at IS NULL
AND c.deleted_at IS NULL
${filterClause} ${filterClause}
GROUP BY p.id, p.name, p.color GROUP BY p.id, p.name, p.color
ORDER BY total_seconds DESC ORDER BY total_seconds DESC
@@ -69,6 +73,9 @@ export class TimeEntryService {
JOIN projects p ON p.id = te.project_id JOIN projects p ON p.id = te.project_id
JOIN clients c ON c.id = p.client_id JOIN clients c ON c.id = p.client_id
WHERE te.user_id = ${userId} WHERE te.user_id = ${userId}
AND te.deleted_at IS NULL
AND p.deleted_at IS NULL
AND c.deleted_at IS NULL
${filterClause} ${filterClause}
GROUP BY c.id, c.name GROUP BY c.id, c.name
ORDER BY total_seconds DESC ORDER BY total_seconds DESC
@@ -81,7 +88,11 @@ export class TimeEntryService {
COUNT(te.id)::bigint AS entry_count COUNT(te.id)::bigint AS entry_count
FROM time_entries te FROM time_entries te
JOIN projects p ON p.id = te.project_id JOIN projects p ON p.id = te.project_id
JOIN clients c ON c.id = p.client_id
WHERE te.user_id = ${userId} WHERE te.user_id = ${userId}
AND te.deleted_at IS NULL
AND p.deleted_at IS NULL
AND c.deleted_at IS NULL
${filterClause} ${filterClause}
`, `,
), ),
@@ -125,10 +136,11 @@ export class TimeEntryService {
const where: { const where: {
userId: string; userId: string;
deletedAt: null;
startTime?: { gte?: Date; lte?: Date }; startTime?: { gte?: Date; lte?: Date };
projectId?: string; projectId?: string;
project?: { clientId?: string }; project?: { deletedAt: null; clientId?: string; client: { deletedAt: null } };
} = { userId }; } = { userId, deletedAt: null };
if (startDate || endDate) { if (startDate || endDate) {
where.startTime = {}; where.startTime = {};
@@ -140,9 +152,13 @@ export class TimeEntryService {
where.projectId = projectId; where.projectId = projectId;
} }
if (clientId) { // Always filter out entries whose project or client is soft-deleted,
where.project = { clientId }; // merging the optional clientId filter into the project relation filter.
} where.project = {
deletedAt: null,
client: { deletedAt: null },
...(clientId && { clientId }),
};
const [entries, total] = await Promise.all([ const [entries, total] = await Promise.all([
prisma.timeEntry.findMany({ prisma.timeEntry.findMany({
@@ -182,7 +198,12 @@ export class TimeEntryService {
async findById(id: string, userId: string) { async findById(id: string, userId: string) {
return prisma.timeEntry.findFirst({ return prisma.timeEntry.findFirst({
where: { id, userId }, where: {
id,
userId,
deletedAt: null,
project: { deletedAt: null, client: { deletedAt: null } },
},
include: { include: {
project: { project: {
select: { select: {
@@ -217,9 +238,9 @@ export class TimeEntryService {
throw new BadRequestError("Break time cannot exceed total duration"); throw new BadRequestError("Break time cannot exceed total duration");
} }
// Verify the project belongs to the user // Verify the project belongs to the user and is not soft-deleted (nor its client)
const project = await prisma.project.findFirst({ const project = await prisma.project.findFirst({
where: { id: data.projectId, userId }, where: { id: data.projectId, userId, deletedAt: null, client: { deletedAt: null } },
}); });
if (!project) { if (!project) {
@@ -288,10 +309,10 @@ export class TimeEntryService {
throw new BadRequestError("Break time cannot exceed total duration"); throw new BadRequestError("Break time cannot exceed total duration");
} }
// If project changed, verify it belongs to the user // If project changed, verify it belongs to the user and is not soft-deleted
if (data.projectId && data.projectId !== entry.projectId) { if (data.projectId && data.projectId !== entry.projectId) {
const project = await prisma.project.findFirst({ const project = await prisma.project.findFirst({
where: { id: data.projectId, userId }, where: { id: data.projectId, userId, deletedAt: null, client: { deletedAt: null } },
}); });
if (!project) { if (!project) {
@@ -345,8 +366,9 @@ export class TimeEntryService {
throw new NotFoundError("Time entry not found"); throw new NotFoundError("Time entry not found");
} }
await prisma.timeEntry.delete({ await prisma.timeEntry.update({
where: { id }, where: { id },
data: { deletedAt: new Date() },
}); });
} }
} }

View File

@@ -11,6 +11,7 @@ export interface Client {
description: string | null; description: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
deletedAt: string | null;
} }
export interface Project { export interface Project {
@@ -22,6 +23,7 @@ export interface Project {
client: Pick<Client, 'id' | 'name'>; client: Pick<Client, 'id' | 'name'>;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
deletedAt: string | null;
} }
export interface TimeEntry { export interface TimeEntry {
@@ -36,6 +38,7 @@ export interface TimeEntry {
}; };
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
deletedAt: string | null;
} }
export interface OngoingTimer { export interface OngoingTimer {
@@ -149,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 {