Skip to content

Confirm intest feature - #321

Open
GeheimagentNr1 wants to merge 28 commits into
vatger:mainfrom
GeheimagentNr1:confirm-intest-feature
Open

Confirm intest feature#321
GeheimagentNr1 wants to merge 28 commits into
vatger:mainfrom
GeheimagentNr1:confirm-intest-feature

Conversation

@GeheimagentNr1

Copy link
Copy Markdown

Monthly Waiting List Interest Verification

Implements the waiting-list policy change announced here:
https://board.vatsim-germany.org/threads/atd-policy-aenderung-08-08-2026.75053/post-1016537


English

Summary

Waiting lists for courses (rating, endorsement, familiarisation, roster, guest)
have grown long over time, and a meaningful share of the people on them are no
longer actually interested in the training slot they're queued for. This PR
implements the monthly interest-verification process announced in the ATD
policy update linked above: once a month, everyone on a waiting list has to
reconfirm they're still interested in their spot, or they're automatically
removed.

This is a direct implementation of the technical approach proposed in the
linked forum thread:

  1. Für jeden Wartelistenplatz wird die Information gespeichert, ob noch
    Interesse besteht.
  2. Einmal im Monat (alle 2, 3, 6 Monate, wie auch immer), z. B. immer zum
  1. des Monats, wird eine Benachrichtigung mit der Frage, ob an der
    entsprechenden Warteliste noch Interesse besteht, verschickt.
  1. Alle, die nach dem einen Monat nicht für die Warteliste einen neuen
    „Interesse bestätigen" Button gedrückt haben, werden von der Warteliste
    entfernt.

Das löst 2 Probleme:
1.) Es ist möglich, auf mehreren Wartelisten zu stehen.
2.) Leute, die kein Interesse mehr haben, werden automatisch von der
Warteliste entfernt.

("1) For each waiting-list slot, store whether interest still exists. 2)
Once a month (or every 2, 3, 6 months, whatever), e.g. always on the 1st,
send a notification asking whether interest still exists for that waiting
list. 3) Everyone who hasn't pressed a new 'confirm interest' button for
that waiting list after one month gets removed from it. That solves two
problems: staying on multiple waiting lists stays possible, and people who
are no longer interested get automatically removed."
)

Mapped 1:1 onto this implementation:

Proposal Implementation
1) Store per-slot interest waiting_list_entries.is_interested (per entry, not per user — so being on multiple lists works correctly)
2) Monthly notification per waiting list ProcessMonthlyWaitingListVerification, scheduled via waitinglists:verify-interest; entries reset + notification sent
3) Remove after a month without confirmation Purge step removes any entry still unconfirmed from the previous cycle
Being on multiple lists should work Notifications are batched per user across all their entries — one notification, not one per list, addressing the exact concern the post raises about the existing multi-list notification mechanism

What changed

  • Data model: waiting_list_entries gains is_interested (boolean,
    defaults to true) and interest_confirmed_at. A new
    waiting_list_verification_runs table records which calendar month's cycle
    has already run, so the scheduled job can safely trigger daily without
    double-applying its effects within the same month.
  • Monthly cycle (ProcessMonthlyWaitingListVerification, run via the new
    waitinglists:verify-interest artisan command, scheduled daily at 06:00):
    1. Removes any entry still is_interested = false from the previous
      cycle — i.e. anyone who didn't reconfirm in time.
    2. Resets everyone remaining to is_interested = false and sends a
      notification asking them to reconfirm before the next cycle.
    3. Records the run so it isn't repeated within the same calendar month.
    • The purge step is additionally skipped if the previous run happened less
      than ~25 days ago, so two runs landing close together across a month
      boundary (e.g. right after this feature deploys, or after scheduler
      downtime) can't wipe out an entire waiting list without anyone having had
      a real month to respond.
    • Notifications are batched per user — someone on multiple waiting lists
      gets one notification, not one per entry.
  • Trainee UI: a "Confirm you're still interested" button appears next to
    the existing waiting-list controls whenever confirmation is pending.
  • Mentor UI: the waiting-list management view shows a
    "Confirmed" / "Pending confirmation" badge per entry.
  • New domain events (WaitingListInterestConfirmed,
    WaitingListPurgedForInactivity, WaitingListVerificationRequested) are
    logged to the activity log, consistent with every other state change in
    this codebase.

Why this approach

New joiners default to "interested" so they're never purged for a cycle they
were never asked about. The purge always happens before the reset within
the same run, so someone who just joined can't be caught by the same cycle
that resets everyone else. Full design rationale and alternatives considered
are in
docs/superpowers/specs/2026-08-10-waiting-list-interest-verification-design.md.

