diff --git a/backend/prisma/migrations/20260223200000_add_soft_delete/migration.sql b/backend/prisma/migrations/20260223200000_add_soft_delete/migration.sql new file mode 100644 index 0000000..2e1a248 --- /dev/null +++ b/backend/prisma/migrations/20260223200000_add_soft_delete/migration.sql @@ -0,0 +1,20 @@ +-- 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); + +-- DropForeignKey: remove cascade from projects -> clients +ALTER TABLE "projects" DROP CONSTRAINT "projects_client_id_fkey"; + +-- DropForeignKey: remove cascade from time_entries -> projects +ALTER TABLE "time_entries" DROP CONSTRAINT "time_entries_project_id_fkey"; + +-- AddForeignKey: re-add without onDelete cascade +ALTER TABLE "projects" ADD CONSTRAINT "projects_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "clients"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey: re-add without onDelete cascade +ALTER TABLE "time_entries" ADD CONSTRAINT "time_entries_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 399cb79..0932af2 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -25,11 +25,12 @@ model User { } model Client { - id String @id @default(uuid()) - name String @db.VarChar(255) - description String? @db.Text - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + id String @id @default(uuid()) + name String @db.VarChar(255) + description String? @db.Text + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") userId String @map("user_id") @db.VarChar(255) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -41,17 +42,18 @@ model Client { } model Project { - id String @id @default(uuid()) - name String @db.VarChar(255) - description String? @db.Text - color String? @db.VarChar(7) // Hex color code - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + id String @id @default(uuid()) + name String @db.VarChar(255) + description String? @db.Text + color String? @db.VarChar(7) // Hex color code + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") userId String @map("user_id") @db.VarChar(255) user User @relation(fields: [userId], references: [id], onDelete: Cascade) clientId String @map("client_id") - client Client @relation(fields: [clientId], references: [id], onDelete: Cascade) + client Client @relation(fields: [clientId], references: [id]) timeEntries TimeEntry[] ongoingTimers OngoingTimer[] @@ -62,18 +64,19 @@ model Project { } model TimeEntry { - id String @id @default(uuid()) - startTime DateTime @map("start_time") @db.Timestamptz() - endTime DateTime @map("end_time") @db.Timestamptz() - breakMinutes Int @default(0) @map("break_minutes") - description String? @db.Text - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + id String @id @default(uuid()) + startTime DateTime @map("start_time") @db.Timestamptz() + endTime DateTime @map("end_time") @db.Timestamptz() + breakMinutes Int @default(0) @map("break_minutes") + description String? @db.Text + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") userId String @map("user_id") @db.VarChar(255) user User @relation(fields: [userId], references: [id], onDelete: Cascade) projectId String @map("project_id") - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + project Project @relation(fields: [projectId], references: [id]) @@index([userId]) @@index([userId, startTime]) diff --git a/backend/src/services/client.service.ts b/backend/src/services/client.service.ts index c6e44be..1cc240b 100644 --- a/backend/src/services/client.service.ts +++ b/backend/src/services/client.service.ts @@ -5,14 +5,14 @@ import type { CreateClientInput, UpdateClientInput } from "../types"; export class ClientService { async findAll(userId: string) { return prisma.client.findMany({ - where: { userId }, + where: { userId, deletedAt: null }, orderBy: { name: "asc" }, }); } async findById(id: string, userId: string) { 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"); } - await prisma.client.delete({ + await prisma.client.update({ where: { id }, + data: { deletedAt: new Date() }, }); } } diff --git a/backend/src/services/project.service.ts b/backend/src/services/project.service.ts index 854b4de..3c8277b 100644 --- a/backend/src/services/project.service.ts +++ b/backend/src/services/project.service.ts @@ -7,6 +7,8 @@ export class ProjectService { return prisma.project.findMany({ where: { userId, + deletedAt: null, + client: { deletedAt: null }, ...(clientId && { clientId }), }, orderBy: { name: "asc" }, @@ -23,7 +25,12 @@ export class ProjectService { async findById(id: string, userId: string) { return prisma.project.findFirst({ - where: { id, userId }, + where: { + id, + userId, + deletedAt: null, + client: { deletedAt: null }, + }, include: { client: { select: { @@ -36,9 +43,9 @@ export class ProjectService { } 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({ - where: { id: data.clientId, userId }, + where: { id: data.clientId, userId, deletedAt: null }, }); if (!client) { @@ -70,10 +77,10 @@ export class ProjectService { 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) { const client = await prisma.client.findFirst({ - where: { id: data.clientId, userId }, + where: { id: data.clientId, userId, deletedAt: null }, }); if (!client) { @@ -108,8 +115,9 @@ export class ProjectService { throw new NotFoundError("Project not found"); } - await prisma.project.delete({ + await prisma.project.update({ where: { id }, + data: { deletedAt: new Date() }, }); } } diff --git a/backend/src/services/timeEntry.service.ts b/backend/src/services/timeEntry.service.ts index 898693f..0db48ef 100644 --- a/backend/src/services/timeEntry.service.ts +++ b/backend/src/services/timeEntry.service.ts @@ -46,7 +46,11 @@ export class TimeEntryService { COUNT(te.id)::bigint AS entry_count FROM time_entries te JOIN projects p ON p.id = te.project_id + JOIN clients c ON c.id = p.client_id WHERE te.user_id = ${userId} + AND te.deleted_at IS NULL + AND p.deleted_at IS NULL + AND c.deleted_at IS NULL ${filterClause} GROUP BY p.id, p.name, p.color ORDER BY total_seconds DESC @@ -69,6 +73,9 @@ export class TimeEntryService { JOIN projects p ON p.id = te.project_id JOIN clients c ON c.id = p.client_id WHERE te.user_id = ${userId} + AND te.deleted_at IS NULL + AND p.deleted_at IS NULL + AND c.deleted_at IS NULL ${filterClause} GROUP BY c.id, c.name ORDER BY total_seconds DESC @@ -81,7 +88,11 @@ export class TimeEntryService { COUNT(te.id)::bigint AS entry_count FROM time_entries te JOIN projects p ON p.id = te.project_id + JOIN clients c ON c.id = p.client_id WHERE te.user_id = ${userId} + AND te.deleted_at IS NULL + AND p.deleted_at IS NULL + AND c.deleted_at IS NULL ${filterClause} `, ), @@ -125,10 +136,11 @@ export class TimeEntryService { const where: { userId: string; + deletedAt: null; startTime?: { gte?: Date; lte?: Date }; projectId?: string; - project?: { clientId?: string }; - } = { userId }; + project?: { deletedAt: null; clientId?: string; client: { deletedAt: null } }; + } = { userId, deletedAt: null }; if (startDate || endDate) { where.startTime = {}; @@ -140,9 +152,13 @@ export class TimeEntryService { where.projectId = projectId; } - if (clientId) { - where.project = { clientId }; - } + // Always filter out entries whose project or client is soft-deleted, + // 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([ prisma.timeEntry.findMany({ @@ -182,7 +198,12 @@ export class TimeEntryService { async findById(id: string, userId: string) { return prisma.timeEntry.findFirst({ - where: { id, userId }, + where: { + id, + userId, + deletedAt: null, + project: { deletedAt: null, client: { deletedAt: null } }, + }, include: { project: { select: { @@ -217,9 +238,9 @@ export class TimeEntryService { 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({ - where: { id: data.projectId, userId }, + where: { id: data.projectId, userId, deletedAt: null, client: { deletedAt: null } }, }); if (!project) { @@ -288,10 +309,10 @@ export class TimeEntryService { 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) { const project = await prisma.project.findFirst({ - where: { id: data.projectId, userId }, + where: { id: data.projectId, userId, deletedAt: null, client: { deletedAt: null } }, }); if (!project) { @@ -345,8 +366,9 @@ export class TimeEntryService { throw new NotFoundError("Time entry not found"); } - await prisma.timeEntry.delete({ + await prisma.timeEntry.update({ where: { id }, + data: { deletedAt: new Date() }, }); } } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 61542b5..eceee0c 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -11,6 +11,7 @@ export interface Client { description: string | null; createdAt: string; updatedAt: string; + deletedAt: string | null; } export interface Project { @@ -22,6 +23,7 @@ export interface Project { client: Pick; createdAt: string; updatedAt: string; + deletedAt: string | null; } export interface TimeEntry { @@ -36,6 +38,7 @@ export interface TimeEntry { }; createdAt: string; updatedAt: string; + deletedAt: string | null; } export interface OngoingTimer {