docs: add account deletion implementation plan
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
03e1f335bc
commit
a8bae0c1c3
1 changed files with 320 additions and 0 deletions
320
docs/superpowers/plans/2026-04-27-expo-account-deletion.md
Normal file
320
docs/superpowers/plans/2026-04-27-expo-account-deletion.md
Normal file
|
|
@ -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 <your-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(
|
||||
<Trash2 size={20} color="#ef4444" />,
|
||||
"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"
|
||||
```
|
||||
Loading…
Reference in a new issue