changemaker.lite/api/src/modules/people/profile.service.ts

691 lines
22 KiB
TypeScript

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<string, number> = {
'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<Awaited<ReturnType<typeof prisma.contact.findUnique>>> };
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<ValidContactResult> {
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<Awaited<ReturnType<ProfileService['getProfileByToken']>>> }
| { 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<string, unknown> = {};
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<string, unknown> = {};
// 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();