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>
498 lines
15 KiB
Vue
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>
|