From 07d61421b369b475f7a27e149a94186b3837cf4b Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Wed, 29 Apr 2026 15:45:03 +0200 Subject: [PATCH] fix(api): improve deleteMe handler and test placement - Use direct cast pattern consistent with rest of file - Add console.warn/error logging for count query failure and rollback failures - Move DELETE /me tests to end of suite to avoid ownerUser teardown ordering issue Co-Authored-By: Claude Sonnet 4.6 (1M context) --- apps/api/src/__tests__/routes/user.test.ts | 45 ++++++++--------- apps/api/src/routers/user.ts | 56 +++++++++++++++------- 2 files changed, 61 insertions(+), 40 deletions(-) diff --git a/apps/api/src/__tests__/routes/user.test.ts b/apps/api/src/__tests__/routes/user.test.ts index 857f5db..451a65c 100644 --- a/apps/api/src/__tests__/routes/user.test.ts +++ b/apps/api/src/__tests__/routes/user.test.ts @@ -163,28 +163,6 @@ describe("User Endpoint", () => { }); }); - describe("DELETE /me - Delete Account", () => { - it("should return 401 when unauthenticated", async () => { - const res = await client.users.me.$delete({}); - expect(res.status).toBe(401); - }); - - it("should delete the authenticated user's account", async () => { - const res = await client.users.me.$delete( - {}, - { - headers: { - Authorization: `Bearer ${ownerUser.accessToken}`, - "Content-Type": "application/json", - }, - } - ); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data).toEqual({ message: "Account deleted successfully" }); - }); - }); - describe("DELETE /profile/avatar - Delete Avatar", () => { it("should delete avatar for owner user", async () => { const res = await client.users.profile.avatar.$delete( @@ -246,4 +224,27 @@ describe("User Endpoint", () => { expect(res.status).toBe(401); }); }); + + // DELETE /me must run last — it hard-deletes the auth user, making ownerUser unusable for subsequent tests + describe("DELETE /me - Delete Account", () => { + it("should return 401 when unauthenticated", async () => { + const res = await client.users.me.$delete({}); + expect(res.status).toBe(401); + }); + + it("should delete the authenticated user's account", async () => { + const res = await client.users.me.$delete( + {}, + { + headers: { + Authorization: `Bearer ${ownerUser.accessToken}`, + "Content-Type": "application/json", + }, + } + ); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data).toEqual({ message: "Account deleted successfully" }); + }); + }); }); diff --git a/apps/api/src/routers/user.ts b/apps/api/src/routers/user.ts index c072c22..36e96b0 100644 --- a/apps/api/src/routers/user.ts +++ b/apps/api/src/routers/user.ts @@ -805,46 +805,66 @@ const deleteMe = factory.createHandlers(async (c) => { const user = c.get("user"); const supabase = c.get("supabase"); - // Fetch profile to get organization_id - const { data: profile, error: profileError } = await supabase + const { data: rawProfile, error: profileError } = await supabase .from("profiles") .select("organization_id") .eq("id", user.id) .single(); - if (profileError || !profile) { + if (profileError || !rawProfile) { return c.json({ error: "User not found" }, 404); } - const typedProfile = profile as typeof profile & { organization_id: number | null }; + const profile = rawProfile as typeof rawProfile & { organization_id: number | null }; + const deletedAt = new Date().toISOString(); + let orgWasSoftDeleted = false; - // If user is sole org member, soft-delete the org too - if (typedProfile.organization_id) { - const { count } = await supabase + if (profile.organization_id) { + const { count, error: countError } = await supabase .from("profiles") .select("id", { count: "exact", head: true }) - .eq("organization_id", typedProfile.organization_id); + .eq("organization_id", profile.organization_id); - if ((count ?? 0) === 1) { - await (supabase.from("organizations") as any) - .update({ deleted_at: new Date().toISOString() }) - .eq("id", typedProfile.organization_id); + if (countError) { + console.warn("Failed to count org members during account deletion, skipping org soft-delete:", countError.message); + } else if ((count ?? 0) === 1) { + const { error: orgDeleteError } = await (supabase.from("organizations") as any) + .update({ deleted_at: deletedAt }) + .eq("id", profile.organization_id); + if (orgDeleteError) { + return c.json({ error: "Failed to delete account" }, 500); + } + orgWasSoftDeleted = true; } } - // Soft-delete the profile - await (supabase.from("profiles") as any) - .update({ deleted_at: new Date().toISOString() }) + const { error: profileDeleteError } = await (supabase.from("profiles") as any) + .update({ deleted_at: deletedAt }) .eq("id", user.id); - // Hard-delete the Supabase auth user (revokes all sessions immediately) + if (profileDeleteError) { + if (orgWasSoftDeleted) { + const { error: rollbackErr } = await (supabase.from("organizations") as any) + .update({ deleted_at: null }) + .eq("id", profile.organization_id); + if (rollbackErr) console.error("Failed to roll back org soft-delete:", rollbackErr.message); + } + return c.json({ error: "Failed to delete account" }, 500); + } + const { error: authDeleteError } = await supabase.auth.admin.deleteUser(user.id); if (authDeleteError) { - // Roll back the soft delete - await (supabase.from("profiles") as any) + const { error: profileRollbackErr } = await (supabase.from("profiles") as any) .update({ deleted_at: null }) .eq("id", user.id); + if (profileRollbackErr) console.error("Failed to roll back profile soft-delete:", profileRollbackErr.message); + if (orgWasSoftDeleted) { + const { error: orgRollbackErr } = await (supabase.from("organizations") as any) + .update({ deleted_at: null }) + .eq("id", profile.organization_id); + if (orgRollbackErr) console.error("Failed to roll back org soft-delete:", orgRollbackErr.message); + } return c.json({ error: "Failed to delete account" }, 500); }