Skip to content

Commit 5084a28

Browse files
authored
Merge pull request #3402 from SeedCompany/validate-partnership-dates
2 parents 4f4fbfb + 909cc22 commit 5084a28

9 files changed

+218
-77
lines changed
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { isNotFalsy, type NonEmptyArray } from '@seedcompany/common';
2+
import type { ID } from '../id-field';
3+
import { CalendarDate } from '../temporal';
4+
import type { Range } from '../types';
5+
import { RangeException } from './range.exception';
6+
7+
type Conflict = Readonly<{
8+
__typename: string;
9+
id: ID;
10+
label: string;
11+
point: 'start' | 'end';
12+
date: CalendarDate;
13+
}>;
14+
15+
interface IdentifiableResource {
16+
__typename: string;
17+
id: ID;
18+
[key: string]: unknown;
19+
}
20+
21+
export class DateOverrideConflictException extends RangeException {
22+
constructor(
23+
readonly object: IdentifiableResource,
24+
readonly canonical: Range<CalendarDate | null>,
25+
label: [singular: string, plural: string],
26+
readonly conflicts: NonEmptyArray<Conflict>,
27+
) {
28+
const message = [
29+
conflicts.length === 1
30+
? `${label[0]} has a date outside the new range`
31+
: `${label[1]} have dates outside the new range`,
32+
...conflicts.map(({ date, point, label }) => {
33+
const pointStr = point === 'start' ? 'Start' : 'End';
34+
const dateStr = date.toISO();
35+
return ` - ${pointStr} date of ${label} is ${dateStr}`;
36+
}),
37+
].join('\n');
38+
super({ message });
39+
}
40+
41+
static findConflicts(
42+
canonical: Range<CalendarDate | null>,
43+
items: ReadonlyArray<{
44+
__typename: string;
45+
id: ID;
46+
label: string;
47+
start: CalendarDate | null;
48+
end: CalendarDate | null;
49+
}>,
50+
): NonEmptyArray<Conflict> | undefined {
51+
const maybeConflicts = items.flatMap<Conflict | null>(
52+
({ start, end, ...item }) => [
53+
canonical.start && start && canonical.start > start
54+
? {
55+
...item,
56+
point: 'start' as const,
57+
date: start,
58+
}
59+
: null,
60+
canonical.end && end && canonical.end < end
61+
? {
62+
...item,
63+
point: 'end' as const,
64+
date: end,
65+
}
66+
: null,
67+
],
68+
);
69+
return asNonEmpty(maybeConflicts.filter(isNotFalsy));
70+
}
71+
}
72+
73+
const asNonEmpty = <T>(items: readonly T[]) =>
74+
items.length === 0 ? undefined : (items as NonEmptyArray<T>);

src/common/exceptions/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ export * from './unauthorized.exception';
99
export * from './service-unavailable.exception';
1010
export * from './invalid-id-for-type.exception';
1111
export * from './creation-failed.exception';
12+
export * from './date-override-conflict.exception';
Lines changed: 25 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,95 +1,46 @@
1-
import { isNotFalsy, NonEmptyArray } from '@seedcompany/common';
2-
import { CalendarDate, ID, RangeException, type UnsecuredDto } from '~/common';
1+
import { DateOverrideConflictException, EnhancedResource } from '~/common';
32
import { EventsHandler, IEventHandler } from '~/core';
4-
import type { Project } from '../../project/dto';
53
import { ProjectUpdatedEvent } from '../../project/events';
64
import { EngagementService } from '../engagement.service';
75

