Skip to content

Commit 4f1389d

Browse files
authored
Validate field region/zone director roles (#3445)
2 parents 4edb68e + 6549d91 commit 4f1389d

18 files changed

+263
-26
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { type UnsecuredDto } from '~/common';
2+
import { type FieldRegion, type UpdateFieldRegion } from '../dto';
3+
4+
export class FieldRegionUpdatedEvent {
5+
constructor(
6+
readonly updated: UnsecuredDto<FieldRegion>,
7+
readonly previous: UnsecuredDto<FieldRegion>,
8+
readonly input: UpdateFieldRegion,
9+
) {}
10+
}

src/components/field-region/field-region.gel.repository.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Injectable } from '@nestjs/common';
2-
import { type PublicOf } from '~/common';
3-
import { RepoFor } from '~/core/gel';
2+
import { type ID, type PublicOf } from '~/common';
3+
import { e, RepoFor } from '~/core/gel';
44
import { FieldRegion } from './dto';
55
import { type FieldRegionRepository } from './field-region.repository';
66

@@ -13,4 +13,16 @@ export class FieldRegionGelRepository
1313
fieldZone: true,
1414
}),
1515
})
16-
implements PublicOf<FieldRegionRepository> {}
16+
implements PublicOf<FieldRegionRepository>
17+
{
18+
async readAllByDirector(id: ID<'User'>) {
19+
return await this.db.run(this.readAllByDirectorQuery, { id });
20+
}
21+
private readonly readAllByDirectorQuery = e.params({ id: e.uuid }, ($) => {
22+
const director = e.cast(e.User, $.id);
23+
return e.select(e.FieldRegion, (region) => ({
24+
filter: e.op(region.director, '=', director),
25+
...this.hydrate(region),
26+
}));
27+
});
28+
}

src/components/field-region/field-region.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { FieldRegionLoader } from './field-region.loader';
88
import { FieldRegionRepository } from './field-region.repository';
99
import { FieldRegionResolver } from './field-region.resolver';
1010
import { FieldRegionService } from './field-region.service';
11+
import { RestrictRegionDirectorRemovalHandler } from './handlers/restrict-region-director-removal.handler';
1112

1213
@Module({
1314
imports: [
@@ -20,6 +21,7 @@ import { FieldRegionService } from './field-region.service';
2021
FieldRegionService,
2122
splitDb(FieldRegionRepository, FieldRegionGelRepository),
2223
FieldRegionLoader,
24+
RestrictRegionDirectorRemovalHandler,
2325
],
2426
exports: [FieldRegionService],
2527
})

src/components/field-region/field-region.repository.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,4 +115,17 @@ export class FieldRegionRepository extends DtoRepository(FieldRegion) {
115115
.first();
116116
return result!; // result from paginate() will always have 1 row.
117117
}
118+
119+
async readAllByDirector(id: ID<'User'>) {
120+
return await this.db
121+
.query()
122+
.match([
123+
node('node', 'FieldRegion'),
124+
relation('out', '', 'director', ACTIVE),
125+
node('', 'User', { id }),
126+
])
127+
.apply(this.hydrate())
128+
.map('dto')
129+
.run();
130+
}
118131
}

src/components/field-region/field-region.service.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,38 @@
11
import { Injectable } from '@nestjs/common';
22
import {
33
type ID,
4+
InputException,
5+
NotFoundException,
46
type ObjectView,
57
ServerException,
68
type UnsecuredDto,
79
} from '~/common';
810
import { HandleIdLookup } from '~/core';
11+
import { IEventBus } from '~/core/events';
912
import { Privileges } from '../authorization';
13+
import { UserService } from '../user';
1014
import {
1115
type CreateFieldRegion,
1216
FieldRegion,
1317
type FieldRegionListInput,
1418
type FieldRegionListOutput,
1519
type UpdateFieldRegion,
1620
} from './dto';
21+
import { FieldRegionUpdatedEvent } from './events/field-region-updated.event';
1722
import { FieldRegionRepository } from './field-region.repository';
1823

