From 180eced0d005cc3c2d41f09c4f9c9bb9a08bce71 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Wed, 3 Dec 2025 15:55:04 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(contacts):=20add=20import/expo?= =?UTF-8?q?rt=20with=20Google=20Contacts=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add vCard/CSV file import with duplicate detection and merge options - Add Google Contacts OAuth2 integration for importing from Google - Add vCard/CSV export with format selection and filtering options - Add connected_accounts table for OAuth token storage - Add FileUploader, ImportPreview, GoogleImport, ExportModal components - Add i18n translations for import/export (DE/EN) --- apps/contacts/CLAUDE.md | 33 +- apps/contacts/apps/backend/package.json | 6 +- apps/contacts/apps/backend/src/app.module.ts | 6 + .../db/schema/connected-accounts.schema.ts | 36 ++ .../apps/backend/src/db/schema/index.ts | 1 + .../apps/backend/src/export/dto/export.dto.ts | 27 ++ .../backend/src/export/export.controller.ts | 53 +++ .../apps/backend/src/export/export.module.ts | 10 + .../apps/backend/src/export/export.service.ts | 140 ++++++ .../src/export/generators/csv.generator.ts | 85 ++++ .../src/export/generators/vcard.generator.ts | 122 ++++++ .../apps/backend/src/google/dto/google.dto.ts | 95 +++++ .../backend/src/google/google.controller.ts | 74 ++++ .../apps/backend/src/google/google.module.ts | 10 + .../apps/backend/src/google/google.service.ts | 389 +++++++++++++++++ .../apps/backend/src/import/dto/import.dto.ts | 122 ++++++ .../backend/src/import/import.controller.ts | 80 ++++ .../apps/backend/src/import/import.module.ts | 10 + .../apps/backend/src/import/import.service.ts | 227 ++++++++++ .../backend/src/import/parsers/csv.parser.ts | 318 ++++++++++++++ .../src/import/parsers/vcard.parser.ts | 247 +++++++++++ .../src/import/utils/duplicate-detector.ts | 132 ++++++ .../apps/backend/src/types/multer.d.ts | 21 + apps/contacts/apps/web/src/lib/api/export.ts | 101 +++++ apps/contacts/apps/web/src/lib/api/google.ts | 132 ++++++ apps/contacts/apps/web/src/lib/api/import.ts | 155 +++++++ .../web/src/lib/components/ContactList.svelte | 200 +++++++++ .../lib/components/export/ExportModal.svelte | 196 +++++++++ .../lib/components/import/FileUploader.svelte | 130 ++++++ .../lib/components/import/GoogleImport.svelte | 398 ++++++++++++++++++ .../components/import/ImportPreview.svelte | 206 +++++++++ .../apps/web/src/lib/i18n/locales/de.json | 71 +++- .../apps/web/src/lib/i18n/locales/en.json | 71 +++- .../web/src/routes/(app)/import/+page.svelte | 283 +++++++++++++ 34 files changed, 4182 insertions(+), 5 deletions(-) create mode 100644 apps/contacts/apps/backend/src/db/schema/connected-accounts.schema.ts create mode 100644 apps/contacts/apps/backend/src/export/dto/export.dto.ts create mode 100644 apps/contacts/apps/backend/src/export/export.controller.ts create mode 100644 apps/contacts/apps/backend/src/export/export.module.ts create mode 100644 apps/contacts/apps/backend/src/export/export.service.ts create mode 100644 apps/contacts/apps/backend/src/export/generators/csv.generator.ts create mode 100644 apps/contacts/apps/backend/src/export/generators/vcard.generator.ts create mode 100644 apps/contacts/apps/backend/src/google/dto/google.dto.ts create mode 100644 apps/contacts/apps/backend/src/google/google.controller.ts create mode 100644 apps/contacts/apps/backend/src/google/google.module.ts create mode 100644 apps/contacts/apps/backend/src/google/google.service.ts create mode 100644 apps/contacts/apps/backend/src/import/dto/import.dto.ts create mode 100644 apps/contacts/apps/backend/src/import/import.controller.ts create mode 100644 apps/contacts/apps/backend/src/import/import.module.ts create mode 100644 apps/contacts/apps/backend/src/import/import.service.ts create mode 100644 apps/contacts/apps/backend/src/import/parsers/csv.parser.ts create mode 100644 apps/contacts/apps/backend/src/import/parsers/vcard.parser.ts create mode 100644 apps/contacts/apps/backend/src/import/utils/duplicate-detector.ts create mode 100644 apps/contacts/apps/backend/src/types/multer.d.ts create mode 100644 apps/contacts/apps/web/src/lib/api/export.ts create mode 100644 apps/contacts/apps/web/src/lib/api/google.ts create mode 100644 apps/contacts/apps/web/src/lib/api/import.ts create mode 100644 apps/contacts/apps/web/src/lib/components/ContactList.svelte create mode 100644 apps/contacts/apps/web/src/lib/components/export/ExportModal.svelte create mode 100644 apps/contacts/apps/web/src/lib/components/import/FileUploader.svelte create mode 100644 apps/contacts/apps/web/src/lib/components/import/GoogleImport.svelte create mode 100644 apps/contacts/apps/web/src/lib/components/import/ImportPreview.svelte create mode 100644 apps/contacts/apps/web/src/routes/(app)/import/+page.svelte diff --git a/apps/contacts/CLAUDE.md b/apps/contacts/CLAUDE.md index 1f5b05da4..a95ef9c01 100644 --- a/apps/contacts/CLAUDE.md +++ b/apps/contacts/CLAUDE.md @@ -97,8 +97,17 @@ pnpm build # Build for production | `/api/v1/notes/:id` | DELETE | Delete note | | `/api/v1/contacts/:id/activities` | GET | Get contact activities | | `/api/v1/contacts/:id/activities` | POST | Log activity | -| `/api/v1/contacts/import` | POST | Import contacts (vCard/CSV)| -| `/api/v1/contacts/export` | GET | Export contacts | +| `/api/v1/import/preview` | POST | Preview file import (vCard/CSV) | +| `/api/v1/import/execute` | POST | Execute contact import | +| `/api/v1/import/template/csv` | GET | Download CSV template | +| `/api/v1/google/auth-url` | GET | Get Google OAuth URL | +| `/api/v1/google/callback` | POST | Exchange OAuth code | +| `/api/v1/google/status` | GET | Get Google connection status | +| `/api/v1/google/disconnect` | DELETE | Disconnect Google account | +| `/api/v1/google/contacts` | GET | Fetch Google contacts | +| `/api/v1/google/import` | POST | Import from Google | +| `/api/v1/export` | GET | Quick export all contacts | +| `/api/v1/export` | POST | Export with options | | `/api/v1/organizations/:orgId/contacts` | GET | Get organization contacts | | `/api/v1/teams/:teamId/contacts` | GET | Get team contacts | | `/api/v1/contacts/:id/share` | POST | Share contact | @@ -165,6 +174,20 @@ pnpm build # Build for production - `metadata` (JSONB) - `created_at` (TIMESTAMP) +**connected_accounts** - OAuth provider connections (Google, etc.) + +- `id` (UUID) - Primary key +- `user_id` (VARCHAR) - User reference +- `provider` (VARCHAR) - Provider name (e.g., 'google') +- `provider_account_id` (VARCHAR) - Provider's user ID +- `provider_email` (VARCHAR) - Provider account email +- `access_token` (TEXT) - OAuth access token (encrypted) +- `refresh_token` (TEXT) - OAuth refresh token (encrypted) +- `token_expires_at` (TIMESTAMP) - Token expiration time +- `scope` (TEXT) - Granted OAuth scopes +- `provider_data` (JSONB) - Additional provider-specific data +- `created_at`, `updated_at` (TIMESTAMP) + ### Environment Variables #### Backend (.env) @@ -180,6 +203,12 @@ S3_REGION=us-east-1 S3_ACCESS_KEY=minioadmin S3_SECRET_KEY=minioadmin S3_BUCKET=contacts-photos + +# Google OAuth (for contacts import) +# Get credentials from https://console.cloud.google.com/apis/credentials +GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=your-client-secret +GOOGLE_REDIRECT_URI=http://localhost:5184/import?tab=google ``` #### Mobile (.env) diff --git a/apps/contacts/apps/backend/package.json b/apps/contacts/apps/backend/package.json index 3d3fc92c8..8943d6c20 100644 --- a/apps/contacts/apps/backend/package.json +++ b/apps/contacts/apps/backend/package.json @@ -26,17 +26,21 @@ "@nestjs/platform-express": "^10.4.15", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "csv-parse": "^6.1.0", "dotenv": "^16.4.7", "drizzle-kit": "^0.30.2", "drizzle-orm": "^0.38.3", + "googleapis": "^144.0.0", "postgres": "^3.4.5", "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "vcard4": "^4.0.2" }, "devDependencies": { "@nestjs/cli": "^10.4.9", "@nestjs/schematics": "^10.2.3", "@types/express": "^5.0.0", + "@types/multer": "^1.4.11", "@types/node": "^22.10.2", "@typescript-eslint/eslint-plugin": "^8.18.1", "@typescript-eslint/parser": "^8.18.1", diff --git a/apps/contacts/apps/backend/src/app.module.ts b/apps/contacts/apps/backend/src/app.module.ts index 0dba2b9a1..67ab6f46b 100644 --- a/apps/contacts/apps/backend/src/app.module.ts +++ b/apps/contacts/apps/backend/src/app.module.ts @@ -7,6 +7,9 @@ import { TagModule } from './tag/tag.module'; import { NoteModule } from './note/note.module'; import { ActivityModule } from './activity/activity.module'; import { HealthModule } from './health/health.module'; +import { ImportModule } from './import/import.module'; +import { ExportModule } from './export/export.module'; +import { GoogleModule } from './google/google.module'; @Module({ imports: [ @@ -21,6 +24,9 @@ import { HealthModule } from './health/health.module'; NoteModule, ActivityModule, HealthModule, + ImportModule, + ExportModule, + GoogleModule, ], }) export class AppModule {} diff --git a/apps/contacts/apps/backend/src/db/schema/connected-accounts.schema.ts b/apps/contacts/apps/backend/src/db/schema/connected-accounts.schema.ts new file mode 100644 index 000000000..50227c718 --- /dev/null +++ b/apps/contacts/apps/backend/src/db/schema/connected-accounts.schema.ts @@ -0,0 +1,36 @@ +import { pgTable, uuid, timestamp, varchar, text, jsonb } from 'drizzle-orm/pg-core'; + +export interface GoogleContactsProviderData { + syncToken?: string; + lastSyncedAt?: string; + importedResourceNames?: string[]; + totalContacts?: number; +} + +export type ProviderData = GoogleContactsProviderData; + +export const connectedAccounts = pgTable('connected_accounts', { + id: uuid('id').primaryKey().defaultRandom(), + userId: varchar('user_id', { length: 255 }).notNull(), + + // Provider identification + provider: varchar('provider', { length: 50 }).notNull(), // 'google' + providerAccountId: varchar('provider_account_id', { length: 255 }), + providerEmail: varchar('provider_email', { length: 255 }), + + // OAuth tokens + accessToken: text('access_token').notNull(), + refreshToken: text('refresh_token'), + tokenExpiresAt: timestamp('token_expires_at', { withTimezone: true }), + scope: text('scope'), + + // Provider-specific metadata + providerData: jsonb('provider_data').$type(), + + // Timestamps + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}); + +export type ConnectedAccount = typeof connectedAccounts.$inferSelect; +export type NewConnectedAccount = typeof connectedAccounts.$inferInsert; diff --git a/apps/contacts/apps/backend/src/db/schema/index.ts b/apps/contacts/apps/backend/src/db/schema/index.ts index 184e3adda..81f1d7ca3 100644 --- a/apps/contacts/apps/backend/src/db/schema/index.ts +++ b/apps/contacts/apps/backend/src/db/schema/index.ts @@ -3,3 +3,4 @@ export * from './groups.schema'; export * from './tags.schema'; export * from './notes.schema'; export * from './activities.schema'; +export * from './connected-accounts.schema'; diff --git a/apps/contacts/apps/backend/src/export/dto/export.dto.ts b/apps/contacts/apps/backend/src/export/dto/export.dto.ts new file mode 100644 index 000000000..f9214ba3c --- /dev/null +++ b/apps/contacts/apps/backend/src/export/dto/export.dto.ts @@ -0,0 +1,27 @@ +import { IsEnum, IsOptional, IsArray, IsUUID } from 'class-validator'; + +export type ExportFormat = 'vcard' | 'csv'; + +export class ExportRequestDto { + @IsEnum(['vcard', 'csv']) + format: ExportFormat; + + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + contactIds?: string[]; + + @IsOptional() + @IsUUID('4') + groupId?: string; + + @IsOptional() + @IsUUID('4') + tagId?: string; + + @IsOptional() + includeFavorites?: boolean; + + @IsOptional() + includeArchived?: boolean; +} diff --git a/apps/contacts/apps/backend/src/export/export.controller.ts b/apps/contacts/apps/backend/src/export/export.controller.ts new file mode 100644 index 000000000..9088fdf37 --- /dev/null +++ b/apps/contacts/apps/backend/src/export/export.controller.ts @@ -0,0 +1,53 @@ +import { Controller, Get, Post, Body, Query, Res, UseGuards } from '@nestjs/common'; +import { Response } from 'express'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { ExportService } from './export.service'; +import { ExportRequestDto, ExportFormat } from './dto/export.dto'; + +@Controller('api/v1/export') +@UseGuards(JwtAuthGuard) +export class ExportController { + constructor(private readonly exportService: ExportService) {} + + /** + * Export contacts via POST with options in body + */ + @Post() + async exportContacts( + @CurrentUser() user: CurrentUserData, + @Body() exportRequest: ExportRequestDto, + @Res() res: Response + ) { + const result = await this.exportService.exportContacts(user.userId, exportRequest); + + res.setHeader('Content-Type', result.mimeType); + res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`); + res.setHeader('X-Contact-Count', result.contactCount.toString()); + res.send(result.data); + } + + /** + * Quick export all contacts via GET + */ + @Get() + async quickExport( + @CurrentUser() user: CurrentUserData, + @Res() res: Response, + @Query('format') format: ExportFormat = 'vcard', + @Query('favorites') favorites?: string, + @Query('archived') archived?: string + ) { + const exportRequest: ExportRequestDto = { + format, + includeFavorites: favorites === 'true' ? true : favorites === 'false' ? false : undefined, + includeArchived: archived === 'true', + }; + + const result = await this.exportService.exportContacts(user.userId, exportRequest); + + res.setHeader('Content-Type', result.mimeType); + res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`); + res.setHeader('X-Contact-Count', result.contactCount.toString()); + res.send(result.data); + } +} diff --git a/apps/contacts/apps/backend/src/export/export.module.ts b/apps/contacts/apps/backend/src/export/export.module.ts new file mode 100644 index 000000000..feb591e6b --- /dev/null +++ b/apps/contacts/apps/backend/src/export/export.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ExportController } from './export.controller'; +import { ExportService } from './export.service'; + +@Module({ + controllers: [ExportController], + providers: [ExportService], + exports: [ExportService], +}) +export class ExportModule {} diff --git a/apps/contacts/apps/backend/src/export/export.service.ts b/apps/contacts/apps/backend/src/export/export.service.ts new file mode 100644 index 000000000..8238fa660 --- /dev/null +++ b/apps/contacts/apps/backend/src/export/export.service.ts @@ -0,0 +1,140 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { eq, and, inArray } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { type Database } from '../db/connection'; +import { contacts, type Contact } from '../db/schema'; +import { contactToGroups, contactToTags } from '../db/schema'; +import { ExportRequestDto, ExportFormat } from './dto/export.dto'; +import { generateVCardFile } from './generators/vcard.generator'; +import { generateCsvFile } from './generators/csv.generator'; + +export interface ExportResult { + data: string; + filename: string; + mimeType: string; + contactCount: number; +} + +@Injectable() +export class ExportService { + constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} + + /** + * Export contacts based on the request options + */ + async exportContacts(userId: string, options: ExportRequestDto): Promise { + // Get contacts based on filters + const contactList = await this.getContactsForExport(userId, options); + + // Generate export data + const { data, mimeType, extension } = this.generateExportData(contactList, options.format); + + // Generate filename + const timestamp = new Date().toISOString().slice(0, 10); + const filename = `contacts-${timestamp}.${extension}`; + + return { + data, + filename, + mimeType, + contactCount: contactList.length, + }; + } + + /** + * Get contacts based on export options + */ + private async getContactsForExport( + userId: string, + options: ExportRequestDto + ): Promise { + const { contactIds, groupId, tagId, includeFavorites, includeArchived = false } = options; + + // If specific contact IDs are provided, fetch those + if (contactIds && contactIds.length > 0) { + return this.db + .select() + .from(contacts) + .where(and(eq(contacts.userId, userId), inArray(contacts.id, contactIds))); + } + + // If a group is specified, get contacts in that group + if (groupId) { + const groupContacts = await this.db + .select({ contactId: contactToGroups.contactId }) + .from(contactToGroups) + .where(eq(contactToGroups.groupId, groupId)); + + const contactIdsInGroup = groupContacts.map((gc) => gc.contactId); + + if (contactIdsInGroup.length === 0) { + return []; + } + + return this.db + .select() + .from(contacts) + .where(and(eq(contacts.userId, userId), inArray(contacts.id, contactIdsInGroup))); + } + + // If a tag is specified, get contacts with that tag + if (tagId) { + const taggedContacts = await this.db + .select({ contactId: contactToTags.contactId }) + .from(contactToTags) + .where(eq(contactToTags.tagId, tagId)); + + const contactIdsWithTag = taggedContacts.map((tc) => tc.contactId); + + if (contactIdsWithTag.length === 0) { + return []; + } + + return this.db + .select() + .from(contacts) + .where(and(eq(contacts.userId, userId), inArray(contacts.id, contactIdsWithTag))); + } + + // Default: get all contacts with optional filters + let conditions = [eq(contacts.userId, userId)]; + + if (!includeArchived) { + conditions.push(eq(contacts.isArchived, false)); + } + + if (includeFavorites !== undefined) { + conditions.push(eq(contacts.isFavorite, includeFavorites)); + } + + return this.db + .select() + .from(contacts) + .where(and(...conditions)); + } + + /** + * Generate export data in the specified format + */ + private generateExportData( + contactList: Contact[], + format: ExportFormat + ): { data: string; mimeType: string; extension: string } { + switch (format) { + case 'vcard': + return { + data: generateVCardFile(contactList), + mimeType: 'text/vcard', + extension: 'vcf', + }; + case 'csv': + return { + data: generateCsvFile(contactList), + mimeType: 'text/csv', + extension: 'csv', + }; + default: + throw new Error(`Unsupported export format: ${format}`); + } + } +} diff --git a/apps/contacts/apps/backend/src/export/generators/csv.generator.ts b/apps/contacts/apps/backend/src/export/generators/csv.generator.ts new file mode 100644 index 000000000..7ca99ef14 --- /dev/null +++ b/apps/contacts/apps/backend/src/export/generators/csv.generator.ts @@ -0,0 +1,85 @@ +import { Contact } from '../../db/schema/contacts.schema'; + +/** + * CSV column configuration + */ +const CSV_COLUMNS = [ + { key: 'firstName', header: 'First Name' }, + { key: 'lastName', header: 'Last Name' }, + { key: 'displayName', header: 'Display Name' }, + { key: 'nickname', header: 'Nickname' }, + { key: 'email', header: 'Email' }, + { key: 'phone', header: 'Phone' }, + { key: 'mobile', header: 'Mobile' }, + { key: 'company', header: 'Company' }, + { key: 'jobTitle', header: 'Job Title' }, + { key: 'department', header: 'Department' }, + { key: 'street', header: 'Street' }, + { key: 'city', header: 'City' }, + { key: 'postalCode', header: 'Postal Code' }, + { key: 'country', header: 'Country' }, + { key: 'website', header: 'Website' }, + { key: 'birthday', header: 'Birthday' }, + { key: 'notes', header: 'Notes' }, +] as const; + +/** + * Generate CSV file from contacts + */ +export function generateCsvFile(contacts: Contact[]): string { + const lines: string[] = []; + + // Header row + const headers = CSV_COLUMNS.map((col) => col.header); + lines.push(headers.join(',')); + + // Data rows + for (const contact of contacts) { + const row = CSV_COLUMNS.map((col) => { + const value = contact[col.key as keyof Contact]; + return escapeCsvValue(formatValue(value)); + }); + lines.push(row.join(',')); + } + + return lines.join('\r\n'); +} + +/** + * Format a value for CSV output + */ +function formatValue(value: unknown): string { + if (value === null || value === undefined) { + return ''; + } + + if (value instanceof Date) { + return value.toISOString().slice(0, 10); // YYYY-MM-DD + } + + if (typeof value === 'boolean') { + return value ? 'true' : 'false'; + } + + if (typeof value === 'object') { + return JSON.stringify(value); + } + + return String(value); +} + +/** + * Escape a value for CSV (RFC 4180) + */ +function escapeCsvValue(value: string): string { + if (!value) { + return ''; + } + + // If the value contains comma, quote, or newline, wrap in quotes and escape quotes + if (value.includes(',') || value.includes('"') || value.includes('\n') || value.includes('\r')) { + return `"${value.replace(/"/g, '""')}"`; + } + + return value; +} diff --git a/apps/contacts/apps/backend/src/export/generators/vcard.generator.ts b/apps/contacts/apps/backend/src/export/generators/vcard.generator.ts new file mode 100644 index 000000000..359989ad2 --- /dev/null +++ b/apps/contacts/apps/backend/src/export/generators/vcard.generator.ts @@ -0,0 +1,122 @@ +import { Contact } from '../../db/schema/contacts.schema'; + +/** + * Generates vCard 3.0 format + * Reference: https://www.rfc-editor.org/rfc/rfc2426 + */ +export function generateVCard(contact: Contact): string { + const lines: string[] = []; + + lines.push('BEGIN:VCARD'); + lines.push('VERSION:3.0'); + + // Name + const lastName = contact.lastName || ''; + const firstName = contact.firstName || ''; + lines.push(`N:${escapeVCardValue(lastName)};${escapeVCardValue(firstName)};;;`); + + // Full name + const fn = + contact.displayName || [firstName, lastName].filter(Boolean).join(' ') || 'Unnamed Contact'; + lines.push(`FN:${escapeVCardValue(fn)}`); + + // Nickname + if (contact.nickname) { + lines.push(`NICKNAME:${escapeVCardValue(contact.nickname)}`); + } + + // Organization + if (contact.company || contact.department) { + const org = [contact.company, contact.department].filter(Boolean).join(';'); + lines.push(`ORG:${escapeVCardValue(org)}`); + } + + // Job Title + if (contact.jobTitle) { + lines.push(`TITLE:${escapeVCardValue(contact.jobTitle)}`); + } + + // Email + if (contact.email) { + lines.push(`EMAIL;TYPE=INTERNET:${escapeVCardValue(contact.email)}`); + } + + // Phone + if (contact.phone) { + lines.push(`TEL;TYPE=WORK:${escapeVCardValue(contact.phone)}`); + } + + // Mobile + if (contact.mobile) { + lines.push(`TEL;TYPE=CELL:${escapeVCardValue(contact.mobile)}`); + } + + // Address + if (contact.street || contact.city || contact.postalCode || contact.country) { + const adr = [ + '', // PO Box + '', // Extended address + contact.street || '', + contact.city || '', + '', // Region + contact.postalCode || '', + contact.country || '', + ] + .map(escapeVCardValue) + .join(';'); + lines.push(`ADR;TYPE=HOME:${adr}`); + } + + // Website + if (contact.website) { + lines.push(`URL:${escapeVCardValue(contact.website)}`); + } + + // Birthday + if (contact.birthday) { + // Format: YYYYMMDD - birthday is stored as string (date type in DB) + const bday = String(contact.birthday).replace(/-/g, ''); + lines.push(`BDAY:${bday}`); + } + + // Notes + if (contact.notes) { + lines.push(`NOTE:${escapeVCardValue(contact.notes)}`); + } + + // Photo URL + if (contact.photoUrl) { + lines.push(`PHOTO;VALUE=URI:${escapeVCardValue(contact.photoUrl)}`); + } + + // UID + lines.push(`UID:${contact.id}`); + + // Revision timestamp + const rev = contact.updatedAt?.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; + lines.push(`REV:${rev}`); + + lines.push('END:VCARD'); + + return lines.join('\r\n'); +} + +/** + * Generate multiple vCards as a single file + */ +export function generateVCardFile(contacts: Contact[]): string { + return contacts.map((contact) => generateVCard(contact)).join('\r\n'); +} + +/** + * Escape special characters for vCard values + */ +function escapeVCardValue(value: string): string { + if (!value) return ''; + + return value + .replace(/\\/g, '\\\\') // Escape backslashes first + .replace(/;/g, '\\;') // Escape semicolons + .replace(/,/g, '\\,') // Escape commas + .replace(/\n/g, '\\n'); // Escape newlines +} diff --git a/apps/contacts/apps/backend/src/google/dto/google.dto.ts b/apps/contacts/apps/backend/src/google/dto/google.dto.ts new file mode 100644 index 000000000..b1f7c498a --- /dev/null +++ b/apps/contacts/apps/backend/src/google/dto/google.dto.ts @@ -0,0 +1,95 @@ +import { IsString, IsOptional, IsArray, IsBoolean } from 'class-validator'; + +export class GoogleCallbackDto { + @IsString() + code: string; + + @IsOptional() + @IsString() + state?: string; +} + +export class GoogleImportDto { + @IsOptional() + @IsArray() + @IsString({ each: true }) + resourceNames?: string[]; + + @IsOptional() + @IsBoolean() + all?: boolean; +} + +export interface GoogleContact { + resourceName: string; + etag?: string; + names?: Array<{ + displayName?: string; + familyName?: string; + givenName?: string; + middleName?: string; + }>; + emailAddresses?: Array<{ + value?: string; + type?: string; + }>; + phoneNumbers?: Array<{ + value?: string; + type?: string; + }>; + addresses?: Array<{ + streetAddress?: string; + city?: string; + postalCode?: string; + country?: string; + type?: string; + }>; + organizations?: Array<{ + name?: string; + title?: string; + department?: string; + }>; + urls?: Array<{ + value?: string; + type?: string; + }>; + birthdays?: Array<{ + date?: { + year?: number; + month?: number; + day?: number; + }; + }>; + biographies?: Array<{ + value?: string; + }>; + photos?: Array<{ + url?: string; + }>; +} + +export interface GoogleAuthUrlResponse { + url: string; +} + +export interface GoogleContactsResponse { + contacts: GoogleContact[]; + nextPageToken?: string; + totalPeople?: number; +} + +export interface GoogleImportResult { + imported: number; + skipped: number; + errors: Array<{ + resourceName: string; + error: string; + }>; +} + +export interface ConnectedAccountResponse { + id: string; + provider: string; + providerEmail: string | null; + createdAt: Date; +} diff --git a/apps/contacts/apps/backend/src/google/google.controller.ts b/apps/contacts/apps/backend/src/google/google.controller.ts new file mode 100644 index 000000000..1861fac47 --- /dev/null +++ b/apps/contacts/apps/backend/src/google/google.controller.ts @@ -0,0 +1,74 @@ +import { Controller, Get, Post, Delete, Body, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { GoogleService } from './google.service'; +import { GoogleCallbackDto, GoogleImportDto } from './dto/google.dto'; + +@Controller('google') +@UseGuards(JwtAuthGuard) +export class GoogleController { + constructor(private readonly googleService: GoogleService) {} + + /** + * Get OAuth2 authorization URL + */ + @Get('auth-url') + getAuthUrl(@Query('state') state?: string) { + const url = this.googleService.getAuthUrl(state); + return { url }; + } + + /** + * Handle OAuth2 callback + */ + @Post('callback') + async handleCallback(@CurrentUser() user: CurrentUserData, @Body() dto: GoogleCallbackDto) { + const account = await this.googleService.handleCallback(user.userId, dto.code); + return { success: true, account }; + } + + /** + * Get connected account status + */ + @Get('status') + async getStatus(@CurrentUser() user: CurrentUserData) { + const account = await this.googleService.getConnectedAccount(user.userId); + return { + connected: !!account, + account: account + ? { + id: account.id, + providerEmail: account.providerEmail, + createdAt: account.createdAt, + } + : null, + }; + } + + /** + * Disconnect Google account + */ + @Delete('disconnect') + async disconnect(@CurrentUser() user: CurrentUserData) { + await this.googleService.disconnect(user.userId); + return { success: true }; + } + + /** + * Fetch contacts from Google + */ + @Get('contacts') + async fetchContacts( + @CurrentUser() user: CurrentUserData, + @Query('pageToken') pageToken?: string + ) { + return this.googleService.fetchContacts(user.userId, pageToken); + } + + /** + * Import selected Google contacts + */ + @Post('import') + async importContacts(@CurrentUser() user: CurrentUserData, @Body() dto: GoogleImportDto) { + return this.googleService.importContacts(user.userId, dto.resourceNames, dto.all); + } +} diff --git a/apps/contacts/apps/backend/src/google/google.module.ts b/apps/contacts/apps/backend/src/google/google.module.ts new file mode 100644 index 000000000..20e356206 --- /dev/null +++ b/apps/contacts/apps/backend/src/google/google.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { GoogleController } from './google.controller'; +import { GoogleService } from './google.service'; + +@Module({ + controllers: [GoogleController], + providers: [GoogleService], + exports: [GoogleService], +}) +export class GoogleModule {} diff --git a/apps/contacts/apps/backend/src/google/google.service.ts b/apps/contacts/apps/backend/src/google/google.service.ts new file mode 100644 index 000000000..ff625ecbc --- /dev/null +++ b/apps/contacts/apps/backend/src/google/google.service.ts @@ -0,0 +1,389 @@ +import { Injectable, Inject, UnauthorizedException, BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { google, people_v1, Auth } from 'googleapis'; +import { eq, and } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { type Database } from '../db/connection'; +import { + connectedAccounts, + type ConnectedAccount, + type GoogleContactsProviderData, +} from '../db/schema'; +import { contacts, type NewContact } from '../db/schema'; +import type { + GoogleContact, + GoogleContactsResponse, + GoogleImportResult, + ConnectedAccountResponse, +} from './dto/google.dto'; + +const GOOGLE_SCOPES = [ + 'https://www.googleapis.com/auth/contacts.readonly', + 'https://www.googleapis.com/auth/userinfo.email', +]; + +@Injectable() +export class GoogleService { + private oauth2Client: Auth.OAuth2Client; + + constructor( + @Inject(DATABASE_CONNECTION) private db: Database, + private configService: ConfigService + ) { + this.oauth2Client = new google.auth.OAuth2( + this.configService.get('GOOGLE_CLIENT_ID'), + this.configService.get('GOOGLE_CLIENT_SECRET'), + this.configService.get('GOOGLE_REDIRECT_URI') + ); + } + + /** + * Generate OAuth2 authorization URL + */ + getAuthUrl(state?: string): string { + return this.oauth2Client.generateAuthUrl({ + access_type: 'offline', + scope: GOOGLE_SCOPES, + prompt: 'consent', + state, + }); + } + + /** + * Exchange authorization code for tokens and store them + */ + async handleCallback(userId: string, code: string): Promise { + const { tokens } = await this.oauth2Client.getToken(code); + + if (!tokens.access_token) { + throw new BadRequestException('Failed to get access token from Google'); + } + + // Get user info + this.oauth2Client.setCredentials(tokens); + const oauth2 = google.oauth2({ version: 'v2', auth: this.oauth2Client }); + const { data: userInfo } = await oauth2.userinfo.get(); + + // Check if already connected + const existing = await this.getConnectedAccount(userId); + + if (existing) { + // Update existing connection + const [updated] = await this.db + .update(connectedAccounts) + .set({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || existing.refreshToken, + tokenExpiresAt: tokens.expiry_date ? new Date(tokens.expiry_date) : null, + scope: tokens.scope || null, + providerEmail: userInfo.email || null, + providerAccountId: userInfo.id || null, + updatedAt: new Date(), + }) + .where(eq(connectedAccounts.id, existing.id)) + .returning(); + + return this.toResponse(updated); + } + + // Create new connection + const [account] = await this.db + .insert(connectedAccounts) + .values({ + userId, + provider: 'google', + providerAccountId: userInfo.id || null, + providerEmail: userInfo.email || null, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || null, + tokenExpiresAt: tokens.expiry_date ? new Date(tokens.expiry_date) : null, + scope: tokens.scope || null, + providerData: {}, + }) + .returning(); + + return this.toResponse(account); + } + + /** + * Get connected Google account for user + */ + async getConnectedAccount(userId: string): Promise { + const [account] = await this.db + .select() + .from(connectedAccounts) + .where(and(eq(connectedAccounts.userId, userId), eq(connectedAccounts.provider, 'google'))); + + return account || null; + } + + /** + * Disconnect Google account + */ + async disconnect(userId: string): Promise { + const account = await this.getConnectedAccount(userId); + + if (account) { + // Revoke token + try { + await this.oauth2Client.revokeToken(account.accessToken); + } catch { + // Ignore revoke errors + } + + // Delete from database + await this.db + .delete(connectedAccounts) + .where(and(eq(connectedAccounts.userId, userId), eq(connectedAccounts.provider, 'google'))); + } + } + + /** + * Fetch contacts from Google + */ + async fetchContacts(userId: string, pageToken?: string): Promise { + const account = await this.getConnectedAccount(userId); + if (!account) { + throw new UnauthorizedException('Google account not connected'); + } + + // Ensure token is valid + await this.ensureValidToken(account); + + this.oauth2Client.setCredentials({ + access_token: account.accessToken, + refresh_token: account.refreshToken, + }); + + const peopleService = google.people({ version: 'v1', auth: this.oauth2Client }); + + const response = await peopleService.people.connections.list({ + resourceName: 'people/me', + pageSize: 100, + pageToken, + personFields: + 'names,emailAddresses,phoneNumbers,addresses,organizations,urls,birthdays,biographies,photos', + }); + + const googleContacts: GoogleContact[] = (response.data.connections || []).map((person) => ({ + resourceName: person.resourceName || '', + etag: person.etag || undefined, + names: person.names?.map((n) => ({ + displayName: n.displayName || undefined, + familyName: n.familyName || undefined, + givenName: n.givenName || undefined, + middleName: n.middleName || undefined, + })), + emailAddresses: person.emailAddresses?.map((e) => ({ + value: e.value || undefined, + type: e.type || undefined, + })), + phoneNumbers: person.phoneNumbers?.map((p) => ({ + value: p.value || undefined, + type: p.type || undefined, + })), + addresses: person.addresses?.map((a) => ({ + streetAddress: a.streetAddress || undefined, + city: a.city || undefined, + postalCode: a.postalCode || undefined, + country: a.country || undefined, + type: a.type || undefined, + })), + organizations: person.organizations?.map((o) => ({ + name: o.name || undefined, + title: o.title || undefined, + department: o.department || undefined, + })), + urls: person.urls?.map((u) => ({ + value: u.value || undefined, + type: u.type || undefined, + })), + birthdays: person.birthdays?.map((b) => ({ + date: b.date + ? { + year: b.date.year || undefined, + month: b.date.month || undefined, + day: b.date.day || undefined, + } + : undefined, + })), + biographies: person.biographies?.map((bio) => ({ + value: bio.value || undefined, + })), + photos: person.photos?.map((p) => ({ + url: p.url || undefined, + })), + })); + + return { + contacts: googleContacts, + nextPageToken: response.data.nextPageToken || undefined, + totalPeople: response.data.totalPeople || undefined, + }; + } + + /** + * Import selected Google contacts + */ + async importContacts( + userId: string, + resourceNames?: string[], + importAll = false + ): Promise { + const result: GoogleImportResult = { + imported: 0, + skipped: 0, + errors: [], + }; + + // Fetch all contacts if importAll + let contactsToImport: GoogleContact[] = []; + + if (importAll) { + let pageToken: string | undefined; + do { + const response = await this.fetchContacts(userId, pageToken); + contactsToImport.push(...response.contacts); + pageToken = response.nextPageToken; + } while (pageToken); + } else if (resourceNames && resourceNames.length > 0) { + const response = await this.fetchContacts(userId); + contactsToImport = response.contacts.filter((c) => resourceNames.includes(c.resourceName)); + } + + // Import each contact + for (const googleContact of contactsToImport) { + try { + const contactData = this.mapGoogleContactToContact(googleContact, userId); + + // Check for duplicates by email + if (contactData.email) { + const [existing] = await this.db + .select() + .from(contacts) + .where(and(eq(contacts.userId, userId), eq(contacts.email, contactData.email))); + + if (existing) { + result.skipped++; + continue; + } + } + + await this.db.insert(contacts).values(contactData); + result.imported++; + } catch (error) { + result.errors.push({ + resourceName: googleContact.resourceName, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + + // Update provider data with imported resource names + const account = await this.getConnectedAccount(userId); + if (account) { + const providerData = (account.providerData as GoogleContactsProviderData) || {}; + const importedNames = contactsToImport.map((c) => c.resourceName); + + await this.db + .update(connectedAccounts) + .set({ + providerData: { + ...providerData, + lastSyncedAt: new Date().toISOString(), + importedResourceNames: [ + ...(providerData.importedResourceNames || []), + ...importedNames, + ], + }, + updatedAt: new Date(), + }) + .where(eq(connectedAccounts.id, account.id)); + } + + return result; + } + + /** + * Ensure OAuth token is valid, refresh if needed + */ + private async ensureValidToken(account: ConnectedAccount): Promise { + if (account.tokenExpiresAt && new Date() >= account.tokenExpiresAt) { + if (!account.refreshToken) { + throw new UnauthorizedException('Token expired and no refresh token available'); + } + + this.oauth2Client.setCredentials({ + refresh_token: account.refreshToken, + }); + + const { credentials } = await this.oauth2Client.refreshAccessToken(); + + await this.db + .update(connectedAccounts) + .set({ + accessToken: credentials.access_token!, + tokenExpiresAt: credentials.expiry_date ? new Date(credentials.expiry_date) : null, + updatedAt: new Date(), + }) + .where(eq(connectedAccounts.id, account.id)); + + account.accessToken = credentials.access_token!; + } + } + + /** + * Map Google contact to our contact schema + */ + private mapGoogleContactToContact(googleContact: GoogleContact, userId: string): NewContact { + const name = googleContact.names?.[0]; + const email = googleContact.emailAddresses?.[0]; + const phone = googleContact.phoneNumbers?.find((p) => p.type !== 'mobile'); + const mobile = googleContact.phoneNumbers?.find((p) => p.type === 'mobile'); + const address = googleContact.addresses?.[0]; + const org = googleContact.organizations?.[0]; + const website = googleContact.urls?.[0]; + const birthday = googleContact.birthdays?.[0]; + const bio = googleContact.biographies?.[0]; + const photo = googleContact.photos?.[0]; + + let birthdayStr: string | undefined; + if (birthday?.date?.year && birthday?.date?.month && birthday?.date?.day) { + birthdayStr = `${birthday.date.year}-${String(birthday.date.month).padStart(2, '0')}-${String(birthday.date.day).padStart(2, '0')}`; + } + + return { + userId, + createdBy: userId, + firstName: name?.givenName || null, + lastName: name?.familyName || null, + displayName: name?.displayName || null, + email: email?.value || null, + phone: phone?.value || null, + mobile: mobile?.value || googleContact.phoneNumbers?.[0]?.value || null, + street: address?.streetAddress || null, + city: address?.city || null, + postalCode: address?.postalCode || null, + country: address?.country || null, + company: org?.name || null, + jobTitle: org?.title || null, + department: org?.department || null, + website: website?.value || null, + birthday: birthdayStr || null, + notes: bio?.value || null, + photoUrl: photo?.url || null, + }; + } + + /** + * Convert to response DTO + */ + private toResponse(account: ConnectedAccount): ConnectedAccountResponse { + return { + id: account.id, + provider: account.provider, + providerEmail: account.providerEmail, + createdAt: account.createdAt, + }; + } +} diff --git a/apps/contacts/apps/backend/src/import/dto/import.dto.ts b/apps/contacts/apps/backend/src/import/dto/import.dto.ts new file mode 100644 index 000000000..a55b87a61 --- /dev/null +++ b/apps/contacts/apps/backend/src/import/dto/import.dto.ts @@ -0,0 +1,122 @@ +import { IsEnum, IsOptional, IsArray, ValidateNested, IsString, IsEmail } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class ParsedContactDto { + @IsOptional() + @IsString() + firstName?: string; + + @IsOptional() + @IsString() + lastName?: string; + + @IsOptional() + @IsString() + displayName?: string; + + @IsOptional() + @IsString() + nickname?: string; + + @IsOptional() + @IsEmail() + email?: string; + + @IsOptional() + @IsString() + phone?: string; + + @IsOptional() + @IsString() + mobile?: string; + + @IsOptional() + @IsString() + street?: string; + + @IsOptional() + @IsString() + city?: string; + + @IsOptional() + @IsString() + postalCode?: string; + + @IsOptional() + @IsString() + country?: string; + + @IsOptional() + @IsString() + company?: string; + + @IsOptional() + @IsString() + jobTitle?: string; + + @IsOptional() + @IsString() + department?: string; + + @IsOptional() + @IsString() + website?: string; + + @IsOptional() + @IsString() + birthday?: string; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsString() + photoUrl?: string; +} + +export type DuplicateAction = 'skip' | 'merge' | 'create'; + +export class DuplicateInfo { + importIndex: number; + existingContactId: string; + existingContactName: string; + matchField: 'email' | 'phone'; + matchValue: string; +} + +export class ImportPreviewResponseDto { + contacts: ParsedContactDto[]; + duplicates: DuplicateInfo[]; + totalParsed: number; + validCount: number; + invalidCount: number; + errors: string[]; +} + +export class ExecuteImportDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ParsedContactDto) + contacts: ParsedContactDto[]; + + @IsEnum(['skip', 'merge', 'create']) + duplicateAction: DuplicateAction; + + @IsOptional() + @IsArray() + skipIndices?: number[]; +} + +export class ImportResultDto { + imported: number; + skipped: number; + merged: number; + errors: ImportErrorDto[]; +} + +export class ImportErrorDto { + index: number; + contactName: string; + error: string; +} diff --git a/apps/contacts/apps/backend/src/import/import.controller.ts b/apps/contacts/apps/backend/src/import/import.controller.ts new file mode 100644 index 000000000..5c126c3c6 --- /dev/null +++ b/apps/contacts/apps/backend/src/import/import.controller.ts @@ -0,0 +1,80 @@ +import { + Controller, + Post, + Get, + Body, + UseGuards, + UseInterceptors, + UploadedFile, + BadRequestException, + Res, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { Response } from 'express'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { ImportService } from './import.service'; +import { ExecuteImportDto } from './dto/import.dto'; + +const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + +const ALLOWED_EXTENSIONS = ['.vcf', '.vcard', '.csv']; + +@Controller('import') +@UseGuards(JwtAuthGuard) +export class ImportController { + constructor(private readonly importService: ImportService) {} + + /** + * Preview import from uploaded file + */ + @Post('preview') + @UseInterceptors( + FileInterceptor('file', { + limits: { fileSize: MAX_FILE_SIZE }, + fileFilter: (req, file, callback) => { + const ext = '.' + file.originalname.split('.').pop()?.toLowerCase(); + if (!ALLOWED_EXTENSIONS.includes(ext)) { + callback( + new BadRequestException(`Invalid file type. Allowed: ${ALLOWED_EXTENSIONS.join(', ')}`), + false + ); + return; + } + callback(null, true); + }, + }) + ) + async previewImport( + @CurrentUser() user: CurrentUserData, + @UploadedFile() file: Express.Multer.File + ) { + if (!file) { + throw new BadRequestException('No file uploaded'); + } + + return this.importService.preview(user.userId, file); + } + + /** + * Execute the import with selected options + */ + @Post('execute') + async executeImport(@CurrentUser() user: CurrentUserData, @Body() dto: ExecuteImportDto) { + return this.importService.execute(user.userId, dto); + } + + /** + * Download CSV template + */ + @Get('template/csv') + async getCsvTemplate(@Res() res: Response) { + const template = this.importService.getCsvTemplate(); + + res.set({ + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': 'attachment; filename="contacts-template.csv"', + }); + + res.send(template); + } +} diff --git a/apps/contacts/apps/backend/src/import/import.module.ts b/apps/contacts/apps/backend/src/import/import.module.ts new file mode 100644 index 000000000..dba95063c --- /dev/null +++ b/apps/contacts/apps/backend/src/import/import.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ImportController } from './import.controller'; +import { ImportService } from './import.service'; + +@Module({ + controllers: [ImportController], + providers: [ImportService], + exports: [ImportService], +}) +export class ImportModule {} diff --git a/apps/contacts/apps/backend/src/import/import.service.ts b/apps/contacts/apps/backend/src/import/import.service.ts new file mode 100644 index 000000000..e6b0fdfd8 --- /dev/null +++ b/apps/contacts/apps/backend/src/import/import.service.ts @@ -0,0 +1,227 @@ +import { Injectable, Inject, BadRequestException } from '@nestjs/common'; +import { eq } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { type Database } from '../db/connection'; +import { contacts, type Contact, type NewContact } from '../db/schema'; +import { VCardParser } from './parsers/vcard.parser'; +import { CsvParser, CsvFieldMapping } from './parsers/csv.parser'; +import { DuplicateDetector } from './utils/duplicate-detector'; +import { + ParsedContactDto, + ImportPreviewResponseDto, + ExecuteImportDto, + ImportResultDto, + ImportErrorDto, + DuplicateInfo, +} from './dto/import.dto'; + +@Injectable() +export class ImportService { + private vcardParser = new VCardParser(); + private csvParser = new CsvParser(); + private duplicateDetector = new DuplicateDetector(); + + constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} + + /** + * Preview import from uploaded file + */ + async preview( + userId: string, + file: Express.Multer.File + ): Promise { + const content = file.buffer.toString('utf-8'); + const extension = this.getFileExtension(file.originalname); + + let parsedContacts: ParsedContactDto[] = []; + let parseErrors: string[] = []; + let fieldMapping: CsvFieldMapping[] | undefined; + + // Parse based on file type + if (extension === 'vcf' || extension === 'vcard') { + const result = this.vcardParser.parse(content); + parsedContacts = result.contacts; + parseErrors = result.errors; + } else if (extension === 'csv') { + const result = this.csvParser.parse(content); + parsedContacts = result.contacts; + parseErrors = result.errors; + fieldMapping = result.fieldMapping; + } else { + throw new BadRequestException( + `Unsupported file type: .${extension}. Use .vcf or .csv files.` + ); + } + + // Validate contacts + const { valid, invalid } = this.validateContacts(parsedContacts); + + // Fetch existing contacts for duplicate detection + const existingContacts = await this.db + .select() + .from(contacts) + .where(eq(contacts.userId, userId)); + + // Detect duplicates + const duplicates = this.duplicateDetector.detectDuplicates(valid, existingContacts); + + return { + contacts: valid, + duplicates, + totalParsed: parsedContacts.length, + validCount: valid.length, + invalidCount: invalid.length, + errors: parseErrors, + fieldMapping, + }; + } + + /** + * Execute the import with the selected options + */ + async execute(userId: string, dto: ExecuteImportDto): Promise { + const result: ImportResultDto = { + imported: 0, + skipped: 0, + merged: 0, + errors: [], + }; + + // Build skip set for fast lookup + const skipSet = new Set(dto.skipIndices || []); + + // Fetch existing contacts for merge operations + let existingContactsMap = new Map(); + if (dto.duplicateAction === 'merge') { + const existingContacts = await this.db + .select() + .from(contacts) + .where(eq(contacts.userId, userId)); + + const duplicates = this.duplicateDetector.detectDuplicates(dto.contacts, existingContacts); + + for (const dup of duplicates) { + const existing = existingContacts.find((c) => c.id === dup.existingContactId); + if (existing) { + existingContactsMap.set(dup.importIndex.toString(), existing); + } + } + } + + // Process each contact + for (let i = 0; i < dto.contacts.length; i++) { + if (skipSet.has(i)) { + result.skipped++; + continue; + } + + const contact = dto.contacts[i]; + + try { + // Check if this is a duplicate that needs handling + const existingContact = existingContactsMap.get(i.toString()); + + if (existingContact && dto.duplicateAction === 'merge') { + // Merge: Update existing contact with new data + const updates = this.duplicateDetector.mergeContacts(existingContact, contact); + + if (Object.keys(updates).length > 0) { + await this.db + .update(contacts) + .set({ ...updates, updatedAt: new Date() }) + .where(eq(contacts.id, existingContact.id)); + } + + result.merged++; + } else if (existingContact && dto.duplicateAction === 'skip') { + // Skip: Don't import + result.skipped++; + } else { + // Create new contact + const newContact: NewContact = { + userId, + createdBy: userId, + firstName: contact.firstName, + lastName: contact.lastName, + displayName: contact.displayName, + nickname: contact.nickname, + email: contact.email, + phone: contact.phone, + mobile: contact.mobile, + street: contact.street, + city: contact.city, + postalCode: contact.postalCode, + country: contact.country, + company: contact.company, + jobTitle: contact.jobTitle, + department: contact.department, + website: contact.website, + birthday: contact.birthday, + notes: contact.notes, + photoUrl: contact.photoUrl, + }; + + await this.db.insert(contacts).values(newContact); + result.imported++; + } + } catch (error) { + result.errors.push({ + index: i, + contactName: this.getContactName(contact), + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + + return result; + } + + /** + * Generate CSV template + */ + getCsvTemplate(): string { + return CsvParser.generateTemplate(); + } + + /** + * Get file extension from filename + */ + private getFileExtension(filename: string): string { + const parts = filename.toLowerCase().split('.'); + return parts[parts.length - 1]; + } + + /** + * Validate contacts - separate valid from invalid + */ + private validateContacts(parsedContacts: ParsedContactDto[]): { + valid: ParsedContactDto[]; + invalid: ParsedContactDto[]; + } { + const valid: ParsedContactDto[] = []; + const invalid: ParsedContactDto[] = []; + + for (const contact of parsedContacts) { + // A contact is valid if it has at least a name or email + if (contact.firstName || contact.lastName || contact.email || contact.displayName) { + valid.push(contact); + } else { + invalid.push(contact); + } + } + + return { valid, invalid }; + } + + /** + * Get display name for a contact + */ + private getContactName(contact: ParsedContactDto): string { + if (contact.displayName) return contact.displayName; + if (contact.firstName || contact.lastName) { + return [contact.firstName, contact.lastName].filter(Boolean).join(' '); + } + if (contact.email) return contact.email; + return 'Unknown'; + } +} diff --git a/apps/contacts/apps/backend/src/import/parsers/csv.parser.ts b/apps/contacts/apps/backend/src/import/parsers/csv.parser.ts new file mode 100644 index 000000000..981b660ea --- /dev/null +++ b/apps/contacts/apps/backend/src/import/parsers/csv.parser.ts @@ -0,0 +1,318 @@ +import { parse } from 'csv-parse/sync'; +import { ParsedContactDto } from '../dto/import.dto'; + +// Common header variations mapped to our fields +const HEADER_MAPPINGS: Record = { + // First Name + 'first name': 'firstName', + first_name: 'firstName', + firstname: 'firstName', + 'given name': 'firstName', + given_name: 'firstName', + vorname: 'firstName', + + // Last Name + 'last name': 'lastName', + last_name: 'lastName', + lastname: 'lastName', + surname: 'lastName', + 'family name': 'lastName', + family_name: 'lastName', + nachname: 'lastName', + + // Display Name + 'display name': 'displayName', + display_name: 'displayName', + displayname: 'displayName', + 'full name': 'displayName', + name: 'displayName', + anzeigename: 'displayName', + + // Nickname + nickname: 'nickname', + nick: 'nickname', + spitzname: 'nickname', + + // Email + email: 'email', + 'e-mail': 'email', + 'email address': 'email', + mail: 'email', + + // Phone + phone: 'phone', + telephone: 'phone', + tel: 'phone', + 'phone number': 'phone', + telefon: 'phone', + + // Mobile + mobile: 'mobile', + 'mobile phone': 'mobile', + cell: 'mobile', + 'cell phone': 'mobile', + cellphone: 'mobile', + handy: 'mobile', + mobil: 'mobile', + + // Street + street: 'street', + 'street address': 'street', + address: 'street', + strasse: 'street', + straße: 'street', + + // City + city: 'city', + town: 'city', + stadt: 'city', + ort: 'city', + + // Postal Code + 'postal code': 'postalCode', + postal_code: 'postalCode', + postalcode: 'postalCode', + zip: 'postalCode', + 'zip code': 'postalCode', + zipcode: 'postalCode', + plz: 'postalCode', + postleitzahl: 'postalCode', + + // Country + country: 'country', + land: 'country', + + // Company + company: 'company', + organization: 'company', + organisation: 'company', + org: 'company', + firma: 'company', + unternehmen: 'company', + + // Job Title + 'job title': 'jobTitle', + job_title: 'jobTitle', + jobtitle: 'jobTitle', + title: 'jobTitle', + position: 'jobTitle', + rolle: 'jobTitle', + + // Department + department: 'department', + dept: 'department', + abteilung: 'department', + + // Website + website: 'website', + url: 'website', + 'web site': 'website', + homepage: 'website', + webseite: 'website', + + // Birthday + birthday: 'birthday', + 'birth date': 'birthday', + birthdate: 'birthday', + dob: 'birthday', + geburtstag: 'birthday', + geburtsdatum: 'birthday', + + // Notes + notes: 'notes', + note: 'notes', + comments: 'notes', + comment: 'notes', + notizen: 'notes', + bemerkungen: 'notes', +}; + +export interface CsvFieldMapping { + csvHeader: string; + contactField: keyof ParsedContactDto | null; + sampleValue: string; +} + +export class CsvParser { + /** + * Parse CSV content into contacts + */ + parse(content: string): { + contacts: ParsedContactDto[]; + errors: string[]; + fieldMapping: CsvFieldMapping[]; + } { + const errors: string[] = []; + let records: Record[]; + + try { + records = parse(content, { + columns: true, + skip_empty_lines: true, + trim: true, + bom: true, + relaxColumnCount: true, + relaxQuotes: true, + }); + } catch (error) { + errors.push(`CSV parse error: ${error instanceof Error ? error.message : 'Unknown error'}`); + return { contacts: [], errors, fieldMapping: [] }; + } + + if (records.length === 0) { + return { contacts: [], errors: ['CSV file is empty'], fieldMapping: [] }; + } + + // Detect field mapping from headers + const headers = Object.keys(records[0]); + const fieldMapping = this.detectFieldMapping(headers, records[0]); + + // Parse contacts + const contacts: ParsedContactDto[] = []; + + for (let i = 0; i < records.length; i++) { + try { + const contact = this.mapRecordToContact(records[i], fieldMapping); + if ( + contact && + (contact.firstName || contact.lastName || contact.email || contact.displayName) + ) { + contacts.push(contact); + } + } catch (error) { + errors.push(`Row ${i + 2}: ${error instanceof Error ? error.message : 'Parse error'}`); + } + } + + return { contacts, errors, fieldMapping }; + } + + /** + * Detect field mapping based on header names + */ + private detectFieldMapping( + headers: string[], + sampleRecord: Record + ): CsvFieldMapping[] { + return headers.map((header) => { + const normalizedHeader = header.toLowerCase().trim(); + const contactField = HEADER_MAPPINGS[normalizedHeader] || null; + + return { + csvHeader: header, + contactField, + sampleValue: sampleRecord[header] || '', + }; + }); + } + + /** + * Map a CSV record to a ParsedContactDto + */ + private mapRecordToContact( + record: Record, + fieldMapping: CsvFieldMapping[] + ): ParsedContactDto | null { + const contact: ParsedContactDto = {}; + + for (const mapping of fieldMapping) { + if (!mapping.contactField) continue; + + const value = record[mapping.csvHeader]?.trim(); + if (!value) continue; + + // Special handling for birthday + if (mapping.contactField === 'birthday') { + contact.birthday = this.parseBirthday(value); + } else { + contact[mapping.contactField] = value; + } + } + + // Generate displayName if not set + if (!contact.displayName && (contact.firstName || contact.lastName)) { + contact.displayName = [contact.firstName, contact.lastName].filter(Boolean).join(' '); + } + + return contact; + } + + /** + * Parse various birthday formats to ISO format + */ + private parseBirthday(value: string): string | undefined { + // Try common formats + const formats = [ + /^(\d{4})-(\d{2})-(\d{2})$/, // ISO: 2000-01-15 + /^(\d{2})\/(\d{2})\/(\d{4})$/, // US: 01/15/2000 + /^(\d{2})\.(\d{2})\.(\d{4})$/, // EU: 15.01.2000 + /^(\d{2})-(\d{2})-(\d{4})$/, // Alt: 15-01-2000 + ]; + + // ISO format + if (formats[0].test(value)) { + return value; + } + + // US format MM/DD/YYYY + const usMatch = value.match(formats[1]); + if (usMatch) { + return `${usMatch[3]}-${usMatch[1]}-${usMatch[2]}`; + } + + // EU format DD.MM.YYYY or DD-MM-YYYY + const euMatch = value.match(formats[2]) || value.match(formats[3]); + if (euMatch) { + return `${euMatch[3]}-${euMatch[2]}-${euMatch[1]}`; + } + + return undefined; + } + + /** + * Generate a CSV template with all supported fields + */ + static generateTemplate(): string { + const headers = [ + 'First Name', + 'Last Name', + 'Display Name', + 'Nickname', + 'Email', + 'Phone', + 'Mobile', + 'Street', + 'City', + 'Postal Code', + 'Country', + 'Company', + 'Job Title', + 'Department', + 'Website', + 'Birthday', + 'Notes', + ]; + + const sampleRow = [ + 'Max', + 'Mustermann', + 'Max Mustermann', + 'Maxi', + 'max@example.com', + '+49 123 456789', + '+49 170 1234567', + 'Musterstraße 1', + 'Berlin', + '10115', + 'Germany', + 'Musterfirma GmbH', + 'Software Engineer', + 'Engineering', + 'https://example.com', + '1990-01-15', + 'Example contact', + ]; + + return [headers.join(','), sampleRow.join(',')].join('\n'); + } +} diff --git a/apps/contacts/apps/backend/src/import/parsers/vcard.parser.ts b/apps/contacts/apps/backend/src/import/parsers/vcard.parser.ts new file mode 100644 index 000000000..5c6c08496 --- /dev/null +++ b/apps/contacts/apps/backend/src/import/parsers/vcard.parser.ts @@ -0,0 +1,247 @@ +import { ParsedContactDto } from '../dto/import.dto'; + +interface VCardProperty { + name: string; + params: Record; + value: string; +} + +export class VCardParser { + /** + * Parse vCard content (supports v2.1, v3.0, v4.0) + */ + parse(content: string): { contacts: ParsedContactDto[]; errors: string[] } { + const contacts: ParsedContactDto[] = []; + const errors: string[] = []; + + // Normalize line endings and unfold long lines + const normalizedContent = this.unfoldLines(content.replace(/\r\n/g, '\n').replace(/\r/g, '\n')); + + // Split into individual vCards + const vcardBlocks = this.splitVCards(normalizedContent); + + for (let i = 0; i < vcardBlocks.length; i++) { + try { + const contact = this.parseVCard(vcardBlocks[i]); + if ( + contact && + (contact.firstName || contact.lastName || contact.email || contact.displayName) + ) { + contacts.push(contact); + } + } catch (error) { + errors.push(`vCard ${i + 1}: ${error instanceof Error ? error.message : 'Parse error'}`); + } + } + + return { contacts, errors }; + } + + private unfoldLines(content: string): string { + // RFC 2425: Long lines are folded by inserting CRLF + whitespace + return content.replace(/\n[ \t]/g, ''); + } + + private splitVCards(content: string): string[] { + const vcards: string[] = []; + const regex = /BEGIN:VCARD[\s\S]*?END:VCARD/gi; + let match; + + while ((match = regex.exec(content)) !== null) { + vcards.push(match[0]); + } + + return vcards; + } + + private parseVCard(vcardContent: string): ParsedContactDto | null { + const lines = vcardContent.split('\n').filter((line) => line.trim() !== ''); + const properties: VCardProperty[] = []; + + for (const line of lines) { + if (line.startsWith('BEGIN:') || line.startsWith('END:') || line.startsWith('VERSION:')) { + continue; + } + + const property = this.parseLine(line); + if (property) { + properties.push(property); + } + } + + return this.mapToContact(properties); + } + + private parseLine(line: string): VCardProperty | null { + // Format: NAME;PARAM1=VALUE1;PARAM2=VALUE2:value + const colonIndex = line.indexOf(':'); + if (colonIndex === -1) return null; + + const nameAndParams = line.substring(0, colonIndex); + const value = line.substring(colonIndex + 1); + + const parts = nameAndParams.split(';'); + const name = parts[0].toUpperCase(); + const params: Record = {}; + + for (let i = 1; i < parts.length; i++) { + const paramPart = parts[i]; + const equalIndex = paramPart.indexOf('='); + if (equalIndex !== -1) { + const paramName = paramPart.substring(0, equalIndex).toUpperCase(); + const paramValue = paramPart.substring(equalIndex + 1); + params[paramName] = paramValue; + } else { + // Handle vCard 2.1 style params without = + params[paramPart.toUpperCase()] = 'true'; + } + } + + return { name, params, value: this.decodeValue(value, params) }; + } + + private decodeValue(value: string, params: Record): string { + // Handle quoted-printable encoding (common in vCard 2.1) + if (params['ENCODING'] === 'QUOTED-PRINTABLE') { + return this.decodeQuotedPrintable(value); + } + + // Handle escaped characters + return value + .replace(/\\n/gi, '\n') + .replace(/\\,/g, ',') + .replace(/\\;/g, ';') + .replace(/\\\\/g, '\\'); + } + + private decodeQuotedPrintable(str: string): string { + return str.replace(/=([0-9A-F]{2})/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16))); + } + + private mapToContact(properties: VCardProperty[]): ParsedContactDto { + const contact: ParsedContactDto = {}; + + for (const prop of properties) { + switch (prop.name) { + case 'N': + this.parseName(prop.value, contact); + break; + + case 'FN': + contact.displayName = prop.value; + break; + + case 'NICKNAME': + contact.nickname = prop.value; + break; + + case 'EMAIL': + if (!contact.email) { + contact.email = prop.value; + } + break; + + case 'TEL': + this.parsePhone(prop, contact); + break; + + case 'ADR': + this.parseAddress(prop.value, contact); + break; + + case 'ORG': + this.parseOrganization(prop.value, contact); + break; + + case 'TITLE': + contact.jobTitle = prop.value; + break; + + case 'URL': + if (!contact.website) { + contact.website = prop.value; + } + break; + + case 'BDAY': + contact.birthday = this.parseBirthday(prop.value); + break; + + case 'NOTE': + contact.notes = prop.value; + break; + + case 'PHOTO': + // Only store URL references, not base64 data + if (prop.params['VALUE'] === 'URI' || prop.value.startsWith('http')) { + contact.photoUrl = prop.value; + } + break; + } + } + + // Generate displayName if not set + if (!contact.displayName && (contact.firstName || contact.lastName)) { + contact.displayName = [contact.firstName, contact.lastName].filter(Boolean).join(' '); + } + + return contact; + } + + private parseName(value: string, contact: ParsedContactDto): void { + // N:LastName;FirstName;MiddleName;Prefix;Suffix + const parts = value.split(';'); + if (parts[0]) contact.lastName = parts[0]; + if (parts[1]) contact.firstName = parts[1]; + } + + private parsePhone(prop: VCardProperty, contact: ParsedContactDto): void { + const typeStr = (prop.params['TYPE'] || '').toUpperCase(); + + if (typeStr.includes('CELL') || typeStr.includes('MOBILE')) { + if (!contact.mobile) { + contact.mobile = prop.value; + } + } else { + if (!contact.phone) { + contact.phone = prop.value; + } + } + } + + private parseAddress(value: string, contact: ParsedContactDto): void { + // ADR:POBox;Extended;Street;City;State;PostalCode;Country + const parts = value.split(';'); + if (parts[2]) contact.street = parts[2]; + if (parts[3]) contact.city = parts[3]; + // parts[4] is state/region - we could append to city + if (parts[5]) contact.postalCode = parts[5]; + if (parts[6]) contact.country = parts[6]; + } + + private parseOrganization(value: string, contact: ParsedContactDto): void { + // ORG:Company;Department + const parts = value.split(';'); + if (parts[0]) contact.company = parts[0]; + if (parts[1]) contact.department = parts[1]; + } + + private parseBirthday(value: string): string | undefined { + // Handle various formats: YYYY-MM-DD, YYYYMMDD, --MMDD + const cleaned = value.replace(/-/g, ''); + + if (cleaned.length === 8) { + const year = cleaned.substring(0, 4); + const month = cleaned.substring(4, 6); + const day = cleaned.substring(6, 8); + return `${year}-${month}-${day}`; + } + + // Already in ISO format + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { + return value; + } + + return undefined; + } +} diff --git a/apps/contacts/apps/backend/src/import/utils/duplicate-detector.ts b/apps/contacts/apps/backend/src/import/utils/duplicate-detector.ts new file mode 100644 index 000000000..ca1db3b3e --- /dev/null +++ b/apps/contacts/apps/backend/src/import/utils/duplicate-detector.ts @@ -0,0 +1,132 @@ +import { Contact } from '../../db/schema'; +import { DuplicateInfo, ParsedContactDto } from '../dto/import.dto'; + +export class DuplicateDetector { + /** + * Detect duplicates between imported contacts and existing contacts + */ + detectDuplicates( + importedContacts: ParsedContactDto[], + existingContacts: Contact[] + ): DuplicateInfo[] { + const duplicates: DuplicateInfo[] = []; + + // Build lookup maps for faster matching + const emailMap = new Map(); + const phoneMap = new Map(); + + for (const contact of existingContacts) { + if (contact.email) { + emailMap.set(this.normalizeEmail(contact.email), contact); + } + if (contact.phone) { + phoneMap.set(this.normalizePhone(contact.phone), contact); + } + if (contact.mobile) { + phoneMap.set(this.normalizePhone(contact.mobile), contact); + } + } + + // Check each imported contact for duplicates + for (let i = 0; i < importedContacts.length; i++) { + const imported = importedContacts[i]; + + // Check email first (primary match) + if (imported.email) { + const normalizedEmail = this.normalizeEmail(imported.email); + const existingByEmail = emailMap.get(normalizedEmail); + + if (existingByEmail) { + duplicates.push({ + importIndex: i, + existingContactId: existingByEmail.id, + existingContactName: this.getContactName(existingByEmail), + matchField: 'email', + matchValue: imported.email, + }); + continue; // Skip phone check if email matches + } + } + + // Check phone (secondary match) + const phonesToCheck = [imported.phone, imported.mobile].filter(Boolean) as string[]; + + for (const phone of phonesToCheck) { + const normalizedPhone = this.normalizePhone(phone); + const existingByPhone = phoneMap.get(normalizedPhone); + + if (existingByPhone) { + duplicates.push({ + importIndex: i, + existingContactId: existingByPhone.id, + existingContactName: this.getContactName(existingByPhone), + matchField: 'phone', + matchValue: phone, + }); + break; // Only report first phone match + } + } + } + + return duplicates; + } + + /** + * Normalize email for comparison + */ + private normalizeEmail(email: string): string { + return email.toLowerCase().trim(); + } + + /** + * Normalize phone number for comparison + * Removes all non-digit characters except leading + + */ + private normalizePhone(phone: string): string { + const hasPlus = phone.startsWith('+'); + const digits = phone.replace(/\D/g, ''); + return hasPlus ? '+' + digits : digits; + } + + /** + * Get display name for a contact + */ + private getContactName(contact: Contact): string { + if (contact.displayName) return contact.displayName; + if (contact.firstName || contact.lastName) { + return [contact.firstName, contact.lastName].filter(Boolean).join(' '); + } + if (contact.email) return contact.email; + return 'Unknown'; + } + + /** + * Merge imported data with existing contact + * Only fills in missing fields from the imported data + */ + mergeContacts(existing: Contact, imported: ParsedContactDto): Partial { + const updates: Partial = {}; + + // Only update fields that are empty in existing contact + if (!existing.firstName && imported.firstName) updates.firstName = imported.firstName; + if (!existing.lastName && imported.lastName) updates.lastName = imported.lastName; + if (!existing.displayName && imported.displayName) updates.displayName = imported.displayName; + if (!existing.nickname && imported.nickname) updates.nickname = imported.nickname; + if (!existing.email && imported.email) updates.email = imported.email; + if (!existing.phone && imported.phone) updates.phone = imported.phone; + if (!existing.mobile && imported.mobile) updates.mobile = imported.mobile; + if (!existing.street && imported.street) updates.street = imported.street; + if (!existing.city && imported.city) updates.city = imported.city; + if (!existing.postalCode && imported.postalCode) updates.postalCode = imported.postalCode; + if (!existing.country && imported.country) updates.country = imported.country; + if (!existing.company && imported.company) updates.company = imported.company; + if (!existing.jobTitle && imported.jobTitle) updates.jobTitle = imported.jobTitle; + if (!existing.department && imported.department) updates.department = imported.department; + if (!existing.website && imported.website) updates.website = imported.website; + if (!existing.birthday && imported.birthday) updates.birthday = imported.birthday; + if (!existing.notes && imported.notes) updates.notes = imported.notes; + if (!existing.photoUrl && imported.photoUrl) updates.photoUrl = imported.photoUrl; + + return updates; + } +} diff --git a/apps/contacts/apps/backend/src/types/multer.d.ts b/apps/contacts/apps/backend/src/types/multer.d.ts new file mode 100644 index 000000000..d2832f778 --- /dev/null +++ b/apps/contacts/apps/backend/src/types/multer.d.ts @@ -0,0 +1,21 @@ +/// + +declare global { + namespace Express { + namespace Multer { + interface File { + fieldname: string; + originalname: string; + encoding: string; + mimetype: string; + size: number; + destination: string; + filename: string; + path: string; + buffer: Buffer; + } + } + } +} + +export {}; diff --git a/apps/contacts/apps/web/src/lib/api/export.ts b/apps/contacts/apps/web/src/lib/api/export.ts new file mode 100644 index 000000000..b78485020 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/api/export.ts @@ -0,0 +1,101 @@ +import { authStore } from '$lib/stores/auth.svelte'; + +const API_BASE = 'http://localhost:3015/api/v1'; + +export type ExportFormat = 'vcard' | 'csv'; + +export interface ExportOptions { + format: ExportFormat; + contactIds?: string[]; + groupId?: string; + tagId?: string; + includeFavorites?: boolean; + includeArchived?: boolean; +} + +async function fetchWithAuth(url: string, options: RequestInit = {}) { + const token = await authStore.getAccessToken(); + + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...(options.headers || {}), + }; + + if (token) { + (headers as Record)['Authorization'] = `Bearer ${token}`; + } + + return fetch(`${API_BASE}${url}`, { + ...options, + headers, + }); +} + +export const exportApi = { + /** + * Export contacts with options + */ + async exportContacts(options: ExportOptions): Promise { + const response = await fetchWithAuth('/export', { + method: 'POST', + body: JSON.stringify(options), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Export failed' })); + throw new Error(error.message || 'Export failed'); + } + + // Get filename from Content-Disposition header + const contentDisposition = response.headers.get('Content-Disposition'); + const filenameMatch = contentDisposition?.match(/filename="(.+)"/); + const filename = filenameMatch + ? filenameMatch[1] + : `contacts.${options.format === 'vcard' ? 'vcf' : 'csv'}`; + + // Get the blob and trigger download + const blob = await response.blob(); + downloadBlob(blob, filename); + + // Return contact count from header + const contactCount = response.headers.get('X-Contact-Count'); + return; + }, + + /** + * Quick export all contacts + */ + async quickExport(format: ExportFormat = 'vcard'): Promise { + const response = await fetchWithAuth(`/export?format=${format}`); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Export failed' })); + throw new Error(error.message || 'Export failed'); + } + + // Get filename from Content-Disposition header + const contentDisposition = response.headers.get('Content-Disposition'); + const filenameMatch = contentDisposition?.match(/filename="(.+)"/); + const filename = filenameMatch + ? filenameMatch[1] + : `contacts.${format === 'vcard' ? 'vcf' : 'csv'}`; + + // Get the blob and trigger download + const blob = await response.blob(); + downloadBlob(blob, filename); + }, +}; + +/** + * Helper to trigger file download + */ +function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} diff --git a/apps/contacts/apps/web/src/lib/api/google.ts b/apps/contacts/apps/web/src/lib/api/google.ts new file mode 100644 index 000000000..a7e0b459d --- /dev/null +++ b/apps/contacts/apps/web/src/lib/api/google.ts @@ -0,0 +1,132 @@ +import { authStore } from '$lib/stores/auth.svelte'; + +const API_BASE = 'http://localhost:3015/api/v1'; + +export interface GoogleContact { + resourceName: string; + names?: Array<{ + displayName?: string; + familyName?: string; + givenName?: string; + }>; + emailAddresses?: Array<{ + value?: string; + type?: string; + }>; + phoneNumbers?: Array<{ + value?: string; + type?: string; + }>; + organizations?: Array<{ + name?: string; + title?: string; + }>; + photos?: Array<{ + url?: string; + }>; +} + +export interface GoogleContactsResponse { + contacts: GoogleContact[]; + nextPageToken?: string; + totalPeople?: number; +} + +export interface ConnectedAccount { + id: string; + providerEmail: string | null; + createdAt: string; +} + +export interface GoogleStatus { + connected: boolean; + account: ConnectedAccount | null; +} + +export interface GoogleImportResult { + imported: number; + skipped: number; + errors: Array<{ + resourceName: string; + error: string; + }>; +} + +async function fetchWithAuth(url: string, options: RequestInit = {}) { + const token = await authStore.getAccessToken(); + + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...(options.headers || {}), + }; + + if (token) { + (headers as Record)['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(`${API_BASE}${url}`, { + ...options, + headers, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Request failed' })); + throw new Error(error.message || 'Request failed'); + } + + return response.json(); +} + +export const googleApi = { + /** + * Get OAuth authorization URL + */ + async getAuthUrl(): Promise { + const response = await fetchWithAuth('/google/auth-url'); + return response.url; + }, + + /** + * Exchange authorization code for tokens + */ + async handleCallback(code: string): Promise<{ success: boolean; account: ConnectedAccount }> { + return fetchWithAuth('/google/callback', { + method: 'POST', + body: JSON.stringify({ code }), + }); + }, + + /** + * Get connection status + */ + async getStatus(): Promise { + return fetchWithAuth('/google/status'); + }, + + /** + * Disconnect Google account + */ + async disconnect(): Promise { + await fetchWithAuth('/google/disconnect', { + method: 'DELETE', + }); + }, + + /** + * Fetch contacts from Google + */ + async fetchContacts(pageToken?: string): Promise { + const params = pageToken ? `?pageToken=${encodeURIComponent(pageToken)}` : ''; + return fetchWithAuth(`/google/contacts${params}`); + }, + + /** + * Import selected contacts + */ + async importContacts(resourceNames?: string[], all = false): Promise { + return fetchWithAuth('/google/import', { + method: 'POST', + body: JSON.stringify({ resourceNames, all }), + }); + }, +}; diff --git a/apps/contacts/apps/web/src/lib/api/import.ts b/apps/contacts/apps/web/src/lib/api/import.ts new file mode 100644 index 000000000..964dca29d --- /dev/null +++ b/apps/contacts/apps/web/src/lib/api/import.ts @@ -0,0 +1,155 @@ +import { authStore } from '$lib/stores/auth.svelte'; + +const API_BASE = 'http://localhost:3015/api/v1'; + +export interface ParsedContact { + firstName?: string; + lastName?: string; + displayName?: string; + nickname?: string; + email?: string; + phone?: string; + mobile?: string; + street?: string; + city?: string; + postalCode?: string; + country?: string; + company?: string; + jobTitle?: string; + department?: string; + website?: string; + birthday?: string; + notes?: string; + photoUrl?: string; +} + +export interface DuplicateInfo { + importIndex: number; + existingContactId: string; + existingContactName: string; + matchField: 'email' | 'phone'; + matchValue: string; +} + +export interface CsvFieldMapping { + csvHeader: string; + contactField: keyof ParsedContact | null; + sampleValue: string; +} + +export interface ImportPreviewResponse { + contacts: ParsedContact[]; + duplicates: DuplicateInfo[]; + totalParsed: number; + validCount: number; + invalidCount: number; + errors: string[]; + fieldMapping?: CsvFieldMapping[]; +} + +export interface ImportError { + index: number; + contactName: string; + error: string; +} + +export interface ImportResult { + imported: number; + skipped: number; + merged: number; + errors: ImportError[]; +} + +export type DuplicateAction = 'skip' | 'merge' | 'create'; + +export const importApi = { + /** + * Preview import from uploaded file + */ + async preview(file: File): Promise { + const token = await authStore.getAccessToken(); + + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch(`${API_BASE}/import/preview`, { + method: 'POST', + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: formData, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Upload failed' })); + throw new Error(error.message || 'Upload failed'); + } + + return response.json(); + }, + + /** + * Execute the import + */ + async execute( + contacts: ParsedContact[], + duplicateAction: DuplicateAction, + skipIndices?: number[] + ): Promise { + const token = await authStore.getAccessToken(); + + const response = await fetch(`${API_BASE}/import/execute`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ + contacts, + duplicateAction, + skipIndices, + }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Import failed' })); + throw new Error(error.message || 'Import failed'); + } + + return response.json(); + }, + + /** + * Get CSV template download URL + */ + getTemplateUrl(): string { + return `${API_BASE}/import/template/csv`; + }, + + /** + * Download CSV template + */ + async downloadTemplate(): Promise { + const token = await authStore.getAccessToken(); + + const response = await fetch(`${API_BASE}/import/template/csv`, { + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); + + if (!response.ok) { + throw new Error('Failed to download template'); + } + + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'contacts-template.csv'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, +}; diff --git a/apps/contacts/apps/web/src/lib/components/ContactList.svelte b/apps/contacts/apps/web/src/lib/components/ContactList.svelte new file mode 100644 index 000000000..de9a6b0e4 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/ContactList.svelte @@ -0,0 +1,200 @@ + + +
+ +
+

