691 lines
22 KiB
TypeScript
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();
|