[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;
|
payment_schedule_id?: string | null;
|
||||||
late_fee_rules?: string | null;
|
late_fee_rules?: string | null;
|
||||||
auto_renewal?: boolean | 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;
|
encrypted_data?: string | null;
|
||||||
created_on?: number | null;
|
created_on?: number | null;
|
||||||
updated_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
|
late_fee_rules: text("late_fee_rules"), // JSON string of late fee rules
|
||||||
auto_renewal: boolean("auto_renewal").default(false),
|
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
|
encrypted_data: text("encrypted_data"), // Encrypted lease information
|
||||||
created_on: bigint("created_on", { mode: "number" }),
|
created_on: bigint("created_on", { mode: "number" }),
|
||||||
updated_on: bigint("updated_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 { define } from "../../../utils.ts";
|
||||||
import { page } from "fresh";
|
import { page } from "fresh";
|
||||||
import type { SessionCookie } from "../../auth/callback.ts";
|
import type { SessionCookie } from "../../auth/callback.ts";
|
||||||
import {
|
|
||||||
createTenantApplication,
|
|
||||||
hasUserAppliedForProperty,
|
|
||||||
} from "../../../services/tenantApplicationService.ts";
|
|
||||||
import {
|
import {
|
||||||
markRoleOnboarded,
|
markRoleOnboarded,
|
||||||
startGuestOnboarding,
|
startGuestOnboarding,
|
||||||
@ -16,12 +12,14 @@ import {
|
|||||||
removeTenantInviteEmail,
|
removeTenantInviteEmail,
|
||||||
} from "../../../services/tenantInviteService.ts";
|
} from "../../../services/tenantInviteService.ts";
|
||||||
import { getUserById } from "../../../services/userService.ts";
|
import { getUserById } from "../../../services/userService.ts";
|
||||||
|
import { getLeaseById } from "../../../services/leaseService.ts";
|
||||||
import LoginPurposeButton from "../../../islands/LoginPurposeButton.tsx";
|
import LoginPurposeButton from "../../../islands/LoginPurposeButton.tsx";
|
||||||
import type {
|
import type {
|
||||||
Property,
|
Property,
|
||||||
PropertyMedia,
|
PropertyMedia,
|
||||||
TenantInvite,
|
TenantInvite,
|
||||||
User,
|
User,
|
||||||
|
Lease,
|
||||||
} from "../../../db/interfaces.ts";
|
} from "../../../db/interfaces.ts";
|
||||||
|
|
||||||
type InvitePreviewData = {
|
type InvitePreviewData = {
|
||||||
@ -32,6 +30,7 @@ type InvitePreviewData = {
|
|||||||
inviteeEmail: string;
|
inviteeEmail: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
lease?: Lease | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildPropertyLabel(property: Property | null) {
|
function buildPropertyLabel(property: Property | null) {
|
||||||
@ -84,6 +83,7 @@ async function buildInvitePreview(
|
|||||||
: null;
|
: null;
|
||||||
const photos = property?.id ? await getPhotosByProperty(property.id) : [];
|
const photos = property?.id ? await getPhotosByProperty(property.id) : [];
|
||||||
const inviteeEmail = user?.email?.trim() || "";
|
const inviteeEmail = user?.email?.trim() || "";
|
||||||
|
const lease = invite?.lease_id ? await getLeaseById(invite.lease_id) : null;
|
||||||
|
|
||||||
return page({
|
return page({
|
||||||
invite,
|
invite,
|
||||||
@ -92,6 +92,7 @@ async function buildInvitePreview(
|
|||||||
user,
|
user,
|
||||||
inviteeEmail,
|
inviteeEmail,
|
||||||
status,
|
status,
|
||||||
|
lease,
|
||||||
error: options.error,
|
error: options.error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -130,23 +131,28 @@ export const handler = define.handlers({
|
|||||||
}
|
}
|
||||||
|
|
||||||
await startGuestOnboarding(userId, "tenant");
|
await startGuestOnboarding(userId, "tenant");
|
||||||
const alreadyApplied = await hasUserAppliedForProperty(
|
|
||||||
invite.property_id,
|
const response = await fetch("/api/lease/request-approval", {
|
||||||
userId,
|
method: "POST",
|
||||||
);
|
headers: { "Content-Type": "application/json" },
|
||||||
if (!alreadyApplied) {
|
body: JSON.stringify({
|
||||||
const payload = JSON.stringify({
|
|
||||||
source: "invite",
|
|
||||||
invite_id: invite.id,
|
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");
|
await markRoleOnboarded(userId, "tenant");
|
||||||
|
|
||||||
return new Response("", {
|
return new Response("", {
|
||||||
status: 302,
|
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,
|
user,
|
||||||
inviteeEmail,
|
inviteeEmail,
|
||||||
status,
|
status,
|
||||||
|
lease,
|
||||||
error,
|
error,
|
||||||
} = data as InvitePreviewData;
|
} = 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) {
|
if (!invite || !property) {
|
||||||
return (
|
return (
|
||||||
<div class="min-h-screen bg-gray-50 flex items-center justify-center px-6">
|
<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 cancelHref = user?.id ? "/tenant/dashboard" : "/";
|
||||||
const isLoggedIn = Boolean(user?.id);
|
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 (
|
return (
|
||||||
<div class="min-h-screen bg-gray-50">
|
<div class="min-h-screen bg-gray-50">
|
||||||
<div class="max-w-6xl mx-auto px-6 py-10">
|
<div class="max-w-6xl mx-auto px-6 py-10">
|
||||||
@ -403,14 +430,171 @@ export default define.page(function TenantInvitePreview({ data }) {
|
|||||||
</dl>
|
</dl>
|
||||||
</div>
|
</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">
|
<div class="bg-white shadow rounded-lg p-6">
|
||||||
<h2 class="text-lg font-semibold text-gray-900">
|
<h2 class="text-lg font-semibold text-gray-900">
|
||||||
What happens next
|
What happens next
|
||||||
</h2>
|
</h2>
|
||||||
<ul class="mt-4 space-y-2 text-sm text-gray-500">
|
<ul class="mt-4 space-y-2 text-sm text-gray-500">
|
||||||
<li>- Start the lease to submit your tenant application.</li>
|
{!hasLease && (
|
||||||
<li>- Your landlord will review and respond.</li>
|
<>
|
||||||
<li>- Track your status in the tenant portal.</li>
|
<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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@ -423,12 +607,14 @@ export default define.page(function TenantInvitePreview({ data }) {
|
|||||||
Ready to proceed?
|
Ready to proceed?
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-sm text-gray-500">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
{isLoggedIn
|
{!hasLease && isLoggedIn && (
|
||||||
? (
|
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
<input type="hidden" name="intent" value="start" />
|
<input type="hidden" name="intent" value="start" />
|
||||||
<button
|
<button
|
||||||
@ -438,8 +624,16 @@ export default define.page(function TenantInvitePreview({ data }) {
|
|||||||
Start Lease
|
Start Lease
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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
|
<LoginPurposeButton
|
||||||
purpose="tenant"
|
purpose="tenant"
|
||||||
lockRoute={`/tenant/invite/${
|
lockRoute={`/tenant/invite/${
|
||||||
|
|||||||
@ -30,6 +30,7 @@ import {
|
|||||||
getE2ELandlordDashboardData,
|
getE2ELandlordDashboardData,
|
||||||
getE2EPaymentMethodsByUser,
|
getE2EPaymentMethodsByUser,
|
||||||
getE2EPropertiesByOwner,
|
getE2EPropertiesByOwner,
|
||||||
|
getE2EPendingApprovals,
|
||||||
getE2EUserById,
|
getE2EUserById,
|
||||||
isE2EEnabled,
|
isE2EEnabled,
|
||||||
} from "./e2eFixtures.ts";
|
} 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
|
// Get landlord dashboard data
|
||||||
export async function getLandlordDashboardData(
|
export async function getLandlordDashboardData(
|
||||||
user_id: string,
|
user_id: string,
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import {
|
|||||||
getE2ELeasesByOwner,
|
getE2ELeasesByOwner,
|
||||||
getE2ELeasesByTenant,
|
getE2ELeasesByTenant,
|
||||||
isE2EEnabled,
|
isE2EEnabled,
|
||||||
|
getE2EPendingApprovals,
|
||||||
|
addE2ELeaseWithApproval,
|
||||||
} from "./e2eFixtures.ts";
|
} from "./e2eFixtures.ts";
|
||||||
|
|
||||||
// export interface Lease {
|
// 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
|
* 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
|
* 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
|
* existing invite by: lease's property and owner and attach lease_id
|
||||||
* there; if none exists, create a new tenant_invites row linking to the lease.
|
* there; if none exists, create a new tenant_invites row linking to: lease.
|
||||||
*/
|
*/
|
||||||
export async function inviteLease(lease_id: string, inviteId?: string) {
|
export async function inviteLease(lease_id: string, inviteId?: string) {
|
||||||
// If an explicit inviteId was provided, update that invite record
|
// If an explicit inviteId was provided, update that invite record
|
||||||
@ -276,7 +278,7 @@ export async function inviteLease(lease_id: string, inviteId?: string) {
|
|||||||
.returning();
|
.returning();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise derive the property and landlord from the lease
|
// Otherwise derive of property and landlord from lease
|
||||||
const lease = await getLeaseById(lease_id);
|
const lease = await getLeaseById(lease_id);
|
||||||
if (!lease) return null;
|
if (!lease) return null;
|
||||||
|
|
||||||
@ -314,3 +316,134 @@ export async function inviteLease(lease_id: string, inviteId?: string) {
|
|||||||
updated_on: now,
|
updated_on: now,
|
||||||
}).returning();
|
}).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