From 5377b88e0e0c27ecde18c2a366fb7d06f279f2ec Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Mon, 11 May 2026 23:39:22 +0200 Subject: [PATCH] feat(customers): replace-assignments endpoint for a single customer --- backend/src/routes/adminCustomers.js | 31 +++++++++ .../src/services/customerAccountsService.js | 64 +++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/backend/src/routes/adminCustomers.js b/backend/src/routes/adminCustomers.js index 07579db8..54d7a8ae 100644 --- a/backend/src/routes/adminCustomers.js +++ b/backend/src/routes/adminCustomers.js @@ -310,4 +310,35 @@ router.post('/:id/password-reset', [ successResponse(res, { email: result.email, expiresAt: result.expiresAt }); })); +/** + * PUT /api/admin/customers/:id/events — replace the customer's full + * event assignment list. Backs the "Manage galleries" dialog on the + * customer detail page. Body is `{ event_ids: number[] }`. Empty + * array clears every assignment. + * + * Access revocation is implicit: gallery middleware checks for a + * live event_customer_assignments row whenever it decodes a + * customer-minted gallery JWT, so removing an assignment here + * immediately blocks the customer's next gallery request without + * needing to enumerate + revoke any active tokens. Permission tier + * is customers.create (same as invite + deactivate) — managing + * which galleries a customer can see is a write-class operation + * on the customer record. + */ +router.put('/:id/events', [ + adminAuth, + requirePermission('customers.create'), + param('id').isInt({ min: 1 }), + body('event_ids').isArray(), + body('event_ids.*').isInt({ min: 1 }), +], handleAsync(async (req, res) => { + validateRequest(req); + const result = await customerAccountsService.setAssignmentsForCustomer( + parseInt(req.params.id, 10), + req.body.event_ids, + req.admin.id, + ); + successResponse(res, result); +})); + module.exports = router; diff --git a/backend/src/services/customerAccountsService.js b/backend/src/services/customerAccountsService.js index 1271f58f..9511d303 100644 --- a/backend/src/services/customerAccountsService.js +++ b/backend/src/services/customerAccountsService.js @@ -638,6 +638,69 @@ async function setAssignmentsForEvent(eventId, targetCustomerIds, adminId, trx = return { added: toAdd.length, removed: toRemove.length }; } +/** + * Inverse of setAssignmentsForEvent: replace the full set of events a + * single customer is assigned to. Backs the "Manage galleries" dialog + * on the customer detail page — admins pick from every available + * event and we diff against the existing row set. + * + * Returns { added, removed } so the caller can surface a useful toast. + * + * `targetEventIds` may be empty to clear every assignment. + * + * Access revocation: removing a row from event_customer_assignments + * is enough on its own — gallery middleware (galleryMiddleware.js) + * checks for a live assignment whenever it decodes a JWT minted via + * the customer access-token endpoint (decoded.via === 'customer'). + * No separate revoked_tokens write needed; the customer's next + * request 401s the moment this transaction commits. + */ +async function setAssignmentsForCustomer(customerId, targetEventIds, adminId, trx = db) { + const wanted = new Set((targetEventIds || []).map(Number).filter((n) => Number.isFinite(n) && n > 0)); + const existing = await trx('event_customer_assignments') + .where('customer_account_id', customerId) + .select('id', 'event_id'); + const existingIds = new Set(existing.map((r) => r.event_id)); + + const toAdd = [...wanted].filter((id) => !existingIds.has(id)); + const toRemove = existing.filter((r) => !wanted.has(r.event_id)); + + if (toRemove.length > 0) { + await trx('event_customer_assignments') + .whereIn('id', toRemove.map((r) => r.id)) + .del(); + } + + if (toAdd.length > 0) { + // Validate the events exist + are not archived before inserting. + // Mirrors the customer-side check in setAssignmentsForEvent so an + // admin can't accidentally pin a customer to an archived event + // that they couldn't actually open anyway. + const valid = await trx('events') + .whereIn('id', toAdd) + .where('is_archived', formatBoolean(false)) + .pluck('id'); + const validSet = new Set(valid); + const ignored = toAdd.filter((id) => !validSet.has(id)); + if (ignored.length > 0) { + logger.warn('Ignoring missing/archived event ids in customer assignment', { + customerId, ignored, + }); + } + const rows = [...validSet].map((eventId) => ({ + event_id: eventId, + customer_account_id: customerId, + assigned_by_admin_id: adminId, + assigned_at: new Date(), + })); + if (rows.length > 0) { + await trx('event_customer_assignments').insert(rows); + } + } + + return { added: toAdd.length, removed: toRemove.length }; +} + /** * Fetch the customers currently assigned to an event. Returned by the * admin event-detail endpoint so the picker can hydrate. @@ -961,6 +1024,7 @@ module.exports = { eraseCustomer, searchCustomers, setAssignmentsForEvent, + setAssignmentsForCustomer, getAssignmentsForEvent, listEventsForCustomer, customerHasAccessToEvent,