Skip to content

Commit e531d9e

Browse files
committed
Add ability to set an area email
This will be used to set the owners group mailing list email for each area. This allows for members to more easily reach out to the owners.
1 parent 5cade9e commit e531d9e

File tree

18 files changed

+329
-9
lines changed

18 files changed

+329
-9
lines changed

scripts/populate-local-dev.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ function event {
1010

1111
# Metal shop
1212
event 'api/areas/create' '{"id": "eeaf7f8b-77a3-429d-ae9d-2f7ade53736e", "name": "Metal Shop"}'
13+
event 'api/areas/set-mailing-List' '{"id": "eeaf7f8b-77a3-429d-ae9d-2f7ade53736e", "email": "metalshop@example.com"}'
1314

1415
# Metal lathe
1516
event 'api/equipment/add' '{"id": "4224ee94-09b0-47d4-ae60-fac46b8ca93e", "name": "Metal Lathe", "areaId": "eeaf7f8b-77a3-429d-ae9d-2f7ade53736e"}'

src/commands/area/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import {removeArea} from './remove-area';
66
import {removeAreaForm} from './remove-area-form';
77
import {removeOwner} from './remove-owner';
88
import {removeOwnerForm} from './remove-owner-form';
9+
import {setMailingList} from './set-mailing-list';
10+
import {setMailingListForm} from './set-mailing-list-form';
11+
912

1013
export const area = {
1114
create: {
@@ -24,4 +27,8 @@ export const area = {
2427
...removeArea,
2528
...removeAreaForm,
2629
},
30+
setMailingList: {
31+
...setMailingList,
32+
...setMailingListForm,
33+
},
2734
};
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import {flow, pipe} from 'fp-ts/lib/function';
2+
import * as E from 'fp-ts/Either';
3+
import * as t from 'io-ts';
4+
import {StatusCodes} from 'http-status-codes';
5+
import {formatValidationErrors} from 'io-ts-reporters';
6+
import {
7+
FailureWithStatus,
8+
failureWithStatus,
9+
} from '../../types/failure-with-status';
10+
import {Form} from '../../types/form';
11+
import {
12+
html,
13+
safe,
14+
sanitizeString,
15+
toLoggedInContent,
16+
} from '../../types/html';
17+
import {SharedReadModel} from '../../read-models/shared-state';
18+
import {
19+
areasTable,
20+
} from '../../read-models/shared-state/state';
21+
import {eq} from 'drizzle-orm';
22+
23+
type ViewModel = {
24+
areaId: string;
25+
areaName: string;
26+
currentEmail: string;
27+
};
28+
29+
const renderBody = (viewModel: ViewModel) => html`
30+
<h1>Update mailing list for '${sanitizeString(viewModel.areaName)}'</h1>
31+
<form action="#" method="post">
32+
<label for="email">Mailing list email address</label>
33+
<input
34+
type="text"
35+
name="email"
36+
id="email"
37+
value="${safe(viewModel.currentEmail)}"
38+
/>
39+
<input type="hidden" name="id" value="${safe(viewModel.areaId)}" />
40+
<button type="submit">Update mailing list</button>
41+
</form>
42+
<p>
43+
<small>Leave empty to remove the mailing list email</small>
44+
</p>
45+
`;
46+
47+
const renderForm = (viewModel: ViewModel) =>
48+
pipe(viewModel, renderBody, toLoggedInContent(safe('Update area mailing list')));
49+
50+
const paramsCodec = t.strict({
51+
area: t.string,
52+
});
53+
54+
const getAreaId = (input: unknown) =>
55+
pipe(
56+
input,
57+
paramsCodec.decode,
58+
E.map(params => params.area),
59+
E.mapLeft(
60+
flow(
61+
formatValidationErrors,
62+
failureWithStatus(
63+
'Parameters submitted to the form were invalid',
64+
StatusCodes.BAD_REQUEST
65+
)
66+
)
67+
)
68+
);
69+
70+
const getAreaInfo = (db: SharedReadModel['db'], areaId: string) =>
71+
pipe(
72+
db
73+
.select({areaName: areasTable.name, email: areasTable.email})
74+
.from(areasTable)
75+
.where(eq(areasTable.id, areaId))
76+
.get(),
77+
E.fromNullable(
78+
failureWithStatus(
79+
'The requested area does not exist',
80+
StatusCodes.NOT_FOUND
81+
)()
82+
),
83+
E.map(result => ({
84+
areaName: result.areaName,
85+
currentEmail: result.email ?? '',
86+
}))
87+
);
88+
89+
const constructForm: Form<ViewModel>['constructForm'] =
90+
input =>
91+
({readModel}): E.Either<FailureWithStatus, ViewModel> =>
92+
pipe(
93+
E.Do,
94+
E.bind('areaId', () => getAreaId(input)),
95+
E.bind('areaInfo', ({areaId}) => getAreaInfo(readModel.db, areaId)),
96+
E.map(({areaId, areaInfo}) => ({
97+
areaId,
98+
areaName: areaInfo.areaName,
99+
currentEmail: areaInfo.currentEmail,
100+
}))
101+
);
102+
103+
export const setMailingListForm: Form<ViewModel> = {
104+
renderForm,
105+
constructForm,
106+
};
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import {constructEvent} from '../../types';
2+
import * as t from 'io-ts';
3+
import * as tt from 'io-ts-types';
4+
import * as O from 'fp-ts/Option';
5+
import * as E from 'fp-ts/Either';
6+
import {pipe} from 'fp-ts/lib/function';
7+
import {Command} from '../command';
8+
import {isAdminOrSuperUser} from '../is-admin-or-super-user';
9+
import {EmailAddressCodec} from '../../types/email-address';
10+
11+
const codec = t.strict({
12+
id: tt.UUID,
13+
email: t.string,
14+
});
15+
16+
type SetMailingList = t.TypeOf<typeof codec>;
17+
18+
const process: Command<SetMailingList>['process'] = input => {
19+
return pipe(
20+
input.command.email,
21+
email => email === "" ? O.none : O.some(email),
22+
O.map(EmailAddressCodec.decode),
23+
O.getOrElseW(() => E.right(null)),
24+
O.fromEither,
25+
O.map(email => ({
26+
...input.command,
27+
email,
28+
})),
29+
O.map(constructEvent('AreaEmailUpdated')),
30+
);
31+
};
32+
33+
const resource: Command<SetMailingList>['resource'] = (command: SetMailingList) => ({
34+
type: 'Area',
35+
id: command.id,
36+
});
37+
38+
export const setMailingList: Command<SetMailingList> = {
39+
process,
40+
resource,
41+
decode: codec.decode,
42+
isAuthorized: isAdminOrSuperUser,
43+
};
44+

src/queries/areas/render.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ const renderEquipment = (equipment: ReadonlyArray<Equipment>) =>
9191
const renderArea = (area: Area) => html`
9292
<article>
9393
<h2>${sanitizeString(area.name)}</h2>
94+
${O.isSome(area.email)
95+
? html`<p><strong>Mailing list:</strong> ${safe(area.email.value)}</p>`
96+
: html``}
9497
<div>${renderEquipment(area.equipment)}</div>
9598
${renderOwnerTable(area.id, area.owners)}
9699
<div class="wrap">
@@ -100,6 +103,9 @@ const renderArea = (area: Area) => html`
100103
<a class="button" href="/equipment/add?area=${safe(area.id)}"
101104
>Add RED equipment</a
102105
>
106+
<a class="button" href="/areas/set-mailing-list?area=${safe(area.id)}"
107+
>Set mailing list</a
108+
>
103109
<a class="button" href="/areas/remove?area=${safe(area.id)}"
104110
>Remove area</a
105111
>

src/queries/equipment/render.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,13 @@ export const render = (viewModel: ViewModel) =>
344344
(viewModel: ViewModel) => html`
345345
<div class="stack">
346346
<h1>${sanitizeString(viewModel.equipment.name)}</h1>
347+
<p>
348+
<strong>Area:</strong> ${sanitizeString(viewModel.equipment.area.name)}
349+
${O.isSome(viewModel.equipment.area.email)
350+
? html` | <strong>Mailing list:</strong>
351+
${safe(viewModel.equipment.area.email.value)}`
352+
: html``}
353+
</p>
347354
${equipmentActions(viewModel)}
348355
<h2>Trainers</h2>
349356
${trainersList(viewModel.equipment.trainers)}

src/read-models/areas/area.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import {UUID} from 'io-ts-types';
2+
import {EmailAddress} from '../../types';
3+
import * as O from 'fp-ts/Option';
24

35
export type Area = {
46
id: UUID;
57
name: string;
68
owners: number[];
9+
email: O.Option<EmailAddress>;
710
};

src/read-models/areas/get-all.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
11
import {pipe} from 'fp-ts/lib/function';
22
import {DomainEvent, SubsetOfDomainEvent, filterByName} from '../../types';
33
import * as RA from 'fp-ts/ReadonlyArray';
4+
import * as O from 'fp-ts/Option';
45
import {Area} from './area';
56

6-
const pertinentEvents = ['AreaCreated' as const, 'OwnerAdded' as const];
7+
const pertinentEvents = [
8+
'AreaCreated' as const,
9+
'OwnerAdded' as const,
10+
'AreaEmailUpdated' as const,
11+
];
712

813
const updateAreas = (
914
state: Map<string, Area>,
1015
event: SubsetOfDomainEvent<typeof pertinentEvents>
1116
) => {
1217
switch (event.type) {
1318
case 'AreaCreated':
14-
state.set(event.id, {...event, owners: []});
19+
state.set(event.id, {...event, owners: [], email: O.none});
1520
return state;
1621
case 'OwnerAdded': {
1722
const current = state.get(event.areaId);
@@ -24,6 +29,17 @@ const updateAreas = (
2429
});
2530
return state;
2631
}
32+
case 'AreaEmailUpdated': {
33+
const current = state.get(event.id);
34+
if (!current) {
35+
return state;
36+
}
37+
state.set(event.id, {
38+
...current,
39+
email: O.fromNullable(event.email),
40+
});
41+
return state;
42+
}
2743
}
2844
};
2945

src/read-models/areas/get-area.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,19 @@ import * as RA from 'fp-ts/ReadonlyArray';
77
import {Area} from './area';
88
import {UUID} from 'io-ts-types';
99

10-
const pertinentEvents = ['AreaCreated' as const, 'OwnerAdded' as const];
10+
const pertinentEvents = [
11+
'AreaCreated' as const,
12+
'OwnerAdded' as const,
13+
'AreaEmailUpdated' as const,
14+
];
1115

1216
const updateAreas = (
1317
state: Map<string, Area>,
1418
event: SubsetOfDomainEvent<typeof pertinentEvents>
1519
) => {
1620
switch (event.type) {
1721
case 'AreaCreated':
18-
state.set(event.id, {...event, owners: []});
22+
state.set(event.id, {...event, owners: [], email: O.none});
1923
return state;
2024
case 'OwnerAdded': {
2125
const current = state.get(event.areaId);
@@ -28,6 +32,17 @@ const updateAreas = (
2832
});
2933
return state;
3034
}
35+
case 'AreaEmailUpdated': {
36+
const current = state.get(event.id);
37+
if (!current) {
38+
return state;
39+
}
40+
state.set(event.id, {
41+
...current,
42+
email: O.fromNullable(event.email),
43+
});
44+
return state;
45+
}
3146
}
3247
};
3348

src/read-models/shared-state/area/get.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,21 @@ import * as RA from 'fp-ts/ReadonlyArray';
55
import {pipe} from 'fp-ts/lib/function';
66
import {areasTable} from '../state';
77
import {UUID} from 'io-ts-types';
8+
import {EmailAddress} from '../../../types';
89
import {MinimalArea} from '../return-types';
910

1011
const transformRow = <
1112
R extends {
1213
id: string;
14+
name: string;
15+
email: string | null;
1316
},
1417
>(
1518
row: R
16-
) => ({
17-
...row,
19+
): MinimalArea => ({
1820
id: row.id as UUID,
21+
name: row.name,
22+
email: O.fromNullable(row.email as EmailAddress | null),
1923
});
2024

2125
export const getAreaMinimal =

0 commit comments

Comments
 (0)