Merge main: Guidad Felsökning flyttar ur roten till felsokning/

Main är sedan den här grenen skapades en helt annan produkt — Semantika,
en mobilapp med egen CDK-infrastruktur. Den äger nu repots rot: en
npm-workspaces-monorepo med apps/mobile, services/api och infra.

För att båda ska rymmas i samma repo flyttar Guidad Felsökning in i en
egen katalog i stället för att göra anspråk på roten:

  felsokning/app        webbklienten (Vite, egen package.json och
                        eslint-/vitest-konfiguration)
  felsokning/services   plattformstjänsten och AI-orkestern
  felsokning/infra      Terraform och databasschemat
  felsokning/docs       vision, moduler, drift
  felsokning/supabase   edge-funktion och migrationer

Merge:n hade tagit bort 128 filer som Guidad Felsökning bygger på —
värdapplikationens komponenter, Supabase-klienten, tillgångar — eftersom
main raderat dem och den här grenen inte råkat ändra just dem. De är
återställda på sin nya plats. Utan dem gick varken bygget eller
testerna: ai.ts och synk.ts importerar Supabase-klienten.

Semantikas rotfiler är orörda: package.json, eslint.config.js och
.github/workflows/ är deras. Guidad Felsökning har egna motsvarigheter i
sin katalog.

CI flyttar samtidigt från GitHub Actions till .gitea/workflows — samma
syntax, egna runners. .github/workflows/ tillhör Semantika härefter.