1924
@Injectable()
2025
export class FieldRegionService {
2126
constructor(
2227
private readonly privileges: Privileges,
28+
private readonly events: IEventBus,
29+
private readonly users: UserService,
2330
private readonly repo: FieldRegionRepository,
2431
) {}
2532

2633
async create(input: CreateFieldRegion): Promise<FieldRegion> {
2734
this.privileges.for(FieldRegion).verifyCan('create');
35+
await this.validateDirectorRole(input.directorId);
2836
const dto = await this.repo.create(input);
2937
return this.secure(dto);
3038
}
@@ -50,10 +58,44 @@ export class FieldRegionService {
5058
const changes = this.repo.getActualChanges(fieldRegion, input);
5159
this.privileges.for(FieldRegion, fieldRegion).verifyChanges(changes);
5260

61+
if (changes.directorId) {
62+
await this.validateDirectorRole(changes.directorId);
63+
}
64+
65+
if (Object.keys(changes).length === 0) {
66+
return this.secure(fieldRegion);
67+
}
68+
5369
const updated = await this.repo.update({ id: input.id, ...changes });
70+
71+
const event = new FieldRegionUpdatedEvent(fieldRegion, updated, {
72+
id: input.id,
73+
...changes,
74+
});
75+
await this.events.publish(event);
76+
5477
return this.secure(updated);
5578
}
5679

80+
private async validateDirectorRole(directorId: ID<'User'>) {
81+
let director;
82+
try {
83+
director = await this.users.readOneUnsecured(directorId);
84+
} catch (e) {
85+
if (e instanceof NotFoundException) {
86+
throw e.withField('fieldRegion.directorId');
87+
}
88+
throw e;
89+
}
90+
if (!director.roles.includes('RegionalDirector')) {
91+
throw new InputException(
92+
'User does not have the Regional Director role',
93+
'fieldRegion.directorId',
94+
);
95+
}
96+
return director;
97+
}
98+
5799
async delete(id: ID): Promise<void> {
58100
const object = await this.readOne(id);
59101

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { InputException } from '~/common';
2+
import { EventsHandler } from '~/core/events';
3+
import { UserUpdatedEvent } from '../../user/events/user-updated.event';
4+
import { FieldRegionRepository } from '../field-region.repository';
5+
6+
@EventsHandler(UserUpdatedEvent)
7+
export class RestrictRegionDirectorRemovalHandler {
8+
constructor(private readonly repo: FieldRegionRepository) {}
9+
10+
async handle(event: UserUpdatedEvent) {
11+
if (!event.updated.roles) {
12+
return;
13+
}
14+
const roleRemoved =
15+
event.previous.roles.includes('RegionalDirector') &&
16+
!event.updated.roles.includes('RegionalDirector');
17+
if (!roleRemoved) {
18+
return;
19+
}
20+
21+
const regions = await this.repo.readAllByDirector(event.updated.id);
22+
if (regions.length > 0) {
23+
throw new InputException(
24+
'User is still a director for these field regions:\n' +
25+
regions.map((z) => ` - ${z.name}`).join('\n'),
26+
);
27+
}
28+
}
29+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { type UnsecuredDto } from '~/common';
2+
import { type FieldZone, type UpdateFieldZone } from '../dto';
3+
4+
export class FieldZoneUpdatedEvent {
5+
constructor(
6+
readonly updated: UnsecuredDto<FieldZone>,
7+
readonly previous: UnsecuredDto<FieldZone>,
8+
readonly input: UpdateFieldZone,
9+
) {}
10+
}

src/components/field-zone/field-zone.gel.repository.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Injectable } from '@nestjs/common';
2-
import { type PublicOf } from '~/common';
3-
import { RepoFor } from '~/core/gel';
2+
import { type ID, type PublicOf } from '~/common';
3+
import { e, RepoFor } from '~/core/gel';
44
import { FieldZone } from './dto';
55
import { type FieldZoneRepository } from './field-zone.repository';
66

@@ -12,4 +12,16 @@ export class FieldZoneGelRepository
1212
director: true,
1313
}),
1414
})
15-
implements PublicOf<FieldZoneRepository> {}
15+
implements PublicOf<FieldZoneRepository>
16+
{
17+
async readAllByDirector(id: ID<'User'>) {
18+
return await this.db.run(this.readAllByDirectorQuery, { id });
19+
}
20+
private readonly readAllByDirectorQuery = e.params({ id: e.uuid }, ($) => {
21+
const director = e.cast(e.User, $.id);
22+
return e.select(e.FieldZone, (zone) => ({
23+
filter: e.op(zone.director, '=', director),
24+
...this.hydrate(zone),
25+
}));
26+
});
27+
}

src/components/field-zone/field-zone.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { FieldZoneLoader } from './field-zone.loader';
77
import { FieldZoneRepository } from './field-zone.repository';
88
import { FieldZoneResolver } from './field-zone.resolver';
99
import { FieldZoneService } from './field-zone.service';
10+
import { RestrictZoneDirectorRemovalHandler } from './handlers/restrict-zone-director-removal.handler';
1011

1112
@Module({
1213
imports: [
@@ -18,6 +19,7 @@ import { FieldZoneService } from './field-zone.service';
1819
FieldZoneService,
1920
splitDb(FieldZoneRepository, FieldZoneGelRepository),
2021
FieldZoneLoader,
22+
RestrictZoneDirectorRemovalHandler,
2123
],
2224
exports: [FieldZoneService],
2325
})

src/components/field-zone/field-zone.repository.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,4 +133,17 @@ export class FieldZoneRepository extends DtoRepository(FieldZone) {
133133
.first();
134134
return result!; // result from paginate() will always have 1 row.
135135
}
136+
137+
async readAllByDirector(id: ID<'User'>) {
138+
return await this.db
139+
.query()
140+
.match([
141+
node('node', 'FieldZone'),
142+
relation('out', '', 'director', ACTIVE),
143+
node('', 'User', { id }),
144+
])
145+
.apply(this.hydrate())
146+
.map('dto')
147+
.run();
148+
}
136149
}

0 commit comments

Comments
 (0)