Testing

  • 609 tests passing (vendor/bin/pest), including dedicated coverage for the
    purge/reset ordering, the double-run guard, the elapsed-time purge-skip
    guard, and per-user notification dedup.
  • Manual end-to-end testing steps (docker-compose based, no local
    PHP/Node/MySQL required) are documented in
    docs/manual-testing-waiting-list-verification.md.

Deutsch

Zusammenfassung

Die Warteliste für Kurse (Rating, Endorsement, Familiarisation, Roster, Gast)
ist über die Zeit stark angewachsen, und ein nicht unerheblicher Teil der
Wartenden hat tatsächlich kein Interesse mehr an dem Trainingsplatz, auf den
sie warten. Dieser PR setzt den im oben verlinkten ATD-Policy-Update
angekündigten monatlichen Bestätigungsprozess um: Einmal im Monat muss jede
Person auf einer Warteliste ihr Interesse an ihrem Platz erneut bestätigen,
sonst wird sie automatisch von der Liste entfernt.

Dies ist eine direkte Umsetzung des technischen Vorschlags aus dem
verlinkten Forenthread:

  1. Für jeden Wartelistenplatz wird die Information gespeichert, ob noch
    Interesse besteht.
  2. Einmal im Monat (alle 2, 3, 6 Monate, wie auch immer), z. B. immer zum
  1. des Monats, wird eine Benachrichtigung mit der Frage, ob an der
    entsprechenden Warteliste noch Interesse besteht, verschickt.
  1. Alle, die nach dem einen Monat nicht für die Warteliste einen neuen
    „Interesse bestätigen" Button gedrückt haben, werden von der Warteliste
    entfernt.

Das löst 2 Probleme:
1.) Es ist möglich, auf mehreren Wartelisten zu stehen.
2.) Leute, die kein Interesse mehr haben, werden automatisch von der
Warteliste entfernt.

1:1 umgesetzt in dieser PR:

Vorschlag Umsetzung
1) Interesse pro Platz speichern waiting_list_entries.is_interested (pro Eintrag, nicht pro Nutzer — dadurch funktioniert das Stehen auf mehreren Wartelisten korrekt)
2) Monatliche Benachrichtigung pro Warteliste ProcessMonthlyWaitingListVerification, geplant über waitinglists:verify-interest; Einträge werden zurückgesetzt und Benachrichtigung verschickt
3) Entfernung nach einem Monat ohne Bestätigung Der Bereinigungsschritt entfernt jeden Eintrag, der seit dem vorherigen Zyklus noch unbestätigt ist
Stehen auf mehreren Wartelisten soll funktionieren Benachrichtigungen werden pro Nutzer über alle seine Einträge hinweg gebündelt — eine Benachrichtigung, nicht eine pro Warteliste, genau der im Beitrag angesprochene Punkt zum bestehenden Mehrfach-Wartelisten-Mechanismus

Was sich ändert

  • Datenmodell: waiting_list_entries erhält is_interested (boolean,
    Standardwert true) und interest_confirmed_at. Eine neue Tabelle
    waiting_list_verification_runs speichert, für welchen Kalendermonat der
    Zyklus bereits gelaufen ist, damit der geplante Job täglich ausgelöst
    werden kann, ohne die Wirkung innerhalb desselben Monats doppelt
    anzuwenden.
  • Monatlicher Zyklus (ProcessMonthlyWaitingListVerification, ausgeführt
    über den neuen artisan-Befehl waitinglists:verify-interest, täglich um
    06:00 Uhr geplant):
    1. Entfernt jeden Eintrag, der seit dem vorherigen Zyklus noch
      is_interested = false ist — also alle, die nicht rechtzeitig bestätigt
      haben.
    2. Setzt bei allen verbleibenden Einträgen is_interested auf false
      zurück und schickt eine Benachrichtigung mit der Bitte um erneute
      Bestätigung vor dem nächsten Zyklus.
    3. Speichert den Lauf, damit er nicht innerhalb desselben Kalendermonats
      wiederholt wird.
    • Der Bereinigungsschritt wird zusätzlich übersprungen, wenn der vorherige
      Lauf weniger als ca. 25 Tage zurückliegt — so kann nicht die gesamte
      Warteliste gelöscht werden, falls zwei Läufe kurz hintereinander über
      eine Monatsgrenze fallen (z. B. direkt nach dem Deployment dieses
      Features oder nach einem Ausfall des Schedulers), ohne dass jemand
      wirklich einen Monat Zeit zum Reagieren hatte.
    • Benachrichtigungen werden pro Nutzer gebündelt — wer auf mehreren
      Wartelisten steht, bekommt eine Benachrichtigung, nicht eine pro Eintrag.
  • Trainee-UI: Ein „Confirm you're still interested"-Button erscheint
    neben den bestehenden Warteliste-Steuerelementen, sobald eine Bestätigung
    aussteht.
  • Mentor-UI: Die Wartelisten-Verwaltungsansicht zeigt pro Eintrag ein
    Badge „Confirmed" / „Pending confirmation".
  • Neue Domain-Events (WaitingListInterestConfirmed,
    WaitingListPurgedForInactivity, WaitingListVerificationRequested)
    werden im Activity-Log protokolliert, konsistent mit jeder anderen
    Zustandsänderung in dieser Codebase.

