// routes/landlord/messages/index.tsx import { define } from "../../../utils.ts"; import { page } from "fresh"; import { SessionCookie } from "../../auth/callback.ts"; import { deleteMessagesForUser, getMessageThread, getThreadsForUser, ThreadPage, ThreadSummary, } from "../../../services/messageService.ts"; import LandlordMessageSelection from "../../../islands/LandlordMessageSelection.tsx"; import LandlordMessageThreads from "../../../islands/LandlordMessageThreads.tsx"; type FilterValue = "all" | "read" | "unread"; type TypeValue = "all" | "general" | "maintenance" | "payment" | "notice"; type DirectionValue = "all" | "sent" | "received"; type SortValue = "newest" | "oldest"; interface Filters { status: FilterValue; type: TypeValue; direction: DirectionValue; sort: SortValue; search: string; pageSize: number; } interface MessagesPageData { stateData: SessionCookie; threadPage: ThreadPage; filters: Filters; error?: string; } const STATUS_VALUES: FilterValue[] = ["all", "read", "unread"]; const TYPE_VALUES: TypeValue[] = [ "all", "general", "maintenance", "payment", "notice", ]; const DIRECTION_VALUES: DirectionValue[] = ["all", "sent", "received"]; const SORT_VALUES: SortValue[] = ["newest", "oldest"]; const PAGE_SIZES = [10, 20, 50]; function sanitizeEnum( value: string | null, allowed: T[], fallback: T, ) { if (!value) return fallback; return allowed.includes(value as T) ? (value as T) : fallback; } function buildPageUrl( filters: Filters, pageNumber: number, ) { const params = new URLSearchParams(); params.set("page", String(pageNumber)); params.set("page_size", String(filters.pageSize)); params.set("status", filters.status); params.set("type", filters.type); params.set("direction", filters.direction); params.set("sort", filters.sort); if (filters.search) params.set("q", filters.search); return `/landlord/messages?${params.toString()}`; } export const handler = define.handlers({ async GET(ctx) { const stateData: SessionCookie = ctx.state.data; const { user_id } = stateData; try { const url = new URL(ctx.req.url); const pageNumber = Math.max( 1, Number(url.searchParams.get("page") || "1"), ); const rawPageSize = Number(url.searchParams.get("page_size") || "10"); const pageSize = PAGE_SIZES.includes(rawPageSize) ? rawPageSize : 10; const status = sanitizeEnum( url.searchParams.get("status"), STATUS_VALUES, "all", ); const type = sanitizeEnum( url.searchParams.get("type"), TYPE_VALUES, "all", ); const direction = sanitizeEnum( url.searchParams.get("direction"), DIRECTION_VALUES, "all", ); const sort = sanitizeEnum( url.searchParams.get("sort"), SORT_VALUES, "newest", ); const search = url.searchParams.get("q")?.trim() || ""; const threadPage = await getThreadsForUser(user_id, { page: pageNumber, pageSize, sort, status, type, direction, search, }); return page({ stateData, threadPage, filters: { status, type, direction, sort, search, pageSize, }, }); } catch (error) { console.error("Error loading messages:", error); return page({ stateData, threadPage: { threads: [], page: 1, pageSize: 10, total: 0, totalThreads: 0, totalPages: 1, unread: 0, }, filters: { status: "all", type: "all", direction: "all", sort: "newest", search: "", pageSize: 10, }, error: "Failed to load messages.", }); } }, async POST(ctx) { const stateData: SessionCookie = ctx.state.data; const { user_id } = stateData; try { const formData = await ctx.req.formData(); const action = formData.get("action")?.toString() || ""; const returnTo = formData.get("return_to")?.toString() || "/landlord/messages"; const safeReturnTo = returnTo.startsWith("/") ? returnTo : "/landlord/messages"; const redirectUrl = new URL(safeReturnTo, ctx.req.url); if (action !== "delete_selected") { return Response.redirect(redirectUrl, 303); } const rootIds = formData.getAll("message_ids") .map((value) => value.toString()) .filter(Boolean); if (rootIds.length > 0) { const messageIds: string[] = []; for (const rootId of rootIds) { const thread = await getMessageThread(rootId); if (thread) { for (const m of thread) { if (m.sender_id === user_id || m.recipient_id === user_id) { messageIds.push(m.id); } } } } if (messageIds.length > 0) { await deleteMessagesForUser(user_id, messageIds); } } return Response.redirect(redirectUrl, 303); } catch (error) { console.error("Error deleting messages:", error); const fallbackUrl = new URL("/landlord/messages", ctx.req.url); return Response.redirect(fallbackUrl, 303); } }, }); export default define.page(function LandlordMessages({ data }) { const { stateData, threadPage, filters, error } = data as MessagesPageData; const { threads, page, pageSize, total, totalPages } = threadPage; const totalThreads = threadPage.totalThreads ?? total; const unread = threadPage.unread ?? 0; const startIndex = total === 0 ? 0 : (page - 1) * pageSize + 1; const endIndex = Math.min(total, page * pageSize); const returnTo = buildPageUrl(filters, page); const hasThreads = threads.length > 0; const ThreadRow = ({ thread }: { thread: ThreadSummary }) => { const msg = thread.latestMessage; const isOutbound = msg.sender_id === stateData.user_id; const counterpart = isOutbound ? (msg.recipient_name ?? "Recipient") : (msg.sender_name ?? "Tenant"); const contentPreview = (msg.content_encrypted || msg.content || "") .substring(0, 100); return (
  • {thread.hasUnread && ( New! )} {thread.subject} {thread.messageCount > 1 && ( ({thread.messageCount} messages) )}

    {isOutbound ? "To" : "From"} {counterpart}: {" "} {contentPreview || "No content preview"} {contentPreview.length >= 100 ? "..." : ""}

    {msg.property_address && (

    Property: {msg.property_address}

    )}
    {msg.created_on ? new Date(msg.created_on).toLocaleDateString() : "—"}
    View Thread
  • ); }; return (

    Messages

    Review all landlord-tenant conversations in one place.

    {error && (
    {error}
    )}

    All Messages

    Showing {startIndex}-{endIndex} of {totalThreads} threads {unread > 0 && ( ({unread} unread) )}

    page {page} of {totalPages}
    {threads.length === 0 ? (

    No threads match the selected filters.

    ) : (
      {threads.map((thread) => ( ))}
    )}
    Previous
    {Array.from({ length: totalPages }, (_, index) => index + 1) .slice(Math.max(0, page - 3), Math.min(totalPages, page + 2)) .map((pageNumber) => ( {pageNumber} ))}
    Next
    ); });