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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-07-02 13:42:34 -04:00
co-authored by Claude Opus 4.8
parent 969d5d4c8a
commit c9968531e4
282 changed files with 41530 additions and 4013 deletions
+53
View File
@@ -0,0 +1,53 @@
import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { account_members } from "@/lib/db/schema"
export type AccountRole = "owner" | "member" | "viewer"
export type AccountContext = {
/** The logged-in user. */
userId: string
/** Whose portfolio the user operates on — themselves if they're an owner. */
ownerId: string
role: AccountRole
isOwner: boolean
/** Owners and members can write; viewers are read-only. */
canWrite: boolean
}
/**
* Resolve the account a user operates under (TEAM ACCESS).
*
* If the user is an ACTIVE member of another owner's account, they act on that
* owner's data; otherwise they own their own account. A user can be an active
* member of at most one account. This is the single source of truth for team
* scoping — data queries must scope by `ownerId`, not the raw session user id.
*
* Fails safe: on any error it returns the user as their own owner (they only
* ever see their own data), never someone else's.
*/
export async function getAccountContext(userId: string): Promise<AccountContext> {
try {
const membership = await db.query.account_members.findFirst({
where: and(eq(account_members.member_id, userId), eq(account_members.status, "active")),
})
if (membership) {
const role: AccountRole = membership.role === "viewer" ? "viewer" : "member"
return {
userId,
ownerId: membership.owner_id,
role,
isOwner: false,
canWrite: role !== "viewer",
}
}
} catch {
// fall through to self-owned context
}
return { userId, ownerId: userId, role: "owner", isOwner: true, canWrite: true }
}
/** The user_id that data queries/ownership checks should be scoped by. */
export async function getEffectiveOwnerId(userId: string): Promise<string> {
return (await getAccountContext(userId)).ownerId
}
+142
View File
@@ -0,0 +1,142 @@
import { and, eq, gt } from "drizzle-orm"
import { db } from "@/lib/db"
import { accounting_connections, rent_payments, expenses } from "@/lib/db/schema"
import { encrypt, decrypt } from "@/lib/crypto"
import { quickbooks } from "./quickbooks"
import { xero } from "./xero"
import type { AccountingProvider, OAuthTokens, Provider } from "./types"
export type { Provider } from "./types"
const PROVIDERS: Record<Provider, AccountingProvider> = { quickbooks, xero }
export function getProvider(id: string): AccountingProvider | null {
return id === "quickbooks" || id === "xero" ? PROVIDERS[id] : null
}
export function listProviders() {
return (Object.keys(PROVIDERS) as Provider[]).map((id) => ({
id,
label: PROVIDERS[id].label,
configured: PROVIDERS[id].configured(),
}))
}
/** Upsert an encrypted connection for (owner, provider). */
export async function saveConnection(ownerId: string, provider: Provider, tokens: OAuthTokens) {
const values = {
user_id: ownerId,
provider,
access_token: encrypt(tokens.accessToken),
refresh_token: encrypt(tokens.refreshToken),
expires_at: tokens.expiresAt,
realm_id: tokens.realmId,
org_name: tokens.orgName,
status: "active" as const,
last_error: null,
}
const existing = await db.query.accounting_connections.findFirst({
where: and(eq(accounting_connections.user_id, ownerId), eq(accounting_connections.provider, provider)),
columns: { id: true },
})
if (existing) {
await db.update(accounting_connections).set({ ...values, updated_at: new Date().toISOString() }).where(eq(accounting_connections.id, existing.id))
} else {
await db.insert(accounting_connections).values(values)
}
}
export async function getConnection(ownerId: string, provider: Provider) {
return db.query.accounting_connections.findFirst({
where: and(eq(accounting_connections.user_id, ownerId), eq(accounting_connections.provider, provider)),
})
}
export async function listConnections(ownerId: string) {
const rows = await db.query.accounting_connections.findMany({ where: eq(accounting_connections.user_id, ownerId) })
// Never leak tokens to callers.
return rows.map((r) => ({ provider: r.provider, orgName: r.org_name, status: r.status, lastSyncAt: r.last_sync_at, lastError: r.last_error }))
}
export async function disconnect(ownerId: string, provider: Provider) {
await db.delete(accounting_connections).where(and(eq(accounting_connections.user_id, ownerId), eq(accounting_connections.provider, provider)))
}
/** Load tokens, refreshing (and persisting) if the access token is near expiry. */
async function getValidTokens(ownerId: string, provider: Provider): Promise<OAuthTokens | null> {
const conn = await getConnection(ownerId, provider)
if (!conn) return null
let tokens: OAuthTokens = {
accessToken: decrypt(conn.access_token),
refreshToken: decrypt(conn.refresh_token),
expiresAt: conn.expires_at,
realmId: conn.realm_id,
orgName: conn.org_name,
}
const nearExpiry = conn.expires_at && new Date(conn.expires_at).getTime() - Date.now() < 60_000
if (nearExpiry) {
tokens = await getProvider(provider)!.refresh(tokens)
await saveConnection(ownerId, provider, tokens)
}
return tokens
}
/**
* One-way sync: push rent income + expenses created since the last sync into the
* connected provider. Uses a created_at watermark for idempotency (records synced
* once are not re-pushed).
*/
export async function syncNow(ownerId: string, provider: Provider): Promise<{ income: number; expense: number }> {
const prov = getProvider(provider)
if (!prov) throw new Error("Unknown provider")
const conn = await getConnection(ownerId, provider)
if (!conn) throw new Error("Not connected")
try {
const tokens = (await getValidTokens(ownerId, provider))!
const since = conn.last_sync_at ?? "1970-01-01T00:00:00.000Z"
const paid = await db.query.rent_payments.findMany({
where: and(eq(rent_payments.user_id, ownerId), eq(rent_payments.status, "paid"), gt(rent_payments.created_at, since)),
columns: { id: true, amount: true, paid_date: true, due_date: true, tenant_id: true },
with: { tenant: { columns: { first_name: true, last_name: true } } },
})
const exp = await db.query.expenses.findMany({
where: and(eq(expenses.user_id, ownerId), gt(expenses.created_at, since)),
columns: { id: true, amount: true, expense_date: true, category: true, vendor: true, description: true },
})
const income = await prov.pushIncome(
tokens,
paid.map((p) => ({
externalId: p.id,
date: (p.paid_date ?? p.due_date ?? "").slice(0, 10),
amount: Number(p.amount),
customerName: `${p.tenant?.first_name ?? ""} ${p.tenant?.last_name ?? ""}`.trim() || "Tenant",
description: `Rent payment`,
}))
)
const expense = await prov.pushExpense(
tokens,
exp.map((e) => ({
externalId: e.id,
date: (e.expense_date ?? "").slice(0, 10),
amount: Number(e.amount),
category: e.category ?? "General",
vendor: e.vendor ?? null,
description: e.description ?? e.category ?? "Expense",
}))
)
await db
.update(accounting_connections)
.set({ last_sync_at: new Date().toISOString(), status: "active", last_error: null, updated_at: new Date().toISOString() })
.where(eq(accounting_connections.id, conn.id))
return { income: income.synced, expense: expense.synced }
} catch (e) {
const msg = (e as Error).message.slice(0, 500)
await db.update(accounting_connections).set({ status: "error", last_error: msg, updated_at: new Date().toISOString() }).where(eq(accounting_connections.id, conn.id))
throw new Error(msg)
}
}
+127
View File
@@ -0,0 +1,127 @@
import type { AccountingProvider, OAuthTokens, IncomeEntry, ExpenseEntry } from "./types"
import { redirectUri } from "./types"
// QuickBooks Online. Docs: https://developer.intuit.com/app/developer/qbo/docs/develop
const CLIENT_ID = process.env.QBO_CLIENT_ID ?? ""
const CLIENT_SECRET = process.env.QBO_CLIENT_SECRET ?? ""
const ENV = (process.env.QBO_ENVIRONMENT ?? "sandbox").toLowerCase()
const API_BASE = ENV === "production" ? "https://quickbooks.api.intuit.com" : "https://sandbox-quickbooks.api.intuit.com"
const TOKEN_URL = "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer"
const MINOR = "73"
function basicAuth() {
return "Basic " + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")
}
async function tokenRequest(form: Record<string, string>): Promise<OAuthTokens> {
const res = await fetch(TOKEN_URL, {
method: "POST",
headers: { Authorization: basicAuth(), "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
body: new URLSearchParams(form),
})
if (!res.ok) throw new Error(`QuickBooks token error ${res.status}: ${await res.text()}`)
const j = (await res.json()) as { access_token: string; refresh_token: string; expires_in: number }
return {
accessToken: j.access_token,
refreshToken: j.refresh_token,
expiresAt: new Date(Date.now() + j.expires_in * 1000).toISOString(),
realmId: null,
orgName: null,
}
}
async function api(tokens: OAuthTokens, path: string, init?: RequestInit) {
const res = await fetch(`${API_BASE}/v3/company/${tokens.realmId}/${path}${path.includes("?") ? "&" : "?"}minorversion=${MINOR}`, {
...init,
headers: { Authorization: `Bearer ${tokens.accessToken}`, Accept: "application/json", "Content-Type": "application/json", ...(init?.headers ?? {}) },
})
if (!res.ok) throw new Error(`QuickBooks API ${res.status}: ${(await res.text()).slice(0, 300)}`)
return res.json()
}
async function query<T = Record<string, unknown>>(tokens: OAuthTokens, q: string): Promise<T[]> {
const j = await api(tokens, `query?query=${encodeURIComponent(q)}`)
const key = Object.keys(j.QueryResponse ?? {}).find((k) => Array.isArray(j.QueryResponse[k]))
return key ? j.QueryResponse[key] : []
}
export const quickbooks: AccountingProvider = {
id: "quickbooks",
label: "QuickBooks Online",
configured: () => Boolean(CLIENT_ID && CLIENT_SECRET),
getAuthUrl(state) {
const p = new URLSearchParams({
client_id: CLIENT_ID,
response_type: "code",
scope: "com.intuit.quickbooks.accounting",
redirect_uri: redirectUri("quickbooks"),
state,
})
return `https://appcenter.intuit.com/connect/oauth2?${p.toString()}`
},
async exchangeCode(code, realmId) {
const tokens = await tokenRequest({ grant_type: "authorization_code", code, redirect_uri: redirectUri("quickbooks") })
tokens.realmId = realmId ?? null
try {
const info = await api(tokens, "companyinfo/" + tokens.realmId)
tokens.orgName = info?.CompanyInfo?.CompanyName ?? null
} catch {
/* name is best-effort */
}
return tokens
},
async refresh(tokens) {
const next = await tokenRequest({ grant_type: "refresh_token", refresh_token: tokens.refreshToken })
next.realmId = tokens.realmId
next.orgName = tokens.orgName
return next
},
async pushIncome(tokens, entries) {
if (!entries.length) return { synced: 0 }
// Post each rent payment as a SalesReceipt against the first Service item.
const items = await query<{ Id: string }>(tokens, "SELECT Id FROM Item WHERE Type='Service' MAXRESULTS 1")
const itemRef = items[0]?.Id
if (!itemRef) throw new Error("No QuickBooks Service item found to record income against")
let synced = 0
for (const e of entries) {
await api(tokens, "salesreceipt", {
method: "POST",
body: JSON.stringify({
TxnDate: e.date,
PrivateNote: `${e.description} [pmn:${e.externalId}]`,
Line: [{ Amount: e.amount, DetailType: "SalesItemLineDetail", Description: e.description, SalesItemLineDetail: { ItemRef: { value: itemRef }, Qty: 1, UnitPrice: e.amount } }],
}),
})
synced++
}
return { synced }
},
async pushExpense(tokens, entries) {
if (!entries.length) return { synced: 0 }
const banks = await query<{ Id: string }>(tokens, "SELECT Id FROM Account WHERE AccountType='Bank' MAXRESULTS 1")
const expenses = await query<{ Id: string }>(tokens, "SELECT Id FROM Account WHERE AccountType='Expense' MAXRESULTS 1")
const bankRef = banks[0]?.Id
const expRef = expenses[0]?.Id
if (!bankRef || !expRef) throw new Error("No QuickBooks Bank/Expense account found to record expenses against")
let synced = 0
for (const e of entries) {
await api(tokens, "purchase", {
method: "POST",
body: JSON.stringify({
PaymentType: "Cash",
AccountRef: { value: bankRef },
TxnDate: e.date,
PrivateNote: `${e.description} [pmn:${e.externalId}]`,
Line: [{ Amount: e.amount, DetailType: "AccountBasedExpenseLineDetail", Description: e.description, AccountBasedExpenseLineDetail: { AccountRef: { value: expRef } } }],
}),
})
synced++
}
return { synced }
},
}
+23
View File
@@ -0,0 +1,23 @@
import crypto from "crypto"
// Signed OAuth `state` (HMAC-SHA256) — carries the initiating owner + provider
// and is tamper-proof, so the callback can't be forged/CSRF'd.
const SECRET = process.env.BETTER_AUTH_SECRET ?? "dev-secret"
export function signState(data: { ownerId: string; provider: string }): string {
const payload = Buffer.from(JSON.stringify(data)).toString("base64url")
const sig = crypto.createHmac("sha256", SECRET).update(payload).digest("base64url")
return `${payload}.${sig}`
}
export function verifyState(state: string): { ownerId: string; provider: string } | null {
const [payload, sig] = state.split(".")
if (!payload || !sig) return null
const expect = crypto.createHmac("sha256", SECRET).update(payload).digest("base64url")
if (sig.length !== expect.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect))) return null
try {
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))
} catch {
return null
}
}
+50
View File
@@ -0,0 +1,50 @@
export type Provider = "quickbooks" | "xero"
export interface OAuthTokens {
accessToken: string
refreshToken: string
/** ISO timestamp of access-token expiry, or null. */
expiresAt: string | null
/** QuickBooks realmId / Xero tenantId. */
realmId: string | null
orgName: string | null
}
export interface IncomeEntry {
externalId: string
date: string // YYYY-MM-DD
amount: number
customerName: string
description: string
}
export interface ExpenseEntry {
externalId: string
date: string // YYYY-MM-DD
amount: number
category: string
vendor: string | null
description: string
}
export interface AccountingProvider {
id: Provider
label: string
/** True when the provider's OAuth app credentials are configured in env. */
configured(): boolean
/** Build the provider's OAuth authorize URL. */
getAuthUrl(state: string): string
/** Exchange an authorization code for tokens. `realmId` is QBO's callback param. */
exchangeCode(code: string, realmId?: string | null): Promise<OAuthTokens>
/** Refresh an expired access token; returns the new token set (refresh token may rotate). */
refresh(tokens: OAuthTokens): Promise<OAuthTokens>
/** Push income (rent) into the books. Returns how many were written. */
pushIncome(tokens: OAuthTokens, entries: IncomeEntry[]): Promise<{ synced: number }>
/** Push expenses into the books. */
pushExpense(tokens: OAuthTokens, entries: ExpenseEntry[]): Promise<{ synced: number }>
}
export function redirectUri(provider: Provider): string {
const base = (process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000").replace(/\/+$/, "")
return `${base}/api/integrations/${provider}/callback`
}
+114
View File
@@ -0,0 +1,114 @@
import type { AccountingProvider, OAuthTokens, IncomeEntry, ExpenseEntry } from "./types"
import { redirectUri } from "./types"
// Xero. Docs: https://developer.xero.com/documentation/guides/oauth2/
const CLIENT_ID = process.env.XERO_CLIENT_ID ?? ""
const CLIENT_SECRET = process.env.XERO_CLIENT_SECRET ?? ""
const TOKEN_URL = "https://identity.xero.com/connect/token"
const API_BASE = "https://api.xero.com/api.xro/2.0"
const SCOPES = "openid profile email accounting.transactions accounting.contacts offline_access"
// Standard default account codes (present in most Xero orgs).
const SALES_CODE = process.env.XERO_SALES_ACCOUNT_CODE ?? "200"
const EXPENSE_CODE = process.env.XERO_EXPENSE_ACCOUNT_CODE ?? "400"
function basicAuth() {
return "Basic " + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")
}
async function tokenRequest(form: Record<string, string>): Promise<OAuthTokens> {
const res = await fetch(TOKEN_URL, {
method: "POST",
headers: { Authorization: basicAuth(), "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(form),
})
if (!res.ok) throw new Error(`Xero token error ${res.status}: ${await res.text()}`)
const j = (await res.json()) as { access_token: string; refresh_token: string; expires_in: number }
return {
accessToken: j.access_token,
refreshToken: j.refresh_token,
expiresAt: new Date(Date.now() + j.expires_in * 1000).toISOString(),
realmId: null,
orgName: null,
}
}
async function api(tokens: OAuthTokens, path: string, init?: RequestInit) {
const res = await fetch(`${API_BASE}/${path}`, {
...init,
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
"Xero-tenant-id": tokens.realmId ?? "",
Accept: "application/json",
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
})
if (!res.ok) throw new Error(`Xero API ${res.status}: ${(await res.text()).slice(0, 300)}`)
return res.json()
}
export const xero: AccountingProvider = {
id: "xero",
label: "Xero",
configured: () => Boolean(CLIENT_ID && CLIENT_SECRET),
getAuthUrl(state) {
const p = new URLSearchParams({
response_type: "code",
client_id: CLIENT_ID,
redirect_uri: redirectUri("xero"),
scope: SCOPES,
state,
})
return `https://login.xero.com/identity/connect/authorize?${p.toString()}`
},
async exchangeCode(code) {
const tokens = await tokenRequest({ grant_type: "authorization_code", code, redirect_uri: redirectUri("xero") })
// Resolve the connected organisation (tenant).
const res = await fetch("https://api.xero.com/connections", { headers: { Authorization: `Bearer ${tokens.accessToken}`, Accept: "application/json" } })
const conns = (await res.json()) as Array<{ tenantId: string; tenantName: string }>
tokens.realmId = conns[0]?.tenantId ?? null
tokens.orgName = conns[0]?.tenantName ?? null
return tokens
},
async refresh(tokens) {
const next = await tokenRequest({ grant_type: "refresh_token", refresh_token: tokens.refreshToken })
next.realmId = tokens.realmId
next.orgName = tokens.orgName
return next
},
async pushIncome(tokens, entries) {
if (!entries.length) return { synced: 0 }
// ACCREC invoices (sales) against the standard sales account.
const invoices = entries.map((e) => ({
Type: "ACCREC",
Contact: { Name: e.customerName },
Date: e.date,
DueDate: e.date,
Status: "AUTHORISED",
Reference: `pmn:${e.externalId}`,
LineItems: [{ Description: e.description, Quantity: 1, UnitAmount: e.amount, AccountCode: SALES_CODE }],
}))
await api(tokens, "Invoices", { method: "POST", body: JSON.stringify({ Invoices: invoices }) })
return { synced: entries.length }
},
async pushExpense(tokens, entries) {
if (!entries.length) return { synced: 0 }
// ACCPAY invoices (bills) against the standard expense account.
const invoices = entries.map((e) => ({
Type: "ACCPAY",
Contact: { Name: e.vendor || "Expense" },
Date: e.date,
DueDate: e.date,
Status: "AUTHORISED",
Reference: `pmn:${e.externalId}`,
LineItems: [{ Description: e.description, Quantity: 1, UnitAmount: e.amount, AccountCode: EXPENSE_CODE }],
}))
await api(tokens, "Invoices", { method: "POST", body: JSON.stringify({ Invoices: invoices }) })
return { synced: entries.length }
},
}
+1
View File
@@ -11,6 +11,7 @@ export type AdminAction =
| "stop_impersonate"
| "delete_user"
| "resend_verification"
| "maintenance_mode"
/**
* Append one immutable row to admin_audit_log. Call this for EVERY mutating
+9 -3
View File
@@ -4,7 +4,9 @@ import { usage_events, profiles } from "@/lib/db/schema"
import { PLAN_LIMITS } from "@/lib/stripe/plans"
import type { Plan } from "@/types"
type QuotaResult = { ok: true } | { ok: false; status: number; error: string }
type QuotaResult =
| { ok: true; used: number; limit: number }
| { ok: false; status: number; error: string }
// Sentinel for "effectively unlimited" plans. PLAN_LIMITS uses Infinity for
// some limits; treat Infinity (or anything absurdly large) as uncapped so we
@@ -42,7 +44,11 @@ export async function enforceAiQuota(
// Unlimited plans: skip counting, but still record the event for analytics.
if (!Number.isFinite(limit) || limit >= UNLIMITED_THRESHOLD) {
await db.insert(usage_events).values({ user_id: userId, event_type: eventType })
return { ok: true }
const [{ count }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(usage_events)
.where(and(eq(usage_events.user_id, userId), gte(usage_events.created_at, monthStart.toISOString())))
return { ok: true, used: count, limit }
}
// Sentinel thrown from inside the transaction to signal an over-limit
@@ -74,7 +80,7 @@ export async function enforceAiQuota(
await tx.insert(usage_events).values({ user_id: userId, event_type: eventType })
return { ok: true } as QuotaResult
return { ok: true, used: count + 1, limit } as QuotaResult
})
} catch (err) {
if (err === overLimit) return overLimit
+78
View File
@@ -0,0 +1,78 @@
import { createHash, randomBytes } from "crypto"
import { eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { api_keys } from "@/lib/db/schema"
import { getAccountContext } from "@/lib/account"
// ============================================================================
// Public API authentication (Bearer API keys for /api/v1).
//
// Keys look like `pmn_live_<48 hex chars>`. We persist ONLY the SHA-256 hash;
// the plaintext is returned once at creation and never stored. Lookups hash the
// presented token and match on the unique key_hash column.
// ============================================================================
const KEY_PREFIX = "pmn_live_"
/** Generate a new API key. Returns the one-time plaintext plus what to store. */
export function generateApiKey(): { plaintext: string; hash: string; prefix: string } {
const secret = randomBytes(24).toString("hex") // 48 hex chars
const plaintext = `${KEY_PREFIX}${secret}`
return {
plaintext,
hash: hashApiKey(plaintext),
// Non-secret display identifier, e.g. "pmn_live_ab12cd34…"
prefix: `${KEY_PREFIX}${secret.slice(0, 8)}`,
}
}
export function hashApiKey(key: string): string {
return createHash("sha256").update(key).digest("hex")
}
/** Extract a Bearer token from the Authorization header, or null. */
function bearerToken(request: Request): string | null {
const header = request.headers.get("authorization") ?? ""
const m = /^Bearer\s+(.+)$/i.exec(header.trim())
const token = m?.[1]?.trim()
return token ? token : null
}
export type ApiContext = {
/** The user the API key belongs to. */
userId: string
/** Whose portfolio to scope data by (team-aware). */
ownerId: string
/** False for viewer-role memberships. */
canWrite: boolean
}
/**
* Resolve an incoming API request to its account context, or null if the
* Bearer key is missing/invalid/revoked. Also best-effort stamps last_used_at.
* Data queries MUST scope by the returned `ownerId`, mirroring the session
* routes' use of getEffectiveOwnerId.
*/
export async function resolveApiRequest(request: Request): Promise<ApiContext | null> {
const token = bearerToken(request)
if (!token) return null
const row = await db.query.api_keys.findFirst({
where: eq(api_keys.key_hash, hashApiKey(token)),
columns: { id: true, user_id: true, revoked_at: true },
})
if (!row || row.revoked_at) return null
// Best-effort usage timestamp; never block the request on it.
try {
await db
.update(api_keys)
.set({ last_used_at: new Date().toISOString() })
.where(eq(api_keys.id, row.id))
} catch {
/* ignore */
}
const ctx = await getAccountContext(row.user_id)
return { userId: row.user_id, ownerId: ctx.ownerId, canWrite: ctx.canWrite }
}
+5 -39
View File
@@ -4,7 +4,7 @@ import { nextCookies } from "better-auth/next-js"
import { admin } from "better-auth/plugins"
import { db } from "@/lib/db"
import { user, session, account, verification, profiles } from "@/lib/db/schema"
import { sendEmail } from "@/lib/email/send"
import { sendEmail, resetPasswordHtml, verifyEmailHtml } from "@/lib/email/send"
// Bootstrap superadmins from env — no API path lets a user self-promote.
const ADMIN_USER_IDS = (process.env.ADMIN_USER_IDS ?? "")
@@ -22,7 +22,7 @@ export const auth = betterAuth({
emailAndPassword: {
enabled: true,
// Env-gated so production can require a verified email without breaking
// local dev (where RESEND is typically unconfigured). Set
// local dev (where SMTP is typically unconfigured). Set
// REQUIRE_EMAIL_VERIFICATION=true in production to enforce.
requireEmailVerification: process.env.REQUIRE_EMAIL_VERIFICATION === "true",
minPasswordLength: 8,
@@ -38,6 +38,9 @@ export const auth = betterAuth({
// is gated by REQUIRE_EMAIL_VERIFICATION (see emailAndPassword above).
emailVerification: {
sendOnSignUp: true,
// After the user clicks the verification link, sign them in and send them
// to the callbackURL (set to /dashboard on sign-up).
autoSignInAfterVerification: true,
sendVerificationEmail: async ({ user: u, url }) => {
await sendEmail({
to: u.email,
@@ -88,40 +91,3 @@ export const auth = betterAuth({
],
})
function resetPasswordHtml(url: string) {
return `
<!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;">
<h1 style="font-size: 20px; margin: 0 0 8px; color: #fff;">Reset your password</h1>
<p style="color: rgba(255,255,255,0.6); margin: 0 0 24px;">
Click the button below to choose a new password. If you didn't request this, you can ignore this email.
</p>
<a href="${url}" style="display: inline-block; background: #6366f1; color: #fff; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">
Reset Password
</a>
<p style="color: rgba(255,255,255,0.4); font-size: 12px; margin: 24px 0 0;">Property Management Network</p>
</div>
</body>
</html>`
}
function verifyEmailHtml(url: string) {
return `
<!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;">
<h1 style="font-size: 20px; margin: 0 0 8px; color: #fff;">Verify your email</h1>
<p style="color: rgba(255,255,255,0.6); margin: 0 0 24px;">
Confirm your email address to finish setting up your account. If you didn't create an account, you can ignore this email.
</p>
<a href="${url}" style="display: inline-block; background: #6366f1; color: #fff; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">
Verify Email
</a>
<p style="color: rgba(255,255,255,0.4); font-size: 12px; margin: 24px 0 0;">Property Management Network</p>
</div>
</body>
</html>`
}
+75
View File
@@ -0,0 +1,75 @@
import { eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { profiles } from "@/lib/db/schema"
import { PLAN_LIMITS } from "@/lib/stripe/plans"
import type { Plan } from "@/types"
export type Branding = {
brandName: string | null
logoUrl: string | null
color: string | null
hidePoweredBy: boolean
/** True only when the owner's plan includes white-label (landlord/lifetime). */
enabled: boolean
}
const DEFAULT_BRANDING: Branding = {
brandName: null,
logoUrl: null,
color: null,
hidePoweredBy: false,
enabled: false,
}
/**
* Loads a landlord's white-label branding for a given owner user id.
*
* Branding only applies when that owner's plan includes white-label
* (landlord/lifetime via PLAN_LIMITS[plan].hasWhiteLabel). A downgraded user's
* stored branding is preserved in the DB but `enabled` returns false, so callers
* fall back to the default (unbranded) look. Fails safe to unbranded on error.
*/
export async function getBranding(ownerUserId: string): Promise<Branding> {
if (!ownerUserId) return DEFAULT_BRANDING
try {
const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, ownerUserId),
columns: {
plan: true,
brand_name: true,
brand_logo_url: true,
brand_color: true,
hide_powered_by: true,
},
})
if (!profile) return DEFAULT_BRANDING
const plan = (profile.plan ?? "starter") as Plan
const enabled = PLAN_LIMITS[plan]?.hasWhiteLabel === true
if (!enabled) return DEFAULT_BRANDING
return {
brandName: profile.brand_name?.trim() || null,
logoUrl: profile.brand_logo_url?.trim() || null,
color: profile.brand_color?.trim() || null,
hidePoweredBy: profile.hide_powered_by === true,
enabled: true,
}
} catch {
return DEFAULT_BRANDING
}
}
/** Matches a 6-digit hex color like #RRGGBB (case-insensitive). */
export const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/
/** Returns a normalized #RRGGBB hex string, or null if invalid/empty. */
export function normalizeHexColor(input: string | null | undefined): string | null {
if (!input) return null
const value = input.trim()
if (!value) return null
return HEX_COLOR_RE.test(value) ? value.toLowerCase() : null
}
+27
View File
@@ -0,0 +1,27 @@
import crypto from "crypto"
// AES-256-GCM encryption for secrets at rest (OAuth tokens). The key is derived
// from ACCOUNTING_ENCRYPTION_KEY, falling back to BETTER_AUTH_SECRET, via SHA-256
// so no additional configuration is required.
function getKey(): Buffer {
const secret = process.env.ACCOUNTING_ENCRYPTION_KEY || process.env.BETTER_AUTH_SECRET
if (!secret) throw new Error("No encryption key configured (BETTER_AUTH_SECRET)")
return crypto.createHash("sha256").update(secret).digest()
}
/** Encrypt a UTF-8 string → "iv:tag:ciphertext" (all base64). */
export function encrypt(plaintext: string): string {
const iv = crypto.randomBytes(12)
const cipher = crypto.createCipheriv("aes-256-gcm", getKey(), iv)
const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()])
const tag = cipher.getAuthTag()
return [iv.toString("base64"), tag.toString("base64"), enc.toString("base64")].join(":")
}
/** Decrypt a value produced by encrypt(). */
export function decrypt(payload: string): string {
const [ivB64, tagB64, dataB64] = payload.split(":")
const decipher = crypto.createDecipheriv("aes-256-gcm", getKey(), Buffer.from(ivB64, "base64"))
decipher.setAuthTag(Buffer.from(tagB64, "base64"))
return Buffer.concat([decipher.update(Buffer.from(dataB64, "base64")), decipher.final()]).toString("utf8")
}
+2 -1
View File
@@ -254,7 +254,8 @@ export function getEnvHealth() {
"STRIPE_WEBHOOK_SECRET",
"NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY",
"OPENAI_API_KEY",
"RESEND_API_KEY",
"SMTP_HOST",
"SMTP_USER",
"GOOGLE_CLIENT_ID",
"CRON_SECRET",
"NEXT_PUBLIC_APP_URL",
+3 -3
View File
@@ -3,9 +3,9 @@ import { Pool, types } from "pg"
import * as schema from "./schema"
// ── pg type parsers ───────────────────────────────────────────────
// Make the driver return the same value shapes the app relied on under
// Supabase/PostgREST, so the ~hundreds of existing read sites keep working:
// numeric -> JS number (was parsed as number by PostgREST)
// Make the driver return the value shapes the app relies on across its
// ~hundreds of read sites:
// numeric -> JS number
// date -> "YYYY-MM-DD" string
// timestamp / timestamptz -> ISO 8601 string
types.setTypeParser(1700, (v) => (v === null ? null : parseFloat(v))) // numeric
@@ -0,0 +1,33 @@
-- NOTE: made idempotent (IF NOT EXISTS / guarded constraint) by hand.
-- The admin tables/columns below were previously applied to some databases via
-- `drizzle-kit push` without a migration file, so migration history had drifted.
-- Guarding these statements lets 0001 provision a fresh database (creating the
-- admin objects + app_settings) while safely no-op'ing on already-migrated DBs.
CREATE TABLE IF NOT EXISTS "admin_audit_log" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"admin_id" text,
"action" text NOT NULL,
"target_user_id" text,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"ip_address" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "app_settings" (
"key" text PRIMARY KEY NOT NULL,
"value" jsonb DEFAULT '{}'::jsonb NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "session" ADD COLUMN IF NOT EXISTS "impersonated_by" text;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS "role" text DEFAULT 'user';--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS "banned" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS "ban_reason" text;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS "ban_expires" timestamp;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'admin_audit_log_admin_id_user_id_fk'
) THEN
ALTER TABLE "admin_audit_log" ADD CONSTRAINT "admin_audit_log_admin_id_user_id_fk" FOREIGN KEY ("admin_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
+20
View File
@@ -0,0 +1,20 @@
CREATE TABLE "account_members" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"owner_id" text NOT NULL,
"member_id" text,
"email" text NOT NULL,
"role" text DEFAULT 'member' NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"invite_token" text DEFAULT gen_random_uuid()::text NOT NULL,
"accepted_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "account_members_invite_token_unique" UNIQUE("invite_token")
);
--> statement-breakpoint
ALTER TABLE "profiles" ADD COLUMN "brand_name" text;--> statement-breakpoint
ALTER TABLE "profiles" ADD COLUMN "brand_logo_url" text;--> statement-breakpoint
ALTER TABLE "profiles" ADD COLUMN "brand_color" text;--> statement-breakpoint
ALTER TABLE "profiles" ADD COLUMN "hide_powered_by" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "account_members" ADD CONSTRAINT "account_members_owner_id_profiles_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "account_members" ADD CONSTRAINT "account_members_member_id_profiles_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;
+13
View File
@@ -0,0 +1,13 @@
CREATE TABLE "api_keys" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"name" text NOT NULL,
"key_hash" text NOT NULL,
"key_prefix" text NOT NULL,
"last_used_at" timestamp with time zone,
"revoked_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "api_keys_key_hash_unique" UNIQUE("key_hash")
);
--> statement-breakpoint
ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_user_id_profiles_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "profiles" ADD COLUMN "calendar_token" text DEFAULT gen_random_uuid()::text;--> statement-breakpoint
ALTER TABLE "profiles" ADD CONSTRAINT "profiles_calendar_token_unique" UNIQUE("calendar_token");
@@ -0,0 +1,17 @@
CREATE TABLE "accounting_connections" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"provider" text NOT NULL,
"access_token" text NOT NULL,
"refresh_token" text NOT NULL,
"expires_at" timestamp with time zone,
"realm_id" text,
"org_name" text,
"status" text DEFAULT 'active' NOT NULL,
"last_sync_at" timestamp with time zone,
"last_error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "accounting_connections" ADD CONSTRAINT "accounting_connections_user_id_profiles_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;
+38
View File
@@ -0,0 +1,38 @@
CREATE TABLE "webhook_deliveries" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"endpoint_id" uuid NOT NULL,
"event" text NOT NULL,
"payload" jsonb NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"attempts" integer DEFAULT 0 NOT NULL,
"max_attempts" integer DEFAULT 5 NOT NULL,
"next_attempt_at" timestamp with time zone DEFAULT now() NOT NULL,
"response_status" integer,
"response_body" text,
"error" text,
"delivered_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "webhook_endpoints" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"url" text NOT NULL,
"description" text,
"events" text[] DEFAULT '{}'::text[] NOT NULL,
"secret" text NOT NULL,
"status" text DEFAULT 'active' NOT NULL,
"source" text DEFAULT 'dashboard' NOT NULL,
"last_success_at" timestamp with time zone,
"last_error_at" timestamp with time zone,
"last_error" text,
"failure_count" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "webhook_deliveries" ADD CONSTRAINT "webhook_deliveries_user_id_profiles_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "webhook_deliveries" ADD CONSTRAINT "webhook_deliveries_endpoint_id_webhook_endpoints_id_fk" FOREIGN KEY ("endpoint_id") REFERENCES "public"."webhook_endpoints"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "webhook_endpoints" ADD CONSTRAINT "webhook_endpoints_user_id_profiles_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;
@@ -0,0 +1,19 @@
CREATE TABLE "signature_requests" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"lease_id" uuid,
"provider" text NOT NULL,
"external_id" text,
"status" text DEFAULT 'sent' NOT NULL,
"signer_email" text NOT NULL,
"signer_name" text,
"document_name" text,
"signed_document_url" text,
"last_error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"completed_at" timestamp with time zone,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "signature_requests" ADD CONSTRAINT "signature_requests_user_id_profiles_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "signature_requests" ADD CONSTRAINT "signature_requests_lease_id_leases_id_fk" FOREIGN KEY ("lease_id") REFERENCES "public"."leases"("id") ON DELETE set null ON UPDATE no action;
@@ -0,0 +1,2 @@
ALTER TABLE "properties" ADD COLUMN "latitude" double precision;--> statement-breakpoint
ALTER TABLE "properties" ADD COLUMN "longitude" double precision;
@@ -0,0 +1,2 @@
ALTER TABLE "profiles" ADD COLUMN "paypal_subscription_id" text;--> statement-breakpoint
ALTER TABLE "profiles" ADD COLUMN "billing_provider" text;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+63
View File
@@ -8,6 +8,69 @@
"when": 1782223467789,
"tag": "0000_next_red_skull",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1782928287616,
"tag": "0001_furry_christian_walker",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1782935722973,
"tag": "0002_quiet_freak",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1782975002382,
"tag": "0003_api_keys",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1782978054678,
"tag": "0004_yellow_switch",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1782980416142,
"tag": "0005_large_black_queen",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1782980797596,
"tag": "0006_webhooks",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1782981198010,
"tag": "0007_chubby_sinister_six",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1782992530365,
"tag": "0008_dazzling_white_tiger",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1782994066547,
"tag": "0009_amusing_blackheart",
"breakpoints": true
}
]
}
+193 -3
View File
@@ -10,13 +10,14 @@ import {
timestamp,
date,
jsonb,
doublePrecision,
} from "drizzle-orm/pg-core"
// ============================================================
// Helpers
// ============================================================
// timestamptz returned as ISO strings (matches the previous Supabase/PostgREST
// behaviour the app code relies on). `date` columns returned as "YYYY-MM-DD".
// timestamptz returned as ISO strings (the shape the app code relies on).
// `date` columns returned as "YYYY-MM-DD".
const tstz = (name: string) => timestamp(name, { withTimezone: true, mode: "string" })
const createdAt = () => tstz("created_at").notNull().defaultNow()
const updatedAt = () =>
@@ -103,9 +104,20 @@ export const profiles = pgTable("profiles", {
stripe_customer_id: text("stripe_customer_id").unique(),
stripe_subscription_id: text("stripe_subscription_id"),
subscription_status: text("subscription_status"),
// Which processor owns the active subscription. Stripe fields above and the
// PayPal id below are mutually exclusive per active subscription.
paypal_subscription_id: text("paypal_subscription_id"),
billing_provider: text("billing_provider").$type<"stripe" | "paypal">(),
trial_ends_at: tstz("trial_ends_at"),
onboarding_completed: boolean("onboarding_completed").notNull().default(false),
usage_count: integer("usage_count").notNull().default(0),
// White-label branding (Landlord/Lifetime plans). Applied to the tenant portal.
brand_name: text("brand_name"),
brand_logo_url: text("brand_logo_url"),
brand_color: text("brand_color"),
hide_powered_by: boolean("hide_powered_by").notNull().default(false),
// Read-only iCal (ICS) subscription feed token — served at /api/calendar/<token>.ics
calendar_token: text("calendar_token").unique().default(sql`gen_random_uuid()::text`),
created_at: createdAt(),
updated_at: updatedAt(),
})
@@ -125,6 +137,10 @@ export const properties = pgTable("properties", {
state: text("state"),
postal_code: text("postal_code"),
country: text("country").notNull().default("US"),
// Geocoded from the address on save (OpenStreetMap Nominatim). Null until
// geocoding succeeds; drives the property map view.
latitude: doublePrecision("latitude"),
longitude: doublePrecision("longitude"),
property_type: text("property_type")
.$type<"residential" | "commercial" | "mixed">()
.notNull()
@@ -511,7 +527,170 @@ export const admin_audit_log = pgTable("admin_audit_log", {
})
// ============================================================
// RELATIONS (for Drizzle relational queries — replace PostgREST embeds)
// APP SETTINGS (global key/value — e.g. site maintenance mode)
// ============================================================
// A tiny key/value store for runtime-toggled platform settings that must
// persist and be changeable from the admin dashboard without a redeploy.
export const app_settings = pgTable("app_settings", {
key: text("key").primaryKey(),
value: jsonb("value").$type<Record<string, unknown>>().notNull().default({}),
updated_at: updatedAt(),
})
// ============================================================
// ACCOUNT MEMBERS (team access — Landlord/Lifetime plans)
// ============================================================
// Lets an account OWNER invite other users to access their portfolio. A member
// with status='active' operates under the owner's data (resolved by
// getEffectiveOwnerId in lib/account.ts). owner_id is the portfolio owner;
// member_id is set once the invite is accepted.
export const account_members = pgTable("account_members", {
id: uuid("id").primaryKey().defaultRandom(),
owner_id: text("owner_id")
.notNull()
.references(() => profiles.id, { onDelete: "cascade" }),
member_id: text("member_id").references(() => profiles.id, { onDelete: "cascade" }),
email: text("email").notNull(),
role: text("role").$type<"member" | "viewer">().notNull().default("member"),
status: text("status").$type<"pending" | "active" | "revoked">().notNull().default("pending"),
invite_token: text("invite_token").notNull().unique().default(sql`gen_random_uuid()::text`),
accepted_at: tstz("accepted_at"),
created_at: createdAt(),
updated_at: updatedAt(),
})
// ============================================================
// API KEYS (public REST API — Bearer auth for /api/v1)
// ============================================================
// Each key belongs to a user. We store ONLY a SHA-256 hash of the secret; the
// plaintext is shown once at creation and never persisted. `key_prefix` is a
// short, non-secret identifier for display in the dashboard.
export const api_keys = pgTable("api_keys", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id")
.notNull()
.references(() => profiles.id, { onDelete: "cascade" }),
name: text("name").notNull(),
key_hash: text("key_hash").notNull().unique(),
key_prefix: text("key_prefix").notNull(),
last_used_at: tstz("last_used_at"),
revoked_at: tstz("revoked_at"),
created_at: createdAt(),
})
// ============================================================
// ACCOUNTING CONNECTIONS (QuickBooks / Xero OAuth sync)
// ============================================================
// One row per (owner, provider). OAuth tokens are stored AES-256-GCM encrypted
// (see lib/crypto.ts). A landlord connects their books and rent income +
// expenses are pushed one-way into QuickBooks Online or Xero.
export const accounting_connections = pgTable("accounting_connections", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id")
.notNull()
.references(() => profiles.id, { onDelete: "cascade" }),
provider: text("provider").$type<"quickbooks" | "xero">().notNull(),
access_token: text("access_token").notNull(), // encrypted
refresh_token: text("refresh_token").notNull(), // encrypted
expires_at: tstz("expires_at"),
// Provider account id: QuickBooks realmId / Xero tenantId.
realm_id: text("realm_id"),
org_name: text("org_name"),
status: text("status").$type<"active" | "error" | "revoked">().notNull().default("active"),
last_sync_at: tstz("last_sync_at"),
last_error: text("last_error"),
created_at: createdAt(),
updated_at: updatedAt(),
})
// ============================================================
// SIGNATURE REQUESTS (e-signature — DocuSign / Dropbox Sign)
// ============================================================
// Tracks a lease document sent out for e-signature. `external_id` is the
// provider's envelope / signature_request id; the webhook flips status to
// "signed" and records the completed document.
export const signature_requests = pgTable("signature_requests", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id")
.notNull()
.references(() => profiles.id, { onDelete: "cascade" }),
lease_id: uuid("lease_id").references(() => leases.id, { onDelete: "set null" }),
provider: text("provider").$type<"docusign" | "dropbox_sign">().notNull(),
external_id: text("external_id"),
status: text("status")
.$type<"sent" | "signed" | "declined" | "voided" | "error">()
.notNull()
.default("sent"),
signer_email: text("signer_email").notNull(),
signer_name: text("signer_name"),
document_name: text("document_name"),
signed_document_url: text("signed_document_url"),
last_error: text("last_error"),
sent_at: createdAt(),
completed_at: tstz("completed_at"),
updated_at: updatedAt(),
})
// ============================================================
// WEBHOOK ENDPOINTS (outbound webhooks / Zapier integration)
// ============================================================
// A landlord registers HTTPS endpoints that receive a signed JSON POST every
// time a subscribed event occurs (e.g. tenant.created, payment.paid). Scoped by
// the account owner id so every event in the portfolio is delivered. The
// `secret` is the HMAC-SHA256 signing key surfaced in the dashboard so the
// receiver can verify the `X-PMN-Signature` header. `events` is the set of
// subscribed event ids; an empty array means "all events". `source` records who
// created it (dashboard, the REST API, or a Zapier REST-hook subscription).
export const webhook_endpoints = pgTable("webhook_endpoints", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id")
.notNull()
.references(() => profiles.id, { onDelete: "cascade" }),
url: text("url").notNull(),
description: text("description"),
events: text("events").array().notNull().default(sql`'{}'::text[]`),
secret: text("secret").notNull(),
status: text("status").$type<"active" | "disabled">().notNull().default("active"),
source: text("source").$type<"dashboard" | "api" | "zapier">().notNull().default("dashboard"),
last_success_at: tstz("last_success_at"),
last_error_at: tstz("last_error_at"),
last_error: text("last_error"),
// Consecutive delivery failures; reset to 0 on any success.
failure_count: integer("failure_count").notNull().default(0),
created_at: createdAt(),
updated_at: updatedAt(),
})
// ============================================================
// WEBHOOK DELIVERIES (per-endpoint delivery log + retry queue)
// ============================================================
// One row per (event, endpoint). Created "pending"; the emitter attempts an
// immediate delivery and the webhooks cron retries anything still pending/failed
// with exponential backoff until max_attempts is reached.
export const webhook_deliveries = pgTable("webhook_deliveries", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id")
.notNull()
.references(() => profiles.id, { onDelete: "cascade" }),
endpoint_id: uuid("endpoint_id")
.notNull()
.references(() => webhook_endpoints.id, { onDelete: "cascade" }),
event: text("event").notNull(),
payload: jsonb("payload").$type<Record<string, unknown>>().notNull(),
status: text("status").$type<"pending" | "success" | "failed">().notNull().default("pending"),
attempts: integer("attempts").notNull().default(0),
max_attempts: integer("max_attempts").notNull().default(5),
next_attempt_at: tstz("next_attempt_at").notNull().defaultNow(),
response_status: integer("response_status"),
response_body: text("response_body"),
error: text("error"),
delivered_at: tstz("delivered_at"),
created_at: createdAt(),
updated_at: updatedAt(),
})
// ============================================================
// RELATIONS (for Drizzle relational queries)
// ============================================================
export const profilesRelations = relations(profiles, ({ one }) => ({
user: one(user, { fields: [profiles.id], references: [user.id] }),
@@ -587,3 +766,14 @@ export const vendorsRelations = relations(vendors, ({ one }) => ({
export const follow_up_logRelations = relations(follow_up_log, ({ one }) => ({
rule: one(follow_up_rules, { fields: [follow_up_log.rule_id], references: [follow_up_rules.id] }),
}))
export const webhook_endpointsRelations = relations(webhook_endpoints, ({ many }) => ({
deliveries: many(webhook_deliveries),
}))
export const webhook_deliveriesRelations = relations(webhook_deliveries, ({ one }) => ({
endpoint: one(webhook_endpoints, {
fields: [webhook_deliveries.endpoint_id],
references: [webhook_endpoints.id],
}),
}))
+30 -6
View File
@@ -1,9 +1,33 @@
import { Resend } from "resend"
import nodemailer, { type Transporter } from "nodemailer"
// Use a placeholder when no key is configured so the constructor doesn't throw
// at module load (it's imported on the auth path). Sends will fail gracefully
// and are caught in sendEmail().
export const resend = new Resend(process.env.RESEND_API_KEY || "re_placeholder")
// SMTP transport (SMTP2GO). Created lazily so importing this on the auth path
// doesn't require SMTP to be configured. Sends fail gracefully in sendEmail().
const SMTP_HOST = process.env.SMTP_HOST
const SMTP_PORT = Number(process.env.SMTP_PORT ?? 587)
const SMTP_USER = process.env.SMTP_USER
const SMTP_PASS = process.env.SMTP_PASS
export const FROM_EMAIL = process.env.RESEND_FROM_EMAIL ?? "noreply@propertymanagement.network"
export const FROM_EMAIL =
process.env.EMAIL_FROM ??
process.env.SMTP_FROM ??
"postmaster@propertymanagement.network"
export const APP_NAME = process.env.NEXT_PUBLIC_APP_NAME ?? "Property Management Network"
/** True when SMTP is configured (host + credentials present). */
export function emailConfigured(): boolean {
return Boolean(SMTP_HOST && SMTP_USER && SMTP_PASS)
}
let _transporter: Transporter | null = null
export function getTransporter(): Transporter {
if (!_transporter) {
_transporter = nodemailer.createTransport({
host: SMTP_HOST,
port: SMTP_PORT,
// Port 465 uses implicit TLS/SSL; 587/2525/etc. negotiate STARTTLS.
secure: SMTP_PORT === 465,
auth: SMTP_USER && SMTP_PASS ? { user: SMTP_USER, pass: SMTP_PASS } : undefined,
})
}
return _transporter
}
+245
View File
@@ -0,0 +1,245 @@
// Shared, cross-client email design system.
//
// Every transactional email in the app is composed through `emailShell()` so
// they share one consistent, deliverable-in-every-client look. The markup is
// deliberately table-based with MSO/VML fallbacks and inline styles — that is
// what renders reliably in Outlook, Gmail, Apple Mail, etc. The design is a
// clean, light "premium SaaS" style with a branded header and a colored accent
// bar that gives each email type its own identity.
const APP_NAME = process.env.NEXT_PUBLIC_APP_NAME ?? "Property Management Network"
// Absolute base URL so <img> logos resolve in a recipient's email client
// (relative paths and Next <Image>/SVG don't work in email). Light-themed
// emails use the dark wordmark lockup, which is designed for light surfaces.
const APP_URL = (process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000").replace(/\/+$/, "")
const LOGO_WORDMARK_URL = `${APP_URL}/logo-dark.png`
// Brand palette
export const BRAND = {
indigo: "#6366f1",
indigoDark: "#4f46e5",
violet: "#7c3aed",
red: "#dc2626",
amber: "#d97706",
green: "#059669",
ink: "#18181b",
body: "#52525b",
muted: "#8b8f9a",
line: "#eceef2",
panel: "#f7f8fa",
page: "#eef1f6",
} as const
export function escapeHtml(value: unknown): string {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
}
const FONT_STACK =
"-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif"
export interface EmailButton {
href: string
label: string
}
export interface EmailShellOptions {
/** Hidden inbox-preview text shown next to the subject line. */
preheader?: string
/** Accent hex used for the top bar and button. Defaults to brand indigo. */
accent?: string
/** Small uppercase label rendered above the title. */
eyebrow?: string
/** Main heading. Plain text — will be escaped. */
title: string
/** Intro/greeting HTML (already escaped by caller). */
intro?: string
/** Main content HTML block (already escaped by caller). */
body?: string
/** Primary call-to-action button. */
button?: EmailButton
/** Extra note HTML rendered under the button (already escaped by caller). */
footerNote?: string
}
/** Bulletproof, rounded CTA button that degrades to a solid rectangle in Outlook. */
export function emailButton({ href, label }: EmailButton, accent: string = BRAND.indigoDark): string {
const safeHref = escapeHtml(href)
const safeLabel = escapeHtml(label)
return `
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin:8px 0 4px;">
<tr>
<td align="center" bgcolor="${accent}" style="border-radius:10px;background:linear-gradient(135deg,${accent},${BRAND.violet});">
<a href="${safeHref}" target="_blank" style="display:inline-block;padding:14px 30px;font-family:${FONT_STACK};font-size:16px;font-weight:600;line-height:1;color:#ffffff;text-decoration:none;border-radius:10px;">${safeLabel}</a>
</td>
</tr>
</table>`
}
/** A rounded panel of label/value rows — used for rent/lease detail summaries. */
export function detailTable(
rows: Array<{ label: string; value: string; accent?: boolean }>,
accent: string = BRAND.indigo
): string {
const body = rows
.map(
(r, i) => `
<tr>
<td style="padding:${i === 0 ? "2px" : "10px"} 0 ${i === rows.length - 1 ? "2px" : "10px"};font-family:${FONT_STACK};font-size:14px;color:${BRAND.muted};">${escapeHtml(r.label)}</td>
<td align="right" style="padding:${i === 0 ? "2px" : "10px"} 0 ${i === rows.length - 1 ? "2px" : "10px"};font-family:${FONT_STACK};font-size:14px;font-weight:600;color:${r.accent ? accent : BRAND.ink};">${escapeHtml(r.value)}</td>
</tr>${i === rows.length - 1 ? "" : `\n <tr><td colspan="2" style="border-top:1px solid ${BRAND.line};font-size:0;line-height:0;">&nbsp;</td></tr>`}`
)
.join("")
return `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin:8px 0 24px;background:${BRAND.panel};border:1px solid ${BRAND.line};border-radius:12px;">
<tr>
<td style="padding:18px 22px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">${body}
</table>
</td>
</tr>
</table>`
}
/** A small colored status pill. */
export function statusBadge(label: string, accent: string = BRAND.indigo): string {
return `<span style="display:inline-block;padding:5px 12px;border-radius:999px;background:${accent}1a;color:${accent};font-family:${FONT_STACK};font-size:13px;font-weight:600;line-height:1;text-transform:capitalize;">${escapeHtml(label)}</span>`
}
/** A styled paragraph helper for template bodies. */
export function paragraph(html: string, opts: { muted?: boolean; small?: boolean } = {}): string {
const color = opts.muted ? BRAND.muted : BRAND.body
const size = opts.small ? "13px" : "15px"
return `<p style="margin:0 0 18px;font-family:${FONT_STACK};font-size:${size};line-height:1.65;color:${color};">${html}</p>`
}
export function emailShell(opts: EmailShellOptions): string {
const accent = opts.accent ?? BRAND.indigo
const year = new Date().getFullYear()
const appName = escapeHtml(APP_NAME)
const preheader = opts.preheader
? `<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;font-size:1px;line-height:1px;color:${BRAND.page};opacity:0;">${escapeHtml(opts.preheader)}${"&#8199;&#65279;&#847; ".repeat(60)}</div>`
: ""
const eyebrow = opts.eyebrow
? `<p style="margin:0 0 10px;font-family:${FONT_STACK};font-size:12px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:${accent};">${escapeHtml(opts.eyebrow)}</p>`
: ""
const intro = opts.intro
? `<p style="margin:0 0 18px;font-family:${FONT_STACK};font-size:15px;line-height:1.65;color:${BRAND.body};">${opts.intro}</p>`
: ""
const button = opts.button ? emailButton(opts.button, accent) : ""
const footerNote = opts.footerNote
? `<p style="margin:20px 0 0;font-family:${FONT_STACK};font-size:13px;line-height:1.6;color:${BRAND.muted};">${opts.footerNote}</p>`
: ""
return `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="color-scheme" content="light only" />
<meta name="supported-color-schemes" content="light only" />
<title>${escapeHtml(opts.title)}</title>
<!--[if mso]>
<noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript>
<![endif]-->
<style>
:root { color-scheme: light only; supported-color-schemes: light only; }
body { margin:0; padding:0; width:100% !important; -webkit-text-size-adjust:100%; -ms-text-size-adjust:100%; }
table { border-collapse:collapse; }
img { border:0; outline:none; text-decoration:none; -ms-interpolation-mode:bicubic; }
a { text-decoration:none; }
@media only screen and (max-width:620px) {
.email-card { width:100% !important; border-radius:0 !important; }
.email-pad { padding-left:24px !important; padding-right:24px !important; }
}
</style>
</head>
<body style="margin:0;padding:0;background-color:${BRAND.page};">
${preheader}
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color:${BRAND.page};">
<tr>
<td align="center" style="padding:32px 16px;">
<table role="presentation" width="600" class="email-card" cellspacing="0" cellpadding="0" border="0" style="width:600px;max-width:600px;background-color:#ffffff;border:1px solid ${BRAND.line};border-radius:16px;overflow:hidden;">
<!-- accent bar -->
<tr><td style="height:4px;background:linear-gradient(90deg,${accent},${BRAND.violet});font-size:0;line-height:0;">&nbsp;</td></tr>
<!-- brand header -->
<tr>
<td class="email-pad" style="padding:28px 40px 4px;">
<a href="${APP_URL}" target="_blank" style="text-decoration:none;">
<img src="${LOGO_WORDMARK_URL}" alt="${appName}" width="264" height="28" style="display:block;height:28px;width:auto;max-width:264px;border:0;outline:none;font-family:${FONT_STACK};font-size:18px;font-weight:700;color:${BRAND.ink};text-decoration:none;" />
</a>
</td>
</tr>
<!-- content -->
<tr>
<td class="email-pad" style="padding:24px 40px 8px;">
${eyebrow}
<h1 style="margin:0 0 14px;font-family:${FONT_STACK};font-size:23px;line-height:1.3;font-weight:700;color:${BRAND.ink};">${escapeHtml(opts.title)}</h1>
${intro}
${opts.body ?? ""}
${button}
${footerNote}
</td>
</tr>
<!-- footer -->
<tr>
<td class="email-pad" style="padding:28px 40px 34px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr><td style="border-top:1px solid ${BRAND.line};font-size:0;line-height:0;padding-top:22px;">&nbsp;</td></tr>
</table>
<p style="margin:0 0 4px;font-family:${FONT_STACK};font-size:13px;font-weight:600;color:${BRAND.body};">${appName}</p>
<p style="margin:0;font-family:${FONT_STACK};font-size:12px;line-height:1.6;color:${BRAND.muted};">Simple property management for modern landlords.</p>
<p style="margin:12px 0 0;font-family:${FONT_STACK};font-size:11px;color:${BRAND.muted};">&copy; ${year} ${appName}. All rights reserved.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`
}
/** Best-effort plain-text version of an HTML email for the multipart fallback. */
export function htmlToText(html: string): string {
return html
.replace(/<head[\s\S]*?<\/head>/gi, "")
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<!--[\s\S]*?-->/g, "")
// Links: keep "text (url)" for real links; drop links that only wrap an
// image (e.g. the header logo) so we don't leak a bare URL into the text.
.replace(/<a\b[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, (_m, href, inner) => {
const text = String(inner).replace(/<[^>]+>/g, "").trim()
return text ? `${text} (${href})` : ""
})
.replace(/<img\b[^>]*>/gi, "")
.replace(/<\/(p|div|tr|h1|h2|h3|h4|li|table)>/gi, "\n")
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<[^>]+>/g, "")
.replace(/[͏]/g, "")
.replace(/&#8199;|&#65279;|&#847;|&zwnj;|&nbsp;/gi, " ")
.replace(/&copy;/gi, "©")
.replace(/&mdash;/gi, "—")
.replace(/&ndash;/gi, "")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#39;|&apos;/gi, "'")
.split("\n")
.map((l) => l.replace(/[ \t]+/g, " ").trim())
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim()
}
+187 -95
View File
@@ -1,35 +1,47 @@
import { resend, FROM_EMAIL, APP_NAME } from "./client"
import { getTransporter, emailConfigured, FROM_EMAIL, APP_NAME } from "./client"
import {
BRAND,
emailShell,
detailTable,
statusBadge,
paragraph,
escapeHtml,
htmlToText,
} from "./layout"
export function escapeHtml(value: unknown): string {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
}
// Re-exported so existing importers (`@/lib/email/send`) keep working.
export { escapeHtml }
interface SendEmailOptions {
to: string
subject: string
html: string
/** Optional plain-text part. Auto-derived from `html` when omitted. */
text?: string
from?: string
}
export async function sendEmail({ to, subject, html, from }: SendEmailOptions) {
const { data, error } = await resend.emails.send({
from: from ?? `${APP_NAME} <${FROM_EMAIL}>`,
to,
subject,
html,
})
export async function sendEmail({ to, subject, html, text, from }: SendEmailOptions) {
if (!emailConfigured()) {
console.warn(`[email] SMTP not configured — skipping "${subject}" to ${to}`)
return { success: false, error: "email not configured" }
}
if (error) {
try {
const info = await getTransporter().sendMail({
from: from ?? `${APP_NAME} <${FROM_EMAIL}>`,
to,
subject,
html,
// A plain-text alternative improves inbox placement and gives clients
// that can't render HTML something clean to show.
text: text ?? htmlToText(html),
})
return { success: true, id: info.messageId }
} catch (error) {
console.error("Email send failed:", error)
return { success: false, error }
}
return { success: true, id: data?.id }
}
// ── Email templates ──────────────────────────────────────────────
@@ -49,29 +61,22 @@ export function rentDueReminderHtml({
dueDate: string
paymentLink?: string
}) {
return `
<!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;">
<h1 style="font-size: 20px; margin: 0 0 8px; color: #fff;">Rent Due Reminder</h1>
<p style="color: rgba(255,255,255,0.6); margin: 0 0 24px;">Hi ${escapeHtml(tenantName)},</p>
<p style="color: rgba(255,255,255,0.6); margin: 0 0 24px;">
Your rent payment of <strong style="color:#fff">${escapeHtml(amount)}</strong> for
<strong style="color:#fff">${escapeHtml(propertyName)} — Unit ${escapeHtml(unitNumber)}</strong>
is due on <strong style="color:#fff">${escapeHtml(dueDate)}</strong>.
</p>
${paymentLink ? `
<a href="${escapeHtml(paymentLink)}" style="display: inline-block; background: #6366f1; color: #fff; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">
Pay Rent Now
</a>
` : ""}
<p style="color: rgba(255,255,255,0.4); font-size: 12px; margin: 24px 0 0;">
Property Management Network
</p>
</div>
</body>
</html>`
return emailShell({
preheader: `Your rent of ${amount} is due on ${dueDate}.`,
eyebrow: "Rent reminder",
accent: BRAND.indigo,
title: "Your rent is due soon",
intro: `Hi ${escapeHtml(tenantName)}, this is a friendly reminder about your upcoming rent payment.`,
body: detailTable([
{ label: "Property", value: `${propertyName} — Unit ${unitNumber}` },
{ label: "Amount due", value: amount, accent: true },
{ label: "Due date", value: dueDate },
]),
button: paymentLink ? { href: paymentLink, label: "Pay rent now" } : undefined,
footerNote: paymentLink
? "If the button doesn't work, contact your landlord for alternative payment options."
: "Please arrange payment before the due date. Reach out to your landlord with any questions.",
})
}
export function rentOverdueHtml({
@@ -87,25 +92,25 @@ export function rentOverdueHtml({
amount: string
dueDate: string
}) {
return `
<!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(239,68,68,0.3); border-radius: 12px; padding: 32px;">
<h1 style="font-size: 20px; margin: 0 0 8px; color: #ef4444;">Rent Overdue</h1>
<p style="color: rgba(255,255,255,0.6); margin: 0 0 24px;">Hi ${escapeHtml(tenantName)},</p>
<p style="color: rgba(255,255,255,0.6); margin: 0 0 24px;">
Your rent payment of <strong style="color:#fff">${escapeHtml(amount)}</strong> for
<strong style="color:#fff">${escapeHtml(propertyName)} — Unit ${escapeHtml(unitNumber)}</strong>
was due on <strong style="color:#ef4444">${escapeHtml(dueDate)}</strong> and is now overdue.
Please make payment as soon as possible.
</p>
<p style="color: rgba(255,255,255,0.4); font-size: 12px; margin: 24px 0 0;">
Property Management Network
</p>
</div>
</body>
</html>`
return emailShell({
preheader: `Your rent payment of ${amount} is now overdue.`,
eyebrow: "Action needed",
accent: BRAND.red,
title: "Your rent is overdue",
intro: `Hi ${escapeHtml(tenantName)}, our records show the payment below hasn't been received yet.`,
body:
detailTable(
[
{ label: "Property", value: `${propertyName} — Unit ${unitNumber}` },
{ label: "Amount due", value: amount, accent: true },
{ label: "Was due", value: dueDate, accent: true },
],
BRAND.red
) +
paragraph(
"Please make payment as soon as possible to avoid any late fees. If you've already paid, you can disregard this notice."
),
})
}
export function leaseExpiryHtml({
@@ -121,24 +126,22 @@ export function leaseExpiryHtml({
leaseEnd: string
daysLeft: number
}) {
return `
<!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(245,158,11,0.3); border-radius: 12px; padding: 32px;">
<h1 style="font-size: 20px; margin: 0 0 8px; color: #f59e0b;">Lease Expiring Soon</h1>
<p style="color: rgba(255,255,255,0.6); margin: 0 0 24px;">Hi ${escapeHtml(tenantName)},</p>
<p style="color: rgba(255,255,255,0.6); margin: 0 0 24px;">
Your lease for <strong style="color:#fff">${escapeHtml(propertyName)} — Unit ${escapeHtml(unitNumber)}</strong>
expires on <strong style="color:#f59e0b">${escapeHtml(leaseEnd)}</strong>
(${escapeHtml(daysLeft)} days from now). Please contact your landlord to discuss renewal.
</p>
<p style="color: rgba(255,255,255,0.4); font-size: 12px; margin: 24px 0 0;">
Property Management Network
</p>
</div>
</body>
</html>`
return emailShell({
preheader: `Your lease ends on ${leaseEnd} (${daysLeft} days away).`,
eyebrow: "Lease update",
accent: BRAND.amber,
title: "Your lease is expiring soon",
intro: `Hi ${escapeHtml(tenantName)}, your current lease is coming to an end.`,
body:
detailTable(
[
{ label: "Property", value: `${propertyName} — Unit ${unitNumber}` },
{ label: "Lease ends", value: leaseEnd, accent: true },
{ label: "Time remaining", value: `${daysLeft} days` },
],
BRAND.amber
) + paragraph("Please contact your landlord to discuss renewal options or next steps."),
})
}
export function maintenanceUpdateHtml({
@@ -152,20 +155,109 @@ export function maintenanceUpdateHtml({
status: string
resolutionNotes?: string
}) {
return `
<!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;">
<h1 style="font-size: 20px; margin: 0 0 8px;">Maintenance Update</h1>
<p style="color: rgba(255,255,255,0.6); margin: 0 0 24px;">Hi ${escapeHtml(tenantName)},</p>
<p style="color: rgba(255,255,255,0.6); margin: 0 0 12px;">
Your maintenance request "<strong style="color:#fff">${escapeHtml(title)}</strong>"
has been updated to: <strong style="color:#6366f1; text-transform: capitalize;">${escapeHtml(status).replace("_", " ")}</strong>
</p>
${resolutionNotes ? `<p style="color: rgba(255,255,255,0.5); margin: 0 0 24px; font-size: 14px;">${escapeHtml(resolutionNotes)}</p>` : ""}
<p style="color: rgba(255,255,255,0.4); font-size: 12px; margin: 24px 0 0;">Property Management Network</p>
</div>
</body>
</html>`
const prettyStatus = status.replace(/_/g, " ")
const fontStack =
"-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif"
return emailShell({
preheader: `Your maintenance request is now "${prettyStatus}".`,
eyebrow: "Maintenance",
accent: BRAND.indigo,
title: "Update on your maintenance request",
intro: `Hi ${escapeHtml(tenantName)}, there's an update on your request.`,
body:
`<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin:8px 0 20px;background:${BRAND.panel};border:1px solid ${BRAND.line};border-radius:12px;">
<tr><td style="padding:18px 22px;">
<p style="margin:0 0 12px;font-family:${fontStack};font-size:15px;font-weight:600;color:${BRAND.ink};">${escapeHtml(title)}</p>
${statusBadge(prettyStatus, BRAND.indigo)}
</td></tr>
</table>` +
(resolutionNotes ? paragraph(escapeHtml(resolutionNotes), { muted: true }) : ""),
})
}
export function resetPasswordHtml(url: string) {
return emailShell({
preheader: "Reset your Property Management Network password.",
eyebrow: "Security",
title: "Reset your password",
intro:
"We received a request to reset your password. Click the button below to choose a new one — this link will expire shortly for your security.",
button: { href: url, label: "Reset password" },
footerNote:
"If you didn't request a password reset, you can safely ignore this email — your password won't change.",
})
}
export function verifyEmailHtml(url: string) {
return emailShell({
preheader: "Confirm your email to finish setting up your account.",
eyebrow: "Welcome",
title: "Verify your email address",
intro:
"Thanks for signing up! Please confirm your email address to finish setting up your account.",
button: { href: url, label: "Verify email" },
footerNote: "If you didn't create an account, you can safely ignore this email.",
})
}
export function teamInviteHtml({
inviterName,
inviteUrl,
role,
}: {
inviterName: string
inviteUrl: string
role: "member" | "viewer"
}) {
const roleLabel = role === "viewer" ? "view" : "manage"
return emailShell({
preheader: `${inviterName} invited you to their property portfolio.`,
eyebrow: "Team invitation",
title: "You've been invited to a team",
intro: `<strong style="color:${BRAND.ink};">${escapeHtml(inviterName)}</strong> has invited you to ${escapeHtml(roleLabel)} their property portfolio on ${escapeHtml(APP_NAME)}.`,
button: { href: inviteUrl, label: "Accept invitation" },
footerNote:
"If you don't have an account yet, you'll be asked to sign in or sign up first.",
})
}
export function followUpHtml(message: string) {
return emailShell({
preheader: message.slice(0, 140),
accent: BRAND.indigo,
title: "A quick note from your landlord",
body: paragraph(escapeHtml(message).replace(/\n/g, "<br/>")),
footerNote: "Sent automatically by your landlord's follow-up system.",
})
}
export function paymentLinkHtml({
tenantName,
amount,
dueDate,
propertyLabel,
senderName,
paymentLink,
}: {
tenantName: string
amount: string
dueDate: string
propertyLabel: string
senderName: string
paymentLink?: string
}) {
return emailShell({
preheader: `Rent payment of ${amount} due ${dueDate}.`,
eyebrow: "Rent payment",
accent: BRAND.indigo,
title: "Your rent payment is due",
intro: `Hi ${escapeHtml(tenantName)}, here are the details for your upcoming rent payment.`,
body: detailTable([
{ label: "Property", value: propertyLabel },
{ label: "Amount due", value: amount, accent: true },
{ label: "Due date", value: dueDate },
]),
button: paymentLink ? { href: paymentLink, label: "Pay rent now" } : undefined,
footerNote: `Sent by ${escapeHtml(senderName)} via ${escapeHtml(APP_NAME)}.`,
})
}
+65
View File
@@ -0,0 +1,65 @@
import type { ESignAdapter, SendParams, WebhookResult } from "./types"
// DocuSign eSignature REST API. Uses a pre-obtained access token (via JWT grant
// or OAuth) — set DOCUSIGN_ACCESS_TOKEN / DOCUSIGN_ACCOUNT_ID / DOCUSIGN_BASE_URI.
// Docs: https://developers.docusign.com/docs/esign-rest-api/reference/envelopes/envelopes/create/
const ACCESS_TOKEN = process.env.DOCUSIGN_ACCESS_TOKEN ?? ""
const ACCOUNT_ID = process.env.DOCUSIGN_ACCOUNT_ID ?? ""
const BASE_URI = (process.env.DOCUSIGN_BASE_URI ?? "https://demo.docusign.net").replace(/\/+$/, "")
function extOf(name: string): string {
const e = name.split(".").pop()?.toLowerCase()
return e && /^(pdf|docx?|png|jpe?g)$/.test(e) ? e : "pdf"
}
export const docusign: ESignAdapter = {
id: "docusign",
label: "DocuSign",
configured: () => Boolean(ACCESS_TOKEN && ACCOUNT_ID),
async send({ document, documentName, signerEmail, signerName, subject }: SendParams) {
const envelope = {
emailSubject: subject,
status: "sent",
documents: [{ documentBase64: document.toString("base64"), name: documentName, fileExtension: extOf(documentName), documentId: "1" }],
recipients: {
signers: [
{
email: signerEmail,
name: signerName,
recipientId: "1",
routingOrder: "1",
// Default sign placement (bottom of page 1). Use a template/anchor
// string for precise field placement in production.
tabs: { signHereTabs: [{ documentId: "1", pageNumber: "1", xPosition: "100", yPosition: "650" }] },
},
],
},
}
const res = await fetch(`${BASE_URI}/restapi/v2.1/accounts/${ACCOUNT_ID}/envelopes`, {
method: "POST",
headers: { Authorization: `Bearer ${ACCESS_TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify(envelope),
})
if (!res.ok) throw new Error(`DocuSign ${res.status}: ${(await res.text()).slice(0, 300)}`)
const j = (await res.json()) as { envelopeId?: string }
if (!j.envelopeId) throw new Error("DocuSign did not return an envelopeId")
return { externalId: j.envelopeId }
},
parseWebhook(body): WebhookResult | null {
// DocuSign Connect (JSON format) payload.
try {
const j = JSON.parse(body) as { event?: string; data?: { envelopeId?: string; envelopeSummary?: { status?: string } } }
const externalId = j.data?.envelopeId
const status = (j.data?.envelopeSummary?.status ?? j.event ?? "").toLowerCase()
if (!externalId) return null
if (status.includes("completed") || status.includes("signed")) return { externalId, status: "signed" }
if (status.includes("declined")) return { externalId, status: "declined" }
if (status.includes("voided")) return { externalId, status: "voided" }
return null
} catch {
return null
}
},
}
+53
View File
@@ -0,0 +1,53 @@
import type { ESignAdapter, SendParams, WebhookResult } from "./types"
// Dropbox Sign (formerly HelloSign). API-key auth. Docs:
// https://developers.hellosign.com/api/reference/operation/signatureRequestSend/
const API_KEY = process.env.DROPBOX_SIGN_API_KEY ?? ""
const TEST_MODE = process.env.DROPBOX_SIGN_TEST_MODE === "true" ? "1" : "0"
const BASE = "https://api.hellosign.com/v3"
function auth() {
return "Basic " + Buffer.from(`${API_KEY}:`).toString("base64")
}
export const dropboxSign: ESignAdapter = {
id: "dropbox_sign",
label: "Dropbox Sign",
configured: () => Boolean(API_KEY),
async send({ document, documentName, signerEmail, signerName, subject, message }: SendParams) {
const fd = new FormData()
fd.append("subject", subject)
fd.append("message", message)
fd.append("test_mode", TEST_MODE)
fd.append("signers[0][email_address]", signerEmail)
fd.append("signers[0][name]", signerName)
fd.append("file[0]", new Blob([new Uint8Array(document)], { type: "application/pdf" }), documentName)
const res = await fetch(`${BASE}/signature_request/send`, { method: "POST", headers: { Authorization: auth() }, body: fd })
if (!res.ok) throw new Error(`Dropbox Sign ${res.status}: ${(await res.text()).slice(0, 300)}`)
const j = (await res.json()) as { signature_request?: { signature_request_id?: string } }
const id = j.signature_request?.signature_request_id
if (!id) throw new Error("Dropbox Sign did not return a signature_request_id")
return { externalId: id }
},
parseWebhook(body): WebhookResult | null {
// Dropbox Sign posts multipart form-data with a `json` field.
let event: { event?: { event_type?: string }; signature_request?: { signature_request_id?: string } }
try {
// Extract the JSON payload whether sent raw or as a form field.
const m = body.match(/name="json"\r?\n\r?\n([\s\S]*?)\r?\n--/) ?? body.match(/^(\{[\s\S]*\})\s*$/)
event = JSON.parse(m ? m[1] : body)
} catch {
return null
}
const type = event.event?.event_type
const externalId = event.signature_request?.signature_request_id
if (!externalId || !type) return null
if (type === "signature_request_all_signed") return { externalId, status: "signed" }
if (type === "signature_request_declined") return { externalId, status: "declined" }
if (type === "signature_request_canceled") return { externalId, status: "voided" }
return null
},
}
+97
View File
@@ -0,0 +1,97 @@
import { and, desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { leases, signature_requests } from "@/lib/db/schema"
import { readFile } from "@/lib/storage"
import { docusign } from "./docusign"
import { dropboxSign } from "./dropbox-sign"
import type { ESignAdapter, ESignProvider } from "./types"
export type { ESignProvider } from "./types"
const ADAPTERS: Record<ESignProvider, ESignAdapter> = { docusign, dropbox_sign: dropboxSign }
export function getAdapter(id: string): ESignAdapter | null {
return id === "docusign" || id === "dropbox_sign" ? ADAPTERS[id] : null
}
export function listAdapters() {
return (Object.keys(ADAPTERS) as ESignProvider[]).map((id) => ({ id, label: ADAPTERS[id].label, configured: ADAPTERS[id].configured() }))
}
export function anyEsignConfigured(): boolean {
return listAdapters().some((a) => a.configured)
}
async function getDocumentBytes(documentUrl: string): Promise<{ bytes: Buffer; name: string }> {
const prefix = "/api/files/"
if (documentUrl.startsWith(prefix)) {
const key = documentUrl.slice(prefix.length)
return { bytes: await readFile(key), name: key.split("/").pop() ?? "lease.pdf" }
}
const res = await fetch(documentUrl)
if (!res.ok) throw new Error("Could not fetch the lease document")
return { bytes: Buffer.from(await res.arrayBuffer()), name: documentUrl.split("/").pop()?.split("?")[0] ?? "lease.pdf" }
}
export async function sendLeaseForSignature(ownerId: string, leaseId: string, provider: ESignProvider) {
const adapter = getAdapter(provider)
if (!adapter) throw new Error("Unknown provider")
if (!adapter.configured()) throw new Error(`${adapter.label} is not configured`)
const lease = await db.query.leases.findFirst({
where: and(eq(leases.id, leaseId), eq(leases.user_id, ownerId)),
with: { tenant: { columns: { first_name: true, last_name: true, email: true } } },
})
if (!lease) throw new Error("Lease not found")
if (!lease.document_url) throw new Error("Upload a lease document before sending it for signature")
const email = lease.tenant?.email
if (!email) throw new Error("The tenant has no email address on file")
const signerName = `${lease.tenant?.first_name ?? ""} ${lease.tenant?.last_name ?? ""}`.trim() || "Tenant"
const { bytes, name } = await getDocumentBytes(lease.document_url)
try {
const { externalId } = await adapter.send({
document: bytes,
documentName: name,
signerEmail: email,
signerName,
subject: "Please sign your lease agreement",
message: "Your landlord has sent your lease agreement for electronic signature.",
})
const [row] = await db
.insert(signature_requests)
.values({ user_id: ownerId, lease_id: leaseId, provider, external_id: externalId, status: "sent", signer_email: email, signer_name: signerName, document_name: name })
.returning()
return row
} catch (e) {
const msg = (e as Error).message.slice(0, 500)
await db
.insert(signature_requests)
.values({ user_id: ownerId, lease_id: leaseId, provider, status: "error", signer_email: email, signer_name: signerName, document_name: name, last_error: msg })
throw new Error(msg)
}
}
export async function listRequestsForLease(ownerId: string, leaseId: string) {
return db.query.signature_requests.findMany({
where: and(eq(signature_requests.user_id, ownerId), eq(signature_requests.lease_id, leaseId)),
orderBy: desc(signature_requests.sent_at),
})
}
/** Update a request's status from an inbound provider webhook. */
export async function handleEsignWebhook(provider: string, body: string, headers: Headers) {
const adapter = getAdapter(provider)
if (!adapter) return
const result = adapter.parseWebhook(body, headers)
if (!result) return
await db
.update(signature_requests)
.set({
status: result.status,
completed_at: result.status === "signed" ? new Date().toISOString() : null,
updated_at: new Date().toISOString(),
})
.where(eq(signature_requests.external_id, result.externalId))
}
+26
View File
@@ -0,0 +1,26 @@
export type ESignProvider = "docusign" | "dropbox_sign"
export interface SendParams {
document: Buffer
documentName: string
signerEmail: string
signerName: string
subject: string
message: string
}
export interface WebhookResult {
externalId: string
status: "signed" | "declined" | "voided"
}
export interface ESignAdapter {
id: ESignProvider
label: string
/** True when this provider's credentials are configured in env. */
configured(): boolean
/** Send a document for signature; returns the provider's request/envelope id. */
send(p: SendParams): Promise<{ externalId: string }>
/** Parse an inbound webhook body into a status update (or null to ignore). */
parseWebhook(body: string, headers: Headers): WebhookResult | null
}
+190
View File
@@ -0,0 +1,190 @@
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 { logActivity } from "@/lib/activity"
import { sendEmail, followUpHtml } from "@/lib/email/send"
/**
* Process all active follow-up rules for a single owner account and send the
* resulting emails. Everything is scoped by `userId` (the effective owner id).
* Returns the number of follow-up actions triggered.
*/
export async function runFollowUpsForUser(userId: string): Promise<{ sent: number }> {
const ownerId = userId
const rules = await db
.select()
.from(follow_up_rules)
.where(and(eq(follow_up_rules.user_id, ownerId), eq(follow_up_rules.is_active, true)))
if (!rules.length) return { sent: 0 }
const now = new Date()
const followUpsToLog: (typeof follow_up_log.$inferInsert)[] = []
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, ownerId),
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: ownerId,
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, ownerId),
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: ownerId,
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, ownerId),
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: ownerId,
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, ownerId), 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: ownerId,
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, ownerId)))
}
// 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: followUpHtml(log.message),
})
} catch {
log.status = "failed"
}
}
}
if (followUpsToLog.length > 0) {
await db.insert(follow_up_log).values(followUpsToLog)
}
await logActivity({
userId: ownerId,
type: "ai_action",
title: `Follow-ups processed: ${followUpsToLog.length} action${followUpsToLog.length !== 1 ? "s" : ""} triggered`,
})
return { sent: followUpsToLog.length }
}
+66
View File
@@ -0,0 +1,66 @@
// Free, keyless forward geocoding via OpenStreetMap Nominatim.
//
// Usage policy — https://operations.osmfoundation.org/policies/nominatim/ :
// • max 1 request/second, no heavy bulk use
// • an identifying User-Agent is REQUIRED (set GEOCODER_USER_AGENT to a
// contact URL/email in production)
// • results must be cached — we persist latitude/longitude on the property,
// so each address is geocoded once on save, never on map render.
//
// Best-effort by design: every failure path returns null and the caller simply
// proceeds without coordinates (the property is omitted from the map).
const NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
const USER_AGENT =
process.env.GEOCODER_USER_AGENT ||
`PropertyManagementNetwork/1.0 (${process.env.NEXT_PUBLIC_APP_URL || "https://propertymanagement.network"})`
export type Coordinates = { latitude: number; longitude: number }
export type AddressParts = {
address_line1?: string | null
address_line2?: string | null
city?: string | null
state?: string | null
postal_code?: string | null
country?: string | null
}
/** True when there's enough of an address to bother geocoding. */
export function hasGeocodableAddress(a: AddressParts): boolean {
return Boolean(a.address_line1 || a.city || a.postal_code)
}
export async function geocodeAddress(a: AddressParts): Promise<Coordinates | null> {
if (!hasGeocodableAddress(a)) return null
const street = [a.address_line1, a.address_line2].filter(Boolean).join(" ").trim()
const params = new URLSearchParams({ format: "jsonv2", limit: "1", addressdetails: "0" })
if (street) params.set("street", street)
if (a.city) params.set("city", a.city)
if (a.state) params.set("state", a.state)
if (a.postal_code) params.set("postalcode", a.postal_code)
params.set("country", a.country || "US")
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 8000)
try {
const res = await fetch(`${NOMINATIM_URL}?${params.toString()}`, {
headers: { "User-Agent": USER_AGENT, Accept: "application/json" },
signal: controller.signal,
})
if (!res.ok) return null
const json = (await res.json()) as Array<{ lat: string; lon: string }>
const first = Array.isArray(json) ? json[0] : undefined
if (!first) return null
const latitude = Number(first.lat)
const longitude = Number(first.lon)
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null
return { latitude, longitude }
} catch {
return null
} finally {
clearTimeout(timer)
}
}
+89
View File
@@ -0,0 +1,89 @@
// ============================================================================
// Central legal configuration — single source of truth for every legal page.
//
// ⚠️ REVIEW BEFORE LAUNCH: `entity`, `address`, `governingLaw`, and `forum`
// are placeholders. Set them to your real registered company, business address,
// and chosen governing-law jurisdiction, and have qualified legal counsel review
// all policy copy for your specific business and markets. These templates are a
// strong, comprehensive starting point — not a substitute for legal advice.
// ============================================================================
export const LEGAL = {
/** Product / service name shown to users. */
service: "Property Management Network",
/** Legal entity that operates the Service. Update to your registered company. */
entity: "Property Management Network",
/** Registered business address (shown on legal pages). */
address: "[Registered business address]",
// Contacts
contactEmail: "support@propertymanagement.network",
privacyEmail: "privacy@propertymanagement.network",
legalEmail: "legal@propertymanagement.network",
dpoEmail: "dpo@propertymanagement.network",
securityEmail: "security@propertymanagement.network",
// Dispute resolution — update to your actual jurisdiction.
governingLaw: "the State of Delaware, United States",
forum: "the state and federal courts located in Delaware, United States",
// Dates (update `lastUpdated` whenever a policy changes).
effectiveDate: "July 1, 2026",
lastUpdated: "July 1, 2026",
// Refund window for the Lifetime one-time plan.
lifetimeRefundDays: 14,
// How long personal data is retained after account deletion (days).
dataDeletionDays: 30,
} as const
/**
* Third parties that process personal data on our behalf. This list is the
* authoritative sub-processor register referenced by the DPA and GDPR pages.
* Keep it accurate — it reflects the actual production stack.
*/
export const SUBPROCESSORS = [
{
name: "DigitalOcean, LLC",
purpose: "Cloud hosting (App Platform), Managed PostgreSQL database, and Spaces object storage for uploaded files",
location: "United States / EU (data-center region dependent)",
},
{
name: "Stripe, Inc.",
purpose: "Payment processing and subscription billing (card data is handled by Stripe; we never store it)",
location: "United States (Standard Contractual Clauses)",
},
{
name: "SMTP2GO (SMTP2GO Ltd.)",
purpose: "Transactional and notification email delivery (SMTP)",
location: "United States / New Zealand (Standard Contractual Clauses)",
},
{
name: "OpenAI, L.L.C.",
purpose: "AI-generated insights; portfolio data is sent per request and is not used to train models",
location: "United States (Standard Contractual Clauses)",
},
{
name: "Cloudflare, Inc.",
purpose: "Bot-protection challenge (Turnstile) on authentication forms",
location: "Global edge network",
},
{
name: "Google LLC",
purpose: "Optional Google sign-in (OAuth) when a user chooses it",
location: "United States (Standard Contractual Clauses)",
},
] as const
/** All legal pages, in the order shown in the footer and cross-link nav. */
export const LEGAL_PAGES = [
{ href: "/terms", label: "Terms of Service" },
{ href: "/privacy", label: "Privacy Policy" },
{ href: "/cookie-policy", label: "Cookie Policy" },
{ href: "/acceptable-use", label: "Acceptable Use Policy" },
{ href: "/refund-policy", label: "Refund & Cancellation" },
{ href: "/dpa", label: "Data Processing Addendum" },
{ href: "/subprocessors", label: "Sub-processors" },
{ href: "/gdpr", label: "GDPR & Data Rights" },
{ href: "/disclaimer", label: "Disclaimer" },
] as const
+123
View File
@@ -0,0 +1,123 @@
import { paypalFetch } from "./client"
// We encode the app user id + target plan into PayPal's `custom_id` so webhooks
// and the return handler can resolve who/what a subscription or order is for,
// without trusting query params. Format: "<userId>:<plan>".
export function encodeCustomId(userId: string, plan: string): string {
return `${userId}:${plan}`
}
export function decodeCustomId(customId: string | null | undefined): { userId: string; plan: string } | null {
if (!customId) return null
const idx = customId.lastIndexOf(":")
if (idx <= 0) return null
return { userId: customId.slice(0, idx), plan: customId.slice(idx + 1) }
}
function approveUrl(links: Array<{ rel: string; href: string }> | undefined): string | undefined {
return links?.find((l) => l.rel === "approve" || l.rel === "payer-action")?.href
}
const BRAND = "Property Management Network"
/** Create a recurring subscription; returns its id + the PayPal approval URL. */
export async function createSubscription(params: {
planId: string
userId: string
plan: string
email?: string | null
returnUrl: string
cancelUrl: string
}): Promise<{ id: string; approveUrl?: string }> {
const res = await paypalFetch("/v1/billing/subscriptions", {
method: "POST",
body: JSON.stringify({
plan_id: params.planId,
custom_id: encodeCustomId(params.userId, params.plan),
subscriber: params.email ? { email_address: params.email } : undefined,
application_context: {
brand_name: BRAND,
user_action: "SUBSCRIBE_NOW",
shipping_preference: "NO_SHIPPING",
return_url: params.returnUrl,
cancel_url: params.cancelUrl,
},
}),
})
if (!res.ok) throw new Error(`PayPal createSubscription failed: ${res.status} ${await res.text().catch(() => "")}`)
const json = (await res.json()) as { id: string; links?: Array<{ rel: string; href: string }> }
return { id: json.id, approveUrl: approveUrl(json.links) }
}
/** Create a one-time order (used for the Lifetime plan). */
export async function createOrder(params: {
amount: number
userId: string
plan: string
returnUrl: string
cancelUrl: string
}): Promise<{ id: string; approveUrl?: string }> {
const res = await paypalFetch("/v2/checkout/orders", {
method: "POST",
body: JSON.stringify({
intent: "CAPTURE",
purchase_units: [
{
amount: { currency_code: "USD", value: params.amount.toFixed(2) },
custom_id: encodeCustomId(params.userId, params.plan),
description: `${BRAND} — Lifetime`,
},
],
application_context: {
brand_name: BRAND,
user_action: "PAY_NOW",
shipping_preference: "NO_SHIPPING",
return_url: params.returnUrl,
cancel_url: params.cancelUrl,
},
}),
})
if (!res.ok) throw new Error(`PayPal createOrder failed: ${res.status} ${await res.text().catch(() => "")}`)
const json = (await res.json()) as { id: string; links?: Array<{ rel: string; href: string }> }
return { id: json.id, approveUrl: approveUrl(json.links) }
}
/** Capture an approved order. Returns the captured order (status COMPLETED). */
export async function captureOrder(orderId: string): Promise<{
status: string
custom_id?: string
} | null> {
const res = await paypalFetch(`/v2/checkout/orders/${orderId}/capture`, {
method: "POST",
body: "{}",
})
if (!res.ok) return null
const json = (await res.json()) as {
status: string
purchase_units?: Array<{ custom_id?: string; payments?: { captures?: Array<{ custom_id?: string }> } }>
}
const unit = json.purchase_units?.[0]
const custom_id = unit?.custom_id ?? unit?.payments?.captures?.[0]?.custom_id
return { status: json.status, custom_id }
}
export type PaypalSubscription = {
id: string
status: string
custom_id?: string
billing_info?: { next_billing_time?: string }
}
export async function getSubscription(id: string): Promise<PaypalSubscription | null> {
const res = await paypalFetch(`/v1/billing/subscriptions/${id}`, { method: "GET" })
if (!res.ok) return null
return (await res.json()) as PaypalSubscription
}
export async function cancelSubscription(id: string, reason = "Cancelled by subscriber"): Promise<boolean> {
const res = await paypalFetch(`/v1/billing/subscriptions/${id}/cancel`, {
method: "POST",
body: JSON.stringify({ reason }),
})
// 204 = cancelled; 422 = already inactive (treat as success so the UI settles).
return res.ok || res.status === 204 || res.status === 422
}
+57
View File
@@ -0,0 +1,57 @@
// PayPal REST API client — OAuth2 client-credentials + a thin fetch helper.
//
// Enabled only when PAYPAL_CLIENT_ID and PAYPAL_SECRET are set (mirrors the
// gating used for the other optional integrations). PAYPAL_ENVIRONMENT selects
// the sandbox (default) or live host.
const ENVIRONMENT = process.env.PAYPAL_ENVIRONMENT === "live" ? "live" : "sandbox"
const BASE_URL =
ENVIRONMENT === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com"
export function paypalConfigured(): boolean {
return Boolean(process.env.PAYPAL_CLIENT_ID && process.env.PAYPAL_SECRET)
}
export function paypalEnvironment() {
return ENVIRONMENT
}
// Access tokens live ~9h; cache in-process (the app runs a persistent Node
// server, so this survives across requests) and refresh a minute early.
let cachedToken: { token: string; expiresAt: number } | null = null
async function getAccessToken(): Promise<string> {
if (cachedToken && cachedToken.expiresAt > Date.now() + 60_000) return cachedToken.token
const id = process.env.PAYPAL_CLIENT_ID
const secret = process.env.PAYPAL_SECRET
if (!id || !secret) throw new Error("PayPal is not configured")
const res = await fetch(`${BASE_URL}/v1/oauth2/token`, {
method: "POST",
headers: {
Authorization: `Basic ${Buffer.from(`${id}:${secret}`).toString("base64")}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: "grant_type=client_credentials",
})
if (!res.ok) throw new Error(`PayPal auth failed: ${res.status} ${await res.text().catch(() => "")}`)
const json = (await res.json()) as { access_token: string; expires_in: number }
cachedToken = { token: json.access_token, expiresAt: Date.now() + json.expires_in * 1000 }
return cachedToken.token
}
/** Authenticated fetch against the PayPal REST API. Path is relative (e.g. "/v1/..."). */
export async function paypalFetch(path: string, init: RequestInit = {}): Promise<Response> {
const token = await getAccessToken()
return fetch(`${BASE_URL}${path}`, {
...init,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...(init.headers ?? {}),
},
})
}
+53
View File
@@ -0,0 +1,53 @@
import { eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { profiles } from "@/lib/db/schema"
import type { Plan } from "@/types"
// Applies PayPal subscription/order outcomes to a profile. Shared by the return
// handler (synchronous, on approval redirect) and the webhook (async, for
// renewals/cancellations). Both are idempotent.
const RECURRING: ReadonlyArray<Plan> = ["pro", "landlord"]
export async function fulfillSubscription(
userId: string,
plan: string,
subscriptionId: string,
nextBillingTime?: string | null,
status = "active",
): Promise<void> {
if (!RECURRING.includes(plan as Plan)) return
await db
.update(profiles)
.set({
plan: plan as Plan,
subscription_status: status,
paypal_subscription_id: subscriptionId,
billing_provider: "paypal",
plan_expires_at: nextBillingTime ?? null,
})
.where(eq(profiles.id, userId))
}
export async function fulfillLifetime(userId: string): Promise<void> {
await db
.update(profiles)
.set({ plan: "lifetime", subscription_status: "active", billing_provider: "paypal" })
.where(eq(profiles.id, userId))
}
/** Downgrade/mark a profile by its PayPal subscription id (cancel/expire/suspend). */
export async function markPaypalSubscriptionInactive(
subscriptionId: string,
status: string,
downgrade: boolean,
): Promise<void> {
await db
.update(profiles)
.set(
downgrade
? { subscription_status: status, plan: "starter", paypal_subscription_id: null, plan_expires_at: null }
: { subscription_status: status },
)
.where(eq(profiles.paypal_subscription_id, subscriptionId))
}
+28
View File
@@ -0,0 +1,28 @@
import type { Plan } from "@/types"
// PayPal billing-plan IDs, one per (plan, interval). Create them once with
// `node scripts/paypal-setup-plans.mjs` and paste the printed IDs into the
// environment. A plan/interval with no configured ID simply isn't offered.
const PAYPAL_PLAN_IDS: Record<string, string | undefined> = {
"pro:month": process.env.PAYPAL_PRO_MONTHLY_PLAN_ID,
"pro:year": process.env.PAYPAL_PRO_YEARLY_PLAN_ID,
"landlord:month": process.env.PAYPAL_LANDLORD_MONTHLY_PLAN_ID,
"landlord:year": process.env.PAYPAL_LANDLORD_YEARLY_PLAN_ID,
}
/** Recurring plans PayPal can bill (lifetime is a one-time order, not a plan). */
export const PAYPAL_RECURRING_PLANS = ["pro", "landlord"] as const
export function getPaypalPlanId(plan: Plan, interval: "month" | "year"): string | undefined {
return PAYPAL_PLAN_IDS[`${plan}:${interval}`] || undefined
}
/** True when at least one PayPal-billable plan is configured. */
export function anyPaypalPlanConfigured(): boolean {
return Object.values(PAYPAL_PLAN_IDS).some(Boolean)
}
/** Annual PayPal billing is offered only when both yearly plan IDs exist. */
export function paypalAnnualEnabled(): boolean {
return Boolean(PAYPAL_PLAN_IDS["pro:year"] && PAYPAL_PLAN_IDS["landlord:year"])
}
+36
View File
@@ -0,0 +1,36 @@
import { paypalFetch } from "./client"
// Verify an inbound PayPal webhook using PayPal's verify-webhook-signature API.
// Requires PAYPAL_WEBHOOK_ID (from the webhook you create in the PayPal app).
// Returns false (reject) when the id is missing or verification doesn't succeed.
export async function verifyPaypalWebhook(headers: Headers, rawBody: string): Promise<boolean> {
const webhookId = process.env.PAYPAL_WEBHOOK_ID
if (!webhookId) return false
let event: unknown
try {
event = JSON.parse(rawBody)
} catch {
return false
}
try {
const res = await paypalFetch("/v1/notifications/verify-webhook-signature", {
method: "POST",
body: JSON.stringify({
auth_algo: headers.get("paypal-auth-algo"),
cert_url: headers.get("paypal-cert-url"),
transmission_id: headers.get("paypal-transmission-id"),
transmission_sig: headers.get("paypal-transmission-sig"),
transmission_time: headers.get("paypal-transmission-time"),
webhook_id: webhookId,
webhook_event: event,
}),
})
if (!res.ok) return false
const json = (await res.json()) as { verification_status?: string }
return json.verification_status === "SUCCESS"
} catch {
return false
}
}
+41
View File
@@ -0,0 +1,41 @@
import { eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { profiles } from "@/lib/db/schema"
import { PLAN_LIMITS } from "@/lib/stripe/plans"
import { getUserStorageBytes } from "@/lib/storage"
import type { Plan } from "@/types"
/**
* Server-side plan-limit enforcement helpers. Single source of truth is
* PLAN_LIMITS in lib/stripe/plans.ts — never hardcode limit numbers in routes.
*/
/** The user's current plan (defaults to "starter" if no profile row). */
export async function getUserPlan(userId: string): Promise<Plan> {
const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, userId),
columns: { plan: true },
})
return (profile?.plan ?? "starter") as Plan
}
/**
* Returns an error message if storing `incomingBytes` more would exceed the
* user's plan storage cap, otherwise null. Reads actual usage from the storage
* backend so it stays accurate regardless of which tables reference the files.
*/
export async function checkStorageLimit(
userId: string,
incomingBytes: number
): Promise<string | null> {
const plan = await getUserPlan(userId)
const maxBytes = PLAN_LIMITS[plan].maxStorageMB * 1024 * 1024
if (!Number.isFinite(maxBytes)) return null // unlimited plan
const used = await getUserStorageBytes(userId)
if (used + incomingBytes > maxBytes) {
const limitMb = PLAN_LIMITS[plan].maxStorageMB
return `Storage limit reached (${limitMb} MB on your plan). Upgrade for more space.`
}
return null
}
+46
View File
@@ -0,0 +1,46 @@
import { eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { app_settings } from "@/lib/db/schema"
const MAINTENANCE_KEY = "maintenance_mode"
export type MaintenanceState = {
enabled: boolean
message: string | null
}
const DEFAULT_MAINTENANCE: MaintenanceState = { enabled: false, message: null }
/**
* Reads the site maintenance-mode flag from app_settings.
*
* Fails OPEN: any DB/read error returns "disabled" so a database hiccup can
* never accidentally lock the entire site (including admins) out. This is
* called from the marketing + dashboard layouts on navigation.
*/
export async function getMaintenanceMode(): Promise<MaintenanceState> {
try {
const row = await db.query.app_settings.findFirst({
where: eq(app_settings.key, MAINTENANCE_KEY),
})
if (!row) return DEFAULT_MAINTENANCE
const v = row.value as Partial<MaintenanceState> | null
return {
enabled: Boolean(v?.enabled),
message: typeof v?.message === "string" && v.message.trim() ? v.message : null,
}
} catch {
return DEFAULT_MAINTENANCE
}
}
/** Upserts the site maintenance-mode flag. Admin-gated by the calling action. */
export async function setMaintenanceMode(state: MaintenanceState): Promise<void> {
await db
.insert(app_settings)
.values({ key: MAINTENANCE_KEY, value: state })
.onConflictDoUpdate({
target: app_settings.key,
set: { value: state, updated_at: new Date().toISOString() },
})
}
+218 -11
View File
@@ -1,9 +1,18 @@
import { promises as fs } from "fs"
import path from "path"
import { randomBytes } from "crypto"
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
ListObjectsV2Command,
type GetObjectCommandOutput,
} from "@aws-sdk/client-s3"
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
// Root directory for uploaded files. Kept OUTSIDE the public web root so files
// are only ever served through the auth-gated /api/files route.
// Local-disk fallback root. Used only when object storage is not configured.
// Kept OUTSIDE the public web root so files are only served through /api/files.
const STORAGE_DIR = path.resolve(process.cwd(), process.env.STORAGE_DIR ?? "./storage")
const MIME_BY_EXT: Record<string, string> = {
@@ -26,12 +35,103 @@ export function contentTypeForKey(key: string): string {
return MIME_BY_EXT[ext] ?? "application/octet-stream"
}
/** Resolve a storage key to an absolute path, refusing path traversal. */
function resolveKey(key: string): string {
// Single source of truth for what users may upload. Deliberately excludes svg
// and any html/script types, which can execute JavaScript when served inline
// from our origin. Enforce this at EVERY upload entry point (see /api/upload
// and /api/documents) — divergence is how an allowlist gets bypassed.
export const ALLOWED_UPLOAD_EXTENSIONS = [
"pdf", "png", "jpg", "jpeg", "gif", "webp",
"doc", "docx", "xls", "xlsx", "csv", "txt",
] as const
export function extOf(filename: string): string {
return filename.split(".").pop()?.toLowerCase() ?? ""
}
export function isAllowedUploadExt(filename: string): boolean {
return (ALLOWED_UPLOAD_EXTENSIONS as readonly string[]).includes(extOf(filename))
}
// ── Object storage (DigitalOcean Spaces / S3-compatible) ──────────────────────
// When SPACES_* are configured, uploads and serving use the bucket instead of
// local disk. The key scheme (`<userId>/<scope>/<file>`) is identical either
// way, so existing /api/files/<key> URLs stored in the DB keep working after the
// backend switch — only the bytes move.
const SPACES_BUCKET = process.env.SPACES_BUCKET ?? ""
export function usingSpaces(): boolean {
return Boolean(process.env.SPACES_KEY && process.env.SPACES_SECRET && SPACES_BUCKET)
}
/**
* Thrown when a write is attempted in production without object storage
* configured. The local-disk fallback is EPHEMERAL on App Platform, so silently
* using it means uploads vanish on the next deploy. We fail loud instead.
*/
export class StorageNotConfiguredError extends Error {
constructor() {
super(
"Object storage (SPACES_*) is not configured. Refusing to write uploads to " +
"ephemeral local disk in production — files would be lost on the next deploy."
)
this.name = "StorageNotConfiguredError"
}
}
function newClient(endpoint: string | undefined): S3Client {
return new S3Client({
region: process.env.SPACES_REGION || "us-east-1",
endpoint,
forcePathStyle: false,
credentials: {
accessKeyId: process.env.SPACES_KEY!,
secretAccessKey: process.env.SPACES_SECRET!,
},
})
}
// Origin client — used for all mutating/reading operations (PUT/GET/DELETE/HEAD).
let _s3: S3Client | null = null
function s3(): S3Client {
if (!_s3) _s3 = newClient(process.env.SPACES_ENDPOINT) // e.g. https://nyc3.digitaloceanspaces.com
return _s3
}
// Rewrite a presigned origin URL to the Spaces CDN edge host when the CDN is
// enabled. The URL is signed against the ORIGIN host; the CDN forwards requests
// to origin with the origin Host header, so the SigV4 signature still validates.
// (Signing directly against the CDN host is rejected by origin with 403.)
function toCdnUrl(signedUrl: string): string {
const cdn = process.env.SPACES_CDN_ENDPOINT
const origin = process.env.SPACES_ENDPOINT
if (!cdn || !origin) return signedUrl
try {
const originHost = new URL(origin).host // e.g. nyc3.digitaloceanspaces.com
const cdnHost = new URL(cdn).host // e.g. nyc3.cdn.digitaloceanspaces.com
const u = new URL(signedUrl)
if (u.host.endsWith(originHost)) {
u.host = u.host.slice(0, u.host.length - originHost.length) + cdnHost
return u.toString()
}
return signedUrl
} catch {
return signedUrl
}
}
// ── key helpers ───────────────────────────────────────────────────────────────
/** Validate a storage key, refusing empty/traversal segments. Returns it cleaned. */
function assertSafeKey(key: string): string {
const clean = key.replace(/^\/+/, "")
if (clean.split(/[\\/]+/).some((seg) => seg === ".." || seg === ".")) {
if (!clean || clean.split(/[\\/]+/).some((seg) => seg === "" || seg === "." || seg === "..")) {
throw new Error("Invalid storage path")
}
return clean
}
/** Resolve a key to an absolute local path (local-disk backend only). */
function resolveKey(key: string): string {
const clean = assertSafeKey(key)
const abs = path.resolve(STORAGE_DIR, clean)
if (abs !== STORAGE_DIR && !abs.startsWith(STORAGE_DIR + path.sep)) {
throw new Error("Invalid storage path")
@@ -43,6 +143,18 @@ function sanitizeSegment(s: string): string {
return s.replace(/[^a-zA-Z0-9_-]/g, "_")
}
async function bodyToBuffer(body: GetObjectCommandOutput["Body"]): Promise<Buffer> {
if (!body) return Buffer.alloc(0)
// The AWS SDK v3 Node runtime adds transformToByteArray() to the stream body.
const stream = body as { transformToByteArray?: () => Promise<Uint8Array> }
if (typeof stream.transformToByteArray === "function") {
return Buffer.from(await stream.transformToByteArray())
}
const chunks: Buffer[] = []
for await (const chunk of body as AsyncIterable<Uint8Array>) chunks.push(Buffer.from(chunk))
return Buffer.concat(chunks)
}
/**
* Persist an uploaded File under `${userId}/${scope}/<random>.<ext>` and return
* the storage key (relative path). Optionally pass `fixedName` to make the file
@@ -57,23 +169,118 @@ export async function saveFile(
? sanitizeSegment(opts.fixedName)
: `${Date.now()}-${randomBytes(6).toString("hex")}`
const key = `${sanitizeSegment(opts.userId)}/${sanitizeSegment(opts.scope)}/${base}.${ext}`
const abs = resolveKey(key)
await fs.mkdir(path.dirname(abs), { recursive: true })
const buffer = Buffer.from(await file.arrayBuffer())
await fs.writeFile(abs, buffer)
const type = file.type || contentTypeForKey(key)
return { key, size: file.size, type: file.type || contentTypeForKey(key) }
if (usingSpaces()) {
await s3().send(
new PutObjectCommand({
Bucket: SPACES_BUCKET,
Key: key,
Body: buffer,
ContentType: type,
ACL: "private",
})
)
} else {
// In production the local-disk backend is ephemeral (lost on redeploy), so a
// misconfigured Spaces setup must fail loudly rather than silently drop data.
if (process.env.NODE_ENV === "production") throw new StorageNotConfiguredError()
const abs = resolveKey(key)
await fs.mkdir(path.dirname(abs), { recursive: true })
await fs.writeFile(abs, buffer)
}
return { key, size: file.size, type }
}
export async function readFile(key: string): Promise<Buffer> {
if (usingSpaces()) {
const res = await s3().send(new GetObjectCommand({ Bucket: SPACES_BUCKET, Key: assertSafeKey(key) }))
return bodyToBuffer(res.Body)
}
return fs.readFile(resolveKey(key))
}
/**
* Presigned, time-limited GET URL for an object in Spaces — lets the browser
* fetch the bytes directly from the bucket (offloading them from the app) while
* access stays gated: the caller must pass auth + ownership checks before this
* is issued, and the URL expires. `ResponseContent*` control how the browser
* treats the file (inline image vs. attachment download) with the right type.
*/
export async function presignGetUrl(
key: string,
opts: { expiresIn?: number; disposition?: "inline" | "attachment"; filename?: string } = {}
): Promise<string> {
const safe = assertSafeKey(key)
// The key's basename is already generated/sanitized at upload; keep only
// filename-safe characters for the Content-Disposition header.
const filename = (opts.filename ?? safe.split("/").pop() ?? "file").replace(/[^A-Za-z0-9._-]/g, "_")
const cmd = new GetObjectCommand({
Bucket: SPACES_BUCKET,
Key: safe,
ResponseContentType: contentTypeForKey(safe),
ResponseContentDisposition: `${opts.disposition ?? "inline"}; filename="${filename}"`,
})
const signed = await getSignedUrl(s3(), cmd, { expiresIn: opts.expiresIn ?? 3600 })
return toCdnUrl(signed)
}
export async function deleteFile(key: string): Promise<void> {
try {
await fs.unlink(resolveKey(key))
if (usingSpaces()) {
await s3().send(new DeleteObjectCommand({ Bucket: SPACES_BUCKET, Key: assertSafeKey(key) }))
} else {
await fs.unlink(resolveKey(key))
}
} catch {
// Already gone — ignore.
}
}
async function walkDirSize(dir: string): Promise<number> {
let entries
try {
entries = await fs.readdir(dir, { withFileTypes: true })
} catch {
return 0 // directory doesn't exist yet → 0 bytes
}
let total = 0
for (const e of entries) {
const full = path.join(dir, e.name)
if (e.isDirectory()) total += await walkDirSize(full)
else if (e.isFile()) {
try {
total += (await fs.stat(full)).size
} catch {
// File vanished between readdir and stat — ignore.
}
}
}
return total
}
/**
* Total bytes currently stored for a user, across whichever backend is active.
* Files are namespaced under `<userId>/…`, so we sum that prefix. Used to
* enforce per-plan storage quotas at upload time.
*/
export async function getUserStorageBytes(userId: string): Promise<number> {
const prefix = `${sanitizeSegment(userId)}/`
if (usingSpaces()) {
let total = 0
let token: string | undefined
do {
const res = await s3().send(
new ListObjectsV2Command({ Bucket: SPACES_BUCKET, Prefix: prefix, ContinuationToken: token })
)
for (const obj of res.Contents ?? []) total += obj.Size ?? 0
token = res.IsTruncated ? res.NextContinuationToken : undefined
} while (token)
return total
}
return walkDirSize(path.join(STORAGE_DIR, sanitizeSegment(userId)))
}
+19 -19
View File
@@ -35,25 +35,25 @@ export const PLAN_LIMITS: Record<Plan, PlanLimits> = {
},
}
export const PLAN_PRICES: Record<string, { plan: Plan; priceId: string; amount: number; interval: string }> = {
pro: {
plan: "pro",
priceId: process.env.STRIPE_PRO_MONTHLY_PRICE_ID!,
amount: 29,
interval: "month",
},
landlord: {
plan: "landlord",
priceId: process.env.STRIPE_LANDLORD_MONTHLY_PRICE_ID!,
amount: 59,
interval: "month",
},
lifetime: {
plan: "lifetime",
priceId: process.env.STRIPE_LIFETIME_PRICE_ID!,
amount: 199,
interval: "one_time",
},
// Single source of truth for DISPLAYED prices (USD). Client-safe (plain numbers,
// no env). Billing always charges the Stripe Price ID, so a mismatch here only
// affects what the marketing/billing UI shows — keep these in sync with the
// amounts configured on the Stripe Prices referenced below.
export const PLAN_AMOUNTS = { starter: 0, pro: 29, landlord: 59, lifetime: 199 } as const
// Plan metadata (client-safe — no env, no price IDs). The actual Stripe price
// is resolved at runtime by lib/stripe/prices.ts using stable lookup keys, so
// the same code works in test and live with ONLY an API-key swap.
export const PLAN_PRICES: Record<string, { plan: Plan; amount: number; interval: string }> = {
pro: { plan: "pro", amount: PLAN_AMOUNTS.pro, interval: "month" },
landlord: { plan: "landlord", amount: PLAN_AMOUNTS.landlord, interval: "month" },
lifetime: { plan: "lifetime", amount: PLAN_AMOUNTS.lifetime, interval: "one_time" },
}
// Annual billing is always offered; the yearly price is resolved (and
// auto-provisioned if missing) at checkout time by lib/stripe/prices.ts.
export function annualEnabled(): boolean {
return true
}
export function checkLimit(
+87
View File
@@ -0,0 +1,87 @@
import { stripe } from "./client"
import { PLAN_AMOUNTS } from "./plans"
import type { Plan } from "@/types"
// Resolve Stripe prices by STABLE LOOKUP KEYS instead of hardcoded price IDs.
//
// The same lookup keys exist in both test and live mode, so the app finds the
// right price for whichever API key is configured — making go-live a pure
// key swap with NO price IDs to copy. If a price doesn't exist yet in the
// current mode, it's auto-created from PLAN_AMOUNTS on first use, so even the
// very first live checkout just works.
export const PRICE_LOOKUP_KEYS = {
pro_month: "pmn_pro_monthly",
pro_year: "pmn_pro_yearly",
landlord_month: "pmn_landlord_monthly",
landlord_year: "pmn_landlord_yearly",
lifetime: "pmn_lifetime",
} as const
type LookupKey = (typeof PRICE_LOOKUP_KEYS)[keyof typeof PRICE_LOOKUP_KEYS]
// How to create each price if it's missing in the current mode (amounts mirror
// the single source of truth in plans.ts; yearly = 10× monthly, ~2 months free).
const PRICE_SPEC: Record<LookupKey, { product: string; dollars: number; interval: "month" | "year" | null }> = {
[PRICE_LOOKUP_KEYS.pro_month]: { product: "Pro", dollars: PLAN_AMOUNTS.pro, interval: "month" },
[PRICE_LOOKUP_KEYS.pro_year]: { product: "Pro", dollars: PLAN_AMOUNTS.pro * 10, interval: "year" },
[PRICE_LOOKUP_KEYS.landlord_month]: { product: "Landlord", dollars: PLAN_AMOUNTS.landlord, interval: "month" },
[PRICE_LOOKUP_KEYS.landlord_year]: { product: "Landlord", dollars: PLAN_AMOUNTS.landlord * 10, interval: "year" },
[PRICE_LOOKUP_KEYS.lifetime]: { product: "Lifetime", dollars: PLAN_AMOUNTS.lifetime, interval: null },
}
// Per-process cache. A deployment runs with a single API key, so test and live
// never share a process — no cross-mode leakage.
const cache = new Map<LookupKey, string>()
async function resolveByLookup(key: LookupKey): Promise<string | undefined> {
const cached = cache.get(key)
if (cached) return cached
const existing = await stripe.prices.list({ lookup_keys: [key], active: true, limit: 1 })
if (existing.data[0]) {
cache.set(key, existing.data[0].id)
return existing.data[0].id
}
// Not present in this mode yet — create the product + price on the fly so the
// first checkout after a key swap succeeds without any manual setup.
const spec = PRICE_SPEC[key]
try {
const product = await stripe.products.create({
name: `Property Management Network — ${spec.product}`,
})
const price = await stripe.prices.create({
product: product.id,
currency: "usd",
unit_amount: Math.round(spec.dollars * 100),
lookup_key: key,
transfer_lookup_key: true,
...(spec.interval ? { recurring: { interval: spec.interval } } : {}),
})
cache.set(key, price.id)
return price.id
} catch {
// Lost a race with a concurrent checkout — re-read and use the winner.
const retry = await stripe.prices.list({ lookup_keys: [key], active: true, limit: 1 })
if (retry.data[0]) {
cache.set(key, retry.data[0].id)
return retry.data[0].id
}
return undefined
}
}
/** Resolve the Stripe price id for a plan + interval (auto-provisions if needed). */
export async function resolvePriceId(plan: Plan, interval: "month" | "year"): Promise<string | undefined> {
switch (plan) {
case "lifetime":
return resolveByLookup(PRICE_LOOKUP_KEYS.lifetime)
case "pro":
return resolveByLookup(interval === "year" ? PRICE_LOOKUP_KEYS.pro_year : PRICE_LOOKUP_KEYS.pro_month)
case "landlord":
return resolveByLookup(interval === "year" ? PRICE_LOOKUP_KEYS.landlord_year : PRICE_LOOKUP_KEYS.landlord_month)
default:
return undefined
}
}
+37
View File
@@ -0,0 +1,37 @@
const VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
/**
* Verifies a Cloudflare Turnstile token server-side against the siteverify API.
*
* Fails CLOSED when Turnstile is configured (secret present) but the token is
* missing or invalid. Fails OPEN only when `TURNSTILE_SECRET_KEY` is unset — so
* environments that haven't configured Turnstile keep working, matching how the
* other optional integrations (Stripe / OpenAI / SMTP email) degrade in this app.
*/
export async function verifyTurnstile(
token: string | undefined | null,
remoteIp?: string | null
): Promise<boolean> {
const secret = process.env.TURNSTILE_SECRET_KEY
if (!secret) return true // integration disabled — do not block auth
if (!token) return false
try {
const body = new URLSearchParams()
body.append("secret", secret)
body.append("response", token)
if (remoteIp) body.append("remoteip", remoteIp)
const res = await fetch(VERIFY_URL, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body,
cache: "no-store",
})
const data = (await res.json()) as { success?: boolean }
return data.success === true
} catch {
// Network / provider error — fail closed so a challenge can't be bypassed.
return false
}
}
+11
View File
@@ -1,4 +1,5 @@
import { z } from "zod"
import { WEBHOOK_EVENT_IDS } from "@/lib/webhooks/events"
export const propertySchema = z.object({
name: z.string().min(1, "Property name is required").max(200),
@@ -71,6 +72,7 @@ export const leaseSchema = z.object({
rent_amount: z.number().positive("Rent amount must be positive"),
security_deposit: z.number().positive().optional(),
lease_type: z.enum(["fixed", "month_to_month"]).default("fixed"),
status: z.enum(["active", "expired", "terminated", "renewed"]).optional(),
auto_renew: z.boolean().default(false),
notes: z.string().max(5000).optional(),
})
@@ -122,6 +124,15 @@ export const followUpRuleSchema = z.object({
message_template: z.string().max(5000).optional(),
})
// Outbound webhooks. `events` is the set of subscribed event ids; an empty array
// means "all events". SSRF/host validation happens server-side (see lib/webhooks/ssrf).
export const webhookEndpointSchema = z.object({
url: z.string().url("Enter a valid URL").max(2000),
description: z.string().max(200).optional().or(z.literal("")),
events: z.array(z.enum(WEBHOOK_EVENT_IDS as [string, ...string[]])).default([]),
})
export type ExpenseFormValues = z.infer<typeof expenseSchema>
export type VendorFormValues = z.infer<typeof vendorSchema>
export type InspectionFormValues = z.infer<typeof inspectionSchema>
export type WebhookEndpointFormValues = z.infer<typeof webhookEndpointSchema>
+179
View File
@@ -0,0 +1,179 @@
import { createHmac, randomBytes, timingSafeEqual } from "crypto"
import { and, asc, eq, lte } from "drizzle-orm"
import { db } from "@/lib/db"
import { webhook_deliveries, webhook_endpoints } from "@/lib/db/schema"
import { isSafeWebhookUrl } from "./ssrf"
// ============================================================================
// Webhook delivery: signing + HTTP POST + retry bookkeeping.
//
// Signature scheme (Stripe-style, HMAC-SHA256):
// header X-PMN-Signature: t=<unix>,v1=<hex>
// signed `${t}.${rawBody}`
// Receivers recompute the HMAC with their endpoint secret and compare. A short
// timestamp lets them reject replays.
// ============================================================================
const SECRET_PREFIX = "whsec_"
const DELIVERY_TIMEOUT_MS = 10_000
const MAX_RESPONSE_CHARS = 2_000
// Retry backoff (minutes) indexed by the attempt number just completed.
const BACKOFF_MINUTES = [1, 5, 15, 60, 180]
type EndpointRow = typeof webhook_endpoints.$inferSelect
type DeliveryRow = typeof webhook_deliveries.$inferSelect
/** Generate a new endpoint signing secret, e.g. `whsec_<64 hex>`. */
export function generateWebhookSecret(): string {
return `${SECRET_PREFIX}${randomBytes(32).toString("hex")}`
}
/** Compute the `X-PMN-Signature` header value for a raw body + secret. */
export function signaturePayload(secret: string, timestamp: number, body: string): string {
return createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex")
}
function signatureHeader(secret: string, body: string): { header: string; timestamp: number } {
const timestamp = Math.floor(Date.now() / 1000)
const v1 = signaturePayload(secret, timestamp, body)
return { header: `t=${timestamp},v1=${v1}`, timestamp }
}
/**
* Verify an inbound signature header against a body + secret. Exposed so that a
* receiver built on this codebase (and our own tests) can validate deliveries.
* Tolerates clock skew up to `toleranceSeconds` (default 5 min).
*/
export function verifySignature(
secret: string,
header: string,
body: string,
toleranceSeconds = 300
): boolean {
const parts = Object.fromEntries(
header.split(",").map((kv) => {
const [k, v] = kv.split("=")
return [k?.trim(), v?.trim()]
})
)
const t = Number(parts.t)
const given = parts.v1
if (!Number.isFinite(t) || !given) return false
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false
const expected = signaturePayload(secret, t, body)
const a = Buffer.from(expected)
const b = Buffer.from(given)
return a.length === b.length && timingSafeEqual(a, b)
}
function backoffIso(attemptsCompleted: number): string {
const minutes = BACKOFF_MINUTES[attemptsCompleted - 1] ?? BACKOFF_MINUTES[BACKOFF_MINUTES.length - 1]
return new Date(Date.now() + minutes * 60_000).toISOString()
}
/**
* Attempt to deliver a single delivery row to its endpoint, then persist the
* outcome (status, attempt count, next_attempt_at) and update the endpoint's
* health fields. Never throws — returns whether the POST succeeded.
*/
export async function attemptDelivery(delivery: DeliveryRow, endpoint: EndpointRow): Promise<boolean> {
const body = JSON.stringify(delivery.payload)
const nowIso = new Date().toISOString()
const attemptsNow = delivery.attempts + 1
let ok = false
let responseStatus: number | null = null
let responseBody: string | null = null
let error: string | null = null
try {
// Re-check for SSRF at delivery time — DNS may have changed since creation.
if (!(await isSafeWebhookUrl(endpoint.url))) {
throw new Error("Destination blocked by SSRF protection")
}
const { header } = signatureHeader(endpoint.secret, body)
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), DELIVERY_TIMEOUT_MS)
try {
const res = await fetch(endpoint.url, {
method: "POST",
headers: {
"content-type": "application/json",
"user-agent": "PMN-Webhooks/1.0",
"x-pmn-event": delivery.event,
"x-pmn-delivery": delivery.id,
"x-pmn-webhook-id": endpoint.id,
"x-pmn-signature": header,
},
body,
signal: controller.signal,
redirect: "manual",
})
responseStatus = res.status
responseBody = (await res.text().catch(() => "")).slice(0, MAX_RESPONSE_CHARS)
ok = res.status >= 200 && res.status < 300
if (!ok) error = `Endpoint returned HTTP ${res.status}`
} finally {
clearTimeout(timer)
}
} catch (e) {
error = e instanceof Error ? (e.name === "AbortError" ? "Request timed out" : e.message) : "Delivery failed"
}
const exhausted = !ok && attemptsNow >= delivery.max_attempts
await db
.update(webhook_deliveries)
.set({
status: ok ? "success" : exhausted ? "failed" : "pending",
attempts: attemptsNow,
response_status: responseStatus,
response_body: responseBody,
error: ok ? null : error,
delivered_at: ok ? nowIso : delivery.delivered_at,
next_attempt_at: ok || exhausted ? delivery.next_attempt_at : backoffIso(attemptsNow),
updated_at: nowIso,
})
.where(eq(webhook_deliveries.id, delivery.id))
await db
.update(webhook_endpoints)
.set(
ok
? { last_success_at: nowIso, failure_count: 0, last_error: null }
: { last_error_at: nowIso, last_error: error, failure_count: endpoint.failure_count + 1 }
)
.where(eq(webhook_endpoints.id, endpoint.id))
return ok
}
/**
* Cron drain: deliver every pending delivery whose retry time has come, for all
* accounts. Returns counts for the cron response. Endpoints that are disabled
* are left untouched (their pending deliveries resume if re-enabled).
*/
export async function processDueDeliveries(limit = 100): Promise<{ processed: number; delivered: number }> {
const nowIso = new Date().toISOString()
const due = await db.query.webhook_deliveries.findMany({
where: and(
eq(webhook_deliveries.status, "pending"),
lte(webhook_deliveries.next_attempt_at, nowIso)
),
with: { endpoint: true },
orderBy: asc(webhook_deliveries.next_attempt_at),
limit,
})
let delivered = 0
let processed = 0
for (const row of due) {
const { endpoint, ...delivery } = row
if (!endpoint || endpoint.status !== "active") continue
processed++
if (await attemptDelivery(delivery as DeliveryRow, endpoint)) delivered++
}
return { processed, delivered }
}
+114
View File
@@ -0,0 +1,114 @@
import { randomUUID } from "crypto"
import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { webhook_deliveries, webhook_endpoints } from "@/lib/db/schema"
import { PING_EVENT, type WebhookEnvelope, type WebhookEvent } from "./events"
import { attemptDelivery } from "./deliver"
/** Build the delivery envelope for an event. `id` is a unique per-event id. */
function buildEnvelope(
event: WebhookEvent | typeof PING_EVENT,
data: Record<string, unknown>
): WebhookEnvelope {
return {
id: `evt_${randomUUID().replace(/-/g, "")}`,
event,
created_at: new Date().toISOString(),
data,
}
}
// ============================================================================
// Event emission — the one function domain code calls after a mutation.
//
// await emitWebhookEvent({ ownerId, event: "tenant.created", data: {...} })
//
// It resolves the account's subscribed endpoints, records a pending delivery per
// endpoint, and fires an immediate best-effort delivery in the background (this
// app runs as a persistent Node server, so post-response work completes). The
// webhooks cron is the safety net for retries and process restarts.
// ============================================================================
type EndpointRow = typeof webhook_endpoints.$inferSelect
async function activeSubscribedEndpoints(
ownerId: string,
event: WebhookEvent
): Promise<EndpointRow[]> {
const endpoints = await db.query.webhook_endpoints.findMany({
where: and(eq(webhook_endpoints.user_id, ownerId), eq(webhook_endpoints.status, "active")),
})
// Empty `events` means "subscribe to everything".
return endpoints.filter((e) => e.events.length === 0 || e.events.includes(event))
}
export async function emitWebhookEvent(opts: {
ownerId: string
event: WebhookEvent
data: Record<string, unknown>
}): Promise<void> {
const { ownerId, event, data } = opts
try {
const endpoints = await activeSubscribedEndpoints(ownerId, event)
if (!endpoints.length) return
const envelope = buildEnvelope(event, data)
// Give the immediate background attempt ~60s before the cron would retry, so
// the two never race to double-deliver the same row.
const nextAttempt = new Date(Date.now() + 60_000).toISOString()
const rows = await db
.insert(webhook_deliveries)
.values(
endpoints.map((e) => ({
user_id: ownerId,
endpoint_id: e.id,
event,
payload: envelope as unknown as Record<string, unknown>,
next_attempt_at: nextAttempt,
}))
)
.returning()
const byId = new Map(endpoints.map((e) => [e.id, e]))
void Promise.allSettled(
rows.map((r) => {
const ep = byId.get(r.endpoint_id)
return ep ? attemptDelivery(r, ep) : Promise.resolve(false)
})
).catch(() => {})
} catch {
// The webhook subsystem must never break the originating request.
}
}
/**
* Synchronously deliver a one-off `ping` to a single endpoint (the dashboard
* "Send test event" button). Awaited so the caller can show the result.
*/
export async function deliverTestPing(
endpoint: EndpointRow
): Promise<{ ok: boolean; responseStatus: number | null; error: string | null }> {
const envelope = buildEnvelope(PING_EVENT, {
message: "Test event from Property Management Network",
endpoint_id: endpoint.id,
})
const [row] = await db
.insert(webhook_deliveries)
.values({
user_id: endpoint.user_id,
endpoint_id: endpoint.id,
event: PING_EVENT,
payload: envelope as unknown as Record<string, unknown>,
max_attempts: 1,
next_attempt_at: new Date().toISOString(),
})
.returning()
const ok = await attemptDelivery(row, endpoint)
const updated = await db.query.webhook_deliveries.findFirst({
where: eq(webhook_deliveries.id, row.id),
columns: { response_status: true, error: true },
})
return { ok, responseStatus: updated?.response_status ?? null, error: updated?.error ?? null }
}
+68
View File
@@ -0,0 +1,68 @@
// ============================================================================
// Webhook event catalog.
//
// The single source of truth for the outbound-webhook / Zapier integration.
// Every event a landlord can subscribe to is declared here. `emitWebhookEvent`
// (see ./emit) fans an event out to all of an account's endpoints that either
// subscribe to the event id or subscribe to everything (empty `events` array).
//
// This module is intentionally dependency-free (no `crypto`, no db) so it can be
// imported from client components (e.g. the settings form) and shared schemas.
// ============================================================================
export const WEBHOOK_EVENTS = [
{
id: "property.created",
label: "Property created",
description: "A property was added to the portfolio.",
},
{
id: "tenant.created",
label: "Tenant created",
description: "A new tenant was added.",
},
{
id: "maintenance.created",
label: "Maintenance request opened",
description: "A maintenance request was submitted (dashboard, API, or tenant portal).",
},
{
id: "maintenance.updated",
label: "Maintenance status changed",
description: "A maintenance request moved to a new status (e.g. resolved).",
},
{
id: "payment.recorded",
label: "Payment recorded",
description: "A rent payment record was created.",
},
{
id: "payment.paid",
label: "Payment marked paid",
description: "A rent payment was marked as paid.",
},
{
id: "lease.created",
label: "Lease created",
description: "A lease was created for a tenant.",
},
] as const
export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number]["id"]
/** All valid event ids, plus the reserved `ping` used by the "Send test" button. */
export const WEBHOOK_EVENT_IDS = WEBHOOK_EVENTS.map((e) => e.id) as WebhookEvent[]
export const PING_EVENT = "ping" as const
export function isWebhookEvent(value: unknown): value is WebhookEvent {
return typeof value === "string" && WEBHOOK_EVENT_IDS.includes(value as WebhookEvent)
}
/** The JSON envelope every webhook POST body uses. */
export type WebhookEnvelope = {
id: string
event: WebhookEvent | typeof PING_EVENT
created_at: string
data: Record<string, unknown>
}
+121
View File
@@ -0,0 +1,121 @@
import { lookup } from "dns/promises"
import { isIP } from "net"
// ============================================================================
// SSRF protection for outbound webhooks.
//
// Webhook URLs are attacker-controllable input that the server dials on a
// schedule. Without guardrails a tenant could point one at http://169.254.169.254
// (cloud metadata) or an internal service and use our servers as a proxy. We:
// 1. require https (http allowed only outside production, for local testing);
// 2. reject credentials / non-default-ish shapes;
// 3. reject hostnames that ARE private/reserved IP literals; and
// 4. resolve the hostname and reject if ANY resolved address is private.
//
// Set WEBHOOKS_ALLOW_PRIVATE_HOSTS=true to bypass (1) https-in-prod is still
// enforced) and the private-range checks — intended ONLY for local dev where the
// receiver runs on localhost.
// ============================================================================
const ALLOW_PRIVATE = process.env.WEBHOOKS_ALLOW_PRIVATE_HOSTS === "true"
export class WebhookUrlError extends Error {}
/** True for IPv4 addresses in a private, loopback, link-local or reserved range. */
function isPrivateIPv4(ip: string): boolean {
const parts = ip.split(".").map((n) => parseInt(n, 10))
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return true
const [a, b] = parts
if (a === 0) return true // 0.0.0.0/8 "this network"
if (a === 10) return true // private
if (a === 127) return true // loopback
if (a === 100 && b >= 64 && b <= 127) return true // CGNAT 100.64.0.0/10
if (a === 169 && b === 254) return true // link-local (incl. 169.254.169.254 metadata)
if (a === 172 && b >= 16 && b <= 31) return true // private 172.16.0.0/12
if (a === 192 && b === 0) return true // 192.0.0.0/24 IETF protocol assignments
if (a === 192 && b === 168) return true // private
if (a === 198 && (b === 18 || b === 19)) return true // benchmarking 198.18.0.0/15
if (a >= 224) return true // multicast (224/4) + reserved (240/4) + broadcast
return false
}
/** True for IPv6 loopback, unspecified, ULA, link-local, multicast, or mapped-v4. */
function isPrivateIPv6(ip: string): boolean {
const addr = ip.toLowerCase().split("%")[0] // strip zone id
if (addr === "::1" || addr === "::") return true
// IPv4-mapped / -compatible (e.g. ::ffff:169.254.169.254) — check the v4 part.
const mapped = addr.match(/(?:^::ffff:|^::)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/)
if (mapped) return isPrivateIPv4(mapped[1])
const head = addr.replace(/^\[|\]$/g, "")
if (head.startsWith("fe8") || head.startsWith("fe9") || head.startsWith("fea") || head.startsWith("feb"))
return true // fe80::/10 link-local
if (head.startsWith("fc") || head.startsWith("fd")) return true // fc00::/7 unique-local
if (head.startsWith("ff")) return true // ff00::/8 multicast
return false
}
function isPrivateAddress(ip: string): boolean {
const kind = isIP(ip)
if (kind === 4) return isPrivateIPv4(ip)
if (kind === 6) return isPrivateIPv6(ip)
return true // not a parseable IP → treat as unsafe
}
/**
* Validate a user-supplied webhook URL and, unless private hosts are allowed,
* resolve it to confirm it does not point at an internal address. Throws
* WebhookUrlError with a user-facing message on any violation.
*/
export async function assertSafeWebhookUrl(raw: string): Promise<void> {
let url: URL
try {
url = new URL(raw)
} catch {
throw new WebhookUrlError("Enter a valid absolute URL.")
}
const isProd = process.env.NODE_ENV === "production"
if (url.protocol !== "https:" && !(url.protocol === "http:" && !isProd)) {
throw new WebhookUrlError("Webhook URLs must use https://")
}
if (url.username || url.password) {
throw new WebhookUrlError("Webhook URLs must not contain credentials.")
}
const host = url.hostname.replace(/^\[|\]$/g, "")
if (ALLOW_PRIVATE) return
if (host.toLowerCase() === "localhost" || host.toLowerCase().endsWith(".localhost")) {
throw new WebhookUrlError("Webhook URLs must be publicly reachable, not localhost.")
}
// If the host is an IP literal, check it directly.
if (isIP(host)) {
if (isPrivateAddress(host)) {
throw new WebhookUrlError("Webhook URLs must not point at private or reserved IP addresses.")
}
return
}
// Otherwise resolve it and reject if any address is internal.
let addresses: { address: string }[]
try {
addresses = await lookup(host, { all: true })
} catch {
throw new WebhookUrlError("Could not resolve the webhook host.")
}
if (!addresses.length || addresses.some((a) => isPrivateAddress(a.address))) {
throw new WebhookUrlError("Webhook host resolves to a private or reserved address.")
}
}
/** Non-throwing variant used at delivery time. */
export async function isSafeWebhookUrl(raw: string): Promise<boolean> {
try {
await assertSafeWebhookUrl(raw)
return true
} catch {
return false
}
}