← Back to projects

Project 06 / Case study

Java backend · React · Android

Getting the table to fill itself.

Coordinating a tabletop RPG group is a scheduling problem, not one of willingness. A service where each player declares when they are available, the system finds the slots that coincide for the largest number of people and notifies them before they forget. A Java API with domain modelling, a versioned schema and an installable Android app that writes to the phone calendar.

37REST endpoints
11entities and tables
18foreign keys
1.877lines of Java
RoleDesign, development and deployment
ClientsReact web and Android APK
TechnologiesJava, Spring Boot, PostgreSQL, Flyway, React, Capacitor
01

The challenge

Running the poll by hand works up to four people. From there it gets lost in chat threads: slots nobody answered, last-minute changes, notices that arrive late and a coordinator who ends up acting as secretary. The real problem was not making a calendar, it was matching availability without anyone having to reread seventeen messages.

02

The solution

A group with members and roles. Each one registers availability slots and the API calculates which ones coincide, ordered by how many people can attend. Over a slot a poll is opened with options and explicit confirmation. Sessions have notice rules with lead time and repetition, and a dispatcher that hands them out. The same frontend is served on the web and packaged as an APK.

03

What I learned

That the isolation between clients of one same service is designed in the route map, not inside the queries. And that a badly expressed notice rule turns into noise: separating the "how early" from the "how often" as group data is what lets the system avoid being a nuisance.

Data model

The group as the boundary, and the database as the contract.

Eleven entities and eleven tables with eighteen foreign keys and six uniqueness constraints, created by versioned migrations. Everything hangs off the group, and the routes are nested so that membership is checked on the way in.

The routes are nested: /api/groups/{groupId}/availability, /api/groups/{groupId}/sessions/{sessionId} so that the group is resolved and authorised before reaching the data. The eighteen foreign keys are what guarantee that there is no orphaned availability, no session without a group and no vote hanging off a deleted option.

The initial schema creates the eleven tables: users, groups, memberships with their role, availability slots, sessions, three polling tables (question, option and vote), notice rules and notification deliveries. The second migration does not add a table: it adds the google_subject column to app_users with its uniqueness constraint, which is what ties a Google identity to an account without duplicating the person.

Session tokens are not stored in clear text: the hexadecimal of their SHA-256 is stored and looked up by hash on each request, with configurable expiry. Sending notices goes through a deliveries table, so a duplicated reminder is detected by reading the database instead of interrogating the mail server.

app_users ─┐ ├─ group_members (role) ─ role_groups ─┬─ availabilities (18 FK) ├─ game_sessions ── notification_rules ├─ session_polls ─ poll_options ─ poll_votes └─ notification_deliveries api_tokens token_guardado = SHA-256(token) · expires V1__initial_schema.sql 11 tables · 18 references · 6 unique constraints V2__google_accounts.sql app_users.google_subject + UNIQUE
  • Data

      PostgreSQLFlywayH2 in tests18 foreign keys
  • Backend

      JavaSpring BootSpring SecurityOpaque tokens hashed with SHA-256Transaction per endpoint
  • Frontend

      React 18TypeScript 5.7Vite 6pnpm
  • Mobile

      CapacitorSystem calendarLocal notificationsDebug APK
  • Mail

      SMTPMailpit in developmentDeliveries recorded

Business rules

Notifying in time without turning into noise.

A reminder that arrives badly is worse than none at all: it trains people to ignore the system. The notice rules are group data, not constants in the code, and the dispatcher calculates the cycle in whole blocks.

Every group is born with a default rule: notify 1.440 minutes ahead (one day) and repeat every 1.320 (twenty-two hours). The practical result is a daily reminder until the session is confirmed, with room to answer. Changing it does not touch code: it is a row.

There are two validations that are written on purpose. An availability slot lasts between thirty minutes and eight hours, and the ones that cross midnight are normalised by adding a day before comparing. And the repeat interval cannot be larger than the notice lead time, because otherwise the second repeat would fall before the first one.

The dispatcher does not keep a timer per session: it calculates the time of each delivery from the start time. First it looks at the largest lead time among all the rules of the group and sweeps only the sessions that fall in that window plus five minutes. Then, for each rule, it projects the cycle with integer division.

