integratedhub/services/messageService.ts
2026-02-14 02:20:19 +00:00

448 lines
12 KiB
TypeScript

// 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<Message[]> {
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<MessagePage> {
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<number>`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<number>`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<string, { name: string | null }>();
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<string, string>();
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<Message | null> {
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<Message[] | null> {
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<Message[]> {
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;
}