Skip to content

Commit 85870cd

Browse files
committed
Remove training sheet id
1 parent fc2574c commit 85870cd

File tree

11 files changed

+221
-8
lines changed

11 files changed

+221
-8
lines changed

src/commands/equipment/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import {add} from './add';
22
import {addForm} from './add-form';
33
import {registerTrainingSheet} from './register-training-sheet';
44
import {registerTrainingSheetForm} from './register-training-sheet-form';
5+
import {removeTrainingSheet} from './remove-training-sheet';
6+
import {removeTrainingSheetForm} from './remove-training-sheet-form';
57

68
export const equipment = {
79
add: {
@@ -12,4 +14,8 @@ export const equipment = {
1214
...registerTrainingSheet,
1315
...registerTrainingSheetForm,
1416
},
17+
removeTrainingSheet: {
18+
...removeTrainingSheet,
19+
...removeTrainingSheetForm,
20+
},
1521
};
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import {pipe} from 'fp-ts/lib/function';
2+
import * as E from 'fp-ts/Either';
3+
import {html, safe, sanitizeString, toLoggedInContent} from '../../types/html';
4+
import {Form} from '../../types/form';
5+
import {getEquipmentIdFromForm} from './get-equipment-id-from-form';
6+
import {UUID} from 'io-ts-types';
7+
import {failureWithStatus} from '../../types/failure-with-status';
8+
import {StatusCodes} from 'http-status-codes';
9+
import {currentTrainingSheetButton} from '../../queries/shared-render/current-training-sheet-button';
10+
11+
type ViewModel = {
12+
equipmentId: UUID;
13+
equipmentName: string;
14+
currentTrainingSheetId: string;
15+
};
16+
17+
const renderForm = (viewModel: ViewModel) =>
18+
pipe(
19+
html`
20+
<h1>
21+
Are you sure you wish to remove the current training sheet for
22+
${sanitizeString(viewModel.equipmentName)}?
23+
</h1>
24+
${currentTrainingSheetButton(viewModel.currentTrainingSheetId)}
25+
<form action="/equipment/remove-training-sheet" method="del">
26+
<input
27+
type="hidden"
28+
name="equipmentId"
29+
value="${viewModel.equipmentId}"
30+
/>
31+
<button type="submit">Confirm</button>
32+
</form>
33+
`,
34+
toLoggedInContent(safe('Remove training sheet'))
35+
);
36+
37+
const constructForm: Form<ViewModel>['constructForm'] =
38+
input =>
39+
({readModel}) =>
40+
pipe(
41+
E.Do,
42+
E.bind('equipmentId', () => getEquipmentIdFromForm(input)),
43+
E.bind('equipment', ({equipmentId}) =>
44+
pipe(
45+
equipmentId,
46+
readModel.equipment.get,
47+
E.fromOption(() =>
48+
failureWithStatus('No such equipment', StatusCodes.NOT_FOUND)()
49+
)
50+
)
51+
),
52+
E.bind('equipmentName', ({equipment}) => E.right(equipment.name)),
53+
E.bind('currentTrainingSheetId', ({equipment}) =>
54+
pipe(
55+
equipment.trainingSheetId,
56+
E.fromOption(() =>
57+
failureWithStatus(
58+
'No training sheet currently registered',
59+
StatusCodes.NOT_FOUND
60+
)()
61+
)
62+
)
63+
)
64+
);
65+
66+
export const removeTrainingSheetForm: Form<ViewModel> = {
67+
renderForm,
68+
constructForm,
69+
};
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import * as t from 'io-ts';
2+
import * as tt from 'io-ts-types';
3+
import * as O from 'fp-ts/Option';
4+
import {DomainEvent, constructEvent} from '../../types';
5+
import {Actor} from '../../types/actor';
6+
import {Command} from '../command';
7+
import {isAdminOrSuperUser} from '../is-admin-or-super-user';
8+
import {isEquipmentTrainer} from '../is-equipment-trainer';
9+
import {isEquipmentOwner} from '../is-equipment-owner';
10+
11+
const codec = t.strict({
12+
equipmentId: tt.UUID,
13+
});
14+
15+
type RemoveTrainingSheet = t.TypeOf<typeof codec>;
16+
17+
const process = (input: {
18+
command: RemoveTrainingSheet;
19+
events: ReadonlyArray<DomainEvent>;
20+
}): O.Option<DomainEvent> =>
21+
O.some(constructEvent('EquipmentTrainingSheetRemoved')(input.command));
22+
23+
const resource = (command: RemoveTrainingSheet) => ({
24+
type: 'Equipment',
25+
id: command.equipmentId,
26+
});
27+
28+
const isAuthorized = (input: {
29+
actor: Actor;
30+
events: ReadonlyArray<DomainEvent>;
31+
input: RemoveTrainingSheet;
32+
}) =>
33+
isAdminOrSuperUser(input) ||
34+
isEquipmentTrainer(input.input.equipmentId)(input.actor, input.events) ||
35+
isEquipmentOwner(input.input.equipmentId)(input.actor, input.events);
36+
37+
export const removeTrainingSheet: Command<RemoveTrainingSheet> = {
38+
process,
39+
resource,
40+
decode: codec.decode,
41+
isAuthorized,
42+
};

src/queries/equipment/render.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {DateTime} from 'luxon';
2222
import {UUID} from 'io-ts-types';
2323
import {contramap} from 'fp-ts/lib/Ord';
2424
import {renderMembersAsList} from '../../templates/member-link-list';
25+
import {currentTrainingSheetButton} from '../shared-render/current-training-sheet-button';
2526

2627
const trainersList = (trainers: ViewModel['equipment']['trainers']) =>
2728
pipe(
@@ -92,21 +93,36 @@ const currentSheet = (viewModel: ViewModel) =>
9293
O.of,
9394
O.filter(isTrainerOrOwner),
9495
O.flatMap(viewModel => viewModel.equipment.trainingSheetId),
95-
O.map(trainingSheetId => sanitizeString(trainingSheetId)),
96-
O.map(trainingSheetId => {
97-
return html`<li>
98-
<a href="https://docs.google.com/spreadsheets/d/${trainingSheetId}">
99-
Current training sheet
100-
</a>
101-
</li>`;
102-
}),
96+
O.map(currentTrainingSheetButton),
97+
O.getOrElse(() => html``)
98+
);
99+
100+
const removeTrainingSheet = (viewModel: ViewModel) =>
101+
pipe(
102+
viewModel,
103+
O.of,
104+
O.filter(isTrainerOrOwner),
105+
O.flatMap(viewModel =>
106+
O.isNone(viewModel.equipment.trainingSheetId)
107+
? O.none
108+
: O.some(viewModel.equipment.id)
109+
),
110+
O.map(
111+
id =>
112+
html` <li>
113+
<a href="/equipment/remove-training-sheet?equipmentId=${id}">
114+
Remove training sheet
115+
</a>
116+
</li>`
117+
),
103118
O.getOrElse(() => html``)
104119
);
105120

106121
const equipmentActions = (viewModel: ViewModel) => html`
107122
<ul>
108123
${trainMember(viewModel)} ${addTrainer(viewModel)}
109124
${registerSheet(viewModel)} ${currentSheet(viewModel)}
125+
${removeTrainingSheet(viewModel)}
110126
</ul>
111127
`;
112128

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import {html, sanitizeString} from '../../types/html';
2+
3+
export const currentTrainingSheetButton = (trainingSheetId: string) => {
4+
return html`<li>
5+
<a
6+
href="https://docs.google.com/spreadsheets/d/${sanitizeString(
7+
trainingSheetId
8+
)}"
9+
>
10+
Current training sheet
11+
</a>
12+
</li>`;
13+
};

src/read-models/shared-state/update-state.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,13 @@ export const updateState =
317317
revokeSuperuser(db, event.memberNumber);
318318
break;
319319
}
320+
case 'EquipmentTrainingSheetRemoved': {
321+
db.update(equipmentTable)
322+
.set({trainingSheetId: null})
323+
.where(eq(equipmentTable.id, event.equipmentId))
324+
.run();
325+
break;
326+
}
320327
default:
321328
break;
322329
}

