437 lines
12 KiB
TypeScript
437 lines
12 KiB
TypeScript
// services/landlordService.ts
|
|
// Handle landlord-specific operations, onboarding workflow, and dashboard data
|
|
|
|
import { and, desc, eq, or } from "drizzle-orm";
|
|
import { db } from "../db/db.ts";
|
|
import {
|
|
leases,
|
|
messages,
|
|
payment_methods,
|
|
properties,
|
|
tenant_applications,
|
|
users,
|
|
} from "../db/schema.ts";
|
|
import { DashboardTenantApplication } from "./tenantApplicationService.ts";
|
|
import {
|
|
DashboardMaintenanceRequest,
|
|
getMaintenanceDataByLandlord,
|
|
} from "./maintenanceRequestService.ts";
|
|
import { DashboardMessage } from "./messageService.ts";
|
|
import {
|
|
Lease as _Lease,
|
|
Message as _Message,
|
|
PaymentMethod as _PaymentMethod,
|
|
Property,
|
|
TenantApplication,
|
|
User as _User,
|
|
} from "../db/interfaces.ts";
|
|
import { getUser, updateUser } from "./userService.ts";
|
|
|
|
export interface LandlordOnboardingData {
|
|
user_id: string;
|
|
current_step: number;
|
|
steps_completed: {
|
|
profile_setup: boolean;
|
|
email_verification: boolean;
|
|
property_added: boolean;
|
|
payment_setup: boolean;
|
|
first_property_published: boolean;
|
|
};
|
|
total_steps: number;
|
|
completion_percentage: number;
|
|
}
|
|
|
|
export interface LandlordDashboardData {
|
|
propertyData: {
|
|
total: number;
|
|
available: number;
|
|
occupied: number;
|
|
draft: number;
|
|
};
|
|
properties?: Property[];
|
|
leaseData: {
|
|
active: number;
|
|
draft: number;
|
|
expired: number;
|
|
};
|
|
applicationData: {
|
|
pending: number;
|
|
total_this_month: number;
|
|
};
|
|
paymentData: {
|
|
expected_this_month: number;
|
|
received_this_month: number;
|
|
overdue: number;
|
|
};
|
|
tenantApplications?: DashboardTenantApplication[];
|
|
maintenanceRequests?: DashboardMaintenanceRequest[];
|
|
recentMessages?: DashboardMessage[];
|
|
monthlyRevenue?: number;
|
|
}
|
|
|
|
// Extended dashboard interface with additional UI data arrays
|
|
export interface ExtendedLandlordDashboardData extends LandlordDashboardData {
|
|
tenantApplications: DashboardTenantApplication[];
|
|
maintenanceRequests: DashboardMaintenanceRequest[];
|
|
recentMessages: DashboardMessage[];
|
|
monthlyRevenue?: number;
|
|
}
|
|
|
|
// Initialize landlord user type and onboarding
|
|
export async function initializeLandlordOnboarding(user_id: string) {
|
|
const now = Date.now();
|
|
return await updateUser(user_id, {
|
|
user_type: "landlord",
|
|
onboarding_step: 1,
|
|
email_verified: false,
|
|
banking_setup_complete: false,
|
|
updated_on: now,
|
|
});
|
|
}
|
|
|
|
// Update onboarding step
|
|
export async function updateOnboardingStep(user_id: string, step: number) {
|
|
const now = Date.now();
|
|
return await updateUser(user_id, {
|
|
onboarding_step: step,
|
|
updated_on: now,
|
|
});
|
|
}
|
|
|
|
// Update landlord onboarding step (alias for compatibility with routes)
|
|
export async function updateLandlordOnboardingStep(
|
|
user_id: string,
|
|
step: number,
|
|
) {
|
|
return await updateOnboardingStep(user_id, step);
|
|
}
|
|
|
|
// Mark email as verified
|
|
export async function markEmailVerified(user_id: string) {
|
|
const now = Date.now();
|
|
return await updateUser(user_id, {
|
|
email_verified: true,
|
|
updated_on: now,
|
|
});
|
|
}
|
|
|
|
// Mark banking setup as complete
|
|
export async function markBankingSetupComplete(user_id: string) {
|
|
const now = Date.now();
|
|
return await updateUser(user_id, {
|
|
banking_setup_complete: true,
|
|
updated_on: now,
|
|
});
|
|
}
|
|
|
|
// Get landlord onboarding status
|
|
export async function getLandlordOnboardingStatus(
|
|
user_id: string,
|
|
): Promise<LandlordOnboardingData> {
|
|
// Get user data
|
|
const user = await getUser(user_id);
|
|
|
|
if (!user) {
|
|
throw new Error("User not found");
|
|
}
|
|
|
|
// Get properties count
|
|
const propertiesResult = await db.select()
|
|
.from(properties)
|
|
.where(eq(properties.owner_id, user_id));
|
|
|
|
// Get payment methods count
|
|
const paymentMethodsResult = await db.select()
|
|
.from(payment_methods)
|
|
.where(eq(payment_methods.user_id, user_id));
|
|
|
|
// Determine completion status
|
|
const steps_completed = {
|
|
profile_setup: !!(user.name && user.email && user.phone),
|
|
email_verification: user.email_verified || false,
|
|
property_added: propertiesResult.length > 0,
|
|
payment_setup: paymentMethodsResult.length > 0,
|
|
first_property_published: propertiesResult.some((p) =>
|
|
p.rental_status === "available"
|
|
),
|
|
};
|
|
|
|
const completed_count = Object.values(steps_completed).filter(Boolean).length;
|
|
const total_steps = 5;
|
|
const completion_percentage = Math.round(
|
|
(completed_count / total_steps) * 100,
|
|
);
|
|
|
|
return {
|
|
user_id,
|
|
current_step: user.onboarding_step || 1,
|
|
steps_completed,
|
|
total_steps,
|
|
completion_percentage,
|
|
};
|
|
}
|
|
|
|
// Get pending lease approvals for a landlord
|
|
export async function getPendingLeaseApprovals(
|
|
landlord_id: string,
|
|
) {
|
|
const pendingLeases = await db.select()
|
|
.from(leases)
|
|
.where(
|
|
and(
|
|
eq(leases.owner_id, landlord_id),
|
|
eq(leases.approval_status, "pending_approval"),
|
|
),
|
|
)
|
|
.orderBy(leases.approval_requested_at);
|
|
|
|
if (pendingLeases.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const propertyIds = pendingLeases
|
|
.map((lease) => lease.property_id)
|
|
.filter((id): id is string => Boolean(id));
|
|
|
|
const tenantIds = pendingLeases
|
|
.map((lease) => lease.tenant_id)
|
|
.filter((id): id is string => Boolean(id));
|
|
|
|
const propertiesData = propertyIds.length > 0
|
|
? await db
|
|
.select({
|
|
id: properties.id,
|
|
title: properties.title,
|
|
address_1: properties.address_1,
|
|
address_2: properties.address_2,
|
|
city: properties.city,
|
|
state: properties.state,
|
|
zip: properties.zip,
|
|
image_url: properties.image_url,
|
|
monthly_rent: properties.monthly_rent,
|
|
bedrooms: properties.bedrooms,
|
|
bathrooms: properties.bathrooms,
|
|
})
|
|
.from(properties)
|
|
.where(eq(properties.id, propertyIds[0]))
|
|
: [];
|
|
|
|
const tenantsData = tenantIds.length > 0
|
|
? await db
|
|
.select({
|
|
id: users.id,
|
|
name: users.name,
|
|
email: users.email,
|
|
phone: users.phone,
|
|
})
|
|
.from(users)
|
|
.where(eq(users.id, tenantIds[0]))
|
|
: [];
|
|
|
|
const propertyMap = new Map(
|
|
propertiesData.map((p) => [p.id, p]),
|
|
);
|
|
const tenantMap = new Map(
|
|
tenantsData.map((t) => [t.id, t]),
|
|
);
|
|
|
|
return pendingLeases.map((lease) => ({
|
|
...lease,
|
|
property: lease.property_id ? propertyMap.get(lease.property_id) : null,
|
|
tenant: lease.tenant_id ? tenantMap.get(lease.tenant_id) : null,
|
|
}));
|
|
}
|
|
|
|
// Get landlord dashboard data
|
|
export async function getLandlordDashboardData(
|
|
user_id: string,
|
|
): Promise<LandlordDashboardData> {
|
|
// Get properties data
|
|
const propertiesResult = await db.select()
|
|
.from(properties)
|
|
.where(eq(properties.owner_id, user_id));
|
|
|
|
// Get leases data
|
|
const leasesResult = await db.select()
|
|
.from(leases)
|
|
.where(eq(leases.owner_id, user_id));
|
|
|
|
// Get applications data
|
|
const property_ids = propertiesResult.map((p) => p.id);
|
|
let applicationsResult: TenantApplication[] = [];
|
|
|
|
if (property_ids.length > 0) {
|
|
// In a real implementation, you'd use an IN clause
|
|
// For now, we'll get all applications and filter
|
|
applicationsResult = await db.select()
|
|
.from(tenant_applications) as TenantApplication[];
|
|
|
|
applicationsResult = applicationsResult.filter((app) =>
|
|
app.property_id && property_ids.includes(app.property_id)
|
|
);
|
|
}
|
|
|
|
const tenantApplications = await db.select({
|
|
id: tenant_applications.id,
|
|
property_id: tenant_applications.property_id,
|
|
applicant_id: tenant_applications.applicant_id,
|
|
application_status: tenant_applications.application_status,
|
|
application_data_encrypted: tenant_applications.application_data_encrypted,
|
|
submitted_on: tenant_applications.submitted_on,
|
|
reviewed_on: tenant_applications.reviewed_on,
|
|
applicant_name: users.name,
|
|
applicant_email: users.email,
|
|
property_address: properties.address_1,
|
|
})
|
|
.from(tenant_applications)
|
|
.innerJoin(properties, eq(tenant_applications.property_id, properties.id))
|
|
.leftJoin(users, eq(tenant_applications.applicant_id, users.id))
|
|
.where(eq(properties.owner_id, user_id))
|
|
.orderBy(tenant_applications.submitted_on);
|
|
|
|
const maintenanceRequests = await getMaintenanceDataByLandlord(user_id);
|
|
|
|
const recentMessages = await db.select({
|
|
id: messages.id,
|
|
lease_id: messages.lease_id,
|
|
sender_id: messages.sender_id,
|
|
recipient_id: messages.recipient_id,
|
|
message_type: messages.message_type,
|
|
subject: messages.subject,
|
|
content: messages.content,
|
|
content_encrypted: messages.content_encrypted,
|
|
is_read: messages.is_read,
|
|
created_on: messages.created_on,
|
|
sender_name: users.name,
|
|
})
|
|
.from(messages)
|
|
.leftJoin(users, eq(messages.sender_id, users.id))
|
|
.where(or(
|
|
eq(messages.sender_id, user_id),
|
|
eq(messages.recipient_id, user_id),
|
|
))
|
|
.orderBy(desc(messages.created_on))
|
|
.limit(10) as unknown as DashboardMessage[];
|
|
|
|
// Calculate current month timestamp range
|
|
const now = new Date();
|
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).getTime();
|
|
const endOfMonth = new Date(
|
|
now.getFullYear(),
|
|
now.getMonth() + 1,
|
|
0,
|
|
23,
|
|
59,
|
|
59,
|
|
).getTime();
|
|
|
|
// Calculate dashboard metrics
|
|
const dashboardData: LandlordDashboardData = {
|
|
propertyData: {
|
|
total: propertiesResult.length,
|
|
available:
|
|
propertiesResult.filter((p) => p.rental_status === "available").length,
|
|
occupied:
|
|
propertiesResult.filter((p) => p.rental_status === "occupied").length,
|
|
draft: propertiesResult.filter((p) => p.rental_status === "draft").length,
|
|
},
|
|
properties: propertiesResult,
|
|
leaseData: {
|
|
active: leasesResult.filter((l) => l.lease_status === "active").length,
|
|
draft: leasesResult.filter((l) => l.lease_status === "draft").length,
|
|
expired: leasesResult.filter((l) => l.lease_status === "expired").length,
|
|
},
|
|
applicationData: {
|
|
pending:
|
|
applicationsResult.filter((app) => app.application_status === "pending")
|
|
.length,
|
|
total_this_month: applicationsResult.filter((app) =>
|
|
app.submitted_on &&
|
|
app.submitted_on >= startOfMonth && app.submitted_on <= endOfMonth
|
|
).length,
|
|
},
|
|
paymentData: {
|
|
expected_this_month: leasesResult
|
|
.filter((l) => l.lease_status === "active")
|
|
.reduce((sum, lease) => sum + (lease.rent || 0), 0),
|
|
received_this_month: 0, // Would need payment_transactions data
|
|
overdue: 0, // Would need payment_transactions data with due date logic
|
|
},
|
|
tenantApplications: tenantApplications.map((app) => ({
|
|
...app,
|
|
applicant_name: app.applicant_name ?? undefined,
|
|
applicant_email: app.applicant_email ?? undefined,
|
|
property_address: app.property_address ?? undefined,
|
|
status: app.application_status || "pending",
|
|
created_at: app.submitted_on ?? null,
|
|
})),
|
|
maintenanceRequests,
|
|
recentMessages: recentMessages.map((message) => ({
|
|
...message,
|
|
sender_name: message.sender_name || "Tenant",
|
|
created_at: message.created_on ?? null,
|
|
})),
|
|
monthlyRevenue: propertiesResult.reduce(
|
|
(sum, property) => sum + (property.monthly_rent || 0),
|
|
0,
|
|
),
|
|
};
|
|
|
|
return dashboardData;
|
|
}
|
|
|
|
// Check if user is a landlord
|
|
export async function isUserLandlord(user_id: string): Promise<boolean> {
|
|
const result = await db.select()
|
|
.from(users)
|
|
.where(and(
|
|
eq(users.id, user_id),
|
|
or(eq(users.user_type, "landlord"), eq(users.onboarded_landlord, true)),
|
|
));
|
|
|
|
return result.length > 0;
|
|
}
|
|
|
|
// Get landlord profile
|
|
export async function getLandlordProfile(user_id: string) {
|
|
const result = await db.select()
|
|
.from(users)
|
|
.where(and(
|
|
eq(users.id, user_id),
|
|
or(eq(users.user_type, "landlord"), eq(users.onboarded_landlord, true)),
|
|
));
|
|
|
|
return result[0] || null;
|
|
}
|
|
|
|
// Complete onboarding
|
|
export async function completeLandlordOnboarding(user_id: string) {
|
|
const now = Date.now();
|
|
return await db.update(users)
|
|
.set({
|
|
onboarding_step: 6, // Completed
|
|
updated_on: now,
|
|
})
|
|
.where(eq(users.id, user_id))
|
|
.returning();
|
|
}
|
|
|
|
// Get onboarding progress for multiple landlords (admin function)
|
|
export async function getAllLandlordsOnboardingProgress() {
|
|
const result = await db.select()
|
|
.from(users)
|
|
.where(
|
|
or(eq(users.user_type, "landlord"), eq(users.onboarded_landlord, true)),
|
|
);
|
|
|
|
return result.map((user) => ({
|
|
user_id: user.id,
|
|
name: user.name,
|
|
email: user.email,
|
|
onboarding_step: user.onboarding_step || 1,
|
|
email_verified: user.email_verified || false,
|
|
banking_setup_complete: user.banking_setup_complete || false,
|
|
started_on: user.started_on,
|
|
}));
|
|
}
|