Files
dogfather-universe/server-internal/routes/webdesign-annahme.js
T
DogFatherGitandClaude Opus 5 131bc8a755 Anfragen annehmen und ablehnen — mit laufender Zeit bis zur Übergabe
Annehmen war bisher nur ein Etikett: Man stellte den Status um, und es
passierte nichts. Kunde, Projekt, Aufgabenliste, Termin und Zugang musste
man danach von Hand in fünf Schritten nachbauen. Der alte Code gab das im
Kommentar selbst zu -- schlug der zweite von zwei Aufrufen fehl, stand der
Kunde schon in der Datenbank und man durfte es nicht noch einmal
versuchen.

Jetzt macht das EIN Aufruf, ganz oder gar nicht:
Kunde finden oder anlegen, Projekt anlegen, die komplette Aufgabenliste
des Pakets einsetzen, Liefertermin berechnen, Einladungslink erzeugen.
Der Kunde verfolgt ab diesem Moment alles in seinem Portal.

Die Zeit läuft wirklich:
- Eigenes Datumsfeld (termin_am) neben dem freien Text. Aus 'Mitte
  Oktober' kann man keine verbleibenden Tage rechnen.
- Werktage statt Kalendertage, inklusive luxemburgischer Feiertage. Die
  beweglichen werden über die Osterformel berechnet statt gepflegt --
  eine Liste ist im übernächsten Jahr lautlos falsch.
- Vorschlag je Paket (Onepager 10, Website 20, Shop 30 Werktage),
  überschreibbar vor dem Bestätigen.
- Verbleibende Zeit wird SERVERSEITIG gerechnet. Der Browser kennt die
  Feiertage nicht; zwei verschiedene Zahlen für denselben Termin wären
  schlimmer als gar keine.

Ablehnen mit Grund und vorbereiteter Absage in fünf Sprachen, Text
serverseitig erzeugt und vor dem Abschicken lesbar. Ein laufendes
Projekt lässt sich nicht nachträglich als Anfrage ablehnen.

Sechs Fehler dabei gefunden:
- Ein unbekanntes Paket hätte GAR KEINEN Termin bekommen statt des
  Ersatzwerts. Zwei Funktionen lasen dieselbe Tabelle, nur eine hatte
  einen Rückfallwert. Eine fehlende Zahl fällt nirgends auf.
- wd_anfragen hat keine Spalte 'firma' -- better-sqlite3 weist undefined
  ab, die ganze Annahme wäre gescheitert.
- Migrationen stehen in einer ausdrücklichen Liste; 0015 fehlte darin.
- datumKurz() wurde aufgerufen, gab es aber nicht. Der Fehler wäre erst
  NACH dem Anlegen aufgetreten.
- Der Installations-Hinweis lag fest über dem Annehmen-Knopf und machte
  ihn auf dem Handy untreffbar. Die Seite reserviert jetzt Platz dafür.
- 'richttermin' ist freier Text, lief aber durch einen Datumsformatierer:
  Der Kunde las 'Invalid Date' an der wichtigsten Stelle seines Projekts.

Geprüft: 49 Prüfungen der Terminrechnung gegen nachschlagbare Osterdaten
und Wochentage, 70 gegen eine echte Datenbank (darunter: zweiter Klick
legt nichts doppelt an, Abbruch mittendrin lässt NICHTS zurück, gesperrter
Kunde wird nicht still entsperrt), 42 im Browser auf Computer und Handy,
40 im Portal. Alle bestehenden Prüfungen weiter grün.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-23 22:44:08 +02:00

487 lines
22 KiB
JavaScript