src/routes.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ export const initRoutes = (
3535
'add-training-sheet',
3636
commands.equipment.trainingSheet
3737
),
38+
...command(
39+
'equipment',
40+
'remove-training-sheet',
41+
commands.equipment.removeTrainingSheet
42+
),
3843
...command(
3944
'equipment',
4045
'mark-member-trained',

src/types/domain-event.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,13 @@ const EquipmentTrainingSheetRegistered = defineEvent(
9393
}
9494
);
9595

96+
const EquipmentTrainingSheetRemoved = defineEvent(
97+
'EquipmentTrainingSheetRemoved',
98+
{
99+
equipmentId: tt.UUID,
100+
}
101+
);
102+
96103
const EquipmentTrainingQuizResult = defineEvent('EquipmentTrainingQuizResult', {
97104
equipmentId: tt.UUID,
98105
trainingSheetId: t.string,
@@ -199,6 +206,7 @@ export const events = [
199206
MemberNumberLinkedToEmail,
200207
LinkingMemberNumberToAnAlreadyUsedEmailAttempted,
201208
EquipmentTrainingSheetRegistered,
209+
EquipmentTrainingSheetRemoved,
202210
EquipmentTrainingQuizResult,
203211
EquipmentTrainingQuizSync,
204212
MemberDetailsUpdated,
@@ -226,6 +234,7 @@ export const DomainEvent = t.union([
226234
MemberNumberLinkedToEmail.codec,
227235
LinkingMemberNumberToAnAlreadyUsedEmailAttempted.codec,
228236
EquipmentTrainingSheetRegistered.codec,
237+
EquipmentTrainingSheetRemoved.codec,
229238
EquipmentTrainingQuizResult.codec,
230239
EquipmentTrainingQuizSync.codec,
231240
MemberDetailsUpdated.codec,
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import {faker} from '@faker-js/faker';
2+
import {UUID} from 'io-ts-types';
3+
import {pipe} from 'fp-ts/lib/function';
4+
import * as RA from 'fp-ts/ReadonlyArray';
5+
6+
import {removeTrainingSheet} from '../../../src/commands/equipment/remove-training-sheet';
7+
import {arbitraryActor, getSomeOrFail} from '../../helpers';
8+
9+
describe('remove-training-sheet', () => {
10+
const command = {
11+
equipmentId: faker.string.uuid() as UUID,
12+
trainingSheetId: faker.string.alphanumeric(8),
13+
actor: arbitraryActor(),
14+
};
15+
16+
const result = pipe(
17+
removeTrainingSheet.process({command, events: RA.empty}),
18+
getSomeOrFail
19+
);
20+
21+
it('Records the remove training sheet event', () => {
22+
expect(result).toStrictEqual(
23+
expect.objectContaining({
24+
type: 'EquipmentTrainingSheetRemoved',
25+
equipmentId: command.equipmentId,
26+
})
27+
);
28+
});
29+
});

tests/read-models/equipment/get-all.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,20 @@ describe('get-all', () => {
9595
O.some(registerSheet.trainingSheetId)
9696
);
9797
});
98+
describe('then the training sheet is revoked', () => {
99+
const removeTrainingSheet = {
100+
equipmentId: registerSheet.equipmentId,
101+
};
102+
beforeEach(async () => {
103+
await framework.commands.equipment.removeTrainingSheet(
104+
removeTrainingSheet
105+
);
106+
});
107+
it('no longer returns a sheet id', () => {
108+
const allEquipment = getAll(events);
109+
expect(allEquipment[0].trainingSheetId).toStrictEqual(O.none);
110+
});
111+
});
98112
});
99113

100114
describe('when equipment has had multiple sheets registered', () => {

0 commit comments

Comments
 (0)