Warum dieser Ansatz

Neue Wartelisten-Einträge starten standardmäßig als „interessiert", damit sie
nie für einen Zyklus entfernt werden, zu dem sie noch gar nicht befragt
wurden. Die Bereinigung läuft innerhalb eines Durchlaufs immer vor dem
Zurücksetzen, damit jemand, der gerade erst beigetreten ist, nicht vom
selben Zyklus erfasst werden kann, der alle anderen zurücksetzt. Die
vollständige Design-Begründung und geprüfte Alternativen stehen in
docs/superpowers/specs/2026-08-10-waiting-list-interest-verification-design.md.

Testing

  • 609 Tests laufen erfolgreich durch (vendor/bin/pest), inklusive
    dedizierter Abdeckung für die Reihenfolge von Bereinigung/Reset, die
    Doppellauf-Sperre, die zeitbasierte Bereinigungs-Sperre und die
    Zusammenfassung von Benachrichtigungen pro Nutzer.
  • Eine Schritt-für-Schritt-Anleitung für manuelles End-to-End-Testing (auf
    Docker-Compose-Basis, kein lokales PHP/Node/MySQL nötig) steht in
    docs/manual-testing-waiting-list-verification.md.

GeheimagentNr1 and others added 17 commits August 10, 2026 19:50
Documents the data model, domain actions/events, scheduled command,
notification flow, and frontend/testing plan for requiring trainees
to reconfirm waiting-list interest monthly, with automatic removal
for those who don't.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… failures

Fix 1: gate the purge on real elapsed time, not the calendar month. The
year_month guard alone let a run landing late in a month be followed by a
run on the 1st, purging everyone who had less than a day to reconfirm (and
the same collapse after scheduler downtime across a month boundary). The
purge now requires >= 25 days since the previous run's ran_at.

Fix 3: move notification HTTP sends out of the DB transaction. The actions
now collect per-user notification payloads inside the transaction and the
sends happen after commit, so a table-level write lock is not held open for
the whole fan-out and nothing is sent for a transaction that rolls back.

Fix 4: scope the reset to WaitingListEntry::where('is_interested', true)
instead of an unscoped mass update, so correctness no longer depends on
purgeUnconfirmed() having run first in the same cycle.

Fix 6: check sendNotification's success return value and log a warning on
failure. VatgerClient swallows its own exceptions and returns success=false,
so the surrounding try/catch never fired on a real failure.

Fix 5: the "one notification" test now asserts the actual sendNotification
call count via a Mockery mock, not just event dedup. It asserts one
"Removed from Waiting List" and one "Confirm Waiting List Interest" call
(the brief suggested a single ->once(), but this run legitimately sends
both notification types to the user).

Also adds tests covering both sides of the new purge guard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…cked

docker-compose.yml builds `dockerfile: Dockerfile.dev`, but only the compose
file and the manual-testing doc were committed. A fresh clone of this branch
could not run `docker compose up -d --build` — the exact first step the
manual-testing doc prescribes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ow null

Fix 7: explain why `artisan serve --no-reload` is required (its file-watcher
restart drops the container env overrides and falls back to .env's mysql
settings). Placed as a YAML comment above the `command:` key rather than
inside it — the folded (>) scalar collapses to a single shell line, so a
`#` there would comment out the rest of the command.

Fix 8: `waiting_list_interest_confirmed` is `boolean | null`, not `boolean` —
MentorManagementController::index() emits `$waitingEntry?->is_interested`,
which is null when the user has no entry for that course.

Fix 11: removed the stray untracked mentor-waiting-lists.tsx.bak backup file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… PHP

