Files
Webklar-Kundenbereich/server/services/newsPosts.js
T

199 lines
8.7 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 image = p.imageId ? await readNewsImageMeta(projectId, p.imageId) : 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 }
}