1225 lines
35 KiB
TypeScript
1225 lines
35 KiB
TypeScript
import type { SessionCookie } from "../routes/auth/callback.ts";
|
|
import type {
|
|
Lease,
|
|
LeaseDocument,
|
|
MaintenanceRequest,
|
|
Message,
|
|
PaymentTransaction,
|
|
Property,
|
|
PropertyMedia,
|
|
RentSchedule,
|
|
TenantApplication,
|
|
TenantInvite,
|
|
User,
|
|
} from "../db/interfaces.ts";
|
|
import type { PropertyMergeResult } from "./propertyMergeService.ts";
|
|
import type { PaymentMethod } from "./paymentService.ts";
|
|
import type { MaintenanceData } from "./maintenanceRequestService.ts";
|
|
|
|
const E2E_ENABLED = Deno.env.get("E2E_TEST") === "true";
|
|
const BASE_TIME = new Date("2024-01-15T12:00:00.000Z").getTime();
|
|
|
|
const e2eUserIds = {
|
|
landlord: "e2e-landlord-1",
|
|
"landlord-onboarding": "e2e-landlord-setup-1",
|
|
tenant: "e2e-tenant-1",
|
|
};
|
|
|
|
let e2eUsers: Record<string, User> = {
|
|
[e2eUserIds.landlord]: {
|
|
id: e2eUserIds.landlord,
|
|
name: "Alex Landlord",
|
|
email: "alex.landlord@example.com",
|
|
phone: "555-0100",
|
|
address_1: "88 Beacon Way",
|
|
address_2: "",
|
|
city: "Seattle",
|
|
state: "WA",
|
|
zip: "98101",
|
|
country: "USA",
|
|
user_type: "landlord",
|
|
onboarded_landlord: true,
|
|
onboarded_tenant: false,
|
|
email_verified: true,
|
|
banking_setup_complete: true,
|
|
onboarding_step: 0,
|
|
encrypted_data: null,
|
|
started_on: BASE_TIME - 1000 * 60 * 60 * 24,
|
|
updated_on: BASE_TIME,
|
|
},
|
|
[e2eUserIds["landlord-onboarding"]]: {
|
|
id: e2eUserIds["landlord-onboarding"],
|
|
name: "",
|
|
email: "",
|
|
phone: "",
|
|
address_1: "",
|
|
address_2: "",
|
|
city: "",
|
|
state: "",
|
|
zip: "",
|
|
country: "",
|
|
user_type: "landlord",
|
|
onboarded_landlord: false,
|
|
onboarded_tenant: false,
|
|
email_verified: false,
|
|
banking_setup_complete: false,
|
|
onboarding_step: 1,
|
|
encrypted_data: null,
|
|
started_on: BASE_TIME - 1000 * 60 * 60 * 48,
|
|
updated_on: BASE_TIME,
|
|
},
|
|
[e2eUserIds.tenant]: {
|
|
id: e2eUserIds.tenant,
|
|
name: "Taylor Tenant",
|
|
email: "taylor.tenant@example.com",
|
|
phone: "555-0199",
|
|
address_1: "402 Pine St",
|
|
address_2: "Unit 12",
|
|
city: "Seattle",
|
|
state: "WA",
|
|
zip: "98102",
|
|
country: "USA",
|
|
user_type: "tenant",
|
|
onboarded_landlord: false,
|
|
onboarded_tenant: true,
|
|
email_verified: true,
|
|
banking_setup_complete: false,
|
|
onboarding_step: 0,
|
|
encrypted_data: null,
|
|
started_on: BASE_TIME - 1000 * 60 * 60 * 24,
|
|
updated_on: BASE_TIME,
|
|
},
|
|
};
|
|
|
|
let e2eProperties: Property[] = [
|
|
{
|
|
id: "e2e-prop-1",
|
|
title: "Skyline Lofts",
|
|
description: "Modern loft with skyline views.",
|
|
address_1: "123 Harbor Ave",
|
|
address_2: "",
|
|
city: "Seattle",
|
|
state: "WA",
|
|
zip: "98109",
|
|
country: "USA",
|
|
bedrooms: 2,
|
|
bathrooms: 2,
|
|
square_footage: 980,
|
|
year_built: 2018,
|
|
property_type: "apartment",
|
|
listing_status: "active",
|
|
rental_status: "available",
|
|
monthly_rent: 2400,
|
|
security_deposit: 2400,
|
|
lease_duration_months: 12,
|
|
amenities: "Rooftop deck, gym",
|
|
created_on: BASE_TIME - 1000 * 60 * 60 * 24 * 14,
|
|
updated_on: BASE_TIME - 1000 * 60 * 60 * 24 * 3,
|
|
owner_id: e2eUserIds.landlord,
|
|
},
|
|
{
|
|
id: "e2e-prop-2",
|
|
title: "Maple Row",
|
|
description: "Cozy townhome near the park.",
|
|
address_1: "456 Maple St",
|
|
address_2: "",
|
|
city: "Seattle",
|
|
state: "WA",
|
|
zip: "98122",
|
|
country: "USA",
|
|
bedrooms: 3,
|
|
bathrooms: 2,
|
|
square_footage: 1250,
|
|
year_built: 2012,
|
|
property_type: "townhome",
|
|
listing_status: "active",
|
|
rental_status: "occupied",
|
|
monthly_rent: 1800,
|
|
security_deposit: 1800,
|
|
lease_duration_months: 12,
|
|
amenities: "Garage, patio",
|
|
created_on: BASE_TIME - 1000 * 60 * 60 * 24 * 30,
|
|
updated_on: BASE_TIME - 1000 * 60 * 60 * 24 * 1,
|
|
owner_id: e2eUserIds.landlord,
|
|
},
|
|
];
|
|
|
|
let e2eLeases: Lease[] = [
|
|
{
|
|
id: "e2e-lease-1",
|
|
title: "Maple Row Lease",
|
|
property_id: "e2e-prop-2",
|
|
property_address: "456 Maple St, Seattle, WA",
|
|
tenant_id: e2eUserIds.tenant,
|
|
owner_id: e2eUserIds.landlord,
|
|
start_date: BASE_TIME - 1000 * 60 * 60 * 24 * 120,
|
|
end_date: BASE_TIME + 1000 * 60 * 60 * 24 * 245,
|
|
rent: 1800,
|
|
security_deposit: 1800,
|
|
lease_status: "active",
|
|
created_on: BASE_TIME - 1000 * 60 * 60 * 24 * 120,
|
|
updated_on: BASE_TIME - 1000 * 60 * 60 * 24 * 5,
|
|
},
|
|
];
|
|
|
|
const extraE2EMessages: Message[] = Array.from({ length: 9 }, (_, index) => ({
|
|
id: `e2e-msg-${index + 3}`,
|
|
lease_id: "e2e-lease-1",
|
|
sender_id: e2eUserIds.landlord,
|
|
recipient_id: e2eUserIds.tenant,
|
|
message_type: "general",
|
|
subject: `Lease update ${index + 1}`,
|
|
content: `Follow-up note ${index + 1} about the lease timeline.`,
|
|
is_read: true,
|
|
created_on: BASE_TIME - 1000 * 60 * 60 * (4 + index),
|
|
}));
|
|
|
|
let e2eMessages: Message[] = [
|
|
{
|
|
id: "e2e-msg-1",
|
|
lease_id: "e2e-lease-1",
|
|
sender_id: e2eUserIds.tenant,
|
|
recipient_id: e2eUserIds.landlord,
|
|
message_type: "maintenance",
|
|
subject: "Leaking kitchen faucet",
|
|
content: "The kitchen faucet drips after turning it off.",
|
|
is_read: false,
|
|
created_on: BASE_TIME - 1000 * 60 * 60 * 3,
|
|
},
|
|
{
|
|
id: "e2e-msg-2",
|
|
lease_id: "e2e-lease-1",
|
|
sender_id: e2eUserIds.landlord,
|
|
recipient_id: e2eUserIds.tenant,
|
|
message_type: "notice",
|
|
subject: "Inspection scheduled",
|
|
content: "Annual inspection will take place next Tuesday.",
|
|
is_read: true,
|
|
created_on: BASE_TIME - 1000 * 60 * 60 * 26,
|
|
},
|
|
...extraE2EMessages,
|
|
];
|
|
|
|
export function addE2EMessage(
|
|
leaseId: string | null,
|
|
senderId: string,
|
|
recipientId: string,
|
|
messageType: string,
|
|
subject: string,
|
|
content: string,
|
|
parentMessageId: string | null = null,
|
|
attachmentsJson: string | null = null,
|
|
) {
|
|
const newMessage: Message = {
|
|
id: crypto.randomUUID(),
|
|
lease_id: leaseId ?? null,
|
|
sender_id: senderId,
|
|
recipient_id: recipientId,
|
|
message_type: messageType,
|
|
subject,
|
|
content,
|
|
content_encrypted: content,
|
|
parent_message_id: parentMessageId,
|
|
attachments_json: attachmentsJson,
|
|
is_read: false,
|
|
created_on: BASE_TIME,
|
|
};
|
|
e2eMessages = [newMessage, ...e2eMessages];
|
|
return newMessage;
|
|
}
|
|
|
|
export function getE2EMessageById(messageId: string): Message | null {
|
|
return e2eMessages.find((message) => message.id === messageId) ?? null;
|
|
}
|
|
|
|
export function getE2EMessageThread(messageId: string): Message[] | null {
|
|
const initialMessage = getE2EMessageById(messageId);
|
|
if (!initialMessage) return null;
|
|
const rootId = initialMessage.parent_message_id || initialMessage.id;
|
|
return e2eMessages
|
|
.filter((message) =>
|
|
message.id === rootId || message.parent_message_id === rootId
|
|
)
|
|
.sort((a, b) => (a.created_on ?? 0) - (b.created_on ?? 0));
|
|
}
|
|
|
|
export function getE2EMessagesByLease(leaseId: string) {
|
|
return e2eMessages.filter((message) => message.lease_id === leaseId);
|
|
}
|
|
|
|
let e2eMaintenanceRequests: MaintenanceRequest[] = [
|
|
{
|
|
id: "e2e-maint-1",
|
|
property_id: "e2e-prop-2",
|
|
tenant_id: e2eUserIds.tenant,
|
|
request_type: "plumbing",
|
|
description: "Kitchen sink is leaking.",
|
|
status: "open",
|
|
priority: "high",
|
|
created_on: BASE_TIME - 1000 * 60 * 60 * 12,
|
|
resolved_on: null,
|
|
},
|
|
{
|
|
id: "e2e-maint-2",
|
|
property_id: "e2e-prop-2",
|
|
tenant_id: e2eUserIds.tenant,
|
|
request_type: "electrical",
|
|
description: "Living room outlet not working.",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
created_on: BASE_TIME - 1000 * 60 * 60 * 72,
|
|
resolved_on: null,
|
|
},
|
|
];
|
|
|
|
let e2eTenantApplications: TenantApplication[] = [
|
|
{
|
|
id: "e2e-app-1",
|
|
property_id: "e2e-prop-1",
|
|
applicant_id: e2eUserIds.tenant,
|
|
application_status: "pending",
|
|
application_data_encrypted: '{"source":"e2e"}',
|
|
submitted_on: BASE_TIME - 1000 * 60 * 60 * 6,
|
|
reviewed_on: null,
|
|
},
|
|
];
|
|
|
|
let e2eTenantInvites: TenantInvite[] = [];
|
|
|
|
let e2eRentSchedules: RentSchedule[] = [];
|
|
|
|
let e2ePropertyMedia: PropertyMedia[] = [
|
|
{
|
|
id: "e2e-media-1",
|
|
property_id: "e2e-prop-1",
|
|
media_type: "photo",
|
|
file_url: "/logo.svg",
|
|
file_name: "Skyline Lofts",
|
|
display_order: 0,
|
|
created_on: BASE_TIME,
|
|
},
|
|
{
|
|
id: "e2e-media-2",
|
|
property_id: "e2e-prop-1",
|
|
media_type: "photo",
|
|
file_url: "/logo.svg",
|
|
file_name: "Skyline Lofts Interior",
|
|
display_order: 1,
|
|
created_on: BASE_TIME,
|
|
},
|
|
];
|
|
|
|
let e2ePaymentMethods: PaymentMethod[] = [
|
|
{
|
|
id: "e2e-pay-method-1",
|
|
user_id: e2eUserIds.tenant,
|
|
bank_name: "Northwest Credit Union",
|
|
method_type: "bank_account",
|
|
account_type: "checking",
|
|
routing_number_encrypted: "test-routing",
|
|
account_number_encrypted: "test-account",
|
|
is_default: true,
|
|
created_on: BASE_TIME - 1000 * 60 * 60 * 24 * 10,
|
|
updated_on: BASE_TIME - 1000 * 60 * 60 * 24 * 2,
|
|
},
|
|
];
|
|
|
|
let e2ePaymentTransactions: PaymentTransaction[] = [
|
|
{
|
|
id: "e2e-pay-0",
|
|
lease_id: "e2e-lease-1",
|
|
amount: 1800,
|
|
payment_date: BASE_TIME - 1000 * 60 * 60 * 24 * 10,
|
|
payment_method: "ach",
|
|
transaction_status: "completed",
|
|
stripe_payment_id: "pi_test_jan_1",
|
|
created_on: BASE_TIME - 1000 * 60 * 60 * 24 * 10,
|
|
},
|
|
{
|
|
id: "e2e-pay-late-1",
|
|
lease_id: "e2e-lease-1",
|
|
amount: 1800,
|
|
payment_date: BASE_TIME - 1000 * 60 * 60 * 24 * 7,
|
|
payment_method: "ach",
|
|
transaction_status: "pending",
|
|
stripe_payment_id: "pi_test_late_1",
|
|
created_on: BASE_TIME - 1000 * 60 * 60 * 24 * 7,
|
|
},
|
|
{
|
|
id: "e2e-pay-1",
|
|
lease_id: "e2e-lease-1",
|
|
amount: 1800,
|
|
payment_date: BASE_TIME - 1000 * 60 * 60 * 24 * 25,
|
|
payment_method: "ach",
|
|
transaction_status: "completed",
|
|
stripe_payment_id: "pi_test_123",
|
|
created_on: BASE_TIME - 1000 * 60 * 60 * 24 * 25,
|
|
},
|
|
{
|
|
id: "e2e-pay-2",
|
|
lease_id: "e2e-lease-1",
|
|
amount: 1800,
|
|
payment_date: BASE_TIME - 1000 * 60 * 60 * 24 * 55,
|
|
payment_method: "ach",
|
|
transaction_status: "completed",
|
|
stripe_payment_id: "pi_test_456",
|
|
created_on: BASE_TIME - 1000 * 60 * 60 * 24 * 55,
|
|
},
|
|
];
|
|
|
|
let e2eLeaseDocuments: LeaseDocument[] = [
|
|
{
|
|
id: "e2e-doc-lease-1",
|
|
lease_id: "e2e-lease-1",
|
|
document_type: "lease",
|
|
file_url: "/static/sample-lease.pdf",
|
|
file_name: "maple-row-lease.pdf",
|
|
signed_by_landlord: false,
|
|
signed_by_tenant: false,
|
|
signature_date: null,
|
|
created_on: BASE_TIME - 1000 * 60 * 60 * 24 * 20,
|
|
},
|
|
{
|
|
id: "e2e-doc-receipt-1",
|
|
lease_id: "e2e-lease-1",
|
|
document_type: "receipt",
|
|
file_url: "/static/sample-receipt.pdf",
|
|
file_name: "rent-receipt-jan.pdf",
|
|
signed_by_landlord: true,
|
|
signed_by_tenant: true,
|
|
signature_date: BASE_TIME - 1000 * 60 * 60 * 24 * 15,
|
|
created_on: BASE_TIME - 1000 * 60 * 60 * 24 * 15,
|
|
},
|
|
];
|
|
|
|
export function getE2ENow() {
|
|
return BASE_TIME;
|
|
}
|
|
|
|
export function addE2EPaymentMethod(
|
|
userId: string,
|
|
input: Partial<PaymentMethod>,
|
|
) {
|
|
const now = BASE_TIME;
|
|
const isDefault = input.is_default ?? false;
|
|
if (isDefault) {
|
|
e2ePaymentMethods = e2ePaymentMethods.map((method) =>
|
|
method.user_id === userId ? { ...method, is_default: false } : method
|
|
);
|
|
}
|
|
const newMethod: PaymentMethod = {
|
|
id: crypto.randomUUID(),
|
|
user_id: userId,
|
|
bank_name: input.bank_name ?? null,
|
|
method_type: input.method_type ?? "bank_account",
|
|
account_type: input.account_type ?? null,
|
|
routing_number_encrypted: input.routing_number_encrypted ?? null,
|
|
account_number_encrypted: input.account_number_encrypted ?? null,
|
|
is_default: isDefault,
|
|
created_on: now,
|
|
updated_on: now,
|
|
};
|
|
e2ePaymentMethods = [...e2ePaymentMethods, newMethod];
|
|
return newMethod;
|
|
}
|
|
|
|
export function updateE2EPaymentMethod(
|
|
id: string,
|
|
updates: Partial<PaymentMethod>,
|
|
) {
|
|
let updated: PaymentMethod | null = null;
|
|
e2ePaymentMethods = e2ePaymentMethods.map((method) => {
|
|
if (method.id !== id) return method;
|
|
updated = { ...method, ...updates, updated_on: BASE_TIME };
|
|
return updated;
|
|
});
|
|
return updated;
|
|
}
|
|
|
|
export function deleteE2EPaymentMethod(id: string) {
|
|
const existing = e2ePaymentMethods.find((method) => method.id === id) || null;
|
|
e2ePaymentMethods = e2ePaymentMethods.filter((method) => method.id !== id);
|
|
return existing;
|
|
}
|
|
|
|
export function getE2EDefaultPaymentMethod(userId: string) {
|
|
return e2ePaymentMethods.find((method) =>
|
|
method.user_id === userId && method.is_default
|
|
) || null;
|
|
}
|
|
|
|
export function addE2EPaymentTransaction(
|
|
leaseId: string,
|
|
amount: number,
|
|
paymentDate: number,
|
|
paymentMethod: string,
|
|
transactionStatus: string,
|
|
stripePaymentId?: string,
|
|
) {
|
|
const newTransaction: PaymentTransaction = {
|
|
id: crypto.randomUUID(),
|
|
lease_id: leaseId,
|
|
amount,
|
|
payment_date: paymentDate,
|
|
payment_method: paymentMethod,
|
|
transaction_status: transactionStatus,
|
|
stripe_payment_id: stripePaymentId ?? null,
|
|
created_on: BASE_TIME,
|
|
};
|
|
e2ePaymentTransactions = [newTransaction, ...e2ePaymentTransactions];
|
|
return newTransaction;
|
|
}
|
|
|
|
export function isE2EEnabled() {
|
|
return E2E_ENABLED;
|
|
}
|
|
|
|
export function resolveE2EUserKey(input: string | null | undefined) {
|
|
if (!input) return null;
|
|
const trimmed = input.trim();
|
|
if (!trimmed) return null;
|
|
const roleMatch = (e2eUserIds as Record<string, string>)[trimmed];
|
|
if (roleMatch) return roleMatch;
|
|
if (e2eUsers[trimmed]) return trimmed;
|
|
return null;
|
|
}
|
|
|
|
export function getE2ESession(
|
|
input: string | null | undefined,
|
|
): SessionCookie | null {
|
|
const userId = resolveE2EUserKey(input);
|
|
if (!userId) return null;
|
|
const user = e2eUsers[userId];
|
|
if (!user) return null;
|
|
return {
|
|
started_on: user.started_on || BASE_TIME,
|
|
user_name: user.name || "E2E User",
|
|
user_encrypted: false,
|
|
user_id: user.id,
|
|
role: user.user_type || "",
|
|
};
|
|
}
|
|
|
|
export function getE2EUserById(userId: string): User | null {
|
|
return e2eUsers[userId] ? { ...e2eUsers[userId] } : null;
|
|
}
|
|
|
|
export function setE2EUser(user: User) {
|
|
e2eUsers = { ...e2eUsers, [user.id]: { ...user } };
|
|
}
|
|
|
|
export function updateE2EUser(
|
|
userId: string,
|
|
updates: Partial<User>,
|
|
): User | null {
|
|
const existing = e2eUsers[userId];
|
|
if (!existing) return null;
|
|
const updated = { ...existing, ...updates, updated_on: BASE_TIME };
|
|
e2eUsers = { ...e2eUsers, [userId]: updated };
|
|
return { ...updated };
|
|
}
|
|
|
|
export function getE2EAllProperties() {
|
|
return [...e2eProperties];
|
|
}
|
|
|
|
export function getE2EPropertiesByOwner(ownerId: string) {
|
|
return e2eProperties.filter((property) => property.owner_id === ownerId);
|
|
}
|
|
|
|
export function getE2EPropertyById(propertyId: string): Property | null {
|
|
return e2eProperties.find((property) => property.id === propertyId) || null;
|
|
}
|
|
|
|
export function addE2EProperty(ownerId: string, input: Partial<Property>) {
|
|
const newProperty: Property = {
|
|
id: crypto.randomUUID(),
|
|
owner_id: ownerId,
|
|
title: input.title || "New Property",
|
|
description: input.description || "",
|
|
address_1: input.address_1 || "",
|
|
address_2: input.address_2 || "",
|
|
city: input.city || "",
|
|
state: input.state || "",
|
|
zip: input.zip || "",
|
|
country: input.country || "",
|
|
bedrooms: input.bedrooms || 0,
|
|
bathrooms: input.bathrooms || 0,
|
|
property_type: input.property_type || "",
|
|
monthly_rent: input.monthly_rent || 0,
|
|
security_deposit: input.security_deposit || 0,
|
|
lease_duration_months: input.lease_duration_months || 12,
|
|
amenities: input.amenities || "",
|
|
rental_status: "draft",
|
|
created_on: BASE_TIME,
|
|
updated_on: BASE_TIME,
|
|
};
|
|
e2eProperties = [...e2eProperties, newProperty];
|
|
return newProperty;
|
|
}
|
|
|
|
export function addE2EPropertyMedia(
|
|
propertyId: string,
|
|
mediaType: string,
|
|
fileUrl: string,
|
|
fileName: string,
|
|
displayOrder = 0,
|
|
) {
|
|
const media: PropertyMedia = {
|
|
id: crypto.randomUUID(),
|
|
property_id: propertyId,
|
|
media_type: mediaType,
|
|
file_url: fileUrl,
|
|
file_name: fileName,
|
|
display_order: displayOrder,
|
|
created_on: BASE_TIME,
|
|
};
|
|
e2ePropertyMedia = [...e2ePropertyMedia, media];
|
|
return media;
|
|
}
|
|
|
|
function sortMediaByOrder(media: PropertyMedia[]) {
|
|
return [...media].sort((a, b) =>
|
|
(a.display_order ?? 0) - (b.display_order ?? 0)
|
|
);
|
|
}
|
|
|
|
export function getE2EMediaByProperty(propertyId: string) {
|
|
return sortMediaByOrder(
|
|
e2ePropertyMedia.filter((media) => media.property_id === propertyId),
|
|
);
|
|
}
|
|
|
|
export function getE2EMediaByPropertyAndType(
|
|
propertyId: string,
|
|
mediaType: string,
|
|
) {
|
|
return sortMediaByOrder(
|
|
e2ePropertyMedia.filter((media) =>
|
|
media.property_id === propertyId && media.media_type === mediaType
|
|
),
|
|
);
|
|
}
|
|
|
|
export function updateE2EPropertyMediaOrder(id: string, displayOrder: number) {
|
|
let updated: PropertyMedia | null = null;
|
|
e2ePropertyMedia = e2ePropertyMedia.map((media) => {
|
|
if (media.id !== id) return media;
|
|
updated = { ...media, display_order: displayOrder };
|
|
return updated;
|
|
});
|
|
return updated ? [updated] : [];
|
|
}
|
|
|
|
export function updateE2EMultipleMediaOrders(
|
|
updates: { id: string; display_order: number }[],
|
|
) {
|
|
const updatesMap = new Map(
|
|
updates.map((update) => [update.id, update.display_order]),
|
|
);
|
|
const changed: PropertyMedia[] = [];
|
|
e2ePropertyMedia = e2ePropertyMedia.map((media) => {
|
|
const nextOrder = updatesMap.get(media.id);
|
|
if (nextOrder === undefined) return media;
|
|
const updated = { ...media, display_order: nextOrder };
|
|
changed.push(updated);
|
|
return updated;
|
|
});
|
|
return changed;
|
|
}
|
|
|
|
export function deleteE2EPropertyMedia(id: string) {
|
|
const existing = e2ePropertyMedia.find((media) => media.id === id) || null;
|
|
e2ePropertyMedia = e2ePropertyMedia.filter((media) => media.id !== id);
|
|
return existing ? [existing] : [];
|
|
}
|
|
|
|
export function deleteE2EAllMediaForProperty(propertyId: string) {
|
|
const removed = e2ePropertyMedia.filter((media) =>
|
|
media.property_id === propertyId
|
|
);
|
|
e2ePropertyMedia = e2ePropertyMedia.filter((media) =>
|
|
media.property_id !== propertyId
|
|
);
|
|
return removed;
|
|
}
|
|
|
|
export function getE2EMediaCountByProperty(propertyId: string) {
|
|
const media = getE2EMediaByProperty(propertyId);
|
|
return {
|
|
total: media.length,
|
|
photos: media.filter((item) => item.media_type === "photo").length,
|
|
videos: media.filter((item) => item.media_type === "video").length,
|
|
documents: media.filter((item) => item.media_type === "document").length,
|
|
};
|
|
}
|
|
|
|
export function getE2EPrimaryPhoto(propertyId: string) {
|
|
const photos = getE2EMediaByPropertyAndType(propertyId, "photo");
|
|
return photos[0] || null;
|
|
}
|
|
|
|
export function mergeE2EProperty(
|
|
canonicalPropertyId: string,
|
|
duplicatePropertyId: string,
|
|
canonicalAddress: string,
|
|
): PropertyMergeResult {
|
|
if (canonicalPropertyId === duplicatePropertyId) {
|
|
throw new Error("Select two different properties to merge.");
|
|
}
|
|
|
|
const canonical = getE2EPropertyById(canonicalPropertyId);
|
|
const duplicate = getE2EPropertyById(duplicatePropertyId);
|
|
if (!canonical || !duplicate) {
|
|
throw new Error("Property not found.");
|
|
}
|
|
|
|
const warnings: string[] = [];
|
|
const canonicalActive = e2eLeases.some((lease) =>
|
|
lease.property_id === canonicalPropertyId && lease.lease_status === "active"
|
|
);
|
|
const duplicateActive = e2eLeases.some((lease) =>
|
|
lease.property_id === duplicatePropertyId && lease.lease_status === "active"
|
|
);
|
|
|
|
if (canonicalActive && duplicateActive) {
|
|
warnings.push(
|
|
"Both properties have active leases. Confirm lease expectations after merge.",
|
|
);
|
|
}
|
|
|
|
const canonicalSchedules =
|
|
e2eRentSchedules.filter((schedule) =>
|
|
schedule.property_id === canonicalPropertyId
|
|
).length;
|
|
const duplicateSchedules =
|
|
e2eRentSchedules.filter((schedule) =>
|
|
schedule.property_id === duplicatePropertyId
|
|
).length;
|
|
|
|
if (canonicalSchedules && duplicateSchedules) {
|
|
warnings.push(
|
|
"Both properties have rent schedules. Review schedules after merge.",
|
|
);
|
|
}
|
|
|
|
let leaseUpdates = 0;
|
|
let scheduleUpdates = 0;
|
|
let applicationUpdates = 0;
|
|
let maintenanceUpdates = 0;
|
|
let mediaUpdates = 0;
|
|
|
|
e2eLeases = e2eLeases.map((lease) => {
|
|
if (lease.property_id !== duplicatePropertyId) return lease;
|
|
leaseUpdates += 1;
|
|
return {
|
|
...lease,
|
|
property_id: canonicalPropertyId,
|
|
property_address: canonicalAddress || lease.property_address || "",
|
|
updated_on: BASE_TIME,
|
|
};
|
|
});
|
|
|
|
e2eRentSchedules = e2eRentSchedules.map((schedule) => {
|
|
if (schedule.property_id !== duplicatePropertyId) return schedule;
|
|
scheduleUpdates += 1;
|
|
return {
|
|
...schedule,
|
|
property_id: canonicalPropertyId,
|
|
updated_on: BASE_TIME,
|
|
};
|
|
});
|
|
|
|
e2eTenantApplications = e2eTenantApplications.map((application) => {
|
|
if (application.property_id !== duplicatePropertyId) return application;
|
|
applicationUpdates += 1;
|
|
return { ...application, property_id: canonicalPropertyId };
|
|
});
|
|
|
|
e2eMaintenanceRequests = e2eMaintenanceRequests.map((request) => {
|
|
if (request.property_id !== duplicatePropertyId) return request;
|
|
maintenanceUpdates += 1;
|
|
return { ...request, property_id: canonicalPropertyId };
|
|
});
|
|
|
|
e2ePropertyMedia = e2ePropertyMedia.map((media) => {
|
|
if (media.property_id !== duplicatePropertyId) return media;
|
|
mediaUpdates += 1;
|
|
return { ...media, property_id: canonicalPropertyId };
|
|
});
|
|
|
|
e2eProperties = e2eProperties
|
|
.map((property) =>
|
|
property.id === canonicalPropertyId
|
|
? { ...property, updated_on: BASE_TIME }
|
|
: property
|
|
)
|
|
.filter((property) => property.id !== duplicatePropertyId);
|
|
|
|
return {
|
|
canonicalPropertyId,
|
|
duplicatePropertyId,
|
|
updated: {
|
|
leases: leaseUpdates,
|
|
rentSchedules: scheduleUpdates,
|
|
tenantApplications: applicationUpdates,
|
|
maintenanceRequests: maintenanceUpdates,
|
|
propertyMedia: mediaUpdates,
|
|
},
|
|
warnings,
|
|
};
|
|
}
|
|
|
|
export function getE2ELeasesByTenant(tenantId: string) {
|
|
return e2eLeases.filter((lease) => lease.tenant_id === tenantId);
|
|
}
|
|
|
|
export function getE2ELeasesByOwner(ownerId: string) {
|
|
return e2eLeases.filter((lease) => lease.owner_id === ownerId);
|
|
}
|
|
|
|
export function getE2ELeaseById(leaseId: string): Lease | null {
|
|
return e2eLeases.find((lease) => lease.id === leaseId) || null;
|
|
}
|
|
|
|
export function getE2EMessagesByUser(userId: string) {
|
|
return e2eMessages.filter((message) =>
|
|
message.sender_id === userId || message.recipient_id === userId
|
|
);
|
|
}
|
|
|
|
export function markE2EMessageAsRead(messageId: string) {
|
|
e2eMessages = e2eMessages.map((message) =>
|
|
message.id === messageId ? { ...message, is_read: true } : message
|
|
);
|
|
return true;
|
|
}
|
|
|
|
export function deleteE2EMessage(messageId: string) {
|
|
const existing = e2eMessages.find((message) => message.id === messageId) ||
|
|
null;
|
|
e2eMessages = e2eMessages.filter((message) => message.id !== messageId);
|
|
return existing;
|
|
}
|
|
|
|
export function deleteE2EMessagesForUser(userId: string, messageIds: string[]) {
|
|
if (messageIds.length === 0) return [];
|
|
const idSet = new Set(messageIds);
|
|
const removed: Message[] = [];
|
|
e2eMessages = e2eMessages.filter((message) => {
|
|
const isOwned = message.sender_id === userId ||
|
|
message.recipient_id === userId;
|
|
const shouldDelete = isOwned && idSet.has(message.id);
|
|
if (shouldDelete) removed.push(message);
|
|
return !shouldDelete;
|
|
});
|
|
return removed;
|
|
}
|
|
|
|
export function getE2EMaintenanceRequestsByUser(userId: string) {
|
|
return e2eMaintenanceRequests.filter((request) =>
|
|
request.tenant_id === userId
|
|
);
|
|
}
|
|
|
|
export function getE2EMaintenanceRequestsByLandlord(landlordId: string) {
|
|
const propertyIds = e2eProperties
|
|
.filter((property) => property.owner_id === landlordId)
|
|
.map((property) => property.id);
|
|
return e2eMaintenanceRequests.filter((request) =>
|
|
propertyIds.includes(request.property_id || "")
|
|
);
|
|
}
|
|
|
|
export function getE2EMaintenanceDataByLandlord(
|
|
landlordId: string,
|
|
): MaintenanceData[] {
|
|
return getE2EMaintenanceRequestsByLandlord(landlordId).map((request) => {
|
|
const tenant = request.tenant_id ? e2eUsers[request.tenant_id] : null;
|
|
const property = request.property_id
|
|
? e2eProperties.find((item) => item.id === request.property_id)
|
|
: null;
|
|
return {
|
|
id: request.id,
|
|
property_id: request.property_id || "",
|
|
tenant_id: request.tenant_id || "",
|
|
request_type: request.request_type || null,
|
|
description: request.description || null,
|
|
status: request.status || null,
|
|
priority: request.priority || null,
|
|
created_on: request.created_on || BASE_TIME,
|
|
resolved_on: request.resolved_on || null,
|
|
tenant_name: tenant?.name || "Tenant",
|
|
tenant_email: tenant?.email || "",
|
|
tenant_phone: tenant?.phone || "",
|
|
property_address: property?.address_1 || "",
|
|
property_city: property?.city || "",
|
|
property_state: property?.state || "",
|
|
property_zip: property?.zip || "",
|
|
property_title: property?.title || "Property",
|
|
};
|
|
});
|
|
}
|
|
|
|
export function updateE2EMaintenanceRequest(
|
|
requestId: string,
|
|
updates: { status?: string; priority?: string },
|
|
) {
|
|
e2eMaintenanceRequests = e2eMaintenanceRequests.map((request) => {
|
|
if (request.id !== requestId) return request;
|
|
return {
|
|
...request,
|
|
status: updates.status ?? request.status,
|
|
priority: updates.priority ?? request.priority,
|
|
resolved_on: updates.status === "completed"
|
|
? BASE_TIME
|
|
: request.resolved_on,
|
|
};
|
|
});
|
|
}
|
|
|
|
export function createE2EMaintenanceRequest(
|
|
propertyId: string,
|
|
tenantId: string,
|
|
requestType: string,
|
|
description: string,
|
|
priority: string,
|
|
) {
|
|
const newRequest: MaintenanceRequest = {
|
|
id: crypto.randomUUID(),
|
|
property_id: propertyId,
|
|
tenant_id: tenantId,
|
|
request_type: requestType,
|
|
description,
|
|
status: "open",
|
|
priority,
|
|
created_on: BASE_TIME,
|
|
resolved_on: null,
|
|
};
|
|
e2eMaintenanceRequests = [newRequest, ...e2eMaintenanceRequests];
|
|
return newRequest;
|
|
}
|
|
|
|
export function getE2ETenantApplicationsByLandlord(landlordId: string) {
|
|
const propertyIds = e2eProperties
|
|
.filter((property) => property.owner_id === landlordId)
|
|
.map((property) => property.id);
|
|
|
|
return e2eTenantApplications
|
|
.filter((
|
|
app,
|
|
): app is TenantApplication & { property_id: string } =>
|
|
typeof app.property_id === "string" &&
|
|
propertyIds.includes(app.property_id)
|
|
)
|
|
.map((app) => {
|
|
const applicantId = app.applicant_id ?? "";
|
|
const applicant = applicantId ? e2eUsers[applicantId] : null;
|
|
const property = e2eProperties.find((item) =>
|
|
item.id === app.property_id
|
|
);
|
|
return {
|
|
...app,
|
|
applicant_name: applicant?.name || "Applicant",
|
|
applicant_email: applicant?.email || "",
|
|
property_address: property?.address_1 || "",
|
|
monthly_income: 6800,
|
|
application_notes: "Strong rental history.",
|
|
status: app.application_status || "pending",
|
|
created_at: app.submitted_on,
|
|
};
|
|
});
|
|
}
|
|
|
|
export function hasE2EApplication(propertyId: string, applicantId: string) {
|
|
return e2eTenantApplications.some((app) =>
|
|
app.property_id === propertyId && app.applicant_id === applicantId
|
|
);
|
|
}
|
|
|
|
export function createE2ETenantApplication(
|
|
propertyId: string,
|
|
applicantId: string,
|
|
applicationData: string,
|
|
documentUrlsJson: string | null = null,
|
|
) {
|
|
const newApplication: TenantApplication = {
|
|
id: crypto.randomUUID(),
|
|
property_id: propertyId,
|
|
applicant_id: applicantId,
|
|
application_status: "pending",
|
|
application_data_encrypted: applicationData,
|
|
document_urls_json: documentUrlsJson,
|
|
submitted_on: BASE_TIME,
|
|
reviewed_on: null,
|
|
};
|
|
e2eTenantApplications = [newApplication, ...e2eTenantApplications];
|
|
return newApplication;
|
|
}
|
|
|
|
export function updateE2ETenantApplicationStatus(
|
|
applicationId: string,
|
|
status: "approved" | "rejected",
|
|
) {
|
|
e2eTenantApplications = e2eTenantApplications.map((app) =>
|
|
app.id === applicationId
|
|
? { ...app, application_status: status, reviewed_on: BASE_TIME }
|
|
: app
|
|
);
|
|
}
|
|
|
|
export function addE2ETenantInvite(
|
|
landlordId: string,
|
|
propertyId: string,
|
|
options: { availableOn?: number | null } = {},
|
|
) {
|
|
const existing = e2eTenantInvites.find((item) =>
|
|
item.landlord_id === landlordId && item.property_id === propertyId
|
|
);
|
|
if (existing) return { ...existing };
|
|
const now = BASE_TIME;
|
|
const invite: TenantInvite = {
|
|
id: crypto.randomUUID(),
|
|
landlord_id: landlordId,
|
|
property_id: propertyId,
|
|
available_on: options.availableOn ?? null,
|
|
invited_emails: JSON.stringify([]),
|
|
created_on: now,
|
|
updated_on: now,
|
|
};
|
|
e2eTenantInvites = [invite, ...e2eTenantInvites];
|
|
return invite;
|
|
}
|
|
|
|
export function getE2ETwitterInviteById(inviteId: string) {
|
|
const invite = e2eTenantInvites.find((item) => item.id === inviteId);
|
|
return invite ? { ...invite } : null;
|
|
}
|
|
|
|
export function getE2ETwitterInviteByProperty(
|
|
landlordId: string,
|
|
propertyId: string,
|
|
) {
|
|
const invite = e2eTenantInvites.find((item) =>
|
|
item.landlord_id === landlordId && item.property_id === propertyId
|
|
);
|
|
return invite ? { ...invite } : null;
|
|
}
|
|
|
|
export function updateE2ETwitterInvite(
|
|
inviteId: string,
|
|
updates: Partial<TenantInvite>,
|
|
) {
|
|
let updated: TenantInvite | null = null;
|
|
e2eTenantInvites = e2eTenantInvites.map((invite) => {
|
|
if (invite.id !== inviteId) return invite;
|
|
updated = { ...invite, ...updates, updated_on: BASE_TIME };
|
|
return updated;
|
|
});
|
|
return updated;
|
|
}
|
|
|
|
function parseInviteEmails(rawEmails?: string | null) {
|
|
if (!rawEmails) return [];
|
|
try {
|
|
const parsed = JSON.parse(rawEmails);
|
|
if (!Array.isArray(parsed)) return [];
|
|
return parsed.filter((entry) => typeof entry === "string");
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export function getE2ETwitterInvitesByEmail(email: string) {
|
|
const normalizedEmail = email.trim().toLowerCase();
|
|
if (!normalizedEmail) return [];
|
|
return e2eTenantInvites.filter((invite) => {
|
|
const emails = parseInviteEmails(invite.invited_emails);
|
|
return emails.some((entry) => entry.toLowerCase() === normalizedEmail);
|
|
}).map((invite) => ({ ...invite }));
|
|
}
|
|
|
|
export function getE2EPaymentMethodsByUser(userId: string) {
|
|
return e2ePaymentMethods.filter((method) => method.user_id === userId);
|
|
}
|
|
|
|
export function getE2EPaymentTransactionsByLease(leaseId: string) {
|
|
return e2ePaymentTransactions.filter((tx) => tx.lease_id === leaseId);
|
|
}
|
|
|
|
export function getE2EPaymentTransactionsByUser(userId: string) {
|
|
const leaseIds = e2eLeases
|
|
.filter((lease) => lease.tenant_id === userId)
|
|
.map((lease) => lease.id);
|
|
return e2ePaymentTransactions.filter((tx) =>
|
|
leaseIds.includes(tx.lease_id || "")
|
|
);
|
|
}
|
|
|
|
export function getE2EPaymentTransactionsByOwner(ownerId: string) {
|
|
const leaseIds = e2eLeases
|
|
.filter((lease) => lease.owner_id === ownerId)
|
|
.map((lease) => lease.id);
|
|
return e2ePaymentTransactions.filter((tx) =>
|
|
leaseIds.includes(tx.lease_id || "")
|
|
);
|
|
}
|
|
|
|
export function getE2EDocumentsByLease(leaseId: string) {
|
|
return e2eLeaseDocuments.filter((doc) => doc.lease_id === leaseId);
|
|
}
|
|
|
|
export function getE2EDocumentById(documentId: string) {
|
|
return e2eLeaseDocuments.find((doc) => doc.id === documentId) || null;
|
|
}
|
|
|
|
export function getE2EDocumentsByLeaseAndType(
|
|
leaseId: string,
|
|
documentType: string,
|
|
) {
|
|
return e2eLeaseDocuments.filter((doc) =>
|
|
doc.lease_id === leaseId && doc.document_type === documentType
|
|
);
|
|
}
|
|
|
|
export function createE2ELeaseDocument(
|
|
leaseId: string,
|
|
documentType: string,
|
|
fileUrl: string,
|
|
fileName: string,
|
|
) {
|
|
const newDocument: LeaseDocument = {
|
|
id: crypto.randomUUID(),
|
|
lease_id: leaseId,
|
|
document_type: documentType,
|
|
file_url: fileUrl,
|
|
file_name: fileName,
|
|
signed_by_landlord: false,
|
|
signed_by_tenant: false,
|
|
signature_date: null,
|
|
created_on: BASE_TIME,
|
|
};
|
|
e2eLeaseDocuments = [newDocument, ...e2eLeaseDocuments];
|
|
return newDocument;
|
|
}
|
|
|
|
export function deleteE2ELeaseDocument(documentId: string) {
|
|
const existing = getE2EDocumentById(documentId);
|
|
e2eLeaseDocuments = e2eLeaseDocuments.filter((doc) => doc.id !== documentId);
|
|
return existing;
|
|
}
|
|
|
|
export function signE2EDocumentAsLandlord(documentId: string) {
|
|
let updated: LeaseDocument | null = null;
|
|
e2eLeaseDocuments = e2eLeaseDocuments.map((doc) => {
|
|
if (doc.id !== documentId) return doc;
|
|
updated = {
|
|
...doc,
|
|
signed_by_landlord: true,
|
|
signature_date: BASE_TIME,
|
|
};
|
|
return updated;
|
|
});
|
|
return updated;
|
|
}
|
|
|
|
export function signE2EDocumentAsTenant(documentId: string) {
|
|
let updated: LeaseDocument | null = null;
|
|
e2eLeaseDocuments = e2eLeaseDocuments.map((doc) => {
|
|
if (doc.id !== documentId) return doc;
|
|
updated = {
|
|
...doc,
|
|
signed_by_tenant: true,
|
|
signature_date: BASE_TIME,
|
|
};
|
|
return updated;
|
|
});
|
|
return updated;
|
|
}
|
|
|
|
export function updateE2EDocumentFile(
|
|
documentId: string,
|
|
fileUrl: string,
|
|
fileName: string,
|
|
) {
|
|
let updated: LeaseDocument | null = null;
|
|
e2eLeaseDocuments = e2eLeaseDocuments.map((doc) => {
|
|
if (doc.id !== documentId) return doc;
|
|
updated = {
|
|
...doc,
|
|
file_url: fileUrl,
|
|
file_name: fileName,
|
|
signed_by_landlord: false,
|
|
signed_by_tenant: false,
|
|
signature_date: null,
|
|
};
|
|
return updated;
|
|
});
|
|
return updated;
|
|
}
|
|
|
|
export function getE2ELandlordDashboardData(userId: string) {
|
|
const properties = getE2EPropertiesByOwner(userId);
|
|
const leases = getE2ELeasesByOwner(userId);
|
|
const tenantApplications = getE2ETenantApplicationsByLandlord(userId);
|
|
const maintenanceRequests = getE2EMaintenanceRequestsByLandlord(userId).map(
|
|
(request) => ({
|
|
...request,
|
|
tenant_name: e2eUsers[request.tenant_id || ""]?.name || "Tenant",
|
|
tenant_email: e2eUsers[request.tenant_id || ""]?.email || "",
|
|
property_address:
|
|
getE2EPropertyById(request.property_id || "")?.address_1 || "",
|
|
created_at: request.created_on,
|
|
}),
|
|
);
|
|
|
|
const recentMessages = getE2EMessagesByUser(userId).map((message) => ({
|
|
...message,
|
|
sender_name: e2eUsers[message.sender_id || ""]?.name || "Tenant",
|
|
created_at: message.created_on,
|
|
}));
|
|
|
|
const monthlyRevenue = properties.reduce(
|
|
(sum, property) => sum + (property.monthly_rent || 0),
|
|
0,
|
|
);
|
|
|
|
return {
|
|
propertyData: {
|
|
total: properties.length,
|
|
available:
|
|
properties.filter((property) => property.rental_status === "available")
|
|
.length,
|
|
occupied:
|
|
properties.filter((property) => property.rental_status === "occupied")
|
|
.length,
|
|
draft: properties.filter((property) => property.rental_status === "draft")
|
|
.length,
|
|
},
|
|
leaseData: {
|
|
active: leases.filter((lease) => lease.lease_status === "active").length,
|
|
draft: leases.filter((lease) => lease.lease_status === "draft").length,
|
|
expired:
|
|
leases.filter((lease) => lease.lease_status === "expired").length,
|
|
},
|
|
applicationData: {
|
|
pending: tenantApplications.filter((app) => app.status === "pending")
|
|
.length,
|
|
total_this_month: tenantApplications.length,
|
|
},
|
|
paymentData: {
|
|
expected_this_month: leases
|
|
.filter((lease) => lease.lease_status === "active")
|
|
.reduce((sum, lease) => sum + (lease.rent || 0), 0),
|
|
received_this_month: monthlyRevenue,
|
|
overdue: 0,
|
|
},
|
|
properties,
|
|
tenantApplications,
|
|
maintenanceRequests,
|
|
recentMessages,
|
|
monthlyRevenue,
|
|
};
|
|
}
|
|
|