598 lines
16 KiB
TypeScript
598 lines
16 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 { leases, messages, properties, users } from "../db/schema.ts";
|
|
import { Message } from "../db/interfaces.ts";
|
|
import {
|
|
addE2EMessage,
|
|
deleteE2EMessage,
|
|
deleteE2EMessagesForUser,
|
|
getE2ELeaseById,
|
|
getE2EMessageById,
|
|
getE2EMessagesByLease,
|
|
getE2EMessagesByUser,
|
|
getE2EMessageThread,
|
|
getE2EPropertyById,
|
|
getE2EUserById,
|
|
isE2EEnabled,
|
|
markE2EMessageAsRead,
|
|
} from "./e2eFixtures.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;
|
|
if (isE2EEnabled()) {
|
|
return [addE2EMessage(
|
|
lease_id ?? null,
|
|
sender_id,
|
|
recipient_id,
|
|
message_type,
|
|
subject,
|
|
content,
|
|
parent_message_id,
|
|
attachmentsJson,
|
|
)];
|
|
}
|
|
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) {
|
|
if (isE2EEnabled()) {
|
|
return getE2EMessagesByLease(lease_id);
|
|
}
|
|
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) {
|
|
if (isE2EEnabled()) {
|
|
return getE2EMessagesByUser(user_id);
|
|
}
|
|
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);
|
|
|
|
if (isE2EEnabled()) {
|
|
let messagesList = getE2EMessagesByUser(user_id);
|
|
|
|
if (direction === "sent") {
|
|
messagesList = messagesList.filter((message) =>
|
|
message.sender_id === user_id
|
|
);
|
|
} else if (direction === "received") {
|
|
messagesList = messagesList.filter((message) =>
|
|
message.recipient_id === user_id
|
|
);
|
|
}
|
|
|
|
if (status === "read") {
|
|
messagesList = messagesList.filter((message) =>
|
|
message.recipient_id === user_id && message.is_read
|
|
);
|
|
} else if (status === "unread") {
|
|
messagesList = messagesList.filter((message) =>
|
|
message.recipient_id === user_id && !message.is_read
|
|
);
|
|
}
|
|
|
|
if (type !== "all") {
|
|
messagesList = messagesList.filter((message) =>
|
|
message.message_type === type
|
|
);
|
|
}
|
|
|
|
if (search) {
|
|
const query = search.toLowerCase();
|
|
messagesList = messagesList.filter((message) =>
|
|
(message.subject || "").toLowerCase().includes(query) ||
|
|
(message.content || "").toLowerCase().includes(query)
|
|
);
|
|
}
|
|
|
|
messagesList = [...messagesList].sort((a, b) => {
|
|
const left = a.created_on ?? 0;
|
|
const right = b.created_on ?? 0;
|
|
return sort === "oldest" ? left - right : right - left;
|
|
});
|
|
|
|
const unread = getE2EMessagesByUser(user_id).filter((message) =>
|
|
message.recipient_id === user_id && !message.is_read
|
|
).length;
|
|
const total = messagesList.length;
|
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
|
const startIndex = (page - 1) * pageSize;
|
|
const pageItems = messagesList.slice(startIndex, startIndex + pageSize);
|
|
|
|
const messages = pageItems.map((message) => {
|
|
const sender = message.sender_id
|
|
? getE2EUserById(message.sender_id)
|
|
: null;
|
|
const recipient = message.recipient_id
|
|
? getE2EUserById(message.recipient_id)
|
|
: null;
|
|
const lease = message.lease_id ? getE2ELeaseById(message.lease_id) : null;
|
|
const property = lease?.property_id
|
|
? getE2EPropertyById(lease.property_id)
|
|
: null;
|
|
return {
|
|
...message,
|
|
sender_name: sender?.name || "Tenant",
|
|
recipient_name: recipient?.name || "Recipient",
|
|
property_address: lease?.property_address ||
|
|
formatPropertyAddress(property) ||
|
|
"",
|
|
created_at: message.created_on ?? null,
|
|
};
|
|
});
|
|
|
|
return {
|
|
messages,
|
|
page,
|
|
pageSize,
|
|
total,
|
|
totalMessages: total,
|
|
totalPages,
|
|
unread,
|
|
};
|
|
}
|
|
|
|
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) {
|
|
if (isE2EEnabled()) {
|
|
return getE2EMessagesByUser(user_id).filter((message) => !message.is_read);
|
|
}
|
|
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) {
|
|
if (isE2EEnabled()) {
|
|
markE2EMessageAsRead(id);
|
|
return [];
|
|
}
|
|
return await db.update(messages)
|
|
.set({ is_read: true })
|
|
.where(eq(messages.id, id))
|
|
.returning();
|
|
}
|
|
|
|
export async function markMessageAsReadForUser(
|
|
id: string,
|
|
user_id: string,
|
|
) {
|
|
if (isE2EEnabled()) {
|
|
const message = getE2EMessagesByUser(user_id).find((item) =>
|
|
item.id === id && item.recipient_id === user_id
|
|
);
|
|
if (!message) return [];
|
|
markE2EMessageAsRead(id);
|
|
return [{ ...message, is_read: true }];
|
|
}
|
|
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> {
|
|
if (isE2EEnabled()) {
|
|
return getE2EMessageById(id);
|
|
}
|
|
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> {
|
|
if (isE2EEnabled()) {
|
|
return getE2EMessageThread(id);
|
|
}
|
|
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[]> {
|
|
if (isE2EEnabled()) {
|
|
const deleted = deleteE2EMessage(id);
|
|
return deleted ? [deleted] : [];
|
|
}
|
|
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 [];
|
|
|
|
if (isE2EEnabled()) {
|
|
return deleteE2EMessagesForUser(user_id, normalizedIds);
|
|
}
|
|
|
|
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 [];
|
|
|
|
if (isE2EEnabled()) {
|
|
normalizedIds.forEach((id) => markE2EMessageAsRead(id));
|
|
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;
|
|
}
|