|
|
|
@@ -0,0 +1,486 @@
|
|
|
|
|
/* =====================================================================
|
|
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
}
|