Skip to content

Commit 47a170f

Browse files
committed
add a column to store number of hours actually made in the assessment
1 parent b33b0b3 commit 47a170f

File tree

14 files changed

+138
-47
lines changed

14 files changed

+138
-47
lines changed

back/src/config/pg/kysely/model/database.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,7 @@ interface EstablishmentsDeleted {
666666
interface ImmersionAssessments {
667667
convention_id: string;
668668
status: string;
669+
number_of_hours_actually_made: number | null;
669670
last_day_of_presence: Timestamp | null;
670671
number_of_missed_hours: number | null;
671672
ended_with_a_job: boolean;
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { MigrationBuilder } from "node-pg-migrate";
2+
3+
export async function up(pgm: MigrationBuilder): Promise<void> {
4+
pgm.addColumn("immersion_assessments", {
5+
number_of_hours_actually_made: {
6+
type: "numeric(5, 2)",
7+
notNull: false,
8+
},
9+
});
10+
}
11+
12+
export async function down(pgm: MigrationBuilder): Promise<void> {
13+
pgm.dropColumn("immersion_assessments", "number_of_hours_actually_made");
14+
}

back/src/domains/convention/adapters/PgAssessmentRepository.integration.test.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,13 @@ const convention = new ConventionDtoBuilder().withId(conventionId).build();
2525
const minimalAssessment: AssessmentEntity = {
2626
...new AssessmentDtoBuilder().withMinimalInformations().build(),
2727
_entityName: "Assessment",
28+
numberOfHoursActuallyMade: convention.schedule.totalHours,
2829
};
2930

3031
const fullAssessment: AssessmentEntity = {
3132
...new AssessmentDtoBuilder().withFullInformations().build(),
3233
_entityName: "Assessment",
34+
numberOfHoursActuallyMade: convention.schedule.totalHours,
3335
};
3436

3537
describe("PgAssessmentRepository", () => {
@@ -112,12 +114,10 @@ describe("PgAssessmentRepository", () => {
112114
expect(
113115
await db.selectFrom("immersion_assessments").selectAll().execute(),
114116
).toHaveLength(1);
115-
expectToEqual(
116-
await assessmentRepository.getByConventionId(
117-
fullAssessment.conventionId,
118-
),
119-
fullAssessment,
117+
const savedAssessment = await assessmentRepository.getByConventionId(
118+
fullAssessment.conventionId,
120119
);
120+
expectToEqual(savedAssessment, fullAssessment);
121121
});
122122
});
123123

@@ -151,6 +151,7 @@ describe("PgAssessmentRepository", () => {
151151
endedWithAJob: false,
152152
establishmentFeedback: "Ca s'est bien passé",
153153
establishmentAdvices: "mon conseil",
154+
numberOfHoursActuallyMade: 404,
154155
_entityName: "Assessment",
155156
};
156157

back/src/domains/convention/adapters/PgAssessmentRepository.ts

Lines changed: 13 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,10 @@
11
import { sql } from "kysely";
2-
import {
3-
AssessmentStatus,
4-
ConventionId,
5-
DateString,
6-
assessmentSchema,
7-
errors,
8-
} from "shared";
2+
import { AssessmentStatus, ConventionId, DateString, errors } from "shared";
93
import {
104
KyselyDb,
115
jsonBuildObject,
126
} from "../../../config/pg/kysely/kyselyUtils";
7+
import { assessmentEntitySchema } from "../../../utils/assessment";
138
import { AssessmentEntity } from "../entities/AssessmentEntity";
149
import { AssessmentRepository } from "../ports/AssessmentRepository";
1510

@@ -25,17 +20,20 @@ const createAssessmentQueryBuilder = (transaction: KyselyDb) => {
2520
typeOfContract: eb.ref("type_of_contract"),
2621
lastDayOfPresence: sql<DateString>`date_to_iso(last_day_of_presence)`,
2722
numberOfMissedHours: eb.ref("number_of_missed_hours"),
23+
numberOfHoursActuallyMade: eb.ref("number_of_hours_actually_made"),
2824
}).as("assessment"),
2925
]);
3026
};
3127

