From 56f5cc79b426413e2399ae3a91c3ecaaf41d0c80 Mon Sep 17 00:00:00 2001 From: Malek Salem Date: Fri, 19 Jun 2026 15:21:45 +0100 Subject: [PATCH 01/63] feat: migrate contact export to edge function (#2812) --- .../src/controllers/contacts.controller.ts | 276 +-------- backend/src/routes/contacts.routes.ts | 16 +- backend/src/services/export/exports/csv.ts | 121 ---- .../export/exports/google/contacts-api.ts | 533 ------------------ .../services/export/exports/google/index.ts | 69 --- backend/src/services/export/exports/vcard.ts | 69 --- backend/src/services/export/types.ts | 30 - .../mining/buttons/ExportContacts.vue | 145 ++--- frontend/src/types/mining.ts | 1 + supabase/functions/_shared/rate-limiter.ts | 113 ++++ .../export-contacts/contacts-client.ts | 108 ++++ supabase/functions/export-contacts/deno.json | 8 + .../functions/export-contacts/formats/csv.ts | 121 ++++ .../export-contacts/formats/factory.ts | 21 +- .../formats/google/contacts-api.ts | 446 +++++++++++++++ .../export-contacts/formats/google/index.ts | 59 ++ .../export-contacts/formats/strategy.ts | 18 + .../export-contacts/formats/vcard.ts | 89 +++ supabase/functions/export-contacts/i18n.ts | 14 + .../functions/export-contacts/i18n/en.json | 17 + .../functions/export-contacts/i18n/fr.json | 17 + supabase/functions/export-contacts/index.ts | 220 ++++++++ .../functions/export-contacts/middlewares.ts | 19 + .../export-contacts/tests/export.test.ts | 151 +++++ supabase/functions/export-contacts/types.ts | 115 ++++ 25 files changed, 1588 insertions(+), 1208 deletions(-) delete mode 100644 backend/src/services/export/exports/csv.ts delete mode 100644 backend/src/services/export/exports/google/contacts-api.ts delete mode 100644 backend/src/services/export/exports/google/index.ts delete mode 100644 backend/src/services/export/exports/vcard.ts delete mode 100644 backend/src/services/export/types.ts create mode 100644 supabase/functions/_shared/rate-limiter.ts create mode 100644 supabase/functions/export-contacts/contacts-client.ts create mode 100644 supabase/functions/export-contacts/deno.json create mode 100644 supabase/functions/export-contacts/formats/csv.ts rename backend/src/services/export/index.ts => supabase/functions/export-contacts/formats/factory.ts (59%) create mode 100644 supabase/functions/export-contacts/formats/google/contacts-api.ts create mode 100644 supabase/functions/export-contacts/formats/google/index.ts create mode 100644 supabase/functions/export-contacts/formats/strategy.ts create mode 100644 supabase/functions/export-contacts/formats/vcard.ts create mode 100644 supabase/functions/export-contacts/i18n.ts create mode 100644 supabase/functions/export-contacts/i18n/en.json create mode 100644 supabase/functions/export-contacts/i18n/fr.json create mode 100644 supabase/functions/export-contacts/index.ts create mode 100644 supabase/functions/export-contacts/middlewares.ts create mode 100644 supabase/functions/export-contacts/tests/export.test.ts create mode 100644 supabase/functions/export-contacts/types.ts diff --git a/backend/src/controllers/contacts.controller.ts b/backend/src/controllers/contacts.controller.ts index 8daeb44ee..07b9b3d3d 100644 --- a/backend/src/controllers/contacts.controller.ts +++ b/backend/src/controllers/contacts.controller.ts @@ -1,275 +1,3 @@ -import { User } from '@supabase/supabase-js'; -import { NextFunction, Request, Response } from 'express'; -import { Contacts } from '../db/interfaces/Contacts'; -import { Contact, ExportService } from '../db/types'; -import Billing from '../utils/billing-plugin'; -import { ExportOptions, ExportType } from '../services/export/types'; -import ExportFactory from '../services/export'; -import { - MiningSources, - OAuthMiningSourceCredentials -} from '../db/interfaces/MiningSources'; - -async function validateRequest( - req: Request, - res: Response, - miningSourceService: MiningSources -) { - const user = res.locals.user as User; - const partialExport = req.body.partialExport ?? false; - const updateEmptyFieldsOnly = req.body.updateEmptyFieldsOnly ?? false; - const exportType = (req.params.exportType ?? ExportType.CSV) as ExportType; - - const localeFromHeader = req.headers['accept-language']; - const delimiterOption = req.query.delimiter?.toString(); - - const validExportType = Object.values(ExportType).includes( - exportType as ExportType - ); - - if (!validExportType) { - throw new Error(`Invalid export type: ${exportType}`); - } - - let googleContactsOptions: ExportOptions['googleContactsOptions'] = { - userId: user.id, - accessToken: undefined, - refreshToken: undefined, - updateEmptyFieldsOnly - }; - - if (exportType === ExportType.GOOGLE_CONTACTS) { - const targetEmail = req.body.targetEmail || user.email; - const sources = await miningSourceService.getSourcesForUser( - user.id, - targetEmail as string - ); - const oauthCredentials = sources?.find((e) => e.email === targetEmail) - ?.credentials as OAuthMiningSourceCredentials; - - googleContactsOptions = { - ...googleContactsOptions, - accessToken: oauthCredentials?.accessToken, - refreshToken: oauthCredentials?.refreshToken - }; - } - - const { - ids, - exportAllContacts - }: { ids?: string[]; exportAllContacts: boolean } = req.body; - - if (!exportAllContacts && (!Array.isArray(ids) || !ids.length)) { - return { - userId: user.id, - exportType, - contactsToExport: null, - partialExport, - exportOptions: { - locale: localeFromHeader, - delimiter: undefined, - googleContactsOptions - } - }; - } - - const contactsToExport = exportAllContacts ? undefined : ids; - - return { - userId: user.id, - exportType, - contactsToExport, - partialExport, - exportOptions: { - locale: localeFromHeader, - delimiter: delimiterOption, - googleContactsOptions - } - }; -} - -async function respondWithContacts( - res: Response, - userId: string, - contacts: Contacts, - exportType: ExportType, - contactsToExport?: string[], - exportOption?: ExportOptions -) { - const selectedContacts = await contacts.getContacts(userId, contactsToExport); - - if (!selectedContacts.length) { - return res.sendStatus(204); // 204 No Content - } - - try { - const { content, contentType } = await ExportFactory.get(exportType).export( - selectedContacts, - exportOption - ); - - return res.header('Content-Type', contentType).status(200).send(content); - } catch (err) { - if ((err as Error).message === 'Invalid credentials.') { - return res.sendStatus(401); - } - throw err; - } -} - -async function verifyCredits( - userId: string, - contacts: Contacts, - contactsToExport?: string[] -) { - const newContacts = await contacts.getNonExportedContacts( - userId, - contactsToExport - ); - const previousExportedContacts = await contacts.getExportedContacts( - userId, - contactsToExport - ); - const creditsInfo = await Billing!.validateCustomerCredits( - userId, - newContacts.length - ); - const response = { - total: newContacts.length + previousExportedContacts.length, - available: Math.floor(creditsInfo.availableUnits), - availableAlready: previousExportedContacts.length - }; - return { - newContacts, - previousExportedContacts, - creditsInfo, - response - }; -} - -async function registerAndDeductCredits( - userId: string, - exportType: ExportType, - availableUnits: number, - contacts: Contacts, - availableContacts: Contact[] -) { - if (availableContacts.length) { - await contacts.registerExportedContacts( - availableContacts.map(({ id }) => id), - exportType as unknown as ExportService, - userId - ); - await Billing!.deductCustomerCredits(userId, availableUnits); - } -} - -async function respondWithConfirmedContacts( - res: Response, - userId: string, - exportType: ExportType, - contacts: Contacts, - newContacts: Contact[], - previousExportedContacts: Contact[], - availableUnits: number, - statusCode: number, - exportOptions?: ExportOptions -) { - const availableContacts = newContacts.slice(0, availableUnits); - const selectedContacts = [...previousExportedContacts, ...availableContacts]; - - try { - const { content, contentType } = await ExportFactory.get(exportType).export( - selectedContacts, - exportOptions - ); - - await registerAndDeductCredits( - userId, - exportType, - availableUnits, - contacts, - selectedContacts - ); - - return res - .header('Content-Type', contentType) - .status(statusCode) - .send(content); - } catch (err) { - if ((err as Error).message === 'Invalid credentials.') { - return res.sendStatus(401); - } - throw err; - } -} - -export default function initializeContactsController( - contacts: Contacts, - miningSources: MiningSources -) { - return { - async exportContactsCSV(req: Request, res: Response, next: NextFunction) { - const { - userId, - contactsToExport, - partialExport, - exportType, - exportOptions - } = await validateRequest(req, res, miningSources); - if (contactsToExport === null) { - return res.status(400).json({ - message: 'Parameter "ids" must be a non-empty list of person ids' - }); - } - - let statusCode = 200; - try { - if (!Billing) { - // No need to Verify Credits, Export. - return await respondWithContacts( - res, - userId, - contacts, - exportType, - contactsToExport, - exportOptions - ); - } - - const { newContacts, previousExportedContacts, creditsInfo, response } = - await verifyCredits(userId, contacts, contactsToExport); - - if ( - creditsInfo.hasDeficientCredits && - !previousExportedContacts.length - ) { - return res.status(402).json(response); - } - - if (creditsInfo.hasInsufficientCredits && newContacts.length) { - if (!partialExport) { - res.statusMessage = 'Confirm Partial Content'; - return res.status(266).json(response); // 266 Confirm Partial Content - } - statusCode = 206; // 206 Partial Content - } - - // Export confirmed contacts. - return await respondWithConfirmedContacts( - res, - userId, - exportType, - contacts, - newContacts, - previousExportedContacts, - creditsInfo.availableUnits, - statusCode, - exportOptions - ); - } catch (error) { - return next(error); - } - } - }; +export default function initializeContactsController() { + return {}; } diff --git a/backend/src/routes/contacts.routes.ts b/backend/src/routes/contacts.routes.ts index 809154c0d..7f2186209 100644 --- a/backend/src/routes/contacts.routes.ts +++ b/backend/src/routes/contacts.routes.ts @@ -1,16 +1,14 @@ import { Router } from 'express'; import rateLimit from 'express-rate-limit'; -import initializeContactsController from '../controllers/contacts.controller'; import initializeContactsVerificationController from '../controllers/contacts-verification.controller'; import { Contacts } from '../db/interfaces/Contacts'; import initializeAuthMiddleware from '../middleware/auth'; import AuthResolver from '../services/auth/AuthResolver'; -import { MiningSources } from '../db/interfaces/MiningSources'; export default function initializeContactsRoutes( contacts: Contacts, authResolver: AuthResolver, - miningSources: MiningSources + miningSources: unknown ) { const router = Router(); const contactsRouteLimiter = rateLimit({ @@ -18,21 +16,9 @@ export default function initializeContactsRoutes( max: 100 }); - const { exportContactsCSV } = initializeContactsController( - contacts, - miningSources - ); - const { verifyEmailStatus } = initializeContactsVerificationController(contacts); - router.post( - '/contacts/export/:exportType', - initializeAuthMiddleware(authResolver), - contactsRouteLimiter, - exportContactsCSV - ); - router.post( '/contacts/verify', initializeAuthMiddleware(authResolver), diff --git a/backend/src/services/export/exports/csv.ts b/backend/src/services/export/exports/csv.ts deleted file mode 100644 index 03dd3fdc3..000000000 --- a/backend/src/services/export/exports/csv.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { stringify } from 'csv-stringify'; -import { Contact } from '../../../db/types'; -import { ExportStrategy, ExportType, ExportResult } from '../types'; - -export default class CsvExport implements ExportStrategy { - readonly contentType = 'text/csv'; - - readonly type = ExportType.CSV; - - async export( - contacts: Contact[], - options?: { locale: string; delimiter: string } - ): Promise { - const delimiter = - options?.delimiter ?? CsvExport.getLocalizedCsvSeparator(options?.locale); - - const csvData = contacts.map((contact) => ({ - name: contact.name?.trim(), - email: contact.email, - recency: contact.recency - ? new Date(contact.recency).toISOString().slice(0, 10) - : '', - seniority: contact.seniority - ? new Date(contact.seniority).toISOString().slice(0, 10) - : '', - occurrence: contact.occurrence, - sender: contact.sender, - recipient: contact.recipient, - conversations: contact.conversations, - replied_conversations: contact.replied_conversations, - tags: contact.tags?.join(','), - status: contact.status, - given_name: contact.given_name, - family_name: contact.family_name, - alternate_name: contact.alternate_name?.join(','), - location: contact.location, - works_for: contact.works_for, - job_title: contact.job_title, - same_as: contact.same_as?.join(','), - telephone: contact.telephone?.join(','), - image: contact.image - })); - - const content = await CsvExport.getCsvStr( - [ - { key: 'name', header: 'Name' }, - { key: 'email', header: 'Email' }, - { key: 'recency', header: 'Recency' }, - { key: 'seniority', header: 'Seniority' }, - { key: 'occurrence', header: 'Occurrence' }, - { key: 'sender', header: 'Sender' }, - { key: 'recipient', header: 'Recipient' }, - { key: 'conversations', header: 'Conversations' }, - { key: 'replied_conversations', header: 'Replies' }, - { key: 'tags', header: 'Tags' }, - { key: 'status', header: 'Reachable' }, - { key: 'given_name', header: 'Given name' }, - { key: 'family_name', header: 'Family name' }, - { key: 'alternate_name', header: 'Alternate names' }, - { key: 'location', header: 'Location' }, - { key: 'works_for', header: 'Works for' }, - { key: 'job_title', header: 'Job title' }, - { key: 'same_as', header: 'Same as' }, - { key: 'telephone', header: 'Telephone' }, - { key: 'image', header: 'Avatar URL' } - ], - csvData, - delimiter - ); - - return { - content, - contentType: this.contentType, - charset: 'utf-8', - extension: 'csv' - }; - } - - static getLocalizedCsvSeparator(locale?: string) { - const language = locale?.substring(0, 2); - - switch (language) { - case 'fr': - case 'de': - case 'es': - case 'pt': - case 'it': - return ';'; - default: - return ','; - } - } - - static getCsvStr( - columns: { key: keyof T; header: string }[], - rows: T[], - delimiter: string - ) { - return new Promise((resolve, reject) => { - stringify( - rows, - { - columns: columns.map(({ key, header }) => ({ - key: String(key), - header - })), - bom: true, - delimiter, - header: true, - quoted_string: true - }, - (err, data) => { - if (err) { - return reject(err); - } - return resolve(data); - } - ); - }); - } -} diff --git a/backend/src/services/export/exports/google/contacts-api.ts b/backend/src/services/export/exports/google/contacts-api.ts deleted file mode 100644 index 4c46c8ed8..000000000 --- a/backend/src/services/export/exports/google/contacts-api.ts +++ /dev/null @@ -1,533 +0,0 @@ -import { people_v1 } from 'googleapis'; -import { ContactFrontend } from '../../../../db/types'; -import logger from '../../../../utils/logger'; -import { Distribution, TokenBucketRateLimiter } from '../../../rate-limiter'; -import ENV from '../../../../config'; - -export type QuotaType = 'criticalRead' | 'criticalWrite' | 'read' | 'write'; - -export default class GoogleContactsSession { - private readonly PERSON_FIELDS = - 'names,emailAddresses,phoneNumbers,organizations,addresses,nicknames,urls,memberships'; - - private readonly FULL_READ_MASK = `${this.PERSON_FIELDS},metadata`; - - private labelMap = new Map(); - - private service: people_v1.People; - - private limiters: Record; - - constructor( - service: people_v1.People, - private readonly appName: string, - private readonly userId: string - ) { - this.service = service; - - const config = { - criticalRead: { - requests: ENV.GOOGLE_CONTACTS_CRITICAL_READ_REQUESTS, - intervalSeconds: ENV.GOOGLE_CONTACTS_CRITICAL_READ_INTERVAL, - uniqueKey: `crit-read-${this.userId}`, - executeEvenly: true, - distribution: Distribution.Memory - }, // Limit is 90 - criticalWrite: { - requests: ENV.GOOGLE_CONTACTS_CRITICAL_WRITE_REQUESTS, - intervalSeconds: ENV.GOOGLE_CONTACTS_CRITICAL_WRITE_INTERVAL, - uniqueKey: `crit-write-${this.userId}`, - executeEvenly: true, - distribution: Distribution.Memory - }, // Limit is 90 - read: { - requests: ENV.GOOGLE_CONTACTS_READ_REQUESTS, - intervalSeconds: ENV.GOOGLE_CONTACTS_READ_INTERVAL, - uniqueKey: `read-${this.userId}`, - executeEvenly: true, - distribution: Distribution.Memory - }, // Limit is 120 (Groups) - write: { - requests: ENV.GOOGLE_CONTACTS_WRITE_REQUESTS, - intervalSeconds: ENV.GOOGLE_CONTACTS_WRITE_INTERVAL, - uniqueKey: `write-${this.userId}`, - executeEvenly: true, - distribution: Distribution.Memory - } // Limit is 90 (Groups/Delete) - }; - - this.limiters = { - criticalRead: new TokenBucketRateLimiter(config.criticalRead), - criticalWrite: new TokenBucketRateLimiter(config.criticalWrite), - read: new TokenBucketRateLimiter(config.read), - write: new TokenBucketRateLimiter(config.write) - }; - } - - private async useQuota( - requirements: { type: QuotaType; weight: number }[], - callback: () => Promise - ): Promise { - await Promise.all( - requirements.map((q) => this.limiters[q.type].removeTokens(q.weight)) - ); - return callback(); - } - - private static batchPerLimit(array: T[], size: number): T[][] { - const chunks: T[][] = []; - for (let i = 0; i < array.length; i += size) { - chunks.push(array.slice(i, i + size)); - } - return chunks; - } - - async run( - contacts: ContactFrontend[], - updateEmptyOnly: boolean - ): Promise<{ labelId: string | null }> { - try { - logger.debug('GoogleContactsSession.run(): Starting sync process', { - userId: this.userId, - contactCount: contacts.length, - updateEmptyOnly - }); - - const existingLabels = await this.listLabels(); - - logger.debug('GoogleContactsSession.run(): Fetched labels fetched', { - labelCount: existingLabels.size, - labels: Array.from(existingLabels.keys()) - }); - - const contactsLabels = [{ tags: [ENV.APP_NAME] }, ...contacts] - .map(({ tags }) => tags) - .flat() - .filter((tag): tag is string => Boolean(tag)); - - logger.debug('GoogleContactsSession.run(): Extracted contact tags', { - uniqueTagCount: new Set(contactsLabels).size, - tags: [...new Set(contactsLabels)] - }); - - const created = await this.createLabels(existingLabels, contactsLabels); - - this.labelMap = new Map([...existingLabels, ...created]); - - logger.debug( - 'GoogleContactsSession.run(): Created missing labels and build labelMap', - { - newLabelCount: created.size, - newLabels: Array.from(created.keys()), - totalLabels: this.labelMap.size - } - ); - - const { create, update } = await this.contactsToCreateUpdate(contacts); - - const createBatches = GoogleContactsSession.batchPerLimit( - Array.from(create.values()), - 200 - ); - const updateBatches = GoogleContactsSession.batchPerLimit( - Array.from(update.values()), - 200 - ); - - logger.debug('GoogleContactsSession.run(): Batches prepared', { - createBatches: createBatches.length, - updateBatches: updateBatches.length - }); - - /* eslint-disable no-await-in-loop */ - for (let i = 0; i < createBatches.length; i += 1) { - const batch = createBatches[i]; - logger.debug( - `GoogleContactsSession.run(): Processing create batch ${i + 1}/${createBatches.length}`, - { - batchSize: batch.length - } - ); - - await this.useQuota( - [ - { type: 'criticalRead', weight: 6 }, - { type: 'criticalWrite', weight: 6 } - ], - async () => { - await this.executeBatchCreate(batch); - } - ); - - logger.debug( - `GoogleContactsSession.run(): Create batch ${i + 1}/${createBatches.length} completed` - ); - } - - /* eslint-disable no-await-in-loop */ - for (let i = 0; i < updateBatches.length; i += 1) { - const batch = updateBatches[i]; - logger.debug( - `GoogleContactsSession.run(): Processing update batch ${i + 1}/${updateBatches.length}`, - { - batchSize: batch.length - } - ); - - await this.useQuota( - [ - { type: 'criticalRead', weight: 6 }, - { type: 'criticalWrite', weight: 6 } - ], - async () => { - await this.executeBatchUpdate(batch, updateEmptyOnly); - } - ); - - logger.debug( - `GoogleContactsSession.run(): Update batch ${i + 1}/${updateBatches.length} completed` - ); - } - - logger.info('GoogleContactsSession.run(): Sync completed successfully', { - totalContacts: contacts.length, - created: create.size, - updated: update.size - }); - - const appLabelResourceName = this.labelMap.get( - ENV.APP_NAME.toLowerCase() - ); - const labelId = appLabelResourceName - ? appLabelResourceName.replace('contactGroups/', '') - : null; - - return { labelId }; - } catch (err) { - logger.error( - `GoogleContactsSession.run(): Error during sync: ${(err as Error).message}`, - err - ); - throw err; - } - } - - private async contactsToCreateUpdate(contacts: ContactFrontend[]) { - const create: Map = new Map(); - const update: Map< - string, - { existing: people_v1.Schema$Person; incoming: ContactFrontend } - > = new Map(); - - const { emailMap, phoneMap } = await this.getUserContacts(); - - for (const contact of contacts) { - const potentialMatches = new Map(); - - const emailMatches = contact.email - ? emailMap.get(contact.email.toLowerCase()) || [] - : []; - emailMatches.forEach((p) => { - if (p.resourceName) potentialMatches.set(p.resourceName, p); - }); - - if (!emailMatches.length && contact.telephone) { - for (const phone of contact.telephone) { - const normalizedPhone = phone.replace(/\s+/g, ''); - const phoneMatches = phoneMap.get(normalizedPhone) || []; - phoneMatches.forEach((p) => { - if (p.resourceName) potentialMatches.set(p.resourceName, p); - }); - } - } - - if (potentialMatches.size === 1) { - const existing = Array.from(potentialMatches.values())[0]; - update.set(existing.resourceName as string, { - existing, - incoming: contact - }); - } else { - // Use identifier (email or phone) as the dedup key in `create`. - // Phone-only contacts have no email, so the map key is the - // primary phone number. - const key = contact.email ?? contact.telephone?.[0] ?? contact.id; - if (key) { - create.set(key, contact); - } - if (potentialMatches.size > 1) { - logger.warn( - `Ambiguous match for ${contact.email ?? contact.telephone?.[0]}: Found ${potentialMatches.size} contacts. Creating new record to avoid corruption.` - ); - } - } - } - return { create, update }; - } - - private async listLabels(): Promise> { - const labels = new Map(); - await this.useQuota([{ type: 'read', weight: 1 }], async () => { - const res = await this.service.contactGroups.list({ - groupFields: 'name' - }); - (res.data.contactGroups || []).forEach((g) => { - if (g.name && g.resourceName) - labels.set(g.name.toLowerCase(), g.resourceName); - }); - }); - return labels; - } - - private async createLabels( - existing: Map, - labels: string[] - ): Promise> { - const labelsMap = new Map(); - const toCreate = [...new Set(labels)].filter( - (label) => !existing.has(label?.toLowerCase()) - ); - const tasks = toCreate.map(async (tag) => { - await this.useQuota( - [ - { type: 'write', weight: 1 }, - { type: 'read', weight: 1 } - ], - async () => { - const newGroup = await this.service.contactGroups.create({ - requestBody: { contactGroup: { name: tag } } - }); - - if (newGroup.data.resourceName) - labelsMap.set(tag.toLowerCase(), newGroup.data.resourceName); - } - ); - }); - - await Promise.all(tasks); - - return labelsMap; - } - - private async executeBatchUpdate( - batch: { existing: people_v1.Schema$Person; incoming: ContactFrontend }[], - updateEmptyOnly: boolean - ) { - const contactsMap: { [key: string]: people_v1.Schema$Person } = {}; - - batch.forEach((item) => { - const person = this.mapToPerson( - item.incoming, - item.existing, - updateEmptyOnly - ); - if (item.existing.resourceName) { - contactsMap[item.existing.resourceName] = { - ...person, - etag: item.existing.etag - }; - } - }); - - if (Object.keys(contactsMap).length === 0) return; - - await this.service.people.batchUpdateContacts({ - requestBody: { - contacts: contactsMap, - updateMask: this.PERSON_FIELDS - } - }); - logger.info(`Batch updated ${batch.length} contacts.`); - } - - private async executeBatchCreate(batch: ContactFrontend[]) { - const res = await this.service.people.batchCreateContacts({ - requestBody: { - contacts: batch.map((c) => ({ contactPerson: this.mapToPerson(c) })) - } - }); - logger.info(`Batch created ${batch.length} contacts.`); - return res.data; - } - - private async getUserContacts(): Promise<{ - emailMap: Map; - phoneMap: Map; - }> { - const emailMap = new Map(); - const phoneMap = new Map(); - let pageToken: string | undefined; - - do { - const currentPageToken = pageToken; - const res = await this.useQuota( - [{ type: 'criticalRead', weight: 1 }], - async () => - this.service.people.connections.list({ - resourceName: 'people/me', - pageSize: 1000, - pageToken: currentPageToken, - personFields: this.FULL_READ_MASK - }) - ); - - const connections = (res.data.connections || []).filter((p) => - p.metadata?.sources?.some((s) => s.type === 'CONTACT') - ); - - for (const person of connections) { - person.emailAddresses?.forEach((e) => { - if (!e.value) return; - const key = e.value.toLowerCase(); - const existing = emailMap.get(key) || []; - emailMap.set(key, [...existing, person]); - }); - - person.phoneNumbers?.forEach((p) => { - if (!p.value) return; - const key = p.value.replace(/\s+/g, ''); - const existing = phoneMap.get(key) || []; - phoneMap.set(key, [...existing, person]); - }); - } - - pageToken = res.data.nextPageToken ?? undefined; - } while (pageToken); - - return { emailMap, phoneMap }; - } - - // skipcq: JS-R1005 - Pre-existing on main; refactor tracked in #2831. Function maps 7+ schema field types (names, emails, phones, orgs, urls, addresses, memberships) in one pass and the per-field "is duplicate" checks against existing fields inherently inflate cyclomatic complexity. Splitting into helpers would be a behavioral change and out of scope for PR #2830. - private mapToPerson( - contact: ContactFrontend, - existing?: people_v1.Schema$Person, - updateEmptyOnly = false - ): people_v1.Schema$Person { - const existingUrls = existing?.urls || []; - const existingNames = existing?.names || []; - const existingOrgs = existing?.organizations || []; - const existingAddresses = existing?.addresses || []; - const existingPhones = existing?.phoneNumbers || []; - const existingEmails = existing?.emailAddresses || []; - const existingMemberships = existing?.memberships || []; - const labels = [this.appName, ...(contact.tags ?? [])].filter(Boolean); - - // Helper function to check if a field is empty - const isEmpty = (val: string | null | undefined) => - val === null || val === undefined || val === ''; - - let names: people_v1.Schema$Name[] = existingNames; - if (!updateEmptyOnly || existingNames.length === 0) { - const newName: people_v1.Schema$Name = {}; - if (!isEmpty(contact.given_name)) { - newName.givenName = contact.given_name; - } - if (!isEmpty(contact.family_name)) { - newName.familyName = contact.family_name; - } - if (!isEmpty(contact.name)) { - newName.unstructuredName = contact.name; - } - - if (newName.givenName || newName.familyName || newName.unstructuredName) { - names = [newName]; - } - } - - const hasEmail = !isEmpty(contact.email); - const emailAddresses = - !updateEmptyOnly || existingEmails.length === 0 - ? [ - ...existingEmails.filter( - (e) => hasEmail && e.value !== contact.email - ), - ...(hasEmail ? [{ value: contact.email as string }] : []) - ] - : existingEmails; - - let phoneNumbers: people_v1.Schema$PhoneNumber[] = existingPhones; - const newPhones = - contact.telephone - ?.filter((tel) => !isEmpty(tel)) - .map((tel) => ({ value: tel })) || []; - if (newPhones.length > 0) { - const existingValues = new Set(existingPhones.map((p) => p.value)); - const uniqueNewPhones = newPhones.filter( - (p) => !existingValues.has(p.value) - ); - if (uniqueNewPhones.length > 0) { - phoneNumbers = [...existingPhones, ...uniqueNewPhones]; - } - } - - let organizations: people_v1.Schema$Organization[] = existingOrgs; - const newOrg: people_v1.Schema$Organization = {}; - if (!isEmpty(contact.works_for)) { - newOrg.name = contact.works_for; - } - if (!isEmpty(contact.job_title)) { - newOrg.title = contact.job_title; - } - if (newOrg.name || newOrg.title) { - const isDuplicate = existingOrgs.some( - (o) => o.name === newOrg.name && o.title === newOrg.title - ); - if (!isDuplicate) { - organizations = [...existingOrgs, newOrg]; - } - } - - let urls: people_v1.Schema$Url[] = existingUrls; - const newUrls = - contact.same_as - ?.filter((url) => !isEmpty(url)) - .map((url) => ({ value: url })) || []; - if (newUrls.length > 0) { - const existingValues = new Set(existingUrls.map((u) => u.value)); - const uniqueNewUrls = newUrls.filter((u) => !existingValues.has(u.value)); - if (uniqueNewUrls.length > 0) { - urls = [...existingUrls, ...uniqueNewUrls]; - } - } - - let addresses: people_v1.Schema$Address[] = existingAddresses; - if (!isEmpty(contact.location)) { - const newAddress = { streetAddress: contact.location }; - const isDuplicate = existingAddresses.some( - (a) => a.streetAddress === newAddress.streetAddress - ); - if (!isDuplicate) { - addresses = [...existingAddresses, newAddress]; - } - } - - const person: people_v1.Schema$Person = { - names, - emailAddresses, - phoneNumbers, - organizations, - urls, - addresses, - memberships: [ - ...existingMemberships.filter( - (m) => - !labels.some( - (l) => - this.labelMap.get(l.toLowerCase()) === - m.contactGroupMembership?.contactGroupResourceName - ) - ), - ...labels - .map((l) => this.labelMap.get(l.toLowerCase())) - .filter(Boolean) - .map((groupResourceName) => ({ - contactGroupMembership: { - contactGroupResourceName: groupResourceName - } - })) - ] - }; - - return person; - } -} diff --git a/backend/src/services/export/exports/google/index.ts b/backend/src/services/export/exports/google/index.ts deleted file mode 100644 index 958abe5a3..000000000 --- a/backend/src/services/export/exports/google/index.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { GaxiosError } from 'gaxios'; -import ENV from '../../../../config'; -import { ContactFrontend } from '../../../../db/types'; -import logger from '../../../../utils/logger'; -import { - getPeopleService, - warmupSearchIndex -} from '../../../OAuth2/googlePeopleClient'; -import { - ExportStrategy, - ExportType, - ExportOptions, - ExportResult -} from '../../types'; -import GoogleContactsSession from './contacts-api'; - -export default class GoogleContactsExport - implements ExportStrategy -{ - readonly type = ExportType.GOOGLE_CONTACTS; - - readonly contentType = 'application/json'; - - async export( - contacts: ContactFrontend[], - options: ExportOptions - ): Promise { - if (!options?.googleContactsOptions) - throw new Error('Missing required options'); - - try { - const opts = options.googleContactsOptions; - const service = await getPeopleService({ - accessToken: opts.accessToken, - refreshToken: opts.refreshToken - }); - await warmupSearchIndex(service); - const session = new GoogleContactsSession( - service, - ENV.APP_NAME, - opts.userId - ); - const { labelId } = await session.run( - contacts, - opts.updateEmptyFieldsOnly ?? false - ); - - return { - content: JSON.stringify({ labelId }), - contentType: this.contentType, - charset: '', - extension: '' - }; - } catch (err) { - if (err instanceof GaxiosError) { - logger.error(`Export error: ${err.message}`, err); - if ( - err.response?.status === 401 || - err.response?.data.error === 'invalid_grant' - ) { - throw new Error('Invalid credentials.'); - } - } else { - logger.error(`Export error: ${(err as Error).message}`, err); - } - throw err; - } - } -} diff --git a/backend/src/services/export/exports/vcard.ts b/backend/src/services/export/exports/vcard.ts deleted file mode 100644 index 06821634f..000000000 --- a/backend/src/services/export/exports/vcard.ts +++ /dev/null @@ -1,69 +0,0 @@ -import VCard from 'vcard-creator'; -import { Contact } from '../../../db/types'; -import { ExportStrategy, ExportType, ExportResult } from '../types'; -import ENV from '../../../config'; - -export default class VCardExport implements ExportStrategy { - readonly contentType = 'text/vcard'; - - readonly type = ExportType.VCARD; - - async export(contacts: Contact[]): Promise { - const content = contacts - .map((contact) => VCardExport.contactToVCard(contact)) - .join('\n'); - - return { - content, - contentType: this.contentType, - charset: 'utf-8', - extension: 'vcf' - }; - } - - private static contactToVCard(contact: Contact): string { - const vcard = new VCard(); - - vcard.addCategories([ENV.APP_NAME]); - - if (contact.given_name?.length || contact.family_name?.length) { - vcard.addName(contact.family_name ?? '', contact.given_name ?? ''); - } else if (contact.name) { - vcard.addName(contact.name); - } - - if (contact.email) { - vcard.addEmail(contact.email); - } - - contact.telephone?.forEach((phone) => { - vcard.addPhoneNumber(phone); - }); - - if (contact.works_for) { - vcard.addCompany(contact.works_for); - } - - if (contact.job_title) { - vcard.addJobtitle(contact.job_title); - } - - // Address / location - if (contact.location) { - vcard.addAddress(contact.location); - } - - if (contact.alternate_name) - vcard.addNickname(contact.alternate_name.join(',')); - - contact.same_as?.forEach((url) => { - vcard.addURL(url); - }); - - if (contact.image) { - vcard.addPhotoURL(contact.image); - } - - return vcard.toString(); - } -} diff --git a/backend/src/services/export/types.ts b/backend/src/services/export/types.ts deleted file mode 100644 index 79b6478b2..000000000 --- a/backend/src/services/export/types.ts +++ /dev/null @@ -1,30 +0,0 @@ -export enum ExportType { - CSV = 'csv', - VCARD = 'vcard', - GOOGLE_CONTACTS = 'google_contacts' -} - -export interface ExportOptions { - delimiter?: string; - locale?: string; - googleContactsOptions?: { - userId: string; - accessToken?: string; - refreshToken?: string; - updateEmptyFieldsOnly?: boolean; - }; -} - -export interface ExportResult { - content: string | Buffer; - contentType: string; - charset: string; - extension: string; - metadata?: Record; -} - -export interface ExportStrategy { - readonly type: ExportType; - - export(data: T[], options?: ExportOptions): Promise; -} diff --git a/frontend/src/components/mining/buttons/ExportContacts.vue b/frontend/src/components/mining/buttons/ExportContacts.vue index 440d1e58a..fc490530d 100644 --- a/frontend/src/components/mining/buttons/ExportContacts.vue +++ b/frontend/src/components/mining/buttons/ExportContacts.vue @@ -1,14 +1,8 @@