ADR 0011: Custom reminders in notification-service (story #134) and the #153 channel-gating fix¶
- Status: Accepted
- Date: 2026-06-20
- Deciders: jobhub-architect (story #134, sub-issue #154); David R H
- Affects: notification-service, api-contracts, db/init, and (via the #153 root-cause) the existing preferences PUT path
Context¶
Story #134 lets a job seeker schedule their own future-dated reminders on a specific application (screening / interview / offer hint), with channel choice (in-app and/or email), that fire once at the chosen time on top of the system's default reminders. This sits alongside the existing notification-service capabilities: the weekly digest (ADR 0008), the 24h+1h interview reminders (ADR 0009), the ghosted-alert scheduler (the other 0009), and the in-app notification center.
JobHub conventions in scope: Hexagonal architecture for notification-service (ADR 0007),
contract-first api-contracts (interface-only generation), schema-per-service, DB owned by
db/init/ SQL, no framework annotations below the adapter layer, and the existing
internal-call patterns (auth-service /internal/users/emails, application-service
/internal/applications/...) when the scheduler needs cross-service data.
Bug #153 ("Toogle to enable email for interview reminder not working") is on the same preferences write/read code path that this story extends, and the architect ticket #154 asked us to root-cause it and decide whether to fold the fix into backend ticket #157.
What already exists that this design builds on:
NotificationPreferences(domain + entity + mapper + Panache repo) carrying bothinterviewReminders(master) andinterviewReminderEmail(channel sub-pref) plus the three other toggles.NotificationRepository/NotificationEntityfor in-app delivery (already has an optionalapplication_idcolumn from migration 044, story #82).- The
@Scheduledto use-case to outbound-port flow used byWeeklyDigestScheduler,InterviewReminderSchedulerandGhostedAlertScheduler, with a per-run log table for idempotency (digest_run,interview_reminder_sent). - The
QuteReminderMailer/QuteAlertMailer/QuteDigestMaileroutbound mail adapters using Qute templates with the shared notification email styling (story #62). - Application ownership is already verified by
application-service; the only new cross-service question is "does this application belong to this user".
Decision¶
1. Architecture: Hexagonal (same as the rest of notification-service)¶
We will keep notification-service hexagonal (ADR 0007). No blended layers. Custom reminders are a small set of mechanistic use cases (create, edit, cancel, list, dispatch) with a near 1:1 mapping between REST endpoints and use cases, plus one scheduled dispatch loop. The technical-service rubric (CLAUDE.md decision guide) clearly picks Hex over Clean.
2. Domain shape (Layer 1)¶
domain/model/
CustomReminder -- @Getter @Builder, immutable
id (UUID), userId (UUID), applicationId (UUID),
title (String, 1..200), note (String?, 0..2000),
triggerAtUtc (Instant), channels (Set<CustomReminderChannel>),
stage (CustomReminderStage?), status (CustomReminderStatus),
createdAt (Instant), updatedAt (Instant)
CustomReminderChannel -- enum IN_APP, EMAIL
CustomReminderStage -- enum SCREENING, INTERVIEW, OFFER
CustomReminderStatus -- enum SCHEDULED, FIRED, CANCELLED
domain/exception/
CustomReminderNotFoundException -- 404 (also used for non-owner)
CustomReminderNotScheduledException -- 409 (edit/cancel of FIRED or CANCELLED)
CustomReminderTriggerInPastException -- 400 (create/edit with non-future trigger)
CustomReminderInvalidChannelsException -- 400 (empty channels after normalisation)
ApplicationNotOwnedException -- 404 on create when application is not the user's
Domain invariants enforced in the model (not at the REST adapter):
- triggerAtUtc strictly in the future at creation/edit time (validated against an injected
Clock).
- channels non-empty after de-duplication (normalised to a Set<CustomReminderChannel>).
- title non-blank, length 1..200; note length 0..2000 when present.
Owner-scoping is enforced at the use-case layer: every read/write loads the row by
(id, userId) and treats "found by id but wrong user" identically to "not found" (404),
matching the notification-center precedent and avoiding existence leaks.
3. Ports (Layer 2)¶
domain/port/in/
CreateCustomReminderUseCase -- create(userId, command) -> CustomReminder
UpdateCustomReminderUseCase -- update(userId, id, command) -> CustomReminder
CancelCustomReminderUseCase -- cancel(userId, id) -> void
GetCustomReminderUseCase -- get(userId, id) -> CustomReminder
ListMyCustomRemindersUseCase -- list(userId, includeFired) -> List<CustomReminder>
ListCustomRemindersByApplicationUseCase
-- list(userId, applicationId, includeFired) -> List<CustomReminder>
DispatchDueCustomRemindersUseCase -- run() -> void (scheduler entry)
domain/port/out/
CustomReminderRepository -- save, update, findByIdForUser, findAllForUser,
findAllForUserAndApplication, findDue(now, limit),
markFired(id, channelsActuallyFired, firedAt)
ApplicationOwnershipGateway -- isOwnedByUser(applicationId, userId) -> boolean
(calls application-service internal endpoint, see section 7)
CustomReminderMailer -- send(toEmail, reminder)
(new Qute template; reuses notification-email base styling)
(reuse) NotificationRepository -- to write the IN_APP delivery row
(reuse) NotificationPreferencesRepository -- to read master gates at dispatch time
(reuse) UserEmailGateway -- batched email resolution for due reminders
4. Channel gating rule (locked, applies to dispatch only)¶
Decision: master "email me" preferences GATE custom reminders. No override.
At dispatch time, for each due reminder owned by user U asking for channels C:
- If C contains
EMAILandNotificationPreferences.interviewReminderEmailisfalse, the EMAIL channel is dropped silently for this firing. (Until story #135's follow-up adds a dedicated "custom-reminder email" flag,interviewReminderEmailis the master email gate the UI already exposes as "Also email me for alerts"; ADR 0010 already binds that copy to this flag.) - If C contains
IN_APPit is always honoured. There is no master "in-app" gate today;inAppNotificationsEnabledis inert (ADR 0010), so we do NOT consult it. If a later story wires it up, custom-reminder in-app delivery inherits that behaviour like any other in-app notification, no contract change needed. - If C contains
EMAILbut no email can be resolved for the user (auth-service down or user has no verified email), the EMAIL channel is dropped and the failure is logged but the reminder is still considered FIRED for its in-app side (mirrors the interview reminder behaviour). - If, after gating, the effective channel set is empty (user asked email-only and email is
gated off), the reminder is still marked FIRED to avoid an infinite retry loop, and a
WARN log records that the user gated themselves out. The audit row records
channels_fired = ''so this case is queryable.
Rationale: this is the fall-through-to-master rule, not the override-master rule. The product story explicitly says "the user receives the chosen notification(s) ... gated by the user's master notification preferences"; treating a custom reminder as a hard override of a user-set "don't email me" toggle would surprise users and contradict the master-toggle contract. The trade-off (a user might create an email reminder, then disable master email, then forget the reminder won't email) is acceptable for v1 and is exactly the same contract every other notification on the platform follows.
Future expansion: if product later wants per-reminder override semantics, add a
bypassChannelGates: boolean to the request/response and a new ADR; do NOT change the
default.
Editing semantics: at create/edit time we do NOT validate against gates. A user may legitimately ask for EMAIL while currently having it disabled, intending to flip it on later. Gating happens at dispatch only.
5. Use cases and the REST adapter (Layer 3 in)¶
The REST resource implements the generated CustomRemindersApi (api-contracts) under the
existing NotificationResource's package, either as a sibling CustomReminderResource
(preferred for cohesion) or merged into NotificationResource. Decision: new
CustomReminderResource class. Keeps the existing resource focused on preferences +
notification center; matches the way InterviewReminderScheduler lives next to
WeeklyDigestScheduler rather than inside one mega-class.
Implementation note (added 2026-06-21 during P3 conformance): the
notification-service generator config in api-contracts/pom.xml does not set
useTags=true, so openapi-generator-maven-plugin groups operations by path prefix:
every operation under /notifications/* lands on a single NotificationsApi
interface (preferences + notification center + custom-reminder CRUD), and only the
by-application listing under /applications/* gets its own ApplicationsApi.
Honouring the generated interface therefore forces the custom-reminder CRUD onto the
class that implements NotificationsApi (i.e. NotificationResource); the
CustomReminderResource that does exist implements ApplicationsApi for the
by-application listing. The contract is the boundary, not the resource-class split, so
this is accepted as conformant. If a future story needs the cleaner per-tag class
split, switch this service's generator to useTags=true in a dedicated change (it
would rename the generated interfaces and reshape every resource in the service, so
not a stealth change).
adapter/in/rest/
CustomReminderResource implements contract.api.CustomRemindersApi
@Path is taken from the generated interface (/notifications/custom-reminders and
/applications/{applicationId}/custom-reminders).
@RolesAllowed("user"); JWT subject -> userId (UUID).
Constructor-injects the seven use cases above plus the response mapper.
dto/CustomReminderResponseMapper static domain -> contract.CustomReminderResponse
dto/CustomReminderListMapper static List<domain> -> contract.CustomReminderList
exception/
CustomReminderNotFoundExceptionMapper -> 404
CustomReminderNotScheduledExceptionMapper -> 409
CustomReminderTriggerInPastExceptionMapper -> 400
CustomReminderInvalidChannelsExceptionMapper -> 400
ApplicationNotOwnedExceptionMapper -> 404
The resource takes the contract request DTOs (CreateCustomReminderRequest,
UpdateCustomReminderRequest), maps them to a small in-package command record (immutable),
calls the use case, and maps the returned domain back to the contract response. The
existing GenericExceptionMapper is the fallback.
6. Scheduler integration¶
A new CustomReminderDispatchScheduler under adapter/in/scheduler/ runs frequently and
calls DispatchDueCustomRemindersUseCase.run(). Mirrors the existing scheduler shape
(kill-switch config key + cron + try/catch).
- Default cron: every minute (
0 * * * * ?), config keynotification.custom-reminder.cron. One minute is the delivery tolerance the user perceives ("at the trigger time +/- tolerance"). - Kill switch:
notification.custom-reminder.enabled(defaulttrue). - Tolerance window: the use case loads reminders with
status = SCHEDULED AND trigger_at_utc <= now(), ordered bytrigger_at_utcASC, with a batch size confignotification.custom-reminder.batch-size(default200). No upper-bound check on "how late": if the scheduler was down for a day, all due reminders still fire on the next tick (catch-up). This is the same behaviour as the digest/interview/ghosted jobs. - Idempotency: the row's
statusis the truth. The scheduler reads SCHEDULED rows, attempts dispatch, then flips to FIRED withfired_at_utc = now()andchannels_firedrecording what actually went out. A second concurrent scheduler tick cannot re-fire because the UPDATE is conditional (UPDATE ... WHERE id = ? AND status = 'SCHEDULED'); zero rows updated means another tick won the race and we skip. - Per-reminder failure isolation: each reminder is processed in its own try/catch so one
failure doesn't abort the batch (same shape as the interview scheduler's
processItemloop). - Email-resolution batching: collect distinct
userIds for the batch, calluserEmailGateway.fetchEmails(userIds)once, then dispatch.
There is no need for a separate custom_reminder_fired log table: unlike the
interview-reminder case where one application has multiple offsets (H24+H1) keyed
independently, a custom reminder is one-shot, so the row's own status IS the log.
7. Application ownership check (cross-service call)¶
To honour "a user cannot create a reminder on an application that isn't theirs", the
create use case must verify ownership. application-service already has an internal
endpoint pattern (/internal/... + X-Service-Key, ADR 0008 / 0009). The architect
decision here is: add a new internal endpoint
HEAD /internal/applications/{id}/owner/{userId} returning 204 (owned) or 404 (not
owned / not found), called from notification-service via a new ApplicationOwnershipGateway
adapter that wraps the existing AppInternalRestClient.
Why HEAD: cheapest possible call, no body required, mirrors the boolean question.
Alternative considered: reuse the existing /internal/applications/upcoming-next-steps
endpoint and filter; rejected, that endpoint is windowed and not ownership-shaped.
This is a separate work item for the application-service developer; the backend
ticket #157 should call it out as a dependency. Until it exists, the architect's
fallback: trust the applicationId in the request body and let the dispatch step
silently no-op for rows whose application no longer exists. The implementer should
prefer the proper check; the fallback is for sequencing only.
8. Persistence (Layer 3 out)¶
notification.custom_reminder (
id UUID PK DEFAULT gen_random_uuid(),
user_id UUID NOT NULL, -- references auth.user.id (no FK, cross-schema)
application_id UUID NOT NULL, -- references applications.application.id (no FK)
title VARCHAR(200) NOT NULL,
note TEXT, -- nullable
trigger_at_utc TIMESTAMPTZ NOT NULL,
channels TEXT NOT NULL, -- comma-joined: 'IN_APP' | 'EMAIL' | 'IN_APP,EMAIL'
stage VARCHAR(20), -- nullable: 'SCREENING' | 'INTERVIEW' | 'OFFER'
status VARCHAR(20) NOT NULL DEFAULT 'SCHEDULED',
channels_fired TEXT, -- populated when status becomes FIRED
fired_at_utc TIMESTAMPTZ, -- populated when status becomes FIRED
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_custom_reminder_status
CHECK (status IN ('SCHEDULED', 'FIRED', 'CANCELLED')),
CONSTRAINT chk_custom_reminder_stage
CHECK (stage IS NULL OR stage IN ('SCREENING', 'INTERVIEW', 'OFFER')),
CONSTRAINT chk_custom_reminder_channels_nonempty
CHECK (length(trim(channels)) > 0)
);
CREATE INDEX idx_custom_reminder_user_status_trigger
ON notification.custom_reminder (user_id, status, trigger_at_utc);
CREATE INDEX idx_custom_reminder_user_app
ON notification.custom_reminder (user_id, application_id);
CREATE INDEX idx_custom_reminder_due
ON notification.custom_reminder (status, trigger_at_utc)
WHERE status = 'SCHEDULED';
CREATE OR REPLACE FUNCTION notification.trg_custom_reminder_updated() RETURNS TRIGGER
LANGUAGE plpgsql AS $$ BEGIN NEW.updated_at := NOW(); RETURN NEW; END; $$;
CREATE TRIGGER trg_custom_reminder_before_update
BEFORE UPDATE ON notification.custom_reminder
FOR EACH ROW EXECUTE FUNCTION notification.trg_custom_reminder_updated();
Three indexes, each justified:
- (user_id, status, trigger_at_utc) for list-mine-upcoming and list-mine-all (the two
common UI reads).
- (user_id, application_id) for list-by-application.
- Partial (status, trigger_at_utc) WHERE status='SCHEDULED' for the dispatcher poll;
partial keeps it small even as FIRED rows accumulate.
channels as a comma-joined TEXT (not a Postgres array, not a child table) matches the
existing interview_reminder_sent.channels precedent (migration 043) and stays simple
for v1. The closed enum (2 values) makes the parse trivial.
The Panache entity / mapper / repository follow the existing notification-service shape:
@Entity @Table(name="custom_reminder", schema="notification"), explicit @Column
names, mapper translates Set<CustomReminderChannel> to/from the comma-joined string.
9. Migration number assignment¶
Assigned: db/init/046-notification-custom-reminders.sql.
Reasoning: the existing notification range is 040..045 (latest is 045-notification-
preferences-drop-redundant-idx.sql). 046 is the next free number. The migration file
contains only the DDL above (table + indexes + trigger). Forward-only, no data
migration. As per CLAUDE.md, on an existing data volume the file must be applied by hand
(podman exec -i jobhub-db psql ... < db/init/046-...sql) and the notification-service
restarted.
The number has been posted as a comment on backend ticket #157.
10. API contract additions (already frozen in notification-service.yaml)¶
| Operation | Method/Path | Notes |
|---|---|---|
listMyCustomReminders |
GET /notifications/custom-reminders?includeFired |
Default upcoming-only, asc by trigger. |
createCustomReminder |
POST /notifications/custom-reminders |
201 + Location; 400 (validation), 404 (app not owned). |
getCustomReminder |
GET /notifications/custom-reminders/{id} |
404 on non-owner. |
updateCustomReminder |
PUT /notifications/custom-reminders/{id} |
Partial-update; 409 if not SCHEDULED. |
deleteCustomReminder |
DELETE /notifications/custom-reminders/{id} |
Soft-cancel (sets CANCELLED); 204; idempotent; 409 if FIRED. |
listCustomRemindersByApplication |
GET /applications/{applicationId}/custom-reminders?includeFired |
404 on non-owner. |
Schemas added: CustomReminderChannel, CustomReminderStage, CustomReminderStatus,
CustomReminderResponse, CreateCustomReminderRequest, UpdateCustomReminderRequest,
CustomReminderList. NotificationType enum extended with CUSTOM_REMINDER. All new
items carry x-implementation-status: planned. uniqueItems was deliberately omitted
on the channels arrays (it triggers a Jackson JsonDeserialize import the generator
can't satisfy in api-contracts); uniqueness is enforced in the domain. The contract
compiles (mvn -pl api-contracts -DskipTests compile -> BUILD SUCCESS) so backend +
frontend can build in parallel against it.
11. Bug #153 root-cause and verdict¶
Verdict: fold the fix into backend ticket #157. Same code path, same PR, same component-test class. Splitting would force a second PR that just adds a parameter to the very interface #157 will already be extending.
Root cause: five compounding holes on the preferences write/read path. The first three were identified pre-implementation by reading the source; backend developer #157 surfaced the remaining two during TDD and they are folded in here for posterity:
NotificationPreferencesService.updatePreferences(filenotification-service/src/main/java/com/davidcreate/jobhub/notification/domain/service/NotificationPreferencesService.java) has signature(UUID userId, Boolean weeklyDigestEmail, Boolean inAppNotificationsEnabled, Boolean interviewReminders, Boolean ghostedAlert).interviewReminderEmailis missing from the parameter list.- The merged builder in the same method never calls
.interviewReminderEmail(...), so Lombok's@Builderfalls back to the primitive defaultfalseon every PUT. Thedefaults()helper has the same omission, so a first-time user also getsfalse. NotificationResource.updateNotificationPreferences(filenotification-service/src/main/java/com/davidcreate/jobhub/notification/adapter/in/rest/NotificationResource.java, lines 70..79) reads four getters off the contract DTO (getWeeklyDigestEmail,getInAppNotificationsEnabled,getInterviewReminders,getGhostedAlert) and forwards them, but never readsrequest.getInterviewReminderEmail(), so the value the UI sends is dropped at the REST adapter even before the service bug above would matter.NotificationPreferencesResponseMapper.toResponse(...)(filenotification-service/src/main/java/com/davidcreate/jobhub/notification/adapter/in/rest/dto/NotificationPreferencesResponseMapper.java) sets four properties on the contract response but does not call.interviewReminderEmail(...). Even once holes 1..3 are closed, GET prefs would serialise the persisted value as the contract default rather than the actual state, so the UI would still render the toggle wrong on first load.UnrecognisedPreferencesFilter.RECOGNISED_FIELDS(filenotification-service/src/main/java/com/davidcreate/jobhub/notification/adapter/in/rest/exception/UnrecognisedPreferencesFilter.java) lists the four legacy fields but omits"interviewReminderEmail". A PUT body containing only{"interviewReminderEmail": true}therefore looks "all-unknown" to the filter and gets rejected as 400 Bad Request, blocking the very write the bug fix needs to enable.
All five are in the same family ("the four-field code path forgot the fifth field across read, write, and validation"). Splitting them across stories would have left a trap door open in each surface.
End-to-end effect: the UI PUTs interviewReminderEmail=true, the REST adapter ignores
it, the service overwrites it to false, the entity persists interview_reminder_email
= false, and InterviewReminderService.processItem reads prefs.isInterviewReminderEmail()
= false → no email is ever sent regardless of what the toggle shows. There is no DB
bug, no Hibernate bug, and no scheduler bug; the value never reaches storage.
The fix backend ticket #157 must apply:
- Add
Boolean interviewReminderEmailtoUpdatePreferencesUseCase.updatePreferences(port interface) and toNotificationPreferencesService.updatePreferences(implementation), merge it into the builder, and add it to thedefaults()helper. - In
NotificationResource.updateNotificationPreferences, forwardrequest.getInterviewReminderEmail()as the new last argument. - Add a regression component test: PUT with
interviewReminderEmail=true, GET back, asserttrue; PUT withinterviewReminderEmail=false, GET back, assertfalse; and a scheduler-level test that withinterviewReminders=trueandinterviewReminderEmail=truethe mailer is actually invoked. QAE will own the cases. - PR closes #153 alongside #134.
No contract change is required for the fix: the OpenAPI spec already declares
interviewReminderEmail on both the response and the request schemas. This is a pure
service-side wiring fix.
12. Config keys (notification-service)¶
| Key | Default | Purpose |
|---|---|---|
notification.custom-reminder.enabled |
true |
Kill switch for the dispatch scheduler |
notification.custom-reminder.cron |
0 * * * * ? |
One-minute tick (tolerance is the same minute) |
notification.custom-reminder.batch-size |
200 |
Max reminders processed per tick |
notification.custom-reminder.title.max-length |
200 |
Mirrored from contract; enforce in domain |
notification.custom-reminder.note.max-length |
2000 |
Mirrored from contract; enforce in domain |
Reuses the existing notification.internal.service-key,
quarkus.rest-client.app-internal.url, and quarkus.rest-client.auth-internal.url from
ADRs 0008/0009. No new env vars.
13. Test shape¶
| Layer | What | How |
|---|---|---|
Domain: invariant tests on CustomReminder create/edit |
future-only, channels non-empty, title bounds | Plain JUnit |
Unit: CustomReminderService use-case handlers |
owner-scoping, status guards, channel normalisation, gating decision call into prefs | Mockito, mock all outbound ports |
Unit: CustomReminderResponseMapper / CustomReminderListMapper |
round-trip | Plain JUnit |
| Unit: persistence mapper | Set<Channel> to/from comma-joined string |
Plain JUnit |
Component: CustomReminderResource happy + 4xx + 409 paths |
full CRUD + list-mine + list-by-application against DevServices DB | @QuarkusTest, fixed seed |
Component: CustomReminderDispatchScheduler |
due reminder triggers in-app + email, gating drops email when master is off, FIRED row, idempotent | @QuarkusTest + WireMock for app/auth-service, mock mailer port |
Regression for #153: NotificationResource PUT preferences |
round-trip interviewReminderEmail true/false |
Component test class extended |
Regression for #153: InterviewReminderService |
with interviewReminders=true and interviewReminderEmail=true, mailer is invoked |
Unit, Mockito |
Consequences¶
- Positive: Reuses every existing notification-service pattern (scheduler shape, Qute mailer, Panache repo, internal-call pattern, status-as-log idempotency). No new cross-cutting infra, no new Maven module, no new env vars.
- Positive: The fold-in of #153 puts the fix on exactly the same code path the story extends, so the developer touches the file twice (once for the new param, once for the new use cases) instead of twice in two PRs. QAE writes both regression cases in the same component test class.
- Positive: The gating rule is the same rule every other channel in the service follows; "master toggle wins" is a single contract for the user to learn.
- Negative / cost: A new internal endpoint in application-service
(
HEAD /internal/applications/{id}/owner/{userId}) is needed for clean ownership checks. This is a small but real cross-service dependency for #157. The implementer may temporarily fall back to "trust the request body" if sequencing requires it; the ADR records the fallback so it isn't silent. - Negative / accepted: "Email me" is gated by
interviewReminderEmailuntil a later story splits out a dedicatedcustomReminderEmailflag. ADR 0010 already binds the UI label "Also email me for alerts" to this flag, so the user's mental model is consistent. - Negative / accepted:
uniqueItemsis not in the contract because the generator can't compile it inside api-contracts. The domain de-dups; backend must add a service- side test for "POST with[EMAIL, EMAIL]returns one EMAIL in the response". - Follow-ups:
- Backend ticket #157: implement the design + fold #153 fix; closes both issues.
- application-service developer (separate ticket if not folded into #157): the
HEAD /internal/applications/{id}/owner/{userId}endpoint +ServiceKeyFiltercoverage. - PDA / QAE: refine acceptance criteria and write test cases against this ADR + the frozen contract slice.
- Frontend: implement against the frozen contract; the "channel gated by preference" fall-through is invisible at the UI level (the UI sends the request, the dispatcher silently drops gated channels), so no UI copy is owed beyond what story #135 already provides.
- Future ADR: if product later wants per-reminder gate-override or a dedicated
customReminderEmailmaster flag, open a new ADR (contract + service + numbered migration in the notification range).
Alternatives considered¶
- Per-reminder override of master gates (a
bypassChannelGates: booleanon the request). Rejected for v1: contradicts the master-toggle contract every other notification follows, surprises users, and the user can already achieve the effect by flipping the master toggle on. - Separate
custom_reminder_firedaudit log table mirroringinterview_reminder_sent. Rejected: a custom reminder is one-shot, so the row's own status is the log. The H24/H1 multi-offset case that justified the separate table for interview reminders does not apply here. - A
recurringflag onCustomReminderfor v1. Rejected: out of scope per story #134's "Recurring reminders (one-shot only for v1)". Adding a column now would invite half-built recurrence logic; defer until a story actually asks for it. - Postgres
text[]forchannels(or a childcustom_reminder_channeltable). Rejected: heavier than warranted for a closed two-value enum. Matches the existinginterview_reminder_sent.channelsprecedent (comma-joined TEXT). - Putting the new endpoints inside
NotificationResource. Rejected: the resource is already two stories deep (preferences + notification center). A siblingCustomReminderResourcekeeps the file size honest and matches the way the schedulers are split. - Splitting #153 into its own ticket. Rejected: same file, same code path, same component test class as #157. Folding eliminates a coordinating PR with no downside; the architect ticket here records the root cause so the developer doesn't have to rediscover it.
- Adding
jackson-databindas aprovideddependency on api-contracts to allowuniqueItems: true. Rejected: introduces a transitive shape across every consuming service for a property the domain can enforce in one line. DroppinguniqueItemsis the smaller change.
14. Addendum: PDA / QAE follow-up answers (story #134)¶
Posted 2026-06-20 after PDA (#155) shipped docs/specs/US6-custom-reminders.md and QAE
(#156) shipped 162 test cases. These answers unblock the developer tickets (#157 backend,
158 frontend, #160 application-service) and lock the open PDA-TAG items so QAE can¶
finalise assertions. Numbering tracks the questions surfaced on #134.
A1. PDA Q1: ownership check for listCustomRemindersByApplication before #160 lands¶
Decision: option (b), with empty list on non-owner. The listing endpoint
GET /applications/{applicationId}/custom-reminders filters on
WHERE user_id = jwt.userId AND application_id = :applicationId against
notification.custom_reminder. Because create is the only path that inserts rows and
already verifies ownership, no row owned by user A can ever exist under user B's
applicationId. A query by user B for user A's application therefore returns an empty
list naturally, with no cross-owner leak possible at the table level. Listing is not
blocked by #160.
Why empty list (not 404): the resource cannot distinguish "valid application that isn't yours" from "valid application that's yours but has zero reminders" without an ownership call. Returning 404 in the former case would leak the application's existence to a non-owner (the contract explicitly avoids this leak elsewhere), and returning 404 when the user simply has no reminders yet would be wrong. Empty list is correct for both shapes and matches REST list semantics.
Once #160 ships, the create path tightens (the existing ApplicationOwnershipGateway
call replaces the ADR-7 fallback) but the listing endpoint keeps the owner-scoped query
unchanged: there is no value in adding a HEAD round-trip for a query that is already
owner-safe. The contract line "Returns 404 if the application is not owned" in the
listing operation is therefore relaxed in the addendum: list returns 200 + empty array
for non-owner. QAE: update AC-LS-5 / CR-C cases to assert 200 + items: [] instead of
404 on the by-application listing for a non-owner application.
A2. PDA Q3: email body fallback when note is null¶
Decision: title only on a single line; no parenthetical filler. The Qute template
renders {reminder.note} when present and falls back to the title alone when null or
blank (after trim). No "(no extra details)" or similar copy: the subject already carries
the title, and a body that just restates it without explanatory parenthetical is the
cleanest signal that the user did not attach a note. The body still carries the
existing notification-email chrome (header, footer, "view application" CTA from BR-13)
so the email is never empty.
QAE assertion: when the seed reminder has note = null, the rendered email body
contains the title exactly once as a paragraph and does not contain any literal
"(no extra details)" or "No note" string.
A3. QAE CR-U-007: exception class for blank or too-long title¶
Decision: introduce a new domain exception CustomReminderInvalidTitleException,
maps to 400. Reusing CustomReminderInvalidChannelsException would be wrong (channels
vs title are independent invariants and the exception name would mislead future
readers), and there is no existing generic "invalid input" exception in
notification-service. Add CustomReminderInvalidTitleException extends RuntimeException
under domain/exception/, with a matching @Provider
CustomReminderInvalidTitleExceptionMapper returning 400 with
{"error":"Invalid Title","message":"<detail>"}. Thrown by the domain model factory /
use-case handler when title is blank after trim or exceeds 200 characters (config key
notification.custom-reminder.title.max-length, already in ADR section 12). The same
exception covers both create and update paths.
A4. QAE CR-U-041: second DELETE on an already-CANCELLED reminder¶
Decision: pure no-op at the service layer. CancelCustomReminderUseCase.cancel(...)
loads by (id, userId). If status == CANCELLED it returns without calling
markCancelled and without writing the row. If status == SCHEDULED it calls
markCancelled and writes. If status == FIRED it throws
CustomReminderNotScheduledException (409). The resource always returns 204 on a
non-thrown path, so idempotency at the contract is preserved while the DB sees no
redundant UPDATE, no updated_at churn, and no trigger fire. QAE: assert in
CR-U-041 that repo.markCancelled is verified never() for the CANCELLED-already
case, and that the resource still returns 204.
A5. QAE CR-C-033 / CR-UI-034: UI behaviour when DELETE returns 404 (stale-list race)¶
Decision: silent refetch + remove row, no toast. The 404 indicates another tab or
the dispatcher already finalised the reminder; the user's intent ("get rid of it") is
satisfied either way. Showing a one-line notice would imply user error where there is
none. The UI handler swallows the 404, removes the row from local state immediately,
and triggers the normal list refetch in the background so any other server-side state
(e.g. a sibling FIRED in the same race) appears correctly. All other 4xx / 5xx delete
errors still surface an inline error as today. QAE: CR-UI-034 asserts no toast / no
visible error text, row removed from rendered list, and that the listing fetcher is
called once after the 404.
A6. QAE CR-UI-060: display format for triggerAtUtc¶
Decision: absolute date + time in the user's locale and timezone, no relative
phrasing. Format token: Intl.DateTimeFormat(userLocale, { weekday: "short", day:
"numeric", month: "short", hour: "2-digit", minute: "2-digit" }), producing strings of
the shape "Mon 22 Jun, 14:30" (locale-dependent). Relative phrasings ("in 3 hours") are
brittle around DST and don't read well for reminders weeks out (which is the common
case). The notification bell already uses a relative timeAgo() for delivered
notifications (NotificationBell.jsx line 32), but that is a different signal (when
something happened) versus this one (when something will happen): mixing the two
in the same product would confuse users. QAE CR-UI-060: assert the rendered string
contains both date and time components and does NOT contain "ago" / "in " / "minutes".
A7. QAE CR-UI-070: icon + label for CUSTOM_REMINDER in the bell dropdown¶
Decision: icon clock, no per-type label. Extend TYPE_ICON in
JobHub-ui/src/components/NotificationBell.jsx with
CUSTOM_REMINDER: "clock". The clock glyph already exists in Icon.jsx (line 45),
so no new asset, no new dependency. It distinguishes custom reminders ("user-set time")
from INTERVIEW_REMINDER which uses calendar ("scheduled event") and from
GHOSTED_ALERT which uses alert-circle. No textual type label is added: the bell
dropdown already shows the notification title + body, and adding a type chip would
be inconsistent with the existing rows. QAE CR-UI-070: assert
data-testid="notification-icon-CUSTOM_REMINDER" exists, its child SVG matches the
clock glyph, and the row renders without falling back to the default info icon.