diff --git a/db/schema/audit.ts b/db/schema/audit.ts deleted file mode 100644 index 6efa836..0000000 --- a/db/schema/audit.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Audit log. Source of truth: `db/migrations/0001_init.sql`. - */ -import { - pgTable, - uuid, - text, - jsonb, - timestamp, - index, -} from "drizzle-orm/pg-core"; -import { brands } from "./brands"; - -export const auditLog = pgTable( - "audit_log", - { - id: uuid("id").primaryKey().defaultRandom(), - brandId: uuid("brand_id").references(() => brands.id, { - onDelete: "cascade", - }), - userId: uuid("user_id"), // FK to neon_auth.user(id) — plain UUID, enforced at DB level - action: text("action").notNull(), - targetType: text("target_type"), - targetId: uuid("target_id"), - payload: jsonb("payload"), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (t) => ({ - brandIdx: index("audit_log_brand_idx").on(t.brandId, t.createdAt), - }), -); - -export type AuditLog = typeof auditLog.$inferSelect; -export type NewAuditLog = typeof auditLog.$inferInsert; diff --git a/src/actions/admin/audit.ts b/src/actions/admin/audit.ts deleted file mode 100644 index ff0e095..0000000 --- a/src/actions/admin/audit.ts +++ /dev/null @@ -1,56 +0,0 @@ -"use server"; - -import { pool } from "@/lib/db"; -import { getSession } from "@/lib/auth"; - -type AdminActionPayload = { - action_type: "create" | "update" | "delete"; - admin_id?: string; - admin_email?: string; - affected_user_id?: string; - brand_id?: string; - details?: Record; -}; - -type UserActivityPayload = { - user_id: string; - activity_type: "login" | "logout" | "password_change" | "profile_update" | "email_change"; - details?: Record; - ip_address?: string; - user_agent?: string; -}; - -export async function logAdminAction(payload: AdminActionPayload): Promise { - -await getSession(); try { - await pool.query("SELECT log_admin_action($1::jsonb)", [ - JSON.stringify({ - action_type: payload.action_type, - admin_id: payload.admin_id ?? null, - admin_email: payload.admin_email ?? null, - affected_user_id: payload.affected_user_id ?? null, - brand_id: payload.brand_id ?? null, - details: payload.details ?? {}, - }), - ]); - } catch { - // logging failed silently - } -} - -export async function logUserActivity(payload: UserActivityPayload): Promise { - -await getSession(); try { - await pool.query("SELECT log_user_activity($1::jsonb)", [ - JSON.stringify({ - user_id: payload.user_id, - activity_type: payload.activity_type, - details: payload.details ?? {}, - ip_address: payload.ip_address ?? null, - user_agent: payload.user_agent ?? null, - }), - ]); - } catch { - // logging failed silently - } -} diff --git a/src/actions/square-inventory.ts b/src/actions/square-inventory.ts deleted file mode 100644 index 8a7a006..0000000 --- a/src/actions/square-inventory.ts +++ /dev/null @@ -1,215 +0,0 @@ -"use server"; - -import { getAdminUser } from "@/lib/admin-permissions"; -import { getPaymentSettings } from "@/actions/payments"; -import { withBrand } from "@/db/client"; -import { products } from "@/db/schema"; -import { inArray } from "drizzle-orm"; -import { getSession } from "@/lib/auth"; - -function getSquareBaseUrl() { - return process.env.SQUARE_ENVIRONMENT === "production" - ? "https://connect.squareup.com" - : "https://connect.squareupsandbox.com"; -} - -async function getSquareCatalogItemVariation( - accessToken: string, - catalogObjectId: string -) { - const baseUrl = getSquareBaseUrl(); - const response = await fetch( - `${baseUrl}/v2/catalog/object/${catalogObjectId}`, - { - method: "GET", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - "Square-Version": "2025-01-16", - }, - } - ); - if (!response.ok) throw new Error(`Catalog object fetch failed: ${await response.text()}`); - return response.json(); -} - -async function batchUpdateSquareInventory( - accessToken: string, - locationId: string, - updates: Array<{ - catalogObjectId: string; - quantity: number; - type: "AVAILABLE" | "ON_HAND" | "SOLD_OUT"; - }> -) { - const baseUrl = getSquareBaseUrl(); - const response = await fetch( - `${baseUrl}/v2/inventory/changes/batch-create`, - { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - "Square-Version": "2025-01-16", - }, - body: JSON.stringify({ - idempotency_key: crypto.randomUUID(), - changes: updates.map((u) => ({ - type: "ADJUSTMENT", - physical_count: { - catalog_object_id: u.catalogObjectId, - location_id: locationId, - quantity: String(u.quantity), - measurement_unit: { quantity_unit: u.type }, - }, - occurred_at: new Date().toISOString(), - })), - }), - } - ); - if (!response.ok) { - const err = await response.text(); - throw new Error(`Batch inventory update failed: ${err}`); - } - return response.json(); -} - -export type SyncResult = { - success: boolean; - synced: number; - errors: string[]; -}; - -/** - * Sync inventory from Route Commerce products TO Square. - * Reduces Square inventory for each product sold. - * Called after an RC order is placed or updated. - * - * @param brandId - brand to sync for - * @param items - array of { productId, quantity } representing sold items - */ -export async function syncInventoryToSquare( - brandId: string, - items: Array<{ productId: string; quantity: number }> -): Promise { - -await getSession(); const adminUser = await getAdminUser(); - if (!adminUser) return { success: false, synced: 0, errors: ["Not authenticated"] }; - - const settingsResult = await getPaymentSettings(brandId); - if (!settingsResult.success || !settingsResult.settings) { - return { success: false, synced: 0, errors: ["Could not load payment settings"] }; - } - const settings = settingsResult.settings; - - if ( - !settings.square_access_token || - !settings.square_location_id || - !settings.square_sync_enabled || - !["rc_to_square", "bidirectional"].includes(settings.square_inventory_mode) - ) { - return { success: true, synced: 0, errors: [] }; // Not enabled, no-op - } - - const errors: string[] = []; - const synced = 0; - - // Build product name → quantity map - const itemQtyMap = new Map(items.map((i) => [i.productId, i.quantity])); - - try { - // Fetch product details from RC - const productIds = items.map((i) => i.productId); - const rows = await withBrand(brandId, (db) => - db - .select({ id: products.id, name: products.name }) - .from(products) - .where(inArray(products.id, productIds)) - ); - if (rows.length === 0 && productIds.length > 0) { - return { success: false, synced: 0, errors: ["Failed to fetch products from RC"] }; - } - - // Find Square catalog items matching product names and reduce quantity - const updates: Array<{ catalogObjectId: string; quantity: number; type: "AVAILABLE" | "ON_HAND" | "SOLD_OUT" }> = []; - - for (const product of rows) { - const qty = itemQtyMap.get(product.id) ?? 0; - if (qty <= 0) continue; - - // In a real implementation, we'd maintain a mapping of RC product ID → Square catalog object ID - // For now, we skip this without the mapping (requires manual catalog linking) - // A future improvement would store square_catalog_object_id on RC products table - errors.push(`Product "${product.name}": inventory sync requires Square catalog object ID mapping (not yet implemented)`); - } - } catch (err) { - errors.push(`Inventory sync error: ${String(err)}`); - } - - return { success: errors.length === 0, synced, errors }; -} - -/** - * Sync inventory from Square TO Route Commerce (polling). - * Fetches Square inventory counts and updates RC product inventory. - */ -export async function syncInventoryFromSquare(brandId: string): Promise { - -await getSession(); const adminUser = await getAdminUser(); - if (!adminUser) return { success: false, synced: 0, errors: ["Not authenticated"] }; - if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) { - return { success: false, synced: 0, errors: ["Not authorized"] }; - } - - const settingsResult = await getPaymentSettings(brandId); - if (!settingsResult.success || !settingsResult.settings) { - return { success: false, synced: 0, errors: ["Could not load payment settings"] }; - } - const settings = settingsResult.settings; - - if ( - !settings.square_access_token || - !settings.square_location_id || - !settings.square_sync_enabled || - !["square_to_rc", "bidirectional"].includes(settings.square_inventory_mode) - ) { - return { success: true, synced: 0, errors: [] }; // Not enabled, no-op - } - - const errors: string[] = []; - const synced = 0; - - try { - // Fetch Square inventory counts for the location - const baseUrl = getSquareBaseUrl(); - const response = await fetch( - `${baseUrl}/v2/inventory/${settings.square_location_id}/counts`, - { - method: "GET", - headers: { - Authorization: `Bearer ${settings.square_access_token}`, - "Content-Type": "application/json", - "Square-Version": "2025-01-16", - }, - } - ); - - if (!response.ok) { - return { success: false, synced: 0, errors: [`Square inventory fetch failed: ${await response.text()}`] }; - } - - const data = await response.json(); - - // For each inventory count, update RC product if we have a mapping - for (const count of data.counts ?? []) { - // count.catalog_object_id, count.quantity, count.calculated_at - // Would need a mapping table to update correct RC product - // This is noted as a future enhancement - errors.push(`Inventory item ${count.catalog_object_id}: requires Square catalog object ID mapping`); - } - } catch (err) { - errors.push(`Inventory sync error: ${String(err)}`); - } - - return { success: errors.length === 0, synced, errors }; -} diff --git a/src/app/admin/settings/ai/AIClient.tsx b/src/app/admin/settings/ai/AIClient.tsx deleted file mode 100644 index da83174..0000000 --- a/src/app/admin/settings/ai/AIClient.tsx +++ /dev/null @@ -1,1931 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; -import Link from "next/link"; -import { AdminCard } from "@/components/admin/design-system"; -import { setAIProviderSettings } from "@/actions/integrations/ai-providers"; - -// ── Pure helpers (module scope) ────────────────────────────────────────────── - -function copyToClipboard(text: string) { - navigator.clipboard.writeText(text); -} - -const reportTypes = [ - { value: "orders-by-stop", label: "Orders by Stop" }, - { value: "sales-by-product", label: "Sales by Product" }, - { value: "fulfillment", label: "Fulfillment" }, - { value: "pickup-status", label: "Pickup Status" }, - { value: "contact-growth", label: "Contact Growth" }, - { value: "campaigns", label: "Campaign Activity" }, -]; - -const exampleQueries = [ - "Which customers haven't ordered in 45 days?", - "What products are trending this month?", - "Who are my top customers by revenue?", - "Show recent orders from the last 7 days", - "Which customers are at risk of churning?", -]; - -// ── Header Icon Component ───────────────────────────────────────────────────── - -function AIHeaderIcon() { - return ( -
- - - -
- ); -} - -// ── Tool Card Component ─────────────────────────────────────────────────────── - -type ToolCardProps = { - tool: AITool; - isConnected: boolean; - onOpen: (tool: AITool) => void; -}; - -function ToolCard({ tool, isConnected, onOpen }: ToolCardProps) { - const isReady = tool.status === "available" && isConnected; - - return ( - -
- {tool.icon} -
-
-

