From d3207302514328780898d451c8efe344b73d15df Mon Sep 17 00:00:00 2001 From: knso Date: Wed, 30 Sep 2026 16:45:42 +0000 Subject: [PATCH] =?UTF-8?q?News:=20Beitr=C3=A4ge=20=E2=80=93=20Titel=20opt?= =?UTF-8?q?ional,=20Datumspr=C3=BCfung,=20Bildregeln?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- server/services/newsPosts.js | 22 ++++++++++++++++------ test/newsPosts.test.js | 24 ++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/server/services/newsPosts.js b/server/services/newsPosts.js index b84097e..8155183 100644 --- a/server/services/newsPosts.js +++ b/server/services/newsPosts.js @@ -42,7 +42,8 @@ export function visibleUntilFor(duration, from, dateIso, now = from) { 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))) { + 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) @@ -69,8 +70,8 @@ export function validatePostInput(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) 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') @@ -111,7 +112,10 @@ async function requireImage(projectId, 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) + 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, @@ -121,7 +125,6 @@ export async function createPost({ customerId, projectId, input, now = new Date( visibleFrom: iso, visibleUntil: until ? until.toISOString() : null, takenDownAt: null, createdAt: iso, updatedAt: iso, }) - if (v.imageId) await claimNewsImage(projectId, v.imageId) return doc } @@ -153,9 +156,16 @@ export async function updatePost({ customerId, projectId, id, input, now = new D 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) + // 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) + 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, diff --git a/test/newsPosts.test.js b/test/newsPosts.test.js index b24306d..648ea66 100644 --- a/test/newsPosts.test.js +++ b/test/newsPosts.test.js @@ -49,6 +49,8 @@ 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.throws(() => np.visibleUntilFor('date', NOW, '2026-11-31'), { status: 400, field: 'date' }) + assert.throws(() => np.visibleUntilFor('date', NOW, '2027-02-31'), { status: 400, field: 'date' }) assert.ok(np.visibleUntilFor('date', NOW, '2026-10-02')) assert.ok(np.visibleUntilFor('date', NOW, '2027-10-01')) }) @@ -58,6 +60,8 @@ test('validierung', () => { 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.equal(np.validatePostInput({ ...base, title: undefined }).title, '') + assert.equal(np.validatePostInput({ ...base, type: 'photo', title: '', text: '', imageId: 'i1' }).title, '') 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') @@ -118,3 +122,23 @@ test('bild_gehoert_zum_projekt_und_wird_mitgeloescht', async () => { assert.equal(existsSync(newsImagePath('pd', b.imageId, 'l')), false) assert.equal((await np.listPosts('pd', NOW)).hanging.length, 0) }) + +test('bearbeiten_datum_bild_regeln', async () => { + const post = await mk('pf', { duration: 'date', date: '2026-10-10' }) + const edit = (input, now) => np.updatePost({ customerId: 'c1', projectId: 'pf', id: post.$id, input: { ...base, duration: 'date', ...input }, now }) + const late = new Date('2026-10-10T08:00:00.000Z') + // unveraendertes Datum (heute) bleibt speicherbar, geaendertes wird geprueft + const ok = await edit({ date: '2026-10-10', title: 'Neu' }, late) + assert.equal(ok.title, 'Neu') + assert.equal(ok.visibleUntil, post.visibleUntil) + await assert.rejects(edit({ date: '2026-10-11' }, new Date('2026-10-11T08:00:00.000Z')), { status: 400, field: 'date' }) + // Bild eines anderen Beitrags + const img = await storeNewsImage({ customerId: 'c1', projectId: 'pf', buffer: await png() }) + await mk('pf', { type: 'photo', imageId: img.imageId }) + await assert.rejects(edit({ type: 'photo', imageId: img.imageId, date: '2026-10-10' }, late), { status: 400, field: 'imageId' }) +}) + +test('create_ohne_bild_belegt_nichts', async () => { + await assert.rejects(mk('pg', { type: 'photo', imageId: 'nope' }), { status: 400, field: 'imageId' }) + assert.equal((await np.listPosts('pg', NOW)).hanging.length, 0) +})