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.
This commit is contained in:
@@ -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;
|
||||
@@ -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])
|
||||
|
||||
@@ -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() },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Client, 'id' | 'name'>;
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user