Consolidate audit-fixes branch: webhooks, integrations, and deploy hardening
Batch commit of the pending working tree on security/audit-fixes-2026-07. Major areas: - Outbound webhooks / Zapier: schema + signed delivery with retries, public v1 API (REST-hook subscribe/unsubscribe), settings UI, cron drain. - Deploy hardening: email via SMTP2GO (Resend fully removed), verified DB TLS (DATABASE_SSL=require + DATABASE_CA), storage fails loud in production when Spaces is unconfigured instead of silently using ephemeral disk. - Integrations & features (concurrent work): accounting (QuickBooks/Xero), e-signature (DocuSign/Dropbox Sign), PayPal, geocoding/maps, onboarding, expanded legal pages. - DB migrations 0006–0009. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
969d5d4c8a
commit
c9968531e4
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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 }
|
||||
},
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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>`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
@@ -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 $$;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
|
||||
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;"> </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)}${" ͏ ".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;"> </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;"> </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};">© ${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(/ ||͏|‌| /gi, " ")
|
||||
.replace(/©/gi, "©")
|
||||
.replace(/—/gi, "—")
|
||||
.replace(/–/gi, "–")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'|'/gi, "'")
|
||||
.split("\n")
|
||||
.map((l) => l.replace(/[ \t]+/g, " ").trim())
|
||||
.join("\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim()
|
||||
}
|
||||
+187
-95
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
// 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)}.`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ?? {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user