chahinebrini 20c74de81e feat(domain-approval): Legend-priority + 24h-SLA-deadline + user-info cards
User-Wunsch: Legend-User priorisieren, 24h Approval-SLA, sichtbar wer/wann/Restzeit.

Backend:
- Schema: DomainSubmission.user @relation Profile (FK + composite-index status,createdAt)
- Migration: 20260509_domain_submission_user_relation (additive, FK via DO $$ block,
  idempotent IF NOT EXISTS index)
- db/domains.ts getPendingSubmissions enriched:
  - include user { id, nickname, plan }
  - returns PendingSubmissionRow with planPriority (legend=2, pro=1, free=0)
  - deadlineAt = createdAt + 24h
  - msUntilDeadline (negative when overdue)
  - sort: Legend > Pro > Free, FIFO innerhalb plan-bucket
- Constant ADMIN_APPROVAL_SLA_MS exported

Tests:
- backend/tests/admin/domains.test.ts — 5 cases (priority-sort, FIFO, deadline,
  overdue, user-null fallback). 83 backend tests passing total.

Frontend (apps/admin/pages/domains.vue):
- Card-list (statt UTable — sichtbarer urgency-stripe links)
- Filter-chips „Alle | Nur Legend | Überfällig" mit live counts
- Per row: nickname, plan-badge (Legend = sparkles + warning/gold),
  request-age (relative), deadline-countdown („noch 18h" / „ÜBERFÄLLIG (6h)")
- Visual urgency-stripe (1px border-left full-height):
  - Overdue: red-600 + warning-icon
  - <2h: red-500
  - Legend: amber-400 (gold)
  - <12h: yellow-500
  - Normal: gray-700

⚠️ Migration auto-deploy via pipeline (b38bf17 detection).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:55:28 +02:00

498 lines
15 KiB
Vue

