Funktionen: Terminplaner als zweite Funktion (Team-Route, Kundenansicht)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-03 08:17:43 +00:00
1 parent ae1886c030
commit 9e8fd33713
12 files changed
+434 -33

No files matched your search

+4
View File
@@ -15,6 +15,10 @@ export const KUNDENANSICHT_ALLOWED = [
['PATCH', /^\/api\/news\/[^/]+\/?$/],
['DELETE', /^\/api\/news\/[^/]+\/?$/],
['POST', /^\/api\/funktionen\/analyse\/heatmap-link\/?$/],
// Terminplaner: Einstellungen und Termine kosten nichts und sind keine Erklaerungen im Namen des Kunden (Spec 2026-10-02, Abschnitt 9)
['POST', /^\/api\/termine\/.+$/],
['PATCH', /^\/api\/termine\/einstellungen\/?$/],
['DELETE', /^\/api\/termine\/buchungen\/[^/]+\/?$/],
['POST', /^\/api\/auth\/kundenansicht\/ende\/?$/],
['POST', /^\/api\/auth\/logout\/?$/],
['POST', /^\/api\/auth\/login\/?$/],
+29 -12
View File
@@ -2,10 +2,19 @@ import { Router } from 'express'
import { config } from '../../config.js'
import { getDocument } from '../../services/appwriteAdmin.js'
import { withLocks } from '../../services/keyedLock.js'
import { adminActivate, assertOwnProject, disable, getFeatureDoc, listCustomerFeatures } from '../../services/siteFeatures.js'
import {
FEATURE_KEYS,
FEATURE_LABELS,
adminActivate,
assertOwnProject,
disable,
getFeatureDoc,
listCustomerFeatureDocs,
listCustomerFeatures,
} from '../../services/siteFeatures.js'
/**
* Funktionen (News-Pinnwand) je Kundenprojekt im Ticketsystem (Kunden-Reiter "Funktionen").
* Funktionen (News-Pinnwand, Terminplaner) je Kundenprojekt im Ticketsystem (Kunden-Reiter "Funktionen").
* Eingehaengt in routes/team/index.js unter /api/team/funktionen, dort hinter requireStaffJwt.
*/
export const funktionenAdminRouter = Router()
@@ -37,13 +46,17 @@ async function ticketExists(ticketId) {
}
}
// Die Funktionsdokumente des Kunden werden einmal gelesen und fuer alle Projekte und Funktionen verwendet
async function adminView(customerId) {
const projects = await listCustomerFeatures(customerId)
const docs = await listCustomerFeatureDocs(customerId)
const projects = await listCustomerFeatures(customerId, { docs })
for (const p of projects) {
const news = p.features.news
const doc = await getFeatureDoc(customerId, p.projectId, 'news')
news.adminNote = doc?.adminNote || ''
news.ticketMissing = news.state === 'setup' && !(doc?.ticketId && (await ticketExists(doc.ticketId)))
for (const key of FEATURE_KEYS) {
const view = p.features[key]
const doc = docs.find((d) => d.projectId === p.projectId && d.featureKey === key)
view.adminNote = doc?.adminNote || ''
view.ticketMissing = view.state === 'setup' && !(doc?.ticketId && (await ticketExists(doc.ticketId)))
}
}
return projects
}
@@ -56,9 +69,11 @@ funktionenAdminRouter.get('/:customerId', async (req, res) => {
}
})
funktionenAdminRouter.post('/:customerId/news', async (req, res) => {
// Aktivieren (ohne Zahlung) und Abschalten je Funktion: /news wie bisher, /termine neu; jeder andere Schluessel -> 404
funktionenAdminRouter.post('/:customerId/:key', async (req, res) => {
try {
const { customerId } = req.params
const { customerId, key } = req.params
if (!FEATURE_KEYS.includes(key)) return res.status(404).json({ error: 'Unbekannte Funktion' })
const { projectId, action } = req.body || {}
const note = typeof req.body?.note === 'string' ? req.body.note.trim().slice(0, 120) : ''
if (action !== 'activate' && action !== 'disable') {
@@ -70,12 +85,14 @@ funktionenAdminRouter.post('/:customerId/news', async (req, res) => {
await assertOwnProject(customerId, projectId)
const by = adminName(req)
await withLocks([`feature-admin:${projectId}`], async () => {
const doc = await getFeatureDoc(customerId, projectId, 'news')
const doc = await getFeatureDoc(customerId, projectId, key)
const adminNote = composeAdminNote(doc?.adminNote, { by, action, note })
if (action === 'activate') {
await adminActivate(customerId, projectId, 'news', { by, adminNote })
await adminActivate(customerId, projectId, key, { by, adminNote })
} else {
if (!doc) throw Object.assign(new Error('Für dieses Projekt gibt es noch keine News-Pinnwand'), { status: 409 })
if (!doc) {
throw Object.assign(new Error(`Für dieses Projekt gibt es noch ${FEATURE_LABELS[key].keine}`), { status: 409 })
}
await disable(doc.$id, { by, adminNote })
}
})
+3
View File
@@ -14,6 +14,7 @@ import {
markOrdered,
runActivation,
} from '../services/siteFeatures.js'
import { termineFuerKunde } from '../services/termine/schalter.js'
import {
ELIGIBILITY_MESSAGES,
@@ -104,6 +105,8 @@ router.get('/', requireSession, async (req, res) => {
projects,
setupEstimate: config.funktionen.setupEstimate,
consentText: config.funktionen.consentText,
// Terminplaner: Schalter TERMINE_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel
termine: { verfuegbar: termineFuerKunde(req.session.email) },
})
} catch (err) {
fail(res, err, 'Die Funktionen konnten gerade nicht geladen werden. Bitte lade die Seite gleich noch einmal.')
+9
View File
@@ -282,6 +282,15 @@ export async function deleteCustomerWithPortalAccess(customerId) {
console.error(`[customerAdmin] Analyse von Kunde ${customerId} nicht beendet:`, err.message)
}
// Terminplaner abschalten: Gaeste koennen ihre Termine noch absagen, die Daten loescht der Tageslauf 30 Tage nach disabledAt.
// Fehler halten das Loeschen nicht auf (wie bei der Analyse).
try {
const { disableCustomerFeatures } = await import('./siteFeatures.js')
await disableCustomerFeatures(customerId, 'termine', { adminNote: 'Kunde gelöscht' })
} catch (err) {
console.error(`[customerAdmin] Terminplaner von Kunde ${customerId} nicht abgeschaltet:`, err.message)
}
const portalAccess = await getPortalAccessByCustomerId(customerId)
if (portalAccess) {
await deleteDocument(
+12 -1
View File
@@ -5,7 +5,18 @@ import { createDocument, deleteDocument, listDocuments, Query } from './appwrite
* Protokoll der Kundenansicht (Datenschutzerklaerung Abschnitt 6): wer wann das Portal eines Kunden geoeffnet
* oder darin etwas geaendert hat. Schreiben nur ueber den Server-Schluessel; Eintraege leben 12 Monate.
*/
export const VIEW_ACTIONS = ['geoeffnet', 'beendet', 'abgelaufen', 'news_angelegt', 'news_bild', 'news_geaendert', 'news_geloescht']
export const VIEW_ACTIONS = [
'geoeffnet',
'beendet',
'abgelaufen',
'news_angelegt',
'news_bild',
'news_geaendert',
'news_geloescht',
'termin_einstellungen',
'termin_aktion',
'termin_manuell',
]
const DETAIL_MAX = 300
const RETENTION_MS = 365 * 24 * 60 * 60 * 1000
+44 -9
View File
@@ -6,7 +6,12 @@ import { sendAdminNotice, sendCustomerMail } from './mailer.js'
import { getStripe, retrieveCheckoutSession } from './stripeBilling.js'
import { createFeatureTicket } from './ticketAdmin.js'
export const FEATURE_KEYS = ['news']
export const FEATURE_KEYS = ['news', 'termine']
/** Bezeichnung (name) und Verneinung im Satz "Für dieses Projekt gibt es noch {keine}" (keine) je Funktion. */
export const FEATURE_LABELS = {
news: { name: 'News-Pinnwand', keine: 'keine News-Pinnwand' },
termine: { name: 'Terminplaner', keine: 'keinen Terminplaner' },
}
export const STATES = ['off', 'ordered', 'setup', 'active', 'disabled']
export const DISABLED_MESSAGE =
@@ -119,16 +124,19 @@ async function featureView(doc) {
}
}
export async function listCustomerFeatures(customerId) {
/** Alle Funktionsdokumente eines Kunden (alle Projekte, alle Funktionen). */
export function listCustomerFeatureDocs(customerId) {
return listDocuments(config.collections.portalFeatures, [Query.equal('customerId', customerId), Query.limit(1000)])
}
/** docs: bereits geladene Funktionsdokumente des Kunden (listCustomerFeatureDocs), damit sie nicht doppelt gelesen werden. */
export async function listCustomerFeatures(customerId, { docs: geladen } = {}) {
const projects = await listDocuments(config.collections.websiteProjects, [
Query.equal('customerId', customerId),
Query.orderDesc('$createdAt'),
Query.limit(1000),
])
const docs = await listDocuments(config.collections.portalFeatures, [
Query.equal('customerId', customerId),
Query.limit(1000),
])
const docs = geladen ?? (await listCustomerFeatureDocs(customerId))
const result = []
for (const p of projects) {
const features = {}
@@ -287,6 +295,8 @@ export async function activate(docId, { by, adminNote } = {}) {
return updateDocument(config.collections.portalFeatures, docId, {
...stateFields('active'),
activatedAt: new Date().toISOString(),
// Eine wieder eingeschaltete Funktion darf nie in den 30-Tage-Loeschlauf des Terminplaners fallen (disabledAt-Marker)
disabledAt: null,
...(note ? { adminNote: note } : {}),
})
}
@@ -302,6 +312,30 @@ export async function disable(docId, { by, adminNote } = {}) {
return doc
}
/**
* Alle noch nicht abgeschalteten Dokumente einer Funktion eines Kunden abschalten (Kunde wird geloescht). Schon abgeschaltete
* behalten ihr disabledAt (Beginn der Loeschfrist), 'off' hat nichts zu sperren. Scheitert eines, werden die uebrigen trotzdem
* abgeschaltet und danach der erste Fehler weitergereicht.
* @returns {Promise<object[]>} die abgeschalteten Dokumente
*/
export async function disableCustomerFeatures(customerId, key, { by, adminNote } = {}) {
assertKey(key)
const docs = (await listCustomerFeatureDocs(customerId)).filter(
(d) => d.featureKey === key && d.state !== 'off' && d.state !== 'disabled'
)
const disabled = []
let failure = null
for (const doc of docs) {
try {
disabled.push(await disable(doc.$id, { by, adminNote }))
} catch (err) {
failure ||= err
}
}
if (failure) throw failure
return disabled
}
// Analyse gesperrt (auch Loeschfrist): Sammeldienst abschalten, Neubau und Entfernen-Ticket. Best effort, Zustand ist schon gespeichert.
// Dynamischer Import: analyse.js importiert dieses Modul.
async function stopAnalyseCollecting(doc) {
@@ -352,11 +386,12 @@ export async function activateClosedTickets({ now = new Date(), customerId = nul
return activated
}
export async function findActiveBySiteKey(siteKey) {
if (!/^[a-z2-7]{16}$/.test(String(siteKey || ''))) return null
/** Aktive Funktion zu einem siteKey; ohne featureKey 'news' (Pinnwand), unbekannte Funktion oder siteKey => null. */
export async function findActiveBySiteKey(siteKey, featureKey = 'news') {
if (!/^[a-z2-7]{16}$/.test(String(siteKey || '')) || !FEATURE_KEYS.includes(featureKey)) return null
const [doc] = await listDocuments(config.collections.portalFeatures, [
Query.equal('siteKey', siteKey),
Query.equal('featureKey', 'news'),
Query.equal('featureKey', featureKey),
Query.equal('state', 'active'),
Query.limit(1),
])
+3 -7
View File
@@ -1,7 +1,8 @@
import { config } from '../../config.js'
import { Query, createDocument, getDocument, listDocuments, updateDocument } from '../appwriteAdmin.js'
import { createDocument, getDocument, updateDocument } from '../appwriteAdmin.js'
import { analyseHosts } from '../analyse.js'
import { withLocks } from '../keyedLock.js'
import { findActiveBySiteKey } from '../siteFeatures.js'
import { termineAus } from './schalter.js'
import { pruefeNow, pruefeText } from './util.js'
@@ -95,12 +96,7 @@ export async function kontextPerSiteKey(siteKey, now = new Date()) {
if (gemerkt && gemerkt.bis > nowMs) return gemerkt.ctx
const stand = generation
const [feature] = await listDocuments(config.collections.portalFeatures, [
Query.equal('siteKey', siteKey),
Query.equal('featureKey', FUNKTION),
Query.equal('state', 'active'),
Query.limit(1),
])
const feature = await findActiveBySiteKey(siteKey, FUNKTION)
let ctx = feature ? await baueKontext(feature) : null
if (ctx && !ctx.einstellung) ctx = null
if (stand === generation) merke(siteKey, ctx, nowMs)