DER LEITGEDANKE: SO WENIG TIPPEN WIE MOEGLICH Niemand schreibt hier den Leistungsumfang. Er entsteht aus der Aufgabenvorlage des Pakets -- denselben 92 Punkten, nach denen spaeter gearbeitet wird. Der Nebeneffekt ist wichtiger als die gesparte Tipparbeit: Was im Angebot steht, IST die Arbeitsliste. Ein von Hand geschriebenes Angebot und eine getrennt gepflegte Aufgabenliste laufen unweigerlich auseinander -- und dann steht im Angebot etwas, das niemand abarbeitet. Zu tun bleibt: Paket waehlen, Preis bestaetigen. Beides ist vorbelegt, Laufzeit und Ablaufdatum werden gerechnet. DER WORTLAUT WIRD BEIM ABSENDEN EINGEFROREN Ein angenommenes Angebot ist ein Vertrag. Bei einem Streit zaehlt, WAS dem Kunden gezeigt wurde, als er zusagte. Wuerde der Text bei jeder Ansicht neu aus den Vorlagen erzeugt, staende nach der naechsten Vorlagenaenderung etwas anderes da als damals. Die Zusage wird mit Zeitpunkt und (gehashter) Herkunft belegt -- die Beweislast liegt beim Unternehmer. WAS AUTOMATISCH PASSIERT Angebot raus -> Anfrage steht auf 'angebot' (der Wunsch: 'wenn ich ein angebot rausschicke soll der automatisch das erkennen'). Zusage -> Projekt angelegt, Aufgabenliste eingesetzt, Anzahlung als offene Rechnung erzeugt, Anfrage auf 'angenommen', Benachrichtigung an mich. Die UHR startet dabei NICHT. Sie startet erst mit dem Zahlungseingang -- das ist die Regel aus dem vorigen Schritt, und sie gilt auch dann, wenn der Kunde selbst zugesagt hat. Genau dieser Punkt wird ausdruecklich geprueft: Es fuehlt sich richtig an, mit der Zusage loszulegen. ZWEI FEHLER IN DER GELDANZEIGE GEFUNDEN Beide fielen erst auf, als das erste Angebot ueber tausend Euro entstand -- alle frueheren Testbetraege lagen darunter: - Kein Tausenderpunkt: 1490 Euro erschienen als '1490,00 €'. - Das Minuszeichen ging verloren: Math.trunc(-0.5) ergibt -0, und -0 schreibt sich als '0'. Eine ERSTATTUNG von 50 Cent erschien damit als '0,50 €' -- also wie eine Forderung. Ausgerechnet beim Abbruch mit Rueckzahlung waere das der falsche Ort fuer einen Anzeigefehler. Bewusst von Hand statt ueber Intl.NumberFormat: Die Ausgabe muss auf dem Server und im Browser zeichengleich sein -- ein Betrag, der in der Verwaltung anders aussieht als im Portal, saet Zweifel an der Zahl. Geprueft: 56 zu den Angeboten, 53 zur Automatik (inkl. der neun Geldpruefungen), 70 zur Annahme, 28 zur Suche -- alle gegen eine echte Datenbank. Darunter: zweiter Klick legt nichts doppelt an, abgelaufene Angebote lassen sich nicht mehr annehmen (ein Angebot, das man sieht aber nicht annehmen kann, waere eine Falle), ein fremder Kunde erfaehrt nicht einmal, dass ein Angebot existiert. Co-Authored-By: Claude Opus 5 <[email protected]>
87 lines
3.9 KiB
JavaScript
87 lines
3.9 KiB
JavaScript
/* =====================================================================
|
|
db.js — SQLite-Datenbank (better-sqlite3), Ersatz für Cloudflare D1.
|
|
Führt beim ersten Start alle Migrationen aus ../cloudflare-worker/migrations/
|
|
in der ursprünglichen Reihenfolge aus (0001 Basis-Schema, 0002 app_settings,
|
|
0003 Supporter-Abo, 0004 TikTok/Discord-Spalten, 0005 Supporter-Abstimmungen)
|
|
— anders als bei VanVans Shop ist hier NIE alles in einer konsolidierten
|
|
schema.sql zusammengefasst worden, deshalb müssen wir hier tatsächlich alle
|
|
der Reihe nach anwenden.
|
|
===================================================================== */
|
|
|
|
import Database from "better-sqlite3";
|
|
import { readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const DB_PATH = process.env.DB_PATH || "/var/lib/dogfather-internal/dogfather-internal.db";
|
|
const MIGRATIONS_DIR = join(__dirname, "..", "cloudflare-worker", "migrations");
|
|
|
|
mkdirSync(dirname(DB_PATH), { recursive: true });
|
|
|
|
export const db = new Database(DB_PATH);
|
|
db.pragma("journal_mode = WAL");
|
|
db.pragma("foreign_keys = ON");
|
|
|
|
const MIGRATION_FILES = [
|
|
"0001_init.sql",
|
|
"0002_app_settings.sql",
|
|
"0003_supporter_abo.sql",
|
|
"0004_supporter_oauth_providers.sql",
|
|
"0005_supporter_polls.sql",
|
|
"0006_testimonials.sql",
|
|
"0007_team_members.sql",
|
|
"0008_team_members_rich.sql",
|
|
"0009_supporter_access_code.sql",
|
|
"0010_testimonials_avatar.sql",
|
|
// Webdesign-Bereich (22.08.2026): Anfragen, Kunden, Projekte, Dateien,
|
|
// Nachrichten, Änderungswünsche, pflegbare Inhalte, Änderungsverlauf.
|
|
// Alle Tabellen tragen das Präfix "wd_" und sind fachlich vollständig
|
|
// vom Universe getrennt — Begründung im Kopf der Migration.
|
|
"0011_webdesign.sql",
|
|
// Zahlungen im Webdesign-Bereich (22.08.2026): PayPal-Einmalzahlungen
|
|
// fuer Anzahlung/Restbetrag/Zusatzangebote, PayPal-Abo fuer die
|
|
// monatliche Betreuung, Webhook-Merkliste gegen Doppelverbuchung.
|
|
"0012_webdesign_zahlungen.sql",
|
|
// Aufgabenliste je Projekt ("was ist schon gemacht von dem, was im Plan
|
|
// war") und ein allgemeines Postfach fuer Nachrichten OHNE Projektbezug
|
|
// (22.08.2026). Begruendung fuer die getrennte Postfach-Tabelle steht
|
|
// im Kopf der Migration.
|
|
"0013_webdesign_aufgaben.sql",
|
|
// Elektronische Widerrufsfunktion (§ 356a BGB, seit 19.06.2026) und der
|
|
// Nachweis der Zustimmung zum vorzeitigen Leistungsbeginn (§ 356 Abs. 4
|
|
// BGB). Ausfuehrliche Begruendung im Kopf der Migration -- kurz: Im
|
|
// Portal werden Zusatzangebote per Klick verbindlich angenommen, das ist
|
|
// ein Vertragsschluss ueber eine Online-Benutzeroberflaeche.
|
|
"0014_webdesign_widerruf.sql",
|
|
// Annehmen und Ablehnen einer Anfrage: echter Liefertermin (termin_am)
|
|
// fuer den Countdown, Zeitpunkt der Annahme, Ablehnungsgrund.
|
|
"0015_webdesign_annahme.sql",
|
|
// Die Uhr laeuft erst bei Anzahlung; Abbruch mit Erstattung;
|
|
// Benachrichtigungen fuer die Verwaltung.
|
|
"0016_webdesign_automatik.sql",
|
|
// Angebote mit Zusage im Portal.
|
|
"0017_webdesign_angebote.sql",
|
|
];
|
|
|
|
function alreadyApplied(name) {
|
|
const row = db.prepare(`SELECT 1 FROM _migrations WHERE name = ?`).get(name);
|
|
return !!row;
|
|
}
|
|
|
|
export function initDb() {
|
|
db.exec(`CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)`);
|
|
for (const file of MIGRATION_FILES) {
|
|
if (alreadyApplied(file)) continue;
|
|
const path = join(MIGRATIONS_DIR, file);
|
|
if (!existsSync(path)) {
|
|
console.warn(`⚠️ Migration ${file} nicht gefunden unter ${path}, übersprungen.`);
|
|
continue;
|
|
}
|
|
const sql = readFileSync(path, "utf-8");
|
|
db.exec(sql);
|
|
db.prepare(`INSERT INTO _migrations (name, applied_at) VALUES (?, ?)`).run(file, new Date().toISOString());
|
|
console.log(`Migration angewendet: ${file}`);
|
|
}
|
|
}
|