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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-07-02 13:42:34 -04:00
co-authored by Claude Opus 4.8
parent 969d5d4c8a
commit c9968531e4
282 changed files with 41530 additions and 4013 deletions
+2 -1
View File
@@ -254,7 +254,8 @@ export function getEnvHealth() {
"STRIPE_WEBHOOK_SECRET",
"NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY",
"OPENAI_API_KEY",
"RESEND_API_KEY",
"SMTP_HOST",
"SMTP_USER",
"GOOGLE_CLIENT_ID",
"CRON_SECRET",
"NEXT_PUBLIC_APP_URL",
+3 -3
View File
@@ -3,9 +3,9 @@ import { Pool, types } from "pg"
import * as schema from "./schema"
// ── pg type parsers ───────────────────────────────────────────────
// Make the driver return the same value shapes the app relied on under
// Supabase/PostgREST, so the ~hundreds of existing read sites keep working:
// numeric -> JS number (was parsed as number by PostgREST)
// Make the driver return the value shapes the app relies on across its
// ~hundreds of read sites:
// numeric -> JS number
// date -> "YYYY-MM-DD" string
// timestamp / timestamptz -> ISO 8601 string
types.setTypeParser(1700, (v) => (v === null ? null : parseFloat(v))) // numeric
@@ -0,0 +1,33 @@
-- NOTE: made idempotent (IF NOT EXISTS / guarded constraint) by hand.
-- The admin tables/columns below were previously applied to some databases via
-- `drizzle-kit push` without a migration file, so migration history had drifted.
-- Guarding these statements lets 0001 provision a fresh database (creating the
-- admin objects + app_settings) while safely no-op'ing on already-migrated DBs.
CREATE TABLE IF NOT EXISTS "admin_audit_log" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"admin_id" text,
"action" text NOT NULL,
"target_user_id" text,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"ip_address" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "app_settings" (
"key" text PRIMARY KEY NOT NULL,
"value" jsonb DEFAULT '{}'::jsonb NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "session" ADD COLUMN IF NOT EXISTS "impersonated_by" text;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS "role" text DEFAULT 'user';--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS "banned" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS "ban_reason" text;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS "ban_expires" timestamp;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'admin_audit_log_admin_id_user_id_fk'
) THEN
ALTER TABLE "admin_audit_log" ADD CONSTRAINT "admin_audit_log_admin_id_user_id_fk" FOREIGN KEY ("admin_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
+20
View File
@@ -0,0 +1,20 @@
CREATE TABLE "account_members" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"owner_id" text NOT NULL,
"member_id" text,
"email" text NOT NULL,
"role" text DEFAULT 'member' NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"invite_token" text DEFAULT gen_random_uuid()::text NOT NULL,
"accepted_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "account_members_invite_token_unique" UNIQUE("invite_token")
);
--> statement-breakpoint
ALTER TABLE "profiles" ADD COLUMN "brand_name" text;--> statement-breakpoint
ALTER TABLE "profiles" ADD COLUMN "brand_logo_url" text;--> statement-breakpoint
ALTER TABLE "profiles" ADD COLUMN "brand_color" text;--> statement-breakpoint
ALTER TABLE "profiles" ADD COLUMN "hide_powered_by" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "account_members" ADD CONSTRAINT "account_members_owner_id_profiles_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "account_members" ADD CONSTRAINT "account_members_member_id_profiles_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;
+13
View File
@@ -0,0 +1,13 @@
CREATE TABLE "api_keys" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"name" text NOT NULL,
"key_hash" text NOT NULL,
"key_prefix" text NOT NULL,
"last_used_at" timestamp with time zone,
"revoked_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "api_keys_key_hash_unique" UNIQUE("key_hash")
);
--> statement-breakpoint
ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_user_id_profiles_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "profiles" ADD COLUMN "calendar_token" text DEFAULT gen_random_uuid()::text;--> statement-breakpoint
ALTER TABLE "profiles" ADD CONSTRAINT "profiles_calendar_token_unique" UNIQUE("calendar_token");
@@ -0,0 +1,17 @@
CREATE TABLE "accounting_connections" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"provider" text NOT NULL,
"access_token" text NOT NULL,
"refresh_token" text NOT NULL,
"expires_at" timestamp with time zone,
"realm_id" text,
"org_name" text,
"status" text DEFAULT 'active' NOT NULL,
"last_sync_at" timestamp with time zone,
"last_error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "accounting_connections" ADD CONSTRAINT "accounting_connections_user_id_profiles_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;
+38
View File
@@ -0,0 +1,38 @@
CREATE TABLE "webhook_deliveries" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"endpoint_id" uuid NOT NULL,
"event" text NOT NULL,
"payload" jsonb NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"attempts" integer DEFAULT 0 NOT NULL,
"max_attempts" integer DEFAULT 5 NOT NULL,
"next_attempt_at" timestamp with time zone DEFAULT now() NOT NULL,
"response_status" integer,
"response_body" text,
"error" text,
"delivered_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "webhook_endpoints" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"url" text NOT NULL,
"description" text,
"events" text[] DEFAULT '{}'::text[] NOT NULL,
"secret" text NOT NULL,
"status" text DEFAULT 'active' NOT NULL,
"source" text DEFAULT 'dashboard' NOT NULL,
"last_success_at" timestamp with time zone,
"last_error_at" timestamp with time zone,
"last_error" text,
"failure_count" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "webhook_deliveries" ADD CONSTRAINT "webhook_deliveries_user_id_profiles_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "webhook_deliveries" ADD CONSTRAINT "webhook_deliveries_endpoint_id_webhook_endpoints_id_fk" FOREIGN KEY ("endpoint_id") REFERENCES "public"."webhook_endpoints"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "webhook_endpoints" ADD CONSTRAINT "webhook_endpoints_user_id_profiles_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;
@@ -0,0 +1,19 @@
CREATE TABLE "signature_requests" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"lease_id" uuid,
"provider" text NOT NULL,
"external_id" text,
"status" text DEFAULT 'sent' NOT NULL,
"signer_email" text NOT NULL,
"signer_name" text,
"document_name" text,
"signed_document_url" text,
"last_error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"completed_at" timestamp with time zone,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "signature_requests" ADD CONSTRAINT "signature_requests_user_id_profiles_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "signature_requests" ADD CONSTRAINT "signature_requests_lease_id_leases_id_fk" FOREIGN KEY ("lease_id") REFERENCES "public"."leases"("id") ON DELETE set null ON UPDATE no action;
@@ -0,0 +1,2 @@
ALTER TABLE "properties" ADD COLUMN "latitude" double precision;--> statement-breakpoint
ALTER TABLE "properties" ADD COLUMN "longitude" double precision;
@@ -0,0 +1,2 @@
ALTER TABLE "profiles" ADD COLUMN "paypal_subscription_id" text;--> statement-breakpoint
ALTER TABLE "profiles" ADD COLUMN "billing_provider" text;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+63
View File
@@ -8,6 +8,69 @@
"when": 1782223467789,
"tag": "0000_next_red_skull",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1782928287616,
"tag": "0001_furry_christian_walker",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1782935722973,
"tag": "0002_quiet_freak",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1782975002382,
"tag": "0003_api_keys",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1782978054678,
"tag": "0004_yellow_switch",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1782980416142,
"tag": "0005_large_black_queen",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1782980797596,
"tag": "0006_webhooks",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1782981198010,
"tag": "0007_chubby_sinister_six",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1782992530365,
"tag": "0008_dazzling_white_tiger",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1782994066547,
"tag": "0009_amusing_blackheart",
"breakpoints": true
}
]
}
+193 -3
View File
@@ -10,13 +10,14 @@ import {
timestamp,
date,
jsonb,
doublePrecision,
} from "drizzle-orm/pg-core"
// ============================================================
// Helpers
// ============================================================
// timestamptz returned as ISO strings (matches the previous Supabase/PostgREST
// behaviour the app code relies on). `date` columns returned as "YYYY-MM-DD".
// timestamptz returned as ISO strings (the shape the app code relies on).
// `date` columns returned as "YYYY-MM-DD".
const tstz = (name: string) => timestamp(name, { withTimezone: true, mode: "string" })
const createdAt = () => tstz("created_at").notNull().defaultNow()
const updatedAt = () =>
@@ -103,9 +104,20 @@ export const profiles = pgTable("profiles", {
stripe_customer_id: text("stripe_customer_id").unique(),
stripe_subscription_id: text("stripe_subscription_id"),
subscription_status: text("subscription_status"),
// Which processor owns the active subscription. Stripe fields above and the
// PayPal id below are mutually exclusive per active subscription.
paypal_subscription_id: text("paypal_subscription_id"),
billing_provider: text("billing_provider").$type<"stripe" | "paypal">(),
trial_ends_at: tstz("trial_ends_at"),
onboarding_completed: boolean("onboarding_completed").notNull().default(false),
usage_count: integer("usage_count").notNull().default(0),
// White-label branding (Landlord/Lifetime plans). Applied to the tenant portal.
brand_name: text("brand_name"),
brand_logo_url: text("brand_logo_url"),
brand_color: text("brand_color"),
hide_powered_by: boolean("hide_powered_by").notNull().default(false),
// Read-only iCal (ICS) subscription feed token — served at /api/calendar/<token>.ics
calendar_token: text("calendar_token").unique().default(sql`gen_random_uuid()::text`),
created_at: createdAt(),
updated_at: updatedAt(),
})
@@ -125,6 +137,10 @@ export const properties = pgTable("properties", {
state: text("state"),
postal_code: text("postal_code"),
country: text("country").notNull().default("US"),
// Geocoded from the address on save (OpenStreetMap Nominatim). Null until
// geocoding succeeds; drives the property map view.
latitude: doublePrecision("latitude"),
longitude: doublePrecision("longitude"),
property_type: text("property_type")
.$type<"residential" | "commercial" | "mixed">()
.notNull()
@@ -511,7 +527,170 @@ export const admin_audit_log = pgTable("admin_audit_log", {
})
// ============================================================
// RELATIONS (for Drizzle relational queries — replace PostgREST embeds)
// APP SETTINGS (global key/value — e.g. site maintenance mode)
// ============================================================
// A tiny key/value store for runtime-toggled platform settings that must
// persist and be changeable from the admin dashboard without a redeploy.
export const app_settings = pgTable("app_settings", {
key: text("key").primaryKey(),
value: jsonb("value").$type<Record<string, unknown>>().notNull().default({}),
updated_at: updatedAt(),
})
// ============================================================
// ACCOUNT MEMBERS (team access — Landlord/Lifetime plans)
// ============================================================
// Lets an account OWNER invite other users to access their portfolio. A member
// with status='active' operates under the owner's data (resolved by
// getEffectiveOwnerId in lib/account.ts). owner_id is the portfolio owner;
// member_id is set once the invite is accepted.
export const account_members = pgTable("account_members", {
id: uuid("id").primaryKey().defaultRandom(),
owner_id: text("owner_id")
.notNull()
.references(() => profiles.id, { onDelete: "cascade" }),
member_id: text("member_id").references(() => profiles.id, { onDelete: "cascade" }),
email: text("email").notNull(),
role: text("role").$type<"member" | "viewer">().notNull().default("member"),
status: text("status").$type<"pending" | "active" | "revoked">().notNull().default("pending"),
invite_token: text("invite_token").notNull().unique().default(sql`gen_random_uuid()::text`),
accepted_at: tstz("accepted_at"),
created_at: createdAt(),
updated_at: updatedAt(),
})
// ============================================================
// API KEYS (public REST API — Bearer auth for /api/v1)
// ============================================================
// Each key belongs to a user. We store ONLY a SHA-256 hash of the secret; the
// plaintext is shown once at creation and never persisted. `key_prefix` is a
// short, non-secret identifier for display in the dashboard.
export const api_keys = pgTable("api_keys", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id")
.notNull()
.references(() => profiles.id, { onDelete: "cascade" }),
name: text("name").notNull(),
key_hash: text("key_hash").notNull().unique(),
key_prefix: text("key_prefix").notNull(),
last_used_at: tstz("last_used_at"),
revoked_at: tstz("revoked_at"),
created_at: createdAt(),
})
// ============================================================
// ACCOUNTING CONNECTIONS (QuickBooks / Xero OAuth sync)
// ============================================================
// One row per (owner, provider). OAuth tokens are stored AES-256-GCM encrypted
// (see lib/crypto.ts). A landlord connects their books and rent income +
// expenses are pushed one-way into QuickBooks Online or Xero.
export const accounting_connections = pgTable("accounting_connections", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id")
.notNull()
.references(() => profiles.id, { onDelete: "cascade" }),
provider: text("provider").$type<"quickbooks" | "xero">().notNull(),
access_token: text("access_token").notNull(), // encrypted
refresh_token: text("refresh_token").notNull(), // encrypted
expires_at: tstz("expires_at"),
// Provider account id: QuickBooks realmId / Xero tenantId.
realm_id: text("realm_id"),
org_name: text("org_name"),
status: text("status").$type<"active" | "error" | "revoked">().notNull().default("active"),
last_sync_at: tstz("last_sync_at"),
last_error: text("last_error"),
created_at: createdAt(),
updated_at: updatedAt(),
})
// ============================================================
// SIGNATURE REQUESTS (e-signature — DocuSign / Dropbox Sign)
// ============================================================
// Tracks a lease document sent out for e-signature. `external_id` is the
// provider's envelope / signature_request id; the webhook flips status to
// "signed" and records the completed document.
export const signature_requests = pgTable("signature_requests", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id")
.notNull()
.references(() => profiles.id, { onDelete: "cascade" }),
lease_id: uuid("lease_id").references(() => leases.id, { onDelete: "set null" }),
provider: text("provider").$type<"docusign" | "dropbox_sign">().notNull(),
external_id: text("external_id"),
status: text("status")
.$type<"sent" | "signed" | "declined" | "voided" | "error">()
.notNull()
.default("sent"),
signer_email: text("signer_email").notNull(),
signer_name: text("signer_name"),
document_name: text("document_name"),
signed_document_url: text("signed_document_url"),
last_error: text("last_error"),
sent_at: createdAt(),
completed_at: tstz("completed_at"),
updated_at: updatedAt(),
})
// ============================================================
// WEBHOOK ENDPOINTS (outbound webhooks / Zapier integration)
// ============================================================
// A landlord registers HTTPS endpoints that receive a signed JSON POST every
// time a subscribed event occurs (e.g. tenant.created, payment.paid). Scoped by
// the account owner id so every event in the portfolio is delivered. The
// `secret` is the HMAC-SHA256 signing key surfaced in the dashboard so the
// receiver can verify the `X-PMN-Signature` header. `events` is the set of
// subscribed event ids; an empty array means "all events". `source` records who
// created it (dashboard, the REST API, or a Zapier REST-hook subscription).
export const webhook_endpoints = pgTable("webhook_endpoints", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id")
.notNull()
.references(() => profiles.id, { onDelete: "cascade" }),
url: text("url").notNull(),
description: text("description"),
events: text("events").array().notNull().default(sql`'{}'::text[]`),
secret: text("secret").notNull(),
status: text("status").$type<"active" | "disabled">().notNull().default("active"),
source: text("source").$type<"dashboard" | "api" | "zapier">().notNull().default("dashboard"),
last_success_at: tstz("last_success_at"),
last_error_at: tstz("last_error_at"),
last_error: text("last_error"),
// Consecutive delivery failures; reset to 0 on any success.
failure_count: integer("failure_count").notNull().default(0),
created_at: createdAt(),
updated_at: updatedAt(),
})
// ============================================================
// WEBHOOK DELIVERIES (per-endpoint delivery log + retry queue)
// ============================================================
// One row per (event, endpoint). Created "pending"; the emitter attempts an
// immediate delivery and the webhooks cron retries anything still pending/failed
// with exponential backoff until max_attempts is reached.
export const webhook_deliveries = pgTable("webhook_deliveries", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id")
.notNull()
.references(() => profiles.id, { onDelete: "cascade" }),
endpoint_id: uuid("endpoint_id")
.notNull()
.references(() => webhook_endpoints.id, { onDelete: "cascade" }),
event: text("event").notNull(),
payload: jsonb("payload").$type<Record<string, unknown>>().notNull(),
status: text("status").$type<"pending" | "success" | "failed">().notNull().default("pending"),
attempts: integer("attempts").notNull().default(0),
max_attempts: integer("max_attempts").notNull().default(5),
next_attempt_at: tstz("next_attempt_at").notNull().defaultNow(),
response_status: integer("response_status"),
response_body: text("response_body"),
error: text("error"),
delivered_at: tstz("delivered_at"),
created_at: createdAt(),
updated_at: updatedAt(),
})
// ============================================================
// RELATIONS (for Drizzle relational queries)
// ============================================================
export const profilesRelations = relations(profiles, ({ one }) => ({
user: one(user, { fields: [profiles.id], references: [user.id] }),
@@ -587,3 +766,14 @@ export const vendorsRelations = relations(vendors, ({ one }) => ({
export const follow_up_logRelations = relations(follow_up_log, ({ one }) => ({
rule: one(follow_up_rules, { fields: [follow_up_log.rule_id], references: [follow_up_rules.id] }),
}))
export const webhook_endpointsRelations = relations(webhook_endpoints, ({ many }) => ({
deliveries: many(webhook_deliveries),
}))
export const webhook_deliveriesRelations = relations(webhook_deliveries, ({ one }) => ({
endpoint: one(webhook_endpoints, {
fields: [webhook_deliveries.endpoint_id],
references: [webhook_endpoints.id],
}),
}))