Consolidate audit-fixes branch: webhooks, integrations, and deploy hardening

Batch commit of the pending working tree on security/audit-fixes-2026-07.
Major areas:
- Outbound webhooks / Zapier: schema + signed delivery with retries, public
  v1 API (REST-hook subscribe/unsubscribe), settings UI, cron drain.
- Deploy hardening: email via SMTP2GO (Resend fully removed), verified DB TLS
  (DATABASE_SSL=require + DATABASE_CA), storage fails loud in production when
  Spaces is unconfigured instead of silently using ephemeral disk.
- Integrations & features (concurrent work): accounting (QuickBooks/Xero),
  e-signature (DocuSign/Dropbox Sign), PayPal, geocoding/maps, onboarding,
  expanded legal pages.
- DB migrations 0006–0009.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-07-02 13:42:34 -04:00
co-authored by Claude Opus 4.8
parent 969d5d4c8a
commit c9968531e4
282 changed files with 41530 additions and 4013 deletions
+4 -1
View File
@@ -3,11 +3,14 @@ import { desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { activity_log } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId } from "@/lib/account"
export async function GET(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id)
const { searchParams } = new URL(request.url)
const limit = Math.min(100, parseInt(searchParams.get("limit") ?? "50", 10))
@@ -15,7 +18,7 @@ export async function GET(request: Request) {
const data = await db
.select()
.from(activity_log)
.where(eq(activity_log.user_id, user.id))
.where(eq(activity_log.user_id, ownerId))
.orderBy(desc(activity_log.created_at))
.limit(limit)
+12 -9
View File
@@ -12,6 +12,7 @@ import {
expenses,
} from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId } from "@/lib/account"
import { openai } from "@/lib/ai/client"
import { enforceAiQuota } from "@/lib/ai/usage"
import { dataBlock } from "@/lib/ai/prompts"
@@ -23,8 +24,10 @@ export async function POST(request: Request) {
const quota = await enforceAiQuota(user.id, "ai_ask")
if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status })
const ownerId = await getEffectiveOwnerId(user.id)
const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, user.id),
where: eq(profiles.id, ownerId),
columns: { full_name: true },
})
@@ -45,7 +48,7 @@ export async function POST(request: Request) {
total_units: properties.total_units,
})
.from(properties)
.where(eq(properties.user_id, user.id)),
.where(eq(properties.user_id, ownerId)),
db
.select({
id: units.id,
@@ -56,7 +59,7 @@ export async function POST(request: Request) {
status: units.status,
})
.from(units)
.where(eq(units.user_id, user.id)),
.where(eq(units.user_id, ownerId)),
db
.select({
id: tenants.id,
@@ -69,7 +72,7 @@ export async function POST(request: Request) {
move_in_date: tenants.move_in_date,
})
.from(tenants)
.where(and(eq(tenants.user_id, user.id), eq(tenants.status, "active"))),
.where(and(eq(tenants.user_id, ownerId), eq(tenants.status, "active"))),
db
.select({
amount: rent_payments.amount,
@@ -79,7 +82,7 @@ export async function POST(request: Request) {
property_id: rent_payments.property_id,
})
.from(rent_payments)
.where(and(eq(rent_payments.user_id, user.id), gte(rent_payments.due_date, threeMonthsAgo))),
.where(and(eq(rent_payments.user_id, ownerId), gte(rent_payments.due_date, threeMonthsAgo))),
db
.select({
id: maintenance_requests.id,
@@ -91,7 +94,7 @@ export async function POST(request: Request) {
created_at: maintenance_requests.created_at,
})
.from(maintenance_requests)
.where(and(eq(maintenance_requests.user_id, user.id), inArray(maintenance_requests.status, ["open", "in_progress"]))),
.where(and(eq(maintenance_requests.user_id, ownerId), inArray(maintenance_requests.status, ["open", "in_progress"]))),
db
.select({
id: leases.id,
@@ -102,7 +105,7 @@ export async function POST(request: Request) {
status: leases.status,
})
.from(leases)
.where(and(eq(leases.user_id, user.id), eq(leases.status, "active"))),
.where(and(eq(leases.user_id, ownerId), eq(leases.status, "active"))),
db
.select({
amount: expenses.amount,
@@ -112,7 +115,7 @@ export async function POST(request: Request) {
property_id: expenses.property_id,
})
.from(expenses)
.where(and(eq(expenses.user_id, user.id), gte(expenses.expense_date, threeMonthsAgo))),
.where(and(eq(expenses.user_id, ownerId), gte(expenses.expense_date, threeMonthsAgo))),
])
// Build summary stats
@@ -162,5 +165,5 @@ Answer the landlord's question in a helpful, concise, and professional manner. U
const answer = completion.choices[0].message.content ?? ""
return NextResponse.json({ answer })
return NextResponse.json({ answer, usage: { used: quota.used, limit: quota.limit } })
}
+4 -1
View File
@@ -3,15 +3,18 @@ import { eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { ai_recommendations } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId } from "@/lib/account"
export async function GET() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id)
const all = await db
.select()
.from(ai_recommendations)
.where(eq(ai_recommendations.user_id, user.id))
.where(eq(ai_recommendations.user_id, ownerId))
const approved = all.filter((r) => r.status === "approved")
const dismissed = all.filter((r) => r.status === "dismissed")
+5 -2
View File
@@ -3,6 +3,7 @@ import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { properties, maintenance_requests } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId } from "@/lib/account"
import { openai } from "@/lib/ai/client"
import { MAINTENANCE_SUMMARY_PROMPT, dataBlock } from "@/lib/ai/prompts"
import { enforceAiQuota } from "@/lib/ai/usage"
@@ -14,6 +15,8 @@ export async function POST(request: Request) {
const quota = await enforceAiQuota(user.id, "ai_maintenance_summary")
if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status })
const ownerId = await getEffectiveOwnerId(user.id)
const { property_id } = await request.json() as { property_id: string }
const requests = await db
@@ -29,10 +32,10 @@ export async function POST(request: Request) {
resolved_at: maintenance_requests.resolved_at,
})
.from(maintenance_requests)
.where(and(eq(maintenance_requests.user_id, user.id), eq(maintenance_requests.property_id, property_id)))
.where(and(eq(maintenance_requests.user_id, ownerId), eq(maintenance_requests.property_id, property_id)))
const property = await db.query.properties.findFirst({
where: and(eq(properties.id, property_id), eq(properties.user_id, user.id)),
where: and(eq(properties.id, property_id), eq(properties.user_id, ownerId)),
columns: { name: true },
})
+18 -11
View File
@@ -12,6 +12,7 @@ import {
expenses,
} from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
import { openai } from "@/lib/ai/client"
import { logActivity } from "@/lib/activity"
import { enforceAiQuota } from "@/lib/ai/usage"
@@ -21,10 +22,12 @@ export async function GET() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id)
const data = await db
.select()
.from(ai_predictions)
.where(eq(ai_predictions.user_id, user.id))
.where(eq(ai_predictions.user_id, ownerId))
.orderBy(desc(ai_predictions.created_at))
.limit(30)
@@ -38,13 +41,17 @@ export async function POST() {
const quota = await enforceAiQuota(user.id, "ai_predictions")
if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status })
const ctx = await getAccountContext(user.id)
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const ownerId = ctx.ownerId
const now = new Date()
const sixMonthsAgo = new Date(now)
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6)
const sixMonthsAgoDate = sixMonthsAgo.toISOString().slice(0, 10)
const [propertiesData, unitsData, tenantsData, payments, maintenance, leasesData, expensesData] = await Promise.all([
db.select({ id: properties.id, name: properties.name }).from(properties).where(eq(properties.user_id, user.id)),
db.select({ id: properties.id, name: properties.name }).from(properties).where(eq(properties.user_id, ownerId)),
db
.select({
id: units.id,
@@ -54,7 +61,7 @@ export async function POST() {
status: units.status,
})
.from(units)
.where(eq(units.user_id, user.id)),
.where(eq(units.user_id, ownerId)),
db
.select({
id: tenants.id,
@@ -64,7 +71,7 @@ export async function POST() {
property_id: tenants.property_id,
})
.from(tenants)
.where(and(eq(tenants.user_id, user.id), eq(tenants.status, "active"))),
.where(and(eq(tenants.user_id, ownerId), eq(tenants.status, "active"))),
db
.select({
amount: rent_payments.amount,
@@ -73,7 +80,7 @@ export async function POST() {
property_id: rent_payments.property_id,
})
.from(rent_payments)
.where(and(eq(rent_payments.user_id, user.id), gte(rent_payments.due_date, sixMonthsAgoDate)))
.where(and(eq(rent_payments.user_id, ownerId), gte(rent_payments.due_date, sixMonthsAgoDate)))
.orderBy(rent_payments.due_date),
db
.select({
@@ -84,7 +91,7 @@ export async function POST() {
property_id: maintenance_requests.property_id,
})
.from(maintenance_requests)
.where(eq(maintenance_requests.user_id, user.id)),
.where(eq(maintenance_requests.user_id, ownerId)),
db
.select({
tenant_id: leases.tenant_id,
@@ -94,7 +101,7 @@ export async function POST() {
status: leases.status,
})
.from(leases)
.where(eq(leases.user_id, user.id)),
.where(eq(leases.user_id, ownerId)),
db
.select({
amount: expenses.amount,
@@ -103,7 +110,7 @@ export async function POST() {
property_id: expenses.property_id,
})
.from(expenses)
.where(and(eq(expenses.user_id, user.id), gte(expenses.expense_date, sixMonthsAgoDate))),
.where(and(eq(expenses.user_id, ownerId), gte(expenses.expense_date, sixMonthsAgoDate))),
])
// Build monthly revenue trend
@@ -183,10 +190,10 @@ Only return valid JSON, no other text.`
}
// Replace old predictions
await db.delete(ai_predictions).where(eq(ai_predictions.user_id, user.id))
await db.delete(ai_predictions).where(eq(ai_predictions.user_id, ownerId))
const toInsert = predictions.map((p: any) => ({
user_id: user.id,
user_id: ownerId,
type: p.type ?? "growth_opportunity",
title: p.title,
prediction: p.prediction,
@@ -199,7 +206,7 @@ Only return valid JSON, no other text.`
const inserted = toInsert.length > 0 ? await db.insert(ai_predictions).values(toInsert).returning() : []
await logActivity({
userId: user.id,
userId: ownerId,
type: "ai_action",
title: `AI generated ${inserted.length} predictions and risk alerts`,
entityType: "ai_predictions",
+7 -2
View File
@@ -3,12 +3,17 @@ import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { ai_recommendations } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account"
import { logActivity } from "@/lib/activity"
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const ownerId = ctx.ownerId
const { id } = await params
const { status } = await request.json() as { status: "approved" | "dismissed" }
@@ -23,13 +28,13 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
const [data] = await db
.update(ai_recommendations)
.set(updateData)
.where(and(eq(ai_recommendations.id, id), eq(ai_recommendations.user_id, user.id)))
.where(and(eq(ai_recommendations.id, id), eq(ai_recommendations.user_id, ownerId)))
.returning()
if (!data) return NextResponse.json({ error: "Not found" }, { status: 404 })
await logActivity({
userId: user.id,
userId: ownerId,
type: "ai_action",
title: status === "approved"
? `AI recommendation approved: ${data.title}`
+18 -11
View File
@@ -12,6 +12,7 @@ import {
expenses,
} from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
import { openai } from "@/lib/ai/client"
import { logActivity } from "@/lib/activity"
import { enforceAiQuota } from "@/lib/ai/usage"
@@ -21,10 +22,12 @@ export async function GET() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id)
const data = await db
.select()
.from(ai_recommendations)
.where(eq(ai_recommendations.user_id, user.id))
.where(eq(ai_recommendations.user_id, ownerId))
.orderBy(desc(ai_recommendations.created_at))
return NextResponse.json(data)
@@ -37,6 +40,10 @@ export async function POST() {
const quota = await enforceAiQuota(user.id, "ai_recommendations")
if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status })
const ctx = await getAccountContext(user.id)
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const ownerId = ctx.ownerId
// Fetch portfolio data
const now = new Date()
const threeMonthsAgo = new Date(now)
@@ -47,7 +54,7 @@ export async function POST() {
db
.select({ id: properties.id, name: properties.name, address_line1: properties.address_line1, city: properties.city })
.from(properties)
.where(eq(properties.user_id, user.id)),
.where(eq(properties.user_id, ownerId)),
db
.select({
id: units.id,
@@ -57,7 +64,7 @@ export async function POST() {
status: units.status,
})
.from(units)
.where(eq(units.user_id, user.id)),
.where(eq(units.user_id, ownerId)),
db
.select({
id: tenants.id,
@@ -69,7 +76,7 @@ export async function POST() {
move_in_date: tenants.move_in_date,
})
.from(tenants)
.where(and(eq(tenants.user_id, user.id), eq(tenants.status, "active"))),
.where(and(eq(tenants.user_id, ownerId), eq(tenants.status, "active"))),
db
.select({
id: rent_payments.id,
@@ -80,7 +87,7 @@ export async function POST() {
property_id: rent_payments.property_id,
})
.from(rent_payments)
.where(and(eq(rent_payments.user_id, user.id), gte(rent_payments.due_date, threeMonthsAgoDate))),
.where(and(eq(rent_payments.user_id, ownerId), gte(rent_payments.due_date, threeMonthsAgoDate))),
db
.select({
id: maintenance_requests.id,
@@ -91,7 +98,7 @@ export async function POST() {
created_at: maintenance_requests.created_at,
})
.from(maintenance_requests)
.where(and(eq(maintenance_requests.user_id, user.id), inArray(maintenance_requests.status, ["open", "in_progress"]))),
.where(and(eq(maintenance_requests.user_id, ownerId), inArray(maintenance_requests.status, ["open", "in_progress"]))),
db
.select({
id: leases.id,
@@ -102,7 +109,7 @@ export async function POST() {
status: leases.status,
})
.from(leases)
.where(and(eq(leases.user_id, user.id), eq(leases.status, "active"))),
.where(and(eq(leases.user_id, ownerId), eq(leases.status, "active"))),
db
.select({
amount: expenses.amount,
@@ -111,7 +118,7 @@ export async function POST() {
expense_date: expenses.expense_date,
})
.from(expenses)
.where(and(eq(expenses.user_id, user.id), gte(expenses.expense_date, threeMonthsAgoDate))),
.where(and(eq(expenses.user_id, ownerId), gte(expenses.expense_date, threeMonthsAgoDate))),
])
const totalRevenue = payments.filter((p) => p.status === "paid").reduce((s, p) => s + Number(p.amount), 0)
@@ -175,10 +182,10 @@ Only return valid JSON, no other text.`
// Delete old pending recommendations and insert new ones
await db
.delete(ai_recommendations)
.where(and(eq(ai_recommendations.user_id, user.id), eq(ai_recommendations.status, "pending")))
.where(and(eq(ai_recommendations.user_id, ownerId), eq(ai_recommendations.status, "pending")))
const toInsert = recommendations.map((r: any) => ({
user_id: user.id,
user_id: ownerId,
type: r.type ?? "opportunity",
title: r.title,
description: r.description,
@@ -192,7 +199,7 @@ Only return valid JSON, no other text.`
const inserted = toInsert.length > 0 ? await db.insert(ai_recommendations).values(toInsert).returning() : []
await logActivity({
userId: user.id,
userId: ownerId,
type: "ai_action",
title: `AI generated ${inserted.length} new recommendations`,
entityType: "ai_recommendations",
+121
View File
@@ -0,0 +1,121 @@
import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { profiles, rent_payments, leases, inspections } from "@/lib/db/schema"
// Public, token-authenticated iCal (ICS) subscription feed. A landlord subscribes
// to /api/calendar/<calendar_token>.ics in Google/Apple/Outlook and their rent
// due dates, lease expiries, and inspections appear (read-only, auto-refreshing).
export const dynamic = "force-dynamic"
const PRODID = "-//Property Management Network//Calendar//EN"
function icsDate(d: string): string {
return d.slice(0, 10).replace(/-/g, "")
}
function icsDatePlusOne(d: string): string {
const dt = new Date(d.slice(0, 10) + "T00:00:00Z")
dt.setUTCDate(dt.getUTCDate() + 1)
return dt.toISOString().slice(0, 10).replace(/-/g, "")
}
function esc(s: unknown): string {
return String(s ?? "").replace(/[\\;,]/g, (m) => "\\" + m).replace(/\r?\n/g, "\\n")
}
// Fold lines to 75 octets per RFC 5545.
function fold(line: string): string {
if (line.length <= 75) return line
const parts: string[] = []
let rest = line
parts.push(rest.slice(0, 75))
rest = rest.slice(75)
while (rest.length > 74) {
parts.push(" " + rest.slice(0, 74))
rest = rest.slice(74)
}
if (rest.length) parts.push(" " + rest)
return parts.join("\r\n")
}
export async function GET(_req: Request, { params }: { params: Promise<{ token: string }> }) {
const { token: raw } = await params
const token = raw.replace(/\.ics$/i, "")
if (!token) return new Response("Not found", { status: 404 })
const profile = await db.query.profiles.findFirst({
where: eq(profiles.calendar_token, token),
columns: { id: true },
})
if (!profile) return new Response("Not found", { status: 404 })
const ownerId = profile.id
const [payments, leaseList, inspList] = await Promise.all([
db.query.rent_payments.findMany({
where: eq(rent_payments.user_id, ownerId),
columns: { id: true, due_date: true, amount: true, status: true },
with: { tenant: { columns: { first_name: true, last_name: true } }, property: { columns: { name: true } }, unit: { columns: { unit_number: true } } },
}),
db.query.leases.findMany({
where: and(eq(leases.user_id, ownerId), eq(leases.status, "active")),
columns: { id: true, lease_end: true },
with: { tenant: { columns: { first_name: true, last_name: true } }, property: { columns: { name: true } } },
}),
db.query.inspections.findMany({
where: eq(inspections.user_id, ownerId),
columns: { id: true, date: true, type: true, status: true },
with: { property: { columns: { name: true } }, unit: { columns: { unit_number: true } } },
}),
])
const stamp = new Date().toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"
const out: string[] = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
`PRODID:${PRODID}`,
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"X-WR-CALNAME:Property Management Network",
"X-WR-TIMEZONE:UTC",
"REFRESH-INTERVAL;VALUE=DURATION:PT6H",
"X-PUBLISHED-TTL:PT6H",
]
const addEvent = (uid: string, date: string, summary: string, description: string) => {
out.push(
"BEGIN:VEVENT",
fold(`UID:${uid}@propertymanagement.network`),
`DTSTAMP:${stamp}`,
`DTSTART;VALUE=DATE:${icsDate(date)}`,
`DTEND;VALUE=DATE:${icsDatePlusOne(date)}`,
fold(`SUMMARY:${esc(summary)}`),
fold(`DESCRIPTION:${esc(description)}`),
"TRANSP:TRANSPARENT",
"END:VEVENT"
)
}
for (const p of payments) {
if (!p.due_date) continue
const who = `${p.tenant?.first_name ?? ""} ${p.tenant?.last_name ?? ""}`.trim() || "Tenant"
const amt = `$${Number(p.amount).toLocaleString("en-US")}`
addEvent(`rent-${p.id}`, p.due_date, `Rent due — ${who} (${amt})`, `${p.status.toUpperCase()} · ${p.property?.name ?? ""}${p.unit ? ` Unit ${p.unit.unit_number}` : ""}`)
}
for (const l of leaseList) {
if (!l.lease_end) continue
const who = `${l.tenant?.first_name ?? ""} ${l.tenant?.last_name ?? ""}`.trim() || "Tenant"
addEvent(`lease-${l.id}`, l.lease_end, `Lease ends — ${who}`, `${l.property?.name ?? ""}`)
}
for (const ins of inspList) {
if (!ins.date) continue
const type = ins.type.replace("_", "-")
addEvent(`insp-${ins.id}`, ins.date, `${type} inspection`, `${ins.property?.name ?? ""}${ins.unit ? ` Unit ${ins.unit.unit_number}` : ""} · ${ins.status}`)
}
out.push("END:VCALENDAR")
return new Response(out.join("\r\n") + "\r\n", {
headers: {
"Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": 'inline; filename="property-management-network.ics"',
"Cache-Control": "public, max-age=3600",
},
})
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { formatCurrency, formatDate, daysUntil } from "@/lib/utils"
import { isAuthorizedCron } from "@/lib/cron-auth"
// Combined daily cron: rent reminders + overdue marking + lease expiry emails
// Runs daily at 9am (see vercel.json)
// Runs daily at 9am UTC (scheduled via DigitalOcean Functions — see DIGITALOCEAN.md)
export async function GET(request: Request) {
if (!isAuthorizedCron(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from "next/server"
import { eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { follow_up_rules } from "@/lib/db/schema"
import { isAuthorizedCron } from "@/lib/cron-auth"
import { runFollowUpsForUser } from "@/lib/follow-ups"
// Automated follow-ups cron: runs every user's active follow-up rules.
// Runs daily at 10:00 UTC (see functions/project.yml → follow-ups trigger).
export async function GET(request: Request) {
if (!isAuthorizedCron(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
// Distinct user ids that have at least one active follow-up rule.
const rows = await db
.selectDistinct({ user_id: follow_up_rules.user_id })
.from(follow_up_rules)
.where(eq(follow_up_rules.is_active, true))
let processed = 0
let total = 0
for (const { user_id } of rows) {
try {
const result = await runFollowUpsForUser(user_id)
total += result.sent
processed++
} catch (err) {
console.error(`follow-ups cron failed for user ${user_id}:`, err)
}
}
return NextResponse.json({ processed, sent: total })
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { db } from "@/lib/db"
import { rent_payments, expenses } from "@/lib/db/schema"
import { isAuthorizedCron } from "@/lib/cron-auth"
// Vercel Cron: runs daily at 8am (see vercel.json)
// Scheduled task: runs daily at 8am UTC (scheduled via DigitalOcean Functions — see DIGITALOCEAN.md)
export async function GET(request: Request) {
if (!isAuthorizedCron(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
-67
View File
@@ -1,67 +0,0 @@
import { NextResponse } from "next/server"
import { and, eq, lte } from "drizzle-orm"
import { db } from "@/lib/db"
import { leases } from "@/lib/db/schema"
import { sendEmail, leaseExpiryHtml } from "@/lib/email/send"
import { formatDate, daysUntil } from "@/lib/utils"
import { isAuthorizedCron } from "@/lib/cron-auth"
export async function GET(request: Request) {
if (!isAuthorizedCron(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const checkpoints = [
{ days: 60, field: "reminder_60_sent" as const },
{ days: 30, field: "reminder_30_sent" as const },
{ days: 7, field: "reminder_7_sent" as const },
]
let sent = 0
for (const { days, field } of checkpoints) {
const target = new Date()
target.setDate(target.getDate() + days)
const targetStr = target.toISOString().slice(0, 10)
const expiringLeases = await db.query.leases.findMany({
where: and(
eq(leases.status, "active"),
eq(leases[field], false),
lte(leases.lease_end, targetStr)
),
with: {
tenant: { columns: { first_name: true, last_name: true, email: true } },
property: { columns: { name: true } },
unit: { columns: { unit_number: true } },
},
})
for (const lease of expiringLeases) {
if (!lease.tenant?.email) continue
const daysLeft = daysUntil(lease.lease_end)
await sendEmail({
to: lease.tenant.email,
subject: `Your lease expires in ${daysLeft} days — ${lease.property?.name}`,
html: leaseExpiryHtml({
tenantName: `${lease.tenant.first_name} ${lease.tenant.last_name}`,
propertyName: lease.property?.name ?? "",
unitNumber: lease.unit?.unit_number ?? "—",
leaseEnd: formatDate(lease.lease_end),
daysLeft,
}),
})
await db
.update(leases)
.set({ [field]: true })
.where(eq(leases.id, lease.id))
sent++
}
}
return NextResponse.json({ reminders_sent: sent })
}
-79
View File
@@ -1,79 +0,0 @@
import { NextResponse } from "next/server"
import { and, eq, lt } from "drizzle-orm"
import { db } from "@/lib/db"
import { rent_payments } from "@/lib/db/schema"
import { sendEmail, rentDueReminderHtml, rentOverdueHtml } from "@/lib/email/send"
import { formatCurrency, formatDate } from "@/lib/utils"
import { isAuthorizedCron } from "@/lib/cron-auth"
// Called by Vercel Cron — runs daily
export async function GET(request: Request) {
if (!isAuthorizedCron(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const today = new Date().toISOString().slice(0, 10)
const in3Days = new Date()
in3Days.setDate(in3Days.getDate() + 3)
const in3DaysStr = in3Days.toISOString().slice(0, 10)
// Payments due in 3 days → send reminder
const upcoming = await db.query.rent_payments.findMany({
where: and(eq(rent_payments.status, "pending"), eq(rent_payments.due_date, in3DaysStr)),
with: {
tenant: { columns: { first_name: true, last_name: true, email: true } },
property: { columns: { name: true } },
unit: { columns: { unit_number: true } },
},
})
for (const payment of upcoming) {
if (!payment.tenant?.email) continue
await sendEmail({
to: payment.tenant.email,
subject: `Rent Due in 3 Days — ${payment.property?.name}`,
html: rentDueReminderHtml({
tenantName: `${payment.tenant.first_name} ${payment.tenant.last_name}`,
propertyName: payment.property?.name ?? "",
unitNumber: payment.unit?.unit_number ?? "—",
amount: formatCurrency(payment.amount),
dueDate: formatDate(payment.due_date),
}),
})
}
// Payments past due date → mark overdue + send notice
const pastDue = await db.query.rent_payments.findMany({
where: and(eq(rent_payments.status, "pending"), lt(rent_payments.due_date, today)),
with: {
tenant: { columns: { first_name: true, last_name: true, email: true } },
property: { columns: { name: true } },
unit: { columns: { unit_number: true } },
},
})
for (const payment of pastDue) {
await db
.update(rent_payments)
.set({ status: "overdue" })
.where(eq(rent_payments.id, payment.id))
if (!payment.tenant?.email) continue
await sendEmail({
to: payment.tenant.email,
subject: `Rent Overdue — ${payment.property?.name}`,
html: rentOverdueHtml({
tenantName: `${payment.tenant.first_name} ${payment.tenant.last_name}`,
propertyName: payment.property?.name ?? "",
unitNumber: payment.unit?.unit_number ?? "—",
amount: formatCurrency(payment.amount),
dueDate: formatDate(payment.due_date),
}),
})
}
return NextResponse.json({
reminders_sent: upcoming.length,
marked_overdue: pastDue.length,
})
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server"
import { isAuthorizedCron } from "@/lib/cron-auth"
import { processDueDeliveries } from "@/lib/webhooks/deliver"
// Webhook delivery retry drain. The emitter attempts an immediate delivery when
// an event fires; this cron re-attempts anything still pending whose backoff
// window has elapsed (and covers deliveries orphaned by a process restart).
// Scheduled every 5 minutes via DigitalOcean Functions — see functions/project.yml.
export async function GET(request: Request) {
if (!isAuthorizedCron(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { processed, delivered } = await processDueDeliveries(200)
return NextResponse.json({ processed, delivered })
}
+10 -3
View File
@@ -4,14 +4,17 @@ import { db } from "@/lib/db"
import { documents } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { deleteFile } from "@/lib/storage"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
export async function GET(_: Request, { params }: { params: Promise<{ id: string }> }) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id)
const { id } = await params
const doc = await db.query.documents.findFirst({
where: and(eq(documents.id, id), eq(documents.user_id, user.id)),
where: and(eq(documents.id, id), eq(documents.user_id, ownerId)),
})
if (!doc) return NextResponse.json({ error: "Not found" }, { status: 404 })
@@ -25,16 +28,20 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const { id } = await params
const doc = await db.query.documents.findFirst({
where: and(eq(documents.id, id), eq(documents.user_id, user.id)),
where: and(eq(documents.id, id), eq(documents.user_id, ownerId)),
columns: { storage_path: true },
})
if (!doc) return NextResponse.json({ error: "Not found" }, { status: 404 })
await db.delete(documents).where(and(eq(documents.id, id), eq(documents.user_id, user.id)))
await db.delete(documents).where(and(eq(documents.id, id), eq(documents.user_id, ownerId)))
if (doc.storage_path) {
await deleteFile(doc.storage_path)
+34 -9
View File
@@ -3,20 +3,24 @@ import { and, desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { documents, properties } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { saveFile } from "@/lib/storage"
import { saveFile, isAllowedUploadExt, StorageNotConfiguredError } from "@/lib/storage"
import { checkStorageLimit } from "@/lib/plan-limits"
import { ownsProperty, ownsTenant } from "@/lib/db/ownership"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
export async function GET(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id)
const { searchParams } = new URL(request.url)
const propertyId = searchParams.get("property_id")
let propertyName = ""
if (propertyId) {
const prop = await db.query.properties.findFirst({
where: and(eq(properties.id, propertyId), eq(properties.user_id, user.id)),
where: and(eq(properties.id, propertyId), eq(properties.user_id, ownerId)),
columns: { name: true },
})
propertyName = prop?.name ?? ""
@@ -24,7 +28,7 @@ export async function GET(request: Request) {
const data = await db.query.documents.findMany({
where: and(
eq(documents.user_id, user.id),
eq(documents.user_id, ownerId),
propertyId ? eq(documents.property_id, propertyId) : undefined
),
orderBy: desc(documents.created_at),
@@ -37,6 +41,10 @@ export async function POST(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const contentType = request.headers.get("content-type") ?? ""
if (contentType.includes("multipart/form-data")) {
@@ -48,20 +56,37 @@ export async function POST(request: Request) {
if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 })
if (file.size > 20 * 1024 * 1024) return NextResponse.json({ error: "File too large (max 20 MB)" }, { status: 400 })
if (!isAllowedUploadExt(file.name)) return NextResponse.json({ error: "File type not allowed" }, { status: 400 })
const storageError = await checkStorageLimit(ownerId, file.size)
if (storageError) return NextResponse.json({ error: storageError }, { status: 403 })
// Verify the property belongs to the user before attaching a document to it.
const prop = await db.query.properties.findFirst({
where: and(eq(properties.id, propertyId), eq(properties.user_id, user.id)),
where: and(eq(properties.id, propertyId), eq(properties.user_id, ownerId)),
columns: { id: true },
})
if (!prop) return NextResponse.json({ error: "Property not found" }, { status: 404 })
const { key, size, type } = await saveFile(file, { userId: user.id, scope: "documents" })
let saved
try {
saved = await saveFile(file, { userId: ownerId, scope: "documents" })
} catch (err) {
if (err instanceof StorageNotConfiguredError) {
console.error("[documents]", err.message)
return NextResponse.json(
{ error: "File uploads are temporarily unavailable. Please try again later." },
{ status: 503 }
)
}
throw err
}
const { key, size, type } = saved
const [data] = await db
.insert(documents)
.values({
user_id: user.id,
user_id: ownerId,
property_id: propertyId,
name: name || file.name,
category,
@@ -81,10 +106,10 @@ export async function POST(request: Request) {
const tenantId = body.tenant_id as string | undefined
// Verify the property/tenant belong to the user before attaching a document.
if (!(await ownsProperty(user.id, propertyId))) {
if (!(await ownsProperty(ownerId, propertyId))) {
return NextResponse.json({ error: "Property not found" }, { status: 404 })
}
if (!(await ownsTenant(user.id, tenantId))) {
if (!(await ownsTenant(ownerId, tenantId))) {
return NextResponse.json({ error: "Tenant not found" }, { status: 404 })
}
@@ -92,7 +117,7 @@ export async function POST(request: Request) {
const [data] = await db
.insert(documents)
.values({
user_id: user.id,
user_id: ownerId,
property_id: propertyId as string,
tenant_id: tenantId,
name: body.name as string,
+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server"
import { handleEsignWebhook, getAdapter } from "@/lib/esign"
// Inbound e-signature status webhook (DocuSign Connect / Dropbox Sign callback).
export async function POST(request: Request, { params }: { params: Promise<{ provider: string }> }) {
const { provider } = await params
if (!getAdapter(provider)) return NextResponse.json({ error: "unknown provider" }, { status: 404 })
const body = await request.text()
try {
await handleEsignWebhook(provider, body, request.headers)
} catch {
// never fail the webhook — providers retry on non-2xx
}
// Dropbox Sign requires this exact response body to validate the callback URL.
if (provider === "dropbox_sign") return new Response("Hello API Event Received", { status: 200 })
return NextResponse.json({ ok: true })
}
+13 -4
View File
@@ -5,19 +5,24 @@ import { expenses } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { expenseSchema } from "@/lib/validations"
import { ownsProperty, ownsUnit } from "@/lib/db/ownership"
import { getAccountContext } from "@/lib/account"
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const { id } = await params
const body = await request.json()
const parsed = expenseSchema.partial().safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
if (
!(await ownsProperty(user.id, parsed.data.property_id)) ||
!(await ownsUnit(user.id, parsed.data.unit_id))
!(await ownsProperty(ownerId, parsed.data.property_id)) ||
!(await ownsUnit(ownerId, parsed.data.unit_id))
) {
return NextResponse.json({ error: "Invalid reference" }, { status: 403 })
}
@@ -25,7 +30,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
const [data] = await db
.update(expenses)
.set(parsed.data)
.where(and(eq(expenses.id, id), eq(expenses.user_id, user.id)))
.where(and(eq(expenses.id, id), eq(expenses.user_id, ownerId)))
.returning()
return NextResponse.json(data)
@@ -35,7 +40,11 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const { id } = await params
await db.delete(expenses).where(and(eq(expenses.id, id), eq(expenses.user_id, user.id)))
await db.delete(expenses).where(and(eq(expenses.id, id), eq(expenses.user_id, ownerId)))
return NextResponse.json({ success: true })
}
+4 -1
View File
@@ -4,17 +4,20 @@ import { db } from "@/lib/db"
import { expenses } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { toCsv } from "@/lib/db/admin-queries"
import { getEffectiveOwnerId } from "@/lib/account"
export async function GET(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id)
const { searchParams } = new URL(request.url)
const propertyId = searchParams.get("property_id")
const data = await db.query.expenses.findMany({
where: and(
eq(expenses.user_id, user.id),
eq(expenses.user_id, ownerId),
propertyId ? eq(expenses.property_id, propertyId) : undefined
),
with: {
+11 -4
View File
@@ -5,18 +5,21 @@ import { expenses } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { expenseSchema } from "@/lib/validations"
import { ownsProperty, ownsUnit } from "@/lib/db/ownership"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
export async function GET(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id)
const { searchParams } = new URL(request.url)
const propertyId = searchParams.get("property_id")
const category = searchParams.get("category")
const data = await db.query.expenses.findMany({
where: and(
eq(expenses.user_id, user.id),
eq(expenses.user_id, ownerId),
propertyId ? eq(expenses.property_id, propertyId) : undefined,
category ? eq(expenses.category, category as typeof expenses.$inferSelect.category) : undefined
),
@@ -34,20 +37,24 @@ export async function POST(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const body = await request.json()
const parsed = expenseSchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
if (
!(await ownsProperty(user.id, parsed.data.property_id)) ||
!(await ownsUnit(user.id, parsed.data.unit_id))
!(await ownsProperty(ownerId, parsed.data.property_id)) ||
!(await ownsUnit(ownerId, parsed.data.unit_id))
) {
return NextResponse.json({ error: "Invalid reference" }, { status: 403 })
}
const [data] = await db
.insert(expenses)
.values({ ...parsed.data, user_id: user.id })
.values({ ...parsed.data, user_id: ownerId })
.returning()
return NextResponse.json(data, { status: 201 })
+4 -1
View File
@@ -4,13 +4,16 @@ import { db } from "@/lib/db"
import { rent_payments } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { toCsv } from "@/lib/db/admin-queries"
import { getEffectiveOwnerId } from "@/lib/account"
export async function GET() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id)
const rows = await db.query.rent_payments.findMany({
where: eq(rent_payments.user_id, user.id),
where: eq(rent_payments.user_id, ownerId),
with: {
tenant: { columns: { first_name: true, last_name: true, email: true } },
property: { columns: { name: true } },
+4 -1
View File
@@ -4,13 +4,16 @@ import { db } from "@/lib/db"
import { tenants } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { toCsv } from "@/lib/db/admin-queries"
import { getEffectiveOwnerId } from "@/lib/account"
export async function GET() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id)
const rows = await db.query.tenants.findMany({
where: eq(tenants.user_id, user.id),
where: eq(tenants.user_id, ownerId),
with: {
property: { columns: { name: true } },
unit: { columns: { unit_number: true } },
+33 -11
View File
@@ -1,38 +1,60 @@
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/session"
import { readFile, contentTypeForKey } from "@/lib/storage"
import { getEffectiveOwnerId } from "@/lib/account"
import { readFile, contentTypeForKey, usingSpaces, presignGetUrl } from "@/lib/storage"
// Only these image types are safe to render inline from our origin. Everything
// else (including svg, html, documents) is forced to download as an attachment.
const INLINE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp"]
// Auth-gated file serving. Storage keys are namespaced by user id
// (`<userId>/<scope>/<file>`), so a file belongs to the requester iff the key's
// first segment equals their session user id.
// Auth-gated file serving. Storage keys are namespaced by the portfolio's owner
// id (`<ownerId>/<scope>/<file>`), so a file belongs to the requester iff the
// key's first segment equals their EFFECTIVE owner id — this lets active team
// members view the owner's files while preserving isolation between accounts.
//
// When object storage (Spaces) is configured we issue a short-lived presigned
// redirect so the bytes stream straight from the bucket to the browser instead
// of through the app. The auth + ownership checks below still gate every request
// (the presigned URL is only minted for the rightful owner and expires quickly).
export async function GET(_: Request, { params }: { params: Promise<{ key: string[] }> }) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id)
const { key: segments } = await params
// Reject traversal / malformed segments (no double-decode — params are already decoded).
const badSegment = segments.some(
(s) => s === "" || s === "." || s === ".." || s.includes("/") || s.includes("\\")
)
// Ownership: the first path segment must be EXACTLY the caller's user id.
if (badSegment || segments[0] !== user.id) {
// Ownership: the first path segment must be EXACTLY the caller's effective owner id.
if (badSegment || segments[0] !== ownerId) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const key = segments.join("/")
const ext = key.split(".").pop()?.toLowerCase() ?? ""
const disposition = INLINE_EXTENSIONS.includes(ext) ? "inline" : "attachment"
const basename = (key.split("/").pop() ?? "file").replace(/["\\\r\n]/g, "")
// Object storage: redirect to a presigned URL (bytes served by Spaces).
if (usingSpaces()) {
try {
const url = await presignGetUrl(key, { disposition, filename: basename, expiresIn: 3600 })
const res = NextResponse.redirect(url, 302)
// Let the browser reuse the redirect for a while (< the URL's TTL) so
// repeat views skip the app hop entirely, without outliving the signature.
res.headers.set("Cache-Control", "private, max-age=1800")
return res
} catch {
return NextResponse.json({ error: "Not found" }, { status: 404 })
}
}
// Local-disk fallback: stream the bytes through the app.
try {
const buffer = await readFile(key)
const ext = key.split(".").pop()?.toLowerCase() ?? ""
const disposition = INLINE_EXTENSIONS.includes(ext) ? "inline" : "attachment"
const basename = (key.split("/").pop() ?? "file").replace(/["\\\r\n\x00-\x1f]/g, "")
return new NextResponse(new Uint8Array(buffer), {
headers: {
"Content-Type": contentTypeForKey(key),
+11 -2
View File
@@ -3,11 +3,16 @@ import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { follow_up_rules } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account"
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const { id } = await params
const body = await request.json()
@@ -20,7 +25,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
const [data] = await db
.update(follow_up_rules)
.set(update)
.where(and(eq(follow_up_rules.id, id), eq(follow_up_rules.user_id, user.id)))
.where(and(eq(follow_up_rules.id, id), eq(follow_up_rules.user_id, ownerId)))
.returning()
if (!data) return NextResponse.json({ error: "Not found" }, { status: 404 })
@@ -32,9 +37,13 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const { id } = await params
await db
.delete(follow_up_rules)
.where(and(eq(follow_up_rules.id, id), eq(follow_up_rules.user_id, user.id)))
.where(and(eq(follow_up_rules.id, id), eq(follow_up_rules.user_id, ownerId)))
return NextResponse.json({ ok: true })
}
+10 -3
View File
@@ -4,21 +4,24 @@ import { db } from "@/lib/db"
import { follow_up_rules, follow_up_log } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { followUpRuleSchema } from "@/lib/validations"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
export async function GET() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id)
const [rules, logs] = await Promise.all([
db
.select()
.from(follow_up_rules)
.where(eq(follow_up_rules.user_id, user.id))
.where(eq(follow_up_rules.user_id, ownerId))
.orderBy(follow_up_rules.created_at),
db
.select()
.from(follow_up_log)
.where(eq(follow_up_log.user_id, user.id))
.where(eq(follow_up_log.user_id, ownerId))
.orderBy(desc(follow_up_log.created_at))
.limit(30),
])
@@ -30,6 +33,10 @@ export async function POST(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const body = await request.json()
const parsed = followUpRuleSchema.safeParse(body)
if (!parsed.success) {
@@ -40,7 +47,7 @@ export async function POST(request: Request) {
const [data] = await db
.insert(follow_up_rules)
.values({ user_id: user.id, type, name, trigger_days, message_template })
.values({ user_id: ownerId, type, name, trigger_days, message_template })
.returning()
return NextResponse.json(data, { status: 201 })
+8 -188
View File
@@ -1,198 +1,18 @@
import { NextResponse } from "next/server"
import { and, eq, gte, lte } from "drizzle-orm"
import { db } from "@/lib/db"
import {
follow_up_rules,
follow_up_log,
rent_payments,
maintenance_requests,
leases,
units,
} from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { logActivity } from "@/lib/activity"
import { sendEmail, escapeHtml } from "@/lib/email/send"
import { getEffectiveOwnerId } from "@/lib/account"
import { runFollowUpsForUser } from "@/lib/follow-ups"
export async function POST() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const rules = await db
.select()
.from(follow_up_rules)
.where(and(eq(follow_up_rules.user_id, user.id), eq(follow_up_rules.is_active, true)))
const ownerId = await getEffectiveOwnerId(user.id)
if (!rules.length) return NextResponse.json({ sent: 0, results: [] })
const result = await runFollowUpsForUser(ownerId)
const now = new Date()
const followUpsToLog: any[] = []
for (const rule of rules) {
const cutoff = new Date(now)
cutoff.setDate(cutoff.getDate() - rule.trigger_days)
if (rule.type === "overdue_rent") {
const overdue = await db.query.rent_payments.findMany({
where: and(
eq(rent_payments.user_id, user.id),
eq(rent_payments.status, "overdue"),
lte(rent_payments.due_date, cutoff.toISOString().slice(0, 10))
),
columns: { id: true, amount: true, due_date: true },
with: {
tenant: { columns: { first_name: true, last_name: true, email: true } },
},
})
for (const payment of overdue) {
const tenant = payment.tenant
if (!tenant?.email) continue
const daysOverdue = Math.ceil((now.getTime() - new Date(payment.due_date).getTime()) / (1000 * 60 * 60 * 24))
followUpsToLog.push({
user_id: user.id,
rule_id: rule.id,
type: "overdue_rent",
recipient_name: `${tenant.first_name} ${tenant.last_name}`,
recipient_email: tenant.email,
subject: `Rent Payment Reminder — ${daysOverdue} Days Overdue`,
message: rule.message_template
?? `Dear ${tenant.first_name}, your rent payment of $${Number(payment.amount).toLocaleString()} was due on ${payment.due_date} and is now ${daysOverdue} days overdue. Please make your payment as soon as possible to avoid further action.`,
status: "sent",
})
}
}
if (rule.type === "maintenance_stale") {
const stale = await db.query.maintenance_requests.findMany({
where: and(
eq(maintenance_requests.user_id, user.id),
eq(maintenance_requests.status, "open"),
lte(maintenance_requests.created_at, cutoff.toISOString())
),
columns: { id: true, title: true, priority: true, created_at: true },
with: {
tenant: { columns: { first_name: true, last_name: true, email: true } },
},
})
for (const req of stale) {
const tenant = req.tenant
const daysOpen = Math.ceil((now.getTime() - new Date(req.created_at).getTime()) / (1000 * 60 * 60 * 24))
followUpsToLog.push({
user_id: user.id,
rule_id: rule.id,
type: "maintenance_stale",
recipient_name: tenant ? `${tenant.first_name} ${tenant.last_name}` : "N/A",
recipient_email: tenant?.email ?? null,
subject: `Maintenance Update: ${req.title}`,
message: rule.message_template
?? `Your maintenance request "${req.title}" has been open for ${daysOpen} days. We are working on resolving this as soon as possible and will update you shortly.`,
status: "sent",
})
}
}
if (rule.type === "lease_renewal") {
const renewalDate = new Date(now)
renewalDate.setDate(renewalDate.getDate() + rule.trigger_days)
const expiring = await db.query.leases.findMany({
where: and(
eq(leases.user_id, user.id),
eq(leases.status, "active"),
lte(leases.lease_end, renewalDate.toISOString().slice(0, 10)),
gte(leases.lease_end, now.toISOString().slice(0, 10))
),
columns: { id: true, lease_end: true, rent_amount: true },
with: {
tenant: { columns: { first_name: true, last_name: true, email: true } },
},
})
for (const lease of expiring) {
const tenant = lease.tenant
if (!tenant?.email) continue
const daysLeft = Math.ceil((new Date(lease.lease_end).getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
followUpsToLog.push({
user_id: user.id,
rule_id: rule.id,
type: "lease_renewal",
recipient_name: `${tenant.first_name} ${tenant.last_name}`,
recipient_email: tenant.email,
subject: `Lease Renewal Notice — Expires in ${daysLeft} Days`,
message: rule.message_template
?? `Dear ${tenant.first_name}, your lease expires on ${lease.lease_end} (${daysLeft} days from now). Please contact us to discuss renewal options and ensure continuity of your tenancy.`,
status: "sent",
})
}
}
if (rule.type === "vacant_unit") {
const vacant = await db.query.units.findMany({
where: and(eq(units.user_id, user.id), eq(units.status, "vacant")),
columns: { id: true, unit_number: true, rent_amount: true },
with: {
property: { columns: { name: true } },
},
})
for (const unit of vacant) {
const property = unit.property
followUpsToLog.push({
user_id: user.id,
rule_id: rule.id,
type: "vacant_unit",
recipient_name: "You",
recipient_email: null,
subject: `Vacant Unit Alert: ${property?.name ?? ""} — Unit ${unit.unit_number}`,
message: rule.message_template
?? `Unit ${unit.unit_number} at ${property?.name ?? "your property"} has been vacant. Consider reviewing your listing or adjusting the rent of $${Number(unit.rent_amount).toLocaleString()}/month to attract tenants faster.`,
status: "sent",
})
}
}
// Update last_run_at
await db
.update(follow_up_rules)
.set({ last_run_at: now.toISOString() })
.where(and(eq(follow_up_rules.id, rule.id), eq(follow_up_rules.user_id, user.id)))
}
// Send actual emails for all follow-ups that have a recipient
for (const log of followUpsToLog) {
if (log.recipient_email) {
try {
await sendEmail({
to: log.recipient_email,
subject: log.subject,
html: `<!DOCTYPE html>
<html>
<body style="font-family:sans-serif;background:#09090b;color:#fff;padding:40px 20px;max-width:560px;margin:0 auto;">
<div style="background:#16161f;border:1px solid rgba(255,255,255,0.08);border-radius:12px;padding:32px;">
<p style="font-size:15px;line-height:1.6;color:rgba(255,255,255,0.8);margin:0 0 24px;">${escapeHtml(log.message).replace(/\n/g, "<br/>")}</p>
<p style="color:rgba(255,255,255,0.3);font-size:11px;margin:24px 0 0;border-top:1px solid rgba(255,255,255,0.06);padding-top:16px;">
Property Management Network — Automated Follow-up System
</p>
</div>
</body>
</html>`
})
} catch {
log.status = "failed"
}
}
}
if (followUpsToLog.length > 0) {
await db.insert(follow_up_log).values(followUpsToLog)
}
await logActivity({
userId: user.id,
type: "ai_action",
title: `Follow-ups processed: ${followUpsToLog.length} action${followUpsToLog.length !== 1 ? "s" : ""} triggered`,
})
return NextResponse.json({ sent: followUpsToLog.length, results: followUpsToLog })
// Preserve the original response shape ({ sent, results }). The detailed
// per-follow-up rows now live only in follow_up_log; the client re-fetches
// rules for last_run_at and tolerates an empty results array.
return NextResponse.json({ sent: result.sent, results: [] })
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"
// Lightweight liveness probe used by the Docker HEALTHCHECK and Coolify.
// Lightweight liveness probe used by the Docker HEALTHCHECK and App Platform.
// Intentionally does NOT touch the database — a transient DB blip should not
// cause the container to be marked unhealthy and restarted.
export const dynamic = "force-dynamic"
+20 -4
View File
@@ -3,14 +3,17 @@ import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { inspections } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
export async function GET(_: Request, { params }: { params: Promise<{ id: string }> }) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id)
const { id } = await params
const data = await db.query.inspections.findFirst({
where: and(eq(inspections.id, id), eq(inspections.user_id, user.id)),
where: and(eq(inspections.id, id), eq(inspections.user_id, ownerId)),
with: {
property: { columns: { name: true } },
unit: { columns: { unit_number: true } },
@@ -25,11 +28,20 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const { id } = await params
const body = await request.json()
const allowed: Partial<typeof inspections.$inferInsert> = {}
if (body.status !== undefined) allowed.status = body.status
if (body.status !== undefined) {
if (body.status !== "draft" && body.status !== "complete") {
return NextResponse.json({ error: "Invalid status" }, { status: 400 })
}
allowed.status = body.status
}
if (body.notes !== undefined) allowed.notes = body.notes
if (body.items !== undefined) allowed.items = body.items
if (body.date !== undefined) allowed.date = body.date
@@ -38,7 +50,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
const [data] = await db
.update(inspections)
.set(allowed)
.where(and(eq(inspections.id, id), eq(inspections.user_id, user.id)))
.where(and(eq(inspections.id, id), eq(inspections.user_id, ownerId)))
.returning()
return NextResponse.json(data)
@@ -51,9 +63,13 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const { id } = await params
try {
await db.delete(inspections).where(and(eq(inspections.id, id), eq(inspections.user_id, user.id)))
await db.delete(inspections).where(and(eq(inspections.id, id), eq(inspections.user_id, ownerId)))
return NextResponse.json({ success: true })
} catch (e) {
return NextResponse.json({ error: (e as Error).message }, { status: 500 })
+11 -4
View File
@@ -5,6 +5,7 @@ import { inspections } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { inspectionSchema } from "@/lib/validations"
import { ownsProperty, ownsUnit } from "@/lib/db/ownership"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
const DEFAULT_ITEMS = [
"Walls & Ceilings", "Floors", "Windows & Blinds", "Doors & Locks",
@@ -16,9 +17,11 @@ export async function GET() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id)
try {
const data = await db.query.inspections.findMany({
where: eq(inspections.user_id, user.id),
where: eq(inspections.user_id, ownerId),
with: {
property: { columns: { name: true } },
unit: { columns: { unit_number: true } },
@@ -36,6 +39,10 @@ export async function POST(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const body = await request.json()
const parsed = inspectionSchema.safeParse(body)
if (!parsed.success) {
@@ -43,8 +50,8 @@ export async function POST(request: Request) {
}
if (
!(await ownsProperty(user.id, parsed.data.property_id)) ||
!(await ownsUnit(user.id, parsed.data.unit_id))
!(await ownsProperty(ownerId, parsed.data.property_id)) ||
!(await ownsUnit(ownerId, parsed.data.unit_id))
) {
return NextResponse.json({ error: "Invalid reference" }, { status: 403 })
}
@@ -55,7 +62,7 @@ export async function POST(request: Request) {
const [data] = await db
.insert(inspections)
.values({
user_id: user.id,
user_id: ownerId,
property_id: parsed.data.property_id,
unit_id: parsed.data.unit_id || null,
type: parsed.data.type,
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server"
import { getProvider, saveConnection, type Provider } from "@/lib/accounting"
import { verifyState } from "@/lib/accounting/state"
// OAuth callback — exchanges the code for tokens and stores the connection.
export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) {
const { provider: pid } = await params
const prov = getProvider(pid)
const url = new URL(request.url)
const settings = new URL("/settings/integrations", request.url)
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const realmId = url.searchParams.get("realmId") // QuickBooks includes this
const oauthError = url.searchParams.get("error")
if (oauthError || !prov) {
settings.searchParams.set("error", "connect_failed")
return NextResponse.redirect(settings)
}
const st = state ? verifyState(state) : null
if (!code || !st || st.provider !== pid) {
settings.searchParams.set("error", "invalid_state")
return NextResponse.redirect(settings)
}
try {
const tokens = await prov.exchangeCode(code, realmId)
if (!tokens.realmId) throw new Error("No organisation returned from provider")
await saveConnection(st.ownerId, pid as Provider, tokens)
settings.searchParams.set("connected", pid)
} catch {
settings.searchParams.set("error", "connect_failed")
}
return NextResponse.redirect(settings)
}
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account"
import { getProvider } from "@/lib/accounting"
import { signState } from "@/lib/accounting/state"
// Starts the OAuth connect flow for an accounting provider (owner-only).
export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) {
const { provider: pid } = await params
const prov = getProvider(pid)
const settings = new URL("/settings/integrations", request.url)
if (!prov) {
settings.searchParams.set("error", "unknown_provider")
return NextResponse.redirect(settings)
}
const user = await getSessionUser()
if (!user) return NextResponse.redirect(new URL("/login", request.url))
const ctx = await getAccountContext(user.id)
if (!ctx.isOwner) {
settings.searchParams.set("error", "owner_only")
return NextResponse.redirect(settings)
}
if (!prov.configured()) {
settings.searchParams.set("error", "not_configured")
return NextResponse.redirect(settings)
}
const state = signState({ ownerId: ctx.ownerId, provider: pid })
return NextResponse.redirect(prov.getAuthUrl(state))
}
+14 -5
View File
@@ -5,20 +5,25 @@ import { leases } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { leaseSchema } from "@/lib/validations"
import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership"
import { getAccountContext } from "@/lib/account"
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const { id } = await params
const body = await request.json()
const parsed = leaseSchema.partial().safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
if (
!(await ownsProperty(user.id, parsed.data.property_id)) ||
!(await ownsUnit(user.id, parsed.data.unit_id)) ||
!(await ownsTenant(user.id, parsed.data.tenant_id))
!(await ownsProperty(ownerId, parsed.data.property_id)) ||
!(await ownsUnit(ownerId, parsed.data.unit_id)) ||
!(await ownsTenant(ownerId, parsed.data.tenant_id))
) {
return NextResponse.json({ error: "Invalid reference" }, { status: 403 })
}
@@ -26,7 +31,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
const [data] = await db
.update(leases)
.set(parsed.data)
.where(and(eq(leases.id, id), eq(leases.user_id, user.id)))
.where(and(eq(leases.id, id), eq(leases.user_id, ownerId)))
.returning()
return NextResponse.json(data)
@@ -36,7 +41,11 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const { id } = await params
await db.delete(leases).where(and(eq(leases.id, id), eq(leases.user_id, user.id)))
await db.delete(leases).where(and(eq(leases.id, id), eq(leases.user_id, ownerId)))
return NextResponse.json({ success: true })
}
+15 -5
View File
@@ -5,18 +5,22 @@ import { leases } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { leaseSchema } from "@/lib/validations"
import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
import { emitWebhookEvent } from "@/lib/webhooks/emit"
export async function GET(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id)
const { searchParams } = new URL(request.url)
const status = searchParams.get("status")
const tenantId = searchParams.get("tenant_id")
const data = await db.query.leases.findMany({
where: and(
eq(leases.user_id, user.id),
eq(leases.user_id, ownerId),
status ? eq(leases.status, status as typeof leases.$inferSelect.status) : undefined,
tenantId ? eq(leases.tenant_id, tenantId) : undefined
),
@@ -35,22 +39,28 @@ export async function POST(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const body = await request.json()
const parsed = leaseSchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
if (
!(await ownsProperty(user.id, parsed.data.property_id)) ||
!(await ownsUnit(user.id, parsed.data.unit_id)) ||
!(await ownsTenant(user.id, parsed.data.tenant_id))
!(await ownsProperty(ownerId, parsed.data.property_id)) ||
!(await ownsUnit(ownerId, parsed.data.unit_id)) ||
!(await ownsTenant(ownerId, parsed.data.tenant_id))
) {
return NextResponse.json({ error: "Invalid reference" }, { status: 403 })
}
const [data] = await db
.insert(leases)
.values({ ...parsed.data, user_id: user.id })
.values({ ...parsed.data, user_id: ownerId })
.returning()
await emitWebhookEvent({ ownerId, event: "lease.created", data: { lease: data } })
return NextResponse.json(data, { status: 201 })
}
+55 -7
View File
@@ -1,10 +1,12 @@
import { NextResponse } from "next/server"
import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { maintenance_requests } from "@/lib/db/schema"
import { maintenance_requests, notifications, profiles } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { maintenanceSchema } from "@/lib/validations"
import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
import { emitWebhookEvent } from "@/lib/webhooks/emit"
import { z } from "zod"
const maintenancePatchSchema = maintenanceSchema.partial().extend({
@@ -18,9 +20,11 @@ export async function GET(_: Request, { params }: { params: Promise<{ id: string
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id)
const { id } = await params
const data = await db.query.maintenance_requests.findFirst({
where: and(eq(maintenance_requests.id, id), eq(maintenance_requests.user_id, user.id)),
where: and(eq(maintenance_requests.id, id), eq(maintenance_requests.user_id, ownerId)),
with: {
property: { columns: { name: true, address_line1: true, city: true } },
unit: { columns: { unit_number: true } },
@@ -36,19 +40,29 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const { id } = await params
const body = await request.json()
const parsed = maintenancePatchSchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
if (
!(await ownsProperty(user.id, parsed.data.property_id)) ||
!(await ownsUnit(user.id, parsed.data.unit_id)) ||
!(await ownsTenant(user.id, parsed.data.tenant_id))
!(await ownsProperty(ownerId, parsed.data.property_id)) ||
!(await ownsUnit(ownerId, parsed.data.unit_id)) ||
!(await ownsTenant(ownerId, parsed.data.tenant_id))
) {
return NextResponse.json({ error: "Invalid reference" }, { status: 403 })
}
// Read the current row so we can tell whether the status actually changed.
const existing = await db.query.maintenance_requests.findFirst({
where: and(eq(maintenance_requests.id, id), eq(maintenance_requests.user_id, ownerId)),
columns: { status: true, title: true },
})
const updateData: Record<string, unknown> = { ...parsed.data }
// Auto-set resolved_at when status → resolved
@@ -59,10 +73,40 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
const [data] = await db
.update(maintenance_requests)
.set(updateData)
.where(and(eq(maintenance_requests.id, id), eq(maintenance_requests.user_id, user.id)))
.where(and(eq(maintenance_requests.id, id), eq(maintenance_requests.user_id, ownerId)))
.returning()
if (!data) return NextResponse.json({ error: "Not found" }, { status: 404 })
// If the status actually changed, drop a dashboard notification row for the
// owner and fire the maintenance.updated webhook.
if (parsed.data.status && existing && parsed.data.status !== existing.status) {
const owner = await db.query.profiles.findFirst({
where: eq(profiles.id, ownerId),
columns: { email: true },
})
if (owner?.email) {
await db.insert(notifications).values({
user_id: ownerId,
type: "maintenance_update",
recipient_email: owner.email,
subject: `Maintenance "${data.title}" marked ${data.status}`,
status: "sent",
metadata: {
maintenance_id: id,
status: data.status,
body: `Status changed from ${existing.status} to ${data.status}.`,
},
})
}
await emitWebhookEvent({
ownerId,
event: "maintenance.updated",
data: { maintenance: data, previous_status: existing.status },
})
}
return NextResponse.json(data)
}
@@ -70,10 +114,14 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const { id } = await params
await db
.delete(maintenance_requests)
.where(and(eq(maintenance_requests.id, id), eq(maintenance_requests.user_id, user.id)))
.where(and(eq(maintenance_requests.id, id), eq(maintenance_requests.user_id, ownerId)))
return NextResponse.json({ success: true })
}
+18 -6
View File
@@ -6,6 +6,8 @@ import { getSessionUser } from "@/lib/session"
import { maintenanceSchema } from "@/lib/validations"
import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership"
import { logActivity } from "@/lib/activity"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
import { emitWebhookEvent } from "@/lib/webhooks/emit"
const VALID_STATUSES = ["open", "in_progress", "resolved", "closed"]
const VALID_PRIORITIES = ["low", "medium", "high", "emergency"]
@@ -14,6 +16,8 @@ export async function GET(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id)
const { searchParams } = new URL(request.url)
const status = searchParams.get("status")
const priority = searchParams.get("priority")
@@ -29,7 +33,7 @@ export async function GET(request: Request) {
const data = await db.query.maintenance_requests.findMany({
where: and(
eq(maintenance_requests.user_id, user.id),
eq(maintenance_requests.user_id, ownerId),
status ? eq(maintenance_requests.status, status as typeof maintenance_requests.$inferSelect.status) : undefined,
priority ? eq(maintenance_requests.priority, priority as typeof maintenance_requests.$inferSelect.priority) : undefined,
propertyId ? eq(maintenance_requests.property_id, propertyId) : undefined
@@ -53,8 +57,10 @@ export async function POST(request: Request) {
let userId: string
if (user) {
// Authenticated landlord
userId = user.id
// Authenticated landlord — scope to the account owner and enforce write access.
const ctx = await getAccountContext(user.id)
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
userId = ctx.ownerId
} else {
// Tenant portal submission — verify portal_token
const portalToken = body.portal_token as string | undefined
@@ -90,9 +96,9 @@ export async function POST(request: Request) {
// (The portal-token path already validates these against the tenant above.)
if (
user &&
(!(await ownsProperty(user.id, parsed.data.property_id)) ||
!(await ownsUnit(user.id, parsed.data.unit_id)) ||
!(await ownsTenant(user.id, parsed.data.tenant_id)))
(!(await ownsProperty(userId, parsed.data.property_id)) ||
!(await ownsUnit(userId, parsed.data.unit_id)) ||
!(await ownsTenant(userId, parsed.data.tenant_id)))
) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
@@ -111,5 +117,11 @@ export async function POST(request: Request) {
entityId: data.id,
})
await emitWebhookEvent({
ownerId: userId,
event: "maintenance.created",
data: { maintenance: data },
})
return NextResponse.json(data, { status: 201 })
}
+6 -1
View File
@@ -3,16 +3,21 @@ import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { notifications } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account"
// PATCH /api/notifications/read — mark all of the user's notifications as read.
export async function PATCH() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
await db
.update(notifications)
.set({ read: true })
.where(and(eq(notifications.user_id, user.id), eq(notifications.read, false)))
.where(and(eq(notifications.user_id, ownerId), eq(notifications.read, false)))
return NextResponse.json({ ok: true })
}
+16 -6
View File
@@ -3,18 +3,21 @@ import { and, desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { notifications, rent_payments, maintenance_requests } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { sendEmail, rentDueReminderHtml, rentOverdueHtml, maintenanceUpdateHtml } from "@/lib/email/send"
import { sendEmail, rentDueReminderHtml, maintenanceUpdateHtml } from "@/lib/email/send"
import { formatCurrency, formatDate } from "@/lib/utils"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
export async function GET() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id)
try {
const data = await db
.select()
.from(notifications)
.where(eq(notifications.user_id, user.id))
.where(eq(notifications.user_id, ownerId))
.orderBy(desc(notifications.sent_at))
.limit(50)
@@ -28,6 +31,10 @@ export async function POST(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const body = await request.json() as {
type: string
payment_id?: string
@@ -38,7 +45,7 @@ export async function POST(request: Request) {
if (body.type === "rent_reminder" && body.payment_id) {
const payment = await db.query.rent_payments.findFirst({
where: and(eq(rent_payments.id, body.payment_id), eq(rent_payments.user_id, user.id)),
where: and(eq(rent_payments.id, body.payment_id), eq(rent_payments.user_id, ownerId)),
with: {
tenant: { columns: { first_name: true, last_name: true, email: true } },
property: { columns: { name: true } },
@@ -64,19 +71,22 @@ export async function POST(request: Request) {
if (result.success) {
await db.insert(notifications).values({
user_id: user.id,
user_id: ownerId,
type: "rent_reminder",
recipient_email: payment.tenant.email,
subject: `Rent Due Reminder — ${payment.property.name}`,
status: "sent",
metadata: { payment_id: body.payment_id },
metadata: {
payment_id: body.payment_id,
body: `Reminder sent to ${payment.tenant.first_name} ${payment.tenant.last_name} for ${formatCurrency(payment.amount)} due ${formatDate(payment.due_date)}.`,
},
})
}
}
if (body.type === "maintenance_update" && body.maintenance_id) {
const req = await db.query.maintenance_requests.findFirst({
where: and(eq(maintenance_requests.id, body.maintenance_id), eq(maintenance_requests.user_id, user.id)),
where: and(eq(maintenance_requests.id, body.maintenance_id), eq(maintenance_requests.user_id, ownerId)),
with: {
tenant: { columns: { first_name: true, last_name: true, email: true } },
},
+32
View File
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server"
import { eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { profiles } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { cancelSubscription } from "@/lib/paypal/checkout"
// Cancel the signed-in user's PayPal subscription. The account keeps access
// until the paid period ends; the BILLING.SUBSCRIPTION.CANCELLED webhook does
// the final downgrade to starter.
export async function POST() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, user.id),
columns: { paypal_subscription_id: true },
})
if (!profile?.paypal_subscription_id) {
return NextResponse.json({ error: "No PayPal subscription to cancel" }, { status: 400 })
}
const ok = await cancelSubscription(profile.paypal_subscription_id)
if (!ok) return NextResponse.json({ error: "PayPal cancellation failed" }, { status: 502 })
await db
.update(profiles)
.set({ subscription_status: "canceled" })
.where(eq(profiles.id, user.id))
return NextResponse.json({ ok: true })
}
+73
View File
@@ -0,0 +1,73 @@
import { NextResponse } from "next/server"
import { eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { profiles } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { paypalConfigured } from "@/lib/paypal/client"
import { getPaypalPlanId } from "@/lib/paypal/plans"
import { createSubscription, createOrder } from "@/lib/paypal/checkout"
import { PLAN_AMOUNTS } from "@/lib/stripe/plans"
const RECURRING = new Set(["pro", "landlord"])
// Start a PayPal checkout for a plan upgrade and return the approval URL.
// Recurring plans → Subscriptions API; lifetime → one-time Orders API.
export async function POST(request: Request) {
if (!paypalConfigured()) {
return NextResponse.json({ error: "PayPal is not configured" }, { status: 400 })
}
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { plan, interval } = (await request.json().catch(() => ({}))) as {
plan?: string
interval?: "month" | "year"
}
if (!plan || (plan !== "lifetime" && !RECURRING.has(plan))) {
return NextResponse.json({ error: "Invalid plan" }, { status: 400 })
}
const appUrl = process.env.NEXT_PUBLIC_APP_URL!
const cancelUrl = `${appUrl}/settings/billing?canceled=true`
try {
if (plan === "lifetime") {
const { approveUrl } = await createOrder({
amount: PLAN_AMOUNTS.lifetime,
userId: user.id,
plan: "lifetime",
returnUrl: `${appUrl}/api/paypal/return?type=order`,
cancelUrl,
})
if (!approveUrl) throw new Error("PayPal did not return an approval URL")
return NextResponse.json({ url: approveUrl })
}
const billingInterval = interval === "year" ? "year" : "month"
const planId = getPaypalPlanId(plan as "pro" | "landlord", billingInterval)
if (!planId) {
return NextResponse.json({ error: "That plan isn't available on PayPal yet." }, { status: 400 })
}
const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, user.id),
columns: { email: true },
})
const { approveUrl } = await createSubscription({
planId,
userId: user.id,
plan,
email: profile?.email ?? user.email,
returnUrl: `${appUrl}/api/paypal/return?type=subscription`,
cancelUrl,
})
if (!approveUrl) throw new Error("PayPal did not return an approval URL")
return NextResponse.json({ url: approveUrl })
} catch (e) {
return NextResponse.json(
{ error: (e as Error).message || "PayPal checkout failed" },
{ status: 502 }
)
}
}
+52
View File
@@ -0,0 +1,52 @@
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/session"
import { captureOrder, getSubscription, decodeCustomId } from "@/lib/paypal/checkout"
import { fulfillSubscription, fulfillLifetime } from "@/lib/paypal/fulfill"
// PayPal redirects the approver back here. We finalize synchronously (capture
// the order / confirm the subscription) so the plan is live the moment they
// land on the billing page — the webhook is a backstop, not the only path.
export async function GET(request: Request) {
const url = new URL(request.url)
const type = url.searchParams.get("type")
const appUrl = process.env.NEXT_PUBLIC_APP_URL!
const ok = NextResponse.redirect(`${appUrl}/settings/billing?success=true`)
const fail = NextResponse.redirect(`${appUrl}/settings/billing?error=paypal`)
const user = await getSessionUser()
if (!user) return NextResponse.redirect(`${appUrl}/login`)
try {
if (type === "order") {
const orderId = url.searchParams.get("token")
if (!orderId) return fail
const captured = await captureOrder(orderId)
if (!captured || captured.status !== "COMPLETED") return fail
const decoded = decodeCustomId(captured.custom_id)
if (!decoded || decoded.userId !== user.id) return fail
await fulfillLifetime(user.id)
return ok
}
// Subscription approval.
const subId = url.searchParams.get("subscription_id")
if (!subId) return fail
const sub = await getSubscription(subId)
if (!sub) return fail
const decoded = decodeCustomId(sub.custom_id)
// Only accept a subscription whose custom_id matches the signed-in user.
if (!decoded || decoded.userId !== user.id) return fail
const active = sub.status === "ACTIVE" || sub.status === "APPROVED"
await fulfillSubscription(
user.id,
decoded.plan,
sub.id,
sub.billing_info?.next_billing_time,
active ? "active" : sub.status.toLowerCase()
)
return ok
} catch {
return fail
}
}
+89
View File
@@ -0,0 +1,89 @@
import { NextResponse } from "next/server"
import { verifyPaypalWebhook } from "@/lib/paypal/webhook"
import { decodeCustomId, getSubscription } from "@/lib/paypal/checkout"
import { fulfillSubscription, fulfillLifetime, markPaypalSubscriptionInactive } from "@/lib/paypal/fulfill"
// Inbound PayPal webhook. Signature is verified via PayPal's API using
// PAYPAL_WEBHOOK_ID; unverified events are rejected.
export async function POST(request: Request) {
const body = await request.text()
const valid = await verifyPaypalWebhook(request.headers, body)
if (!valid) return NextResponse.json({ error: "invalid signature" }, { status: 400 })
let event: { event_type?: string; resource?: Record<string, unknown> }
try {
event = JSON.parse(body)
} catch {
return NextResponse.json({ ok: true })
}
const type = event.event_type ?? ""
const resource = (event.resource ?? {}) as Record<string, any>
try {
switch (type) {
case "BILLING.SUBSCRIPTION.ACTIVATED":
case "BILLING.SUBSCRIPTION.UPDATED": {
const decoded = decodeCustomId(resource.custom_id)
if (decoded && resource.id) {
await fulfillSubscription(
decoded.userId,
decoded.plan,
resource.id,
resource.billing_info?.next_billing_time,
"active"
)
}
break
}
case "PAYMENT.SALE.COMPLETED": {
// A recurring payment cleared — refresh status + next billing date.
const subId = resource.billing_agreement_id as string | undefined
if (subId) {
const sub = await getSubscription(subId)
const decoded = decodeCustomId(sub?.custom_id)
if (sub && decoded) {
await fulfillSubscription(
decoded.userId,
decoded.plan,
subId,
sub.billing_info?.next_billing_time,
"active"
)
}
}
break
}
case "BILLING.SUBSCRIPTION.CANCELLED":
case "BILLING.SUBSCRIPTION.EXPIRED": {
if (resource.id) {
await markPaypalSubscriptionInactive(
resource.id,
type.endsWith("CANCELLED") ? "canceled" : "expired",
true
)
}
break
}
case "BILLING.SUBSCRIPTION.SUSPENDED": {
if (resource.id) await markPaypalSubscriptionInactive(resource.id, "suspended", false)
break
}
case "PAYMENT.CAPTURE.COMPLETED": {
// Lifetime order capture (backup to the return handler).
const decoded = decodeCustomId(resource.custom_id)
if (decoded && decoded.plan === "lifetime") await fulfillLifetime(decoded.userId)
break
}
}
} catch {
// Never loop forever on a handler bug — PayPal retries non-2xx.
}
return NextResponse.json({ received: true })
}
+32 -4
View File
@@ -4,14 +4,18 @@ import { db } from "@/lib/db"
import { properties } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { propertySchema } from "@/lib/validations"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
import { geocodeAddress } from "@/lib/geocoding"
export async function GET(_: Request, { params }: { params: Promise<{ id: string }> }) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id)
const { id } = await params
const data = await db.query.properties.findFirst({
where: and(eq(properties.id, id), eq(properties.user_id, user.id)),
where: and(eq(properties.id, id), eq(properties.user_id, ownerId)),
with: { units: {} },
})
@@ -23,16 +27,36 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const { id } = await params
const body = await request.json()
const parsed = propertySchema.partial().safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
// Re-geocode only when an address field actually changed. Merge with the
// existing row so a partial update still geocodes the complete address. Keep
// the old coordinates if geocoding fails (never overwrite good data with null).
const addressKeys = ["address_line1", "address_line2", "city", "state", "postal_code", "country"] as const
let coords: { latitude: number; longitude: number } | undefined
if (addressKeys.some((k) => k in parsed.data)) {
const existing = await db.query.properties.findFirst({
where: and(eq(properties.id, id), eq(properties.user_id, ownerId)),
columns: { address_line1: true, address_line2: true, city: true, state: true, postal_code: true, country: true },
})
if (existing) {
const geo = await geocodeAddress({ ...existing, ...parsed.data })
if (geo) coords = geo
}
}
try {
const [data] = await db
.update(properties)
.set(parsed.data)
.where(and(eq(properties.id, id), eq(properties.user_id, user.id)))
.set({ ...parsed.data, ...(coords ?? {}) })
.where(and(eq(properties.id, id), eq(properties.user_id, ownerId)))
.returning()
return NextResponse.json(data)
@@ -45,9 +69,13 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const { id } = await params
try {
await db.delete(properties).where(and(eq(properties.id, id), eq(properties.user_id, user.id)))
await db.delete(properties).where(and(eq(properties.id, id), eq(properties.user_id, ownerId)))
return NextResponse.json({ success: true })
} catch (e) {
return NextResponse.json({ error: (e as Error).message }, { status: 500 })
+23 -13
View File
@@ -1,16 +1,23 @@
import { NextResponse } from "next/server"
import { and, desc, eq, sql } from "drizzle-orm"
import { desc, eq, sql } from "drizzle-orm"
import { db } from "@/lib/db"
import { properties, profiles } from "@/lib/db/schema"
import { properties } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { propertySchema } from "@/lib/validations"
import { getUserPlan } from "@/lib/plan-limits"
import { checkLimit } from "@/lib/stripe/plans"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
import { emitWebhookEvent } from "@/lib/webhooks/emit"
import { geocodeAddress } from "@/lib/geocoding"
export async function GET() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id)
const data = await db.query.properties.findMany({
where: eq(properties.user_id, user.id),
where: eq(properties.user_id, ownerId),
with: { units: { columns: { id: true, status: true } } },
orderBy: desc(properties.created_at),
})
@@ -22,6 +29,10 @@ export async function POST(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const body = await request.json()
const parsed = propertySchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
@@ -30,23 +41,22 @@ export async function POST(request: Request) {
const [{ count }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(properties)
.where(eq(properties.user_id, user.id))
.where(eq(properties.user_id, ownerId))
const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, user.id),
columns: { plan: true },
})
const limits: Record<string, number> = { starter: 1, pro: 10, landlord: Infinity, lifetime: Infinity }
const limit = limits[profile?.plan ?? "starter"] ?? 1
if (count >= limit) {
const plan = await getUserPlan(ownerId)
if (!checkLimit(plan, "maxProperties", count)) {
return NextResponse.json({ error: "Plan limit reached. Upgrade to add more properties." }, { status: 403 })
}
// Best-effort geocode so the property shows up on the map (never blocks save).
const coords = await geocodeAddress(parsed.data)
const [data] = await db
.insert(properties)
.values({ ...parsed.data, user_id: user.id })
.values({ ...parsed.data, user_id: ownerId, ...(coords ?? {}) })
.returning()
await emitWebhookEvent({ ownerId, event: "property.created", data: { property: data } })
return NextResponse.json(data, { status: 201 })
}
+17 -6
View File
@@ -6,20 +6,26 @@ import { getSessionUser } from "@/lib/session"
import { rentPaymentSchema } from "@/lib/validations"
import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership"
import { logActivity } from "@/lib/activity"
import { getAccountContext } from "@/lib/account"
import { emitWebhookEvent } from "@/lib/webhooks/emit"
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const { id } = await params
const body = await request.json()
const parsed = rentPaymentSchema.partial().safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
if (
!(await ownsProperty(user.id, parsed.data.property_id)) ||
!(await ownsUnit(user.id, parsed.data.unit_id)) ||
!(await ownsTenant(user.id, parsed.data.tenant_id))
!(await ownsProperty(ownerId, parsed.data.property_id)) ||
!(await ownsUnit(ownerId, parsed.data.unit_id)) ||
!(await ownsTenant(ownerId, parsed.data.tenant_id))
) {
return NextResponse.json({ error: "Invalid reference" }, { status: 403 })
}
@@ -33,17 +39,18 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
const [data] = await db
.update(rent_payments)
.set(updateData)
.where(and(eq(rent_payments.id, id), eq(rent_payments.user_id, user.id)))
.where(and(eq(rent_payments.id, id), eq(rent_payments.user_id, ownerId)))
.returning()
if (parsed.data.status === "paid") {
await logActivity({
userId: user.id,
userId: ownerId,
type: "rent_paid",
title: "Rent payment marked as paid",
entityType: "rent_payment",
entityId: id,
})
await emitWebhookEvent({ ownerId, event: "payment.paid", data: { payment: data } })
}
return NextResponse.json(data)
@@ -53,7 +60,11 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const { id } = await params
await db.delete(rent_payments).where(and(eq(rent_payments.id, id), eq(rent_payments.user_id, user.id)))
await db.delete(rent_payments).where(and(eq(rent_payments.id, id), eq(rent_payments.user_id, ownerId)))
return NextResponse.json({ success: true })
}
+8 -3
View File
@@ -3,11 +3,16 @@ import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { leases, rent_payments } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account"
export async function POST(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const { year, month } = await request.json() as { year: number; month: number }
if (!year || month === undefined) return NextResponse.json({ error: "year and month required" }, { status: 400 })
@@ -21,7 +26,7 @@ export async function POST(request: Request) {
rent_amount: leases.rent_amount,
})
.from(leases)
.where(and(eq(leases.user_id, user.id), eq(leases.status, "active")))
.where(and(eq(leases.user_id, ownerId), eq(leases.status, "active")))
if (!activeLeases.length) return NextResponse.json({ created: 0, skipped: 0 })
@@ -33,14 +38,14 @@ export async function POST(request: Request) {
const existing = await db
.select({ tenant_id: rent_payments.tenant_id })
.from(rent_payments)
.where(and(eq(rent_payments.user_id, user.id), eq(rent_payments.due_date, due_date)))
.where(and(eq(rent_payments.user_id, ownerId), eq(rent_payments.due_date, due_date)))
const existingTenantIds = new Set(existing.map((p) => p.tenant_id))
const toInsert = activeLeases
.filter((l) => !existingTenantIds.has(l.tenant_id))
.map((l) => ({
user_id: user.id,
user_id: ownerId,
tenant_id: l.tenant_id,
property_id: l.property_id,
unit_id: l.unit_id ?? null,
+7 -2
View File
@@ -4,15 +4,20 @@ import { db } from "@/lib/db"
import { rent_payments } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { createRentPaymentLink } from "@/lib/stripe/payment-links"
import { getAccountContext } from "@/lib/account"
export async function POST(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const { payment_id } = await request.json() as { payment_id: string }
const payment = await db.query.rent_payments.findFirst({
where: and(eq(rent_payments.id, payment_id), eq(rent_payments.user_id, user.id)),
where: and(eq(rent_payments.id, payment_id), eq(rent_payments.user_id, ownerId)),
with: {
tenant: { columns: { first_name: true, last_name: true } },
property: { columns: { name: true } },
@@ -35,7 +40,7 @@ export async function POST(request: Request) {
await db
.update(rent_payments)
.set({ stripe_payment_link_id: link.id })
.where(and(eq(rent_payments.id, payment_id), eq(rent_payments.user_id, user.id)))
.where(and(eq(rent_payments.id, payment_id), eq(rent_payments.user_id, ownerId)))
return NextResponse.json({ url: link.url })
}
+19 -5
View File
@@ -5,11 +5,15 @@ import { rent_payments } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { rentPaymentSchema } from "@/lib/validations"
import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
import { emitWebhookEvent } from "@/lib/webhooks/emit"
export async function GET(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id)
const { searchParams } = new URL(request.url)
const status = searchParams.get("status")
const tenantId = searchParams.get("tenant_id")
@@ -18,7 +22,7 @@ export async function GET(request: Request) {
const offset = (page - 1) * limit
const where = and(
eq(rent_payments.user_id, user.id),
eq(rent_payments.user_id, ownerId),
status ? eq(rent_payments.status, status as typeof rent_payments.$inferSelect.status) : undefined,
tenantId ? eq(rent_payments.tenant_id, tenantId) : undefined
)
@@ -45,22 +49,32 @@ export async function POST(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const body = await request.json()
const parsed = rentPaymentSchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
if (
!(await ownsProperty(user.id, parsed.data.property_id)) ||
!(await ownsUnit(user.id, parsed.data.unit_id)) ||
!(await ownsTenant(user.id, parsed.data.tenant_id))
!(await ownsProperty(ownerId, parsed.data.property_id)) ||
!(await ownsUnit(ownerId, parsed.data.unit_id)) ||
!(await ownsTenant(ownerId, parsed.data.tenant_id))
) {
return NextResponse.json({ error: "Invalid reference" }, { status: 403 })
}
const [data] = await db
.insert(rent_payments)
.values({ ...parsed.data, user_id: user.id })
.values({ ...parsed.data, user_id: ownerId })
.returning()
await emitWebhookEvent({ ownerId, event: "payment.recorded", data: { payment: data } })
// A payment created already in the "paid" state also fires payment.paid.
if (data.status === "paid") {
await emitWebhookEvent({ ownerId, event: "payment.paid", data: { payment: data } })
}
return NextResponse.json(data, { status: 201 })
}
+21 -26
View File
@@ -3,13 +3,18 @@ import { and, eq, sql } from "drizzle-orm"
import { db } from "@/lib/db"
import { rent_payments, profiles } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { sendEmail, escapeHtml } from "@/lib/email/send"
import { sendEmail, paymentLinkHtml } from "@/lib/email/send"
import { paymentLinkSchema } from "@/lib/validations"
import { getAccountContext } from "@/lib/account"
export async function POST(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const body = await request.json()
const parsed = paymentLinkSchema.safeParse(body)
if (!parsed.success) {
@@ -20,7 +25,7 @@ export async function POST(request: Request) {
// Fetch payment with tenant details
const payment = await db.query.rent_payments.findFirst({
where: and(eq(rent_payments.id, payment_id), eq(rent_payments.user_id, user.id)),
where: and(eq(rent_payments.id, payment_id), eq(rent_payments.user_id, ownerId)),
with: {
tenant: { columns: { first_name: true, last_name: true, email: true } },
property: { columns: { name: true } },
@@ -31,9 +36,9 @@ export async function POST(request: Request) {
if (!payment) return NextResponse.json({ error: "Payment not found" }, { status: 404 })
if (!payment.tenant?.email) return NextResponse.json({ error: "Tenant has no email address" }, { status: 400 })
// Fetch landlord profile for payment instructions
// Fetch landlord (portfolio owner) profile for payment instructions
const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, user.id),
where: eq(profiles.id, ownerId),
columns: { full_name: true },
})
@@ -41,27 +46,17 @@ export async function POST(request: Request) {
const amount = Number(payment.amount).toLocaleString("en-US", { style: "currency", currency: "USD" })
const dueDate = new Date(payment.due_date).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })
const html = `
<div style="font-family:sans-serif;max-width:560px;margin:0 auto;background:#09090b;color:#fff;border-radius:12px;overflow:hidden;">
<div style="background:linear-gradient(135deg,#4f46e5,#7c3aed);padding:32px;text-align:center;">
<h1 style="margin:0;font-size:24px;font-weight:700;">Rent Payment Due</h1>
<p style="margin:8px 0 0;opacity:.8;font-size:14px;">Property Management Network</p>
</div>
<div style="padding:32px;">
<p style="font-size:16px;margin:0 0 8px;">Hi ${escapeHtml(tenantName)},</p>
<p style="color:rgba(255,255,255,.6);font-size:14px;margin:0 0 24px;">
Your rent payment of <strong style="color:#fff;">${amount}</strong> is due on <strong style="color:#fff;">${dueDate}</strong>
for ${escapeHtml(payment.property?.name)}${payment.unit ? ` Unit ${escapeHtml(payment.unit.unit_number)}` : ""}.
</p>
<p style="color:rgba(255,255,255,.6);font-size:14px;margin:0 0 16px;">
Please arrange payment at your earliest convenience. Contact your landlord if you have any questions.
</p>
<p style="color:rgba(255,255,255,.4);font-size:12px;margin:24px 0 0;border-top:1px solid rgba(255,255,255,.08);padding-top:16px;">
Sent by ${escapeHtml(profile?.full_name ?? "Your Landlord")} via Property Management Network
</p>
</div>
</div>
`
const propertyLabel = `${payment.property?.name ?? "your property"}${
payment.unit ? ` — Unit ${payment.unit.unit_number}` : ""
}`
const html = paymentLinkHtml({
tenantName,
amount,
dueDate,
propertyLabel,
senderName: profile?.full_name ?? "Your Landlord",
})
try {
await sendEmail({
@@ -76,7 +71,7 @@ export async function POST(request: Request) {
// Update rent_payment to mark reminder sent
try {
await db.execute(
sql`update rent_payments set reminder_sent_at = now() where id = ${payment_id} and user_id = ${user.id}`
sql`update rent_payments set reminder_sent_at = now() where id = ${payment_id} and user_id = ${ownerId}`
)
} catch {
// Email sent successfully, but tracking update failed — still return ok
+6 -3
View File
@@ -3,11 +3,14 @@ import { and, eq, ilike, or } from "drizzle-orm"
import { db } from "@/lib/db"
import { tenants, properties, maintenance_requests } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId } from "@/lib/account"
export async function GET(req: NextRequest) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id)
const q = req.nextUrl.searchParams.get("q")?.trim() ?? ""
if (q.length < 2) return NextResponse.json({ tenants: [], properties: [], maintenance: [] })
@@ -25,7 +28,7 @@ export async function GET(req: NextRequest) {
.from(tenants)
.where(
and(
eq(tenants.user_id, user.id),
eq(tenants.user_id, ownerId),
or(ilike(tenants.first_name, like), ilike(tenants.last_name, like), ilike(tenants.email, like))
)
)
@@ -40,7 +43,7 @@ export async function GET(req: NextRequest) {
.from(properties)
.where(
and(
eq(properties.user_id, user.id),
eq(properties.user_id, ownerId),
or(ilike(properties.name, like), ilike(properties.address_line1, like), ilike(properties.city, like))
)
)
@@ -53,7 +56,7 @@ export async function GET(req: NextRequest) {
priority: maintenance_requests.priority,
})
.from(maintenance_requests)
.where(and(eq(maintenance_requests.user_id, user.id), ilike(maintenance_requests.title, like)))
.where(and(eq(maintenance_requests.user_id, ownerId), ilike(maintenance_requests.title, like)))
.limit(5),
])
+15 -5
View File
@@ -5,16 +5,26 @@ import { profiles } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { stripe } from "@/lib/stripe/client"
import { PLAN_PRICES } from "@/lib/stripe/plans"
import { resolvePriceId } from "@/lib/stripe/prices"
export async function POST(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { plan } = await request.json() as { plan: string }
const { plan, interval } = await request.json() as {
plan: string
interval?: "month" | "year"
}
const planConfig = PLAN_PRICES[plan]
if (!planConfig) return NextResponse.json({ error: "Invalid plan" }, { status: 400 })
const billingInterval = interval === "year" ? "year" : "month"
const priceId = await resolvePriceId(planConfig.plan, billingInterval)
if (!priceId) {
return NextResponse.json({ error: "Could not resolve the plan price. Check STRIPE_SECRET_KEY." }, { status: 400 })
}
const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, user.id),
columns: { stripe_customer_id: true, email: true, full_name: true },
@@ -27,7 +37,7 @@ export async function POST(request: Request) {
const customer = await stripe.customers.create({
email: profile?.email ?? user.email,
name: profile?.full_name ?? undefined,
metadata: { supabase_user_id: user.id },
metadata: { user_id: user.id },
})
customerId = customer.id
@@ -43,16 +53,16 @@ export async function POST(request: Request) {
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: isLifetime ? "payment" : "subscription",
line_items: [{ price: planConfig.priceId, quantity: 1 }],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${appUrl}/settings/billing?success=true`,
cancel_url: `${appUrl}/settings/billing?canceled=true`,
metadata: {
supabase_user_id: user.id,
user_id: user.id,
plan: planConfig.plan,
},
...(isLifetime ? {} : {
subscription_data: {
metadata: { supabase_user_id: user.id, plan: planConfig.plan },
metadata: { user_id: user.id, plan: planConfig.plan },
},
}),
})
+6 -4
View File
@@ -19,11 +19,13 @@ export async function POST(request: Request) {
}
// Webhooks are not user-scoped: they identify the target row by the id /
// customer id stored in Stripe metadata. There is no RLS to bypass anymore.
// customer id stored in Stripe metadata. `user_id` is the current key;
// `supabase_user_id` is read as a fallback so subscriptions/checkouts created
// before the rename keep resolving. (Both hold the same app user id.)
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session
const userId = session.metadata?.supabase_user_id
const userId = session.metadata?.user_id ?? session.metadata?.supabase_user_id
const plan = session.metadata?.plan
if (!userId || !plan) break
@@ -41,7 +43,7 @@ export async function POST(request: Request) {
case "customer.subscription.created":
case "customer.subscription.updated": {
const subscription = event.data.object as Stripe.Subscription
const userId = subscription.metadata?.supabase_user_id
const userId = subscription.metadata?.user_id ?? subscription.metadata?.supabase_user_id
const plan = subscription.metadata?.plan
if (!userId) break
@@ -62,7 +64,7 @@ export async function POST(request: Request) {
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription
const userId = subscription.metadata?.supabase_user_id
const userId = subscription.metadata?.user_id ?? subscription.metadata?.supabase_user_id
if (!userId) break
+66
View File
@@ -0,0 +1,66 @@
import { NextResponse } from "next/server"
import { and, eq } from "drizzle-orm"
import { z } from "zod"
import { db } from "@/lib/db"
import { account_members } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
const patchSchema = z.object({
role: z.enum(["member", "viewer"]),
})
/**
* PATCH — change a member's role (member <-> viewer). Owner-only; scoped to
* rows the session user owns so a member can't edit someone else's team.
*/
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const body = await request.json().catch(() => null)
const parsed = patchSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid input" },
{ status: 400 }
)
}
const [updated] = await db
.update(account_members)
.set({ role: parsed.data.role })
.where(and(eq(account_members.id, id), eq(account_members.owner_id, user.id)))
.returning({
id: account_members.id,
email: account_members.email,
role: account_members.role,
status: account_members.status,
})
if (!updated) return NextResponse.json({ error: "Not found" }, { status: 404 })
return NextResponse.json(updated)
}
/**
* DELETE — revoke a member's access. Sets status='revoked' and clears
* member_id so getAccountContext immediately stops resolving them to this
* owner. Owner-only; scoped to rows the session user owns.
*/
export async function DELETE(_: Request, { params }: { params: Promise<{ id: string }> }) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const [revoked] = await db
.update(account_members)
.set({ status: "revoked", member_id: null })
.where(and(eq(account_members.id, id), eq(account_members.owner_id, user.id)))
.returning({ id: account_members.id })
if (!revoked) return NextResponse.json({ error: "Not found" }, { status: 404 })
return NextResponse.json({ ok: true })
}
+132
View File
@@ -0,0 +1,132 @@
import { NextResponse } from "next/server"
import { and, desc, eq, ne } from "drizzle-orm"
import { z } from "zod"
import { db } from "@/lib/db"
import { account_members, profiles } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account"
import { PLAN_LIMITS } from "@/lib/stripe/plans"
import { sendEmail, teamInviteHtml } from "@/lib/email/send"
import type { Plan } from "@/types"
const inviteSchema = z.object({
email: z.string().email("Enter a valid email address").max(254),
role: z.enum(["member", "viewer"]).default("member"),
})
const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"
/**
* GET — list the members the current OWNER has invited to their account.
* Team management is always scoped to the real session user acting as owner
* (never getEffectiveOwnerId), so a member can't manage the owner's team.
*/
export async function GET() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const members = await db
.select({
id: account_members.id,
email: account_members.email,
role: account_members.role,
status: account_members.status,
member_id: account_members.member_id,
accepted_at: account_members.accepted_at,
created_at: account_members.created_at,
})
.from(account_members)
.where(and(eq(account_members.owner_id, user.id), ne(account_members.status, "revoked")))
.orderBy(desc(account_members.created_at))
return NextResponse.json(members)
}
/**
* POST — invite a user (by email) to the current owner's account. Gated to
* plans with team access; only the account owner may invite.
*/
export async function POST(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
// Only an account OWNER can manage a team — a member of someone else's
// account must not be able to invite people into that account.
const ctx = await getAccountContext(user.id)
if (!ctx.isOwner) {
return NextResponse.json(
{ error: "Only the account owner can manage the team" },
{ status: 403 }
)
}
// Gate on the owner's plan.
const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, user.id),
columns: { plan: true, email: true },
})
const plan = (profile?.plan ?? "starter") as Plan
if (!PLAN_LIMITS[plan].hasTeamAccess) {
return NextResponse.json(
{ error: "Team access is available on the Landlord and Lifetime plans" },
{ status: 403 }
)
}
const body = await request.json().catch(() => null)
const parsed = inviteSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid input" },
{ status: 400 }
)
}
const email = parsed.data.email.trim().toLowerCase()
const { role } = parsed.data
// Can't invite yourself.
if (email === (profile?.email ?? user.email ?? "").toLowerCase()) {
return NextResponse.json({ error: "You can't invite yourself" }, { status: 400 })
}
// Prevent a duplicate pending/active invite for the same email.
const existing = await db.query.account_members.findFirst({
where: and(eq(account_members.owner_id, user.id), eq(account_members.email, email)),
})
if (existing && (existing.status === "pending" || existing.status === "active")) {
return NextResponse.json(
{
error:
existing.status === "active"
? "That person is already a member of your account"
: "An invite is already pending for that email",
},
{ status: 409 }
)
}
let inviteToken: string
try {
// Re-invite a previously revoked email by inserting a fresh pending row.
const [row] = await db
.insert(account_members)
.values({ owner_id: user.id, email, role, status: "pending" })
.returning({ invite_token: account_members.invite_token })
inviteToken = row.invite_token
} catch (e) {
return NextResponse.json({ error: (e as Error).message }, { status: 500 })
}
const inviteUrl = `${APP_URL}/team/accept/${inviteToken}`
const inviterName = profile?.email ?? user.email ?? "A landlord"
await sendEmail({
to: email,
subject: "You've been invited to a Property Management Network account",
html: teamInviteHtml({ inviterName, inviteUrl, role }),
})
return NextResponse.json({ ok: true })
}
@@ -4,17 +4,21 @@ import { randomUUID } from "crypto"
import { db } from "@/lib/db"
import { tenants } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account"
// Lets a landlord rotate a tenant's portal token (invalidates the old private link).
export async function POST(_: Request, { params }: { params: Promise<{ id: string }> }) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const { id } = await params
const [row] = await db
.update(tenants)
.set({ portal_token: randomUUID() })
.where(and(eq(tenants.id, id), eq(tenants.user_id, user.id)))
.where(and(eq(tenants.id, id), eq(tenants.user_id, ctx.ownerId)))
.returning({ id: tenants.id, portal_token: tenants.portal_token })
if (!row) return NextResponse.json({ error: "Not found" }, { status: 404 })
+18 -7
View File
@@ -5,14 +5,17 @@ import { tenants, units } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { tenantSchema } from "@/lib/validations"
import { ownsProperty, ownsUnit } from "@/lib/db/ownership"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
export async function GET(_: Request, { params }: { params: Promise<{ id: string }> }) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id)
const { id } = await params
const data = await db.query.tenants.findFirst({
where: and(eq(tenants.id, id), eq(tenants.user_id, user.id)),
where: and(eq(tenants.id, id), eq(tenants.user_id, ownerId)),
with: {
unit: {},
property: {},
@@ -29,14 +32,18 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const { id } = await params
const body = await request.json()
const parsed = tenantSchema.partial().safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
if (
!(await ownsProperty(user.id, parsed.data.property_id)) ||
!(await ownsUnit(user.id, parsed.data.unit_id))
!(await ownsProperty(ownerId, parsed.data.property_id)) ||
!(await ownsUnit(ownerId, parsed.data.unit_id))
) {
return NextResponse.json({ error: "Invalid reference" }, { status: 403 })
}
@@ -45,7 +52,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
const [data] = await db
.update(tenants)
.set(parsed.data)
.where(and(eq(tenants.id, id), eq(tenants.user_id, user.id)))
.where(and(eq(tenants.id, id), eq(tenants.user_id, ownerId)))
.returning()
return NextResponse.json(data)
@@ -58,23 +65,27 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const { id } = await params
try {
// Get tenant to free unit
const tenant = await db.query.tenants.findFirst({
where: and(eq(tenants.id, id), eq(tenants.user_id, user.id)),
where: and(eq(tenants.id, id), eq(tenants.user_id, ownerId)),
columns: { unit_id: true },
})
await db.delete(tenants).where(and(eq(tenants.id, id), eq(tenants.user_id, user.id)))
await db.delete(tenants).where(and(eq(tenants.id, id), eq(tenants.user_id, ownerId)))
// Free unit
if (tenant?.unit_id) {
await db
.update(units)
.set({ status: "vacant", current_tenant_id: null })
.where(and(eq(units.id, tenant.unit_id), eq(units.user_id, user.id)))
.where(and(eq(units.id, tenant.unit_id), eq(units.user_id, ownerId)))
}
return NextResponse.json({ success: true })
+32 -7
View File
@@ -5,12 +5,18 @@ import { tenants, units } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { tenantSchema } from "@/lib/validations"
import { ownsProperty, ownsUnit } from "@/lib/db/ownership"
import { getUserPlan } from "@/lib/plan-limits"
import { checkLimit } from "@/lib/stripe/plans"
import { logActivity } from "@/lib/activity"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
import { emitWebhookEvent } from "@/lib/webhooks/emit"
export async function GET(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id)
const { searchParams } = new URL(request.url)
const propertyId = searchParams.get("property_id")
const status = searchParams.get("status") ?? "active"
@@ -24,7 +30,7 @@ export async function GET(request: Request) {
}
const where = and(
eq(tenants.user_id, user.id),
eq(tenants.user_id, ownerId),
eq(tenants.status, status as typeof tenants.$inferSelect.status),
propertyId ? eq(tenants.property_id, propertyId) : undefined
)
@@ -50,20 +56,37 @@ export async function POST(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const body = await request.json()
const parsed = tenantSchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
// Enforce per-plan tenant limit (Starter = 3).
const plan = await getUserPlan(ownerId)
const [{ count }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(tenants)
.where(eq(tenants.user_id, ownerId))
if (!checkLimit(plan, "maxTenants", count)) {
return NextResponse.json(
{ error: "Plan limit reached. Upgrade to add more tenants." },
{ status: 403 }
)
}
if (
!(await ownsProperty(user.id, parsed.data.property_id)) ||
!(await ownsUnit(user.id, parsed.data.unit_id))
!(await ownsProperty(ownerId, parsed.data.property_id)) ||
!(await ownsUnit(ownerId, parsed.data.unit_id))
) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const [tenant] = await db
.insert(tenants)
.values({ ...parsed.data, user_id: user.id })
.values({ ...parsed.data, user_id: ownerId })
.returning()
// Mark unit as occupied — verify unit belongs to the submitted property first
@@ -72,7 +95,7 @@ export async function POST(request: Request) {
where: and(
eq(units.id, parsed.data.unit_id),
eq(units.property_id, parsed.data.property_id),
eq(units.user_id, user.id)
eq(units.user_id, ownerId)
),
columns: { id: true },
})
@@ -81,12 +104,12 @@ export async function POST(request: Request) {
await db
.update(units)
.set({ status: "occupied", current_tenant_id: tenant.id })
.where(and(eq(units.id, parsed.data.unit_id), eq(units.user_id, user.id)))
.where(and(eq(units.id, parsed.data.unit_id), eq(units.user_id, ownerId)))
}
}
await logActivity({
userId: user.id,
userId: ownerId,
type: "tenant_added",
title: `New tenant added: ${parsed.data.first_name} ${parsed.data.last_name}`,
description: parsed.data.email ?? undefined,
@@ -94,5 +117,7 @@ export async function POST(request: Request) {
entityId: tenant.id,
})
await emitWebhookEvent({ ownerId, event: "tenant.created", data: { tenant } })
return NextResponse.json(tenant, { status: 201 })
}
+12 -3
View File
@@ -5,17 +5,22 @@ import { units } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { unitSchema } from "@/lib/validations"
import { ownsProperty } from "@/lib/db/ownership"
import { getAccountContext } from "@/lib/account"
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const { id } = await params
const body = await request.json()
const parsed = unitSchema.partial().safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
if (!(await ownsProperty(user.id, parsed.data.property_id))) {
if (!(await ownsProperty(ownerId, parsed.data.property_id))) {
return NextResponse.json({ error: "Invalid reference" }, { status: 403 })
}
@@ -23,7 +28,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
const [data] = await db
.update(units)
.set(parsed.data)
.where(and(eq(units.id, id), eq(units.user_id, user.id)))
.where(and(eq(units.id, id), eq(units.user_id, ownerId)))
.returning()
return NextResponse.json(data)
@@ -36,9 +41,13 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const { id } = await params
try {
await db.delete(units).where(and(eq(units.id, id), eq(units.user_id, user.id)))
await db.delete(units).where(and(eq(units.id, id), eq(units.user_id, ownerId)))
return NextResponse.json({ success: true })
} catch (e) {
return NextResponse.json({ error: (e as Error).message }, { status: 500 })
+10 -3
View File
@@ -5,17 +5,20 @@ import { units } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { unitSchema } from "@/lib/validations"
import { ownsProperty } from "@/lib/db/ownership"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
export async function GET(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id)
const { searchParams } = new URL(request.url)
const propertyId = searchParams.get("property_id")
const data = await db.query.units.findMany({
where: and(
eq(units.user_id, user.id),
eq(units.user_id, ownerId),
propertyId ? eq(units.property_id, propertyId) : undefined
),
with: {
@@ -31,17 +34,21 @@ export async function POST(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const body = await request.json()
const parsed = unitSchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
if (!(await ownsProperty(user.id, parsed.data.property_id))) {
if (!(await ownsProperty(ownerId, parsed.data.property_id))) {
return NextResponse.json({ error: "Invalid reference" }, { status: 403 })
}
const [data] = await db
.insert(units)
.values({ ...parsed.data, user_id: user.id })
.values({ ...parsed.data, user_id: ownerId })
.returning()
return NextResponse.json(data, { status: 201 })
+31 -23
View File
@@ -1,32 +1,23 @@
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/session"
import { saveFile } from "@/lib/storage"
import { getAccountContext } from "@/lib/account"
import { saveFile, isAllowedUploadExt, StorageNotConfiguredError } from "@/lib/storage"
import { checkStorageLimit } from "@/lib/plan-limits"
const ALLOWED_SCOPES = ["property-images", "maintenance", "documents", "misc"]
// Allowlisted upload extensions. Deliberately excludes svg and any html/script
// types, which can execute JavaScript when served inline from our origin.
const ALLOWED_EXTENSIONS = [
"pdf",
"png",
"jpg",
"jpeg",
"gif",
"webp",
"doc",
"docx",
"xls",
"xlsx",
"csv",
"txt",
]
// Generic authenticated upload endpoint. Saves the file to local disk under the
// user's namespace and returns a URL pointing at the auth-gated /api/files route.
// Generic authenticated upload endpoint. Persists the file under the user's
// namespace (DigitalOcean Spaces when configured, else local disk in dev) and
// returns a URL pointing at the auth-gated /api/files route.
export async function POST(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
// Uploads belong to the effective owner's portfolio. Viewers are read-only.
const ctx = await getAccountContext(user.id)
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const ownerId = ctx.ownerId
const fd = await request.formData()
const file = fd.get("file") as File | null
const scopeRaw = (fd.get("scope") as string) || "misc"
@@ -38,12 +29,29 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "File too large (max 20 MB)" }, { status: 400 })
}
const ext = file.name.split(".").pop()?.toLowerCase() ?? ""
if (!ALLOWED_EXTENSIONS.includes(ext)) {
if (!isAllowedUploadExt(file.name)) {
return NextResponse.json({ error: "File type not allowed" }, { status: 400 })
}
const { key, size, type } = await saveFile(file, { userId: user.id, scope, fixedName })
// Enforce per-plan storage quota (accounts for everything already stored in
// the owner's portfolio namespace).
const storageError = await checkStorageLimit(ownerId, file.size)
if (storageError) return NextResponse.json({ error: storageError }, { status: 403 })
let saved
try {
saved = await saveFile(file, { userId: ownerId, scope, fixedName })
} catch (err) {
if (err instanceof StorageNotConfiguredError) {
console.error("[upload]", err.message)
return NextResponse.json(
{ error: "File uploads are temporarily unavailable. Please try again later." },
{ status: 503 }
)
}
throw err
}
const { key, size, type } = saved
return NextResponse.json({
url: `/api/files/${key}`,
+76
View File
@@ -0,0 +1,76 @@
import { NextResponse } from "next/server"
import { and, eq } from "drizzle-orm"
import { z } from "zod"
import { db } from "@/lib/db"
import { maintenance_requests } from "@/lib/db/schema"
import { maintenanceSchema } from "@/lib/validations"
import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership"
import { resolveApiRequest } from "@/lib/api-auth"
import { emitWebhookEvent } from "@/lib/webhooks/emit"
// Public REST API (v1) — update a single maintenance request. Bearer API-key
// auth. Owner-scoped by id; mirrors the internal PATCH schema.
const unauthorized = () =>
NextResponse.json({ error: { code: 401, message: "Unauthorized" } }, { status: 401 })
const forbidden = () =>
NextResponse.json({ error: { code: 403, message: "Forbidden" } }, { status: 403 })
const maintenancePatchSchema = maintenanceSchema.partial().extend({
status: z.enum(["open", "in_progress", "resolved", "closed"]).optional(),
resolution_notes: z.string().optional(),
actual_cost: z.number().positive().optional(),
resolved_at: z.string().optional(),
})
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
if (!ctx.canWrite) return forbidden()
const { id } = await params
const body = await request.json().catch(() => null)
const parsed = maintenancePatchSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: { code: 400, message: parsed.error.flatten() } },
{ status: 400 }
)
}
// If any FK is being changed, ensure the caller owns the referenced rows.
if (
!(await ownsProperty(ctx.ownerId, parsed.data.property_id)) ||
!(await ownsUnit(ctx.ownerId, parsed.data.unit_id)) ||
!(await ownsTenant(ctx.ownerId, parsed.data.tenant_id))
) {
return forbidden()
}
const updateData: Record<string, unknown> = { ...parsed.data }
// Auto-stamp resolved_at when transitioning to resolved.
if (parsed.data.status === "resolved" && !parsed.data.resolved_at) {
updateData.resolved_at = new Date().toISOString()
}
const [data] = await db
.update(maintenance_requests)
.set(updateData)
.where(and(eq(maintenance_requests.id, id), eq(maintenance_requests.user_id, ctx.ownerId)))
.returning()
if (!data) {
return NextResponse.json({ error: { code: 404, message: "Not found" } }, { status: 404 })
}
if (parsed.data.status) {
await emitWebhookEvent({
ownerId: ctx.ownerId,
event: "maintenance.updated",
data: { maintenance: data },
})
}
return NextResponse.json({ data })
}
+89
View File
@@ -0,0 +1,89 @@
import { NextResponse } from "next/server"
import { and, desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { maintenance_requests } from "@/lib/db/schema"
import { maintenanceSchema } from "@/lib/validations"
import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership"
import { resolveApiRequest } from "@/lib/api-auth"
import { emitWebhookEvent } from "@/lib/webhooks/emit"
// Public REST API (v1) — maintenance requests. Bearer API-key auth.
// Scoped by the resolved owner id.
const unauthorized = () =>
NextResponse.json({ error: { code: 401, message: "Unauthorized" } }, { status: 401 })
const forbidden = () =>
NextResponse.json({ error: { code: 403, message: "Forbidden" } }, { status: 403 })
const VALID_STATUSES = ["open", "in_progress", "resolved", "closed"]
const VALID_PRIORITIES = ["low", "medium", "high", "emergency"]
export async function GET(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
const { searchParams } = new URL(request.url)
const status = searchParams.get("status")
const priority = searchParams.get("priority")
const propertyId = searchParams.get("property_id")
if (status && !VALID_STATUSES.includes(status)) {
return NextResponse.json({ error: { code: 400, message: "Invalid status" } }, { status: 400 })
}
if (priority && !VALID_PRIORITIES.includes(priority)) {
return NextResponse.json({ error: { code: 400, message: "Invalid priority" } }, { status: 400 })
}
const data = await db.query.maintenance_requests.findMany({
where: and(
eq(maintenance_requests.user_id, ctx.ownerId),
status ? eq(maintenance_requests.status, status as typeof maintenance_requests.$inferSelect.status) : undefined,
priority ? eq(maintenance_requests.priority, priority as typeof maintenance_requests.$inferSelect.priority) : undefined,
propertyId ? eq(maintenance_requests.property_id, propertyId) : undefined
),
with: {
property: { columns: { name: true } },
unit: { columns: { unit_number: true } },
tenant: { columns: { first_name: true, last_name: true } },
},
orderBy: desc(maintenance_requests.created_at),
})
return NextResponse.json({ data, count: data.length })
}
export async function POST(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
if (!ctx.canWrite) return forbidden()
const body = await request.json().catch(() => null)
const parsed = maintenanceSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: { code: 400, message: parsed.error.flatten() } },
{ status: 400 }
)
}
if (
!(await ownsProperty(ctx.ownerId, parsed.data.property_id)) ||
!(await ownsUnit(ctx.ownerId, parsed.data.unit_id)) ||
!(await ownsTenant(ctx.ownerId, parsed.data.tenant_id))
) {
return forbidden()
}
const [data] = await db
.insert(maintenance_requests)
.values({ ...parsed.data, user_id: ctx.ownerId, status: "open" })
.returning()
await emitWebhookEvent({
ownerId: ctx.ownerId,
event: "maintenance.created",
data: { maintenance: data },
})
return NextResponse.json({ data }, { status: 201 })
}
+90
View File
@@ -0,0 +1,90 @@
import { NextResponse } from "next/server"
import { and, desc, eq, gte, lte } from "drizzle-orm"
import { db } from "@/lib/db"
import { rent_payments } from "@/lib/db/schema"
import { rentPaymentSchema } from "@/lib/validations"
import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership"
import { resolveApiRequest } from "@/lib/api-auth"
import { emitWebhookEvent } from "@/lib/webhooks/emit"
// Public REST API (v1) — rent payments. Bearer API-key auth. Maps to the
// rent_payments table / internal /api/rent logic. Scoped by resolved owner id.
const unauthorized = () =>
NextResponse.json({ error: { code: 401, message: "Unauthorized" } }, { status: 401 })
const forbidden = () =>
NextResponse.json({ error: { code: 403, message: "Forbidden" } }, { status: 403 })
const VALID_STATUSES = ["pending", "paid", "overdue", "partial", "waived"]
export async function GET(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
const { searchParams } = new URL(request.url)
const status = searchParams.get("status")
const tenantId = searchParams.get("tenant_id")
// Date-range filter on the payment's due_date ("YYYY-MM-DD").
const from = searchParams.get("from")
const to = searchParams.get("to")
if (status && !VALID_STATUSES.includes(status)) {
return NextResponse.json(
{ error: { code: 400, message: "Invalid status" } },
{ status: 400 }
)
}
const data = await db.query.rent_payments.findMany({
where: and(
eq(rent_payments.user_id, ctx.ownerId),
status ? eq(rent_payments.status, status as typeof rent_payments.$inferSelect.status) : undefined,
tenantId ? eq(rent_payments.tenant_id, tenantId) : undefined,
from ? gte(rent_payments.due_date, from) : undefined,
to ? lte(rent_payments.due_date, to) : undefined
),
with: {
tenant: { columns: { first_name: true, last_name: true, email: true } },
property: { columns: { name: true } },
unit: { columns: { unit_number: true } },
},
orderBy: desc(rent_payments.due_date),
})
return NextResponse.json({ data, count: data.length })
}
export async function POST(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
if (!ctx.canWrite) return forbidden()
const body = await request.json().catch(() => null)
const parsed = rentPaymentSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: { code: 400, message: parsed.error.flatten() } },
{ status: 400 }
)
}
if (
!(await ownsProperty(ctx.ownerId, parsed.data.property_id)) ||
!(await ownsUnit(ctx.ownerId, parsed.data.unit_id)) ||
!(await ownsTenant(ctx.ownerId, parsed.data.tenant_id))
) {
return forbidden()
}
const [data] = await db
.insert(rent_payments)
.values({ ...parsed.data, user_id: ctx.ownerId })
.returning()
await emitWebhookEvent({ ownerId: ctx.ownerId, event: "payment.recorded", data: { payment: data } })
if (data.status === "paid") {
await emitWebhookEvent({ ownerId: ctx.ownerId, event: "payment.paid", data: { payment: data } })
}
return NextResponse.json({ data }, { status: 201 })
}
+55
View File
@@ -0,0 +1,55 @@
import { NextResponse } from "next/server"
import { desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { properties } from "@/lib/db/schema"
import { propertySchema } from "@/lib/validations"
import { resolveApiRequest } from "@/lib/api-auth"
import { emitWebhookEvent } from "@/lib/webhooks/emit"
import { geocodeAddress } from "@/lib/geocoding"
// Public REST API (v1) — Bearer API-key auth. All data is scoped by the
// resolved account owner id (team-aware), never the raw session user.
const unauthorized = () =>
NextResponse.json({ error: { code: 401, message: "Unauthorized" } }, { status: 401 })
const forbidden = () =>
NextResponse.json({ error: { code: 403, message: "Forbidden" } }, { status: 403 })
export async function GET(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
const data = await db.query.properties.findMany({
where: eq(properties.user_id, ctx.ownerId),
with: { units: { columns: { id: true, status: true } } },
orderBy: desc(properties.created_at),
})
return NextResponse.json({ data, count: data.length })
}
export async function POST(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
if (!ctx.canWrite) return forbidden()
const body = await request.json().catch(() => null)
const parsed = propertySchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: { code: 400, message: parsed.error.flatten() } },
{ status: 400 }
)
}
const coords = await geocodeAddress(parsed.data)
const [data] = await db
.insert(properties)
.values({ ...parsed.data, user_id: ctx.ownerId, ...(coords ?? {}) })
.returning()
await emitWebhookEvent({ ownerId: ctx.ownerId, event: "property.created", data: { property: data } })
return NextResponse.json({ data }, { status: 201 })
}
+45
View File
@@ -0,0 +1,45 @@
import { NextResponse } from "next/server"
import { and, desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { tenants } from "@/lib/db/schema"
import { resolveApiRequest } from "@/lib/api-auth"
// Public REST API (v1) — Bearer API-key auth. Scoped by the resolved owner id.
const unauthorized = () =>
NextResponse.json({ error: { code: 401, message: "Unauthorized" } }, { status: 401 })
export async function GET(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
const { searchParams } = new URL(request.url)
const propertyId = searchParams.get("property_id")
const status = searchParams.get("status")
const validStatuses = ["active", "moved_out", "evicted"]
if (status && !validStatuses.includes(status)) {
return NextResponse.json(
{ error: { code: 400, message: "Invalid status" } },
{ status: 400 }
)
}
// Include unit + lease status so consumers can see each tenant's lease state,
// mirroring the internal /api/tenants route's enriched shape.
const data = await db.query.tenants.findMany({
where: and(
eq(tenants.user_id, ctx.ownerId),
status ? eq(tenants.status, status as typeof tenants.$inferSelect.status) : undefined,
propertyId ? eq(tenants.property_id, propertyId) : undefined
),
with: {
unit: { columns: { unit_number: true, rent_amount: true, status: true } },
property: { columns: { name: true } },
leases: { columns: { id: true, status: true, lease_start: true, lease_end: true } },
},
orderBy: desc(tenants.created_at),
})
return NextResponse.json({ data, count: data.length })
}
+106
View File
@@ -0,0 +1,106 @@
import { NextResponse } from "next/server"
import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { webhook_endpoints } from "@/lib/db/schema"
import { resolveApiRequest } from "@/lib/api-auth"
import { webhookEndpointSchema } from "@/lib/validations"
import { isWebhookEvent } from "@/lib/webhooks/events"
import { assertSafeWebhookUrl, WebhookUrlError } from "@/lib/webhooks/ssrf"
// Public REST API (v1) — read/update/delete a single webhook subscription.
// DELETE is what Zapier's REST-hook unsubscribe calls. Owner-scoped by id.
const unauthorized = () =>
NextResponse.json({ error: { code: 401, message: "Unauthorized" } }, { status: 401 })
const forbidden = () =>
NextResponse.json({ error: { code: 403, message: "Forbidden" } }, { status: 403 })
const notFound = () =>
NextResponse.json({ error: { code: 404, message: "Not found" } }, { status: 404 })
// Fields returned to API consumers (never the signing secret).
const RETURN_COLUMNS = {
id: webhook_endpoints.id,
url: webhook_endpoints.url,
description: webhook_endpoints.description,
events: webhook_endpoints.events,
status: webhook_endpoints.status,
source: webhook_endpoints.source,
created_at: webhook_endpoints.created_at,
}
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
const { id } = await params
const data = await db.query.webhook_endpoints.findFirst({
where: and(eq(webhook_endpoints.id, id), eq(webhook_endpoints.user_id, ctx.ownerId)),
columns: { secret: false },
})
if (!data) return notFound()
return NextResponse.json({ data })
}
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
if (!ctx.canWrite) return forbidden()
const { id } = await params
const body = (await request.json().catch(() => null)) as Record<string, unknown> | null
const parsed = webhookEndpointSchema.partial().safeParse(body ?? {})
if (!parsed.success) {
return NextResponse.json(
{ error: { code: 400, message: parsed.error.flatten() } },
{ status: 400 }
)
}
const patch: Partial<typeof webhook_endpoints.$inferInsert> = {}
if (parsed.data.url !== undefined) {
try {
await assertSafeWebhookUrl(parsed.data.url)
} catch (e) {
return NextResponse.json(
{
error: {
code: 400,
message: e instanceof WebhookUrlError ? e.message : "Invalid webhook URL",
},
},
{ status: 400 }
)
}
patch.url = parsed.data.url
}
if (parsed.data.events !== undefined) {
patch.events = Array.from(new Set(parsed.data.events.filter(isWebhookEvent)))
}
if (parsed.data.description !== undefined) patch.description = parsed.data.description || null
if (body?.status === "active" || body?.status === "disabled") patch.status = body.status
const [data] = await db
.update(webhook_endpoints)
.set(patch)
.where(and(eq(webhook_endpoints.id, id), eq(webhook_endpoints.user_id, ctx.ownerId)))
.returning(RETURN_COLUMNS)
if (!data) return notFound()
return NextResponse.json({ data })
}
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
if (!ctx.canWrite) return forbidden()
const { id } = await params
const [deleted] = await db
.delete(webhook_endpoints)
.where(and(eq(webhook_endpoints.id, id), eq(webhook_endpoints.user_id, ctx.ownerId)))
.returning({ id: webhook_endpoints.id })
if (!deleted) return notFound()
return NextResponse.json({ data: { id: deleted.id, deleted: true } })
}
+82
View File
@@ -0,0 +1,82 @@
import { NextResponse } from "next/server"
import { desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { webhook_endpoints } from "@/lib/db/schema"
import { resolveApiRequest } from "@/lib/api-auth"
import { webhookEndpointSchema } from "@/lib/validations"
import { isWebhookEvent } from "@/lib/webhooks/events"
import { assertSafeWebhookUrl, WebhookUrlError } from "@/lib/webhooks/ssrf"
import { generateWebhookSecret } from "@/lib/webhooks/deliver"
// Public REST API (v1) — outbound webhook subscriptions. Bearer API-key auth.
// This is the surface Zapier's REST Hooks use: POST here to subscribe, DELETE
// /:id to unsubscribe. Scoped by the resolved account owner id.
const unauthorized = () =>
NextResponse.json({ error: { code: 401, message: "Unauthorized" } }, { status: 401 })
const forbidden = () =>
NextResponse.json({ error: { code: 403, message: "Forbidden" } }, { status: 403 })
const badRequest = (message: unknown) =>
NextResponse.json({ error: { code: 400, message } }, { status: 400 })
// The signing secret is not returned on list/read (only at creation & rotation).
const PUBLIC_COLUMNS = {
id: webhook_endpoints.id,
url: webhook_endpoints.url,
description: webhook_endpoints.description,
events: webhook_endpoints.events,
status: webhook_endpoints.status,
source: webhook_endpoints.source,
last_success_at: webhook_endpoints.last_success_at,
last_error_at: webhook_endpoints.last_error_at,
failure_count: webhook_endpoints.failure_count,
created_at: webhook_endpoints.created_at,
}
export async function GET(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
const data = await db
.select(PUBLIC_COLUMNS)
.from(webhook_endpoints)
.where(eq(webhook_endpoints.user_id, ctx.ownerId))
.orderBy(desc(webhook_endpoints.created_at))
return NextResponse.json({ data, count: data.length })
}
export async function POST(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
if (!ctx.canWrite) return forbidden()
const body = await request.json().catch(() => null)
const parsed = webhookEndpointSchema.safeParse(body)
if (!parsed.success) return badRequest(parsed.error.flatten())
try {
await assertSafeWebhookUrl(parsed.data.url)
} catch (e) {
return badRequest(e instanceof WebhookUrlError ? e.message : "Invalid webhook URL")
}
// Requests coming through Zapier's REST-hook subscribe carry a Zapier UA.
const ua = request.headers.get("user-agent") ?? ""
const source = /zapier/i.test(ua) ? "zapier" : "api"
const [data] = await db
.insert(webhook_endpoints)
.values({
user_id: ctx.ownerId,
url: parsed.data.url,
description: parsed.data.description || null,
events: Array.from(new Set(parsed.data.events.filter(isWebhookEvent))),
secret: generateWebhookSecret(),
source,
})
.returning()
// Return the secret exactly once, at creation, so the subscriber can verify signatures.
return NextResponse.json({ data }, { status: 201 })
}
+13 -3
View File
@@ -4,11 +4,16 @@ import { db } from "@/lib/db"
import { vendors } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { ownsProperty } from "@/lib/db/ownership"
import { getAccountContext } from "@/lib/account"
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const { id } = await params
const body = await request.json()
@@ -20,7 +25,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
if (body.notes !== undefined) allowed.notes = body.notes || null
if (body.property_id !== undefined) allowed.property_id = body.property_id || null
if (!(await ownsProperty(user.id, allowed.property_id))) {
if (!(await ownsProperty(ownerId, allowed.property_id))) {
return NextResponse.json({ error: "Invalid reference" }, { status: 403 })
}
@@ -28,7 +33,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
const [data] = await db
.update(vendors)
.set(allowed)
.where(and(eq(vendors.id, id), eq(vendors.user_id, user.id)))
.where(and(eq(vendors.id, id), eq(vendors.user_id, ownerId)))
.returning()
return NextResponse.json(data)
@@ -40,7 +45,12 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
export async function DELETE(_: Request, { params }: { params: Promise<{ id: string }> }) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const { id } = await params
await db.delete(vendors).where(and(eq(vendors.id, id), eq(vendors.user_id, user.id)))
await db.delete(vendors).where(and(eq(vendors.id, id), eq(vendors.user_id, ownerId)))
return NextResponse.json({ ok: true })
}
+10 -3
View File
@@ -5,15 +5,18 @@ import { vendors } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { vendorSchema } from "@/lib/validations"
import { ownsProperty } from "@/lib/db/ownership"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
export async function GET() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id)
const data = await db
.select()
.from(vendors)
.where(eq(vendors.user_id, user.id))
.where(eq(vendors.user_id, ownerId))
.orderBy(asc(vendors.name))
return NextResponse.json(data)
@@ -23,13 +26,17 @@ export async function POST(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const body = await request.json()
const parsed = vendorSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid input" }, { status: 400 })
}
if (!(await ownsProperty(user.id, parsed.data.property_id))) {
if (!(await ownsProperty(ownerId, parsed.data.property_id))) {
return NextResponse.json({ error: "Invalid reference" }, { status: 403 })
}
@@ -37,7 +44,7 @@ export async function POST(request: Request) {
const [data] = await db
.insert(vendors)
.values({
user_id: user.id,
user_id: ownerId,
name: parsed.data.name,
trade: parsed.data.trade || null,
phone: parsed.data.phone || null,