diff --git a/backend/api/package.json b/backend/api/package.json index 3054163..afdea03 100644 --- a/backend/api/package.json +++ b/backend/api/package.json @@ -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", diff --git a/backend/api/src/app.ts b/backend/api/src/app.ts index f1fbaf3..696e116 100644 --- a/backend/api/src/app.ts +++ b/backend/api/src/app.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 } = { 'like-profile': likeProfile, 'mark-all-notifs-read': markAllNotifsRead, 'me/delete': deleteMe, + 'me/data': getUserDataExport, 'me/private': getCurrentPrivateUser, 'me/update': updateMe, 'react-to-message': reactToMessage, diff --git a/backend/api/src/get-user-data-export.ts b/backend/api/src/get-user-data-export.ts new file mode 100644 index 0000000..c04445c --- /dev/null +++ b/backend/api/src/get-user-data-export.ts @@ -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>( + 'select * from users where id = $1', + [userId] + ) + + const privateUser = await pg.oneOrNone>( + '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>( + 'select * from user_activity where user_id = $1', + [userId] + ) + + const searchBookmarks = await pg.manyOrNone>( + '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>( + 'select * from private_user_message_channels where id = any($1)', + [channelIds] + ) + : [] + + const messages = channelIds.length + ? await pg.manyOrNone>( + `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>( + 'select * from reports where user_id = $1 order by created_time desc nulls last', + [userId] + ) + + const contactMessages = await pg.manyOrNone>( + '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, + } +} + diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index aeed446..577cbe2 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -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, + summary: 'Download all data for the authenticated user as JSON', + tag: 'Users', + }, 'me/update': { method: 'POST', authed: true, diff --git a/web/components/email-verification-button.tsx b/web/components/email-verification-button.tsx index d40b677..a21c5f0 100644 --- a/web/components/email-verification-button.tsx +++ b/web/components/email-verification-button.tsx @@ -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 ✔️') diff --git a/web/components/optional-profile-form.tsx b/web/components/optional-profile-form.tsx index 813a124..83205af 100644 --- a/web/components/optional-profile-form.tsx +++ b/web/components/optional-profile-form.tsx @@ -979,4 +979,3 @@ const Big5Slider = (props: { ) } - diff --git a/web/messages/de.json b/web/messages/de.json index 2c1c46c..c8d0f75 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -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", diff --git a/web/messages/fr.json b/web/messages/fr.json index 77672e7..71c89b0 100644 --- a/web/messages/fr.json +++ b/web/messages/fr.json @@ -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", diff --git a/web/pages/settings.tsx b/web/pages/settings.tsx index 2def5cf..01ac0a4 100644 --- a/web/pages/settings.tsx +++ b/web/pages/settings.tsx @@ -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: {

{t('settings.general.language', 'Language')}

+

{t('settings.data_privacy.title', 'Data & Privacy')}

+ +

{t('settings.general.people', 'People')}

{/*
{t('settings.hidden_profiles.title', 'Hidden profiles')}
*/} - @@ -128,7 +133,7 @@ const LoadedGeneralSettings = (props: { {!isChangingEmail ? ( - ) : ( @@ -155,7 +160,7 @@ const LoadedGeneralSettings = (props: { )}
- @@ -175,14 +181,14 @@ const LoadedGeneralSettings = (props: {
{t('settings.general.password', 'Password')}
{t('settings.danger_zone', 'Danger Zone')}
-
@@ -191,3 +197,63 @@ const LoadedGeneralSettings = (props: { } + +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 ( +
+

+ {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.' + )} +

+ +
+ ) +} +