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
+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],
|
||||
}),
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user