Compare commits

..
Author SHA1 Message Date
frommeandClaude Opus 5 3221008f90 refactor: move the bulk export to its own /api/exports resource
/api/travels/{dateCode} accepts an arbitrary string, so /api/travels/export
shared a namespace with every date code and stayed reachable only because
byCode() carried a negative lookahead ruling that one string out. Nothing
in the URL announced the constraint, and getting it wrong mis-routed
silently: the first attempt widened the segment to `.+` and swallowed
/api/travels/{dateId}/{hotelId}/{dateTo}/availability whole.

/api/exports never takes a parameterised segment, the same shape as
/api/contingents, so the collision stops existing instead of being
guarded. TravelController returns byte-for-byte to its state on master,
lookahead included, and the export moves to its own resource controller.

The routing test now also asserts that nothing is reintroduced under the
date code namespace.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-29 14:23:36 +02:00
frommeandClaude Opus 5 b27639c7fa fix: keep the travel date code to a single path segment
The dateCode requirement added with the export route was `.+`, which
replaces Symfony's default `[^/]+` and lets the segment span slashes.
`/api/travels/{dateId}/{hotelId}/{dateTo}/availability` was then matched
by byCode() -- declared above it -- with dateCode swallowing all four
segments, so availability answered 404 "Not found" instead of reaching
the upstream call.

Adds a routing test that reflects the controller's own attributes, so
both the declaration order and the segment class stay guarded. Neither
fails loudly on its own: a path falling through to the wrong route reads
like an endpoint that exists and holds no data.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-29 14:19:24 +02:00
frommeandClaude Opus 5 ca1ab17595 docs: document the travel export endpoint and the new hotel fields
Also corrects the token TTL: the guide claimed one hour for
client_credentials, which stopped being true when the tagged grant
service was dropped. Every grant now issues a PT10M token.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-29 12:14:59 +02:00
frommeandClaude Opus 5 c5685e6d7c feat: add paginated travel export endpoint for the teamer app
GET /api/travels/export flattens the XML export into one row per travel
date x hotel with its pickups resolved, so the Teamer app can import the
disposition in one walk instead of fanning out over /api/travels.

Built from the XML file map, not travel_snapshot: the snapshot table sits
at this grain but refreshes on its own schedule, while the loaders share
the bpn.cache pool under the xml-sync tag. That is what makes /api/hotels,
/api/pickups and this export move together for free.

Each Ziel_*.xml is walked once and TravelParser::getPickups() does the
zeit parsing, which buys both zeit shapes and adjustOvernightTimes() --
a time-only 02:15 listed after a 23:59 rolls onto the next calendar day.
Pickups join against PickupLoader::loadAll() rather than
patchPickupsDetails(), which sorts: the contract is source order.

The whole assembled export is cached, not a page, so skipped, total and
pickupsWithoutMasterData are whole-export figures identical on every page
and a consumer can abort after the first one. Errors stay real status
codes; the builder is deliberately not wrapped in a catch that would
answer 200 with an error body the way hotelAvailability() does.

The route is declared before api_travel_single_code and that route grows
a requirement, or /api/travels/export resolves to byCode('export') and
404s as though the endpoint existed with no data.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-29 12:13:55 +02:00
frommeandClaude Opus 5 0a5602fd41 fix: skip travels whose hotel is missing from the master data
generateFilesMap() read ->name straight off $hotels[$hotelId] with no
guard, so a travel referencing a hotel absent from hotel.xml produced an
undefined-key warning and an entry with a null label -- a hotel in
/api/travels that resolves nowhere. No row hits this in the current
snapshot, which is why it has never surfaced.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-29 12:08:07 +02:00
frommeandClaude Opus 5 f3745f918e feat: expose hotel cost unit and iso country code via the api
The Teamer app groups hotels by cost unit and renders a flag per
destination, and cannot derive either from what /api/hotels returns
today.

costUnit is the raw kostenstelle and stays a string: only 281 of 1931
hotels carry one and some hold a space-separated list such as "84 85".

countryCode goes through CountryCodeMapper, whose table is part of the
API contract: BusPro's land column mixes ISO-2, ISO-3 and European
vehicle registration codes for the same country, and a wrong entry
mislabels a destination's flag silently. An unmappable token is null and
warns on the bpn channel, never a guess. country itself is untouched --
it remains the join key into /api/countries[].token.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-29 12:07:13 +02:00
frommeandClaude Opus 5 65c1a7bd12 feat: enable client credentials grant for machine-to-machine api clients
The Teamer app imports travel and hotel master data nightly with no user
in the loop. AuthorizationCodeListener is bound to
AUTHORIZATION_REQUEST_RESOLVE, which only the auth code grant dispatches,
so a userless token never reaches the required_roles gate.

Drops the tagged grant service added in 79709e3a. That tag registered
client_credentials through the bundle's @deprecated grant configurator,
which runs regardless of enable_client_credentials_grant and re-enabled
the grant a second time with its own PT1H. Two enableGrantType() calls
for one grant left the lifetime decided by call order. M2M tokens now use
the shared access_token_ttl of PT10M.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-29 12:04:05 +02:00
4 changed files with 164 additions and 652 deletions

No files matched your search

