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>
This commit is contained in:
chahinebrini 2026-05-09 15:55:18 +02:00
parent 0700f65485
commit f743556dc5
5 changed files with 496 additions and 106 deletions

View File

@ -5,6 +5,7 @@
<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
@ -19,6 +20,35 @@
</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"
@ -52,81 +82,122 @@
<!-- Empty-State -->
<div
v-else-if="!submissions || submissions.length === 0"
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">Keine pending requests</p>
<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>
<!-- Tabelle -->
<!-- Card-Liste statt Table wegen border-left urgency-color besser sichtbar -->
<div v-else class="space-y-2">
<div
v-else
class="rounded-lg border border-gray-800 bg-gray-900 overflow-hidden"
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 }"
>
<UTable
:data="submissions"
:columns="columns"
:ui="{
th: 'text-xs uppercase tracking-wider text-gray-500 bg-gray-900/60',
td: 'text-sm text-gray-300 align-middle',
tr: 'border-b border-gray-800 last:border-b-0',
}"
>
<template #domain-cell="{ row }">
<span class="font-mono text-white">{{ row.original.domain }}</span>
<!-- 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
v-if="row.original.status === 'in_review'"
: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"
class="ml-2"
>
in_review
</UBadge>
</template>
<UBadge
v-else-if="sub.status === 'pending'"
color="neutral"
variant="subtle"
size="xs"
>
voting
</UBadge>
</div>
<template #requestedBy-cell="{ row }">
<span class="font-mono text-xs text-gray-400">
{{ shortId(row.original.userId) }}
<!-- 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>
</template>
<template #requestedAt-cell="{ row }">
<span class="text-gray-400">{{
formatDate(row.original.createdAt)
}}</span>
</template>
<template #votes-cell="{ row }">
<div class="flex items-center gap-3">
<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" />
{{ row.original.yesVotes }}
{{ 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" />
{{ row.original.noVotes }}
{{ sub.noVotes }}
</span>
</span>
</div>
</template>
</div>
<template #actions-cell="{ row }">
<div class="flex items-center justify-end gap-2">
<!-- Actions -->
<div class="flex items-center gap-2 px-4 shrink-0">
<UButton
color="success"
variant="soft"
size="xs"
icon="heroicons:check"
:loading="busyId === row.original.id && busyAction === 'approve'"
:loading="busyId === sub.id && busyAction === 'approve'"
:disabled="!!busyId"
@click="onApprove(row.original)"
@click="onApprove(sub)"
>
Approve
</UButton>
@ -135,15 +206,14 @@
variant="soft"
size="xs"
icon="heroicons:x-mark"
:loading="busyId === row.original.id && busyAction === 'reject'"
:loading="busyId === sub.id && busyAction === 'reject'"
:disabled="!!busyId"
@click="askReject(row.original)"
@click="askReject(sub)"
>
Reject
</UButton>
</div>
</template>
</UTable>
</div>
</div>
<!-- Reject-Confirm-Dialog -->
@ -189,8 +259,6 @@
</template>
<script setup lang="ts">
import type { TableColumn } from "@nuxt/ui";
definePageMeta({ middleware: "admin-auth" });
interface DomainSubmission {
@ -203,6 +271,10 @@ interface DomainSubmission {
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();
@ -217,34 +289,130 @@ const {
server: false, // Auth-Guard erst client-side -> SSR-Fetch wuerde unauthenticated landen
});
const columns: TableColumn<DomainSubmission>[] = [
{ accessorKey: "domain", header: "Domain" },
{ accessorKey: "requestedBy", header: "Angefragt von" },
{ accessorKey: "requestedAt", header: "Angefragt am" },
{ accessorKey: "votes", header: "Votes" },
{ accessorKey: "actions", header: "", meta: { class: { td: "text-right" } } },
];
// 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 shortId(id: string): string {
if (!id) return "—";
return id.slice(0, 8);
function planBadgeColor(
plan: string | null | undefined,
): "primary" | "success" | "warning" | "neutral" {
if (plan === "legend") return "warning";
if (plan === "pro") return "primary";
return "neutral";
}
function formatDate(iso: string): string {
function relativeTime(iso: string): string {
if (!iso) return "—";
try {
return new Date(iso).toLocaleString("de-DE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
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

View File

@ -0,0 +1,37 @@
-- Domain-Approval Enhancement — Profile-Relation + 24h-Deadline-Sort-Index
--
-- Hintergrund: `getPendingSubmissions` muss user.nickname + user.plan joinen
-- damit Admins (Domain-Approval-Page) Legend-Requests prioritisiert sehen
-- und die 24h-Deadline pro Submission anzeigen können.
--
-- Bisher gab es zwischen `domain_submissions.user_id` und `profiles.id` KEINEN
-- expliziten Foreign-Key (siehe 0_init Migration — nur `custom_domain_id`-FK).
-- Diese Migration fügt den FK additiv hinzu, plus Composite-Index für die neue
-- Sort-Order (status + createdAt ASC für „älteste zuerst" innerhalb Plan-Bucket).
--
-- Drift-Hinweis: deploy via `pnpm prisma migrate deploy`. Lokal NICHT laufen.
-- Falls Drift erkannt wird:
-- pnpm prisma migrate resolve --applied 20260509_domain_submission_user_relation
-- 1. FK auf profiles(id) — idempotent (DO-Block weil Postgres kein
-- `ADD CONSTRAINT IF NOT EXISTS` unterstützt).
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'domain_submissions_user_id_fkey'
AND connamespace = 'rebreak'::regnamespace
) THEN
ALTER TABLE "rebreak"."domain_submissions"
ADD CONSTRAINT "domain_submissions_user_id_fkey"
FOREIGN KEY ("user_id")
REFERENCES "rebreak"."profiles"("id")
ON DELETE NO ACTION
ON UPDATE CASCADE;
END IF;
END $$;
-- 2. Composite-Index für neue Sort-Order (status, created_at)
CREATE INDEX IF NOT EXISTS "domain_submissions_status_created_at_idx"
ON "rebreak"."domain_submissions" ("status", "created_at");

View File

@ -65,6 +65,7 @@ model Profile {
communityPosts CommunityPost[]
communityReplies CommunityReply[]
domainSubmissions DomainSubmission[]
@@index([deletedAt])
@@index([plan])
@ -377,8 +378,10 @@ model DomainSubmission {
reviewedAt DateTime? @map("reviewed_at")
customDomain UserCustomDomain @relation(fields: [customDomainId], references: [id], onDelete: Cascade)
user Profile @relation(fields: [userId], references: [id])
votes DomainVote[]
@@index([status, createdAt])
@@map("domain_submissions")
@@schema("rebreak")
}

View File

@ -299,11 +299,47 @@ export async function adminRejectSubmission(
return { customDomainId: sub.customDomainId };
}
export async function getPendingSubmissions() {
// 24 Stunden in Millisekunden — SLA für Admin-Approval.
// Legend-Requests sollen erst recht innerhalb dieser Frist beantwortet werden.
export const ADMIN_APPROVAL_SLA_MS = 24 * 60 * 60 * 1000;
export type PendingSubmissionRow = {
id: string;
domain: string;
yesVotes: number;
noVotes: number;
status: string;
createdAt: Date;
userId: string;
postId: string | null;
customDomain: { id: string } | null;
user: { id: string; nickname: string | null; plan: string } | null;
/** Plan-Priority — 2 = legend, 1 = pro, 0 = free/unknown */
planPriority: number;
/** ISO-deadline = createdAt + 24h. Wenn jetzt > deadline → overdue. */
deadlineAt: Date;
/** ms bis Deadline (negativ wenn überfällig) */
msUntilDeadline: number;
};
/**
* Pending Domain-Submissions für Admin-Review.
*
* Sort-Order:
* 1. Legend zuerst (plan === "legend" hat höchste Priorität)
* 2. Innerhalb gleicher Plan-Priority: älteste createdAt zuerst (FIFO)
*
* Postgres kann nach computed plan-priority nicht direkt orderBy'en, deswegen
* sortieren wir post-fetch in JS Liste ist klein (Admin-Review-Queue).
*
* Pro Row wird `deadlineAt` (createdAt + 24h) berechnet damit das Frontend
* den Countdown / Overdue-Badge ohne Re-Computation zeigen kann.
*/
export async function getPendingSubmissions(): Promise<PendingSubmissionRow[]> {
const db = usePrisma();
return db.domainSubmission.findMany({
const rows = await db.domainSubmission.findMany({
where: { status: { in: ["pending", "in_review"] } },
orderBy: [{ status: "asc" }, { yesVotes: "desc" }],
orderBy: [{ createdAt: "asc" }],
select: {
id: true,
domain: true,
@ -314,8 +350,32 @@ export async function getPendingSubmissions() {
userId: true,
postId: true,
customDomain: { select: { id: true } },
user: { select: { id: true, nickname: true, plan: true } },
},
});
const now = Date.now();
const enriched = rows.map((r) => {
const plan = r.user?.plan ?? "free";
const planPriority = plan === "legend" ? 2 : plan === "pro" ? 1 : 0;
const deadlineAt = new Date(r.createdAt.getTime() + ADMIN_APPROVAL_SLA_MS);
return {
...r,
planPriority,
deadlineAt,
msUntilDeadline: deadlineAt.getTime() - now,
};
});
// Legend (2) vor Pro (1) vor Free (0). Bei Gleichstand: ältere zuerst.
enriched.sort((a, b) => {
if (a.planPriority !== b.planPriority) {
return b.planPriority - a.planPriority;
}
return a.createdAt.getTime() - b.createdAt.getTime();
});
return enriched;
}
// ─── Global Blocklist ─────────────────────────────────────────────────────────

View File

@ -0,0 +1,122 @@
/**
* Tests für getPendingSubmissions (Domain-Approval-Queue).
*
* Schwerpunkt: Sort-Order-Garantie + Deadline-Computation.
* - Legend > Pro > Free Plan-Priority
* - Innerhalb gleicher Priority: älteste createdAt zuerst (FIFO)
* - deadlineAt = createdAt + 24h, msUntilDeadline negativ wenn überfällig
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
const prismaMock = vi.hoisted(() => ({
domainSubmission: {
findMany: vi.fn(),
},
}));
vi.mock("../../server/utils/prisma", () => ({
usePrisma: () => prismaMock,
}));
import {
getPendingSubmissions,
ADMIN_APPROVAL_SLA_MS,
} from "../../server/db/domains";
beforeEach(() => {
vi.clearAllMocks();
});
function makeRow(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: "sub-id",
domain: "example.com",
yesVotes: 0,
noVotes: 0,
status: "in_review",
createdAt: new Date("2026-05-09T10:00:00Z"),
userId: "user-id",
postId: null,
customDomain: { id: "cd-id" },
user: { id: "user-id", nickname: "nick", plan: "free" },
...overrides,
};
}
describe("getPendingSubmissions — Plan-Priority + Deadline", () => {
it("sortiert Legend vor Pro vor Free (Plan-Priority)", async () => {
const sameTime = new Date("2026-05-09T10:00:00Z");
prismaMock.domainSubmission.findMany.mockResolvedValueOnce([
makeRow({
id: "free-1",
createdAt: sameTime,
user: { id: "u1", nickname: "a", plan: "free" },
}),
makeRow({
id: "legend-1",
createdAt: sameTime,
user: { id: "u2", nickname: "b", plan: "legend" },
}),
makeRow({
id: "pro-1",
createdAt: sameTime,
user: { id: "u3", nickname: "c", plan: "pro" },
}),
]);
const result = await getPendingSubmissions();
expect(result.map((r) => r.id)).toEqual(["legend-1", "pro-1", "free-1"]);
});
it("innerhalb gleicher Plan-Priority: älteste zuerst (FIFO)", async () => {
prismaMock.domainSubmission.findMany.mockResolvedValueOnce([
makeRow({
id: "legend-newer",
createdAt: new Date("2026-05-09T12:00:00Z"),
user: { id: "u1", nickname: "x", plan: "legend" },
}),
makeRow({
id: "legend-older",
createdAt: new Date("2026-05-09T08:00:00Z"),
user: { id: "u2", nickname: "y", plan: "legend" },
}),
]);
const result = await getPendingSubmissions();
expect(result.map((r) => r.id)).toEqual(["legend-older", "legend-newer"]);
});
it("berechnet deadlineAt = createdAt + 24h pro row", async () => {
const created = new Date("2026-05-09T10:00:00Z");
prismaMock.domainSubmission.findMany.mockResolvedValueOnce([
makeRow({ createdAt: created }),
]);
const result = await getPendingSubmissions();
const expectedDeadline = new Date(
created.getTime() + ADMIN_APPROVAL_SLA_MS,
);
expect(result[0]!.deadlineAt.toISOString()).toBe(
expectedDeadline.toISOString(),
);
});
it("msUntilDeadline ist negativ wenn Submission überfällig (>24h alt)", async () => {
// 30h alte Submission → 6h überfällig → ms negativ
const created = new Date(Date.now() - 30 * 60 * 60 * 1000);
prismaMock.domainSubmission.findMany.mockResolvedValueOnce([
makeRow({ createdAt: created }),
]);
const result = await getPendingSubmissions();
expect(result[0]!.msUntilDeadline).toBeLessThan(0);
});
it("planPriority fällt auf 0 zurück wenn user.plan unbekannt / null", async () => {
prismaMock.domainSubmission.findMany.mockResolvedValueOnce([
makeRow({ user: null }),
]);
const result = await getPendingSubmissions();
expect(result[0]!.planPriority).toBe(0);
});
});