Skip to content

Commit bb9a307

Browse files
committed
feat: unenrolment should be available only over 48h before occurrence
KK-1436. Add `AppConfig.enrolmentCancellationTimeLimitHours` that can be used to configure the time (in hours) that limits the cancellation time.
1 parent 3a0121f commit bb9a307

4 files changed

Lines changed: 116 additions & 3 deletions

File tree

src/domain/app/AppConfig.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,17 @@ class AppConfig {
304304
static get datetimeFormat() {
305305
return 'dd.MM.yyyy HH:mm:ss';
306306
}
307+
308+
/**
309+
* Time limit in hours for cancelling an enrolment.
310+
* This is the time before the event occurrence when a user can no longer cancel their enrolment.
311+
* Read env variable `VITE_ENROLMENT_CANCELLATION_TIME_LIMIT_HOURS`. Defaults to 48 hours if not set.
312+
*/
313+
static get enrolmentCancellationTimeLimitHours() {
314+
return (
315+
Number(import.meta.env.VITE_ENROLMENT_CANCELLATION_TIME_LIMIT_HOURS) || 48
316+
); // Default to 48 hours if not set
317+
}
307318
}
308319

309320
// Accept both variable and name so that variable can be correctly replaced

src/domain/event/EventIsEnrolled.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import EventPage from './EventPage';
1616
import ErrorMessage from '../../common/components/error/Error';
1717
import Button from '../../common/components/button/Button';
1818
import useChildRouteGoBackTo from '../profile/children/child/useChildRouteGoBackTo';
19+
import { shouldAllowUnenrolment } from './EventUtils';
1920

