Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,16 @@ Two extraction traps:
`gewiswebgewis`; the register is reached with the same account, `8000` holding both `ROLE_ADMIN` and
`ROLE_DATABASE_ADMIN` while `8001` holds only the former and `8002` only the latter. (Migrations have already run by
the time you get here — they fire one-shot on container start.)
- **Walking the member flows by hand.** The seed is set up so every one of them can be reached without editing rows.
Signed in as `8000`: **My details** (`/en/user/settings/details`) shows the addresses (a home one is on file, the
other two are not, so adding, correcting and removing are all reachable) and the mailing lists — `8000` is on
`activities`, which a member manages, and on `announcements`, which they do not and which therefore stays off the
page. Changing the address there sends the confirmation to MailPit; following its link and confirming moves the
subscriptions with it and writes to the address that was replaced. The two sweeps have a member each, expiring
twenty days after the seed was loaded: `make sf c=check:membership:conversion:graduate` writes to
`conversiontarget@example.com` and `make sf c=check:membership:renewal:graduate` to `renewaltarget@example.com`;
both messages carry a link that answers. `make sf c=check:membership:consistency` mails the membership report,
which finds nothing until a membership is made to overlap by hand.
- `make bash` — shell into the FrankenPHP `app` container; `make exec cmd="..."` runs a single command in it.
- `make sf c=...` / `make composer c='...'` — the console and Composer inside the container.
- `make cc` — clear the cache and restart the worker.
Expand Down
5 changes: 5 additions & 0 deletions config/packages/messenger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,11 @@ framework:
# which Stripe repeats the same event) or a page that hangs on the MTA.
'App\Message\Database\RegistrationUpdateEmail': high_priority
'App\Message\Database\RefundProblemEmail': high_priority
# A member waiting on a page for a confirmation link, and the address that has just been replaced hearing
# that it was, are both worth little late.
'App\Message\Database\EmailChangeConfirmationEmail': high_priority
'App\Message\Database\EmailChangedNoticeEmail': high_priority
'App\Message\Database\GraduateRemovalRequested': normal_priority
'App\Message\User\ExportUserDataMessage': bulk
'App\Message\User\RevokeSessionsRealtimeMessage': high_priority
# A security notice is worth little late, and it must not delay whatever raised it.
Expand Down
21 changes: 21 additions & 0 deletions config/routes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,27 @@ join_renew:
_locale: '%kernel.default_locale%'
methods: [GET, POST]

# Where the link in the ending-membership email points, and the page it leads to. The same two steps as the renewal
# above: the token is exchanged for a hash, and the page that asks is served at an address that contains nothing.
join_graduate_claim:
path: '/{_locale}/graduate/{token}'
controller: App\Controller\Database\ProspectiveMemberController::graduateClaim
requirements:
_locale: '%app.locales%'
token: '[0-9a-f]{32}\.[0-9a-f]{96}'
defaults:
_locale: '%kernel.default_locale%'
methods: [GET]

join_graduate:
path: '/{_locale}/graduate'
controller: App\Controller\Database\ProspectiveMemberController::graduate
requirements:
_locale: '%app.locales%'
defaults:
_locale: '%kernel.default_locale%'
methods: [GET, POST]

# GET only, for the same reason as the sign-up address above: the form submits to the address the page is served at.
join_renew_legacy_short:
path: /renew/{token}
Expand Down
46 changes: 46 additions & 0 deletions migrations/database/Version20260923140501.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare(strict_types=1);

namespace Database\Migrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* phpcs:disable Generic.Files.LineLength.TooLong
* phpcs:disable SlevomatCodingStandard.Functions.RequireMultiLineCall.RequiredMultiLineCall
*/
final class Version20260923140501 extends AbstractMigration
{
public function getDescription(): string
{
return 'Room for what a member changes about themselves: the address they asked to be reached at while that'
. ' is waiting to be confirmed, the audit entries saying an address or an e-mail address changed, and'
. ' whether a mailing list is one a member may put themselves on.';
}

public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE ActionLink ADD newEmail VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE ActionLink ADD previousEmail VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE ActionLink ADD requestedOn TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('ALTER TABLE AuditEntry ADD addressType VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE AuditEntry ADD detailAction VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE AuditEntry ADD oldEmail VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE AuditEntry ADD newEmail VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE MailingList ADD selfService BOOLEAN DEFAULT false NOT NULL');
}

