Allow users to download all their data

This commit is contained in:
MartinBraquet
2026-02-13 16:50:06 +01:00
parent cd434e2fb5
commit 8ff5b8a577
9 changed files with 285 additions and 7 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "@compass/api",
"description": "Backend API endpoints",
"version": "1.6.0",
"version": "1.7.0",
"private": true,
"scripts": {
"watch:serve": "tsx watch src/serve.ts",

View File

@@ -79,6 +79,7 @@ import {getOptions} from "api/get-options";
import {hideProfile} from "api/hide-profile";
import {unhideProfile} from "api/unhide-profile";
import {getHiddenProfiles} from "api/get-hidden-profiles";
import {getUserDataExport} from "./get-user-data-export";
// const corsOptions: CorsOptions = {
// origin: ['*'], // Only allow requests from this domain
@@ -349,6 +350,7 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
'like-profile': likeProfile,
'mark-all-notifs-read': markAllNotifsRead,
'me/delete': deleteMe,
'me/data': getUserDataExport,
'me/private': getCurrentPrivateUser,
'me/update': updateMe,
'react-to-message': reactToMessage,

View File

@@ -0,0 +1,186 @@
import {APIHandler} from './helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {Row} from 'common/supabase/utils'
import {getLikesAndShipsMain} from './get-likes-and-ships'
import {parseJsonContentToText} from 'common/util/parse'
export const getUserDataExport: APIHandler<'me/data'> = async (_, auth) => {
const userId = auth.uid
const pg = createSupabaseDirectClient()
const user = await pg.oneOrNone<Row<'users'>>(
'select * from users where id = $1',
[userId]
)
const privateUser = await pg.oneOrNone<Row<'private_users'>>(
'select * from private_users where id = $1',
[userId]
)
const profile = await pg.oneOrNone(
`
select profiles.*,
users.name,
users.username,
users.data as "user",
COALESCE(profile_interests.interests, '{}') as interests,
COALESCE(profile_causes.causes, '{}') as causes,
COALESCE(profile_work.work, '{}') as work
from profiles
join users on users.id = profiles.user_id
left join (select pi.profile_id,
array_agg(i.name order by i.id) as interests
from profile_interests pi
join interests i on i.id = pi.option_id
group by pi.profile_id) as profile_interests on profile_interests.profile_id = profiles.id
left join (select pc.profile_id,
array_agg(c.name order by c.id) as causes
from profile_causes pc
join causes c on c.id = pc.option_id
group by pc.profile_id) as profile_causes on profile_causes.profile_id = profiles.id
left join (select pw.profile_id,
array_agg(w.name order by w.id) as work
from profile_work pw
join work w on w.id = pw.option_id
group by pw.profile_id) as profile_work on profile_work.profile_id = profiles.id
where profiles.user_id = $1
`,
[userId]
)
if (profile.bio) {
profile.bio_clean = parseJsonContentToText(profile.bio).replace(/\n/g, ' ').trim()
}
const compatibilityAnswers = await pg.manyOrNone(
`
select a.*,
p.question,
p.answer_type,
p.multiple_choice_options,
p.category,
p.importance_score
from compatibility_answers a
join compatibility_prompts p
on p.id = a.question_id
where a.creator_id = $1
order by a.created_time desc
`,
[userId]
)
const userActivity = await pg.oneOrNone<Row<'user_activity'>>(
'select * from user_activity where user_id = $1',
[userId]
)
const searchBookmarks = await pg.manyOrNone<Row<'bookmarked_searches'>>(
'select * from bookmarked_searches where creator_id = $1 order by id desc',
[userId]
)
const hiddenProfiles = await pg.manyOrNone(
`select hp.id, hp.hidden_user_id, hp.created_time, u.username
from hidden_profiles hp
join users u on u.id = hp.hidden_user_id
where hp.hider_user_id = $1
order by hp.id desc`,
[userId]
)
const messageChannelMemberships = await pg.manyOrNone<
Row<'private_user_message_channel_members'>
>(
'select * from private_user_message_channel_members where user_id = $1',
[userId]
)
const channelIds = Array.from(
new Set(messageChannelMemberships.map((m) => m.channel_id))
)
const messageChannels = channelIds.length
? await pg.manyOrNone<Row<'private_user_message_channels'>>(
'select * from private_user_message_channels where id = any($1)',
[channelIds]
)
: []
const messages = channelIds.length
? await pg.manyOrNone<Row<'private_user_messages'>>(
`select *
from private_user_messages
where channel_id = any ($1)
order by created_time`,
[channelIds]
)
: []
const membershipsWithUsernames = channelIds.length
? await pg.manyOrNone(
`
select m.*,
u.username
from private_user_message_channel_members m
join users u on u.id = m.user_id
where m.channel_id = any ($1)
and m.user_id != $2
`,
[channelIds, userId]
)
: []
const endorsements = await getLikesAndShipsMain(userId)
const accountMetadata = {
// userData: (user as any)?.data ?? null,
userActivity,
}
const voteAnswers = await pg.manyOrNone(
`
select r.*,
v.title,
v.description,
v.is_anonymous,
v.status,
v.created_time as vote_created_time
from vote_results r
join votes v on v.id = r.vote_id
where r.user_id = $1
order by v.created_time desc
`,
[userId]
)
const reports = await pg.manyOrNone<Row<'reports'>>(
'select * from reports where user_id = $1 order by created_time desc nulls last',
[userId]
)
const contactMessages = await pg.manyOrNone<Row<'contact'>>(
'select * from contact where user_id = $1 order by created_time desc nulls last',
[userId]
)
return {
user,
privateUser,
profile,
compatibilityAnswers,
voteAnswers,
messages: {
channels: messageChannels,
memberships: membershipsWithUsernames,
messages,
},
endorsements,
searchBookmarks,
hiddenProfiles,
reports,
contactMessages,
accountMetadata,
}
}

View File

@@ -188,6 +188,18 @@ export const API = (_apiTypeCheck = {
summary: 'Get the authenticated user full data',
tag: 'Users',
},
'me/data': {
method: 'GET',
authed: true,
rateLimited: true,
props: z.object({}),
// Full JSON export of the user's data, including
// profile, private user, answers, messages, endorsements, bookmarks, etc.
// We deliberately keep this loosely typed as it's meant for export/inspection.
returns: {} as Record<string, any>,
summary: 'Download all data for the authenticated user as JSON',
tag: 'Users',
},
'me/update': {
method: 'POST',
authed: true,

View File

@@ -18,6 +18,7 @@ export function EmailVerificationButton(props: {
color={'gray-outline'}
onClick={() => sendVerificationEmail(user, t)}
disabled={isEmailVerified}
className={'w-fit'}
>
{isEmailVerified
? t('settings.email.verified', 'Email Verified ✔️')

View File

@@ -979,4 +979,3 @@ const Big5Slider = (props: {
)
}

View File

@@ -860,6 +860,12 @@
"settings.hidden_profiles.unhide": "Einblenden",
"settings.hidden_profiles.empty": "Sie haben keine Profile ausgeblendet.",
"settings.hidden_profiles.description": "Diese Personen erscheinen nicht in Ihren Suchergebnissen.",
"settings.data_privacy.title": "Daten & Datenschutz",
"settings.data_privacy.description": "Laden Sie eine JSON-Datei mit allen Ihren Informationen herunter: Profil, Konto, Nachrichten, Kompatibilitätsantworten, markierte Profile, Stimmen, Empfehlungen, Such-Lesezeichen usw.",
"settings.data_privacy.download": "Alle meine Daten herunterladen (JSON)",
"settings.data_privacy.downloading": "Herunterladen...",
"settings.data_privacy.download.success": "Ihr Daten-Export wurde heruntergeladen.",
"settings.data_privacy.download.error": "Fehler beim Herunterladen Ihrer Daten. Bitte versuchen Sie es erneut.",
"signin.continue": "Oder fortfahren mit",
"signin.email": "E-Mail",
"signin.enter_email": "Bitte geben Sie Ihre E-Mail ein",

View File

@@ -860,6 +860,12 @@
"settings.hidden_profiles.unhide": "Afficher",
"settings.hidden_profiles.empty": "Vous n'avez masqué aucun profil.",
"settings.hidden_profiles.description": "Ces personnes n'apparaissent pas dans vos résultats de recherche.",
"settings.data_privacy.title": "Données & Confidentialité",
"settings.data_privacy.description": "Téléchargez un fichier JSON contenant toutes vos informations : profil, compte, messages, réponses de compatibilité, profils en favoris, votes, recommandations, recherches enregistrées, etc.",
"settings.data_privacy.download": "Télécharger toutes mes données (JSON)",
"settings.data_privacy.downloading": "Téléchargement...",
"settings.data_privacy.download.success": "Votre export de données a été téléchargé.",
"settings.data_privacy.download.error": "Échec du téléchargement de vos données. Veuillez réessayer.",
"signin.continue": "Ou continuez avec",
"signin.email": "E-mail",
"signin.enter_email": "Veuillez taper votre email",

View File

@@ -23,6 +23,8 @@ import {LanguagePicker} from "web/components/language/language-picker";
import {useT} from "web/lib/locale";
import HiddenProfilesModal from 'web/components/settings/hidden-profiles-modal'
import {EmailVerificationButton} from "web/components/email-verification-button";
import {api} from 'web/lib/api'
import {useUser} from "web/hooks/use-user";
export default function NotificationsPage() {
const t = useT()
@@ -116,9 +118,12 @@ const LoadedGeneralSettings = (props: {
<h3>{t('settings.general.language', 'Language')}</h3>
<LanguagePicker className={'w-fit min-w-[120px]'}/>
<h3>{t('settings.data_privacy.title', 'Data & Privacy')}</h3>
<DataPrivacySettings/>
<h3>{t('settings.general.people', 'People')}</h3>
{/*<h5>{t('settings.hidden_profiles.title', 'Hidden profiles')}</h5>*/}
<Button color={'gray-outline'} onClick={() => setShowHiddenProfiles(true)}>
<Button color={'gray-outline'} onClick={() => setShowHiddenProfiles(true)} className="w-fit">
{t('settings.hidden_profiles.manage', 'Manage hidden profiles')}
</Button>
@@ -128,7 +133,7 @@ const LoadedGeneralSettings = (props: {
<EmailVerificationButton user={user}/>
{!isChangingEmail ? (
<Button color={'gray-outline'} onClick={() => setIsChangingEmail(true)}>
<Button color={'gray-outline'} onClick={() => setIsChangingEmail(true)} className="w-fit">
{t('settings.email.change', 'Change email address')}
</Button>
) : (
@@ -155,7 +160,7 @@ const LoadedGeneralSettings = (props: {
)}
</Col>
<div className="flex gap-2">
<Button type="submit" color="green">
<Button type="submit" color="green" className="w-fit">
{t('settings.action.save', 'Save')}
</Button>
<Button
@@ -165,6 +170,7 @@ const LoadedGeneralSettings = (props: {
setIsChangingEmail(false)
reset()
}}
className="w-fit"
>
{t('settings.action.cancel', 'Cancel')}
</Button>
@@ -175,14 +181,14 @@ const LoadedGeneralSettings = (props: {
<h5>{t('settings.general.password', 'Password')}</h5>
<Button
onClick={() => sendPasswordReset(privateUser?.email)}
className="mb-2 max-w-[250px]"
className="mb-2 max-w-[250px] w-fit"
color={'gray-outline'}
>
{t('settings.password.send_reset', 'Send password reset email')}
</Button>
<h5>{t('settings.danger_zone', 'Danger Zone')}</h5>
<Button color="red" onClick={handleDeleteAccount}>
<Button color="red" onClick={handleDeleteAccount} className="w-fit">
{t('settings.delete_account', 'Delete Account')}
</Button>
</div>
@@ -191,3 +197,63 @@ const LoadedGeneralSettings = (props: {
<HiddenProfilesModal open={showHiddenProfiles} setOpen={setShowHiddenProfiles}/>
</>
}
const DataPrivacySettings = () => {
const t = useT()
const user = useUser()
const [isDownloading, setIsDownloading] = useState(false)
const handleDownload = async () => {
if (isDownloading) return
try {
setIsDownloading(true)
const data = await api('me/data', {})
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: 'application/json',
})
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `compass-data-export${user?.username ? `-${user.username}` : ''}.json`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
toast.success(
t(
'settings.data_privacy.download.success',
'Your data export has been downloaded.'
)
)
} catch (e) {
console.error('Error downloading data export', e)
toast.error(
t(
'settings.data_privacy.download.error',
'Failed to download your data. Please try again.'
)
)
} finally {
setIsDownloading(false)
}
}
return (
<div className="flex flex-col gap-4 max-w-xl">
<p className="text-sm guidance">
{t(
'settings.data_privacy.description',
'Download a JSON file containing all your information: profile, account, messages, compatibility answers, starred profiles, votes, endorsements, search bookmarks, etc.'
)}
</p>
<Button color="gray-outline" onClick={handleDownload} className="w-fit" disabled={isDownloading}
loading={isDownloading}>
{isDownloading ? t('settings.data_privacy.downloading', 'Downloading...')
: t('settings.data_privacy.download', 'Download all my data (JSON)')
}
</Button>
</div>
)
}