86
@EventsHandler(ProjectUpdatedEvent)
9-
export class ValidateEngDateOverridesOnProjectChangeHandlerSetLastStatusDate
7+
export class ValidateEngDateOverridesOnProjectChangeHandler
108
implements IEventHandler<ProjectUpdatedEvent>
119
{
1210
constructor(private readonly engagements: EngagementService) {}
1311

1412
async handle(event: ProjectUpdatedEvent) {
15-
const { changes, updated, session } = event;
13+
const { updated: project, changes, session } = event;
1614

1715
if (changes.mouStart === undefined && changes.mouEnd === undefined) {
1816
return;
1917
}
2018

21-
const project = {
22-
id: updated.id,
23-
name: updated.name,
24-
mouStart: updated.mouStart,
25-
mouEnd: updated.mouEnd,
26-
};
2719
const engagements = await this.engagements.listAllByProjectId(
2820
project.id,
2921
session,
3022
);
31-
const errors = engagements
32-
.flatMap<
33-
EngagementDateOverrideConflictException['engagements'][0] | null
34-
>((eng) => {
35-
const common = {
36-
id: eng.id,
37-
label: (eng.label.language ?? eng.label.intern)!,
38-
} as const;
39-
const { startDateOverride: start, endDateOverride: end } = eng;
40-
return [
41-
project.mouStart && start && project.mouStart > start
42-
? {
43-
...common,
44-
point: 'start' as const,
45-
date: start,
46-
}
47-
: null,
48-
project.mouEnd && end && project.mouEnd < end
49-
? {
50-
...common,
51-
point: 'end' as const,
52-
date: end,
53-
}
54-
: null,
55-
];
56-
})
57-
.filter(isNotFalsy);
58-
if (errors.length === 0) {
59-
return;
60-
}
61-
throw new EngagementDateOverrideConflictException(project, [
62-
errors[0]!,
63-
...errors.slice(1),
64-
]);
65-
}
66-
}
67-
68-
class EngagementDateOverrideConflictException extends RangeException {
69-
constructor(
70-
readonly project: Pick<
71-
UnsecuredDto<Project>,
72-
'id' | 'name' | 'mouStart' | 'mouEnd'
73-
>,
74-
readonly engagements: NonEmptyArray<
75-
Readonly<{
76-
id: ID<'Engagement'>;
77-
label: string;
78-
point: 'start' | 'end';
79-
date: CalendarDate;
80-
}>
81-
>,
82-
) {
83-
const message = [
84-
engagements.length === 1
85-
? 'An engagement has a date outside the new range'
86-
: 'Some engagements have dates outside the new range',
87-
...engagements.map((eng) => {
88-
const pointStr = eng.point === 'start' ? 'Start' : 'End';
89-
const dateStr = eng.date.toISO();
90-
return ` - ${pointStr} date of ${eng.label} is ${dateStr}`;
91-
}),
92-
].join('\n');
93-
super({ message });
23+
const canonical = { start: project.mouStart, end: project.mouEnd };
24+
const conflicts = DateOverrideConflictException.findConflicts(
25+
canonical,
26+
engagements.map((eng) => ({
27+
__typename: EnhancedResource.resolve(eng.__typename).name,
28+
id: eng.id,
29+
label: (eng.label.language ?? eng.label.intern)!,
30+
start: eng.startDateOverride,
31+
end: eng.endDateOverride,
32+
})),
33+
);
34+
if (!conflicts) return;
35+
throw new DateOverrideConflictException(
36+
{
37+
__typename: event.resource.name,
38+
id: project.id,
39+
name: project.name,
40+
},
41+
canonical,
42+
['An engagement', 'Some engagements'],
43+
conflicts,
44+
);
9445
}
9546
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './apply-finalized-changeset-to-partnership.handler';
2+
export * from './validate-partnership-date-overrides-on-project-change.handler';
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { DateOverrideConflictException } from '~/common';
2+
import { EventsHandler, IEventHandler } from '~/core';
3+
import { ProjectUpdatedEvent } from '../../project/events';
4+
import { PartnershipService } from '../partnership.service';
5+
6+
@EventsHandler(ProjectUpdatedEvent)
7+
export class ValidatePartnershipDateOverridesOnProjectChangeHandler
8+
implements IEventHandler<ProjectUpdatedEvent>
9+
{
10+
constructor(private readonly partnerships: PartnershipService) {}
11+
12+
async handle(event: ProjectUpdatedEvent) {
13+
const { updated: project, changes, session } = event;
14+
15+
if (changes.mouStart === undefined && changes.mouEnd === undefined) {
16+
return;
17+
}
18+
19+
const canonical = { start: project.mouStart, end: project.mouEnd };
20+
const partnerships = await this.partnerships.listAllByProjectId(
21+
project.id,
22+
session,
23+
);
24+
const conflicts = DateOverrideConflictException.findConflicts(
25+
canonical,
26+
partnerships.map((partnership) => ({
27+
__typename: 'Partnership',
28+
id: partnership.id,
29+
label: partnership.id, // TODO
30+
start: partnership.mouStartOverride,
31+
end: partnership.mouEndOverride,
32+
})),
33+
);
34+
if (!conflicts) return;
35+
throw new DateOverrideConflictException(
36+
{
37+
__typename: event.resource.name,
38+
id: project.id,
39+
name: project.name,
40+
},
41+
canonical,
42+
['A partnership', 'Some partnerships'],
43+
conflicts,
44+
);
45+
}
46+
}

src/components/partnership/partnership.gel.repository.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@ export class PartnershipGelRepository
4949
),
5050
);
5151

52+
async listAllByProjectId(project: ID) {
53+
return await this.db.run(this.listAllByProjectIdQuery, { project });
54+
}
55+
private readonly listAllByProjectIdQuery = e.params(
56+
{ project: e.uuid },
57+
($) => e.select(e.cast(e.Project, $.project).partnerships, this.hydrate),
58+
);
59+
5260
async isFirstPartnership(projectId: ID) {
5361
const project = e.cast(e.Project, e.uuid(projectId));
5462
const query = e.op('not', e.op('exists', project.partnerships));

src/components/partnership/partnership.repository.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,19 @@ export class PartnershipRepository extends DtoRepository<
183183
.run();
184184
}
185185