Verifierat på den nya platsen: 96 vitest-tester, typkontroll, eslint på
både klient och tjänster, bygge, och integrationstest mot riktig Postgres.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
Claude
2026-08-04 12:22:49 +00:00
298 changed files with 23491 additions and 6140 deletions
@@ -0,0 +1,74 @@
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0";
/**
* Verifies the caller is an authenticated admin.
* Returns { ok: true } on success or { ok: false, response } with a
* ready-to-return Response on failure.
*/
export async function requireAdmin(
req: Request,
corsHeaders: Record<string, string>,
): Promise<{ ok: true; userId: string } | { ok: false; response: Response }> {
const authHeader = req.headers.get("Authorization");
if (!authHeader?.startsWith("Bearer ")) {
return {
ok: false,
response: new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}),
};
}
const supabaseUrl = Deno.env.get("SUPABASE_URL");
const anonKey = Deno.env.get("SUPABASE_ANON_KEY");
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
if (!supabaseUrl || !anonKey || !serviceKey) {
return {
ok: false,
response: new Response(JSON.stringify({ error: "Server misconfigured" }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}),
};
}
const supabase = createClient(supabaseUrl, anonKey, {
global: { headers: { Authorization: authHeader } },
});
const token = authHeader.replace("Bearer ", "");
const { data, error } = await supabase.auth.getClaims(token);
if (error || !data?.claims?.sub) {
return {
ok: false,
response: new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}),
};
}
const userId = data.claims.sub as string;
// Verify admin role via service-role client (bypasses RLS deterministically)
const admin = createClient(supabaseUrl, serviceKey);
const { data: role, error: roleErr } = await admin
.from("user_roles")
.select("role")
.eq("user_id", userId)
.eq("role", "admin")
.maybeSingle();
if (roleErr || !role) {
return {
ok: false,
response: new Response(JSON.stringify({ error: "Forbidden" }), {
status: 403,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}),
};
}
return { ok: true, userId };
}
@@ -0,0 +1,87 @@
import { encode } from "https://deno.land/std@0.168.0/encoding/hex.ts";
import Stripe from "https://esm.sh/stripe@22.0.2";
const getEnv = (key: string): string => {
const value = Deno.env.get(key);
if (!value) throw new Error(`${key} is not configured`);
return value;
};
export type StripeEnv = "sandbox" | "live";
const GATEWAY_STRIPE_BASE = "https://connector-gateway.lovable.dev/stripe";
export function getConnectionApiKey(env: StripeEnv): string {
return env === "sandbox"
? getEnv("STRIPE_SANDBOX_API_KEY")
: getEnv("STRIPE_LIVE_API_KEY");
}
export function createStripeClient(env: StripeEnv): Stripe {
const connectionApiKey = getConnectionApiKey(env);
const lovableApiKey = getEnv("LOVABLE_API_KEY");
return new Stripe(connectionApiKey, {
apiVersion: "2026-03-25.dahlia" as any,
httpClient: Stripe.createFetchHttpClient((input, init) => {
const stripeUrl = input instanceof Request ? input.url : input.toString();
const gatewayUrl = stripeUrl.replace("https://api.stripe.com", GATEWAY_STRIPE_BASE);
return fetch(gatewayUrl, {
...init,
headers: {
...Object.fromEntries(
new Headers(
init?.headers ?? (input instanceof Request ? input.headers : undefined),
).entries(),
),
"X-Connection-Api-Key": connectionApiKey,
"Lovable-API-Key": lovableApiKey,
},
});
}),
});
}
export async function verifyWebhook(
req: Request,
env: StripeEnv,
): Promise<{ type: string; data: { object: any } }> {
const signature = req.headers.get("stripe-signature");
const body = await req.text();
const secret =
env === "sandbox"
? getEnv("PAYMENTS_SANDBOX_WEBHOOK_SECRET")
: getEnv("PAYMENTS_LIVE_WEBHOOK_SECRET");
if (!signature || !body) throw new Error("Missing signature or body");
let timestamp: string | undefined;
const v1Signatures: string[] = [];
for (const part of signature.split(",")) {
const [key, value] = part.split("=", 2);
if (key === "t") timestamp = value;
if (key === "v1") v1Signatures.push(value);
}
if (!timestamp || v1Signatures.length === 0) throw new Error("Invalid signature format");
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (age > 300) throw new Error("Webhook timestamp too old");
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const signed = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(`${timestamp}.${body}`),
);
const expected = new TextDecoder().decode(encode(new Uint8Array(signed)));
if (!v1Signatures.includes(expected)) throw new Error("Invalid webhook signature");
return JSON.parse(body);
}
@@ -0,0 +1,91 @@
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0";
import { z } from "npm:zod@3.23.8";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, x-supabase-client-platform, x-supabase-api-version, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
const BodySchema = z.object({
email: z.string().trim().email().max(255),
password: z.string().min(8).max(100),
company: z.string().trim().max(200).optional().default(""),
phone: z.string().trim().max(30).optional().default(""),
});
const admin = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);
Deno.serve(async (req) => {
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
if (req.method !== "POST") {
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
try {
const parsed = BodySchema.safeParse(await req.json());
if (!parsed.success) {
return new Response(
JSON.stringify({ error: "Ogiltiga fält", details: parsed.error.flatten().fieldErrors }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
const { email, password, company, phone } = parsed.data;
// Try to create user with email_confirm=true so they can sign in immediately
const { data: created, error: createErr } = await admin.auth.admin.createUser({
email,
password,
email_confirm: true,
user_metadata: { company, phone },
});
if (createErr) {
const msg = createErr.message || "";
const alreadyExists =
msg.toLowerCase().includes("already") ||
msg.toLowerCase().includes("registered") ||
msg.toLowerCase().includes("exists");
if (alreadyExists) {
return new Response(JSON.stringify({ ok: true, existed: true }), {
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
console.error("createUser error:", createErr);
return new Response(JSON.stringify({ error: msg }), {
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
// Best-effort update profile
if (created?.user?.id) {
await admin
.from("profiles")
.update({
company_name: company || null,
phone: phone || null,
})
.eq("user_id", created.user.id);
}
return new Response(JSON.stringify({ ok: true, existed: false }), {
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
} catch (err: any) {
console.error("create-account error:", err);
return new Response(JSON.stringify({ error: err?.message ?? "Något gick fel" }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
});
@@ -0,0 +1,284 @@
import { z } from "npm:zod@3.23.8";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0";
import { type StripeEnv, createStripeClient } from "../_shared/stripe.ts";
const supabaseAdmin = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);
async function sendOrderEmail(payload: Record<string, unknown>) {
try {
const res = await fetch(
`${Deno.env.get("SUPABASE_URL")}/functions/v1/send-order-email`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")}`,
},
body: JSON.stringify(payload),
},
);
if (!res.ok) console.error("send-order-email failed:", res.status, await res.text());
} catch (e) {
console.error("send-order-email error:", e);
}
}
async function insertOrder(row: {
method: "card" | "invoice";
b: any;
cleaned: string;
monthlyAmountOre: number;
stripeSessionId?: string | null;
stripeSubscriptionId?: string | null;
stripeCustomerId: string;
}) {
const { b, method, cleaned, monthlyAmountOre } = row;
const orderNumber = `ABO-${Date.now().toString(36).toUpperCase()}`;
const totalKr = monthlyAmountOre / 100;
const { error } = await supabaseAdmin.from("orders").insert({
order_number: orderNumber,
customer_email: b.email,
customer_name: b.company,
total_amount: totalKr,
currency: "SEK",
status: method === "invoice" ? "invoiced" : "pending",
items: [
{
typ: "Kakabonnemang",
betalning: method === "card" ? "Kort (månadsvis)" : "Faktura (månadsvis)",
antal_anställda: b.employees,
kg_per_vecka: b.kg_per_week,
pris_per_månad_exkl_moms: totalKr,
valuta: "SEK",
leverans: {
adress: b.address,
postnummer: cleaned,
stad: b.city,
},
telefon: b.phone,
kommentarer: b.comments,
stripe_session_id: row.stripeSessionId ?? null,
stripe_subscription_id: row.stripeSubscriptionId ?? null,
stripe_customer_id: row.stripeCustomerId,
},
],
});
if (error) console.error("Order insert failed:", error);
await sendOrderEmail({
order_number: orderNumber,
customer_email: b.email,
customer_name: b.company,
kg_per_week: b.kg_per_week,
employees: b.employees,
monthly_amount_sek: totalKr,
method,
address: b.address ?? "",
postal_code: cleaned,
phone: b.phone ?? "",
comments: b.comments ?? "",
});
}
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, x-supabase-client-platform, x-supabase-api-version, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
// TEMP TEST: 1 kr/kg (sätt tillbaka till 32500 efter testet)
const PRICE_PER_KG_ORE = 100; // 1 kr in öre
const WEEKS_PER_MONTH = 4;
const BodySchema = z.object({
method: z.enum(["card", "invoice"]),
employees: z.number().int().min(1).max(500),
kg_per_week: z.number().min(1).max(200),
company: z.string().trim().min(1).max(200),
email: z.string().trim().email().max(255),
phone: z.string().trim().max(30).optional().default(""),
address: z.string().trim().min(1).max(200),
postal_code: z.string().trim().min(1).max(20),
city: z.string().trim().min(1).max(100),
comments: z.string().trim().max(1000).optional().default(""),
return_url: z.string().url(),
environment: z.enum(["sandbox", "live"]),
});
Deno.serve(async (req) => {
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
if (req.method !== "POST") {
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
try {
const raw = await req.json();
const parsed = BodySchema.safeParse(raw);
if (!parsed.success) {
return new Response(
JSON.stringify({ error: "Ogiltiga fält", details: parsed.error.flatten().fieldErrors }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
const b = parsed.data;
// Stockholm postal enforcement
const cleaned = b.postal_code.replace(/\s/g, "");
if (!/^\d{5}$/.test(cleaned) || parseInt(cleaned, 10) < 10000 || parseInt(cleaned, 10) > 19999) {
return new Response(
JSON.stringify({ error: "Vi levererar endast inom Storstockholm (100 00 199 99)." }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
const env: StripeEnv = b.environment;
const stripe = createStripeClient(env);
// Monthly amount in öre = kg × 4 veckor × 325 kr
const monthlyAmountOre = Math.round(b.kg_per_week * WEEKS_PER_MONTH * PRICE_PER_KG_ORE);
// Find or create customer
const existing = await stripe.customers.list({ email: b.email, limit: 1 });
const customer =
existing.data[0] ??
(await stripe.customers.create({
email: b.email,
name: b.company,
phone: b.phone || undefined,
address: b.address
? {
line1: b.address,
postal_code: cleaned,
city: b.city || undefined,
country: "SE",
}
: undefined,
metadata: {
company: b.company,
employees: String(b.employees),
kg_per_week: String(b.kg_per_week),
comments: b.comments,
},
}));
if (b.method === "card") {
// Embedded Checkout with dynamic recurring price_data
const session = await stripe.checkout.sessions.create({
mode: "subscription",
ui_mode: "embedded_page" as any,
return_url: b.return_url,
customer: customer.id,
line_items: [
{
quantity: 1,
price_data: {
currency: "sek",
product_data: {
name: "Kakabonnemang",
description: "Månatligt kakabonnemang för företag inom Storstockholm.",
tax_code: "txcd_40060003",
},
recurring: { interval: "month" },
unit_amount: monthlyAmountOre,
} as any,
},
],
automatic_tax: { enabled: true },
customer_update: { address: "auto", name: "auto" },
subscription_data: {
description: `Kakabonnemang ${b.kg_per_week} kg/vecka`,
metadata: {
company: b.company,
customer_email: b.email,
kg_per_week: String(b.kg_per_week),
employees: String(b.employees),
},
},
metadata: {
company: b.company,
customer_email: b.email,
kg_per_week: String(b.kg_per_week),
},
} as any);
await insertOrder({
method: "card",
b,
cleaned,
monthlyAmountOre,
stripeSessionId: session.id,
stripeSubscriptionId: null,
stripeCustomerId: customer.id,
});
return new Response(JSON.stringify({ clientSecret: session.client_secret }), {
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
// Invoice method: create subscription with send_invoice
const subscription = await stripe.subscriptions.create({
customer: customer.id,
collection_method: "send_invoice",
days_until_due: 30,
items: [
{
price_data: {
currency: "sek",
product_data: {
name: "Kakabonnemang",
description: "Månatligt kakabonnemang för företag inom Storstockholm.",
tax_code: "txcd_40060003",
},
recurring: { interval: "month" },
unit_amount: monthlyAmountOre,
tax_behavior: "exclusive",
} as any,
quantity: 1,
},
],
automatic_tax: { enabled: true },
description: `Kakabonnemang ${b.kg_per_week} kg/vecka`,
metadata: {
company: b.company,
customer_email: b.email,
kg_per_week: String(b.kg_per_week),
employees: String(b.employees),
},
} as any);
await insertOrder({
method: "invoice",
b,
cleaned,
monthlyAmountOre,
stripeSessionId: null,
stripeSubscriptionId: subscription.id,
stripeCustomerId: customer.id,
});
return new Response(
JSON.stringify({
invoice: true,
subscription_id: subscription.id,
message: "Faktura skickas till din e-post inom kort.",
}),
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
} catch (err: any) {
console.error("create-checkout error:", err);
return new Response(
JSON.stringify({ error: err?.message ?? "Något gick fel vid checkout" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
});
@@ -0,0 +1,85 @@
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0";
import { z } from "npm:zod@3.23.8";
import { type StripeEnv, createStripeClient } from "../_shared/stripe.ts";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, x-supabase-client-platform, x-supabase-api-version, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);
const BodySchema = z.object({
return_url: z.string().url(),
environment: z.enum(["sandbox", "live"]),
});
Deno.serve(async (req) => {
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
if (req.method !== "POST") {
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
try {
const token = req.headers.get("Authorization")?.replace("Bearer ", "") ?? "";
const { data: userData, error: authErr } = await supabase.auth.getUser(token);
if (authErr || !userData?.user) {
return new Response(JSON.stringify({ error: "Ej inloggad" }), {
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const parsed = BodySchema.safeParse(await req.json());
if (!parsed.success) {
return new Response(JSON.stringify({ error: "Ogiltiga fält" }), {
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const env: StripeEnv = parsed.data.environment;
// Hämta senaste subscription för denna user i rätt env
const { data: sub } = await supabase
.from("subscriptions")
.select("stripe_customer_id")
.eq("user_id", userData.user.id)
.eq("environment", env)
.order("created_at", { ascending: false })
.limit(1)
.maybeSingle();
if (!sub?.stripe_customer_id) {
return new Response(
JSON.stringify({ error: "Inget abonnemang hittades för detta konto." }),
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
const stripe = createStripeClient(env);
const portal = await stripe.billingPortal.sessions.create({
customer: sub.stripe_customer_id as string,
return_url: parsed.data.return_url,
});
return new Response(JSON.stringify({ url: portal.url }), {
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
} catch (e: any) {
console.error("create-portal-session error:", e);
return new Response(JSON.stringify({ error: e?.message ?? "Kunde inte öppna kundportal" }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
});
@@ -0,0 +1,308 @@
// Plattformens AI-orkester för Guidad Felsökning.
//
// AI:n drivs av plattformen: Claude API-nyckeln är en serverhemlighet
// (ANTHROPIC_API_KEY) och lämnar aldrig backend. Vi kör flera modeller i
// vår infrastruktur och routar per uppgift — servern äger hela orkestern
// (modellval, effort, systemprompt, svarsschema), så routingen kan
// justeras utan klientändringar och AI-reglerna kan inte kringgås från
// klientsidan.
//
// Kräver inloggad användare (verify_jwt = true i config.toml).
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
import Anthropic from "npm:@anthropic-ai/sdk";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
};
const MAX_PROMPT_LANGD = 40000;
const GRUND_REGLER = `Du arbetar i Guidad Felsökning, en professionell diagnostikplattform för tekniker. Du är inte en AI-mekaniker: du ersätter aldrig teknikerns kompetens eller tillverkarens dokumentation.
Absoluta regler:
- Hitta aldrig på fakta. Låtsas aldrig veta. Gissa aldrig.
- Presentera aldrig en hypotes som ett konstaterat fel.
- Skilj strikt mellan raderna du returnerar:
- "verifierat": endast det som är belagt av mätvärden eller dokumenterade kontroller i underlaget.
- "observation": det som konstaterats utan slutsats.
- "hypotes": möjlig felorsak som KRÄVER verifiering — formulera alltid vad som skulle verifiera den.
- "rekommendation": nästa verifierbara kontroll eller mätning.
- Om underlaget är otillräckligt: säg det uttryckligen i en observation och rekommendera vad som behöver dokumenteras.
- Skriv aldrig "OK", "kontrollerad", "inga fel" eller "åtgärdad" om evidens saknas i underlaget — skriv i stället "Evidens saknas" och begär rätt underlag: foto för det synliga, video med ljud för det som låter, video för det som rör sig, mätvärde för det som mäts, foto av skärmen när ett instrument eller en diagnosdator visar informationen.
- Skriv aldrig "felet konstaterat" om symptomet varken reproducerats eller verifierats med dokumentation — skriv då "Kundens beskrivning kunde inte reproduceras under de förhållanden som rådde vid undersökningen."
- Svara på svenska, konsekvent, kortfattat och metodiskt. "nastaSteg" är EN konkret, verifierbar åtgärd.`;
const SVARS_SCHEMA = {
type: "object",
properties: {
rader: {
type: "array",
items: {
type: "object",
properties: {
typ: { type: "string", enum: ["observation", "verifierat", "hypotes", "rekommendation"] },
text: { type: "string" },
},
required: ["typ", "text"],
additionalProperties: false,
},
},
nastaSteg: { type: "string" },
},
required: ["rader", "nastaSteg"],
additionalProperties: false,
};
const METODIK_SCHEMA = {
type: "object",
properties: {
metodikId: { type: "string", enum: ["vibration", "elsystem", "generisk"] },
},
required: ["metodikId"],
additionalProperties: false,
};
// Fält som kan tolkas ur en skannad arbetsorder. Klienten har samma
// katalog (src/felsokning/ai.ts) och filtrerar dessutom okända id:n.
const ARBETSORDER_FALT_ID = [
"kund_namn", "kund_foretag", "kund_telefon", "kund_epost",
"fordon_regnr", "fordon_vin", "fordon_marke", "fordon_modell",
"fordon_motor", "fordon_arsmodell", "fordon_matarstallning",
"fordon_motorkod", "fordon_vaxellada",
"ao_nummer", "ao_claim", "ao_skadenummer", "ao_referens", "ao_serviceradgivare", "ao_bokningsdatum",
"felbeskrivning",
];
const ARBETSORDER_SCHEMA = {
type: "object",
properties: {
falt: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "string", enum: ARBETSORDER_FALT_ID },
varde: { type: "string" },
konfidens: { type: "number", minimum: 0, maximum: 1 },
omrade: {
type: "object",
properties: {
x: { type: "number" },
y: { type: "number" },
bredd: { type: "number" },
hojd: { type: "number" },
},
required: ["x", "y", "bredd", "hojd"],
additionalProperties: false,
},
},
required: ["id", "varde", "konfidens"],
additionalProperties: false,
},
},
},
required: ["falt"],
additionalProperties: false,
};
const DOKUMENT_REGLER = `Du tolkar ett foto av en arbetsorder från en fordonsverkstad (OCR + layoutförståelse). Layouter varierar mellan verkstäder — identifiera fälten oavsett var de står, i tabeller såväl som fritext.
Regler:
- Returnera ENDAST fält som faktiskt går att läsa i dokumentet. Hitta aldrig på värden.
- "konfidens" är din läs-säkerhet 01: 1.0 endast vid helt entydig läsning; sänk vid oskarp text, handstil eller tvetydig layout.
- "omrade" anger ungefär var värdet står i bilden, normaliserat 01 (x, y = övre vänstra hörnet).
- Normalisera: registreringsnummer i versaler utan mellanslag, VIN med 17 tecken, datum som ÅÅÅÅ-MM-DD, mätarställning med enhet.
- "felbeskrivning" är kundens beskrivna problem/arbetsbegäran om en sådan finns i dokumentet.`;
// Visual-first: instrument och diagnosskärmar fotograferas i stället för
// att integreras — kameran är det universella gränssnittet.
const INSTRUMENT_SCHEMA = {
type: "object",
properties: {
instrumenttyp: { type: "string" },
varden: {
type: "array",
items: {
type: "object",
properties: {
beskrivning: { type: "string" },
varde: { type: "string" },
enhet: { type: "string" },
konfidens: { type: "number", minimum: 0, maximum: 1 },
},
required: ["beskrivning", "varde", "konfidens"],
additionalProperties: false,
},
},
},
required: ["instrumenttyp", "varden"],
additionalProperties: false,
};
const INSTRUMENT_REGLER = `Du läser av ett foto av ett instrument eller en skärm i en fordonsverkstad: multimeter, diagnosdator, oscilloskop, batteritestare, mätarkluster, manometer, utskrift eller etikett.
Regler:
- Identifiera instrumenttypen och extrahera ENDAST värden som faktiskt går att läsa i bilden. Hitta aldrig på värden.
- Varje värde får en beskrivning (t.ex. "Batterispänning", "Felkod P0301 misständning cylinder 1"), värdet som text och enhet när den syns (V, A, Ω, bar, °C, rpm …).
- Felkoder returneras som egna värden med kod och klartext om skärmen visar den.
- "konfidens" är din läs-säkerhet 01; sänk vid oskarp bild, reflexer eller delvis skymda siffror.`;
const MAX_BILD_LANGD = 3_000_000;
interface UppgiftKonfig {
modell: string;
// effort utelämnas för modeller som saknar parametern (Haiku 4.5).
effort?: "low" | "medium" | "high";
maxTokens: number;
system: string;
schema: object;
// Uppgiften tar en bifogad bild (data-URL) i stället för ren text.
bild?: boolean;
}
// Orkestern: en modell per uppgiftstyp.
// - handledning körs vid varje dokumentation och är latenskänslig på
// verkstadsgolvet → Sonnet 5 (nära Opus-kvalitet i realtid).
// - granskning läser hela underlaget och är kvalitetskritisk → Opus 5
// med hög effort.
// - sammanfattning (överlämning) → Sonnet 5, låg effort.
// - metodikval är ren klassificering → Haiku 4.5.
const ORKESTER: Record<string, UppgiftKonfig> = {
handledning: {
modell: "claude-sonnet-5",
effort: "medium",
maxTokens: 1024,
system: `${GRUND_REGLER}
Uppgift: Du är teknikerns digitala felsökningshandledare. Teknikern har just dokumenterat något nytt — svara på det i ljuset av ärendebriefen. En kontroll i taget, inte långa utläggningar: max fyra rader plus nästa steg.`,
schema: SVARS_SCHEMA,
},
granskning: {
modell: "claude-opus-5",
effort: "high",
maxTokens: 2048,
system: `${GRUND_REGLER}
Uppgift: Granska HELA underlaget i ärendet. Leta specifikt efter (1) motsägelser mellan observationer eller mätvärden, (2) luckor — kontroller som borde vara gjorda givet symptombilden men saknas, (3) förhastade slutsatser som saknar stöd. Rapportera motsägelser och luckor som "observation", möjliga felorsaker som underlaget antyder som "hypotes", och det viktigaste att åtgärda som "rekommendation". Max sex rader.`,
schema: SVARS_SCHEMA,
},
sammanfattning: {
modell: "claude-sonnet-5",
effort: "low",
maxTokens: 1024,
system: `${GRUND_REGLER}
Uppgift: En tekniker lämnar över ärendet. Komplettera överlämningen med det som inte syns i checklistorna: risker och osäkerheter i underlaget, klassificerade som rader. "nastaSteg" är det första nästa tekniker bör göra. Max fyra rader.`,
schema: SVARS_SCHEMA,
},
metodikval: {
modell: "claude-haiku-4-5",
maxTokens: 256,
system: `Du klassificerar en felbeskrivning från en verkstad till EN felsökningsmetodik:
- "vibration": vibrationer, skakningar eller obalans under körning.
- "elsystem": elektriska fel — reläer, säkringar, spänning, batteri, belysning, givare, strömförsörjning.
- "generisk": allt annat, eller när det är oklart.
Gissa inte: välj "generisk" om beskrivningen inte tydligt hör till en specifik metodik.`,
schema: METODIK_SCHEMA,
},
// Ärendestart: teknikern fotograferar arbetsordern; vi läser dokumentet
// och returnerar strukturerade fält med konfidens per värde.
// Visual-first: foto av instrument/diagnosskärm → strukturerade värden.
instrumentavlasning: {
modell: "claude-sonnet-5",
effort: "low",
maxTokens: 1024,
system: INSTRUMENT_REGLER,
schema: INSTRUMENT_SCHEMA,
bild: true,
},
dokumenttolkning: {
modell: "claude-sonnet-5",
effort: "low",
maxTokens: 2048,
system: DOKUMENT_REGLER,
schema: ARBETSORDER_SCHEMA,
bild: true,
},
};
serve(async (req: Request): Promise<Response> => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
try {
const apiNyckel = Deno.env.get("ANTHROPIC_API_KEY");
if (!apiNyckel) {
return svara(503, { error: "AI-tjänsten är inte konfigurerad." });
}
const { uppgift, prompt, bild } = await req.json();
const konfig = ORKESTER[uppgift as string];
if (!konfig) {
return svara(400, { error: "Okänd uppgift." });
}
if (typeof prompt !== "string" || prompt.trim().length === 0) {
return svara(400, { error: "prompt saknas." });
}
if (prompt.length > MAX_PROMPT_LANGD) {
return svara(400, { error: "prompt är för lång." });
}
// Bilduppgifter: bilden skickas som data-URL och blir ett image-block
// före textprompten i samma användarmeddelande.
let innehall: unknown = prompt;
if (konfig.bild) {
const matchning =
typeof bild === "string" && bild.length <= MAX_BILD_LANGD
? bild.match(/^data:(image\/(?:jpeg|png|webp));base64,([A-Za-z0-9+/=]+)$/)
: null;
if (!matchning) return svara(400, { error: "bild saknas eller har fel format." });
innehall = [
{ type: "image", source: { type: "base64", media_type: matchning[1], data: matchning[2] } },
{ type: "text", text: prompt },
];
}
const klient = new Anthropic({ apiKey: apiNyckel });
const svar = await klient.beta.messages.create({
model: konfig.modell,
max_tokens: konfig.maxTokens,
output_config: {
...(konfig.effort ? { effort: konfig.effort } : {}),
format: { type: "json_schema", schema: konfig.schema },
},
// Avböjer säkerhetsklassificerarna faller anropet automatiskt
// tillbaka till Anthropics rekommenderade reservmodell.
betas: ["server-side-fallback-2026-07-01"],
fallbacks: "default",
system: [{ type: "text", text: konfig.system, cache_control: { type: "ephemeral" } }],
messages: [{ role: "user", content: innehall }],
// deno-lint-ignore no-explicit-any -- fallbacks/output_config ligger före SDK-typerna
} as any);
if (svar.stop_reason === "refusal") {
return svara(502, { error: "AI-tjänsten avböjde förfrågan." });
}
const textBlock = svar.content.find((block: { type: string }) => block.type === "text");
if (!textBlock) {
return svara(502, { error: "AI-svaret saknade innehåll." });
}
return svara(200, {
modell: konfig.modell,
svar: JSON.parse((textBlock as { text: string }).text),
});
} catch (fel) {
console.error("felsokning-ai:", fel);
return svara(500, { error: "AI-anropet misslyckades." });
}
});
function svara(status: number, kropp: object): Response {
return new Response(JSON.stringify(kropp), {
status,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
@@ -0,0 +1,133 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { requireAdmin } from "../_shared/admin-auth.ts";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
};
const STORSTOCKHOLM = [
"Stockholm", "Solna", "Sundbyberg", "Täby", "Danderyd", "Lidingö",
"Nacka", "Sollentuna", "Järfälla", "Upplands Väsby", "Huddinge",
"Botkyrka", "Haninge", "Tyresö", "Nynäshamn", "Vallentuna", "Vaxholm",
"Österåker", "Ekerö", "Salem", "Nykvarn", "Södertälje",
];
serve(async (req) => {
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
const auth = await requireAdmin(req, corsHeaders);
if (!auth.ok) return auth.response;
try {
const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY");
if (!LOVABLE_API_KEY) throw new Error("LOVABLE_API_KEY is not configured");
const response = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${LOVABLE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "google/gemini-3-flash-preview",
messages: [
{
role: "system",
content: `Du är B2B-säljare för Lennart Svenssons Konditorivaror, ett bageri i Stockholm som levererar färska handgjorda kakor på abonnemang till företag.
Din uppgift: identifiera potentiella B2B-kunder i STORSTOCKHOLM.
ABSOLUTA REGLER:
- Endast företag inom följande kommuner räknas som Storstockholm:
${STORSTOCKHOLM.map((k) => `${k}`).join("\n")}
- Företag utanför Storstockholm får ALDRIG genereras.
- Använd endast verifierbar, realistisk information. Hitta inte på fakta.
- Fokusera på kontor 10+ anställda, hotell, restauranger, coworking, eventlokaler, större företag med personalrum/fika.
- Variera bransch och kommun inte bara innerstaden.`,
},
{
role: "user",
content: "Generera 15 potentiella B2B-kunder för kakabonnemang i Storstockholm.",
},
],
tools: [
{
type: "function",
function: {
name: "generate_leads",
description: "Returnerar en lista med potentiella B2B-kunder i Storstockholm",
parameters: {
type: "object",
properties: {
leads: {
type: "array",
items: {
type: "object",
properties: {
company_name: { type: "string" },
industry: { type: "string" },
employee_count: { type: "number" },
district: {
type: "string",
description: "Kommun i Storstockholm (t.ex. Solna, Nacka, Södertälje)",
},
reason: { type: "string" },
},
required: ["company_name", "industry", "employee_count", "district", "reason"],
additionalProperties: false,
},
},
},
required: ["leads"],
additionalProperties: false,
},
},
},
],
tool_choice: { type: "function", function: { name: "generate_leads" } },
}),
});
if (!response.ok) {
if (response.status === 429) {
return new Response(JSON.stringify({ error: "Rate limit överskriden, försök igen senare." }), {
status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
if (response.status === 402) {
return new Response(JSON.stringify({ error: "Krediter slut, vänligen fyll på." }), {
status: 402, headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const errorText = await response.text();
console.error("AI gateway error:", response.status, errorText);
throw new Error(`AI gateway error: ${response.status}`);
}
const data = await response.json();
const toolCall = data.choices?.[0]?.message?.tool_calls?.[0];
if (!toolCall || toolCall.function.name !== "generate_leads") {
throw new Error("Unexpected AI response format");
}
const parsed = JSON.parse(toolCall.function.arguments);
// Extra säkerhet: filtrera bort allt utanför Storstockholm
const allowed = new Set(STORSTOCKHOLM.map((s) => s.toLowerCase()));
const leads = (parsed.leads || []).filter((l: any) =>
allowed.has(String(l.district || "").toLowerCase().trim()),
);
return new Response(JSON.stringify({ leads }), {
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error generating leads:", error);
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return new Response(JSON.stringify({ error: errorMessage }), {
status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
});
@@ -0,0 +1,169 @@
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0";
import { verifyWebhook, type StripeEnv } from "../_shared/stripe.ts";
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);
/** Find existing auth user by email, or create one and send magic-link invite. */
async function ensureUser(email: string, companyName?: string): Promise<string | null> {
if (!email) return null;
try {
// Search by email (paginated). For low volumes this is fine.
const { data: list } = await supabase.auth.admin.listUsers({ page: 1, perPage: 200 });
const existing = list?.users?.find((u) => u.email?.toLowerCase() === email.toLowerCase());
if (existing) return existing.id;
// Create + invite so kunden får ett välkomstmejl med länk att sätta lösenord.
const siteUrl = Deno.env.get("SUPABASE_URL")?.replace(".supabase.co", ".lovable.app") ?? undefined;
const { data: invited, error: inviteErr } = await supabase.auth.admin.inviteUserByEmail(email, {
data: { company_name: companyName ?? null },
redirectTo: siteUrl ? `${siteUrl}/account` : undefined,
});
if (inviteErr) {
console.error("inviteUserByEmail failed:", inviteErr);
return null;
}
return invited?.user?.id ?? null;
} catch (e) {
console.error("ensureUser error:", e);
return null;
}
}
async function upsertSubscription(sub: any, env: StripeEnv) {
const item = sub.items?.data?.[0];
const priceId = item?.price?.lookup_key || item?.price?.id;
const periodStart = item?.current_period_start ?? sub.current_period_start;
const periodEnd = item?.current_period_end ?? sub.current_period_end;
const customerEmail: string =
sub.metadata?.customer_email ||
sub.customer_email ||
(typeof sub.customer === "object" ? sub.customer?.email : null) ||
"";
const companyName = sub.metadata?.company ?? null;
const kgPerWeek = sub.metadata?.kg_per_week ? Number(sub.metadata.kg_per_week) : null;
const unitAmount = item?.price?.unit_amount ?? 0;
const quantity = item?.quantity ?? 1;
const monthlyAmount = (unitAmount * quantity) / 100;
// Ensure a Lovable Cloud user exists for this email, unless we can already
// link via customer metadata.userId (future-proof).
let userId: string | null = sub.metadata?.userId ?? null;
if (!userId && customerEmail) {
userId = await ensureUser(customerEmail, companyName ?? undefined);
}
await supabase.from("subscriptions").upsert(
{
user_id: userId,
customer_email: customerEmail,
company_name: companyName,
stripe_subscription_id: sub.id,
stripe_customer_id: typeof sub.customer === "string" ? sub.customer : sub.customer?.id,
price_id: priceId,
kg_per_week: kgPerWeek,
monthly_amount: monthlyAmount || null,
status: sub.status,
collection_method: sub.collection_method ?? null,
current_period_start: periodStart ? new Date(periodStart * 1000).toISOString() : null,
current_period_end: periodEnd ? new Date(periodEnd * 1000).toISOString() : null,
cancel_at_period_end: !!sub.cancel_at_period_end,
environment: env,
updated_at: new Date().toISOString(),
},
{ onConflict: "stripe_subscription_id" },
);
}
async function markCanceled(sub: any, env: StripeEnv) {
const periodEnd = sub.items?.data?.[0]?.current_period_end ?? sub.current_period_end;
await supabase
.from("subscriptions")
.update({
status: "canceled",
cancel_at_period_end: !!sub.cancel_at_period_end,
current_period_end: periodEnd ? new Date(periodEnd * 1000).toISOString() : null,
updated_at: new Date().toISOString(),
})
.eq("stripe_subscription_id", sub.id)
.eq("environment", env);
}
async function handleCheckoutCompleted(session: any, env: StripeEnv) {
// For subscription checkouts, subscription events kommer separat — men
// vi ser till att koppla user_id om vi har e-post redan här.
const email = session.customer_details?.email || session.customer_email;
if (email) {
await ensureUser(email, session.metadata?.company);
}
// Markera ordern som betald genom att matcha stripe_session_id i items[0].
const paid = session.payment_status === "paid" || session.status === "complete";
if (paid && session.id) {
// Match by scanning recent pending orders for this session id inside items[0]
const { data: rows, error: selErr } = await supabase
.from("orders")
.select("id, items, status")
.eq("status", "pending")
.order("created_at", { ascending: false })
.limit(50);
if (selErr) console.error("orders select failed:", selErr);
const match = rows?.find((r: any) =>
Array.isArray(r.items) && r.items.some((it: any) => it?.stripe_session_id === session.id)
);
if (match) {
const { error: updErr } = await supabase
.from("orders")
.update({ status: "paid", updated_at: new Date().toISOString() })
.eq("id", match.id);
if (updErr) console.error("orders update failed:", updErr);
} else {
console.log("No pending order matched session", session.id);
}
}
}
Deno.serve(async (req) => {
if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
const rawEnv = new URL(req.url).searchParams.get("env");
if (rawEnv !== "sandbox" && rawEnv !== "live") {
return new Response(JSON.stringify({ received: true, ignored: "invalid env" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
const env: StripeEnv = rawEnv;
try {
const event = await verifyWebhook(req, env);
console.log(`[payments-webhook:${env}] ${event.type}`);
switch (event.type) {
case "customer.subscription.created":
case "customer.subscription.updated":
await upsertSubscription(event.data.object, env);
break;
case "customer.subscription.deleted":
await markCanceled(event.data.object, env);
break;
case "checkout.session.completed":
await handleCheckoutCompleted(event.data.object, env);
break;
default:
// ignore
break;
}
return new Response(JSON.stringify({ received: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (e) {
console.error("Webhook error:", e);
return new Response("Webhook error", { status: 400 });
}
});
@@ -0,0 +1,225 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { requireAdmin } from "../_shared/admin-auth.ts";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
};
const STORSTOCKHOLM = [
"Stockholm", "Solna", "Sundbyberg", "Täby", "Danderyd", "Lidingö",
"Nacka", "Sollentuna", "Järfälla", "Upplands Väsby", "Huddinge",
"Botkyrka", "Haninge", "Tyresö", "Nynäshamn", "Vallentuna", "Vaxholm",
"Österåker", "Ekerö", "Salem", "Nykvarn", "Södertälje",
];
interface CompanyResearchRequest {
company_name: string;
district: string;
industry: string;
}
serve(async (req) => {
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
const auth = await requireAdmin(req, corsHeaders);
if (!auth.ok) return auth.response;
try {
const FIRECRAWL_API_KEY = Deno.env.get("FIRECRAWL_API_KEY");
const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY");
if (!FIRECRAWL_API_KEY) throw new Error("FIRECRAWL_API_KEY is not configured");
if (!LOVABLE_API_KEY) throw new Error("LOVABLE_API_KEY is not configured");
const { company_name, district, industry }: CompanyResearchRequest = await req.json();
if (!company_name) throw new Error("company_name is required");
// Storstockholm-kontroll
const allowed = new Set(STORSTOCKHOLM.map((s) => s.toLowerCase()));
if (!allowed.has(String(district || "").toLowerCase().trim())) {
return new Response(
JSON.stringify({
error: `Vi kontaktar endast företag i Storstockholm. "${district}" ligger utanför området.`,
}),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
console.log(`Researching company: ${company_name} (${district})`);
// Steg 1: Research via Firecrawl
const searchResponse = await fetch("https://api.firecrawl.dev/v1/search", {
method: "POST",
headers: {
"Authorization": `Bearer ${FIRECRAWL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
query: `${company_name} ${district} Stockholm företag verksamhet`,
limit: 3,
scrapeOptions: { formats: ["markdown"] },
}),
});
let companyInfo = "";
if (searchResponse.ok) {
const searchData = await searchResponse.json();
companyInfo = searchData.data?.slice(0, 2)
.map((r: any) => r.markdown || r.description).join("\n\n") || "";
} else {
console.error("Firecrawl search error:", await searchResponse.text());
}
// Slumpad variation för att undvika mall-liknande utskick
const openingStyles = [
"börja med en konkret observation om företaget",
"börja med en fråga om deras vardag på kontoret",
"börja med ett kort konstaterande aldrig med hälsningsfras",
"börja med en referens till deras bransch eller uppdrag",
];
const ctaStyles = [
'"Skulle det här kunna passa ert kontor?"',
'"Vill ni att jag skickar lite mer information?"',
'"Har ni redan en lösning för fika på kontoret?"',
'"Kan jag höra av mig med ett förslag?"',
'"Är det intressant för er?"',
];
const opening = openingStyles[Math.floor(Math.random() * openingStyles.length)];
const cta = ctaStyles[Math.floor(Math.random() * ctaStyles.length)];
const mentionTiffanyAge = Math.random() < 0.35; // ibland, aldrig alltid
// Steg 2: Generera personligt mejl enligt masterprompt
const emailResponse = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${LOVABLE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "google/gemini-3-flash-preview",
messages: [
{
role: "system",
content: `Du agerar som en av världens bästa B2B-säljare, copywriter och SDR för Lennart Svenssons Konditorivaror ett bageri i Stockholm som levererar färska handgjorda kakor på veckoabonnemang till kontor i Storstockholm.
MÅL: få mottagaren att svara på mejlet eller vilja veta mer om kakabonnemanget.
MÅLGRUPP: endast företag i Storstockholm. Skriv aldrig till någon utanför området.
SPRÅK: korrekt, professionell och naturlig svenska. Skriv som en människa aldrig som en AI.
RESEARCH: använd endast information som går att verifiera i researchmaterialet nedan. Hitta ALDRIG på fakta, siffror, namn eller händelser. Om du inte har säker info håll det generellt om deras bransch utan att påstå specifika detaljer.
VARIATION (kritisk regel två företag får aldrig få samma mejl):
- Detta mejl ska ${opening}.
- Använd följande CTA (variera formuleringen lätt om det passar): ${cta}
- Variera alltid inledning, disposition, formuleringar, argument och avslut.
TON:
- Professionell, vänlig, personlig, självsäker, kort, lätt att läsa.
- Undvik säljklyschor, spamord, överdrifter, "Jag hoppas allt är bra", "Jag tänkte bara...", och AI-liknande formuleringar.
- Använd inte många utropstecken, versaler eller överdrivna löften.
PRODUKTVÄRDE (välj det som passar företaget, inte alla på en gång):
- Färska handgjorda kakor
- Leverans direkt till arbetsplatsen varje vecka
- Enkelt abonnemang, ingen administration
- Uppskattat av personal, skapar trivsel på kontoret
- Sparar tid för den som annars ordnar fikat
AVSÄNDARE: Tiffany. ${mentionTiffanyAge ? "I detta mejl får du nämna att Tiffany är 16 år och driver företaget men bara om det glider in naturligt, aldrig för att skapa medlidande." : "Nämn inte Tiffanys ålder i detta mejl."}
SIGNATUR: avsluta alltid med:
Vänliga hälsningar,
Tiffany
Lennart Svenssons Konditorivaror
Använd ALDRIG platshållare som "(ditt namn)".
LÄNGD: max ca 130 ord i brödtexten.
CTA: exakt en fråga i slutet.`,
},
{
role: "user",
content: `Skriv ett unikt kall-mejl till:
Företag: ${company_name}
Bransch: ${industry}
Kommun (Storstockholm): ${district}
Researchmaterial (endast dessa fakta är verifierade hitta inte på mer):
${companyInfo || "Ingen specifik information hittad. Håll mejlet generellt kring branschen utan att påstå specifika detaljer om företaget."}`,
},
],
tools: [
{
type: "function",
function: {
name: "generate_email",
description: "Genererar ett unikt personligt kall-mejl enligt masterprompten",
parameters: {
type: "object",
properties: {
subject: {
type: "string",
description: "Kort, mänsklig ämnesrad utan spamord eller utropstecken",
},
body: {
type: "string",
description: "Mejlets brödtext, inkl. signatur från Tiffany",
},
personalization_points: {
type: "array",
items: { type: "string" },
description: "Verifierbara punkter från researchen som mejlet bygger på",
},
},
required: ["subject", "body", "personalization_points"],
additionalProperties: false,
},
},
},
],
tool_choice: { type: "function", function: { name: "generate_email" } },
}),
});
if (!emailResponse.ok) {
if (emailResponse.status === 429) {
return new Response(JSON.stringify({ error: "Rate limit överskriden, försök igen senare." }), {
status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
if (emailResponse.status === 402) {
return new Response(JSON.stringify({ error: "Krediter slut, vänligen fyll på." }), {
status: 402, headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const errorText = await emailResponse.text();
console.error("AI gateway error:", emailResponse.status, errorText);
throw new Error(`AI gateway error: ${emailResponse.status}`);
}
const emailData = await emailResponse.json();
const toolCall = emailData.choices?.[0]?.message?.tool_calls?.[0];
if (!toolCall || toolCall.function.name !== "generate_email") {
throw new Error("Unexpected AI response format");
}
const emailContent = JSON.parse(toolCall.function.arguments);
return new Response(JSON.stringify({
success: true,
company_name,
research_summary: companyInfo.substring(0, 500),
email: emailContent,
}), { headers: { ...corsHeaders, "Content-Type": "application/json" } });
} catch (error) {
console.error("Error researching company:", error);
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return new Response(JSON.stringify({ error: errorMessage }), {
status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
});
@@ -0,0 +1,157 @@
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
};
interface ContactRequest {
name: string;
email: string;
company: string;
message: string;
}
// HTML-escape any user-supplied string before embedding in email HTML
const esc = (s: string): string =>
s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
// In-memory rate limit per client IP (best-effort; resets on cold start).
// Key: ip, Value: array of request timestamps (ms).
const rateBuckets = new Map<string, number[]>();
const RATE_WINDOW_MS = 60 * 60 * 1000; // 1 hour
const RATE_MAX = 5; // max 5 messages/hour per IP
function getClientIp(req: Request): string {
const fwd = req.headers.get("x-forwarded-for");
if (fwd) return fwd.split(",")[0].trim();
return req.headers.get("cf-connecting-ip") || req.headers.get("x-real-ip") || "unknown";
}
function checkRateLimit(ip: string): boolean {
const now = Date.now();
const bucket = (rateBuckets.get(ip) || []).filter((t) => now - t < RATE_WINDOW_MS);
if (bucket.length >= RATE_MAX) {
rateBuckets.set(ip, bucket);
return false;
}
bucket.push(now);
rateBuckets.set(ip, bucket);
return true;
}
const handler = async (req: Request): Promise<Response> => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
if (req.method !== "POST") {
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { "Content-Type": "application/json", ...corsHeaders },
});
}
const ip = getClientIp(req);
if (!checkRateLimit(ip)) {
return new Response(
JSON.stringify({ error: "För många förfrågningar. Försök igen senare." }),
{
status: 429,
headers: { "Content-Type": "application/json", ...corsHeaders },
},
);
}
try {
const { name, email, company, message }: ContactRequest = await req.json();
// Validate required fields
if (!name || !email || !message) {
throw new Error("Missing required fields");
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
throw new Error("Invalid email format");
}
// Validate field lengths
if (
name.length > 100 ||
email.length > 255 ||
(company && company.length > 100) ||
message.length > 2000
) {
throw new Error("Field length exceeded");
}
const RESEND_API_KEY = Deno.env.get("RESEND_API_KEY");
const recipientEmail = Deno.env.get("CONTACT_EMAIL") || "kontakt@example.com";
if (!RESEND_API_KEY) {
throw new Error("RESEND_API_KEY not configured");
}
// Escape all user-supplied values before embedding in HTML
const safeName = esc(name);
const safeEmail = esc(email);
const safeCompany = company ? esc(company) : "";
const safeMessage = esc(message).replace(/\n/g, "<br />");
const res = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${RESEND_API_KEY}`,
},
body: JSON.stringify({
from: "Lennart Svensson Konditorivaror <noreply@lennartsvensson.se>",
to: [recipientEmail],
reply_to: email,
subject: `Kontaktförfrågan från ${safeName}`,
html: `
<h2>Ny kontaktförfrågan</h2>
<p><strong>Namn:</strong> ${safeName}</p>
<p><strong>E-post:</strong> ${safeEmail}</p>
${safeCompany ? `<p><strong>Företag:</strong> ${safeCompany}</p>` : ""}
<hr />
<p><strong>Meddelande:</strong></p>
<p>${safeMessage}</p>
`,
}),
});
if (!res.ok) {
const error = await res.text();
console.error("Resend API error:", error);
throw new Error("Failed to send email");
}
const data = await res.json();
console.log("Contact email sent successfully:", data);
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json", ...corsHeaders },
});
} catch (error: any) {
console.error("Error in send-contact-email function:", error);
return new Response(
JSON.stringify({ error: "Kunde inte skicka meddelandet." }),
{
status: 500,
headers: { "Content-Type": "application/json", ...corsHeaders },
},
);
}
};
serve(handler);
@@ -0,0 +1,134 @@
import { z } from "npm:zod@3.23.8";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
const GATEWAY_URL = "https://connector-gateway.lovable.dev/brevo";
const BodySchema = z.object({
order_number: z.string().min(1).max(100),
customer_email: z.string().email(),
customer_name: z.string().min(1).max(200),
kg_per_week: z.number().min(0),
employees: z.number().int().min(0),
monthly_amount_sek: z.number().min(0),
method: z.enum(["card", "invoice"]),
address: z.string().max(200).optional().default(""),
postal_code: z.string().max(20).optional().default(""),
phone: z.string().max(30).optional().default(""),
comments: z.string().max(1000).optional().default(""),
});
function fmtKr(n: number) {
return new Intl.NumberFormat("sv-SE", {
style: "currency",
currency: "SEK",
maximumFractionDigits: 0,
}).format(n);
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
try {
const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY");
const BREVO_API_KEY = Deno.env.get("BREVO_API_KEY");
if (!LOVABLE_API_KEY || !BREVO_API_KEY) {
throw new Error("Email service not configured");
}
const raw = await req.json();
const parsed = BodySchema.safeParse(raw);
if (!parsed.success) {
return new Response(
JSON.stringify({ error: "Ogiltiga fält", details: parsed.error.flatten().fieldErrors }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
const o = parsed.data;
const senderEmail = Deno.env.get("BREVO_SENDER_EMAIL") ?? "noreply@konditorivaror.se";
const senderName = Deno.env.get("BREVO_SENDER_NAME") ?? "Lennart Svensson Konditorivaror";
const adminEmail = Deno.env.get("ORDER_NOTIFICATION_EMAIL") ?? senderEmail;
const methodLabel = o.method === "card" ? "Kort (månadsvis)" : "Faktura (månadsvis)";
const customerHtml = `
<div style="font-family: Georgia, serif; color:#3d2817; max-width:600px; margin:0 auto; padding:24px;">
<h1 style="color:#7a4a1f;">Tack för din beställning!</h1>
<p>Hej ${o.customer_name},</p>
<p>Vi har mottagit din beställning av <strong>Kakabonnemang</strong>. Goda saker är på väg leverans varje tisdag inom Storstockholm.</p>
<h2 style="color:#7a4a1f; margin-top:24px;">Orderdetaljer</h2>
<table style="width:100%; border-collapse:collapse;">
<tr><td style="padding:6px 0;"><strong>Ordernummer:</strong></td><td>${o.order_number}</td></tr>
<tr><td style="padding:6px 0;"><strong>Mängd:</strong></td><td>${o.kg_per_week} kg/vecka</td></tr>
<tr><td style="padding:6px 0;"><strong>Antal anställda:</strong></td><td>${o.employees}</td></tr>
<tr><td style="padding:6px 0;"><strong>Månadskostnad (exkl. moms):</strong></td><td>${fmtKr(o.monthly_amount_sek)}</td></tr>
<tr><td style="padding:6px 0;"><strong>Betalning:</strong></td><td>${methodLabel}</td></tr>
</table>
<p style="margin-top:24px;">Har du frågor? Svara bara på detta mejl.</p>
<p style="color:#7a4a1f; font-style:italic;">Äkta svenskt konditorhantverk</p>
</div>`;
const adminHtml = `
<div style="font-family: Arial, sans-serif; color:#111; max-width:600px;">
<h2>Ny order: ${o.order_number}</h2>
<p><strong>Företag:</strong> ${o.customer_name}<br/>
<strong>E-post:</strong> ${o.customer_email}<br/>
<strong>Telefon:</strong> ${o.phone || "-"}</p>
<p><strong>Mängd:</strong> ${o.kg_per_week} kg/vecka<br/>
<strong>Anställda:</strong> ${o.employees}<br/>
<strong>Månadskostnad (exkl moms):</strong> ${fmtKr(o.monthly_amount_sek)}<br/>
<strong>Betalning:</strong> ${methodLabel}</p>
<p><strong>Leverans:</strong> ${o.address || "-"}, ${o.postal_code || "-"}</p>
${o.comments ? `<p><strong>Kommentar:</strong> ${o.comments}</p>` : ""}
</div>`;
async function send(to: string, subject: string, html: string) {
const res = await fetch(`${GATEWAY_URL}/v3/smtp/email`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${LOVABLE_API_KEY}`,
"X-Connection-Api-Key": BREVO_API_KEY,
},
body: JSON.stringify({
sender: { name: senderName, email: senderEmail },
to: [{ email: to }],
subject,
htmlContent: html,
}),
});
if (!res.ok) {
const body = await res.text();
console.error(`Brevo send failed [${res.status}]:`, body);
throw new Error(`Brevo ${res.status}: ${body}`);
}
return res.json();
}
const results = await Promise.allSettled([
send(o.customer_email, `Orderbekräftelse ${o.order_number} Lennart Svensson Konditorivaror`, customerHtml),
send(adminEmail, `Ny order: ${o.order_number} (${o.customer_name})`, adminHtml),
]);
const errors = results
.map((r, i) => (r.status === "rejected" ? { i, reason: String(r.reason) } : null))
.filter(Boolean);
return new Response(
JSON.stringify({ success: errors.length === 0, errors }),
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
} catch (err: any) {
console.error("send-order-email error:", err);
return new Response(
JSON.stringify({ error: err?.message ?? "Kunde inte skicka mejl" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
});
@@ -0,0 +1,87 @@
import { z } from "npm:zod@3.23.8";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
const GATEWAY_URL = "https://connector-gateway.lovable.dev/brevo";
const BodySchema = z.object({
to_email: z.string().email(),
to_name: z.string().min(1).max(200),
subject: z.string().min(1).max(300),
body: z.string().min(1).max(10000),
});
Deno.serve(async (req) => {
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
try {
const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY");
const BREVO_API_KEY = Deno.env.get("BREVO_API_KEY");
if (!LOVABLE_API_KEY || !BREVO_API_KEY) {
throw new Error("Email service not configured");
}
const parsed = BodySchema.safeParse(await req.json());
if (!parsed.success) {
return new Response(
JSON.stringify({ error: "Ogiltiga fält", details: parsed.error.flatten().fieldErrors }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
const { to_email, to_name, subject, body } = parsed.data;
const senderEmail = Deno.env.get("BREVO_SENDER_EMAIL") ?? "konditorivaror@gmail.com";
const senderName = "Tiffany Lennart Svenssons Konditorivaror";
// Konvertera radbrytningar till <br> för HTML
const htmlBody = `
<div style="font-family: Georgia, serif; font-size: 15px; color:#3d2817; line-height:1.6; max-width:600px;">
${body
.split(/\n{2,}/)
.map((p) => `<p>${p.replace(/\n/g, "<br/>")}</p>`)
.join("")}
</div>`;
const res = await fetch(`${GATEWAY_URL}/v3/smtp/email`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${LOVABLE_API_KEY}`,
"X-Connection-Api-Key": BREVO_API_KEY,
},
body: JSON.stringify({
sender: { name: senderName, email: senderEmail },
to: [{ email: to_email, name: to_name }],
replyTo: { email: senderEmail, name: senderName },
subject,
htmlContent: htmlBody,
textContent: body,
}),
});
if (!res.ok) {
const errBody = await res.text();
console.error(`Brevo send failed [${res.status}]:`, errBody);
return new Response(
JSON.stringify({ error: "Brevo avvisade mejlet", status: res.status, details: errBody }),
{ status: res.status, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
const data = await res.json();
return new Response(JSON.stringify({ success: true, messageId: data.messageId }), {
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
} catch (err: any) {
console.error("send-outreach-email error:", err);
return new Response(
JSON.stringify({ error: err?.message ?? "Kunde inte skicka mejl" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
});
@@ -0,0 +1,101 @@
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0";
import { corsHeaders } from "npm:@supabase/supabase-js@2/cors";
import { z } from "https://esm.sh/zod@3.23.8";
const BodySchema = z.object({
företag: z.string().trim().min(1).max(100),
epost: z.string().trim().email().max(255),
telefon: z.string().trim().max(30).optional().default(""),
adress: z.string().trim().max(200).optional().default(""),
postnummer: z
.string()
.trim()
.regex(/^\s*\d{3}\s*\d{2}\s*$/, "Ogiltigt postnummer")
.refine((v) => {
const n = parseInt(v.replace(/\s/g, ""), 10);
return n >= 10000 && n <= 19999;
}, "Vi levererar endast inom Storstockholm (100 00 199 99)"),
stad: z.string().trim().max(100).optional().default(""),
kommentarer: z.string().trim().max(1000).optional().default(""),
employees: z.number().int().min(1).max(1000),
kg_per_vecka: z.number().min(1).max(500),
pris_per_vecka: z.number().min(0).max(1000000),
});
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
try {
const parsed = BodySchema.safeParse(await req.json());
if (!parsed.success) {
return new Response(
JSON.stringify({ error: parsed.error.flatten().fieldErrors }),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
const d = parsed.data;
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
const orderNumber = `ABO-${Date.now().toString(36).toUpperCase()}`;
const { data, error } = await supabase
.from("orders")
.insert({
order_number: orderNumber,
customer_email: d.epost,
customer_name: d.företag,
items: [
{
typ: "Kakabonnemang",
antal_anställda: d.employees,
kg_per_vecka: d.kg_per_vecka,
pris_per_vecka_exkl_moms: d.pris_per_vecka,
valuta: "SEK",
leverans: {
adress: d.adress,
postnummer: d.postnummer,
stad: d.stad,
},
telefon: d.telefon,
kommentarer: d.kommentarer,
},
],
total_amount: d.pris_per_vecka,
currency: "SEK",
status: "pending",
})
.select("id, order_number")
.single();
if (error) {
console.error("Order insert failed:", error);
return new Response(
JSON.stringify({ error: "Kunde inte spara beställning", details: error.message }),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
return new Response(JSON.stringify({ ok: true, order: data }), {
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
} catch (e) {
console.error("submit-order error:", e);
return new Response(JSON.stringify({ error: (e as Error).message }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
});
@@ -0,0 +1,132 @@
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0";
import { z } from "npm:zod@3.23.8";
import { type StripeEnv, createStripeClient } from "../_shared/stripe.ts";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, x-supabase-client-platform, x-supabase-api-version, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
const PRICE_PER_KG_ORE = 32500; // 325 kr exkl. moms
const WEEKS_PER_MONTH = 4;
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);
const BodySchema = z.object({
new_kg_per_week: z.number().min(1).max(200),
environment: z.enum(["sandbox", "live"]),
});
Deno.serve(async (req) => {
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
if (req.method !== "POST") {
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
try {
const token = req.headers.get("Authorization")?.replace("Bearer ", "") ?? "";
const { data: userData, error: authErr } = await supabase.auth.getUser(token);
if (authErr || !userData?.user) {
return new Response(JSON.stringify({ error: "Ej inloggad" }), {
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const parsed = BodySchema.safeParse(await req.json());
if (!parsed.success) {
return new Response(JSON.stringify({ error: "Ogiltiga fält" }), {
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const env: StripeEnv = parsed.data.environment;
const newKg = parsed.data.new_kg_per_week;
const { data: sub } = await supabase
.from("subscriptions")
.select("id, stripe_subscription_id, kg_per_week")
.eq("user_id", userData.user.id)
.eq("environment", env)
.in("status", ["active", "trialing", "past_due"])
.order("created_at", { ascending: false })
.limit(1)
.maybeSingle();
if (!sub?.stripe_subscription_id) {
return new Response(
JSON.stringify({ error: "Inget aktivt abonnemang hittades." }),
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
const stripe = createStripeClient(env);
const stripeSub = await stripe.subscriptions.retrieve(sub.stripe_subscription_id);
const item = stripeSub.items?.data?.[0];
if (!item) {
return new Response(JSON.stringify({ error: "Kunde inte hitta prisrad" }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const productId =
typeof item.price.product === "string" ? item.price.product : item.price.product.id;
const newUnitAmount = Math.round(newKg * WEEKS_PER_MONTH * PRICE_PER_KG_ORE);
await stripe.subscriptions.update(sub.stripe_subscription_id, {
items: [
{
id: item.id,
price_data: {
currency: "sek",
product: productId,
recurring: { interval: "month" },
unit_amount: newUnitAmount,
} as any,
quantity: 1,
},
],
proration_behavior: "create_prorations",
metadata: {
...(stripeSub.metadata ?? {}),
kg_per_week: String(newKg),
},
description: `Kakabonnemang ${newKg} kg/vecka`,
} as any);
// Uppdatera vår tabell direkt (webhook uppdaterar också, detta ger snabb UI-återkoppling)
await supabase
.from("subscriptions")
.update({
kg_per_week: newKg,
monthly_amount: newUnitAmount / 100,
updated_at: new Date().toISOString(),
})
.eq("id", sub.id);
return new Response(
JSON.stringify({
success: true,
new_kg_per_week: newKg,
new_monthly_amount_exkl_moms: newUnitAmount / 100,
}),
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
} catch (e: any) {
console.error("update-subscription-quantity error:", e);
return new Response(JSON.stringify({ error: e?.message ?? "Kunde inte uppdatera abonnemang" }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
});