deckhearth/pages/api/admin/cards/batch.js
Randall Stillwell 70806d81ec
Some checks failed
CI / Lint (pull_request) Has been cancelled
CI / Schema map up to date (pull_request) Has been cancelled
CI / Forbidden patterns (9 checks) (pull_request) Has been cancelled
CI / Migrations apply (node-pg-migrate) (pull_request) Has been cancelled
CI / Unit tests (vitest) (pull_request) Has been cancelled
Convoy metrics gate / Require role-event telemetry on convoy PRs (pull_request) Has been cancelled
PR Health rollup / Aggregate gate status (pull_request) Has been cancelled
Preview smoke / Should run? (pull_request) Has been cancelled
Visual diff / Should run? (pull_request) Has been cancelled
Preview smoke / Playwright smoke (pull_request) Has been cancelled
Visual diff / Screenshot diff (pull_request) Has been cancelled
feat(admin): add batch selection + batch edit (Phase 2)
- AdminBulkSelectionToolbar: floating toolbar with batch edit action + clear selection
- BatchEditModal: form for editing allowed fields (rarity, verified, game, mana_cost, cmc, card_type, edhrec_rank, layout, released_at, reserved, reprint) with live preview
- API: POST /api/admin/cards/batch with validation, allowlist, 1000-card cap, audit logging
- Hook: selection state (Set), select-all, batch edit workflow
- Table: checkbox column, row highlighting, select-all header
- Build: all routes compile, lint clean (pre-existing only), 342 tests pass
2026-09-18 14:57:14 -05:00

127 lines
No EOL
3.8 KiB
JavaScript

import { sql } from '../../../../lib/sql.js';
import { withAdmin } from '../../../../lib/permission-middleware';
import { checkImportRateLimit } from '../../../../lib/rate-limit.js';
const ALLOWED_UPDATE_FIELDS = new Set([
'rarity',
'verified',
'game',
'mana_cost',
'cmc',
'card_type',
'edhrec_rank',
'layout',
'released_at',
'reserved',
'reprint'
]);
const FIELD_VALIDATORS = {
rarity: (v) => typeof v === 'string' && v.length > 0 && v.length <= 50,
verified: (v) => typeof v === 'boolean',
game: (v) => ['mtg', 'pokemon', 'lorcana'].includes(v),
mana_cost: (v) => v === null || (typeof v === 'string' && v.length <= 50),
cmc: (v) => v === null || (Number.isInteger(v) && v >= 0 && v <= 20),
card_type: (v) => v === null || (typeof v === 'string' && v.length <= 255),
edhrec_rank: (v) => v === null || (Number.isInteger(v) && v >= 0),
layout: (v) => v === null || (typeof v === 'string' && v.length <= 50),
released_at: (v) => v === null || (typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v)),
reserved: (v) => typeof v === 'boolean',
reprint: (v) => typeof v === 'boolean'
};
export default withAdmin(async function handler(req, res, user) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const { allowed, reset } = await checkImportRateLimit(req, user.userId);
if (!allowed) {
res.setHeader('Retry-After', Math.ceil((reset - Date.now()) / 1000));
return res.status(429).json({ error: 'Too many attempts. Try again later.' });
}
try {
const { cardIds, updates } = req.body;
if (!Array.isArray(cardIds) || cardIds.length === 0) {
return res.status(400).json({ error: 'cardIds array is required' });
}
if (!updates || typeof updates !== 'object') {
return res.status(400).json({ error: 'updates object is required' });
}
// Validate and filter allowed fields
const validUpdates = {};
const errors = [];
for (const [field, value] of Object.entries(updates)) {
if (!ALLOWED_UPDATE_FIELDS.has(field)) {
errors.push(`Field '${field}' is not allowed for batch update`);
continue;
}
const validator = FIELD_VALIDATORS[field];
if (validator && !validator(value)) {
errors.push(`Invalid value for field '${field}'`);
continue;
}
validUpdates[field] = value;
}
if (errors.length > 0) {
return res.status(400).json({ error: 'Validation failed', details: errors });
}
if (Object.keys(validUpdates).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
// Safety cap
if (cardIds.length > 1000) {
return res.status(400).json({ error: 'Maximum 1000 cards per batch operation' });
}
// Build dynamic update query
const setClauses = [];
const values = [];
let paramIdx = 1;
for (const [field, value] of Object.entries(validUpdates)) {
setClauses.push(`${field} = $${paramIdx}`);
values.push(value);
paramIdx++;
}
// Add updated_at
setClauses.push(`updated_at = CURRENT_TIMESTAMP`);
const placeholders = cardIds.map((_, i) => `$${paramIdx + i}`).join(',');
values.push(...cardIds);
const query = `
UPDATE cards
SET ${setClauses.join(', ')}
WHERE id IN (${placeholders})
`;
const result = await sql.query(query, values);
// Log the batch edit for audit
console.log(`[admin batch edit] User ${user.email} updated ${result.rowCount} cards:`, validUpdates);
res.status(200).json({
success: true,
updated: result.rowCount,
fields: Object.keys(validUpdates)
});
} catch (error) {
console.error('Error in batch edit:', error);
res.status(500).json({
success: false,
error: 'Internal server error',
message: error.message
});
}
});