Skip to content

Prayer spaces: type, coordinates, and admin CRUD - #16

Merged
IbraTech04 merged 2 commits into
minbar-featuresfrom
minbar/prayer-spaces-crud
Aug 30, 2026
Merged

IbraTech04 merged 2 commits into
minbar-featuresfrom
minbar/prayer-spaces-crud

Conversation

@IbraTech04

Copy link
Copy Markdown
Collaborator

Prayer spaces were readable and nothing else. Two GETs returned the JPA entity directly, there was no service layer, and no way to create, edit or delete one — a space could only be changed by editing the database by hand. The admin console's own page comment called that the largest gap between what it can manage and what Minbar actually runs.

Model

PrayerSpaceType {BROTHERS, SISTERS, MULTIFAITH, REFLECTION}. audience was doing double duty as both who is this listed for and what kind of room is it, which cannot describe the campus: a multifaith room and a reflection bay are both shown to everyone and are not the same place. Type carries a defaultAudience(), so the common case still only states one of the two, and the unusual one — a bay a building has designated sisters-only — stays representable.

latitude/longitude, nullable and written as a pair. Without them the app cannot pin a space, sort by distance, or offer a Maps link, which is why the map screen was still reading mock data. Nullable because a reflection bay inside a building has no useful pin, and a space can be listed before anyone has stood in it with a phone. One coordinate without the other is rejected — that is not a partial location.

Also imageUrl, notes, and a prose directions fallback for a space nobody has written step-by-step directions for yet. location becomes roomInfo; it described where in the building the room is, which stops reading as "location" once the table also holds coordinates.

Wire

PrayerSpaceView replaces the entity on both endpoints. steps is a lazy @OneToMany, so whether a caller got the walking directions depended on whether a transaction happened to be open — which is why the console fetched each space again, one at a time. The view is assembled inside the service, so the list carries full directions and that fan-out disappears.

Directions stay stored as their parts (walkTimeMinutes + startingPoint, not "2 min walk from the CCT main entrance"). Clients compose the copy; the app and the board word it differently and the server should not pick a side.

Admin

/api/admin/minbar/prayer-spaces — GET/POST/PATCH/DELETE.

Reads on board:content:read: where the musallahs are is not a secret, and /api/minbar serves the same data unauthenticated. What this adds is the unfiltered list, since a console editing the sisters' musallah has to load it whoever is signed in.

Writes on a new board:prayerspace:write, bundled into BOARD_EDITOR and BOARD_ADMIN. Everything else under board content is displayed on a wall, where wrong means stale. These are turn-by-turn instructions somebody follows through a building to find somewhere to pray, where wrong means the wrong floor.

PATCH semantics: null leaves a field alone, "" clears an optional text field, a list replaces wholesale, and clearCoordinates: true removes a pin — there is no empty-string trick for a number, so without it a space pinned to the wrong building could be corrected but never un-pinned.

The bug this turned up

V8 added the three *_PRAYER_SPACE audit actions but did not widen audit_events' CHECK constraint, and ddl-auto=validate does not inspect CHECK constraints. Every audit insert failed, and AdminAuditService catches and logs rather than rethrowing — correctly, since a failed audit write must not roll back the operation it describes. So the edits went through and none were recorded. Only visible by reading the table.

LINK_TICKET_EVENT and UNLINK_TICKET_EVENT turn out to have had the same bug since V5.

V9 fixes both dialects and adds a PRAYER_SPACE entity type. MigrationEnumParityTest is the part that matters: 178 cases asserting every constant of every @Enumerated(STRING) enum is named somewhere in each dialect's migrations. Crude on purpose — it cannot prove a value landed in the right constraint — but whole-cloth omission is the mistake that keeps happening, and it is now a build failure with a message that says what to do. Confirmed it fails on the real defect before V9 fixes it.

Testing

794 tests pass, including 22 service tests and 18 covering the read/write permission split.

Exercised against a live SQLite dev database with six seeded spaces: audience filtering, type→audience derivation, patch and clear semantics, the half-pin rejection, unauthenticated writes refused, and audit rows landing with the right actor and target. Both frontends were driven in Chrome against it.

