diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fad777..15f2aff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project will be documented in this file. +## 2.22.1 - 2026-10-27 + +### Fixed + +- Playlist saves now update existing slides, groups, and schedule rules in place, preserving their database IDs. +- Playlist group schedule rules remain visible in the editor after refreshing the page. + ## 2.22.0 - 2026-10-27 ### Added diff --git a/build/package.player.json b/build/package.player.json index 8e0d7ae..59bd357 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.22.0", + "version": "2.22.1", "private": false, "description": "Pulse Signage player application bundle", "engines": { diff --git a/build/package.web.json b/build/package.web.json index 60387f3..0895abe 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.22.0", + "version": "2.22.1", "private": false, "description": "Pulse Signage web and bridge application bundle", "engines": { diff --git a/package-lock.json b/package-lock.json index 6391ca9..d527cce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "2.22.0", + "version": "2.22.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "2.22.0", + "version": "2.22.1", "dependencies": { "@better-auth/passkey": "^1.7.13", "animate.css": "^4.1.1", diff --git a/package.json b/package.json index 54982b2..6ef995e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.22.0", + "version": "2.22.1", "private": false, "description": "Pulse Signage application with MySQL and media storage", "engines": { diff --git a/src/web/routes/admin/playlists.js b/src/web/routes/admin/playlists.js index 51f7d2c..0c1d55f 100644 --- a/src/web/routes/admin/playlists.js +++ b/src/web/routes/admin/playlists.js @@ -160,6 +160,42 @@ module.exports = function registerPlaylistRoutes(app, deps) { } } + async function saveScheduleRulesInPlace(connection, ownerColumn, ownerId, scheduleRules, existingRules, actorId) { + const rules = Array.isArray(scheduleRules) ? scheduleRules : []; + const existing = Array.isArray(existingRules) ? existingRules : []; + for (let index = 0; index < rules.length; index += 1) { + const rule = rules[index]; + const existingRule = existing[index]; + const position = Number.isInteger(Number(rule.position)) ? Number(rule.position) : index; + const startDatetime = rule.startDatetime || rule.start_datetime || null; + const endDatetime = rule.endDatetime || rule.end_datetime || null; + const startTime = rule.startTime || rule.start_time || null; + const endTime = rule.endTime || rule.end_time || null; + const daysJson = rule.daysJson !== undefined + ? rule.daysJson + : rule.days && rule.days.length + ? JSON.stringify(rule.days) + : rule.schedule_days_json || null; + if (existingRule) { + await connection.query( + `UPDATE c_playlist_schedule_rules SET position = ?, start_datetime = ?, end_datetime = ?, start_time = ?, end_time = ?, schedule_days_json = ?, modified_by = ? WHERE id = ? AND ${ownerColumn} = ?`, + [position, startDatetime, endDatetime, startTime, endTime, daysJson, actorId, existingRule.id, ownerId] + ); + } else { + await connection.query( + `INSERT INTO c_playlist_schedule_rules (${ownerColumn}, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ownerId, position, startDatetime, endDatetime, startTime, endTime, daysJson, actorId, actorId] + ); + } + } + for (const removedRule of existing.slice(rules.length)) { + await connection.query( + `DELETE FROM c_playlist_schedule_rules WHERE id = ? AND ${ownerColumn} = ?`, + [removedRule.id, ownerId] + ); + } + } + function createValidationError(message) { const error = new Error(message); error.statusCode = 400; @@ -402,45 +438,132 @@ module.exports = function registerPlaylistRoutes(app, deps) { ? Number(playlist.canvas_id) : requestedCanvasId || selectedCanvasId || null; + let existingPlaylistSlides = []; + let existingPlaylistGroups = []; + let existingScheduleRules = []; + if (shouldUpdatePlaylist) { + [existingPlaylistSlides] = await connection.query( + 'SELECT ps.id, ps.slide_id, ps.group_id FROM c_playlist_slides ps WHERE ps.playlist_id = ? ORDER BY ps.position ASC, ps.id ASC FOR UPDATE', + [playlist.id] + ); + [existingPlaylistGroups] = await connection.query( + 'SELECT id, group_key FROM c_playlist_groups WHERE playlist_id = ? FOR UPDATE', + [playlist.id] + ); + const ownerFilters = []; + const ownerParams = []; + if (existingPlaylistSlides.length) { + ownerFilters.push('r.playlist_slide_id IN (?)'); + ownerParams.push(existingPlaylistSlides.map(function (item) { return item.id; })); + } + if (existingPlaylistGroups.length) { + ownerFilters.push('r.playlist_group_id IN (?)'); + ownerParams.push(existingPlaylistGroups.map(function (group) { return group.id; })); + } + if (ownerFilters.length) { + [existingScheduleRules] = await connection.query( + `SELECT r.id, r.playlist_slide_id, r.playlist_group_id, r.position, r.start_datetime, r.end_datetime, r.start_time, r.end_time, r.schedule_days_json FROM c_playlist_schedule_rules r WHERE ${ownerFilters.join(' OR ')} ORDER BY r.playlist_slide_id ASC, r.playlist_group_id ASC, r.position ASC, r.id ASC FOR UPDATE`, + ownerParams + ); + } + } + + const existingSlidesById = new Map(existingPlaylistSlides.map(function (item) { return [Number(item.id), item]; })); + const existingSlidesBySlideId = new Map(); + existingPlaylistSlides.forEach(function (item) { + const slideId = Number(item.slide_id); + if (!existingSlidesBySlideId.has(slideId)) existingSlidesBySlideId.set(slideId, []); + existingSlidesBySlideId.get(slideId).push(item); + }); + const retainedSlideIds = new Set(); + if (shouldUpdatePlaylist) { + normalizedSlides.forEach(function (item) { + const rowId = /^\d+$/.test(item.rowKey) ? Number(item.rowKey) : null; + let existing = rowId ? existingSlidesById.get(rowId) : null; + if (rowId && (!existing || Number(existing.slide_id) !== item.slideId)) { + throw createValidationError('Playlist slide data is invalid.'); + } + if (!existing) { + existing = (existingSlidesBySlideId.get(item.slideId) || []).find(function (candidate) { + return !retainedSlideIds.has(Number(candidate.id)); + }); + } + if (existing) { + const existingId = Number(existing.id); + if (retainedSlideIds.has(existingId)) { + throw createValidationError('Playlist slide data is invalid.'); + } + item.existingPlaylistSlideId = existingId; + retainedSlideIds.add(existingId); + } + }); + } + if (shouldUpdatePlaylist) { await connection.query('UPDATE c_playlists SET name = ?, fade_between_slides = ?, skip_unavailable_rtmp = ?, canvas_id = ?, modified_by = ? WHERE id = ?', [name, fadeBetweenSlides, skipUnavailableRtmp, nextCanvasId, actorId, playlist.id]); } - await connection.query('DELETE FROM c_playlist_slides WHERE playlist_id = ?', [playlist.id]); - await connection.query('DELETE FROM c_playlist_groups WHERE playlist_id = ?', [playlist.id]); const groupIdsByKey = new Map(); + const existingGroupsByKey = new Map(existingPlaylistGroups.map(function (group) { return [String(group.group_key), group]; })); + const retainedGroupIds = new Set(); + const existingRulesByOwner = new Map(); + existingScheduleRules.forEach(function (rule) { + const owner = rule.playlist_group_id !== null && rule.playlist_group_id !== undefined + ? 'group:' + Number(rule.playlist_group_id) + : 'slide:' + Number(rule.playlist_slide_id); + if (!existingRulesByOwner.has(owner)) existingRulesByOwner.set(owner, []); + existingRulesByOwner.get(owner).push(rule); + }); for (const [groupKey, settings] of groupSettings) { - const [groupResult] = await connection.query( - 'INSERT INTO c_playlist_groups (playlist_id, group_key, name, pick_count, shuffle, mode, repeat_count, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', - [playlist.id, groupKey, settings.name, settings.pickCount, settings.shuffle, settings.mode, settings.repeatCount, actorId, actorId] - ); - const groupId = Number(groupResult && groupResult.insertId); + const existingGroup = existingGroupsByKey.get(groupKey); + let groupId; + if (existingGroup) { + groupId = Number(existingGroup.id); + await connection.query( + 'UPDATE c_playlist_groups SET name = ?, pick_count = ?, shuffle = ?, mode = ?, repeat_count = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', + [settings.name, settings.pickCount, settings.shuffle, settings.mode, settings.repeatCount, actorId, groupId, playlist.id] + ); + retainedGroupIds.add(groupId); + } else { + const [groupResult] = await connection.query( + 'INSERT INTO c_playlist_groups (playlist_id, group_key, name, pick_count, shuffle, mode, repeat_count, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + [playlist.id, groupKey, settings.name, settings.pickCount, settings.shuffle, settings.mode, settings.repeatCount, actorId, actorId] + ); + groupId = Number(groupResult && groupResult.insertId); + } if (!groupId) { throw new Error('Could not create playlist group ' + groupKey + '.'); } groupIdsByKey.set(groupKey, groupId); - for (let ruleIndex = 0; ruleIndex < settings.scheduleRules.length; ruleIndex += 1) { - const rule = settings.scheduleRules[ruleIndex]; - await connection.query( - 'INSERT INTO c_playlist_schedule_rules (playlist_group_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', - [groupId, ruleIndex, rule.start_datetime || null, rule.end_datetime || null, rule.start_time || null, rule.end_time || null, rule.days && rule.days.length ? JSON.stringify(rule.days) : null, actorId, actorId] - ); - } + await saveScheduleRulesInPlace(connection, 'playlist_group_id', groupId, settings.scheduleRules, existingRulesByOwner.get('group:' + groupId), actorId); } for (let i = 0; i < normalizedSlides.length; i += 1) { const item = normalizedSlides[i]; - const [insertResult] = await connection.query( - 'INSERT INTO c_playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, disable_audio, group_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', - [playlist.id, item.slideId, item.position, item.durationSeconds, item.useVideoDuration, item.disableAudio, item.groupKey ? groupIdsByKey.get(item.groupKey) : null, actorId, actorId] - ); - const insertedPlaylistSlideId = Number(insertResult.insertId); const itemRules = Array.isArray(item.scheduleRules) ? item.scheduleRules : []; - for (let ruleIndex = 0; ruleIndex < itemRules.length; ruleIndex += 1) { - const rule = itemRules[ruleIndex]; + const groupId = item.groupKey ? groupIdsByKey.get(item.groupKey) : null; + let playlistSlideId = item.existingPlaylistSlideId; + if (playlistSlideId) { await connection.query( - 'INSERT INTO c_playlist_schedule_rules (playlist_slide_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', - [insertedPlaylistSlideId, rule.position || ruleIndex, rule.startDatetime || null, rule.endDatetime || null, rule.startTime || null, rule.endTime || null, rule.daysJson || null, actorId, actorId] + 'UPDATE c_playlist_slides SET slide_id = ?, position = ?, duration_seconds = ?, use_video_duration = ?, disable_audio = ?, group_id = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', + [item.slideId, item.position, item.durationSeconds, item.useVideoDuration, item.disableAudio, groupId, actorId, playlistSlideId, playlist.id] ); + } else { + const [insertResult] = await connection.query( + 'INSERT INTO c_playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, disable_audio, group_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + [playlist.id, item.slideId, item.position, item.durationSeconds, item.useVideoDuration, item.disableAudio, groupId, actorId, actorId] + ); + playlistSlideId = Number(insertResult.insertId); + } + await saveScheduleRulesInPlace(connection, 'playlist_slide_id', playlistSlideId, itemRules, existingRulesByOwner.get('slide:' + playlistSlideId), actorId); + } + for (const existingSlide of existingPlaylistSlides) { + if (!retainedSlideIds.has(Number(existingSlide.id))) { + await connection.query('DELETE FROM c_playlist_slides WHERE id = ? AND playlist_id = ?', [existingSlide.id, playlist.id]); + } + } + for (const existingGroup of existingPlaylistGroups) { + if (!retainedGroupIds.has(Number(existingGroup.id))) { + await connection.query('DELETE FROM c_playlist_groups WHERE id = ? AND playlist_id = ?', [existingGroup.id, playlist.id]); } } @@ -537,6 +660,7 @@ module.exports = function registerPlaylistRoutes(app, deps) { } } + await connection.beginTransaction(); const previousItems = typeof recordRequestAuditEvent === 'function' ? await fetchPlaylistAuditItems(connection, playlist.id) : []; const saveResult = await savePlaylistItems(connection, playlist, req.body, actorId, { updatePlaylist: true }); const nextItems = typeof recordRequestAuditEvent === 'function' ? await fetchPlaylistAuditItems(connection, playlist.id) : []; diff --git a/src/web/routes/signage/playlists/form-view-model.js b/src/web/routes/signage/playlists/form-view-model.js index 7d2abe4..6373e72 100644 --- a/src/web/routes/signage/playlists/form-view-model.js +++ b/src/web/routes/signage/playlists/form-view-model.js @@ -190,31 +190,35 @@ function buildPlaylistFormViewModel(playlist, data, message, currentUser, option } return a.id - b.id; }) - .map((item, index, items) => Object.assign({}, item, { - row_key: String(item.row_key || item.id || ''), - displayOrder: index + 1, - isFirst: index === 0, - isLast: index === items.length - 1, - canvasSizeId: Number(item.canvas_size_id) || null, - showVideoDurationButton: hasVideoRegion(item.content_json), - showMuteButton: hasPlayableMediaRegion(item.content_json), - videoSourcePath: getVideoSourcePath(item), - videoDurationSeconds: getVideoDurationSeconds(item), - useVideoDuration: Boolean(item.use_video_duration), - disableAudio: getDisableAudioValue(item), - durationSeconds: getVideoDurationValue(item), - groupKey: item.group_key || '', - groupName: item.group_name || '', - groupPickCount: Number(item.group_pick_count) || 1, - groupShuffle: Boolean(Number(item.group_shuffle)), - groupMode: item.group_mode === 'repeat' ? 'repeat' : 'select', - groupRepeatCount: Math.min(100, Math.max(1, Number(item.group_repeat_count) || 1)), - groupScheduleRules: (Array.isArray(item.groupScheduleRules) ? item.groupScheduleRules : []).map((rule) => Object.assign({}, rule, { + .map((item, index, items) => { + const groupScheduleRules = (Array.isArray(item.groupScheduleRules) ? item.groupScheduleRules : []).map((rule) => Object.assign({}, rule, { days: normalizeDayValues(rule.days || rule.schedule_days_json) - })), - scheduleRules: (Array.isArray(item.scheduleRules) ? item.scheduleRules : []).map(formatScheduleRuleForView), - scheduleSummary: scheduleSummary(item) - })); + })); + return Object.assign({}, item, { + row_key: String(item.row_key || item.id || ''), + displayOrder: index + 1, + isFirst: index === 0, + isLast: index === items.length - 1, + canvasSizeId: Number(item.canvas_size_id) || null, + showVideoDurationButton: hasVideoRegion(item.content_json), + showMuteButton: hasPlayableMediaRegion(item.content_json), + videoSourcePath: getVideoSourcePath(item), + videoDurationSeconds: getVideoDurationSeconds(item), + useVideoDuration: Boolean(item.use_video_duration), + disableAudio: getDisableAudioValue(item), + durationSeconds: getVideoDurationValue(item), + groupKey: item.group_key || '', + groupName: item.group_name || '', + groupPickCount: Number(item.group_pick_count) || 1, + groupShuffle: Boolean(Number(item.group_shuffle)), + groupMode: item.group_mode === 'repeat' ? 'repeat' : 'select', + groupRepeatCount: Math.min(100, Math.max(1, Number(item.group_repeat_count) || 1)), + groupScheduleRules: groupScheduleRules, + groupScheduleRulesJson: JSON.stringify(groupScheduleRules), + scheduleRules: (Array.isArray(item.scheduleRules) ? item.scheduleRules : []).map(formatScheduleRuleForView), + scheduleSummary: scheduleSummary(item) + }); + }); const assignedSlideIds = new Set(playlistSlides.map((item) => item.slide_id)); const storedCanvasId = Number(viewPlaylist.canvas_id) || null; const playlistSlideCanvasIds = Array.from(new Set(playlistSlides.map((item) => Number(item.canvasSizeId || item.canvas_size_id)).filter((value) => Number.isInteger(value) && value > 0))); diff --git a/src/web/views/signage/playlists/slide-row.hbs b/src/web/views/signage/playlists/slide-row.hbs index 5c85538..3f5163c 100644 --- a/src/web/views/signage/playlists/slide-row.hbs +++ b/src/web/views/signage/playlists/slide-row.hbs @@ -30,7 +30,7 @@ - +
{{slide.scheduleSummary}}
diff --git a/test/playlist-form-view-model.test.js b/test/playlist-form-view-model.test.js index a3ce974..7552e72 100644 --- a/test/playlist-form-view-model.test.js +++ b/test/playlist-form-view-model.test.js @@ -41,6 +41,32 @@ test('new playlist rows receive preview URLs, titles and canvas dimensions', () assert.equal(attrs['aria-label'], 'Preview slide: Portrait'); }); +test('playlist group rule JSON is escaped in the hidden form field and remains intact', () => { + const { renderView } = require('../src/web/view'); + const model = buildPlaylistFormViewModel({ id: 3, name: 'Group rules' }, { + playlistSlides: [{ + id: 12, + playlist_id: 3, + slide_id: 22, + position: 0, + group_id: 5, + group_key: 'events', + group_name: 'Events', + group_pick_count: 1, + groupScheduleRules: [{ id: 71, playlist_group_id: 5, position: 0, start_time: '09:00:00', end_time: '17:00:00', schedule_days_json: '[1,3]' }], + scheduleRules: [] + }], + slides: [], + canvasSizes: [] + }, '', null, { isEdit: true }); + const html = renderView('playlists/form', model); + const hiddenRuleField = html.match(/]+data-playlist-group-schedule-rules[^>]+>/); + + assert.ok(hiddenRuleField); + assert.match(hiddenRuleField[0], /value="\[\{"id":71,/); + assert.doesNotMatch(hiddenRuleField[0], /value="\[\{"id/); +}); + test('playlist schedule summaries show up to two short rules on separate lines and count longer summaries', () => { const fs = require('node:fs'); const path = require('node:path'); diff --git a/test/playlists-routes.test.js b/test/playlists-routes.test.js index 3c42e1c..0a3de37 100644 --- a/test/playlists-routes.test.js +++ b/test/playlists-routes.test.js @@ -54,14 +54,9 @@ test('playlist create and update redirect to the canonical edit path', async () calls.push('update-playlist'); return [{ affectedRows: 1 }]; } - if (sql.startsWith('DELETE FROM c_playlist_slides')) { - calls.push('delete-slides'); - return [{ affectedRows: 1 }]; - } - if (sql.startsWith('DELETE FROM c_playlist_groups')) { - calls.push('delete-groups'); - return [{ affectedRows: 1 }]; - } + if (sql.startsWith('SELECT ps.id, ps.slide_id, ps.group_id')) return [[]]; + if (sql.startsWith('SELECT id, group_key FROM c_playlist_groups')) return [[]]; + if (sql.startsWith('SELECT r.id, r.playlist_slide_id')) return [[]]; if (sql.startsWith('SELECT sl.id, st.canvas_size_id')) { return [[{ id: 11, canvas_size_id: 7 }]]; } @@ -171,12 +166,8 @@ test('playlist create and update redirect to the canonical edit path', async () [71, 1, null, '2026-10-31T18:00', null, null, null, 12, 12] ]); assert.deepEqual(calls, [ - 'delete-slides', - 'delete-groups', { kind: 'redirectAfterSave', url: '/playlists/4/edit', path: '/playlists' }, 'update-playlist', - 'delete-slides', - 'delete-groups', 'insert-group', 'insert-group-rule', 'insert-group-rule', @@ -220,24 +211,134 @@ test('playlist create and update redirect to the canonical edit path', async () } }); +test('playlist save updates retained slide, group, and schedule rows in place', async () => { + const writes = []; + let items = [ + { id: 101, slide_id: 11, group_id: 201, duration_seconds: 10, position: 0, use_video_duration: 0, disable_audio: 1 }, + { id: 102, slide_id: 12, group_id: null, duration_seconds: 15, position: 1, use_video_duration: 0, disable_audio: 1 } + ]; + let groups = [{ id: 201, group_key: 'news' }]; + let rules = [ + { id: 301, playlist_slide_id: null, playlist_group_id: 201, position: 0, start_datetime: null, end_datetime: null, start_time: '08:00:00', end_time: '17:00:00', schedule_days_json: '[1,2]' }, + { id: 302, playlist_slide_id: 101, playlist_group_id: null, position: 0, start_datetime: null, end_datetime: null, start_time: '09:00:00', end_time: '18:00:00', schedule_days_json: null }, + { id: 303, playlist_slide_id: 102, playlist_group_id: null, position: 0, start_datetime: null, end_datetime: null, start_time: '10:00:00', end_time: '19:00:00', schedule_days_json: null } + ]; + const connection = { + async beginTransaction() {}, async commit() {}, async rollback() {}, release() {}, + async query(sql, params) { + if (sql.startsWith('SELECT ps.id, ps.slide_id, ps.group_id FROM c_playlist_slides')) return [structuredClone(items)]; + if (sql.startsWith('SELECT id, group_key FROM c_playlist_groups')) return [structuredClone(groups)]; + if (sql.startsWith('SELECT r.id, r.playlist_slide_id')) return [structuredClone(rules)]; + if (sql.startsWith('SELECT sl.id, st.canvas_size_id')) return [[{ id: 11, canvas_size_id: 7 }, { id: 13, canvas_size_id: 7 }]]; + if (sql.startsWith('UPDATE c_playlists SET')) return [{ affectedRows: 1 }]; + if (sql.startsWith('UPDATE c_playlist_groups SET')) { + writes.push({ kind: 'update-group', id: params[6], name: params[0] }); + return [{ affectedRows: 1 }]; + } + if (sql.startsWith('UPDATE c_playlist_slides SET')) { + writes.push({ kind: 'update-slide', id: params[7], duration: params[2] }); + items = items.map(item => item.id === params[7] ? Object.assign({}, item, { + slide_id: params[0], position: params[1], duration_seconds: params[2], + use_video_duration: params[3], disable_audio: params[4], group_id: params[5] + }) : item); + return [{ affectedRows: 1 }]; + } + if (sql.startsWith('UPDATE c_playlist_schedule_rules SET')) { + writes.push({ kind: 'update-rule', id: params[7] }); + rules = rules.map(rule => rule.id === params[7] ? Object.assign({}, rule, { + position: params[0], start_datetime: params[1], end_datetime: params[2], + start_time: params[3], end_time: params[4], schedule_days_json: params[5] + }) : rule); + return [{ affectedRows: 1 }]; + } + if (sql.startsWith('INSERT INTO c_playlist_slides')) { + writes.push({ kind: 'insert-slide', slideId: params[1] }); + items.push({ id: 103, slide_id: params[1], group_id: params[6], duration_seconds: params[3], position: params[2] }); + return [{ insertId: 103 }]; + } + if (sql.startsWith('DELETE FROM c_playlist_slides')) { + writes.push({ kind: 'delete-slide', id: params[0] }); + items = items.filter(item => item.id !== params[0]); + rules = rules.filter(rule => rule.playlist_slide_id !== params[0]); + } + return [[]]; + } + }; + const { app, handlers } = createAppAndHandlers(); + registerPlaylistRoutes(app, { + pool: { async getConnection() { return connection; } }, + common: { + async fetchDuplicateName() { return null; }, + async fetchPlaylistById() { return { id: 4, name: 'Loop', canvas_id: 7 }; } + }, + readArrayField(body, keys) { + for (const key of keys) { + if (Object.hasOwn(body, key)) return Array.isArray(body[key]) ? body[key] : [body[key]]; + } + return []; + }, + fetchScreensByPlaylistId: async () => [], getAuditUserId: () => 12, + redirectAfterSave(_req, res, url) { res.redirect(url); }, notifyPlayerScreens: async () => {}, broadcastDashboardState: async () => {}, + requirePermission: () => () => {} + }); + + const response = createResponse(); + await handlers['/playlists/:id'].at(-1)({ + params: { id: '4' }, + body: { + name: 'Loop', + slide_id: ['11', '13'], row_key: ['101', 'new-slide'], + duration_seconds: ['20', '12'], use_video_duration: ['0', '0'], disable_audio: ['1', '1'], + group_key: ['news', ''], group_name: ['News updated', ''], group_pick_count: ['1', ''], group_shuffle: ['0', ''], + group_mode: ['repeat', ''], group_repeat_count: ['3', ''], + group_schedule_rules_json: [JSON.stringify([{ start_time: '08:30', end_time: '17:30', days: [1, 2] }]), ''], + schedule_rule_row_key: ['101'], schedule_rule_start_time: ['09:30'], + schedule_rule_end_time: ['18:30'], schedule_rule_days_csv: [''] + } + }, response, error => { throw error; }); + + assert.equal(response.redirectedTo, '/playlists/4/edit', response.statusCode + ': ' + String(response.body || '')); + assert.deepEqual(writes, [ + { kind: 'update-group', id: 201, name: 'News updated' }, + { kind: 'update-rule', id: 301 }, + { kind: 'update-slide', id: 101, duration: 20 }, + { kind: 'update-rule', id: 302 }, + { kind: 'insert-slide', slideId: 13 }, + { kind: 'delete-slide', id: 102 } + ]); + assert.deepEqual(items.map(item => item.id), [101, 103]); + assert.deepEqual(rules.map(rule => rule.id), [301, 302]); +}); + test('playlist audit compares persisted items and schedules without recreated row IDs', async () => { - let items = [{ id: 101, slide_id: 11, group_id: null, duration_seconds: 10 }]; - let rules = [{ playlist_slide_id: 101, start_time: '08:00:00', end_time: '17:00:00', schedule_days_json: '[1,2]' }]; + let items = [{ id: 101, slide_id: 11, group_id: null, duration_seconds: 10, position: 0, use_video_duration: 0, disable_audio: 1 }]; + let rules = [{ id: 301, playlist_slide_id: 101, playlist_group_id: null, position: 0, start_datetime: null, end_datetime: null, start_time: '08:00:00', end_time: '17:00:00', schedule_days_json: '[1,2]' }]; const events = []; const connection = { - async commit() {}, async rollback() {}, release() {}, + async beginTransaction() {}, async commit() {}, async rollback() {}, release() {}, async query(sql, params) { + if (sql.startsWith('SELECT ps.id, ps.slide_id, ps.group_id FROM c_playlist_slides')) return [structuredClone(items)]; if (sql.includes('FROM c_playlist_slides ps') && sql.includes('WHERE ps.playlist_id')) return [structuredClone(items)]; + if (sql.startsWith('SELECT id, group_key FROM c_playlist_groups')) return [[]]; if (sql.includes('FROM c_playlist_schedule_rules r')) return [structuredClone(rules)]; if (sql.startsWith('SELECT sl.id, st.canvas_size_id')) return [[{ id: 11, canvas_size_id: 7 }]]; - if (sql.startsWith('DELETE FROM c_playlist_slides')) { items = []; rules = []; } - if (sql.startsWith('DELETE FROM c_playlist_groups')) return [{ affectedRows: 0 }]; - if (sql.startsWith('INSERT INTO c_playlist_slides')) { - items = [{ id: 202, slide_id: params[1], group_id: params[6], duration_seconds: params[3] }]; - return [{ insertId: 202 }]; + if (sql.startsWith('UPDATE c_playlist_slides SET')) { + items = items.map(item => item.id === params[7] ? Object.assign({}, item, { + slide_id: params[0], position: params[1], duration_seconds: params[2], + use_video_duration: params[3], disable_audio: params[4], group_id: params[5] + }) : item); + return [{ affectedRows: 1 }]; } - if (sql.startsWith('INSERT INTO c_playlist_schedule_rules')) { - rules.push({ playlist_slide_id: params[0], start_time: params[4] + ':00', end_time: params[5] + ':00', schedule_days_json: params[6] }); + if (sql.startsWith('UPDATE c_playlist_schedule_rules SET')) { + rules = rules.map(rule => rule.id === params[7] ? Object.assign({}, rule, { + position: params[0], start_datetime: params[1], end_datetime: params[2], + start_time: params[3] + ':00', end_time: params[4] + ':00', schedule_days_json: params[5] + }) : rule); + return [{ affectedRows: 1 }]; + } + if (sql.startsWith('DELETE FROM c_playlist_slides')) { + items = items.filter(item => item.id !== params[0]); + rules = rules.filter(rule => rule.playlist_slide_id !== params[0]); } return [[]]; }