Vollständiger 1:1-Port des cloudflare-worker/ (dogfather-universe-postfach) auf server-internal/: Bewerbungs-Postfach mit Rollen/Rechten, Team- Zugangsverwaltung (verschlüsselte Owner-only-Codes), DogiCrew-Supporter-Abo (PayPal Subscriptions, Google-Login, Resend-E-Mail, 30-Monats-Prämienzyklus) und automatische TikTok-Live-Erkennung. D1 -> better-sqlite3, Cloudflare- Cron -> node-cron. Läuft ohne gesetzte Secrets weiter (PayPal/Google/Resend/ Ticketanizer/Discord bleiben "nicht konfiguriert" statt kaputt), bis die echten Zugangsdaten eingetragen werden.
229 lines
10 KiB
JavaScript
229 lines
10 KiB
JavaScript
/* =====================================================================
|
|
routes/applications.js — Bewerbungen (Postfach), mit vollem Rollen-/
|
|
Rechte-Modell statt einem pauschalen Owner/Helfer-Zugriff. Status,
|
|
Zuweisung, Notizen und Antwortentwürfe pro Bewerbung. 1:1 portiert aus
|
|
cloudflare-worker/src/routes/applications.js.
|
|
===================================================================== */
|
|
import { db } from "../db.js";
|
|
import { generateId, nowIso } from "../lib/crypto.js";
|
|
import { hasPermission } from "../lib/permissions.js";
|
|
import { logAction } from "../lib/audit.js";
|
|
import { json } from "../lib/http.js";
|
|
import { bewerbungAlsTicketAnlegen } from "../lib/ticketanizer.js";
|
|
import { bewerbungAlsDiscordNachrichtPosten } from "../lib/discord-webhooks.js";
|
|
|
|
const ALLOWED_TYPES = ["creator", "scout", "kooperation", "modi"];
|
|
|
|
function can(session, key) {
|
|
if (!session) return false;
|
|
if (session.isOwner) return true;
|
|
return hasPermission(session.permissions, session.overrides, key);
|
|
}
|
|
|
|
function viewPermissionForType(type) {
|
|
// Der Rechte-Katalog kommt fest aus dem Anforderungsdokument und kennt nur
|
|
// zwei Bewerbungs-Sichtbereiche (Creator / "Rest"). Scout, Kooperation und
|
|
// Modi laufen deshalb alle über APPLICATIONS_VIEW_MANAGER_SCOUT — es wird
|
|
// bewusst KEIN neuer Permission-Key erfunden, der nicht im Dokument steht.
|
|
return type === "creator" ? "APPLICATIONS_VIEW_CREATOR" : "APPLICATIONS_VIEW_MANAGER_SCOUT";
|
|
}
|
|
|
|
export async function submitApplication(req, res) {
|
|
const body = req.body;
|
|
const { type, data } = body || {};
|
|
if (!ALLOWED_TYPES.includes(type) || typeof data !== "object" || !data) {
|
|
return json(res, { ok: false, error: "Ungültige Bewerbungsdaten." }, 400);
|
|
}
|
|
const values = Object.values(data).map((v) => String(v || "").trim());
|
|
if (values.every((v) => v === "")) return json(res, { ok: false, error: "Leere Bewerbung." }, 400);
|
|
|
|
const id = generateId();
|
|
db.prepare(`INSERT INTO applications (id, type, data, status, archived, created_at) VALUES (?, ?, ?, 'neu', 0, ?)`).run(
|
|
id,
|
|
type,
|
|
JSON.stringify(data),
|
|
nowIso()
|
|
);
|
|
|
|
// Zusätzlich (rein informativ, blockiert die Bewerbung selbst nie):
|
|
// 1) echtes Ticket bei Ticketanizer im passenden Bereich anlegen
|
|
// (Web-Inbox zum Bearbeiten/Antworten)
|
|
// 2) direkte, garantiert sichtbare Discord-Nachricht in den passenden
|
|
// Kanal posten (Ticketanizer erzeugt bei API-Tickets keinen echten
|
|
// Discord-Kanal, siehe discord-webhooks.js)
|
|
// Läuft im Hintergrund weiter (kein ctx.waitUntil-Äquivalent in Express
|
|
// nötig — der Node-Prozess bleibt am Leben), blockiert aber die Antwort
|
|
// an die bewerbende Person nicht.
|
|
Promise.all([bewerbungAlsTicketAnlegen(type, data), bewerbungAlsDiscordNachrichtPosten(type, data)]).catch(() => {});
|
|
|
|
return json(res, { ok: true });
|
|
}
|
|
|
|
export async function listApplications(req, res, session) {
|
|
if (!session) return json(res, { ok: false, error: "Nicht angemeldet." }, 401);
|
|
const body = req.body || {};
|
|
const includeArchived = !!body.includeArchived;
|
|
|
|
const allowedTypes = ALLOWED_TYPES.filter((t) => session.isOwner || can(session, viewPermissionForType(t)));
|
|
if (allowedTypes.length === 0) return json(res, { ok: true, applications: [] });
|
|
if (includeArchived && !session.isOwner && !can(session, "APPLICATIONS_VIEW_ARCHIVED")) {
|
|
return json(res, { ok: false, error: "Keine Berechtigung für archivierte Bewerbungen." }, 403);
|
|
}
|
|
|
|
const placeholders = allowedTypes.map(() => "?").join(",");
|
|
const archivedClause = includeArchived ? "" : "AND archived = 0";
|
|
const rows = db
|
|
.prepare(`SELECT * FROM applications WHERE type IN (${placeholders}) ${archivedClause} ORDER BY created_at DESC LIMIT 300`)
|
|
.all(...allowedTypes);
|
|
|
|
const applications = rows.map((r) => ({
|
|
id: r.id,
|
|
type: r.type,
|
|
data: session.isOwner || can(session, "APPLICATIONS_OPEN_FULL") ? JSON.parse(r.data) : null,
|
|
status: r.status,
|
|
assignedTo: r.assigned_to,
|
|
archived: !!r.archived,
|
|
createdAt: r.created_at,
|
|
}));
|
|
|
|
return json(res, { ok: true, applications });
|
|
}
|
|
|
|
export async function changeApplicationStatus(req, res, session) {
|
|
if (!can(session, "APPLICATIONS_CHANGE_STATUS")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
|
const body = req.body || {};
|
|
const id = String(body.id || "");
|
|
const status = String(body.status || "").slice(0, 40);
|
|
db.prepare(`UPDATE applications SET status = ? WHERE id = ?`).run(status, id);
|
|
logAction(session.actor, "application.status", id, { status });
|
|
return json(res, { ok: true });
|
|
}
|
|
|
|
export async function takeOverApplication(req, res, session) {
|
|
if (!can(session, "APPLICATIONS_TAKE_OVER")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
|
const body = req.body || {};
|
|
const id = String(body.id || "");
|
|
const who = session.isOwner ? "owner" : session.userId;
|
|
db.prepare(`UPDATE applications SET assigned_to = ? WHERE id = ?`).run(who, id);
|
|
logAction(session.actor, "application.take_over", id, null);
|
|
return json(res, { ok: true });
|
|
}
|
|
|
|
export async function assignApplication(req, res, session) {
|
|
if (!can(session, "APPLICATIONS_ASSIGN")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
|
const body = req.body || {};
|
|
const id = String(body.id || "");
|
|
const assignedTo = body.assignedTo ? String(body.assignedTo) : null;
|
|
db.prepare(`UPDATE applications SET assigned_to = ? WHERE id = ?`).run(assignedTo, id);
|
|
logAction(session.actor, "application.assign", id, { assignedTo });
|
|
return json(res, { ok: true });
|
|
}
|
|
|
|
export async function archiveApplication(req, res, session) {
|
|
if (!can(session, "APPLICATIONS_ARCHIVE")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
|
const body = req.body || {};
|
|
const id = String(body.id || "");
|
|
db.prepare(`UPDATE applications SET archived = 1 WHERE id = ?`).run(id);
|
|
logAction(session.actor, "application.archive", id, null);
|
|
return json(res, { ok: true });
|
|
}
|
|
|
|
export async function restoreApplication(req, res, session) {
|
|
if (!can(session, "APPLICATIONS_RESTORE")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
|
const body = req.body || {};
|
|
const id = String(body.id || "");
|
|
db.prepare(`UPDATE applications SET archived = 0 WHERE id = ?`).run(id);
|
|
logAction(session.actor, "application.restore", id, null);
|
|
return json(res, { ok: true });
|
|
}
|
|
|
|
export async function deleteApplication(req, res, session) {
|
|
if (!can(session, "APPLICATIONS_DELETE")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
|
const body = req.body || {};
|
|
const id = String(body.id || "");
|
|
db.prepare(`DELETE FROM application_notes WHERE application_id = ?`).run(id);
|
|
db.prepare(`DELETE FROM application_drafts WHERE application_id = ?`).run(id);
|
|
db.prepare(`DELETE FROM applications WHERE id = ?`).run(id);
|
|
logAction(session.actor, "application.delete", id, null);
|
|
return json(res, { ok: true });
|
|
}
|
|
|
|
export async function markAnswered(req, res, session) {
|
|
if (!can(session, "APPLICATIONS_MARK_ANSWERED")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
|
const body = req.body || {};
|
|
const id = String(body.id || "");
|
|
db.prepare(`UPDATE applications SET status = 'beantwortet' WHERE id = ?`).run(id);
|
|
logAction(session.actor, "application.mark_answered", id, null);
|
|
return json(res, { ok: true });
|
|
}
|
|
|
|
/* ---- Notizen ---- */
|
|
export async function listNotes(req, res, session) {
|
|
if (!can(session, "NOTES_READ")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
|
const body = req.body || {};
|
|
const rows = db
|
|
.prepare(`SELECT * FROM application_notes WHERE application_id = ? ORDER BY created_at ASC`)
|
|
.all(String(body.applicationId || ""));
|
|
return json(res, { ok: true, notes: rows });
|
|
}
|
|
|
|
export async function addNote(req, res, session) {
|
|
if (!can(session, "NOTES_WRITE")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
|
const body = req.body || {};
|
|
const text = String(body.text || "").slice(0, 2000);
|
|
if (!text.trim()) return json(res, { ok: false, error: "Leere Notiz." }, 400);
|
|
db.prepare(`INSERT INTO application_notes (application_id, author, text, created_at) VALUES (?, ?, ?, ?)`).run(
|
|
String(body.applicationId || ""),
|
|
session.actor,
|
|
text,
|
|
nowIso()
|
|
);
|
|
logAction(session.actor, "note.add", body.applicationId, null);
|
|
return json(res, { ok: true });
|
|
}
|
|
|
|
export async function deleteNote(req, res, session) {
|
|
if (!can(session, "NOTES_DELETE")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
|
const body = req.body || {};
|
|
db.prepare(`DELETE FROM application_notes WHERE id = ?`).run(Number(body.id));
|
|
logAction(session.actor, "note.delete", String(body.id), null);
|
|
return json(res, { ok: true });
|
|
}
|
|
|
|
/* ---- Antwortentwürfe ---- */
|
|
export async function listDrafts(req, res, session) {
|
|
const body = req.body || {};
|
|
const rows = db
|
|
.prepare(`SELECT * FROM application_drafts WHERE application_id = ? ORDER BY updated_at DESC`)
|
|
.all(String(body.applicationId || ""));
|
|
return json(res, { ok: true, drafts: rows });
|
|
}
|
|
|
|
export async function saveDraft(req, res, session) {
|
|
const body = req.body || {};
|
|
const content = String(body.content || "").slice(0, 5000);
|
|
|
|
if (body.id) {
|
|
const existing = db.prepare(`SELECT * FROM application_drafts WHERE id = ?`).get(Number(body.id));
|
|
if (!existing) return json(res, { ok: false, error: "Entwurf nicht gefunden." }, 404);
|
|
const isOwnDraft = existing.author === session.actor;
|
|
if (!can(session, isOwnDraft ? "DRAFTS_EDIT_OWN" : "DRAFTS_EDIT_OTHERS")) {
|
|
return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
|
}
|
|
db.prepare(`UPDATE application_drafts SET content = ?, updated_at = ? WHERE id = ?`).run(content, nowIso(), Number(body.id));
|
|
logAction(session.actor, "draft.update", String(body.id), null);
|
|
return json(res, { ok: true });
|
|
}
|
|
|
|
if (!can(session, "DRAFTS_CREATE")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
|
db.prepare(`INSERT INTO application_drafts (application_id, author, content, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`).run(
|
|
String(body.applicationId || ""),
|
|
session.actor,
|
|
content,
|
|
nowIso(),
|
|
nowIso()
|
|
);
|
|
logAction(session.actor, "draft.create", body.applicationId, null);
|
|
return json(res, { ok: true });
|
|
}
|