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:
@@ -0,0 +1,31 @@
|
||||
project_id = "teeojvlqulhghditoakc"
|
||||
|
||||
[functions.generate-leads]
|
||||
verify_jwt = false
|
||||
|
||||
[functions.research-company]
|
||||
verify_jwt = false
|
||||
|
||||
[functions.send-contact-email]
|
||||
verify_jwt = false
|
||||
|
||||
[functions.submit-order]
|
||||
verify_jwt = false
|
||||
|
||||
[functions.create-account]
|
||||
verify_jwt = false
|
||||
|
||||
[functions.create-checkout]
|
||||
verify_jwt = false
|
||||
|
||||
[functions.payments-webhook]
|
||||
verify_jwt = false
|
||||
|
||||
[functions.create-portal-session]
|
||||
verify_jwt = false
|
||||
|
||||
[functions.update-subscription-quantity]
|
||||
verify_jwt = false
|
||||
|
||||
[functions.felsokning-ai]
|
||||
verify_jwt = true
|
||||
@@ -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 0–1: 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 0–1 (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 0–1; 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
// 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" },
|
||||
});
|
||||
}
|
||||
});
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
-- Create profiles table for user data
|
||||
CREATE TABLE public.profiles (
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
user_id UUID NOT NULL UNIQUE REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
display_name TEXT,
|
||||
phone TEXT,
|
||||
address TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Enable Row Level Security
|
||||
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Users can view their own profile
|
||||
CREATE POLICY "Users can view their own profile"
|
||||
ON public.profiles
|
||||
FOR SELECT
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
-- Users can insert their own profile
|
||||
CREATE POLICY "Users can insert their own profile"
|
||||
ON public.profiles
|
||||
FOR INSERT
|
||||
WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
-- Users can update their own profile
|
||||
CREATE POLICY "Users can update their own profile"
|
||||
ON public.profiles
|
||||
FOR UPDATE
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
-- Create function to update timestamps
|
||||
CREATE OR REPLACE FUNCTION public.update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SET search_path = public;
|
||||
|
||||
-- Create trigger for automatic timestamp updates
|
||||
CREATE TRIGGER update_profiles_updated_at
|
||||
BEFORE UPDATE ON public.profiles
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.update_updated_at_column();
|
||||
|
||||
-- Create function to automatically create profile on signup
|
||||
CREATE OR REPLACE FUNCTION public.handle_new_user()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
INSERT INTO public.profiles (user_id)
|
||||
VALUES (NEW.id);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public;
|
||||
|
||||
-- Create trigger to auto-create profile on user signup
|
||||
CREATE TRIGGER on_auth_user_created
|
||||
AFTER INSERT ON auth.users
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.handle_new_user();
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Add company_name column to profiles table
|
||||
ALTER TABLE public.profiles
|
||||
ADD COLUMN company_name text;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Add separate address fields to profiles table
|
||||
ALTER TABLE public.profiles
|
||||
ADD COLUMN street_address text,
|
||||
ADD COLUMN postal_code text,
|
||||
ADD COLUMN city text;
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
-- Create orders table to store e-commerce orders
|
||||
CREATE TABLE public.orders (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
order_number TEXT NOT NULL,
|
||||
customer_email TEXT NOT NULL,
|
||||
customer_name TEXT,
|
||||
items JSONB NOT NULL DEFAULT '[]',
|
||||
total_amount DECIMAL(10,2) NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'SEK',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
shopify_checkout_id TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE public.orders ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Create policy for authenticated users (admins) to view all orders
|
||||
CREATE POLICY "Authenticated users can view all orders"
|
||||
ON public.orders
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (true);
|
||||
|
||||
-- Create policy for inserting orders (from edge function)
|
||||
CREATE POLICY "Service role can insert orders"
|
||||
ON public.orders
|
||||
FOR INSERT
|
||||
WITH CHECK (true);
|
||||
|
||||
-- Create trigger for updated_at
|
||||
CREATE TRIGGER update_orders_updated_at
|
||||
BEFORE UPDATE ON public.orders
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.update_updated_at_column();
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
-- Create customers table
|
||||
CREATE TABLE public.customers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
name TEXT,
|
||||
phone TEXT,
|
||||
company TEXT,
|
||||
total_orders INTEGER DEFAULT 0,
|
||||
total_spent DECIMAL(10,2) DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Create payments table
|
||||
CREATE TABLE public.payments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
order_id UUID REFERENCES public.orders(id) ON DELETE CASCADE,
|
||||
amount DECIMAL(10,2) NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'SEK',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
payment_method TEXT,
|
||||
shopify_payment_id TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Add customer reference to orders
|
||||
ALTER TABLE public.orders ADD COLUMN customer_id UUID REFERENCES public.customers(id);
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE public.customers ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.payments ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS policies for customers (only authenticated admins)
|
||||
CREATE POLICY "Authenticated users can view customers"
|
||||
ON public.customers FOR SELECT TO authenticated USING (true);
|
||||
|
||||
CREATE POLICY "Authenticated users can manage customers"
|
||||
ON public.customers FOR ALL TO authenticated USING (true) WITH CHECK (true);
|
||||
|
||||
-- RLS policies for payments
|
||||
CREATE POLICY "Authenticated users can view payments"
|
||||
ON public.payments FOR SELECT TO authenticated USING (true);
|
||||
|
||||
CREATE POLICY "Service can insert payments"
|
||||
ON public.payments FOR INSERT WITH CHECK (true);
|
||||
|
||||
-- Triggers for updated_at
|
||||
CREATE TRIGGER update_customers_updated_at
|
||||
BEFORE UPDATE ON public.customers
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
|
||||
-- Role enum
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE public.app_role AS ENUM ('admin', 'user');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- user_roles table
|
||||
CREATE TABLE IF NOT EXISTS public.user_roles (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
role public.app_role NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (user_id, role)
|
||||
);
|
||||
|
||||
GRANT SELECT ON public.user_roles TO authenticated;
|
||||
GRANT ALL ON public.user_roles TO service_role;
|
||||
|
||||
ALTER TABLE public.user_roles ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "Users can view own roles" ON public.user_roles;
|
||||
CREATE POLICY "Users can view own roles"
|
||||
ON public.user_roles FOR SELECT
|
||||
TO authenticated
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
-- Security definer role check
|
||||
CREATE OR REPLACE FUNCTION public.has_role(_user_id uuid, _role public.app_role)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM public.user_roles
|
||||
WHERE user_id = _user_id AND role = _role
|
||||
)
|
||||
$$;
|
||||
|
||||
-- Backfill admin for existing owner
|
||||
INSERT INTO public.user_roles (user_id, role)
|
||||
SELECT id, 'admin'::public.app_role
|
||||
FROM auth.users
|
||||
WHERE lower(email) = 'tiffanysvson@gmail.com'
|
||||
ON CONFLICT (user_id, role) DO NOTHING;
|
||||
|
||||
-- Auto-grant admin on future signup with owner email
|
||||
CREATE OR REPLACE FUNCTION public.handle_new_user()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO public.profiles (user_id) VALUES (NEW.id)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
IF lower(NEW.email) = 'tiffanysvson@gmail.com' THEN
|
||||
INSERT INTO public.user_roles (user_id, role)
|
||||
VALUES (NEW.id, 'admin')
|
||||
ON CONFLICT (user_id, role) DO NOTHING;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,7 @@
|
||||
|
||||
REVOKE ALL ON FUNCTION public.has_role(uuid, public.app_role) FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.has_role(uuid, public.app_role) TO authenticated, service_role;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.handle_new_user() FROM PUBLIC, anon, authenticated;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.update_updated_at_column() FROM PUBLIC, anon, authenticated;
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
|
||||
-- Orders: admin-only reads
|
||||
DROP POLICY IF EXISTS "Authenticated users can view all orders" ON public.orders;
|
||||
CREATE POLICY "Admins can view orders"
|
||||
ON public.orders FOR SELECT
|
||||
TO authenticated
|
||||
USING (public.has_role(auth.uid(), 'admin'));
|
||||
|
||||
-- Customers: admin-only
|
||||
DROP POLICY IF EXISTS "Authenticated users can manage customers" ON public.customers;
|
||||
DROP POLICY IF EXISTS "Authenticated users can view customers" ON public.customers;
|
||||
CREATE POLICY "Admins can view customers"
|
||||
ON public.customers FOR SELECT
|
||||
TO authenticated
|
||||
USING (public.has_role(auth.uid(), 'admin'));
|
||||
CREATE POLICY "Admins can manage customers"
|
||||
ON public.customers FOR ALL
|
||||
TO authenticated
|
||||
USING (public.has_role(auth.uid(), 'admin'))
|
||||
WITH CHECK (public.has_role(auth.uid(), 'admin'));
|
||||
|
||||
-- Payments: admin-only reads
|
||||
DROP POLICY IF EXISTS "Authenticated users can view payments" ON public.payments;
|
||||
CREATE POLICY "Admins can view payments"
|
||||
ON public.payments FOR SELECT
|
||||
TO authenticated
|
||||
USING (public.has_role(auth.uid(), 'admin'));
|
||||
@@ -0,0 +1,3 @@
|
||||
|
||||
DROP POLICY IF EXISTS "Service role can insert orders" ON public.orders;
|
||||
DROP POLICY IF EXISTS "Service can insert payments" ON public.payments;
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
-- Restrict direct execution of SECURITY DEFINER function has_role.
|
||||
-- Switching to SECURITY INVOKER: when called as auth.uid() the user_roles
|
||||
-- RLS policy already lets a user see (only) their own role rows, so
|
||||
-- has_role(auth.uid(), 'admin') still returns correctly.
|
||||
-- We also revoke EXECUTE from anon/public so the function cannot be
|
||||
-- invoked directly from the Data API.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.has_role(_user_id uuid, _role app_role)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY INVOKER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.user_roles
|
||||
WHERE user_id = _user_id
|
||||
AND role = _role
|
||||
)
|
||||
$$;
|
||||
|
||||
REVOKE EXECUTE ON FUNCTION public.has_role(uuid, app_role) FROM PUBLIC;
|
||||
REVOKE EXECUTE ON FUNCTION public.has_role(uuid, app_role) FROM anon;
|
||||
-- authenticated retains EXECUTE so RLS policies referencing has_role
|
||||
-- continue to work; the function itself is now SECURITY INVOKER so it
|
||||
-- cannot be used to bypass RLS.
|
||||
GRANT EXECUTE ON FUNCTION public.has_role(uuid, app_role) TO authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.has_role(uuid, app_role) TO service_role;
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
|
||||
CREATE TABLE public.subscriptions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
customer_email TEXT NOT NULL,
|
||||
company_name TEXT,
|
||||
stripe_subscription_id TEXT NOT NULL UNIQUE,
|
||||
stripe_customer_id TEXT NOT NULL,
|
||||
price_id TEXT,
|
||||
kg_per_week NUMERIC,
|
||||
monthly_amount NUMERIC,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
collection_method TEXT,
|
||||
current_period_start TIMESTAMPTZ,
|
||||
current_period_end TIMESTAMPTZ,
|
||||
cancel_at_period_end BOOLEAN NOT NULL DEFAULT false,
|
||||
environment TEXT NOT NULL DEFAULT 'sandbox',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_subscriptions_user_id ON public.subscriptions(user_id);
|
||||
CREATE INDEX idx_subscriptions_customer_email ON public.subscriptions(customer_email);
|
||||
CREATE INDEX idx_subscriptions_stripe_id ON public.subscriptions(stripe_subscription_id);
|
||||
|
||||
GRANT SELECT ON public.subscriptions TO authenticated;
|
||||
GRANT ALL ON public.subscriptions TO service_role;
|
||||
|
||||
ALTER TABLE public.subscriptions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users can view own subscriptions"
|
||||
ON public.subscriptions FOR SELECT
|
||||
TO authenticated
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Admins can view all subscriptions"
|
||||
ON public.subscriptions FOR SELECT
|
||||
TO authenticated
|
||||
USING (public.has_role(auth.uid(), 'admin'::app_role));
|
||||
|
||||
CREATE TRIGGER update_subscriptions_updated_at
|
||||
BEFORE UPDATE ON public.subscriptions
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.has_active_kaka_subscription(user_uuid UUID)
|
||||
RETURNS BOOLEAN
|
||||
LANGUAGE SQL
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM public.subscriptions
|
||||
WHERE user_id = user_uuid
|
||||
AND (
|
||||
(status IN ('active','trialing','past_due') AND (current_period_end IS NULL OR current_period_end > now()))
|
||||
OR (status = 'canceled' AND current_period_end > now())
|
||||
)
|
||||
);
|
||||
$$;
|
||||
@@ -0,0 +1,3 @@
|
||||
|
||||
REVOKE EXECUTE ON FUNCTION public.has_active_kaka_subscription(UUID) FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.has_active_kaka_subscription(UUID) TO service_role;
|
||||
@@ -0,0 +1,72 @@
|
||||
-- Guidad Felsökning: ärenden och append-only händelselogg.
|
||||
-- Loggen är enda sanningskällan; alla vyer är projektioner.
|
||||
-- Append-only garanteras även i databasen: inga update/delete-policyer.
|
||||
|
||||
create table public.felsokning_arenden (
|
||||
id text primary key,
|
||||
nummer integer not null,
|
||||
skapad timestamptz not null,
|
||||
skapad_av uuid not null default auth.uid(),
|
||||
delningskod text unique,
|
||||
insatt timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table public.felsokning_handelser (
|
||||
id text primary key,
|
||||
arende_id text not null references public.felsokning_arenden(id),
|
||||
tidpunkt timestamptz not null,
|
||||
anvandare text not null,
|
||||
handelse jsonb not null,
|
||||
insatt timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index felsokning_handelser_arende_idx
|
||||
on public.felsokning_handelser (arende_id, tidpunkt);
|
||||
|
||||
alter table public.felsokning_arenden enable row level security;
|
||||
alter table public.felsokning_handelser enable row level security;
|
||||
|
||||
-- Beta: alla inloggade användare delar arbetsyta (samarbete i ärenden).
|
||||
-- Multi-tenant-uppdelning per organisation införs i nästa fas.
|
||||
create policy "Inloggade läser ärenden"
|
||||
on public.felsokning_arenden for select to authenticated using (true);
|
||||
create policy "Inloggade skapar ärenden"
|
||||
on public.felsokning_arenden for insert to authenticated with check (true);
|
||||
create policy "Inloggade läser händelser"
|
||||
on public.felsokning_handelser for select to authenticated using (true);
|
||||
create policy "Inloggade lägger till händelser"
|
||||
on public.felsokning_handelser for insert to authenticated with check (true);
|
||||
|
||||
-- Inga update/delete-policyer, och rättigheterna återkallas dessutom explicit:
|
||||
-- historiken kan aldrig ändras eller raderas från klienten.
|
||||
revoke update, delete on public.felsokning_arenden from authenticated, anon;
|
||||
revoke update, delete on public.felsokning_handelser from authenticated, anon;
|
||||
|
||||
-- Live Share: anonym läsning sker ENDAST via delningskod genom en
|
||||
-- security definer-funktion — tabellerna exponeras aldrig direkt för anon.
|
||||
-- Interna poster (kategoribyten, hypoteser) filtreras bort ur den delade
|
||||
-- vyn: hypoteser är arbetsmaterial och får aldrig läsas som konstaterade fel.
|
||||
create or replace function public.hamta_delat_arende(kod text)
|
||||
returns jsonb
|
||||
language sql
|
||||
security definer
|
||||
set search_path = public
|
||||
stable
|
||||
as $$
|
||||
select jsonb_build_object(
|
||||
'arende', to_jsonb(a) - 'skapad_av',
|
||||
'handelser', coalesce((
|
||||
select jsonb_agg(to_jsonb(h) order by h.tidpunkt, h.id)
|
||||
from felsokning_handelser h
|
||||
where h.arende_id = a.id
|
||||
and h.handelse->>'typ' not in ('kategori_byte', 'hypotes', 'ai_svar', 'ansvarig_satt', 'arbetsorder_skannad')
|
||||
), '[]'::jsonb)
|
||||
)
|
||||
from felsokning_arenden a
|
||||
where a.delningskod = kod and a.delningskod is not null;
|
||||
$$;
|
||||
|
||||
grant execute on function public.hamta_delat_arende(text) to anon, authenticated;
|
||||
|
||||
-- Realtime-uppdateringar för framtida prenumerationer i klienten.
|
||||
alter publication supabase_realtime add table public.felsokning_handelser;
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Delningsgränsen görs om från nekalista till tillåtelselista.
|
||||
--
|
||||
-- Tidigare filtrerade hamta_delat_arende bort en uppräknad mängd interna
|
||||
-- händelsetyper. Varje ny händelsetyp blev därmed automatiskt synlig för
|
||||
-- kunden tills någon kom ihåg att neka den — fel håll att fela åt på en
|
||||
-- integritetsgräns. Nu räknas i stället upp vad som FÅR delas; allt annat
|
||||
-- är internt tills det aktivt släpps fram.
|
||||
--
|
||||
-- Listan speglar DELBART_KUND i services/plattform/server.mjs, som låses
|
||||
-- av testet src/felsokning/__tests__/delning.test.ts.
|
||||
|
||||
create or replace function public.hamta_delat_arende(kod text)
|
||||
returns jsonb
|
||||
language sql
|
||||
security definer
|
||||
set search_path = public
|
||||
stable
|
||||
as $$
|
||||
select jsonb_build_object(
|
||||
'arende', to_jsonb(a) - 'skapad_av',
|
||||
'handelser', coalesce((
|
||||
select jsonb_agg(to_jsonb(h) order by h.tidpunkt, h.id)
|
||||
from felsokning_handelser h
|
||||
where h.arende_id = a.id
|
||||
and h.handelse->>'typ' in (
|
||||
'objekt_identifierat', 'arendetyp_satt', 'felbeskrivning', 'fraga_besvarad',
|
||||
'kontroll_utford', 'observation', 'matvarde', 'foto', 'video', 'kommentar',
|
||||
'inaktivitet_forklarad', 'overlamning', 'historik_kontrollerad', 'matarstallning',
|
||||
'reproducering', 'felorsak', 'atgardsforslag', 'kundbeslut', 'atgard_utford',
|
||||
'kvalitetskontroll', 'export_skapad', 'arende_avslutat'
|
||||
)
|
||||
), '[]'::jsonb)
|
||||
)
|
||||
from felsokning_arenden a
|
||||
where a.delningskod = kod and a.delningskod is not null;
|
||||
$$;
|
||||
|
||||
grant execute on function public.hamta_delat_arende(text) to anon, authenticated;
|
||||
Reference in New Issue
Block a user