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:
parent
0700f65485
commit
f743556dc5
@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
@ -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");
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
122
backend/tests/admin/domains.test.ts
Normal file
122
backend/tests/admin/domains.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user