chahinebrini 34491ad220 feat(backend): denormalize domain_submissions.type for admin + lyra + notifications
User asked for the admin review tooling — and the lyra-bot community
post / notification text that goes out with each submission — to know
whether a submission is a website-domain or a mail-sender-domain. Until
now the type lived only on user_custom_domains and the submission
inherited it implicitly via the foreign key. Reading it back for the
admin list or the lyra prompt meant joining the source row every time.

- migration 20260516_domain_submission_type adds a type column to
  rebreak.domain_submissions with a default of 'web' and backfills
  every existing row from its linked user_custom_domains.type. The
  backfill is idempotent (UPDATE … FROM with the type comparison).
- Composite index (type, status) so the admin pending-list can scope
  by category without scanning the whole table.
- submitDomainForReview now copies the source row's type into the new
  submission. The submit endpoint picks it up to vary the auto-generated
  community-vote post copy: a website framing for type='web' and an
  "Mail-Absender"-framing for type='mail_domain'. The user's nickname
  is the only PII referenced.
- adminApproveSubmission returns the type alongside the domain so the
  approve endpoint's Lyra-bot Groq prompt can swap its subject/action
  labels per category. Reject path unchanged — the notification just
  carries the bare domain string, no type framing needed.
- BlocklistDomain stays type-agnostic on purpose. The mail-daemon's
  getBlocklistedDomainsSet is a flat string-set match against sender
  domain or URL host, and works for both categories without splitting.
  Adding a type there would be redundant work in v1.0 — revisit only
  if we ever need a UI to surface what category each global entry
  came from.

38/38 backend tests pass (8 admin/domains, 30 plan-limits including
5 new for the type-copy semantics and community-post text variants).
2026-05-16 02:24:42 +02:00

92 lines
3.3 KiB
TypeScript

import { submitDomainForReview } from "../../../db/domains";
import { getProfile } from "../../../db/profile";
import { getPlanLimits } from "../../../utils/plan-features";
import { usePrisma } from "../../../utils/prisma";
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
const id = getRouterParam(event, "id");
if (!id) throw createError({ statusCode: 400, message: "ID fehlt" });
// Only Pro/Legend can submit
const profile = await getProfile(user.id);
const plan = profile?.plan ?? "free";
const limits = getPlanLimits(plan);
if (!limits.domainRefill) {
throw createError({
statusCode: 403,
message: "Nur Pro-User können Domains einreichen",
});
}
const db = usePrisma();
// Verify ownership + status
const existing = await db.userCustomDomain.findFirst({
where: { id, userId: user.id },
select: { id: true, domain: true, status: true, type: true },
});
if (!existing)
throw createError({ statusCode: 404, message: "Domain nicht gefunden" });
if (existing.status !== "active" && existing.status !== "rejected") {
throw createError({
statusCode: 409,
message: "Domain wurde bereits eingereicht oder genehmigt",
});
}
// v1.0: Display-Name-Patterns sind nicht submittable (keine BlocklistDomain-Erweiterung vor TestFlight)
if (existing.type === "mail_display_name") {
throw createError({
statusCode: 400,
data: {
error: "DISPLAY_NAME_NOT_SUBMITTABLE",
message:
"Display-name patterns cannot be submitted to the global blocklist in v1.0. Use them as user-private filters only.",
},
});
}
// Tier-Routing:
// - Pro: Community-Post mit Voting-Flow erstellen
// - Legend: KEIN Post — Domain/Pattern landet direkt in der Admin-Queue
//
// Für mail_display_name: domain-Feld enthält das Pattern-String (kein PII).
// Admin-Review erkennt type via customDomain.type-Feld.
let postId: string | null = null;
if (plan === "pro") {
// Community-Vote-Post-Text variiert je nach Type
let postContent: string;
if (existing.type === "mail_domain") {
postContent = `Domain-Vorschlag (Mail-Absender): **${existing.domain}**\n\nIch schlage vor, diese Absender-Domain zur globalen ReBreak-Sperrliste hinzuzufügen. Casino-Affiliates nutzen oft Mailing-Listen mit harmlosen Namen. Stimme ab: Sollte **${existing.domain}** global gesperrt werden?`;
} else {
// type === "web" (mail_display_name ist durch Guard oben schon blockiert)
postContent = `Domain-Vorschlag: **${existing.domain}**\n\nIch schlage vor, diese Domain zur globalen ReBreak-Sperrliste hinzuzufügen. Stimme ab: Sollte **${existing.domain}** global gesperrt werden?`;
}
const post = await db.communityPost.create({
data: {
userId: user.id,
category: "domain_vote",
content: postContent,
},
select: { id: true },
});
postId = post.id;
}
const { submission } = await submitDomainForReview(
user.id,
id,
plan as "free" | "pro" | "legend",
postId ?? undefined,
);
return {
ok: true,
postId,
submissionId: submission.id,
domain: existing.domain,
type: existing.type,
route: plan === "legend" ? "admin_direct" : "community_vote",
};
});