diff --git a/src/app/(dashboard)/settings/workspace/components/danger-zone.tsx b/src/app/(dashboard)/settings/workspace/components/danger-zone.tsx new file mode 100644 index 0000000..a10b979 --- /dev/null +++ b/src/app/(dashboard)/settings/workspace/components/danger-zone.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { useActionState, useEffect, useState, useTransition } from "react"; +import { AlertTriangle, Download, Loader2, Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + deleteWorkspaceAction, + initialDeleteWorkspaceState, +} from "@/lib/appwrite/account-delete-actions"; + +export function DangerZone({ companyName }: { companyName: string }) { + const [downloadBusy, startDownload] = useTransition(); + const [open, setOpen] = useState(false); + const [confirm, setConfirm] = useState(""); + const [state, action, pending] = useActionState( + deleteWorkspaceAction, + initialDeleteWorkspaceState, + ); + + useEffect(() => { + if (state.error) toast.error(state.error); + }, [state]); + + function downloadExport() { + startDownload(async () => { + try { + const res = await fetch("/api/account/export"); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `dls-export-${Date.now()}.json`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + toast.success("Veri dışa aktarıldı."); + } catch (e) { + toast.error(e instanceof Error ? e.message : "İndirilemedi."); + } + }); + } + + return ( + + + + + Tehlikeli Bölge + + + Verinizi dışa aktarın veya çalışma alanını kalıcı olarak silin. + + + +
+
+

Verilerimi indir

+

+ Çalışma alanınızdaki tüm veriler (hastalar, işler, ödemeler, geçmiş) + JSON formatında dışa aktarılır. Silmeden önce yedek almanız önerilir. +

+
+ +
+ +
+
+

Çalışma alanını sil

+

+ Tüm hastalar, işler, ödemeler, dosyalar ve geçmiş kalıcı olarak silinir. + Bu işlem geri alınamaz. +

+
+ + + + + Çalışma alanını kalıcı sil + + Onaylamak için aşağıya {companyName} yazın. Bu + işlem hastalar, işler, ödemeler, dosyalar ve tüm geçmişi içerir + ve geri alınamaz. + + +
+
+ + setConfirm(e.target.value)} + placeholder={companyName} + /> +
+ + + + + + +
+
+
+
+
+
+ ); +} diff --git a/src/app/(dashboard)/settings/workspace/page.tsx b/src/app/(dashboard)/settings/workspace/page.tsx index 674dea2..148ad64 100644 --- a/src/app/(dashboard)/settings/workspace/page.tsx +++ b/src/app/(dashboard)/settings/workspace/page.tsx @@ -3,6 +3,7 @@ import { redirect } from "next/navigation"; import { getLogoUrl } from "@/lib/appwrite/storage"; import { requireTenant } from "@/lib/appwrite/tenant-guard"; +import { DangerZone } from "./components/danger-zone"; import { LogoUploader } from "./components/logo-uploader"; import { WorkspaceSettingsForm } from "./components/workspace-form"; @@ -50,6 +51,10 @@ export default async function WorkspaceSettingsPage() { memberNumber: ctx.settings?.memberNumber ?? "", }} /> + + {ctx.role === "owner" && ( + + )} ); } diff --git a/src/app/api/account/export/route.ts b/src/app/api/account/export/route.ts new file mode 100644 index 0000000..afa53e4 --- /dev/null +++ b/src/app/api/account/export/route.ts @@ -0,0 +1,103 @@ +import { NextResponse } from "next/server"; +import { Query } from "node-appwrite"; + +import { DATABASE_ID, TABLES } from "@/lib/appwrite/schema"; +import { createAdminClient } from "@/lib/appwrite/server"; +import { requireTenant } from "@/lib/appwrite/tenant-guard"; + +const TENANT_TABLES = [ + TABLES.tenantSettings, + TABLES.profiles, + TABLES.connections, + TABLES.patients, + TABLES.clinicPricing, + TABLES.jobs, + TABLES.jobFiles, + TABLES.jobStatusHistory, + TABLES.prosthetics, + TABLES.financeEntries, + TABLES.payments, + TABLES.notifications, + TABLES.auditLogs, +] as const; + +const TENANT_FIELDS_BY_TABLE: Record = { + // Most tables use 'tenantId' or 'clinicTenantId'/'labTenantId' for ownership. + [TABLES.tenantSettings]: ["tenantId"], + [TABLES.profiles]: ["tenantId"], + [TABLES.connections]: ["clinicTenantId", "labTenantId"], + [TABLES.patients]: ["clinicTenantId"], + [TABLES.clinicPricing]: ["labTenantId", "clinicTenantId"], + [TABLES.jobs]: ["clinicTenantId", "labTenantId"], + [TABLES.jobFiles]: ["clinicTenantId", "labTenantId"], + [TABLES.jobStatusHistory]: ["clinicTenantId", "labTenantId"], + [TABLES.prosthetics]: ["tenantId"], + [TABLES.financeEntries]: ["tenantId"], + [TABLES.payments]: ["tenantId", "counterpartTenantId"], + [TABLES.notifications]: ["tenantId"], + [TABLES.auditLogs]: ["tenantId"], +}; + +/** + * Return a JSON file containing every row this tenant has access to. + * Used for KVKK 'data portability' and as a sanity-check pre-delete. + */ +export async function GET() { + let ctx; + try { + ctx = await requireTenant(); + } catch { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { tablesDB } = createAdminClient(); + const out: Record = {}; + + for (const table of TENANT_TABLES) { + const fields = TENANT_FIELDS_BY_TABLE[table] ?? ["tenantId"]; + try { + // Fetch each row where ANY of the ownership fields equals our tenantId. + // For tables with two fields (jobs, connections, ...) issue both queries + // and dedupe — Appwrite doesn't have OR across distinct equality terms. + const seen = new Set(); + const rows: unknown[] = []; + for (const field of fields) { + const result = await tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: table, + queries: [Query.equal(field, ctx.tenantId), Query.limit(500)], + }); + for (const r of result.rows) { + const id = (r as { $id?: string }).$id; + if (id && !seen.has(id)) { + seen.add(id); + rows.push(r); + } + } + } + out[table] = rows; + } catch (e) { + out[table] = [ + { error: e instanceof Error ? e.message : "fetch failed" }, + ]; + } + } + + const payload = { + exportedAt: new Date().toISOString(), + tenantId: ctx.tenantId, + tenantKind: ctx.kind, + requestedBy: { id: ctx.user.id, email: ctx.user.email, name: ctx.user.name }, + data: out, + }; + + const fileName = `dls-export-${ctx.tenantId}-${Date.now()}.json`; + return new NextResponse(JSON.stringify(payload, null, 2), { + status: 200, + headers: { + "Content-Type": "application/json; charset=utf-8", + "Content-Disposition": `attachment; filename="${fileName}"`, + "Cache-Control": "private, no-store", + }, + }); +} diff --git a/src/lib/appwrite/account-delete-actions.ts b/src/lib/appwrite/account-delete-actions.ts new file mode 100644 index 0000000..f8fa168 --- /dev/null +++ b/src/lib/appwrite/account-delete-actions.ts @@ -0,0 +1,172 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { AppwriteException, Query } from "node-appwrite"; + +import { + BUCKETS, + DATABASE_ID, + TABLES, + type JobFile, + type TenantSettings, +} from "./schema"; +import { APPWRITE_SESSION_COOKIE, createAdminClient } from "./server"; +import { ACTIVE_TENANT_COOKIE } from "./tenant-types"; +import { requireRole, requireTenant } from "./tenant-guard"; + +export type DeleteWorkspaceState = { + ok: boolean; + error?: string; +}; + +export const initialDeleteWorkspaceState: DeleteWorkspaceState = { ok: false }; + +function appwriteError(e: unknown, fallback = "Beklenmeyen bir hata oluştu."): string { + if (e instanceof AppwriteException) return e.message || fallback; + return process.env.NODE_ENV !== "production" && e instanceof Error + ? `${fallback} (${e.message})` + : fallback; +} + +/** Best-effort delete every row where any of `fields` equals tenantId. */ +async function purgeTable(table: string, fields: string[], tenantId: string) { + const { tablesDB } = createAdminClient(); + const ids = new Set(); + for (const field of fields) { + let offset = 0; + // Page through to handle tables with more than 500 rows. + while (true) { + const result = await tablesDB + .listRows({ + databaseId: DATABASE_ID, + tableId: table, + queries: [Query.equal(field, tenantId), Query.limit(500), Query.offset(offset)], + }) + .catch(() => ({ rows: [] })); + for (const row of result.rows) { + const id = (row as { $id?: string }).$id; + if (id) ids.add(id); + } + if (result.rows.length < 500) break; + offset += 500; + } + } + await Promise.allSettled( + Array.from(ids).map((id) => + tablesDB.deleteRow(DATABASE_ID, table, id), + ), + ); +} + +/** + * Hard-delete an entire workspace and everything it owns. Reversible only + * via your own backup — Appwrite has no undo. Caller must be owner of the + * tenant and must confirm by typing the company name back to us. + */ +export async function deleteWorkspaceAction( + _prev: DeleteWorkspaceState, + formData: FormData, +): Promise { + const confirm = String(formData.get("confirm") ?? "").trim(); + let ctx; + try { + ctx = await requireTenant(); + requireRole(ctx, ["owner"]); + } catch { + return { ok: false, error: "Yalnızca sahip silebilir." }; + } + const expected = ctx.settings?.companyName?.trim() ?? ""; + if (!expected || confirm !== expected) { + return { + ok: false, + error: "Onaylamak için çalışma alanı adını birebir yazmanız gerekiyor.", + }; + } + + const tenantId = ctx.tenantId; + const { tablesDB, storage, teams } = createAdminClient(); + + // 1) Wipe Storage objects we still own (logo + any non-archived job files). + try { + const settingsRes = await tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.tenantSettings, + queries: [Query.equal("tenantId", tenantId), Query.limit(1)], + }); + const settings = (settingsRes.rows[0] as unknown as TenantSettings | undefined) ?? null; + if (settings?.logo) { + try { + await storage.deleteFile(BUCKETS.tenantLogos, settings.logo); + } catch { + /* ignore */ + } + } + + const files = await tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.jobFiles, + queries: [ + Query.or([ + Query.equal("clinicTenantId", tenantId), + Query.equal("labTenantId", tenantId), + ]), + Query.limit(1000), + ], + }); + await Promise.allSettled( + (files.rows as unknown as JobFile[]).map(async (f) => { + if (f.archivedAt) return; + try { + await storage.deleteFile(BUCKETS.jobFiles, f.fileId); + } catch { + /* ignore */ + } + }), + ); + } catch { + /* best-effort */ + } + + // 2) Purge all DB tables tied to this tenant. Order doesn't matter + // because everything is hard-deleted. + const tablePurges: Array<[string, string[]]> = [ + [TABLES.notifications, ["tenantId"]], + [TABLES.auditLogs, ["tenantId"]], + [TABLES.payments, ["tenantId", "counterpartTenantId"]], + [TABLES.financeEntries, ["tenantId"]], + [TABLES.jobStatusHistory, ["clinicTenantId", "labTenantId"]], + [TABLES.jobFiles, ["clinicTenantId", "labTenantId"]], + [TABLES.jobs, ["clinicTenantId", "labTenantId"]], + [TABLES.clinicPricing, ["labTenantId", "clinicTenantId"]], + [TABLES.prosthetics, ["tenantId"]], + [TABLES.patients, ["clinicTenantId"]], + [TABLES.connections, ["clinicTenantId", "labTenantId"]], + [TABLES.profiles, ["tenantId"]], + [TABLES.tenantSettings, ["tenantId"]], + ]; + for (const [table, fields] of tablePurges) { + await purgeTable(table, fields, tenantId); + } + + // 3) Finally delete the Appwrite Team itself. This boots every member's + // permission to read anything that might have slipped through above. + try { + await teams.delete({ teamId: tenantId }); + } catch (e) { + return { ok: false, error: appwriteError(e, "Çalışma alanı silindi ama takım kaldı.") }; + } + + // Drop session/active-tenant cookies — the user is also effectively + // signed out of this workspace. + const cookieStore = await cookies(); + cookieStore.delete(ACTIVE_TENANT_COOKIE); + // Keep the Appwrite session itself so the user can still re-onboard or + // hop to another workspace they own. If they want to drop the session, + // there's already a 'Çıkış yap' button. + void APPWRITE_SESSION_COOKIE; // referenced to avoid unused import + + revalidatePath("/"); + redirect("/onboarding"); +}