From 434156414f1343f020f6c0ea65842404ebfaf48f Mon Sep 17 00:00:00 2001 From: Jacob Bowdoin <7559478+jacob-8@users.noreply.github.com> Date: Mon, 24 Feb 2025 15:11:17 -0600 Subject: [PATCH] Finish Firebase migration: managers, contributors, invites, auth (#534) * finish migration away from Firebase (frontend) * Uses Amazon SES --- README.md | 2 +- .../deleteMediaOnDictionaryDelete.ts | 5 - packages/functions/interfaceExplanations.ts | 41 - packages/scripts/config-firebase.ts | 8 +- packages/scripts/download-audio.ts | 54 + packages/scripts/gcs.ts | 15 + .../editors-info-invites-partners.ts | 234 ++++ .../generate-dictionary-inserts.test.ts | 38 - .../generate-dictionary-inserts.ts | 147 --- .../generate-inserts.test.ts | 112 ++ .../migrate-to-supabase/generate-inserts.ts | 154 +++ packages/scripts/migrate-to-supabase/notes.md | 36 + .../operations/operations.ts | 20 +- .../migrate-to-supabase}/timestamp_to_date.ts | 64 +- packages/scripts/package.json | 4 +- .../refactor/recursive-firebase-delete.ts} | 0 packages/scripts/reset-local-db.ts | 1 - packages/site/.env | 11 +- packages/site/.env.development | 3 + packages/site/package.json | 8 +- packages/site/src/app.d.ts | 28 +- .../site/src/db-tests/content-update.test.bak | 20 +- packages/site/src/docs/misc/env.md | 5 +- packages/site/src/hooks.server.ts | 16 +- .../site/src/lib/components/Filter.svelte | 17 +- .../ContributorInvitationStatus.svelte | 8 +- .../ContributorInvitationStatus.variants.ts | 36 +- .../lib/components/home/SelectedDict.svelte | 38 +- .../src/lib/components/modals/Contact.svelte | 14 +- .../src/lib/components/shell/AuthModal.svelte | 152 ++- .../src/lib/components/shell/Header.svelte | 4 +- .../site/src/lib/components/shell/User.svelte | 63 +- .../site/src/lib/components/ui/Toasts.svelte | 66 +- packages/site/src/lib/constants.ts | 5 + .../lib/export/prepareDictionariesForCsv.ts | 9 - packages/site/src/lib/helpers/cookies.ts | 56 + .../src/lib/helpers/dictionariesManaging.ts | 41 - packages/site/src/lib/helpers/inviteHelper.ts | 48 +- packages/site/src/lib/helpers/time.ts | 4 +- packages/site/src/lib/i18n/locales/en.json | 6 +- packages/site/src/lib/mocks/layout.ts | 5 +- .../site/src/lib/server/firebase-admin.ts | 52 - packages/site/src/lib/supabase/auth.ts | 69 ++ packages/site/src/lib/supabase/cached-data.ts | 2 +- .../site/src/lib/supabase/database.types.ts | 4 - .../site/src/lib/supabase/dictionaries.ts | 39 + packages/site/src/lib/supabase/history.ts | 11 - packages/site/src/lib/supabase/index.ts | 7 +- packages/site/src/lib/supabase/operations.ts | 60 +- packages/site/src/lib/supabase/sign_in.ts | 30 + packages/site/src/lib/supabase/user.ts | 83 ++ packages/site/src/routes/+layout.server.ts | 21 +- packages/site/src/routes/+layout.svelte | 4 + packages/site/src/routes/+layout.ts | 23 +- packages/site/src/routes/+page.svelte | 3 +- packages/site/src/routes/+page.ts | 50 +- .../site/src/routes/[dictionaryId]/+layout.ts | 92 +- .../routes/[dictionaryId]/about/+page.svelte | 16 +- .../src/routes/[dictionaryId]/about/+page.ts | 26 +- .../[dictionaryId]/about/_page.variants.ts | 74 +- .../[dictionaryId]/contributors/+page.svelte | 50 +- .../[dictionaryId]/contributors/+page.ts | 138 ++- .../contributors/Citation.svelte | 12 +- .../contributors/Partners.svelte | 18 +- .../contributors/Partners.variants.ts | 172 +-- .../contributors/build-citation.ts | 8 +- .../entries/EntriesPrint.svelte | 21 +- .../routes/[dictionaryId]/entries/View.svelte | 6 +- .../entry/[entryId]/+page.svelte | 2 + .../[dictionaryId]/entry/[entryId]/+page.ts | 19 +- .../entry/[entryId]/EntryDisplay.svelte | 9 +- .../entry/[entryId]/EntryHistory.svelte | 35 +- .../[dictionaryId]/grammar/+page.svelte | 16 +- .../routes/[dictionaryId]/grammar/+page.ts | 27 +- .../invite/[inviteId]/+page.svelte | 138 ++- .../[dictionaryId]/invite/[inviteId]/+page.ts | 54 +- .../[dictionaryId]/settings/+page.svelte | 2 +- .../routes/[dictionaryId]/settings/+page.ts | 10 +- .../[dictionaryId]/synopsis/VisualMap.svelte | 10 +- packages/site/src/routes/account/+page.svelte | 52 +- packages/site/src/routes/admin/+layout.ts | 120 ++ .../routes/admin/dictionaries/+page.svelte | 67 +- .../src/routes/admin/dictionaries/+page.ts | 49 - .../dictionaries/DictionariesHelping.svelte | 47 - .../dictionaries/DictionaryFieldEdit.svelte | 5 +- .../admin/dictionaries/DictionaryRow.svelte | 94 +- .../admin/dictionaries/RolesManagment.svelte | 49 +- .../dictionaries/SelectDictionaryModal.svelte | 50 - .../admin/dictionaries/SelectUserModal.svelte | 38 + .../dictionaries/SortDictionaries.svelte | 33 +- .../dictionaries/dictionaryWithHelpers.d.ts | 20 - .../dictionaryWithHelpers.types.ts | 7 + .../src/routes/admin/dictionaries/export.ts | 34 +- .../site/src/routes/admin/users/+page.svelte | 66 +- .../admin/users/DictionariesHelping.svelte | 24 + .../admin/users/SelectDictionaryModal.svelte | 34 + .../routes/admin/users/SelectUserModal.svelte | 50 - .../src/routes/admin/users/SortUsers.svelte | 85 +- .../src/routes/admin/users/UserRow.svelte | 89 +- .../src/routes/api/db/check-permission.ts | 38 +- .../routes/api/db/content-update/+server.ts | 38 +- .../api/db/create-dictionary/+server.ts | 40 +- .../routes/api/db/create-dictionary/_call.ts | 8 +- .../api/db/update-dev-admin-role/+server.ts | 39 +- .../api/db/update-dev-admin-role/_call.ts | 6 + .../api/db/update-dictionary/+server.ts | 56 - .../routes/api/db/update-dictionary/_call.ts | 11 - .../site/src/routes/api/email/addresses.ts | 7 +- .../routes/api/email/announcement/+server.ts | 8 +- .../src/routes/api/email/invite/+server.ts | 100 +- .../site/src/routes/api/email/invite/_call.ts | 6 + .../new_dictionary/composeMessages.test.ts | 11 +- .../email/new_dictionary/composeMessages.ts | 10 +- .../email/new_dictionary/dictionary-emails.ts | 10 +- .../src/routes/api/email/new_user/+server.ts | 44 +- .../src/routes/api/email/new_user/_call.ts | 6 + .../email/new_user/save-user-to-supabase.ts | 24 - .../site/src/routes/api/email/otp/+server.ts | 43 + .../site/src/routes/api/email/otp/_call.ts | 6 + .../api/email/request_access/+server.ts | 35 +- .../routes/api/email/request_access/_call.ts | 6 + .../site/src/routes/api/email/send-email.ts | 174 +-- .../src/routes/api/gcs_serving_url/+server.ts | 14 +- .../src/routes/api/gcs_serving_url/_call.ts | 9 +- .../site/src/routes/api/upload/+server.ts | 31 +- packages/site/src/routes/api/upload/_call.ts | 8 +- .../src/routes/create-dictionary/+page.svelte | 548 +++++---- .../src/routes/create-dictionary/+page.ts | 35 +- .../site/src/routes/dictionaries/+page.ts | 3 +- packages/site/src/service-worker.ts | 131 +- packages/site/tsconfig.json | 3 +- .../types/dictionary-settings.interface.ts | 5 - packages/types/dictionary.interface.ts | 2 +- packages/types/helper.interface.ts | 7 - packages/types/index.ts | 17 +- packages/types/invite.interface.ts | 18 +- packages/types/supabase/augments.types.ts | 18 + packages/types/supabase/combined.types.ts | 1081 ++++++++++++++++- .../supabase/content-update.interface.ts | 6 - packages/types/supabase/generated.types.ts | 753 +++++++++++- packages/types/supabase/users.types.ts | 4 + packages/types/user.interface.ts | 2 +- pnpm-lock.yaml | 791 +++++++++--- supabase/config.toml | 13 +- ...ined_with_tags__add_linguistic_history.sql | 153 +++ ...202503_move-content-update-client-side.sql | 29 + ...onaries-entries-sentences-texts-tables.sql | 2 +- ...pl-variant-dialects-ei-field-plus-meta.sql | 2 +- .../20250205145245_user-profiles.sql | 44 + ...0250205145246_dictionary-roles-invites.sql | 149 +++ ...0250205145249_dictionary-info-partners.sql | 84 ++ .../migrations/20250205145250_admin-rls.sql | 27 + ...0205145300_users_with_dictionary_roles.sql | 125 ++ 153 files changed, 6213 insertions(+), 2651 deletions(-) delete mode 100644 packages/functions/deleteMediaOnDictionaryDelete.ts delete mode 100644 packages/functions/interfaceExplanations.ts create mode 100644 packages/scripts/download-audio.ts create mode 100644 packages/scripts/gcs.ts create mode 100644 packages/scripts/migrate-to-supabase/editors-info-invites-partners.ts delete mode 100644 packages/scripts/migrate-to-supabase/generate-dictionary-inserts.test.ts delete mode 100644 packages/scripts/migrate-to-supabase/generate-dictionary-inserts.ts create mode 100644 packages/scripts/migrate-to-supabase/generate-inserts.test.ts create mode 100644 packages/scripts/migrate-to-supabase/generate-inserts.ts rename packages/{site/src/lib/transformers => scripts/migrate-to-supabase}/timestamp_to_date.ts (54%) rename packages/{functions/recursiveDelete.ts => scripts/refactor/recursive-firebase-delete.ts} (100%) create mode 100644 packages/site/src/lib/helpers/cookies.ts delete mode 100644 packages/site/src/lib/helpers/dictionariesManaging.ts delete mode 100644 packages/site/src/lib/server/firebase-admin.ts create mode 100644 packages/site/src/lib/supabase/auth.ts delete mode 100644 packages/site/src/lib/supabase/database.types.ts create mode 100644 packages/site/src/lib/supabase/dictionaries.ts delete mode 100644 packages/site/src/lib/supabase/history.ts create mode 100644 packages/site/src/lib/supabase/sign_in.ts create mode 100644 packages/site/src/lib/supabase/user.ts create mode 100644 packages/site/src/routes/admin/+layout.ts delete mode 100644 packages/site/src/routes/admin/dictionaries/+page.ts delete mode 100644 packages/site/src/routes/admin/dictionaries/DictionariesHelping.svelte delete mode 100644 packages/site/src/routes/admin/dictionaries/SelectDictionaryModal.svelte create mode 100644 packages/site/src/routes/admin/dictionaries/SelectUserModal.svelte delete mode 100644 packages/site/src/routes/admin/dictionaries/dictionaryWithHelpers.d.ts create mode 100644 packages/site/src/routes/admin/dictionaries/dictionaryWithHelpers.types.ts create mode 100644 packages/site/src/routes/admin/users/DictionariesHelping.svelte create mode 100644 packages/site/src/routes/admin/users/SelectDictionaryModal.svelte delete mode 100644 packages/site/src/routes/admin/users/SelectUserModal.svelte create mode 100644 packages/site/src/routes/api/db/update-dev-admin-role/_call.ts delete mode 100644 packages/site/src/routes/api/db/update-dictionary/+server.ts delete mode 100644 packages/site/src/routes/api/db/update-dictionary/_call.ts create mode 100644 packages/site/src/routes/api/email/invite/_call.ts create mode 100644 packages/site/src/routes/api/email/new_user/_call.ts delete mode 100644 packages/site/src/routes/api/email/new_user/save-user-to-supabase.ts create mode 100644 packages/site/src/routes/api/email/otp/+server.ts create mode 100644 packages/site/src/routes/api/email/otp/_call.ts create mode 100644 packages/site/src/routes/api/email/request_access/_call.ts delete mode 100644 packages/types/dictionary-settings.interface.ts delete mode 100644 packages/types/helper.interface.ts create mode 100644 packages/types/supabase/users.types.ts create mode 100644 supabase/ideas/202502_dialects_combined_with_tags__add_linguistic_history.sql create mode 100644 supabase/ideas/202503_move-content-update-client-side.sql create mode 100644 supabase/migrations/20250205145245_user-profiles.sql create mode 100644 supabase/migrations/20250205145246_dictionary-roles-invites.sql create mode 100644 supabase/migrations/20250205145249_dictionary-info-partners.sql create mode 100644 supabase/migrations/20250205145250_admin-rls.sql create mode 100644 supabase/migrations/20250205145300_users_with_dictionary_roles.sql diff --git a/README.md b/README.md index ef189d5be..5be188af7 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A mobile-first community focused dictionary-building web app built by [Living To [](https://svelte.dev/) [](https://kit.svelte.dev/) [](https://unocss.dev/integrations/svelte-scoped) -[](https://firebase.google.com/) +[](https://supabase.com/) [](https://vercel.com/) [](https://www.orama.com/) [](https://cloud.google.com/storage) diff --git a/packages/functions/deleteMediaOnDictionaryDelete.ts b/packages/functions/deleteMediaOnDictionaryDelete.ts deleted file mode 100644 index 42b7e604d..000000000 --- a/packages/functions/deleteMediaOnDictionaryDelete.ts +++ /dev/null @@ -1,5 +0,0 @@ -// const bucket = admin.storage().bucket() - -// return bucket.deleteFiles({ -// prefix: `${dictionaryId}`, -// }) diff --git a/packages/functions/interfaceExplanations.ts b/packages/functions/interfaceExplanations.ts deleted file mode 100644 index d24586576..000000000 --- a/packages/functions/interfaceExplanations.ts +++ /dev/null @@ -1,41 +0,0 @@ -export const entryInterface = { - id: - 'Unique Entry ID - use this for links to the entry on Living Dictionaries, as well as to compare new imports with previous data', - lx: - 'lexeme is the linguistic term meaning the \'headword\' for the dictionary entry - this is the word or phrase in the vernacular language which is being described by all of the other data, being photographed, and recorded', - gl: { - en: - 'English gloss - the word/phrase used in English that is a translation of the lexeme into English', - es: - 'Multiple keys can show up in the gloss object - one for each language which the lexemes in this dictionary are being made available in. So for a dictionary that makes words understandable to English and Spanish speakers, you would see a gloss object with \'en\' and \'es\' keys (keys are the bcp codes for languages)', - }, - in: 'interlinearization', - lo: - 'Local/Alternate Orthography - some languages have two writings systems or more, and these fields give opportunity for expressing these alternate writing systems - Look at the optional dictionary.alternateOrthographies object to learn the name of each orthography', - lo2: 'Local Orthography 2...', - lo3: 'Local Orthography 3...', - lo4: 'Local Orthography 4...', - lo5: 'Local Orthography 5...', - di: 'dialect', - ph: 'phonetic pronunciation - usually written in IPA (international phonetic alphabet)', - ps: 'part of speech abrreviation (see partOfSpeechMappings)', - sdn: 'array of semantic domain number strings (see semanticDomainNumberMappings)', - sd: 'semantic domain strings (deprecated)', - nt: 'notes', - xv: 'example vernacular', - xs: - 'example sentences object formatted in same manner as gl object, except the \'vn\' key refers to the vernacular language', - mr: 'morphology (of the lexeme)', - sf: { - speakerName: 'name of speaker in recorded sound file', - sp: 'id speaker in recorded sound file', - ts: 'timestamp of recording', - audioURL: 'Audio file location', - }, - pf: { - imageURL: 'Image file location', - }, - ca: 'created at', - ua: 'updated at', - ei: 'Elicitation Id', -} diff --git a/packages/scripts/config-firebase.ts b/packages/scripts/config-firebase.ts index f9f57d24c..ffa81491e 100644 --- a/packages/scripts/config-firebase.ts +++ b/packages/scripts/config-firebase.ts @@ -7,14 +7,14 @@ import { firebase_dev_service_account, firebase_prod_service_account } from './s import './record-logs' program - .option('-e, --environment [dev/prod]', 'Firebase/Supabase Project', 'dev') + .option('-fb, --firebase [dev/prod]', 'Firebase Project', 'dev') .allowUnknownOption() // because config is shared by multiple scripts .parse(process.argv) -export const environment = program.opts().environment === 'prod' ? 'prod' : 'dev' -console.log(`Firebase running on ${environment}`) +export const firebase_environment = program.opts().firebase === 'prod' ? 'prod' : 'dev' +console.log(`Firebase running on ${firebase_environment}`) -const serviceAccount = environment === 'dev' ? firebase_dev_service_account : firebase_prod_service_account +const serviceAccount = firebase_environment === 'dev' ? firebase_dev_service_account : firebase_prod_service_account export const projectId = serviceAccount.project_id initializeApp({ diff --git a/packages/scripts/download-audio.ts b/packages/scripts/download-audio.ts new file mode 100644 index 000000000..615bc3845 --- /dev/null +++ b/packages/scripts/download-audio.ts @@ -0,0 +1,54 @@ +import * as fs from 'node:fs' +import { pipeline } from 'node:stream/promises' +import { GetObjectCommand } from '@aws-sdk/client-s3' +import type { AudioWithSpeakerIds, EntryMainFields, SenseWithSentences } from '@living-dictionaries/types/supabase/entry.interface' +import { friendlyName } from '@living-dictionaries/site/src/routes/[dictionaryId]/export/friendlyName' +import { GCLOUD_MEDIA_BUCKET_S3 } from './gcs' +import { admin_supabase } from './config-supabase' + +const directory_path = 'downloaded' + +async function download_dictionary_audio(dictionary_id: string) { + let entries: { + main: EntryMainFields + audios?: AudioWithSpeakerIds[] + senses?: SenseWithSentences[] + }[] = [] + let offset = 0 + const limit = 1000 + + do { + const { data, error } = await admin_supabase.from('materialized_entries_view') + .select('id, main, audios, senses') + .eq('dictionary_id', dictionary_id) + .range(offset, offset + limit - 1) + + if (error) { + console.error('Error fetching entries:', error) + throw new Error(error.message) + } + + entries = entries.concat(data) + offset += limit + } while (entries.length % limit === 0 && entries.length !== 0) + + const photo_ids = entries.flatMap(({ senses }) => (senses || []).map(({ photo_ids }) => photo_ids).flat()).filter(Boolean) // just for logging + const audio_ids = entries.flatMap(({ audios }) => (audios || []).map(({ id }) => id)) // just for logging + console.log({ entries_length: entries.length, photo_ids_length: photo_ids.length, audio_ids_length: audio_ids.length }) + + for (const entry of entries.splice(0, 200)) { + for (const audio of entry.audios || []) { + const { Body } = await GCLOUD_MEDIA_BUCKET_S3.send(new GetObjectCommand({ + Bucket: 'talking-dictionaries-alpha.appspot.com', + Key: audio.storage_path, + })) + + const friendly_audio_name = friendlyName(entry, audio.storage_path) + const filePath = `${directory_path}/${friendly_audio_name}` + // @ts-expect-error Body isn't typed as a ReadableStream but it still works + await pipeline(Body, fs.createWriteStream(filePath)) + } + } +} + +download_dictionary_audio('babanki') diff --git a/packages/scripts/gcs.ts b/packages/scripts/gcs.ts new file mode 100644 index 000000000..819c35a02 --- /dev/null +++ b/packages/scripts/gcs.ts @@ -0,0 +1,15 @@ +import { S3Client } from '@aws-sdk/client-s3' +import { config } from 'dotenv' + +config({ path: '../site/.env.production.local' }) + +const GCLOUD_MEDIA_BUCKET_S3 = new S3Client({ + region: 'us', + endpoint: `https://storage.googleapis.com`, + credentials: { + accessKeyId: process.env.GCLOUD_MEDIA_BUCKET_ACCESS_KEY_ID!, // Get these by going to Settings in your bucket > Interoperability and creating a Service Account HMAC (may also require creating a new service account) + secretAccessKey: process.env.GCLOUD_MEDIA_BUCKET_SECRET_ACCESS_KEY!, + }, +}) + +export { GCLOUD_MEDIA_BUCKET_S3 } diff --git a/packages/scripts/migrate-to-supabase/editors-info-invites-partners.ts b/packages/scripts/migrate-to-supabase/editors-info-invites-partners.ts new file mode 100644 index 000000000..f4cb7c6ee --- /dev/null +++ b/packages/scripts/migrate-to-supabase/editors-info-invites-partners.ts @@ -0,0 +1,234 @@ +import { writeFileSync } from 'node:fs' +import path, { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { IHelper, IInvite } from '@living-dictionaries/types/invite.interface' +import type { Citation, IAbout, IGrammar, Partner } from '@living-dictionaries/types/dictionary.interface' +import { db } from '../config-firebase' +import { admin_supabase, environment, postgres } from '../config-supabase' +import { reset_local_db } from '../reset-local-db' +import { sql_file_string } from '../import/to-sql-string' +import { jacob_ld_user_id } from '../constants' +import { sync_users_across_and_write_fb_sb_mappings, write_users_to_disk } from './users' +import { load_fb_to_sb_user_ids } from './get-user-id' +import { generate_inserts } from './generate-inserts' + +migrate_the_rest() + +async function migrate_the_rest() { + if (environment === 'dev') { + await reset_local_db() + } + // if (environment === 'prod') { + // await save_dictionaries() + // } + await write_users_to_disk() // just do once when testing, and once when final run + await sync_users_across_and_write_fb_sb_mappings() // needs run twice, first to sync users, then to save them to disk + await sync_users_across_and_write_fb_sb_mappings() // needs run twice, first to sync users, then to save them to disk + await load_fb_to_sb_user_ids() + + const dictionaries = await load_saved_dictionaries() + + const fb_managers = await get_managers_by_dictionary_id() + const fb_contributors = await get_contributors_by_dictionary_id() + const fb_writeInCollaborators = await get_writeInCollaborators_by_dictionary_id() + const fb_invites = await get_invites_by_dictionary_id() + const fb_partners = await get_partners_by_dictionary_id() + const fb_dictionary_infos = await get_info_by_dictionary_id() + + let sql_query = 'BEGIN;' // Start a transaction + if (environment === 'dev') { + for (const { id, name } of dictionaries) { + const dictionary_sql = sql_file_string('dictionaries', { id, name, created_by: jacob_ld_user_id, updated_by: jacob_ld_user_id }) + sql_query += `${dictionary_sql}\n` + } + } + + const sql = generate_inserts({ + dictionary_ids: dictionaries.map(({ id }) => id), + fb_managers, + fb_contributors, + fb_writeInCollaborators, + fb_dictionary_infos, + fb_invites, + fb_partners, + }) + sql_query += `${sql}\n` + sql_query += '\nCOMMIT;' // End the transaction + try { + writeFileSync(`./logs/${Date.now()}_migrate-the-rest-query.sql`, sql_query) + console.log('executing sql query') + await postgres.execute_query(sql_query) + console.log('finished') + } catch (err) { + console.error(err) + await postgres.execute_query('ROLLBACK;') // Rollback the transaction in case of error + } +} + +const FOLDER = 'firestore-data' +const __dirname = dirname(fileURLToPath(import.meta.url)) + +async function save_dictionaries() { + const { data: dictionaries_1 } = await admin_supabase.from('dictionaries') + .select('id, name') + .order('id', { ascending: true }) + .range(0, 999) + const { data: dictionaries_2 } = await admin_supabase.from('dictionaries') + .select('id, name') + .order('id', { ascending: true }) + .range(1000, 1999) + + writeFileSync(path.resolve(__dirname, FOLDER, 'dictionaries.json'), JSON.stringify([...dictionaries_1, ...dictionaries_2], null, 2)) +} + +async function load_saved_dictionaries() { + const dictionaries = (await import('./firestore-data/dictionaries.json')).default + return dictionaries +} + +// get managers using collection group from dictionaries/{dictionary_id}/managers +async function get_managers_by_dictionary_id() { + const fb_managers: Record = {} + const snapshot = await db.collectionGroup('managers').get() + snapshot.forEach((doc) => { + const data = doc.data() as IHelper + const dictionaryId = doc.ref.parent.parent?.id + + if (dictionaryId) { + if (!fb_managers[dictionaryId]) { + fb_managers[dictionaryId] = [] + } + fb_managers[dictionaryId].push(data) + } else { + console.log('no dictionary id found for manager') + } + }) + return fb_managers +} + +// get contributors using collection group from dictionaries/{dictionary_id}/contributors +async function get_contributors_by_dictionary_id() { + const fb_contributors: Record = {} + const snapshot = await db.collectionGroup('contributors').get() + snapshot.forEach((doc) => { + const data = doc.data() as IHelper + const dictionaryId = doc.ref.parent.parent?.id + + if (dictionaryId) { + if (!fb_contributors[dictionaryId]) { + fb_contributors[dictionaryId] = [] + } + fb_contributors[dictionaryId].push(data) + } else { + console.log('no dictionary id found for contributor') + } + }) + return fb_contributors +} + +// get writeInCollaborators using collection group from dictionaries/{dictionary_id}/writeInCollaborators +async function get_writeInCollaborators_by_dictionary_id() { + const fb_writeInCollaborators: Record = {} + const snapshot = await db.collectionGroup('writeInCollaborators').get() + snapshot.forEach((doc) => { + const data = doc.data() as IHelper + const dictionaryId = doc.ref.parent.parent?.id + + if (dictionaryId) { + if (!fb_writeInCollaborators[dictionaryId]) { + fb_writeInCollaborators[dictionaryId] = [] + } + fb_writeInCollaborators[dictionaryId].push(data) + } else { + console.log('no dictionary id found for writeInCollaborator') + } + }) + return fb_writeInCollaborators +} + +// get partners using collection group from dictionaries/{dictionary_id}/partners +async function get_partners_by_dictionary_id() { + const fb_partners: Record = {} + const snapshot = await db.collectionGroup('partners').get() + snapshot.forEach((doc) => { + const data = doc.data() as Partner + const dictionaryId = doc.ref.parent.parent?.id + + if (dictionaryId) { + if (!fb_partners[dictionaryId]) { + fb_partners[dictionaryId] = [] + } + fb_partners[dictionaryId].push(data) + } else { + console.log('no dictionary id found for partner') + } + }) + return fb_partners +} + +// get invites using collection group from dictionaries/{dictionary_id}/invites +async function get_invites_by_dictionary_id() { + const fb_invites: Record = {} + const snapshot = await db.collectionGroup('invites').get() + snapshot.forEach((doc) => { + const data = doc.data() as IInvite + const dictionaryId = doc.ref.parent.parent?.id + + if (dictionaryId) { + if (!fb_invites[dictionaryId]) { + fb_invites[dictionaryId] = [] + } + fb_invites[dictionaryId].push(data) + } else { + console.log('no dictionary id found for invite') + } + }) + return fb_invites +} + +// get about, grammar, citation using collection group from dictionaries/{dictionary_id}/info/about (about/grammar/citation is the doc id) +async function get_info_by_dictionary_id() { + const fb_dictionary_info: Record = {} + const snapshot = await db.collectionGroup('info').get() + snapshot.forEach((doc) => { + const data = doc.data() as IAbout & IGrammar & Citation + const dictionaryId = doc.ref.parent.parent?.id + + let info_type: 'about' | 'grammar' | 'citation' + if (doc.id === 'about') { + info_type = 'about' + } else if (doc.id === 'grammar') { + info_type = 'grammar' + } else if (doc.id === 'citation') { + info_type = 'citation' + } else { + throw new Error('info type not found') + } + + if (!data[info_type]) { + return + } + + if (dictionaryId) { + if (!fb_dictionary_info[dictionaryId]) { + fb_dictionary_info[dictionaryId] = {} + } + fb_dictionary_info[dictionaryId][info_type] = data[info_type] + if (data.createdBy) { + fb_dictionary_info[dictionaryId].createdBy = data.createdBy + } + if (data.updatedBy) { + fb_dictionary_info[dictionaryId].updatedBy = data.updatedBy + } + } else { + console.log('no dictionary id found for info') + } + }) + return fb_dictionary_info +} diff --git a/packages/scripts/migrate-to-supabase/generate-dictionary-inserts.test.ts b/packages/scripts/migrate-to-supabase/generate-dictionary-inserts.test.ts deleted file mode 100644 index fcc739c14..000000000 --- a/packages/scripts/migrate-to-supabase/generate-dictionary-inserts.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { generate_dictionary_inserts } from './generate-dictionary-inserts' -import type { IDictionary } from './types' - -test(generate_dictionary_inserts, () => { - const sql = generate_dictionary_inserts([{ - id: '-glossary585', - name: 'قاموس المصطلحات', - glossLanguages: [ - 'en', - 'ar', - ], - alternateNames: [ - 'glossary', - ], - entryCount: 0, - iso6393: 'arb', - glottocode: 'eng', - languageUsedByCommunity: true, - communityPermission: 'yes', - authorConnection: 'Arabic is the third most widespread official language after English and French,[16] one of six official languages of the United Nations,[17] and the liturgical language of Islam.[18] Arabic is widely taught in schools and universities around the world and is used to varying degrees in workplaces, governments and the media.[18] During the Middle Ages, Arabic was a major vehicle of culture and learning, especially in science, mathematics and philosophy. As a result, many European languages have borrowed words from it. Arabic influence, mainly in vocabulary, is seen in European languages (mainly Spanish and to a lesser extent Portuguese, Catalan, and Sicilian) owing to the proximity of Europe and the long-lasting Arabic cultural and linguistic presence, mainly in Southern Iberia, during the Al-Andalus era. Maltese is a Semitic language developed from a dialect of Arabic and written in the Latin alphabet.[19] The Balkan languages, including Albanian, Greek, Serbo-Croatian, and Bulgarian, have also acquired many words of Arabic origin, mainly through direct contact with Ottoman Turkish.', - createdBy: 'NMR122L1cMWGMfl7HsHKLB7KnZX2', - updatedBy: 'NMR122L1cMWGMfl7HsHKLB7KnZX2', - createdAt: { - // @ts-expect-error - _seconds: 1732273172, - _nanoseconds: 680000000, - }, - location: '-', - updatedAt: { - _seconds: 1732274159, - _nanoseconds: 17000000, - }, - }] as IDictionary[]) - expect(sql.slice(0, 1000)).toMatchInlineSnapshot(` - "INSERT INTO dictionaries ("alternate_names", "author_connection", "community_permission", "con_language_description", "coordinates", "copyright", "created_at", "created_by", "featured_image", "gloss_languages", "glottocode", "hide_living_tongues_logo", "id", "iso_639_3", "language_used_by_community", "location", "metadata", "name", "orthographies", "print_access", "public", "updated_at", "updated_by") VALUES - ('{glossary}', 'Arabic is the third most widespread official language after English and French,[16] one of six official languages of the United Nations,[17] and the liturgical language of Islam.[18] Arabic is widely taught in schools and universities around the world and is used to varying degrees in workplaces, governments and the media.[18] During the Middle Ages, Arabic was a major vehicle of culture and learning, especially in science, mathematics and philosophy. As a result, many European languages have borrowed words from it. Arabic influence, mainly in vocabulary, is seen in" - `) -}) diff --git a/packages/scripts/migrate-to-supabase/generate-dictionary-inserts.ts b/packages/scripts/migrate-to-supabase/generate-dictionary-inserts.ts deleted file mode 100644 index e7f2336ee..000000000 --- a/packages/scripts/migrate-to-supabase/generate-dictionary-inserts.ts +++ /dev/null @@ -1,147 +0,0 @@ -import type { TablesInsert } from '@living-dictionaries/types' -import { sql_file_string } from '../import/to-sql-string' -import { jacob_ld_user_id } from '../constants' -import type { IDictionary } from './types' -import { get_supabase_user_id_from_firebase_uid } from './get-user-id' - -export function generate_dictionary_inserts(dictionaries: IDictionary[]): string { - let sql_statements = '' - - for (const firebase_dictionary of dictionaries) { - const { id, name, alternateNames, glossLanguages, location, iso6393, glottocode, coordinates, points, regions, public: is_public, printAccess, copyright, alternateOrthographies, languageUsedByCommunity, authorConnection, communityPermission, conLangDescription, featuredImage, hideLivingTonguesLogo, publishYear, population, thumbnail, url, type, createdAt, createdBy, updatedAt, updatedBy } = firebase_dictionary - - const created_by = get_supabase_user_id_from_firebase_uid(createdBy) || jacob_ld_user_id - // @ts-expect-error - const created_at_seconds = createdAt?._seconds || updatedAt?._seconds - const created_at = created_at_seconds ? seconds_to_timestamp_string(created_at_seconds) : new Date().toISOString() - - let metadata: TablesInsert<'dictionaries'>['metadata'] = null - if (publishYear - || population - || thumbnail - || url - || type) { - metadata = { - ...(publishYear && { publish_year: publishYear }), - ...(population && { population }), - ...(thumbnail && { thumbnail }), - ...(url && { url }), - ...(type && { type }), - } - } - - let combined_coordinates: TablesInsert<'dictionaries'>['coordinates'] = null - - if (firebase_dictionary.coordinates === null) { - delete firebase_dictionary.coordinates - } - if (coordinates || points || regions) { - if (coordinates?._lat && coordinates?._long) { - combined_coordinates = { - points: [{ coordinates: { latitude: coordinates._lat, longitude: coordinates._long } }], - } - delete firebase_dictionary.coordinates - } - if (coordinates?._longitude && coordinates?._latitude) { - combined_coordinates = { - points: [{ coordinates: { latitude: coordinates._latitude, longitude: coordinates._longitude } }], - } - delete firebase_dictionary.coordinates - } - if (coordinates?.longitude !== undefined && coordinates?.latitude !== undefined) { - combined_coordinates = { - points: [{ coordinates: { latitude: coordinates.latitude, longitude: coordinates.longitude } }], - } - delete firebase_dictionary.coordinates - } - if (points) { - combined_coordinates = { - ...combined_coordinates, - points: [...combined_coordinates?.points || [], ...points], - } - delete firebase_dictionary.points - } - if (regions) { - combined_coordinates = { - ...combined_coordinates, - regions, - } - delete firebase_dictionary.regions - } - } - - const dictionary: TablesInsert<'dictionaries'> = { - id, - name, - alternate_names: alternateNames, - orthographies: alternateOrthographies ? alternateOrthographies.map(name => ({ bcp: '', name: { default: name } })) : null, - author_connection: authorConnection, - community_permission: communityPermission, - con_language_description: conLangDescription, - location, - coordinates: combined_coordinates, - copyright, - featured_image: featuredImage, - gloss_languages: glossLanguages, - glottocode, - hide_living_tongues_logo: hideLivingTonguesLogo, - iso_639_3: iso6393, - language_used_by_community: languageUsedByCommunity, - metadata, - print_access: printAccess, - public: !!is_public, - created_at, - created_by, - // @ts-expect-error - updated_at: updatedAt?._seconds ? seconds_to_timestamp_string(updatedAt._seconds) : created_at, - updated_by: get_supabase_user_id_from_firebase_uid(updatedBy) || created_by, - } - - delete firebase_dictionary.id - delete firebase_dictionary.name - delete firebase_dictionary.alternateNames - delete firebase_dictionary.glossLanguages - delete firebase_dictionary.location - delete firebase_dictionary.iso6393 - delete firebase_dictionary.glottocode - - delete firebase_dictionary.public - delete firebase_dictionary.printAccess - delete firebase_dictionary.copyright - delete firebase_dictionary.alternateOrthographies - delete firebase_dictionary.languageUsedByCommunity - delete firebase_dictionary.authorConnection - delete firebase_dictionary.communityPermission - delete firebase_dictionary.conLangDescription - delete firebase_dictionary.featuredImage - delete firebase_dictionary.hideLivingTonguesLogo - delete firebase_dictionary.publishYear - delete firebase_dictionary.population - delete firebase_dictionary.thumbnail - delete firebase_dictionary.url - delete firebase_dictionary.type - delete firebase_dictionary.createdAt - delete firebase_dictionary.createdBy - delete firebase_dictionary.updatedAt - delete firebase_dictionary.updatedBy - delete firebase_dictionary.entryCount - delete firebase_dictionary.videoAccess - delete firebase_dictionary.allContribute - // @ts-expect-error - delete firebase_dictionary.lo - - if (Object.keys(firebase_dictionary).length !== 0) { - console.log({ firebase_dictionary }) - throw new Error('Entry not fully converted') - } - - const sql = sql_file_string('dictionaries', dictionary, 'UPSERT') - sql_statements += sql - } - - return sql_statements -} - -function seconds_to_timestamp_string(seconds: number): string { - return new Date(seconds * 1000).toISOString() -} diff --git a/packages/scripts/migrate-to-supabase/generate-inserts.test.ts b/packages/scripts/migrate-to-supabase/generate-inserts.test.ts new file mode 100644 index 000000000..8a944a1e2 --- /dev/null +++ b/packages/scripts/migrate-to-supabase/generate-inserts.test.ts @@ -0,0 +1,112 @@ +import { jacob_ld_user_id } from '../constants' +import { generate_inserts } from './generate-inserts' + +vi.mock('./get-user-id', () => { + return { + get_supabase_user_id_from_firebase_uid: () => jacob_ld_user_id, + } +}) + +vi.mock('node:crypto', () => { + const uuid_template = '11111111-1111-1111-1111-111111111111' + let current_uuid_index = 0 + + function incremental_consistent_uuid() { + return uuid_template.slice(0, -5) + (current_uuid_index++).toString().padStart(5, '0') + } + + return { + randomUUID: incremental_consistent_uuid, + } +}) + +test(generate_inserts, () => { + const sql = generate_inserts({ + dictionary_ids: ['d_id-1', 'd_id-2', 'd_id-3'], + fb_managers: { + 'd_id-1': [ + { id: '1', name: 'Bob' }, + { + id: '2', + name: 'Jim', + // @ts-expect-error + createdAt: { _seconds: 123 }, + }, + ], + 'd_id-2': [ + { id: '3', name: 'Alice' }, + { id: '4', name: 'Eve' }, + ], + }, + fb_contributors: { + 'd_id-1': [ + { id: '5', name: 'Charlie' }, + ], + }, + fb_writeInCollaborators: { + 'd_id-2': [ + { id: 'not-important', name: 'Dave', createdBy: 'jim', updatedBy: 'jim' }, + { id: 'not-important', name: 'Eve' }, + ], + }, + fb_partners: { + 'd_id-1': [ + { + id: 'not-important', + name: 'Frank', + logo: { fb_storage_path: 'foo', specifiable_image_url: 'abc' }, + // @ts-expect-error + createdAt: { _seconds: 123 }, + // @ts-expect-error + updatedAt: { _seconds: 456 }, + }, + { + id: 'not-important', + name: 'George', + }, + ], + }, + fb_invites: { + 'd_id-2': [ + { + id: '8', + targetEmail: 'foo@g.com', + role: 'contributor', + status: 'sent', + dictionaryName: 'bar', + inviterEmail: 'g@g.com', + inviterName: 'h', + }, + ], + }, + fb_dictionary_infos: { + 'd_id-1': { about: 'about 1', citation: 'citation 1', createdBy: 'jim', updatedBy: 'jim' }, + 'd_id-2': { about: 'about 2', grammar: 'grammar 2' }, + }, + }) + expect(sql).toMatchInlineSnapshot(` + "INSERT INTO dictionary_roles ("dictionary_id", "role", "user_id") VALUES + ('d_id-1', 'manager', 'de2d3715-6337-45a3-a81a-d82c3210b2a7'); + INSERT INTO dictionary_roles ("created_at", "dictionary_id", "role", "user_id") VALUES + ('1970-01-01T00:02:03.000Z', 'd_id-1', 'manager', 'de2d3715-6337-45a3-a81a-d82c3210b2a7'); + INSERT INTO dictionary_roles ("dictionary_id", "role", "user_id") VALUES + ('d_id-1', 'contributor', 'de2d3715-6337-45a3-a81a-d82c3210b2a7'); + INSERT INTO photos ("created_at", "created_by", "dictionary_id", "id", "serving_url", "storage_path", "updated_at", "updated_by") VALUES + ('1970-01-01T00:02:03.000Z', 'de2d3715-6337-45a3-a81a-d82c3210b2a7', 'd_id-1', '11111111-1111-1111-1111-111111100000', 'abc', 'foo', '1970-01-01T00:07:36.000Z', 'de2d3715-6337-45a3-a81a-d82c3210b2a7'); + INSERT INTO dictionary_partners ("created_at", "created_by", "dictionary_id", "name", "photo_id", "updated_at", "updated_by") VALUES + ('1970-01-01T00:02:03.000Z', 'de2d3715-6337-45a3-a81a-d82c3210b2a7', 'd_id-1', 'Frank', '11111111-1111-1111-1111-111111100000', '1970-01-01T00:07:36.000Z', 'de2d3715-6337-45a3-a81a-d82c3210b2a7'); + INSERT INTO dictionary_partners ("created_by", "dictionary_id", "name", "updated_by") VALUES + ('de2d3715-6337-45a3-a81a-d82c3210b2a7', 'd_id-1', 'George', 'de2d3715-6337-45a3-a81a-d82c3210b2a7'); + INSERT INTO dictionary_info ("about", "citation", "created_by", "id", "updated_by") VALUES + ('about 1', 'citation 1', 'de2d3715-6337-45a3-a81a-d82c3210b2a7', 'd_id-1', 'de2d3715-6337-45a3-a81a-d82c3210b2a7'); + INSERT INTO dictionary_roles ("dictionary_id", "role", "user_id") VALUES + ('d_id-2', 'manager', 'de2d3715-6337-45a3-a81a-d82c3210b2a7'); + INSERT INTO dictionary_roles ("dictionary_id", "role", "user_id") VALUES + ('d_id-2', 'manager', 'de2d3715-6337-45a3-a81a-d82c3210b2a7'); + INSERT INTO invites ("created_by", "dictionary_id", "inviter_email", "role", "status", "target_email") VALUES + ('de2d3715-6337-45a3-a81a-d82c3210b2a7', 'd_id-2', 'g@g.com', 'contributor', 'sent', 'foo@g.com'); + INSERT INTO dictionary_info ("about", "created_by", "grammar", "id", "updated_by", "write_in_collaborators") VALUES + ('about 2', 'de2d3715-6337-45a3-a81a-d82c3210b2a7', 'grammar 2', 'd_id-2', 'de2d3715-6337-45a3-a81a-d82c3210b2a7', '{Dave,Eve}'); + " + `) +}) diff --git a/packages/scripts/migrate-to-supabase/generate-inserts.ts b/packages/scripts/migrate-to-supabase/generate-inserts.ts new file mode 100644 index 000000000..0bc531b51 --- /dev/null +++ b/packages/scripts/migrate-to-supabase/generate-inserts.ts @@ -0,0 +1,154 @@ +import { randomUUID } from 'node:crypto' +import type { TablesInsert } from '@living-dictionaries/types' +import type { IHelper, IInvite } from '@living-dictionaries/types/invite.interface' +import type { Partner } from '@living-dictionaries/types/dictionary.interface' +import { sql_file_string } from '../import/to-sql-string' +import { jacob_ld_user_id } from '../constants' +import { get_supabase_user_id_from_firebase_uid } from './get-user-id' + +export function generate_inserts({ + dictionary_ids, + fb_managers, + fb_contributors, + fb_partners, + fb_invites, + fb_writeInCollaborators, + fb_dictionary_infos, +}: { + dictionary_ids: string[] + fb_managers: Record + fb_contributors: Record + fb_partners: Record + fb_invites: Record + fb_writeInCollaborators: Record + fb_dictionary_infos: Record +}): string { + let sql_statements = '' + + for (const dictionary_id of dictionary_ids) { + for (const manager of fb_managers[dictionary_id] || []) { + // @ts-expect-error + const seconds_created_at = manager.createdAt?._seconds + + const user_id = get_supabase_user_id_from_firebase_uid(manager.id) + if (!user_id) { + console.error(`trying to add manager: No Supabase user found for Firebase UID: ${manager.id}`) + continue + } + const dictionary_role: TablesInsert<'dictionary_roles'> = { + dictionary_id, + role: 'manager', + user_id: get_supabase_user_id_from_firebase_uid(manager.id), + ...(seconds_created_at && { created_at: seconds_to_timestamp_string(seconds_created_at) }), + } + sql_statements += sql_file_string('dictionary_roles', dictionary_role) + } + + for (const contributor of fb_contributors[dictionary_id] || []) { + // @ts-expect-error + const seconds_created_at = contributor.createdAt?._seconds + + const dictionary_role: TablesInsert<'dictionary_roles'> = { + dictionary_id, + role: 'contributor', + user_id: get_supabase_user_id_from_firebase_uid(contributor.id), + ...(seconds_created_at && { created_at: seconds_to_timestamp_string(seconds_created_at) }), + } + sql_statements += sql_file_string('dictionary_roles', dictionary_role) + } + + for (const partner of fb_partners[dictionary_id] || []) { + // @ts-expect-error + const seconds_created_at = partner.createdAt?._seconds + // @ts-expect-error + const seconds_updated_at = partner.updatedAt?._seconds + + let photo_id: string + if (partner.logo?.fb_storage_path) { + photo_id = randomUUID() + const photo: TablesInsert<'photos'> = { + id: photo_id, + dictionary_id, + storage_path: partner.logo.fb_storage_path, + serving_url: partner.logo.specifiable_image_url, + ...(seconds_created_at && { created_at: seconds_to_timestamp_string(seconds_created_at) }), + ...(seconds_updated_at && { updated_at: seconds_to_timestamp_string(seconds_updated_at) }), + created_by: get_supabase_user_id_from_firebase_uid(partner.createdBy) || jacob_ld_user_id, + updated_by: get_supabase_user_id_from_firebase_uid(partner.updatedBy) || jacob_ld_user_id, + } + sql_statements += sql_file_string('photos', photo) + } + + const dictionary_partner: TablesInsert<'dictionary_partners'> = { + dictionary_id, + name: partner.name, + ...(photo_id && { photo_id }), + ...(seconds_created_at && { created_at: seconds_to_timestamp_string(seconds_created_at) }), + ...(seconds_updated_at && { updated_at: seconds_to_timestamp_string(seconds_updated_at) }), + created_by: get_supabase_user_id_from_firebase_uid(partner.createdBy) || jacob_ld_user_id, + updated_by: get_supabase_user_id_from_firebase_uid(partner.updatedBy) || jacob_ld_user_id, + } + sql_statements += sql_file_string('dictionary_partners', dictionary_partner) + } + + for (const invite of fb_invites[dictionary_id] || []) { + // @ts-expect-error + const seconds_created_at = invite.createdAt?._seconds + const dictionary_invite: TablesInsert<'invites'> = { + dictionary_id, + inviter_email: invite.inviterEmail, + target_email: invite.targetEmail, + role: invite.role || 'contributor', + status: invite.status, + created_by: get_supabase_user_id_from_firebase_uid(invite.createdBy) || jacob_ld_user_id, + ...(seconds_created_at && { created_at: seconds_to_timestamp_string(seconds_created_at) }), + } + + sql_statements += sql_file_string('invites', dictionary_invite) + } + + const dictionary_info_update: TablesInsert<'dictionary_info'> = { + id: dictionary_id, + created_by: jacob_ld_user_id, + updated_by: jacob_ld_user_id, + } + + if (fb_writeInCollaborators[dictionary_id]) { + dictionary_info_update.write_in_collaborators = [...fb_writeInCollaborators[dictionary_id].map(({ name }) => name)] + if (fb_writeInCollaborators[dictionary_id][0].createdBy) { + dictionary_info_update.created_by = get_supabase_user_id_from_firebase_uid(fb_writeInCollaborators[dictionary_id][0].createdBy) + } + if (fb_writeInCollaborators[dictionary_id][0].updatedBy) { + dictionary_info_update.updated_by = get_supabase_user_id_from_firebase_uid(fb_writeInCollaborators[dictionary_id][0].updatedBy) + } + } + + if (fb_dictionary_infos[dictionary_id]) { + if (fb_dictionary_infos[dictionary_id].about) + dictionary_info_update.about = fb_dictionary_infos[dictionary_id].about + if (fb_dictionary_infos[dictionary_id].grammar) + dictionary_info_update.grammar = fb_dictionary_infos[dictionary_id].grammar + if (fb_dictionary_infos[dictionary_id].citation) + dictionary_info_update.citation = fb_dictionary_infos[dictionary_id].citation + if (fb_dictionary_infos[dictionary_id].createdBy) + dictionary_info_update.created_by = get_supabase_user_id_from_firebase_uid(fb_dictionary_infos[dictionary_id].createdBy) + if (fb_dictionary_infos[dictionary_id].updatedBy) + dictionary_info_update.updated_by = get_supabase_user_id_from_firebase_uid(fb_dictionary_infos[dictionary_id].updatedBy) + } + + if (Object.keys(dictionary_info_update).length > 3) { + sql_statements += sql_file_string('dictionary_info', dictionary_info_update) + } + } + return sql_statements +} + +function seconds_to_timestamp_string(seconds: number): string { + return new Date(seconds * 1000).toISOString() +} diff --git a/packages/scripts/migrate-to-supabase/notes.md b/packages/scripts/migrate-to-supabase/notes.md index 0f22203b4..41eeedb59 100644 --- a/packages/scripts/migrate-to-supabase/notes.md +++ b/packages/scripts/migrate-to-supabase/notes.md @@ -1,5 +1,41 @@ +# Migrate Auth +- commit, check, test +- push sql to prod +- lock firebase +- run all data-migrations + - partners `dictionaries/${dictionary.id}/partners` + - invites `dictionaries/${dictionary.id}/invites` + - managers `dictionaries/${dictionary.id}/managers` + - contributors `dictionaries/${dictionary.id}/contributors` + - write_in_collaborators `dictionaries/${dictionary.id}/writeInCollaborators` + - about: `dictionaries/${dictionary.id}/info/about` + - grammar `dictionaries/${dictionary.id}/info/grammar` + - citation `dictionaries/${dictionary.id}/info/citation` +- push code live +## Clean Up +- entry history from pop-up entry modal +- test admin rls, alternative is auth.jwt() read https://supabase.com/docs/guides/database/postgres/row-level-security#authjwt to see if better +- Diego: AuthModal.svelte translations +- review updated_by triggers across all tables +- review created_by forcers to see which tables need set_created_by + - audio_speakers + - dictionary_info + - dictionary_partners + - entry_tags + - invites + - sense_photos +- remove Entry History i18n +- remove firebase vercel credentials +- remove import_meta from content update endpoint +- remove firebase in SQL +- remove firebase in scripts +- remove unneeded urls from https://console.cloud.google.com/auth/clients/215143435444-fugm4gpav71r3l89n6i0iath4m436qnv.apps.googleusercontent.com?inv=1&invt=AboyXQ&project=talking-dictionaries-alpha +- move featured images to photos table and make a connection to the dictionary +- use line-clamp instead of truncateString in SelectedDict.svelte and also look at inline-children-elements purpose + # Migrate Entries and Speakers from Firestore to Supabase +- unpack content-update to be handle client-sides after adding RLS policies for photos, audio, video - build new Orama indexes every hour offset after materialized view is updated - Remove extra row in dictionary downloads csv and entries download csv - deal with content-update and content-import interface differences diff --git a/packages/scripts/migrate-to-supabase/operations/operations.ts b/packages/scripts/migrate-to-supabase/operations/operations.ts index b54640852..484fddcee 100644 --- a/packages/scripts/migrate-to-supabase/operations/operations.ts +++ b/packages/scripts/migrate-to-supabase/operations/operations.ts @@ -15,7 +15,7 @@ export function insert_entry({ }) { return prepare_sql({ update_id: randomUUID(), - auth_token: null, + dictionary_id, entry_id, type: 'insert_entry', @@ -39,7 +39,7 @@ export function insert_sense({ }) { return prepare_sql({ update_id: randomUUID(), - auth_token: null, + dictionary_id, entry_id, sense_id, @@ -66,7 +66,7 @@ export function insert_dialect({ }) { return prepare_sql({ update_id: randomUUID(), - auth_token: null, + dictionary_id, dialect_id, type: 'insert_dialect', @@ -98,7 +98,7 @@ export function assign_dialect({ }) { return prepare_sql({ update_id: randomUUID(), - auth_token: null, + dictionary_id, dialect_id, entry_id, @@ -124,7 +124,7 @@ export function upsert_speaker({ }) { return prepare_sql({ update_id: randomUUID(), - auth_token: null, + dictionary_id, speaker_id, type: 'upsert_speaker', @@ -152,7 +152,7 @@ export function assign_speaker({ }) { return prepare_sql({ update_id: randomUUID(), - auth_token: null, + data: { created_by: user_id, created_at: timestamp, @@ -180,7 +180,7 @@ export function upsert_audio({ }) { return prepare_sql({ update_id: randomUUID(), - auth_token: null, + dictionary_id, entry_id, audio_id, @@ -205,7 +205,7 @@ export function insert_sentence({ }) { return prepare_sql({ update_id: randomUUID(), - auth_token: null, + dictionary_id, sentence_id, sense_id, @@ -230,7 +230,7 @@ export function insert_photo({ }) { return prepare_sql({ update_id: randomUUID(), - auth_token: null, + dictionary_id, sense_id, photo_id, @@ -255,7 +255,7 @@ export function insert_video({ }) { return prepare_sql({ update_id: randomUUID(), - auth_token: null, + dictionary_id, sense_id, video_id, diff --git a/packages/site/src/lib/transformers/timestamp_to_date.ts b/packages/scripts/migrate-to-supabase/timestamp_to_date.ts similarity index 54% rename from packages/site/src/lib/transformers/timestamp_to_date.ts rename to packages/scripts/migrate-to-supabase/timestamp_to_date.ts index 1e59c4a19..f58e7c258 100644 --- a/packages/site/src/lib/transformers/timestamp_to_date.ts +++ b/packages/scripts/migrate-to-supabase/timestamp_to_date.ts @@ -1,68 +1,70 @@ -import type { Timestamp } from 'firebase/firestore'; +import type { Timestamp } from 'firebase/firestore' export function convert_timestamp_to_date_object(timestamp: number | Date | Timestamp): Date | null { if (timestamp instanceof Date) - return timestamp; + return timestamp if (typeof timestamp === 'number') { - const SECONDS_LENGTH = 10; - const MILLISECONDS_LENGTH = 13; + const SECONDS_LENGTH = 10 + const MILLISECONDS_LENGTH = 13 if (timestamp.toString().length === SECONDS_LENGTH) { - const milliseconds = timestamp * 1000; - return new Date(milliseconds); + const milliseconds = timestamp * 1000 + return new Date(milliseconds) } if (timestamp.toString().length === MILLISECONDS_LENGTH) - return new Date(timestamp); + return new Date(timestamp) - return null; + return null } // eslint-disable-next-line no-prototype-builtins if (timestamp?.hasOwnProperty('toDate')) - return timestamp.toDate(); + return timestamp.toDate() - return null; + return null } if (import.meta.vitest) { describe('convert_timestamp_to_date_object', () => { - const ts_in_milliseconds = 1620000000000; + const ts_in_milliseconds = 1620000000000 test('converts milliseconds', () => { - const expected = new Date(ts_in_milliseconds); - expect(convert_timestamp_to_date_object(ts_in_milliseconds)).toEqual(expected); - }); + const expected = new Date(ts_in_milliseconds) + expect(convert_timestamp_to_date_object(ts_in_milliseconds)).toEqual(expected) + }) test('converts seconds', () => { - const ts_in_seconds = 1620000000; - const ts_converted_to_milliseconds = ts_in_seconds * 1000; - const expected = new Date(ts_converted_to_milliseconds); - expect(convert_timestamp_to_date_object(ts_in_seconds)).toEqual(expected); - }); + const ts_in_seconds = 1620000000 + const ts_converted_to_milliseconds = ts_in_seconds * 1000 + const expected = new Date(ts_converted_to_milliseconds) + expect(convert_timestamp_to_date_object(ts_in_seconds)).toEqual(expected) + }) test('converts a Firestore Timestamp', () => { const mockToDate = function () { - return new Date(this.toMillis()); + // @ts-expect-error + return new Date(this.toMillis()) } const mockToMillis = function () { - return 1e3 * this.seconds + this.nanoseconds / 1e6; + // @ts-expect-error + return 1e3 * this.seconds + this.nanoseconds / 1e6 } const fs_timestamp = { seconds: 1620000000, nanoseconds: 0, toDate: mockToDate, toMillis: mockToMillis, - } as Timestamp; - const expected = new Date(ts_in_milliseconds); - expect(convert_timestamp_to_date_object(fs_timestamp)).toEqual(expected); - }); + } as Timestamp + const expected = new Date(ts_in_milliseconds) + expect(convert_timestamp_to_date_object(fs_timestamp)).toEqual(expected) + }) test('leaves a date object alone', () => { - const now = new Date(); - expect(convert_timestamp_to_date_object(now)).toBe(now); - }); + const now = new Date() + expect(convert_timestamp_to_date_object(now)).toBe(now) + }) test('handles undefined', () => { - expect(convert_timestamp_to_date_object(undefined)).toBe(null); - }); - }); + expect(convert_timestamp_to_date_object(undefined)).toBe(null) + }) + }) } diff --git a/packages/scripts/package.json b/packages/scripts/package.json index 124e63248..86da3f569 100644 --- a/packages/scripts/package.json +++ b/packages/scripts/package.json @@ -14,12 +14,14 @@ }, "main": "index.ts", "scripts": { + "download-audio": "tsx download-audio -e prod", + "migrate-rest": "tsx migrate-to-supabase/editors-info-invites-partners.ts --firebase prod", + "migrate-rest:prod": "tsx migrate-to-supabase/editors-info-invites-partners.ts --firebase prod -e prod", "import-dictionary:dev:dry": "tsx import/import.ts --id example-v4", "import-dictionary:dev:live": "tsx import/import.ts --id example-v4 --live", "import-dictionary:prod:live": "tsx import/import.ts --id example-v4 -e prod --live", "update-locales": "tsx locales/update-locales.ts", "create-indexes": "tsx create-indexes/add-to-cloudflare.ts", - "migrate-dictionaries": "tsx migrate-to-supabase/dictionaries.ts -e prod", "migrate-users": "tsx migrate-to-supabase/auth.ts", "get-emails": "tsx refactor/get-email.ts -e prod", "test": "vitest", diff --git a/packages/functions/recursiveDelete.ts b/packages/scripts/refactor/recursive-firebase-delete.ts similarity index 100% rename from packages/functions/recursiveDelete.ts rename to packages/scripts/refactor/recursive-firebase-delete.ts diff --git a/packages/scripts/reset-local-db.ts b/packages/scripts/reset-local-db.ts index a01e5d0bf..eba859b27 100644 --- a/packages/scripts/reset-local-db.ts +++ b/packages/scripts/reset-local-db.ts @@ -11,7 +11,6 @@ export async function reset_local_db() { console.info('reseting db from seed sql') await postgres.execute_query(`truncate table auth.users cascade;`) - await postgres.execute_query('truncate table entry_updates cascade;') // const seedFilePath = '../../supabase/seed.sql' // const seed_sql = readFileSync(seedFilePath, 'utf8') diff --git a/packages/site/.env b/packages/site/.env index 6f8d41329..04f6a9faa 100644 --- a/packages/site/.env +++ b/packages/site/.env @@ -2,6 +2,8 @@ # URL Restricted PUBLIC_mapboxAccessToken=pk.eyJ1IjoidGFsa2luZ2RpY3Rpb25hcmllcyIsImEiOiJja3BkYW84NjcwYXA2Mm90NjEzemdrZmxjIn0.W9YL4gEpnfIvHhZ_XaFa1g +PUBLIC_MODE=production +PUBLIC_STORAGE_BUCKET=talking-dictionaries-alpha.appspot.com # Supabase values are overridden by .env.development values when running locally. If you don't care about updating the database schema and running Supabase locally, you can run `pnpm prod` instead of `pnpm dev` to use the production database, just as if you were using the live site. Note that some server endpoints that require a Supabase service role key will not work. PUBLIC_SUPABASE_API_URL=https://actkqboqpzniojhgtqzw.supabase.co @@ -12,10 +14,9 @@ PUBLIC_INBUCKET_URL= # no live inbucket for emails PROCESS_IMAGE_URL= -DKIM_PRIVATE_KEY= -MAILCHANNELS_API_KEY= -FIREBASE_SERVICE_ACCOUNT_CREDENTIALS= -PUBLIC_FIREBASE_CONFIG={"projectId":"placeholder-for-sveltefirets-in-db-tests"} +AWS_SES_ACCESS_KEY_ID= +AWS_SES_SECRET_ACCESS_KEY= +AWS_SES_REGION=us-east-2 GCLOUD_MEDIA_BUCKET_ACCESS_KEY_ID= -GCLOUD_MEDIA_BUCKET_SECRET_ACCESS_KEY= \ No newline at end of file +GCLOUD_MEDIA_BUCKET_SECRET_ACCESS_KEY= diff --git a/packages/site/.env.development b/packages/site/.env.development index 2b4de1091..5b8d41bc6 100644 --- a/packages/site/.env.development +++ b/packages/site/.env.development @@ -4,3 +4,6 @@ PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYm SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU PUBLIC_STUDIO_URL=http://127.0.0.1:54323 # convenience link PUBLIC_INBUCKET_URL=http://127.0.0.1:54324 # convenience link + +PUBLIC_MODE=development +PUBLIC_STORAGE_BUCKET=talking-dictionaries-dev.appspot.com diff --git a/packages/site/package.json b/packages/site/package.json index a0f9da639..b2b96c21f 100644 --- a/packages/site/package.json +++ b/packages/site/package.json @@ -21,11 +21,11 @@ "test:components": "playwright test kitbook", "reset-db": "pnpx tsx ./src/lib/mocks/seed/write-seed-and-reset-db-script.ts", "local-kitbook": "link:../../../kitbook/packages/kitbook", - "local-svelte-pieces": "link:../../../svelte-pieces", - "local-sveltefirets": "link:../../../sveltefirets" + "local-svelte-pieces": "link:../../../svelte-pieces" }, "devDependencies": { "@aws-sdk/client-s3": "^3.693.0", + "@aws-sdk/client-ses": "^3.750.0", "@aws-sdk/s3-request-presigner": "^3.693.0", "@iconify/json": "^2.2.263", "@julr/unocss-preset-forms": "^0.0.5", @@ -46,6 +46,7 @@ "@types/d3-geo": "^3.1.0", "@types/file-saver": "^2.0.4", "@types/geojson": "^7946.0.10", + "@types/google-one-tap": "^1.2.6", "@types/mapbox-gl": "^2.7.6", "@types/mapbox__mapbox-gl-geocoder": "^4.7.3", "@types/pg": "^8.11.8", @@ -59,8 +60,6 @@ "d3-dsv": "^3.0.1", "d3-geo": "^3.1.0", "file-saver": "^2.0.5", - "firebase": "^10.9.0", - "firebase-admin": "^12.7.0", "idb-keyval": "^6.2.1", "jszip": "^3.7.1", "kitbook": "1.0.0-beta.31", @@ -72,7 +71,6 @@ "svelte": "^4.2.12", "svelte-check": "^3.6.8", "svelte-pieces": "2.0.0-next.16", - "sveltefirets": "0.0.42", "topojson-client": "^3.1.0", "tslib": "^2.6.1", "typescript": "^5.1.6", diff --git a/packages/site/src/app.d.ts b/packages/site/src/app.d.ts index 86691b435..9d6038b87 100644 --- a/packages/site/src/app.d.ts +++ b/packages/site/src/app.d.ts @@ -1,24 +1,26 @@ // https://kit.svelte.dev/docs/types#app -// import type { BaseUser } from '$lib/supabase/user' -// import type { AuthResponse } from '@supabase/supabase-js' +import type { AuthResponse } from '@supabase/supabase-js' import type { Readable } from 'svelte/store' import type { LayoutData as DictionaryLayoutData } from './routes/[dictionaryId]/$types' -import type { Supabase } from '$lib/supabase/database.types' +import type { BaseUser } from '$lib/supabase/user' +import type { DictionaryWithRoles } from '$lib/supabase/dictionaries' +import type { Supabase } from '$lib/supabase' declare global { namespace App { - // interface Locals { - // getSession(): Promise | null - // } + interface Locals { + getSession: () => Promise | null + } interface PageData { locale: import('$lib/i18n/locales').LocaleCode t: import('$lib/i18n/types.ts').TranslateFunction - user: Readable admin: Readable - // authResponse: AuthResponse + supabase: Supabase + authResponse: AuthResponse + user: Readable + my_dictionaries: Readable // From dictionary layout so all optional - supabase?: Supabase dictionary?: DictionaryLayoutData['dictionary'] dbOperations?: DictionaryLayoutData['dbOperations'] url_from_storage_path?: DictionaryLayoutData['url_from_storage_path'] @@ -29,6 +31,10 @@ declare global { photos?: DictionaryLayoutData['photos'] videos?: DictionaryLayoutData['videos'] sentences?: DictionaryLayoutData['sentences'] + dictionary_info?: DictionaryLayoutData['dictionary_info'] + dictionary_editors?: DictionaryLayoutData['dictionary_editors'] + load_partners?: DictionaryLayoutData['load_partners'] + update_dictionary?: DictionaryLayoutData['update_dictionary'] } interface PageState { entry_id?: string @@ -48,6 +54,10 @@ declare global { // eslint-disable-next-line ts/method-signature-style startViewTransition(updateCallback: () => Promise): ViewTransition } + + interface Window { + handleSignInWithGoogle: (response) => Promise + } } export {} diff --git a/packages/site/src/db-tests/content-update.test.bak b/packages/site/src/db-tests/content-update.test.bak index bf0c1e407..0a87d3511 100644 --- a/packages/site/src/db-tests/content-update.test.bak +++ b/packages/site/src/db-tests/content-update.test.bak @@ -25,7 +25,7 @@ describe('sense operations', () => { test('add sense with noun class to first entry', async () => { const { error } = await post_request(content_update_endpoint, { update_id: incremental_consistent_uuid(), - auth_token: null, + import_meta: { user_id: seeded_user_id_1, timestamp: test_timestamp, @@ -58,7 +58,7 @@ describe('sense operations', () => { // test.fails('noun class remains (upsert)', async () => { // const { error } = await post_request(content_update_endpoint, { // update_id: incremental_consistent_uuid(), - // auth_token: null, + // // import_meta: { // user_id: seeded_user_id_2, // timestamp: test_timestamp, @@ -97,7 +97,7 @@ describe('sense operations', () => { test('adds glosses field to second sense in first entry', async () => { const { error } = await post_request(content_update_endpoint, { update_id: incremental_consistent_uuid(), - auth_token: null, + import_meta: { user_id: seeded_user_id_1, timestamp: test_timestamp, @@ -140,7 +140,7 @@ describe('sense operations', () => { test('add a third sense to first entry with a glosses field', async () => { const { error } = await post_request(content_update_endpoint, { update_id: incremental_consistent_uuid(), - auth_token: null, + user_id_from_local: seeded_user_id_1, dictionary_id: seeded_dictionary_id, entry_id: first_entry_id, @@ -184,7 +184,7 @@ describe('sense operations', () => { test('delete the third sense from the first entry', async () => { const { error } = await post_request(content_update_endpoint, { update_id: incremental_consistent_uuid(), - auth_token: null, + user_id_from_local: seeded_user_id_1, dictionary_id: seeded_dictionary_id, entry_id: first_entry_id, @@ -228,7 +228,7 @@ describe('sense sentence operations', () => { test('post to endpoint', async () => { const { data, error } = await post_request(content_update_endpoint, { update_id: change_id, - auth_token: null, + user_id_from_local: seeded_user_id_1, dictionary_id: seeded_dictionary_id, sentence_id: first_sentence_id, @@ -328,7 +328,7 @@ describe('sense sentence operations', () => { test('post to endpoint', async () => { const { data, error } = await post_request(content_update_endpoint, { update_id: change_id, - auth_token: null, + user_id_from_local: seeded_user_id_1, dictionary_id: seeded_dictionary_id, sentence_id: first_sentence_id, @@ -400,7 +400,7 @@ describe('sense sentence operations', () => { const change_id = incremental_consistent_uuid() await post_request(content_update_endpoint, { update_id: change_id, - auth_token: null, + user_id_from_local: seeded_user_id_1, dictionary_id: seeded_dictionary_id, sentence_id: first_sentence_id, @@ -435,7 +435,7 @@ describe('sense sentence operations', () => { const change_id = incremental_consistent_uuid() const { data, error } = await post_request(content_update_endpoint, { update_id: change_id, - auth_token: null, + user_id_from_local: seeded_user_id_1, dictionary_id: seeded_dictionary_id, sentence_id: first_sentence_id, @@ -484,7 +484,7 @@ describe('sense sentence operations', () => { test('remove sentence from sense', async () => { const { error } = await post_request(content_update_endpoint, { update_id: incremental_consistent_uuid(), - auth_token: null, + user_id_from_local: seeded_user_id_1, dictionary_id: seeded_dictionary_id, sentence_id: first_sentence_id, diff --git a/packages/site/src/docs/misc/env.md b/packages/site/src/docs/misc/env.md index b7133c22c..c7dd1d62c 100644 --- a/packages/site/src/docs/misc/env.md +++ b/packages/site/src/docs/misc/env.md @@ -1,6 +1,5 @@ # Environment Variables -- Ask for the .env.local to get the unrestricted Mapbox key and Firebase dev api key for working on localhost -- For Vercel deployment, putting each line from the contents of a private .env file into an environment variable will work also (e.g. PUBLIC_FIREBASE_CONFIG=) +- Ask for the .env.local to get the unrestricted Mapbox key and other dev api keys for working on localhost -- The dictionary import script in /functions requires a .env file. \ No newline at end of file +- The dictionary import script in /functions requires a .env file. diff --git a/packages/site/src/hooks.server.ts b/packages/site/src/hooks.server.ts index 84b3e58f8..e04fcbbf2 100644 --- a/packages/site/src/hooks.server.ts +++ b/packages/site/src/hooks.server.ts @@ -1,7 +1,19 @@ +import { getSession, getSupabase } from '$lib/supabase' +import { ACCESS_TOKEN_COOKIE_NAME, REFRESH_TOKEN_COOKIE_NAME } from '$lib/constants' + /** @type {import('@sveltejs/kit').Handle} */ export async function handle({ event, resolve }) { + // only useful for things that are guaranteed to run server-side but not for passing to the client + event.locals.getSession = async () => { + const supabase = getSupabase() + const access_token = event.cookies.get(ACCESS_TOKEN_COOKIE_NAME) + const refresh_token = event.cookies.get(REFRESH_TOKEN_COOKIE_NAME) + const session = await getSession({ supabase, access_token, refresh_token }) + return { ...session, supabase } + } + const response = await resolve(event, { transformPageChunk: ({ html }) => html.replace('%unocss-svelte-scoped.global%', 'unocss_svelte_scoped_global_styles'), - }); - return response; + }) + return response } diff --git a/packages/site/src/lib/components/Filter.svelte b/packages/site/src/lib/components/Filter.svelte index 7606a318e..e9468d9ec 100644 --- a/packages/site/src/lib/components/Filter.svelte +++ b/packages/site/src/lib/components/Filter.svelte @@ -1,18 +1,17 @@ diff --git a/packages/site/src/lib/components/contributors/ContributorInvitationStatus.svelte b/packages/site/src/lib/components/contributors/ContributorInvitationStatus.svelte index 7b503bca9..f13c2702c 100644 --- a/packages/site/src/lib/components/contributors/ContributorInvitationStatus.svelte +++ b/packages/site/src/lib/components/contributors/ContributorInvitationStatus.svelte @@ -1,17 +1,17 @@ -
+
Invited: - {invite.targetEmail} + {invite.target_email} {#if admin} {:else} - -
- {@html sanitize(truncateString(about, 200))} - {#if about.length > 200} - - {$page.data.t('home.read_more')} - - {/if} -
-
+
+ {@html sanitize(truncateString(about, 200))} + {#if about.length > 200} + + {$page.data.t('home.read_more')} + + {/if} +
+
+ + {:else} +
+ {$page.data.t('account.enter_6_digit_code_sent_to')}: {email} +
+ + {/if} diff --git a/packages/site/src/lib/components/shell/Header.svelte b/packages/site/src/lib/components/shell/Header.svelte index 664e45807..7d2324f86 100644 --- a/packages/site/src/lib/components/shell/Header.svelte +++ b/packages/site/src/lib/components/shell/Header.svelte @@ -1,8 +1,8 @@
@@ -22,7 +22,7 @@ class="mr-2 ml-2" style="height: 30px; width: 30px; filter: invert(100%);" /> {$page.data.t('misc.LD')} - {#if firebaseConfig.projectId === 'talking-dictionaries-dev'} + {#if mode === 'development'} (dev) {/if} diff --git a/packages/site/src/lib/components/shell/User.svelte b/packages/site/src/lib/components/shell/User.svelte index 4159c380f..23641ff25 100644 --- a/packages/site/src/lib/components/shell/User.svelte +++ b/packages/site/src/lib/components/shell/User.svelte @@ -1,10 +1,11 @@ {#if $user}
{#if show_menu} -
{$user.displayName}
-
{$user.email}
+
{$user.user_metadata?.full_name || $user.email}
+ {#if $user.user_metadata?.full_name} +
{$user.email}
+ {/if} {#if $admin} Admin Panel @@ -44,12 +71,12 @@ {/if} {$page.data.t('account.account_settings')} - - {#if firebaseConfig.projectId === 'talking-dictionaries-dev'} + + {#if mode === 'development'} {/if}
@@ -65,7 +92,7 @@ {#if show} {#await import('$lib/components/shell/AuthModal.svelte') then { default: AuthModal }} - + {/await} {/if} diff --git a/packages/site/src/lib/components/ui/Toasts.svelte b/packages/site/src/lib/components/ui/Toasts.svelte index 3ad976d24..f8010a84c 100644 --- a/packages/site/src/lib/components/ui/Toasts.svelte +++ b/packages/site/src/lib/components/ui/Toasts.svelte @@ -1,43 +1,47 @@ - - onMount(() => { - //@ts-ignore - window.pushToast = pushToast; - }); + -
- {#each toasts as toast (toast._id)} +
+ {#each $toasts as toast (toast.id)}
- {toast.msg} + {toast.message}
{/each}
- + + diff --git a/packages/site/src/lib/constants.ts b/packages/site/src/lib/constants.ts index b8ba9df77..e3061446a 100644 --- a/packages/site/src/lib/constants.ts +++ b/packages/site/src/lib/constants.ts @@ -12,3 +12,8 @@ export enum ResponseCodes { } export const MINIMUM_ABOUT_LENGTH = 200 + +export const ACCESS_TOKEN_COOKIE_NAME = 'sb-access-token' +export const REFRESH_TOKEN_COOKIE_NAME = 'sb-refresh-token' + +export const USER_LOCAL_STORAGE_KEY = 'ld_user' diff --git a/packages/site/src/lib/export/prepareDictionariesForCsv.ts b/packages/site/src/lib/export/prepareDictionariesForCsv.ts index 411728b58..270d04f24 100644 --- a/packages/site/src/lib/export/prepareDictionariesForCsv.ts +++ b/packages/site/src/lib/export/prepareDictionariesForCsv.ts @@ -1,5 +1,4 @@ import type { DictionaryView } from '@living-dictionaries/types' -import type { Timestamp } from 'firebase/firestore' enum StandardDictionaryCSVFields { name = 'Dictionary Name', @@ -41,14 +40,6 @@ export function prepareDictionaryForCsv(dictionary: DictionaryView): StandardDic } } -export function timestamp_to_string_date(timestamp: Timestamp): string { - if (timestamp) { - const milliseconds = timestamp.seconds * 1000 + Math.floor(timestamp.nanoseconds / 1000000) - const date = new Date(milliseconds) - return date.toDateString() - } -} - function create_dictionary_url(dictionary_id: string) { return `https://livingdictionaries.app/${dictionary_id}` } diff --git a/packages/site/src/lib/helpers/cookies.ts b/packages/site/src/lib/helpers/cookies.ts new file mode 100644 index 000000000..95ce14dc3 --- /dev/null +++ b/packages/site/src/lib/helpers/cookies.ts @@ -0,0 +1,56 @@ +export function set_cookie(name: string, value: string, options: CookieOptions = {}) { + document.cookie = format_cookie(name, value, options) +} + +interface CookieOptions { + domain?: string + expires?: string | Date + httpOnly?: boolean + maxAge?: number + path?: string + sameSite?: 'strict' | 'lax' | 'none' + secure?: boolean +} + +function format_cookie(name: string, value: string, options: CookieOptions = {}) { + if (options.expires instanceof Date) + options.expires = options.expires.toUTCString() + + const updatedCookie = { + [encodeURIComponent(name)]: encodeURIComponent(value), + sameSite: 'strict', + path: '/', + ...options, + } + + const cookie = Object.entries(updatedCookie) + .filter(([key]) => key !== 'secure') + .map(kv => kv.join('=')) + .join(';') + + return options.secure === false ? cookie : `${cookie};secure` +} + +if (import.meta.vitest) { + describe(set_cookie, () => { + test('basic, secure by default, root path by default', () => { + const cookie = format_cookie('my-cookie', '123') + expect(cookie).toEqual('my-cookie=123;sameSite=strict;path=/;secure') + }) + + test('handles options', () => { + const cookie = format_cookie('my-cookie', '123', { maxAge: 1234, path: '/foo', sameSite: 'lax' }) + expect(cookie).toEqual('my-cookie=123;sameSite=lax;path=/foo;maxAge=1234;secure') + }) + + test('secure false', () => { + const cookie = format_cookie('my-cookie', '123', { secure: false }) + expect(cookie).toEqual('my-cookie=123;sameSite=strict;path=/') + }) + + test('clear cookie', () => { + const cookie = format_cookie('my-cookie', '', { expires: new Date(0) }) + expect(cookie).toEqual('my-cookie=;sameSite=strict;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT;secure') + }) + }) +} diff --git a/packages/site/src/lib/helpers/dictionariesManaging.ts b/packages/site/src/lib/helpers/dictionariesManaging.ts deleted file mode 100644 index 3443981ae..000000000 --- a/packages/site/src/lib/helpers/dictionariesManaging.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { deleteDocument, deleteDocumentOnline, setOnline } from 'sveltefirets' -import type { IHelper } from '@living-dictionaries/types' - -export async function addDictionaryManager(manager: IHelper, dictionaryId: string) { - await setOnline(`dictionaries/${dictionaryId}/managers/${manager.id}`, { - id: manager.id, - name: manager.name, - }) -} - -export async function removeDictionaryManager(manager: IHelper, dictionaryId: string) { - if (confirm(`Are you sure you want to remove ${manager.name} as manager from ${dictionaryId}?`)) - await deleteDocumentOnline(`dictionaries/${dictionaryId}/managers/${manager.id}`) -} - -export async function addDictionaryContributor(contributor: IHelper, dictionaryId: string) { - await setOnline(`dictionaries/${dictionaryId}/contributors/${contributor.id}`, { - id: contributor.id, - name: contributor.name, - }) -} - -export async function removeDictionaryContributor(contributor: IHelper, dictionaryId: string) { - if ( - confirm( - `Are you sure you want to remove ${contributor.name} as contributor from ${dictionaryId}?`, - ) - ) { - await deleteDocument(`dictionaries/${dictionaryId}/contributors/${contributor.id}`) - } -} - -export async function removeDictionaryCollaborator(collaborator: IHelper, dictionaryId: string) { - if ( - confirm( - `Are you sure you want to remove ${collaborator.name} as write-in collaborator from ${dictionaryId}?`, - ) - ) { - await deleteDocument(`dictionaries/${dictionaryId}/writeInCollaborators/${collaborator.id}`) - } -} diff --git a/packages/site/src/lib/helpers/inviteHelper.ts b/packages/site/src/lib/helpers/inviteHelper.ts index 42a07cc4c..03d59a549 100644 --- a/packages/site/src/lib/helpers/inviteHelper.ts +++ b/packages/site/src/lib/helpers/inviteHelper.ts @@ -1,47 +1,29 @@ -import type { DictionaryView, IInvite, Tables } from '@living-dictionaries/types' import { get } from 'svelte/store' -import { authState } from 'sveltefirets' -import { post_request } from './get-post-requests' import { page } from '$app/stores' -import type { InviteRequestBody } from '$api/email/invite/+server' +import { api_dictionary_invite } from '$api/email/invite/_call' export async function inviteHelper( role: 'manager' | 'contributor', - dictionary: Tables<'dictionaries'> | DictionaryView, + dictionary_id: string, ) { - const { data: { t, user } } = get(page) - const $user = get(user) + const { data: { t } } = get(page) - const targetEmail = prompt(`${t('contact.email')}?`) - if (!targetEmail) return + const target_email = prompt(`${t('contact.email')}?`) + if (!target_email) return - const isEmail = /^\S[^\s@]*@\S[^\s.]*\.\S+$/.test(targetEmail) + const isEmail = /^\S[^\s@]*@\S[^\s.]*\.\S+$/.test(target_email) if (!isEmail) return alert(t('misc.invalid')) - try { - const invite: IInvite = { - inviterEmail: $user.email, - inviterName: $user.displayName, - dictionaryName: dictionary.name, - targetEmail, - role, - status: 'queued', - } + const { error } = await api_dictionary_invite({ + dictionary_id, + role, + target_email, + origin: location.origin, + }) - const auth_state_user = get(authState) - const auth_token = await auth_state_user.getIdToken() - - const { error } = await post_request('/api/email/invite', { - auth_token, - dictionaryId: dictionary.id, - invite, - }) - - if (error) - throw new Error(error.message) - } catch (err) { - alert(`${t('misc.error')}: ${err}`) - console.error(err) + if (error) { + alert(`${t('misc.error')}: ${error.message}`) + console.error(error) } } diff --git a/packages/site/src/lib/helpers/time.ts b/packages/site/src/lib/helpers/time.ts index 72c3c7c38..26a0254d5 100644 --- a/packages/site/src/lib/helpers/time.ts +++ b/packages/site/src/lib/helpers/time.ts @@ -18,13 +18,13 @@ export function printDate(date: Date | number): string { export function supabase_date_to_friendly(supabase_date: string, language_code = 'en-US'): string { const date = new Date(supabase_date) + const is_this_year = date.getFullYear() === new Date().getFullYear() return new Intl.DateTimeFormat(language_code, { - year: 'numeric', + year: is_this_year ? undefined : 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric', - second: 'numeric', }).format(date) } diff --git a/packages/site/src/lib/i18n/locales/en.json b/packages/site/src/lib/i18n/locales/en.json index 5af28e8a2..6f37b582b 100644 --- a/packages/site/src/lib/i18n/locales/en.json +++ b/packages/site/src/lib/i18n/locales/en.json @@ -66,8 +66,11 @@ "how_to_cite_instructions": "Add the authors of this dictionary to show their names in the citation" }, "account": { + "your_name": "Your Name", "account_settings": "Account Settings", - "log_out": "Log Out" + "log_out": "Log Out", + "send_code": "Send Code", + "enter_6_digit_code_sent_to": "Enter the 6-digit code sent to" }, "audio": { "tapToStopRecording": "Then you will tap to stop recording", @@ -345,7 +348,6 @@ "history": "History", "entry": "Entry", "editor": "Editor", - "entry_history": "Entry history", "field": "Field", "date": "Date", "old_value": "Former Value", diff --git a/packages/site/src/lib/mocks/layout.ts b/packages/site/src/lib/mocks/layout.ts index ef372a316..c16ece2fd 100644 --- a/packages/site/src/lib/mocks/layout.ts +++ b/packages/site/src/lib/mocks/layout.ts @@ -4,14 +4,14 @@ import type { LayoutData } from '../../routes/$types' import type { LayoutData as DictionaryLayoutData } from '../../routes/[dictionaryId]/$types' import { logDbOperations } from './db' +// @ts-expect-error export const mockAppLayoutData: LayoutData = { t: null, locale: null, admin: readable(0), supabase: null, - // authResponse: null, + authResponse: null, user: readable(null), - user_from_cookies: null, preferred_table_columns: null, } @@ -29,7 +29,6 @@ export const justMockDictionaryLayoutData = { search_entries: null, default_entries_per_page: null, dbOperations: logDbOperations, - load_citation: null, load_partners: null, // about_content: readable(null) as Awaited>>, // about_content: readable({ diff --git a/packages/site/src/lib/server/firebase-admin.ts b/packages/site/src/lib/server/firebase-admin.ts deleted file mode 100644 index 862ed0f29..000000000 --- a/packages/site/src/lib/server/firebase-admin.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { type App, type ServiceAccount, cert, getApps, initializeApp } from 'firebase-admin/app' -import { getAuth } from 'firebase-admin/auth' -import type { Firestore } from 'firebase-admin/firestore' -import { getFirestore } from 'firebase-admin/firestore' -import { FIREBASE_SERVICE_ACCOUNT_CREDENTIALS } from '$env/static/private' - -const SERVICE_ACCOUNT: ServiceAccount & { project_id?: string } = JSON.parse(FIREBASE_SERVICE_ACCOUNT_CREDENTIALS || '{}') // Firebase Admin typings use camelCase but Google Cloud Service Account credentials use snake_case oddly enough - -let firebaseAdminApp: App = null -let db: Firestore = null - -export function getFirebaseAdminApp(): App { - if (firebaseAdminApp) - return firebaseAdminApp - - const currentApps = getApps() - if (currentApps.length) { - [firebaseAdminApp] = currentApps - return firebaseAdminApp - } - - firebaseAdminApp = initializeApp({ - credential: cert(SERVICE_ACCOUNT), - databaseURL: `https://${SERVICE_ACCOUNT.project_id}.firebaseio.com`, - }) - - console.info('Firebase Admin initialized on server') - - return firebaseAdminApp -} - -export function getDb(): Firestore { - if (db) - return db - - db = getFirestore(getFirebaseAdminApp()) - return db -} - -export async function decodeToken(token: string) { - if (!token) - throw new Error('Firebase user token missing.') - - try { - return await getAuth(getFirebaseAdminApp()).verifyIdToken(token) - } catch (err) { - console.error(err) - throw new Error(`Trouble initializing Firebase and verifying token: ${err}`) - } -} - -// see https://github.com/ManuelDeLeon/sveltekit-firebase-ssr for other possible backend firebase-admin use-cases diff --git a/packages/site/src/lib/supabase/auth.ts b/packages/site/src/lib/supabase/auth.ts new file mode 100644 index 000000000..073e7b67b --- /dev/null +++ b/packages/site/src/lib/supabase/auth.ts @@ -0,0 +1,69 @@ +// https://developers.google.com/identity/gsi/web/guides/overview + +import type { CredentialResponse } from 'google-one-tap' +import { loadScriptOnce } from 'svelte-pieces' +import { handle_sign_in_response } from './sign_in' +import { remove_cached_user } from './user' +import { getSupabase } from '$lib/supabase' +import { invalidateAll } from '$app/navigation' + +const client_id = '215143435444-fugm4gpav71r3l89n6i0iath4m436qnv.apps.googleusercontent.com' + +let loaded = false + +async function load_google_sign_in() { + if (loaded) + return + await loadScriptOnce('https://accounts.google.com/gsi/client') + // @ts-expect-error + google.accounts.id.initialize({ + client_id, + callback: handleSignInWithGoogle, + auto_select: true, + itp_support: true, + use_fedcm_for_prompt: true, + }) + // eslint-disable-next-line require-atomic-updates + loaded = true +} + +async function handleSignInWithGoogle(response: CredentialResponse) { + const supabase = getSupabase() + + const { data, error } = await supabase.auth.signInWithIdToken({ + provider: 'google', + token: response.credential, + }) + handle_sign_in_response({ user: data?.user, error, supabase }) +} + +export async function display_one_tap_popover() { + await load_google_sign_in() + // @ts-expect-error + google.accounts.id.prompt() +} + +export async function display_one_tap_button(button_parent: HTMLElement) { + await load_google_sign_in() + // @ts-expect-error + google.accounts.id.renderButton(button_parent, { + theme: 'outline', + size: 'large', + text: 'signin_with', + logo_alignment: 'left', + shape: 'rectangular', + type: 'standard', + }) +} + +export async function sign_out() { + const supabase = getSupabase() + const { error } = await supabase.auth.signOut() + if (error) { + remove_cached_user() + console.error(error) + alert('Error signing out - cleared user cache') + } else { + invalidateAll() + } +} diff --git a/packages/site/src/lib/supabase/cached-data.ts b/packages/site/src/lib/supabase/cached-data.ts index ae96801c9..75dac9627 100644 --- a/packages/site/src/lib/supabase/cached-data.ts +++ b/packages/site/src/lib/supabase/cached-data.ts @@ -1,7 +1,7 @@ import { writable } from 'svelte/store' import { del as del_idb, get as get_idb, set as set_idb } from 'idb-keyval' import type { Database, Tables } from '@living-dictionaries/types' -import type { Supabase } from './database.types' +import type { Supabase } from '.' import { browser } from '$app/environment' export interface CachedDataStoreOptions { diff --git a/packages/site/src/lib/supabase/database.types.ts b/packages/site/src/lib/supabase/database.types.ts deleted file mode 100644 index 2fd931995..000000000 --- a/packages/site/src/lib/supabase/database.types.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { SupabaseClient } from '@supabase/supabase-js' -import type { Database } from '@living-dictionaries/types' - -export type Supabase = SupabaseClient diff --git a/packages/site/src/lib/supabase/dictionaries.ts b/packages/site/src/lib/supabase/dictionaries.ts new file mode 100644 index 000000000..9c96e436c --- /dev/null +++ b/packages/site/src/lib/supabase/dictionaries.ts @@ -0,0 +1,39 @@ +import { readable } from 'svelte/store' +import type { DictionaryView } from '@living-dictionaries/types' +import type { Supabase } from '.' +import { browser } from '$app/environment' + +export interface DictionaryWithRoles extends DictionaryView { + role: 'manager' | 'contributor' +} + +export function create_my_dictionaries_store({ user_id, supabase }: { user_id: string, supabase: Supabase }) { + if (!browser || !user_id) { + return readable([]) + } + const key = `my_dictionaries--${user_id}` + const start_with = JSON.parse(localStorage[key] || '[]') as DictionaryWithRoles[] + const my_dictionaries = readable(start_with, (set) => { + (async () => { + const { data: dictionary_roles, error: my_dictionaries_error } = await supabase.from('dictionary_roles').select().eq('user_id', user_id) + if (my_dictionaries_error) { + console.error(my_dictionaries_error) + return null + } + + const dictionaries = await Promise.all(dictionary_roles.map(async ({ dictionary_id, role }) => { + const { data: dictionary, error } = await supabase.from('dictionaries_view').select().eq('id', dictionary_id).single() + if (error) { + console.error(`Could not fetch my-dictionary: ${dictionary_id}`) + return null + } + return { ...dictionary, role } + })) + const updated_dictionaries = dictionaries.filter(Boolean) as DictionaryWithRoles[] + set(updated_dictionaries) + localStorage.setItem(key, JSON.stringify(updated_dictionaries)) + })() + }) + + return my_dictionaries +} diff --git a/packages/site/src/lib/supabase/history.ts b/packages/site/src/lib/supabase/history.ts deleted file mode 100644 index b997cab46..000000000 --- a/packages/site/src/lib/supabase/history.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { getSupabase } from '.' - -export async function get_entry_history(entry_id: string) { - const supabase = getSupabase() - - const { data: entry_content_updates, error } = await supabase.from('content_updates') - .select('*') - .eq('entry_id', entry_id) - .order('timestamp', { ascending: false }) - return { entry_content_updates, error } -} diff --git a/packages/site/src/lib/supabase/index.ts b/packages/site/src/lib/supabase/index.ts index e7dde3b25..9b17a451a 100644 --- a/packages/site/src/lib/supabase/index.ts +++ b/packages/site/src/lib/supabase/index.ts @@ -1,9 +1,10 @@ -import { type AuthResponse, createClient } from '@supabase/supabase-js' +import { type AuthResponse, type SupabaseClient, createClient } from '@supabase/supabase-js' import type { Database } from '@living-dictionaries/types' -import type { Supabase } from './database.types' -import { PUBLIC_STUDIO_URL, PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_API_URL } from '$env/static/public' +import { PUBLIC_MODE, PUBLIC_STUDIO_URL, PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_API_URL } from '$env/static/public' // https://supabase.com/docs/reference/javascript/typescript-support +export type Supabase = SupabaseClient +export const mode = PUBLIC_MODE as 'development' | 'production' const browser = typeof window !== 'undefined' let supabase: Supabase | undefined diff --git a/packages/site/src/lib/supabase/operations.ts b/packages/site/src/lib/supabase/operations.ts index b26949e1e..1eec68bd7 100644 --- a/packages/site/src/lib/supabase/operations.ts +++ b/packages/site/src/lib/supabase/operations.ts @@ -1,4 +1,3 @@ -import { authState } from 'sveltefirets' import { get } from 'svelte/store' import type { MultiString, TablesInsert, TablesUpdate } from '@living-dictionaries/types' import { page } from '$app/stores' @@ -9,19 +8,15 @@ import { content_update } from '$api/db/content-update/_call' function randomUUID() { return window.crypto.randomUUID() } -async function get_pieces() { - const auth_state_user = get(authState) - const auth_token = await auth_state_user.getIdToken() - +function get_pieces() { const { params: { dictionaryId: dictionary_id, entryId: entry_id_from_url }, state: { entry_id: entry_id_from_state }, data: { entries, photos, videos, sentences, tags, dialects, speakers } } = get(page) - return { auth_token, dictionary_id, entry_id_from_url: entry_id_from_url || entry_id_from_state, refresh_entries: entries.refresh, refresh_photos: photos.refresh, refresh_videos: videos.refresh, refresh_sentences: sentences.refresh, refresh_dialects: dialects.refresh, refresh_speakers: speakers.refresh, refresh_tags: tags.refresh } + return { dictionary_id, entry_id_from_url: entry_id_from_url || entry_id_from_state, refresh_entries: entries.refresh, refresh_photos: photos.refresh, refresh_videos: videos.refresh, refresh_sentences: sentences.refresh, refresh_dialects: dialects.refresh, refresh_speakers: speakers.refresh, refresh_tags: tags.refresh } } export async function insert_entry(lexeme: MultiString) { try { - const { auth_token, dictionary_id, refresh_entries } = await get_pieces() + const { dictionary_id, refresh_entries } = get_pieces() const { data, error } = await content_update({ - auth_token, update_id: randomUUID(), dictionary_id, entry_id: randomUUID(), @@ -48,9 +43,8 @@ export async function update_entry({ entry_id?: string }) { try { - const { auth_token, dictionary_id, entry_id_from_url, refresh_entries } = await get_pieces() + const { dictionary_id, entry_id_from_url, refresh_entries } = get_pieces() const { error } = await content_update({ - auth_token, update_id: randomUUID(), dictionary_id, entry_id: entry_id_from_function || entry_id_from_url || randomUUID(), @@ -78,10 +72,9 @@ export async function insert_sense({ sense_id?: string }) { try { - const { auth_token, dictionary_id, refresh_entries } = await get_pieces() + const { dictionary_id, refresh_entries } = get_pieces() const { error } = await content_update({ - auth_token, update_id: randomUUID(), dictionary_id, entry_id, @@ -109,9 +102,8 @@ export async function update_sense({ sense_id: string }) { try { - const { auth_token, dictionary_id, refresh_entries } = await get_pieces() + const { dictionary_id, refresh_entries } = get_pieces() const { error } = await content_update({ - auth_token, update_id: randomUUID(), dictionary_id, sense_id, @@ -139,9 +131,9 @@ export async function insert_sentence({ sense_id: string }) { try { - const { auth_token, dictionary_id, refresh_entries, refresh_sentences } = await get_pieces() + const { dictionary_id, refresh_entries, refresh_sentences } = get_pieces() const { data, error } = await content_update({ - auth_token, + update_id: randomUUID(), dictionary_id, sense_id, @@ -170,9 +162,8 @@ export async function update_sentence({ sentence_id: string }) { try { - const { auth_token, dictionary_id, refresh_entries, refresh_sentences } = await get_pieces() + const { dictionary_id, refresh_entries, refresh_sentences } = get_pieces() const { data, error } = await content_update({ - auth_token, update_id: randomUUID(), dictionary_id, sentence_id, @@ -204,9 +195,8 @@ export async function upsert_audio({ refresh_entry?: boolean }) { try { - const { auth_token, dictionary_id, refresh_entries } = await get_pieces() + const { dictionary_id, refresh_entries } = get_pieces() const { data, error } = await content_update({ - auth_token, update_id: randomUUID(), dictionary_id, entry_id, @@ -236,9 +226,8 @@ export async function upsert_speaker({ speaker_id?: string }) { try { - const { auth_token, dictionary_id, refresh_speakers } = await get_pieces() + const { dictionary_id, refresh_speakers } = get_pieces() const { data, error } = await content_update({ - auth_token, update_id: randomUUID(), dictionary_id, speaker_id: speaker_id || randomUUID(), @@ -268,9 +257,8 @@ export async function assign_speaker({ remove?: boolean }) { try { - const { auth_token, dictionary_id, refresh_entries, refresh_videos } = await get_pieces() + const { dictionary_id, refresh_entries, refresh_videos } = get_pieces() const { data, error } = await content_update({ - auth_token, update_id: randomUUID(), dictionary_id, speaker_id, @@ -302,9 +290,8 @@ export async function insert_tag({ tag_id?: string }) { try { - const { auth_token, dictionary_id, refresh_tags } = await get_pieces() + const { dictionary_id, refresh_tags } = get_pieces() const { data, error } = await content_update({ - auth_token, update_id: randomUUID(), dictionary_id, tag_id: tag_id || randomUUID(), @@ -332,9 +319,8 @@ export async function assign_tag({ remove?: boolean }) { try { - const { auth_token, dictionary_id, refresh_entries } = await get_pieces() + const { dictionary_id, refresh_entries } = get_pieces() const { data, error } = await content_update({ - auth_token, update_id: randomUUID(), dictionary_id, tag_id, @@ -363,9 +349,8 @@ export async function insert_dialect({ dialect_id?: string }) { try { - const { auth_token, dictionary_id, refresh_dialects } = await get_pieces() + const { dictionary_id, refresh_dialects } = get_pieces() const { data, error } = await content_update({ - auth_token, update_id: randomUUID(), dictionary_id, dialect_id: dialect_id || randomUUID(), @@ -393,9 +378,8 @@ export async function assign_dialect({ remove?: boolean }) { try { - const { auth_token, dictionary_id, refresh_entries } = await get_pieces() + const { dictionary_id, refresh_entries } = get_pieces() const { data, error } = await content_update({ - auth_token, update_id: randomUUID(), dictionary_id, dialect_id, @@ -426,9 +410,8 @@ export async function insert_photo({ sense_id: string }) { try { - const { auth_token, dictionary_id, refresh_entries, refresh_photos } = await get_pieces() + const { dictionary_id, refresh_entries, refresh_photos } = get_pieces() const { data, error } = await content_update({ - auth_token, update_id: randomUUID(), dictionary_id, sense_id, @@ -457,9 +440,8 @@ export async function update_photo({ photo_id: string }) { try { - const { auth_token, dictionary_id, refresh_entries, refresh_photos } = await get_pieces() + const { dictionary_id, refresh_entries, refresh_photos } = get_pieces() const { data, error } = await content_update({ - auth_token, update_id: randomUUID(), dictionary_id, photo_id, @@ -487,9 +469,8 @@ export async function insert_video({ sense_id: string }) { try { - const { auth_token, dictionary_id, refresh_entries, refresh_videos } = await get_pieces() + const { dictionary_id, refresh_entries, refresh_videos } = get_pieces() const { data, error } = await content_update({ - auth_token, update_id: randomUUID(), dictionary_id, sense_id, @@ -518,9 +499,8 @@ export async function update_video({ video_id: string }) { try { - const { auth_token, dictionary_id, refresh_entries, refresh_videos } = await get_pieces() + const { dictionary_id, refresh_entries, refresh_videos } = get_pieces() const { data, error } = await content_update({ - auth_token, update_id: randomUUID(), dictionary_id, video_id, diff --git a/packages/site/src/lib/supabase/sign_in.ts b/packages/site/src/lib/supabase/sign_in.ts new file mode 100644 index 000000000..28fb44c67 --- /dev/null +++ b/packages/site/src/lib/supabase/sign_in.ts @@ -0,0 +1,30 @@ +import type { AuthError, User } from '@supabase/supabase-js' +import { api_email_new_user } from '$api/email/new_user/_call' +import { dev } from '$app/environment' +import { invalidateAll } from '$app/navigation' +import { toast } from '$lib/components/ui/Toasts.svelte' +import type { Supabase } from '$lib/supabase' + +const TEN_SECONDS = 10000 +const FOUR_SECONDS = 4000 + +export async function handle_sign_in_response({ user, error, supabase }: { user?: User, error?: AuthError, supabase: Supabase }) { + if (error) { + console.info({ error }) + return toast(error.message, TEN_SECONDS) + } + + toast(`Signed in with ${user.email}`, FOUR_SECONDS) + invalidateAll() + + if (!dev) { + const { data, error: check_for_email_status_error } = await supabase.from('user_data').select('welcome_email_sent').eq('id', user.id) + if (check_for_email_status_error) + return console.error({ check_for_email_status_error }) + if (!data?.[0]?.welcome_email_sent) { + const { error: sending_welcome_error } = await api_email_new_user({ }) + if (sending_welcome_error) + console.error({ sending_welcome_error }) + } + } +} diff --git a/packages/site/src/lib/supabase/user.ts b/packages/site/src/lib/supabase/user.ts new file mode 100644 index 000000000..c723ffb57 --- /dev/null +++ b/packages/site/src/lib/supabase/user.ts @@ -0,0 +1,83 @@ +import type { AuthResponse, Session, User } from '@supabase/supabase-js' +import { writable } from 'svelte/store' +import type { GoogleAuthUserMetaData } from '@living-dictionaries/types' +import { ACCESS_TOKEN_COOKIE_NAME, REFRESH_TOKEN_COOKIE_NAME, USER_LOCAL_STORAGE_KEY } from '../constants' +import type { Supabase } from '.' +import { set_cookie } from '$lib/helpers/cookies' + +const browser = typeof window !== 'undefined' + +export type BaseUser = User & { + app_metadata: { + provider?: string + providers?: string[] + admin?: number + } + user_metadata: GoogleAuthUserMetaData +} + +/** Subscribes to current user, caches it to local storage, and sets cookie for server-side rendering. */ +export function createUserStore({ supabase, authResponse, log = false }: { supabase: Supabase, authResponse: AuthResponse, log?: boolean }) { + const { subscribe, set } = writable(authResponse?.data.user) + if (browser) { + let cached_user_stringified = null + + if (authResponse?.data?.user) { + cached_user_stringified = localStorage.getItem(USER_LOCAL_STORAGE_KEY) + if (cached_user_stringified) + set(JSON.parse(cached_user_stringified)) + } + + let current_session_expires_at = authResponse?.data?.session?.expires_at + + supabase.auth.onAuthStateChange((event, session) => { + if (log) + console.info({ authStateChangeEvent: event }) + const new_session_expires_at = session?.expires_at + const same_session = current_session_expires_at === new_session_expires_at + if (log) + console.info({ same_session, current_session_expires_at, new_session_expires_at }) + + if (session) { + if (same_session) { + const new_user_stringified = JSON.stringify(session.user) + if (new_user_stringified !== cached_user_stringified) { + set(session.user) + cache_user(session) + } + } else { + set(session.user) + cache_user(session) + } + } else { + set(null) + if (log) + console.info('set user to null') + remove_cached_user() + } + + current_session_expires_at = session?.expires_at + }) + } + + return { + subscribe, + } +} + +function cache_user(session: Session) { + localStorage.setItem(USER_LOCAL_STORAGE_KEY, JSON.stringify(session.user)) + + const century = 100 * 365 * 24 * 60 * 60 + set_cookie(ACCESS_TOKEN_COOKIE_NAME, session.access_token, { maxAge: century, path: '/', sameSite: 'lax' }) + set_cookie(REFRESH_TOKEN_COOKIE_NAME, session.refresh_token, { maxAge: century, path: '/', sameSite: 'lax' }) + // Cookies should be limited to 4kb, about 1,000-4000 characters +} + +export function remove_cached_user() { + localStorage.removeItem(USER_LOCAL_STORAGE_KEY) + + const yearsAgo = new Date(0) + set_cookie(ACCESS_TOKEN_COOKIE_NAME, '', { expires: yearsAgo, path: '/', sameSite: 'lax' }) + set_cookie(REFRESH_TOKEN_COOKIE_NAME, '', { expires: yearsAgo, path: '/', sameSite: 'lax' }) +} diff --git a/packages/site/src/routes/+layout.server.ts b/packages/site/src/routes/+layout.server.ts index c532969db..e9018cfd4 100644 --- a/packages/site/src/routes/+layout.server.ts +++ b/packages/site/src/routes/+layout.server.ts @@ -1,20 +1,17 @@ -import type { IBaseUser } from 'sveltefirets'; -import type { LayoutServerLoad } from './$types'; -import { findSupportedLocaleFromAcceptedLanguages } from '$lib/i18n/locales'; +import type { LayoutServerLoad } from './$types' +import { findSupportedLocaleFromAcceptedLanguages } from '$lib/i18n/locales' +import { ACCESS_TOKEN_COOKIE_NAME, REFRESH_TOKEN_COOKIE_NAME } from '$lib/constants' export const load: LayoutServerLoad = ({ cookies, request }) => { const chosenLocale = cookies.get('locale') const acceptedLanguage = findSupportedLocaleFromAcceptedLanguages(request.headers.get('accept-language')) - let user_from_cookies: IBaseUser = null; - try { - user_from_cookies = JSON.parse(cookies.get('user') || null) as IBaseUser; - } catch (err) { - console.error(err); - } + const access_token = cookies.get(ACCESS_TOKEN_COOKIE_NAME) + const refresh_token = cookies.get(REFRESH_TOKEN_COOKIE_NAME) return { serverLocale: chosenLocale || acceptedLanguage, - user_from_cookies, - }; -}; + access_token, + refresh_token, + } +} diff --git a/packages/site/src/routes/+layout.svelte b/packages/site/src/routes/+layout.svelte index 118d7fd1e..30e0e3eb2 100644 --- a/packages/site/src/routes/+layout.svelte +++ b/packages/site/src/routes/+layout.svelte @@ -14,6 +14,10 @@ {/if} {/if} +{#await import('$lib/components/ui/Toasts.svelte') then { default: Toasts }} + +{/await} + {#if $is_manager} - @@ -195,7 +199,7 @@ {$page.data.t('contributors.how_to_cite_academics')} - +
diff --git a/packages/site/src/routes/[dictionaryId]/contributors/+page.ts b/packages/site/src/routes/[dictionaryId]/contributors/+page.ts index ed0575f82..c7a1489e7 100644 --- a/packages/site/src/routes/[dictionaryId]/contributors/+page.ts +++ b/packages/site/src/routes/[dictionaryId]/contributors/+page.ts @@ -1,20 +1,15 @@ -import { addOnline, deleteDocumentOnline, getCollection, setOnline, updateOnline } from 'sveltefirets' -import type { Citation, IHelper, IInvite, Partner, Tables } from '@living-dictionaries/types' -import { where } from 'firebase/firestore' import type { PageLoad } from './$types' import { upload_image } from '$lib/components/image/upload-image' import { invalidate } from '$app/navigation' import { inviteHelper } from '$lib/helpers/inviteHelper' -import { DICTIONARY_UPDATED_LOAD_TRIGGER } from '$lib/dbOperations' -import { api_update_dictionary } from '$api/db/update-dictionary/_call' const CONTRIBUTORS_UPDATED_LOAD_TRIGGER = 'contributors:updated' -export const load = (async ({ params: { dictionaryId }, parent, depends }) => { - const { t, load_citation, load_partners } = await parent() +export const load = (async ({ params: { dictionaryId: dictionary_id }, parent, depends }) => { + const { t, load_partners, supabase, update_dictionary } = await parent() depends(CONTRIBUTORS_UPDATED_LOAD_TRIGGER) - async function performDbOperation(operation: () => Promise) { + async function reload_after_operation(operation: () => Promise) { try { await operation() await invalidate(CONTRIBUTORS_UPDATED_LOAD_TRIGGER) @@ -24,86 +19,147 @@ export const load = (async ({ params: { dictionaryId }, parent, depends }) => { } const editor_edits = { - inviteHelper: (role: 'manager' | 'contributor', dictionary: Tables<'dictionaries'>) => { + inviteHelper: (role: 'manager' | 'contributor') => { return async function () { - await performDbOperation(() => inviteHelper(role, dictionary)) + await reload_after_operation(() => inviteHelper(role, dictionary_id)) } }, - removeContributor: (id: string) => { + removeContributor: (user_id: string) => { return async function () { if (!confirm(`${t('misc.delete')}?`)) return - await performDbOperation(() => deleteDocumentOnline(`dictionaries/${dictionaryId}/contributors/${id}`)) + await reload_after_operation(async () => { + const { error } = await supabase.from('dictionary_roles') + .delete() + .eq('dictionary_id', dictionary_id) + .eq('user_id', user_id) + if (error) throw new Error(error.message) + }) } }, - writeInCollaborator: async () => { + writeInCollaborator: async (current_collaborators: string[]) => { const name = prompt(`${t('speakers.name')}?`) if (!name) return - await performDbOperation(() => addOnline(`dictionaries/${dictionaryId}/writeInCollaborators`, { name })) + await reload_after_operation(async () => { + const { error } = await supabase + .from('dictionary_info') + .upsert([ + { id: dictionary_id, write_in_collaborators: [...current_collaborators, name] }, + ], { onConflict: 'id' }) + if (error) throw new Error(error.message) + }) }, - removeWriteInCollaborator: (id: string) => { + removeWriteInCollaborator: (current_collaborators: string[], name: string) => { return async function () { if (!confirm(`${t('misc.delete')}?`)) return - await performDbOperation(() => deleteDocumentOnline(`dictionaries/${dictionaryId}/writeInCollaborators/${id}`)) + await reload_after_operation(async () => { + const { error } = await supabase + .from('dictionary_info') + .upsert([ + { id: dictionary_id, write_in_collaborators: current_collaborators.filter(n => n !== name) }, + ], { onConflict: 'id' }) + if (error) throw new Error(error.message) + }) } }, - cancelInvite: (id: string) => { + cancelInvite: (invite_id: string) => { return async function () { if (!confirm(`${t('misc.cancel')}?`)) return - await performDbOperation(() => updateOnline(`dictionaries/${dictionaryId}/invites/${id}`, { status: 'cancelled' })) + await reload_after_operation(async () => { + const { error } = await supabase.from('invites').update({ status: 'cancelled' }).eq('id', invite_id) + if (error) throw new Error(error.message) + }) } }, } const partner_edits = { add_partner_name: async (name: string) => { - await performDbOperation(() => addOnline(`dictionaries/${dictionaryId}/partners`, { name })) + await reload_after_operation(async () => { + const { error } = await supabase.from('dictionary_partners') + .insert({ dictionary_id, name }) + if (error) throw new Error(error.message) + }) }, delete_partner: async (partner_id: string) => { - await performDbOperation(() => deleteDocumentOnline(`dictionaries/${dictionaryId}/partners/${partner_id}`)) + await reload_after_operation(async () => { + const { error } = await supabase.from('dictionary_partners') + .delete() + .eq('id', partner_id) + if (error) { + console.error(error) + } + }) }, add_partner_image: (partner_id: string, file: File) => { - const status = upload_image({ file, folder: `${dictionaryId}/partners/${partner_id}/logo` }) - const unsubscribe = status.subscribe(async ({ storage_path, serving_url }) => { + const status = upload_image({ file, folder: `${dictionary_id}/partners/${partner_id}/logo` }) + status.subscribe(async ({ storage_path, serving_url }) => { if (storage_path && serving_url) { - await performDbOperation(() => updateOnline(`dictionaries/${dictionaryId}/partners/${partner_id}`, { logo: { - fb_storage_path: storage_path, - specifiable_image_url: serving_url, - } })) - unsubscribe() + const { data, error: photo_saving_error } = await supabase.from('photos') + .insert({ dictionary_id, storage_path, serving_url }).select('id').single() + if (photo_saving_error) throw new Error(photo_saving_error.message) + + const { error } = await supabase.from('dictionary_partners') + .update({ photo_id: data.id }) + .eq('id', partner_id) + if (error) { + console.error(error) + alert(`${t('misc.error')}: ${error.message}`) + } + location.reload() } }) return status }, - delete_partner_image: async ({ partner_id }: { partner_id: string, fb_storage_path: string }) => { - await performDbOperation(() => updateOnline(`dictionaries/${dictionaryId}/partners/${partner_id}`, { logo: null })) - // Presently we are not removing images from GCP storage, this could be done in a future where Supabase supports GCP as role-level security roles for storage is easy in Supabase - // const storage = getStorage(); - // const imageRef = ref(storage, fb_storage_path); - // await deleteObject(imageRef) + delete_partner_image: async ({ partner_id, photo_id }: { partner_id: string, photo_id: string }) => { + await reload_after_operation(async () => { + const { error } = await supabase.from('dictionary_partners') + .update({ photo_id: null }) + .eq('id', partner_id) + if (error) throw new Error(error.message) + const { error: delete_error } = await supabase.from('photos') + .update({ deleted: new Date().toISOString() }) + .eq('id', photo_id) + if (delete_error) throw delete_error.message + }) }, hide_living_tongues_logo: async (hide: boolean) => { - await api_update_dictionary({ hide_living_tongues_logo: hide, id: dictionaryId }) - await invalidate(DICTIONARY_UPDATED_LOAD_TRIGGER) + try { + await update_dictionary({ hide_living_tongues_logo: hide }) + } catch (err) { + alert(`${t('misc.error')}: ${err}`) + } }, } async function update_citation(citation: string) { - await performDbOperation(() => setOnline(`dictionaries/${dictionaryId}/info/citation`, { citation })) + await reload_after_operation(async () => { + const { error } = await supabase + .from('dictionary_info') + .upsert([ + { id: dictionary_id, citation }, + ], { onConflict: 'id' }) + if (error) throw new Error(error.message) + }) + } + + async function get_invites() { + const { data: invites, error } = await supabase.from('invites') + .select() + .eq('dictionary_id', dictionary_id) + .in('status', ['queued', 'sent']) + if (error) throw new Error(error.message) + return invites } return { editor_edits, partner_edits, - managers_promise: getCollection(`dictionaries/${dictionaryId}/managers`), - contributors_promise: getCollection(`dictionaries/${dictionaryId}/contributors`), - invites_promise: getCollection(`dictionaries/${dictionaryId}/invites`, [where('status', 'in', ['queued', 'sent'])]), - writeInCollaborators_promise: getCollection(`dictionaries/${dictionaryId}/writeInCollaborators`), update_citation, + invites_promise: get_invites(), partners_promise: load_partners(), - citation_promise: load_citation(), } }) satisfies PageLoad diff --git a/packages/site/src/routes/[dictionaryId]/contributors/Citation.svelte b/packages/site/src/routes/[dictionaryId]/contributors/Citation.svelte index 4ce3ad4db..3ef08c48a 100644 --- a/packages/site/src/routes/[dictionaryId]/contributors/Citation.svelte +++ b/packages/site/src/routes/[dictionaryId]/contributors/Citation.svelte @@ -1,13 +1,13 @@
- {$page.data.t('history.entry_history')} (work in progress): - {#await get_entry_history(entry_id)} - Loading... - {:then { entry_content_updates }} - {#if can_edit} - {#each entry_content_updates as record} -

{$page.data.t('history.entry_message')} {supabase_date_to_friendly(record.timestamp, $page.data.locale)}

- {/each} - {:else} -

{$page.data.t('history.edited')} {supabase_date_to_friendly(entry_content_updates[0].timestamp, $page.data.locale)}

- {/if} - {:catch error} -

Error: {error.message}

- {/await} +
{$page.data.t('history.history')} (work in progress)
+ {#if can_edit} + {#each entry_history as record} + {@const editor_name = $dictionary_editors.find(({ user_id }) => user_id === record.user_id)?.full_name} +
+ {supabase_date_to_friendly(record.timestamp, $page.data.locale)}, + {#if editor_name} + {editor_name}, + {/if} + {record.change.type} +
+ {/each} + {:else if entry_history?.length} +

{$page.data.t('history.edited')} {supabase_date_to_friendly(entry_history[0].timestamp, $page.data.locale)}

+ {/if}
diff --git a/packages/site/src/routes/[dictionaryId]/grammar/+page.svelte b/packages/site/src/routes/[dictionaryId]/grammar/+page.svelte index 4277c4c99..8288b5f63 100644 --- a/packages/site/src/routes/[dictionaryId]/grammar/+page.svelte +++ b/packages/site/src/routes/[dictionaryId]/grammar/+page.svelte @@ -5,7 +5,7 @@ import SeoMetaTags from '$lib/components/SeoMetaTags.svelte' export let data - $: ({ is_manager, dictionary, update_grammar } = data) + $: ({ is_manager, dictionary, update_grammar, dictionary_info } = data) let updated = '' let editing = false @@ -19,7 +19,13 @@ {#if $is_manager} {#if editing} - + {:else} {/if} @@ -29,13 +35,13 @@ {#if editing}
{#await import('$lib/components/editor/ClassicCustomized.svelte') then { default: ClassicCustomized }} - (updated = detail)} /> + (updated = detail)} /> {/await}
{/if}
- {#if updated || data.grammar} - {@html sanitize(updated || data.grammar)} + {#if updated || $dictionary_info.grammar} + {@html sanitize(updated || $dictionary_info.grammar)} {:else} {$page.data.t('dictionary.no_info_yet')} {/if} diff --git a/packages/site/src/routes/[dictionaryId]/grammar/+page.ts b/packages/site/src/routes/[dictionaryId]/grammar/+page.ts index 4e85ef14e..34e57d773 100644 --- a/packages/site/src/routes/[dictionaryId]/grammar/+page.ts +++ b/packages/site/src/routes/[dictionaryId]/grammar/+page.ts @@ -1,25 +1,24 @@ -import { getDocument, setOnline } from 'sveltefirets' -import type { IGrammar } from '@living-dictionaries/types' import { invalidateAll } from '$app/navigation' -export async function load({ params: { dictionaryId }, parent }) { - const path = `dictionaries/${dictionaryId}/info/grammar` - +export function load({ params: { dictionaryId }, parent }) { async function update_grammar(updated: string) { - const { t } = await parent() + const { t, supabase } = await parent() try { - await setOnline(path, { grammar: updated }) + const { error } = await supabase + .from('dictionary_info') + .upsert([ + { id: dictionaryId, grammar: updated }, + ], { onConflict: 'id' }) + if (error) { + console.error(error) + throw error.message + } + await invalidateAll() } catch (err) { alert(`${t('misc.error')}: ${err}`) } } - try { - const grammarDoc = await getDocument(path) - return { update_grammar, grammar: grammarDoc?.grammar } - } catch (err) { - console.error(err) - return { update_grammar, grammar: null } - } + return { update_grammar } } diff --git a/packages/site/src/routes/[dictionaryId]/invite/[inviteId]/+page.svelte b/packages/site/src/routes/[dictionaryId]/invite/[inviteId]/+page.svelte index 4b59fa260..a117e36bb 100644 --- a/packages/site/src/routes/[dictionaryId]/invite/[inviteId]/+page.svelte +++ b/packages/site/src/routes/[dictionaryId]/invite/[inviteId]/+page.svelte @@ -6,74 +6,78 @@ $: ({ user, dictionary, is_manager, is_contributor, invite, accept_invite } = data) -
- {#if invite?.status === 'sent'} -

- {$page.data.t('invite.invited_by')}: {invite.inviterName} -

-

- {$page.data.t('invite.role')}: {invite.role} -

- {#if $user} - {#if $is_manager} -

- You are already a manager. -

- - {:else if $is_contributor && invite.role === 'contributor'} -

- You are already a contributor. -

- - {:else} - - -
- {$page.data.t('terms.agree_by_submit')} - - {$page.data.t('dictionary.terms_of_use')} - - . -
- {/if} +{#if invite?.status === 'sent'} +

+ {$page.data.t('invite.invited_by')}: {invite.inviter_email} +

+

+ {$page.data.t('invite.role')}: {invite.role} +

+ {#if $user} + {#if $is_manager} +

+ You are already a manager. +

+ + {:else if $is_contributor && invite.role === 'contributor'} +

+ You are already a contributor. +

+ {:else} - -