mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
feat(content): batch operations — multi-select, batch delete, batch move (#224)
The content library had no batch operations — every item was managed one at a time. Add multi-select with batch delete and batch move. Backend (content.js): - POST /content/batch/delete — array of ids, atomic: validates + authorizes EVERY id first (malformed/missing/forbidden rejects the whole batch), then deletes in one transaction. Reuses the single-delete teardown. - POST /content/batch/move — array of ids + target folder_id, same atomic validate-all-first; target folder must share each item's workspace. Folder is organizational (not in the snapshot), so no device push. - Refactor: extract purgeContentRow() (file removal + snapshot scrub + row delete + affected-device collection) and pushContentUpdates(); DELETE /:id now uses them, so single + batch share one scrub path (no duplication). Add a boolean contentWritable() mirroring checkContentWrite's authorization. - 500-item cap per batch; UUID validation guards the snapshot-scrub LIKE. Frontend (content-library): - Per-card selection checkbox, select-all/none (visible), shift-click range. - Selection persists across folders/pages (issue-aligned cross-page selection); cleared after a successful batch op. - Batch toolbar (shown when >0 selected): count, move-to-folder picker, delete with click-again confirm. Selected cards get an outline. - api.batchDeleteContent / batchMoveContent; en/es i18n. Not included: batch "set expiry" (listed in the issue's toolbar sketch but only delete/move had endpoint specs) — deferred; PUT already does per-item expiry. Test: content-batch-ops.test.js — batch delete removes rows+files+scrubs snapshots; atomic rejection leaves valid rows intact; malformed id -> 400; batch move reassigns folder; cross-workspace folder refused; empty batch -> 400. Suite 553/553. Closes #213 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5c6d508032
commit
8b661a7347
|
|
@ -75,6 +75,15 @@ export const api = {
|
|||
method: 'PUT',
|
||||
body: JSON.stringify({ folder_id: folderId })
|
||||
}),
|
||||
// #213: batch operations — the whole batch succeeds or fails atomically server-side.
|
||||
batchDeleteContent: (ids) => request('/content/batch/delete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids })
|
||||
}),
|
||||
batchMoveContent: (ids, folderId) => request('/content/batch/move', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids, folder_id: folderId || null })
|
||||
}),
|
||||
|
||||
// Folders
|
||||
getFolders: () => request('/folders'),
|
||||
|
|
|
|||
|
|
@ -197,6 +197,14 @@ export default {
|
|||
'content.sort_name': 'Name A–Z',
|
||||
'content.sort_size': 'Largest first',
|
||||
'content.result_count': '{count} result(s)',
|
||||
'content.batch_selected': '{count} selected',
|
||||
'content.batch_select_all': 'Select all',
|
||||
'content.batch_select_none': 'Select none',
|
||||
'content.batch_move_placeholder': 'Move to…',
|
||||
'content.batch_delete': 'Delete ({count})',
|
||||
'content.batch_delete_confirm': 'Click again to delete {count}',
|
||||
'content.toast.batch_moved': '{count} item(s) moved',
|
||||
'content.toast.batch_deleted': '{count} item(s) deleted',
|
||||
'content.new_folder_btn': '+ New Folder',
|
||||
'content.breadcrumb_root': 'All Content',
|
||||
'content.rename_btn': 'Rename',
|
||||
|
|
|
|||
|
|
@ -160,6 +160,14 @@ export default {
|
|||
'content.sort_name': 'Nombre A–Z',
|
||||
'content.sort_size': 'Más grandes primero',
|
||||
'content.result_count': '{count} resultado(s)',
|
||||
'content.batch_selected': '{count} seleccionado(s)',
|
||||
'content.batch_select_all': 'Seleccionar todo',
|
||||
'content.batch_select_none': 'Deseleccionar todo',
|
||||
'content.batch_move_placeholder': 'Mover a…',
|
||||
'content.batch_delete': 'Eliminar ({count})',
|
||||
'content.batch_delete_confirm': 'Haz clic otra vez para eliminar {count}',
|
||||
'content.toast.batch_moved': '{count} elemento(s) movido(s)',
|
||||
'content.toast.batch_deleted': '{count} elemento(s) eliminado(s)',
|
||||
'content.new_folder_btn': '+ Nueva carpeta',
|
||||
'content.breadcrumb_root': 'Todo el contenido',
|
||||
'content.rename_btn': 'Renombrar',
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ export function render(container) {
|
|||
</label>
|
||||
</div>
|
||||
<div id="folderBreadcrumb" style="display:flex;gap:6px;align-items:center;margin-bottom:12px;font-size:13px;flex-wrap:wrap"></div>
|
||||
<div id="batchToolbar" style="display:none"></div>
|
||||
<div id="folderGrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;margin-bottom:16px"></div>
|
||||
<div class="content-grid" id="contentGrid">
|
||||
<div class="empty-state" style="grid-column:1/-1"><h3>${t('common.loading')}</h3></div>
|
||||
|
|
@ -230,6 +231,8 @@ const state = {
|
|||
search: '', // #214: server-side text search (spans the whole workspace)
|
||||
type: 'all', // #214: type filter — all | video | image | youtube | web
|
||||
sort: 'date_desc', // #214: sort order — date_desc | date_asc | name | size
|
||||
selected: new Set(), // #213: ids selected for batch operations (scoped to the current view)
|
||||
lastClickedId: null, // #213: anchor for shift-click range selection
|
||||
};
|
||||
|
||||
async function handleFiles(files) {
|
||||
|
|
@ -419,7 +422,10 @@ async function loadContent() {
|
|||
grid.innerHTML = content.map(c => {
|
||||
const exp = expiryInfo(c);
|
||||
return `
|
||||
<div class="content-item" draggable="true" data-content-id="${c.id}" data-folder="${c.folder || ''}" style="${exp.expired ? 'opacity:.55' : ''}">
|
||||
<div class="content-item" draggable="true" data-content-id="${c.id}" data-folder="${c.folder || ''}" style="position:relative;${state.selected.has(c.id) ? 'outline:2px solid var(--primary,#3B82F6);outline-offset:-2px;' : ''}${exp.expired ? 'opacity:.55' : ''}">
|
||||
<label class="content-select-wrap" style="position:absolute;top:6px;left:6px;z-index:2;background:rgba(0,0,0,.55);border-radius:4px;padding:3px;display:flex;cursor:pointer">
|
||||
<input type="checkbox" class="content-select" data-content-id="${c.id}" ${state.selected.has(c.id) ? 'checked' : ''} style="width:16px;height:16px;margin:0;cursor:pointer">
|
||||
</label>
|
||||
<div class="content-item-preview">
|
||||
${c.mime_type === 'video/youtube'
|
||||
? `<div style="position:relative;width:100%;height:100%;background:#000;display:flex;align-items:center;justify-content:center">
|
||||
|
|
@ -491,8 +497,34 @@ async function loadContent() {
|
|||
});
|
||||
});
|
||||
|
||||
// #213: selection checkboxes (with shift-click range). `content` is the current page's
|
||||
// ordered list, so a range fills between the anchor and the clicked item.
|
||||
grid.querySelectorAll('.content-select').forEach(cb => {
|
||||
cb.addEventListener('click', (e) => {
|
||||
const id = cb.dataset.contentId;
|
||||
if (e.shiftKey && state.lastClickedId) {
|
||||
const order = content.map(c => c.id);
|
||||
const a = order.indexOf(state.lastClickedId);
|
||||
const b = order.indexOf(id);
|
||||
if (a !== -1 && b !== -1) {
|
||||
const [lo, hi] = a < b ? [a, b] : [b, a];
|
||||
const on = cb.checked; // apply the clicked box's new state across the range
|
||||
for (let i = lo; i <= hi; i++) { if (on) state.selected.add(order[i]); else state.selected.delete(order[i]); }
|
||||
}
|
||||
} else if (cb.checked) {
|
||||
state.selected.add(id);
|
||||
} else {
|
||||
state.selected.delete(id);
|
||||
}
|
||||
state.lastClickedId = id;
|
||||
loadContent(); // re-render to reflect range + selection outlines + toolbar
|
||||
});
|
||||
});
|
||||
|
||||
// Delete handler via event delegation
|
||||
grid.onclick = async (e) => {
|
||||
// #213: ignore clicks originating on a selection checkbox (handled above).
|
||||
if (e.target.closest('.content-select-wrap')) return;
|
||||
// Preview on click (not on delete button)
|
||||
const previewTarget = e.target.closest('.content-item-preview');
|
||||
if (previewTarget) {
|
||||
|
|
@ -552,11 +584,87 @@ async function loadContent() {
|
|||
}, 3000);
|
||||
};
|
||||
|
||||
// #213: batch-operations toolbar reflects the current selection.
|
||||
renderBatchToolbar(content);
|
||||
|
||||
} catch (err) {
|
||||
grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><h3>${t('content.failed_to_load')}</h3><p>${esc(err.message)}</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// #213: the batch toolbar — shown only when something is selected. `visible` is the current
|
||||
// page's items, used by "select all". Actions validate/act atomically server-side; on success
|
||||
// the selection is cleared and the grid reloaded.
|
||||
function renderBatchToolbar(visible) {
|
||||
const bar = document.getElementById('batchToolbar');
|
||||
if (!bar) return;
|
||||
const count = state.selected.size;
|
||||
if (count === 0) { bar.style.display = 'none'; bar.innerHTML = ''; return; }
|
||||
|
||||
const allVisibleSelected = visible.length > 0 && visible.every(c => state.selected.has(c.id));
|
||||
bar.style.display = 'flex';
|
||||
bar.style.cssText = 'display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;padding:10px 14px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg)';
|
||||
bar.innerHTML = `
|
||||
<strong style="font-size:13px">${t('content.batch_selected', { count })}</strong>
|
||||
<button class="btn btn-secondary btn-sm" id="batchSelectAll">${allVisibleSelected ? t('content.batch_select_none') : t('content.batch_select_all')}</button>
|
||||
<div style="display:flex;align-items:center;gap:6px;margin-left:auto">
|
||||
<select id="batchMoveFolder" class="input btn-sm" style="width:auto;background:var(--bg-input)">
|
||||
<option value="">${t('content.batch_move_placeholder')}</option>
|
||||
<option value="__root__">${t('content.folder_root_option')}</option>
|
||||
${state.folders.map(f => `<option value="${f.id}">${esc(folderPath(f, state.folders))}</option>`).join('')}
|
||||
</select>
|
||||
<button class="btn btn-danger btn-sm" id="batchDelete">${t('content.batch_delete', { count })}</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
bar.querySelector('#batchSelectAll').onclick = () => {
|
||||
if (allVisibleSelected) visible.forEach(c => state.selected.delete(c.id));
|
||||
else visible.forEach(c => state.selected.add(c.id));
|
||||
loadContent();
|
||||
};
|
||||
|
||||
bar.querySelector('#batchMoveFolder').onchange = async (e) => {
|
||||
const val = e.target.value;
|
||||
if (!val) return;
|
||||
const folderId = val === '__root__' ? null : val;
|
||||
const ids = [...state.selected];
|
||||
try {
|
||||
await api.batchMoveContent(ids, folderId);
|
||||
showToast(t('content.toast.batch_moved', { count: ids.length }), 'success');
|
||||
state.selected.clear();
|
||||
state.lastClickedId = null;
|
||||
loadContent();
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const delBtn = bar.querySelector('#batchDelete');
|
||||
delBtn.onclick = async () => {
|
||||
const ids = [...state.selected];
|
||||
if (delBtn.dataset.confirming !== 'true') {
|
||||
delBtn.dataset.confirming = 'true';
|
||||
delBtn.textContent = t('content.batch_delete_confirm', { count: ids.length });
|
||||
setTimeout(() => { if (delBtn.dataset.confirming === 'true') { delBtn.dataset.confirming = 'false'; delBtn.textContent = t('content.batch_delete', { count: ids.length }); } }, 3000);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
delBtn.disabled = true;
|
||||
await api.batchDeleteContent(ids);
|
||||
showToast(t('content.toast.batch_deleted', { count: ids.length }), 'success');
|
||||
state.selected.clear();
|
||||
state.lastClickedId = null;
|
||||
loadContent();
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
delBtn.disabled = false;
|
||||
delBtn.dataset.confirming = 'false';
|
||||
delBtn.textContent = t('content.batch_delete', { count: ids.length });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function showEditModal(contentItem, onSave) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay';
|
||||
|
|
|
|||
|
|
@ -271,6 +271,132 @@ function checkContentWrite(req, res) {
|
|||
return content;
|
||||
}
|
||||
|
||||
// #213: boolean form of checkContentWrite for batch paths (no res side effects). True if
|
||||
// req.user may modify this content row. Mirrors checkContentWrite's authorization exactly.
|
||||
function contentWritable(req, content) {
|
||||
if (!content) return false;
|
||||
if (!content.workspace_id) return PLATFORM_ROLES.includes(req.user.role);
|
||||
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(content.workspace_id);
|
||||
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
|
||||
if (!ctx) return false;
|
||||
if (!ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
// #213: shared single-row teardown used by DELETE /:id and POST /batch/delete. Removes the
|
||||
// row's files, scrubs it from published snapshots in its workspace, deletes the row (cascades
|
||||
// playlist_items). Returns the device ids whose playlists referenced it so the caller can push
|
||||
// updates. Pure DB+FS, no HTTP. `content.id` MUST be a validated UUID (LIKE scrub) and the
|
||||
// caller MUST have authorized the write. File unlinks are wrapped so they never throw.
|
||||
function purgeContentRow(content) {
|
||||
const id = content.id;
|
||||
const unlink = (rel) => {
|
||||
if (!rel) return;
|
||||
const p = path.join(config.contentDir, path.basename(rel));
|
||||
if (fs.existsSync(p)) { try { fs.unlinkSync(p); } catch (e) { /* best-effort */ } }
|
||||
};
|
||||
unlink(content.filepath);
|
||||
unlink(content.thumbnail_path);
|
||||
unlink(content.subtitle_url); // #216 sidecar (undefined on pre-#216 rows — no-op)
|
||||
|
||||
const affected = db.prepare(`
|
||||
SELECT DISTINCT d.id as device_id FROM devices d
|
||||
JOIN playlists p ON d.playlist_id = p.id
|
||||
JOIN playlist_items pi ON pi.playlist_id = p.id
|
||||
WHERE pi.content_id = ?
|
||||
`).all(id).map(r => r.device_id);
|
||||
|
||||
const snapshotPlaylists = db.prepare(
|
||||
"SELECT id, published_snapshot FROM playlists WHERE workspace_id = ? AND published_snapshot LIKE ?"
|
||||
).all(content.workspace_id, `%${id}%`);
|
||||
for (const pl of snapshotPlaylists) {
|
||||
try {
|
||||
const items = JSON.parse(pl.published_snapshot);
|
||||
const filtered = items.filter(item => item.content_id !== id);
|
||||
if (filtered.length !== items.length) {
|
||||
db.prepare('UPDATE playlists SET published_snapshot = ? WHERE id = ?').run(JSON.stringify(filtered), pl.id);
|
||||
}
|
||||
} catch (e) { /* corrupt snapshot, skip */ }
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM content WHERE id = ?').run(id);
|
||||
return affected;
|
||||
}
|
||||
|
||||
// #213: push a playlist refresh to a set of device ids (deduped). Silent on any failure.
|
||||
function pushContentUpdates(req, deviceIds) {
|
||||
try {
|
||||
const io = req.app.get('io');
|
||||
if (!io) return;
|
||||
const { buildPlaylistPayload } = require('../ws/deviceSocket');
|
||||
const commandQueue = require('../lib/command-queue');
|
||||
const deviceNs = io.of('/device');
|
||||
for (const id of new Set(deviceIds)) {
|
||||
commandQueue.queueOrEmitPlaylistUpdate(deviceNs, id, buildPlaylistPayload);
|
||||
}
|
||||
} catch (e) { /* silent */ }
|
||||
}
|
||||
|
||||
// #213: batch delete. Validates + authorizes EVERY id first (atomic — the whole batch is
|
||||
// rejected if any id is malformed/missing/forbidden), then deletes in one transaction.
|
||||
router.post('/batch/delete', (req, res) => {
|
||||
const ids = Array.isArray(req.body.ids) ? req.body.ids : null;
|
||||
if (!ids || ids.length === 0) return res.status(400).json({ error: 'ids must be a non-empty array' });
|
||||
if (ids.length > 500) return res.status(400).json({ error: 'Too many items (max 500 per batch)' });
|
||||
|
||||
const rows = [];
|
||||
for (const id of ids) {
|
||||
if (typeof id !== 'string' || !UUID_RE.test(id)) return res.status(400).json({ error: `Invalid content ID: ${id}` });
|
||||
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id);
|
||||
if (!content) return res.status(404).json({ error: `Content not found: ${id}` });
|
||||
if (!contentWritable(req, content)) return res.status(403).json({ error: `Access denied for content: ${id}` });
|
||||
rows.push(content);
|
||||
}
|
||||
|
||||
const affected = new Set();
|
||||
db.transaction(() => {
|
||||
for (const content of rows) for (const d of purgeContentRow(content)) affected.add(d);
|
||||
})();
|
||||
pushContentUpdates(req, affected);
|
||||
res.json({ success: true, deleted: rows.length, affectedDevices: [...affected] });
|
||||
});
|
||||
|
||||
// #213: batch move. Reassigns folder_id for many items at once. Folder is organizational only
|
||||
// (not in the published snapshot), so no device push is needed. Same atomic validate-all-first.
|
||||
router.post('/batch/move', (req, res) => {
|
||||
const ids = Array.isArray(req.body.ids) ? req.body.ids : null;
|
||||
const folderId = req.body.folder_id || null;
|
||||
if (!ids || ids.length === 0) return res.status(400).json({ error: 'ids must be a non-empty array' });
|
||||
if (ids.length > 500) return res.status(400).json({ error: 'Too many items (max 500 per batch)' });
|
||||
|
||||
const rows = [];
|
||||
for (const id of ids) {
|
||||
if (typeof id !== 'string' || !UUID_RE.test(id)) return res.status(400).json({ error: `Invalid content ID: ${id}` });
|
||||
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id);
|
||||
if (!content) return res.status(404).json({ error: `Content not found: ${id}` });
|
||||
if (!contentWritable(req, content)) return res.status(403).json({ error: `Access denied for content: ${id}` });
|
||||
rows.push(content);
|
||||
}
|
||||
// Target folder (if any) must exist and share the workspace of every moved item.
|
||||
if (folderId) {
|
||||
const target = db.prepare('SELECT workspace_id FROM content_folders WHERE id = ?').get(folderId);
|
||||
if (!target) return res.status(400).json({ error: 'Invalid folder_id' });
|
||||
for (const content of rows) {
|
||||
if (target.workspace_id !== content.workspace_id) {
|
||||
return res.status(403).json({ error: 'Cannot move content to a folder in another workspace' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
db.transaction(() => {
|
||||
const stmt = db.prepare('UPDATE content SET folder_id = ? WHERE id = ?');
|
||||
for (const content of rows) stmt.run(folderId, content.id);
|
||||
})();
|
||||
res.json({ success: true, moved: rows.length, folder_id: folderId });
|
||||
});
|
||||
|
||||
// Get content metadata
|
||||
router.get('/:id', (req, res) => {
|
||||
const content = checkContentRead(req, res);
|
||||
|
|
@ -411,65 +537,14 @@ router.get('/:id/thumbnail', (req, res) => {
|
|||
router.delete('/:id', (req, res) => {
|
||||
const content = checkContentWrite(req, res);
|
||||
if (!content) return;
|
||||
|
||||
// Delete file from disk (skip for remote URL content)
|
||||
if (content.filepath) {
|
||||
const filePath = path.join(config.contentDir, content.filepath);
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||
}
|
||||
|
||||
// Delete thumbnail
|
||||
if (content.thumbnail_path) {
|
||||
const thumbPath = path.join(config.contentDir, content.thumbnail_path);
|
||||
if (fs.existsSync(thumbPath)) fs.unlinkSync(thumbPath);
|
||||
}
|
||||
|
||||
// Get devices that have this content in their playlist (via playlist_items)
|
||||
const affectedDevices = db.prepare(`
|
||||
SELECT DISTINCT d.id as device_id FROM devices d
|
||||
JOIN playlists p ON d.playlist_id = p.id
|
||||
JOIN playlist_items pi ON pi.playlist_id = p.id
|
||||
WHERE pi.content_id = ?
|
||||
`).all(req.params.id);
|
||||
|
||||
// Scrub published snapshots that reference this content
|
||||
// Validate UUID format to prevent LIKE wildcard injection
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
// Validate UUID format to prevent LIKE wildcard injection in the snapshot scrub.
|
||||
if (!UUID_RE.test(req.params.id)) return res.status(400).json({ error: 'Invalid content ID format' });
|
||||
// Phase 2.2k: scope snapshot scrubbing by content.workspace_id (was content.user_id).
|
||||
// Playlists referencing this content live in the same workspace; user_id-keying missed
|
||||
// cross-user playlists in the same workspace once playlists became workspace-scoped.
|
||||
const snapshotPlaylists = db.prepare(
|
||||
"SELECT id, published_snapshot FROM playlists WHERE workspace_id = ? AND published_snapshot LIKE ?"
|
||||
).all(content.workspace_id, `%${req.params.id}%`);
|
||||
for (const pl of snapshotPlaylists) {
|
||||
try {
|
||||
const items = JSON.parse(pl.published_snapshot);
|
||||
const filtered = items.filter(item => item.content_id !== req.params.id);
|
||||
if (filtered.length !== items.length) {
|
||||
db.prepare('UPDATE playlists SET published_snapshot = ? WHERE id = ?')
|
||||
.run(JSON.stringify(filtered), pl.id);
|
||||
}
|
||||
} catch (e) { /* corrupt snapshot, skip */ }
|
||||
}
|
||||
|
||||
// Delete from DB (cascades to playlist_items via ON DELETE CASCADE)
|
||||
db.prepare('DELETE FROM content WHERE id = ?').run(req.params.id);
|
||||
|
||||
// Push updated snapshots to affected devices
|
||||
try {
|
||||
const io = req.app.get('io');
|
||||
if (io) {
|
||||
const { buildPlaylistPayload } = require('../ws/deviceSocket');
|
||||
const commandQueue = require('../lib/command-queue');
|
||||
const deviceNs = io.of('/device');
|
||||
for (const d of affectedDevices) {
|
||||
commandQueue.queueOrEmitPlaylistUpdate(deviceNs, d.device_id, buildPlaylistPayload);
|
||||
}
|
||||
}
|
||||
} catch (e) { /* silent */ }
|
||||
|
||||
res.json({ success: true, affectedDevices: affectedDevices.map(d => d.device_id) });
|
||||
// #213: shared teardown (file removal + snapshot scrub + row delete). Returns the affected
|
||||
// device ids so we can push a refresh.
|
||||
const affectedDevices = purgeContentRow(content);
|
||||
pushContentUpdates(req, affectedDevices);
|
||||
res.json({ success: true, affectedDevices });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
124
server/test/content-batch-ops.test.js
Normal file
124
server/test/content-batch-ops.test.js
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
'use strict';
|
||||
|
||||
// #213 batch operations. POST /content/batch/delete and /batch/move. The atomic
|
||||
// validate-all-first contract and the shared snapshot-scrub path are the parts worth guarding.
|
||||
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
const crypto = require('node:crypto');
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'st-batch-'));
|
||||
process.env.DATA_DIR = TMP;
|
||||
process.env.SELF_HOSTED = 'true';
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
const { test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const http = require('node:http');
|
||||
const express = require('express');
|
||||
const { db } = require('../db/database');
|
||||
const { publishPlaylist } = require('../routes/playlists');
|
||||
|
||||
const UUID = () => crypto.randomUUID();
|
||||
const USER = 'u-batch';
|
||||
let server, base;
|
||||
|
||||
function post(pathname, body) {
|
||||
const data = Buffer.from(JSON.stringify(body));
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(`${base}${pathname}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Content-Length': data.length },
|
||||
}, (r) => { let o = ''; r.on('data', (c) => (o += c)); r.on('end', () => resolve({ status: r.statusCode, json: o ? JSON.parse(o) : null })); });
|
||||
req.on('error', reject);
|
||||
req.end(data);
|
||||
});
|
||||
}
|
||||
|
||||
function mkContent(id, { filepath = '', workspace = 'ws-batch' } = {}) {
|
||||
db.prepare('INSERT INTO content (id, filename, mime_type, filepath, workspace_id) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(id, id + '.mp4', 'video/mp4', filepath, workspace);
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
fs.mkdirSync(require('../config').contentDir, { recursive: true });
|
||||
db.prepare("INSERT INTO users (id, email, password_hash, plan_id) VALUES (?, ?, 'x', 'free')").run(USER, USER + '@t.local');
|
||||
db.prepare("INSERT INTO organizations (id, name, owner_user_id) VALUES ('org-batch', 'Org', ?)").run(USER);
|
||||
db.prepare("INSERT INTO workspaces (id, organization_id, name) VALUES ('ws-batch', 'org-batch', 'WS')").run();
|
||||
db.prepare("INSERT INTO workspaces (id, organization_id, name) VALUES ('ws-other', 'org-batch', 'Other')").run();
|
||||
db.prepare("INSERT INTO content_folders (id, name, user_id, workspace_id) VALUES ('f-batch', 'Folder', 'u-batch', 'ws-batch')").run();
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => { req.workspaceId = 'ws-batch'; req.user = { id: USER, role: 'platform_admin' }; next(); });
|
||||
app.use('/content', require('../routes/content'));
|
||||
server = http.createServer(app);
|
||||
await new Promise((r) => server.listen(0, r));
|
||||
base = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
|
||||
after(() => new Promise((r) => server.close(r)));
|
||||
|
||||
test('batch delete removes all rows, their files, and scrubs published snapshots', async () => {
|
||||
const a = UUID(), b = UUID();
|
||||
// give `a` a real file on disk to prove it's removed
|
||||
const fp = a + '.mp4';
|
||||
fs.writeFileSync(path.join(require('../config').contentDir, fp), 'x');
|
||||
mkContent(a, { filepath: fp });
|
||||
mkContent(b);
|
||||
|
||||
// a published playlist carrying both -> must be scrubbed of both
|
||||
const pl = UUID();
|
||||
db.prepare("INSERT INTO playlists (id, user_id, workspace_id, name, status) VALUES (?, ?, 'ws-batch', 'P', 'draft')").run(pl, USER);
|
||||
db.prepare('INSERT INTO playlist_items (playlist_id, content_id, sort_order) VALUES (?, ?, 0)').run(pl, a);
|
||||
db.prepare('INSERT INTO playlist_items (playlist_id, content_id, sort_order) VALUES (?, ?, 1)').run(pl, b);
|
||||
publishPlaylist(pl);
|
||||
assert.equal(JSON.parse(db.prepare('SELECT published_snapshot FROM playlists WHERE id=?').get(pl).published_snapshot).length, 2);
|
||||
|
||||
const res = await post('/content/batch/delete', { ids: [a, b] });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.json.deleted, 2);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) n FROM content WHERE id IN (?,?)').get(a, b).n, 0);
|
||||
assert.ok(!fs.existsSync(path.join(require('../config').contentDir, fp)), 'file removed from disk');
|
||||
assert.equal(JSON.parse(db.prepare('SELECT published_snapshot FROM playlists WHERE id=?').get(pl).published_snapshot).length, 0);
|
||||
});
|
||||
|
||||
test('batch delete is atomic: one bad id rejects the whole batch, nothing deleted', async () => {
|
||||
const a = UUID();
|
||||
mkContent(a);
|
||||
const res = await post('/content/batch/delete', { ids: [a, UUID() /* not found */] });
|
||||
assert.equal(res.status, 404);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) n FROM content WHERE id=?').get(a).n, 1, 'valid row survived the rejected batch');
|
||||
});
|
||||
|
||||
test('batch delete rejects a malformed id (LIKE-injection guard)', async () => {
|
||||
const res = await post('/content/batch/delete', { ids: ['not-a-uuid'] });
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
test('batch move reassigns folder for all ids', async () => {
|
||||
const a = UUID(), b = UUID();
|
||||
mkContent(a); mkContent(b);
|
||||
const res = await post('/content/batch/move', { ids: [a, b], folder_id: 'f-batch' });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.json.moved, 2);
|
||||
for (const id of [a, b]) assert.equal(db.prepare('SELECT folder_id FROM content WHERE id=?').get(id).folder_id, 'f-batch');
|
||||
// move back to root
|
||||
const res2 = await post('/content/batch/move', { ids: [a, b], folder_id: null });
|
||||
assert.equal(res2.status, 200);
|
||||
assert.equal(db.prepare('SELECT folder_id FROM content WHERE id=?').get(a).folder_id, null);
|
||||
});
|
||||
|
||||
test('batch move to a folder in another workspace is refused', async () => {
|
||||
const a = UUID();
|
||||
mkContent(a);
|
||||
db.prepare("INSERT INTO content_folders (id, name, user_id, workspace_id) VALUES ('f-other', 'Other', 'u-batch', 'ws-other')").run();
|
||||
const res = await post('/content/batch/move', { ids: [a], folder_id: 'f-other' });
|
||||
assert.equal(res.status, 403);
|
||||
assert.equal(db.prepare('SELECT folder_id FROM content WHERE id=?').get(a).folder_id, null, 'not moved');
|
||||
});
|
||||
|
||||
test('empty / oversized batches are rejected', async () => {
|
||||
assert.equal((await post('/content/batch/delete', { ids: [] })).status, 400);
|
||||
assert.equal((await post('/content/batch/move', { ids: [], folder_id: null })).status, 400);
|
||||
});
|
||||
Loading…
Reference in a new issue