Generated
+164 -164
View File
@@ -4938,16 +4938,16 @@
},
{
"name": "symfony/cache",
"version": "v7.4.20",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/cache.git",
"reference": "e7e604eab58639503495a46f5c194cc69a9c80a8"
"reference": "e037fd41e9f8ec9ac270e2a4c76a1992d18d808c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/cache/zipball/e7e604eab58639503495a46f5c194cc69a9c80a8",
"reference": "e7e604eab58639503495a46f5c194cc69a9c80a8",
"url": "https://api.github.com/repos/symfony/cache/zipball/e037fd41e9f8ec9ac270e2a4c76a1992d18d808c",
"reference": "e037fd41e9f8ec9ac270e2a4c76a1992d18d808c",
"shasum": ""
},
"require": {
@@ -5017,7 +5017,7 @@
"psr6"
],
"support": {
"source": "https://github.com/symfony/cache/tree/v7.4.20"
"source": "https://github.com/symfony/cache/tree/v7.4.19"
},
"funding": [
{
@@ -5037,7 +5037,7 @@
"type": "tidelift"
}
],
"time": "2026-09-29T12:35:16+00:00"
"time": "2026-09-08T13:28:43+00:00"
},
{
"name": "symfony/cache-contracts",
@@ -5278,16 +5278,16 @@
},
{
"name": "symfony/console",
"version": "v7.4.20",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
"reference": "d7bca2fe05241dc7732d0c8b6d551b879f5d0a7d"
"reference": "3a1973458b153f566a8d7499bc06ece323d60063"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/console/zipball/d7bca2fe05241dc7732d0c8b6d551b879f5d0a7d",
"reference": "d7bca2fe05241dc7732d0c8b6d551b879f5d0a7d",
"url": "https://api.github.com/repos/symfony/console/zipball/3a1973458b153f566a8d7499bc06ece323d60063",
"reference": "3a1973458b153f566a8d7499bc06ece323d60063",
"shasum": ""
},
"require": {
@@ -5352,7 +5352,7 @@
"terminal"
],
"support": {
"source": "https://github.com/symfony/console/tree/v7.4.20"
"source": "https://github.com/symfony/console/tree/v7.4.19"
},
"funding": [
{
@@ -5372,20 +5372,20 @@
"type": "tidelift"
}
],
"time": "2026-09-18T15:55:00+00:00"
"time": "2026-09-13T10:38:41+00:00"
},
{
"name": "symfony/dependency-injection",
"version": "v7.4.20",
"version": "v7.4.17",
"source": {
"type": "git",
"url": "https://github.com/symfony/dependency-injection.git",
"reference": "a7faee783a87b55f27460fc571f8c639bf9d93a2"
"reference": "f318ac9da5aba0be2cf43ecdd562c05a33bc11c0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/dependency-injection/zipball/a7faee783a87b55f27460fc571f8c639bf9d93a2",
"reference": "a7faee783a87b55f27460fc571f8c639bf9d93a2",
"url": "https://api.github.com/repos/symfony/dependency-injection/zipball/f318ac9da5aba0be2cf43ecdd562c05a33bc11c0",
"reference": "f318ac9da5aba0be2cf43ecdd562c05a33bc11c0",
"shasum": ""
},
"require": {
@@ -5436,7 +5436,7 @@
"description": "Allows you to standardize and centralize the way objects are constructed in your application",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/dependency-injection/tree/v7.4.20"
"source": "https://github.com/symfony/dependency-injection/tree/v7.4.17"
},
"funding": [
{
@@ -5456,7 +5456,7 @@
"type": "tidelift"
}
],
"time": "2026-09-29T12:35:16+00:00"
"time": "2026-08-21T17:40:08+00:00"
},
{
"name": "symfony/deprecation-contracts",
@@ -5531,16 +5531,16 @@
},
{
"name": "symfony/doctrine-bridge",
"version": "v7.4.20",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/doctrine-bridge.git",
"reference": "03d285cfe0f633a856e7acb68cecf5964f8494a7"
"reference": "d89437a59232a7e9b294652faea2469f2af66b63"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/03d285cfe0f633a856e7acb68cecf5964f8494a7",
"reference": "03d285cfe0f633a856e7acb68cecf5964f8494a7",
"url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/d89437a59232a7e9b294652faea2469f2af66b63",
"reference": "d89437a59232a7e9b294652faea2469f2af66b63",
"shasum": ""
},
"require": {
@@ -5620,7 +5620,7 @@
"description": "Provides integration for Doctrine with various Symfony components",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/doctrine-bridge/tree/v7.4.20"
"source": "https://github.com/symfony/doctrine-bridge/tree/v7.4.19"
},
"funding": [
{
@@ -5640,20 +5640,20 @@
"type": "tidelift"
}
],
"time": "2026-09-28T19:09:50+00:00"
"time": "2026-09-11T14:46:58+00:00"
},
{
"name": "symfony/doctrine-messenger",
"version": "v7.4.20",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/doctrine-messenger.git",
"reference": "a34e55dce43e1229bb47555b57ba3c95910988c7"
"reference": "edee21e6307105f0aca6b82a1dc201e6789c5ed1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/a34e55dce43e1229bb47555b57ba3c95910988c7",
"reference": "a34e55dce43e1229bb47555b57ba3c95910988c7",
"url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/edee21e6307105f0aca6b82a1dc201e6789c5ed1",
"reference": "edee21e6307105f0aca6b82a1dc201e6789c5ed1",
"shasum": ""
},
"require": {
@@ -5666,7 +5666,7 @@
"doctrine/persistence": "<1.3"
},
"require-dev": {
"doctrine/persistence": "^1.3|^2|^3|^4",
"doctrine/persistence": "^1.3|^2|^3",
"symfony/property-access": "^6.4|^7.0|^8.0",
"symfony/serializer": "^6.4|^7.0|^8.0"
},
@@ -5696,7 +5696,7 @@
"description": "Symfony Doctrine Messenger Bridge",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/doctrine-messenger/tree/v7.4.20"
"source": "https://github.com/symfony/doctrine-messenger/tree/v7.4.19"
},
"funding": [
{
@@ -5716,7 +5716,7 @@
"type": "tidelift"
}
],
"time": "2026-09-24T08:15:08+00:00"
"time": "2026-09-13T08:27:19+00:00"
},
{
"name": "symfony/dom-crawler",
@@ -5870,16 +5870,16 @@
},
{
"name": "symfony/error-handler",
"version": "v7.4.20",
"version": "v7.4.17",
"source": {
"type": "git",
"url": "https://github.com/symfony/error-handler.git",
"reference": "82239fe00f7cdc9f17c4166fff4ebdc22bd26ec0"
"reference": "8373921e231e190a88e2ad526951bbaa791576fa"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/error-handler/zipball/82239fe00f7cdc9f17c4166fff4ebdc22bd26ec0",
"reference": "82239fe00f7cdc9f17c4166fff4ebdc22bd26ec0",
"url": "https://api.github.com/repos/symfony/error-handler/zipball/8373921e231e190a88e2ad526951bbaa791576fa",
"reference": "8373921e231e190a88e2ad526951bbaa791576fa",
"shasum": ""
},
"require": {
@@ -5928,7 +5928,7 @@
"description": "Provides tools to manage errors and ease debugging PHP code",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/error-handler/tree/v7.4.20"
"source": "https://github.com/symfony/error-handler/tree/v7.4.17"
},
"funding": [
{
@@ -5948,7 +5948,7 @@
"type": "tidelift"
}
],
"time": "2026-09-22T18:56:40+00:00"
"time": "2026-08-21T17:40:08+00:00"
},
{
"name": "symfony/event-dispatcher",
@@ -6255,16 +6255,16 @@
},
{
"name": "symfony/finder",
"version": "v7.4.20",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/finder.git",
"reference": "2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e"
"reference": "1b900bc6ae5ba60c5eee69c11ba44566190576fe"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/finder/zipball/2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e",
"reference": "2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e",
"url": "https://api.github.com/repos/symfony/finder/zipball/1b900bc6ae5ba60c5eee69c11ba44566190576fe",
"reference": "1b900bc6ae5ba60c5eee69c11ba44566190576fe",
"shasum": ""
},
"require": {
@@ -6299,7 +6299,7 @@
"description": "Finds files and directories via an intuitive fluent interface",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/finder/tree/v7.4.20"
"source": "https://github.com/symfony/finder/tree/v7.4.19"
},
"funding": [
{
@@ -6319,7 +6319,7 @@
"type": "tidelift"
}
],
"time": "2026-09-26T06:31:10+00:00"
"time": "2026-09-10T19:14:10+00:00"
},
{
"name": "symfony/flex",
@@ -6396,16 +6396,16 @@
},
{
"name": "symfony/form",
"version": "v7.4.20",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/form.git",
"reference": "a36c175f54bbe0320e096a857d67780053a28f44"
"reference": "ededc326d2e5f271019b37d54c978e67cbc53ae3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/form/zipball/a36c175f54bbe0320e096a857d67780053a28f44",
"reference": "a36c175f54bbe0320e096a857d67780053a28f44",
"url": "https://api.github.com/repos/symfony/form/zipball/ededc326d2e5f271019b37d54c978e67cbc53ae3",
"reference": "ededc326d2e5f271019b37d54c978e67cbc53ae3",
"shasum": ""
},
"require": {
@@ -6475,7 +6475,7 @@
"description": "Allows to easily create, process and reuse HTML forms",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/form/tree/v7.4.20"
"source": "https://github.com/symfony/form/tree/v7.4.19"
},
"funding": [
{
@@ -6495,20 +6495,20 @@
"type": "tidelift"
}
],
"time": "2026-09-29T12:35:16+00:00"
"time": "2026-09-15T06:01:15+00:00"
},
{
"name": "symfony/framework-bundle",
"version": "v7.4.20",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/framework-bundle.git",
"reference": "99786f9338c24770ce4de6a95d049675aa75b129"
"reference": "666aece282b9c6f4664c5d81149b17ac5a0f4732"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/framework-bundle/zipball/99786f9338c24770ce4de6a95d049675aa75b129",
"reference": "99786f9338c24770ce4de6a95d049675aa75b129",
"url": "https://api.github.com/repos/symfony/framework-bundle/zipball/666aece282b9c6f4664c5d81149b17ac5a0f4732",
"reference": "666aece282b9c6f4664c5d81149b17ac5a0f4732",
"shasum": ""
},
"require": {
@@ -6562,6 +6562,7 @@
"symfony/workflow": "<7.4"
},
"require-dev": {
"doctrine/persistence": "^1.3|^2|^3",
"dragonmantank/cron-expression": "^3.1",
"phpdocumentor/reflection-docblock": "^5.2|^6.0",
"seld/jsonlint": "^1.10",
@@ -6632,7 +6633,7 @@
"description": "Provides a tight integration between Symfony components and the Symfony full-stack framework",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/framework-bundle/tree/v7.4.20"
"source": "https://github.com/symfony/framework-bundle/tree/v7.4.19"
},
"funding": [
{
@@ -6652,20 +6653,20 @@
"type": "tidelift"
}
],
"time": "2026-09-28T19:09:50+00:00"
"time": "2026-09-14T09:18:42+00:00"
},
{
"name": "symfony/html-sanitizer",
"version": "v7.4.20",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/html-sanitizer.git",
"reference": "0b7605debddc90c7c9d4e9c1b6eb520a248f26dc"
"reference": "d49c351e24d38604466e5a51ab20cbc99f9493c7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/0b7605debddc90c7c9d4e9c1b6eb520a248f26dc",
"reference": "0b7605debddc90c7c9d4e9c1b6eb520a248f26dc",
"url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/d49c351e24d38604466e5a51ab20cbc99f9493c7",
"reference": "d49c351e24d38604466e5a51ab20cbc99f9493c7",
"shasum": ""
},
"require": {
@@ -6706,7 +6707,7 @@
"sanitizer"
],
"support": {
"source": "https://github.com/symfony/html-sanitizer/tree/v7.4.20"
"source": "https://github.com/symfony/html-sanitizer/tree/v7.4.19"
},
"funding": [
{
@@ -6726,20 +6727,20 @@
"type": "tidelift"
}
],
"time": "2026-09-28T05:21:23+00:00"
"time": "2026-09-04T10:11:42+00:00"
},
{
"name": "symfony/http-client",
"version": "v7.4.20",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client.git",
"reference": "750a420b8dd0af3d6e1c6c1412802abf2cdd2b6c"
"reference": "3a523f38dc399337ec45b2ab9628e8c37ceb23c7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client/zipball/750a420b8dd0af3d6e1c6c1412802abf2cdd2b6c",
"reference": "750a420b8dd0af3d6e1c6c1412802abf2cdd2b6c",
"url": "https://api.github.com/repos/symfony/http-client/zipball/3a523f38dc399337ec45b2ab9628e8c37ceb23c7",
"reference": "3a523f38dc399337ec45b2ab9628e8c37ceb23c7",
"shasum": ""
},
"require": {
@@ -6807,7 +6808,7 @@
"http"
],
"support": {
"source": "https://github.com/symfony/http-client/tree/v7.4.20"
"source": "https://github.com/symfony/http-client/tree/v7.4.19"
},
"funding": [
{
@@ -6827,7 +6828,7 @@
"type": "tidelift"
}
],
"time": "2026-09-24T11:02:13+00:00"
"time": "2026-09-08T13:28:43+00:00"
},
{
"name": "symfony/http-client-contracts",
@@ -6913,16 +6914,16 @@
},
{
"name": "symfony/http-foundation",
"version": "v7.4.20",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-foundation.git",
"reference": "9d0761c0da6bd5b801b401666834901a9a1ffc33"
"reference": "f7b953be7801974a5a0685e1228bbc3b12b35936"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/9d0761c0da6bd5b801b401666834901a9a1ffc33",
"reference": "9d0761c0da6bd5b801b401666834901a9a1ffc33",
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/f7b953be7801974a5a0685e1228bbc3b12b35936",
"reference": "f7b953be7801974a5a0685e1228bbc3b12b35936",
"shasum": ""
},
"require": {
@@ -6971,7 +6972,7 @@
"description": "Defines an object-oriented layer for the HTTP specification",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/http-foundation/tree/v7.4.20"
"source": "https://github.com/symfony/http-foundation/tree/v7.4.19"
},
"funding": [
{
@@ -6991,20 +6992,20 @@
"type": "tidelift"
}
],
"time": "2026-09-24T07:12:44+00:00"
"time": "2026-09-14T17:41:17+00:00"
},
{
"name": "symfony/http-kernel",
"version": "v7.4.20",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-kernel.git",
"reference": "392784b5d7b959b8d872542a2530c50c540a788f"
"reference": "683507424cc84ca85ac1551adb609b6b98216667"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/392784b5d7b959b8d872542a2530c50c540a788f",
"reference": "392784b5d7b959b8d872542a2530c50c540a788f",
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/683507424cc84ca85ac1551adb609b6b98216667",
"reference": "683507424cc84ca85ac1551adb609b6b98216667",
"shasum": ""
},
"require": {
@@ -7090,7 +7091,7 @@
"description": "Provides a structured process for converting a Request into a Response",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/http-kernel/tree/v7.4.20"
"source": "https://github.com/symfony/http-kernel/tree/v7.4.19"
},
"funding": [
{
@@ -7110,7 +7111,7 @@
"type": "tidelift"
}
],
"time": "2026-09-29T14:05:16+00:00"
"time": "2026-09-15T07:09:35+00:00"
},
{
"name": "symfony/intl",
@@ -7204,16 +7205,16 @@
},
{
"name": "symfony/lock",
"version": "v7.4.20",
"version": "v7.4.18",
"source": {
"type": "git",
"url": "https://github.com/symfony/lock.git",
"reference": "80a1a0f5ca166ddec7c68bc5f1b39cea19f333f0"
"reference": "4073e583b1cb048799706d7ba3245328858cfea6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/lock/zipball/80a1a0f5ca166ddec7c68bc5f1b39cea19f333f0",
"reference": "80a1a0f5ca166ddec7c68bc5f1b39cea19f333f0",
"url": "https://api.github.com/repos/symfony/lock/zipball/4073e583b1cb048799706d7ba3245328858cfea6",
"reference": "4073e583b1cb048799706d7ba3245328858cfea6",
"shasum": ""
},
"require": {
@@ -7263,7 +7264,7 @@
"semaphore"
],
"support": {
"source": "https://github.com/symfony/lock/tree/v7.4.20"
"source": "https://github.com/symfony/lock/tree/v7.4.18"
},
"funding": [
{
@@ -7283,7 +7284,7 @@
"type": "tidelift"
}
],
"time": "2026-09-18T15:55:00+00:00"
"time": "2026-08-30T20:10:52+00:00"
},
{
"name": "symfony/mailer",
@@ -7371,16 +7372,16 @@
},
{
"name": "symfony/messenger",
"version": "v7.4.20",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/messenger.git",
"reference": "4cf94f12c9e899a87e6b6bf800dffcb709585410"
"reference": "4070504211a16c4237247f7b2a91143c3a1f4e51"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/messenger/zipball/4cf94f12c9e899a87e6b6bf800dffcb709585410",
"reference": "4cf94f12c9e899a87e6b6bf800dffcb709585410",
"url": "https://api.github.com/repos/symfony/messenger/zipball/4070504211a16c4237247f7b2a91143c3a1f4e51",
"reference": "4070504211a16c4237247f7b2a91143c3a1f4e51",
"shasum": ""
},
"require": {
@@ -7441,7 +7442,7 @@
"description": "Helps applications send and receive messages to/from other applications or via message queues",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/messenger/tree/v7.4.20"
"source": "https://github.com/symfony/messenger/tree/v7.4.19"
},
"funding": [
{
@@ -7461,7 +7462,7 @@
"type": "tidelift"
}
],
"time": "2026-09-29T06:38:53+00:00"
"time": "2026-09-14T15:23:30+00:00"
},
{
"name": "symfony/mime",
@@ -8925,16 +8926,16 @@
},
{
"name": "symfony/property-info",
"version": "v7.4.20",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/property-info.git",
"reference": "d24c4c810b1285c5d8d77c6fd7b25e714479bd48"
"reference": "547d2acbe865d511809b96ab653304fd632abbaa"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/property-info/zipball/d24c4c810b1285c5d8d77c6fd7b25e714479bd48",
"reference": "d24c4c810b1285c5d8d77c6fd7b25e714479bd48",
"url": "https://api.github.com/repos/symfony/property-info/zipball/547d2acbe865d511809b96ab653304fd632abbaa",
"reference": "547d2acbe865d511809b96ab653304fd632abbaa",
"shasum": ""
},
"require": {
@@ -8991,7 +8992,7 @@
"validator"
],
"support": {
"source": "https://github.com/symfony/property-info/tree/v7.4.20"
"source": "https://github.com/symfony/property-info/tree/v7.4.19"
},
"funding": [
{
@@ -9011,7 +9012,7 @@
"type": "tidelift"
}
],
"time": "2026-09-28T09:47:41+00:00"
"time": "2026-09-04T10:11:42+00:00"
},
{
"name": "symfony/psr-http-message-bridge",
@@ -9103,16 +9104,16 @@
},
{
"name": "symfony/routing",
"version": "v7.4.20",
"version": "v7.4.18",
"source": {
"type": "git",
"url": "https://github.com/symfony/routing.git",
"reference": "2bacb72be5eadeaf9874d5accb75bfb33d21c74b"
"reference": "ddd558991e98f693ae6bf5063cc1b0362c6bbec3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/routing/zipball/2bacb72be5eadeaf9874d5accb75bfb33d21c74b",
"reference": "2bacb72be5eadeaf9874d5accb75bfb33d21c74b",
"url": "https://api.github.com/repos/symfony/routing/zipball/ddd558991e98f693ae6bf5063cc1b0362c6bbec3",
"reference": "ddd558991e98f693ae6bf5063cc1b0362c6bbec3",
"shasum": ""
},
"require": {
@@ -9164,7 +9165,7 @@
"url"
],
"support": {
"source": "https://github.com/symfony/routing/tree/v7.4.20"
"source": "https://github.com/symfony/routing/tree/v7.4.18"
},
"funding": [
{
@@ -9184,7 +9185,7 @@
"type": "tidelift"
}
],
"time": "2026-09-28T19:09:50+00:00"
"time": "2026-08-17T13:12:36+00:00"
},
{
"name": "symfony/runtime",
@@ -9273,16 +9274,16 @@
},
{
"name": "symfony/security-bundle",
"version": "v7.4.20",
"version": "v7.4.18",
"source": {
"type": "git",
"url": "https://github.com/symfony/security-bundle.git",
"reference": "a2a8dc7ab9d5d90d5937963a4ef1b9c8e3e7258c"
"reference": "caaa8b92f2657a6b99057f4da36ee89f3baab506"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/security-bundle/zipball/a2a8dc7ab9d5d90d5937963a4ef1b9c8e3e7258c",
"reference": "a2a8dc7ab9d5d90d5937963a4ef1b9c8e3e7258c",
"url": "https://api.github.com/repos/symfony/security-bundle/zipball/caaa8b92f2657a6b99057f4da36ee89f3baab506",
"reference": "caaa8b92f2657a6b99057f4da36ee89f3baab506",
"shasum": ""
},
"require": {
@@ -9323,7 +9324,6 @@
"symfony/framework-bundle": "^6.4.13|^7.1.6|^8.0",
"symfony/http-client": "^6.4|^7.0|^8.0",
"symfony/ldap": "^6.4|^7.0|^8.0",
"symfony/lock": "^6.4|^7.0|^8.0",
"symfony/process": "^6.4|^7.0|^8.0",
"symfony/rate-limiter": "^6.4|^7.0|^8.0",
"symfony/runtime": "^6.4.13|^7.1.6|^8.0",
@@ -9362,7 +9362,7 @@
"description": "Provides a tight integration of the Security component into the Symfony full-stack framework",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/security-bundle/tree/v7.4.20"
"source": "https://github.com/symfony/security-bundle/tree/v7.4.18"
},
"funding": [
{
@@ -9382,20 +9382,20 @@
"type": "tidelift"
}
],
"time": "2026-09-24T11:02:13+00:00"
"time": "2026-08-30T17:23:54+00:00"
},
{
"name": "symfony/security-core",
"version": "v7.4.20",
"version": "v7.4.18",
"source": {
"type": "git",
"url": "https://github.com/symfony/security-core.git",
"reference": "4e5c01656aa79e6d463fde45117041ebccbaf829"
"reference": "4fac226354298b8d66f680275b0daef1d3f580d2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/security-core/zipball/4e5c01656aa79e6d463fde45117041ebccbaf829",
"reference": "4e5c01656aa79e6d463fde45117041ebccbaf829",
"url": "https://api.github.com/repos/symfony/security-core/zipball/4fac226354298b8d66f680275b0daef1d3f580d2",
"reference": "4fac226354298b8d66f680275b0daef1d3f580d2",
"shasum": ""
},
"require": {
@@ -9454,7 +9454,7 @@
"description": "Symfony Security Component - Core Library",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/security-core/tree/v7.4.20"
"source": "https://github.com/symfony/security-core/tree/v7.4.18"
},
"funding": [
{
@@ -9474,7 +9474,7 @@
"type": "tidelift"
}
],
"time": "2026-09-28T16:00:27+00:00"
"time": "2026-08-30T00:47:26+00:00"
},
{
"name": "symfony/security-csrf",
@@ -9552,16 +9552,16 @@
},
{
"name": "symfony/security-http",
"version": "v7.4.20",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/security-http.git",
"reference": "5fa566b8bfbb398cded6988f059c3d97506ed692"
"reference": "c13182ec07b8a0c5316505a4c9c51948f87d87bb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/security-http/zipball/5fa566b8bfbb398cded6988f059c3d97506ed692",
"reference": "5fa566b8bfbb398cded6988f059c3d97506ed692",
"url": "https://api.github.com/repos/symfony/security-http/zipball/c13182ec07b8a0c5316505a4c9c51948f87d87bb",
"reference": "c13182ec07b8a0c5316505a4c9c51948f87d87bb",
"shasum": ""
},
"require": {
@@ -9620,7 +9620,7 @@
"description": "Symfony Security Component - HTTP Integration",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/security-http/tree/v7.4.20"
"source": "https://github.com/symfony/security-http/tree/v7.4.19"
},
"funding": [
{
@@ -9640,20 +9640,20 @@
"type": "tidelift"
}
],
"time": "2026-09-28T18:33:58+00:00"
"time": "2026-09-15T06:01:15+00:00"
},
{
"name": "symfony/serializer",
"version": "v7.4.20",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/serializer.git",
"reference": "67c5b0b2ef39863835f43b9c34dcb3af80b892cf"
"reference": "d631b82e0db971444651c4e6a076040d20878caa"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/serializer/zipball/67c5b0b2ef39863835f43b9c34dcb3af80b892cf",
"reference": "67c5b0b2ef39863835f43b9c34dcb3af80b892cf",
"url": "https://api.github.com/repos/symfony/serializer/zipball/d631b82e0db971444651c4e6a076040d20878caa",
"reference": "d631b82e0db971444651c4e6a076040d20878caa",
"shasum": ""
},
"require": {
@@ -9724,7 +9724,7 @@
"description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/serializer/tree/v7.4.20"
"source": "https://github.com/symfony/serializer/tree/v7.4.19"
},
"funding": [
{
@@ -9744,7 +9744,7 @@
"type": "tidelift"
}
],
"time": "2026-09-28T09:39:38+00:00"
"time": "2026-09-08T13:28:43+00:00"
},
{
"name": "symfony/service-contracts",
@@ -10452,16 +10452,16 @@
},
{
"name": "symfony/type-info",
"version": "v7.4.20",
"version": "v7.4.17",
"source": {
"type": "git",
"url": "https://github.com/symfony/type-info.git",
"reference": "bbaaf3366400f5c85f66800b955c77b46e8abad2"
"reference": "62d0ad6995630a4f7ea8fd270aadb3e2545c8e31"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/type-info/zipball/bbaaf3366400f5c85f66800b955c77b46e8abad2",
"reference": "bbaaf3366400f5c85f66800b955c77b46e8abad2",
"url": "https://api.github.com/repos/symfony/type-info/zipball/62d0ad6995630a4f7ea8fd270aadb3e2545c8e31",
"reference": "62d0ad6995630a4f7ea8fd270aadb3e2545c8e31",
"shasum": ""
},
"require": {
@@ -10511,7 +10511,7 @@
"type"
],
"support": {
"source": "https://github.com/symfony/type-info/tree/v7.4.20"
"source": "https://github.com/symfony/type-info/tree/v7.4.17"
},
"funding": [
{
@@ -10531,20 +10531,20 @@
"type": "tidelift"
}
],
"time": "2026-09-28T12:19:37+00:00"
"time": "2026-08-21T15:52:48+00:00"
},
{
"name": "symfony/uid",
"version": "v7.4.20",
"version": "v7.4.17",
"source": {
"type": "git",
"url": "https://github.com/symfony/uid.git",
"reference": "a96bbdb02a04ff3b5592cee6741b03edc78b56aa"
"reference": "69d732355a139c6f8881337d28515aa01f12b8be"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/uid/zipball/a96bbdb02a04ff3b5592cee6741b03edc78b56aa",
"reference": "a96bbdb02a04ff3b5592cee6741b03edc78b56aa",
"url": "https://api.github.com/repos/symfony/uid/zipball/69d732355a139c6f8881337d28515aa01f12b8be",
"reference": "69d732355a139c6f8881337d28515aa01f12b8be",
"shasum": ""
},
"require": {
@@ -10589,7 +10589,7 @@
"uuid"
],
"support": {
"source": "https://github.com/symfony/uid/tree/v7.4.20"
"source": "https://github.com/symfony/uid/tree/v7.4.17"
},
"funding": [
{
@@ -10609,7 +10609,7 @@
"type": "tidelift"
}
],
"time": "2026-09-23T16:18:28+00:00"
"time": "2026-08-11T07:38:58+00:00"
},
{
"name": "symfony/ux-twig-component",
@@ -10702,16 +10702,16 @@
},
{
"name": "symfony/validator",
"version": "v7.4.20",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/validator.git",
"reference": "75a30e116016554477f662e106fd8e337f0c4dac"
"reference": "a6d275d96d107fb2000dae04ab23b2e0c29ceb66"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/validator/zipball/75a30e116016554477f662e106fd8e337f0c4dac",
"reference": "75a30e116016554477f662e106fd8e337f0c4dac",
"url": "https://api.github.com/repos/symfony/validator/zipball/a6d275d96d107fb2000dae04ab23b2e0c29ceb66",
"reference": "a6d275d96d107fb2000dae04ab23b2e0c29ceb66",
"shasum": ""
},
"require": {
@@ -10782,7 +10782,7 @@
"description": "Provides tools to validate values",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/validator/tree/v7.4.20"
"source": "https://github.com/symfony/validator/tree/v7.4.19"
},
"funding": [
{
@@ -10802,7 +10802,7 @@
"type": "tidelift"
}
],
"time": "2026-09-29T12:35:16+00:00"
"time": "2026-09-09T19:50:33+00:00"
},
{
"name": "symfony/var-dumper",
@@ -11137,16 +11137,16 @@
},
{
"name": "symfony/yaml",
"version": "v7.4.20",
"version": "v7.4.18",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
"reference": "0222bf4dfaa1e3ceb8fff579635d655f2950d5a7"
"reference": "4cef939e55b8a21c5780418de0d98ab00d0736e1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/yaml/zipball/0222bf4dfaa1e3ceb8fff579635d655f2950d5a7",
"reference": "0222bf4dfaa1e3ceb8fff579635d655f2950d5a7",
"url": "https://api.github.com/repos/symfony/yaml/zipball/4cef939e55b8a21c5780418de0d98ab00d0736e1",
"reference": "4cef939e55b8a21c5780418de0d98ab00d0736e1",
"shasum": ""
},
"require": {
@@ -11189,7 +11189,7 @@
"description": "Loads and dumps YAML files",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/yaml/tree/v7.4.20"
"source": "https://github.com/symfony/yaml/tree/v7.4.18"
},
"funding": [
{
@@ -11209,7 +11209,7 @@
"type": "tidelift"
}
],
"time": "2026-09-26T06:31:10+00:00"
"time": "2026-08-30T00:47:26+00:00"
},
{
"name": "twig/extra-bundle",
@@ -13055,16 +13055,16 @@
},
{
"name": "phpunit/phpunit",
"version": "12.5.37",
"version": "12.5.36",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "684340c1590e5f3f3b859f5e80619725d88b63ba"
"reference": "ddb775588a0ea447d1c9b190c0f29c174ce81d65"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/684340c1590e5f3f3b859f5e80619725d88b63ba",
"reference": "684340c1590e5f3f3b859f5e80619725d88b63ba",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/ddb775588a0ea447d1c9b190c0f29c174ce81d65",
"reference": "ddb775588a0ea447d1c9b190c0f29c174ce81d65",
"shasum": ""
},
"require": {
@@ -13133,7 +13133,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.37"
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.36"
},
"funding": [
{
@@ -13141,7 +13141,7 @@
"type": "other"
}
],
"time": "2026-09-29T12:08:54+00:00"
"time": "2026-09-25T08:47:40+00:00"
},
{
"name": "react/cache",
@@ -15103,16 +15103,16 @@
},
{
"name": "symfony/web-profiler-bundle",
"version": "v7.4.20",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/web-profiler-bundle.git",
"reference": "916c2a4edf51270a5173a57d8d3936c02a4543a1"
"reference": "f9c5fed39f307b23c47e8a11c431d848390f8a67"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/916c2a4edf51270a5173a57d8d3936c02a4543a1",
"reference": "916c2a4edf51270a5173a57d8d3936c02a4543a1",
"url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/f9c5fed39f307b23c47e8a11c431d848390f8a67",
"reference": "f9c5fed39f307b23c47e8a11c431d848390f8a67",
"shasum": ""
},
"require": {
@@ -15169,7 +15169,7 @@
"dev"
],
"support": {
"source": "https://github.com/symfony/web-profiler-bundle/tree/v7.4.20"
"source": "https://github.com/symfony/web-profiler-bundle/tree/v7.4.19"
},
"funding": [
{
@@ -15189,7 +15189,7 @@
"type": "tidelift"
}
],
"time": "2026-09-28T19:09:50+00:00"
"time": "2026-09-04T10:11:42+00:00"
},
{
"name": "theseer/tokenizer",
-199
View File
@@ -1,199 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\User;
use App\Repository\UserRepository;
use App\Security\Role;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Removes local accounts that carry nothing worth keeping.
*
* Nothing here destroys customer data: BusPro owns the person, the bookings and the roles, and
* BpnAuthenticator rebuilds the local row on the next login (`findOneBy(email) ?? new User()`),
* re-deriving the roles, the hotel codes and the profile completeness from the CRM as it goes.
* An account is a cache of what BusPro says, so dropping one costs its owner nothing.
*
* One thing is not rebuilt, and it is the reason every removal gets a log line of its own: the
* uuid, minted in the constructor and exported as the OAuth2 `sub` claim (see UserinfoController).
* A returning account gets a fresh one, so the old subject cannot be tied to the new row by
* anything except that log entry. The SSO clients that gate on a role — E&P Team, E&P Hub — never
* see these accounts anyway, since the roles they require are exactly the ones excluded below.
*
* An account is removed when all of the following hold:
*
* - it holds no effective role beyond ROLE_CUSTOMER, that role being the fallback for an account
* with no privileges at all (see Role::withCustomerFallback()). An account left with no roles
* at all is the same state, spelled the way it was before the fallback landed;
* - it carries no pending nomination, which would otherwise disappear from /admin/user before
* anybody had ruled on it;
* - it holds no booking edit draft, and nothing in the Groups subsystem names it.
*
* --inactive-for adds a "has not signed in for N months" condition on top. It is off by default:
* a returning customer is recreated on sight, so age says nothing about whether a row may go.
*
* Deleting requires --force. The removal cannot be undone and the first run clears thousands of
* rows, so a bare invocation reports what it would do and stops. The guard is a flag rather than
* an $io->confirm() because the scheduler hands the command a StringInput, which reports itself
* as interactive — a confirmation would sit waiting on a STDIN nobody is attached to. A flag
* behaves the same way whether a person or cron is running it, and it makes the schedule entry
* say out loud that the task destroys data.
*/
#[AsCommand(
name: 'app:cleanup:users',
description: 'Removes accounts that hold no role, no draft and no group booking reference',
)]
class CleanupUsersCommand extends Command
{
/**
* How many accounts are removed between two flushes. The unit of work holds every scheduled
* removal until then, and the first run has thousands of rows to get through, so it is a
* memory bound rather than a policy one.
*/
private const BATCH_SIZE = 500;
public function __construct(
private readonly UserRepository $userRepository,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption(
'inactive-for',
null,
InputOption::VALUE_REQUIRED,
'Only consider accounts without a login for this many months (default: no such limit)',
)
->addOption(
'limit',
null,
InputOption::VALUE_REQUIRED,
'Maximum number of accounts to delete in one run (default: no limit)',
)
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be deleted without actually deleting')
->addOption('force', null, InputOption::VALUE_NONE, 'Actually delete; without it the command only reports what it would do')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$months = null !== $input->getOption('inactive-for') ? (int) $input->getOption('inactive-for') : null;
$limit = null !== $input->getOption('limit') ? (int) $input->getOption('limit') : null;
$dryRun = (bool) $input->getOption('dry-run');
$force = (bool) $input->getOption('force');
if ((null !== $months && $months < 1) || (null !== $limit && $limit < 1)) {
$io->error('--inactive-for and --limit have to be at least 1 when given.');
return Command::INVALID;
}
$inactiveSince = null !== $months ? new \DateTimeImmutable(sprintf('-%d months', $months)) : null;
$candidates = array_values(array_filter(
$this->userRepository->findCleanupCandidates($inactiveSince),
static fn (User $user): bool => self::isDisposable($user),
));
// After the role filter, never before it: --limit caps the accounts actually deleted, and
// the candidate query cannot tell which ones those are.
if (null !== $limit) {
$candidates = \array_slice($candidates, 0, $limit);
}
if ([] === $candidates) {
$io->success('No accounts to remove.');
return Command::SUCCESS;
}
if (true === $dryRun || false === $force) {
$io->table(
['E-Mail', 'Letzter Login', 'Rollen'],
array_map(static fn (User $user): array => [
$user->getEmail(),
$user->getLastLoginAt()?->format('Y-m-d') ?? '-',
implode(', ', Role::assignedOnly($user->getRoles())) ?: '-',
], $candidates),
);
$io->note(sprintf('Would delete %d account(s).', \count($candidates)));
if (true === $dryRun) {
return Command::SUCCESS;
}
// Not a dry run, just an invocation that forgot to say so. Reported as a failure so a
// schedule entry missing --force turns into a mail rather than a task that quietly
// does nothing every week.
$io->error('Refusing to delete without --force. Pass --dry-run to preview, or --force to delete.');
return Command::INVALID;
}
foreach ($candidates as $index => $user) {
// One line per account, before the removal rather than after: once the row is gone
// this is the only place left that records which account the uuid belonged to. Worth
// a log_entry row per deletion, including the several thousand the first run writes —
// they age out with the six-month log retention.
$this->logger->info('Deleting account without roles', [
'uuid' => $user->getUuid(),
'email' => $user->getEmail(),
'lastLoginAt' => $user->getLastLoginAt()?->format(\DateTimeInterface::ATOM),
'roles' => Role::assignedOnly($user->getRoles()),
]);
$this->entityManager->remove($user);
if (0 === ($index + 1) % self::BATCH_SIZE) {
$this->entityManager->flush();
}
}
$this->entityManager->flush();
$count = \count($candidates);
$this->logger->info('Deleted accounts without roles', [
'count' => $count,
'inactiveSince' => $inactiveSince?->format('Y-m-d'),
]);
$io->success(sprintf('Deleted %d account(s).', $count));
return Command::SUCCESS;
}
/**
* Whether the account's role set says it may go. ROLE_CUSTOMER is the fallback rather than a
* baseline, so "customer only" and "nothing at all" are the same state — the second being the
* spelling accounts kept that have not signed in since the fallback landed.
*
* Pending markers grant nothing, but they are a nomination waiting for an administrator, so an
* account carrying one stays until somebody has ruled on it.
*/
private static function isDisposable(User $user): bool
{
$roles = $user->getRoles();
return [] === Role::pendingOnly($roles)
&& [] === array_diff(Role::effectiveOnly($roles), [Role::CUSTOMER]);
}
}
-88
View File
@@ -4,12 +4,7 @@ declare(strict_types=1);
namespace App\Repository;
use App\Entity\BookingEditDraft;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Entity\Groups\AccommodationPrice;
use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService;
use App\Entity\User;
use App\Form\Model\Filter\UserFilterDto;
use App\Repository\Filter\AppliesListFiltersTrait;
@@ -127,87 +122,4 @@ class UserRepository extends ServiceEntityRepository
return $users;
}
/**
* The entities that point at a User and would silently lose that pointer if the account were
* deleted. Every one of these join columns is ON DELETE SET NULL, so the database does not
* stand in the way — it just erases who created, last touched or looks after a group booking.
* The cleanup therefore has to ask, rather than let the constraint decide.
*
* @var array<class-string, string[]>
*/
private const USER_REFERENCES = [
Accommodation::class => ['createdBy', 'updatedBy'],
AccommodationPrice::class => ['createdBy', 'updatedBy'],
AccommodationBooking::class => ['createdBy', 'updatedBy', 'managedBy'],
AdditionalService::class => ['createdBy', 'updatedBy'],
BoardService::class => ['createdBy', 'updatedBy'],
];
/**
* Accounts the cleanup may consider: holding no booking edit draft and named by nothing in the
* Groups subsystem. $inactiveSince narrows that to accounts dormant since then, and is the
* caller's choice rather than this method's default — BpnAuthenticator recreates an account on
* sight, so how long ago somebody last signed in says nothing about whether the row may go.
*
* Deliberately unbounded: the caller still has to drop everything whose roles disqualify it,
* so a LIMIT here would cap the rows examined rather than the rows acted on, and a caller
* asking for ten deletions would get however many of the first ten candidates survived the
* role filter. Ordered oldest login first so a caller that does cap the result takes the
* least recently seen accounts.
*
* The roles are deliberately not part of this query. They live in a JSON array column with no
* JSON_CONTAINS registered, and the column holds more than one spelling of the same set — rows
* written by different Doctrine versions differ in whitespace — so a LIKE or an equality test
* on the raw JSON would quietly miss accounts or, far worse, match the wrong ones. The caller
* filters the shortlist through Role::effectiveOnly(), which is the only thing that knows what
* a role set actually means.
*
* @return User[]
*/
public function findCleanupCandidates(?\DateTimeImmutable $inactiveSince = null): array
{
$qb = $this->createQueryBuilder('u');
if (null !== $inactiveSince) {
$qb
->andWhere('u.lastLoginAt IS NOT NULL')
->andWhere('u.lastLoginAt < :inactiveSince')
->setParameter('inactiveSince', $inactiveSince)
;
}
$qb->andWhere($qb->expr()->not($qb->expr()->exists(
$this->getEntityManager()->createQueryBuilder()
->select('draft.id')
->from(BookingEditDraft::class, 'draft')
->andWhere('draft.user = u')
->getDQL()
)));
$index = 0;
foreach (self::USER_REFERENCES as $class => $fields) {
foreach ($fields as $field) {
$alias = sprintf('ref%d%s', $index++, $field);
$qb->andWhere($qb->expr()->not($qb->expr()->exists(
$this->getEntityManager()->createQueryBuilder()
->select($alias.'.id')
->from($class, $alias)
->andWhere(sprintf('%s.%s = u', $alias, $field))
->getDQL()
)));
}
}
/** @var User[] $users */
$users = $qb
->orderBy('u.lastLoginAt', 'ASC')
->getQuery()
->getResult()
;
return $users;
}
}
-201
View File
@@ -1,201 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Command;
use App\Command\CleanupUsersCommand;
use App\Entity\User;
use App\Repository\UserRepository;
use App\Security\Role;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
class CleanupUsersCommandTest extends TestCase
{
public function testDeletesAccountsWithoutAnEffectiveRole(): void
{
$customer = $this->user('[email protected]', [Role::CUSTOMER]);
$roleless = $this->user('[email protected]', []);
$entityManager = $this->createMock(EntityManagerInterface::class);
$removed = [];
$entityManager->expects(self::exactly(2))
->method('remove')
->willReturnCallback(static function (User $user) use (&$removed): void {
$removed[] = $user->getEmail();
});
$entityManager->expects(self::once())->method('flush');
$tester = new CommandTester($this->command([$customer, $roleless], $entityManager));
$tester->execute(['--force' => true]);
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
self::assertSame(['[email protected]', '[email protected]'], $removed);
self::assertStringContainsString('Deleted 2 account(s).', $tester->getDisplay());
}
/**
* The candidate query cannot read the roles, so everything the role policy protects has to
* survive this filter — including an account whose only effective role sits beside the
* ROLE_CUSTOMER fallback that older rows still carry.
*
* @param string[] $roles
*/
#[DataProvider('protectedRoleSets')]
public function testKeepsAccountsHoldingAnythingBeyondTheCustomerFallback(array $roles): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::never())->method('remove');
$tester = new CommandTester($this->command([$this->user('[email protected]', $roles)], $entityManager));
$tester->execute(['--force' => true]);
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
self::assertStringContainsString('No accounts to remove.', $tester->getDisplay());
}
/**
* @return iterable<string, array{string[]}>
*/
public static function protectedRoleSets(): iterable
{
yield 'teamer' => [[Role::TEAMER]];
yield 'employee' => [[Role::EMPLOYEE]];
yield 'approved administrative role' => [[Role::TEAMER, Role::MANAGER]];
yield 'house manager' => [[Role::HOUSE_MANAGER]];
yield 'pending nomination awaiting approval' => [[Role::CUSTOMER, Role::pending(Role::ADMIN)]];
yield 'stale fallback beside a real role' => [[Role::CUSTOMER, Role::TEAMER]];
}
/**
* The whole point of the guard: a bare invocation is a preview, not a deletion. Symfony's
* VALUE_REQUIRED only says an option needs a value when it is given, so nothing in the
* definition ever forces the caller to state an intent — this does.
*/
public function testRefusesToDeleteWithoutForce(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::never())->method('remove');
$entityManager->expects(self::never())->method('flush');
$tester = new CommandTester($this->command([$this->user('[email protected]', [Role::CUSTOMER])], $entityManager));
$tester->execute([]);
self::assertSame(Command::INVALID, $tester->getStatusCode());
self::assertStringContainsString('Would delete 1 account(s).', $tester->getDisplay());
self::assertStringContainsString('Refusing to delete without --force', $tester->getDisplay());
}
public function testDryRunDeletesNothing(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::never())->method('remove');
$entityManager->expects(self::never())->method('flush');
$tester = new CommandTester($this->command([$this->user('[email protected]', [Role::CUSTOMER])], $entityManager));
$tester->execute(['--dry-run' => true]);
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
self::assertStringContainsString('Would delete 1 account(s).', $tester->getDisplay());
self::assertStringNotContainsString('Refusing to delete', $tester->getDisplay());
self::assertStringContainsString('[email protected]', $tester->getDisplay());
}
/**
* --limit caps the deletions, not the candidates examined, so a run that finds one deletable
* account among protected ones still deletes it rather than spending the budget on rows it
* then discards.
*/
public function testLimitCountsDeletionsRatherThanCandidates(): void
{
$users = [
$this->user('[email protected]', [Role::TEAMER]),
$this->user('[email protected]', [Role::CUSTOMER]),
];
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::once())
->method('remove')
->with(self::callback(static fn (User $user): bool => '[email protected]' === $user->getEmail()));
$tester = new CommandTester($this->command($users, $entityManager));
$tester->execute(['--limit' => 1, '--force' => true]);
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
self::assertStringContainsString('Deleted 1 account(s).', $tester->getDisplay());
}
public function testInactivityWindowIsOffUnlessAskedFor(): void
{
$repository = $this->createMock(UserRepository::class);
$repository->expects(self::once())
->method('findCleanupCandidates')
->with(null)
->willReturn([]);
$tester = new CommandTester($this->commandWith($repository, $this->createStub(EntityManagerInterface::class)));
$tester->execute(['--force' => true]);
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
}
public function testInactivityWindowIsPassedOnAsADate(): void
{
$repository = $this->createMock(UserRepository::class);
$repository->expects(self::once())
->method('findCleanupCandidates')
->with(self::callback(static function (?\DateTimeImmutable $since): bool {
$expected = new \DateTimeImmutable('-6 months');
return null !== $since && $since->format('Y-m-d') === $expected->format('Y-m-d');
}))
->willReturn([]);
$tester = new CommandTester($this->commandWith($repository, $this->createStub(EntityManagerInterface::class)));
$tester->execute(['--inactive-for' => 6, '--force' => true]);
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
}
public function testRejectsANonPositiveWindow(): void
{
$repository = $this->createMock(UserRepository::class);
$repository->expects(self::never())->method('findCleanupCandidates');
$tester = new CommandTester($this->commandWith($repository, $this->createStub(EntityManagerInterface::class)));
$tester->execute(['--inactive-for' => 0]);
self::assertSame(Command::INVALID, $tester->getStatusCode());
}
/**
* @param string[] $roles
*/
private function user(string $email, array $roles): User
{
return (new User($email))
->setRoles($roles)
->setLastLoginAt(new \DateTimeImmutable('-2 years'));
}
/**
* @param User[] $candidates
*/
private function command(array $candidates, EntityManagerInterface $entityManager): CleanupUsersCommand
{
$repository = $this->createStub(UserRepository::class);
$repository->method('findCleanupCandidates')->willReturn($candidates);
return $this->commandWith($repository, $entityManager);
}
private function commandWith(UserRepository $repository, EntityManagerInterface $entityManager): CleanupUsersCommand
{
return new CleanupUsersCommand($repository, $entityManager, $this->createStub(LoggerInterface::class));
}
}