since3-portal/modules/shared/actions/product-checks.ts
2026-09-29 15:20:50 +02:00

108 lines
4.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use server";
import * as z from "zod";
import { revalidatePath } from "next/cache";
import { createClient } from "@/lib/supabase/server";
import { createAdminClient } from "@/lib/supabase/admin";
import { verifySession } from "@/lib/supabase/dal";
import { getClientId } from "@/modules/shared/queries/profile";
import { checkUrl, toCheckableUrl, type CheckResult } from "@/modules/shared/lib/reachability";
import { AUTO_CHECK_STALE_AFTER_MS } from "@/modules/shared/schemas/info";
const MANUAL_MIN_INTERVAL_MS = 30 * 1000;
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
const IdSchema = z.string().uuid();
export type CheckActionResult = { error: string } | { result: CheckResult };
type CheckableProduct = { id: string; url: URL; lastCheckedAt: string | null };
// Lesen über RLS (Admin: alle, Client: nur eigene) – damit ist gleichzeitig geprüft, ob der Aufrufer das Produkt sehen darf.
async function loadCheckableProducts(profileId: string, productId?: string) {
const session = await verifySession();
if (!IdSchema.safeParse(profileId).success) return null;
if (!session.isAdmin && session.userId !== profileId) return null;
const clientId = await getClientId(profileId);
if (!clientId) return null;
const supabase = await createClient();
let query = supabase.from("client_products").select("id, url").eq("client_id", clientId);
if (productId) query = query.eq("id", productId);
const { data: products, error } = await query;
if (error || !products?.length) return null;
const { data: summaries } = await supabase
.from("product_check_summary")
.select("product_id, checked_at")
.in("product_id", products.map((product) => product.id));
const lastChecked = new Map((summaries ?? []).map((row) => [row.product_id as string, row.checked_at as string | null]));
const checkable: CheckableProduct[] = [];
for (const product of products) {
const url = toCheckableUrl(product.url);
if (url) checkable.push({ id: product.id, url, lastCheckedAt: lastChecked.get(product.id) ?? null });
}
return { clientId, products: checkable };
}
async function runAndStore(clientId: string, products: CheckableProduct[]) {
const results = await Promise.all(products.map(async (product) => ({ product, result: await checkUrl(product.url) })));
// Service Role, weil es bewusst keine Insert-Policy gibt (Clients sollen Ergebnisse nicht selbst schreiben können).
const admin = createAdminClient();
await admin.from("product_checks").insert(
results.map(({ product, result }) => ({
product_id: product.id,
client_id: clientId,
ok: result.ok,
status_code: result.statusCode,
response_ms: result.responseMs,
error: result.error,
}))
);
await admin
.from("product_checks")
.delete()
.in("product_id", products.map((product) => product.id))
.lt("checked_at", new Date(Date.now() - RETENTION_MS).toISOString());
return results;
}
function revalidateInfo(profileId: string) {
revalidatePath("/client/dashboard");
revalidatePath(`/admin/clients/${profileId}`);
}
function olderThan(timestamp: string | null, ms: number) {
return timestamp === null || Date.now() - Date.parse(timestamp) > ms;
}
export async function checkProduct(profileId: string, productId: string): Promise<CheckActionResult> {
if (!IdSchema.safeParse(productId).success) return { error: "Ungültiges Produkt." };
const loaded = await loadCheckableProducts(profileId, productId);
const product = loaded?.products[0];
if (!loaded || !product) return { error: "Dieses Produkt kann nicht automatisch geprüft werden." };
if (!olderThan(product.lastCheckedAt, MANUAL_MIN_INTERVAL_MS)) {
return { error: "Wurde gerade erst geprüft. Bitte kurz warten." };
}
const [{ result }] = await runAndStore(loaded.clientId, [product]);
revalidateInfo(profileId);
return { result };
}
export async function checkStaleProducts(profileId: string): Promise<{ checked: number }> {
const loaded = await loadCheckableProducts(profileId);
const stale = loaded?.products.filter((product) => olderThan(product.lastCheckedAt, AUTO_CHECK_STALE_AFTER_MS)) ?? [];
if (!loaded || stale.length === 0) return { checked: 0 };
await runAndStore(loaded.clientId, stale);
revalidateInfo(profileId);
return { checked: stale.length };
}