[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:
parent
f7f6d6a23a
commit
f88da558b1
5
db/interfaces.ts
Executable file → Normal file
5
db/interfaces.ts
Executable file → Normal 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;
|
||||
|
||||
@ -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" }),
|
||||
|
||||
34
drizzle/0008_add_lease_approval_fields.sql
Normal file
34
drizzle/0008_add_lease_approval_fields.sql
Normal 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
102
routes/api/lease/approve.ts
Normal 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" },
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
114
routes/api/lease/pending-approvals.ts
Normal file
114
routes/api/lease/pending-approvals.ts
Normal 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
104
routes/api/lease/reject.ts
Normal 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" },
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
123
routes/api/lease/request-approval.ts
Normal file
123
routes/api/lease/request-approval.ts
Normal 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" },
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
@ -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" />
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user