import { config } from '../config.js' import { createDocument, deleteDocument, getDocument, listDocuments, updateDocument, Query } from './appwriteAdmin.js' import { berlinDay, berlinTime } from './deadlines.js' import { claimNewsImage, deleteNewsImage, readNewsImageMeta } from './newsImages.js' import { addMonths } from './stripeBilling.js' /** * News-Beitraege der Pinnwand: Zettel (Notiz, Foto oder beides) mit Anzeigedauer. * Zeiten stehen als ISO-Strings im Dokument; "hängend" = nicht abgenommen, sichtbar ab, nicht abgelaufen. */ export const DURATIONS = ['1w', '2w', '1m', '3m', 'open', 'date'] export const PAPERS = ['sun', 'sky', 'coral', 'white'] export const TYPES = ['note', 'photo', 'combo'] export const TITLE_MAX = 60 export const TEXT_MAX = 400 export const PAGE_SIZE = 6 const DAY_MS = 24 * 60 * 60 * 1000 const DATE_RE = /^\d{4}-\d{2}-\d{2}$/ function httpError(status, message, field) { const err = Object.assign(new Error(message), { status }) if (field) err.field = field return err } const plusCalendarDays = (ymd, n) => { const [y, m, d] = ymd.split('-').map(Number) return new Date(Date.UTC(y, m - 1, d + n)).toISOString().slice(0, 10) } /** * Ende der Anzeige. `from` ist der Beginn, `now` der Bezugspunkt fuer die Datumsgrenzen (Standard: from). * `date`: 23:59:59.999 Europe/Berlin, fruehestens morgen, hoechstens heute + 1 Jahr. */ export function visibleUntilFor(duration, from, dateIso, now = from) { switch (duration) { case '1w': return new Date(from.getTime() + 7 * DAY_MS) case '2w': return new Date(from.getTime() + 14 * DAY_MS) case '1m': return addMonths(from, 1) case '3m': return addMonths(from, 3) case 'open': return null case 'date': { if (typeof dateIso !== 'string' || !DATE_RE.test(dateIso) || Number.isNaN(Date.parse(dateIso)) || new Date(`${dateIso}T00:00:00Z`).toISOString().slice(0, 10) !== dateIso) { throw httpError(400, 'Bitte wähle ein gültiges Datum.', 'date') } const today = berlinDay(now) const latest = addMonths(new Date(`${today}T00:00:00Z`), 12).toISOString().slice(0, 10) if (dateIso < plusCalendarDays(today, 1)) throw httpError(400, 'Das Datum muss mindestens morgen sein.', 'date') if (dateIso > latest) throw httpError(400, 'Das Datum darf höchstens ein Jahr in der Zukunft liegen.', 'date') return berlinTime(dateIso, 23, 59, 59, 999) } default: throw httpError(400, 'Bitte wähle eine Anzeigedauer.', 'duration') } } export function isHanging(post, now) { if (post.takenDownAt) return false const t = now.getTime() if (post.visibleFrom && Date.parse(post.visibleFrom) > t) return false if (post.visibleUntil && t >= Date.parse(post.visibleUntil)) return false return true } export function validatePostInput(input) { const src = input && typeof input === 'object' ? input : {} const str = (v) => (typeof v === 'string' ? v.trim() : '') const type = src.type if (!TYPES.includes(type)) throw httpError(400, 'Bitte wähle eine Art von Beitrag.', 'type') // Der Titel ist optional (leer gespeichert) const title = str(src.title) if (title.length > TITLE_MAX) throw httpError(400, `Der Titel darf höchstens ${TITLE_MAX} Zeichen lang sein.`, 'title') const text = str(src.text) if (text.length > TEXT_MAX) throw httpError(400, `Der Text darf höchstens ${TEXT_MAX} Zeichen lang sein.`, 'text') if ((type === 'note' || type === 'combo') && !text) throw httpError(400, 'Bitte gib einen Text ein.', 'text') const imageId = str(src.imageId) if ((type === 'photo' || type === 'combo') && !imageId) throw httpError(400, 'Bitte lade ein Foto hoch.', 'imageId') if (!PAPERS.includes(src.paper)) throw httpError(400, 'Bitte wähle eine Zettelfarbe.', 'paper') if (!DURATIONS.includes(src.duration)) throw httpError(400, 'Bitte wähle eine Anzeigedauer.', 'duration') const out = { type, title, text, // bei reinem Foto ist ein Text optional paper: src.paper, duration: src.duration, } if (src.duration === 'date') out.date = src.date if (type !== 'note') out.imageId = imageId return out } const byNewest = (a, b) => Date.parse(b.visibleFrom) - Date.parse(a.visibleFrom) || Date.parse(b.createdAt) - Date.parse(a.createdAt) async function projectPosts(projectId) { return listDocuments(config.collections.newsPosts, [Query.equal('projectId', projectId), Query.limit(1000)]) } export async function listPosts(projectId, now = new Date()) { const posts = (await projectPosts(projectId)).sort(byNewest) return { hanging: posts.filter((p) => isHanging(p, now)), expired: posts.filter((p) => !isHanging(p, now)), } } async function requireImage(projectId, imageId) { if (!(await readNewsImageMeta(projectId, imageId))) throw httpError(400, 'Das Foto wurde nicht gefunden. Bitte lade es erneut hoch.', 'imageId') } export async function createPost({ customerId, projectId, input, now = new Date() }) { const v = validatePostInput(input) const until = visibleUntilFor(v.duration, now, v.date, now) if (v.imageId) { await requireImage(projectId, v.imageId) await claimNewsImage(projectId, v.imageId) } const iso = now.toISOString() const doc = await createDocument(config.collections.newsPosts, { customerId, projectId, type: v.type, title: v.title, text: v.text, imageId: v.imageId || null, paper: v.paper, duration: v.duration, visibleFrom: iso, visibleUntil: until ? until.toISOString() : null, takenDownAt: null, createdAt: iso, updatedAt: iso, }) return doc } async function ownPost(customerId, projectId, id) { let post try { post = await getDocument(config.collections.newsPosts, id) } catch (err) { if (err.status === 404) throw httpError(404, 'Beitrag nicht gefunden.') throw err } if (post.customerId !== customerId || post.projectId !== projectId) throw httpError(403, 'Kein Zugriff auf diesen Beitrag.') return post } export async function updatePost({ customerId, projectId, id, input, now = new Date() }) { const post = await ownPost(customerId, projectId, id) const iso = now.toISOString() const action = input && input.action let patch let oldImageId = null if (action === 'takeDown') { patch = { takenDownAt: iso } } else if (action === 'rehang') { const duration = input.duration const until = visibleUntilFor(duration, now, input.date, now) patch = { duration, visibleFrom: iso, visibleUntil: until ? until.toISOString() : null, takenDownAt: null } } else { const v = validatePostInput(input) // Relative Dauern zaehlen ab dem urspruenglichen Beginn, damit ein Bearbeiten nichts verlaengert const from = new Date(post.visibleFrom) // Unverändertes Datum bleibt speicherbar, auch wenn es inzwischen unter "morgen" liegt const sameDate = v.duration === 'date' && post.duration === 'date' && DATE_RE.test(v.date || '') && post.visibleUntil === berlinTime(v.date, 23, 59, 59, 999).toISOString() const until = sameDate ? new Date(post.visibleUntil) : visibleUntilFor(v.duration, from, v.date, now) const imageId = v.imageId || null if (imageId && imageId !== post.imageId) { await requireImage(projectId, imageId) const used = (await projectPosts(projectId)).some((p) => p.$id !== id && p.imageId === imageId) if (used) throw httpError(400, 'Dieses Foto gehört schon zu einem anderen Beitrag.', 'imageId') } patch = { type: v.type, title: v.title, text: v.text, paper: v.paper, duration: v.duration, visibleUntil: until ? until.toISOString() : null, imageId, } if (post.imageId && post.imageId !== imageId) oldImageId = post.imageId if (imageId && imageId !== post.imageId) await claimNewsImage(projectId, imageId) } const doc = await updateDocument(config.collections.newsPosts, id, { ...patch, updatedAt: iso }) if (oldImageId) await deleteNewsImage(projectId, oldImageId) return doc } export async function deletePost({ customerId, projectId, id }) { const post = await ownPost(customerId, projectId, id) await deleteDocument(config.collections.newsPosts, id) if (post.imageId) await deleteNewsImage(projectId, post.imageId) } /** Oeffentliche Seite der Website: nur haengende Beitraege, ohne interne Felder, 6 je Seite. */ export async function publicPage(projectId, page, now = new Date()) { const n = Math.max(1, Math.floor(Number(page)) || 1) const { hanging } = await listPosts(projectId, now) const slice = hanging.slice((n - 1) * PAGE_SIZE, n * PAGE_SIZE) const posts = [] for (const p of slice) { const meta = p.imageId ? await readNewsImageMeta(projectId, p.imageId) : null // v (Zeitstempel der letzten Aenderung) dient der Route als Cache-Buster fuer die Bild-URL const image = meta ? { ...meta, v: Date.parse(p.updatedAt || p.visibleFrom) } : null posts.push({ id: p.$id, type: p.type, title: p.title, text: p.text, paper: p.paper, date: berlinDay(p.visibleFrom), image }) } return { posts, hasMore: hanging.length > n * PAGE_SIZE } }