diff --git a/docs/superpowers/plans/2026-04-27-expo-account-deletion.md b/docs/superpowers/plans/2026-04-27-expo-account-deletion.md new file mode 100644 index 0000000..7114552 --- /dev/null +++ b/docs/superpowers/plans/2026-04-27-expo-account-deletion.md @@ -0,0 +1,320 @@ +# Account Deletion Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add account deletion to the Expo app — a "Danger Zone" button in Settings that calls a new `DELETE /users/me` API endpoint, soft-deletes the profile (and org if sole member), then hard-deletes the Supabase auth user. + +**Architecture:** The API endpoint sets `deleted_at` on `profiles` (and `organizations` if the user is the sole member), then calls `supabase.auth.admin.deleteUser` to revoke access. The Expo app shows a native `Alert` confirmation, calls the endpoint with a Bearer token, then calls `signOut()` to clear local state. No cron purge — orphaned data stays in the DB. + +**Tech Stack:** Hono (API), Supabase (auth + DB), React Native `Alert`, Zustand auth store, Expo Router + +--- + +## File Map + +| Action | File | +|--------|------| +| Modify | `apps/api/src/routers/user.ts` | +| Modify | `apps/api/src/__tests__/routes/user.test.ts` | +| Modify | `xtablo-expo/app/(app)/(tabs)/settings.tsx` | + +--- + +### Task 1: Database Migrations + +**Files:** +- Modify: Supabase database (via dashboard SQL editor or Supabase CLI) + +- [ ] **Step 1: Run migration SQL in Supabase dashboard** + +Open the Supabase project → SQL Editor → run: + +```sql +-- Add deleted_at to profiles +ALTER TABLE profiles ADD COLUMN IF NOT EXISTS deleted_at timestamptz DEFAULT NULL; + +-- Add deleted_at to organizations +ALTER TABLE organizations ADD COLUMN IF NOT EXISTS deleted_at timestamptz DEFAULT NULL; +``` + +- [ ] **Step 2: Verify columns exist** + +In Supabase SQL Editor: + +```sql +SELECT column_name, data_type, is_nullable +FROM information_schema.columns +WHERE table_name IN ('profiles', 'organizations') + AND column_name = 'deleted_at'; +``` + +Expected: 2 rows returned, both with `data_type = 'timestamp with time zone'` and `is_nullable = YES`. + +- [ ] **Step 3: Regenerate TypeScript types** + +```bash +cd /path/to/repo +npx supabase gen types typescript --project-id > packages/shared-types/src/database.types.ts +``` + +Verify `profiles.Row` now has `deleted_at: string | null`. + +- [ ] **Step 4: Commit** + +```bash +git add packages/shared-types/src/database.types.ts +git commit -m "chore: add deleted_at to profiles and organizations, regenerate types" +``` + +--- + +### Task 2: API — `DELETE /users/me` Endpoint (TDD) + +**Files:** +- Modify: `apps/api/src/__tests__/routes/user.test.ts` +- Modify: `apps/api/src/routers/user.ts` + +- [ ] **Step 1: Write the failing test** + +Open `apps/api/src/__tests__/routes/user.test.ts`. Add a new `describe` block after the existing ones: + +```typescript +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" }); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +cd apps/api +pnpm test --reporter=verbose src/__tests__/routes/user.test.ts +``` + +Expected: the new tests fail with `404` or a method-not-found error (route doesn't exist yet). The existing tests should still pass. + +- [ ] **Step 3: Implement the `deleteMe` handler in `apps/api/src/routers/user.ts`** + +Add this handler after the `removeOrganizationMember` handler (around line 803, before `export const getUserRouter`): + +```typescript +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 + .from("profiles") + .select("organization_id") + .eq("id", user.id) + .single(); + + if (profileError || !profile) { + return c.json({ error: "User not found" }, 404); + } + + // If user is sole org member, soft-delete the org too + if (profile.organization_id) { + const { count } = await supabase + .from("profiles") + .select("id", { count: "exact", head: true }) + .eq("organization_id", profile.organization_id); + + if ((count ?? 0) === 1) { + await supabase + .from("organizations") + .update({ deleted_at: new Date().toISOString() }) + .eq("id", profile.organization_id); + } + } + + // Soft-delete the profile + await supabase + .from("profiles") + .update({ deleted_at: new Date().toISOString() }) + .eq("id", user.id); + + // Hard-delete the Supabase auth user (revokes all sessions immediately) + const { error: authDeleteError } = await supabase.auth.admin.deleteUser(user.id); + + if (authDeleteError) { + // Roll back the soft delete + await supabase + .from("profiles") + .update({ deleted_at: null }) + .eq("id", user.id); + return c.json({ error: "Failed to delete account" }, 500); + } + + return c.json({ message: "Account deleted successfully" }); +}); +``` + +- [ ] **Step 4: Register the route in `getUserRouter`** + +In the `getUserRouter` function (around line 804), add the new route: + +```typescript +export const getUserRouter = () => { + const userRouter = new Hono(); + + userRouter.get("/me", ...getMe); + userRouter.delete("/me", ...deleteMe); // ← add this line + userRouter.post("/mark-temporary", ...markTemporary); + userRouter.post("/profile/avatar", ...uploadAvatar); + userRouter.delete("/profile/avatar", ...deleteAvatar); + userRouter.get("/organization", ...getOrganization); + userRouter.patch("/organization", ...updateOrganization); + userRouter.post("/organization/invite", ...inviteToOrganization); + userRouter.delete("/organization/members/:memberId", ...removeOrganizationMember); + + return userRouter; +}; +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +```bash +cd apps/api +pnpm test --reporter=verbose src/__tests__/routes/user.test.ts +``` + +Expected: all tests pass, including the two new DELETE ones. + +- [ ] **Step 6: Commit** + +```bash +git add apps/api/src/routers/user.ts apps/api/src/__tests__/routes/user.test.ts +git commit -m "feat(api): add DELETE /users/me account deletion endpoint" +``` + +--- + +### Task 3: Expo — Danger Zone UI in Settings + +**Files:** +- Modify: `xtablo-expo/app/(app)/(tabs)/settings.tsx` + +- [ ] **Step 1: Add the `Trash2` icon import** + +In `settings.tsx`, find the existing lucide import block (lines 22–30) and add `Trash2`: + +```typescript +import { + User, + Bell, + Moon, + Shield, + HelpCircle, + Info, + MessageSquare, + LogOut, + ChevronRight, + Smartphone, + Globe, + Lock, + Heart, + Trash2, // ← add this +} from "lucide-react-native"; +``` + +- [ ] **Step 2: Add the `handleDeleteAccount` function** + +In `settings.tsx`, add this function after `handleRateApp` (around line 91): + +```typescript +const handleDeleteAccount = () => { + Alert.alert( + "Supprimer le compte", + "Cette action est irréversible. Votre compte et toutes vos données seront définitivement supprimés. Êtes-vous sûr de vouloir continuer ?", + [ + { text: "Annuler", style: "cancel" }, + { + text: "Supprimer mon compte", + style: "destructive", + onPress: async () => { + try { + const session = useAuthStore.getState().session; + await api.delete("/api/v1/users/me", { + headers: { Authorization: `Bearer ${session?.access_token}` }, + }); + await signOut(); + } catch { + Alert.alert( + "Erreur", + "Une erreur est survenue lors de la suppression de votre compte. Veuillez réessayer." + ); + } + }, + }, + ] + ); +}; +``` + +- [ ] **Step 3: Add the Danger Zone section above the Sign Out button** + +In `settings.tsx`, find the `{/* Sign Out Section */}` comment (around line 315). Insert the Danger Zone section immediately before it: + +```tsx + {/* Danger Zone Section */} + {renderSettingsSection( + "Zone de danger", + <> + {renderSettingsItem( + , + "Supprimer le compte", + "Supprimer définitivement votre compte et vos données", + handleDeleteAccount, + undefined, + true + )} + + )} + + {/* Sign Out Section */} +``` + +- [ ] **Step 4: Verify the import of `useAuthStore` is used for `session`** + +The `handleDeleteAccount` function calls `useAuthStore.getState().session`. Confirm `useAuthStore` is already imported at the top of the file: + +```typescript +import { useAuthStore } from "@/stores/auth"; +``` + +It already is (line 14) — no change needed. + +- [ ] **Step 5: Run the Expo app and test the flow manually** + +```bash +cd xtablo-expo +npx expo start +``` + +Navigate to Settings → scroll to "Zone de danger" → tap "Supprimer le compte" → confirm the Alert appears with the warning text and a "Supprimer mon compte" destructive button → tap Cancel to verify it dismisses. + +- [ ] **Step 6: Commit** + +```bash +git add xtablo-expo/app/(app)/(tabs)/settings.tsx +git commit -m "feat(expo): add account deletion in settings danger zone" +```