feat(customers): replace-assignments endpoint for a single customer
This commit is contained in:
@@ -310,4 +310,35 @@ router.post('/:id/password-reset', [
|
|||||||
successResponse(res, { email: result.email, expiresAt: result.expiresAt });
|
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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -638,6 +638,69 @@ async function setAssignmentsForEvent(eventId, targetCustomerIds, adminId, trx =
|
|||||||
return { added: toAdd.length, removed: toRemove.length };
|
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
|
* Fetch the customers currently assigned to an event. Returned by the
|
||||||
* admin event-detail endpoint so the picker can hydrate.
|
* admin event-detail endpoint so the picker can hydrate.
|
||||||
@@ -961,6 +1024,7 @@ module.exports = {
|
|||||||
eraseCustomer,
|
eraseCustomer,
|
||||||
searchCustomers,
|
searchCustomers,
|
||||||
setAssignmentsForEvent,
|
setAssignmentsForEvent,
|
||||||
|
setAssignmentsForCustomer,
|
||||||
getAssignmentsForEvent,
|
getAssignmentsForEvent,
|
||||||
listEventsForCustomer,
|
listEventsForCustomer,
|
||||||
customerHasAccessToEvent,
|
customerHasAccessToEvent,
|
||||||
|
|||||||
Reference in New Issue
Block a user