Deploy on DigitalOcean App Platform (GitHub-source build) + consolidate audit-fixes
Deploy config: - .do/app.yaml: build the Dockerfile directly from GitHub (deploy_on_push) instead of a pre-built DOCR image; NEXT_PUBLIC_* set RUN_AND_BUILD_TIME with the propertymanagement.network domain so they bake into the client bundle; add custom domains block (apex + www); wire Sentry DSN (server + browser). Included pending work from the audit-fixes branch: - AI provider abstraction (OpenAI/Anthropic, admin-selectable; Anthropic default) - Per-landlord e-signature (DocuSign OAuth + Dropbox Sign) + migration 0010 - Outbound webhooks / Zapier integration - PayPal removal (Stripe-only billing) - Storage hardening (fail-loud when Spaces unconfigured), security fixes Verified: full production Docker build (same build-args as DO) passes clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
917a06ee85
commit
5495b94924
@@ -0,0 +1,146 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import type OpenAI from "openai"
|
||||
import { db } from "@/lib/db"
|
||||
import { app_settings } from "@/lib/db/schema"
|
||||
import { openai } from "@/lib/ai/client"
|
||||
import { getAnthropic } from "@/lib/ai/anthropic"
|
||||
|
||||
// ============================================================================
|
||||
// AI provider abstraction — one call site for every AI feature, backed by
|
||||
// EITHER OpenAI or Anthropic (Claude). The active provider is chosen by an admin
|
||||
// in Settings → System (persisted in app_settings), falling back to whichever
|
||||
// provider actually has an API key configured on the server.
|
||||
// ============================================================================
|
||||
|
||||
export type AiProvider = "openai" | "anthropic"
|
||||
export type AiRole = "system" | "user" | "assistant"
|
||||
export type AiMessage = { role: AiRole; content: string }
|
||||
|
||||
export const AI_PROVIDER_KEY = "ai_provider"
|
||||
|
||||
// Models are env-overridable. Both default to each provider's cheapest tier to
|
||||
// keep token spend low: OpenAI gpt-4o-mini, Anthropic Claude Haiku 4.5 ($1/$5
|
||||
// per 1M). Pin a stronger model via OPENAI_MODEL / ANTHROPIC_MODEL if desired.
|
||||
export const OPENAI_MODEL = process.env.OPENAI_MODEL ?? "gpt-4o-mini"
|
||||
export const ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL ?? "claude-haiku-4-5"
|
||||
|
||||
export function openaiConfigured(): boolean {
|
||||
return Boolean(process.env.OPENAI_API_KEY)
|
||||
}
|
||||
export function anthropicConfigured(): boolean {
|
||||
return Boolean(process.env.ANTHROPIC_API_KEY)
|
||||
}
|
||||
|
||||
function isProvider(v: unknown): v is AiProvider {
|
||||
return v === "openai" || v === "anthropic"
|
||||
}
|
||||
|
||||
/** The admin-selected provider (defaults to openai). Fails safe to openai. */
|
||||
export async function getAiProvider(): Promise<AiProvider> {
|
||||
try {
|
||||
const row = await db.query.app_settings.findFirst({
|
||||
where: eq(app_settings.key, AI_PROVIDER_KEY),
|
||||
})
|
||||
const v = (row?.value as { provider?: string } | null)?.provider
|
||||
return isProvider(v) ? v : "openai"
|
||||
} catch {
|
||||
return "openai"
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the admin's provider choice. Admin-gated by the calling action. */
|
||||
export async function setAiProvider(provider: AiProvider): Promise<void> {
|
||||
await db
|
||||
.insert(app_settings)
|
||||
.values({ key: AI_PROVIDER_KEY, value: { provider } })
|
||||
.onConflictDoUpdate({
|
||||
target: app_settings.key,
|
||||
set: { value: { provider }, updated_at: new Date().toISOString() },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider actually used for a request: the selected one, unless it has no
|
||||
* API key on the server and the other provider does — then we fall back so AI
|
||||
* features keep working after a provider switch even if the key isn't set yet.
|
||||
*/
|
||||
function resolveEffective(selected: AiProvider): AiProvider {
|
||||
if (selected === "anthropic" && !anthropicConfigured() && openaiConfigured()) return "openai"
|
||||
if (selected === "openai" && !openaiConfigured() && anthropicConfigured()) return "anthropic"
|
||||
return selected
|
||||
}
|
||||
|
||||
/** Everything the admin UI needs to render the provider picker. */
|
||||
export async function aiProviderStatus() {
|
||||
const selected = await getAiProvider()
|
||||
return {
|
||||
selected,
|
||||
effective: resolveEffective(selected),
|
||||
openaiConfigured: openaiConfigured(),
|
||||
anthropicConfigured: anthropicConfigured(),
|
||||
openaiModel: OPENAI_MODEL,
|
||||
anthropicModel: ANTHROPIC_MODEL,
|
||||
}
|
||||
}
|
||||
|
||||
/** Strip a ```json fenced code block, if the model wrapped its JSON in one. */
|
||||
function stripFences(s: string): string {
|
||||
return s
|
||||
.replace(/^\s*```(?:json)?\s*/i, "")
|
||||
.replace(/```\s*$/i, "")
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider-agnostic single-shot completion. Returns the model's text output.
|
||||
*
|
||||
* `json: true` asks for a JSON object (OpenAI uses response_format; both
|
||||
* providers rely on the prompt saying "JSON only") and strips any code fences
|
||||
* so the caller can `JSON.parse` the result directly.
|
||||
*/
|
||||
export async function aiComplete(opts: {
|
||||
messages: AiMessage[]
|
||||
maxTokens?: number
|
||||
json?: boolean
|
||||
}): Promise<string> {
|
||||
const provider = resolveEffective(await getAiProvider())
|
||||
const maxTokens = opts.maxTokens ?? 1024
|
||||
|
||||
let text: string
|
||||
if (provider === "anthropic") {
|
||||
// Anthropic takes a top-level `system`; the rest are user/assistant turns.
|
||||
const system = opts.messages
|
||||
.filter((m) => m.role === "system")
|
||||
.map((m) => m.content)
|
||||
.join("\n\n")
|
||||
const convo = opts.messages
|
||||
.filter((m) => m.role !== "system")
|
||||
.map((m) => ({ role: (m.role === "assistant" ? "assistant" : "user") as "assistant" | "user", content: m.content }))
|
||||
if (convo.length === 0) convo.push({ role: "user", content: system || "Continue." })
|
||||
|
||||
const res = await getAnthropic().messages.create({
|
||||
model: ANTHROPIC_MODEL,
|
||||
max_tokens: maxTokens,
|
||||
...(system ? { system } : {}),
|
||||
messages: convo,
|
||||
})
|
||||
text = res.content.map((b) => (b.type === "text" ? b.text : "")).join("")
|
||||
} else {
|
||||
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = opts.messages.map((m) =>
|
||||
m.role === "system"
|
||||
? { role: "system", content: m.content }
|
||||
: m.role === "assistant"
|
||||
? { role: "assistant", content: m.content }
|
||||
: { role: "user", content: m.content }
|
||||
)
|
||||
const res = await openai.chat.completions.create({
|
||||
model: OPENAI_MODEL,
|
||||
max_tokens: maxTokens,
|
||||
messages,
|
||||
...(opts.json ? { response_format: { type: "json_object" as const } } : {}),
|
||||
})
|
||||
text = res.choices[0]?.message?.content ?? ""
|
||||
}
|
||||
|
||||
return opts.json ? stripFences(text) : text
|
||||
}
|
||||
Reference in New Issue
Block a user