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]>
487 lines
22 KiB
JavaScript
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,
|
|
});
|
|
}
|