mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 06:16:20 -06:00
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>
125 lines
5.9 KiB
JavaScript
125 lines
5.9 KiB
JavaScript
'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);
|
|
});
|