mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 20:51:17 +00:00
108 lines
4.4 KiB
TypeScript
108 lines
4.4 KiB
TypeScript
"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 };
|
||
}
|