32-
const parseAssessmentSchema = (assessment: any) => {
33-
return assessmentSchema.parse({
28+
const parseAssessmentEntitySchema = (assessment: any) =>
29+
assessmentEntitySchema.parse({
30+
_entityName: "Assessment",
3431
conventionId: assessment.conventionId,
3532
status: assessment.status,
3633
establishmentFeedback: assessment.establishmentFeedback,
3734
establishmentAdvices: assessment.establishmentAdvices,
3835
endedWithAJob: assessment.endedWithAJob,
36+
numberOfHoursActuallyMade: assessment.numberOfHoursActuallyMade,
3937
...(assessment.contractStartDate
4038
? { contractStartDate: assessment.contractStartDate }
4139
: {}),
@@ -49,7 +47,6 @@ const parseAssessmentSchema = (assessment: any) => {
4947
? { numberOfMissedHours: assessment.numberOfMissedHours }
5048
: {}),
5149
});
52-
};
5350

5451
export class PgAssessmentRepository implements AssessmentRepository {
5552
constructor(private transaction: KyselyDb) {}
@@ -64,12 +61,7 @@ export class PgAssessmentRepository implements AssessmentRepository {
6461
const assessment = result?.assessment;
6562
if (!assessment) return;
6663

67-
const dto = parseAssessmentSchema(assessment);
68-
69-
return {
70-
_entityName: "Assessment",
71-
...dto,
72-
};
64+
return parseAssessmentEntitySchema(assessment);
7365
}
7466

7567
public async getByConventionIds(
@@ -81,12 +73,9 @@ export class PgAssessmentRepository implements AssessmentRepository {
8173
.where("convention_id", "in", conventionIds)
8274
.execute();
8375

84-
return result.map(({ assessment }) => {
85-
return {
86-
_entityName: "Assessment",
87-
...parseAssessmentSchema(assessment),
88-
};
89-
});
76+
return result.map(({ assessment }) =>
77+
parseAssessmentEntitySchema(assessment),
78+
);
9079
}
9180

9281
public async save(assessmentEntity: AssessmentEntity): Promise<void> {
@@ -112,6 +101,8 @@ export class PgAssessmentRepository implements AssessmentRepository {
112101
: null,
113102
establishment_feedback: assessmentEntity.establishmentFeedback,
114103
establishment_advices: assessmentEntity.establishmentAdvices,
104+
number_of_hours_actually_made:
105+
assessmentEntity.numberOfHoursActuallyMade,
115106
})
116107
.execute()
117108
.catch((error) => {
Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,55 @@
1-
import { AssessmentDto, ConventionDto, ConventionStatus, errors } from "shared";
1+
import {
2+
AssessmentDto,
3+
ConventionDto,
4+
ConventionStatus,
5+
calculateTotalImmersionHoursBetweenDateComplex,
6+
errors,
7+
} from "shared";
28
import { EntityFromDto } from "../../../utils/EntityFromDto";
39

4-
export type AssessmentEntity = EntityFromDto<AssessmentDto, "Assessment">;
10+
export type AssessmentEntity = EntityFromDto<AssessmentDto, "Assessment"> & {
11+
numberOfHoursActuallyMade: number | null;
12+
};
513

614
export const acceptedConventionStatusesForAssessment: ConventionStatus[] = [
715
"ACCEPTED_BY_VALIDATOR",
816
];
917

18+
const calculateNumberOfHoursActuallyMade = (
19+
assessment: AssessmentDto,
20+
convention: ConventionDto,
21+
): number => {
22+
if (assessment.status === "DID_NOT_SHOW") return 0;
23+
if (assessment.status === "COMPLETED") return convention.schedule.totalHours;
24+
25+
return (
26+
calculateTotalImmersionHoursBetweenDateComplex({
27+
complexSchedule: convention.schedule.complexSchedule,
28+
dateStart: convention.dateStart,
29+
dateEnd: assessment.lastDayOfPresence ?? convention.dateEnd,
30+
}) - assessment.numberOfMissedHours
31+
);
32+
};
33+
1034
export const createAssessmentEntity = (
11-
dto: AssessmentDto,
35+
assessment: AssessmentDto,
1236
convention: ConventionDto,
1337
): AssessmentEntity => {
1438
if (!acceptedConventionStatusesForAssessment.includes(convention.status))
1539
throw errors.assessment.badStatus(convention.status);
1640

1741
return {
1842
_entityName: "Assessment",
19-
...dto,
43+
...assessment,
44+
numberOfHoursActuallyMade: calculateNumberOfHoursActuallyMade(
45+
assessment,
46+
convention,
47+
),
2048
};
2149
};
2250

2351
export const toAssessmentDto = ({
2452
_entityName,
53+
numberOfHoursActuallyMade: _,
2554
...assessmentEntity
2655
}: AssessmentEntity): AssessmentDto => assessmentEntity;

back/src/domains/convention/use-cases/CreateAssessment.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
ConventionDomainPayload,
44
ConventionDto,
55
ForbiddenError,
6-
assessmentSchema,
6+
assessmentDtoSchema,
77
errors,
88
} from "shared";
99
import { agencyWithRightToAgencyDto } from "../../../utils/agency";
@@ -26,7 +26,7 @@ export const makeCreateAssessment = createTransactionalUseCase<
2626
ConventionDomainPayload | undefined,
2727
WithCreateNewEvent
2828
>(
29-
{ name: "CreateAssessment", inputSchema: assessmentSchema },
29+
{ name: "CreateAssessment", inputSchema: assessmentDtoSchema },
3030
async ({
3131
inputParams: assessment,
3232
uow,

back/src/domains/convention/use-cases/CreateAssessment.unit.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
expectObjectInArrayToMatch,
1414
expectPromiseToFailWithError,
1515
makeEmailHash,
16+
reasonableSchedule,
1617
splitCasesBetweenPassingAndFailing,
1718
} from "shared";
1819
import { toAgencyWithRights } from "../../../utils/agency";
@@ -128,7 +129,11 @@ describe("CreateAssessment", () => {
128129

129130
it("throws ConflictError if the assessment already exists for the Convention", async () => {
130131
uow.assessmentRepository.setAssessments([
131-
{ ...assessment, _entityName: "Assessment" },
132+
{
133+
...assessment,
134+
_entityName: "Assessment",
135+
numberOfHoursActuallyMade: validatedConvention.schedule.totalHours,
136+
},
132137
]);
133138
await expectPromiseToFailWithError(
134139
createAssessment.execute(assessment, tutorPayload),
@@ -213,11 +218,49 @@ describe("CreateAssessment", () => {
213218
{
214219
...assessment,
215220
_entityName: "Assessment",
221+
numberOfHoursActuallyMade: validatedConvention.schedule.totalHours,
216222
},
217223
]);
218224
},
219225
);
220226

227+
it("should create an assessment with correct duration when partially completed", async () => {
228+
const convention = new ConventionDtoBuilder(validatedConvention)
229+
.withStatus("ACCEPTED_BY_VALIDATOR")
230+
.withDateStart(new Date("2025-01-20").toISOString())
231+
.withDateEnd(new Date("2025-01-24").toISOString())
232+
.withSchedule(reasonableSchedule)
233+
.build();
234+
235+
const partiallyCompletedAssessment: AssessmentDto = {
236+
conventionId: validatedConvention.id,
237+
status: "PARTIALLY_COMPLETED",
238+
lastDayOfPresence: new Date("2025-01-23").toISOString(),
239+
numberOfMissedHours: 2.5,
240+
endedWithAJob: false,
241+
establishmentFeedback: "Ca c'est bien passé",
242+
establishmentAdvices: "mon conseil",
243+
};
244+
245+
uow.conventionRepository.setConventions([convention]);
246+
247+
await createAssessment.execute(partiallyCompletedAssessment, {
248+
...tutorPayload,
249+
role: "establishment-tutor",
250+
emailHash: makeHashByRolesForTest(convention, counsellor, validator)[
251+
"establishment-tutor"
252+
],
253+
});
254+
255+
expectArraysToEqual(uow.assessmentRepository.assessments, [
256+
{
257+
...partiallyCompletedAssessment,
258+
_entityName: "Assessment",
259+
numberOfHoursActuallyMade: 25.5, // 4 days * 7 hours - 2.5 missed hours
260+
},
261+
]);
262+
});
263+
221264
it("should dispatch an AssessmentCreated event", async () => {
222265
await createAssessment.execute(assessment, tutorPayload);
223266

back/src/domains/convention/use-cases/GetAssessmentByConventionId.unit.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ describe("GetAssessmentByConventionId", () => {
7272
uow.assessmentRepository.setAssessments([
7373
{
7474
_entityName: "Assessment",
75+
numberOfHoursActuallyMade: convention.schedule.totalHours,
7576
...assessment,
7677
},
7778
]);

back/src/utils/assessment.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@ import {
33
ConventionDomainPayload,
44
ConventionDto,
55
Role,
6+
assessmentDtoSchema,
67
errors,
78
isSomeEmailMatchingEmailHash,
89
} from "shared";
10+
import { z } from "zod";
11+
import { AssessmentEntity } from "../domains/convention/entities/AssessmentEntity";
912

1013
export const throwForbiddenIfNotAllowedForAssessments = (
1114
convention: ConventionDto,
@@ -22,6 +25,14 @@ export const throwForbiddenIfNotAllowedForAssessments = (
2225
throw errors.assessment.forbidden();
2326
};
2427

28+
export const assessmentEntitySchema: z.Schema<AssessmentEntity> =
29+
assessmentDtoSchema.and(
30+
z.object({
31+
_entityName: z.literal("Assessment"),
32+
numberOfHoursActuallyMade: z.number().or(z.null()),
33+
}),
34+
);
35+
2536
const assessmentEmailsByRole = (
2637
convention: ConventionDto,
2738
agency: AgencyDto,

front/src/app/components/forms/assessment/AssessmentForm.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
ConventionReadDto,
2424
DotNestedKeys,
2525
InternshipKind,
26-
assessmentSchema,
26+
assessmentDtoSchema,
2727
assessmentStatuses,
2828
computeTotalHours,
2929
convertLocaleDateToUtcTimezoneDate,
@@ -90,7 +90,7 @@ export const AssessmentForm = ({
9090
endedWithAJob: false,
9191
};
9292
const methods = useForm<AssessmentDto>({
93-
resolver: zodResolver(assessmentSchema),
93+
resolver: zodResolver(assessmentDtoSchema),
9494
mode: "onTouched",
9595
defaultValues: initialValues,
9696
});

shared/src/assessment/assessment.schema.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { ZodError } from "zod";
22
import { expectToEqual } from "../test.helpers";
33
import { AssessmentDto, DateRange } from "./assessment.dto";
4-
import { assessmentSchema, withDateRangeSchema } from "./assessment.schema";
4+
import { assessmentDtoSchema, withDateRangeSchema } from "./assessment.schema";
55

66
describe("Assessment schema date range", () => {
77
it("accepts valid date range", () => {
@@ -46,7 +46,7 @@ describe("Assessment schema", () => {
4646
establishmentAdvices: "establishment advice",
4747
establishmentFeedback: "establishment feedback",
4848
};
49-
const parsedAssessment = assessmentSchema.parse(assessment);
49+
const parsedAssessment = assessmentDtoSchema.parse(assessment);
5050
expectToEqual(assessment, parsedAssessment);
5151
});
5252

@@ -62,7 +62,7 @@ describe("Assessment schema", () => {
6262
establishmentAdvices: "establishment advices",
6363
establishmentFeedback: "establishment feedback",
6464
};
65-
const parsedAssessment = assessmentSchema.parse(assessment);
65+
const parsedAssessment = assessmentDtoSchema.parse(assessment);
6666
expectToEqual(assessment, parsedAssessment);
6767
});
6868

@@ -74,7 +74,7 @@ describe("Assessment schema", () => {
7474
establishmentAdvices: "establishment advices",
7575
establishmentFeedback: "establishment feedback",
7676
};
77-
const parsedAssessment = assessmentSchema.parse(assessment);
77+
const parsedAssessment = assessmentDtoSchema.parse(assessment);
7878
expectToEqual(assessment, parsedAssessment);
7979
});
8080

@@ -90,7 +90,7 @@ describe("Assessment schema", () => {
9090
establishmentAdvices: "my minimum establishment advices",
9191
establishmentFeedback: "my minimum establishment feedback",
9292
};
93-
expect(() => assessmentSchema.parse(assessment)).toThrowError();
93+
expect(() => assessmentDtoSchema.parse(assessment)).toThrowError();
9494
});
9595
});
9696
const expectDateRangeToFailWithError = (

0 commit comments

Comments
 (0)