Skip to content

feat(board): the boards come from the files that record them - #949

Merged
ExtraToast merged 1 commit into
mainfrom
feat/boards-from-csv
Sep 1, 2026
Merged

feat(board): the boards come from the files that record them#949
ExtraToast merged 1 commit into
mainfrom
feat/boards-from-csv

Conversation

@ExtraToast

@ExtraToast ExtraToast commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Slice 2 of #922, the backend half. Nothing about the board page's shape changes here — that is #930. What changes is where the association's history lives and what a board is allowed to carry.

The migration is unreleased and is rewritten in place

V87__board_history.sql has never run in production. The newest tag, v1.6.0, predates it, no tag contains it, and this repo deploys on release. So it is rewritten in place per the house rule rather than followed by a V103, and its data-loading SQL is deleted outright — no backfill, no regex archaeology on Roos “SkyeWolf” Kruk. The rewritten migration is schema only.

Every developer database and every staging environment that has run the old V87 must be dropped and reseeded. Flyway will refuse to start otherwise: the checksum has moved, and clean-on-validation-error does not rescue a mismatch on a versioned migration. This is not a suggestion — I hit it myself mid-review and the cure was DROP DATABASE. The seed files are what makes that cheap: a fresh database comes up with the whole history.

What a board carries now

ALTER TABLE boards
    ADD COLUMN number      INT          NOT NULL AFTER id,
    ADD COLUMN cheer       VARCHAR(255) NULL     AFTER name,
    ADD COLUMN accent      VARCHAR(32)  NULL     AFTER cheer,
    ADD COLUMN description TEXT         NULL     AFTER accent,
    MODIFY COLUMN name VARCHAR(255) NULL;

ALTER TABLE boards DROP INDEX uk_boards_name_start_date_deleted_at;
ALTER TABLE boards ADD CONSTRAINT uk_boards_number_deleted_at UNIQUE (number, deleted_at);

ALTER TABLE board_members
    ADD COLUMN nickname VARCHAR(128) NULL AFTER display_name;

The identity moves from the name and the start date to the number, because a name is a thing a board chose and may never have been written down, while its place in the line always is. accent mirrors game.accent exactly — VARCHAR(32), nullable, free string, blank meaning the association's blue.

One statement in there is not schema: UPDATE boards SET number = id WHERE number = 0. Adding a NOT NULL column fills existing rows with 0, and the unique key cannot then be built if a database holds more than one live board. Numbering them by their own key is deterministic, needs no ordering logic, and is not an attempt to recover history — the history comes from the files.

