// services/messageService.ts // Handle encrypted communication between landlords and tenants import { and, asc, desc, eq, ilike, inArray, or, sql } from "drizzle-orm"; import { db } from "../db/db.ts"; import { Message } from "../db/interfaces.ts"; import { leases, messages, properties, users } from "../db/schema.ts"; export type MessageAttachment = { url: string; name: string; type: string }; // Extended interface for dashboard messages with UI properties export interface DashboardMessage extends Message { sender_name?: string; recipient_name?: string; property_address?: string; created_at?: number | null; // UI-friendly timestamp (maps to created_on) time_ago?: string; // Human-readable time difference conversation_id?: string; // For grouping messages in conversations } export interface MessageQueryOptions { page?: number; pageSize?: number; sort?: "newest" | "oldest"; status?: "all" | "read" | "unread"; type?: "all" | "general" | "maintenance" | "payment" | "notice"; direction?: "all" | "sent" | "received"; search?: string; } export interface MessagePage { messages: DashboardMessage[]; page: number; pageSize: number; total: number; totalMessages: number; totalPages: number; unread: number; } function formatPropertyAddress( property: { address_1?: string | null; address_2?: string | null; city?: string | null; state?: string | null; zip?: string | null; } | null, ) { if (!property) return ""; const street = [property.address_1, property.address_2] .filter(Boolean) .join(" ") .trim(); const cityState = [property.city, property.state] .filter(Boolean) .join(", ") .trim(); const zip = property.zip?.trim() || ""; const region = [cityState, zip].filter(Boolean).join(" ").trim(); return [street, region].filter(Boolean).join(", ").trim(); } function clampPageSize(value: number) { if (Number.isNaN(value) || value <= 0) return 10; return Math.min(50, Math.max(5, value)); } function normalizeOptions(options: MessageQueryOptions) { const page = Math.max(1, options.page ?? 1); const pageSize = clampPageSize(options.pageSize ?? 10); const sort = options.sort === "oldest" ? "oldest" : "newest"; const status = options.status ?? "all"; const type = options.type ?? "all"; const direction = options.direction ?? "all"; const search = options.search?.trim() || ""; return { page, pageSize, sort, status, type, direction, search }; } // Create new message export async function createMessage( lease_id: string | null, sender_id: string, recipient_id: string, message_type: string, subject: string, content: string, parent_message_id: string | null = null, attachments: MessageAttachment[] | null = null, ): Promise { const attachmentsJson = attachments && attachments.length > 0 ? JSON.stringify(attachments) : null; const now = Date.now(); return await db.insert(messages) .values({ id: crypto.randomUUID(), lease_id: lease_id ?? null, sender_id, recipient_id, message_type, subject, parent_message_id, attachments_json: attachmentsJson, content, content_encrypted: content, is_read: false, created_on: now, }) .returning() as unknown as Message[]; } // Get messages for a lease export async function getMessagesByLease(lease_id: string) { return await db.select() .from(messages) .where(eq(messages.lease_id, lease_id)) .orderBy(messages.created_on); } // Get messages for a user (sent or received) export async function getMessagesByUser(user_id: string) { return await db.select() .from(messages) .where(or( eq(messages.sender_id, user_id), eq(messages.recipient_id, user_id), )) .orderBy(messages.created_on); } export async function getMessagesForUser( user_id: string, options: MessageQueryOptions = {}, ): Promise { const { page, pageSize, sort, status, type, direction, search } = normalizeOptions(options); const directionClause = direction === "sent" ? eq(messages.sender_id, user_id) : direction === "received" ? eq(messages.recipient_id, user_id) : or(eq(messages.sender_id, user_id), eq(messages.recipient_id, user_id)); const clauses = [directionClause]; if (status === "read") { clauses.push(and( eq(messages.recipient_id, user_id), eq(messages.is_read, true), )); } else if (status === "unread") { clauses.push(and( eq(messages.recipient_id, user_id), eq(messages.is_read, false), )); } if (type !== "all") { clauses.push(eq(messages.message_type, type)); } if (search) { const pattern = `%${search}%`; clauses.push(or( ilike(messages.subject, pattern), ilike(messages.content, pattern), )); } const whereClause = clauses.length > 1 ? and(...clauses) : clauses[0]; const countResult = await db.select({ count: sql`count(*)`, }) .from(messages) .where(whereClause); const total = countResult[0]?.count ?? 0; const totalPages = Math.max(1, Math.ceil(total / pageSize)); const unreadResult = await db.select({ count: sql`count(*)`, }) .from(messages) .where(and( eq(messages.recipient_id, user_id), eq(messages.is_read, false), )); const unread = unreadResult[0]?.count ?? 0; const offset = (page - 1) * pageSize; const rawMessages = await db.select() .from(messages) .where(whereClause) .orderBy( sort === "oldest" ? asc(messages.created_on) : desc(messages.created_on), ) .limit(pageSize) .offset(offset) as unknown as Message[]; const userIds = Array.from( new Set( rawMessages.flatMap((message) => [ message.sender_id, message.recipient_id, ]).filter(Boolean) as string[], ), ); const userMap = new Map(); if (userIds.length > 0) { const userRows = await db.select({ id: users.id, name: users.name, }) .from(users) .where(inArray(users.id, userIds)); userRows.forEach((row) => userMap.set(row.id, { name: row.name })); } const leaseIds = Array.from( new Set( rawMessages.map((message) => message.lease_id).filter( Boolean, ) as string[], ), ); const leaseMap = new Map< string, { property_id: string | null; property_address: string | null } >(); if (leaseIds.length > 0) { const leaseRows = await db.select({ id: leases.id, property_id: leases.property_id, property_address: leases.property_address, }) .from(leases) .where(inArray(leases.id, leaseIds)); leaseRows.forEach((row) => leaseMap.set(row.id, { property_id: row.property_id, property_address: row.property_address, }) ); } const propertyIds = Array.from( new Set( Array.from(leaseMap.values()) .map((lease) => lease.property_id) .filter(Boolean) as string[], ), ); const propertyMap = new Map(); if (propertyIds.length > 0) { const propertyRows = await db.select({ id: properties.id, address_1: properties.address_1, address_2: properties.address_2, city: properties.city, state: properties.state, zip: properties.zip, }) .from(properties) .where(inArray(properties.id, propertyIds)); propertyRows.forEach((property) => propertyMap.set(property.id, formatPropertyAddress(property)) ); } const messagesList: DashboardMessage[] = rawMessages.map((message) => { const sender = message.sender_id ? userMap.get(message.sender_id) : null; const recipient = message.recipient_id ? userMap.get(message.recipient_id) : null; const lease = message.lease_id ? leaseMap.get(message.lease_id) : null; const propertyAddress = lease?.property_address || (lease?.property_id ? propertyMap.get(lease.property_id) : "") || ""; return { ...message, sender_name: sender?.name || "Tenant", recipient_name: recipient?.name || "Recipient", property_address: propertyAddress, created_at: message.created_on ?? null, }; }); return { messages: messagesList, page, pageSize, total, totalMessages: total, totalPages, unread, }; } // Get unread messages for a user export async function getUnreadMessages(user_id: string) { return await db.select() .from(messages) .where(and( eq(messages.recipient_id, user_id), eq(messages.is_read, false), )) .orderBy(messages.created_on); } // Get messages between two users for a specific lease export async function getConversation( lease_id: string, user1_id: string, user2_id: string, ) { return await db.select() .from(messages) .where(and( eq(messages.lease_id, lease_id), or( and( eq(messages.sender_id, user1_id), eq(messages.recipient_id, user2_id), ), and( eq(messages.sender_id, user2_id), eq(messages.recipient_id, user1_id), ), ), )) .orderBy(messages.created_on); } // Mark message as read export async function markMessageAsRead(id: string) { return await db.update(messages) .set({ is_read: true }) .where(eq(messages.id, id)) .returning(); } export async function markMessageAsReadForUser( id: string, user_id: string, ) { return await db.update(messages) .set({ is_read: true }) .where(and(eq(messages.id, id), eq(messages.recipient_id, user_id))) .returning(); } // Mark all messages as read for a user export async function markAllMessagesAsRead(user_id: string) { return await db.update(messages) .set({ is_read: true }) .where(and( eq(messages.recipient_id, user_id), eq(messages.is_read, false), )) .returning(); } // Get message by ID export async function getMessageById(id: string): Promise { const result = await db.select() .from(messages) .where(eq(messages.id, id)); return (result[0] as unknown as Message | undefined) ?? null; } // Get a message thread (root message and all replies) export async function getMessageThread( id: string, ): Promise { const initialMessage = await getMessageById(id); if (!initialMessage) return null; // Determine the root message ID. If the message has a parent, we use the parent's ID // as the root for the thread query. Otherwise, the message itself is the root. const rootId = initialMessage.parent_message_id || id; const threadMessages = await db.select() .from(messages) .where(or( eq(messages.id, rootId), eq(messages.parent_message_id, rootId), )) .orderBy(asc(messages.created_on)) as unknown as Message[]; return threadMessages; } // Delete message export async function deleteMessage(id: string): Promise { return await db.delete(messages) .where(eq(messages.id, id)) .returning() as unknown as Message[]; } export async function deleteMessagesForUser(user_id: string, ids: string[]) { const normalizedIds = Array.from(new Set(ids)).filter(Boolean); if (normalizedIds.length === 0) return []; return await db.delete(messages) .where(and( inArray(messages.id, normalizedIds), or(eq(messages.sender_id, user_id), eq(messages.recipient_id, user_id)), )) .returning(); } // Mark multiple messages as read export async function markMessagesAsRead(user_id: string, ids: string[]) { const normalizedIds = Array.from(new Set(ids)).filter(Boolean); if (normalizedIds.length === 0) return []; return await db.update(messages) .set({ is_read: true }) .where(and( eq(messages.recipient_id, user_id), inArray(messages.id, normalizedIds), )) .returning(); } // Get message count by type for a lease export async function getMessageStatsByLease(lease_id: string) { const allMessages = await getMessagesByLease(lease_id); return { total: allMessages.length, general: allMessages.filter((m) => m.message_type === "general").length, maintenance: allMessages.filter((m) => m.message_type === "maintenance").length, payment: allMessages.filter((m) => m.message_type === "payment").length, notice: allMessages.filter((m) => m.message_type === "notice").length, unread: allMessages.filter((m) => !m.is_read).length, }; } // Get unread count for a user export async function getUnreadCount(user_id: string) { const unreadMessages = await getUnreadMessages(user_id); return unreadMessages.length; }