{$_('contacts.title')}

+
+ + + + + {$_('contacts.new')} + +
+
+ + +
+ + + + +
+ + + {#if contactsStore.loading} +
+
+
+ {:else if contactsStore.contacts.length === 0} + +
+
👤
+

{$_('contacts.noContacts')}

+

{$_('contacts.addFirst')}

+ + {$_('contacts.new')} + +
+ {:else} + +
+ {#each contactsStore.contacts as contact (contact.id)} +
handleContactClick(contact.id)} + onkeydown={(e) => e.key === 'Enter' && handleContactClick(contact.id)} + class="contact-card w-full text-left cursor-pointer" + > + +
+ {#if contact.photoUrl} + {getDisplayName(contact)} + {:else} + {getInitials(contact)} + {/if} +
+ + +
+
+ {getDisplayName(contact)} +
+ {#if contact.company || contact.jobTitle} +
+ {[contact.jobTitle, contact.company].filter(Boolean).join(' @ ')} +
+ {/if} + {#if contact.email} +
+ {contact.email} +
+ {/if} +
+ + + +
+ {/each} +
+ + +

+ {contactsStore.total} Kontakte +

+ {/if} +
+ + + (showExportModal = false)} /> diff --git a/apps/contacts/apps/web/src/lib/components/export/ExportModal.svelte b/apps/contacts/apps/web/src/lib/components/export/ExportModal.svelte new file mode 100644 index 000000000..62edc7ade --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/export/ExportModal.svelte @@ -0,0 +1,196 @@ + + + + +{#if isOpen} + +
+
+ +
+

{$_('export.title')}

+ +
+ + + {#if selectedContactIds.length > 0} +
+ {$_('export.selectedCount', { values: { count: selectedContactIds.length } })} +
+ {:else} +

{$_('export.allContacts')}

+ {/if} + + + {#if error} +
+ {error} +
+ {/if} + + +
+ +
+ + +
+
+ + +
+ +
+ + +
+ + +
+
+
+{/if} diff --git a/apps/contacts/apps/web/src/lib/components/import/FileUploader.svelte b/apps/contacts/apps/web/src/lib/components/import/FileUploader.svelte new file mode 100644 index 000000000..19e5c45e3 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/import/FileUploader.svelte @@ -0,0 +1,130 @@ + + +
+ + +
+
+ + + +
+ +
+

{$_('import.dropzone.title')}

+

{$_('import.dropzone.subtitle')}

+
+ +
+ + + + + vCard (.vcf) + + + + + + CSV (.csv) + +
+
+
diff --git a/apps/contacts/apps/web/src/lib/components/import/GoogleImport.svelte b/apps/contacts/apps/web/src/lib/components/import/GoogleImport.svelte new file mode 100644 index 000000000..47bd95bd7 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/import/GoogleImport.svelte @@ -0,0 +1,398 @@ + + +
+ {#if error} +
+ {error} +
+ {/if} + + {#if isLoading} +
+
+

{$_('google.loading')}

+
+ {:else if step === 'connect'} + +
+
+ + + + + + +
+ +
+

{$_('google.connect.title')}

+

{$_('google.connect.subtitle')}

+
+ + +
+ {:else if step === 'select'} + +
+ +
+
+
+ + + + + + +
+
+
{$_('google.connected')}
+
{status?.account?.providerEmail || ''}
+
+
+ +
+ + +
+
+

+ {$_('google.contacts')} ({contacts.length}) +

+
+ + | + +
+
+ +
+ {#each contacts as contact} + {@const isSelected = selectedContacts.has(contact.resourceName)} + + + {/each} +
+ + {#if nextPageToken} +
+ +
+ {/if} +
+ + +
+ +
+
+ {:else if step === 'result' && result} + +
+
+ + + +
+ +
+

{$_('import.result.title')}

+

{$_('import.result.subtitle')}

+
+ +
+
+
{result.imported}
+
{$_('import.result.imported')}
+
+
+
{result.skipped}
+
{$_('import.result.skipped')}
+
+
+ + {#if result.errors.length > 0} +
+

{$_('import.result.errors')}

+
    + {#each result.errors as err} +
  • {err.error}
  • + {/each} +
+
+ {/if} + +
+ + +
+
+ {/if} +
diff --git a/apps/contacts/apps/web/src/lib/components/import/ImportPreview.svelte b/apps/contacts/apps/web/src/lib/components/import/ImportPreview.svelte new file mode 100644 index 000000000..9bbee58c7 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/import/ImportPreview.svelte @@ -0,0 +1,206 @@ + + +
+ +
+
+
{preview.totalParsed}
+
{$_('import.preview.total')}
+
+
+
{preview.validCount}
+
{$_('import.preview.valid')}
+
+
+
{preview.duplicates.length}
+
{$_('import.preview.duplicates')}
+
+
+
{selectedContacts.size}
+
{$_('import.preview.selected')}
+
+
+ + + {#if preview.errors.length > 0} +
+

{$_('import.preview.errors')}

+
    + {#each preview.errors as error} +
  • {error}
  • + {/each} +
+
+ {/if} + + + {#if preview.duplicates.length > 0} +
+

{$_('import.preview.duplicateHandling')}

+
+ + + +
+
+ {/if} + + +
+
+

{$_('import.preview.contacts')}

+
+ + | + +
+
+ +
+ {#each preview.contacts as contact, index} + {@const duplicate = duplicatesByIndex.get(index)} + {@const isSelected = selectedContacts.has(index)} + + + {/each} +
+
+ + +
+ + +
+
diff --git a/apps/contacts/apps/web/src/lib/i18n/locales/de.json b/apps/contacts/apps/web/src/lib/i18n/locales/de.json index 10690b337..6e3dd5128 100644 --- a/apps/contacts/apps/web/src/lib/i18n/locales/de.json +++ b/apps/contacts/apps/web/src/lib/i18n/locales/de.json @@ -2,6 +2,10 @@ "app": { "name": "Contacts" }, + "common": { + "back": "Zurück", + "cancel": "Abbrechen" + }, "nav": { "contacts": "Kontakte", "groups": "Gruppen", @@ -9,7 +13,50 @@ "archive": "Archiv", "search": "Suche", "settings": "Einstellungen", - "feedback": "Feedback" + "feedback": "Feedback", + "import": "Importieren" + }, + "import": { + "title": "Kontakte importieren", + "subtitle": "Importiere Kontakte aus vCard- oder CSV-Dateien", + "tabs": { + "file": "Datei-Import", + "google": "Google Kontakte" + }, + "processing": "Datei wird verarbeitet...", + "importing": "Importiere...", + "downloadTemplate": "CSV-Vorlage herunterladen", + "dropzone": { + "title": "Datei hierher ziehen", + "subtitle": "oder klicken zum Auswählen" + }, + "preview": { + "total": "Gesamt", + "valid": "Gültig", + "duplicates": "Duplikate", + "selected": "Ausgewählt", + "errors": "Fehler beim Parsen", + "duplicateHandling": "Wie sollen Duplikate behandelt werden?", + "skip": "Überspringen", + "merge": "Zusammenführen", + "create": "Trotzdem erstellen", + "contacts": "Kontakte zum Importieren", + "selectAll": "Alle auswählen", + "deselectAll": "Alle abwählen", + "duplicateTag": "Duplikat", + "matchesWith": "Stimmt überein mit", + "importButton": "{count} Kontakte importieren" + }, + "result": { + "title": "Import abgeschlossen", + "subtitle": "Deine Kontakte wurden erfolgreich importiert", + "imported": "Importiert", + "merged": "Zusammengeführt", + "skipped": "Übersprungen", + "errors": "Fehler", + "importMore": "Weitere importieren", + "done": "Fertig" + } }, "contacts": { "title": "Kontakte", @@ -60,5 +107,27 @@ "saved": "Gespeichert", "deleted": "Gelöscht", "error": "Ein Fehler ist aufgetreten" + }, + "google": { + "loading": "Verbindung wird geprüft...", + "connect": { + "title": "Mit Google verbinden", + "subtitle": "Importiere deine Kontakte direkt aus Google Kontakte", + "button": "Mit Google verbinden" + }, + "connected": "Verbunden mit Google", + "disconnect": "Trennen", + "contacts": "Kontakte", + "loadMore": "Mehr laden" + }, + "export": { + "title": "Kontakte exportieren", + "button": "Exportieren", + "format": "Format auswählen", + "selectedCount": "{count} Kontakte ausgewählt", + "allContacts": "Alle Kontakte werden exportiert", + "includeArchived": "Archivierte Kontakte einschließen", + "exporting": "Exportiere...", + "success": "Export erfolgreich" } } diff --git a/apps/contacts/apps/web/src/lib/i18n/locales/en.json b/apps/contacts/apps/web/src/lib/i18n/locales/en.json index ad80d6771..99f517c22 100644 --- a/apps/contacts/apps/web/src/lib/i18n/locales/en.json +++ b/apps/contacts/apps/web/src/lib/i18n/locales/en.json @@ -2,6 +2,10 @@ "app": { "name": "Contacts" }, + "common": { + "back": "Back", + "cancel": "Cancel" + }, "nav": { "contacts": "Contacts", "groups": "Groups", @@ -9,7 +13,50 @@ "archive": "Archive", "search": "Search", "settings": "Settings", - "feedback": "Feedback" + "feedback": "Feedback", + "import": "Import" + }, + "import": { + "title": "Import Contacts", + "subtitle": "Import contacts from vCard or CSV files", + "tabs": { + "file": "File Import", + "google": "Google Contacts" + }, + "processing": "Processing file...", + "importing": "Importing...", + "downloadTemplate": "Download CSV template", + "dropzone": { + "title": "Drop file here", + "subtitle": "or click to select" + }, + "preview": { + "total": "Total", + "valid": "Valid", + "duplicates": "Duplicates", + "selected": "Selected", + "errors": "Parse errors", + "duplicateHandling": "How should duplicates be handled?", + "skip": "Skip", + "merge": "Merge", + "create": "Create anyway", + "contacts": "Contacts to import", + "selectAll": "Select all", + "deselectAll": "Deselect all", + "duplicateTag": "Duplicate", + "matchesWith": "Matches with", + "importButton": "Import {count} contacts" + }, + "result": { + "title": "Import complete", + "subtitle": "Your contacts have been successfully imported", + "imported": "Imported", + "merged": "Merged", + "skipped": "Skipped", + "errors": "Errors", + "importMore": "Import more", + "done": "Done" + } }, "contacts": { "title": "Contacts", @@ -60,5 +107,27 @@ "saved": "Saved", "deleted": "Deleted", "error": "An error occurred" + }, + "google": { + "loading": "Checking connection...", + "connect": { + "title": "Connect to Google", + "subtitle": "Import your contacts directly from Google Contacts", + "button": "Connect with Google" + }, + "connected": "Connected to Google", + "disconnect": "Disconnect", + "contacts": "Contacts", + "loadMore": "Load more" + }, + "export": { + "title": "Export Contacts", + "button": "Export", + "format": "Select format", + "selectedCount": "{count} contacts selected", + "allContacts": "All contacts will be exported", + "includeArchived": "Include archived contacts", + "exporting": "Exporting...", + "success": "Export successful" } } diff --git a/apps/contacts/apps/web/src/routes/(app)/import/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/import/+page.svelte new file mode 100644 index 000000000..3fb8a9128 --- /dev/null +++ b/apps/contacts/apps/web/src/routes/(app)/import/+page.svelte @@ -0,0 +1,283 @@ + + + + {$_('import.title')} - Contacts + + +
+ +
+
+

{$_('import.title')}

+

{$_('import.subtitle')}

+
+ + {$_('common.back')} + +
+ + +
+ + +
+ + + {#if error && activeTab === 'file'} +
+ {error} +
+ {/if} + + + {#if activeTab === 'file'} + + {#if step === 'upload'} +
+ {#if isLoading} +
+
+

{$_('import.processing')}

+
+ {:else} + + +
+ +
+ {/if} +
+ {/if} + + + {#if step === 'preview' && preview} + + {/if} + + + {#if step === 'result' && result} +
+
+ + + +
+ +
+

{$_('import.result.title')}

+

{$_('import.result.subtitle')}

+
+ +
+
+
{result.imported}
+
{$_('import.result.imported')}
+
+
+
{result.merged}
+
{$_('import.result.merged')}
+
+
+
{result.skipped}
+
{$_('import.result.skipped')}
+
+
+ + {#if result.errors.length > 0} +
+

{$_('import.result.errors')}

+
    + {#each result.errors as err} +
  • {err.contactName}: {err.error}
  • + {/each} +
+
+ {/if} + +
+ + +
+
+ {/if} + {/if} + + + {#if activeTab === 'google'} + + {/if} +