Admin AI Tools page: unified design system styling, provider selector with OpenAI turd emoji, free-text model input with exact ID warning

This commit is contained in:
2026-06-02 02:21:11 +00:00
parent 809e0061ca
commit 15e939ad7e
116 changed files with 14991 additions and 5326 deletions
+77
View File
@@ -0,0 +1,77 @@
"use server";
import { supabase } from "@/lib/supabase";
export type AIAuthConfig = {
provider: string;
api_key: string;
organization_id: string;
base_url: string;
model: string;
max_tokens: number;
};
export async function getAIPreferences(brandId: string): Promise<{
api_key?: string;
organization_id?: string;
base_url?: string;
model?: string;
max_tokens?: number;
} | null> {
const { data, error } = await supabase
.from("brand_ai_settings")
.select("api_key, organization_id, base_url, model, max_tokens")
.eq("brand_id", brandId)
.single();
if (error || !data) return null;
return data;
}
export async function saveAIPreferences(
brandId: string,
config: AIAuthConfig
): Promise<{ success: boolean; error?: string }> {
const { error } = await supabase
.from("brand_ai_settings")
.upsert({
brand_id: brandId,
provider: config.provider,
api_key: config.api_key || null,
organization_id: config.organization_id || null,
base_url: config.base_url || null,
model: config.model || "gpt-4o-mini",
max_tokens: config.max_tokens || 4000,
updated_at: new Date().toISOString(),
});
if (error) return { success: false, error: error.message };
return { success: true };
}
export async function testAIConnection(config: AIAuthConfig): Promise<{ ok: boolean; message: string }> {
if (!config.api_key?.trim()) {
return { ok: false, message: "API key is required" };
}
try {
const baseUrl = config.base_url?.trim() || "https://api.openai.com/v1";
const url = `${baseUrl.replace(/\/$/, "")}/models`;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${config.api_key}`,
},
});
if (response.ok) {
return { ok: true, message: "Connection successful! Your API key is valid." };
} else if (response.status === 401) {
return { ok: false, message: "Invalid API key. Please check and try again." };
} else {
return { ok: false, message: `Error: ${response.status} ${response.statusText}` };
}
} catch {
return { ok: false, message: "Could not connect. Check your network and API key." };
}
}
@@ -0,0 +1,163 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
// Contact imports bucket
const CONTACTS_BUCKET_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
export type UploadContactsResult =
| { success: true; fileId: string; fileUrl: string; recordCount: number }
| { success: false; error: string };
export async function uploadContactsToBucket(
brandId: string,
file: File
): Promise<UploadContactsResult> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
// Validate file type
if (!file.name.endsWith(".csv")) {
return { success: false, error: "Only CSV files are supported" };
}
// For very large files (farmers with tons of data), check size
const maxSize = 50 * 1024 * 1024; // 50MB max
if (file.size > maxSize) {
return { success: false, error: "File too large. Max 50MB for large imports." };
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Generate unique path
const timestamp = Date.now();
const safeName = file.name.replace(/[^a-zA-Z0-9.-]/g, "_");
const path = `imports/${brandId}/${timestamp}-${safeName}`;
// Upload to bucket
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const uploadRes = await fetch(
`${supabaseUrl}/storage/v1/object/${CONTACTS_BUCKET_ID}/${path}`,
{
method: "PUT",
headers: {
...svcHeaders(supabaseKey),
"Authorization": `Bearer ${supabaseKey}`,
"Content-Type": "text/csv",
"x-upsert": "false",
},
body: buffer,
}
);
if (!uploadRes.ok) {
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
}
const fileUrl = `${supabaseUrl}/storage/v1/object/public/${CONTACTS_BUCKET_ID}/${path}`;
const fileId = `${brandId}/${timestamp}`;
// Get rough row count from file size (approx 200 bytes per row)
const estimatedRows = Math.floor(file.size / 200);
return {
success: true,
fileId,
fileUrl,
recordCount: estimatedRows,
};
}
export type ProcessImportResult =
| { success: true; created: number; updated: number; skipped: number; errors: number }
| { success: false; error: string };
export async function processBucketImport(
brandId: string,
fileUrl: string,
allowOptInOverride: boolean = false
): Promise<ProcessImportResult> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Call RPC to process the file from bucket
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/process_contact_import_from_url`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_file_url: fileUrl,
p_allow_opt_in_override: allowOptInOverride,
}),
}
);
if (!response.ok) {
return { success: false, error: `Processing failed: ${await response.text()}` };
}
const data = await response.json();
return {
success: true,
created: data.created ?? 0,
updated: data.updated ?? 0,
skipped: data.skipped ?? 0,
errors: data.errors ?? 0,
};
}
export async function listImportHistory(
brandId: string,
limit: number = 10
): Promise<{ success: true; imports: ImportHistoryItem[] } | { success: false; error: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// List files in the imports folder for this brand
const response = await fetch(
`${supabaseUrl}/storage/v1/object/list/${CONTACTS_BUCKET_ID}`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
prefix: `imports/${brandId}/`,
limit: limit,
sortBy: { column: "created_at", order: "desc" },
}),
}
);
if (!response.ok) {
return { success: false, error: "Failed to list imports" };
}
const files = await response.json();
return {
success: true,
imports: files.map((f: { name: string; metadata: { size: number }; created_at: string }) => ({
filename: f.name.split("/").pop() ?? "",
size: f.metadata?.size ?? 0,
createdAt: f.created_at,
url: `${supabaseUrl}/storage/v1/object/public/${CONTACTS_BUCKET_ID}/${f.name}`,
})),
};
}
export type ImportHistoryItem = {
filename: string;
size: number;
createdAt: string;
url: string;
};
+2
View File
@@ -51,6 +51,7 @@ export async function savePaymentSettings(params: {
provider: PaymentProvider | null;
stripePublishableKey?: string;
stripeSecretKey?: string;
stripeUserId?: string;
squareAccessToken?: string;
squareLocationId?: string;
squareSyncEnabled?: boolean;
@@ -84,6 +85,7 @@ export async function savePaymentSettings(params: {
p_provider: params.provider,
p_stripe_publishable_key: params.stripePublishableKey ?? null,
p_stripe_secret_key: params.stripeSecretKey ?? null,
p_stripe_user_id: params.stripeUserId ?? null,
p_square_access_token: params.squareAccessToken ?? null,
p_square_location_id: params.squareLocationId ?? null,
p_square_sync_enabled: params.squareSyncEnabled ?? null,
+5 -2
View File
@@ -3,6 +3,9 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
// Product images bucket - UUID from Supabase
const PRODUCT_IMAGES_BUCKET_ID = "80aa01da-ab4b-44f8-b6e7-700552457e18";
export type UploadProductImageResult =
| { success: true; imageUrl: string }
| { success: false; error: string };
@@ -32,7 +35,7 @@ export async function uploadProductImage(
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const uploadRes = await fetch(
`${supabaseUrl}/storage/v1/object/product-images/${path}`,
`${supabaseUrl}/storage/v1/object/${PRODUCT_IMAGES_BUCKET_ID}/${path}`,
{
method: "PUT",
headers: { ...svcHeaders(supabaseKey), "Authorization": `Bearer ${supabaseKey}`, "Content-Type": `image/${ext}`, "x-upsert": "true" },
@@ -44,7 +47,7 @@ export async function uploadProductImage(
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
}
const publicUrl = `${supabaseUrl}/storage/v1/object/public/product-images/${path}`;
const publicUrl = `${supabaseUrl}/storage/v1/object/public/${PRODUCT_IMAGES_BUCKET_ID}/${path}`;
// If productId is "__NEW__", we just upload and return the URL (caller handles saving it)
if (productId === "__NEW__") {
+278
View File
@@ -0,0 +1,278 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
import Stripe from "stripe";
/**
* Get Stripe Connect status for a brand.
* Checks if brand has a stripe_user_id (connected account).
*/
export async function getStripeConnectStatus(brandId: string): Promise<{
is_connected: boolean;
account_id?: string;
charges_enabled?: boolean;
payouts_enabled?: boolean;
details_submitted?: boolean;
error?: string;
}> {
const adminUser = await getAdminUser();
if (!adminUser) return { is_connected: false, error: "Not authenticated" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Get brand's payment settings
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_brand_payment_settings`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!res.ok) {
return { is_connected: false, error: "Failed to fetch payment settings" };
}
const data = await res.json();
const stripeUserId = data?.stripe_user_id;
if (!stripeUserId) {
return { is_connected: false };
}
// Verify the account exists and get status
try {
const stripeKey = process.env.STRIPE_SECRET_KEY;
if (!stripeKey) {
return { is_connected: true, account_id: stripeUserId };
}
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
const account = await stripe.accounts.retrieve(stripeUserId);
return {
is_connected: true,
account_id: stripeUserId,
charges_enabled: account.charges_enabled,
payouts_enabled: account.payouts_enabled,
details_submitted: account.details_submitted,
};
} catch (err) {
// If we can't verify, assume connected but with stale data
return {
is_connected: true,
account_id: stripeUserId,
charges_enabled: false,
payouts_enabled: false,
details_submitted: false,
};
}
}
/**
* Create a new Stripe Connect Express account for a brand
* and return an onboarding link.
*/
export async function createStripeConnectLink(brandId: string): Promise<{
success: boolean;
url?: string;
account_id?: string;
error?: string;
}> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
return { success: false, error: "Not authorized" };
}
const stripeKey = process.env.STRIPE_SECRET_KEY;
if (!stripeKey) {
return { success: false, error: "Stripe is not configured on this platform" };
}
try {
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
const origin = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
// Create Express account
const account = await stripe.accounts.create({
type: "express",
capabilities: {
card_payments: { requested: true },
transfers: { requested: true },
},
metadata: {
brand_id: brandId,
},
});
// Create account link for onboarding
const accountLink = await stripe.accountLinks.create({
account: account.id,
refresh_url: `${origin}/admin/advanced?stripe_refresh=true`,
return_url: `${origin}/admin/advanced?stripe_connected=true`,
type: "account_onboarding",
});
return {
success: true,
url: accountLink.url,
account_id: account.id,
};
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create Stripe account";
return { success: false, error: message };
}
}
/**
* Create a new account link for an existing Stripe Connect account
* (for refreshing expired links or re-onboarding)
*/
export async function refreshStripeConnectLink(brandId: string): Promise<{
success: boolean;
url?: string;
error?: string;
}> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
return { success: false, error: "Not authorized" };
}
// Get existing account ID
const status = await getStripeConnectStatus(brandId);
if (!status.is_connected || !status.account_id) {
// No existing account, create new one
return createStripeConnectLink(brandId);
}
const stripeKey = process.env.STRIPE_SECRET_KEY;
if (!stripeKey) {
return { success: false, error: "Stripe is not configured on this platform" };
}
try {
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
const origin = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
const accountLink = await stripe.accountLinks.create({
account: status.account_id,
refresh_url: `${origin}/admin/advanced?stripe_refresh=true`,
return_url: `${origin}/admin/advanced?stripe_connected=true`,
type: "account_onboarding",
});
return {
success: true,
url: accountLink.url,
};
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to refresh onboarding link";
return { success: false, error: message };
}
}
/**
* Save Stripe Connect account ID to brand settings
*/
export async function saveStripeConnectAccount(brandId: string, accountId: string): Promise<{
success: boolean;
error?: string;
}> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Save to payment_settings via RPC
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/set_stripe_connect_account`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_stripe_user_id: accountId,
}),
}
);
if (!res.ok) {
const error = await res.text();
return { success: false, error: `Failed to save: ${error}` };
}
return { success: true };
}
/**
* Disconnect Stripe Connect account from a brand
*/
export async function disconnectStripeConnect(brandId: string): Promise<{
success: boolean;
error?: string;
}> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
return { success: false, error: "Not authorized" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/disconnect_stripe_connect`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!res.ok) {
return { success: false, error: "Failed to disconnect Stripe account" };
}
return { success: true };
}
/**
* Create a Stripe Express dashboard login link
*/
export async function createStripeDashboardLink(brandId: string): Promise<{
success: boolean;
url?: string;
error?: string;
}> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
return { success: false, error: "Not authorized" };
}
const status = await getStripeConnectStatus(brandId);
if (!status.is_connected || !status.account_id) {
return { success: false, error: "No Stripe account connected" };
}
const stripeKey = process.env.STRIPE_SECRET_KEY;
if (!stripeKey) {
return { success: false, error: "Stripe is not configured" };
}
try {
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
const loginLink = await stripe.accounts.createLoginLink(status.account_id);
return {
success: true,
url: loginLink.url,
};
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create dashboard link";
return { success: false, error: message };
}
}
+18 -1
View File
@@ -18,12 +18,15 @@ function rpcBody(body: Record<string, unknown>) {
export type TimeWorker = {
id: string;
brand_id: string;
name: string;
role: string;
lang: string;
pin: string;
active: boolean;
last_used_at: string | null;
created_at: string;
worker_number: number | null;
};
export type TimeTask = {
@@ -62,12 +65,15 @@ export async function getTimeTrackingWorkers(brandId: string): Promise<TimeWorke
if (useMockData) {
return mockWorkers.map(w => ({
id: w.id,
brand_id: brandId,
name: w.name,
role: w.role,
lang: w.language,
pin: "0000",
active: w.is_active,
last_used_at: null,
created_at: new Date().toISOString(),
worker_number: null,
}));
}
const res = await fetch(
@@ -80,7 +86,18 @@ export async function getTimeTrackingWorkers(brandId: string): Promise<TimeWorke
);
if (!res.ok) return [];
const data = await res.json();
return data?.workers ?? [];
return (data?.workers ?? []).map((w: Record<string, unknown>) => ({
id: w.id as string,
brand_id: w.brand_id as string,
name: w.name as string,
role: w.role as string,
lang: w.lang as string,
pin: w.pin as string,
active: w.active as boolean,
last_used_at: w.last_used_at as string | null,
created_at: w.created_at as string,
worker_number: w.worker_number as number | null,
}));
}
export async function createTimeWorker(