186+
async listAllByProjectId(projectId: ID, session: Session) {
187+
return await this.db
188+
.query()
189+
.match([
190+
node('project', 'Project', { id: projectId }),
191+
relation('out', '', 'partnership', ACTIVE),
192+
node('node', 'Partnership'),
193+
])
194+
.apply(this.hydrate(session))
195+
.map('dto')
196+
.run();
197+
}
198+
186199
protected override hydrate(session: Session, view?: ObjectView) {
187200
return (query: Query) =>
188201
query

src/components/partnership/partnership.service.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { forwardRef, Inject, Injectable } from '@nestjs/common';
22
import {
3+
CalendarDate,
34
CreationFailed,
45
ID,
56
InputException,
67
NotFoundException,
78
ObjectView,
9+
Range,
10+
RangeException,
811
ReadAfterCreationFailed,
912
ServerException,
1013
Session,
@@ -18,6 +21,7 @@ import {
1821
Logger,
1922
ResourceLoader,
2023
} from '~/core';
24+
import { AnyChangesOf } from '~/core/database/changes';
2125
import { Privileges } from '../authorization';
2226
import { FileService } from '../file';
2327
import { PartnerService } from '../partner';
@@ -61,6 +65,8 @@ export class PartnershipService {
6165
): Promise<Partnership> {
6266
const { projectId, partnerId } = input;
6367

68+
PartnershipDateRangeException.throwIfInvalid(input);
69+
6470
const isFirstPartnership = await this.repo.isFirstPartnership(
6571
projectId,
6672
changeset,
@@ -144,6 +150,10 @@ export class PartnershipService {
144150
}));
145151
}
146152

153+
async listAllByProjectId(projectId: ID, session: Session) {
154+
return await this.repo.listAllByProjectId(projectId, session);
155+
}
156+
147157
secure(dto: UnsecuredDto<Partnership>, session: Session) {
148158
return this.privileges.for(session, Partnership).secure(dto);
149159
}
@@ -186,6 +196,8 @@ export class PartnershipService {
186196
this.privileges.for(session, Partnership, object).verifyChanges(changes);
187197
const { mou, agreement, ...simpleChanges } = changes;
188198

199+
PartnershipDateRangeException.throwIfInvalid(existing, changes);
200+
189201
if (changes.primary) {
190202
await this.repo.removePrimaryFromOtherPartnerships(input.id);
191203
}
@@ -293,3 +305,34 @@ export class PartnershipService {
293305
}
294306
}
295307
}
308+
309+
class PartnershipDateRangeException extends RangeException {
310+
static throwIfInvalid(
311+
current: Partial<
312+
Pick<UnsecuredDto<Partnership>, 'mouStartOverride' | 'mouEndOverride'>
313+
>,
314+
changes: AnyChangesOf<Partnership> = {},
315+
) {
316+
const start =
317+
changes.mouStartOverride !== undefined
318+
? changes.mouStartOverride
319+
: current.mouStartOverride;
320+
const end =
321+
changes.mouEndOverride !== undefined
322+
? changes.mouEndOverride
323+
: current.mouEndOverride;
324+
if (start && end && start > end) {
325+
const field =
326+
changes.mouEndOverride !== undefined
327+
? 'partnership.mouEndOverride'
328+
: 'partnership.mouStartOverride';
329+
throw new PartnershipDateRangeException({ start, end }, field);
330+
}
331+
}
332+
333+
constructor(readonly value: Range<CalendarDate>, readonly field: string) {
334+
const message =
335+
"Partnership's MOU start date must be before the MOU end date";
336+
super({ message, field });
337+
}
338+
}
Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1-
import { Session, UnsecuredDto } from '~/common';
2-
import { Project, UpdateProject } from '../dto';
1+
import { EnhancedResource, Session, UnsecuredDto } from '~/common';
2+
import { Project, resolveProjectType, UpdateProject } from '../dto';
33

44
export class ProjectUpdatedEvent {
5+
readonly resource: EnhancedResource<ReturnType<typeof resolveProjectType>>;
6+
57
constructor(
68
public updated: UnsecuredDto<Project>,
79
readonly previous: UnsecuredDto<Project>,
810
readonly changes: UpdateProject,
911
readonly session: Session,
10-
) {}
12+
) {
13+
this.resource = EnhancedResource.of(resolveProjectType(this.updated));
14+
}
1115
}

0 commit comments

Comments
 (0)