<template>
<div>
<div class="flex items-start justify-between mb-8">
<div>
<h1 class="text-xl font-semibold text-white mb-1">Domain-Approval</h1>
<p class="text-sm text-gray-500">
Ausstehende Blocker-Domain-Anfragen genehmigen oder ablehnen.
<span class="text-gray-400">Legend-Requests werden priorisiert behandelt SLA: 24h.</span>
</p>
</div>
<UButton
color="neutral"
variant="ghost"
icon="heroicons:arrow-path"
:loading="pending"
size="sm"
@click="refresh()"
>
Aktualisieren
</UButton>
</div>
<!-- Filter-Bar (Plan-Chips) -->
<div
v-if="submissions && submissions.length > 0"
class="flex items-center gap-2 mb-4 flex-wrap"
>
<span class="text-xs uppercase tracking-wider text-gray-500 mr-1">
Filter:
</span>
<UButton
v-for="opt in filterOptions"
:key="opt.value"
:color="filter === opt.value ? 'primary' : 'neutral'"
:variant="filter === opt.value ? 'solid' : 'soft'"
size="xs"
@click="filter = opt.value"
>
{{ opt.label }}
<span v-if="opt.count !== undefined" class="ml-1 opacity-70">
({{ opt.count }})
</span>
</UButton>
<span class="text-xs text-gray-500 ml-auto">
{{ filteredSubmissions.length }} / {{ submissions.length }} sichtbar
<span v-if="overdueCount > 0" class="text-red-400 ml-2">
{{ overdueCount }} überfällig
</span>
</span>
</div>
<!-- Error-State -->
<div
v-if="error"
class="rounded-lg border border-red-900 bg-red-950/40 p-4 mb-4 flex items-start gap-3"
>
<UIcon
name="heroicons:exclamation-triangle"
class="h-5 w-5 text-red-400 shrink-0 mt-0.5"
/>
<div class="flex-1">
<p class="text-sm font-medium text-red-300">
Fehler beim Laden der Anfragen
</p>
<p class="text-xs text-red-400/80 mt-1">
{{ error.statusMessage || error.message || "Unbekannter Fehler" }}
</p>
</div>
</div>
<!-- Loading-State (initial) -->
<div
v-else-if="pending && !submissions"
class="rounded-lg border border-gray-800 bg-gray-900 p-12 text-center"
>
<UIcon
name="heroicons:arrow-path"
class="h-6 w-6 text-gray-600 mx-auto mb-3 animate-spin"
/>
<p class="text-sm text-gray-500">Lade Domain-Anfragen</p>
</div>
<!-- Empty-State -->
<div
v-else-if="!filteredSubmissions || filteredSubmissions.length === 0"
class="rounded-lg border border-dashed border-gray-700 bg-gray-900 p-12 text-center"
>
<UIcon
name="heroicons:inbox"
class="h-8 w-8 text-gray-600 mx-auto mb-3"
/>
<p class="text-sm text-gray-500">
{{
submissions && submissions.length > 0
? "Keine Treffer für aktuellen Filter"
: "Keine pending requests"
}}
</p>
<p class="text-xs text-gray-600 mt-2">
Alle Domain-Anfragen sind aktuell bearbeitet.
</p>
</div>
<!-- Card-Liste statt Table wegen border-left urgency-color besser sichtbar -->
<div v-else class="space-y-2">
<div
v-for="sub in filteredSubmissions"
:key="sub.id"
class="rounded-lg border border-gray-800 bg-gray-900 overflow-hidden flex"
:class="{ 'opacity-90': busyId === sub.id }"
>
<!-- Urgency-Stripe (border-left thick) -->
<div
class="w-1 shrink-0"
:class="urgencyStripeClass(sub)"
:title="urgencyLabel(sub)"
/>
<div class="flex-1 p-4">
<!-- Row 1: Domain + Plan-Badge + Status -->
<div class="flex items-center gap-2 flex-wrap mb-2">
<span class="font-mono text-white text-sm">{{ sub.domain }}</span>
<UBadge
:color="planBadgeColor(sub.user?.plan)"
variant="subtle"
size="xs"
class="uppercase"
>
<UIcon
v-if="sub.user?.plan === 'legend'"
name="heroicons:sparkles"
class="h-3 w-3 mr-0.5"
/>
{{ sub.user?.plan ?? "free" }}
</UBadge>
<UBadge
v-if="sub.status === 'in_review'"
color="warning"
variant="subtle"
size="xs"
>
in_review
</UBadge>
<UBadge
v-else-if="sub.status === 'pending'"
color="neutral"
variant="subtle"
size="xs"
>
voting
</UBadge>
</div>
<!-- Row 2: User + Zeit-Info -->
<div class="flex items-center gap-4 text-xs text-gray-400 flex-wrap">
<span class="inline-flex items-center gap-1">
<UIcon name="heroicons:user" class="h-3.5 w-3.5" />
{{ sub.user?.nickname ?? "—" }}
</span>
<span class="inline-flex items-center gap-1">
<UIcon name="heroicons:clock" class="h-3.5 w-3.5" />
{{ relativeTime(sub.createdAt) }}
</span>
<span
class="inline-flex items-center gap-1 font-medium"
:class="deadlineTextClass(sub)"
>
<UIcon
:name="
isOverdue(sub)
? 'heroicons:exclamation-triangle'
: 'heroicons:bell-alert'
"
class="h-3.5 w-3.5"
/>
{{ deadlineLabel(sub) }}
</span>
<span class="inline-flex items-center gap-3 ml-auto">
<span class="inline-flex items-center gap-1 text-green-400">
<UIcon name="heroicons:hand-thumb-up" class="h-3.5 w-3.5" />
{{ sub.yesVotes }}
</span>
<span class="inline-flex items-center gap-1 text-red-400">
<UIcon name="heroicons:hand-thumb-down" class="h-3.5 w-3.5" />
{{ sub.noVotes }}
</span>
</span>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 px-4 shrink-0">
<UButton
color="success"
variant="soft"
size="xs"
icon="heroicons:check"
:loading="busyId === sub.id && busyAction === 'approve'"
:disabled="!!busyId"
@click="onApprove(sub)"
>
Approve
</UButton>
<UButton
color="error"
variant="soft"
size="xs"
icon="heroicons:x-mark"
:loading="busyId === sub.id && busyAction === 'reject'"
:disabled="!!busyId"
@click="askReject(sub)"
>
Reject
</UButton>
</div>
</div>
</div>
<!-- Reject-Confirm-Dialog -->
<UModal v-model:open="rejectOpen" title="Domain-Anfrage ablehnen">
<template #body>
<div class="space-y-4">
<p class="text-sm text-gray-300">
Anfrage für
<span class="font-mono text-white">{{
rejectTarget?.domain
}}</span>
wirklich ablehnen?
</p>
<UTextarea
v-model="rejectNote"
placeholder="Optionaler Begründungs-Hinweis (intern)…"
:rows="3"
class="w-full"
/>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-2 w-full">
<UButton
color="neutral"
variant="ghost"
:disabled="!!busyId"
@click="rejectOpen = false"
>
Abbrechen
</UButton>
<UButton
color="error"
:loading="!!busyId"
@click="confirmReject"
>
Ablehnen
</UButton>
</div>
</template>
</UModal>
</div>
</template>
<script setup lang="ts">
definePageMeta({ middleware: "admin-auth" });
interface DomainSubmission {
id: string;
domain: string;
yesVotes: number;
noVotes: number;
status: "pending" | "in_review" | "approved" | "rejected";
createdAt: string;
userId: string;
postId: string | null;
customDomain: { id: string } | null;
user: { id: string; nickname: string | null; plan: string } | null;
planPriority: number;
deadlineAt: string;
msUntilDeadline: number;
}
const toast = useToast();
const {
data: submissions,
pending,
error,
refresh,
} = await useFetch<DomainSubmission[]>("/api/domain-submissions", {
default: () => [],
server: false, // Auth-Guard erst client-side -> SSR-Fetch wuerde unauthenticated landen
});
// ─── Filter ──────────────────────────────────────────────────────────────────
type FilterValue = "all" | "legend" | "overdue";
const filter = ref<FilterValue>("all");
const filterOptions = computed(() => {
const all = submissions.value ?? [];
const legend = all.filter((s) => s.user?.plan === "legend");
const overdue = all.filter((s) => isOverdue(s));
return [
{ label: "Alle", value: "all" as const, count: all.length },
{
label: "Nur Legend",
value: "legend" as const,
count: legend.length,
},
{
label: "Überfällig",
value: "overdue" as const,
count: overdue.length,
},
];
});
const filteredSubmissions = computed(() => {
const all = submissions.value ?? [];
if (filter.value === "legend") {
return all.filter((s) => s.user?.plan === "legend");
}
if (filter.value === "overdue") {
return all.filter((s) => isOverdue(s));
}
return all;
});
const overdueCount = computed(
() => (submissions.value ?? []).filter((s) => isOverdue(s)).length,
);
// ─── Helpers ──────────────────────────────────────────────────────────────────
function planBadgeColor(
plan: string | null | undefined,
): "primary" | "success" | "warning" | "neutral" {
if (plan === "legend") return "warning";
if (plan === "pro") return "primary";
return "neutral";
}
function relativeTime(iso: string): string {
if (!iso) return "—";
const diffMs = Date.now() - new Date(iso).getTime();
const m = Math.floor(diffMs / 60000);
if (m < 1) return "gerade eben";
if (m < 60) return `vor ${m}min`;
const h = Math.floor(m / 60);
if (h < 24) return `vor ${h}h`;
const d = Math.floor(h / 24);
return `vor ${d}d`;
}
function isOverdue(sub: DomainSubmission): boolean {
return sub.msUntilDeadline <= 0;
}
function deadlineLabel(sub: DomainSubmission): string {
if (isOverdue(sub)) {
const overdueMs = -sub.msUntilDeadline;
const overdueH = Math.floor(overdueMs / 3_600_000);
if (overdueH < 1) return "ÜBERFÄLLIG";
return `ÜBERFÄLLIG (${overdueH}h)`;
}
const ms = sub.msUntilDeadline;
const totalMin = Math.floor(ms / 60000);
const h = Math.floor(totalMin / 60);
const m = totalMin % 60;
if (h === 0) return `noch ${m}min`;
return `noch ${h}h ${m}min`;
}
function urgencyLevel(sub: DomainSubmission): "overdue" | "critical" | "soon" | "normal" | "legend" {
if (isOverdue(sub)) return "overdue";
// Legend bekommt eigene gold-stripe — vor Zeit-basierter Urgency
if (sub.user?.plan === "legend") return "legend";
if (sub.msUntilDeadline < 2 * 3_600_000) return "critical";
if (sub.msUntilDeadline < 12 * 3_600_000) return "soon";
return "normal";
}
function urgencyStripeClass(sub: DomainSubmission): string {
switch (urgencyLevel(sub)) {
case "overdue":
return "bg-red-600";
case "critical":
return "bg-red-500";
case "legend":
return "bg-amber-400";
case "soon":
return "bg-yellow-500";
default:
return "bg-gray-700";
}
}
function urgencyLabel(sub: DomainSubmission): string {
switch (urgencyLevel(sub)) {
case "overdue":
return "SLA überschritten";
case "critical":
return "<2h bis Deadline";
case "legend":
return "Legend-Priorität";
case "soon":
return "<12h bis Deadline";
default:
return "Im Zeitrahmen";
}
}
function deadlineTextClass(sub: DomainSubmission): string {
if (isOverdue(sub)) return "text-red-400";
if (sub.msUntilDeadline < 2 * 3_600_000) return "text-red-400";
if (sub.msUntilDeadline < 12 * 3_600_000) return "text-yellow-400";
return "text-gray-400";
}
// ─── Approve / Reject ─────────────────────────────────────────────────────────
const busyId = ref<string | null>(null);
const busyAction = ref<"approve" | "reject" | null>(null);
const rejectOpen = ref(false);
const rejectTarget = ref<DomainSubmission | null>(null);
const rejectNote = ref("");
async function onApprove(row: DomainSubmission) {
if (busyId.value) return;
busyId.value = row.id;
busyAction.value = "approve";
try {
await $fetch(`/api/domain-submissions/${row.id}/approve`, {
method: "POST",
body: {},
});
toast.add({
title: "Domain genehmigt",
description: row.domain,
color: "success",
icon: "heroicons:check-circle",
});
await refresh();
} catch (err: any) {
toast.add({
title: "Approve fehlgeschlagen",
description:
err?.statusMessage ?? err?.message ?? "Unbekannter Fehler",
color: "error",
icon: "heroicons:exclamation-triangle",
});
} finally {
busyId.value = null;
busyAction.value = null;
}
}
function askReject(row: DomainSubmission) {
if (busyId.value) return;
rejectTarget.value = row;
rejectNote.value = "";
rejectOpen.value = true;
}
async function confirmReject() {
const target = rejectTarget.value;
if (!target || busyId.value) return;
busyId.value = target.id;
busyAction.value = "reject";
try {
await $fetch(`/api/domain-submissions/${target.id}/reject`, {
method: "POST",
body: { note: rejectNote.value || undefined },
});
toast.add({
title: "Domain abgelehnt",
description: target.domain,
color: "warning",
icon: "heroicons:x-circle",
});
rejectOpen.value = false;
rejectTarget.value = null;
rejectNote.value = "";
await refresh();
} catch (err: any) {
toast.add({
title: "Reject fehlgeschlagen",
description:
err?.statusMessage ?? err?.message ?? "Unbekannter Fehler",
color: "error",
icon: "heroicons:exclamation-triangle",
});
} finally {
busyId.value = null;
busyAction.value = null;
}
}
</script>