From e3ee7ee55dd525f489fd71f07439d981ac2d975e Mon Sep 17 00:00:00 2001 From: bernardhanna Date: Wed, 16 Sep 2026 16:48:33 +0100 Subject: [PATCH 1/7] Alert on a backed-up queue instead of nothing queue:monitor was not scheduled and no listener for Laravel's QueueBusy event existed, so a stalled worker during October was noticed by a human wondering why activities had stopped appearing. All three pieces are needed: the scheduled check, the event, and a listener. The threshold is QUEUE_BUSY_THRESHOLD so a noisy alert can be tuned rather than switched off. Co-authored-by: Cursor --- app/Listeners/AlertOnBusyQueue.php | 30 ++++++++++++++++++++++++++++ config/codeweek.php | 4 ++++ routes/console.php | 6 ++++++ tests/Feature/BusyQueueAlertTest.php | 21 +++++++++++++++++++ 4 files changed, 61 insertions(+) create mode 100644 app/Listeners/AlertOnBusyQueue.php create mode 100644 tests/Feature/BusyQueueAlertTest.php diff --git a/app/Listeners/AlertOnBusyQueue.php b/app/Listeners/AlertOnBusyQueue.php new file mode 100644 index 000000000..943a15712 --- /dev/null +++ b/app/Listeners/AlertOnBusyQueue.php @@ -0,0 +1,30 @@ +queue, + $event->connection, + $event->size + ); + + Log::error($message); + + \Sentry\captureMessage($message); + } +} diff --git a/config/codeweek.php b/config/codeweek.php index 834baebcd..59cee0f33 100644 --- a/config/codeweek.php +++ b/config/codeweek.php @@ -24,4 +24,8 @@ 'CONTACT_FORM_RECIPIENT_EMAIL', env('ADMIN_EMAIL', 'admin@codeweek.test') ), + + // Pending-job count above which queue:monitor fires QueueBusy. Raise it if + // October traffic makes the alert noisy rather than switching the alert off. + 'queue_busy_threshold' => (int) env('QUEUE_BUSY_THRESHOLD', 100), ]; diff --git a/routes/console.php b/routes/console.php index 124f079b1..bb36783cf 100644 --- a/routes/console.php +++ b/routes/console.php @@ -43,6 +43,12 @@ Schedule::command('events:generate-recurring')->dailyAt('01:00'); +// Fires Laravel's QueueBusy event when the backlog exceeds the threshold, which +// App\Listeners\AlertOnBusyQueue turns into a log line and a Sentry message. +// Nothing else watches the queue, so removing this makes a stalled worker silent. +Schedule::command('queue:monitor default --max='.config('codeweek.queue_busy_threshold')) + ->everyFiveMinutes(); + // Support Gmail copilot: ingest tickets by subject (codeweek-support), run dry-run, email for APPROVE. $supportGmailPoll = Schedule::command('support:gmail:poll --max=10') ->when(fn () => (bool) config('support_gmail.enabled')); diff --git a/tests/Feature/BusyQueueAlertTest.php b/tests/Feature/BusyQueueAlertTest.php new file mode 100644 index 000000000..e03155565 --- /dev/null +++ b/tests/Feature/BusyQueueAlertTest.php @@ -0,0 +1,21 @@ +once() + ->with('Queue [default] on connection [redis] has 250 pending jobs.'); + + event(new QueueBusy('redis', 'default', 250)); + } +} From bb2b103c6dc58568c8616417d481b2171f4f5c83 Mon Sep 17 00:00:00 2001 From: bernardhanna Date: Wed, 16 Sep 2026 16:48:33 +0100 Subject: [PATCH 2/7] Replace the hardcoded certificate admin email with an env allowlist Access to /admin/certificate-backend/* was gated on one hardcoded personal email address, in a public repository, with no role granting access. A new super admin got a 403 and could not operate certificates at all. It now reads a comma-separated CERTIFICATE_ADMIN_EMAILS allowlist and fails closed, saying so in the 403 so the cause is obvious. The profile dropdown link uses the same list rather than its own copy of the address. Co-authored-by: Cursor --- .env.example | 5 ++ .../EnsureSuperCertificateAdmin.php | 17 ++++-- config/codeweek.php | 8 +++ .../layout/menu-profile-dropdown.blade.php | 2 +- routes/web.php | 7 +-- .../Feature/CertificateBackendAccessTest.php | 60 +++++++++++++++++++ 6 files changed, 89 insertions(+), 10 deletions(-) create mode 100644 tests/Feature/CertificateBackendAccessTest.php diff --git a/.env.example b/.env.example index 7ec0a7a8a..e8bd362d1 100644 --- a/.env.example +++ b/.env.example @@ -106,8 +106,13 @@ IPAPI_KEY= # --------------------------------------------------------------------------- # Certificates. PDFLATEX_PATH must point at a real pdflatex binary or the # whole certificate pipeline fails. See docs/handover/08-certificates.md +# +# CERTIFICATE_ADMIN_EMAILS is a comma-separated allowlist for +# /admin/certificate-backend/*. No role grants access, so if this is blank +# the certificate backend is closed to everyone. # --------------------------------------------------------------------------- PDFLATEX_PATH= +CERTIFICATE_ADMIN_EMAILS= # --------------------------------------------------------------------------- # Storage. RESOURCES_BUCKET is a second bucket for Learn & Teach PDFs, and is diff --git a/app/Http/Middleware/EnsureSuperCertificateAdmin.php b/app/Http/Middleware/EnsureSuperCertificateAdmin.php index e707a43f6..2a900e5b7 100644 --- a/app/Http/Middleware/EnsureSuperCertificateAdmin.php +++ b/app/Http/Middleware/EnsureSuperCertificateAdmin.php @@ -4,18 +4,27 @@ use Closure; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Log; use Symfony\Component\HttpFoundation\Response; class EnsureSuperCertificateAdmin { - private const ALLOWED_EMAIL = 'bernard@matrixinternet.ie'; - /** - * Handle an incoming request. Only the super certificate admin email can access. + * Handle an incoming request. Only a configured certificate admin can access. */ public function handle(Request $request, Closure $next): Response { - if (! $request->user() || $request->user()->email !== self::ALLOWED_EMAIL) { + $allowed = config('codeweek.certificate_admin_emails', []); + + if (empty($allowed)) { + Log::warning('Certificate backend access denied: CERTIFICATE_ADMIN_EMAILS is not set.'); + + abort(403, 'The certificate administrator list is not configured. Set CERTIFICATE_ADMIN_EMAILS.'); + } + + $email = $request->user()?->email; + + if ($email === null || ! in_array(strtolower($email), array_map('strtolower', $allowed), true)) { abort(403, 'Access denied. This area is restricted to the certificate administrator.'); } diff --git a/config/codeweek.php b/config/codeweek.php index 59cee0f33..29a8808ff 100644 --- a/config/codeweek.php +++ b/config/codeweek.php @@ -28,4 +28,12 @@ // Pending-job count above which queue:monitor fires QueueBusy. Raise it if // October traffic makes the alert noisy rather than switching the alert off. 'queue_busy_threshold' => (int) env('QUEUE_BUSY_THRESHOLD', 100), + + // Who may use /admin/certificate-backend/*. Comma-separated list of email + // addresses; no role grants access. This used to be a single hardcoded + // address in the middleware, which locked out everyone else. + 'certificate_admin_emails' => array_values(array_filter(array_map( + 'trim', + explode(',', (string) env('CERTIFICATE_ADMIN_EMAILS', '')) + ))), ]; diff --git a/resources/views/layout/menu-profile-dropdown.blade.php b/resources/views/layout/menu-profile-dropdown.blade.php index b0aac6e3e..49ae5fe8b 100644 --- a/resources/views/layout/menu-profile-dropdown.blade.php +++ b/resources/views/layout/menu-profile-dropdown.blade.php @@ -81,7 +81,7 @@ @endrole -@if(auth()->user()->email === 'bernard@matrixinternet.ie') +@if(in_array(strtolower((string) auth()->user()->email), array_map('strtolower', config('codeweek.certificate_admin_emails', [])), true))
  • diff --git a/routes/web.php b/routes/web.php index 67abc885d..75d54ff3c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -252,10 +252,6 @@ ->name('grassroots-grants.preview'); //Static training pages Route::get('/training', [TrainingController::class, 'index'])->name('training.index'); -Route::get( - '/training/cody-color-kit', - [StaticPageController::class, 'static'] -)->name('training.module-0'); Route::get( '/training/coding-without-computers', [StaticPageController::class, 'static'] @@ -685,7 +681,8 @@ }); -// Certificate backend: Excellence & Super Organiser cert generation/sending (bernard@matrixinternet.ie only) +// Certificate backend: Excellence & Super Organiser cert generation/sending. +// Access is the CERTIFICATE_ADMIN_EMAILS allowlist, not a role - see EnsureSuperCertificateAdmin. Route::middleware(['auth', 'super.certificate.admin'])->prefix('admin/certificate-backend')->name('certificate_backend.')->group(function () { Route::get('/', [CertificateBackendController::class, 'index'])->name('index'); Route::get('/list', [CertificateBackendController::class, 'listRecipients'])->name('list'); diff --git a/tests/Feature/CertificateBackendAccessTest.php b/tests/Feature/CertificateBackendAccessTest.php new file mode 100644 index 000000000..d9bd33994 --- /dev/null +++ b/tests/Feature/CertificateBackendAccessTest.php @@ -0,0 +1,60 @@ +get(route('certificate_backend.index')) + ->assertRedirect(route('login')); + } + + #[Test] + public function access_is_closed_to_everyone_when_the_allowlist_is_empty(): void + { + config(['codeweek.certificate_admin_emails' => []]); + + $this->signIn(User::factory()->create(['email' => 'someone@example.com'])); + + $this->get(route('certificate_backend.index'))->assertForbidden(); + } + + #[Test] + public function a_user_not_on_the_allowlist_is_forbidden(): void + { + config(['codeweek.certificate_admin_emails' => ['allowed@example.com']]); + + $this->signIn(User::factory()->create(['email' => 'other@example.com'])); + + $this->get(route('certificate_backend.index'))->assertForbidden(); + } + + #[Test] + public function a_user_on_the_allowlist_is_let_through(): void + { + config(['codeweek.certificate_admin_emails' => ['allowed@example.com', 'second@example.com']]); + + $this->signIn(User::factory()->create(['email' => 'second@example.com'])); + + $this->get(route('certificate_backend.index'))->assertOk(); + } + + #[Test] + public function the_allowlist_is_case_insensitive(): void + { + config(['codeweek.certificate_admin_emails' => ['Allowed@Example.com']]); + + $this->signIn(User::factory()->create(['email' => 'allowed@example.com'])); + + $this->get(route('certificate_backend.index'))->assertOk(); + } +} From e47a98862711822bd9a456640abf1c83b0b143b3 Mon Sep 17 00:00:00 2001 From: bernardhanna Date: Wed, 16 Sep 2026 16:48:45 +0100 Subject: [PATCH 3/7] Stop swallowing the authorization failure on activity rejection EventController@reject called $this->authorize() inside a try with an empty catch, so the country check was defeated and any ambassador could reject any country's activities - while the identical check on approve was enforced. Also gives the Nova reject action a required reason field. It called reject() with no argument, writing an empty moderation message and emailing the organiser a rejection with no explanation, unlike /pending and /review. Co-authored-by: Cursor --- app/Http/Controllers/EventController.php | 6 +--- app/Nova/Actions/RejectEvent.php | 9 ++++-- tests/Feature/RejectEventTest.php | 35 ++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/app/Http/Controllers/EventController.php b/app/Http/Controllers/EventController.php index aed1cd11e..ccc7fe76a 100755 --- a/app/Http/Controllers/EventController.php +++ b/app/Http/Controllers/EventController.php @@ -7,7 +7,6 @@ use App\Queries\EventsQuery; use App\Queries\PendingEventsQuery; use App\User; -use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -231,10 +230,7 @@ public function reject(Request $request, Event $event) { $rejectionText = $request->get('rejectionText', null); - try { - $this->authorize('approve', $event); - } catch (AuthorizationException $e) { - } + $this->authorize('approve', $event); $event->reject($rejectionText); } diff --git a/app/Nova/Actions/RejectEvent.php b/app/Nova/Actions/RejectEvent.php index 9dad8ee95..d04263476 100644 --- a/app/Nova/Actions/RejectEvent.php +++ b/app/Nova/Actions/RejectEvent.php @@ -8,6 +8,7 @@ use Illuminate\Support\Collection; use Laravel\Nova\Actions\Action; use Laravel\Nova\Fields\ActionFields; +use Laravel\Nova\Fields\Textarea; use Laravel\Nova\Http\Requests\NovaRequest; class RejectEvent extends Action @@ -22,7 +23,7 @@ class RejectEvent extends Action public function handle(ActionFields $fields, Collection $models) { foreach ($models as $model) { - $model->reject(); + $model->reject($fields->rejectionText); } } @@ -31,6 +32,10 @@ public function handle(ActionFields $fields, Collection $models) */ public function fields(NovaRequest $request): array { - return []; + return [ + Textarea::make('Rejection reason', 'rejectionText') + ->rules('required', 'string', 'max:2000') + ->help('Sent to the organiser in the rejection email and stored on the activity.'), + ]; } } diff --git a/tests/Feature/RejectEventTest.php b/tests/Feature/RejectEventTest.php index 0788a8897..a3661795f 100644 --- a/tests/Feature/RejectEventTest.php +++ b/tests/Feature/RejectEventTest.php @@ -44,6 +44,41 @@ public function event_can_be_rejected_by_admin(): void } + #[Test] + public function ambassador_can_reject_an_event_in_their_own_country(): void + { + $this->withExceptionHandling(); + + $ambassador = \App\User::factory()->create(['country_iso' => 'BE']); + $ambassador->assignRole('ambassador'); + + $this->signIn($ambassador); + + $event = \App\Event::factory()->create(['status' => 'PENDING', 'country_iso' => 'BE']); + + $this->post(route('event.reject', $event))->assertOk(); + + $this->assertEquals('REJECTED', $event->fresh()->status); + } + + #[Test] + public function ambassador_cannot_reject_an_event_in_another_country(): void + { + $this->withExceptionHandling(); + + $ambassador = \App\User::factory()->create(['country_iso' => 'BE']); + $ambassador->assignRole('ambassador'); + + $this->signIn($ambassador); + + $event = \App\Event::factory()->create(['status' => 'PENDING', 'country_iso' => 'FR']); + + $this->post(route('event.reject', $event))->assertForbidden(); + + $this->assertEquals('PENDING', $event->fresh()->status); + $this->assertCount(0, $event->moderations()->get()); + } + #[Test] public function email_should_be_sent_to_event_email_when_event_is_rejected(): void { From 2eba32399c4b86d68d594fcb28effeb9799d91c1 Mon Sep 17 00:00:00 2001 From: bernardhanna Date: Wed, 16 Sep 2026 16:48:45 +0100 Subject: [PATCH 4/7] Guard users.approved from mass assignment and drop fixed secrets approved controls whether a leading teacher is listed publicly, so it was the one genuine escalation path through the wide-open $guarded. The only place that mass-assigned it now sets it explicitly. Note that id must stay assignable: the legacy placeholder user relies on mass-assigning 1000000. That same legacy-user creation used a hardcoded remember_token and a time-seeded password; both are random now. Also takes a personal address out of two support test fixtures. Co-authored-by: Cursor --- .../Commands/SoftDeleteUsersWithoutConsent.php | 14 ++++++++++---- app/User.php | 4 +++- tests/Unit/Support/GmailIngestServiceTest.php | 2 +- .../Support/SupportProfileRequestParserTest.php | 8 ++++---- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/app/Console/Commands/SoftDeleteUsersWithoutConsent.php b/app/Console/Commands/SoftDeleteUsersWithoutConsent.php index 3580f03e5..4a91e03d7 100644 --- a/app/Console/Commands/SoftDeleteUsersWithoutConsent.php +++ b/app/Console/Commands/SoftDeleteUsersWithoutConsent.php @@ -6,6 +6,7 @@ use App\User; use Illuminate\Console\Command; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Str; class SoftDeleteUsersWithoutConsent extends Command { @@ -17,20 +18,19 @@ public function handle() $this->info('Creating or verifying legacy user...'); // Create legacy user if it doesn't exist - User::firstOrCreate( + $legacyUser = User::firstOrCreate( ['id' => 1000000], [ 'firstname' => 'Codeweek Legacy User', 'lastname' => 'Codeweek Legacy', 'username' => 'codeweek-legacy', - 'password' => Hash::make('some-secure-password-' . time()), + 'password' => Hash::make(Str::random(40)), 'email' => 'legacy@codeweek.eu', 'country_iso' => 'BE', - 'remember_token' => '5Pb4APP0CkOmQFzDtR960OlkdreLev2tvfUiywDawyJOmKDvFb3bmOAWlUL6', + 'remember_token' => Str::random(60), 'privacy' => 1, 'email_display' => 'legacy@codeweek.eu', 'receive_emails' => 0, - 'approved' => 1, 'magic_key' => 2520090911, 'consent_given_at' => now(), 'future_consent_given_at' => now(), @@ -40,6 +40,12 @@ public function handle() ] ); + // 'approved' is guarded against mass assignment, so set it explicitly. + if (! $legacyUser->approved) { + $legacyUser->approved = 1; + $legacyUser->save(); + } + $this->info('Starting to process users without consent...'); // Get users without consent in chunks to avoid memory issues diff --git a/app/User.php b/app/User.php index 8292554a4..ae9913e2f 100644 --- a/app/User.php +++ b/app/User.php @@ -107,7 +107,9 @@ class User extends Authenticatable implements MustVerifyEmail */ - protected $guarded = []; + // 'approved' controls whether a leading teacher is listed publicly, so it must + // never be settable from request data. Set it explicitly instead of mass-assigning. + protected $guarded = ['approved']; /** * The attributes that should be hidden for arrays. diff --git a/tests/Unit/Support/GmailIngestServiceTest.php b/tests/Unit/Support/GmailIngestServiceTest.php index 89540781c..30a85e311 100644 --- a/tests/Unit/Support/GmailIngestServiceTest.php +++ b/tests/Unit/Support/GmailIngestServiceTest.php @@ -177,7 +177,7 @@ public function fetchNewMessages( 'approve1', 't-approve', 'Re: [CW-SUPPORT #20] Support copilot - dry run review', - 'bernard@matrixinternet.ie', + 'support@matrixinternet.ie', "APPROVE\n", ), ], diff --git a/tests/Unit/Support/SupportProfileRequestParserTest.php b/tests/Unit/Support/SupportProfileRequestParserTest.php index 8d864754c..f4334a4fb 100644 --- a/tests/Unit/Support/SupportProfileRequestParserTest.php +++ b/tests/Unit/Support/SupportProfileRequestParserTest.php @@ -10,7 +10,7 @@ final class SupportProfileRequestParserTest extends TestCase public function test_parses_labelled_profile_fields(): void { $text = <<<'TEXT' - Email: bernard@matrixinternet.ie + Email: organiser@example.com Current first name: Bernard Hanna Current last name: Last Name Requested first name: Bernard @@ -19,7 +19,7 @@ public function test_parses_labelled_profile_fields(): void $parsed = (new SupportProfileRequestParser())->parse($text); - $this->assertSame('bernard@matrixinternet.ie', $parsed['email']); + $this->assertSame('organiser@example.com', $parsed['email']); $this->assertSame('Bernard', $parsed['firstname']); $this->assertSame('Hanna', $parsed['lastname']); } @@ -53,7 +53,7 @@ public function test_parses_current_and_requested_names(): void public function test_parses_hanna_to_hannaa_request_without_bleeding_lines(): void { $text = <<<'TEXT' - Email: bernard@matrixinternet.ie + Email: organiser@example.com Current first name: Bernard Current last name: Hanna @@ -66,7 +66,7 @@ public function test_parses_hanna_to_hannaa_request_without_bleeding_lines(): vo $parsed = (new SupportProfileRequestParser())->parse($text); - $this->assertSame('bernard@matrixinternet.ie', $parsed['email']); + $this->assertSame('organiser@example.com', $parsed['email']); $this->assertSame('Bernard', $parsed['firstname']); $this->assertSame('Hannaa', $parsed['lastname']); } From dd8205be4e55b35847d812d9cf8abba38fbded8e Mon Sep 17 00:00:00 2001 From: bernardhanna Date: Wed, 16 Sep 2026 16:48:45 +0100 Subject: [PATCH 5/7] Tell leading teachers why they are missing from the community map The community map groups teachers by city_id and skips any group whose city has no coordinates, so a teacher without a city was rendered nowhere at all - present in the query, absent from the page, with nothing explaining why. This is the most common support question about the community page. Their profile now warns them, and the leading-teachers admin list gains a City column and a 'Not set' filter so an admin can find and chase them. Also makes country_iso required on profile update, which the form already marked as required, and fixes the error block beneath it: it was bound to 'country' rather than 'country_iso', so the message could never appear. Co-authored-by: Cursor --- app/Http/Controllers/UserController.php | 2 +- app/Livewire/LeadingTeachersTable.php | 22 ++++++++ resources/lang/al/base.php | 1 + resources/lang/ba/base.php | 1 + resources/lang/bg/base.php | 1 + resources/lang/cs/base.php | 1 + resources/lang/da/base.php | 1 + resources/lang/de/base.php | 1 + resources/lang/el/base.php | 1 + resources/lang/en/base.php | 1 + resources/lang/es/base.php | 1 + resources/lang/et/base.php | 1 + resources/lang/fi/base.php | 1 + resources/lang/fr/base.php | 1 + resources/lang/hr/base.php | 1 + resources/lang/hu/base.php | 1 + resources/lang/it/base.php | 1 + resources/lang/lt/base.php | 1 + resources/lang/lv/base.php | 1 + resources/lang/me/base.php | 1 + resources/lang/mk/base.php | 1 + resources/lang/mt/base.php | 1 + resources/lang/nl/base.php | 1 + resources/lang/pl/base.php | 1 + resources/lang/pt/base.php | 1 + resources/lang/ro/base.php | 1 + resources/lang/rs/base.php | 1 + resources/lang/sk/base.php | 1 + resources/lang/sl/base.php | 1 + resources/lang/sv/base.php | 1 + resources/lang/tr/base.php | 1 + resources/lang/ua/base.php | 1 + resources/views/profile.blade.php | 7 ++- tests/Feature/ProfileCityNudgeTest.php | 70 +++++++++++++++++++++++++ tests/Feature/UpdateUserTest.php | 25 ++++++++- tests/Feature/UserEmailChangeTest.php | 6 ++- 36 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 tests/Feature/ProfileCityNudgeTest.php diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 8d2c70315..afd93e358 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -23,7 +23,7 @@ public function update() 'lastname' => 'required|string', 'privacy' => 'required', 'receive_emails' => 'required', - 'country_iso' => 'nullable|exists:countries,iso', + 'country_iso' => 'required|exists:countries,iso', 'city_id' => 'nullable|exists:cities,id', 'twitter' => 'nullable', 'website' => 'nullable', diff --git a/app/Livewire/LeadingTeachersTable.php b/app/Livewire/LeadingTeachersTable.php index f50664d5f..d3f9e7895 100644 --- a/app/Livewire/LeadingTeachersTable.php +++ b/app/Livewire/LeadingTeachersTable.php @@ -46,6 +46,25 @@ public function filters(): array } }), + SelectFilter::make('City') + ->setFilterPillTitle('City') + ->setFilterPillValues([ + 'missing' => 'Not set', + 'set' => 'Set', + ]) + ->options([ + '' => 'All', + 'missing' => 'Not set', + 'set' => 'Set', + ]) + ->filter(function(Builder $builder, string $value) { + if ($value === 'missing') { + $builder->whereNull('city_id'); + } elseif ($value === 'set') { + $builder->whereNotNull('city_id'); + } + }), + ]; } @@ -101,6 +120,9 @@ public function columns(): array ->searchable(), Column::make('Country','country_iso') ->sortable(), + Column::make('City', 'city.city') + ->sortable() + ->searchable(), Column::make('email') ->sortable() ->searchable(), diff --git a/resources/lang/al/base.php b/resources/lang/al/base.php index 97a2ead85..92db7323c 100644 --- a/resources/lang/al/base.php +++ b/resources/lang/al/base.php @@ -69,4 +69,5 @@ 'bring_codeweek_to_your_students' => 'Sillni Code Week te studentët tuaj', 'or' => 'ose', 'newsletter' => '', + 'city_required_for_community_map' => 'Select your city so that you appear on the community map. Leading teachers without a city are not shown there.', ]; diff --git a/resources/lang/ba/base.php b/resources/lang/ba/base.php index b7a01843b..86b6e6352 100644 --- a/resources/lang/ba/base.php +++ b/resources/lang/ba/base.php @@ -68,4 +68,5 @@ 'bring_codeweek_to_your_students' => 'Približite Sedmicu kodiranja svojim učenicima', 'or' => 'ili', 'newsletter' => 'Novosti', + 'city_required_for_community_map' => 'Select your city so that you appear on the community map. Leading teachers without a city are not shown there.', ]; diff --git a/resources/lang/bg/base.php b/resources/lang/bg/base.php index c4d90ec25..8fab75840 100644 --- a/resources/lang/bg/base.php +++ b/resources/lang/bg/base.php @@ -69,4 +69,5 @@ 'bring_codeweek_to_your_students' => 'Представете Седмицата на програмирането на своите ученици', 'or' => 'или', 'newsletter' => 'Бюлетин', + 'city_required_for_community_map' => 'Select your city so that you appear on the community map. Leading teachers without a city are not shown there.', ]; diff --git a/resources/lang/cs/base.php b/resources/lang/cs/base.php index 92bb3f0d8..f277ed8c6 100644 --- a/resources/lang/cs/base.php +++ b/resources/lang/cs/base.php @@ -67,4 +67,5 @@ 'bring_codeweek_to_your_students' => 'Přibližte Týden programování svým studentům', 'or' => 'nebo', 'newsletter' => 'Newsletter', + 'city_required_for_community_map' => 'Select your city so that you appear on the community map. Leading teachers without a city are not shown there.', ]; diff --git a/resources/lang/da/base.php b/resources/lang/da/base.php index 1866c9fcd..57b6529ae 100644 --- a/resources/lang/da/base.php +++ b/resources/lang/da/base.php @@ -67,4 +67,5 @@ 'bring_codeweek_to_your_students' => 'Lad kodeugen komme til jeres elever', 'or' => 'eller', 'newsletter' => 'Nyhedsbrev', + 'city_required_for_community_map' => 'Select your city so that you appear on the community map. Leading teachers without a city are not shown there.', ]; diff --git a/resources/lang/de/base.php b/resources/lang/de/base.php index f66f25720..02ee91643 100644 --- a/resources/lang/de/base.php +++ b/resources/lang/de/base.php @@ -67,4 +67,5 @@ 'bring_codeweek_to_your_students' => 'Bringen Sie Ihre Schülerinnen und Schüler zur Code Week', 'or' => 'oder', 'newsletter' => 'Newsletter', + 'city_required_for_community_map' => 'Select your city so that you appear on the community map. Leading teachers without a city are not shown there.', ]; diff --git a/resources/lang/el/base.php b/resources/lang/el/base.php index 2feb5c9c0..f4d046cfd 100644 --- a/resources/lang/el/base.php +++ b/resources/lang/el/base.php @@ -70,4 +70,5 @@ 'bring_codeweek_to_your_students' => 'Μίλησε στους μαθητές για την εβδομάδα προγραμματισμού', 'or' => 'ή', 'newsletter' => 'Ενημερωτικό Δελτίο', + 'city_required_for_community_map' => 'Select your city so that you appear on the community map. Leading teachers without a city are not shown there.', ]; diff --git a/resources/lang/en/base.php b/resources/lang/en/base.php index 5e3b2cb08..6e23b67e1 100644 --- a/resources/lang/en/base.php +++ b/resources/lang/en/base.php @@ -114,4 +114,5 @@ 'receive emails' => 'Receive our emails', 'newsletter' => 'Newsletter', + 'city_required_for_community_map' => 'Select your city so that you appear on the community map. Leading teachers without a city are not shown there.', ]; diff --git a/resources/lang/es/base.php b/resources/lang/es/base.php index ee179ca61..ebb8012ff 100644 --- a/resources/lang/es/base.php +++ b/resources/lang/es/base.php @@ -67,4 +67,5 @@ 'bring_codeweek_to_your_students' => 'Lleva la Semana de la Programación a tus estudiantes', 'or' => 'o', 'newsletter' => 'Boletín', + 'city_required_for_community_map' => 'Select your city so that you appear on the community map. Leading teachers without a city are not shown there.', ]; diff --git a/resources/lang/et/base.php b/resources/lang/et/base.php index 995ecb7e9..bfff6cefe 100644 --- a/resources/lang/et/base.php +++ b/resources/lang/et/base.php @@ -67,4 +67,5 @@ 'bring_codeweek_to_your_students' => 'Viige Code Week oma õpilasteni', 'or' => 'või', 'newsletter' => 'Uudiskiri', + 'city_required_for_community_map' => 'Select your city so that you appear on the community map. Leading teachers without a city are not shown there.', ]; diff --git a/resources/lang/fi/base.php b/resources/lang/fi/base.php index 6301e8dfb..ecf1157b3 100644 --- a/resources/lang/fi/base.php +++ b/resources/lang/fi/base.php @@ -66,4 +66,5 @@ 'bring_codeweek_to_your_students' => 'Ota oppilaat mukaan koodausviikkoon', 'or' => 'tai', 'newsletter' => 'Uutiskirje', + 'city_required_for_community_map' => 'Select your city so that you appear on the community map. Leading teachers without a city are not shown there.', ]; diff --git a/resources/lang/fr/base.php b/resources/lang/fr/base.php index 17f1e5121..b06d4fd8e 100644 --- a/resources/lang/fr/base.php +++ b/resources/lang/fr/base.php @@ -68,4 +68,5 @@ 'bring_codeweek_to_your_students' => 'Faites découvrir la Semaine du code à vos élèves', 'or' => 'ou', 'newsletter' => 'Newsletter', + 'city_required_for_community_map' => 'Select your city so that you appear on the community map. Leading teachers without a city are not shown there.', ]; diff --git a/resources/lang/hr/base.php b/resources/lang/hr/base.php index 7aba847e0..642414a8f 100644 --- a/resources/lang/hr/base.php +++ b/resources/lang/hr/base.php @@ -67,4 +67,5 @@ 'bring_codeweek_to_your_students' => 'Upoznajte svoje učenike s Tjednom programiranja', 'or' => 'ili', 'newsletter' => 'Novosti', + 'city_required_for_community_map' => 'Select your city so that you appear on the community map. Leading teachers without a city are not shown there.', ]; diff --git a/resources/lang/hu/base.php b/resources/lang/hu/base.php index 3bde9ed07..941a9c585 100644 --- a/resources/lang/hu/base.php +++ b/resources/lang/hu/base.php @@ -67,4 +67,5 @@ 'bring_codeweek_to_your_students' => 'Ismertesse meg a Programozási hetet a diákjaival', 'or' => 'vagy', 'newsletter' => 'Hírlevél', + 'city_required_for_community_map' => 'Select your city so that you appear on the community map. Leading teachers without a city are not shown there.', ]; diff --git a/resources/lang/it/base.php b/resources/lang/it/base.php index 5fd434ee8..dea931bc8 100644 --- a/resources/lang/it/base.php +++ b/resources/lang/it/base.php @@ -67,4 +67,5 @@ 'bring_codeweek_to_your_students' => 'Presenta la settimana della programmazione ai tuoi studenti', 'or' => 'o', 'newsletter' => 'Newsletter', + 'city_required_for_community_map' => 'Select your city so that you appear on the community map. Leading teachers without a city are not shown there.', ]; diff --git a/resources/lang/lt/base.php b/resources/lang/lt/base.php index e310f1e1c..0e89a0bda 100644 --- a/resources/lang/lt/base.php +++ b/resources/lang/lt/base.php @@ -67,4 +67,5 @@ 'bring_codeweek_to_your_students' => 'Įtraukite savo moksleivius į Programavimo savaitę', 'or' => 'arba', 'newsletter' => 'Naujienlaiškis', + 'city_required_for_community_map' => 'Select your city so that you appear on the community map. Leading teachers without a city are not shown there.', ]; diff --git a/resources/lang/lv/base.php b/resources/lang/lv/base.php index 7a985e674..c8ea00f42 100644 --- a/resources/lang/lv/base.php +++ b/resources/lang/lv/base.php @@ -67,4 +67,5 @@ 'bring_codeweek_to_your_students' => 'Piedāvājiet Programmēšanas nedēļu saviem skolēniem', 'or' => 'vai', 'newsletter' => 'Jaunumi', + 'city_required_for_community_map' => 'Select your city so that you appear on the community map. Leading teachers without a city are not shown there.', ]; diff --git a/resources/lang/me/base.php b/resources/lang/me/base.php index b2f44359e..30a7a3219 100644 --- a/resources/lang/me/base.php +++ b/resources/lang/me/base.php @@ -68,4 +68,5 @@ 'bring_codeweek_to_your_students' => 'Donesite Nedjelju programiranja vašim učenicima', 'or' => 'ili', 'newsletter' => 'Newsletter', + 'city_required_for_community_map' => 'Select your city so that you appear on the community map. Leading teachers without a city are not shown there.', ]; diff --git a/resources/lang/mk/base.php b/resources/lang/mk/base.php index 21ce8a5c0..43b191ce7 100644 --- a/resources/lang/mk/base.php +++ b/resources/lang/mk/base.php @@ -67,4 +67,5 @@ 'bring_codeweek_to_your_students' => 'Донеси ја неделата на кодирање кај твоите ученици', 'or' => 'или', 'newsletter' => 'Билтен', + 'city_required_for_community_map' => 'Select your city so that you appear on the community map. Leading teachers without a city are not shown there.', ]; diff --git a/resources/lang/mt/base.php b/resources/lang/mt/base.php index f6cadcbb2..165aaea0f 100644 --- a/resources/lang/mt/base.php +++ b/resources/lang/mt/base.php @@ -66,4 +66,5 @@ 'bring_codeweek_to_your_students' => 'Wassal il-Ġimgħa tal-Ikkowdjar lill-istudenti tiegħek', 'or' => 'jew', 'newsletter' => 'Bullettin', + 'city_required_for_community_map' => 'Select your city so that you appear on the community map. Leading teachers without a city are not shown there.', ]; diff --git a/resources/lang/nl/base.php b/resources/lang/nl/base.php index c9e80e974..5c8f3ac64 100644 --- a/resources/lang/nl/base.php +++ b/resources/lang/nl/base.php @@ -67,4 +67,5 @@ 'bring_codeweek_to_your_students' => 'Trakteer je leerlingen op de programmeerweek', 'or' => 'of', 'newsletter' => 'Nieuwsbrief', + 'city_required_for_community_map' => 'Select your city so that you appear on the community map. Leading teachers without a city are not shown there.', ]; diff --git a/resources/lang/pl/base.php b/resources/lang/pl/base.php index 2567bbb50..c00c95d96 100644 --- a/resources/lang/pl/base.php +++ b/resources/lang/pl/base.php @@ -67,4 +67,5 @@ 'bring_codeweek_to_your_students' => 'Daj swoim uczniom szansę na udział w Tygodniu Kodowania', 'or' => 'lub', 'newsletter' => 'Newsletter', + 'city_required_for_community_map' => 'Select your city so that you appear on the community map. Leading teachers without a city are not shown there.', ]; diff --git a/resources/lang/pt/base.php b/resources/lang/pt/base.php index f90e59bca..027052210 100644 --- a/resources/lang/pt/base.php +++ b/resources/lang/pt/base.php @@ -67,4 +67,5 @@ 'bring_codeweek_to_your_students' => 'Faça chegar a Semana da Programação aos seus alunos', 'or' => 'ou', 'newsletter' => 'Newsletter', + 'city_required_for_community_map' => 'Select your city so that you appear on the community map. Leading teachers without a city are not shown there.', ]; diff --git a/resources/lang/ro/base.php b/resources/lang/ro/base.php index d66871d82..86e770abf 100644 --- a/resources/lang/ro/base.php +++ b/resources/lang/ro/base.php @@ -67,4 +67,5 @@ 'bring_codeweek_to_your_students' => 'Aduceți Săptămâna programării elevilor dumneavoastră', 'or' => 'sau', 'newsletter' => 'Newsletter', + 'city_required_for_community_map' => 'Select your city so that you appear on the community map. Leading teachers without a city are not shown there.', ]; diff --git a/resources/lang/rs/base.php b/resources/lang/rs/base.php index 1f1d0a42e..b0343debc 100644 --- a/resources/lang/rs/base.php +++ b/resources/lang/rs/base.php @@ -67,4 +67,5 @@ 'bring_codeweek_to_your_students' => 'Organizujte Nedelju programiranja za svoje učenike', 'or' => 'Ili', 'newsletter' => 'Mesečne novosti', + 'city_required_for_community_map' => 'Select your city so that you appear on the community map. Leading teachers without a city are not shown there.', ]; diff --git a/resources/lang/sk/base.php b/resources/lang/sk/base.php index 9a7523908..dae0896b9 100644 --- a/resources/lang/sk/base.php +++ b/resources/lang/sk/base.php @@ -67,4 +67,5 @@ 'bring_codeweek_to_your_students' => 'Zapojte svojich študentov do Týždňa programovania', 'or' => 'alebo', 'newsletter' => 'Newsletter', + 'city_required_for_community_map' => 'Select your city so that you appear on the community map. Leading teachers without a city are not shown there.', ]; diff --git a/resources/lang/sl/base.php b/resources/lang/sl/base.php index cb0f25556..534280cbe 100644 --- a/resources/lang/sl/base.php +++ b/resources/lang/sl/base.php @@ -67,4 +67,5 @@ 'bring_codeweek_to_your_students' => 'Predstavite teden programiranja svojim učencem', 'or' => 'ali', 'newsletter' => 'E-novice', + 'city_required_for_community_map' => 'Select your city so that you appear on the community map. Leading teachers without a city are not shown there.', ]; diff --git a/resources/lang/sv/base.php b/resources/lang/sv/base.php index 071c817cc..f08887da8 100644 --- a/resources/lang/sv/base.php +++ b/resources/lang/sv/base.php @@ -67,4 +67,5 @@ 'bring_codeweek_to_your_students' => 'Låt eleverna delta i Code Week', 'or' => 'eller', 'newsletter' => 'Nyhetsbrev', + 'city_required_for_community_map' => 'Select your city so that you appear on the community map. Leading teachers without a city are not shown there.', ]; diff --git a/resources/lang/tr/base.php b/resources/lang/tr/base.php index b1b6b1b0e..6b9b664a7 100644 --- a/resources/lang/tr/base.php +++ b/resources/lang/tr/base.php @@ -67,4 +67,5 @@ 'bring_codeweek_to_your_students' => 'Öğrencilerinizi Code Week ile buluşturun', 'or' => 'veya', 'newsletter' => 'Bülten', + 'city_required_for_community_map' => 'Select your city so that you appear on the community map. Leading teachers without a city are not shown there.', ]; diff --git a/resources/lang/ua/base.php b/resources/lang/ua/base.php index 0907a4f06..84342bcfc 100644 --- a/resources/lang/ua/base.php +++ b/resources/lang/ua/base.php @@ -81,4 +81,5 @@ 'receive emails' => 'Отримувати електронні листи від нас', 'newsletter' => 'Бюлетень', + 'city_required_for_community_map' => 'Select your city so that you appear on the community map. Leading teachers without a city are not shown there.', ]; diff --git a/resources/views/profile.blade.php b/resources/views/profile.blade.php index 15f59c0aa..67467c817 100755 --- a/resources/views/profile.blade.php +++ b/resources/views/profile.blade.php @@ -262,13 +262,18 @@ class="border-2 border-solid border-dark-blue-200 w-full rounded-full h-12 px-4
    - @component('components.validation-errors', ['field'=>'country'])@endcomponent + @component('components.validation-errors', ['field'=>'country_iso'])@endcomponent
    + @if ($profileUser->hasRole('leading teacher') && is_null($profileUser->city_id)) +

    + @lang('base.city_required_for_community_map') +

    + @endif ` populated by a view composer in `AppServiceProvider`, which loads every city in the active countries, and narrowed client-side by `cityFilter()` in [public/js/ext/functions.js](../../public/js/ext/functions.js) when the country changes. + +This is **not** the ArcGIS autocomplete used by the activity form. The `/api/proxy/geocode` and `/api/proxy/suggest` endpoints exist but are not wired into the profile. So a user can only pick a city that already exists in the `cities` table for their country — if their town is missing, they cannot add it, and the practical answer is to pick the nearest listed city or insert the row via Nova. + +The leading-teacher signup form is different again: it requires a city, and pre-selects the closest one from GeoIP via `City::getClosestCity()`. + +### Avatars + +The avatar uploader is rendered only for ambassadors and super admins: + +```19:23:resources/views/profile.blade.php + @role('ambassador|super admin') + + @else +

    {{ $profileUser->fullName }}

    + @endrole +``` + +Everyone else, **including leading teachers**, has no way to set a picture from the profile page. Uploads go to S3 under `avatars/{userId}/`, plus an 80px resized copy. Deleting an avatar does not null the column; it sets it to the string `avatars/default.png`. + +Be careful here: there are three different "default avatar" strings in the codebase (`avatars/default_avatar.png`, `avatars/default.png`, `images/default-avatar.png`) and different code paths check for different ones. `User::communityAvatarUrl()` is the method that normalises them to a local placeholder for public pages. + +### Deleting an account + +There are two entirely different deletion paths, and confusing them causes real damage: + +- **User-initiated**, `GET /user/delete`, is a **soft delete**. The row stays, `deleted_at` is set, the user is logged out. This is reversible — the support tooling has a `UserRestoreService` for exactly this. +- **GDPR cleanup**, the `users:delete-without-consent` command dispatching `ProcessUserDeletion`, is a **hard force-delete**, and reassigns the person's activities to the legacy placeholder user `1000000`. This is not reversible. + +See [10](10-scheduled-jobs-and-runbooks.md) before running anything in the second category. + +## Becoming someone: roles + +A registered user has **no role at all**. Roles come from three places: the leading-teacher signup form, volunteer approval, and manual admin action. + +| Role | Permissions from the seeders | Nova access | +|------|------------------------------|-------------| +| `super admin` | all of them | yes | +| `ambassador` | `moderate event` | yes | +| `resource editor` | `moderate resource` | yes | +| `leading teacher` | `submit resource` | no | +| `leading teacher admin` | *none assigned* | no | +| `activities admin` | `feature event` | no | +| `member` | `create event`, `create school` | no | +| `event owner` | `update event`, `generate certificate` | no | +| `school manager` | `update school`, `generate certificate` | no | + +Roles are seeded by `RolesAndPermissionsSeeder`, `LeadingTeacherRoleSeeder`, `ActivitiesAdministratorRoleSeeder`, and `ResourceEditorRoleSeeder`. + +Two traps in that table. First, `leading teacher admin` has no permissions attached, so it only works because routes check the role *name* directly. Second, the `moderate event` permission is seeded for ambassadors but **never checked anywhere** — every moderation check in the codebase tests `hasRole('ambassador')` instead. If you try to grant moderation by attaching the permission to another role, nothing will happen. + +### Leading teacher + +`GET /leading-teachers/signup` renders the `LeadingTeacherSignupForm` Livewire component. It requires name, country, **city**, levels, subjects, expertises, and a tag. On submit it fills the profile and assigns the role: + +```140:145:app/Livewire/LeadingTeacherSignupForm.php + $user->city_id = $this->selectedCity; + $user->country_iso = $this->selectedCountry; + + $user->save(); + + $user->assignRole('leading teacher'); +``` + +Note what it does **not** do: set `approved`. The role is granted instantly, but `approved` stays `false`, so the person does not appear publicly until an admin approves them at `/leading-teachers/list` (super admin or leading teacher admin only). That list is the Livewire `LeadingTeachersTable`, with bulk Approve, Disallow, and Export actions. + +There is also a dead `POST /leading-teachers/signup` route whose controller body is commented out. The live submission is the Livewire `wire:submit`. + +### Ambassador + +There is no self-service ambassador application in the app. `GET /volunteer` records a `Volunteer` row, and an admin visiting `/volunteer/{volunteer}/approve` calls `assignRole('ambassador')`. `/beambassador` is a static information page, and for the 2026 cycle the community page points at an external Microsoft Form. Assigning the role by hand in Nova via the `Ambassador` resource is the normal route in practice. + +Ambassadors are scoped by their own `users.country_iso`. **An ambassador with an empty country moderates nothing**, because every scoping query compares against that column. + +## Why am I not on the community page? + +`/community` has two independent sections with completely different visibility rules: + +```20:31:app/Http/Controllers/CommunityController.php + $ambassadors = User::role('ambassador') + ->filter($filters) + ->whereRaw("bio is not null and trim(bio) <> ''") + ->whereRaw("avatar_path is not null and trim(avatar_path) <> ''") + ->where('avatar_path', '<>', 'images/default-avatar.png') + ->paginate(10); + + $teachers = User::role('leading teacher') + ->where('approved', 1) + ->filter($filters) + ->with(['city', 'expertises']) + ->get(); +``` + +Work through this in order when someone reports being missing. + +**For an ambassador**, all four must be true: + +1. They hold the `ambassador` role. +2. Their `country_iso` matches the country being viewed — the page is always filtered by country, and lands on the visitor's GeoIP country by default. +3. Their `bio` is non-empty. +4. Their `avatar_path` is non-empty and is not `images/default-avatar.png`. + +Missing bio or avatar is the usual answer, and it fails **silently** — there is no warning anywhere telling the ambassador why they are hidden. + +**For a leading teacher**, all of these must be true: + +1. They hold the `leading teacher` role. +2. `approved = 1` — set by an admin at `/leading-teachers/list`, never by signup. +3. Their `country_iso` matches the country being viewed. +4. **They have a city, and that city has coordinates.** + +That last one is the most common and most confusing cause. The leading-teacher section of the page *is* a map, and the map groups teachers by `city_id` and then skips any group whose city has no coordinates: + +```747:748:resources/views/community.blade.php + @foreach ($teachers->groupBy('city_id') as $cityId => $teachersInCity) + @if ($teachersInCity[0]->city && $teachersInCity[0]->city->latitude && $teachersInCity[0]->city->longitude) +``` + +A teacher with no city falls into the `null` group, that group fails the condition, and they are rendered nowhere at all. They are still in the `$teachers` collection — so they will show up in a `dd()` or a database query — but they never reach the page. That gap between "the query returns them" and "the page shows them" is what makes this so hard to diagnose from a support ticket. + +Two things now help: + +- The profile page shows a warning to any leading teacher whose `city_id` is null, telling them they will not appear on the map until they select a city. It is keyed as `base.city_required_for_community_map` in all 30 locale files, currently with English text everywhere pending translation. +- `/leading-teachers/list` has a **City** column and a City filter with a **Not set** option, so an admin can list everyone in this state and chase them. Combine it with the Approved filter to find approved teachers who are invisible. + +Neither changes the map itself. If the requirement later becomes "nobody is ever invisible", the `countries` table already carries `longitude` and `latitude` that could serve as a fallback centroid; that would be a deliberate product change, not a bug fix. + +## Submitting an activity + +| Purpose | Route | +|---------|-------| +| Form | `GET /add` (`create_event`) | +| Create | `POST /events` | +| Edit form | `GET event/edit/{event}` (`edit_event`) | +| Update | `PATCH /events/{event}` | +| Own activities | `GET /my` (`my_events`) | +| Saved locations | `GET /activities-locations` | + +**Login is required** to submit; there is no anonymous submission. The form is a Vue component, ``, not Livewire. Validation lives in `App\Http\Requests\EventRequest`, whose `authorize()` returns `true` unconditionally — access is enforced by route middleware, not the request class. + +There is **no honeypot and no Turnstile on the activity form**. Authentication and CSRF are the only barriers. + +Location is resolved client-side through ArcGIS: `AutocompleteGeo.vue` calls `/api/proxy/suggest` then `/api/proxy/geocode`, both proxied by `GeocodeController`, and sets `geoposition` plus a country ISO that the user can still override in step 3. If `geoposition` ends up as `0,0`, `Event::relocate()` substitutes the country centroid. + +Every public submission starts pending: + +```19:19:app/Queries/EventsQuery.php + $request['status'] = 'PENDING'; +``` + +`status` is a free-form `varchar(50)` with **no database default and no PHP enum**. The three values in use are `PENDING`, `APPROVED`, and `REJECTED`. Do not confuse it with `highlighted_status`, a separate column holding `NONE`, `PROMOTED`, or `FEATURED`. + +Editing a rejected activity puts it back in the queue: + +```113:116:app/Queries/EventsQuery.php + //In order to appear again in the list for the moderators + if ($event->status == 'REJECTED') { + $request['status'] = 'PENDING'; + } +``` + +On submission, three emails are queued: `EventRegistered` to the organiser, and `EventCreated` to each ambassador for that country — or `EventCreatedNoAmbassador` to `info@codeweek.eu` if that country has none. + +## Moderation + +### Who can moderate what + +`App\Policies\EventPolicy` is the single source of truth, auto-discovered by Laravel: + +```15:32:app/Policies/EventPolicy.php + public function before($user, $ability) + { + if ($user->hasRole('super admin')) { + return true; + } + } + + public function approve(User $user, Event $event) + { + + // Log::info("can approve ?" . $user->hasRole('super admin')); + + if ($user->hasRole('ambassador')) { + return $event->country_iso === $user->country_iso; + } + + return false; + } +``` + +Super admins bypass everything through `before()`. Ambassadors are confined to activities whose `country_iso` equals their own. Nobody else can approve, whatever permissions they hold. + +The same country scoping is repeated, rather than shared, in several places — the pending queue, the Nova index query, the review table, and the review controller's country picker. If you change the scoping rule, you must change all of them: + +```18:30:app/Queries/PendingEventsQuery.php + return Event::where(function ($query) use ($country) { + + if (! auth()->user()->hasRole('super admin')) { + $query->where('country_iso', '=', Auth::user()->country->iso); + } + + if (! is_null($country)) { + $query->where('country_iso', '=', $country->iso); + } + + $query->Where('status', 'like', 'PENDING'); + + })->orderBy('updated_at', 'asc')->paginate(30); +``` + +### The three moderation surfaces + +| Surface | Route | Who | Notes | +|---------|-------|-----|-------| +| Pending activities, card grid | `/pending`, `/pending/{country}` | super admin, ambassador | The country variant is super-admin only. Has an **Approve all** button | +| Review, Livewire table | `/review`, `/review/{country}` | super admin, ambassador | Bulk approve. Country variant super-admin only | +| Nova Events | `/nova/resources/events` | super admin, ambassador | Filter by status; ambassadors auto-scoped by `indexQuery` | + +Ambassadors reach `/pending` from the profile dropdown. Pending counts per country come from `CountriesQuery::withPendingEvents()`. + +### What approve and reject actually do + +Both live on the model, and both send email. `Event::approve()` sets the status, records `approved_by`, and queues `EventApproved`. `Event::reject($reason)` creates a `moderations` row with the reason, sets the status, records `approved_by`, and queues `EventRejected`. + +The rejection reason is captured in the `ModerateEvent.vue` modal, which offers four preset reasons from `resources/lang/*/moderation.php` plus a free-text box, and is stored in `moderations.message`. The organiser sees the latest message on the activity page and in the email. + +Approval also has a side effect via `EventObserver`: when an activity crosses into or out of `APPROVED` and has a `leading_teacher_tag`, experience is awarded to or stripped from that leading teacher. + +### Sharp edges in moderation + +These are behaviours to know before you change anything here. + +- **Editing the Status field directly in Nova bypasses `approve()` and `reject()` entirely.** No email is sent and no moderation record is written. Ambassadors have full edit rights on activities in their country, so this is easy to do by accident. Always use the Approve and Reject actions. +- **Bulk uploads and partner imports are auto-approved.** `GenericEventsImport` and roughly twenty named importers hardcode `'status' => 'APPROVED'`, as does the Germany API sync. Nothing else auto-approves; there is no "trusted organiser" logic. See [06](06-bulk-uploads-and-imports.md) and [07](07-partner-feeds-and-apis.md). +- `EventsQuery::trigger()` tests `status = 'FEATURED'`, but `FEATURED` is a value of `highlighted_status`, not `status`. That branch can never match. +- `App\Http\Controllers\ModerationController` is an empty scaffold and there is no Nova resource for `Moderation`. All moderation logic is on the `Event` model. + +Two related bugs were fixed as part of this handover and are recorded in [12](12-risks-and-known-issues.md): rejection used to swallow its own authorization failure, letting any ambassador reject any country's activities, and the Nova Reject action used to send the organiser an email with no reason in it. + +## Approving leading teachers + +Separate from activities, and not in Nova. `GET /leading-teachers/list`, restricted to `super admin` and `leading teacher admin`, renders `LeadingTeachersTable`. Select rows and use the Approve bulk action, which sets `approved = true`. + +Filter by **Approved: No** to find the backlog, and by **City: Not set** to find approved teachers who will still be invisible on the map. Exporting the selection produces an xlsx via `UsersExport`. diff --git a/docs/handover/README.md b/docs/handover/README.md index a3284a866..2724f870f 100644 --- a/docs/handover/README.md +++ b/docs/handover/README.md @@ -34,10 +34,11 @@ Specifically: | 11 | [11-testing-and-local-dev.md](11-testing-and-local-dev.md) | Running the test suite and getting a local environment up. | | 12 | [12-risks-and-known-issues.md](12-risks-and-known-issues.md) | What is still open, behaviour with sharp edges, and a record of what was already fixed. | | 13 | [13-visual-tour.md](13-visual-tour.md) | Screenshots of every major screen, each mapped to the route and controller behind it. | +| 14 | [14-accounts-and-moderation.md](14-accounts-and-moderation.md) | Registration, profiles, roles, and who approves what. Includes the diagnosis for "why am I not on the community page?". | ## Single-file PDF -These chapters can be built into one printable document — roughly 120 pages, with a clickable table of contents, rendered diagrams, and the screenshot tour: +These chapters can be built into one printable document — roughly 134 pages, with a clickable table of contents, rendered diagrams, and the screenshot tour: ```bash python3 docs/handover/_pdf/build.py @@ -45,13 +46,13 @@ python3 docs/handover/_pdf/build.py It writes `docs/handover/codeweek-technical-handover.pdf`. You need `pandoc` and Google Chrome installed, plus an internet connection at build time (the Mermaid renderer is loaded from a CDN). -The PDF is **deliberately not committed** — it is a 7 MB build artefact and this folder is meant to stay text. The markdown files are the source of truth. Regenerate the PDF when you need a copy to hand to someone. +The PDF is **deliberately not committed** — it is a 7.6 MB build artefact and this folder is meant to stay text. The markdown files are the source of truth. Regenerate the PDF when you need a copy to hand to someone. ## If you only read three pages 1. [00-access-checklist.md](00-access-checklist.md) — you will be blocked without this. 2. [02-environments-and-deployment.md](02-environments-and-deployment.md) — the single most misunderstood part of this repo. The Kubernetes manifests at the repo root are **local development only**; production runs on Laravel Forge. -3. [12-risks-and-known-issues.md](12-risks-and-known-issues.md) — several things are hardcoded to the outgoing team and will lock you out on day one until changed. +3. [12-risks-and-known-issues.md](12-risks-and-known-issues.md) — the handful of things that still need your credentials, starting with `CERTIFICATE_ADMIN_EMAILS`, which locks you out of certificates until it is set. ## Ten-minute orientation diff --git a/docs/handover/_pdf/build.py b/docs/handover/_pdf/build.py index 6057e1903..119388f42 100644 --- a/docs/handover/_pdf/build.py +++ b/docs/handover/_pdf/build.py @@ -37,6 +37,7 @@ ("11", "11-testing-and-local-dev.md"), ("12", "12-risks-and-known-issues.md"), ("13", "13-visual-tour.md"), + ("14", "14-accounts-and-moderation.md"), ] SLUG_BY_FILE = {fname: f"chapter-{cid}" for cid, fname in CHAPTERS}