SD Radovljica
A custom member portal for a Slovenian shooting club — training registrations, competition management, season rankings, and iCal feeds, built as a bespoke WordPress plugin with fintech-grade audit trails.

Overview
Most sports clubs manage members in spreadsheets. SD Radovljica — a shooting club in Radovljica, Slovenia — wanted something better: a proper member portal where every registered member can browse the training calendar, sign up for sessions, enter competitions, and see their season standings, all within a secure, role-gated system.
I built it as a custom WordPress plugin — not a theme hack or a pile of shortcodes, but a first-class PSR-4 PHP package with a layered architecture (Models → Repositories → Services → API / Admin / Frontend), a full PHPUnit test suite with Brain Monkey and wp-phpunit, static analysis via PHPStan, and WordPress Coding Standards enforced in CI.
The public website is a GeneratePress child theme ported from approved HTML mockups. The differentiating work is the plugin: a self-contained member management system that would cost five figures as an off-the-shelf SaaS but runs inside a €10/month cPanel host — because every piece of it was built to exactly what the club needs.
Architecture
Reading the diagram: Five surfaces — the public GeneratePress website, the member-facing portal (shortcodes), the WordPress admin panel (7 management pages), iCal feeds, and the WP-Cron email pipeline — all talk to a single plugin core. Ten custom database tables handle the full data model: members are native WordPress users extended with four custom roles and 14 capabilities. Every write that matters — registration, result entry, slot assignment, cancellation — is logged to the audit table with IP address and user-agent.
The member portal in detail
The portal has four role tiers, each with explicit capabilities set at activation time. An SDR Member (SDR Član) can register for training sessions and competitions, view results, and see their own registrations. An SDR Competition Admin adds the ability to manage sessions, manage competitions, enter results, and assign slots. An SDR Board Member (SDR Član IO) gets read access to board documents — meeting minutes, assembly records, rules — which are invisible to ordinary members. Administrators have all 14 capabilities.
Training calendar. Sessions are stored in a custom table with discipline, event type (training, competition, dynamics session, visit, other), location, capacity, and optional recurrence (weekly, biweekly, monthly). Members register through a shortcode-rendered frontend; capacity enforcement and schedule conflict detection happen server-side. Registration timestamps are stored to microsecond precision (datetime(6)) — when a session fills at the exact same second, the earlier microsecond wins, giving a deterministic and fair queue ordering without application-level locks.
Competition management. Competitions have an explicit lifecycle: draft → registration open → registration closed → in progress → completed → cancelled. Members submit up to three time-slot preferences; admins assign a confirmed slot and lane number through an AJAX admin interface. Status transitions — pending, confirmed, DNS, DNF — track every stage of a member’s competition journey.
Season rankings. Every competition result carries a score, category (junior / senior / veteran), and an is_ranked flag. A configurable best-of-N rule (default: best 5 of 10 per season) drives the Ranking_Calculator. Instead of a cron job that recomputes everything on a schedule, rankings rebuild synchronously the moment a result is saved — the sdr_result_saved action fires inside the repository, triggers the calculator scoped to that (discipline_id, season_year) pair, and the leaderboard is current before the HTTP response returns. Dense-rank tie-breaking (1,1,3 rather than 1,2,3) matches how shooting results are conventionally presented.
iCal integration. The plugin generates RFC 5545-compliant .ics feeds — one public feed per discipline or event type, and one personal feed per member (their own registrations only). Personal feeds are protected by a 32-character token stored in user meta; the URL pattern is /sdr-calendar/personal/<user_id>/<token>/. Members subscribe once from Google Calendar or Apple Calendar and stay in sync automatically.
Email automation. Three automated paths: registration confirmation on sign-up (training and competition, separately togglable), results-published notification, and a daily WP-Cron reminder that fires at 08:00 and emails everyone registered for a session in the next 23–25 hours. The reminder uses a PHP template at templates/email/reminder-training.php; each send — success or failure — writes an audit-log row so the board can see delivery at a glance.
Document library. Board documents (meeting minutes, assembly records, rulebooks, forms) are stored with three access tiers: public, members, and board. The document type taxonomy maps cleanly to how Slovenian sports clubs are legally required to maintain their records.
GDPR. Slovenia is EU. The plugin registers with WordPress’s native privacy tooling — a named exporter and a named eraser under Tools → Export / Erase Personal Data. The eraser deletes registrations but anonymizes result rows: user_id is set to NULL (schema-level nullable since DB version 1.1.0), scores and timestamps are preserved, so a member’s erasure request doesn’t punch holes in the club’s historical leaderboards.
Quality baseline. The codebase runs PHPStan at level 5 with the szepeviktor/phpstan-wordpress plugin, PHPCS against WordPress Coding Standards, and a hybrid PHPUnit suite — Brain Monkey for pure unit tests of Services and Models (no WordPress bootstrap required), wp-phpunit for integration tests of repositories, REST controllers, and shortcodes. Every CI push runs all three tools.
Why this matters
This is the kind of project that illustrates what “T4 Care” actually means in practice. The club has maybe sixty active members. Nobody would notice if the registration timestamps were second-precision instead of microsecond-precision. Nobody would notice if GDPR erasure just deleted the result rows. Nobody would notice if there were no audit log.
I built it with those details anyway — because the sixty members are real people, because Slovenian GDPR enforcement is real, and because the club secretary deserves infrastructure that won’t embarrass them in two years. The scale is a fraction of a fintech platform. The care isn’t.
Six things shipped,
three hard ones solved.
Key contributions
- Designed and built the entire custom WordPress plugin from scratch — PSR-4 OOP architecture across Models, Repositories, Services, API, Admin, and Frontend layers.
- Implemented a four-tier role and capability system (member, competition admin, board member, administrator) with 14 granular permissions.
- Built training session management with recurring events, capacity caps, conflict detection, and microsecond-precision registration timestamps to resolve simultaneous signups fairly.
- Built competition management with multi-day slot allocation, lane assignment, and a three-preference slot system so members influence their own schedule.
- Implemented a season ranking engine: dense-rank algorithm per discipline, configurable best-of-N scoring rules, event-driven recalculation on every result save.
- Built iCal feed generation (public + per-member token-authenticated personal feed) so members sync the club calendar directly to Google Calendar or Apple Calendar.
- Wired a daily WP-Cron reminder pipeline that sends 08:00 email alerts for next-day sessions — with audit-log entries per send and graceful failure isolation.
- Integrated WordPress's native GDPR exporter and eraser — registrations deleted on request, competition results anonymized rather than deleted so historical rankings stay intact.
- Set up a full PHPUnit test suite (Brain Monkey for unit, wp-phpunit for integration) with PHPStan static analysis and PHPCS (WordPress Coding Standards) in CI.
Challenges solved
- Simultaneous registration races — solved with microsecond-precision
datetime(6)timestamps and a unique constraint on(session_id, user_id), giving a deterministic queue order without application-level locks. - GDPR vs. historical integrity tension — competition results needed to survive erasure requests without corrupting season rankings; solved by anonymizing the
user_idcolumn to NULL rather than deleting the score row. - Event-driven ranking recalculation — dropping the cron-based approach in favour of a synchronous
sdr_result_savedaction hook that rebuilds the ranking cache per(discipline, season)immediately on result insert or update.
A small club's backend deserves the same rigour as a fintech platform. Audit trails, GDPR erasure, race-safe registrations — the scale is different, but the club members are just as real.
What's under the hood.
Pripravljeni popraviti, zgraditi
ali skalirati?
30 minut, z mano osebno. Preberem vaš sistem kot dnevniško datoteko in povem, kaj bi naredil najprej. Brez prezentacij, brez prodajnega lijaka.
— Davor Majc, ustanovitelj, Numen