201 lines
8.9 KiB
JavaScript
201 lines
8.9 KiB
JavaScript
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 }
|
|
}
|