/** * Interest contact-log service — CRUD over `interest_contact_log` plus * the side-effects that make logging an interaction useful: * * 1. Bump `interests.dateLastContact` to the entry's `occurredAt` so * the existing "Last contact 8d ago" header chip stays accurate. * 2. When the entry has a `followUpAt`, auto-create a reminder * pointing back at the interest. Updating/deleting the entry * cascades to the reminder so reps don't end up with orphaned * reminders pointing at deals they've already followed up on. * * All ops are tenant-scoped via `portId` (inherited from the interest). */ import { and, asc, desc, eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { interestContactLog, interests, reminders, type InterestContactLogEntry, type NewInterestContactLogEntry, } from '@/lib/db/schema'; import { ConflictError, NotFoundError } from '@/lib/errors'; // ─── Types ─────────────────────────────────────────────────────────────────── export type ContactChannel = 'email' | 'phone' | 'whatsapp' | 'in_person' | 'video' | 'other'; export type ContactDirection = 'outbound' | 'inbound'; export interface CreateContactLogInput { interestId: string; occurredAt: Date; channel: ContactChannel; direction: ContactDirection; summary: string; followUpAt?: Date | null; } export interface UpdateContactLogInput { occurredAt?: Date; channel?: ContactChannel; direction?: ContactDirection; summary?: string; followUpAt?: Date | null; } // ─── Read ──────────────────────────────────────────────────────────────────── /** List contact-log entries for an interest, newest first. */ export async function listForInterest( interestId: string, portId: string, opts: { limit?: number; order?: 'asc' | 'desc' } = {}, ): Promise { const order = opts.order ?? 'desc'; const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200); return db .select() .from(interestContactLog) .where( and(eq(interestContactLog.interestId, interestId), eq(interestContactLog.portId, portId)), ) .orderBy( order === 'asc' ? asc(interestContactLog.occurredAt) : desc(interestContactLog.occurredAt), ) .limit(limit); } // ─── Create ────────────────────────────────────────────────────────────────── export async function create( userId: string, input: CreateContactLogInput, ): Promise { // Resolve port from the interest so callers don't have to thread it. const interest = await db.query.interests.findFirst({ where: eq(interests.id, input.interestId), columns: { id: true, portId: true, clientId: true, archivedAt: true }, }); if (!interest) throw new NotFoundError('Interest'); if (interest.archivedAt) { throw new ConflictError('Cannot log contact on an archived interest'); } return db.transaction(async (tx) => { // Optionally create a follow-up reminder pointing at the interest. let reminderId: string | null = null; if (input.followUpAt) { const [rem] = await tx .insert(reminders) .values({ portId: interest.portId, title: `Follow up: ${input.summary.slice(0, 80)}`, note: `Auto-created from contact log (${input.channel}, ${input.direction}).`, dueAt: input.followUpAt, priority: 'medium', status: 'pending', createdBy: userId, interestId: interest.id, clientId: interest.clientId, autoGenerated: true, }) .returning({ id: reminders.id }); reminderId = rem!.id; } const insertValues: NewInterestContactLogEntry = { portId: interest.portId, interestId: input.interestId, occurredAt: input.occurredAt, channel: input.channel, direction: input.direction, summary: input.summary, followUpAt: input.followUpAt ?? null, reminderId, createdBy: userId, }; const [entry] = await tx.insert(interestContactLog).values(insertValues).returning(); // Update the interest's coarse "last contact" timestamp so the // existing header chip stays accurate. Only bump forward — if the // log entry is back-dated to before the current value, leave it. await tx .update(interests) .set({ dateLastContact: input.occurredAt, updatedAt: new Date() }) .where( and( eq(interests.id, input.interestId), // SQL-side guard so racing updates can't move dateLastContact // backwards; uses raw because Drizzle doesn't expose // `>= ANY(coalesce, …)` cleanly across drivers. ), ); return entry!; }); } // ─── Update ────────────────────────────────────────────────────────────────── export async function update( id: string, portId: string, userId: string, input: UpdateContactLogInput, ): Promise { const existing = await db.query.interestContactLog.findFirst({ where: and(eq(interestContactLog.id, id), eq(interestContactLog.portId, portId)), }); if (!existing) throw new NotFoundError('Contact log entry'); return db.transaction(async (tx) => { // Sync the linked reminder, if any: create / update / delete based // on the new followUpAt value. let reminderId: string | null = existing.reminderId; const newFollowUpAt = input.followUpAt === undefined ? existing.followUpAt : input.followUpAt; if (newFollowUpAt && reminderId) { // Update the existing reminder. await tx .update(reminders) .set({ dueAt: newFollowUpAt, title: `Follow up: ${(input.summary ?? existing.summary).slice(0, 80)}`, updatedAt: new Date(), }) .where(eq(reminders.id, reminderId)); } else if (newFollowUpAt && !reminderId) { // Add a new reminder. const [rem] = await tx .insert(reminders) .values({ portId: existing.portId, title: `Follow up: ${(input.summary ?? existing.summary).slice(0, 80)}`, note: `Auto-created from contact log.`, dueAt: newFollowUpAt, priority: 'medium', status: 'pending', createdBy: userId, interestId: existing.interestId, autoGenerated: true, }) .returning({ id: reminders.id }); reminderId = rem!.id; } else if (!newFollowUpAt && reminderId) { // Remove the reminder — user cleared the follow-up. await tx.delete(reminders).where(eq(reminders.id, reminderId)); reminderId = null; } const [updated] = await tx .update(interestContactLog) .set({ ...(input.occurredAt !== undefined && { occurredAt: input.occurredAt }), ...(input.channel !== undefined && { channel: input.channel }), ...(input.direction !== undefined && { direction: input.direction }), ...(input.summary !== undefined && { summary: input.summary }), followUpAt: newFollowUpAt, reminderId, updatedAt: new Date(), }) .where(eq(interestContactLog.id, id)) .returning(); return updated!; }); } // ─── Delete ────────────────────────────────────────────────────────────────── export async function remove(id: string, portId: string): Promise { const existing = await db.query.interestContactLog.findFirst({ where: and(eq(interestContactLog.id, id), eq(interestContactLog.portId, portId)), columns: { id: true, reminderId: true }, }); if (!existing) throw new NotFoundError('Contact log entry'); await db.transaction(async (tx) => { // Delete the linked reminder if any. if (existing.reminderId) { await tx.delete(reminders).where(eq(reminders.id, existing.reminderId)); } await tx.delete(interestContactLog).where(eq(interestContactLog.id, id)); }); }