diff --git a/backend/api/package.json b/backend/api/package.json index b2be5d3a..f1092272 100644 --- a/backend/api/package.json +++ b/backend/api/package.json @@ -1,7 +1,7 @@ { "name": "@compass/api", "description": "Backend API endpoints", - "version": "1.11.1", + "version": "1.12.0", "private": true, "scripts": { "watch:serve": "tsx watch src/serve.ts", diff --git a/backend/api/src/delete-me.ts b/backend/api/src/delete-me.ts index 17ccdec7..2ad883e3 100644 --- a/backend/api/src/delete-me.ts +++ b/backend/api/src/delete-me.ts @@ -5,7 +5,7 @@ import {getUser} from 'shared/utils' import {APIError, APIHandler} from './helpers/endpoint' -export const deleteMe: APIHandler<'me/delete'> = async (_, auth) => { +export const deleteMe: APIHandler<'me/delete'> = async ({reasonCategory, reasonDetails}, auth) => { const user = await getUser(auth.uid) if (!user) { throw new APIError(401, 'Your account was not found') @@ -15,8 +15,23 @@ export const deleteMe: APIHandler<'me/delete'> = async (_, auth) => { throw new APIError(400, 'Invalid user ID') } - // Remove user data from Supabase const pg = createSupabaseDirectClient() + + // Store deletion reason before deleting the account + try { + await pg.none( + ` + INSERT INTO deleted_users (username, reason_category, reason_details) + VALUES ($1, $2, $3) + `, + [user.username, reasonCategory, reasonDetails], + ) + } catch (e) { + console.error('Error storing deletion reason:', e) + // Don't fail the deletion if we can't store the reason + } + + // Remove user data from Supabase await pg.none('DELETE FROM users WHERE id = $1', [userId]) // Should cascade delete in other tables diff --git a/backend/supabase/migration.sql b/backend/supabase/migration.sql index 12c61bf7..fa5be964 100644 --- a/backend/supabase/migration.sql +++ b/backend/supabase/migration.sql @@ -48,4 +48,5 @@ BEGIN; \i backend/supabase/migrations/20260213_add_big_5_to_profiles.sql \i backend/supabase/migrations/20260218_add_events.sql \i backend/supabase/migrations/20260218_add_notification_templates.sql +\i backend/supabase/migrations/20260222_add_deleted_users.sql COMMIT; diff --git a/backend/supabase/migrations/20260222_add_deleted_users.sql b/backend/supabase/migrations/20260222_add_deleted_users.sql new file mode 100644 index 00000000..910e3e7a --- /dev/null +++ b/backend/supabase/migrations/20260222_add_deleted_users.sql @@ -0,0 +1,21 @@ +-- Create deleted_users table to store information about deleted accounts +CREATE TABLE IF NOT EXISTS deleted_users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username TEXT , + reason_category TEXT, + reason_details TEXT, + deleted_time TIMESTAMPTZ DEFAULT now() NOT NULL +); + +-- Create indexes for performance +CREATE INDEX IF NOT EXISTS idx_deleted_users_username ON deleted_users (username); +CREATE INDEX IF NOT EXISTS idx_deleted_users_deleted_time ON deleted_users (deleted_time); +CREATE INDEX IF NOT EXISTS idx_deleted_users_reason_category ON deleted_users (reason_category); + +-- Enable RLS +ALTER TABLE deleted_users ENABLE ROW LEVEL SECURITY; + +-- Policy: Allow insert for everyone (for account deletion) +DROP POLICY IF EXISTS "anyone can insert" ON deleted_users; +CREATE POLICY "anyone can insert" ON deleted_users +FOR INSERT WITH CHECK (true); diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index c6381fc3..b1ba0684 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -250,7 +250,12 @@ export const API = (_apiTypeCheck = { method: 'POST', authed: true, rateLimited: true, - props: z.object({}), + props: z + .object({ + reasonCategory: z.string().nullable().optional(), + reasonDetails: z.string().optional(), + }) + .strict(), summary: 'Delete the authenticated user account', tag: 'Users', }, diff --git a/common/src/supabase/schema.ts b/common/src/supabase/schema.ts index 4a8a1987..ab5a6447 100644 --- a/common/src/supabase/schema.ts +++ b/common/src/supabase/schema.ts @@ -321,6 +321,30 @@ export type Database = { }, ] } + deleted_users: { + Row: { + deleted_time: string + id: string + reason_category: string | null + reason_details: string | null + username: string | null + } + Insert: { + deleted_time?: string + id?: string + reason_category?: string | null + reason_details?: string | null + username?: string | null + } + Update: { + deleted_time?: string + id?: string + reason_category?: string | null + reason_details?: string | null + username?: string | null + } + Relationships: [] + } events: { Row: { created_time: string diff --git a/web/components/buttons/confirmation-button.tsx b/web/components/buttons/confirmation-button.tsx index 475c7412..8180b18e 100644 --- a/web/components/buttons/confirmation-button.tsx +++ b/web/components/buttons/confirmation-button.tsx @@ -24,6 +24,7 @@ export function ConfirmationButton(props: { label?: string color?: ColorType isSubmitting?: boolean + disabled?: boolean } children: ReactNode onSubmit?: () => void @@ -73,6 +74,7 @@ export function ConfirmationButton(props: { } } loading={submitBtn?.isSubmitting} + disabled={submitBtn?.disabled} > {submitBtn?.label ?? 'Submit'} diff --git a/web/components/profile/delete-account-survey-modal.tsx b/web/components/profile/delete-account-survey-modal.tsx new file mode 100644 index 00000000..a2845afa --- /dev/null +++ b/web/components/profile/delete-account-survey-modal.tsx @@ -0,0 +1,223 @@ +import {RadioGroup} from '@headlessui/react' +import router from 'next/router' +import {useState} from 'react' +import toast from 'react-hot-toast' +import {useT} from 'web/lib/locale' +import {deleteAccount} from 'web/lib/util/delete' + +import {ConfirmationButton} from '../buttons/confirmation-button' +import {Col} from '../layout/col' +import {Title} from '../widgets/title' + +export function DeleteAccountSurveyModal() { + const [selectedReason, setSelectedReason] = useState(null) + const [reasonFreeText, setReasonFreeText] = useState('') + const [deleteError, setDeleteError] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + const t = useT() + + const reasonsMap: Record = { + found_connection_on_compass: t( + 'delete_survey.reasons.found_connection_on_compass', + 'I found a meaningful connection on Compass', + ), + + found_connection_elsewhere: t( + 'delete_survey.reasons.found_connection_elsewhere', + 'I found a connection elsewhere', + ), + + not_enough_relevant_people: t( + 'delete_survey.reasons.not_enough_relevant_people', + 'Not enough relevant people near me', + ), + + conversations_didnt_progress: t( + 'delete_survey.reasons.conversations_didnt_progress', + 'Conversations didn’t turn into real connections', + ), + + low_response_rate: t( + 'delete_survey.reasons.low_response_rate', + 'Messages often went unanswered', + ), + + platform_not_active_enough: t( + 'delete_survey.reasons.platform_not_active_enough', + 'The community didn’t feel active enough', + ), + + not_meeting_depth_expectations: t( + 'delete_survey.reasons.not_meeting_depth_expectations', + 'Interactions felt more surface-level than expected', + ), + + too_much_time_or_effort: t( + 'delete_survey.reasons.too_much_time_or_effort', + 'Using the platform required more time or effort than I can give', + ), + + prefer_simpler_apps: t( + 'delete_survey.reasons.prefer_simpler_apps', + 'I prefer simpler or faster apps', + ), + + privacy_concerns: t( + 'delete_survey.reasons.privacy_concerns', + 'Concerns about privacy or profile visibility', + ), + + technical_issues: t('delete_survey.reasons.technical_issues', 'Technical issues or bugs'), + + taking_a_break: t( + 'delete_survey.reasons.taking_a_break', + 'I’m taking a break from meeting apps', + ), + + life_circumstances_changed: t( + 'delete_survey.reasons.life_circumstances_changed', + 'My life circumstances changed', + ), + + other: t('delete_survey.reasons.other', 'Other'), + } + + const handleDeleteAccount = async () => { + setDeleteError(null) // Clear previous errors + + // if (!selectedReason) {} + // setDeleteError() + setIsSubmitting(true) + + // Delete the account (now includes storing the deletion reason) + try { + toast + .promise( + deleteAccount({ + reasonCategory: selectedReason, + reasonDetails: reasonFreeText, + }), + { + loading: t('delete_yourself.toast.loading', 'Deleting account...'), + success: () => { + router.push('/') + return t('delete_yourself.toast.success', 'Your account has been deleted.') + }, + error: () => { + setDeleteError(t('delete_yourself.toast.error', 'Failed to delete account.')) + return t('delete_yourself.toast.error', 'Failed to delete account.') + }, + }, + ) + .catch(() => { + setDeleteError(t('delete_survey.error_saving_reason', 'Error deleting account')) + console.log('Failed to delete account') + }) + + return true + } catch (error) { + console.error('Error deleting account:', error) + setDeleteError(t('delete_survey.error_saving_reason', 'Error deleting account')) + toast.error(t('delete_survey.error_saving_reason', 'Error deleting account')) + return false + } finally { + setIsSubmitting(false) + } + } + + return ( + + + {t('delete_survey.title', 'Sorry to see you go')} + +
+ {t( + 'delete_survey.description', + "We're sorry to see you go. To help us improve Compass, please let us know why you're deleting your account.", + )} +
+ +
+ + + {t('delete_survey.reason_label', 'Why are you deleting your account?')} + + +
+ {Object.entries(reasonsMap).map(([key, value]) => ( + + `${ + checked ? 'bg-canvas-100' : 'border-gray-300' + } relative block cursor-pointer rounded-lg border p-4 focus:bg-canvas-100` + } + > + {({checked}) => ( +
+
+ +
+
+ + {value} + +
+
+ )} +
+ ))} +
+
+ + { +
+ +
+