Instant first = session.getStartsAt() .minus(rule.getLeadMinutes(), MINUTES); if (rule.getRepeatMinutes() == null) return first; long elapsed = Duration .between(first, now).toMinutes(); return first.plus( (elapsed / rule.getRepeatMinutes()) * rule.getRepeatMinutes(), MINUTES);

One build, two targets

The app is not a second backend.

The same frontend is served in the browser and packaged as an Android APK. That forces a precise separation between what belongs to the server and what belongs to the device.

React and TypeScript on Vite, with Capacitor packaging to Android. The APK writes to the system calendar and schedules local notifications: the notice goes out with the application closed and the event lands in the diary the person already checks. On the web there is no system calendar to touch, so the same button downloads a .ics file with the sessions that have not been cancelled.

The phone reminders are not pushed by the server: the application reads the same rules from the API and projects the whole cycle into the future, one notification per moment, skipping sessions already started or cancelled. The identifier of each one comes from hashing group · session · rule · moment, so resyncing the same group does not generate duplicates. And there is a ceiling of two hundred scheduled alarms, because Android allows them and a group with many sessions eats up the system queue.

Two details that only get written after reading the Android documentation: you have to request the exact alarm permission separately from the notification permission (otherwise the system groups the notices and they arrive late), and when a rule changes you have to cancel the pending ones for that group before rescheduling, filtering by the group identifier that the app itself stored in the extra payload of each notice.

The server does not find out about either thing. The API is identical for the browser and for the phone, so the app is just one more client and not a second source of truth to keep in sync.

In development, the SMTP points at Mailpit on port 1025: the messages are built and received, but none of them leaves for the internet. A whole reminder flow can be repeated without bombarding real inboxes, and that convenience is what keeps the mail under test.

  • API surface

      Auth · 6Group · 10Poll · 5Availability · 4Session · 4NotificationRule · 4User · 3Health · 1
  • Cross-cutting

      Global error handlerTransaction declared on the controllerAdmin role enforced on the route
Decision

Nested routes per group instead of flat resources

A flat /api/availability/{id} forces you to remember, in every method, to filter by the group of the authenticated user. With nested routes, membership is validated in the routing. The cost is that the URLs grow and the group has to be resolved in every controller. The benefit is that an isolation mistake between clients stops being a forgotten query and becomes a route that simply does not exist.

Verification

Seven tests that boot the whole application.

A single integration file brings up the full context against an in-memory database and walks the flows over HTTP, as a client would. It is a safety net for refactoring, not a coverage of the domain.

What is covered: the seeded administrator lists their group, an availability is saved and read back, an administrator adds a member and rotates the coordination, requests without a credential are rejected, a regular account registers and starts a session, a valid Google credential creates the account the first time and reuses it the second, and monthly game matches keep adjacent gaps separate and show who is missing.

That last test is the most valuable in the file: the easy mistake of a matcher is to merge two contiguous slots into one, or to count as available someone who did not answer. It is named exactly for that.

The engine difference is a known risk. The tests run against H2 in PostgreSQL compatibility mode and with Flyway disabled (Hibernate creates the schema from time to time). Two consequences: the real migration is never exercised in the tests, and a type accepted by H2 in that mode may not be accepted by real PostgreSQL. The fix is a test profile with containerised PostgreSQL, which is a task of hours and I have not done it.

And what is missing is what cannot be seen from an endpoint: the repeat cycle of the notices and the state transition of a poll. A reminder that fires twice, or a game confirmed from a state that should not allow it, is not caught by any of the seven.

  • 37endpoints in 8 controllers and one global error handler
  • 11entities, 11 tables and 18 foreign keys
  • 2versioned migrations: schema and link to the Google identity
  • 7integration tests on H2 in PostgreSQL mode
  • 1.440 / 1.320minutes of notice and repeat by default
  • 1.877lines of Java and 928 of TypeScript

What I would do differently

Domain tests before new features. The repeat cycle of the notices and the state transition of a poll do not have a single test, and they are precisely the most delicate part of the product: a duplicated reminder or a game confirmed from an unlikely state is something nobody notices until a player tells them. Then, aligning the engines: using PostgreSQL in the integration tests, even if it costs bringing up a container, because today the schema is validated against H2 in compatibility mode and the real Flyway migration never gets to run. And one detail of the surface: the user controller is restricted to administrators at class level, so exposing any self-read inside it requires lowering the restriction to method level; I left it that way on purpose, but it is a place where whoever continues can get it wrong by accident.