mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-02-19 15:27:16 -05:00
Allow users to download all their data
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
186
backend/api/src/get-user-data-export.ts
Normal file
186
backend/api/src/get-user-data-export.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ✔️')
|
||||
|
||||
@@ -979,4 +979,3 @@ const Big5Slider = (props: {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user