News: Beiträge, Anzeigedauer und öffentliche Seitenansicht

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-09-30 16:43:24 +00:00
1 parent ad0b08f32a
commit 434bab0b7a
4 files changed
+317 -2

No files matched your search

+2 -2
View File
@@ -20,7 +20,7 @@ const wallFormat = new Intl.DateTimeFormat('en-US', {
})
// Kalendertag (YYYY-MM-DD) in Deutschland
const berlinDay = (date) => dayFormat.format(new Date(date))
export const berlinDay = (date) => dayFormat.format(new Date(date))
// Reine Kalendertage rechnen wir als UTC-Mitternacht, damit Sommer-/Winterzeit keine Rolle spielt
const dayToDate = (ymd) => new Date(`${ymd}T00:00:00Z`)
@@ -88,7 +88,7 @@ function berlinOffset(t) {
}
/** Zeitpunkt "Tag ymd, Uhrzeit in Deutschland" als Date (die Zeitumstellung liegt nie auf den genutzten Uhrzeiten). */
function berlinTime(ymd, hour, minute = 0, second = 0, ms = 0) {
export function berlinTime(ymd, hour, minute = 0, second = 0, ms = 0) {
const [y, mo, d] = ymd.split('-').map(Number)
const wall = Date.UTC(y, mo - 1, d, hour, minute, second, ms)
const guess = wall - berlinOffset(wall)
+7
View File
@@ -84,6 +84,13 @@ async function readSidecar(projectId, imageId) {
}
}
/** Breite und Hoehe eines Bildes des Projekts, null wenn es nicht existiert. */
export async function readNewsImageMeta(projectId, imageId) {
if (!safeId(projectId) || !safeId(imageId)) return null
const meta = await readSidecar(projectId, imageId)
return meta ? { w: meta.w, h: meta.h } : null
}
/** Markiert ein Bild als an einen Beitrag gebunden (wird nicht mehr aufgeraeumt). */
export async function claimNewsImage(projectId, imageId) {
if (!safeId(projectId) || !safeId(imageId)) return
+188
View File
@@ -0,0 +1,188 @@
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))) {
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')
const title = str(src.title)
if (!title) throw httpError(400, 'Bitte gib einen Titel ein.', '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)
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,
})
if (v.imageId) await claimNewsImage(projectId, v.imageId)
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)
const until = visibleUntilFor(v.duration, from, v.date, now)
const imageId = v.imageId || null
if (imageId && imageId !== post.imageId) await requireImage(projectId, 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 }
}
+120
View File
@@ -0,0 +1,120 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import os from 'node:os'
import path from 'node:path'
import { mkdtempSync } from 'node:fs'
import sharp from 'sharp'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
process.env.NEWS_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'news-posts-'))
const aw = await startAppwriteMock({ schema: { newsPosts: schemaKeys('newsPosts') } })
const st = await startFakeStripe()
after(() => Promise.all([aw.close(), st.close()]))
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const np = await import('../server/services/newsPosts.js')
const { storeNewsImage, newsImagePath } = await import('../server/services/newsImages.js')
const { existsSync } = await import('node:fs')
const { addMonths } = await import('../server/services/stripeBilling.js')
const NOW = new Date('2026-10-01T10:00:00.000Z')
const base = { type: 'note', title: 'Hallo', text: 'Wir haben offen', paper: 'sun', duration: '1m' }
const mk = (projectId, input = {}, now = NOW) => np.createPost({ customerId: 'c1', projectId, input: { ...base, ...input }, now })
const png = () => sharp({ create: { width: 40, height: 30, channels: 3, background: '#c33' } }).png().toBuffer()
test('ablauf_grenzen_berlin', async () => {
const until = np.visibleUntilFor('date', NOW, '2026-10-25')
assert.equal(until.toISOString(), '2026-10-25T22:59:59.999Z')
const post = { visibleFrom: NOW.toISOString(), visibleUntil: until.toISOString(), takenDownAt: null }
assert.equal(np.isHanging(post, new Date(until.getTime() - 1)), true)
assert.equal(np.isHanging(post, until), false)
const open = { visibleFrom: NOW.toISOString(), visibleUntil: null, takenDownAt: null }
assert.equal(np.isHanging(open, new Date('2030-01-01')), true)
assert.equal(np.isHanging({ ...open, takenDownAt: NOW.toISOString() }, new Date('2026-10-02')), false)
})
test('dauer_werte', () => {
assert.equal(np.visibleUntilFor('1w', NOW).getTime() - NOW.getTime(), 7 * 86400000)
assert.equal(np.visibleUntilFor('2w', NOW).getTime() - NOW.getTime(), 14 * 86400000)
const jan = new Date('2027-01-31T09:00:00.000Z')
assert.equal(np.visibleUntilFor('1m', jan).toISOString(), addMonths(jan, 1).toISOString())
assert.equal(np.visibleUntilFor('1m', jan).toISOString(), '2027-02-28T09:00:00.000Z')
assert.equal(np.visibleUntilFor('3m', NOW).toISOString(), addMonths(NOW, 3).toISOString())
assert.equal(np.visibleUntilFor('open', NOW), null)
})
test('datum_grenzen_400', () => {
assert.throws(() => np.visibleUntilFor('date', NOW, '2026-10-01'), { status: 400, field: 'date' })
assert.throws(() => np.visibleUntilFor('date', NOW, '2027-10-02'), { status: 400 })
assert.throws(() => np.visibleUntilFor('date', NOW, 'morgen'), { status: 400 })
assert.ok(np.visibleUntilFor('date', NOW, '2026-10-02'))
assert.ok(np.visibleUntilFor('date', NOW, '2027-10-01'))
})
test('validierung', () => {
assert.throws(() => np.validatePostInput({ ...base, title: 'x'.repeat(61) }), { status: 400, field: 'title' })
assert.throws(() => np.validatePostInput({ ...base, type: 'photo' }), { status: 400, field: 'imageId' })
assert.throws(() => np.validatePostInput({ ...base, paper: 'lila' }), { status: 400, field: 'paper' })
assert.throws(() => np.validatePostInput({ ...base, text: '' }), { status: 400, field: 'text' })
assert.throws(() => np.validatePostInput({ ...base, text: 'x'.repeat(401) }), { status: 400, field: 'text' })
assert.throws(() => np.validatePostInput({ ...base, type: 'combo', imageId: 'i1', text: '' }), { status: 400, field: 'text' })
assert.equal(np.validatePostInput({ ...base, type: 'photo', text: '', imageId: 'i1' }).imageId, 'i1')
})
test('abnehmen_und_wieder_aufhaengen', async () => {
const post = await mk('pa')
assert.equal((await np.listPosts('pa', NOW)).hanging.length, 1)
const later = new Date('2026-10-05T08:00:00.000Z')
await np.updatePost({ customerId: 'c1', projectId: 'pa', id: post.$id, input: { action: 'takeDown' }, now: later })
const l1 = await np.listPosts('pa', later)
assert.equal(l1.hanging.length, 0)
assert.equal(l1.expired.length, 1)
const re = await np.updatePost({ customerId: 'c1', projectId: 'pa', id: post.$id, input: { action: 'rehang', duration: '2w' }, now: later })
assert.equal(re.visibleFrom, later.toISOString())
assert.equal(re.visibleUntil, new Date(later.getTime() + 14 * 86400000).toISOString())
assert.equal((await np.listPosts('pa', later)).hanging.length, 1)
})
test('oeffentlich_nur_haengende_ohne_interna', async () => {
for (let i = 0; i < 8; i++) await mk('pb', { title: `Beitrag ${i}` }, new Date(NOW.getTime() + i * 1000))
for (let i = 0; i < 2; i++) await mk('pb', { duration: '1w' }, new Date('2026-09-01T10:00:00.000Z'))
const { imageId } = await storeNewsImage({ customerId: 'c1', projectId: 'pb', buffer: await png() })
await mk('pb', { type: 'combo', imageId, title: 'Mit Foto' }, new Date(NOW.getTime() + 60000))
const after10 = new Date(NOW.getTime() + 120000)
const p1 = await np.publicPage('pb', 1, after10)
assert.equal(p1.posts.length, 6)
assert.equal(p1.hasMore, true)
assert.equal(p1.posts[0].title, 'Mit Foto')
assert.deepEqual(p1.posts[0].image, { w: 40, h: 30 })
assert.equal(p1.posts[0].date, '2026-10-01')
assert.equal(p1.posts[1].image, null)
for (const p of p1.posts) {
assert.deepEqual(Object.keys(p).sort(), ['date', 'id', 'image', 'paper', 'text', 'title', 'type'])
}
const p2 = await np.publicPage('pb', 2, after10)
assert.equal(p2.posts.length, 3)
assert.equal(p2.hasMore, false)
})
test('fremder_kunde_kann_nicht_aendern', async () => {
const post = await mk('pc')
await assert.rejects(np.updatePost({ customerId: 'c2', projectId: 'pc', id: post.$id, input: { action: 'takeDown' }, now: NOW }), { status: 403 })
await assert.rejects(np.updatePost({ customerId: 'c1', projectId: 'other', id: post.$id, input: { action: 'takeDown' }, now: NOW }), { status: 403 })
await assert.rejects(np.deletePost({ customerId: 'c2', projectId: 'pc', id: post.$id }), { status: 403 })
await assert.rejects(np.deletePost({ customerId: 'c1', projectId: 'pc', id: 'gibtsnicht' }), { status: 404 })
})
test('bild_gehoert_zum_projekt_und_wird_mitgeloescht', async () => {
const a = await storeNewsImage({ customerId: 'c1', projectId: 'pd', buffer: await png() })
await assert.rejects(mk('pe', { type: 'photo', imageId: a.imageId }), { status: 400, field: 'imageId' })
const post = await mk('pd', { type: 'photo', imageId: a.imageId })
const b = await storeNewsImage({ customerId: 'c1', projectId: 'pd', buffer: await png() })
await np.updatePost({ customerId: 'c1', projectId: 'pd', id: post.$id, input: { ...base, type: 'photo', imageId: b.imageId }, now: NOW })
assert.equal(existsSync(newsImagePath('pd', a.imageId, 'l')), false)
assert.equal(existsSync(newsImagePath('pd', b.imageId, 'l')), true)
await np.deletePost({ customerId: 'c1', projectId: 'pd', id: post.$id })
assert.equal(existsSync(newsImagePath('pd', b.imageId, 'l')), false)
assert.equal((await np.listPosts('pd', NOW)).hanging.length, 0)
})