Prompt for reasons upon profile deletion

This commit is contained in:
MartinBraquet
2026-02-22 18:06:38 +01:00
parent 5362403a04
commit ce680d6c8a
13 changed files with 339 additions and 110 deletions

View File

@@ -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",

View File

@@ -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

View File

@@ -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;

View File

@@ -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);

View File

@@ -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',
},

View File

@@ -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

View File

@@ -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'}
</Button>

View File

@@ -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<string | null>(null)
const [reasonFreeText, setReasonFreeText] = useState('')
const [deleteError, setDeleteError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const t = useT()
const reasonsMap: Record<string, string> = {
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 didnt 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 didnt 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',
'Im 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 (
<ConfirmationButton
openModalBtn={{
className: 'p-2',
label: t('delete_yourself.open_label', 'Delete account'),
color: 'red',
}}
submitBtn={{
label: t('delete_yourself.submit', 'Delete account'),
color: selectedReason ? 'red' : 'gray',
isSubmitting: isSubmitting,
disabled: !(selectedReason && reasonFreeText),
}}
onSubmitWithSuccess={handleDeleteAccount}
disabled={false}
>
<Col className="gap-4">
<Title>{t('delete_survey.title', 'Sorry to see you go')}</Title>
<div>
{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.",
)}
</div>
<div className="w-full">
<RadioGroup value={selectedReason} onChange={setSelectedReason} className="space-y-2">
<RadioGroup.Label className="text-sm font-medium">
{t('delete_survey.reason_label', 'Why are you deleting your account?')}
</RadioGroup.Label>
<div className="space-y-2 mt-2">
{Object.entries(reasonsMap).map(([key, value]) => (
<RadioGroup.Option
key={key}
value={key}
className={({checked}) =>
`${
checked ? 'bg-canvas-100' : 'border-gray-300'
} relative block cursor-pointer rounded-lg border p-4 focus:bg-canvas-100`
}
>
{({checked}) => (
<div className="flex items-center">
<div className="flex h-5 items-center">
<input
type="radio"
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500"
checked={checked}
readOnly
/>
</div>
<div className="ml-3 text-sm">
<RadioGroup.Label as="span" className={`font-medium`}>
{value}
</RadioGroup.Label>
</div>
</div>
)}
</RadioGroup.Option>
))}
</div>
</RadioGroup>
{
<div className="mt-4">
<label htmlFor="otherReason" className="block text-sm font-medium">
{t('delete_survey.other_placeholder', 'Please share more details')}*
</label>
<div className="mt-1">
<textarea
id="otherReason"
rows={3}
className="block w-full bg-canvas-0 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
placeholder={t('delete_survey.other_placeholder', 'Please share more details')}
value={reasonFreeText}
onChange={(e) => setReasonFreeText(e.target.value)}
/>
</div>
</div>
}
</div>
{/* Error message display */}
{deleteError && (
<div className="rounded-md">
<h3 className="text-sm font-medium text-red-800">
{t('delete_survey.error_title', 'Error')}: {deleteError}
</h3>
</div>
)}
</Col>
</ConfirmationButton>
)
}

View File

@@ -1,74 +0,0 @@
import {TrashIcon} from '@heroicons/react/solid'
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 {Input} from '../widgets/input'
import {Title} from '../widgets/title'
export function DeleteYourselfButton() {
const [deleteAccountConfirmation, setDeleteAccountConfirmation] = useState('')
const t = useT()
const confirmPhrase = t('delete_yourself.confirm_phrase', 'delete my account')
return (
<ConfirmationButton
openModalBtn={{
className: 'p-2',
label: t('delete_yourself.open_label', 'Permanently delete this account'),
icon: <TrashIcon className="mr-1 h-5 w-5" />,
color: 'red',
}}
submitBtn={{
label: t('delete_yourself.submit', 'Delete account'),
color: deleteAccountConfirmation == confirmPhrase ? 'red' : 'gray',
}}
onSubmitWithSuccess={async () => {
if (deleteAccountConfirmation == confirmPhrase) {
toast
.promise(deleteAccount(), {
loading: t('delete_yourself.toast.loading', 'Deleting account...'),
success: () => {
router.push('/')
return t('delete_yourself.toast.success', 'Your account has been deleted.')
},
error: () => {
return t('delete_yourself.toast.error', 'Failed to delete account.')
},
})
.then(() => {
return true
})
.catch(() => {
return false
})
}
return false
}}
>
<Col>
<Title>{t('delete_yourself.title', 'Are you sure?')}</Title>
<div>
{t(
'delete_yourself.description',
'Deleting your account means you will no longer be able to use your account. You will lose access to all of your data.',
)}
</div>
<Input
type="text"
placeholder={t(
'delete_yourself.input_placeholder',
"Type 'delete my account' to confirm",
)}
className="w-full"
value={deleteAccountConfirmation}
onChange={(e) => setDeleteAccountConfirmation(e.target.value)}
/>
</Col>
</ConfirmationButton>
)
}