{tool.title}

- {tool.badge && ( - - {tool.badge} - - )} - {tool.status === "available" && ( - - {isConnected ? "Ready" : "Disabled"} - - )} - {tool.status === "experimental" && ( - - Experimental - - )} - {tool.status === "coming_soon" && ( - - Coming Soon - - )} -
- {tool.module} -

{tool.description}

-
-
-
- {tool.status === "available" && ( - - )} - {tool.status === "coming_soon" && ( - - )} - {tool.status === "experimental" && ( - - )} -
-
- ); -} - -// ── Connection Status Banner ─────────────────────────────────────────────────── - -function ConnectionStatusBanner({ isConnected, availableCount, onConfigure }: { - isConnected: boolean; - availableCount: number; - onConfigure: () => void; -}) { - if (isConnected) { - return ( - -
-
- - - -
-
-

AI Connected

-

{availableCount} tool{availableCount !== 1 ? "s" : ""} ready to use

-
- - Active - -
-
- ); - } - - return ( - -
-
- - - -
-
-

AI Not Configured

-

Add your API key to enable AI tools.

-
- -
-
- ); -} - -type ToolStatus = "available" | "coming_soon" | "experimental"; - -type AITool = { - id: string; - title: string; - description: string; - icon: string; - module: string; - status: ToolStatus; - badge?: string; -}; - -const AI_TOOLS: AITool[] = [ - { - id: "campaign-writer", - title: "Campaign Idea Generator", - description: "Generate email campaign topics, angles, and subject lines based on your products, season, and customer segments.", - icon: "📧", - module: "Harvest Reach", - status: "available", - badge: "New", - }, - { - id: "product-writer", - title: "Product Description Writer", - description: "AI-assisted product descriptions, pricing analysis, and image alt-text generation for your catalog.", - icon: "🛒", - module: "Products", - status: "available", - }, - { - id: "route-suggester", - title: "Route Optimizer", - description: "Get AI-powered suggestions for stop ordering, grouping, and delivery sequence to minimize drive time.", - icon: "🗺️", - module: "Stops & Routes", - status: "available", - }, - { - id: "customer-insights", - title: "Customer Insights", - description: "Natural language queries across orders and customers. Ask 'Which customers haven't ordered in 30 days?' or 'What products are trending this month?'", - icon: "🔍", - module: "Reports", - status: "available", - }, - { - id: "stop-blast-advisor", - title: "Stop Blast Advisor", - description: "AI suggestions for timing, content, and audience targeting for operational stop blast messages.", - icon: "📢", - module: "Harvest Reach", - status: "available", - }, - { - id: "pricing-advisor", - title: "Pricing Advisor", - description: "Analyze demand, seasonality, and historical sales to suggest optimal product prices for your market.", - icon: "💰", - module: "Products", - status: "available", - }, - { - id: "report-explainer", - title: "Report Explainer", - description: "Plain-English summaries of complex reports. Just ask 'why did sales drop this week?' and get an AI-generated breakdown.", - icon: "📊", - module: "Reports", - status: "available", - }, - { - id: "demand-forecast", - title: "Demand Forecasting", - description: "Predict order volumes and popular products for upcoming stops based on historical trends and seasonal patterns.", - icon: "📈", - module: "Orders", - status: "available", - }, -]; - -type Provider = "openai" | "anthropic" | "google" | "xai" | "custom"; - -type Props = { - isConnected: boolean; - brandId: string; - brandName: string; - provider?: Provider; - model?: string; - customEndpoint?: string; -}; - -// ── Provider Selector ───────────────────────────────────────────────────────── - -type ProviderOption = { - id: Provider; - name: string; - icon: string; - description: string; -}; - -const PROVIDERS: ProviderOption[] = [ - { id: "openai", name: "OpenAI", icon: "💩", description: "GPT-4, GPT-4o, o1" }, - { id: "anthropic", name: "Anthropic", icon: "🧠", description: "Claude 3.5 Sonnet, Opus" }, - { id: "google", name: "Google", icon: "🔴", description: "Gemini 2.0 Flash, Pro" }, - { id: "xai", name: "xAI", icon: "⚡", description: "Grok-2, Grok-1.5" }, - { id: "custom", name: "Custom", icon: "🔧", description: "Bring your own endpoint" }, -]; - -// ── Model Input ─────────────────────────────────────────────────────────────── - -function ModelInput({ - currentModel, - brandId, - onChange -}: { - currentModel: string; - brandId: string; - onChange: (model: string) => void; -}) { - // Uncontrolled input: the editable buffer is the DOM input itself. - // The parent uses `key={currentModel}` so that when the prop changes - // externally, this component fully remounts and `defaultValue` is - // re-applied from the new prop — no stale copy. - const inputRef = useRef(null); - const [saving, setSaving] = useState(false); - const [saved, setSaved] = useState(false); - const [error, setError] = useState(null); - - const handleSave = async () => { - const value = inputRef.current?.value ?? ""; - if (!value.trim()) return; - setSaving(true); - setError(null); - - const result = await setAIProviderSettings(brandId, { model: value.trim() }); - if (result.success) { - onChange(value.trim()); - setSaved(true); - setTimeout(() => setSaved(false), 2000); - } else { - setError(result.error ?? "Failed to save"); - } - setSaving(false); - }; - - return ( - -
- 🤖 -