Migrations

V8 and V9, both dialects. Existing rows backfill space_type from audience.

🤖 Generated with Claude Code

IbraTech04 and others added 2 commits August 28, 2026 11:56
Prayer spaces were readable and nothing else. Two GET endpoints returned the JPA
entity directly, there was no service layer, and no way to create, edit or delete
one — a space could only be changed by editing the database by hand, which the
admin console's own page comment called out as the largest gap between what it can
manage and what Minbar runs.

Model
- PrayerSpaceType {BROTHERS, SISTERS, MULTIFAITH, REFLECTION}. Audience was doing
  double duty as both "who is this listed for" and "what kind of room is it",
  which cannot describe the campus: a multifaith room and a reflection bay are
  both shown to everyone and are not the same place. Type carries a default
  audience so the common case still only states one of them.
- latitude/longitude, nullable and written as a pair. Without them the app cannot
  pin a space, sort by distance, or offer a Maps link — the reason the map screen
  was still reading mock data.
- imageUrl, notes, and a prose `directions` fallback for a space nobody has
  written step-by-step directions for yet.
- `location` becomes `roomInfo`; it described where in the building the room is,
  which stops reading as "location" once the table also holds coordinates.

Wire
PrayerSpaceView replaces the entity on both endpoints. `steps` is a lazy
@onetomany, so whether a caller got the walking directions depended on whether a
transaction happened to be open — which is why the console fetched each space
again one at a time. The view is assembled inside the service, so the list carries
full directions and that fan-out goes away.

Directions stay stored as their parts (walkTimeMinutes + startingPoint, not "2 min
walk from the CCT main entrance"). Clients compose the copy; the app and the board
word it differently and the server should not pick a side.

Admin
/api/admin/minbar/prayer-spaces, GET/POST/PATCH/DELETE. Reads on
board:content:read — where the musallahs are is not a secret, and /api/minbar
serves the same data unauthenticated. Writes on a new board:prayerspace:write,
bundled into BOARD_EDITOR and BOARD_ADMIN: everything else in board content is
displayed on a wall, where wrong means stale; these are turn-by-turn instructions
someone follows through a building to find somewhere to pray, where wrong means
the wrong floor. Every write is audited.

Migration V8, both dialects. Existing rows backfill space_type from audience.

613 tests pass, including 19 service tests and 18 covering the read/write
permission split.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things found by exercising the endpoints against a real database rather
than a mock.

**A pin could be set but never removed.** For the optional text fields, sending
`""` clears them; there was no equivalent for a number, because null already
means "leave unchanged". So a space pinned to the wrong building could be
corrected and never un-pinned, and the only way back was to delete it and type
it in again. `clearCoordinates: true` on the patch request nulls both halves.
Sending it alongside a latitude or longitude is a 400 — that asks for two
contradictory things in one call.

**None of the prayer-space writes were being audited.** V8 added the three
`*_PRAYER_SPACE` actions to `AuditAction` and wired them into the controller,
but did not widen `audit_events`' CHECK constraint, and `ddl-auto=validate` does
not inspect CHECK constraints. Every audit insert failed, and
`AdminAuditService` catches and logs rather than rethrowing — correctly, since a
failed audit write must not roll back the operation it describes. The result was
the worst of both: the edits went through and none of them were recorded. Only
visible by reading the table.

`LINK_TICKET_EVENT` and `UNLINK_TICKET_EVENT` turn out to have had the same bug
since V5. They are fixed here too.

V9 widens both CHECK constraints in both dialects and adds a `PRAYER_SPACE`
entity type, so these stop being filed under `EVENT` alongside calendar entries.

`MigrationEnumParityTest` is the part that matters: 178 cases asserting every
constant of every `@Enumerated(STRING)` enum is named somewhere in each
dialect's migrations. It is deliberately crude — it cannot prove a value landed
in the right constraint — but whole-cloth omission is the mistake that keeps
happening, and it is now a build failure with a message that says what to do.
Verified it fails on the real defect before V9 fixes it.

794 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@IbraTech04
IbraTech04 merged commit ac64752 into minbar-features Aug 30, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant