Workers P2: Formularlogik der Einstellungen (Prüfung, Patch nur Geändertes)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f4ce410345
commit
85b5f3cedb
2 files changed
+268
No files matched your search
@@ -0,0 +1,184 @@
|
||||
// Formularlogik der Hustler-Einstellungen: Umwandlung Einstellungen <-> Formular, Prüfung mit den Grenzen des
|
||||
// Dienstes (P1 settings.js, gleich oder enger) und ein Patch mit nur den geänderten, getypten Feldern.
|
||||
// Reine Funktionen: kein React, kein Appwrite, kein window/document; unter Node testbar (loadModule).
|
||||
|
||||
import { ROOM_TITLES, SEARCH_ROOMS } from './workersRoster.js'
|
||||
|
||||
// Die acht Such-Räume (Etagen anfragen und ohne_website, Rollenreihenfolge) für die Raumauswahl
|
||||
export const ROOM_CHOICES = Object.freeze(SEARCH_ROOMS.map((id) => Object.freeze({ id, title: ROOM_TITLES[id] })))
|
||||
|
||||
// Nie bearbeitbar, nie im Patch (Übersicht §7): paused hat den eigenen Schalter, connectedRooms setzt der Dienst,
|
||||
// privacyInteressentenLive der Datenschutz-Schalter (A5), sendingHalted/sendingHaltedReason die Notbremse (H4;
|
||||
// aufgehoben wird sie nur über den Knopf des Dialogs), offerProductKey erlaubt der Dienst nur als 'hosting'.
|
||||
// previewsSent, privacyInteressentenLive, sendingHalted und sendingHaltedReason sind optional (alte Dienstantwort).
|
||||
export const READONLY_KEYS = Object.freeze(['paused', 'previewsSent', 'updatedAt', 'updatedBy', 'offerProductKey', 'connectedRooms', 'privacyInteressentenLive', 'sendingHalted', 'sendingHaltedReason'])
|
||||
|
||||
const ROOM_IDS = ROOM_CHOICES.map((c) => c.id)
|
||||
|
||||
// Feldregeln als Daten: Typ, Grenzen, Fehlertext. Nur bearbeitbare Felder; die Reihenfolge ist die der Fehlerausgabe.
|
||||
// betrag Dezimalzahl > gt (optional <= max), Komma oder Punkt
|
||||
// ganz ganze Zahl min..max
|
||||
// zeit genau zweistellig HH:MM (00:00 bis 23:59), wie im Dienst
|
||||
// schalter Ja/Nein raeume Id-Liste aus ROOM_CHOICES zeilen Textfeld, eine Region je Zeile
|
||||
// wahl feste Werte text getrimmt, min..max Zeichen mail einfache Adresse
|
||||
const ZEIT_FEHLER = 'Uhrzeit als HH:MM, Beginn vor Ende.'
|
||||
const RULES = [
|
||||
{ key: 'dailyBudgetUsd', typ: 'betrag', gt: 0, fehler: 'Tagesgrenze muss größer als 0 sein.' },
|
||||
{ key: 'monthlyBudgetUsd', typ: 'betrag', gt: 0, max: 150, fehler: 'Monatsgrenze muss größer als 0 sein.', fehlerMax: 'Monatsgrenze höchstens 150 $ (Vorgabe: max. 150 € im Monat).' },
|
||||
{ key: 'warnPct', typ: 'ganz', min: 1, max: 100, fehler: 'Warnschwelle: ganze Zahl von 1 bis 100.' },
|
||||
{ key: 'mailColdPerDay', typ: 'ganz', min: 0, max: 3, fehler: 'Höchstens 3 unverlangte Mails pro Tag.' },
|
||||
{ key: 'mailReplyCapPerDay', typ: 'ganz', min: 0, max: 15, fehler: 'Höchstens 15 Antwort-Mails pro Tag.' },
|
||||
{ key: 'sendWindowStart', typ: 'zeit', fehler: ZEIT_FEHLER },
|
||||
{ key: 'sendWindowEnd', typ: 'zeit', fehler: ZEIT_FEHLER },
|
||||
{ key: 'sendWeekdaysOnly', typ: 'schalter', fehler: 'Bitte Ja oder Nein wählen.' },
|
||||
{ key: 'runsPerNight', typ: 'ganz', min: 1, max: 2, fehler: 'Läufe pro Nacht: 1 oder 2.' },
|
||||
{ key: 'candidatesPerRun', typ: 'ganz', min: 1, max: 20, fehler: 'Kandidaten pro Lauf: 1 bis 20.' },
|
||||
{ key: 'activeRooms', typ: 'raeume', fehler: 'Ungültige Raumauswahl.' },
|
||||
{ key: 'regions', typ: 'zeilen', min: 1, max: 30, maxLaenge: 64, fehler: 'Mindestens eine Region, höchstens 30, je Zeile höchstens 64 Zeichen.' },
|
||||
{ key: 'previewGate', typ: 'wahl', werte: ['erste_n', 'immer', 'nie'], fehler: 'Vorschau-Freigabe: bitte eine Auswahl treffen.' },
|
||||
{ key: 'previewGateN', typ: 'ganz', min: 0, max: 1000, fehler: 'Anzahl Vorschauen: ganze Zahl von 0 bis 1000.' },
|
||||
// 400 Zeichen halten eine Kaltmail unter 180 Wörtern (H3/A11)
|
||||
{ key: 'offerText', typ: 'text', min: 1, max: 400, fehler: 'Angebotstext: 1 bis 400 Zeichen.' },
|
||||
{ key: 'pultStaleDays', typ: 'ganz', min: 1, max: 30, fehler: 'Pult-Erinnerung: 1 bis 30 Tage.' },
|
||||
{ key: 'followupDays', typ: 'ganz', min: 1, max: 60, fehler: 'Nachfassen: 1 bis 60 Tage.' },
|
||||
{ key: 'previewReminderDays', typ: 'ganz', min: 1, max: 60, fehler: 'Vorschau-Erinnerung: 1 bis 60 Tage.' },
|
||||
// der Datenschutztext verspricht höchstens 90 Tage (A11)
|
||||
{ key: 'retentionDays', typ: 'ganz', min: 7, max: 90, fehler: 'Aufbewahrung: 7 bis 90 Tage.' },
|
||||
{ key: 'previewDeleteDays', typ: 'ganz', min: 7, max: 365, fehler: 'Vorschau löschen nach: 7 bis 365 Tage.' },
|
||||
{ key: 'reportEmail', typ: 'mail', max: 256, fehler: 'Bitte eine gültige E-Mail-Adresse eintragen.' },
|
||||
]
|
||||
|
||||
const DEZIMAL = /^-?\d+(?:\.\d+)?$/
|
||||
const GANZ = /^-?\d+$/
|
||||
const ZEIT = /^(?:[01]\d|2[0-3]):[0-5]\d$/
|
||||
const MAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
// Text oder Zahl -> endliche Zahl, sonst null. Dezimalkomma gilt wie Punkt; ganze Zahlen ohne Nachkommateil
|
||||
// (so wird aus „1.000“ nie stillschweigend 1), keine Exponentschreibweise, kein leerer Text.
|
||||
function alsZahl(roh, ganz) {
|
||||
if (typeof roh === 'number') return Number.isFinite(roh) && (!ganz || Number.isInteger(roh)) ? roh : null
|
||||
if (typeof roh !== 'string') return null
|
||||
const t = ganz ? roh.trim() : roh.trim().replace(',', '.')
|
||||
if (!(ganz ? GANZ : DEZIMAL).test(t)) return null
|
||||
const n = Number(t)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
// Id-Liste in ROOM_CHOICES-Reihenfolge, nur bekannte Räume
|
||||
function raumListe(liste) {
|
||||
return ROOM_IDS.filter((id) => liste.includes(id))
|
||||
}
|
||||
|
||||
// Formularwert -> { wert } getypt oder { fehler } mit dem Text der Regel
|
||||
function lesen(r, roh) {
|
||||
switch (r.typ) {
|
||||
case 'betrag': {
|
||||
const n = alsZahl(roh, false)
|
||||
if (n === null || n <= r.gt) return { fehler: r.fehler }
|
||||
if (r.max !== undefined && n > r.max) return { fehler: r.fehlerMax }
|
||||
return { wert: n }
|
||||
}
|
||||
case 'ganz': {
|
||||
const n = alsZahl(roh, true)
|
||||
return n === null || n < r.min || n > r.max ? { fehler: r.fehler } : { wert: n }
|
||||
}
|
||||
case 'zeit': {
|
||||
const t = typeof roh === 'string' ? roh.trim() : ''
|
||||
return ZEIT.test(t) ? { wert: t } : { fehler: r.fehler }
|
||||
}
|
||||
case 'schalter':
|
||||
return typeof roh === 'boolean' ? { wert: roh } : { fehler: r.fehler }
|
||||
case 'raeume':
|
||||
return Array.isArray(roh) ? { wert: raumListe(roh) } : { fehler: r.fehler }
|
||||
case 'zeilen': {
|
||||
const zeilen = Array.isArray(roh) ? roh : typeof roh === 'string' ? roh.split(/\r?\n/) : null
|
||||
if (!zeilen) return { fehler: r.fehler }
|
||||
const wert = zeilen.map((z) => (typeof z === 'string' ? z.trim() : '')).filter((z) => z !== '')
|
||||
return wert.length < r.min || wert.length > r.max || wert.some((z) => z.length > r.maxLaenge) ? { fehler: r.fehler } : { wert }
|
||||
}
|
||||
case 'wahl':
|
||||
return r.werte.includes(roh) ? { wert: roh } : { fehler: r.fehler }
|
||||
case 'text': {
|
||||
const t = typeof roh === 'string' ? roh.trim() : ''
|
||||
return t.length < r.min || t.length > r.max ? { fehler: r.fehler } : { wert: t }
|
||||
}
|
||||
case 'mail': {
|
||||
const t = typeof roh === 'string' ? roh.trim() : ''
|
||||
return t.length <= r.max && MAIL.test(t) ? { wert: t } : { fehler: r.fehler }
|
||||
}
|
||||
default:
|
||||
return { fehler: r.fehler }
|
||||
}
|
||||
}
|
||||
|
||||
// Gespeicherter Wert -> Formularwert: Zahlen als Text, regions als Zeilen, Räume als Id-Liste
|
||||
function anzeige(r, wert) {
|
||||
switch (r.typ) {
|
||||
case 'betrag':
|
||||
case 'ganz':
|
||||
return typeof wert === 'number' ? String(wert) : ''
|
||||
case 'schalter':
|
||||
return wert === true
|
||||
case 'raeume':
|
||||
return Array.isArray(wert) ? [...wert] : []
|
||||
case 'zeilen':
|
||||
return Array.isArray(wert) ? wert.join('\n') : ''
|
||||
default:
|
||||
return typeof wert === 'string' ? wert : ''
|
||||
}
|
||||
}
|
||||
|
||||
function gleich(a, b) {
|
||||
if (Array.isArray(a) || Array.isArray(b)) return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((x, i) => x === b[i])
|
||||
return a === b
|
||||
}
|
||||
|
||||
/** Einstellungen des Dienstes -> Formular. Schreibgeschützte Felder, die da sind, stehen unverändert zur Anzeige drin. */
|
||||
export function settingsToForm(settings) {
|
||||
const s = settings ?? {}
|
||||
const form = {}
|
||||
for (const r of RULES) form[r.key] = anzeige(r, s[r.key])
|
||||
for (const k of READONLY_KEYS) {
|
||||
if (k in s) form[k] = Array.isArray(s[k]) ? [...s[k]] : s[k]
|
||||
}
|
||||
return form
|
||||
}
|
||||
|
||||
/** Prüfung mit den Grenzen des Dienstes: { ok, errors: { [feld]: 'Text' } }. Schreibgeschützte Felder werden nie geprüft. */
|
||||
export function validateForm(form) {
|
||||
const f = form ?? {}
|
||||
const errors = {}
|
||||
const gelesen = {}
|
||||
for (const r of RULES) {
|
||||
gelesen[r.key] = lesen(r, f[r.key])
|
||||
if (gelesen[r.key].fehler) errors[r.key] = gelesen[r.key].fehler
|
||||
}
|
||||
// Tagesgrenze nicht über der Monatsgrenze (Vergleich mit dem Zahlenwert, auch wenn die Monatsgrenze selbst zu hoch ist)
|
||||
const tag = gelesen.dailyBudgetUsd.wert
|
||||
const monat = alsZahl(f.monthlyBudgetUsd, false)
|
||||
if (tag !== undefined && monat !== null && monat > 0 && tag > monat) errors.dailyBudgetUsd = 'Die Tagesgrenze darf nicht über der Monatsgrenze liegen.'
|
||||
// Beginn vor Ende (beide HH:MM, also reicht der Textvergleich); gleich ist nicht „vor“
|
||||
const von = gelesen.sendWindowStart.wert
|
||||
const bis = gelesen.sendWindowEnd.wert
|
||||
if (von !== undefined && bis !== undefined && von >= bis) errors.sendWindowStart = ZEIT_FEHLER
|
||||
return { ok: Object.keys(errors).length === 0, errors }
|
||||
}
|
||||
|
||||
/**
|
||||
* Formular -> Patch mit nur den geänderten, getypten Feldern gegenüber `original`. Nie READONLY_KEYS (es werden
|
||||
* nur die Felder aus RULES gelesen). Ein Feld, das seine eigene Regel verletzt, bleibt draußen (nie NaN oder
|
||||
* Text statt Zahl im Patch); die Meldung dazu kommt aus validateForm, das vor dem Senden läuft.
|
||||
* Räume: reihenfolgeunabhängig verglichen, in ROOM_CHOICES-Reihenfolge ausgegeben; Regionen: reihenfolgetreu.
|
||||
*/
|
||||
export function formToPatch(form, original) {
|
||||
const f = form ?? {}
|
||||
const o = original ?? {}
|
||||
const patch = {}
|
||||
for (const r of RULES) {
|
||||
const g = lesen(r, f[r.key])
|
||||
if (g.fehler) continue
|
||||
const alt = r.typ === 'raeume' && Array.isArray(o[r.key]) ? raumListe(o[r.key]) : o[r.key]
|
||||
if (!gleich(g.wert, alt)) patch[r.key] = g.wert
|
||||
}
|
||||
return patch
|
||||
}
|
||||
Reference in new issue
Block a user