Skip to content

Commit b64bbe8

Browse files
authored
Merge pull request #3396 from SeedCompany/bugfix/valid-date-ranges
2 parents 3147d9d + 3739399 commit b64bbe8

File tree

12 files changed

+253
-6
lines changed

12 files changed

+253
-6
lines changed

dbschema/common.gel

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@ module default {
1818
)
1919
)
2020
);
21-
21+
22+
function array_join_maybe(array: array<str>, delimiter: str) -> optional str using (
23+
with joined := array_join(array, delimiter)
24+
select if joined = "" then <str>{} else joined
25+
);
26+
2227
scalar type nanoid extending str;
2328

2429
scalar type RichText extending json;

dbschema/migrations/00018-m1jf7p4.edgeql

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/common/exceptions/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from './exception';
22
export * from './input.exception';
3+
export * from './range.exception';
34
export * from './duplicate.exception';
45
export * from './not-found.exception';
56
export * from './not-implemented.exception';
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { InputException } from '~/common';
2+
3+
export class RangeException extends InputException {
4+
constructor(options?: { message?: string; field?: string; cause?: Error }) {
5+
super(options?.message ?? `Invalid range`, options?.field, options?.cause);
6+
}
7+
}

src/components/engagement/dto/engagement.dto.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@ class Engagement extends Interfaces {
8181
@Field(() => IProject)
8282
declare readonly parent: BaseNode;
8383

84+
readonly label: Readonly<{
85+
project: string;
86+
language: string | null;
87+
intern: string | null;
88+
}>;
89+
8490
@Field(() => SecuredEngagementStatus, {
8591
middleware: [parentIdMiddleware],
8692
})

src/components/engagement/engagement.gel.repository.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,19 +63,51 @@ const languageHydrate = e.shape(e.LanguageEngagement, (le) => ({
6363
...baseHydrate(le),
6464
__typename: le.__type__.name,
6565
...languageExtraHydrate,
66+
label: e.select({
67+
project: le.project.name,
68+
language: le.language.name,
69+
intern: e.cast(e.str, e.set()),
70+
}),
6671
}));
6772

6873
const internshipHydrate = e.shape(e.InternshipEngagement, (ie) => ({
6974
...baseHydrate(ie),
7075
__typename: ie.__type__.name,
7176
...internshipExtraHydrate,
77+
label: e.select({
78+
project: ie.project.name,
79+
language: e.cast(e.str, e.set()),
80+
intern: e.array_join_maybe(
81+
e.array([ie.intern.displayFirstName, ie.intern.displayLastName]),
82+
' ',
83+
),
84+
}),
7285
}));
7386

74-
const hydrate = e.shape(e.Engagement, (engagement) => ({
75-
...baseHydrate(engagement),
76-
...e.is(e.LanguageEngagement, languageExtraHydrate),
77-
...e.is(e.InternshipEngagement, internshipExtraHydrate),
78-
}));
87+
const hydrate = e.shape(e.Engagement, (engagement) => {
88+
const langEng = e.select(e.LanguageEngagement, () => ({
89+
filter_single: { id: engagement.id },
90+
}));
91+
const internEng = e.select(e.InternshipEngagement, () => ({
92+
filter_single: { id: engagement.id },
93+
}));
94+
return {
95+
...baseHydrate(engagement),
96+
...e.is(e.LanguageEngagement, languageExtraHydrate),
97+
...e.is(e.InternshipEngagement, internshipExtraHydrate),
98+
label: e.select({
99+
project: engagement.project.name,
100+
language: langEng.language.name,
101+
intern: e.array_join_maybe(
102+
e.array([
103+
internEng.intern.displayFirstName,
104+
internEng.intern.displayLastName,
105+
]),
106+
' ',
107+
),
108+
}),
109+
};
110+
});
79111

80112
export const ConcreteRepos = {
81113
LanguageEngagement: class LanguageEngagementRepository extends RepoFor(

src/components/engagement/engagement.repository.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
requestingUser,
5050
SortCol,
5151
sortWith,
52+
textJoinMaybe,
5253
whereNotDeletedInChangeset,
5354
} from '~/core/database/query';
5455
import { Privileges } from '../authorization';
@@ -168,6 +169,7 @@ export class EngagementRepository extends CommonRepository {
168169
relation('out', '', 'mouEnd', ACTIVE),
169170
node('mouEnd'),
170171
])
172+
.apply(matchNames)
171173
.match([
172174
[
173175
node('project'),
@@ -193,6 +195,11 @@ export class EngagementRepository extends CommonRepository {
193195
step: 'step.value',
194196
},
195197
language: 'language { .id }',
198+
label: {
199+
project: 'projectName.value',
200+
language: 'languageName.value',
201+
intern: textJoinMaybe(['dfn.value', 'dln.value']),
202+
},
196203
pnp: { id: 'props.pnp' },
197204
growthPlan: { id: 'props.growthPlan' },
198205
ceremony: 'ceremony { .id }',

src/components/engagement/engagement.service.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { forwardRef, Inject, Injectable } from '@nestjs/common';
22
import {
3+
CalendarDate,
34
ID,
45
InputException,
56
ObjectView,
7+
Range,
8+
RangeException,
69
SecuredList,
710
Session,
811
UnsecuredDto,
@@ -16,6 +19,7 @@ import {
1619
Logger,
1720
ResourceLoader,
1821
} from '~/core';
22+
import { AnyChangesOf } from '~/core/database/changes';
1923
import { Privileges } from '../authorization';
2024
import { CeremonyService } from '../ceremony';
2125
import { ProductService } from '../product';
@@ -67,6 +71,7 @@ export class EngagementService {
6771
): Promise<LanguageEngagement> {
6872
await this.verifyCreateEngagement(input.projectId, session);
6973
this.verifyCreationStatus(input.status);
74+
EngagementDateRangeException.throwIfInvalid(input);
7075

7176
const engagement = await this.repo.createLanguageEngagement(
7277
input,
@@ -87,6 +92,7 @@ export class EngagementService {
8792
): Promise<InternshipEngagement> {
8893
await this.verifyCreateEngagement(input.projectId, session);
8994
this.verifyCreationStatus(input.status);
95+
EngagementDateRangeException.throwIfInvalid(input);
9096

9197
const { id } = await this.repo.createInternshipEngagement(
9298
input,
@@ -172,6 +178,7 @@ export class EngagementService {
172178
this.privileges
173179
.for(session, LanguageEngagement, object)
174180
.verifyChanges(changes);
181+
EngagementDateRangeException.throwIfInvalid(previous, changes);
175182

176183
const updated = await this.repo.updateLanguage(
177184
{
@@ -218,6 +225,7 @@ export class EngagementService {
218225
this.privileges
219226
.for(session, InternshipEngagement, object)
220227
.verifyChanges(changes, { pathPrefix: 'engagement' });
228+
EngagementDateRangeException.throwIfInvalid(previous, changes);
221229

222230
const updated = await this.repo.updateInternship(
223231
{ id: object.id, ...changes },
@@ -310,3 +318,33 @@ export class EngagementService {
310318
return ids.length > 0;
311319
}
312320
}
321+
322+
class EngagementDateRangeException extends RangeException {
323+
static throwIfInvalid(
324+
current: Partial<
325+
Pick<UnsecuredDto<Engagement>, 'startDateOverride' | 'endDateOverride'>
326+
>,
327+
changes: AnyChangesOf<Engagement> = {},
328+
) {
329+
const start =
330+
changes.startDateOverride !== undefined
331+
? changes.startDateOverride
332+
: current.startDateOverride;
333+
const end =
334+
changes.endDateOverride !== undefined
335+
? changes.endDateOverride
336+
: current.endDateOverride;
337+
if (start && end && start > end) {
338+
const field =
339+
changes.endDateOverride !== undefined
340+
? 'engagement.endDateOverride'
341+
: 'engagement.startDateOverride';
342+
throw new EngagementDateRangeException({ start, end }, field);
343+
}
344+
}
345+
346+
constructor(readonly value: Range<CalendarDate>, readonly field: string) {
347+
const message = "Engagement's start date must be before the end date";
348+
super({ message, field });
349+
}
350+
}

src/components/engagement/handlers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './validate-eng-date-overrides-on-project-change.handler';
12
export * from './set-initial-end-date.handler';
23
export * from './update-engagement-status.handler';
34
export * from './set-last-status-date.handler';
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { isNotFalsy, NonEmptyArray } from '@seedcompany/common';
2+
import { CalendarDate, ID, RangeException, type UnsecuredDto } from '~/common';
3+
import { EventsHandler, IEventHandler } from '~/core';
4+
import type { Project } from '../../project/dto';
5+
import { ProjectUpdatedEvent } from '../../project/events';
6+
import { EngagementService } from '../engagement.service';
7+
8+
@EventsHandler(ProjectUpdatedEvent)
9+
export class ValidateEngDateOverridesOnProjectChangeHandlerSetLastStatusDate
10+
implements IEventHandler<ProjectUpdatedEvent>
11+
{
12+
constructor(private readonly engagements: EngagementService) {}
13+
14+
async handle(event: ProjectUpdatedEvent) {
15+
const { changes, updated, session } = event;
16+
17+
if (changes.mouStart === undefined && changes.mouEnd === undefined) {
18+
return;
19+
}
20+
21+
const project = {
22+
id: updated.id,
23+
name: updated.name,
24+
mouStart: updated.mouStart,
25+
mouEnd: updated.mouEnd,
26+
};
27+
const engagements = await this.engagements.listAllByProjectId(
28+
project.id,
29+
session,
30+
);
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 });
94+
}
95+
}

0 commit comments

Comments
 (0)