View File

@@ -4,9 +4,9 @@ import {api} from 'web/lib/api'
import {firebaseLogout} from 'web/lib/firebase/users'
import {track} from 'web/lib/service/analytics'
export async function deleteAccount() {
export async function deleteAccount(reasons?: {reasonCategory?: string | null; reasonDetails?: string}) {
track('delete account')
await api('me/delete')
await api('me/delete', reasons || {})
await firebaseLogout()
clearUserCookie()
localStorage.clear()

View File

@@ -197,6 +197,25 @@
"delete_yourself.toast.error": "Kontolöschung fehlgeschlagen.",
"delete_yourself.toast.loading": "Konto wird gelöscht..",
"delete_yourself.toast.success": "Ihr Konto wurde gelöscht.",
"delete_survey.title": "Schade, dass Sie gehen",
"delete_survey.description": "Es tut uns leid, dass Sie gehen. Um uns bei der Verbesserung von Compass zu helfen, teilen Sie uns bitte mit, warum Sie Ihr Konto löschen.",
"delete_survey.reason_label": "Warum löschen Sie Ihr Konto?",
"delete_survey.reasons.found_connection_on_compass": "Ich habe eine bedeutungsvolle Verbindung auf Compass gefunden",
"delete_survey.reasons.found_connection_elsewhere": "Ich habe woanders eine Verbindung gefunden",
"delete_survey.reasons.not_enough_relevant_people": "Nicht genügend relevante Leute in meiner Nähe",
"delete_survey.reasons.conversations_didnt_progress": "Gespräche wurden nicht zu echten Verbindungen",
"delete_survey.reasons.low_response_rate": "Nachrichten blieben oft unbeantwortet",
"delete_survey.reasons.platform_not_active_enough": "Die Community fühlte sich nicht aktiv genug an",
"delete_survey.reasons.not_meeting_depth_expectations": "Interaktionen fühlten sich oberflächlicher an als erwartet",
"delete_survey.reasons.too_much_time_or_effort": "Die Nutzung der Plattform erforderte mehr Zeit oder Aufwand als ich geben kann",
"delete_survey.reasons.prefer_simpler_apps": "Ich bevorzuge einfachere oder schnellere Apps",
"delete_survey.reasons.privacy_concerns": "Bedenken bezüglich Datenschutz oder Profilsichtbarkeit",
"delete_survey.reasons.technical_issues": "Technische Probleme oder Fehler",
"delete_survey.reasons.taking_a_break": "Ich mache eine Pause von Dating-Apps",
"delete_survey.reasons.life_circumstances_changed": "Meine Lebensumstände haben sich geändert",
"delete_survey.reasons.other": "Andere",
"delete_survey.other_placeholder": "Bitte geben Sie weitere Details an",
"delete_survey.error_saving_reason": "Fehler beim Speichern des Löschgrunds",
"donate.seo.description": "Spenden Sie zur Unterstützung von Compass",
"donate.seo.title": "Spenden",
"donate.title": "Spenden",

View File

@@ -194,6 +194,25 @@
"delete_yourself.toast.error": "Échec de la suppression du compte.",
"delete_yourself.toast.loading": "Suppression du compte..",
"delete_yourself.toast.success": "Votre compte a été supprimé.",
"delete_survey.title": "Désolé de vous voir partir",
"delete_survey.description": "Nous sommes désolés de vous voir partir. Pour nous aider à améliorer Compass, veuillez nous indiquer pourquoi vous supprimez votre compte.",
"delete_survey.reason_label": "Pourquoi supprimez-vous votre compte ?",
"delete_survey.reasons.found_connection_on_compass": "J'ai trouvé une relation significative sur Compass",
"delete_survey.reasons.found_connection_elsewhere": "J'ai trouvé une relation ailleurs",
"delete_survey.reasons.not_enough_relevant_people": "Pas assez de personnes pertinentes près de chez moi",
"delete_survey.reasons.conversations_didnt_progress": "Les conversations ne se sont pas transformées en vraies relations",
"delete_survey.reasons.low_response_rate": "Les messages restaient souvent sans réponse",
"delete_survey.reasons.platform_not_active_enough": "La communauté ne semblait pas assez active",
"delete_survey.reasons.not_meeting_depth_expectations": "Les interactions semblaient plus superficielles que prévu",
"delete_survey.reasons.too_much_time_or_effort": "Utiliser la plateforme demandait plus de temps ou d'effort que je ne peux donner",
"delete_survey.reasons.prefer_simpler_apps": "Je préfère des applications plus simples ou plus rapides",
"delete_survey.reasons.privacy_concerns": "Préoccupations concernant la confidentialité ou la visibilité du profil",
"delete_survey.reasons.technical_issues": "Problèmes techniques ou bugs",
"delete_survey.reasons.taking_a_break": "Je fais une pause des applications de rencontre",
"delete_survey.reasons.life_circumstances_changed": "Mes circonstances de vie ont changé",
"delete_survey.reasons.other": "Autre",
"delete_survey.other_placeholder": "Veuillez partager plus de détails",
"delete_survey.error_saving_reason": "Erreur lors de l'enregistrement de la raison de suppression",
"donate.seo.description": "Faites un don pour soutenir Compass",
"donate.seo.title": "Faire un don",
"donate.title": "Faire un don",

View File

@@ -1,6 +1,5 @@
import {PrivateUser} from 'common/src/user'
import {updateEmail} from 'firebase/auth'
import router from 'next/router'
import {useState} from 'react'
import {useForm} from 'react-hook-form'
import toast from 'react-hot-toast'
@@ -15,6 +14,7 @@ import MeasurementSystemToggle from 'web/components/measurement-system-toggle'
import {NoSEO} from 'web/components/NoSEO'
import {NotificationSettings} from 'web/components/notifications'
import {PageBase} from 'web/components/page-base'
import {DeleteAccountSurveyModal} from 'web/components/profile/delete-account-survey-modal'
import HiddenProfilesModal from 'web/components/settings/hidden-profiles-modal'
import ThemeIcon from 'web/components/theme-icon'
import {WithPrivateUser} from 'web/components/user/with-user'
@@ -26,7 +26,6 @@ import {useUser} from 'web/hooks/use-user'
import {api} from 'web/lib/api'
import {sendPasswordReset} from 'web/lib/firebase/password'
import {useT} from 'web/lib/locale'
import {deleteAccount} from 'web/lib/util/delete'
import {isNativeMobile} from 'web/lib/util/webview'
export default function NotificationsPage() {
@@ -78,31 +77,6 @@ const LoadedGeneralSettings = (props: {privateUser: PrivateUser}) => {
const user = useFirebaseUser()
if (!user) return null
const handleDeleteAccount = async () => {
const confirmed = confirm(
t(
'settings.delete_confirm',
'Are you sure you want to delete your profile? This cannot be undone.',
),
)
if (confirmed) {
toast
.promise(deleteAccount(), {
loading: t('settings.delete.loading', 'Deleting account...'),
success: () => {
router.push('/')
return t('settings.delete.success', 'Your account has been deleted.')
},
error: () => {
return t('settings.delete.error', 'Failed to delete account.')
},
})
.catch(() => {
console.log('Failed to delete account')
})
}
}
const changeUserEmail = async (newEmail: string) => {
if (!user) return
@@ -217,9 +191,9 @@ const LoadedGeneralSettings = (props: {privateUser: PrivateUser}) => {
</Button>
<h5>{t('settings.danger_zone', 'Danger Zone')}</h5>
<Button color="red" onClick={handleDeleteAccount} className="w-fit">
{t('settings.delete_account', 'Delete Account')}
</Button>
<div className={'w-fit'}>
<DeleteAccountSurveyModal />
</div>
</div>
{/* Hidden profiles modal */}