public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE ActionLink DROP newEmail');
$this->addSql('ALTER TABLE ActionLink DROP previousEmail');
$this->addSql('ALTER TABLE ActionLink DROP requestedOn');
$this->addSql('ALTER TABLE AuditEntry DROP addressType');
$this->addSql('ALTER TABLE AuditEntry DROP detailAction');
$this->addSql('ALTER TABLE AuditEntry DROP oldEmail');
$this->addSql('ALTER TABLE AuditEntry DROP newEmail');
$this->addSql('ALTER TABLE MailingList DROP selfService');
}
}
31 changes: 31 additions & 0 deletions migrations/database/Version20260923140502.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Database\Migrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* phpcs:disable Generic.Files.LineLength.TooLong
* phpcs:disable SlevomatCodingStandard.Functions.RequireMultiLineCall.RequiredMultiLineCall
*/
final class Version20260923140502 extends AbstractMigration
{
public function getDescription(): string
{
return 'Room for what a member answered to the offer of staying on as a graduate. The dates it is about are'
. ' the columns a renewal link and a change of email address already use, and mean the same thing.';
}

public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE ActionLink ADD outcome VARCHAR(255) DEFAULT NULL');
}

public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE ActionLink DROP outcome');
}
}
12 changes: 0 additions & 12 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -1164,24 +1164,12 @@ parameters:
count: 1
path: src/Service/Database/MailmanService.php

-
message: '#^Cannot access property \$email on App\\Entity\\Database\\MailingListMember\|null\.$#'
identifier: property.nonObject
count: 1
path: src/Service/Database/Member.php

-
message: '#^Cannot access property \$endDate on App\\Entity\\Database\\Membership\|null\.$#'
identifier: property.nonObject
count: 3
path: src/Service/Database/Member.php

-
message: '#^Cannot access property \$toBeDeleted on App\\Entity\\Database\\MailingListMember\|null\.$#'
identifier: property.nonObject
count: 1
path: src/Service/Database/Member.php

-
message: '#^Cannot access property \$type on App\\Entity\\Database\\Membership\|null\.$#'
identifier: property.nonObject
Expand Down
56 changes: 56 additions & 0 deletions src/Command/Checker/CheckMembershipConsistencyCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

declare(strict_types=1);

namespace App\Command\Checker;

use App\Command\HoldsRunLockTrait;
use App\Service\Checker\Membership as MembershipService;
use Override;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Scheduler\Attribute\AsCronTask;

#[AsCommand(
name: 'check:membership:consistency',
description: 'Check that the memberships of every member run in order and do not overlap.',
)]
// Weekly, beside the database check: a report that arrives every day stops being read.
#[AsCronTask(
expression: '52 6 * * 1',
transports: 'maintenance',
)]
class CheckMembershipConsistencyCommand extends Command
{
use HoldsRunLockTrait;

public function __construct(private readonly MembershipService $membershipService)
{
parent::__construct();
}

#[Override]
protected function execute(
InputInterface $input,
OutputInterface $output,
): int {
return $this->runExclusively(
$output,
fn (): int => $this->executeExclusively(
$input,
$output,
),
);
}

private function executeExclusively(
InputInterface $input,
OutputInterface $output,
): int {
$this->membershipService->check();

return Command::SUCCESS;
}
}
55 changes: 55 additions & 0 deletions src/Command/Checker/CheckMembershipGraduateConversionCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

declare(strict_types=1);

namespace App\Command\Checker;

use App\Command\HoldsRunLockTrait;
use App\Service\Checker\Renewal as RenewalService;
use Override;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Scheduler\Attribute\AsCronTask;

#[AsCommand(
name: 'check:membership:conversion:graduate',
description: 'Offer the members whose membership is ending the chance to stay on as a graduate.',
)]
#[AsCronTask(
expression: '*/30 * * * *',
transports: 'maintenance',
)]
class CheckMembershipGraduateConversionCommand extends Command
{
use HoldsRunLockTrait;

public function __construct(private readonly RenewalService $renewalService)
{
parent::__construct();
}

#[Override]
protected function execute(
InputInterface $input,
OutputInterface $output,
): int {
return $this->runExclusively(
$output,
fn (): int => $this->executeExclusively(
$input,
$output,
),
);
}

private function executeExclusively(
InputInterface $input,
OutputInterface $output,
): int {
$this->renewalService->sendGraduateConversions();

return Command::SUCCESS;
}
}
Loading
Loading