image and candidate deliberately survive

  • boards.image and board_members.image hold asset file names like board9/board9.jpg — paths into the frontend's own source tree. They are what /board draws today, and they must keep working: uploaded pictures arrive in A board and a seat carry an uploaded photograph #929 and these columns are dropped in The asset file names go #935. So they stay, and the CSV files carry them and the seed writes them. This is the expand half of an expand–contract.
  • boards.candidate stays NOT NULL and stays a dead copy of name, by decision (Boards on the island: a timeline, edited in place, seeded from a CSV #922, Out of Scope). Since name is now nullable, everything that writes a board fills it: the board's own name, or Board <number> where the name is blank. That rule is in one place, BoardUseCases.candidateFor, and in the seed's board upsert. It is asserted on create, on update and in the seed.

The history becomes files

services/api/src/main/resources/db/seed/boards/boards.csv and seats.csv, read through the shared SeedCsv bound once by BoardSeed — the same arrangement EsportsSeed uses. No second CSV reader.

number,name,cheer,accent,description,start_date,end_date,image
board,name,nickname,role,description,image

Ten boards and forty-six seats. Dates, roles, blurbs and image names come from the old V87 verbatim, extracted mechanically rather than retyped. The board names, the cheers and the nicknames are what the author supplied; accent and description are blank for all ten and are the author's to fill in later — one cell each, an edit and a deploy.

The names are split. V87 stored Roos “SkyeWolf” Kruk as one string, in two different kinds of quote. The files record Roos Kruk and SkyeWolf separately, the way a roster entry's handle already sits beside a member's name. Thirty-one of the forty-six carried a quoted nickname; boards 8 and 9 have ten more recovered from what their blurbs state; board 7's five are unknown and blank.

Board.vue composes the quoted form back for display, so nothing a reader sees changes.

The tenth board is a candidate board with no seats. Rainbow road, from 2026-09-17 — the day after board 9's handover, so the line has no gap and no overlap. Nobody has taken a seat on it, so it has no rows in seats.csv at all, and both the seed and the tests handle that without complaint. Whether a board is a candidate or in office is read off its dates and never stored, so no column can disagree with them.

Two dates are worth naming:

  • Board 9 hands over on 2026-09-16. V87 guessed 2026-08-31 and its own comment admitted the guess ("the day of the handover is not recorded anywhere, so the year is the unit"). This is the first handover in the history that is actually known, and the rewritten header says so.
  • Board 10's end date, 2027-08-31, is a convention, not a fact. Next year's handover is not known. It is one cell in boards.csv when it is.

The repeatable seed

R__Boards_seed, modelled on R__Esports_seed.

  • getChecksum() hashes the two files' contents, so correcting a row is an edit and a deploy rather than another migration.
  • Idempotent. A second run changes nothing; the update statements carry a NOT (a <=> ? AND b <=> ? …) guard so a row that already agrees is not even written.
  • Deletion outranks the files. A soft-deleted board or seat stays deleted while its row is still in the file. Removing the row is how it leaves for good. A seat on a deleted board is skipped rather than reparented.
  • A corrected row is applied. The files are the reviewed record, so a name, cheer, role, nickname, blurb or image edited in the file lands on the next run.
  • A seat's serving dates come from its board when the seat is created and are never rewritten, because a mid-year handover is recorded on the seat and the files carry no dates of their own. A seat's recorded name is its key, so the files can correct everything about a seat except its name; correcting a name reads as a seat the files have not seen before. Both are stated in the migration's header rather than left to be discovered.

What the auto-link does and does not do

It attaches a seat to an account once, as the seat is created, and never again.

  • It matches the seat's split name against TRIM(CONCAT_WS(' ', first_name, prefix, last_name)) — which is why this is possible at all. The old V87 had a name-match step, but thirty-one of the recorded names had a nickname in quotes in the middle of them, so it could never match, which is why no seat before the seventh board is linked to anybody today.
  • Exactly one match attaches. A name matching nobody, or matching two people, leaves the seat standing under its own recorded name — guessing between two people is worse than leaving it.
  • It never re-matches on a later start. Detaching somebody says who they are not, and a step that re-matched would undo that every time the application came up. Implemented by setting user_id only in the INSERT; the update path does not touch it.
  • It will not seat an account twice on one board: a match that already holds a seat there is skipped, so a person seated by hand under no recorded name is not duplicated by their name in the file.

There is no backfill of existing links and no attempt to attach a seat that already exists.

Encoding

Two names carry characters that are not the ASCII letters they resemble, and one board's name carries an apostrophe, so this is asserted rather than eyeballed:

  • İlayda Hotamişİ is U+0130 (Turkish capital I with dot), ş is U+015F. Corrected from V87's Ìlayda "Vriendelijke kebab" Hotamis, which had U+00CC and a plain s.
  • Kimberly Evertsz — corrected from V87's Evertz.
  • Don't starve together — an apostrophe needs no quoting in a comma-separated field, so it is not quoted; over-quoting it would show up as a quote inside the name.
  • Two of the ten cheers carry a comma (Blueshell, always ahead, RNG, Be With Me!) and are quoted, which is the exact failure mode SeedCsv's own doc comment warns about.

Checked, and asserted at two seams:

claim where
the files are UTF-8 with no byte-order mark, and SeedCsv decodes them as UTF-8 BoardSeedParsingTest — asserts the header is the first thing in the file, and the exact string both literally and as İlayda Hotamiş
the whole path — file bytes → reader → prepared statement → column → row read back — preserves them BoardSeedLoadIT.a name written outside ASCII arrives byte for byte
boards and board_members are utf8mb4 they areutf8mb4_unicode_ci on both tables and on all nine text columns involved, including the four new ones. BoardSeedLoadIT asserts it from information_schema so a column that drifted fails with a reason rather than as a mystery
the integration-test datasource speaks UTF-8 it does — the byte-for-byte case passes against the real MariaDB, and would have read ? if it did not

The byte-exact assertion looks up its row by role, not by name: these columns collate accent- and case-insensitively, so display_name = 'İlayda Hotamiş' would happily match a row that had lost both characters and the assertion would pass against corrupted data.

One thing found while looking: U+0130 will not render in the site's display face — the shipped .ttf covers 226 codepoints and lacks İ ı ş Ş. That is a frontend concern and is not touched here; what matters on this side is that the real character is in the database.

Api and client

number, cheer, accent and description on BoardResponse, CreateBoardRequest and UpdateBoardRequest; nickname on BoardMemberResponse, AddBoardMemberRequest and UpdateBoardMemberRequest; name nullable on the response. Blank strings normalise to absent, so a cleared colour means the association's blue rather than an empty string.

number is validated as at least 1 and refused with a 409 and the number in the message when another board holds it — DuplicateBoardException(number) in place of the old name-and-start-date one, which nothing threw. A board updating to its own number is not a conflict. No arbitrary upper bound: a cap on how many boards the association may ever have is not a rule.

The generated client is regenerated./gradlew :services:api:dumpOpenApiSpec, normalise, yarn gen:blueshell, yarn lint:gen, which is what CI's Validate OpenAPI client generation job runs. The diff is services/api/openapi.json plus types.gen.ts, and every changed line in types.gen.ts is a board type. Nothing else moved.

The existing page keeps working, minimally

/board and /management/boards are #930's to redesign, so they are touched only as far as the schema forces:

  • Board.vue names a board with no recorded name from its number (Board 6; Roman numerals are The board page reads as a timeline #930's) and composes a seat's nickname back into the quoted form.
  • BoardManager.vue gains the number, cheer, colour, description and nickname fields. Not decoration: the api writes what a request carries, so a dialog that did not send them would silently clear a board's cheer the first time somebody corrected its dates.
  • boards.ts grows boardTitle and seatTitle, which is where the display strings belong.

tests/e2e/boards.spec.ts still passes, with four cases added.

Architecture

shared/seed was pinned in SharedFanInArchitectureTest.BELOW_THRESHOLD at one consumer module with the note "until the board seed lands". It has landed, board is the second, and the pin is lifted — that test fails when a pinned package's fan-in recovers, which is what made this a checkable claim rather than a note. No ModuleMetadata is touched: board already names shared.

Nothing reads "the current board"

BoardRepository.findActiveBoard has no callers at all — I looked for every consumer of the idea, since a future-dated board 10 must never answer as the board in office. It could not anyway: the query is startDate <= :date AND (endDate IS NULL OR endDate >= :date), and board 10 starts in the future. BoardSeedLoadIT now pins that, asserting it answers with board 9 today.

The cohort module reaches the board module through BoardMemberService.serversBetween / servedBetween, which are seat-based and windowed. Board 10 has no seats, so it contributes nothing to any cohort. Nothing else in the api reads boards or board_members.

Tests

Run locally, exit codes checked rather than output grepped. The integration suite ran against a throwaway MariaDB 10.11.10 container on port 3309 (3306 was taken by another stack) with blueshell-test created from services/api/0_init.sql and CI's own MYSQL_* env, cwebp present for the shipped art.

command result
./gradlew :services:api:compileKotlin :services:api:compileTestKotlin :services:api:compileIntegrationTestKotlin exit 0
./gradlew :services:api:test exit 0
./gradlew :services:api:integrationTest (full suite) exit 0
yarn typecheck exit 0
yarn lint --max-warnings=0 exit 0
yarn test:unit exit 0 — 129 files, 850 tests
yarn test:e2e --grep "board" exit 0
./gradlew :services:api:dumpOpenApiSpec + yarn gen:blueshell exit 0, diff is board types only

New tests, at the seams #922 settled on:

  • BoardSeedLoadIT — a Spring integration test on what the files do to the database. Ten boards and forty-six seats land; a board with no seats lands; a board's name, cheer and candidate are what the file says; a blank cheer or colour is absent rather than empty; the split name and its nickname; a nickname recovered from a blurb; the five seats with no nickname; the asset file names surviving; a seat's dates coming from its board; a blurb carrying a comma, a quote and a line of its own; a cheer carrying a comma; a name outside ASCII, byte for byte; the columns' character set; a corrected row applied; a deleted board and a deleted seat left deleted; a second run changing nothing; the auto-link attaching an exactly-one match, leaving a no-match and a two-match alone, and never re-attaching after a detach; and the in-office rule read off the dates.
  • BoardSeedParsingTest — a pure unit test on the files themselves: the ten numbers in order, forty-six seats, no byte-order mark, the non-ASCII name literally and as escapes, the apostrophe, both comma-bearing cheers, no quote left inside any name, every seat naming a board the boards file lists, no two seats on a board sharing a name, and the terms running one after another with no gap and no overlap.
  • BoardControllerIT — extended: every new field round-tripping through create, read and update; a board with no name; blanks normalising to absent; a nickname round-tripping through create, update and read; the number conflict refused with 409; a board keeping its own number.
  • BoardUseCasesTest — the candidate fallback, the number conflict, the nickname.
  • BoardControllerSecurityTest — unchanged in what it asserts, so every endpoint's permissions are still covered by exactly the cases that covered them before.

What I did not verify

How many seats auto-link in production. The test database holds no accounts beyond the ones a test creates, so nothing attaches there by default and the mechanism is what is asserted: one exact match attaches, no match and two matches do not. Against the real users table the number is whatever it is — most of the people in this history never had an account here, and the seats that find one will say so in the log line the seed writes.

Closes #927


Diff breakdown added removed, scaled to the largest row.

api                                              +1397   -157   27
  production         █████████████████████░░░░░   +594   -133   19
  unit tests         █████░                       +174     -6    3
  integration tests  █████████████████████░       +605    -10    3
  test fixtures      █░                            +24     -8    2
  generated          █░                             +1     -1    1  ~

frontend                                          +297    -39    6
  production         ██████░                      +168    -25    3
  unit tests         █░                            +29     -7    1
  e2e tests          ███░                         +100     -7    2
  generated          ██░                           +80     -5    1  ~

──────────────────────────────────────────────────────────────────
production                                        +762   -158
tests                                             +932    -38  1.22 test lines per prod line
total (hand-written)                             +1694   -196  33 files
~ generated (excluded)                             +81     -6  2 files

}
}

if (attached > 0) log.info("[boards-seed] {} seats found the account they were recorded under", attached)
@ExtraToast
ExtraToast merged commit d8af803 into main Sep 1, 2026
25 checks passed
@github-actions github-actions Bot mentioned this pull request Sep 1, 2026
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.

The nine boards come from CSV files

2 participants