import crypto from 'crypto'; import path from 'path'; import fs from 'fs/promises'; import { createReadStream, existsSync } from 'fs'; import type { ReadStream } from 'fs'; import bcrypt from 'bcryptjs'; import sharp from 'sharp'; import { prisma } from '../../config/database'; import { logger } from '../../utils/logger'; import { emailService } from '../../services/email.service'; import { siteSettingsService } from '../settings/settings.service'; import { env } from '../../config/env'; import type { ProfileSelfUpdateInput, ProfileActivityInput } from './profile-public.schemas'; import type { GenerateProfileLinkInput, UpdateProfileLinkInput } from './people.schemas'; const UPLOAD_DIR = '/app/uploads/profile-photos'; const COVER_WIDTH = 800; const COVER_HEIGHT = 400; const THUMB_WIDTH = 200; const THUMB_HEIGHT = 100; const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp']; // Activity types that are safe to show to the contact themselves const PUBLIC_ACTIVITY_TYPES = [ 'EMAIL_SENT', 'RESPONSE_SUBMITTED', 'SHIFT_SIGNUP', 'CANVASS_VISIT', 'DONATION', 'PURCHASE', 'VIDEO_VIEW', 'PROFILE_SELF_EDIT', 'PROFILE_PHOTO_UPDATED', ]; // Fields that should NOT be returned to the public profile const HIDDEN_FIELDS = [ 'notes', 'doNotContact', 'signRequested', 'mergedIntoId', 'createdByUserId', ] as const; const BCRYPT_SALT_ROUNDS = 12; /** Map duration enum → milliseconds */ const DURATION_MS: Record = { '24h': 24 * 60 * 60 * 1000, '7d': 7 * 24 * 60 * 60 * 1000, '30d': 30 * 24 * 60 * 60 * 1000, '90d': 90 * 24 * 60 * 60 * 1000, '1y': 365 * 24 * 60 * 60 * 1000, }; type ValidContactResult = | { status: 'not_found' } | { status: 'expired'; contact: { id: string; profileTokenExpiresAt: Date } } | { status: 'ok'; contact: NonNullable>> }; type Branding = { organizationName: string; organizationShortName: string; organizationLogoUrl: string | null; publicColorPrimary: string; publicColorBgBase: string; publicColorBgContainer: string; publicHeaderGradient: string; }; class ProfileService { /** * Shared helper: look up contact by token + check merged + check expiration */ private async getValidContact(token: string): Promise { const contact = await prisma.contact.findUnique({ where: { profileToken: token }, }); if (!contact || contact.mergedIntoId) { return { status: 'not_found' }; } if (contact.profileTokenExpiresAt && contact.profileTokenExpiresAt < new Date()) { return { status: 'expired', contact: { id: contact.id, profileTokenExpiresAt: contact.profileTokenExpiresAt } }; } return { status: 'ok', contact }; } /** * Public pre-check for access control: returns discriminated union for route handler */ async validateProfileAccess(token: string): Promise< | { status: 'ok' } | { status: 'not_found' } | { status: 'expired'; expiresAt: Date } | { status: 'password_required'; branding: Branding } > { const result = await this.getValidContact(token); if (result.status === 'not_found') return { status: 'not_found' }; if (result.status === 'expired') { return { status: 'expired', expiresAt: result.contact.profileTokenExpiresAt }; } // Check password protection if (result.contact.profilePasswordHash) { const settings = await siteSettingsService.get(); return { status: 'password_required', branding: { organizationName: settings.organizationName, organizationShortName: settings.organizationShortName, organizationLogoUrl: settings.organizationLogoUrl, publicColorPrimary: settings.publicColorPrimary, publicColorBgBase: settings.publicColorBgBase, publicColorBgContainer: settings.publicColorBgContainer, publicHeaderGradient: settings.publicHeaderGradient, }, }; } return { status: 'ok' }; } /** * Verify password for a protected profile link * Returns the full profile on success, null on wrong password, or status string for errors */ async verifyProfilePassword(token: string, password: string): Promise< | { status: 'ok'; profile: NonNullable>> } | { status: 'invalid_password' } | { status: 'not_found' } | { status: 'expired'; expiresAt: Date } > { const result = await this.getValidContact(token); if (result.status === 'not_found') return { status: 'not_found' }; if (result.status === 'expired') { return { status: 'expired', expiresAt: result.contact.profileTokenExpiresAt }; } if (!result.contact.profilePasswordHash) { // No password required — just return the profile const profile = await this.getProfileByToken(token); if (!profile) return { status: 'not_found' }; return { status: 'ok', profile }; } const valid = await bcrypt.compare(password, result.contact.profilePasswordHash); if (!valid) return { status: 'invalid_password' }; const profile = await this.getProfileByToken(token); if (!profile) return { status: 'not_found' }; return { status: 'ok', profile }; } /** * Look up a contact by their profile token, returning safe public data. * If viewerUserId is provided and matches contact.userId, adds isOwnProfile + enableSocial flags. */ async getProfileByToken(token: string, viewerUserId?: string) { const result = await this.getValidContact(token); if (result.status !== 'ok') return null; const contact = result.contact; // Get org branding for the profile page header const settings = await siteSettingsService.get(); const branding = { organizationName: settings.organizationName, organizationShortName: settings.organizationShortName, organizationLogoUrl: settings.organizationLogoUrl, publicColorPrimary: settings.publicColorPrimary, publicColorBgBase: settings.publicColorBgBase, publicColorBgContainer: settings.publicColorBgContainer, publicHeaderGradient: settings.publicHeaderGradient, }; // Build engagement summary (reuse people service pattern) const engagement = await this.getPublicEngagementSummary(contact.id, contact.email, contact.phone); // Look up primary address const primaryLink = await prisma.contactAddress.findFirst({ where: { contactId: contact.id, isPrimary: true }, include: { address: { include: { location: { select: { address: true } } } } }, }); const primaryAddress = primaryLink?.address?.location?.address || null; // Check if the authenticated viewer owns this contact profile const isOwnProfile = viewerUserId ? contact.userId === viewerUserId : false; // Return sanitized profile — strip hidden fields return { id: contact.id, displayName: contact.displayName, firstName: contact.firstName, lastName: contact.lastName, email: contact.email, phone: contact.phone, primaryAddress, tags: contact.tags as string[], supportLevel: contact.supportLevel, primarySource: contact.primarySource, emailOptOut: contact.emailOptOut, smsOptOut: contact.smsOptOut, coverPhotoPath: contact.coverPhotoPath ? true : false, // Boolean only — don't expose path engagementScore: engagement.score, engagement, branding, isOwnProfile, enableSocial: settings.enableSocial ?? false, }; } /** * Look up a contact by ID (admin preview) — same shape as public profile + profileToken */ async getProfileByContactId(contactId: string) { const contact = await prisma.contact.findUnique({ where: { id: contactId }, }); if (!contact || contact.mergedIntoId) { return null; } const settings = await siteSettingsService.get(); const branding = { organizationName: settings.organizationName, organizationShortName: settings.organizationShortName, organizationLogoUrl: settings.organizationLogoUrl, publicColorPrimary: settings.publicColorPrimary, publicColorBgBase: settings.publicColorBgBase, publicColorBgContainer: settings.publicColorBgContainer, publicHeaderGradient: settings.publicHeaderGradient, }; const engagement = await this.getPublicEngagementSummary(contact.id, contact.email, contact.phone); // Look up primary address const primaryLink = await prisma.contactAddress.findFirst({ where: { contactId: contact.id, isPrimary: true }, include: { address: { include: { location: { select: { address: true } } } } }, }); const primaryAddress = primaryLink?.address?.location?.address || null; return { id: contact.id, displayName: contact.displayName, firstName: contact.firstName, lastName: contact.lastName, email: contact.email, phone: contact.phone, primaryAddress, tags: contact.tags as string[], supportLevel: contact.supportLevel, primarySource: contact.primarySource, emailOptOut: contact.emailOptOut, smsOptOut: contact.smsOptOut, coverPhotoPath: contact.coverPhotoPath ? true : false, engagementScore: engagement.score, engagement, branding, profileToken: contact.profileToken, }; } /** * Serve cover photo by contact ID (admin preview) */ serveCoverPhotoByContactId(contactId: string, size: 'cover' | 'thumb' = 'cover'): { stream: ReadStream; contentType: string } | null { const suffix = size === 'thumb' ? '-thumb.jpg' : '-cover.jpg'; const filePath = path.join(UPLOAD_DIR, `${contactId}${suffix}`); if (!existsSync(filePath)) return null; return { stream: createReadStream(filePath), contentType: 'image/jpeg', }; } /** * Update only the self-editable fields */ async updateProfileSelfService(token: string, data: ProfileSelfUpdateInput) { const result = await this.getValidContact(token); if (result.status !== 'ok') return null; const contact = result.contact; // Build update payload — only self-editable fields const updateData: Record = {}; if (data.displayName !== undefined) updateData.displayName = data.displayName; if (data.firstName !== undefined) updateData.firstName = data.firstName || null; if (data.lastName !== undefined) updateData.lastName = data.lastName || null; if (data.email !== undefined) updateData.email = data.email || null; if (data.phone !== undefined) updateData.phone = data.phone || null; if (data.emailOptOut !== undefined) updateData.emailOptOut = data.emailOptOut; if (data.smsOptOut !== undefined) updateData.smsOptOut = data.smsOptOut; const hasAddressUpdate = !!(data.address && data.address.trim()); if (Object.keys(updateData).length === 0 && !hasAddressUpdate) { return contact; } let updated = contact; if (Object.keys(updateData).length > 0) { updateData.lastSelfEditAt = new Date(); updated = await prisma.contact.update({ where: { id: contact.id }, data: updateData, }); } // Log activity await prisma.contactActivity.create({ data: { contactId: contact.id, type: 'PROFILE_SELF_EDIT', title: 'Profile updated via self-service', description: `Fields updated: ${Object.keys(data).filter(k => data[k as keyof ProfileSelfUpdateInput] !== undefined).join(', ')}`, occurredAt: new Date(), }, }); // Handle address update via peopleService (geocodes + creates Location/Address/ContactAddress) if (data.address && data.address.trim()) { try { const { peopleService } = await import('./people.service'); await peopleService.addAddress( contact.id, { address: data.address.trim(), isPrimary: true, addToMap: true }, contact.userId, ); } catch (err) { logger.warn(`Self-service address update failed for contact ${contact.id}`, err); } } return updated; } /** * Upload + process a cover photo: resize to 800x400 cover + 200x100 thumb */ async uploadCoverPhoto( token: string, fileBuffer: Buffer, mimeType: string, originalName: string, ) { const result = await this.getValidContact(token); if (result.status !== 'ok') return null; const contact = result.contact; // Validate MIME type if (!ALLOWED_MIME_TYPES.includes(mimeType)) { throw new Error('Invalid file type. Allowed: JPEG, PNG, WebP'); } // Validate file size if (fileBuffer.length > MAX_FILE_SIZE) { throw new Error('File too large. Maximum size: 5MB'); } // Validate with sharp (also strips EXIF) try { await sharp(fileBuffer).metadata(); } catch { throw new Error('Invalid image file'); } // Ensure upload directory exists await fs.mkdir(UPLOAD_DIR, { recursive: true }); const coverPath = path.join(UPLOAD_DIR, `${contact.id}-cover.jpg`); const thumbPath = path.join(UPLOAD_DIR, `${contact.id}-thumb.jpg`); // Process: resize + auto-orient + strip all EXIF (sharp strips metadata by default) await sharp(fileBuffer) .resize(COVER_WIDTH, COVER_HEIGHT, { fit: 'cover', position: 'centre' }) .rotate() // Auto-rotate based on EXIF before strip .jpeg({ quality: 85 }) .toFile(coverPath); await sharp(fileBuffer) .resize(THUMB_WIDTH, THUMB_HEIGHT, { fit: 'cover', position: 'centre' }) .rotate() .jpeg({ quality: 75 }) .toFile(thumbPath); // Update contact record await prisma.contact.update({ where: { id: contact.id }, data: { coverPhotoPath: coverPath, lastSelfEditAt: new Date(), }, }); // Log activity — sanitize user-supplied filename before storage const sanitizedName = originalName.replace(/[<>"'&]/g, '').slice(0, 200); await prisma.contactActivity.create({ data: { contactId: contact.id, type: 'PROFILE_PHOTO_UPDATED', title: 'Cover photo updated via self-service', description: `Uploaded: ${sanitizedName}`, occurredAt: new Date(), }, }); return { success: true }; } /** * Serve cover photo file as a read stream */ async serveCoverPhoto(token: string, size: 'cover' | 'thumb' = 'cover'): Promise<{ stream: ReadStream; contentType: string } | null> { const result = await this.getValidContact(token); if (result.status !== 'ok') return null; const contact = result.contact; if (!contact.coverPhotoPath) return null; const suffix = size === 'thumb' ? '-thumb.jpg' : '-cover.jpg'; const filePath = path.join(UPLOAD_DIR, `${contact.id}${suffix}`); if (!existsSync(filePath)) return null; return { stream: createReadStream(filePath), contentType: 'image/jpeg', }; } /** * Get paginated activity timeline (filtered for public-safe types only) */ async getProfileActivity(token: string, params: ProfileActivityInput) { const result = await this.getValidContact(token); if (result.status !== 'ok') return null; const contact = result.contact; const where = { contactId: contact.id, type: { in: PUBLIC_ACTIVITY_TYPES as any }, }; const [activities, total] = await Promise.all([ prisma.contactActivity.findMany({ where, orderBy: { occurredAt: 'desc' }, skip: (params.page - 1) * params.limit, take: params.limit, }), prisma.contactActivity.count({ where }), ]); return { activities, pagination: { page: params.page, limit: params.limit, total, totalPages: Math.ceil(total / params.limit), }, }; } /** * Generate a new profile token for a contact, with optional expiration + password */ async generateProfileToken(contactId: string, options?: GenerateProfileLinkInput) { const token = crypto.randomBytes(32).toString('hex'); // Compute expiration date let expiresAt: Date | null = null; const duration = options?.expiresIn || 'never'; if (duration !== 'never' && DURATION_MS[duration]) { expiresAt = new Date(Date.now() + DURATION_MS[duration]); } // Hash password if provided let passwordHash: string | null = null; if (options?.password) { passwordHash = await bcrypt.hash(options.password, BCRYPT_SALT_ROUNDS); } const contact = await prisma.contact.update({ where: { id: contactId }, data: { profileToken: token, profileTokenExpiresAt: expiresAt, profilePasswordHash: passwordHash, }, }); return { token: contact.profileToken!, url: this.buildProfileUrl(contact.profileToken!), expiresAt: contact.profileTokenExpiresAt?.toISOString() || null, hasPassword: !!contact.profilePasswordHash, }; } /** * Regenerate profile token (invalidates old one) */ async regenerateProfileToken(contactId: string, options?: GenerateProfileLinkInput) { return this.generateProfileToken(contactId, options); } /** * Revoke profile token — clears token, expiration, and password hash */ async revokeProfileToken(contactId: string) { await prisma.contact.update({ where: { id: contactId }, data: { profileToken: null, profileTokenExpiresAt: null, profilePasswordHash: null, }, }); } /** * Update profile link settings (expiration/password) without regenerating the token */ async updateProfileLinkSettings(contactId: string, options: UpdateProfileLinkInput) { const contact = await prisma.contact.findUnique({ where: { id: contactId } }); if (!contact || !contact.profileToken) { throw new Error('Contact not found or has no profile link'); } const data: Record = {}; // Update expiration if (options.expiresIn !== undefined) { if (options.expiresIn === 'never') { data.profileTokenExpiresAt = null; } else if (DURATION_MS[options.expiresIn]) { data.profileTokenExpiresAt = new Date(Date.now() + DURATION_MS[options.expiresIn]); } } // Update password if (options.removePassword) { data.profilePasswordHash = null; } else if (options.password) { data.profilePasswordHash = await bcrypt.hash(options.password, BCRYPT_SALT_ROUNDS); } if (Object.keys(data).length === 0) { return { token: contact.profileToken, url: this.buildProfileUrl(contact.profileToken), expiresAt: contact.profileTokenExpiresAt?.toISOString() || null, hasPassword: !!contact.profilePasswordHash, }; } const updated = await prisma.contact.update({ where: { id: contactId }, data, }); return { token: updated.profileToken!, url: this.buildProfileUrl(updated.profileToken!), expiresAt: updated.profileTokenExpiresAt?.toISOString() || null, hasPassword: !!updated.profilePasswordHash, }; } /** * Send profile link via email to the contact */ async sendProfileLink(contactId: string, adminUserId: string) { const contact = await prisma.contact.findUnique({ where: { id: contactId } }); if (!contact) throw new Error('Contact not found'); if (!contact.email) throw new Error('Contact has no email address'); // Generate token if not already present if (!contact.profileToken) { await this.generateProfileToken(contactId); } const freshContact = await prisma.contact.findUnique({ where: { id: contactId } }); if (!freshContact?.profileToken) throw new Error('Failed to generate profile token'); const profileUrl = this.buildProfileUrl(freshContact.profileToken); const settings = await siteSettingsService.get(); // Build extra notes for email const extraNotes: string[] = []; if (freshContact.profileTokenExpiresAt) { const expiryDate = freshContact.profileTokenExpiresAt.toLocaleDateString('en-CA', { year: 'numeric', month: 'long', day: 'numeric', }); extraNotes.push(`This link will expire on ${expiryDate}.`); } if (freshContact.profilePasswordHash) { extraNotes.push('This link is password-protected. You will need the password provided separately by the organization.'); } await emailService.sendProfileLinkEmail({ recipientEmail: freshContact.email!, recipientName: freshContact.displayName, profileUrl, organizationName: settings.organizationName, extraNotes: extraNotes.length > 0 ? extraNotes.join(' ') : undefined, }); // Log activity await prisma.contactActivity.create({ data: { contactId, type: 'NOTE_ADDED', title: 'Profile link sent via email', description: `Sent by admin to ${freshContact.email}`, occurredAt: new Date(), }, }); return { success: true, url: profileUrl }; } /** * Build the full profile URL */ private buildProfileUrl(token: string): string { const domain = env.DOMAIN || 'localhost:3000'; const protocol = env.NODE_ENV === 'production' ? 'https' : 'http'; const appSubdomain = env.NODE_ENV === 'production' ? `app.${domain}` : domain; return `${protocol}://${appSubdomain}/profile/${token}`; } /** * Simplified engagement summary for public profile */ private async getPublicEngagementSummary(contactId: string, email: string | null, phone: string | null) { // Count activities by type for this contact const activityCounts = await prisma.contactActivity.groupBy({ by: ['type'], where: { contactId, type: { in: PUBLIC_ACTIVITY_TYPES as any }, }, _count: { type: true }, }); const countMap = Object.fromEntries( activityCounts.map((a) => [a.type, a._count.type]), ); // Simple engagement score calculation const emailsSent = countMap.EMAIL_SENT || 0; const responses = countMap.RESPONSE_SUBMITTED || 0; const shiftSignups = countMap.SHIFT_SIGNUP || 0; const visits = countMap.CANVASS_VISIT || 0; const donations = countMap.DONATION || 0; const videoViews = countMap.VIDEO_VIEW || 0; const score = Math.min(100, emailsSent * 5 + responses * 15 + shiftSignups * 10 + visits * 8 + donations * 20 + videoViews * 2, ); return { score, emailsSent, responsesSubmitted: responses, shiftsSignedUp: shiftSignups, canvassVisits: visits, donationCount: donations, videoViews, }; } } export const profileService = new ProfileService();