// routes/landlord/properties/cleanup.tsx import { define } from "../../../utils.ts"; import { page } from "fresh"; import { SessionCookie } from "../../auth/callback.ts"; import { getLeasesByOwner, getPropertiesByOwner, } from "../../../services/propertyService.ts"; import { getMaintenanceDataByLandlord } from "../../../services/maintenanceRequestService.ts"; import { getTenantApplicationsByLandlord } from "../../../services/tenantApplicationService.ts"; import { mergeDuplicateProperty } from "../../../services/propertyMergeService.ts"; import { Property } from "../../../db/interfaces.ts"; interface PropertyStats { leaseCount: number; activeLeaseCount: number; applicationCount: number; maintenanceCount: number; } interface DuplicateGroup { key: string; label: string; properties: Property[]; } interface CleanupPageData { stateData: SessionCookie; properties: Property[]; duplicateGroups: DuplicateGroup[]; statsByPropertyId: Record; success?: string; error?: string; } function formatPropertyAddress(property: Property) { 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 formatPropertyLabel(property: Property) { const base = property.title || property.address_1 || "Property"; const location = [property.city, property.state].filter(Boolean).join(", "); return location ? `${base} (${location})` : base; } function buildPropertyKey(property: Property) { const parts = [ property.address_1, property.city, property.state, property.zip, ] .map((part) => part?.trim().toLowerCase()) .filter(Boolean); if (parts.length === 0) return null; return parts.join("|"); } function buildDuplicateGroups(properties: Property[]) { const groups = new Map(); for (const property of properties) { const key = buildPropertyKey(property); if (!key) continue; const entry = groups.get(key) || []; entry.push(property); groups.set(key, entry); } return Array.from(groups.entries()) .filter(([, group]) => group.length > 1) .map(([key, group]) => ({ key, label: formatPropertyAddress(group[0]) || group[0].address_1 || "Possible duplicates", properties: group, })); } function buildStatsByPropertyId( properties: Property[], leases: { property_id?: string | null; lease_status?: string | null }[], applications: { property_id?: string | null }[], maintenance: { property_id?: string | null }[], ) { const stats: Record = {}; for (const property of properties) { stats[property.id] = { leaseCount: 0, activeLeaseCount: 0, applicationCount: 0, maintenanceCount: 0, }; } for (const lease of leases) { const propertyId = lease.property_id || ""; if (!propertyId || !stats[propertyId]) continue; stats[propertyId].leaseCount += 1; if (lease.lease_status === "active") { stats[propertyId].activeLeaseCount += 1; } } for (const application of applications) { const propertyId = application.property_id || ""; if (!propertyId || !stats[propertyId]) continue; stats[propertyId].applicationCount += 1; } for (const request of maintenance) { const propertyId = request.property_id || ""; if (!propertyId || !stats[propertyId]) continue; stats[propertyId].maintenanceCount += 1; } return stats; } async function loadCleanupData(userId: string) { const [properties, leases, maintenanceRequests, applications] = await Promise .all([ getPropertiesByOwner(userId), getLeasesByOwner(userId), getMaintenanceDataByLandlord(userId), getTenantApplicationsByLandlord(userId), ]); const statsByPropertyId = buildStatsByPropertyId( properties, leases, applications, maintenanceRequests, ); return { properties, duplicateGroups: buildDuplicateGroups(properties), statsByPropertyId, }; } export const handler = define.handlers({ async GET(ctx) { const stateData: SessionCookie = ctx.state.data; const { user_id } = stateData; if (!user_id) { return new Response("", { status: 302, headers: { Location: "/auth/login" }, }); } try { const url = new URL(ctx.req.url); const mergedCount = Number(url.searchParams.get("merged") || 0); const canonicalId = url.searchParams.get("canonical") || ""; const { properties, duplicateGroups, statsByPropertyId } = await loadCleanupData(user_id); let success: string | undefined; if (mergedCount > 0) { const canonical = canonicalId ? properties.find((property) => property.id === canonicalId) : null; const canonicalLabel = canonical ? formatPropertyLabel(canonical) : "selected property"; const noun = mergedCount === 1 ? "property" : "properties"; success = `Merged ${mergedCount} duplicate ${noun} into ${canonicalLabel}.`; } return page({ stateData, properties, duplicateGroups, statsByPropertyId, success, }); } catch (error) { console.error("Error loading cleanup page:", error); return page({ stateData, properties: [], duplicateGroups: [], statsByPropertyId: {}, error: "Failed to load property cleanup data.", }); } }, async POST(ctx) { const stateData: SessionCookie = ctx.state.data; const { user_id } = stateData; if (!user_id) { return new Response("", { status: 302, headers: { Location: "/auth/login" }, }); } try { const formData = await ctx.req.formData(); const canonicalPropertyId = formData.get("canonical_property_id")?.toString() || ""; const duplicatePropertyIds = formData .getAll("duplicate_property_id") .map((id) => id.toString()) .filter(Boolean) .filter((id) => id !== canonicalPropertyId); const uniqueDuplicateIds = Array.from(new Set(duplicatePropertyIds)); if (!canonicalPropertyId || uniqueDuplicateIds.length === 0) { const cleanupData = await loadCleanupData(user_id); return page({ stateData, ...cleanupData, error: "Select one canonical property and at least one duplicate to merge.", }); } const warnings: string[] = []; for (const duplicateId of uniqueDuplicateIds) { const result = await mergeDuplicateProperty( canonicalPropertyId, duplicateId, user_id, ); warnings.push(...result.warnings); } if (warnings.length > 0) { console.warn("Property merge warnings:", warnings); } return new Response("", { status: 303, headers: { Location: `/landlord/properties/cleanup?merged=${uniqueDuplicateIds.length}&canonical=${canonicalPropertyId}`, }, }); } catch (error) { console.error("Error merging properties:", error); const cleanupData = await loadCleanupData(user_id); return page({ stateData, ...cleanupData, error: "Failed to merge properties. Please try again.", }); } }, }); export default define.page(function LandlordPropertyCleanup({ data }) { const { properties, duplicateGroups, statsByPropertyId, success, error, } = data as CleanupPageData; const hasEnoughProperties = properties.length > 1; return (

Property Cleanup

Merge duplicate properties to keep your portfolio clean.

{success && (
{success}
)} {error && (
{error}
)} {!hasEnoughProperties && (

Add more properties to start cleaning duplicates

You need at least two properties before you can merge duplicates.

)} {hasEnoughProperties && ( <>

Quick Duplicate Merge

Suggested matches based on address details.

{duplicateGroups.length === 0 && (

No obvious duplicates found. Use the manual merge section below if needed.

)} {duplicateGroups.map((group) => (

Possible duplicates: {group.label}

{group.properties.length} properties match this address.

Select the properties you want to merge into the canonical record.

{group.properties.map((property) => { const stats = statsByPropertyId[property.id]; return ( ); })}

Leases, rent schedules, applications, maintenance requests, and media will be reassigned to the canonical property. Selected duplicates are deleted afterward.

))}

Manual Merge

Use this when duplicates are not automatically detected.

Pick the duplicate properties to merge into the canonical record.

{properties.map((property) => { const stats = statsByPropertyId[property.id]; return ( ); })}

Select the duplicates only. The canonical property stays and absorbs the records from selected duplicates.

)}
); });