2021
const EventIsEnrolled = () => {
2122
const { t } = useTranslation();
@@ -55,7 +56,11 @@ const EventIsEnrolled = () => {
5556
</div>
5657
<h2>{t('event.cancellation.heading')}</h2>
5758
<div className={styles.cancelButtonWrapper}>
58-
<Button variant="secondary" onClick={() => setIsOpen(true)}>
59+
<Button
60+
variant="secondary"
61+
onClick={() => setIsOpen(true)}
62+
disabled={!shouldAllowUnenrolment(data.occurrence.time)}
63+
>
5964
{t('event.cancellation.buttonText')}
6065
</Button>
6166
</div>

src/domain/event/EventUtils.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { IconGroup } from 'hds-react';
22
import { addMinutes } from 'date-fns/addMinutes';
33
import uniqBy from 'lodash/uniqBy';
4+
import { differenceInHours } from 'date-fns/differenceInHours';
45

56
import { formatTime, newDate } from '../../common/time/utils';
67
import {
@@ -11,6 +12,7 @@ import {
1112
import { EventParticipantsPerInvite as EventParticipantsPerInviteEnum } from '../api/generatedTypes/graphql';
1213
import RelayList from '../api/relayList';
1314
import { OccurrenceNode, Occurrences } from './types/EventQueryTypes';
15+
import AppConfig from '../app/AppConfig';
1416

1517
const OccurrenceList = RelayList<OccurrenceNode>();
1618

@@ -66,3 +68,20 @@ export function getParticipantsIcon(iconType: EventParticipantsPerInviteEnum) {
6668
return <IconGroup />;
6769
}
6870
}
71+
72+
/**
73+
* Should the user be allowed to unenrol from an event occurrence?
74+
* This function checks if the current time is at least
75+
* `AppConfig.enrolmentCancellationTimeLimitHours` (e.g. 48) hours
76+
* before the occurrence time. If it is, the user can unenrol;
77+
* otherwise, they cannot.
78+
*/
79+
export const shouldAllowUnenrolment = (
80+
occurrenceTime: Date,
81+
enrolmentCancellationTimeLimitHours = AppConfig.enrolmentCancellationTimeLimitHours
82+
) => {
83+
const hoursDiff = differenceInHours(occurrenceTime, new Date(), {
84+
roundingMethod: 'ceil',
85+
});
86+
return Boolean(hoursDiff >= enrolmentCancellationTimeLimitHours);
87+
};

src/domain/event/__tests__/EventIsEnrolled.test.tsx

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import { MockedResponse } from '@apollo/client/testing';
2+
import { screen } from '@testing-library/react';
3+
import range from 'lodash/range';
24

35
import { customRender as render } from '../../../common/test/customRender';
46
import EventIsEnrolled from '../EventIsEnrolled';
57
import occurrenceQuery from '../queries/occurrenceQuery';
8+
import {
9+
EventParticipantsPerInvite,
10+
OccurrenceQuery,
11+
} from '../../api/generatedTypes/graphql';
612

713
const emptyOccurrenceMock: MockedResponse = {
814
request: {
@@ -16,9 +22,81 @@ const emptyOccurrenceMock: MockedResponse = {
1622
},
1723
};
1824

19-
const mocks: MockedResponse[] = [emptyOccurrenceMock];
25+
const occurrenceMock: OccurrenceQuery = {
26+
occurrence: {
27+
__typename: 'OccurrenceNode',
28+
id: 'occurrence-id',
29+
time: new Date(Date.now() + 48 * 60 * 60 * 1000),
30+
event: {
31+
__typename: 'EventNode',
32+
id: 'event-id',
33+
description: 'event description',
34+
image: '',
35+
imageAltText: null,
36+
shortDescription: null,
37+
name: null,
38+
duration: null,
39+
participantsPerInvite: EventParticipantsPerInvite.ChildAndGuardian,
40+
eventGroup: null,
41+
},
42+
remainingCapacity: null,
43+
childHasFreeSpotNotificationSubscription: null,
44+
venue: {
45+
__typename: 'VenueNode',
46+
id: '',
47+
name: null,
48+
address: null,
49+
accessibilityInfo: null,
50+
arrivalInstructions: null,
51+
additionalInfo: null,
52+
wwwUrl: null,
53+
wcAndFacilities: null,
54+
},
55+
},
56+
};
2057

2158
it('renders snapshot correctly', () => {
22-
const { container } = render(<EventIsEnrolled />, mocks);
59+
const { container } = render(<EventIsEnrolled />, [emptyOccurrenceMock]);
2360
expect(container).toMatchSnapshot();
2461
});
62+
63+
describe('unenrolment is possible only 48 before the occurrence', async () => {
64+
it.each(
65+
range(46, 50).map((hoursBeforeOccurrence) => ({
66+
hoursBeforeOccurrence,
67+
isCancellationAvailable: hoursBeforeOccurrence >= 48,
68+
}))
69+
)(
70+
// eslint-disable-next-line max-len
71+
'shows unenrolment button enabled when occurrence is in $hoursBeforeOccurrence hours ($isCancellationAvailable)',
72+
async ({ hoursBeforeOccurrence, isCancellationAvailable }) => {
73+
const occurrenceQueryMock: MockedResponse = {
74+
request: {
75+
query: occurrenceQuery,
76+
variables: { id: undefined, childId: undefined },
77+
},
78+
result: {
79+
data: {
80+
...occurrenceMock,
81+
occurrence: {
82+
...occurrenceMock.occurrence,
83+
time: new Date(
84+
Date.now() + hoursBeforeOccurrence * 60 * 60 * 1000
85+
),
86+
},
87+
},
88+
},
89+
};
90+
render(<EventIsEnrolled />, [occurrenceQueryMock]);
91+
await screen.findByText('event description');
92+
const unenrolButton = await screen.findByRole('button', {
93+
name: 'Peru ilmoittautuminen',
94+
});
95+
if (isCancellationAvailable) {
96+
expect(unenrolButton).toBeEnabled();
97+
} else {
98+
expect(unenrolButton).toBeDisabled();
99+
}
100+
}
101+
);
102+
});

0 commit comments

Comments
 (0)