- Model -

-
-
- - -
-

- ⚠️ Model ID must be exact — check the provider's documentation for the correct identifier. -

- {error &&

{error}

} -
- ); -} - -// ── Provider Selector Component ───────────────────────────────────────────── - -function ProviderSelector({ - currentProvider, - currentModel, - brandId, - onSelect -}: { - currentProvider: Provider; - currentModel: string; - brandId: string; - onSelect: (provider: Provider) => void; -}) { - const handleSelect = async (provider: Provider) => { - onSelect(provider); - await setAIProviderSettings(brandId, { provider }); - }; - - return ( -
-
- 💩 -

- AI Provider -

-
-
- {PROVIDERS.map((provider) => ( - - ))} -
-

- Current model: {currentModel} -

-
- ); -} - -// ── Form Input Styles (Shared) ──────────────────────────────────────────────── - -const inputBaseClass = "w-full rounded-xl px-4 py-2.5 text-sm outline-none transition-colors"; -const inputStyle = { - border: '1px solid var(--admin-border)', - backgroundColor: 'var(--admin-card-bg)', - color: 'var(--admin-text-primary)', -}; -const inputFocusStyle = { - borderColor: 'var(--admin-accent)', - boxShadow: '0 0 0 3px rgba(202, 117, 67, 0.1)', -}; - -const textareaStyle = { ...inputStyle }; -const textareaFocusStyle = { ...inputFocusStyle }; - -const btnPrimaryClass = "rounded-xl px-5 py-2.5 text-sm font-bold text-white transition-all"; -const btnPrimaryStyle = { - background: 'linear-gradient(135deg, #ca7543 0%, #d4865a 100%)', - boxShadow: '0 2px 8px rgba(202, 117, 67, 0.25)', -}; - -const labelStyle = { - display: 'block', - fontSize: '0.875rem', - fontWeight: 500, - marginBottom: '0.25rem', - color: 'var(--admin-text-primary)', -}; - -// ── Campaign Writer Tool ────────────────────────────────────────────────────── - -function CampaignWriterTool({ brandId, brandName }: { brandId: string; brandName: string }) { - const [topic, setTopic] = useState(""); - const [loading, setLoading] = useState(false); - const [results, setResults] = useState<{ angle: string; subject: string; body: string }[]>([]); - const [error, setError] = useState(null); - - async function handleGenerate() { - if (!topic.trim()) return; - setLoading(true); - setError(null); - setResults([]); - - try { - const res = await fetch("/api/ai/campaign-writer", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ topic, brandId, brandName }), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error ?? "Generation failed"); - setResults(data.ideas ?? []); - } catch (err) { - setError(err instanceof Error ? err.message : "Generation failed"); - } finally { - setLoading(false); - } - } - - return ( -
-
- -