[WIP]: Implement Landlord Approval Mechanism for Tenant Leases (#127)

* Apply patch [skip ci]

* Apply patch [skip ci]

* Apply patch [skip ci]

* Apply patch [skip ci]

* Apply patch [skip ci]

* Apply patch [skip ci]

* Apply patch [skip ci]

* Apply patch [skip ci]

* Apply patch [skip ci]

* Apply patch [skip ci]

* Apply patch [skip ci]

* Apply patch [skip ci]

* Apply patch [skip ci]

* Apply patch [skip ci]

* Apply patch [skip ci]

* Apply patch [skip ci]

* Apply patch [skip ci]

* Apply patch [skip ci]

* Apply patch [skip ci]

---------

Co-authored-by: open-swe-dev-andrinoid[bot] <open-swe-dev-andrinoid@users.noreply.github.com>
This commit is contained in:
open-swe-dev-andrinoid[bot] 2026-01-28 16:48:47 -06:00 committed by GitHub
parent f7f6d6a23a
commit f88da558b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 933 additions and 43 deletions

5
db/interfaces.ts Executable file → Normal file
View File

@ -155,6 +155,11 @@ export interface Lease {
payment_schedule_id?: string | null;
late_fee_rules?: string | null;
auto_renewal?: boolean | null;
approval_status?: string | null;
approval_requested_at?: number | null;
approved_at?: number | null;
approved_by?: string | null;
rejection_reason?: string | null;
encrypted_data?: string | null;
created_on?: number | null;
updated_on?: number | null;

View File

@ -182,6 +182,11 @@ export const leases = pgTable("leases", {
),
late_fee_rules: text("late_fee_rules"), // JSON string of late fee rules
auto_renewal: boolean("auto_renewal").default(false),
approval_status: text("approval_status").default("pending_approval"), // 'pending_approval', 'approved', 'rejected'
approval_requested_at: bigint("approval_requested_at", { mode: "number" }), // Timestamp when tenant requested approval
approved_at: bigint("approved_at", { mode: "number" }), // Timestamp when landlord approved
approved_by: text("approved_by").references(() => users.id), // User ID of landlord who approved
rejection_reason: text("rejection_reason"), // Reason for rejection if rejected
encrypted_data: text("encrypted_data"), // Encrypted lease information
created_on: bigint("created_on", { mode: "number" }),
updated_on: bigint("updated_on", { mode: "number" }),

View File

@ -0,0 +1,34 @@
-- Migration: Add lease approval workflow fields to leases table
ALTER TABLE "leases" ADD COLUMN "approval_status" TEXT DEFAULT 'pending_approval';
--> statement-breakpoint
ALTER TABLE "leases" ADD COLUMN "approval_requested_at" BIGINT;
--> statement-breakpoint
ALTER TABLE "leases" ADD COLUMN "approved_at" BIGINT;
--> statement-breakpoint
ALTER TABLE "leases" ADD COLUMN "approved_by" TEXT;
--> statement-breakpoint
ALTER TABLE "leases" ADD COLUMN "rejection_reason" TEXT;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "leases_approval_status_idx"
ON "leases" ("approval_status");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "leases_approved_by_idx"
ON "leases" ("approved_by");
--> statement-breakpoint
ALTER TABLE "leases" ADD CONSTRAINT "leases_approved_by_users_id_fk"
FOREIGN KEY ("approved_by") REFERENCES "users"("id") ON DELETE SET NULL;

102
routes/api/lease/approve.ts Normal file
View File

@ -0,0 +1,102 @@
// routes/api/lease/approve.ts
import { define } from "../../../utils.ts";
import { page } from "fresh";
import { SessionCookie } from "../../auth/callback.ts";
import { approveLease, getLeaseById } from "../../../services/leaseService.ts";
export const handler = define.handlers({
async POST(ctx) {
try {
const stateData: SessionCookie = ctx.state.data;
const { user_id } = stateData;
if (!user_id) {
return new Response(
JSON.stringify({ error: "Unauthorized" }),
{
status: 401,
headers: { "Content-Type": "application/json" },
},
);
}
const contentType = ctx.req.headers.get("content-type") || "";
let data: Record<string, unknown> = {};
if (contentType.includes("application/json")) {
data = await ctx.req.json();
} else {
const formData = await ctx.req.formData();
for (const [key, value] of formData.entries()) {
data[key] = typeof value === "string" ? value : String(value);
}
}
const lease_id = (data.lease_id as string) ||
(data["lease_id"] as string);
if (!lease_id) {
return new Response(
JSON.stringify({ error: "lease_id is required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
const lease = await getLeaseById(lease_id);
if (!lease) {
return new Response(
JSON.stringify({ error: "Lease not found" }),
{
status: 404,
headers: { "Content-Type": "application/json" },
},
);
}
if (lease.owner_id !== user_id) {
return new Response(
JSON.stringify({ error: "Unauthorized: You do not own this lease" }),
{
status: 403,
headers: { "Content-Type": "application/json" },
},
);
}
const result = await approveLease(lease_id, user_id);
if (!result || result.length === 0) {
return new Response(
JSON.stringify({ error: "Failed to approve lease" }),
{
status: 500,
headers: { "Content-Type": "application/json" },
},
);
}
return new Response(
JSON.stringify({
success: true,
lease: result[0],
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
} catch (error) {
console.error("Error approving lease:", error);
return new Response(
JSON.stringify({ error: String(error) }),
{
status: 500,
headers: { "Content-Type": "application/json" },
},
);
}
},
});

View File

@ -0,0 +1,114 @@
// routes/api/lease/pending-approvals.ts
import { define } from "../../../utils.ts";
import { page } from "fresh";
import { SessionCookie } from "../../auth/callback.ts";
import { getPendingApprovals } from "../../../services/leaseService.ts";
import { properties, users } from "../../../db/schema.ts";
import { and, eq } from "drizzle-orm";
import { db } from "../../../db/db.ts";
export const handler = define.handlers({
async GET(ctx) {
try {
const stateData: SessionCookie = ctx.state.data;
const { user_id } = stateData;
if (!user_id) {
return new Response(
JSON.stringify({ error: "Unauthorized" }),
{
status: 401,
headers: { "Content-Type": "application/json" },
},
);
}
const pendingLeases = await getPendingApprovals(user_id);
if (!pendingLeases || pendingLeases.length === 0) {
return new Response(
JSON.stringify({
success: true,
approvals: [],
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
}
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]),
);
const enrichedApprovals = pendingLeases.map((lease) => ({
...lease,
property: lease.property_id ? propertyMap.get(lease.property_id) : null,
tenant: lease.tenant_id ? tenantMap.get(lease.tenant_id) : null,
}));
return new Response(
JSON.stringify({
success: true,
approvals: enrichedApprovals,
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
} catch (error) {
console.error("Error fetching pending approvals:", error);
return new Response(
JSON.stringify({ error: String(error) }),
{
status: 500,
headers: { "Content-Type": "application/json" },
},
);
}
},
});

104
routes/api/lease/reject.ts Normal file
View File

@ -0,0 +1,104 @@
// routes/api/lease/reject.ts
import { define } from "../../../utils.ts";
import { page } from "fresh";
import { SessionCookie } from "../../auth/callback.ts";
import { rejectLease, getLeaseById } from "../../../services/leaseService.ts";
export const handler = define.handlers({
async POST(ctx) {
try {
const stateData: SessionCookie = ctx.state.data;
const { user_id } = stateData;
if (!user_id) {
return new Response(
JSON.stringify({ error: "Unauthorized" }),
{
status: 401,
headers: { "Content-Type": "application/json" },
},
);
}
const contentType = ctx.req.headers.get("content-type") || "";
let data: Record<string, unknown> = {};
if (contentType.includes("application/json")) {
data = await ctx.req.json();
} else {
const formData = await ctx.req.formData();
for (const [key, value] of formData.entries()) {
data[key] = typeof value === "string" ? value : String(value);
}
}
const lease_id = (data.lease_id as string) ||
(data["lease_id"] as string);
const rejection_reason = (data.rejection_reason as string) ||
(data["rejection_reason"] as string);
if (!lease_id) {
return new Response(
JSON.stringify({ error: "lease_id is required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
const lease = await getLeaseById(lease_id);
if (!lease) {
return new Response(
JSON.stringify({ error: "Lease not found" }),
{
status: 404,
headers: { "Content-Type": "application/json" },
},
);
}
if (lease.owner_id !== user_id) {
return new Response(
JSON.stringify({ error: "Unauthorized: You do not own this lease" }),
{
status: 403,
headers: { "Content-Type": "application/json" },
},
);
}
const result = await rejectLease(lease_id, user_id, rejection_reason);
if (!result || result.length === 0) {
return new Response(
JSON.stringify({ error: "Failed to reject lease" }),
{
status: 500,
headers: { "Content-Type": "application/json" },
},
);
}
return new Response(
JSON.stringify({
success: true,
lease: result[0],
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
} catch (error) {
console.error("Error rejecting lease:", error);
return new Response(
JSON.stringify({ error: String(error) }),
{
status: 500,
headers: { "Content-Type": "application/json" },
},
);
}
},
});

View File

@ -0,0 +1,123 @@
// routes/api/lease/request-approval.ts
import { define } from "../../../utils.ts";
import { page } from "fresh";
import { SessionCookie } from "../../auth/callback.ts";
import {
requestLeaseApproval,
getLeaseById,
} from "../../../services/leaseService.ts";
import {
getTenantInviteById,
updateTenantInvite,
} from "../../../services/tenantInviteService.ts";
import { getPropertyById } from "../../../services/propertyService.ts";
export const handler = define.handlers({
async POST(ctx) {
try {
const stateData: SessionCookie = ctx.state.data;
const { user_id } = stateData;
if (!user_id) {
return new Response(
JSON.stringify({ error: "Unauthorized" }),
{
status: 401,
headers: { "Content-Type": "application/json" },
},
);
}
const contentType = ctx.req.headers.get("content-type") || "";
let data: Record<string, unknown> = {};
if (contentType.includes("application/json")) {
data = await ctx.req.json();
} else {
const formData = await ctx.req.formData();
for (const [key, value] of formData.entries()) {
data[key] = typeof value === "string" ? value : String(value);
}
}
const invite_id = (data.invite_id as string) ||
(data["invite_id"] as string);
const property_id = (data.property_id as string) ||
(data["property_id"] as string);
if (!invite_id || !property_id) {
return new Response(
JSON.stringify({
error: "invite_id and property_id are required",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
const invite = await getTenantInviteById(invite_id);
if (!invite) {
return new Response(
JSON.stringify({ error: "Invite not found" }),
{
status: 404,
headers: { "Content-Type": "application/json" },
},
);
}
const property = await getPropertyById(property_id);
if (!property) {
return new Response(
JSON.stringify({ error: "Property not found" }),
{
status: 404,
headers: { "Content-Type": "application/json" },
},
);
}
const leaseResult = await requestLeaseApproval(
user_id,
property_id,
invite_id,
);
if (!leaseResult || leaseResult.length === 0) {
return new Response(
JSON.stringify({ error: "Failed to create lease request" }),
{
status: 500,
headers: { "Content-Type": "application/json" },
},
);
}
const lease = leaseResult[0];
await updateTenantInvite(invite_id, { lease_id: lease.id });
return new Response(
JSON.stringify({
success: true,
lease,
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
} catch (error) {
console.error("Error requesting lease approval:", error);
return new Response(
JSON.stringify({ error: String(error) }),
{
status: 500,
headers: { "Content-Type": "application/json" },
},
);
}
},
});

View File

@ -1,10 +1,6 @@
import { define } from "../../../utils.ts";
import { page } from "fresh";
import type { SessionCookie } from "../../auth/callback.ts";
import {
createTenantApplication,
hasUserAppliedForProperty,
} from "../../../services/tenantApplicationService.ts";
import {
markRoleOnboarded,
startGuestOnboarding,
@ -16,12 +12,14 @@ import {
removeTenantInviteEmail,
} from "../../../services/tenantInviteService.ts";
import { getUserById } from "../../../services/userService.ts";
import { getLeaseById } from "../../../services/leaseService.ts";
import LoginPurposeButton from "../../../islands/LoginPurposeButton.tsx";
import type {
Property,
PropertyMedia,
TenantInvite,
User,
Lease,
} from "../../../db/interfaces.ts";
type InvitePreviewData = {
@ -32,6 +30,7 @@ type InvitePreviewData = {
inviteeEmail: string;
status?: string;
error?: string;
lease?: Lease | null;
};
function buildPropertyLabel(property: Property | null) {
@ -84,6 +83,7 @@ async function buildInvitePreview(
: null;
const photos = property?.id ? await getPhotosByProperty(property.id) : [];
const inviteeEmail = user?.email?.trim() || "";
const lease = invite?.lease_id ? await getLeaseById(invite.lease_id) : null;
return page({
invite,
@ -92,6 +92,7 @@ async function buildInvitePreview(
user,
inviteeEmail,
status,
lease,
error: options.error,
});
}
@ -130,23 +131,28 @@ export const handler = define.handlers({
}
await startGuestOnboarding(userId, "tenant");
const alreadyApplied = await hasUserAppliedForProperty(
invite.property_id,
userId,
);
if (!alreadyApplied) {
const payload = JSON.stringify({
source: "invite",
const response = await fetch("/api/lease/request-approval", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
invite_id: invite.id,
requested_at: Date.now(),
property_id: invite.property_id,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: "Failed to request lease approval" }));
return await buildInvitePreview(ctx, {
error: errorData.error || "Failed to request lease approval",
});
await createTenantApplication(invite.property_id, userId, payload);
}
await markRoleOnboarded(userId, "tenant");
return new Response("", {
status: 302,
headers: { Location: "/tenant/dashboard" },
headers: { Location: `/tenant/invite/${encodeURIComponent(invite.id)}?status=pending` },
});
}
@ -190,9 +196,16 @@ export default define.page(function TenantInvitePreview({ data }) {
user,
inviteeEmail,
status,
lease,
error,
} = data as InvitePreviewData;
const approvalStatus = lease?.approval_status;
const isPending = approvalStatus === "pending_approval";
const isApproved = approvalStatus === "approved";
const isRejected = approvalStatus === "rejected";
const hasLease = Boolean(lease);
if (!invite || !property) {
return (
<div class="min-h-screen bg-gray-50 flex items-center justify-center px-6">
@ -234,6 +247,20 @@ export default define.page(function TenantInvitePreview({ data }) {
const cancelHref = user?.id ? "/tenant/dashboard" : "/";
const isLoggedIn = Boolean(user?.id);
const approvalSteps = [
{ label: "Request Lease", completed: hasLease },
{ label: "Landlord Review", completed: isApproved },
{ label: "Lease Active", completed: isApproved },
];
const getStepStatus = (stepIndex: number) => {
if (!hasLease) return stepIndex === 0 ? "current" : "pending";
if (isPending) return stepIndex <= 1 ? "current" : "pending";
if (isApproved) return stepIndex <= 2 ? "current" : "completed";
if (isRejected) return stepIndex === 1 ? "error" : "completed";
return "pending";
};
return (
<div class="min-h-screen bg-gray-50">
<div class="max-w-6xl mx-auto px-6 py-10">
@ -403,14 +430,171 @@ export default define.page(function TenantInvitePreview({ data }) {
</dl>
</div>
<div class="bg-white shadow rounded-lg p-6">
<h2 class="text-lg font-semibold text-gray-900">
Approval Status
</h2>
{hasLease && (
<div class="mt-4">
<div class="flex items-center space-x-2">
{approvalSteps.map((step, index) => (
<div
key={index}
class={`flex items-center ${
index < approvalSteps.length - 1 ? "" : "hidden"
}`}
>
<div
class={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold ${
getStepStatus(index) === "current"
? "bg-blue-600 text-white"
: getStepStatus(index) === "completed"
? "bg-emerald-600 text-white"
: getStepStatus(index) === "error"
? "bg-rose-600 text-white"
: "bg-gray-200 text-gray-500"
}`}
>
{index + 1}
</div>
{index < approvalSteps.length - 1 && (
<div
class={`h-1 w-12 ${
getStepStatus(index + 1) === "completed" ||
getStepStatus(index + 1) === "current"
? "bg-emerald-600"
: "bg-gray-200"
}`}
/>
)}
</div>
))}
</div>
<div class="mt-4">
{isPending && (
<div class="rounded-lg bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700">
<div class="flex items-center">
<svg
class="h-5 w-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m0 0l-3 3m3 3V8m0 0l-3 3m3 3V8"
/>
</svg>
<span class="font-semibold">Pending Landlord Approval</span>
</div>
<p class="mt-2 text-amber-600">
Your landlord has been notified and will review your lease request. You will receive an email once a decision is made.
</p>
</div>
)}
{isApproved && (
<div class="rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-3 text-sm text-emerald-700">
<div class="flex items-center">
<svg
class="h-5 w-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
<span class="font-semibold">Lease Approved</span>
</div>
<p class="mt-2 text-emerald-600">
Congratulations! Your lease has been approved. You can now access your tenant portal and view your lease details.
</p>
</div>
)}
{isRejected && (
<div class="rounded-lg bg-rose-50 border border-rose-200 px-4 py-3 text-sm text-rose-700">
<div class="flex items-center">
<svg
class="h-5 w-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<span class="font-semibold">Lease Rejected</span>
</div>
<p class="mt-2 text-rose-600">
Your landlord has declined this lease request. If you have questions or would like to discuss this decision, please contact your landlord directly.
</p>
{lease?.rejection_reason && (
<div class="mt-3 p-3 bg-white rounded border border-rose-200">
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider">
Reason for rejection
</p>
<p class="text-sm text-gray-700 mt-1">
{lease.rejection_reason}
</p>
</div>
)}
</div>
)}
</div>
</div>
)}
{!hasLease && (
<div class="mt-4">
<p class="text-sm text-gray-500">
Click "Start Lease" to request landlord approval for this property.
</p>
</div>
)}
</div>
<div class="bg-white shadow rounded-lg p-6">
<h2 class="text-lg font-semibold text-gray-900">
What happens next
</h2>
<ul class="mt-4 space-y-2 text-sm text-gray-500">
<li>- Start the lease to submit your tenant application.</li>
<li>- Your landlord will review and respond.</li>
<li>- Track your status in the tenant portal.</li>
{!hasLease && (
<>
<li>- Click "Start Lease" to request landlord approval.</li>
<li>- Your landlord will review your request.</li>
<li>- Track your approval status on this page.</li>
</>
)}
{isPending && (
<>
<li>- Your landlord has been notified of your request.</li>
<li>- Wait for landlord to review and approve/reject.</li>
<li>- You will receive an email notification with a decision.</li>
</>
)}
{isApproved && (
<>
<li>- Your lease has been approved and activated.</li>
<li>- Access your tenant portal to view lease details.</li>
<li>- Set up payment methods and start paying rent.</li>
</>
)}
{isRejected && (
<>
<li>- Your lease request was declined.</li>
<li>- Contact your landlord if you have questions.</li>
<li>- You can request a new invite from your landlord.</li>
</>
)}
</ul>
</div>
</aside>
@ -423,33 +607,43 @@ export default define.page(function TenantInvitePreview({ data }) {
Ready to proceed?
</h2>
<p class="text-sm text-gray-500">
Start your lease, decline the invite, or come back later.
{!hasLease && "Start your lease request, decline the invite, or come back later."}
{isPending && "Your request is pending landlord review. Check back here for updates."}
{isApproved && "Your lease is approved! Continue to your tenant portal."}
{isRejected && "Your lease request was declined. Contact your landlord for more information."}
</p>
</div>
<div class="flex flex-wrap gap-3">
{isLoggedIn
? (
<form method="POST">
<input type="hidden" name="intent" value="start" />
<button
type="submit"
class="inline-flex items-center justify-center rounded-full bg-emerald-600 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-700"
>
Start Lease
</button>
</form>
)
: (
<LoginPurposeButton
purpose="tenant"
lockRoute={`/tenant/invite/${
encodeURIComponent(invite.id)
}`}
className="inline-flex items-center justify-center rounded-full bg-emerald-600 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-700"
{!hasLease && isLoggedIn && (
<form method="POST">
<input type="hidden" name="intent" value="start" />
<button
type="submit"
class="inline-flex items-center justify-center rounded-full bg-emerald-600 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-700"
>
Sign In to Start Lease
</LoginPurposeButton>
)}
Start Lease
</button>
</form>
)}
{isApproved && isLoggedIn && (
<a
href="/tenant/dashboard"
class="inline-flex items-center justify-center rounded-full bg-emerald-600 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-700"
>
Continue to Tenant Portal
</a>
)}
{!isLoggedIn && (
<LoginPurposeButton
purpose="tenant"
lockRoute={`/tenant/invite/${
encodeURIComponent(invite.id)
}`}
className="inline-flex items-center justify-center rounded-full bg-emerald-600 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-700"
>
Sign In to Start Lease
</LoginPurposeButton>
)}
<form method="POST" class="flex items-center gap-2">
<input type="hidden" name="intent" value="decline" />

View File

@ -30,6 +30,7 @@ import {
getE2ELandlordDashboardData,
getE2EPaymentMethodsByUser,
getE2EPropertiesByOwner,
getE2EPendingApprovals,
getE2EUserById,
isE2EEnabled,
} from "./e2eFixtures.ts";
@ -210,6 +211,81 @@ export async function getLandlordOnboardingStatus(
};
}
// Get pending lease approvals for a landlord
export async function getPendingLeaseApprovals(
landlord_id: string,
) {
if (isE2EEnabled()) {
return getE2EPendingApprovals(landlord_id);
}
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,

View File

@ -10,6 +10,8 @@ import {
getE2ELeasesByOwner,
getE2ELeasesByTenant,
isE2EEnabled,
getE2EPendingApprovals,
addE2ELeaseWithApproval,
} from "./e2eFixtures.ts";
// export interface Lease {
@ -263,8 +265,8 @@ export async function getLeaseStatsByOwner(owner_id: string) {
/**
* Link a lease to a tenant invite. If an inviteId is provided, update that
* tenant_invites row to reference this lease. Otherwise attempt to find an
* existing invite by the lease's property and owner and attach the lease_id
* there; if none exists, create a new tenant_invites row linking to the lease.
* existing invite by: lease's property and owner and attach lease_id
* there; if none exists, create a new tenant_invites row linking to: lease.
*/
export async function inviteLease(lease_id: string, inviteId?: string) {
// If an explicit inviteId was provided, update that invite record
@ -276,7 +278,7 @@ export async function inviteLease(lease_id: string, inviteId?: string) {
.returning();
}
// Otherwise derive the property and landlord from the lease
// Otherwise derive of property and landlord from lease
const lease = await getLeaseById(lease_id);
if (!lease) return null;
@ -314,3 +316,134 @@ export async function inviteLease(lease_id: string, inviteId?: string) {
updated_on: now,
}).returning();
}
// Request lease approval from tenant
export async function requestLeaseApproval(
tenant_id: string,
property_id: string,
invite_id: string,
) {
if (isE2EEnabled()) {
return [addE2ELeaseWithApproval(tenant_id, property_id, invite_id)];
}
const now = Date.now();
return await db.insert(leases)
.values({
id: crypto.randomUUID(),
title: `Lease for Property ${property_id}`,
property_id,
tenant_id,
owner_id: null,
start_date: now,
end_date: now + 365 * 24 * 60 * 60 * 1000,
rent: null,
security_deposit: null,
lease_status: "draft",
approval_status: "pending_approval",
approval_requested_at: now,
approved_at: null,
approved_by: null,
rejection_reason: null,
lease_document_url: null,
payment_schedule_id: null,
late_fee_rules: null,
auto_renewal: false,
encrypted_data: null,
created_on: now,
updated_on: now,
})
.returning();
}
// Approve a lease request
export async function approveLease(
lease_id: string,
landlord_id: string,
) {
if (isE2EEnabled()) {
const lease = getE2ELeaseById(lease_id);
if (lease && lease.owner_id === landlord_id) {
const now = Date.now();
return [{
...lease,
approval_status: "approved",
approved_at: now,
approved_by: landlord_id,
lease_status: "active",
updated_on: now,
}];
}
return [];
}
const now = Date.now();
return await db.update(leases)
.set({
approval_status: "approved",
approved_at: now,
approved_by: landlord_id,
lease_status: "active",
updated_on: now,
})
.where(
and(
eq(leases.id, lease_id),
eq(leases.owner_id, landlord_id),
),
)
.returning();
}
// Reject a lease request
export async function rejectLease(
lease_id: string,
landlord_id: string,
rejection_reason?: string,
) {
if (isE2EEnabled()) {
const lease = getE2ELeaseById(lease_id);
if (lease && lease.owner_id === landlord_id) {
const now = Date.now();
return [{
...lease,
approval_status: "rejected",
rejection_reason: rejection_reason || null,
updated_on: now,
}];
}
return [];
}
const now = Date.now();
return await db.update(leases)
.set({
approval_status: "rejected",
rejection_reason: rejection_reason || null,
updated_on: now,
})
.where(
and(
eq(leases.id, lease_id),
eq(leases.owner_id, landlord_id),
),
)
.returning();
}
// Get pending lease approvals for a landlord
export async function getPendingApprovals(landlord_id: string) {
if (isE2EEnabled()) {
return getE2EPendingApprovals(landlord_id);
}
return await db.select()
.from(leases)
.where(
and(
eq(leases.owner_id, landlord_id),
eq(leases.approval_status, "pending_approval"),
),
)
.orderBy(leases.approval_requested_at);
}