Skip to content

Commit 8e4f364

Browse files
authored
Move email lookup to shared read model (#176)
* Remove unused email lookup * Remove old email edit handling that was never fully implemented * use shared read model to get members by email * Implementation for findByEmail using shared read model and member linking * Fix mapAll in MemberLinking * Remove unused * Added more login link tests * OpenAI codex * Add more tests * Log the normalised email when sending login link
1 parent 1f9e92f commit 8e4f364

File tree

14 files changed

+391
-180
lines changed

14 files changed

+391
-180
lines changed

.codex/config.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Repo-local Codex defaults for the Makespace members app.
2+
3+
model = "gpt-5.4"
4+
model_reasoning_effort = "high"
5+
approval_policy = "on-request"
6+
sandbox_mode = "workspace-write"

AGENTS.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# AGENTS.md
2+
3+
Guidance for coding agents working in this repository.
4+
5+
## Quick start
6+
7+
- Runtime: Node.js 20
8+
- Package manager: Bun
9+
- Language: TypeScript
10+
- Test runner: Jest with `ts-jest`
11+
- Linting: ESLint
12+
- Primary workflow entrypoint: `make`
13+
14+
Prefer these commands:
15+
16+
- `make check` to run all the verification steps
17+
- `make test` to run all tests
18+
- `npx jest path/to/test.test.ts` to run a single test file
19+
- `make lint` to run ESLint
20+
- `make typecheck` to run check types
21+
- `make unused-exports` to detect unused exports
22+
- `make start` to start the local app stack
23+
- `make populate-local-dev` to seed local app stack with data
24+
25+
Local app stack endpoints:
26+
27+
- App: `http://localhost:8080`
28+
- Mailcatcher: `http://localhost:1080`
29+
30+
## Architecture
31+
32+
This app is built around event sourcing.
33+
34+
- Commands live in `src/commands/`
35+
- Read models live in `src/read-models/`
36+
- Queries live in `src/queries/`
37+
- Authentication flows live in `src/authentication/`
38+
- Background sync code lives in `src/sync-worker/`
39+
40+
Important boundaries:
41+
42+
- Commands validate authorization and business rules, then emit domain events.
43+
- Commands should only use the events passed to them. They should not query read models directly.
44+
- Read models project events into queryable state.
45+
- Queries read from read models and render HTTP responses.
46+
- Communication across the app should happen via events, not by coupling command code to read-model code.
47+
48+
## Project patterns
49+
50+
- Prefer existing fp-ts patterns such as `TaskEither`, `Option`, and `pipe`.
51+
- Use existing io-ts types and decoders where applicable.
52+
- Use `deps.logger` for logging instead of `console.log`.
53+
- Follow the repo’s current style rather than introducing a new abstraction or library unless necessary.
54+
- Keep changes small and targeted.
55+
56+
Authentication notes:
57+
58+
- Login is passwordless and handled by magic link flows under `src/authentication/`.
59+
- Member numbers are linked to email addresses through domain events.
60+
- Be careful with authorization and session-related changes.
61+
62+
## Testing guidance
63+
64+
Tests live under `tests/` and mirror the source layout.
65+
66+
- Use `.test.ts` files.
67+
- Command tests should assert on resulting events.
68+
- Read-model tests should feed events into the read model and assert on query results.
69+
- It is acceptable in read-model tests to use `command.process()` only to generate events for setup.
70+
- Avoid calling read models from command tests.
71+
- Tests should follow a behaviour driven testing style
72+
73+
Helpful test utilities already used in the repo:
74+
75+
- `faker` for test data
76+
77+
## Working norms for agents
78+
79+
- Prefer `rg` for searching the codebase.
80+
- Before broader verification, run the smallest relevant check for the files you changed when practical.
81+
- If you change behavior, add or update tests in the matching area under `tests/`.
82+
- Preserve the command/read-model separation when adding features.
83+
- Avoid speculative refactors unless they are necessary for the task.
84+
- Do not overwrite unrelated local changes.
85+
86+
## Useful references
87+
88+
- `README.md` for local setup and operational context
89+
- `CLAUDE.md` for repository guidance already written for coding assistants
90+
- `Makefile` for the canonical development, test, and lint commands
Lines changed: 20 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
import * as E from 'fp-ts/Either';
21
import * as TE from 'fp-ts/TaskEither';
3-
import {flow, pipe} from 'fp-ts/lib/function';
2+
import {pipe} from 'fp-ts/lib/function';
43
import {Dependencies} from '../dependencies';
54
import {Email, EmailAddress, Failure, failure} from '../types';
65
import {Config} from '../configuration';
76
import {magicLink} from '.';
8-
import {readModels} from '../read-models';
97
import mjml2html from 'mjml';
108

119
const toEmail =
@@ -42,37 +40,25 @@ const toEmail =
4240
`).html,
4341
});
4442

45-
const lookupMemberNumber = (emailAddress: string) =>
46-
flow(
47-
readModels.members.lookupByCaseInsensitiveEmail(emailAddress),
48-
members => {
49-
switch (members.length) {
50-
case 0:
51-
return E.left(failure('No member associated with that email')());
52-
case 1:
53-
return E.right(members[0]);
54-
default:
55-
return E.left(
56-
failure(
57-
'Multiple members associated with that email with diffrent capitalization. This is very likely to be a mistake.'
58-
)()
59-
);
60-
}
61-
}
62-
);
63-
64-
type SendLogInLink = (
65-
deps: Dependencies,
43+
export const sendLogInLink = (
44+
deps: Pick<Dependencies, 'sendEmail' | 'rateLimitSendingOfEmails' | 'sharedReadModel' | 'logger'>,
6645
conf: Config
67-
) => (emailAddress: EmailAddress) => TE.TaskEither<Failure, string>;
68-
69-
export const sendLogInLink: SendLogInLink = (deps, conf) => emailAddress =>
70-
pipe(
71-
deps.getAllEvents(),
72-
TE.chainEitherK(lookupMemberNumber(emailAddress)),
73-
TE.map(magicLink.create(conf)),
74-
TE.map(toEmail(emailAddress)),
75-
TE.chain(deps.rateLimitSendingOfEmails),
46+
) => (emailAddress: EmailAddress): TE.TaskEither<Failure, string> => {
47+
const members = deps.sharedReadModel.members.findByEmail(emailAddress);
48+
if (members.length === 0) {
49+
return TE.left(failure('No member associated with that email')());
50+
}
51+
if (members.length > 1) {
52+
deps.logger.error('While looking for email %s we found multiple users!', emailAddress);
53+
return TE.left(failure('Multiple members associated with that email. Please contact an administrator.')());
54+
}
55+
// Note that we intentionally use the stored email address rather than the one provided.
56+
// This prevents attacks where you specify an email address that somehow matches to an existing user but isn't
57+
// actually treated the same by the mailserver(s) so gets routed differently (to the attacker).
58+
const email = toEmail(members[0].emailAddress)(magicLink.create(conf)(members[0]));
59+
return pipe(
60+
deps.rateLimitSendingOfEmails(email),
7661
TE.chain(deps.sendEmail),
77-
TE.map(() => `Sent login link to ${emailAddress}`)
62+
TE.map(() => `Sent login link to ${members[0].emailAddress}`)
7863
);
64+
}

src/read-models/members/index.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
1-
import {lookupByCaseInsensitiveEmail} from './lookup-by-email';
21
import {getFailedImports} from './get-failed-imports';
32

43
export const members = {
5-
lookupByCaseInsensitiveEmail,
64
getFailedImports,
75
};
86

src/read-models/members/lookup-by-email.ts

Lines changed: 0 additions & 15 deletions
This file was deleted.

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,15 @@ import * as O from 'fp-ts/Option';
77
import {createTables} from './state';
88
import {BetterSQLite3Database, drizzle} from 'drizzle-orm/better-sqlite3';
99
import Database from 'better-sqlite3';
10-
import {Area, Equipment, Member} from './return-types';
10+
import {Area, Equipment, Member, MemberCoreInfo} from './return-types';
1111

1212
import {Client} from '@libsql/client';
1313
import {asyncRefresh} from './async-refresh';
1414
import {updateState} from './update-state';
1515
import {Logger} from 'pino';
1616
import {asyncApplyExternalEventSources} from './async-apply-external-event-sources';
1717
import {UUID} from 'io-ts-types';
18-
import {User} from '../../types';
18+
import {EmailAddress, User} from '../../types';
1919
import {getAllEquipmentFull, getEquipmentFull} from './equipment/helpers';
2020
import {getAllAreaFull, getAreaFull} from './area/helpers';
2121
import {
@@ -31,6 +31,7 @@ import { ReadonlyRecord } from 'fp-ts/lib/ReadonlyRecord';
3131
import { TrainingSheetId } from '../../types/training-sheet';
3232
import { EquipmentId } from '../../types/equipment-id';
3333
import { getTrainingSheetIdMapping } from './equipment/get';
34+
import { findByEmail } from './member/get';
3435

3536
export type SharedReadModel = {
3637
db: BetterSQLite3Database;
@@ -44,6 +45,7 @@ export type SharedReadModel = {
4445
get: (memberNumber: number) => O.Option<Member>;
4546
getAll: () => ReadonlyArray<Member>;
4647
getAsActor: (user: User) => (memberNumber: number) => O.Option<Member>;
48+
findByEmail: (email: EmailAddress) => ReadonlyArray<MemberCoreInfo>;
4749
};
4850
equipment: {
4951
get: (id: UUID) => O.Option<Equipment>;
@@ -95,6 +97,7 @@ export const initSharedReadModel = (
9597
get: getMemberFull(readModelDb, linking),
9698
getAll: getAllMemberFull(readModelDb, linking),
9799
getAsActor: getMemberAsActorFull(readModelDb, linking),
100+
findByEmail: findByEmail(readModelDb, linking),
98101
},
99102
equipment: {
100103
get: getEquipmentFull(readModelDb, linking),

src/read-models/shared-state/member-linking.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { MemberNumber } from "../../types/member-number";
22

3+
// NOTE: MemberLinking is a slightly grim solution to the problem that initially it was assumed each 'person' had 1 member number but
4+
// actually users might have multiple.
35
export class MemberLinking {
46
// Stores the linking between member numbers.
57
// This is much more effiently stored directly rather than using sqllite as its
@@ -43,6 +45,6 @@ export class MemberLinking {
4345
}
4446

4547
mapAll(all: readonly MemberNumber[]): ReadonlySet<MemberNumber>[] {
46-
return Array.from(new Set(all.map(this.map)));
48+
return Array.from(new Set(all.map(this.map.bind(this))));
4749
}
4850
}

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

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import {pipe} from 'fp-ts/lib/function';
22
import {BetterSQLite3Database} from 'drizzle-orm/better-sqlite3';
3-
import {inArray, desc} from 'drizzle-orm';
3+
import {inArray, desc, eq} from 'drizzle-orm';
44
import * as O from 'fp-ts/Option';
55
import * as RA from 'fp-ts/ReadonlyArray';
66
import * as RAE from 'fp-ts/ReadonlyNonEmptyArray';
77
import {MemberCoreInfo} from '../return-types';
88
import {membersTable} from '../state';
99
import {MemberCoreInfoPreMerge, mergeMemberCore} from './merge';
1010
import {MemberLinking} from '../member-linking';
11+
import { EmailAddress } from '../../../types';
12+
import { normaliseEmailAddress } from '../normalise-email-address';
1113

1214
const getMergedMember =
1315
(db: BetterSQLite3Database) =>
@@ -52,3 +54,29 @@ export const getAllMemberCore = (
5254
linking: MemberLinking
5355
): ReadonlyArray<MemberCoreInfo> =>
5456
pipe(linking.all(), RA.filterMap(getMergedMemberSet(db)));
57+
58+
export const findByEmail = (
59+
db: BetterSQLite3Database,
60+
linking: MemberLinking
61+
) => (email: EmailAddress): ReadonlyArray<MemberCoreInfo> => {
62+
// This is a bit grim because member numbers were initially assumed to be uniquely
63+
// identify a single member but actually a member can have multiple member numbers.
64+
// This means we need to find all the member numbers then group them then
65+
// finally use those to actually grab the merged members.
66+
// A potential solution would be to introduce a proper primary key that represents a single user
67+
// and then have member numbers map to the primary key 1:M.
68+
const foundMemberNumbers = db.select({
69+
memberNumber: membersTable.memberNumber,
70+
})
71+
.from(membersTable)
72+
.where(eq(membersTable.emailAddress, normaliseEmailAddress(email)))
73+
.orderBy(desc(membersTable.memberNumber))
74+
.all()
75+
.map(row => row.memberNumber);
76+
const groupedMemberNumbers = linking.mapAll(foundMemberNumbers);
77+
return groupedMemberNumbers.map(
78+
getMergedMemberSet(db)
79+
).flatMap(
80+
m => O.isSome(m) ? [m.value] : []
81+
);
82+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { EmailAddress } from "../../types";
2+
3+
export const normaliseEmailAddress = (email: EmailAddress): EmailAddress => {
4+
const splitPoint = email.lastIndexOf('@');
5+
if (splitPoint < 0) {
6+
return email;
7+
}
8+
return (email.substring(0, splitPoint) + '@' + email.substring(splitPoint + 1).toLowerCase()) as EmailAddress;
9+
};

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {and, eq, inArray, sql} from 'drizzle-orm';
1616
import {isOwnerOfAreaContainingEquipment} from './area/helpers';
1717
import {pipe} from 'fp-ts/lib/function';
1818
import {MemberLinking} from './member-linking';
19+
import { normaliseEmailAddress } from './normalise-email-address';
1920

2021
const revokeSuperuser = (db: BetterSQLite3Database, memberNumber: number) =>
2122
db
@@ -33,7 +34,7 @@ export const updateState =
3334
db.insert(membersTable)
3435
.values({
3536
memberNumber: event.memberNumber,
36-
emailAddress: event.email,
37+
emailAddress: normaliseEmailAddress(event.email),
3738
gravatarHash: gravatarHashFromEmail(event.email),
3839
name: O.fromNullable(event.name),
3940
formOfAddress: O.fromNullable(event.formOfAddress),
@@ -285,7 +286,7 @@ export const updateState =
285286
.set({
286287
status,
287288
})
288-
.where(eq(membersTable.emailAddress, event.email))
289+
.where(eq(membersTable.emailAddress, normaliseEmailAddress(event.email)))
289290
.run();
290291
break;
291292
}

0 commit comments

Comments
 (0)