diff --git a/api/src/services/block/block.service.ts b/api/src/services/block/block.service.ts index b63a1654..c26bfbdc 100644 --- a/api/src/services/block/block.service.ts +++ b/api/src/services/block/block.service.ts @@ -82,22 +82,22 @@ export class BlockService { newOwner = result[0].id; } } - // Both branches previously removed the old owner identically, so the removal is - // hoisted out rather than duplicated. - if (oldOwner !== 0) { - await this.roleAssignmentRepository.removeIdFromAssignment(blockId, oldOwner, ownerCode); - await this.roleAssignmentService.reconcilePrimaryRole(oldOwner); - } - if (newOwner !== 0) { - await this.roleAssignmentRepository.addIdToAssignment(blockId, newOwner, ownerCode); - } const oldDeputyIds = data.deputies.map(deputy => deputy.member_id); const newDeputyIds: number[] = []; for (const givenDeputy of givenDeputies) { newDeputyIds.push(await this.updateDeputyId(givenDeputy)); } - await this.roleAssignmentService - .syncDeputies(blockId, deputyCode, oldDeputyIds, newDeputyIds); + // Owner swap, deputy set and reconciliation are one sequence whose ORDER matters, so it + // lives in RoleAssignmentService rather than being re-implemented per place type. + await this.roleAssignmentService.syncPlaceAccess({ + placeId: blockId, + ownerRoleId: ownerCode, + deputyRoleId: deputyCode, + oldOwnerId: oldOwner, + newOwnerId: newOwner, + oldDeputyIds, + newDeputyIds, + }); } public async getMapLocationAndPlaces(blockId: number): Promise { diff --git a/api/src/services/colony/colony.service.ts b/api/src/services/colony/colony.service.ts index b4234ea2..1982b90a 100644 --- a/api/src/services/colony/colony.service.ts +++ b/api/src/services/colony/colony.service.ts @@ -78,22 +78,22 @@ export class ColonyService { newOwner = result[0].id; } } - // Both branches previously removed the old owner identically, so the removal is - // hoisted out rather than duplicated. - if (oldOwner !== 0) { - await this.roleAssignmentRepository.removeIdFromAssignment(colonyId, oldOwner, ownerCode); - await this.roleAssignmentService.reconcilePrimaryRole(oldOwner); - } - if (newOwner !== 0) { - await this.roleAssignmentRepository.addIdToAssignment(colonyId, newOwner, ownerCode); - } const oldDeputyIds = data.deputies.map(deputy => deputy.member_id); const newDeputyIds: number[] = []; for (const givenDeputy of givenDeputies) { newDeputyIds.push(await this.updateDeputyId(givenDeputy)); } - await this.roleAssignmentService - .syncDeputies(colonyId, deputyCode, oldDeputyIds, newDeputyIds); + // Owner swap, deputy set and reconciliation are one sequence whose ORDER matters, so it + // lives in RoleAssignmentService rather than being re-implemented per place type. + await this.roleAssignmentService.syncPlaceAccess({ + placeId: colonyId, + ownerRoleId: ownerCode, + deputyRoleId: deputyCode, + oldOwnerId: oldOwner, + newOwnerId: newOwner, + oldDeputyIds, + newDeputyIds, + }); } /** diff --git a/api/src/services/hood/hood.service.ts b/api/src/services/hood/hood.service.ts index 61292347..d7f6b0a1 100644 --- a/api/src/services/hood/hood.service.ts +++ b/api/src/services/hood/hood.service.ts @@ -77,22 +77,22 @@ export class HoodService { newOwner = result[0].id; } } - // Both branches previously removed the old owner identically, so the removal is - // hoisted out rather than duplicated. - if (oldOwner !== 0) { - await this.roleAssignmentRepository.removeIdFromAssignment(hoodId, oldOwner, ownerCode); - await this.roleAssignmentService.reconcilePrimaryRole(oldOwner); - } - if (newOwner !== 0) { - await this.roleAssignmentRepository.addIdToAssignment(hoodId, newOwner, ownerCode); - } const oldDeputyIds = data.deputies.map(deputy => deputy.member_id); const newDeputyIds: number[] = []; for (const givenDeputy of givenDeputies) { newDeputyIds.push(await this.updateDeputyId(givenDeputy)); } - await this.roleAssignmentService - .syncDeputies(hoodId, deputyCode, oldDeputyIds, newDeputyIds); + // Owner swap, deputy set and reconciliation are one sequence whose ORDER matters, so it + // lives in RoleAssignmentService rather than being re-implemented per place type. + await this.roleAssignmentService.syncPlaceAccess({ + placeId: hoodId, + ownerRoleId: ownerCode, + deputyRoleId: deputyCode, + oldOwnerId: oldOwner, + newOwnerId: newOwner, + oldDeputyIds, + newDeputyIds, + }); } public async getColony(hoodId: number): Promise { diff --git a/api/src/services/place/place.service.ts b/api/src/services/place/place.service.ts index ef9c60f2..ec8174d2 100644 --- a/api/src/services/place/place.service.ts +++ b/api/src/services/place/place.service.ts @@ -252,28 +252,29 @@ export class PlaceService { newOwner = result[0].id; } } - // Both branches previously removed the old owner identically, so the removal is - // hoisted out rather than duplicated. - if (oldOwner !== 0) { - await this.roleAssignmentRepository.removeIdFromAssignment(placeId, oldOwner, ownerCode); - await this.roleAssignmentService.reconcilePrimaryRole(oldOwner); - } - if (newOwner !== 0) { - await this.roleAssignmentRepository.addIdToAssignment(placeId, newOwner, ownerCode); - } // 'jail' and 'cityhall' have an owner role but no deputy role, so findRoleIdsBySlug - // returns deputy: undefined for them. The sync below would then write a role_assignment - // whose role_id is undefined -- a row pointing at no role at all. Skipped wholesale - // rather than guarded per-branch: a place with no deputy role has no deputies to - // reconcile, so there is nothing for the loop to do either way. - if (deputyCode === undefined || deputyCode === null) return; - const oldDeputyIds = data.deputies.map(deputy => deputy.member_id); + // returns deputy: undefined for them. Resolving deputies for such a place is pointless + // work, and syncPlaceAccess skips the deputy half when no deputy role is given -- but it + // still performs the owner swap and the reconciliation, which those places do need. + const hasDeputyRole = deputyCode !== undefined && deputyCode !== null; + const oldDeputyIds = hasDeputyRole ? data.deputies.map(deputy => deputy.member_id) : []; const newDeputyIds: number[] = []; - for (const givenDeputy of givenDeputies) { - newDeputyIds.push(await this.updateDeputyId(givenDeputy)); + if (hasDeputyRole) { + for (const givenDeputy of givenDeputies) { + newDeputyIds.push(await this.updateDeputyId(givenDeputy)); + } } - await this.roleAssignmentService - .syncDeputies(placeId, deputyCode, oldDeputyIds, newDeputyIds); + // Owner swap, deputy set and reconciliation are one sequence whose ORDER matters, so it + // lives in RoleAssignmentService rather than being re-implemented per place type. + await this.roleAssignmentService.syncPlaceAccess({ + placeId, + ownerRoleId: ownerCode, + deputyRoleId: deputyCode, + oldOwnerId: oldOwner, + newOwnerId: newOwner, + oldDeputyIds, + newDeputyIds, + }); } public async updatePlaces(placeinfo: any): Promise { diff --git a/api/src/services/role-assignment/role-assignment.service.spec.ts b/api/src/services/role-assignment/role-assignment.service.spec.ts index e209beab..d19d09cd 100644 --- a/api/src/services/role-assignment/role-assignment.service.spec.ts +++ b/api/src/services/role-assignment/role-assignment.service.spec.ts @@ -172,6 +172,35 @@ describe('RoleAssignmentService', () => { expect(roleAssignmentRepository.addIdToAssignment).not.toHaveBeenCalled(); }); + /** + * The ordering guarantee. reconcilePrimaryRole reads role_assignment to decide whether + * the displayed role is still held, so it must not run until every write has landed -- + * otherwise it observes a state that never settles. + */ + it('reconciles only after every add has landed', async () => { + await service.syncDeputies(PLACE, DEPUTY_ROLE, [A], [C]); + const lastAdd = Math.max( + ...roleAssignmentRepository.addIdToAssignment.mock.invocationCallOrder, + ); + const firstReconcileRead = Math.min( + ...memberRepository.getPrimaryRoleId.mock.invocationCallOrder, + ); + expect(firstReconcileRead).toBeGreaterThan(lastAdd); + }); + + it('defers reconciliation to a collector when given one', async () => { + const touched = new Set(); + await service.syncDeputies(PLACE, DEPUTY_ROLE, [A], [C], touched); + expect([...touched]).toEqual([A]); + expect(memberRepository.getPrimaryRoleId).not.toHaveBeenCalled(); + }); + + it('does not collect a member who merely moved position', async () => { + const touched = new Set(); + await service.syncDeputies(PLACE, DEPUTY_ROLE, [A, B], [B, A], touched); + expect([...touched]).toEqual([]); + }); + /** One failing row must not abandon the rest of the reconciliation. */ it('continues past a failed write', async () => { roleAssignmentRepository.addIdToAssignment @@ -181,4 +210,128 @@ describe('RoleAssignmentService', () => { expect(roleAssignmentRepository.addIdToAssignment).toHaveBeenCalledTimes(2); }); }); + + describe('reconcilePrimaryRoles', () => { + const MEMBER_A = 201; + const MEMBER_B = 202; + + beforeEach(() => { + memberRepository.getPrimaryRoleId.mockResolvedValue(null); + roleAssignmentRepository.getByMemberId.mockResolvedValue([] as any); + }); + + /** The same member can be touched on more than one axis of a single update. */ + it('reconciles each member once even when listed repeatedly', async () => { + await service.reconcilePrimaryRoles([MEMBER_A, MEMBER_A, MEMBER_B, MEMBER_A]); + expect(memberRepository.getPrimaryRoleId).toHaveBeenCalledTimes(2); + }); + + /** 0 is the empty-slot sentinel and is not a member id. */ + it('skips falsy ids', async () => { + await service.reconcilePrimaryRoles([0, MEMBER_A]); + expect(memberRepository.getPrimaryRoleId).toHaveBeenCalledTimes(1); + expect(memberRepository.getPrimaryRoleId).toHaveBeenCalledWith(MEMBER_A); + }); + + it('continues past a member that throws', async () => { + memberRepository.getPrimaryRoleId + .mockRejectedValueOnce(new Error('gone')) + .mockResolvedValue(null); + await service.reconcilePrimaryRoles([MEMBER_A, MEMBER_B]); + expect(memberRepository.getPrimaryRoleId).toHaveBeenCalledTimes(2); + }); + + it('accepts a Set as well as an array', async () => { + await service.reconcilePrimaryRoles(new Set([MEMBER_A, MEMBER_B])); + expect(memberRepository.getPrimaryRoleId).toHaveBeenCalledTimes(2); + }); + }); + + describe('syncPlaceAccess', () => { + const PLACE = 42; + const OWNER_ROLE = 18; + const DEPUTY_ROLE = 20; + const OWNER = 301; + const NEW_OWNER = 302; + const DEPUTY = 303; + + beforeEach(() => { + memberRepository.getPrimaryRoleId.mockResolvedValue(null); + roleAssignmentRepository.getByMemberId.mockResolvedValue([] as any); + }); + + const base = { + placeId: PLACE, + ownerRoleId: OWNER_ROLE, + deputyRoleId: DEPUTY_ROLE, + oldOwnerId: 0, + newOwnerId: 0, + oldDeputyIds: [] as number[], + newDeputyIds: [] as number[], + }; + + /** + * The bug this whole change exists for: re-saving an access page WITHOUT changing the + * owner used to clear that owner's displayed role, because the assignment was removed, + * read as absent, and only then put back. + */ + it('does not reconcile mid-swap when the owner is unchanged', async () => { + await service.syncPlaceAccess({ ...base, oldOwnerId: OWNER, newOwnerId: OWNER }); + const lastWrite = Math.max( + ...roleAssignmentRepository.addIdToAssignment.mock.invocationCallOrder, + ); + const firstRead = Math.min( + ...memberRepository.getPrimaryRoleId.mock.invocationCallOrder, + ); + expect(firstRead).toBeGreaterThan(lastWrite); + }); + + it('reconciles the outgoing owner after the incoming one is written', async () => { + await service.syncPlaceAccess({ ...base, oldOwnerId: OWNER, newOwnerId: NEW_OWNER }); + expect(memberRepository.getPrimaryRoleId).toHaveBeenCalledWith(OWNER); + const lastWrite = Math.max( + ...roleAssignmentRepository.addIdToAssignment.mock.invocationCallOrder, + ); + expect( + Math.min(...memberRepository.getPrimaryRoleId.mock.invocationCallOrder), + ).toBeGreaterThan(lastWrite); + }); + + it('skips the owner writes entirely when the place has no owner either side', async () => { + await service.syncPlaceAccess(base); + expect(roleAssignmentRepository.removeIdFromAssignment).not.toHaveBeenCalled(); + expect(roleAssignmentRepository.addIdToAssignment).not.toHaveBeenCalled(); + }); + + /** 'jail' and 'cityhall': an owner role and no deputy role. */ + it('still swaps and reconciles the owner when there is no deputy role', async () => { + await service.syncPlaceAccess({ + ...base, deputyRoleId: undefined, oldOwnerId: OWNER, newOwnerId: NEW_OWNER, + }); + expect(roleAssignmentRepository.addIdToAssignment) + .toHaveBeenCalledWith(PLACE, NEW_OWNER, OWNER_ROLE); + expect(memberRepository.getPrimaryRoleId).toHaveBeenCalledWith(OWNER); + }); + + /** One member on two axes at once must not be reconciled twice. */ + it('reconciles a member who is both outgoing owner and dropped deputy only once', + async () => { + await service.syncPlaceAccess({ + ...base, oldOwnerId: OWNER, newOwnerId: NEW_OWNER, oldDeputyIds: [OWNER], + }); + const reads = memberRepository.getPrimaryRoleId.mock.calls + .filter(call => call[0] === OWNER); + expect(reads).toHaveLength(1); + }); + + it('applies both the owner swap and the deputy set', async () => { + await service.syncPlaceAccess({ + ...base, oldOwnerId: OWNER, newOwnerId: NEW_OWNER, newDeputyIds: [DEPUTY], + }); + expect(roleAssignmentRepository.addIdToAssignment) + .toHaveBeenCalledWith(PLACE, NEW_OWNER, OWNER_ROLE); + expect(roleAssignmentRepository.addIdToAssignment) + .toHaveBeenCalledWith(PLACE, DEPUTY, DEPUTY_ROLE); + }); + }); }); diff --git a/api/src/services/role-assignment/role-assignment.service.ts b/api/src/services/role-assignment/role-assignment.service.ts index a490d295..4e012e5c 100644 --- a/api/src/services/role-assignment/role-assignment.service.ts +++ b/api/src/services/role-assignment/role-assignment.service.ts @@ -24,9 +24,18 @@ export class RoleAssignmentService { /** * Clears member.primary_role_id if it is no longer a role the member holds. * - * Call after any change to a member's role assignments. role_assignment is the - * authority for what a member holds; primary_role_id only records which of those they - * chose to display, so it has to be re-checked whenever assignments change. + * Call after the COMPLETE set of assignment changes has landed, never between a remove + * and the add that replaces it. This method reads role_assignment to decide what is still + * held, so calling it mid-sequence lets it observe a state that never settles. The + * callers used to do exactly that: postAccessInfo removed the owner's assignment, + * reconciled, and only then re-added it -- so re-saving an access page WITHOUT changing + * the owner cleared that owner's displayed role, because at the moment of the read they + * genuinely held nothing. They kept the role and lost the badge, with nothing in the + * request to explain it. + * + * role_assignment is the authority for what a member holds; primary_role_id only records + * which of those they chose to display, so it has to be re-checked whenever assignments + * change. * * Replaces a block that was copy-pasted across the hood, block, colony, place and * admin services. That version compared the *revoked* role against primary_role_id @@ -89,6 +98,13 @@ export class RoleAssignmentService { deputyRoleId: number | null | undefined, oldDeputyIds: number[], newDeputyIds: number[], + /** + * Optional collector for members whose displayed role needs re-checking. Given one, this + * method defers reconciliation instead of doing it, so a caller that is also changing + * the owner can reconcile everything once after ALL of its writes rather than partway + * through. + */ + deferTo?: Set, ): Promise { if (deputyRoleId === undefined || deputyRoleId === null) return; @@ -98,12 +114,13 @@ export class RoleAssignmentService { const oldIds = asIdSet(oldDeputyIds); const newIds = asIdSet(newDeputyIds.slice(0, RoleAssignmentService.DEPUTY_SLOTS)); + const removed: number[] = []; for (const memberId of oldIds) { if (newIds.has(memberId)) continue; try { await this.roleAssignmentRepository .removeIdFromAssignment(placeId, memberId, deputyRoleId); - await this.reconcilePrimaryRole(memberId); + removed.push(memberId); } catch (e) { console.error(e); } @@ -117,6 +134,83 @@ export class RoleAssignmentService { console.error(e); } } + + // Reconciled only once every write above has landed. reconcilePrimaryRole reads + // role_assignment to decide whether the displayed role is still held, so running it + // between the removes and the adds let it observe a state that never settles -- see + // reconcilePrimaryRoles. + // + // When the caller passes a collector, reconciliation is deferred to it instead, so a + // place update that also changes the owner reconciles the whole set exactly once at the + // very end rather than once per axis. + if (deferTo) { + removed.forEach(memberId => deferTo.add(memberId)); + return; + } + await this.reconcilePrimaryRoles(removed); + } + + /** + * Applies a place's whole access update: owner swap, deputy set, then reconciliation. + * + * The ordering here is the point. reconcilePrimaryRole must not run until every write has + * landed, and getting that wrong is invisible -- the request succeeds and a member quietly + * loses their displayed role. Keeping the sequence in one place means the block, hood, + * colony and place services cannot each get it subtly differently, which is how the + * original bug survived in four copies while being fixed in none. + * + * Callers still own resolving usernames to ids; this owns the order of the writes. + */ + public async syncPlaceAccess(params: { + placeId: number; + ownerRoleId: number; + /** Absent for places with an owner role and no deputy role ('jail', 'cityhall'). */ + deputyRoleId?: number | null; + /** 0 when the place currently has no owner. */ + oldOwnerId: number; + /** 0 when the update leaves the place without an owner. */ + newOwnerId: number; + oldDeputyIds: number[]; + newDeputyIds: number[]; + }): Promise { + const { + placeId, ownerRoleId, deputyRoleId, oldOwnerId, newOwnerId, oldDeputyIds, newDeputyIds, + } = params; + + // Members whose displayed role may need re-checking once every write below has landed. + const touched = new Set(); + + if (oldOwnerId !== 0) { + await this.roleAssignmentRepository + .removeIdFromAssignment(placeId, oldOwnerId, ownerRoleId); + touched.add(oldOwnerId); + } + if (newOwnerId !== 0) { + await this.roleAssignmentRepository.addIdToAssignment(placeId, newOwnerId, ownerRoleId); + } + + // Defers into `touched` rather than reconciling, so the pass below is the only one. + await this.syncDeputies(placeId, deputyRoleId, oldDeputyIds, newDeputyIds, touched); + + await this.reconcilePrimaryRoles(touched); + } + + /** + * Reconciles several members' displayed roles, after all assignment writes are done. + * + * Deduplicated because the same member can be touched on more than one axis of a single + * update -- losing a deputy slot while also being the outgoing owner, say -- and + * reconciling them twice is just a second round trip to reach the same answer. + */ + public async reconcilePrimaryRoles(memberIds: Iterable): Promise { + for (const memberId of new Set(memberIds)) { + if (!memberId) continue; + try { + await this.reconcilePrimaryRole(memberId); + } catch (e) { + console.error(e); + } + } } /**