feat(customers): replace-assignments endpoint for a single customer

This commit is contained in:
Luca
2026-05-11 23:39:22 +02:00
parent 592fa1e1c2
commit 5377b88e0e
2 changed files with 95 additions and 0 deletions
+31
View File
@@ -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;
@@ -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,