diff --git a/server/services/deadlines.js b/server/services/deadlines.js index a06bb9d..c34a0ae 100644 --- a/server/services/deadlines.js +++ b/server/services/deadlines.js @@ -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) diff --git a/server/services/newsImages.js b/server/services/newsImages.js index 39d030a..2ddb5ee 100644 --- a/server/services/newsImages.js +++ b/server/services/newsImages.js @@ -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 diff --git a/server/services/newsPosts.js b/server/services/newsPosts.js new file mode 100644 index 0000000..b84097e --- /dev/null +++ b/server/services/newsPosts.js @@ -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 } +} diff --git a/test/newsPosts.test.js b/test/newsPosts.test.js new file mode 100644 index 0000000..b24306d --- /dev/null +++ b/test/newsPosts.test.js @@ -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) +})