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) <noreply@anthropic.com>
This commit is contained in:
parent
e21f82fd8f
commit
07d61421b3
2 changed files with 61 additions and 40 deletions
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue