chore: sync in-progress work across marketing, admin, API and tests

Snapshot of uncommitted work that had accumulated in the tree alongside
the Turnstile changes:

- marketing pages, SEO helpers (lib/seo.ts, lib/marketing/) and
  structured data
- admin billing actions and a per-user portfolio view, plus an admin
  error boundary
- rate limiting (lib/rate-limit.ts) applied across the /api/v1 surface
- CSP and proxy adjustments, accounting/webhook lib updates
- Playwright config and an e2e/unit test suite
- next bumped to ^16.3.4 with the lockfile regenerated
- generated AGENTS.md / CLAUDE.md

Authored by other sessions working in this tree; committed here so the
Turnstile work could be pushed without leaving the tree dirty.
Typecheck passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-09-05 16:27:16 -04:00
co-authored by Claude Opus 5
parent 8f90347659
commit 1d02598786
71 changed files with 3641 additions and 819 deletions
+101
View File
@@ -401,6 +401,107 @@ export async function getUserDetail(id: string) {
}
}
// ── per-user portfolio (admin support view) ───────────────────────────────────
// Read-only window into ONE user's actual records. Before this existed an admin
// could see only aggregate counts, so answering "what does this customer
// actually have?" meant impersonating them — which mutates their session and
// shows up in their own audit trail. This is deliberately read-only: it answers
// support questions without touching anything.
//
// Every query is scoped by user_id. Admin queries bypass the app's normal
// ownership scoping, so the caller MUST have passed requireAdmin()/getAdminSession().
export async function getUserPortfolio(userId: string) {
const [propertyRows, unitRows, tenantRows, leaseRows, paymentRows, maintenanceRows] =
await Promise.all([
db
.select({
id: properties.id,
name: properties.name,
address_line1: properties.address_line1,
city: properties.city,
state: properties.state,
total_units: properties.total_units,
created_at: properties.created_at,
})
.from(properties)
.where(eq(properties.user_id, userId))
.orderBy(desc(properties.created_at))
.limit(100),
db
.select({
id: units.id,
property_id: units.property_id,
unit_number: units.unit_number,
rent_amount: units.rent_amount,
status: units.status,
})
.from(units)
.where(eq(units.user_id, userId))
.orderBy(desc(units.created_at))
.limit(200),
db
.select({
id: tenants.id,
first_name: tenants.first_name,
last_name: tenants.last_name,
email: tenants.email,
phone: tenants.phone,
status: tenants.status,
move_in_date: tenants.move_in_date,
})
.from(tenants)
.where(eq(tenants.user_id, userId))
.orderBy(desc(tenants.created_at))
.limit(100),
db
.select({
id: leases.id,
tenant_id: leases.tenant_id,
lease_start: leases.lease_start,
lease_end: leases.lease_end,
rent_amount: leases.rent_amount,
status: leases.status,
})
.from(leases)
.where(eq(leases.user_id, userId))
.orderBy(desc(leases.created_at))
.limit(100),
db
.select({
id: rent_payments.id,
amount: rent_payments.amount,
due_date: rent_payments.due_date,
paid_date: rent_payments.paid_date,
status: rent_payments.status,
})
.from(rent_payments)
.where(eq(rent_payments.user_id, userId))
.orderBy(desc(rent_payments.due_date))
.limit(50),
db
.select({
id: maintenance_requests.id,
title: maintenance_requests.title,
priority: maintenance_requests.priority,
status: maintenance_requests.status,
created_at: maintenance_requests.created_at,
})
.from(maintenance_requests)
.where(eq(maintenance_requests.user_id, userId))
.orderBy(desc(maintenance_requests.created_at))
.limit(50),
])
return {
properties: propertyRows,
units: unitRows,
tenants: tenantRows,
leases: leaseRows,
payments: paymentRows,
maintenance: maintenanceRows,
}
}
// ── CSV helpers (shared by admin export routes) ─────────────────────────────────
export function toCsv(headers: string[], rows: (string | number | null | undefined)[][]) {
const esc = (v: string | number | null | undefined) => {