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:
co-authored by
Claude Opus 4.8
parent
969d5d4c8a
commit
c9968531e4
@@ -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
@@ -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 } })
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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,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: {
|
||||
|
||||
@@ -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,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,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 } },
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
])
|
||||
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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}`,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 } })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
Vendored
+13
-3
@@ -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 })
|
||||
}
|
||||
|
||||
Vendored
+10
-3
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user