Merge remote-tracking branch 'origin/main' into feature/oeffnungszeiten
Zusammenführung von TP2-Abschluss und Webshop (origin/main cab9c33) mit Öffnungszeiten live. Beide
Funktionen bleiben vollständig: jeder Konflikt ist als Vereinigung gelöst, Reihenfolge der Funktionsschlüssel
überall news, termine, shop, zeiten.
Konfliktdateien (28): public/app.js, dashboard.html, funktionen.js, widerrufen.html; scripts/stripe-products.mjs;
server/config.js, index.js, middleware/kundenansichtGuard.js, routes/abo.js, routes/admin/funktionenAdmin.js,
routes/funktionen.js, services/customerAdmin.js, customerViewLog.js, featureRegistry.js, retention.js,
siteFeatures.js, stripeBilling.js; test/customerViewLog, featureFreischaltung, featureRegistry, funktionenRoutes,
funktionenTermine, kundenansichtGuard, mailTemplates, publicForms, siteFeatures, stripeProductsScript (je
.test.js) und test/support/schema.js.
Entscheidungen: Mailvorlagen G01–G15, K01–K30, T01–T21 lückenlos (K24–K26 Webshop, K27–K30 Öffnungszeiten);
Stripe-Katalog mit shopSetup und zeitenSetup; Widerruf kennt shop und zeiten; ABO_FUNKTIONEN bleibt analyse
und termine (kein zeiten); activate() schreibt die Zeiten-Felder nur über extra; kein Halter von
feature-activate:<id> ruft disable() auf; die Aufbewahrungs-Löschung löscht Zeiten-Daten vor dem Abschalten und
hält den Kunden bei einem Fehler (held); die Admin-Antwort trägt ersatzTicket und warnung; Hash-Routen webshop
und zeiten, Ansichten view-webshop und view-zeiten; assertOwnProject behält die Meldung von main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
e87140e5ba
245 files changed
+38799
-1050
No files matched your search
@@ -2,12 +2,13 @@
|
||||
import { Router } from 'express'
|
||||
import { config } from '../../config.js'
|
||||
import { getDocument } from '../../services/appwriteAdmin.js'
|
||||
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
||||
import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js'
|
||||
import { activateAnalyse, analyseEligibility, controllerView, deactivateAnalyse, ELIGIBILITY_MESSAGES, validateTextform } from '../../services/analyse.js'
|
||||
import { getFeatureDoc } from '../../services/siteFeatures.js'
|
||||
|
||||
const router = Router()
|
||||
router.use(requireAppwriteStaff)
|
||||
// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins)
|
||||
router.use(requireAppwriteTeam)
|
||||
|
||||
function sendError(res, err) {
|
||||
const status = [400, 403, 404, 409, 503].includes(err.status) && !err.type ? err.status : 500
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Router } from 'express'
|
||||
import { config } from '../../config.js'
|
||||
import { aboMemo } from '../../services/aboStatus.js'
|
||||
import { getDocument } from '../../services/appwriteAdmin.js'
|
||||
import { validateTextform } from '../../services/analyse.js'
|
||||
import { getDocument, updateDocument } from '../../services/appwriteAdmin.js'
|
||||
import { featureConfig, featureHaken } from '../../services/featureRegistry.js'
|
||||
import { withLocks } from '../../services/keyedLock.js'
|
||||
import { featureLegalAttachments, featureLegalPaths } from '../../services/legalDocs.js'
|
||||
import {
|
||||
FEATURE_KEYS,
|
||||
FEATURE_LABELS,
|
||||
TEXTFORM_KEYS,
|
||||
adminActivate,
|
||||
assertOwnProject,
|
||||
customerOf,
|
||||
@@ -15,6 +18,7 @@ import {
|
||||
getFeatureDoc,
|
||||
listCustomerFeatureDocs,
|
||||
listCustomerFeatures,
|
||||
textformFassung,
|
||||
ticketNachlegen,
|
||||
} from '../../services/siteFeatures.js'
|
||||
import { nachAbschaltung } from '../../services/zeiten/abschalten.js'
|
||||
@@ -24,8 +28,10 @@ import { normalisiereTeam } from '../../services/zeiten/pruefen.js'
|
||||
import { ladeZeiten, speichereTeam } from '../../services/zeiten/speicher.js'
|
||||
import { hauptplan, planNachKurz } from '../../services/zeiten/zeitlogik.js'
|
||||
import { driftWarnung, einbauanleitung, ticketStand } from '../../services/termine/einbauanleitung.js'
|
||||
import { freischaltHindernis } from '../../services/termine/freischaltung.js'
|
||||
import { freischaltHindernis, meldeFreigeschaltet } from '../../services/termine/freischaltung.js'
|
||||
import { ladeStand } from '../../services/termine/kontext.js'
|
||||
import { shopTextformNoetig } from '../../services/shop/schalter.js'
|
||||
import { termineTextformNoetig } from '../../services/termine/schalter.js'
|
||||
|
||||
/**
|
||||
* Funktionen (News-Pinnwand, Terminplaner) je Kundenprojekt im Ticketsystem (Kunden-Reiter "Funktionen").
|
||||
@@ -93,15 +99,66 @@ async function customerName(customerId) {
|
||||
}
|
||||
}
|
||||
|
||||
const K16_FEHLT = 'K16 mit Unterlagen NICHT gesendet – von Hand nachsenden'
|
||||
|
||||
/**
|
||||
* Vermerkt in der Notiz des Funktionsdokuments, dass die K16 mit den Unterlagen nicht rausging (Staff-Aktivierung in Textform, die
|
||||
* Unterlagen sind der dauerhafte Datentraeger). Best effort unter der Sperre der Verwaltung: scheitert das Schreiben, steht der Fehler im
|
||||
* Log, die Antwort traegt die Warnung trotzdem.
|
||||
*/
|
||||
async function notiereK16Fehlt(projectId, doc) {
|
||||
try {
|
||||
await withLocks([`feature-admin:${projectId}`], async () => {
|
||||
const frisch = await getDocument(config.collections.portalFeatures, doc.$id)
|
||||
await updateDocument(config.collections.portalFeatures, doc.$id, {
|
||||
adminNote: composeAdminNote(frisch.adminNote, { by: 'System', action: K16_FEHLT }),
|
||||
})
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[funktionen-admin] Notiz zur nicht gesendeten K16 nicht geschrieben:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const ticketExists = async (ticketId) => (await readTicket(ticketId)) !== null
|
||||
|
||||
/** Verlangt die Aktivierung dieser Funktion fuer diese Adresse die Zustimmung in Textform? Nur Terminplaner und Webshop, je nach Modus und Pilotliste der Funktion. */
|
||||
const textformVerlangt = { termine: termineTextformNoetig, shop: shopTextformNoetig }
|
||||
|
||||
/**
|
||||
* Zustimmung des Kunden in Textform fuer die Aktivierung des Terminplaners oder des Webshops durch das Team (F13a; AVV Webshop,
|
||||
* Vorbemerkung Abs. 2). null, wenn sie entfaellt: andere Funktion, Modus off oder pilot, Pilotadresse (termineTextformNoetig,
|
||||
* shopTextformNoetig) oder ein Dokument, das nichts mehr aktiviert und dem nichts fehlt (schon aktiv, oder durch Kauf freigeschaltet,
|
||||
* dann liegt die Zustimmung des Checkouts vor). Sonst { date, channel }; fehlt oder ist eine Angabe ungueltig (Datum vor der Fassung
|
||||
* oder in der Zukunft, kein Weg), 400 - ohne dass etwas angelegt wird.
|
||||
*/
|
||||
async function textformFuerAktivierung({ body, customerId, key, doc }) {
|
||||
if (!TEXTFORM_KEYS.includes(key)) return null
|
||||
if (doc?.state === 'active' || doc?.unlockedByPurchase === true) return null
|
||||
const kunde = await customerOf(customerId)
|
||||
if (!textformVerlangt[key](kunde?.email)) return null
|
||||
// nur Texte: validateTextform macht aus jedem Wert einen Text (aus ['chat'] wuerde 'chat')
|
||||
const text = (wert) => (typeof wert === 'string' ? wert : '')
|
||||
return validateTextform({ date: text(body?.textformDate), channel: text(body?.textformChannel) }, { minDate: textformFassung(key) })
|
||||
}
|
||||
|
||||
/** Leistungsbeschreibung und AVV der Funktion als PDF fuer die K16 der Textform-Aktivierung; nicht lesbar => 503, bevor etwas geaendert wird. */
|
||||
async function unterlagenAlsAnhang(key) {
|
||||
try {
|
||||
return await featureLegalAttachments(key, { ohne: ['widerruf'] })
|
||||
} catch (err) {
|
||||
console.error('[funktionen-admin] Rechtsunterlagen (PDF) fuer die Freischaltung nicht lesbar:', err.message)
|
||||
const funktion = key === 'shop' ? 'Der Webshop' : 'Der Terminplaner'
|
||||
throw Object.assign(new Error(`Die Rechtsunterlagen (PDF) sind nicht lesbar. ${funktion} wurde nicht freigeschaltet. Bitte später erneut versuchen.`), { status: 503 })
|
||||
}
|
||||
}
|
||||
|
||||
// Die Funktionsdokumente des Kunden werden einmal gelesen und fuer alle Projekte und Funktionen verwendet.
|
||||
// hindernis (freischaltHindernis, termine/freischaltung.js): was die Freischaltung eines Terminplaners in setup noch hindert
|
||||
// ('kein_abo' | 'keine_einstellung' | 'abo_unbekannt'), null = nichts; ausserhalb von setup und bei News immer null. Das Abo wird
|
||||
// je Anfrage einmal gelesen (ein gemeinsamer aboMemo fuer alle Projekte des Kunden), portalFeatures nicht erneut.
|
||||
async function adminView(customerId) {
|
||||
const docs = await listCustomerFeatureDocs(customerId)
|
||||
const projects = await listCustomerFeatures(customerId, { docs })
|
||||
const projects = await listCustomerFeatures(customerId, { docs, includeArchived: true })
|
||||
const abo = aboMemo()
|
||||
for (const p of projects) {
|
||||
for (const key of FEATURE_KEYS) {
|
||||
@@ -116,9 +173,36 @@ async function adminView(customerId) {
|
||||
return projects
|
||||
}
|
||||
|
||||
/**
|
||||
* Angaben des Terminplaners fuer die Karte im WOMS (F13a): textformNoetig = verlangt die Aktivierung dieses Kunden die Zustimmung in
|
||||
* Textform (Modus on und Adresse nicht in der Pilotliste), avvVersion = Fassung der Unterlagen, auf die sich die Zustimmung bezieht,
|
||||
* links = absolute, versionierte Adressen von Leistungsbeschreibung und AVV fuer die Chat-Vorlage (nie Adressen ohne Fassung).
|
||||
*/
|
||||
async function termineFeld(customerId) {
|
||||
const kunde = await customerOf(customerId)
|
||||
const { lb, avv } = featureLegalPaths('termine')
|
||||
return {
|
||||
textformNoetig: termineTextformNoetig(kunde?.email),
|
||||
avvVersion: config.termine.avvVersion,
|
||||
links: { lb: `${config.publicBaseUrl}${lb}`, avv: `${config.publicBaseUrl}${avv}` },
|
||||
}
|
||||
}
|
||||
|
||||
/** Dasselbe fuer den Webshop (AVV Webshop, Vorbemerkung Abs. 2): Fassung = config.shop.fassung, Adressen der versionierten Unterlagen des Webshops. */
|
||||
async function shopFeld(customerId) {
|
||||
const kunde = await customerOf(customerId)
|
||||
const { lb, avv } = featureLegalPaths('shop')
|
||||
return {
|
||||
textformNoetig: shopTextformNoetig(kunde?.email),
|
||||
avvVersion: config.shop.fassung,
|
||||
links: { lb: `${config.publicBaseUrl}${lb}`, avv: `${config.publicBaseUrl}${avv}` },
|
||||
}
|
||||
}
|
||||
|
||||
funktionenAdminRouter.get('/:customerId', async (req, res) => {
|
||||
try {
|
||||
res.json({ projects: await adminView(req.params.customerId) })
|
||||
const projects = await adminView(req.params.customerId)
|
||||
res.json({ projects, termine: await termineFeld(req.params.customerId), shop: await shopFeld(req.params.customerId) })
|
||||
} catch (err) {
|
||||
sendError(res, err)
|
||||
}
|
||||
@@ -258,6 +342,7 @@ funktionenAdminRouter.post('/:customerId/:key', async (req, res) => {
|
||||
}
|
||||
await assertOwnProject(customerId, projectId)
|
||||
const by = adminName(req)
|
||||
let freigeschaltet = null // Terminplaner oder Webshop mit Zustimmung in Textform: K16 mit den Unterlagen nach der Sperre
|
||||
let ersatzTicket
|
||||
if (key === 'zeiten' && action === 'activate') {
|
||||
await zeitenAktivieren({ customerId, projectId, by, note })
|
||||
@@ -268,7 +353,10 @@ funktionenAdminRouter.post('/:customerId/:key', async (req, res) => {
|
||||
const doc = await getFeatureDoc(customerId, projectId, key)
|
||||
const adminNote = composeAdminNote(doc?.adminNote, { by, action, note })
|
||||
if (action === 'activate') {
|
||||
await adminActivate(customerId, projectId, key, { by, adminNote })
|
||||
const textform = await textformFuerAktivierung({ body: req.body, customerId, key, doc })
|
||||
const attachments = textform ? await unterlagenAlsAnhang(key) : []
|
||||
const aktiviert = await adminActivate(customerId, projectId, key, { by, adminNote, textform })
|
||||
if (textform) freigeschaltet = { doc: aktiviert, attachments }
|
||||
} else if (action === 'abnahme') {
|
||||
await freischaltenNachAbnahme({ customerId, projectId, key, by, adminNote })
|
||||
} else if (action === 'ticket') {
|
||||
@@ -281,10 +369,18 @@ funktionenAdminRouter.post('/:customerId/:key', async (req, res) => {
|
||||
}
|
||||
})
|
||||
}
|
||||
// Der Kunde bekommt die Unterlagen auf einem dauerhaften Datentraeger (Anhang); ein Versandfehler wird dort geloggt, nie geworfen.
|
||||
// Ging K16 nicht raus (Versand, Kunde ohne Adresse), steht es in der Notiz und die Antwort traegt warnung: true - das Team sendet von Hand nach
|
||||
let warnung = false
|
||||
if (freigeschaltet && !(await meldeFreigeschaltet(freigeschaltet.doc, { attachments: freigeschaltet.attachments }))) {
|
||||
warnung = true
|
||||
await notiereK16Fehlt(projectId, freigeschaltet.doc)
|
||||
}
|
||||
res.json({
|
||||
projects: (await adminView(customerId)).filter((p) => p.projectId === projectId),
|
||||
// Ergebnis des Ersatz-Tickets beim Abschalten der Zeiten (angelegt | aktuell | fehler), damit ein Fehler im Panel sichtbar wird
|
||||
...(ersatzTicket ? { ersatzTicket } : {}),
|
||||
...(warnung ? { warnung } : {}),
|
||||
})
|
||||
} catch (err) {
|
||||
sendError(res, err)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Router } from 'express'
|
||||
import { config } from '../../config.js'
|
||||
import { listDocuments, Query } from '../../services/appwriteAdmin.js'
|
||||
import { syncReposToAppwrite, fetchRepoReadme, importNewRepos } from '../../services/giteaAdmin.js'
|
||||
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
||||
import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use(requireAppwriteStaff)
|
||||
// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins)
|
||||
router.use(requireAppwriteTeam)
|
||||
|
||||
router.post('/sync-repos', async (_req, res) => {
|
||||
try {
|
||||
@@ -35,6 +38,12 @@ router.get('/repo-readme', async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
// Nur Repos, die ein Projekt sind - der System-Schluessel sieht sonst jedes (auch private) Repo
|
||||
const known = await listDocuments(config.collections.websiteProjects, [
|
||||
Query.equal('repoFullName', repoFullName),
|
||||
Query.limit(1),
|
||||
])
|
||||
if (!known[0]) return res.status(404).json({ error: 'Projekt nicht gefunden' })
|
||||
const readme = await fetchRepoReadme(repoFullName)
|
||||
if (!readme) {
|
||||
return res.json({ ok: true, found: false, repoFullName })
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Router } from 'express'
|
||||
import { config } from '../../config.js'
|
||||
import { getDocument, updateDocument } from '../../services/appwriteAdmin.js'
|
||||
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
||||
import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js'
|
||||
import { envKey, encryptEnv, decryptEnv, envNames, validateEnvChange } from '../../services/previewEnv.js'
|
||||
import { listRunLogs, readRunLog, isRunFile } from '../../services/previewLogs.js'
|
||||
import * as runnerClient from '../../services/previewRunnerClient.js'
|
||||
@@ -11,7 +11,8 @@ import { withLocks } from '../../services/keyedLock.js'
|
||||
import { repoHasDevScript } from '../../services/previewDevScript.js'
|
||||
|
||||
const router = Router()
|
||||
router.use(requireAppwriteStaff)
|
||||
// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins)
|
||||
router.use(requireAppwriteTeam)
|
||||
|
||||
const BUSY = 'Server ausgelastet, versuche es gleich noch mal'
|
||||
const MODES = ['auto', 'build', 'dev', 'off']
|
||||
|
||||
@@ -2,11 +2,12 @@ import { Router } from 'express'
|
||||
import { config } from '../../config.js'
|
||||
import { listDocuments, Query } from '../../services/appwriteAdmin.js'
|
||||
import { servicesCatalog } from '../../services/projectKinds.js'
|
||||
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
||||
import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use(requireAppwriteStaff)
|
||||
// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins)
|
||||
router.use(requireAppwriteTeam)
|
||||
|
||||
const CHUNK = 100
|
||||
|
||||
|
||||
@@ -6,14 +6,15 @@ import {
|
||||
} from '../../services/giteaAdmin.js'
|
||||
import { assignProject, reconcileProject } from '../../services/projectAssignment.js'
|
||||
import { completeAssignment } from '../../services/projectKindAssign.js'
|
||||
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
||||
import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use(requireAppwriteStaff)
|
||||
// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins)
|
||||
router.use(requireAppwriteTeam)
|
||||
|
||||
router.post('/create-from-template', async (req, res) => {
|
||||
const { repoName, displayName, subdomain, customerId = '', ticketId = '', owner } = req.body || {}
|
||||
const { repoName, displayName, subdomain, customerId = '', ticketId = '', owner, kind = '' } = req.body || {}
|
||||
|
||||
if (!repoName && !displayName) {
|
||||
return res.status(400).json({ error: 'repoName oder displayName ist erforderlich' })
|
||||
@@ -27,6 +28,8 @@ router.post('/create-from-template', async (req, res) => {
|
||||
customerId,
|
||||
ticketId,
|
||||
owner,
|
||||
kind,
|
||||
staffName: req.appwriteUser?.name || req.appwriteUser?.email || '',
|
||||
})
|
||||
return res.status(201).json({ ok: true, ...result })
|
||||
} catch (err) {
|
||||
@@ -38,6 +41,10 @@ router.post('/create-from-template', async (req, res) => {
|
||||
// Projekt bearbeiten: Name / Subdomain / Typ -> synchronisiert Git + Preview + Appwrite
|
||||
router.patch('/:id', async (req, res) => {
|
||||
const { projectName, subdomain, projectType } = req.body || {}
|
||||
// Vorlagen bleiben Admin-Sache (im Ticketsystem nur fuer Admins sichtbar)
|
||||
if (projectType === 'template' && !req.appwriteUser?.labels?.includes('admin')) {
|
||||
return res.status(403).json({ error: 'Nur Admins können Vorlagen anlegen.' })
|
||||
}
|
||||
try {
|
||||
const result = await updateWebsiteProject(req.params.id, { projectName, subdomain, projectType })
|
||||
return res.json({ ok: true, ...result })
|
||||
|
||||
Reference in new issue
Block a user