From d58443ed73f4a854630a685c05beebdbf3542aa3 Mon Sep 17 00:00:00 2001 From: grabowski Date: Mon, 6 Apr 2026 13:09:27 +0700 Subject: [PATCH] Add soft-delete (archive) for companies, admin-only - Added deletedAt column to companies table for soft delete - System admins see a trash icon on each company card with confirmation modal - Archived companies are filtered from sidebar, dashboard, company list, and direct access - Audit log entry created on archive Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/server/db/schema.ts | 1 + src/routes/(app)/+layout.server.ts | 4 +- src/routes/(app)/companies/+page.server.ts | 33 +++++++- src/routes/(app)/companies/+page.svelte | 78 +++++++++++++++---- .../companies/[companyId]/+layout.server.ts | 4 +- src/routes/(app)/dashboard/+page.server.ts | 4 +- 6 files changed, 101 insertions(+), 23 deletions(-) diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index b9e0c72..e0dbd0f 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -59,6 +59,7 @@ export const companies = pgTable('companies', { description: text('description'), totalBudget: numeric('total_budget', { precision: 15, scale: 2 }).notNull().default('0'), currency: text('currency').notNull().default('THB'), + deletedAt: timestamp('deleted_at', { withTimezone: true }), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow() }); diff --git a/src/routes/(app)/+layout.server.ts b/src/routes/(app)/+layout.server.ts index d92d45f..ff3cf7e 100644 --- a/src/routes/(app)/+layout.server.ts +++ b/src/routes/(app)/+layout.server.ts @@ -2,7 +2,7 @@ import { redirect } from '@sveltejs/kit'; import type { LayoutServerLoad } from './$types'; import { db } from '$lib/server/db/index.js'; import { companyMembers, companies } from '$lib/server/db/schema.js'; -import { eq } from 'drizzle-orm'; +import { eq, and, isNull } from 'drizzle-orm'; export const load: LayoutServerLoad = async ({ locals }) => { if (!locals.user) { @@ -18,7 +18,7 @@ export const load: LayoutServerLoad = async ({ locals }) => { }) .from(companyMembers) .innerJoin(companies, eq(companyMembers.companyId, companies.id)) - .where(eq(companyMembers.userId, locals.user.id)); + .where(and(eq(companyMembers.userId, locals.user.id), isNull(companies.deletedAt))); return { user: locals.user, diff --git a/src/routes/(app)/companies/+page.server.ts b/src/routes/(app)/companies/+page.server.ts index fc1fe66..2580c11 100644 --- a/src/routes/(app)/companies/+page.server.ts +++ b/src/routes/(app)/companies/+page.server.ts @@ -2,7 +2,7 @@ import { fail, redirect } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; import { db } from '$lib/server/db/index.js'; import { companies, companyMembers } from '$lib/server/db/schema.js'; -import { eq } from 'drizzle-orm'; +import { eq, and, isNull } from 'drizzle-orm'; import { logCompanyEvent } from '$lib/server/audit.js'; import { requireAuth } from '$lib/server/authorization.js'; @@ -20,7 +20,7 @@ export const load: PageServerLoad = async ({ locals }) => { }) .from(companyMembers) .innerJoin(companies, eq(companyMembers.companyId, companies.id)) - .where(eq(companyMembers.userId, user.id)); + .where(and(eq(companyMembers.userId, user.id), isNull(companies.deletedAt))); return { companies: userCompanies, isSystemAdmin: user.isSystemAdmin }; }; @@ -46,7 +46,6 @@ export const actions: Actions = { .values({ name, description, currency, totalBudget }) .returning(); - // Add creator as admin of the company await db.insert(companyMembers).values({ userId: user.id, companyId: company.id, @@ -61,5 +60,33 @@ export const actions: Actions = { } redirect(302, `/companies/${company.id}`); + }, + + delete: async ({ request, locals }) => { + const user = requireAuth(locals); + if (!user.isSystemAdmin) { + return fail(403, { error: 'Only system admins can delete companies' }); + } + + const formData = await request.formData(); + const companyId = formData.get('companyId')?.toString(); + if (!companyId) return fail(400, { error: 'Company ID required' }); + + const [company] = await db + .select({ name: companies.name }) + .from(companies) + .where(and(eq(companies.id, companyId), isNull(companies.deletedAt))) + .limit(1); + + if (!company) return fail(404, { error: 'Company not found' }); + + await db + .update(companies) + .set({ deletedAt: new Date(), updatedAt: new Date() }) + .where(eq(companies.id, companyId)); + + await logCompanyEvent(companyId, user.id, 'company_updated', `Company "${company.name}" archived (soft-deleted)`); + + return { success: true, deleted: company.name }; } }; diff --git a/src/routes/(app)/companies/+page.svelte b/src/routes/(app)/companies/+page.svelte index 5cd7c72..d9910b3 100644 --- a/src/routes/(app)/companies/+page.svelte +++ b/src/routes/(app)/companies/+page.svelte @@ -5,6 +5,8 @@ let { data, form } = $props(); let showCreateModal = $state(false); + let confirmDeleteId = $state(null); + let confirmDeleteName = $state(''); @@ -27,6 +29,9 @@ {#if form?.error}
{form.error}
{/if} + {#if form?.deleted} +
Company "{form.deleted}" has been archived.
+ {/if} {#if data.companies.length === 0}
@@ -41,21 +46,31 @@ {:else}
{#each data.companies as company} - -

{company.name}

- {#if company.description} -

{company.description}

+
+ +

{company.name}

+ {#if company.description} +

{company.description}

+ {/if} +
+ Budget: {formatCurrency(company.totalBudget, company.currency)} + + {company.role} + +
+
+ {#if data.isSystemAdmin} + {/if} -
- Budget: {formatCurrency(company.totalBudget, company.currency)} - - {company.role} - -
- +
{/each}
{/if} @@ -136,3 +151,38 @@
{/if} + + +{#if confirmDeleteId} +
+
+

Archive Company

+

+ Are you sure you want to archive {confirmDeleteName}? The company and all its data will be hidden but not permanently deleted. +

+
+ +
{ + return async ({ update }) => { + confirmDeleteId = null; + await update(); + }; + }}> + + +
+
+
+
+{/if} diff --git a/src/routes/(app)/companies/[companyId]/+layout.server.ts b/src/routes/(app)/companies/[companyId]/+layout.server.ts index fd690f3..2205d47 100644 --- a/src/routes/(app)/companies/[companyId]/+layout.server.ts +++ b/src/routes/(app)/companies/[companyId]/+layout.server.ts @@ -2,7 +2,7 @@ import { error } from '@sveltejs/kit'; import type { LayoutServerLoad } from './$types'; import { db } from '$lib/server/db/index.js'; import { companies } from '$lib/server/db/schema.js'; -import { eq } from 'drizzle-orm'; +import { eq, and, isNull } from 'drizzle-orm'; import { requireAuth, getCompanyRole } from '$lib/server/authorization.js'; export const load: LayoutServerLoad = async ({ locals, params }) => { @@ -11,7 +11,7 @@ export const load: LayoutServerLoad = async ({ locals, params }) => { const [company] = await db .select() .from(companies) - .where(eq(companies.id, params.companyId)) + .where(and(eq(companies.id, params.companyId), isNull(companies.deletedAt))) .limit(1); if (!company) { diff --git a/src/routes/(app)/dashboard/+page.server.ts b/src/routes/(app)/dashboard/+page.server.ts index d1eda16..d1239d6 100644 --- a/src/routes/(app)/dashboard/+page.server.ts +++ b/src/routes/(app)/dashboard/+page.server.ts @@ -6,7 +6,7 @@ import { projects, expenses } from '$lib/server/db/schema.js'; -import { eq, and, sql } from 'drizzle-orm'; +import { eq, and, sql, isNull } from 'drizzle-orm'; export const load: PageServerLoad = async ({ locals }) => { const userId = locals.user!.id; @@ -22,7 +22,7 @@ export const load: PageServerLoad = async ({ locals }) => { }) .from(companyMembers) .innerJoin(companies, eq(companyMembers.companyId, companies.id)) - .where(eq(companyMembers.userId, userId)); + .where(and(eq(companyMembers.userId, userId), isNull(companies.deletedAt))); // For each company, get project count and pending expense count const companySummaries = await Promise.all(