Completes the custom-mail-patterns feature (schema + migration shipped
in ba170af alongside the chat-tab-badge commit — apologies for the
mishap, agent staging collided with mine). This is the actual logic
that makes the new type column do work:
- mail-classifier.ts: new layer 2.6 between brand+random-token detect
and the score-based heuristic. Case-insensitive substring match of
the From-display-name against the user's customDisplayNames list.
Hard-block when matched, skip score entirely.
- db/domains.ts: getCustomMailDisplayNames(userId) reads the new
type=mail_display_name rows. countActiveCustomDomains stays a shared
total — matches the user's pick of a single 5/5/10 pool spanning
web + mail patterns rather than separate counts per type.
- scan-internal.post.ts and scan.post.ts both preload the display-name
list per user before the message loop and thread it into classifyMail.
- POST /api/custom-domains accepts { pattern, kind: 'web' | 'mail' }
with the server inferring the concrete type — 'mail' splits into
mail_domain when the input contains a TLD-like shape, otherwise
mail_display_name. Existing { domain } body shape stays accepted
for backwards compatibility with older clients.
- POST /api/custom-domains/:id/submit treats both mail types as
community-submittable. The user explicitly chose this; the admin
review pipeline is the backstop against display-name false positives.
- vitest cases cover: substring match, case insensitivity, no-match
fallthrough to score, mail_domain still flowing through the existing
domain-set path, and shared-pool slot counts (3 web + 2 mail_domain
+ 1 mail_display_name = 6 against the 10-slot legend cap).
526 lines
16 KiB
TypeScript
526 lines
16 KiB
TypeScript
import { usePrisma } from "../utils/prisma";
|
|
import { createNotification } from "./notifications";
|
|
|
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Typ eines Custom-Domain-Eintrags.
|
|
* web — Web-Domain-Block (default, bisheriges Verhalten)
|
|
* mail_domain — Sender-Domain-Block im Mail-Filter (Domain-Match)
|
|
* mail_display_name — Sender-Display-Name-Pattern (Substring, case-insensitive)
|
|
*
|
|
* Alle Types teilen den gleichen Slot-Pool pro Plan.
|
|
*/
|
|
export type CustomDomainType = "web" | "mail_domain" | "mail_display_name";
|
|
|
|
export const CUSTOM_DOMAIN_TYPES: CustomDomainType[] = [
|
|
"web",
|
|
"mail_domain",
|
|
"mail_display_name",
|
|
];
|
|
|
|
// ─── Custom Domains ───────────────────────────────────────────────────────────
|
|
|
|
export async function getUserCustomDomains(userId: string) {
|
|
const db = usePrisma();
|
|
const rows = await db.userCustomDomain.findMany({
|
|
where: { userId },
|
|
orderBy: { addedAt: "desc" },
|
|
select: {
|
|
id: true,
|
|
domain: true,
|
|
status: true,
|
|
type: true,
|
|
postId: true,
|
|
addedAt: true,
|
|
submission: {
|
|
select: { id: true, yesVotes: true, noVotes: true, status: true },
|
|
},
|
|
},
|
|
});
|
|
return rows;
|
|
}
|
|
|
|
/**
|
|
* Counts domains that occupy a slot (active + submitted).
|
|
* approved → slot freed (domain joined global list)
|
|
* rejected → slot freed (user can re-submit or delete)
|
|
*/
|
|
export async function countActiveCustomDomains(userId: string) {
|
|
const db = usePrisma();
|
|
return db.userCustomDomain.count({
|
|
where: { userId, status: { notIn: ["approved", "rejected"] } },
|
|
});
|
|
}
|
|
|
|
export async function addUserCustomDomain(
|
|
userId: string,
|
|
domain: string,
|
|
source = "manual",
|
|
type: CustomDomainType = "web",
|
|
) {
|
|
const db = usePrisma();
|
|
return db.userCustomDomain.create({
|
|
data: { userId, domain, source, type },
|
|
select: { id: true, domain: true, type: true },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Gibt alle Display-Name-Patterns eines Users zurück.
|
|
* Wird vor jedem Mail-Scan geladen und an classifyMail() übergeben (Layer 2.6).
|
|
*
|
|
* DSGVO: keine PII — User-eigene Heuristik-Patterns (z.B. "EXTRASPIN").
|
|
*/
|
|
export async function getCustomMailDisplayNames(userId: string): Promise<string[]> {
|
|
const db = usePrisma();
|
|
const rows = await db.userCustomDomain.findMany({
|
|
where: { userId, type: "mail_display_name" },
|
|
select: { domain: true },
|
|
});
|
|
return rows.map((r) => r.domain);
|
|
}
|
|
|
|
export async function deleteUserCustomDomain(id: string, userId: string) {
|
|
const db = usePrisma();
|
|
// Cannot delete submitted/approved domains (protect integrity)
|
|
const existing = await db.userCustomDomain.findFirst({
|
|
where: { id, userId },
|
|
select: { status: true },
|
|
});
|
|
if (!existing) throw Object.assign(new Error("Not found"), { code: "P2025" });
|
|
if (existing.status === "submitted" || existing.status === "approved") {
|
|
throw Object.assign(
|
|
new Error(
|
|
"Eingereichte oder genehmigte Domains können nicht gelöscht werden",
|
|
),
|
|
{ code: "DOMAIN_LOCKED" },
|
|
);
|
|
}
|
|
return db.userCustomDomain.delete({ where: { id, userId } });
|
|
}
|
|
|
|
export async function deleteAllUserCustomDomains(userId: string) {
|
|
const db = usePrisma();
|
|
return db.userCustomDomain.deleteMany({ where: { userId } });
|
|
}
|
|
|
|
// ─── Domain Submissions ───────────────────────────────────────────────────────
|
|
|
|
// Net-Vote-Schwelle (yesVotes - noVotes) damit eine Submission von der
|
|
// Community-Vote-Phase in die Admin-Review-Phase wandert.
|
|
export const NET_VOTE_THRESHOLD = 10;
|
|
|
|
// Submission-Status:
|
|
// "pending" → Community-Voting-Phase (nur Pro)
|
|
// "in_review" → wartet auf Admin (Legend direkt, Pro nach 10 Netto-Yes-Votes)
|
|
// "approved" → in globaler Blocklist
|
|
// "rejected" → vom Admin abgelehnt
|
|
export type SubmissionPlan = "free" | "pro" | "legend";
|
|
|
|
export async function submitDomainForReview(
|
|
userId: string,
|
|
customDomainId: string,
|
|
plan: SubmissionPlan,
|
|
postId?: string,
|
|
) {
|
|
if (plan === "free") {
|
|
throw Object.assign(new Error("Free-Plan kann keine Domains einreichen"), {
|
|
code: "PLAN_NO_SUBMIT",
|
|
});
|
|
}
|
|
const submissionStatus = plan === "legend" ? "in_review" : "pending";
|
|
const db = usePrisma();
|
|
return db.$transaction(async (tx) => {
|
|
// Mark custom domain as submitted
|
|
const domain = await tx.userCustomDomain.update({
|
|
where: { id: customDomainId, userId },
|
|
data: { status: "submitted", postId: postId ?? null },
|
|
select: { id: true, domain: true },
|
|
});
|
|
// Create submission record
|
|
const submission = await tx.domainSubmission.create({
|
|
data: {
|
|
userId,
|
|
domain: domain.domain,
|
|
customDomainId,
|
|
postId: postId ?? null,
|
|
status: submissionStatus,
|
|
},
|
|
});
|
|
return { domain, submission };
|
|
});
|
|
}
|
|
|
|
export async function castDomainVote(
|
|
userId: string,
|
|
submissionId: string,
|
|
vote: "yes" | "no",
|
|
) {
|
|
const db = usePrisma();
|
|
return db.$transaction(async (tx) => {
|
|
// Upsert vote (change allowed)
|
|
const existing = await tx.domainVote.findUnique({
|
|
where: { userId_submissionId: { userId, submissionId } },
|
|
});
|
|
if (existing) {
|
|
if (existing.vote === vote) return { changed: false };
|
|
await tx.domainVote.update({
|
|
where: { userId_submissionId: { userId, submissionId } },
|
|
data: { vote },
|
|
});
|
|
} else {
|
|
await tx.domainVote.create({ data: { userId, submissionId, vote } });
|
|
}
|
|
|
|
// Recount
|
|
const [yes, no] = await Promise.all([
|
|
tx.domainVote.count({ where: { submissionId, vote: "yes" } }),
|
|
tx.domainVote.count({ where: { submissionId, vote: "no" } }),
|
|
]);
|
|
const updated = await tx.domainSubmission.update({
|
|
where: { id: submissionId },
|
|
data: { yesVotes: yes, noVotes: no },
|
|
});
|
|
|
|
// Netto-Yes (yes - no) muss die Schwelle erreichen, dann wandert die
|
|
// Submission in die Admin-Review-Phase. Approval erfolgt erst durch Admin.
|
|
let movedToReview = false;
|
|
if (updated.status === "pending" && yes - no >= NET_VOTE_THRESHOLD) {
|
|
await tx.domainSubmission.update({
|
|
where: { id: submissionId },
|
|
data: { status: "in_review" },
|
|
});
|
|
movedToReview = true;
|
|
}
|
|
|
|
return { yesVotes: yes, noVotes: no, movedToReview };
|
|
});
|
|
}
|
|
|
|
async function approveDomainSubmissionTx(
|
|
tx: any,
|
|
submissionId: string,
|
|
customDomainId: string,
|
|
domain: string,
|
|
) {
|
|
await tx.domainSubmission.update({
|
|
where: { id: submissionId },
|
|
data: { status: "approved", reviewedAt: new Date() },
|
|
});
|
|
await tx.userCustomDomain.update({
|
|
where: { id: customDomainId },
|
|
data: { status: "approved" },
|
|
});
|
|
// Add to global blocklist
|
|
await tx.blocklistDomain.upsert({
|
|
where: { domain },
|
|
create: { domain, source: "community", isActive: true },
|
|
update: { isActive: true },
|
|
});
|
|
}
|
|
|
|
export async function adminApproveSubmission(
|
|
submissionId: string,
|
|
reviewNote?: string,
|
|
) {
|
|
const db = usePrisma();
|
|
const sub = await db.domainSubmission.findUniqueOrThrow({
|
|
where: { id: submissionId },
|
|
select: {
|
|
customDomainId: true,
|
|
domain: true,
|
|
status: true,
|
|
userId: true,
|
|
postId: true,
|
|
},
|
|
});
|
|
// Admin darf in beiden offenen Phasen approven (Vote oder Review)
|
|
if (sub.status !== "pending" && sub.status !== "in_review") {
|
|
throw new Error("Submission already resolved");
|
|
}
|
|
|
|
await db.$transaction((tx) =>
|
|
approveDomainSubmissionTx(tx, submissionId, sub.customDomainId, sub.domain),
|
|
);
|
|
|
|
// Alle anderen User die diese Domain als Custom Domain haben → Slot freigeben + benachrichtigen
|
|
const affectedDomains = await db.userCustomDomain.findMany({
|
|
where: {
|
|
domain: sub.domain,
|
|
// Submitter's domain already handled in approveDomainSubmissionTx
|
|
id: { not: sub.customDomainId },
|
|
status: { notIn: ["approved", "rejected"] },
|
|
},
|
|
select: { id: true, userId: true },
|
|
});
|
|
|
|
if (affectedDomains.length > 0) {
|
|
// Batch-Update: alle auf "approved" setzen (Slot freigeben)
|
|
await db.userCustomDomain.updateMany({
|
|
where: { id: { in: affectedDomains.map((d) => d.id) } },
|
|
data: { status: "approved" },
|
|
});
|
|
|
|
// Notification an jeden betroffenen User
|
|
await Promise.allSettled(
|
|
affectedDomains.map((d) =>
|
|
createNotification({
|
|
recipientId: d.userId,
|
|
type: "domain_accepted",
|
|
actorName: "ReBreak",
|
|
postId: sub.postId ?? undefined,
|
|
preview: sub.domain,
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Auch Submitter benachrichtigen (Admin hat seine Domain genehmigt)
|
|
await createNotification({
|
|
recipientId: sub.userId,
|
|
type: "domain_accepted",
|
|
actorName: "ReBreak Admin",
|
|
postId: sub.postId ?? undefined,
|
|
preview: sub.domain,
|
|
}).catch(() => {});
|
|
|
|
return db.domainSubmission.findUnique({ where: { id: submissionId } });
|
|
}
|
|
|
|
export async function adminRejectSubmission(
|
|
submissionId: string,
|
|
reviewNote?: string,
|
|
) {
|
|
const db = usePrisma();
|
|
// Submission + zugehörige Custom-Domain laden (für Notification + Cleanup)
|
|
const sub = await db.domainSubmission.findUniqueOrThrow({
|
|
where: { id: submissionId },
|
|
select: {
|
|
userId: true,
|
|
domain: true,
|
|
postId: true,
|
|
customDomainId: true,
|
|
},
|
|
});
|
|
|
|
await db.$transaction(async (tx) => {
|
|
await tx.domainSubmission.update({
|
|
where: { id: submissionId },
|
|
data: {
|
|
status: "rejected",
|
|
reviewedAt: new Date(),
|
|
reviewNote: reviewNote ?? null,
|
|
},
|
|
});
|
|
// Custom-Domain des Submitters komplett aus seiner Liste entfernen
|
|
// (DomainSubmission via onDelete: Cascade automatisch mitgelöscht? NEIN —
|
|
// Submission referenziert customDomain. Wir haben Submission gerade upgedatet,
|
|
// also sicher löschen via Cascade von customDomain → submission.)
|
|
await tx.userCustomDomain.delete({
|
|
where: { id: sub.customDomainId },
|
|
});
|
|
});
|
|
|
|
// Submitter benachrichtigen — Notification von ReBreak (System)
|
|
await createNotification({
|
|
recipientId: sub.userId,
|
|
type: "domain_rejected",
|
|
actorName: "ReBreak",
|
|
postId: sub.postId ?? undefined,
|
|
preview: sub.domain,
|
|
}).catch(() => {});
|
|
|
|
return { customDomainId: sub.customDomainId };
|
|
}
|
|
|
|
// 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();
|
|
const rows = await db.domainSubmission.findMany({
|
|
where: { status: { in: ["pending", "in_review"] } },
|
|
orderBy: [{ createdAt: "asc" }],
|
|
select: {
|
|
id: true,
|
|
domain: true,
|
|
yesVotes: true,
|
|
noVotes: true,
|
|
status: true,
|
|
createdAt: true,
|
|
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 ─────────────────────────────────────────────────────────
|
|
|
|
export async function getActiveBlocklistCount() {
|
|
const db = usePrisma();
|
|
return db.blocklistDomain.count({ where: { isActive: true } });
|
|
}
|
|
|
|
export async function getActiveBlocklistDomains() {
|
|
const db = usePrisma();
|
|
return db.blocklistDomain.findMany({
|
|
where: { isActive: true },
|
|
select: { domain: true },
|
|
});
|
|
}
|
|
|
|
export async function isBlocklistedDomain(
|
|
domain: string,
|
|
userId: string,
|
|
): Promise<boolean> {
|
|
const db = usePrisma();
|
|
const [global, custom] = await Promise.all([
|
|
db.blocklistDomain.findFirst({
|
|
where: { domain, isActive: true },
|
|
select: { domain: true },
|
|
}),
|
|
db.userCustomDomain.findFirst({
|
|
where: { domain, userId },
|
|
select: { domain: true },
|
|
}),
|
|
]);
|
|
return !!(global || custom);
|
|
}
|
|
|
|
export async function getBlocklistedDomainsSet(
|
|
domains: string[],
|
|
userId: string,
|
|
includeGlobal = true,
|
|
): Promise<Set<string>> {
|
|
if (domains.length === 0) return new Set();
|
|
const db = usePrisma();
|
|
const unique = [...new Set(domains)];
|
|
|
|
// Alle möglichen Parent-Domains erzeugen: "mail.casino.de" → ["mail.casino.de", "casino.de"]
|
|
const allVariants = [
|
|
...new Set(
|
|
unique.flatMap((d) => {
|
|
const parts = d.split(".");
|
|
return parts
|
|
.map((_, i) => parts.slice(i).join("."))
|
|
.filter((v) => v.includes("."));
|
|
}),
|
|
),
|
|
];
|
|
|
|
const queries: Promise<{ domain: string }[]>[] = [];
|
|
|
|
if (includeGlobal) {
|
|
queries.push(
|
|
db.blocklistDomain.findMany({
|
|
where: { domain: { in: allVariants }, isActive: true },
|
|
select: { domain: true },
|
|
}),
|
|
);
|
|
}
|
|
|
|
queries.push(
|
|
db.userCustomDomain.findMany({
|
|
where: { domain: { in: allVariants }, userId },
|
|
select: { domain: true },
|
|
}),
|
|
);
|
|
|
|
const results = await Promise.all(queries);
|
|
const blockedSet = new Set(results.flatMap((r) => r.map((d) => d.domain)));
|
|
|
|
// Zurück auf Original-Domains mappen: wenn irgendein Variant geblockt ist → Original blocken
|
|
const result = new Set<string>();
|
|
for (const orig of unique) {
|
|
const parts = orig.split(".");
|
|
const variants = parts
|
|
.map((_, i) => parts.slice(i).join("."))
|
|
.filter((v) => v.includes("."));
|
|
if (variants.some((v) => blockedSet.has(v))) result.add(orig);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export async function upsertBlocklistDomains(
|
|
domains: { domain: string; source: string }[],
|
|
) {
|
|
const db = usePrisma();
|
|
// Batch upsert in chunks
|
|
const CHUNK = 5000;
|
|
let total = 0;
|
|
for (let i = 0; i < domains.length; i += CHUNK) {
|
|
const chunk = domains.slice(i, i + CHUNK).map((d) => ({
|
|
...d,
|
|
isActive: true,
|
|
}));
|
|
for (const d of chunk) {
|
|
await db.blocklistDomain.upsert({
|
|
where: { domain: d.domain },
|
|
create: d,
|
|
update: { isActive: true },
|
|
});
|
|
total++;
|
|
}
|
|
}
|
|
return total;
|
|
}
|