npm run dev requires php on its PATH (the Wayfinder Vite plugin shells out
to `php artisan wayfinder:generate` on every start), which broke `npm run
dev` on machines without PHP installed natively. Extend Dockerfile.dev with
Node/npm alongside PHP, give node_modules its own Docker volume (a Linux
container and a non-Linux host can't safely share native npm binaries), and
expose Vite's port. Update the manual-testing doc to run npm through the
container instead of the host.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docker-compose.yml previously published 8000/5173 on 0.0.0.0, exposing the
dev app (fake auth, no real credentials, but still) to the rest of the
local network. Bind to 127.0.0.1 instead — flagged by automated security
review of the prior commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
MentorManagementController::index() resolved the concrete MoodleClient
class directly (app(MoodleClient::class)) instead of the MoodleClientInterface
everything else in the codebase uses. That bypassed AppServiceProvider's
local/testing fake-client swap, so in local dev this made a real HTTP call
to an unconfigured Moodle API (empty VATGER_API_KEY/URL), which times out,
retries, and fails — always returning moodleSignedUp=false and permanently
blocking the /courses page behind the "Moodle Account Required" modal.

Switch to constructor-injected MoodleClientInterface, matching every other
integration usage in the codebase, so local dev correctly gets
FakeMoodleClient::userExists() => true.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@bencodes07 bencodes07 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot for the effort. I have left some comments. If ATD does come to the conclusion, that this might be a possibility - with the requested changes this would definitely be worth implementing.

Also if you could: Integrate the new is_interested attribute into the admin sections table and form.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I am in the process of trying to remove console commands completely, I would like this to rather be a laravel job

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced with a queued job (App\Jobs\ProcessWaitingListVerification), scheduled via $schedule->job() instead of $schedule->command() — matches your direction of moving away from console commands.

* followed by one on the 1st, or after scheduler downtime across a month
* boundary) skip the purge so people actually get a month to reconfirm.
*/
private const MIN_DAYS_BETWEEN_PURGES = 25;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be an option, to add to the env, config or admin panel instead of hardcoding it

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed. The whole guard is gone now that verification is per-entry (see below) rather than a monthly batch, so there's no "did this month already run" state left to protect. The remaining grace-period value is configurable via WAITING_LIST_INTEREST_CONFIRMATION_DAYS.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally I dont think all of this code belongs in a domain action tbh. Its a lot of logic for a single action. Either the individual logic parts can be split up or all be transferred into the laravel job.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewritten around a per-entry removal_date. Dropping the batch/run-tracking machinery shrank the action a lot on its own — it's now close to the size of CheckUserRosterStatus.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it might make more sense adding a removal_date to the waiting list entries just like already implemented for the roster entries. That logic seems simpler and doesnt complicate the database further. The data also gets removed with the waiting list entry automatically saving on database space

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, exactly as suggested — dropped the table/model and added removal_date directly to waiting_list_entries, mirroring RosterEntry. Simpler and avoids the extra table, as you said.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see WaitingListVerificationRun Model

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same change as above — table and migration removed since the model is gone.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dont think claudes documentation files are necessary :)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed from the PR.


{entry.is_interested ? (
<Badge className="bg-success-100 text-success-800 dark:bg-success-900 dark:text-success-300">
Confirmed

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might not be necessary to show a waiting list entry as confirmed. Should only really be important if its pending confirmation

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed — only "Pending confirmation" is shown now, since that's the state that actually needs attention.

Comment thread docker-compose.yml Outdated

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the reason to dockerise the test environment?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed from the PR — it was just a personal convenience for testing without local PHP/Node/MySQL, not needed for review, so kept out and gitignored locally instead.

GeheimagentNr1 and others added 11 commits August 14, 2026 19:25
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code
- Document WAITING_LIST_INTEREST_CONFIRMATION_DAYS in .env.example
  next to the other roster/waiting-list tuning env vars.
- Restore the dropped idempotency test: running
  ProcessMonthlyWaitingListVerification::execute() twice back-to-back
  must be a no-op, since a queue worker can redeliver the job.
- Lower ProcessWaitingListVerification's job timeout from 120s to 60s
  so it triggers before the production worker's --timeout=90 kills the
  job first, preserving the job's own error-logging catch block.
- Revert the .gitignore change that smuggled a repo-wide policy
  (ignoring docker-compose.yml, Dockerfile.dev, docs/) into this
  feature branch; move the equivalent, properly anchored patterns into
  the local-only .git/info/exclude instead.
- Drop stale "monthly" wording from the waiting-list form helper text
  now that the design isn't calendar-month-based.
@GeheimagentNr1

Copy link
Copy Markdown
Author

Added is_interested to the Filament admin Waiting Lists resource — a status icon column in the table and a read-only toggle on the edit form (disabled + dehydrated(false) so the job stays the only thing that can change it).

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.

2 participants