Initial commit - Route Commerce platform
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type AIProvider = "openai" | "anthropic" | "google" | "xai" | "custom";
|
||||
|
||||
export type AIProviderSettings = {
|
||||
provider: AIProvider;
|
||||
apiKey: string;
|
||||
orgId?: string;
|
||||
model: string;
|
||||
customEndpoint?: string;
|
||||
};
|
||||
|
||||
export type CustomIntegration = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "ai_provider" | "payment_processor" | "other";
|
||||
enabled: boolean;
|
||||
credentials: Record<string, string>;
|
||||
syncOptions: string[];
|
||||
testEndpoint?: string;
|
||||
};
|
||||
|
||||
// ── Get AI provider settings ─────────────────────────────────────────────────────
|
||||
|
||||
export async function getAIProviderSettings(brandId: string): Promise<AIProviderSettings> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_ai_provider_settings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey!),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return { provider: "openai", apiKey: "", model: "gpt-4o-mini" };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
provider: (data.provider as AIProvider) ?? "openai",
|
||||
apiKey: data.api_key ?? "",
|
||||
orgId: data.org_id,
|
||||
model: data.model ?? "gpt-4o-mini",
|
||||
customEndpoint: data.custom_endpoint,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Save AI provider settings ───────────────────────────────────────────────────
|
||||
|
||||
export async function setAIProviderSettings(
|
||||
brandId: string,
|
||||
settings: Partial<AIProviderSettings>
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" };
|
||||
|
||||
const current = await getAIProviderSettings(brandId);
|
||||
const merged = { ...current, ...settings };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
const payload = {
|
||||
provider: merged.provider,
|
||||
api_key: merged.apiKey,
|
||||
org_id: merged.orgId ?? null,
|
||||
model: merged.model,
|
||||
custom_endpoint: merged.customEndpoint ?? null,
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/set_ai_provider_settings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey!),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ p_brand_id: brandId, p_settings: payload }),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data) {
|
||||
return { success: false, error: data?.message ?? "Failed to save" };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Dynamic AI client factory ────────────────────────────────────────────────────
|
||||
|
||||
export async function getAIClient(brandId: string): Promise<{
|
||||
provider: AIProvider;
|
||||
model: string;
|
||||
client: unknown;
|
||||
} | { provider: "openai"; model: string; client: null; error: string }> {
|
||||
const settings = await getAIProviderSettings(brandId);
|
||||
|
||||
if (!settings.apiKey) {
|
||||
// Fall back to env var
|
||||
const envKey = process.env.OPENAI_API_KEY;
|
||||
if (!envKey) return { provider: "openai", model: "gpt-4o-mini", client: null, error: "No API key configured" };
|
||||
const { OpenAI } = await import("openai");
|
||||
return {
|
||||
provider: "openai",
|
||||
model: settings.model || "gpt-4o-mini",
|
||||
client: new OpenAI({ apiKey: envKey }),
|
||||
};
|
||||
}
|
||||
|
||||
switch (settings.provider) {
|
||||
case "anthropic": {
|
||||
const { Anthropic } = await import("@anthropic-ai/sdk");
|
||||
return {
|
||||
provider: "anthropic",
|
||||
model: settings.model || "claude-3-5-sonnet-20241002",
|
||||
client: new Anthropic({ apiKey: settings.apiKey }),
|
||||
};
|
||||
}
|
||||
case "google": {
|
||||
const { GoogleGenerativeAI } = await import("@google/generative-ai");
|
||||
return {
|
||||
provider: "google",
|
||||
model: settings.model || "gemini-2.0-flash",
|
||||
client: new GoogleGenerativeAI(settings.apiKey),
|
||||
};
|
||||
}
|
||||
case "xai": {
|
||||
const { OpenAI: XAIOpenAI } = await import("openai");
|
||||
return {
|
||||
provider: "xai",
|
||||
model: settings.model || "grok-2",
|
||||
client: new XAIOpenAI({ apiKey: settings.apiKey, baseURL: "https://api.x.ai/v1" }),
|
||||
};
|
||||
}
|
||||
case "custom": {
|
||||
if (!settings.customEndpoint) {
|
||||
// @ts-expect-error - intentionally returning extra property for error signaling
|
||||
return { provider: "custom", model: settings.model, client: null, error: "Custom endpoint required" };
|
||||
}
|
||||
const { OpenAI } = await import("openai");
|
||||
return {
|
||||
provider: "custom",
|
||||
model: settings.model || "gpt-4o-mini",
|
||||
client: new OpenAI({ apiKey: settings.apiKey, baseURL: settings.customEndpoint }),
|
||||
};
|
||||
}
|
||||
case "openai":
|
||||
default: {
|
||||
const { OpenAI } = await import("openai");
|
||||
return {
|
||||
provider: "openai",
|
||||
model: settings.model || "gpt-4o-mini",
|
||||
client: new OpenAI({ apiKey: settings.apiKey }),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Custom integrations ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function getCustomIntegrations(brandId: string): Promise<CustomIntegration[]> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_custom_integrations`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey!),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
export async function upsertCustomIntegration(
|
||||
brandId: string,
|
||||
integration: CustomIntegration
|
||||
): Promise<{ success: boolean; integrations?: CustomIntegration[]; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/upsert_custom_integration`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey!),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ p_brand_id: brandId, p_integration: integration }),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) return { success: false, error: data?.message ?? "Failed to save" };
|
||||
|
||||
return { success: true, integrations: data };
|
||||
}
|
||||
|
||||
export async function deleteCustomIntegration(
|
||||
brandId: string,
|
||||
integrationId: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/delete_custom_integration`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey!),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ p_brand_id: brandId, p_integration_id: integrationId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
return { success: false, error: data?.message ?? "Failed to delete" };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ResendCredentials = {
|
||||
api_key: string | null;
|
||||
from_email: string | null;
|
||||
from_name: string | null;
|
||||
};
|
||||
|
||||
export type TwilioCredentials = {
|
||||
account_sid: string | null;
|
||||
auth_token: string | null;
|
||||
phone_number: string | null;
|
||||
};
|
||||
|
||||
export type SaveCredentialsResult =
|
||||
| { success: true }
|
||||
| { success: false; error: string };
|
||||
|
||||
// ── Resend Credentials ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function getResendCredentials(brandId: string): Promise<ResendCredentials> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_resend_credentials`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return { api_key: null, from_email: null, from_name: null };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
api_key: data?.api_key ?? null,
|
||||
from_email: data?.from_email ?? null,
|
||||
from_name: data?.from_name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveResendCredentials(
|
||||
brandId: string,
|
||||
credentials: Partial<{
|
||||
api_key: string | null;
|
||||
from_email: string | null;
|
||||
from_name: string | null;
|
||||
}>
|
||||
): Promise<SaveCredentialsResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) {
|
||||
return { success: false, error: "Not authenticated" };
|
||||
}
|
||||
|
||||
// Brand scoping: brand_admin can only modify their own brand's settings
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Get current credentials to merge
|
||||
const current = await getResendCredentials(brandId);
|
||||
const merged = {
|
||||
api_key: credentials.api_key !== undefined ? credentials.api_key : current.api_key,
|
||||
from_email: credentials.from_email !== undefined ? credentials.from_email : current.from_email,
|
||||
from_name: credentials.from_name !== undefined ? credentials.from_name : current.from_name,
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/set_resend_credentials`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_credentials: merged,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, error: "Failed to save Resend credentials" };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Twilio Credentials ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function getTwilioCredentials(brandId: string): Promise<TwilioCredentials> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_twilio_credentials`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return { account_sid: null, auth_token: null, phone_number: null };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
account_sid: data?.account_sid ?? null,
|
||||
auth_token: data?.auth_token ?? null,
|
||||
phone_number: data?.phone_number ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveTwilioCredentials(
|
||||
brandId: string,
|
||||
credentials: Partial<{
|
||||
account_sid: string | null;
|
||||
auth_token: string | null;
|
||||
phone_number: string | null;
|
||||
}>
|
||||
): Promise<SaveCredentialsResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) {
|
||||
return { success: false, error: "Not authenticated" };
|
||||
}
|
||||
|
||||
// Brand scoping: brand_admin can only modify their own brand's settings
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Get current credentials to merge
|
||||
const current = await getTwilioCredentials(brandId);
|
||||
const merged = {
|
||||
account_sid: credentials.account_sid !== undefined ? credentials.account_sid : current.account_sid,
|
||||
auth_token: credentials.auth_token !== undefined ? credentials.auth_token : current.auth_token,
|
||||
phone_number: credentials.phone_number !== undefined ? credentials.phone_number : current.phone_number,
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/set_twilio_credentials`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_credentials: merged,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, error: "Failed to save Twilio credentials" };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Test Connection Functions ─────────────────────────────────────────────────
|
||||
|
||||
export async function testResendConnection(apiKey: string): Promise<{
|
||||
ok: boolean;
|
||||
message: string;
|
||||
}> {
|
||||
if (!apiKey?.trim()) {
|
||||
return { ok: false, message: "API key is required" };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("https://api.resend.com/domains", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { ok: true, message: "Successfully connected to Resend" };
|
||||
} else {
|
||||
const error = await response.json().catch(() => ({ message: "Invalid API key" }));
|
||||
return { ok: false, message: error.message ?? "Connection failed" };
|
||||
}
|
||||
} catch (err) {
|
||||
return { ok: false, message: "Network error - please check your connection" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function testTwilioConnection(
|
||||
accountSid: string,
|
||||
authToken: string
|
||||
): Promise<{ ok: boolean; message: string }> {
|
||||
if (!accountSid?.trim() || !authToken?.trim()) {
|
||||
return { ok: false, message: "Account SID and Auth Token are required" };
|
||||
}
|
||||
|
||||
try {
|
||||
const credentials = Buffer.from(`${accountSid}:${authToken}`).toString("base64");
|
||||
const response = await fetch(
|
||||
`https://api.twilio.com/2010-04-01/Accounts/${accountSid}.json`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Basic ${credentials}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
return { ok: true, message: "Successfully connected to Twilio" };
|
||||
} else {
|
||||
return { ok: false, message: "Invalid Account SID or Auth Token" };
|
||||
}
|
||||
} catch (err) {
|
||||
return { ok: false, message: "Network error - please check your connection" };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user