/* =====================================================================
webdesign-annahme.js — eine Anfrage annehmen oder ablehnen
WAS HIER PASSIERT, UND WARUM ES EIN EIGENER SCHRITT IST
Bisher konnte man den Status einer Anfrage auf "angenommen" stellen.
Passiert ist dabei nichts: kein Kunde, kein Projekt, keine
Aufgabenliste, kein Zugang. Man musste danach von Hand einen Kunden
anlegen, ein Projekt anlegen, die Vorlage anwenden, einen Termin
eintragen und eine Einladung erzeugen — fünf Schritte, bei denen man
einen vergessen kann. Vergisst man den letzten, wartet der Kunde auf
einen Zugang, der nie kommt, und man selbst hält den Vorgang für
erledigt.
"Annehmen" ist deshalb jetzt EIN Vorgang, der alles davon tut.
ALLES ODER NICHTS
Alle Schritte laufen in einer Transaktion. Bricht einer ab, bleibt
nichts Halbes zurück. Ein Kunde ohne Projekt wäre schlimmer als gar
nichts: Er stünde in der Liste, hätte einen Zugang und sähe darin
eine leere Seite.
NUR EINMAL
Ein zweiter Klick — versehentlich, oder weil die Antwort lange
brauchte — darf kein zweites Projekt anlegen. Deshalb wird zuerst
geprüft, ob zu dieser Anfrage schon ein Projekt existiert. Ist es so,
kommt dasselbe Ergebnis zurück wie beim ersten Mal, statt eines
Fehlers: Für den Bedienenden ist das Ergebnis dasselbe, und ein
Fehler an dieser Stelle würde nur verunsichern.
===================================================================== */
import { db } from "../db.js";
import { json } from "../lib/http.js";
import { darfWebdesign } from "../lib/webdesign-ausweis.js";
import { requireSession } from "../lib/auth.js";
import { hasPermission } from "../lib/permissions.js";
import {
jetzt, neueId, naechsteNummer, text, verlauf, PAKETE,
} from "../lib/webdesign-helfer.js";
import { terminVorschlag, werktageBis, laufzeitFuer, hatLiefertermin }
from "../lib/webdesign-termine.js";
import { vorlageAnwenden } from "./webdesign-aufgaben.js";
import { einladungAnlegen, einladungsLink } from "./webdesign-kunden.js";
async function darf(req, recht) {
return darfWebdesign(req, recht, async () => {
const sitzung = await requireSession(req);
if (!sitzung) return { erlaubt: false, grund: 401, sitzung: null };
if (sitzung.isOwner) return { erlaubt: true, sitzung };
const ok = hasPermission(sitzung.permissions, sitzung.overrides, recht);
return { erlaubt: ok, grund: ok ? null : 403, sitzung };
});
}
/* Wer war das?
Zwei Wege fuehren herein, und sie liefern verschiedene Formen:
ueber den Ausweis der Zugangswand kommt {actor, ueberAusweis}, ueber
eine Team-Anmeldung kommt {sitzung}. Wer nur eine der beiden Formen
liest, schreibt bei jedem zweiten Vorgang "unbekannt" in den Verlauf
-- und ausgerechnet beim Annehmen und Ablehnen will man spaeter
wissen, wer entschieden hat. */
function werIst(p) {
if (p.actor) return p.actor; // Ausweis der Zugangswand
if (p.sitzung && p.sitzung.actor) return "team:" + p.sitzung.actor;
return "unbekannt";
}
/* Ein Projekttitel, der nach etwas aussieht.
Der Kunde liest ihn in seinem Portal, also nicht "Anfrage A-2608-0012".
Firma vor Name: "Website für Musterbau GmbH" sagt mehr als "Website
für Anna Muster", wenn beides da ist. */
function titelBauen(anfrage) {
const PAKETNAME = {
onepager: "Onepager", website: "Website", shop: "Onlineshop",
verwaltung: "Verwaltungsbereich", betreuung: "Betreuung", unklar: "Projekt",
};
const was = PAKETNAME[anfrage.paket] || "Projekt";
/* Kein Firmenfeld: wd_anfragen kennt nur einen Namen. Das
Anfrageformular fragt bewusst nicht nach einer Firma -- die meisten
Anfragen kommen von Einzelpersonen, und ein leeres Pflichtfeld
schreckt ab. */
const wer = (anfrage.name || "").trim();
return wer ? `${was} für ${wer}` : was;
}
/* ---------------------------------------------------------------------
VORSCHAU — was passiert, wenn ich jetzt annehme?
Getrennt vom Annehmen selbst, damit der Dialog etwas zeigen kann,
BEVOR etwas geschieht. Ein Bestätigungsdialog, der nur "wirklich?"
fragt, hilft niemandem; einer, der den Termin, die Zahl der
Arbeitsschritte und den bereits bekannten Kunden nennt, schon.
--------------------------------------------------------------------- */
export async function annahmeVorschau(req, res) {
const p = await darf(req, "WD_PROJEKTE_MANAGE");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const anfrage = db.prepare(`SELECT * FROM wd_anfragen WHERE id = ?`)
.get(String(req.params.id || ""));
if (!anfrage) return json(res, { ok: false, error: "Anfrage nicht gefunden." }, 404);
const paket = PAKETE.includes(anfrage.paket) ? anfrage.paket : "unklar";
const v = terminVorschlag(paket, new Date());
/* Gibt es den Menschen schon? E-Mail-Vergleich ohne Rücksicht auf
Groß- und Kleinschreibung: "Anna@..." und "anna@..." sind derselbe
Mensch, und zwei Kundeneinträge für eine Person sind später kaum
wieder zu trennen. */
const kunde = anfrage.email
? db.prepare(`SELECT id, name, email, freigeschaltet FROM wd_kunden
WHERE lower(email) = lower(?) AND archiviert = 0`).get(anfrage.email)
: null;
const schritte = db.prepare(
`SELECT COUNT(*) AS n FROM wd_aufgaben_vorlagen WHERE paket = ? AND aktiv = 1`
).get(paket);
const schonda = db.prepare(
`SELECT id, nummer FROM wd_projekte WHERE anfrage_id = ?`
).get(anfrage.id);
return json(res, {
ok: true,
paket,
titelVorschlag: titelBauen(anfrage),
terminVorschlag: v.termin,
werktage: v.werktage,
laufend: v.laufend,
schritte: schritte ? schritte.n : 0,
kunde: kunde || null,
kundeNeu: !kunde,
bereitsAngenommen: schonda || null,
});
}
/* ---------------------------------------------------------------------
ANNEHMEN
--------------------------------------------------------------------- */
export async function anfrageAnnehmen(req, res) {
const p = await darf(req, "WD_PROJEKTE_MANAGE");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const anfrage = db.prepare(`SELECT * FROM wd_anfragen WHERE id = ?`)
.get(String(req.params.id || ""));
if (!anfrage) return json(res, { ok: false, error: "Anfrage nicht gefunden." }, 404);
/* Schon angenommen? Dann nicht noch einmal. Siehe Kopfkommentar:
dieselbe Antwort, kein Fehler. */
const schonda = db.prepare(
`SELECT id, nummer FROM wd_projekte WHERE anfrage_id = ?`
).get(anfrage.id);
if (schonda) {
return json(res, {
ok: true, bereitsAngenommen: true,
projektId: schonda.id, projektNummer: schonda.nummer,
hinweis: "Diese Anfrage war bereits angenommen — es wurde nichts doppelt angelegt.",
});
}
if (!anfrage.email) {
return json(res, { ok: false,
error: "Diese Anfrage hat keine E-Mail-Adresse. Ohne sie kann kein Zugang entstehen." }, 400);
}
const paket = PAKETE.includes(anfrage.paket) ? anfrage.paket : "unklar";
const titel = text(req.body?.titel, 160) || titelBauen(anfrage);
/* Termin: Vorschlag, sofern nichts anderes kommt. Ein von Hand
gesetzter Termin wird auf seine Form geprüft -- ein "31.13.2026"
aus einem Tippfehler darf nicht in die Datenbank und dort als
Zusage stehen. */
const gewuenscht = text(req.body?.termin, 10);
let termin = null;
let werktage = laufzeitFuer(paket);
if (gewuenscht) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(gewuenscht) || isNaN(new Date(gewuenscht + "T00:00:00Z"))) {
return json(res, { ok: false, error: "Das Terminformat ist ungültig (erwartet: JJJJ-MM-TT)." }, 400);
}
termin = gewuenscht;
werktage = werktageBis(termin, new Date());
} else if (hatLiefertermin(paket)) {
termin = terminVorschlag(paket, new Date()).termin;
}
const preisEuro = Number(req.body?.preisEuro);
const preisCent = Number.isFinite(preisEuro) && preisEuro >= 0 ? Math.round(preisEuro * 100) : null;
/* Wie beim Anlegen von Hand: Die 30 % werden gerechnet, nicht
eingetippt. Ein Tippfehler in der Anzahlung faellt sonst erst beim
Geldeingang auf. */
const anzahlungCent = preisCent != null ? Math.round(preisCent * 0.3) : null;
let kundeId = null;
let kundeWarNeu = false;
let projektId = null;
let projektNummer = null;
let rohToken = null;
let angelegteSchritte = 0;
try {
db.transaction(() => {
/* ---- 1. Kunde: finden oder anlegen ---- */
const vorhanden = db.prepare(
`SELECT id FROM wd_kunden WHERE lower(email) = lower(?) AND archiviert = 0`
).get(anfrage.email);
if (vorhanden) {
kundeId = vorhanden.id;
/* Ein gesperrter Bestandskunde wird durch eine Annahme NICHT
still entsperrt -- das wäre eine Entscheidung, die hier
niemand getroffen hat. Freigeschaltet wird nur, wer noch
nicht freigeschaltet war. */
db.prepare(
`UPDATE wd_kunden SET freigeschaltet = 1, aktualisiert_am = ?
WHERE id = ? AND gesperrt = 0`
).run(jetzt(), kundeId);
} else {
kundeWarNeu = true;
kundeId = neueId();
db.prepare(
`INSERT INTO wd_kunden (id, email, name, firma, telefon, sprache,
email_bestaetigt, freigeschaltet, gesperrt, erstellt_am)
VALUES (?,?,?,?,?,?,0,1,0,?)`
).run(
kundeId, anfrage.email, anfrage.name || anfrage.email,
/* Firma gibt es in einer Anfrage nicht -- bewusst null statt
undefined: better-sqlite3 weist undefined mit einem Fehler
ab, und der haette die ganze Annahme scheitern lassen. */
null, anfrage.telefon || null,
anfrage.sprache || "de", jetzt()
);
}
/* ---- 2. Projekt ---- */
projektId = neueId();
projektNummer = naechsteNummer("wd_projekte", "P");
db.prepare(
`INSERT INTO wd_projekte
(id, nummer, kunde_id, anfrage_id, titel, paket, beschreibung,
status, naechster_schritt, wartet_auf, preis_cent, anzahlung_cent,
waehrung, start_am, termin_am, angenommen_am, termin_werktage, erstellt_am)
VALUES (?,?,?,?,?,?,?,'briefing',?,'kunde',?,?,'EUR',?,?,?,?,?)`
).run(
projektId, projektNummer, kundeId, anfrage.id, titel, paket,
anfrage.details || null,
/* Der erste Schritt liegt beim KUNDEN, nicht bei mir: Ohne
Texte, Bilder und Zugänge kann nichts beginnen. Stünde hier
"dogfather", sähe der Kunde in seinem Portal "wird bearbeitet"
und wüsste nicht, dass auf ihn gewartet wird. */
"Unterlagen zusammenstellen — Texte, Bilder, Zugänge",
preisCent, anzahlungCent,
jetzt(), termin, jetzt(), werktage, jetzt()
);
/* ---- 3. Aufgabenliste aus der Vorlage ---- */
const v = vorlageAnwenden(projektId, kundeId, paket);
angelegteSchritte = v.neu;
/* ---- 4. Anfrage als angenommen vermerken ---- */
db.prepare(
`UPDATE wd_anfragen SET status = 'angenommen', kunde_id = ?, aktualisiert_am = ?
WHERE id = ?`
).run(kundeId, jetzt(), anfrage.id);
/* ---- 5. Zugang ----
Der Link entsteht INNERHALB der Transaktion, weil er ein
Datenbankeintrag ist. Scheitert etwas danach, gibt es auch
keinen halb gültigen Zugang. */
if (kundeWarNeu) rohToken = einladungAnlegen(kundeId, "einladung");
})();
} catch (fehler) {
return json(res, { ok: false,
error: "Beim Annehmen ist etwas schiefgegangen — es wurde nichts angelegt.",
einzelheit: String(fehler && fehler.message || fehler).slice(0, 300) }, 500);
}
verlauf("anfrage", anfrage.id, "angenommen", werIst(p),
`${projektNummer} · ${paket}${termin ? " · Termin " + termin : ""}`);
verlauf("projekt", projektId, "angelegt_aus_anfrage", werIst(p),
`${angelegteSchritte} Arbeitsschritte`);
return json(res, {
ok: true,
projektId,
projektNummer,
kundeId,
kundeNeu: kundeWarNeu,
termin,
werktage,
schritte: angelegteSchritte,
/* Der Link ist nur JETZT sichtbar. Danach steht in der Datenbank
nur noch sein Abdruck -- wie beim Anlegen von Hand auch. */
einladungsLink: rohToken ? einladungsLink(rohToken) : null,
hinweis: kundeWarNeu
? "Der Einladungslink ist nur jetzt sichtbar. Schick ihn dem Kunden."
: "Der Kunde hatte bereits einen Zugang — er sieht das Projekt sofort.",
});
}
/* ---------------------------------------------------------------------
ABLEHNEN
Der Absagetext entsteht auf dem SERVER, nicht im Browser.
Er ist eine geschäftliche Äußerung, die beim Empfänger ankommt. Käme
er aus dem Browser, könnte man ihn unterwegs verändern — und im
Nachhinein wäre nicht mehr feststellbar, was tatsächlich geschrieben
wurde. Derselbe Grund, aus dem der Zustimmungstext beim Bezahlen
serverseitig festgehalten wird.
--------------------------------------------------------------------- */
export const ABLEHNUNGSGRUENDE = [
"kein_zeitfenster", "passt_nicht", "budget", "zu_gross", "kein_kontakt", "sonstiges",
];
/* Fünf Sprachen, weil die Seite fünf Sprachen hat. Eine Absage auf
Deutsch an jemanden, der auf Portugiesisch angefragt hat, ist die
letzte Nachricht, die dieser Mensch von hier bekommt -- die sollte
sitzen. */
const ABSAGE = {
de: {
anrede: (n) => `Hallo ${n},`,
dank: "vielen Dank für deine Anfrage und das Vertrauen.",
gruende: {
kein_zeitfenster: "Leider habe ich im gewünschten Zeitraum keine freien Kapazitäten, um dein Projekt so umzusetzen, wie es das verdient hätte.",
passt_nicht: "Nach dem Durchlesen glaube ich, dass ich nicht der Richtige dafür bin — du wärst bei jemandem mit einem anderen Schwerpunkt besser aufgehoben.",
budget: "Der Aufwand, den dein Vorhaben braucht, passt leider nicht zum vorgesehenen Budget. Ich möchte dir lieber absagen, als etwas Halbes zu bauen.",
zu_gross: "Dein Vorhaben ist größer, als ich es allein sauber stemmen kann. Eine Zusage wäre unehrlich.",
kein_kontakt: "Leider konnte ich dich trotz mehrerer Versuche nicht erreichen. Ich schließe die Anfrage deshalb vorerst.",
sonstiges: "Leider kann ich dein Projekt derzeit nicht übernehmen.",
},
schluss: "Wenn sich bei dir etwas ändert oder du später noch einmal anfragen möchtest, melde dich jederzeit gern.",
gruss: "Viele Grüße\nDogfather Webdesign",
},
"de-CH": {
anrede: (n) => `Hallo ${n},`,
dank: "vielen Dank für deine Anfrage und das Vertrauen.",
gruende: {
kein_zeitfenster: "Leider habe ich im gewünschten Zeitraum keine freien Kapazitäten, um dein Projekt so umzusetzen, wie es das verdient hätte.",
passt_nicht: "Nach dem Durchlesen glaube ich, dass ich nicht der Richtige dafür bin — du wärst bei jemandem mit einem anderen Schwerpunkt besser aufgehoben.",
budget: "Der Aufwand, den dein Vorhaben braucht, passt leider nicht zum vorgesehenen Budget. Ich möchte dir lieber absagen, als etwas Halbes zu bauen.",
zu_gross: "Dein Vorhaben ist grösser, als ich es allein sauber stemmen kann. Eine Zusage wäre unehrlich.",
kein_kontakt: "Leider konnte ich dich trotz mehrerer Versuche nicht erreichen. Ich schliesse die Anfrage deshalb vorerst.",
sonstiges: "Leider kann ich dein Projekt derzeit nicht übernehmen.",
},
schluss: "Wenn sich bei dir etwas ändert oder du später noch einmal anfragen möchtest, melde dich jederzeit gern.",
gruss: "Viele Grüsse\nDogfather Webdesign",
},
en: {
anrede: (n) => `Hello ${n},`,
dank: "thank you for your enquiry and for thinking of me.",
gruende: {
kein_zeitfenster: "Unfortunately I have no capacity in the period you need, and I would not be able to give your project the attention it deserves.",
passt_nicht: "Having read it through, I do not think I am the right person for this — someone with a different focus would serve you better.",
budget: "The effort your project needs does not match the budget you have in mind. I would rather turn it down than build something half-finished.",
zu_gross: "Your project is larger than I can properly handle on my own. Saying yes would not be honest.",
kein_kontakt: "Unfortunately I was unable to reach you despite several attempts, so I am closing the enquiry for now.",
sonstiges: "Unfortunately I am not able to take on your project at the moment.",
},
schluss: "If anything changes at your end, or you would like to get in touch again later, you are very welcome to do so.",
gruss: "Best regards\nDogfather Webdesign",
},
fr: {
anrede: (n) => `Bonjour ${n},`,
dank: "merci beaucoup pour votre demande et pour votre confiance.",
gruende: {
kein_zeitfenster: "Malheureusement, je n'ai pas de disponibilité sur la période souhaitée pour réaliser votre projet comme il le mériterait.",
passt_nicht: "Après lecture, je ne pense pas être la bonne personne pour ce projet — quelqu'un avec une autre spécialité vous conviendrait mieux.",
budget: "Le travail nécessaire ne correspond malheureusement pas au budget prévu. Je préfère refuser plutôt que de livrer quelque chose d'incomplet.",
zu_gross: "Votre projet est plus important que ce que je peux mener seul correctement. Accepter ne serait pas honnête.",
kein_kontakt: "Malheureusement, je n'ai pas réussi à vous joindre malgré plusieurs tentatives. Je clôture donc la demande pour le moment.",
sonstiges: "Malheureusement, je ne peux pas prendre en charge votre projet actuellement.",
},
schluss: "Si les choses évoluent de votre côté ou si vous souhaitez me recontacter plus tard, n'hésitez surtout pas.",
gruss: "Bien cordialement\nDogfather Webdesign",
},
pt: {
anrede: (n) => `Olá ${n},`,
dank: "muito obrigado pelo seu pedido e pela confiança.",
gruende: {
kein_zeitfenster: "Infelizmente não tenho disponibilidade no período pretendido para realizar o seu projeto como ele merecia.",
passt_nicht: "Depois de ler com atenção, acho que não sou a pessoa certa para isto — alguém com outra especialidade serviria melhor.",
budget: "O trabalho necessário não corresponde ao orçamento previsto. Prefiro recusar a entregar algo incompleto.",
zu_gross: "O seu projeto é maior do que aquilo que consigo fazer sozinho com qualidade. Aceitar não seria honesto.",
kein_kontakt: "Infelizmente não consegui contactá-lo apesar de várias tentativas, pelo que encerro o pedido por agora.",
sonstiges: "Infelizmente não posso assumir o seu projeto neste momento.",
},
schluss: "Se algo mudar do seu lado, ou se quiser voltar a contactar-me mais tarde, será muito bem-vindo.",
gruss: "Com os melhores cumprimentos\nDogfather Webdesign",
},
};
export function absageText(sprache, grund, name) {
const s = ABSAGE[sprache] || ABSAGE.de;
const g = s.gruende[grund] || s.gruende.sonstiges;
const wer = (name || "").trim() || (sprache === "fr" ? "Madame, Monsieur" : "");
return [
wer ? s.anrede(wer) : s.anrede("").replace(/\s+,$/, ","),
"",
s.dank,
"",
g,
"",
s.schluss,
"",
s.gruss,
].join("\n");
}
/* Vorschau des Absagetexts, ohne etwas zu verändern. */
export async function absageVorschau(req, res) {
const p = await darf(req, "WD_ANFRAGEN_MANAGE");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const anfrage = db.prepare(`SELECT name, sprache FROM wd_anfragen WHERE id = ?`)
.get(String(req.params.id || ""));
if (!anfrage) return json(res, { ok: false, error: "Anfrage nicht gefunden." }, 404);
const grund = text(req.query?.grund, 40) || "sonstiges";
return json(res, {
ok: true,
sprache: anfrage.sprache || "de",
grund: ABLEHNUNGSGRUENDE.includes(grund) ? grund : "sonstiges",
text: absageText(anfrage.sprache || "de",
ABLEHNUNGSGRUENDE.includes(grund) ? grund : "sonstiges", anfrage.name),
});
}
export async function anfrageAblehnen(req, res) {
const p = await darf(req, "WD_ANFRAGEN_MANAGE");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const anfrage = db.prepare(`SELECT * FROM wd_anfragen WHERE id = ?`)
.get(String(req.params.id || ""));
if (!anfrage) return json(res, { ok: false, error: "Anfrage nicht gefunden." }, 404);
/* Eine angenommene Anfrage abzulehnen wäre ein Widerspruch: Das
Projekt läuft bereits, der Kunde sieht es in seinem Portal. Wer
wirklich abbrechen will, tut das am Projekt (Status "abgebrochen")
-- dort hängen Zahlungen und Widerrufsfristen dran. */
const schonda = db.prepare(`SELECT nummer FROM wd_projekte WHERE anfrage_id = ?`).get(anfrage.id);
if (schonda) {
return json(res, { ok: false,
error: `Zu dieser Anfrage läuft bereits das Projekt ${schonda.nummer}. Brich es dort ab, statt die Anfrage abzulehnen.` }, 409);
}
const grund = ABLEHNUNGSGRUENDE.includes(text(req.body?.grund, 40))
? text(req.body?.grund, 40) : "sonstiges";
const senden = req.body?.senden === true;
const sprache = anfrage.sprache || "de";
const nachricht = absageText(sprache, grund, anfrage.name);
db.transaction(() => {
db.prepare(
`UPDATE wd_anfragen
SET status = 'abgelehnt', ablehnung_grund = ?, ablehnung_am = ?,
absage_gesendet_am = ?, aktualisiert_am = ?
WHERE id = ?`
).run(grund, jetzt(), senden ? jetzt() : null, jetzt(), anfrage.id);
/* Die Absage landet im Postfach, wenn es einen Kundeneintrag gibt.
Ohne Kundeneintrag gibt es keinen Ort dafür -- dann bekommt man
den Text zum Selbstverschicken zurück, statt ihn ins Leere zu
schreiben und "gesendet" zu vermerken. */
if (senden && anfrage.kunde_id) {
db.prepare(
`INSERT INTO wd_postfach (id, kunde_id, autor, text, gelesen_kunde, gelesen_admin, intern, erstellt_am)
VALUES (?,?, 'dogfather', ?, 0, 1, 0, ?)`
).run(neueId(), anfrage.kunde_id, nachricht, jetzt());
}
})();
verlauf("anfrage", anfrage.id, "abgelehnt", werIst(p),
grund + (senden ? " · Absage verschickt" : " · nur intern"));
const imPostfach = senden && !!anfrage.kunde_id;
return json(res, {
ok: true,
grund,
sprache,
text: nachricht,
imPostfach,
hinweis: senden && !imPostfach
? "Der Mensch hat noch keinen Kundenzugang — der Text steht hier zum Kopieren, verschick ihn per E-Mail."
: null,
});
}