From 5337120a24240094bbb9334db4deb57c59290157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Oester?= Date: Tue, 20 Oct 2026 17:35:10 +0200 Subject: [PATCH] feat(moderation): a player reports a drawing and the operator undoes it - support.v1.SupportService/ReportPlace: a player taps a tile of the drawing and picks a reason; ListPlaceReports and DecidePlaceReport for the operator. The reporter goes with its account, the notice stays. Decisions are one rule for both kinds of report - planet.v1.AdminService/RestoreArea: who painted the area around a tile since a time, and puts it back as it was then, dry run first - planet.v1.AdminService/ClearArea: gives the area to nobody, for a drawing older than the ledger keeps - The game: "Report a drawing" in Settings, then a tap on the globe; ?tile= opens the globe close up on a tile Co-Authored-By: Claude Opus 5.5 --- apps/backend/CLAUDE.md | 73 +- apps/backend/e2e/map_drawing_test.go | 160 +++++ .../generated/proto/planet/v1/admin.pb.go | 625 +++++++++++++----- .../v1/planetv1connect/admin.connect.go | 61 ++ .../generated/proto/support/v1/admin.pb.go | 433 ++++++++++-- .../generated/proto/support/v1/support.pb.go | 238 ++++++- .../v1/supportv1connect/admin.connect.go | 68 +- .../v1/supportv1connect/support.connect.go | 31 + .../internal/planet/internal/clicks/areas.go | 49 ++ .../planet/internal/clicks/areas_test.go | 44 ++ .../audit_clear_area/audit.go | 39 ++ .../audit_clear_area/audit_test.go | 49 ++ .../usecases/clear_area_usecase/usecase.go | 77 +++ .../clear_area_usecase/usecase_test.go | 68 ++ .../internal/planet/internal/ledger/rewind.go | 52 ++ .../planet/internal/ledger/rewind_test.go | 65 ++ .../audit_restore_area/audit.go | 42 ++ .../audit_restore_area/audit_test.go | 50 ++ .../usecases/restore_area_usecase/usecase.go | 121 ++++ .../restore_area_usecase/usecase_test.go | 170 +++++ .../planetv1controller/admin_service.go | 4 + .../clear_area_handler/handler.go | 46 ++ .../clear_area_handler/handler_test.go | 61 ++ .../restore_area_handler/handler.go | 57 ++ .../restore_area_handler/handler_test.go | 83 +++ apps/backend/internal/planet/module.go | 14 +- .../support/internal/decisions/decision.go | 38 +- .../internal/decisions/decision_test.go | 24 +- .../postgres_decision_store/store.go | 45 +- .../postgres_decision_store/store_test.go | 39 +- .../audit_decide_report/audit.go | 27 +- .../audit_decide_report/audit_test.go | 10 +- .../usecases/decide_report_usecase/usecase.go | 19 +- .../decide_report_usecase/usecase_test.go | 16 +- .../20261020120000_place_reports.down.sql | 5 + .../20261020120000_place_reports.up.sql | 21 + .../internal/support/internal/places/place.go | 153 +++++ .../support/internal/places/place_test.go | 97 +++ .../postgres_place_report_store/store.go | 113 ++++ .../postgres_place_report_store/store_test.go | 105 +++ .../usecase.go | 32 + .../usecase_test.go | 54 ++ .../prune_place_reports_usecase/usecase.go | 53 ++ .../usecase_test.go | 62 ++ .../usecases/report_place_usecase/usecase.go | 52 ++ .../report_place_usecase/usecase_test.go | 83 +++ .../places/uuid_id_provider/provider.go | 21 + .../places/uuid_id_provider/provider_test.go | 21 + .../support/internal/reports/report.go | 4 + .../decide_place_report_handler/handler.go | 55 ++ .../handler_test.go | 80 +++ .../decide_report_handler/handler.go | 12 +- .../decide_report_handler/handler_test.go | 22 +- .../supportv1controller/interceptors.go | 2 + .../list_place_reports_handler/handler.go | 38 ++ .../handler_test.go | 71 ++ .../place_reports_query/query_postgres.go | 167 +++++ .../query_postgres_test.go | 164 +++++ .../reports_query/query_postgres_test.go | 2 +- .../report_place_handler/handler.go | 60 ++ .../report_place_handler/handler_test.go | 93 +++ .../reportoutcome/outcome.go | 16 + .../reportoutcome/outcome_test.go | 28 + .../supportv1controller/support_service.go | 6 + apps/backend/internal/support/module.go | 35 +- apps/backoffice/src/fakeAdmin.ts | 12 + .../src/gen/grpc/planet/v1/admin_connect.ts | 24 +- .../src/gen/grpc/planet/v1/admin_pb.ts | 242 +++++++ .../src/gen/grpc/support/v1/admin_connect.ts | 24 +- .../src/gen/grpc/support/v1/admin_pb.ts | 267 +++++++- .../gen/grpc/support/v1/support_connect.ts | 13 +- .../src/gen/grpc/support/v1/support_pb.ts | 118 ++++ apps/frontend/CLAUDE.md | 11 + apps/frontend/src/app/App.tsx | 1 + .../src/app/settings/SettingsPlace.tsx | 12 +- .../frontend/src/app/support/PlacePicking.css | 21 + .../frontend/src/app/support/PlacePicking.tsx | 17 + .../src/app/support/ReportPlaceModal.test.tsx | 55 ++ .../src/app/support/ReportPlaceModal.tsx | 34 + apps/frontend/src/app/viewer/Viewer.css | 3 +- apps/frontend/src/app/viewer/Viewer.tsx | 19 + apps/frontend/src/app/viewer/globe.ts | 47 +- .../frontend/src/app/viewer/useGlobe.test.tsx | 2 +- apps/frontend/src/app/viewer/useGlobe.ts | 13 +- .../src/backends/fakeSupportBackend.ts | 7 + apps/frontend/src/backends/support.ts | 15 +- apps/frontend/src/backends/supportBackend.ts | 18 +- apps/frontend/src/domain/tileLink.test.ts | 22 + apps/frontend/src/domain/tileLink.ts | 16 + .../src/gen/grpc/planet/v1/admin_connect.ts | 24 +- .../src/gen/grpc/planet/v1/admin_pb.ts | 242 +++++++ .../src/gen/grpc/support/v1/admin_connect.ts | 24 +- .../src/gen/grpc/support/v1/admin_pb.ts | 267 +++++++- .../gen/grpc/support/v1/support_connect.ts | 13 +- .../src/gen/grpc/support/v1/support_pb.ts | 118 ++++ apps/frontend/src/main.tsx | 8 +- deploy/vps/README.md | 19 + proto/planet/v1/admin.proto | 45 ++ proto/support/v1/admin.proto | 44 ++ proto/support/v1/support.proto | 21 + 100 files changed, 6194 insertions(+), 369 deletions(-) create mode 100644 apps/backend/e2e/map_drawing_test.go create mode 100644 apps/backend/internal/planet/internal/clicks/areas.go create mode 100644 apps/backend/internal/planet/internal/clicks/areas_test.go create mode 100644 apps/backend/internal/planet/internal/clicks/usecases/clear_area_usecase/audit_clear_area/audit.go create mode 100644 apps/backend/internal/planet/internal/clicks/usecases/clear_area_usecase/audit_clear_area/audit_test.go create mode 100644 apps/backend/internal/planet/internal/clicks/usecases/clear_area_usecase/usecase.go create mode 100644 apps/backend/internal/planet/internal/clicks/usecases/clear_area_usecase/usecase_test.go create mode 100644 apps/backend/internal/planet/internal/ledger/rewind.go create mode 100644 apps/backend/internal/planet/internal/ledger/rewind_test.go create mode 100644 apps/backend/internal/planet/internal/ledger/usecases/restore_area_usecase/audit_restore_area/audit.go create mode 100644 apps/backend/internal/planet/internal/ledger/usecases/restore_area_usecase/audit_restore_area/audit_test.go create mode 100644 apps/backend/internal/planet/internal/ledger/usecases/restore_area_usecase/usecase.go create mode 100644 apps/backend/internal/planet/internal/ledger/usecases/restore_area_usecase/usecase_test.go create mode 100644 apps/backend/internal/planet/internal/planetv1controller/clear_area_handler/handler.go create mode 100644 apps/backend/internal/planet/internal/planetv1controller/clear_area_handler/handler_test.go create mode 100644 apps/backend/internal/planet/internal/planetv1controller/restore_area_handler/handler.go create mode 100644 apps/backend/internal/planet/internal/planetv1controller/restore_area_handler/handler_test.go create mode 100644 apps/backend/internal/support/internal/migrations/20261020120000_place_reports.down.sql create mode 100644 apps/backend/internal/support/internal/migrations/20261020120000_place_reports.up.sql create mode 100644 apps/backend/internal/support/internal/places/place.go create mode 100644 apps/backend/internal/support/internal/places/place_test.go create mode 100644 apps/backend/internal/support/internal/places/postgres_place_report_store/store.go create mode 100644 apps/backend/internal/support/internal/places/postgres_place_report_store/store_test.go create mode 100644 apps/backend/internal/support/internal/places/usecases/anonymize_place_reports_usecase/usecase.go create mode 100644 apps/backend/internal/support/internal/places/usecases/anonymize_place_reports_usecase/usecase_test.go create mode 100644 apps/backend/internal/support/internal/places/usecases/prune_place_reports_usecase/usecase.go create mode 100644 apps/backend/internal/support/internal/places/usecases/prune_place_reports_usecase/usecase_test.go create mode 100644 apps/backend/internal/support/internal/places/usecases/report_place_usecase/usecase.go create mode 100644 apps/backend/internal/support/internal/places/usecases/report_place_usecase/usecase_test.go create mode 100644 apps/backend/internal/support/internal/places/uuid_id_provider/provider.go create mode 100644 apps/backend/internal/support/internal/places/uuid_id_provider/provider_test.go create mode 100644 apps/backend/internal/support/internal/supportv1controller/decide_place_report_handler/handler.go create mode 100644 apps/backend/internal/support/internal/supportv1controller/decide_place_report_handler/handler_test.go create mode 100644 apps/backend/internal/support/internal/supportv1controller/list_place_reports_handler/handler.go create mode 100644 apps/backend/internal/support/internal/supportv1controller/list_place_reports_handler/handler_test.go create mode 100644 apps/backend/internal/support/internal/supportv1controller/list_place_reports_handler/place_reports_query/query_postgres.go create mode 100644 apps/backend/internal/support/internal/supportv1controller/list_place_reports_handler/place_reports_query/query_postgres_test.go create mode 100644 apps/backend/internal/support/internal/supportv1controller/report_place_handler/handler.go create mode 100644 apps/backend/internal/support/internal/supportv1controller/report_place_handler/handler_test.go create mode 100644 apps/backend/internal/support/internal/supportv1controller/reportoutcome/outcome.go create mode 100644 apps/backend/internal/support/internal/supportv1controller/reportoutcome/outcome_test.go create mode 100644 apps/frontend/src/app/support/PlacePicking.css create mode 100644 apps/frontend/src/app/support/PlacePicking.tsx create mode 100644 apps/frontend/src/app/support/ReportPlaceModal.test.tsx create mode 100644 apps/frontend/src/app/support/ReportPlaceModal.tsx create mode 100644 apps/frontend/src/domain/tileLink.test.ts create mode 100644 apps/frontend/src/domain/tileLink.ts diff --git a/apps/backend/CLAUDE.md b/apps/backend/CLAUDE.md index 0220cd19e..b173cb892 100644 --- a/apps/backend/CLAUDE.md +++ b/apps/backend/CLAUDE.md @@ -317,7 +317,7 @@ The events today: | `planet.v1.TileTaken{account_id, tile_id, country, taken_at}` | `planet`, `ledger/publishing_ledger_storage` | each take the ledger records with an account: a click, a spread or an enclose, one event per tile | `player`, for the stats; `seasons`, for the standings | | `planet.v1.BombLanded{country, tile_id, ground, cleared, landed_at}` | `planet`, `drop_bomb_usecase/publishing_drop_bomb` | each bomb that went off, on land or in the sea; a refused drop and a dud publish nothing | `chat`, which announces it | | `planet.v1.Fortified{country, landmass_id, tiles, ground, fortified_at}` | `planet`, `relay_fortifications_usecase` | each landmass a player's take fortified (see [Fortify](#fortify)) | `chat`, which announces the ones of 50 tiles or more | -| `auth.v1.AccountDeleted{account_id}` | `auth`, `delete_account_usecase` and `prune_guests_usecase` | after the account row is deleted; a pruned guest is a deleted account | `player`, which forgets the profile, the stats, the titles, the title worn and the visit; `seasons`, which forgets the account's standings; `planet`, which takes the account off every take postgres keeps; `chat`, which forgets the seen mark; `support`, which takes the account off the notes it sent and deletes the reports that name it | +| `auth.v1.AccountDeleted{account_id}` | `auth`, `delete_account_usecase` and `prune_guests_usecase` | after the account row is deleted; a pruned guest is a deleted account | `player`, which forgets the profile, the stats, the titles, the title worn and the visit; `seasons`, which forgets the account's standings; `planet`, which takes the account off every take postgres keeps; `chat`, which forgets the seen mark; `support`, which takes the account off the notes it sent and the places it reported, and deletes the reports that name it | | `auth.v1.SignedIn{previous_account_id, account_id}` | `auth`, `signin.Admitter`, for `complete_sign_in_usecase` and `complete_email_sign_in_usecase` | after a sign-in or a link is saved; `previous_account_id` is empty for a browser that had no live session | `player`, which moves the browser's visit to the account, and gives the account a username when it has none | | `auth.v1.SignedOut{account_id}` | `auth`, `sign_out_usecase` and `sign_out_everywhere_usecase` | after the session, or every session, is deleted; a cookie with no session publishes nothing | `player`, which takes the account off the roster | | `chat.v1.MessageSent{message_id, account_id, sent_at}` | `chat`, `send_message_usecase/publishing_send_message` | after each message is kept; a refused or failed post publishes nothing | `player`, for the stats | @@ -363,8 +363,9 @@ internal/planet/internal/ port: `Board` (which tile ids exist), `Toll` (what a click costs), `Outcome` and `Shielding` (what a click does to a tile and its shields), `Claiming` (a player's click written: the strike, then the owner it leaves, as an `Impact`), `Pacing` (how an - operator's bulk change is spread out), `Geography` and `Borders`. -- **`ledger/`** — every act of a player on the map, oldest first. Its root holds `Event` and its kinds (`Taking`, `Striking`, `Spreading`, `Enclosing`, `Bombing`, `Shielding`), `Entry`, `Scene`, `Footage`, `Player`, `Tally`, `Runs`, + operator's bulk change is spread out), `Areas` and `Emptying` (an area around a tile, and giving it to nobody), + `Geography` and `Borders`. +- **`ledger/`** — every act of a player on the map, oldest first. Its root holds `Event` and its kinds (`Taking`, `Striking`, `Spreading`, `Enclosing`, `Bombing`, `Shielding`), `Entry`, `Scene`, `Footage`, `Rewind` and `Reach` (an area as it was at a time, and how far back memory goes), `Player`, `Tally`, `Runs`, the `Storage` port, `Recording` (the tile writer that records each act) and `Retention`. `FindPlayers`, `TopPlayers`, `BanPlayer`, `UnbanPlayer` and `RevertPlayer` live here: they are one moderation workflow — find, ban, undo. **The postgres copy is the whole history**: the memory keeps 72h, while `ledger_events` keeps every event and @@ -418,6 +419,8 @@ because it serves every concept over one Connect service. It only maps. | `ledger/usecases/revert_player_usecase` | gives back what one caller still holds | `Ledger`, `Map` | | `ledger/usecases/anonymize_takes_usecase` | flushes the ledger, then takes a deleted account off every take postgres keeps | `Ledger`, `Takes` | | `ledger/usecases/replay_usecase` | the map as it was at a time, and every act on it since | `Ledger`, `MaxIndexReader`, `DenseMapReader`, `Clock` | +| `ledger/usecases/restore_area_usecase` | who painted an area since a time, and puts it back as it was then | `Ledger`, `Map`, `Areas` | +| `clicks/usecases/clear_area_usecase` | gives every tile of an area to nobody | `Map`, `Areas` | | `bonuses/usecases/claim_bonus_usecase` | redeems a box | `Registry`, `Charger` | | `bonuses/usecases/drop_bomb_usecase` | spends a bomb where it was aimed | `Bombs`, `Map`, `Clearer` | | `bonuses/usecases/get_charges_usecase` | what the caller holds | `Charges` | @@ -1593,7 +1596,7 @@ internal/player/internal/ ### Support (`internal/support/`) -**What a player sends the operator.** Today that is a feature suggestion, a bug report, or a report of another player. Always on. It publishes nothing. It asks `auth` for the click token's key, and `player` how to name the accounts of a report when the operator reads it. +**What a player sends the operator.** Today that is a feature suggestion, a bug report, a report of another player, or a report of a drawing on the map. Always on. It publishes nothing. It asks `auth` for the click token's key, and `player` how to name the accounts of a report when the operator reads it. ``` internal/support/internal/ @@ -1631,13 +1634,23 @@ internal/support/internal/ usecases/report_player_usecase/ reads what was filed, draws an id, builds the report, keeps it — IDProvider, Reports usecases/forget_reports_usecase/ reads the reports that name an account, forgets them, saves — Reports usecases/prune_reports_usecase/ reads the reports past retention, forgets them, saves — Reports - decisions/ Decision (NewDecision, TakeSnapshot), Snapshot (Restore), Outcome; the Storage port - postgres_decision_store/ Storage, over support.report_decisions + places/ Report (NewReport, Anonymized, TakeSnapshot), Snapshot (Restore), Complaint, ReportID + (ReportIDOf), TileID, Reason; the Storage and IDProvider ports + postgres_place_report_store/ Storage, over support.place_reports + uuid_id_provider/ IDProvider: a UUIDv7 per report + usecases/report_place_usecase/ draws an id, builds the report, keeps it — IDProvider, Saver + usecases/anonymize_place_reports_usecase/ reads the places an account reported, anonymizes them, saves — Reports + usecases/prune_place_reports_usecase/ reads the reports past retention with a reporter, anonymizes them — Reports + decisions/ Decision[R] (NewDecision, TakeSnapshot), Snapshot[R] (Restore), Outcome; the Storage port: + one rule for the reports of players and of places, R the report's id + postgres_decision_store/ Storage, ForReports over support.report_decisions, ForPlaces over place_report_decisions usecases/decide_report_usecase/ builds the decision and keeps it — Saver - audit_decide_report/ logs every decision at Warn + audit_decide_report/ logs every decision at Warn, with its kind supportv1controller/ SupportService and AdminService (bags), the interceptors suggest_feature_handler/ report_bug_handler/ report_player_handler/ get_attachment_rules_handler/ - create_upload_handler/ decide_report_handler/ + create_upload_handler/ decide_report_handler/ report_place_handler/ decide_place_report_handler/ + list_place_reports_handler/place_reports_query/ PostgresQuery: ListPlaceReportsResponse, decided or not, named — Authors + reportoutcome/ Of: the wire's outcome as the decision's, for both decide handlers list_notes_handler/notes_query/ PostgresQuery: ListNotesResponse straight from SQL, with a link to each file — Links list_reports_handler/reports_query/ PostgresQuery: ListReportsResponse from SQL, decided or not, named — Authors rpc_player_authors/ Authors, from player.v1.InternalService/GetAuthors, as player.v1.Author @@ -1712,7 +1725,25 @@ internal/support/internal/ (the store reads the foreign key's refusal as `decisions.ErrUnknownReport`). - **`ListReports` carries each decision** (`Report.decision`, unset while open), and `open_only` leaves out the decided ones. A stored outcome the wire cannot carry refuses the read (`reports_query.ErrUnknownOutcome`). -- With no internal listener the boot is refused: the module dials it once, at build, for `player`, which names the accounts in `ListReports`. +- With no internal listener the boot is refused: the module dials it once, at build, for `player`, which names the accounts in `ListReports` and `ListPlaceReports`. + +**A report of a drawing is a concept of its own** (`places/`): it names a tile, not a player, and what it is about +is the map, so it is kept beside the reports of players and never in their table. + +- **`ReportPlace(tile_id, reason, text)`**, `Attested`, under the service's limiter like the other reports. The + tile is the one the player tapped in the drawing. **The module does not know the map**: a tile above 0 is kept, + and the operator's tools check it. Reasons are `hate` (a hateful symbol or words), `sexual` and `other` + (`places.Reason`, the type `place_report_reason`); the text is optional and follows `notes.TextOf`. +- **No rule per day**: the limiter bounds it, and reporting a drawing again costs nobody anything. +- **The reporter goes, the notice stays**: a report is the notice the operator acted on or not, so it is + anonymized, never deleted. `auth.v1.AccountDeleted` blanks the reporter at once (`support-places-accounts`), and + the prune past `support.retention`. Kept in `support.place_reports` (`reporter_account_id` NULL once + anonymized; migration `20261020120000_place_reports`). +- **`AdminService/ListPlaceReports(since, limit, open_only)`** and **`DecidePlaceReport(report_id, outcome, + comment)`**, as their player twins. The decision is the same rule (`decisions.Decision[places.ReportID]`), in + `support.place_report_decisions`, and goes with its report. +- `tile_id` is what `planet.v1.AdminService/RestoreArea` and `ClearArea` take, and `https://clickplanet.lol/?tile=` + opens the game on it. - `reports.StorageContractSuite` runs on `inmemory_report_storage` and on postgres, through the same `Kept` hook of snapshots. ### Seasons (`internal/seasons/`) @@ -3315,7 +3346,7 @@ origin with auth off refuses the boot. Caddy sends `/.v1.AdminService/*` on the API's domain to it, so `cp_admin`, which is host-only on that domain, travels from the admin page, a page of the same site. -The admin page is `apps/backoffice` (its `CLAUDE.md`). `planet.v1.AdminService` is the main one, in `proto/planet/v1/admin.proto`; `player.v1.AdminService` (`proto/player/v1/admin.proto`) has `ReconcileTitles`, and the reads that name players, `GetAuthors` and `GetPlayer` — see [Player](#player-internalplayer); `chat.v1.AdminService` (`proto/chat/v1/admin.proto`) has `Mute`, `RemoveMessage`, `GetMessage` and `ListMessages` — see [Mutes](#mutes) and [Removals](#removals); `support.v1.AdminService` (`proto/support/v1/admin.proto`) has `ListNotes`, `ListReports` and `DecideReport` — see [Support](#support-internalsupport); `auth.v1.AdminService` (`proto/auth/v1/admin.proto`) has the admin page's own sign-in and `WhoAmI` — see [Operators](#operators). `planetv1controller.AdminService` is its bag of handlers, the way `ClickService` is. `ReassignCountry` runs `clicks/usecases/reassign_country_usecase`, wrapped in `audit_reassign`: every tile `from_country_id` holds goes to `to_country_id`, while the game runs. +The admin page is `apps/backoffice` (its `CLAUDE.md`). `planet.v1.AdminService` is the main one, in `proto/planet/v1/admin.proto`; `player.v1.AdminService` (`proto/player/v1/admin.proto`) has `ReconcileTitles`, and the reads that name players, `GetAuthors` and `GetPlayer` — see [Player](#player-internalplayer); `chat.v1.AdminService` (`proto/chat/v1/admin.proto`) has `Mute`, `RemoveMessage`, `GetMessage` and `ListMessages` — see [Mutes](#mutes) and [Removals](#removals); `support.v1.AdminService` (`proto/support/v1/admin.proto`) has `ListNotes`, `ListReports`, `DecideReport`, `ListPlaceReports` and `DecidePlaceReport` — see [Support](#support-internalsupport); `auth.v1.AdminService` (`proto/auth/v1/admin.proto`) has the admin page's own sign-in and `WhoAmI` — see [Operators](#operators). `planetv1controller.AdminService` is its bag of handlers, the way `ClickService` is. `ReassignCountry` runs `clicks/usecases/reassign_country_usecase`, wrapped in `audit_reassign`: every tile `from_country_id` holds goes to `to_country_id`, while the game runs. - **The move is paced.** `inmemory_tile_storage.Reassign` moves one batch under the lock and returns where to resume; the use case sleeps 50ms between batches. A batch is a quarter of `tilesStorage.subscriberBuffer`, because each tile is one update on every open stream, the clicks still arriving need the rest of the buffer, and a stream that overflows it is closed. - **Each tile is an ordinary `TileUpdate`** with `Previous` set, not a new event kind: open clients repaint with no frontend release, `counts` move so the toll prices the next click right, and `dirty` puts it in the next flush. @@ -3364,6 +3395,28 @@ stream sent it. The frontend's clip generator plays it back (see its CLAUDE.md, - **The paint is `Restore`**, the revert's compare-and-set, against the owner read at the pick. A tile somebody takes in between stays theirs, so `painted` can be below `picked`. Paced like the reassign, each tile an ordinary `TileUpdate`. It does not write the ledger, like the reassign. - The draw is `clicks.SystemRandom`, math/rand/v2's global source; tests pass a seeded `*rand.Rand`. +#### `RestoreArea` and `ClearArea` + +For a drawing on the map that a player reported (see [Support](#support-internalsupport)): it is many tiles and +often many accounts, so the revert of one player is not the tool. + +- **An area is every tile within `rings` tile spacings of one** (`clicks.Areas`, a bomb's circle; 1 to 50, + `ErrInvalidArea`). `Around` reads `Geography.Within`, and the spacing is measured once at boot. +- **`RestoreArea(tile_id, rings, before, dry_run)` puts each tile back as it was at `before`**: `ledger.Rewind` is + the replay's rule, each tile's owner before its first change since, kept for the area alone. It is a + compare-and-set against the owner read now, paced like a revert, so a tile taken during it stays taken. It also + answers **who painted the area since `before`** (`painters`, a `ledger.Tally` of the takes in it, most first, 50 + at most), for the bans. The ledger in memory keeps whole seconds, so `before` is cut to its second. +- **`before` past the retention is `FailedPrecondition`** (`ErrPastTheLedger`): memory cannot rewind what it no + longer holds, and `kept_since` says from when it can. A reassign, a paint or a revert wrote no event, so a tile + one of them changed is not rewound. +- **`ClearArea(tile_id, rings, dry_run)` gives every owned tile of the area to nobody** (`clicks.Emptying`), for a + drawing older than the ledger keeps. Also paced and compare-and-set. +- Neither writes the ledger, like a revert. `audit_restore_area` and `audit_clear_area` log every call at Warn, dry + runs included. +- `e2e/map_drawing_test.go` paints a tile over another flag, sees the painter and the tile in a dry run, restores + it, and clears an area. + #### Manual bans: `FindPlayers`, `TopPlayers`, `BanPlayer`, `UnbanPlayer`, `RevertPlayer`, `InspectPlayer` For the patterns no watchdog catches but a person sees on the map. A player is an **account on a scope** (`cpipscope`: the address over IPv4, the /64 over IPv6), or a scope alone for takes made with no account. `BanPlayer`, `UnbanPlayer`, `RevertPlayer` and `InspectPlayer` take a `scope` (any address) **or** an `account_id`, never both (`ledger.ParseCaller`; both, neither or a malformed id is `InvalidArgument`). diff --git a/apps/backend/e2e/map_drawing_test.go b/apps/backend/e2e/map_drawing_test.go new file mode 100644 index 000000000..f02ff0f65 --- /dev/null +++ b/apps/backend/e2e/map_drawing_test.go @@ -0,0 +1,160 @@ +package e2e_test + +import ( + "net/http" + "testing" + "time" + + "connectrpc.com/connect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + authv1 "github.com/raphoester/clickplanet.lol-backend/generated/proto/auth/v1" + "github.com/raphoester/clickplanet.lol-backend/generated/proto/auth/v1/authv1connect" + planetv1 "github.com/raphoester/clickplanet.lol-backend/generated/proto/planet/v1" + "github.com/raphoester/clickplanet.lol-backend/generated/proto/planet/v1/planetv1connect" + supportv1 "github.com/raphoester/clickplanet.lol-backend/generated/proto/support/v1" +) + +func (s gameStack) mapDesk() planetv1connect.AdminServiceClient { + return planetv1connect.NewAdminServiceClient(http.DefaultClient, s.consoleURL) +} + +func (s gameStack) restoreArea(t *testing.T, tile uint32, before time.Time, dryRun bool) *planetv1.RestoreAreaResponse { + t.Helper() + + res, err := s.mapDesk().RestoreArea(t.Context(), connect.NewRequest(&planetv1.RestoreAreaRequest{ + TileId: tile, Rings: 1, Before: timestamppb.New(before), DryRun: dryRun, + })) + require.NoError(t, err) + return res.Msg +} + +func (s gameStack) clearArea(t *testing.T, tile uint32, dryRun bool) *planetv1.ClearAreaResponse { + t.Helper() + + res, err := s.mapDesk().ClearArea(t.Context(), connect.NewRequest(&planetv1.ClearAreaRequest{ + TileId: tile, Rings: 1, DryRun: dryRun, + })) + require.NoError(t, err) + return res.Msg +} + +func (p *gamer) reportPlace(tile uint32, reason supportv1.PlaceReportReason, text string) error { + p.t.Helper() + + req := connect.NewRequest(&supportv1.ReportPlaceRequest{TileId: tile, Reason: reason, Text: text}) + p.send(req.Header()) + _, err := p.support().ReportPlace(p.t.Context(), req) + return err //nolint:wrapcheck // the tests read the connect code. +} + +func (s gameStack) placeReports(t *testing.T, openOnly bool) []*supportv1.PlaceReport { + t.Helper() + + res, err := s.supportDesk().ListPlaceReports(t.Context(), + connect.NewRequest(&supportv1.ListPlaceReportsRequest{OpenOnly: openOnly})) + require.NoError(t, err) + return res.Msg.GetReports() +} + +func TestAnOperatorSeesWhoDrewAnAreaAndPutsItBackAsItWas(t *testing.T) { + game := startGame(t) + ada, drawer := game.newPlayer(t), game.newPlayer(t) + ada.click(1, "fr") + before := time.Now().Truncate(time.Second).Add(time.Second) + time.Sleep(time.Until(before)) + drawer.click(1, "de") + + seen := game.restoreArea(t, 1, before, true) + assert.Positive(t, seen.GetTiles()) + assert.Equal(t, uint32(1), seen.GetChanged()) + assert.Zero(t, seen.GetRestored(), "a dry run restores nothing") + require.Len(t, seen.GetPainters(), 1, "the take before the time is not the drawing") + assert.Equal(t, drawer.account(), seen.GetPainters()[0].GetAccountId()) + assert.Equal(t, "de", seen.GetPainters()[0].GetFlagCountryId()) + + restored := game.restoreArea(t, 1, before, false) + assert.Equal(t, uint32(1), restored.GetRestored()) + assert.Zero(t, game.restoreArea(t, 1, before, true).GetChanged(), "the area holds what it held then") + assert.Equal(t, uint32(1), game.clearArea(t, 1, true).GetOwned(), "fr holds its tile again") + + _, err := game.mapDesk().RestoreArea(t.Context(), connect.NewRequest(&planetv1.RestoreAreaRequest{ + TileId: 1, Rings: 1, Before: timestamppb.New(time.Now().Add(-73 * time.Hour)), + })) + assert.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(err), "past what the ledger keeps") +} + +func TestAnOperatorClearsAnAreaTooOldForTheLedger(t *testing.T) { + game := startGame(t) + game.newPlayer(t).click(1, "de") + + assert.Equal(t, uint32(1), game.clearArea(t, 1, true).GetOwned()) + assert.Equal(t, uint32(1), game.clearArea(t, 1, false).GetCleared()) + assert.Zero(t, game.clearArea(t, 1, true).GetOwned()) + + _, err := game.mapDesk().ClearArea(t.Context(), connect.NewRequest(&planetv1.ClearAreaRequest{TileId: 1, Rings: 51})) + assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err)) +} + +func TestAGuestReportsADrawingAndTheOperatorClosesTheReport(t *testing.T) { + game := startGame(t) + guest := game.newPlayer(t) + said, err := guest.post() + require.NoError(t, err) + + require.NoError(t, guest.reportPlace(42, supportv1.PlaceReportReason_PLACE_REPORT_REASON_HATE, " a swastika ")) + + open := game.placeReports(t, true) + require.Len(t, open, 1) + assert.Equal(t, guest.account(), open[0].GetReporterAccountId()) + assert.Equal(t, said.GetAuthorName(), open[0].GetReporterName()) + assert.Equal(t, uint32(42), open[0].GetTileId()) + assert.Equal(t, supportv1.PlaceReportReason_PLACE_REPORT_REASON_HATE, open[0].GetReason()) + assert.Equal(t, "a swastika", open[0].GetText()) + + _, err = game.supportDesk().DecidePlaceReport(t.Context(), connect.NewRequest(&supportv1.DecidePlaceReportRequest{ + ReportId: open[0].GetId(), Outcome: supportv1.ReportOutcome_REPORT_OUTCOME_ACTED, Comment: "restored the area", + })) + require.NoError(t, err) + + assert.Empty(t, game.placeReports(t, true)) + all := game.placeReports(t, false) + require.Len(t, all, 1) + assert.Equal(t, supportv1.ReportOutcome_REPORT_OUTCOME_ACTED, all[0].GetDecision().GetOutcome()) +} + +func TestAPlaceReportNeedsATileAndAReasonAndAnAccount(t *testing.T) { + game := startGame(t) + guest := game.newPlayer(t) + + err := guest.reportPlace(0, supportv1.PlaceReportReason_PLACE_REPORT_REASON_HATE, "") + assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err), "no tile") + + err = guest.reportPlace(42, supportv1.PlaceReportReason_PLACE_REPORT_REASON_UNSPECIFIED, "") + assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err), "no reason") + + nobody := &gamer{t: t, stack: game} + err = nobody.reportPlace(42, supportv1.PlaceReportReason_PLACE_REPORT_REASON_HATE, "") + assert.Equal(t, connect.CodeUnauthenticated, connect.CodeOf(err)) + + assert.Empty(t, game.placeReports(t, false)) +} + +func TestADeletedAccountsPlaceReportsStayWithNoReporter(t *testing.T) { + game := startGame(t) + guest := game.newPlayer(t) + require.NoError(t, guest.reportPlace(42, supportv1.PlaceReportReason_PLACE_REPORT_REASON_OTHER, "an ad")) + + deletion := connect.NewRequest(&authv1.DeleteAccountRequest{}) + guest.send(deletion.Header()) + _, err := authv1connect.NewAuthServiceClient(http.DefaultClient, game.baseURL).DeleteAccount(t.Context(), deletion) + require.NoError(t, err) + + require.Eventually(t, func() bool { + reports := game.placeReports(t, false) + return len(reports) == 1 && reports[0].GetReporterAccountId() == "" + }, 5*time.Second, 20*time.Millisecond) + assert.Equal(t, "an ad", game.placeReports(t, false)[0].GetText(), "the notice stays") +} diff --git a/apps/backend/generated/proto/planet/v1/admin.pb.go b/apps/backend/generated/proto/planet/v1/admin.pb.go index ded484b32..2d64c4fe5 100644 --- a/apps/backend/generated/proto/planet/v1/admin.pb.go +++ b/apps/backend/generated/proto/planet/v1/admin.pb.go @@ -23,6 +23,289 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +type RestoreAreaRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + TileId uint32 `protobuf:"varint,1,opt,name=tile_id,json=tileId,proto3" json:"tile_id,omitempty"` + // The area is every tile within this many tile spacings of the tile, as a bomb's blast is. 1 to 50. + Rings uint32 `protobuf:"varint,2,opt,name=rings,proto3" json:"rings,omitempty"` + // Each tile of the area goes back to what it held then. FailedPrecondition past the ledger's retention. + Before *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=before,proto3" json:"before,omitempty"` + DryRun bool `protobuf:"varint,4,opt,name=dry_run,json=dryRun,proto3" json:"dry_run,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RestoreAreaRequest) Reset() { + *x = RestoreAreaRequest{} + mi := &file_planet_v1_admin_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RestoreAreaRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RestoreAreaRequest) ProtoMessage() {} + +func (x *RestoreAreaRequest) ProtoReflect() protoreflect.Message { + mi := &file_planet_v1_admin_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RestoreAreaRequest.ProtoReflect.Descriptor instead. +func (*RestoreAreaRequest) Descriptor() ([]byte, []int) { + return file_planet_v1_admin_proto_rawDescGZIP(), []int{0} +} + +func (x *RestoreAreaRequest) GetTileId() uint32 { + if x != nil { + return x.TileId + } + return 0 +} + +func (x *RestoreAreaRequest) GetRings() uint32 { + if x != nil { + return x.Rings + } + return 0 +} + +func (x *RestoreAreaRequest) GetBefore() *timestamppb.Timestamp { + if x != nil { + return x.Before + } + return nil +} + +func (x *RestoreAreaRequest) GetDryRun() bool { + if x != nil { + return x.DryRun + } + return false +} + +type RestoreAreaResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // In the area. + Tiles uint32 `protobuf:"varint,1,opt,name=tiles,proto3" json:"tiles,omitempty"` + // That a restore changes: a tile changed since before and not back to what it held then. + Changed uint32 `protobuf:"varint,2,opt,name=changed,proto3" json:"changed,omitempty"` + // That changed: a tile somebody took during the restore stays theirs. + Restored uint32 `protobuf:"varint,3,opt,name=restored,proto3" json:"restored,omitempty"` + // Who took tiles of the area since before, most takes first, 50 at most. Tiles and takes count the area alone. + Painters []*Player `protobuf:"bytes,4,rep,name=painters,proto3" json:"painters,omitempty"` + // The oldest before the ledger can rewind to. + KeptSince *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=kept_since,json=keptSince,proto3" json:"kept_since,omitempty"` + PaintersTotal uint32 `protobuf:"varint,6,opt,name=painters_total,json=paintersTotal,proto3" json:"painters_total,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RestoreAreaResponse) Reset() { + *x = RestoreAreaResponse{} + mi := &file_planet_v1_admin_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RestoreAreaResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RestoreAreaResponse) ProtoMessage() {} + +func (x *RestoreAreaResponse) ProtoReflect() protoreflect.Message { + mi := &file_planet_v1_admin_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RestoreAreaResponse.ProtoReflect.Descriptor instead. +func (*RestoreAreaResponse) Descriptor() ([]byte, []int) { + return file_planet_v1_admin_proto_rawDescGZIP(), []int{1} +} + +func (x *RestoreAreaResponse) GetTiles() uint32 { + if x != nil { + return x.Tiles + } + return 0 +} + +func (x *RestoreAreaResponse) GetChanged() uint32 { + if x != nil { + return x.Changed + } + return 0 +} + +func (x *RestoreAreaResponse) GetRestored() uint32 { + if x != nil { + return x.Restored + } + return 0 +} + +func (x *RestoreAreaResponse) GetPainters() []*Player { + if x != nil { + return x.Painters + } + return nil +} + +func (x *RestoreAreaResponse) GetKeptSince() *timestamppb.Timestamp { + if x != nil { + return x.KeptSince + } + return nil +} + +func (x *RestoreAreaResponse) GetPaintersTotal() uint32 { + if x != nil { + return x.PaintersTotal + } + return 0 +} + +type ClearAreaRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + TileId uint32 `protobuf:"varint,1,opt,name=tile_id,json=tileId,proto3" json:"tile_id,omitempty"` + // As RestoreArea's. + Rings uint32 `protobuf:"varint,2,opt,name=rings,proto3" json:"rings,omitempty"` + DryRun bool `protobuf:"varint,3,opt,name=dry_run,json=dryRun,proto3" json:"dry_run,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClearAreaRequest) Reset() { + *x = ClearAreaRequest{} + mi := &file_planet_v1_admin_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClearAreaRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClearAreaRequest) ProtoMessage() {} + +func (x *ClearAreaRequest) ProtoReflect() protoreflect.Message { + mi := &file_planet_v1_admin_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ClearAreaRequest.ProtoReflect.Descriptor instead. +func (*ClearAreaRequest) Descriptor() ([]byte, []int) { + return file_planet_v1_admin_proto_rawDescGZIP(), []int{2} +} + +func (x *ClearAreaRequest) GetTileId() uint32 { + if x != nil { + return x.TileId + } + return 0 +} + +func (x *ClearAreaRequest) GetRings() uint32 { + if x != nil { + return x.Rings + } + return 0 +} + +func (x *ClearAreaRequest) GetDryRun() bool { + if x != nil { + return x.DryRun + } + return false +} + +type ClearAreaResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // In the area. + Tiles uint32 `protobuf:"varint,1,opt,name=tiles,proto3" json:"tiles,omitempty"` + // That a flag holds. + Owned uint32 `protobuf:"varint,2,opt,name=owned,proto3" json:"owned,omitempty"` + // That were given to nobody: a tile somebody took during the clear stays theirs. + Cleared uint32 `protobuf:"varint,3,opt,name=cleared,proto3" json:"cleared,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClearAreaResponse) Reset() { + *x = ClearAreaResponse{} + mi := &file_planet_v1_admin_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClearAreaResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClearAreaResponse) ProtoMessage() {} + +func (x *ClearAreaResponse) ProtoReflect() protoreflect.Message { + mi := &file_planet_v1_admin_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ClearAreaResponse.ProtoReflect.Descriptor instead. +func (*ClearAreaResponse) Descriptor() ([]byte, []int) { + return file_planet_v1_admin_proto_rawDescGZIP(), []int{3} +} + +func (x *ClearAreaResponse) GetTiles() uint32 { + if x != nil { + return x.Tiles + } + return 0 +} + +func (x *ClearAreaResponse) GetOwned() uint32 { + if x != nil { + return x.Owned + } + return 0 +} + +func (x *ClearAreaResponse) GetCleared() uint32 { + if x != nil { + return x.Cleared + } + return 0 +} + type GetReplayRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Since *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=since,proto3" json:"since,omitempty"` @@ -34,7 +317,7 @@ type GetReplayRequest struct { func (x *GetReplayRequest) Reset() { *x = GetReplayRequest{} - mi := &file_planet_v1_admin_proto_msgTypes[0] + mi := &file_planet_v1_admin_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -46,7 +329,7 @@ func (x *GetReplayRequest) String() string { func (*GetReplayRequest) ProtoMessage() {} func (x *GetReplayRequest) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_admin_proto_msgTypes[0] + mi := &file_planet_v1_admin_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -59,7 +342,7 @@ func (x *GetReplayRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetReplayRequest.ProtoReflect.Descriptor instead. func (*GetReplayRequest) Descriptor() ([]byte, []int) { - return file_planet_v1_admin_proto_rawDescGZIP(), []int{0} + return file_planet_v1_admin_proto_rawDescGZIP(), []int{4} } func (x *GetReplayRequest) GetSince() *timestamppb.Timestamp { @@ -89,7 +372,7 @@ type GetReplayResponse struct { func (x *GetReplayResponse) Reset() { *x = GetReplayResponse{} - mi := &file_planet_v1_admin_proto_msgTypes[1] + mi := &file_planet_v1_admin_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -101,7 +384,7 @@ func (x *GetReplayResponse) String() string { func (*GetReplayResponse) ProtoMessage() {} func (x *GetReplayResponse) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_admin_proto_msgTypes[1] + mi := &file_planet_v1_admin_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -114,7 +397,7 @@ func (x *GetReplayResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetReplayResponse.ProtoReflect.Descriptor instead. func (*GetReplayResponse) Descriptor() ([]byte, []int) { - return file_planet_v1_admin_proto_rawDescGZIP(), []int{1} + return file_planet_v1_admin_proto_rawDescGZIP(), []int{5} } func (x *GetReplayResponse) GetOpening() *GetMapResponse { @@ -156,7 +439,7 @@ type ReplayedEvent struct { func (x *ReplayedEvent) Reset() { *x = ReplayedEvent{} - mi := &file_planet_v1_admin_proto_msgTypes[2] + mi := &file_planet_v1_admin_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -168,7 +451,7 @@ func (x *ReplayedEvent) String() string { func (*ReplayedEvent) ProtoMessage() {} func (x *ReplayedEvent) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_admin_proto_msgTypes[2] + mi := &file_planet_v1_admin_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -181,7 +464,7 @@ func (x *ReplayedEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use ReplayedEvent.ProtoReflect.Descriptor instead. func (*ReplayedEvent) Descriptor() ([]byte, []int) { - return file_planet_v1_admin_proto_rawDescGZIP(), []int{2} + return file_planet_v1_admin_proto_rawDescGZIP(), []int{6} } func (x *ReplayedEvent) GetAt() *timestamppb.Timestamp { @@ -212,7 +495,7 @@ type GrantChargesRequest struct { func (x *GrantChargesRequest) Reset() { *x = GrantChargesRequest{} - mi := &file_planet_v1_admin_proto_msgTypes[3] + mi := &file_planet_v1_admin_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -224,7 +507,7 @@ func (x *GrantChargesRequest) String() string { func (*GrantChargesRequest) ProtoMessage() {} func (x *GrantChargesRequest) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_admin_proto_msgTypes[3] + mi := &file_planet_v1_admin_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -237,7 +520,7 @@ func (x *GrantChargesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GrantChargesRequest.ProtoReflect.Descriptor instead. func (*GrantChargesRequest) Descriptor() ([]byte, []int) { - return file_planet_v1_admin_proto_rawDescGZIP(), []int{3} + return file_planet_v1_admin_proto_rawDescGZIP(), []int{7} } func (x *GrantChargesRequest) GetAccountId() string { @@ -293,7 +576,7 @@ type GrantChargesResponse struct { func (x *GrantChargesResponse) Reset() { *x = GrantChargesResponse{} - mi := &file_planet_v1_admin_proto_msgTypes[4] + mi := &file_planet_v1_admin_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -305,7 +588,7 @@ func (x *GrantChargesResponse) String() string { func (*GrantChargesResponse) ProtoMessage() {} func (x *GrantChargesResponse) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_admin_proto_msgTypes[4] + mi := &file_planet_v1_admin_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -318,7 +601,7 @@ func (x *GrantChargesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GrantChargesResponse.ProtoReflect.Descriptor instead. func (*GrantChargesResponse) Descriptor() ([]byte, []int) { - return file_planet_v1_admin_proto_rawDescGZIP(), []int{4} + return file_planet_v1_admin_proto_rawDescGZIP(), []int{8} } func (x *GrantChargesResponse) GetAccountId() string { @@ -355,7 +638,7 @@ type PaintRandomTilesRequest struct { func (x *PaintRandomTilesRequest) Reset() { *x = PaintRandomTilesRequest{} - mi := &file_planet_v1_admin_proto_msgTypes[5] + mi := &file_planet_v1_admin_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -367,7 +650,7 @@ func (x *PaintRandomTilesRequest) String() string { func (*PaintRandomTilesRequest) ProtoMessage() {} func (x *PaintRandomTilesRequest) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_admin_proto_msgTypes[5] + mi := &file_planet_v1_admin_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -380,7 +663,7 @@ func (x *PaintRandomTilesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PaintRandomTilesRequest.ProtoReflect.Descriptor instead. func (*PaintRandomTilesRequest) Descriptor() ([]byte, []int) { - return file_planet_v1_admin_proto_rawDescGZIP(), []int{5} + return file_planet_v1_admin_proto_rawDescGZIP(), []int{9} } func (x *PaintRandomTilesRequest) GetFlagCountryId() string { @@ -430,7 +713,7 @@ type PaintRandomTilesResponse struct { func (x *PaintRandomTilesResponse) Reset() { *x = PaintRandomTilesResponse{} - mi := &file_planet_v1_admin_proto_msgTypes[6] + mi := &file_planet_v1_admin_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -442,7 +725,7 @@ func (x *PaintRandomTilesResponse) String() string { func (*PaintRandomTilesResponse) ProtoMessage() {} func (x *PaintRandomTilesResponse) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_admin_proto_msgTypes[6] + mi := &file_planet_v1_admin_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -455,7 +738,7 @@ func (x *PaintRandomTilesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PaintRandomTilesResponse.ProtoReflect.Descriptor instead. func (*PaintRandomTilesResponse) Descriptor() ([]byte, []int) { - return file_planet_v1_admin_proto_rawDescGZIP(), []int{6} + return file_planet_v1_admin_proto_rawDescGZIP(), []int{10} } func (x *PaintRandomTilesResponse) GetEligible() uint32 { @@ -496,7 +779,7 @@ type InspectPlayerRequest struct { func (x *InspectPlayerRequest) Reset() { *x = InspectPlayerRequest{} - mi := &file_planet_v1_admin_proto_msgTypes[7] + mi := &file_planet_v1_admin_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -508,7 +791,7 @@ func (x *InspectPlayerRequest) String() string { func (*InspectPlayerRequest) ProtoMessage() {} func (x *InspectPlayerRequest) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_admin_proto_msgTypes[7] + mi := &file_planet_v1_admin_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -521,7 +804,7 @@ func (x *InspectPlayerRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use InspectPlayerRequest.ProtoReflect.Descriptor instead. func (*InspectPlayerRequest) Descriptor() ([]byte, []int) { - return file_planet_v1_admin_proto_rawDescGZIP(), []int{7} + return file_planet_v1_admin_proto_rawDescGZIP(), []int{11} } func (x *InspectPlayerRequest) GetScope() string { @@ -563,7 +846,7 @@ type InspectPlayerResponse struct { func (x *InspectPlayerResponse) Reset() { *x = InspectPlayerResponse{} - mi := &file_planet_v1_admin_proto_msgTypes[8] + mi := &file_planet_v1_admin_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -575,7 +858,7 @@ func (x *InspectPlayerResponse) String() string { func (*InspectPlayerResponse) ProtoMessage() {} func (x *InspectPlayerResponse) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_admin_proto_msgTypes[8] + mi := &file_planet_v1_admin_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -588,7 +871,7 @@ func (x *InspectPlayerResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use InspectPlayerResponse.ProtoReflect.Descriptor instead. func (*InspectPlayerResponse) Descriptor() ([]byte, []int) { - return file_planet_v1_admin_proto_rawDescGZIP(), []int{8} + return file_planet_v1_admin_proto_rawDescGZIP(), []int{12} } func (x *InspectPlayerResponse) GetScope() string { @@ -722,7 +1005,7 @@ type WatchdogReading struct { func (x *WatchdogReading) Reset() { *x = WatchdogReading{} - mi := &file_planet_v1_admin_proto_msgTypes[9] + mi := &file_planet_v1_admin_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -734,7 +1017,7 @@ func (x *WatchdogReading) String() string { func (*WatchdogReading) ProtoMessage() {} func (x *WatchdogReading) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_admin_proto_msgTypes[9] + mi := &file_planet_v1_admin_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -747,7 +1030,7 @@ func (x *WatchdogReading) ProtoReflect() protoreflect.Message { // Deprecated: Use WatchdogReading.ProtoReflect.Descriptor instead. func (*WatchdogReading) Descriptor() ([]byte, []int) { - return file_planet_v1_admin_proto_rawDescGZIP(), []int{9} + return file_planet_v1_admin_proto_rawDescGZIP(), []int{13} } func (x *WatchdogReading) GetWatchdog() string { @@ -789,7 +1072,7 @@ type FindPlayersRequest struct { func (x *FindPlayersRequest) Reset() { *x = FindPlayersRequest{} - mi := &file_planet_v1_admin_proto_msgTypes[10] + mi := &file_planet_v1_admin_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -801,7 +1084,7 @@ func (x *FindPlayersRequest) String() string { func (*FindPlayersRequest) ProtoMessage() {} func (x *FindPlayersRequest) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_admin_proto_msgTypes[10] + mi := &file_planet_v1_admin_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -814,7 +1097,7 @@ func (x *FindPlayersRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FindPlayersRequest.ProtoReflect.Descriptor instead. func (*FindPlayersRequest) Descriptor() ([]byte, []int) { - return file_planet_v1_admin_proto_rawDescGZIP(), []int{10} + return file_planet_v1_admin_proto_rawDescGZIP(), []int{14} } func (x *FindPlayersRequest) GetFlagCountryId() string { @@ -848,7 +1131,7 @@ type FindPlayersResponse struct { func (x *FindPlayersResponse) Reset() { *x = FindPlayersResponse{} - mi := &file_planet_v1_admin_proto_msgTypes[11] + mi := &file_planet_v1_admin_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -860,7 +1143,7 @@ func (x *FindPlayersResponse) String() string { func (*FindPlayersResponse) ProtoMessage() {} func (x *FindPlayersResponse) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_admin_proto_msgTypes[11] + mi := &file_planet_v1_admin_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -873,7 +1156,7 @@ func (x *FindPlayersResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FindPlayersResponse.ProtoReflect.Descriptor instead. func (*FindPlayersResponse) Descriptor() ([]byte, []int) { - return file_planet_v1_admin_proto_rawDescGZIP(), []int{11} + return file_planet_v1_admin_proto_rawDescGZIP(), []int{15} } func (x *FindPlayersResponse) GetPlayers() []*Player { @@ -899,7 +1182,7 @@ type TopPlayersRequest struct { func (x *TopPlayersRequest) Reset() { *x = TopPlayersRequest{} - mi := &file_planet_v1_admin_proto_msgTypes[12] + mi := &file_planet_v1_admin_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -911,7 +1194,7 @@ func (x *TopPlayersRequest) String() string { func (*TopPlayersRequest) ProtoMessage() {} func (x *TopPlayersRequest) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_admin_proto_msgTypes[12] + mi := &file_planet_v1_admin_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -924,7 +1207,7 @@ func (x *TopPlayersRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TopPlayersRequest.ProtoReflect.Descriptor instead. func (*TopPlayersRequest) Descriptor() ([]byte, []int) { - return file_planet_v1_admin_proto_rawDescGZIP(), []int{12} + return file_planet_v1_admin_proto_rawDescGZIP(), []int{16} } func (x *TopPlayersRequest) GetLimit() uint32 { @@ -944,7 +1227,7 @@ type TopPlayersResponse struct { func (x *TopPlayersResponse) Reset() { *x = TopPlayersResponse{} - mi := &file_planet_v1_admin_proto_msgTypes[13] + mi := &file_planet_v1_admin_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -956,7 +1239,7 @@ func (x *TopPlayersResponse) String() string { func (*TopPlayersResponse) ProtoMessage() {} func (x *TopPlayersResponse) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_admin_proto_msgTypes[13] + mi := &file_planet_v1_admin_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -969,7 +1252,7 @@ func (x *TopPlayersResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TopPlayersResponse.ProtoReflect.Descriptor instead. func (*TopPlayersResponse) Descriptor() ([]byte, []int) { - return file_planet_v1_admin_proto_rawDescGZIP(), []int{13} + return file_planet_v1_admin_proto_rawDescGZIP(), []int{17} } func (x *TopPlayersResponse) GetPlayers() []*Player { @@ -1007,7 +1290,7 @@ type Player struct { func (x *Player) Reset() { *x = Player{} - mi := &file_planet_v1_admin_proto_msgTypes[14] + mi := &file_planet_v1_admin_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1019,7 +1302,7 @@ func (x *Player) String() string { func (*Player) ProtoMessage() {} func (x *Player) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_admin_proto_msgTypes[14] + mi := &file_planet_v1_admin_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1032,7 +1315,7 @@ func (x *Player) ProtoReflect() protoreflect.Message { // Deprecated: Use Player.ProtoReflect.Descriptor instead. func (*Player) Descriptor() ([]byte, []int) { - return file_planet_v1_admin_proto_rawDescGZIP(), []int{14} + return file_planet_v1_admin_proto_rawDescGZIP(), []int{18} } func (x *Player) GetScope() string { @@ -1137,7 +1420,7 @@ type BanPlayerRequest struct { func (x *BanPlayerRequest) Reset() { *x = BanPlayerRequest{} - mi := &file_planet_v1_admin_proto_msgTypes[15] + mi := &file_planet_v1_admin_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1149,7 +1432,7 @@ func (x *BanPlayerRequest) String() string { func (*BanPlayerRequest) ProtoMessage() {} func (x *BanPlayerRequest) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_admin_proto_msgTypes[15] + mi := &file_planet_v1_admin_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1162,7 +1445,7 @@ func (x *BanPlayerRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use BanPlayerRequest.ProtoReflect.Descriptor instead. func (*BanPlayerRequest) Descriptor() ([]byte, []int) { - return file_planet_v1_admin_proto_rawDescGZIP(), []int{15} + return file_planet_v1_admin_proto_rawDescGZIP(), []int{19} } func (x *BanPlayerRequest) GetScope() string { @@ -1199,7 +1482,7 @@ type BanPlayerResponse struct { func (x *BanPlayerResponse) Reset() { *x = BanPlayerResponse{} - mi := &file_planet_v1_admin_proto_msgTypes[16] + mi := &file_planet_v1_admin_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1211,7 +1494,7 @@ func (x *BanPlayerResponse) String() string { func (*BanPlayerResponse) ProtoMessage() {} func (x *BanPlayerResponse) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_admin_proto_msgTypes[16] + mi := &file_planet_v1_admin_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1224,7 +1507,7 @@ func (x *BanPlayerResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use BanPlayerResponse.ProtoReflect.Descriptor instead. func (*BanPlayerResponse) Descriptor() ([]byte, []int) { - return file_planet_v1_admin_proto_rawDescGZIP(), []int{16} + return file_planet_v1_admin_proto_rawDescGZIP(), []int{20} } func (x *BanPlayerResponse) GetScope() string { @@ -1272,7 +1555,7 @@ type UnbanPlayerRequest struct { func (x *UnbanPlayerRequest) Reset() { *x = UnbanPlayerRequest{} - mi := &file_planet_v1_admin_proto_msgTypes[17] + mi := &file_planet_v1_admin_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1284,7 +1567,7 @@ func (x *UnbanPlayerRequest) String() string { func (*UnbanPlayerRequest) ProtoMessage() {} func (x *UnbanPlayerRequest) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_admin_proto_msgTypes[17] + mi := &file_planet_v1_admin_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1297,7 +1580,7 @@ func (x *UnbanPlayerRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UnbanPlayerRequest.ProtoReflect.Descriptor instead. func (*UnbanPlayerRequest) Descriptor() ([]byte, []int) { - return file_planet_v1_admin_proto_rawDescGZIP(), []int{17} + return file_planet_v1_admin_proto_rawDescGZIP(), []int{21} } func (x *UnbanPlayerRequest) GetScope() string { @@ -1327,7 +1610,7 @@ type UnbanPlayerResponse struct { func (x *UnbanPlayerResponse) Reset() { *x = UnbanPlayerResponse{} - mi := &file_planet_v1_admin_proto_msgTypes[18] + mi := &file_planet_v1_admin_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1339,7 +1622,7 @@ func (x *UnbanPlayerResponse) String() string { func (*UnbanPlayerResponse) ProtoMessage() {} func (x *UnbanPlayerResponse) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_admin_proto_msgTypes[18] + mi := &file_planet_v1_admin_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1352,7 +1635,7 @@ func (x *UnbanPlayerResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UnbanPlayerResponse.ProtoReflect.Descriptor instead. func (*UnbanPlayerResponse) Descriptor() ([]byte, []int) { - return file_planet_v1_admin_proto_rawDescGZIP(), []int{18} + return file_planet_v1_admin_proto_rawDescGZIP(), []int{22} } func (x *UnbanPlayerResponse) GetScope() string { @@ -1394,7 +1677,7 @@ type RevertPlayerRequest struct { func (x *RevertPlayerRequest) Reset() { *x = RevertPlayerRequest{} - mi := &file_planet_v1_admin_proto_msgTypes[19] + mi := &file_planet_v1_admin_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1406,7 +1689,7 @@ func (x *RevertPlayerRequest) String() string { func (*RevertPlayerRequest) ProtoMessage() {} func (x *RevertPlayerRequest) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_admin_proto_msgTypes[19] + mi := &file_planet_v1_admin_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1419,7 +1702,7 @@ func (x *RevertPlayerRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RevertPlayerRequest.ProtoReflect.Descriptor instead. func (*RevertPlayerRequest) Descriptor() ([]byte, []int) { - return file_planet_v1_admin_proto_rawDescGZIP(), []int{19} + return file_planet_v1_admin_proto_rawDescGZIP(), []int{23} } func (x *RevertPlayerRequest) GetScope() string { @@ -1456,7 +1739,7 @@ type RevertPlayerResponse struct { func (x *RevertPlayerResponse) Reset() { *x = RevertPlayerResponse{} - mi := &file_planet_v1_admin_proto_msgTypes[20] + mi := &file_planet_v1_admin_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1468,7 +1751,7 @@ func (x *RevertPlayerResponse) String() string { func (*RevertPlayerResponse) ProtoMessage() {} func (x *RevertPlayerResponse) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_admin_proto_msgTypes[20] + mi := &file_planet_v1_admin_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1481,7 +1764,7 @@ func (x *RevertPlayerResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RevertPlayerResponse.ProtoReflect.Descriptor instead. func (*RevertPlayerResponse) Descriptor() ([]byte, []int) { - return file_planet_v1_admin_proto_rawDescGZIP(), []int{20} + return file_planet_v1_admin_proto_rawDescGZIP(), []int{24} } func (x *RevertPlayerResponse) GetScope() string { @@ -1530,7 +1813,7 @@ type ReassignCountryRequest struct { func (x *ReassignCountryRequest) Reset() { *x = ReassignCountryRequest{} - mi := &file_planet_v1_admin_proto_msgTypes[21] + mi := &file_planet_v1_admin_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1542,7 +1825,7 @@ func (x *ReassignCountryRequest) String() string { func (*ReassignCountryRequest) ProtoMessage() {} func (x *ReassignCountryRequest) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_admin_proto_msgTypes[21] + mi := &file_planet_v1_admin_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1555,7 +1838,7 @@ func (x *ReassignCountryRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ReassignCountryRequest.ProtoReflect.Descriptor instead. func (*ReassignCountryRequest) Descriptor() ([]byte, []int) { - return file_planet_v1_admin_proto_rawDescGZIP(), []int{21} + return file_planet_v1_admin_proto_rawDescGZIP(), []int{25} } func (x *ReassignCountryRequest) GetFromCountryId() string { @@ -1592,7 +1875,7 @@ type ReassignCountryResponse struct { func (x *ReassignCountryResponse) Reset() { *x = ReassignCountryResponse{} - mi := &file_planet_v1_admin_proto_msgTypes[22] + mi := &file_planet_v1_admin_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1604,7 +1887,7 @@ func (x *ReassignCountryResponse) String() string { func (*ReassignCountryResponse) ProtoMessage() {} func (x *ReassignCountryResponse) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_admin_proto_msgTypes[22] + mi := &file_planet_v1_admin_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1617,7 +1900,7 @@ func (x *ReassignCountryResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ReassignCountryResponse.ProtoReflect.Descriptor instead. func (*ReassignCountryResponse) Descriptor() ([]byte, []int) { - return file_planet_v1_admin_proto_rawDescGZIP(), []int{22} + return file_planet_v1_admin_proto_rawDescGZIP(), []int{26} } func (x *ReassignCountryResponse) GetFromBefore() uint32 { @@ -1659,7 +1942,28 @@ var File_planet_v1_admin_proto protoreflect.FileDescriptor const file_planet_v1_admin_proto_rawDesc = "" + "\n" + - "\x15planet/v1/admin.proto\x12\tplanet.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x16planet/v1/planet.proto\"v\n" + + "\x15planet/v1/admin.proto\x12\tplanet.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x16planet/v1/planet.proto\"\x90\x01\n" + + "\x12RestoreAreaRequest\x12\x17\n" + + "\atile_id\x18\x01 \x01(\rR\x06tileId\x12\x14\n" + + "\x05rings\x18\x02 \x01(\rR\x05rings\x122\n" + + "\x06before\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\x06before\x12\x17\n" + + "\adry_run\x18\x04 \x01(\bR\x06dryRun\"\xf2\x01\n" + + "\x13RestoreAreaResponse\x12\x14\n" + + "\x05tiles\x18\x01 \x01(\rR\x05tiles\x12\x18\n" + + "\achanged\x18\x02 \x01(\rR\achanged\x12\x1a\n" + + "\brestored\x18\x03 \x01(\rR\brestored\x12-\n" + + "\bpainters\x18\x04 \x03(\v2\x11.planet.v1.PlayerR\bpainters\x129\n" + + "\n" + + "kept_since\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\tkeptSince\x12%\n" + + "\x0epainters_total\x18\x06 \x01(\rR\rpaintersTotal\"Z\n" + + "\x10ClearAreaRequest\x12\x17\n" + + "\atile_id\x18\x01 \x01(\rR\x06tileId\x12\x14\n" + + "\x05rings\x18\x02 \x01(\rR\x05rings\x12\x17\n" + + "\adry_run\x18\x03 \x01(\bR\x06dryRun\"Y\n" + + "\x11ClearAreaResponse\x12\x14\n" + + "\x05tiles\x18\x01 \x01(\rR\x05tiles\x12\x14\n" + + "\x05owned\x18\x02 \x01(\rR\x05owned\x12\x18\n" + + "\acleared\x18\x03 \x01(\rR\acleared\"v\n" + "\x10GetReplayRequest\x120\n" + "\x05since\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampR\x05since\x120\n" + "\x05until\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x05until\"\xde\x01\n" + @@ -1808,7 +2112,7 @@ const file_planet_v1_admin_proto_rawDesc = "" + "\x05moved\x18\x03 \x01(\rR\x05moved\x12\x1d\n" + "\n" + "from_after\x18\x04 \x01(\rR\tfromAfter\x12\x19\n" + - "\bto_after\x18\x05 \x01(\rR\atoAfter2\xb2\x06\n" + + "\bto_after\x18\x05 \x01(\rR\atoAfter2\xc8\a\n" + "\fAdminService\x12X\n" + "\x0fReassignCountry\x12!.planet.v1.ReassignCountryRequest\x1a\".planet.v1.ReassignCountryResponse\x12L\n" + "\vFindPlayers\x12\x1d.planet.v1.FindPlayersRequest\x1a\x1e.planet.v1.FindPlayersResponse\x12I\n" + @@ -1820,7 +2124,9 @@ const file_planet_v1_admin_proto_rawDesc = "" + "\x10PaintRandomTiles\x12\".planet.v1.PaintRandomTilesRequest\x1a#.planet.v1.PaintRandomTilesResponse\x12R\n" + "\rInspectPlayer\x12\x1f.planet.v1.InspectPlayerRequest\x1a .planet.v1.InspectPlayerResponse\x12O\n" + "\fGrantCharges\x12\x1e.planet.v1.GrantChargesRequest\x1a\x1f.planet.v1.GrantChargesResponse\x12F\n" + - "\tGetReplay\x12\x1b.planet.v1.GetReplayRequest\x1a\x1c.planet.v1.GetReplayResponseB\xb2\x01\n" + + "\tGetReplay\x12\x1b.planet.v1.GetReplayRequest\x1a\x1c.planet.v1.GetReplayResponse\x12L\n" + + "\vRestoreArea\x12\x1d.planet.v1.RestoreAreaRequest\x1a\x1e.planet.v1.RestoreAreaResponse\x12F\n" + + "\tClearArea\x12\x1b.planet.v1.ClearAreaRequest\x1a\x1c.planet.v1.ClearAreaResponseB\xb2\x01\n" + "\rcom.planet.v1B\n" + "AdminProtoP\x01ZPgithub.com/raphoester/clickplanet.lol-backend/generated/proto/planet/v1;planetv1\xa2\x02\x03PXX\xaa\x02\tPlanet.V1\xca\x02\tPlanet\\V1\xe2\x02\x15Planet\\V1\\GPBMetadata\xea\x02\n" + "Planet::V1b\x06proto3" @@ -1837,88 +2143,99 @@ func file_planet_v1_admin_proto_rawDescGZIP() []byte { return file_planet_v1_admin_proto_rawDescData } -var file_planet_v1_admin_proto_msgTypes = make([]protoimpl.MessageInfo, 23) +var file_planet_v1_admin_proto_msgTypes = make([]protoimpl.MessageInfo, 27) var file_planet_v1_admin_proto_goTypes = []any{ - (*GetReplayRequest)(nil), // 0: planet.v1.GetReplayRequest - (*GetReplayResponse)(nil), // 1: planet.v1.GetReplayResponse - (*ReplayedEvent)(nil), // 2: planet.v1.ReplayedEvent - (*GrantChargesRequest)(nil), // 3: planet.v1.GrantChargesRequest - (*GrantChargesResponse)(nil), // 4: planet.v1.GrantChargesResponse - (*PaintRandomTilesRequest)(nil), // 5: planet.v1.PaintRandomTilesRequest - (*PaintRandomTilesResponse)(nil), // 6: planet.v1.PaintRandomTilesResponse - (*InspectPlayerRequest)(nil), // 7: planet.v1.InspectPlayerRequest - (*InspectPlayerResponse)(nil), // 8: planet.v1.InspectPlayerResponse - (*WatchdogReading)(nil), // 9: planet.v1.WatchdogReading - (*FindPlayersRequest)(nil), // 10: planet.v1.FindPlayersRequest - (*FindPlayersResponse)(nil), // 11: planet.v1.FindPlayersResponse - (*TopPlayersRequest)(nil), // 12: planet.v1.TopPlayersRequest - (*TopPlayersResponse)(nil), // 13: planet.v1.TopPlayersResponse - (*Player)(nil), // 14: planet.v1.Player - (*BanPlayerRequest)(nil), // 15: planet.v1.BanPlayerRequest - (*BanPlayerResponse)(nil), // 16: planet.v1.BanPlayerResponse - (*UnbanPlayerRequest)(nil), // 17: planet.v1.UnbanPlayerRequest - (*UnbanPlayerResponse)(nil), // 18: planet.v1.UnbanPlayerResponse - (*RevertPlayerRequest)(nil), // 19: planet.v1.RevertPlayerRequest - (*RevertPlayerResponse)(nil), // 20: planet.v1.RevertPlayerResponse - (*ReassignCountryRequest)(nil), // 21: planet.v1.ReassignCountryRequest - (*ReassignCountryResponse)(nil), // 22: planet.v1.ReassignCountryResponse - (*timestamppb.Timestamp)(nil), // 23: google.protobuf.Timestamp - (*GetMapResponse)(nil), // 24: planet.v1.GetMapResponse - (*PlanetEvent)(nil), // 25: planet.v1.PlanetEvent - (*ChargesHeld)(nil), // 26: planet.v1.ChargesHeld - (*durationpb.Duration)(nil), // 27: google.protobuf.Duration + (*RestoreAreaRequest)(nil), // 0: planet.v1.RestoreAreaRequest + (*RestoreAreaResponse)(nil), // 1: planet.v1.RestoreAreaResponse + (*ClearAreaRequest)(nil), // 2: planet.v1.ClearAreaRequest + (*ClearAreaResponse)(nil), // 3: planet.v1.ClearAreaResponse + (*GetReplayRequest)(nil), // 4: planet.v1.GetReplayRequest + (*GetReplayResponse)(nil), // 5: planet.v1.GetReplayResponse + (*ReplayedEvent)(nil), // 6: planet.v1.ReplayedEvent + (*GrantChargesRequest)(nil), // 7: planet.v1.GrantChargesRequest + (*GrantChargesResponse)(nil), // 8: planet.v1.GrantChargesResponse + (*PaintRandomTilesRequest)(nil), // 9: planet.v1.PaintRandomTilesRequest + (*PaintRandomTilesResponse)(nil), // 10: planet.v1.PaintRandomTilesResponse + (*InspectPlayerRequest)(nil), // 11: planet.v1.InspectPlayerRequest + (*InspectPlayerResponse)(nil), // 12: planet.v1.InspectPlayerResponse + (*WatchdogReading)(nil), // 13: planet.v1.WatchdogReading + (*FindPlayersRequest)(nil), // 14: planet.v1.FindPlayersRequest + (*FindPlayersResponse)(nil), // 15: planet.v1.FindPlayersResponse + (*TopPlayersRequest)(nil), // 16: planet.v1.TopPlayersRequest + (*TopPlayersResponse)(nil), // 17: planet.v1.TopPlayersResponse + (*Player)(nil), // 18: planet.v1.Player + (*BanPlayerRequest)(nil), // 19: planet.v1.BanPlayerRequest + (*BanPlayerResponse)(nil), // 20: planet.v1.BanPlayerResponse + (*UnbanPlayerRequest)(nil), // 21: planet.v1.UnbanPlayerRequest + (*UnbanPlayerResponse)(nil), // 22: planet.v1.UnbanPlayerResponse + (*RevertPlayerRequest)(nil), // 23: planet.v1.RevertPlayerRequest + (*RevertPlayerResponse)(nil), // 24: planet.v1.RevertPlayerResponse + (*ReassignCountryRequest)(nil), // 25: planet.v1.ReassignCountryRequest + (*ReassignCountryResponse)(nil), // 26: planet.v1.ReassignCountryResponse + (*timestamppb.Timestamp)(nil), // 27: google.protobuf.Timestamp + (*GetMapResponse)(nil), // 28: planet.v1.GetMapResponse + (*PlanetEvent)(nil), // 29: planet.v1.PlanetEvent + (*ChargesHeld)(nil), // 30: planet.v1.ChargesHeld + (*durationpb.Duration)(nil), // 31: google.protobuf.Duration } var file_planet_v1_admin_proto_depIdxs = []int32{ - 23, // 0: planet.v1.GetReplayRequest.since:type_name -> google.protobuf.Timestamp - 23, // 1: planet.v1.GetReplayRequest.until:type_name -> google.protobuf.Timestamp - 24, // 2: planet.v1.GetReplayResponse.opening:type_name -> planet.v1.GetMapResponse - 2, // 3: planet.v1.GetReplayResponse.events:type_name -> planet.v1.ReplayedEvent - 23, // 4: planet.v1.GetReplayResponse.since:type_name -> google.protobuf.Timestamp - 23, // 5: planet.v1.GetReplayResponse.until:type_name -> google.protobuf.Timestamp - 23, // 6: planet.v1.ReplayedEvent.at:type_name -> google.protobuf.Timestamp - 25, // 7: planet.v1.ReplayedEvent.event:type_name -> planet.v1.PlanetEvent - 26, // 8: planet.v1.GrantChargesResponse.before:type_name -> planet.v1.ChargesHeld - 26, // 9: planet.v1.GrantChargesResponse.after:type_name -> planet.v1.ChargesHeld - 23, // 10: planet.v1.InspectPlayerResponse.banned_until:type_name -> google.protobuf.Timestamp - 9, // 11: planet.v1.InspectPlayerResponse.readings:type_name -> planet.v1.WatchdogReading - 27, // 12: planet.v1.InspectPlayerResponse.active_for:type_name -> google.protobuf.Duration - 27, // 13: planet.v1.InspectPlayerResponse.longest_gap:type_name -> google.protobuf.Duration - 23, // 14: planet.v1.InspectPlayerResponse.last_click_at:type_name -> google.protobuf.Timestamp - 23, // 15: planet.v1.WatchdogReading.at:type_name -> google.protobuf.Timestamp - 14, // 16: planet.v1.FindPlayersResponse.players:type_name -> planet.v1.Player - 14, // 17: planet.v1.TopPlayersResponse.players:type_name -> planet.v1.Player - 23, // 18: planet.v1.Player.first_at:type_name -> google.protobuf.Timestamp - 23, // 19: planet.v1.Player.last_at:type_name -> google.protobuf.Timestamp - 23, // 20: planet.v1.Player.banned_until:type_name -> google.protobuf.Timestamp - 27, // 21: planet.v1.Player.active_for:type_name -> google.protobuf.Duration - 27, // 22: planet.v1.BanPlayerRequest.duration:type_name -> google.protobuf.Duration - 23, // 23: planet.v1.BanPlayerResponse.banned_until:type_name -> google.protobuf.Timestamp - 23, // 24: planet.v1.UnbanPlayerResponse.banned_until:type_name -> google.protobuf.Timestamp - 21, // 25: planet.v1.AdminService.ReassignCountry:input_type -> planet.v1.ReassignCountryRequest - 10, // 26: planet.v1.AdminService.FindPlayers:input_type -> planet.v1.FindPlayersRequest - 12, // 27: planet.v1.AdminService.TopPlayers:input_type -> planet.v1.TopPlayersRequest - 15, // 28: planet.v1.AdminService.BanPlayer:input_type -> planet.v1.BanPlayerRequest - 17, // 29: planet.v1.AdminService.UnbanPlayer:input_type -> planet.v1.UnbanPlayerRequest - 19, // 30: planet.v1.AdminService.RevertPlayer:input_type -> planet.v1.RevertPlayerRequest - 5, // 31: planet.v1.AdminService.PaintRandomTiles:input_type -> planet.v1.PaintRandomTilesRequest - 7, // 32: planet.v1.AdminService.InspectPlayer:input_type -> planet.v1.InspectPlayerRequest - 3, // 33: planet.v1.AdminService.GrantCharges:input_type -> planet.v1.GrantChargesRequest - 0, // 34: planet.v1.AdminService.GetReplay:input_type -> planet.v1.GetReplayRequest - 22, // 35: planet.v1.AdminService.ReassignCountry:output_type -> planet.v1.ReassignCountryResponse - 11, // 36: planet.v1.AdminService.FindPlayers:output_type -> planet.v1.FindPlayersResponse - 13, // 37: planet.v1.AdminService.TopPlayers:output_type -> planet.v1.TopPlayersResponse - 16, // 38: planet.v1.AdminService.BanPlayer:output_type -> planet.v1.BanPlayerResponse - 18, // 39: planet.v1.AdminService.UnbanPlayer:output_type -> planet.v1.UnbanPlayerResponse - 20, // 40: planet.v1.AdminService.RevertPlayer:output_type -> planet.v1.RevertPlayerResponse - 6, // 41: planet.v1.AdminService.PaintRandomTiles:output_type -> planet.v1.PaintRandomTilesResponse - 8, // 42: planet.v1.AdminService.InspectPlayer:output_type -> planet.v1.InspectPlayerResponse - 4, // 43: planet.v1.AdminService.GrantCharges:output_type -> planet.v1.GrantChargesResponse - 1, // 44: planet.v1.AdminService.GetReplay:output_type -> planet.v1.GetReplayResponse - 35, // [35:45] is the sub-list for method output_type - 25, // [25:35] is the sub-list for method input_type - 25, // [25:25] is the sub-list for extension type_name - 25, // [25:25] is the sub-list for extension extendee - 0, // [0:25] is the sub-list for field type_name + 27, // 0: planet.v1.RestoreAreaRequest.before:type_name -> google.protobuf.Timestamp + 18, // 1: planet.v1.RestoreAreaResponse.painters:type_name -> planet.v1.Player + 27, // 2: planet.v1.RestoreAreaResponse.kept_since:type_name -> google.protobuf.Timestamp + 27, // 3: planet.v1.GetReplayRequest.since:type_name -> google.protobuf.Timestamp + 27, // 4: planet.v1.GetReplayRequest.until:type_name -> google.protobuf.Timestamp + 28, // 5: planet.v1.GetReplayResponse.opening:type_name -> planet.v1.GetMapResponse + 6, // 6: planet.v1.GetReplayResponse.events:type_name -> planet.v1.ReplayedEvent + 27, // 7: planet.v1.GetReplayResponse.since:type_name -> google.protobuf.Timestamp + 27, // 8: planet.v1.GetReplayResponse.until:type_name -> google.protobuf.Timestamp + 27, // 9: planet.v1.ReplayedEvent.at:type_name -> google.protobuf.Timestamp + 29, // 10: planet.v1.ReplayedEvent.event:type_name -> planet.v1.PlanetEvent + 30, // 11: planet.v1.GrantChargesResponse.before:type_name -> planet.v1.ChargesHeld + 30, // 12: planet.v1.GrantChargesResponse.after:type_name -> planet.v1.ChargesHeld + 27, // 13: planet.v1.InspectPlayerResponse.banned_until:type_name -> google.protobuf.Timestamp + 13, // 14: planet.v1.InspectPlayerResponse.readings:type_name -> planet.v1.WatchdogReading + 31, // 15: planet.v1.InspectPlayerResponse.active_for:type_name -> google.protobuf.Duration + 31, // 16: planet.v1.InspectPlayerResponse.longest_gap:type_name -> google.protobuf.Duration + 27, // 17: planet.v1.InspectPlayerResponse.last_click_at:type_name -> google.protobuf.Timestamp + 27, // 18: planet.v1.WatchdogReading.at:type_name -> google.protobuf.Timestamp + 18, // 19: planet.v1.FindPlayersResponse.players:type_name -> planet.v1.Player + 18, // 20: planet.v1.TopPlayersResponse.players:type_name -> planet.v1.Player + 27, // 21: planet.v1.Player.first_at:type_name -> google.protobuf.Timestamp + 27, // 22: planet.v1.Player.last_at:type_name -> google.protobuf.Timestamp + 27, // 23: planet.v1.Player.banned_until:type_name -> google.protobuf.Timestamp + 31, // 24: planet.v1.Player.active_for:type_name -> google.protobuf.Duration + 31, // 25: planet.v1.BanPlayerRequest.duration:type_name -> google.protobuf.Duration + 27, // 26: planet.v1.BanPlayerResponse.banned_until:type_name -> google.protobuf.Timestamp + 27, // 27: planet.v1.UnbanPlayerResponse.banned_until:type_name -> google.protobuf.Timestamp + 25, // 28: planet.v1.AdminService.ReassignCountry:input_type -> planet.v1.ReassignCountryRequest + 14, // 29: planet.v1.AdminService.FindPlayers:input_type -> planet.v1.FindPlayersRequest + 16, // 30: planet.v1.AdminService.TopPlayers:input_type -> planet.v1.TopPlayersRequest + 19, // 31: planet.v1.AdminService.BanPlayer:input_type -> planet.v1.BanPlayerRequest + 21, // 32: planet.v1.AdminService.UnbanPlayer:input_type -> planet.v1.UnbanPlayerRequest + 23, // 33: planet.v1.AdminService.RevertPlayer:input_type -> planet.v1.RevertPlayerRequest + 9, // 34: planet.v1.AdminService.PaintRandomTiles:input_type -> planet.v1.PaintRandomTilesRequest + 11, // 35: planet.v1.AdminService.InspectPlayer:input_type -> planet.v1.InspectPlayerRequest + 7, // 36: planet.v1.AdminService.GrantCharges:input_type -> planet.v1.GrantChargesRequest + 4, // 37: planet.v1.AdminService.GetReplay:input_type -> planet.v1.GetReplayRequest + 0, // 38: planet.v1.AdminService.RestoreArea:input_type -> planet.v1.RestoreAreaRequest + 2, // 39: planet.v1.AdminService.ClearArea:input_type -> planet.v1.ClearAreaRequest + 26, // 40: planet.v1.AdminService.ReassignCountry:output_type -> planet.v1.ReassignCountryResponse + 15, // 41: planet.v1.AdminService.FindPlayers:output_type -> planet.v1.FindPlayersResponse + 17, // 42: planet.v1.AdminService.TopPlayers:output_type -> planet.v1.TopPlayersResponse + 20, // 43: planet.v1.AdminService.BanPlayer:output_type -> planet.v1.BanPlayerResponse + 22, // 44: planet.v1.AdminService.UnbanPlayer:output_type -> planet.v1.UnbanPlayerResponse + 24, // 45: planet.v1.AdminService.RevertPlayer:output_type -> planet.v1.RevertPlayerResponse + 10, // 46: planet.v1.AdminService.PaintRandomTiles:output_type -> planet.v1.PaintRandomTilesResponse + 12, // 47: planet.v1.AdminService.InspectPlayer:output_type -> planet.v1.InspectPlayerResponse + 8, // 48: planet.v1.AdminService.GrantCharges:output_type -> planet.v1.GrantChargesResponse + 5, // 49: planet.v1.AdminService.GetReplay:output_type -> planet.v1.GetReplayResponse + 1, // 50: planet.v1.AdminService.RestoreArea:output_type -> planet.v1.RestoreAreaResponse + 3, // 51: planet.v1.AdminService.ClearArea:output_type -> planet.v1.ClearAreaResponse + 40, // [40:52] is the sub-list for method output_type + 28, // [28:40] is the sub-list for method input_type + 28, // [28:28] is the sub-list for extension type_name + 28, // [28:28] is the sub-list for extension extendee + 0, // [0:28] is the sub-list for field type_name } func init() { file_planet_v1_admin_proto_init() } @@ -1927,15 +2244,15 @@ func file_planet_v1_admin_proto_init() { return } file_planet_v1_planet_proto_init() - file_planet_v1_admin_proto_msgTypes[8].OneofWrappers = []any{} - file_planet_v1_admin_proto_msgTypes[14].OneofWrappers = []any{} + file_planet_v1_admin_proto_msgTypes[12].OneofWrappers = []any{} + file_planet_v1_admin_proto_msgTypes[18].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_planet_v1_admin_proto_rawDesc), len(file_planet_v1_admin_proto_rawDesc)), NumEnums: 0, - NumMessages: 23, + NumMessages: 27, NumExtensions: 0, NumServices: 1, }, diff --git a/apps/backend/generated/proto/planet/v1/planetv1connect/admin.connect.go b/apps/backend/generated/proto/planet/v1/planetv1connect/admin.connect.go index 889a6b6d4..c421033da 100644 --- a/apps/backend/generated/proto/planet/v1/planetv1connect/admin.connect.go +++ b/apps/backend/generated/proto/planet/v1/planetv1connect/admin.connect.go @@ -60,6 +60,11 @@ const ( AdminServiceGrantChargesProcedure = "/planet.v1.AdminService/GrantCharges" // AdminServiceGetReplayProcedure is the fully-qualified name of the AdminService's GetReplay RPC. AdminServiceGetReplayProcedure = "/planet.v1.AdminService/GetReplay" + // AdminServiceRestoreAreaProcedure is the fully-qualified name of the AdminService's RestoreArea + // RPC. + AdminServiceRestoreAreaProcedure = "/planet.v1.AdminService/RestoreArea" + // AdminServiceClearAreaProcedure is the fully-qualified name of the AdminService's ClearArea RPC. + AdminServiceClearAreaProcedure = "/planet.v1.AdminService/ClearArea" ) // AdminServiceClient is a client for the planet.v1.AdminService service. @@ -74,6 +79,10 @@ type AdminServiceClient interface { InspectPlayer(context.Context, *connect.Request[v1.InspectPlayerRequest]) (*connect.Response[v1.InspectPlayerResponse], error) GrantCharges(context.Context, *connect.Request[v1.GrantChargesRequest]) (*connect.Response[v1.GrantChargesResponse], error) GetReplay(context.Context, *connect.Request[v1.GetReplayRequest]) (*connect.Response[v1.GetReplayResponse], error) + // Who painted the area around a tile since a time, and what putting the area back as it was then changes. Restores it unless dry_run. + RestoreArea(context.Context, *connect.Request[v1.RestoreAreaRequest]) (*connect.Response[v1.RestoreAreaResponse], error) + // Gives every tile of the area around a tile to nobody, for a drawing older than the ledger keeps. Clears it unless dry_run. + ClearArea(context.Context, *connect.Request[v1.ClearAreaRequest]) (*connect.Response[v1.ClearAreaResponse], error) } // NewAdminServiceClient constructs a client for the planet.v1.AdminService service. By default, it @@ -147,6 +156,18 @@ func NewAdminServiceClient(httpClient connect.HTTPClient, baseURL string, opts . connect.WithSchema(adminServiceMethods.ByName("GetReplay")), connect.WithClientOptions(opts...), ), + restoreArea: connect.NewClient[v1.RestoreAreaRequest, v1.RestoreAreaResponse]( + httpClient, + baseURL+AdminServiceRestoreAreaProcedure, + connect.WithSchema(adminServiceMethods.ByName("RestoreArea")), + connect.WithClientOptions(opts...), + ), + clearArea: connect.NewClient[v1.ClearAreaRequest, v1.ClearAreaResponse]( + httpClient, + baseURL+AdminServiceClearAreaProcedure, + connect.WithSchema(adminServiceMethods.ByName("ClearArea")), + connect.WithClientOptions(opts...), + ), } } @@ -162,6 +183,8 @@ type adminServiceClient struct { inspectPlayer *connect.Client[v1.InspectPlayerRequest, v1.InspectPlayerResponse] grantCharges *connect.Client[v1.GrantChargesRequest, v1.GrantChargesResponse] getReplay *connect.Client[v1.GetReplayRequest, v1.GetReplayResponse] + restoreArea *connect.Client[v1.RestoreAreaRequest, v1.RestoreAreaResponse] + clearArea *connect.Client[v1.ClearAreaRequest, v1.ClearAreaResponse] } // ReassignCountry calls planet.v1.AdminService.ReassignCountry. @@ -214,6 +237,16 @@ func (c *adminServiceClient) GetReplay(ctx context.Context, req *connect.Request return c.getReplay.CallUnary(ctx, req) } +// RestoreArea calls planet.v1.AdminService.RestoreArea. +func (c *adminServiceClient) RestoreArea(ctx context.Context, req *connect.Request[v1.RestoreAreaRequest]) (*connect.Response[v1.RestoreAreaResponse], error) { + return c.restoreArea.CallUnary(ctx, req) +} + +// ClearArea calls planet.v1.AdminService.ClearArea. +func (c *adminServiceClient) ClearArea(ctx context.Context, req *connect.Request[v1.ClearAreaRequest]) (*connect.Response[v1.ClearAreaResponse], error) { + return c.clearArea.CallUnary(ctx, req) +} + // AdminServiceHandler is an implementation of the planet.v1.AdminService service. type AdminServiceHandler interface { ReassignCountry(context.Context, *connect.Request[v1.ReassignCountryRequest]) (*connect.Response[v1.ReassignCountryResponse], error) @@ -226,6 +259,10 @@ type AdminServiceHandler interface { InspectPlayer(context.Context, *connect.Request[v1.InspectPlayerRequest]) (*connect.Response[v1.InspectPlayerResponse], error) GrantCharges(context.Context, *connect.Request[v1.GrantChargesRequest]) (*connect.Response[v1.GrantChargesResponse], error) GetReplay(context.Context, *connect.Request[v1.GetReplayRequest]) (*connect.Response[v1.GetReplayResponse], error) + // Who painted the area around a tile since a time, and what putting the area back as it was then changes. Restores it unless dry_run. + RestoreArea(context.Context, *connect.Request[v1.RestoreAreaRequest]) (*connect.Response[v1.RestoreAreaResponse], error) + // Gives every tile of the area around a tile to nobody, for a drawing older than the ledger keeps. Clears it unless dry_run. + ClearArea(context.Context, *connect.Request[v1.ClearAreaRequest]) (*connect.Response[v1.ClearAreaResponse], error) } // NewAdminServiceHandler builds an HTTP handler from the service implementation. It returns the @@ -295,6 +332,18 @@ func NewAdminServiceHandler(svc AdminServiceHandler, opts ...connect.HandlerOpti connect.WithSchema(adminServiceMethods.ByName("GetReplay")), connect.WithHandlerOptions(opts...), ) + adminServiceRestoreAreaHandler := connect.NewUnaryHandler( + AdminServiceRestoreAreaProcedure, + svc.RestoreArea, + connect.WithSchema(adminServiceMethods.ByName("RestoreArea")), + connect.WithHandlerOptions(opts...), + ) + adminServiceClearAreaHandler := connect.NewUnaryHandler( + AdminServiceClearAreaProcedure, + svc.ClearArea, + connect.WithSchema(adminServiceMethods.ByName("ClearArea")), + connect.WithHandlerOptions(opts...), + ) return "/planet.v1.AdminService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case AdminServiceReassignCountryProcedure: @@ -317,6 +366,10 @@ func NewAdminServiceHandler(svc AdminServiceHandler, opts ...connect.HandlerOpti adminServiceGrantChargesHandler.ServeHTTP(w, r) case AdminServiceGetReplayProcedure: adminServiceGetReplayHandler.ServeHTTP(w, r) + case AdminServiceRestoreAreaProcedure: + adminServiceRestoreAreaHandler.ServeHTTP(w, r) + case AdminServiceClearAreaProcedure: + adminServiceClearAreaHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -365,3 +418,11 @@ func (UnimplementedAdminServiceHandler) GrantCharges(context.Context, *connect.R func (UnimplementedAdminServiceHandler) GetReplay(context.Context, *connect.Request[v1.GetReplayRequest]) (*connect.Response[v1.GetReplayResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("planet.v1.AdminService.GetReplay is not implemented")) } + +func (UnimplementedAdminServiceHandler) RestoreArea(context.Context, *connect.Request[v1.RestoreAreaRequest]) (*connect.Response[v1.RestoreAreaResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("planet.v1.AdminService.RestoreArea is not implemented")) +} + +func (UnimplementedAdminServiceHandler) ClearArea(context.Context, *connect.Request[v1.ClearAreaRequest]) (*connect.Response[v1.ClearAreaResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("planet.v1.AdminService.ClearArea is not implemented")) +} diff --git a/apps/backend/generated/proto/support/v1/admin.pb.go b/apps/backend/generated/proto/support/v1/admin.pb.go index 2de7f564d..81d4a0e76 100644 --- a/apps/backend/generated/proto/support/v1/admin.pb.go +++ b/apps/backend/generated/proto/support/v1/admin.pb.go @@ -783,6 +783,321 @@ func (x *DecideReportResponse) GetDecidedAt() *timestamppb.Timestamp { return nil } +type ListPlaceReportsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Unset is every report kept. + Since *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=since,proto3" json:"since,omitempty"` + // Unset is 100; at most 500. + Limit uint32 `protobuf:"varint,2,opt,name=limit,proto3" json:"limit,omitempty"` + // Only the reports nobody decided yet. + OpenOnly bool `protobuf:"varint,3,opt,name=open_only,json=openOnly,proto3" json:"open_only,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListPlaceReportsRequest) Reset() { + *x = ListPlaceReportsRequest{} + mi := &file_support_v1_admin_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListPlaceReportsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListPlaceReportsRequest) ProtoMessage() {} + +func (x *ListPlaceReportsRequest) ProtoReflect() protoreflect.Message { + mi := &file_support_v1_admin_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListPlaceReportsRequest.ProtoReflect.Descriptor instead. +func (*ListPlaceReportsRequest) Descriptor() ([]byte, []int) { + return file_support_v1_admin_proto_rawDescGZIP(), []int{10} +} + +func (x *ListPlaceReportsRequest) GetSince() *timestamppb.Timestamp { + if x != nil { + return x.Since + } + return nil +} + +func (x *ListPlaceReportsRequest) GetLimit() uint32 { + if x != nil { + return x.Limit + } + return 0 +} + +func (x *ListPlaceReportsRequest) GetOpenOnly() bool { + if x != nil { + return x.OpenOnly + } + return false +} + +type ListPlaceReportsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Reports []*PlaceReport `protobuf:"bytes,1,rep,name=reports,proto3" json:"reports,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListPlaceReportsResponse) Reset() { + *x = ListPlaceReportsResponse{} + mi := &file_support_v1_admin_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListPlaceReportsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListPlaceReportsResponse) ProtoMessage() {} + +func (x *ListPlaceReportsResponse) ProtoReflect() protoreflect.Message { + mi := &file_support_v1_admin_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListPlaceReportsResponse.ProtoReflect.Descriptor instead. +func (*ListPlaceReportsResponse) Descriptor() ([]byte, []int) { + return file_support_v1_admin_proto_rawDescGZIP(), []int{11} +} + +func (x *ListPlaceReportsResponse) GetReports() []*PlaceReport { + if x != nil { + return x.Reports + } + return nil +} + +type PlaceReport struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // Empty once the reporter's account is deleted, or past the retention. + ReporterAccountId string `protobuf:"bytes,2,opt,name=reporter_account_id,json=reporterAccountId,proto3" json:"reporter_account_id,omitempty"` + // The name the game shows for the reporter now; empty for an account it can no longer name. + ReporterName string `protobuf:"bytes,3,opt,name=reporter_name,json=reporterName,proto3" json:"reporter_name,omitempty"` + TileId uint32 `protobuf:"varint,4,opt,name=tile_id,json=tileId,proto3" json:"tile_id,omitempty"` + Reason PlaceReportReason `protobuf:"varint,5,opt,name=reason,proto3,enum=support.v1.PlaceReportReason" json:"reason,omitempty"` + Text string `protobuf:"bytes,6,opt,name=text,proto3" json:"text,omitempty"` + CreatedAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + // Unset while the report is open. + Decision *ReportDecision `protobuf:"bytes,8,opt,name=decision,proto3" json:"decision,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PlaceReport) Reset() { + *x = PlaceReport{} + mi := &file_support_v1_admin_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PlaceReport) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PlaceReport) ProtoMessage() {} + +func (x *PlaceReport) ProtoReflect() protoreflect.Message { + mi := &file_support_v1_admin_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PlaceReport.ProtoReflect.Descriptor instead. +func (*PlaceReport) Descriptor() ([]byte, []int) { + return file_support_v1_admin_proto_rawDescGZIP(), []int{12} +} + +func (x *PlaceReport) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *PlaceReport) GetReporterAccountId() string { + if x != nil { + return x.ReporterAccountId + } + return "" +} + +func (x *PlaceReport) GetReporterName() string { + if x != nil { + return x.ReporterName + } + return "" +} + +func (x *PlaceReport) GetTileId() uint32 { + if x != nil { + return x.TileId + } + return 0 +} + +func (x *PlaceReport) GetReason() PlaceReportReason { + if x != nil { + return x.Reason + } + return PlaceReportReason_PLACE_REPORT_REASON_UNSPECIFIED +} + +func (x *PlaceReport) GetText() string { + if x != nil { + return x.Text + } + return "" +} + +func (x *PlaceReport) GetCreatedAt() *timestamppb.Timestamp { + if x != nil { + return x.CreatedAt + } + return nil +} + +func (x *PlaceReport) GetDecision() *ReportDecision { + if x != nil { + return x.Decision + } + return nil +} + +type DecidePlaceReportRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ReportId string `protobuf:"bytes,1,opt,name=report_id,json=reportId,proto3" json:"report_id,omitempty"` + Outcome ReportOutcome `protobuf:"varint,2,opt,name=outcome,proto3,enum=support.v1.ReportOutcome" json:"outcome,omitempty"` + // Optional: why, in the operator's words. + Comment string `protobuf:"bytes,3,opt,name=comment,proto3" json:"comment,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DecidePlaceReportRequest) Reset() { + *x = DecidePlaceReportRequest{} + mi := &file_support_v1_admin_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DecidePlaceReportRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DecidePlaceReportRequest) ProtoMessage() {} + +func (x *DecidePlaceReportRequest) ProtoReflect() protoreflect.Message { + mi := &file_support_v1_admin_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DecidePlaceReportRequest.ProtoReflect.Descriptor instead. +func (*DecidePlaceReportRequest) Descriptor() ([]byte, []int) { + return file_support_v1_admin_proto_rawDescGZIP(), []int{13} +} + +func (x *DecidePlaceReportRequest) GetReportId() string { + if x != nil { + return x.ReportId + } + return "" +} + +func (x *DecidePlaceReportRequest) GetOutcome() ReportOutcome { + if x != nil { + return x.Outcome + } + return ReportOutcome_REPORT_OUTCOME_UNSPECIFIED +} + +func (x *DecidePlaceReportRequest) GetComment() string { + if x != nil { + return x.Comment + } + return "" +} + +type DecidePlaceReportResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + DecidedAt *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=decided_at,json=decidedAt,proto3" json:"decided_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DecidePlaceReportResponse) Reset() { + *x = DecidePlaceReportResponse{} + mi := &file_support_v1_admin_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DecidePlaceReportResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DecidePlaceReportResponse) ProtoMessage() {} + +func (x *DecidePlaceReportResponse) ProtoReflect() protoreflect.Message { + mi := &file_support_v1_admin_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DecidePlaceReportResponse.ProtoReflect.Descriptor instead. +func (*DecidePlaceReportResponse) Descriptor() ([]byte, []int) { + return file_support_v1_admin_proto_rawDescGZIP(), []int{14} +} + +func (x *DecidePlaceReportResponse) GetDecidedAt() *timestamppb.Timestamp { + if x != nil { + return x.DecidedAt + } + return nil +} + var File_support_v1_admin_proto protoreflect.FileDescriptor const file_support_v1_admin_proto_rawDesc = "" + @@ -842,6 +1157,29 @@ const file_support_v1_admin_proto_rawDesc = "" + "\acomment\x18\x03 \x01(\tR\acomment\"Q\n" + "\x14DecideReportResponse\x129\n" + "\n" + + "decided_at\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampR\tdecidedAt\"~\n" + + "\x17ListPlaceReportsRequest\x120\n" + + "\x05since\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampR\x05since\x12\x14\n" + + "\x05limit\x18\x02 \x01(\rR\x05limit\x12\x1b\n" + + "\topen_only\x18\x03 \x01(\bR\bopenOnly\"M\n" + + "\x18ListPlaceReportsResponse\x121\n" + + "\areports\x18\x01 \x03(\v2\x17.support.v1.PlaceReportR\areports\"\xc9\x02\n" + + "\vPlaceReport\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12.\n" + + "\x13reporter_account_id\x18\x02 \x01(\tR\x11reporterAccountId\x12#\n" + + "\rreporter_name\x18\x03 \x01(\tR\freporterName\x12\x17\n" + + "\atile_id\x18\x04 \x01(\rR\x06tileId\x125\n" + + "\x06reason\x18\x05 \x01(\x0e2\x1d.support.v1.PlaceReportReasonR\x06reason\x12\x12\n" + + "\x04text\x18\x06 \x01(\tR\x04text\x129\n" + + "\n" + + "created_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x126\n" + + "\bdecision\x18\b \x01(\v2\x1a.support.v1.ReportDecisionR\bdecision\"\x86\x01\n" + + "\x18DecidePlaceReportRequest\x12\x1b\n" + + "\treport_id\x18\x01 \x01(\tR\breportId\x123\n" + + "\aoutcome\x18\x02 \x01(\x0e2\x19.support.v1.ReportOutcomeR\aoutcome\x12\x18\n" + + "\acomment\x18\x03 \x01(\tR\acomment\"V\n" + + "\x19DecidePlaceReportResponse\x129\n" + + "\n" + "decided_at\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampR\tdecidedAt*R\n" + "\bNoteKind\x12\x19\n" + "\x15NOTE_KIND_UNSPECIFIED\x10\x00\x12\x18\n" + @@ -850,11 +1188,13 @@ const file_support_v1_admin_proto_rawDesc = "" + "\rReportOutcome\x12\x1e\n" + "\x1aREPORT_OUTCOME_UNSPECIFIED\x10\x00\x12\x18\n" + "\x14REPORT_OUTCOME_ACTED\x10\x01\x12\x1c\n" + - "\x18REPORT_OUTCOME_DISMISSED\x10\x022\xfb\x01\n" + + "\x18REPORT_OUTCOME_DISMISSED\x10\x022\xbc\x03\n" + "\fAdminService\x12H\n" + "\tListNotes\x12\x1c.support.v1.ListNotesRequest\x1a\x1d.support.v1.ListNotesResponse\x12N\n" + "\vListReports\x12\x1e.support.v1.ListReportsRequest\x1a\x1f.support.v1.ListReportsResponse\x12Q\n" + - "\fDecideReport\x12\x1f.support.v1.DecideReportRequest\x1a .support.v1.DecideReportResponseB\xb9\x01\n" + + "\fDecideReport\x12\x1f.support.v1.DecideReportRequest\x1a .support.v1.DecideReportResponse\x12]\n" + + "\x10ListPlaceReports\x12#.support.v1.ListPlaceReportsRequest\x1a$.support.v1.ListPlaceReportsResponse\x12`\n" + + "\x11DecidePlaceReport\x12$.support.v1.DecidePlaceReportRequest\x1a%.support.v1.DecidePlaceReportResponseB\xb9\x01\n" + "\x0ecom.support.v1B\n" + "AdminProtoP\x01ZRgithub.com/raphoester/clickplanet.lol-backend/generated/proto/support/v1;supportv1\xa2\x02\x03SXX\xaa\x02\n" + "Support.V1\xca\x02\n" + @@ -873,52 +1213,69 @@ func file_support_v1_admin_proto_rawDescGZIP() []byte { } var file_support_v1_admin_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_support_v1_admin_proto_msgTypes = make([]protoimpl.MessageInfo, 10) +var file_support_v1_admin_proto_msgTypes = make([]protoimpl.MessageInfo, 15) var file_support_v1_admin_proto_goTypes = []any{ - (NoteKind)(0), // 0: support.v1.NoteKind - (ReportOutcome)(0), // 1: support.v1.ReportOutcome - (*ListNotesRequest)(nil), // 2: support.v1.ListNotesRequest - (*ListNotesResponse)(nil), // 3: support.v1.ListNotesResponse - (*Note)(nil), // 4: support.v1.Note - (*NoteAttachment)(nil), // 5: support.v1.NoteAttachment - (*ListReportsRequest)(nil), // 6: support.v1.ListReportsRequest - (*ListReportsResponse)(nil), // 7: support.v1.ListReportsResponse - (*Report)(nil), // 8: support.v1.Report - (*ReportDecision)(nil), // 9: support.v1.ReportDecision - (*DecideReportRequest)(nil), // 10: support.v1.DecideReportRequest - (*DecideReportResponse)(nil), // 11: support.v1.DecideReportResponse - (*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp - (*PageContext)(nil), // 13: support.v1.PageContext - (ReportReason)(0), // 14: support.v1.ReportReason + (NoteKind)(0), // 0: support.v1.NoteKind + (ReportOutcome)(0), // 1: support.v1.ReportOutcome + (*ListNotesRequest)(nil), // 2: support.v1.ListNotesRequest + (*ListNotesResponse)(nil), // 3: support.v1.ListNotesResponse + (*Note)(nil), // 4: support.v1.Note + (*NoteAttachment)(nil), // 5: support.v1.NoteAttachment + (*ListReportsRequest)(nil), // 6: support.v1.ListReportsRequest + (*ListReportsResponse)(nil), // 7: support.v1.ListReportsResponse + (*Report)(nil), // 8: support.v1.Report + (*ReportDecision)(nil), // 9: support.v1.ReportDecision + (*DecideReportRequest)(nil), // 10: support.v1.DecideReportRequest + (*DecideReportResponse)(nil), // 11: support.v1.DecideReportResponse + (*ListPlaceReportsRequest)(nil), // 12: support.v1.ListPlaceReportsRequest + (*ListPlaceReportsResponse)(nil), // 13: support.v1.ListPlaceReportsResponse + (*PlaceReport)(nil), // 14: support.v1.PlaceReport + (*DecidePlaceReportRequest)(nil), // 15: support.v1.DecidePlaceReportRequest + (*DecidePlaceReportResponse)(nil), // 16: support.v1.DecidePlaceReportResponse + (*timestamppb.Timestamp)(nil), // 17: google.protobuf.Timestamp + (*PageContext)(nil), // 18: support.v1.PageContext + (ReportReason)(0), // 19: support.v1.ReportReason + (PlaceReportReason)(0), // 20: support.v1.PlaceReportReason } var file_support_v1_admin_proto_depIdxs = []int32{ - 12, // 0: support.v1.ListNotesRequest.since:type_name -> google.protobuf.Timestamp + 17, // 0: support.v1.ListNotesRequest.since:type_name -> google.protobuf.Timestamp 0, // 1: support.v1.ListNotesRequest.kind:type_name -> support.v1.NoteKind 4, // 2: support.v1.ListNotesResponse.notes:type_name -> support.v1.Note 0, // 3: support.v1.Note.kind:type_name -> support.v1.NoteKind - 12, // 4: support.v1.Note.submitted_at:type_name -> google.protobuf.Timestamp - 13, // 5: support.v1.Note.context:type_name -> support.v1.PageContext + 17, // 4: support.v1.Note.submitted_at:type_name -> google.protobuf.Timestamp + 18, // 5: support.v1.Note.context:type_name -> support.v1.PageContext 5, // 6: support.v1.Note.attachments:type_name -> support.v1.NoteAttachment - 12, // 7: support.v1.ListReportsRequest.since:type_name -> google.protobuf.Timestamp + 17, // 7: support.v1.ListReportsRequest.since:type_name -> google.protobuf.Timestamp 8, // 8: support.v1.ListReportsResponse.reports:type_name -> support.v1.Report - 14, // 9: support.v1.Report.reason:type_name -> support.v1.ReportReason - 12, // 10: support.v1.Report.created_at:type_name -> google.protobuf.Timestamp + 19, // 9: support.v1.Report.reason:type_name -> support.v1.ReportReason + 17, // 10: support.v1.Report.created_at:type_name -> google.protobuf.Timestamp 9, // 11: support.v1.Report.decision:type_name -> support.v1.ReportDecision 1, // 12: support.v1.ReportDecision.outcome:type_name -> support.v1.ReportOutcome - 12, // 13: support.v1.ReportDecision.decided_at:type_name -> google.protobuf.Timestamp + 17, // 13: support.v1.ReportDecision.decided_at:type_name -> google.protobuf.Timestamp 1, // 14: support.v1.DecideReportRequest.outcome:type_name -> support.v1.ReportOutcome - 12, // 15: support.v1.DecideReportResponse.decided_at:type_name -> google.protobuf.Timestamp - 2, // 16: support.v1.AdminService.ListNotes:input_type -> support.v1.ListNotesRequest - 6, // 17: support.v1.AdminService.ListReports:input_type -> support.v1.ListReportsRequest - 10, // 18: support.v1.AdminService.DecideReport:input_type -> support.v1.DecideReportRequest - 3, // 19: support.v1.AdminService.ListNotes:output_type -> support.v1.ListNotesResponse - 7, // 20: support.v1.AdminService.ListReports:output_type -> support.v1.ListReportsResponse - 11, // 21: support.v1.AdminService.DecideReport:output_type -> support.v1.DecideReportResponse - 19, // [19:22] is the sub-list for method output_type - 16, // [16:19] is the sub-list for method input_type - 16, // [16:16] is the sub-list for extension type_name - 16, // [16:16] is the sub-list for extension extendee - 0, // [0:16] is the sub-list for field type_name + 17, // 15: support.v1.DecideReportResponse.decided_at:type_name -> google.protobuf.Timestamp + 17, // 16: support.v1.ListPlaceReportsRequest.since:type_name -> google.protobuf.Timestamp + 14, // 17: support.v1.ListPlaceReportsResponse.reports:type_name -> support.v1.PlaceReport + 20, // 18: support.v1.PlaceReport.reason:type_name -> support.v1.PlaceReportReason + 17, // 19: support.v1.PlaceReport.created_at:type_name -> google.protobuf.Timestamp + 9, // 20: support.v1.PlaceReport.decision:type_name -> support.v1.ReportDecision + 1, // 21: support.v1.DecidePlaceReportRequest.outcome:type_name -> support.v1.ReportOutcome + 17, // 22: support.v1.DecidePlaceReportResponse.decided_at:type_name -> google.protobuf.Timestamp + 2, // 23: support.v1.AdminService.ListNotes:input_type -> support.v1.ListNotesRequest + 6, // 24: support.v1.AdminService.ListReports:input_type -> support.v1.ListReportsRequest + 10, // 25: support.v1.AdminService.DecideReport:input_type -> support.v1.DecideReportRequest + 12, // 26: support.v1.AdminService.ListPlaceReports:input_type -> support.v1.ListPlaceReportsRequest + 15, // 27: support.v1.AdminService.DecidePlaceReport:input_type -> support.v1.DecidePlaceReportRequest + 3, // 28: support.v1.AdminService.ListNotes:output_type -> support.v1.ListNotesResponse + 7, // 29: support.v1.AdminService.ListReports:output_type -> support.v1.ListReportsResponse + 11, // 30: support.v1.AdminService.DecideReport:output_type -> support.v1.DecideReportResponse + 13, // 31: support.v1.AdminService.ListPlaceReports:output_type -> support.v1.ListPlaceReportsResponse + 16, // 32: support.v1.AdminService.DecidePlaceReport:output_type -> support.v1.DecidePlaceReportResponse + 28, // [28:33] is the sub-list for method output_type + 23, // [23:28] is the sub-list for method input_type + 23, // [23:23] is the sub-list for extension type_name + 23, // [23:23] is the sub-list for extension extendee + 0, // [0:23] is the sub-list for field type_name } func init() { file_support_v1_admin_proto_init() } @@ -933,7 +1290,7 @@ func file_support_v1_admin_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_support_v1_admin_proto_rawDesc), len(file_support_v1_admin_proto_rawDesc)), NumEnums: 2, - NumMessages: 10, + NumMessages: 15, NumExtensions: 0, NumServices: 1, }, diff --git a/apps/backend/generated/proto/support/v1/support.pb.go b/apps/backend/generated/proto/support/v1/support.pb.go index afd9dc176..64b2358b0 100644 --- a/apps/backend/generated/proto/support/v1/support.pb.go +++ b/apps/backend/generated/proto/support/v1/support.pb.go @@ -76,6 +76,59 @@ func (ReportReason) EnumDescriptor() ([]byte, []int) { return file_support_v1_support_proto_rawDescGZIP(), []int{0} } +type PlaceReportReason int32 + +const ( + PlaceReportReason_PLACE_REPORT_REASON_UNSPECIFIED PlaceReportReason = 0 + // A hateful symbol or hateful words. + PlaceReportReason_PLACE_REPORT_REASON_HATE PlaceReportReason = 1 + PlaceReportReason_PLACE_REPORT_REASON_SEXUAL PlaceReportReason = 2 + PlaceReportReason_PLACE_REPORT_REASON_OTHER PlaceReportReason = 3 +) + +// Enum value maps for PlaceReportReason. +var ( + PlaceReportReason_name = map[int32]string{ + 0: "PLACE_REPORT_REASON_UNSPECIFIED", + 1: "PLACE_REPORT_REASON_HATE", + 2: "PLACE_REPORT_REASON_SEXUAL", + 3: "PLACE_REPORT_REASON_OTHER", + } + PlaceReportReason_value = map[string]int32{ + "PLACE_REPORT_REASON_UNSPECIFIED": 0, + "PLACE_REPORT_REASON_HATE": 1, + "PLACE_REPORT_REASON_SEXUAL": 2, + "PLACE_REPORT_REASON_OTHER": 3, + } +) + +func (x PlaceReportReason) Enum() *PlaceReportReason { + p := new(PlaceReportReason) + *p = x + return p +} + +func (x PlaceReportReason) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (PlaceReportReason) Descriptor() protoreflect.EnumDescriptor { + return file_support_v1_support_proto_enumTypes[1].Descriptor() +} + +func (PlaceReportReason) Type() protoreflect.EnumType { + return &file_support_v1_support_proto_enumTypes[1] +} + +func (x PlaceReportReason) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use PlaceReportReason.Descriptor instead. +func (PlaceReportReason) EnumDescriptor() ([]byte, []int) { + return file_support_v1_support_proto_rawDescGZIP(), []int{1} +} + type SuggestFeatureRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"` @@ -720,6 +773,104 @@ func (*ReportPlayerResponse) Descriptor() ([]byte, []int) { return file_support_v1_support_proto_rawDescGZIP(), []int{10} } +type ReportPlaceRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // A tile of the drawing, the one the reporter picked. + TileId uint32 `protobuf:"varint,1,opt,name=tile_id,json=tileId,proto3" json:"tile_id,omitempty"` + Reason PlaceReportReason `protobuf:"varint,2,opt,name=reason,proto3,enum=support.v1.PlaceReportReason" json:"reason,omitempty"` + // Optional. + Text string `protobuf:"bytes,3,opt,name=text,proto3" json:"text,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReportPlaceRequest) Reset() { + *x = ReportPlaceRequest{} + mi := &file_support_v1_support_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReportPlaceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReportPlaceRequest) ProtoMessage() {} + +func (x *ReportPlaceRequest) ProtoReflect() protoreflect.Message { + mi := &file_support_v1_support_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReportPlaceRequest.ProtoReflect.Descriptor instead. +func (*ReportPlaceRequest) Descriptor() ([]byte, []int) { + return file_support_v1_support_proto_rawDescGZIP(), []int{11} +} + +func (x *ReportPlaceRequest) GetTileId() uint32 { + if x != nil { + return x.TileId + } + return 0 +} + +func (x *ReportPlaceRequest) GetReason() PlaceReportReason { + if x != nil { + return x.Reason + } + return PlaceReportReason_PLACE_REPORT_REASON_UNSPECIFIED +} + +func (x *ReportPlaceRequest) GetText() string { + if x != nil { + return x.Text + } + return "" +} + +type ReportPlaceResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReportPlaceResponse) Reset() { + *x = ReportPlaceResponse{} + mi := &file_support_v1_support_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReportPlaceResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReportPlaceResponse) ProtoMessage() {} + +func (x *ReportPlaceResponse) ProtoReflect() protoreflect.Message { + mi := &file_support_v1_support_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReportPlaceResponse.ProtoReflect.Descriptor instead. +func (*ReportPlaceResponse) Descriptor() ([]byte, []int) { + return file_support_v1_support_proto_rawDescGZIP(), []int{12} +} + var File_support_v1_support_proto protoreflect.FileDescriptor const file_support_v1_support_proto_rawDesc = "" + @@ -772,17 +923,28 @@ const file_support_v1_support_proto_rawDesc = "" + "message_id\x18\x04 \x01(\tR\tmessageId\x12\x1d\n" + "\n" + "account_id\x18\x05 \x01(\tR\taccountIdJ\x04\b\x01\x10\x02R\x04name\"\x16\n" + - "\x14ReportPlayerResponse*\x9c\x01\n" + + "\x14ReportPlayerResponse\"x\n" + + "\x12ReportPlaceRequest\x12\x17\n" + + "\atile_id\x18\x01 \x01(\rR\x06tileId\x125\n" + + "\x06reason\x18\x02 \x01(\x0e2\x1d.support.v1.PlaceReportReasonR\x06reason\x12\x12\n" + + "\x04text\x18\x03 \x01(\tR\x04text\"\x15\n" + + "\x13ReportPlaceResponse*\x9c\x01\n" + "\fReportReason\x12\x1d\n" + "\x19REPORT_REASON_UNSPECIFIED\x10\x00\x12\x1a\n" + "\x16REPORT_REASON_CHEATING\x10\x01\x12\x1c\n" + "\x18REPORT_REASON_CHAT_ABUSE\x10\x02\x12\x1a\n" + "\x16REPORT_REASON_BAD_NAME\x10\x03\x12\x17\n" + - "\x13REPORT_REASON_OTHER\x10\x042\xc3\x03\n" + + "\x13REPORT_REASON_OTHER\x10\x04*\x95\x01\n" + + "\x11PlaceReportReason\x12#\n" + + "\x1fPLACE_REPORT_REASON_UNSPECIFIED\x10\x00\x12\x1c\n" + + "\x18PLACE_REPORT_REASON_HATE\x10\x01\x12\x1e\n" + + "\x1aPLACE_REPORT_REASON_SEXUAL\x10\x02\x12\x1d\n" + + "\x19PLACE_REPORT_REASON_OTHER\x10\x032\x93\x04\n" + "\x0eSupportService\x12W\n" + "\x0eSuggestFeature\x12!.support.v1.SuggestFeatureRequest\x1a\".support.v1.SuggestFeatureResponse\x12H\n" + "\tReportBug\x12\x1c.support.v1.ReportBugRequest\x1a\x1d.support.v1.ReportBugResponse\x12Q\n" + - "\fReportPlayer\x12\x1f.support.v1.ReportPlayerRequest\x1a .support.v1.ReportPlayerResponse\x12h\n" + + "\fReportPlayer\x12\x1f.support.v1.ReportPlayerRequest\x1a .support.v1.ReportPlayerResponse\x12N\n" + + "\vReportPlace\x12\x1e.support.v1.ReportPlaceRequest\x1a\x1f.support.v1.ReportPlaceResponse\x12h\n" + "\x12GetAttachmentRules\x12%.support.v1.GetAttachmentRulesRequest\x1a&.support.v1.GetAttachmentRulesResponse\"\x03\x90\x02\x01\x12Q\n" + "\fCreateUpload\x12\x1f.support.v1.CreateUploadRequest\x1a .support.v1.CreateUploadResponseB\xbb\x01\n" + "\x0ecom.support.v1B\fSupportProtoP\x01ZRgithub.com/raphoester/clickplanet.lol-backend/generated/proto/support/v1;supportv1\xa2\x02\x03SXX\xaa\x02\n" + @@ -801,41 +963,47 @@ func file_support_v1_support_proto_rawDescGZIP() []byte { return file_support_v1_support_proto_rawDescData } -var file_support_v1_support_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_support_v1_support_proto_msgTypes = make([]protoimpl.MessageInfo, 11) +var file_support_v1_support_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_support_v1_support_proto_msgTypes = make([]protoimpl.MessageInfo, 13) var file_support_v1_support_proto_goTypes = []any{ (ReportReason)(0), // 0: support.v1.ReportReason - (*SuggestFeatureRequest)(nil), // 1: support.v1.SuggestFeatureRequest - (*SuggestFeatureResponse)(nil), // 2: support.v1.SuggestFeatureResponse - (*ReportBugRequest)(nil), // 3: support.v1.ReportBugRequest - (*ReportBugResponse)(nil), // 4: support.v1.ReportBugResponse - (*CreateUploadRequest)(nil), // 5: support.v1.CreateUploadRequest - (*CreateUploadResponse)(nil), // 6: support.v1.CreateUploadResponse - (*GetAttachmentRulesRequest)(nil), // 7: support.v1.GetAttachmentRulesRequest - (*GetAttachmentRulesResponse)(nil), // 8: support.v1.GetAttachmentRulesResponse - (*PageContext)(nil), // 9: support.v1.PageContext - (*ReportPlayerRequest)(nil), // 10: support.v1.ReportPlayerRequest - (*ReportPlayerResponse)(nil), // 11: support.v1.ReportPlayerResponse + (PlaceReportReason)(0), // 1: support.v1.PlaceReportReason + (*SuggestFeatureRequest)(nil), // 2: support.v1.SuggestFeatureRequest + (*SuggestFeatureResponse)(nil), // 3: support.v1.SuggestFeatureResponse + (*ReportBugRequest)(nil), // 4: support.v1.ReportBugRequest + (*ReportBugResponse)(nil), // 5: support.v1.ReportBugResponse + (*CreateUploadRequest)(nil), // 6: support.v1.CreateUploadRequest + (*CreateUploadResponse)(nil), // 7: support.v1.CreateUploadResponse + (*GetAttachmentRulesRequest)(nil), // 8: support.v1.GetAttachmentRulesRequest + (*GetAttachmentRulesResponse)(nil), // 9: support.v1.GetAttachmentRulesResponse + (*PageContext)(nil), // 10: support.v1.PageContext + (*ReportPlayerRequest)(nil), // 11: support.v1.ReportPlayerRequest + (*ReportPlayerResponse)(nil), // 12: support.v1.ReportPlayerResponse + (*ReportPlaceRequest)(nil), // 13: support.v1.ReportPlaceRequest + (*ReportPlaceResponse)(nil), // 14: support.v1.ReportPlaceResponse } var file_support_v1_support_proto_depIdxs = []int32{ - 9, // 0: support.v1.SuggestFeatureRequest.context:type_name -> support.v1.PageContext - 9, // 1: support.v1.ReportBugRequest.context:type_name -> support.v1.PageContext + 10, // 0: support.v1.SuggestFeatureRequest.context:type_name -> support.v1.PageContext + 10, // 1: support.v1.ReportBugRequest.context:type_name -> support.v1.PageContext 0, // 2: support.v1.ReportPlayerRequest.reason:type_name -> support.v1.ReportReason - 1, // 3: support.v1.SupportService.SuggestFeature:input_type -> support.v1.SuggestFeatureRequest - 3, // 4: support.v1.SupportService.ReportBug:input_type -> support.v1.ReportBugRequest - 10, // 5: support.v1.SupportService.ReportPlayer:input_type -> support.v1.ReportPlayerRequest - 7, // 6: support.v1.SupportService.GetAttachmentRules:input_type -> support.v1.GetAttachmentRulesRequest - 5, // 7: support.v1.SupportService.CreateUpload:input_type -> support.v1.CreateUploadRequest - 2, // 8: support.v1.SupportService.SuggestFeature:output_type -> support.v1.SuggestFeatureResponse - 4, // 9: support.v1.SupportService.ReportBug:output_type -> support.v1.ReportBugResponse - 11, // 10: support.v1.SupportService.ReportPlayer:output_type -> support.v1.ReportPlayerResponse - 8, // 11: support.v1.SupportService.GetAttachmentRules:output_type -> support.v1.GetAttachmentRulesResponse - 6, // 12: support.v1.SupportService.CreateUpload:output_type -> support.v1.CreateUploadResponse - 8, // [8:13] is the sub-list for method output_type - 3, // [3:8] is the sub-list for method input_type - 3, // [3:3] is the sub-list for extension type_name - 3, // [3:3] is the sub-list for extension extendee - 0, // [0:3] is the sub-list for field type_name + 1, // 3: support.v1.ReportPlaceRequest.reason:type_name -> support.v1.PlaceReportReason + 2, // 4: support.v1.SupportService.SuggestFeature:input_type -> support.v1.SuggestFeatureRequest + 4, // 5: support.v1.SupportService.ReportBug:input_type -> support.v1.ReportBugRequest + 11, // 6: support.v1.SupportService.ReportPlayer:input_type -> support.v1.ReportPlayerRequest + 13, // 7: support.v1.SupportService.ReportPlace:input_type -> support.v1.ReportPlaceRequest + 8, // 8: support.v1.SupportService.GetAttachmentRules:input_type -> support.v1.GetAttachmentRulesRequest + 6, // 9: support.v1.SupportService.CreateUpload:input_type -> support.v1.CreateUploadRequest + 3, // 10: support.v1.SupportService.SuggestFeature:output_type -> support.v1.SuggestFeatureResponse + 5, // 11: support.v1.SupportService.ReportBug:output_type -> support.v1.ReportBugResponse + 12, // 12: support.v1.SupportService.ReportPlayer:output_type -> support.v1.ReportPlayerResponse + 14, // 13: support.v1.SupportService.ReportPlace:output_type -> support.v1.ReportPlaceResponse + 9, // 14: support.v1.SupportService.GetAttachmentRules:output_type -> support.v1.GetAttachmentRulesResponse + 7, // 15: support.v1.SupportService.CreateUpload:output_type -> support.v1.CreateUploadResponse + 10, // [10:16] is the sub-list for method output_type + 4, // [4:10] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name } func init() { file_support_v1_support_proto_init() } @@ -848,8 +1016,8 @@ func file_support_v1_support_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_support_v1_support_proto_rawDesc), len(file_support_v1_support_proto_rawDesc)), - NumEnums: 1, - NumMessages: 11, + NumEnums: 2, + NumMessages: 13, NumExtensions: 0, NumServices: 1, }, diff --git a/apps/backend/generated/proto/support/v1/supportv1connect/admin.connect.go b/apps/backend/generated/proto/support/v1/supportv1connect/admin.connect.go index bfdf7a565..bb8263bef 100644 --- a/apps/backend/generated/proto/support/v1/supportv1connect/admin.connect.go +++ b/apps/backend/generated/proto/support/v1/supportv1connect/admin.connect.go @@ -41,6 +41,12 @@ const ( // AdminServiceDecideReportProcedure is the fully-qualified name of the AdminService's DecideReport // RPC. AdminServiceDecideReportProcedure = "/support.v1.AdminService/DecideReport" + // AdminServiceListPlaceReportsProcedure is the fully-qualified name of the AdminService's + // ListPlaceReports RPC. + AdminServiceListPlaceReportsProcedure = "/support.v1.AdminService/ListPlaceReports" + // AdminServiceDecidePlaceReportProcedure is the fully-qualified name of the AdminService's + // DecidePlaceReport RPC. + AdminServiceDecidePlaceReportProcedure = "/support.v1.AdminService/DecidePlaceReport" ) // AdminServiceClient is a client for the support.v1.AdminService service. @@ -49,6 +55,10 @@ type AdminServiceClient interface { ListReports(context.Context, *connect.Request[v1.ListReportsRequest]) (*connect.Response[v1.ListReportsResponse], error) // Closes a report with what the operator did about it. A report decided again keeps the last decision. NotFound for a report no longer kept. DecideReport(context.Context, *connect.Request[v1.DecideReportRequest]) (*connect.Response[v1.DecideReportResponse], error) + // Newest first, as ListReports. + ListPlaceReports(context.Context, *connect.Request[v1.ListPlaceReportsRequest]) (*connect.Response[v1.ListPlaceReportsResponse], error) + // As DecideReport, for a report of a place. + DecidePlaceReport(context.Context, *connect.Request[v1.DecidePlaceReportRequest]) (*connect.Response[v1.DecidePlaceReportResponse], error) } // NewAdminServiceClient constructs a client for the support.v1.AdminService service. By default, it @@ -80,14 +90,28 @@ func NewAdminServiceClient(httpClient connect.HTTPClient, baseURL string, opts . connect.WithSchema(adminServiceMethods.ByName("DecideReport")), connect.WithClientOptions(opts...), ), + listPlaceReports: connect.NewClient[v1.ListPlaceReportsRequest, v1.ListPlaceReportsResponse]( + httpClient, + baseURL+AdminServiceListPlaceReportsProcedure, + connect.WithSchema(adminServiceMethods.ByName("ListPlaceReports")), + connect.WithClientOptions(opts...), + ), + decidePlaceReport: connect.NewClient[v1.DecidePlaceReportRequest, v1.DecidePlaceReportResponse]( + httpClient, + baseURL+AdminServiceDecidePlaceReportProcedure, + connect.WithSchema(adminServiceMethods.ByName("DecidePlaceReport")), + connect.WithClientOptions(opts...), + ), } } // adminServiceClient implements AdminServiceClient. type adminServiceClient struct { - listNotes *connect.Client[v1.ListNotesRequest, v1.ListNotesResponse] - listReports *connect.Client[v1.ListReportsRequest, v1.ListReportsResponse] - decideReport *connect.Client[v1.DecideReportRequest, v1.DecideReportResponse] + listNotes *connect.Client[v1.ListNotesRequest, v1.ListNotesResponse] + listReports *connect.Client[v1.ListReportsRequest, v1.ListReportsResponse] + decideReport *connect.Client[v1.DecideReportRequest, v1.DecideReportResponse] + listPlaceReports *connect.Client[v1.ListPlaceReportsRequest, v1.ListPlaceReportsResponse] + decidePlaceReport *connect.Client[v1.DecidePlaceReportRequest, v1.DecidePlaceReportResponse] } // ListNotes calls support.v1.AdminService.ListNotes. @@ -105,12 +129,26 @@ func (c *adminServiceClient) DecideReport(ctx context.Context, req *connect.Requ return c.decideReport.CallUnary(ctx, req) } +// ListPlaceReports calls support.v1.AdminService.ListPlaceReports. +func (c *adminServiceClient) ListPlaceReports(ctx context.Context, req *connect.Request[v1.ListPlaceReportsRequest]) (*connect.Response[v1.ListPlaceReportsResponse], error) { + return c.listPlaceReports.CallUnary(ctx, req) +} + +// DecidePlaceReport calls support.v1.AdminService.DecidePlaceReport. +func (c *adminServiceClient) DecidePlaceReport(ctx context.Context, req *connect.Request[v1.DecidePlaceReportRequest]) (*connect.Response[v1.DecidePlaceReportResponse], error) { + return c.decidePlaceReport.CallUnary(ctx, req) +} + // AdminServiceHandler is an implementation of the support.v1.AdminService service. type AdminServiceHandler interface { ListNotes(context.Context, *connect.Request[v1.ListNotesRequest]) (*connect.Response[v1.ListNotesResponse], error) ListReports(context.Context, *connect.Request[v1.ListReportsRequest]) (*connect.Response[v1.ListReportsResponse], error) // Closes a report with what the operator did about it. A report decided again keeps the last decision. NotFound for a report no longer kept. DecideReport(context.Context, *connect.Request[v1.DecideReportRequest]) (*connect.Response[v1.DecideReportResponse], error) + // Newest first, as ListReports. + ListPlaceReports(context.Context, *connect.Request[v1.ListPlaceReportsRequest]) (*connect.Response[v1.ListPlaceReportsResponse], error) + // As DecideReport, for a report of a place. + DecidePlaceReport(context.Context, *connect.Request[v1.DecidePlaceReportRequest]) (*connect.Response[v1.DecidePlaceReportResponse], error) } // NewAdminServiceHandler builds an HTTP handler from the service implementation. It returns the @@ -138,6 +176,18 @@ func NewAdminServiceHandler(svc AdminServiceHandler, opts ...connect.HandlerOpti connect.WithSchema(adminServiceMethods.ByName("DecideReport")), connect.WithHandlerOptions(opts...), ) + adminServiceListPlaceReportsHandler := connect.NewUnaryHandler( + AdminServiceListPlaceReportsProcedure, + svc.ListPlaceReports, + connect.WithSchema(adminServiceMethods.ByName("ListPlaceReports")), + connect.WithHandlerOptions(opts...), + ) + adminServiceDecidePlaceReportHandler := connect.NewUnaryHandler( + AdminServiceDecidePlaceReportProcedure, + svc.DecidePlaceReport, + connect.WithSchema(adminServiceMethods.ByName("DecidePlaceReport")), + connect.WithHandlerOptions(opts...), + ) return "/support.v1.AdminService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case AdminServiceListNotesProcedure: @@ -146,6 +196,10 @@ func NewAdminServiceHandler(svc AdminServiceHandler, opts ...connect.HandlerOpti adminServiceListReportsHandler.ServeHTTP(w, r) case AdminServiceDecideReportProcedure: adminServiceDecideReportHandler.ServeHTTP(w, r) + case AdminServiceListPlaceReportsProcedure: + adminServiceListPlaceReportsHandler.ServeHTTP(w, r) + case AdminServiceDecidePlaceReportProcedure: + adminServiceDecidePlaceReportHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -166,3 +220,11 @@ func (UnimplementedAdminServiceHandler) ListReports(context.Context, *connect.Re func (UnimplementedAdminServiceHandler) DecideReport(context.Context, *connect.Request[v1.DecideReportRequest]) (*connect.Response[v1.DecideReportResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("support.v1.AdminService.DecideReport is not implemented")) } + +func (UnimplementedAdminServiceHandler) ListPlaceReports(context.Context, *connect.Request[v1.ListPlaceReportsRequest]) (*connect.Response[v1.ListPlaceReportsResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("support.v1.AdminService.ListPlaceReports is not implemented")) +} + +func (UnimplementedAdminServiceHandler) DecidePlaceReport(context.Context, *connect.Request[v1.DecidePlaceReportRequest]) (*connect.Response[v1.DecidePlaceReportResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("support.v1.AdminService.DecidePlaceReport is not implemented")) +} diff --git a/apps/backend/generated/proto/support/v1/supportv1connect/support.connect.go b/apps/backend/generated/proto/support/v1/supportv1connect/support.connect.go index c837d6fb6..4a112b68d 100644 --- a/apps/backend/generated/proto/support/v1/supportv1connect/support.connect.go +++ b/apps/backend/generated/proto/support/v1/supportv1connect/support.connect.go @@ -42,6 +42,9 @@ const ( // SupportServiceReportPlayerProcedure is the fully-qualified name of the SupportService's // ReportPlayer RPC. SupportServiceReportPlayerProcedure = "/support.v1.SupportService/ReportPlayer" + // SupportServiceReportPlaceProcedure is the fully-qualified name of the SupportService's + // ReportPlace RPC. + SupportServiceReportPlaceProcedure = "/support.v1.SupportService/ReportPlace" // SupportServiceGetAttachmentRulesProcedure is the fully-qualified name of the SupportService's // GetAttachmentRules RPC. SupportServiceGetAttachmentRulesProcedure = "/support.v1.SupportService/GetAttachmentRules" @@ -55,6 +58,8 @@ type SupportServiceClient interface { SuggestFeature(context.Context, *connect.Request[v1.SuggestFeatureRequest]) (*connect.Response[v1.SuggestFeatureResponse], error) ReportBug(context.Context, *connect.Request[v1.ReportBugRequest]) (*connect.Response[v1.ReportBugResponse], error) ReportPlayer(context.Context, *connect.Request[v1.ReportPlayerRequest]) (*connect.Response[v1.ReportPlayerResponse], error) + // A drawing on the map, by a tile of it: the operator reads it and may put the area back as it was. + ReportPlace(context.Context, *connect.Request[v1.ReportPlaceRequest]) (*connect.Response[v1.ReportPlaceResponse], error) GetAttachmentRules(context.Context, *connect.Request[v1.GetAttachmentRulesRequest]) (*connect.Response[v1.GetAttachmentRulesResponse], error) CreateUpload(context.Context, *connect.Request[v1.CreateUploadRequest]) (*connect.Response[v1.CreateUploadResponse], error) } @@ -88,6 +93,12 @@ func NewSupportServiceClient(httpClient connect.HTTPClient, baseURL string, opts connect.WithSchema(supportServiceMethods.ByName("ReportPlayer")), connect.WithClientOptions(opts...), ), + reportPlace: connect.NewClient[v1.ReportPlaceRequest, v1.ReportPlaceResponse]( + httpClient, + baseURL+SupportServiceReportPlaceProcedure, + connect.WithSchema(supportServiceMethods.ByName("ReportPlace")), + connect.WithClientOptions(opts...), + ), getAttachmentRules: connect.NewClient[v1.GetAttachmentRulesRequest, v1.GetAttachmentRulesResponse]( httpClient, baseURL+SupportServiceGetAttachmentRulesProcedure, @@ -109,6 +120,7 @@ type supportServiceClient struct { suggestFeature *connect.Client[v1.SuggestFeatureRequest, v1.SuggestFeatureResponse] reportBug *connect.Client[v1.ReportBugRequest, v1.ReportBugResponse] reportPlayer *connect.Client[v1.ReportPlayerRequest, v1.ReportPlayerResponse] + reportPlace *connect.Client[v1.ReportPlaceRequest, v1.ReportPlaceResponse] getAttachmentRules *connect.Client[v1.GetAttachmentRulesRequest, v1.GetAttachmentRulesResponse] createUpload *connect.Client[v1.CreateUploadRequest, v1.CreateUploadResponse] } @@ -128,6 +140,11 @@ func (c *supportServiceClient) ReportPlayer(ctx context.Context, req *connect.Re return c.reportPlayer.CallUnary(ctx, req) } +// ReportPlace calls support.v1.SupportService.ReportPlace. +func (c *supportServiceClient) ReportPlace(ctx context.Context, req *connect.Request[v1.ReportPlaceRequest]) (*connect.Response[v1.ReportPlaceResponse], error) { + return c.reportPlace.CallUnary(ctx, req) +} + // GetAttachmentRules calls support.v1.SupportService.GetAttachmentRules. func (c *supportServiceClient) GetAttachmentRules(ctx context.Context, req *connect.Request[v1.GetAttachmentRulesRequest]) (*connect.Response[v1.GetAttachmentRulesResponse], error) { return c.getAttachmentRules.CallUnary(ctx, req) @@ -143,6 +160,8 @@ type SupportServiceHandler interface { SuggestFeature(context.Context, *connect.Request[v1.SuggestFeatureRequest]) (*connect.Response[v1.SuggestFeatureResponse], error) ReportBug(context.Context, *connect.Request[v1.ReportBugRequest]) (*connect.Response[v1.ReportBugResponse], error) ReportPlayer(context.Context, *connect.Request[v1.ReportPlayerRequest]) (*connect.Response[v1.ReportPlayerResponse], error) + // A drawing on the map, by a tile of it: the operator reads it and may put the area back as it was. + ReportPlace(context.Context, *connect.Request[v1.ReportPlaceRequest]) (*connect.Response[v1.ReportPlaceResponse], error) GetAttachmentRules(context.Context, *connect.Request[v1.GetAttachmentRulesRequest]) (*connect.Response[v1.GetAttachmentRulesResponse], error) CreateUpload(context.Context, *connect.Request[v1.CreateUploadRequest]) (*connect.Response[v1.CreateUploadResponse], error) } @@ -172,6 +191,12 @@ func NewSupportServiceHandler(svc SupportServiceHandler, opts ...connect.Handler connect.WithSchema(supportServiceMethods.ByName("ReportPlayer")), connect.WithHandlerOptions(opts...), ) + supportServiceReportPlaceHandler := connect.NewUnaryHandler( + SupportServiceReportPlaceProcedure, + svc.ReportPlace, + connect.WithSchema(supportServiceMethods.ByName("ReportPlace")), + connect.WithHandlerOptions(opts...), + ) supportServiceGetAttachmentRulesHandler := connect.NewUnaryHandler( SupportServiceGetAttachmentRulesProcedure, svc.GetAttachmentRules, @@ -193,6 +218,8 @@ func NewSupportServiceHandler(svc SupportServiceHandler, opts ...connect.Handler supportServiceReportBugHandler.ServeHTTP(w, r) case SupportServiceReportPlayerProcedure: supportServiceReportPlayerHandler.ServeHTTP(w, r) + case SupportServiceReportPlaceProcedure: + supportServiceReportPlaceHandler.ServeHTTP(w, r) case SupportServiceGetAttachmentRulesProcedure: supportServiceGetAttachmentRulesHandler.ServeHTTP(w, r) case SupportServiceCreateUploadProcedure: @@ -218,6 +245,10 @@ func (UnimplementedSupportServiceHandler) ReportPlayer(context.Context, *connect return nil, connect.NewError(connect.CodeUnimplemented, errors.New("support.v1.SupportService.ReportPlayer is not implemented")) } +func (UnimplementedSupportServiceHandler) ReportPlace(context.Context, *connect.Request[v1.ReportPlaceRequest]) (*connect.Response[v1.ReportPlaceResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("support.v1.SupportService.ReportPlace is not implemented")) +} + func (UnimplementedSupportServiceHandler) GetAttachmentRules(context.Context, *connect.Request[v1.GetAttachmentRulesRequest]) (*connect.Response[v1.GetAttachmentRulesResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("support.v1.SupportService.GetAttachmentRules is not implemented")) } diff --git a/apps/backend/internal/planet/internal/clicks/areas.go b/apps/backend/internal/planet/internal/clicks/areas.go new file mode 100644 index 000000000..f7f97f759 --- /dev/null +++ b/apps/backend/internal/planet/internal/clicks/areas.go @@ -0,0 +1,49 @@ +package clicks + +import ( + "errors" + "fmt" +) + +const MaxAreaRings = 50 + +var ErrInvalidArea = errors.New("an area is 1 to 50 rings around a tile") + +// An area is every tile within a number of tile spacings of one, as a bomb's blast is: what an operator restores or +// clears. +func NewAreas(geography *Geography, spacing float64) Areas { + return Areas{geography: geography, spacing: spacing} +} + +type Areas struct { + geography *Geography + spacing float64 +} + +func (a Areas) Around(tile uint32, rings uint32) ([]uint32, error) { + if rings == 0 || rings > MaxAreaRings { + return nil, fmt.Errorf("%w: %d rings", ErrInvalidArea, rings) + } + + centre, ok := a.geography.Position(tile) + if !ok { + return nil, fmt.Errorf("%w: %d", ErrTileOutOfRange, tile) + } + + return a.geography.Within(centre, float64(rings)*a.spacing), nil +} + +type Owners interface { + Owner(tile uint32) (string, bool) +} + +// Every tile of the area a flag holds, given to nobody against the owner it holds now. +func Emptying(area []uint32, owners Owners) []Restoration { + var restorations []Restoration + for _, tile := range area { + if owner, _ := owners.Owner(tile); owner != "" { + restorations = append(restorations, Restoration{Tile: tile, From: owner, To: ""}) + } + } + return restorations +} diff --git a/apps/backend/internal/planet/internal/clicks/areas_test.go b/apps/backend/internal/planet/internal/clicks/areas_test.go new file mode 100644 index 000000000..675f5f718 --- /dev/null +++ b/apps/backend/internal/planet/internal/clicks/areas_test.go @@ -0,0 +1,44 @@ +package clicks_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks" +) + +type owners map[uint32]string + +func (o owners) Owner(tile uint32) (string, bool) { return o[tile], true } + +func TestAnAreaIsEveryTileWithinItsRings(t *testing.T) { + areas := clicks.NewAreas(equatorRow(t), 0.11) + + one, err := areas.Around(1, 1) + require.NoError(t, err) + assert.Equal(t, []uint32{1, 2}, one) + + two, err := areas.Around(1, 2) + require.NoError(t, err) + assert.Equal(t, []uint32{1, 2, 3}, two) +} + +func TestAnAreaOfNoRingsTooManyOrNoTileIsRefused(t *testing.T) { + areas := clicks.NewAreas(equatorRow(t), 0.11) + + for _, rings := range []uint32{0, clicks.MaxAreaRings + 1} { + _, err := areas.Around(1, rings) + require.ErrorIs(t, err, clicks.ErrInvalidArea, rings) + } + + _, err := areas.Around(4, 1) + require.ErrorIs(t, err, clicks.ErrTileOutOfRange) +} + +func TestEmptyingGivesEveryOwnedTileToNobodyAgainstItsOwnerNow(t *testing.T) { + restorations := clicks.Emptying([]uint32{1, 2, 3}, owners{1: "de", 3: "fr"}) + + assert.Equal(t, []clicks.Restoration{{Tile: 1, From: "de"}, {Tile: 3, From: "fr"}}, restorations) +} diff --git a/apps/backend/internal/planet/internal/clicks/usecases/clear_area_usecase/audit_clear_area/audit.go b/apps/backend/internal/planet/internal/clicks/usecases/clear_area_usecase/audit_clear_area/audit.go new file mode 100644 index 000000000..912e43e2a --- /dev/null +++ b/apps/backend/internal/planet/internal/clicks/usecases/clear_area_usecase/audit_clear_area/audit.go @@ -0,0 +1,39 @@ +package audit_clear_area + +import ( + "context" + "log/slog" + + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks/usecases/clear_area_usecase" +) + +type UseCase interface { + Execute(ctx context.Context, in clear_area_usecase.In) (clear_area_usecase.Out, error) +} + +func New(inner UseCase, logger *slog.Logger) *Audited { + return &Audited{inner: inner, logger: logger} +} + +type Audited struct { + inner UseCase + logger *slog.Logger +} + +func (a *Audited) Execute(ctx context.Context, in clear_area_usecase.In) (clear_area_usecase.Out, error) { + out, err := a.inner.Execute(ctx, in) + + attrs := []any{ + slog.Uint64("tile", uint64(in.Tile)), slog.Uint64("rings", uint64(in.Rings)), slog.Bool("dryRun", in.DryRun), + slog.Int("tiles", out.Tiles), slog.Int("owned", out.Owned), slog.Int("cleared", out.Cleared), + } + + if err != nil { + a.logger.Warn("admin area clear failed", append(attrs, slog.Any("error", err))...) + return out, err //nolint:wrapcheck // a decorator adds a log line, not a sentence. + } + + a.logger.Warn("admin area clear", attrs...) + + return out, nil +} diff --git a/apps/backend/internal/planet/internal/clicks/usecases/clear_area_usecase/audit_clear_area/audit_test.go b/apps/backend/internal/planet/internal/clicks/usecases/clear_area_usecase/audit_clear_area/audit_test.go new file mode 100644 index 000000000..4a7618805 --- /dev/null +++ b/apps/backend/internal/planet/internal/clicks/usecases/clear_area_usecase/audit_clear_area/audit_test.go @@ -0,0 +1,49 @@ +package audit_clear_area_test + +import ( + "bytes" + "context" + "errors" + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks/usecases/clear_area_usecase" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks/usecases/clear_area_usecase/audit_clear_area" +) + +type stubUseCase struct { + out clear_area_usecase.Out + err error +} + +func (s stubUseCase) Execute(context.Context, clear_area_usecase.In) (clear_area_usecase.Out, error) { + return s.out, s.err +} + +func run(t *testing.T, inner stubUseCase) (string, error) { + t.Helper() + + var logs bytes.Buffer + _, err := audit_clear_area.New(inner, slog.New(slog.NewTextHandler(&logs, nil))). + Execute(t.Context(), clear_area_usecase.In{Tile: 42, Rings: 3}) + return logs.String(), err +} + +func TestEveryClearIsLogged(t *testing.T) { + logs, err := run(t, stubUseCase{out: clear_area_usecase.Out{Tiles: 37, Owned: 20, Cleared: 20}}) + + require.NoError(t, err) + assert.Contains(t, logs, `level=WARN msg="admin area clear" tile=42 rings=3 dryRun=false tiles=37 owned=20 cleared=20`) +} + +func TestAFailedClearIsLoggedToo(t *testing.T) { + cause := errors.New("the map is closed") + + logs, err := run(t, stubUseCase{err: cause}) + + require.ErrorIs(t, err, cause) + assert.Contains(t, logs, `level=WARN msg="admin area clear failed" tile=42`) +} diff --git a/apps/backend/internal/planet/internal/clicks/usecases/clear_area_usecase/usecase.go b/apps/backend/internal/planet/internal/clicks/usecases/clear_area_usecase/usecase.go new file mode 100644 index 000000000..cf90782ae --- /dev/null +++ b/apps/backend/internal/planet/internal/clicks/usecases/clear_area_usecase/usecase.go @@ -0,0 +1,77 @@ +package clear_area_usecase + +import ( + "context" + "errors" + "fmt" + + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks" +) + +type Map interface { + Owner(tile uint32) (string, bool) + Restore(ctx context.Context, restorations []clicks.Restoration) (int, error) +} + +type Areas interface { + Around(tile uint32, rings uint32) ([]uint32, error) +} + +type In struct { + Tile uint32 + Rings uint32 + DryRun bool +} + +type Out struct { + Tiles int + Owned int + Cleared int +} + +func New(tiles Map, areas Areas, pace clicks.Pacing) *UseCase { + return &UseCase{tiles: tiles, areas: areas, pacing: pace} +} + +type UseCase struct { + tiles Map + areas Areas + pacing clicks.Pacing +} + +func (u *UseCase) Execute(ctx context.Context, in In) (Out, error) { + if u.pacing.Batch <= 0 { + return Out{}, errors.New("clear batch must be positive") + } + + area, err := u.areas.Around(in.Tile, in.Rings) + if err != nil { + return Out{}, fmt.Errorf("cannot clear: %w", err) + } + + restorations := clicks.Emptying(area, u.tiles) + out := Out{Tiles: len(area), Owned: len(restorations)} + + if in.DryRun { + return out, nil + } + + for len(restorations) > 0 { + batch := restorations[:min(u.pacing.Batch, len(restorations))] + restorations = restorations[len(batch):] + + cleared, err := u.tiles.Restore(ctx, batch) + out.Cleared += cleared + if err != nil { + return out, fmt.Errorf("failed to clear tiles: %w", err) + } + + if len(restorations) > 0 { + if err := u.pacing.Wait(ctx); err != nil { + return out, fmt.Errorf("clear %w", err) + } + } + } + + return out, nil +} diff --git a/apps/backend/internal/planet/internal/clicks/usecases/clear_area_usecase/usecase_test.go b/apps/backend/internal/planet/internal/clicks/usecases/clear_area_usecase/usecase_test.go new file mode 100644 index 000000000..0c7a0db4b --- /dev/null +++ b/apps/backend/internal/planet/internal/clicks/usecases/clear_area_usecase/usecase_test.go @@ -0,0 +1,68 @@ +package clear_area_usecase_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks/usecases/clear_area_usecase" +) + +type stubMap struct { + owners map[uint32]string + batches []int +} + +func (m *stubMap) Owner(tile uint32) (string, bool) { return m.owners[tile], true } + +func (m *stubMap) Restore(_ context.Context, restorations []clicks.Restoration) (int, error) { + m.batches = append(m.batches, len(restorations)) + cleared := 0 + for _, r := range restorations { + if m.owners[r.Tile] == r.From { + m.owners[r.Tile] = r.To + cleared++ + } + } + return cleared, nil +} + +type stubAreas struct { + area []uint32 + err error +} + +func (a stubAreas) Around(uint32, uint32) ([]uint32, error) { return a.area, a.err } + +func TestEveryOwnedTileOfTheAreaGoesToNobodyInBatches(t *testing.T) { + tiles := &stubMap{owners: map[uint32]string{1: "de", 2: "", 3: "de", 4: "fr", 5: "de"}} + + out, err := clear_area_usecase.New(tiles, stubAreas{area: []uint32{1, 2, 3, 4}}, clicks.Pacing{Batch: 2}). + Execute(t.Context(), clear_area_usecase.In{Tile: 1, Rings: 1}) + require.NoError(t, err) + + assert.Equal(t, clear_area_usecase.Out{Tiles: 4, Owned: 3, Cleared: 3}, out) + assert.Equal(t, map[uint32]string{1: "", 2: "", 3: "", 4: "", 5: "de"}, tiles.owners) + assert.Equal(t, []int{2, 1}, tiles.batches) +} + +func TestADryRunCountsAndClearsNothing(t *testing.T) { + tiles := &stubMap{owners: map[uint32]string{1: "de"}} + + out, err := clear_area_usecase.New(tiles, stubAreas{area: []uint32{1, 2}}, clicks.Pacing{Batch: 2}). + Execute(t.Context(), clear_area_usecase.In{Tile: 1, Rings: 1, DryRun: true}) + require.NoError(t, err) + + assert.Equal(t, clear_area_usecase.Out{Tiles: 2, Owned: 1}, out) + assert.Equal(t, "de", tiles.owners[1]) +} + +func TestAnAreaThatIsNotOneIsRefused(t *testing.T) { + _, err := clear_area_usecase.New(&stubMap{}, stubAreas{err: clicks.ErrInvalidArea}, clicks.Pacing{Batch: 2}). + Execute(t.Context(), clear_area_usecase.In{Tile: 1, Rings: 99}) + + require.ErrorIs(t, err, clicks.ErrInvalidArea) +} diff --git a/apps/backend/internal/planet/internal/ledger/rewind.go b/apps/backend/internal/planet/internal/ledger/rewind.go new file mode 100644 index 000000000..340778c23 --- /dev/null +++ b/apps/backend/internal/planet/internal/ledger/rewind.go @@ -0,0 +1,52 @@ +package ledger + +import ( + "time" + + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks" + "github.com/raphoester/clickplanet.lol-backend/internal/shared/cptime" +) + +// What each tile of an area held at a time: what it held before its first change since, as a replay opens on it. +func NewRewind(area []uint32, at time.Time) *Rewind { + return &Rewind{area: area, footage: NewFootage(at, at)} +} + +type Rewind struct { + area []uint32 + footage *Footage +} + +func (r *Rewind) See(event Event) { + r.footage.See(event) +} + +// Each tile of the area a change moved off what it held then, put back against the owner it holds now. A change the +// ledger never saw (a reassign, a paint, a revert) is not rewound. +func (r *Rewind) Restorations(owners Owners) []clicks.Restoration { + var restorations []clicks.Restoration + for _, tile := range r.area { + was, changed := r.footage.owners[tile] + if !changed { + continue + } + if now, _ := owners.Owner(tile); now != was { + restorations = append(restorations, clicks.Restoration{Tile: tile, From: now, To: was}) + } + } + return restorations +} + +// How far back the ledger in memory reaches: its retention, from now. +func NewReach(config Config, clock cptime.Clock) Reach { + return Reach{retention: config.withDefaults().Retention, clock: clock} +} + +type Reach struct { + retention time.Duration + clock cptime.Clock +} + +func (r Reach) Since() time.Time { + return r.clock.Now().Add(-r.retention) +} diff --git a/apps/backend/internal/planet/internal/ledger/rewind_test.go b/apps/backend/internal/planet/internal/ledger/rewind_test.go new file mode 100644 index 000000000..b25fa1410 --- /dev/null +++ b/apps/backend/internal/planet/internal/ledger/rewind_test.go @@ -0,0 +1,65 @@ +package ledger_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/ledger" + "github.com/raphoester/clickplanet.lol-backend/internal/shared/cptime" +) + +func rewindOf(area []uint32, at time.Time, events ...ledger.Event) *ledger.Rewind { + rewind := ledger.NewRewind(area, at) + for _, event := range events { + rewind.See(event) + } + return rewind +} + +func TestEachTileGoesBackToWhatItHeldBeforeItsFirstChangeSince(t *testing.T) { + before := start.Add(time.Hour) + rewind := rewindOf([]uint32{1, 2, 3}, before, + ledger.Taking{Tile: 1, Country: "it", At: start}, + ledger.Taking{Tile: 1, Country: "de", Previous: "it", At: before}, + ledger.Taking{Tile: 1, Country: "at", Previous: "de", At: before.Add(time.Minute)}, + ledger.Taking{Tile: 2, Country: "de", At: before.Add(time.Minute)}, + ledger.Taking{Tile: 3, Country: "de", Previous: "fr", At: start}, + ) + + assert.Equal(t, []clicks.Restoration{ + {Tile: 1, From: "at", To: "it"}, + {Tile: 2, From: "de", To: ""}, + }, rewind.Restorations(owners{1: "at", 2: "de", 3: "de"}), "a tile changed before only is left") +} + +func TestATileBackToWhatItHeldOrOutsideTheAreaIsLeft(t *testing.T) { + before := start.Add(time.Hour) + rewind := rewindOf([]uint32{1}, before, + ledger.Taking{Tile: 1, Country: "de", Previous: "fr", At: before}, + ledger.Taking{Tile: 1, Country: "fr", Previous: "de", At: before.Add(time.Minute)}, + ledger.Taking{Tile: 9, Country: "de", Previous: "fr", At: before}, + ) + + assert.Empty(t, rewind.Restorations(owners{1: "fr", 9: "de"})) +} + +func TestABombIsRewoundToTheFlagsItCleared(t *testing.T) { + before := start.Add(time.Hour) + rewind := rewindOf([]uint32{1, 2}, before, ledger.Bombing{At: before, Blast: clicks.Blast{ + Cleared: []uint32{1, 2}, Owners: []string{"fr", "es"}, + }}) + + assert.Equal(t, []clicks.Restoration{ + {Tile: 1, From: "", To: "fr"}, + {Tile: 2, From: "de", To: "es"}, + }, rewind.Restorations(owners{2: "de"})) +} + +func TestTheLedgerReachesBackItsRetention(t *testing.T) { + reach := ledger.NewReach(ledger.Config{Retention: time.Hour}, cptime.NewFixedClock(start)) + + assert.Equal(t, start.Add(-time.Hour), reach.Since()) +} diff --git a/apps/backend/internal/planet/internal/ledger/usecases/restore_area_usecase/audit_restore_area/audit.go b/apps/backend/internal/planet/internal/ledger/usecases/restore_area_usecase/audit_restore_area/audit.go new file mode 100644 index 000000000..f350cad63 --- /dev/null +++ b/apps/backend/internal/planet/internal/ledger/usecases/restore_area_usecase/audit_restore_area/audit.go @@ -0,0 +1,42 @@ +package audit_restore_area + +import ( + "context" + "log/slog" + "time" + + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/ledger/usecases/restore_area_usecase" +) + +type UseCase interface { + Execute(ctx context.Context, in restore_area_usecase.In) (restore_area_usecase.Out, error) +} + +func New(inner UseCase, logger *slog.Logger) *Audited { + return &Audited{inner: inner, logger: logger} +} + +type Audited struct { + inner UseCase + logger *slog.Logger +} + +func (a *Audited) Execute(ctx context.Context, in restore_area_usecase.In) (restore_area_usecase.Out, error) { + out, err := a.inner.Execute(ctx, in) + + attrs := []any{ + slog.Uint64("tile", uint64(in.Tile)), slog.Uint64("rings", uint64(in.Rings)), + slog.String("before", in.Before.UTC().Format(time.RFC3339)), slog.Bool("dryRun", in.DryRun), + slog.Int("tiles", out.Tiles), slog.Int("changed", out.Changed), slog.Int("restored", out.Restored), + slog.Int("painters", out.PaintersTotal), + } + + if err != nil { + a.logger.Warn("admin area restore failed", append(attrs, slog.Any("error", err))...) + return out, err //nolint:wrapcheck // a decorator adds a log line, not a sentence. + } + + a.logger.Warn("admin area restore", attrs...) + + return out, nil +} diff --git a/apps/backend/internal/planet/internal/ledger/usecases/restore_area_usecase/audit_restore_area/audit_test.go b/apps/backend/internal/planet/internal/ledger/usecases/restore_area_usecase/audit_restore_area/audit_test.go new file mode 100644 index 000000000..9ced77c0c --- /dev/null +++ b/apps/backend/internal/planet/internal/ledger/usecases/restore_area_usecase/audit_restore_area/audit_test.go @@ -0,0 +1,50 @@ +package audit_restore_area_test + +import ( + "bytes" + "context" + "errors" + "log/slog" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/ledger/usecases/restore_area_usecase" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/ledger/usecases/restore_area_usecase/audit_restore_area" +) + +type stubUseCase struct { + out restore_area_usecase.Out + err error +} + +func (s stubUseCase) Execute(context.Context, restore_area_usecase.In) (restore_area_usecase.Out, error) { + return s.out, s.err +} + +func run(t *testing.T, inner stubUseCase) (string, error) { + t.Helper() + + var logs bytes.Buffer + _, err := audit_restore_area.New(inner, slog.New(slog.NewTextHandler(&logs, nil))).Execute(t.Context(), + restore_area_usecase.In{Tile: 42, Rings: 3, Before: time.Date(2026, 10, 20, 11, 0, 0, 0, time.UTC), DryRun: true}) + return logs.String(), err +} + +func TestEveryRestoreIsLoggedDryRunsToo(t *testing.T) { + logs, err := run(t, stubUseCase{out: restore_area_usecase.Out{Tiles: 37, Changed: 12, PaintersTotal: 2}}) + + require.NoError(t, err) + assert.Contains(t, logs, `level=WARN msg="admin area restore" tile=42 rings=3 before=2026-10-20T11:00:00Z dryRun=true tiles=37 changed=12 restored=0 painters=2`) +} + +func TestAFailedRestoreIsLoggedToo(t *testing.T) { + cause := errors.New("the map is closed") + + logs, err := run(t, stubUseCase{err: cause}) + + require.ErrorIs(t, err, cause) + assert.Contains(t, logs, `level=WARN msg="admin area restore failed" tile=42`) +} diff --git a/apps/backend/internal/planet/internal/ledger/usecases/restore_area_usecase/usecase.go b/apps/backend/internal/planet/internal/ledger/usecases/restore_area_usecase/usecase.go new file mode 100644 index 000000000..0009b3005 --- /dev/null +++ b/apps/backend/internal/planet/internal/ledger/usecases/restore_area_usecase/usecase.go @@ -0,0 +1,121 @@ +package restore_area_usecase + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/ledger" + "github.com/raphoester/clickplanet.lol-backend/internal/shared/cpcolls" +) + +const shownPainters = 50 + +var ( + ErrNoTime = errors.New("a restore needs the time to put the area back to") + ErrPastTheLedger = errors.New("the ledger does not reach back that far") +) + +type Ledger interface { + Replay(see func(ledger.Event)) ledger.Position +} + +type Map interface { + Owner(tile uint32) (string, bool) + Restore(ctx context.Context, restorations []clicks.Restoration) (int, error) +} + +type Areas interface { + Around(tile uint32, rings uint32) ([]uint32, error) +} + +type In struct { + Tile uint32 + Rings uint32 + Before time.Time + DryRun bool +} + +type Out struct { + Tiles int + Changed int + Restored int + Painters []ledger.Player + PaintersTotal int + KeptSince time.Time +} + +func New(ledger Ledger, tiles Map, areas Areas, reach ledger.Reach, pace clicks.Pacing) *UseCase { + return &UseCase{ledger: ledger, tiles: tiles, areas: areas, reach: reach, pacing: pace} +} + +type UseCase struct { + ledger Ledger + tiles Map + areas Areas + reach ledger.Reach + pacing clicks.Pacing +} + +func (u *UseCase) Execute(ctx context.Context, in In) (Out, error) { + if in.Before.IsZero() { + return Out{}, ErrNoTime + } + if u.pacing.Batch <= 0 { + return Out{}, errors.New("restore batch must be positive") + } + + area, err := u.areas.Around(in.Tile, in.Rings) + if err != nil { + return Out{}, fmt.Errorf("cannot restore: %w", err) + } + + keptSince := u.reach.Since() + if in.Before.Before(keptSince) { + return Out{}, fmt.Errorf("%w: it keeps from %s", ErrPastTheLedger, keptSince.Format(time.RFC3339)) + } + + // The ledger in memory keeps whole seconds: a take in the second asked for is after it. + since := in.Before.Truncate(time.Second) + inside := cpcolls.NewSet(area...) + rewind := ledger.NewRewind(area, since) + painters := ledger.NewTally(func(taking ledger.Taking) bool { + return !taking.At.Before(since) && inside.Contains(taking.Tile) + }) + u.ledger.Replay(func(event ledger.Event) { + rewind.See(event) + painters.See(event) + }) + + restorations := rewind.Restorations(u.tiles) + everyone := ledger.ByTakes(painters.Players(u.tiles)) + out := Out{ + Tiles: len(area), Changed: len(restorations), KeptSince: keptSince, + Painters: ledger.Top(everyone, shownPainters), PaintersTotal: len(everyone), + } + + if in.DryRun { + return out, nil + } + + for len(restorations) > 0 { + batch := restorations[:min(u.pacing.Batch, len(restorations))] + restorations = restorations[len(batch):] + + restored, err := u.tiles.Restore(ctx, batch) + out.Restored += restored + if err != nil { + return out, fmt.Errorf("failed to restore tiles: %w", err) + } + + if len(restorations) > 0 { + if err := u.pacing.Wait(ctx); err != nil { + return out, fmt.Errorf("restore %w", err) + } + } + } + + return out, nil +} diff --git a/apps/backend/internal/planet/internal/ledger/usecases/restore_area_usecase/usecase_test.go b/apps/backend/internal/planet/internal/ledger/usecases/restore_area_usecase/usecase_test.go new file mode 100644 index 000000000..6cbb5ee12 --- /dev/null +++ b/apps/backend/internal/planet/internal/ledger/usecases/restore_area_usecase/usecase_test.go @@ -0,0 +1,170 @@ +package restore_area_usecase_test + +import ( + "context" + "errors" + "log/slog" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/ledger" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/ledger/inmemory_ledger_storage" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/ledger/usecases/restore_area_usecase" + "github.com/raphoester/clickplanet.lol-backend/internal/shared/cptime" +) + +var ( + now = time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC) + before = now.Add(-time.Hour) +) + +type stubMap struct { + owners map[uint32]string + batches []int + err error +} + +func (m *stubMap) Owner(tile uint32) (string, bool) { return m.owners[tile], true } + +func (m *stubMap) Restore(_ context.Context, restorations []clicks.Restoration) (int, error) { + m.batches = append(m.batches, len(restorations)) + if m.err != nil { + return 0, m.err + } + + restored := 0 + for _, r := range restorations { + if m.owners[r.Tile] == r.From { + m.owners[r.Tile] = r.To + restored++ + } + } + return restored, nil +} + +type stubAreas struct { + area []uint32 + err error +} + +func (a stubAreas) Around(uint32, uint32) ([]uint32, error) { return a.area, a.err } + +func setup(t *testing.T) (*inmemory_ledger_storage.Storage, *stubMap) { + t.Helper() + + book := inmemory_ledger_storage.New(inmemory_ledger_storage.Config{}, inmemory_ledger_storage.NewMemoryPersistence(), + slog.New(slog.DiscardHandler)) + tiles := &stubMap{owners: map[uint32]string{1: "fr", 2: "fr", 3: "", 4: "fr"}} + + book.Append(ledger.Taking{Tile: 1, Scope: "1.1.1.1", Country: "fr", At: before.Add(-time.Minute)}) + for tile := uint32(1); tile <= 3; tile++ { + book.Append(ledger.Taking{Tile: tile, Scope: "9.9.9.9", Country: "de", Previous: tiles.owners[tile], At: before.Add(time.Minute)}) + tiles.owners[tile] = "de" + } + book.Append(ledger.Taking{Tile: 4, Scope: "9.9.9.9", Country: "de", Previous: "fr", At: before.Add(time.Minute)}) + tiles.owners[4] = "de" + + return book, tiles +} + +func restore(book *inmemory_ledger_storage.Storage, tiles *stubMap, areas stubAreas, in restore_area_usecase.In) (restore_area_usecase.Out, error) { + return restore_area_usecase.New(book, tiles, areas, ledger.NewReach(ledger.Config{Retention: 72 * time.Hour}, + cptime.NewFixedClock(now)), clicks.Pacing{Batch: 2}).Execute(context.Background(), in) +} + +func TestTheAreaGoesBackToWhatItHeldBeforeInBatches(t *testing.T) { + book, tiles := setup(t) + + out, err := restore(book, tiles, stubAreas{area: []uint32{1, 2, 3}}, restore_area_usecase.In{Tile: 2, Rings: 1, Before: before}) + require.NoError(t, err) + + assert.Equal(t, 3, out.Tiles) + assert.Equal(t, 3, out.Changed) + assert.Equal(t, 3, out.Restored) + assert.Equal(t, map[uint32]string{1: "fr", 2: "fr", 3: "", 4: "de"}, tiles.owners, "a tile outside the area is left") + assert.Equal(t, []int{2, 1}, tiles.batches) + assert.Equal(t, now.Add(-72*time.Hour), out.KeptSince) +} + +func TestItSaysWhoPaintedTheAreaSinceBeforeOnly(t *testing.T) { + book, tiles := setup(t) + + out, err := restore(book, tiles, stubAreas{area: []uint32{1, 2, 3}}, restore_area_usecase.In{ + Tile: 2, Rings: 1, Before: before, DryRun: true, + }) + require.NoError(t, err) + + require.Len(t, out.Painters, 1, "the take before the time is not the drawing") + assert.Equal(t, 1, out.PaintersTotal) + assert.Equal(t, "9.9.9.9", out.Painters[0].Scope) + assert.Equal(t, 3, out.Painters[0].Takes, "its take outside the area is not counted") + assert.Equal(t, "de", out.Painters[0].Flag) +} + +func TestADryRunCountsAndRestoresNothing(t *testing.T) { + book, tiles := setup(t) + + out, err := restore(book, tiles, stubAreas{area: []uint32{1, 2, 3}}, restore_area_usecase.In{ + Tile: 2, Rings: 1, Before: before, DryRun: true, + }) + require.NoError(t, err) + + assert.Equal(t, 3, out.Changed) + assert.Zero(t, out.Restored) + assert.Empty(t, tiles.batches) +} + +func TestATileTakenSinceTheReadStaysTaken(t *testing.T) { + book, tiles := setup(t) + taker := &racingMap{stubMap: tiles, tile: 2, owner: "pl"} + + out, err := restore_area_usecase.New(book, taker, stubAreas{area: []uint32{1, 2, 3}}, + ledger.NewReach(ledger.Config{Retention: 72 * time.Hour}, cptime.NewFixedClock(now)), clicks.Pacing{Batch: 10}). + Execute(t.Context(), restore_area_usecase.In{Tile: 2, Rings: 1, Before: before}) + require.NoError(t, err) + + assert.Equal(t, 2, out.Restored) + assert.Equal(t, "pl", tiles.owners[2]) +} + +type racingMap struct { + *stubMap + tile uint32 + owner string +} + +func (m *racingMap) Restore(ctx context.Context, restorations []clicks.Restoration) (int, error) { + m.owners[m.tile] = m.owner + return m.stubMap.Restore(ctx, restorations) +} + +func TestARestoreIsRefusedBeforeAnythingIsRead(t *testing.T) { + book, tiles := setup(t) + + for name, refused := range map[string]struct { + areas stubAreas + in restore_area_usecase.In + err error + }{ + "no time": {stubAreas{area: []uint32{1}}, restore_area_usecase.In{Tile: 1, Rings: 1}, restore_area_usecase.ErrNoTime}, + "past the ledger": {stubAreas{area: []uint32{1}}, restore_area_usecase.In{Tile: 1, Rings: 1, Before: now.Add(-73 * time.Hour)}, restore_area_usecase.ErrPastTheLedger}, + "an area that is not": {stubAreas{err: clicks.ErrInvalidArea}, restore_area_usecase.In{Rings: 99, Before: before}, clicks.ErrInvalidArea}, + } { + _, err := restore(book, tiles, refused.areas, refused.in) + require.ErrorIs(t, err, refused.err, name) + } + assert.Empty(t, tiles.batches) +} + +func TestAFailedRestoreSaysWhatItRestored(t *testing.T) { + book, tiles := setup(t) + tiles.err = errors.New("the map is closed") + + _, err := restore(book, tiles, stubAreas{area: []uint32{1, 2, 3}}, restore_area_usecase.In{Tile: 2, Rings: 1, Before: before}) + + require.ErrorIs(t, err, tiles.err) +} diff --git a/apps/backend/internal/planet/internal/planetv1controller/admin_service.go b/apps/backend/internal/planet/internal/planetv1controller/admin_service.go index 7a9ca6011..b397fd506 100644 --- a/apps/backend/internal/planet/internal/planetv1controller/admin_service.go +++ b/apps/backend/internal/planet/internal/planetv1controller/admin_service.go @@ -3,12 +3,14 @@ package planetv1controller import ( "github.com/raphoester/clickplanet.lol-backend/generated/proto/planet/v1/planetv1connect" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/ban_player_handler" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/clear_area_handler" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/find_players_handler" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/get_replay_handler" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/grant_charges_handler" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/inspect_player_handler" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/paint_random_tiles_handler" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/reassign_country_handler" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/restore_area_handler" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/revert_player_handler" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/top_players_handler" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/unban_player_handler" @@ -25,6 +27,8 @@ type AdminService struct { paint_random_tiles_handler.PaintRandomTilesHandler grant_charges_handler.GrantChargesHandler get_replay_handler.GetReplayHandler + restore_area_handler.RestoreAreaHandler + clear_area_handler.ClearAreaHandler } var _ planetv1connect.AdminServiceHandler = AdminService{} diff --git a/apps/backend/internal/planet/internal/planetv1controller/clear_area_handler/handler.go b/apps/backend/internal/planet/internal/planetv1controller/clear_area_handler/handler.go new file mode 100644 index 000000000..2c51a2837 --- /dev/null +++ b/apps/backend/internal/planet/internal/planetv1controller/clear_area_handler/handler.go @@ -0,0 +1,46 @@ +package clear_area_handler + +import ( + "context" + "errors" + + "connectrpc.com/connect" + + planetv1 "github.com/raphoester/clickplanet.lol-backend/generated/proto/planet/v1" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks/usecases/clear_area_usecase" +) + +type UseCase interface { + Execute(ctx context.Context, in clear_area_usecase.In) (clear_area_usecase.Out, error) +} + +func New(useCase UseCase) ClearAreaHandler { + return ClearAreaHandler{useCase: useCase} +} + +type ClearAreaHandler struct { + useCase UseCase +} + +func (h ClearAreaHandler) ClearArea( + ctx context.Context, + req *connect.Request[planetv1.ClearAreaRequest], +) (*connect.Response[planetv1.ClearAreaResponse], error) { + out, err := h.useCase.Execute(ctx, clear_area_usecase.In{ + Tile: req.Msg.GetTileId(), Rings: req.Msg.GetRings(), DryRun: req.Msg.GetDryRun(), + }) + + switch { + case err == nil: + return connect.NewResponse(&planetv1.ClearAreaResponse{ + Tiles: uint32(out.Tiles), //nolint:gosec // a tile count, bounded by the map. + Owned: uint32(out.Owned), //nolint:gosec // a tile count, bounded by the map. + Cleared: uint32(out.Cleared), //nolint:gosec // a tile count, bounded by the map. + }), nil + case errors.Is(err, clicks.ErrTileOutOfRange), errors.Is(err, clicks.ErrInvalidArea): + return nil, connect.NewError(connect.CodeInvalidArgument, err) + default: + return nil, err + } +} diff --git a/apps/backend/internal/planet/internal/planetv1controller/clear_area_handler/handler_test.go b/apps/backend/internal/planet/internal/planetv1controller/clear_area_handler/handler_test.go new file mode 100644 index 000000000..48eb021b0 --- /dev/null +++ b/apps/backend/internal/planet/internal/planetv1controller/clear_area_handler/handler_test.go @@ -0,0 +1,61 @@ +package clear_area_handler_test + +import ( + "context" + "fmt" + "testing" + + "connectrpc.com/connect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + planetv1 "github.com/raphoester/clickplanet.lol-backend/generated/proto/planet/v1" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks/usecases/clear_area_usecase" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/clear_area_handler" +) + +type stubUseCase struct { + in clear_area_usecase.In + out clear_area_usecase.Out + err error +} + +func (s *stubUseCase) Execute(_ context.Context, in clear_area_usecase.In) (clear_area_usecase.Out, error) { + s.in = in + return s.out, s.err +} + +func clearArea(t *testing.T, useCase *stubUseCase) (*planetv1.ClearAreaResponse, error) { + t.Helper() + + res, err := clear_area_handler.New(useCase).ClearArea(t.Context(), + connect.NewRequest(&planetv1.ClearAreaRequest{TileId: 42, Rings: 3, DryRun: true})) + if err != nil { + return nil, fmt.Errorf("clear refused: %w", err) + } + return res.Msg, nil +} + +func TestTheRequestReachesTheUseCaseAndTheCountsComeBack(t *testing.T) { + useCase := &stubUseCase{out: clear_area_usecase.Out{Tiles: 37, Owned: 20}} + + res, err := clearArea(t, useCase) + require.NoError(t, err) + + assert.Equal(t, clear_area_usecase.In{Tile: 42, Rings: 3, DryRun: true}, useCase.in) + assert.Equal(t, uint32(37), res.GetTiles()) + assert.Equal(t, uint32(20), res.GetOwned()) + assert.Zero(t, res.GetCleared()) +} + +func TestErrorsMapToTheirCodes(t *testing.T) { + for err, code := range map[error]connect.Code{ + fmt.Errorf("refused: %w", clicks.ErrTileOutOfRange): connect.CodeInvalidArgument, + fmt.Errorf("refused: %w", clicks.ErrInvalidArea): connect.CodeInvalidArgument, + assert.AnError: connect.CodeUnknown, + } { + _, got := clearArea(t, &stubUseCase{err: err}) + assert.Equal(t, code, connect.CodeOf(got), err.Error()) + } +} diff --git a/apps/backend/internal/planet/internal/planetv1controller/restore_area_handler/handler.go b/apps/backend/internal/planet/internal/planetv1controller/restore_area_handler/handler.go new file mode 100644 index 000000000..b4d9af9dc --- /dev/null +++ b/apps/backend/internal/planet/internal/planetv1controller/restore_area_handler/handler.go @@ -0,0 +1,57 @@ +package restore_area_handler + +import ( + "context" + "errors" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/timestamppb" + + planetv1 "github.com/raphoester/clickplanet.lol-backend/generated/proto/planet/v1" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/ledger/usecases/restore_area_usecase" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/adminplayer" +) + +type UseCase interface { + Execute(ctx context.Context, in restore_area_usecase.In) (restore_area_usecase.Out, error) +} + +func New(useCase UseCase) RestoreAreaHandler { + return RestoreAreaHandler{useCase: useCase} +} + +type RestoreAreaHandler struct { + useCase UseCase +} + +func (h RestoreAreaHandler) RestoreArea( + ctx context.Context, + req *connect.Request[planetv1.RestoreAreaRequest], +) (*connect.Response[planetv1.RestoreAreaResponse], error) { + in := restore_area_usecase.In{Tile: req.Msg.GetTileId(), Rings: req.Msg.GetRings(), DryRun: req.Msg.GetDryRun()} + if before := req.Msg.GetBefore(); before != nil { + in.Before = before.AsTime() + } + + out, err := h.useCase.Execute(ctx, in) + + switch { + case err == nil: + return connect.NewResponse(&planetv1.RestoreAreaResponse{ + Tiles: uint32(out.Tiles), //nolint:gosec // a tile count, bounded by the map. + Changed: uint32(out.Changed), //nolint:gosec // a tile count, bounded by the map. + Restored: uint32(out.Restored), //nolint:gosec // a tile count, bounded by the map. + PaintersTotal: uint32(out.PaintersTotal), //nolint:gosec // a player count, bounded by the ledger. + Painters: adminplayer.Encode(out.Painters), + KeptSince: timestamppb.New(out.KeptSince), + }), nil + case errors.Is(err, clicks.ErrTileOutOfRange), errors.Is(err, clicks.ErrInvalidArea), + errors.Is(err, restore_area_usecase.ErrNoTime): + return nil, connect.NewError(connect.CodeInvalidArgument, err) + case errors.Is(err, restore_area_usecase.ErrPastTheLedger): + return nil, connect.NewError(connect.CodeFailedPrecondition, err) + default: + return nil, err + } +} diff --git a/apps/backend/internal/planet/internal/planetv1controller/restore_area_handler/handler_test.go b/apps/backend/internal/planet/internal/planetv1controller/restore_area_handler/handler_test.go new file mode 100644 index 000000000..b484a4e14 --- /dev/null +++ b/apps/backend/internal/planet/internal/planetv1controller/restore_area_handler/handler_test.go @@ -0,0 +1,83 @@ +package restore_area_handler_test + +import ( + "context" + "fmt" + "testing" + "time" + + "connectrpc.com/connect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + planetv1 "github.com/raphoester/clickplanet.lol-backend/generated/proto/planet/v1" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/ledger" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/ledger/usecases/restore_area_usecase" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/restore_area_handler" +) + +type stubUseCase struct { + in restore_area_usecase.In + out restore_area_usecase.Out + err error +} + +func (s *stubUseCase) Execute(_ context.Context, in restore_area_usecase.In) (restore_area_usecase.Out, error) { + s.in = in + return s.out, s.err +} + +var before = time.Date(2026, 10, 20, 11, 0, 0, 0, time.UTC) + +func restore(t *testing.T, useCase *stubUseCase, req *planetv1.RestoreAreaRequest) (*planetv1.RestoreAreaResponse, error) { + t.Helper() + + res, err := restore_area_handler.New(useCase).RestoreArea(t.Context(), connect.NewRequest(req)) + if err != nil { + return nil, fmt.Errorf("restore refused: %w", err) + } + return res.Msg, nil +} + +func TestTheRequestReachesTheUseCaseAndTheCountsComeBack(t *testing.T) { + useCase := &stubUseCase{out: restore_area_usecase.Out{ + Tiles: 37, Changed: 12, Restored: 11, PaintersTotal: 3, KeptSince: before.Add(-71 * time.Hour), + Painters: []ledger.Player{{Scope: "9.9.9.9", Account: "account-1", Flag: "de", Takes: 12}}, + }} + + res, err := restore(t, useCase, &planetv1.RestoreAreaRequest{TileId: 42, Rings: 3, Before: timestamppb.New(before)}) + require.NoError(t, err) + + assert.Equal(t, restore_area_usecase.In{Tile: 42, Rings: 3, Before: before}, useCase.in) + assert.Equal(t, uint32(37), res.GetTiles()) + assert.Equal(t, uint32(12), res.GetChanged()) + assert.Equal(t, uint32(11), res.GetRestored()) + assert.Equal(t, uint32(3), res.GetPaintersTotal()) + require.Len(t, res.GetPainters(), 1) + assert.Equal(t, "account-1", res.GetPainters()[0].GetAccountId()) + assert.Equal(t, before.Add(-71*time.Hour), res.GetKeptSince().AsTime()) +} + +func TestNoTimeReachesTheUseCaseAsNone(t *testing.T) { + useCase := &stubUseCase{} + + _, err := restore(t, useCase, &planetv1.RestoreAreaRequest{TileId: 42, Rings: 3}) + require.NoError(t, err) + + assert.True(t, useCase.in.Before.IsZero()) +} + +func TestErrorsMapToTheirCodes(t *testing.T) { + for err, code := range map[error]connect.Code{ + fmt.Errorf("refused: %w", clicks.ErrTileOutOfRange): connect.CodeInvalidArgument, + fmt.Errorf("refused: %w", clicks.ErrInvalidArea): connect.CodeInvalidArgument, + restore_area_usecase.ErrNoTime: connect.CodeInvalidArgument, + fmt.Errorf("refused: %w", restore_area_usecase.ErrPastTheLedger): connect.CodeFailedPrecondition, + assert.AnError: connect.CodeUnknown, + } { + _, got := restore(t, &stubUseCase{err: err}, &planetv1.RestoreAreaRequest{TileId: 42, Rings: 3}) + assert.Equal(t, code, connect.CodeOf(got), err.Error()) + } +} diff --git a/apps/backend/internal/planet/module.go b/apps/backend/internal/planet/module.go index 06b195663..47287c551 100644 --- a/apps/backend/internal/planet/module.go +++ b/apps/backend/internal/planet/module.go @@ -35,6 +35,8 @@ import ( "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks/embedded_geodesic_map" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks/inmemory_tile_storage" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks/postgres_tile_store" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks/usecases/clear_area_usecase" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks/usecases/clear_area_usecase/audit_clear_area" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks/usecases/click_usecase" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks/usecases/click_usecase/antibot_attempt_click" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks/usecases/click_usecase/antibot_click" @@ -65,6 +67,8 @@ import ( "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/ledger/usecases/find_players_usecase" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/ledger/usecases/inspect_player_usecase" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/ledger/usecases/replay_usecase" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/ledger/usecases/restore_area_usecase" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/ledger/usecases/restore_area_usecase/audit_restore_area" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/ledger/usecases/revert_player_usecase" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/ledger/usecases/revert_player_usecase/audit_revert" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/ledger/usecases/top_players_usecase" @@ -75,6 +79,7 @@ import ( "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/answer_quiz_handler" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/ban_player_handler" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/claim_bonus_handler" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/clear_area_handler" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/click_handler" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/drop_bomb_handler" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/find_players_handler" @@ -97,6 +102,7 @@ import ( "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/paint_random_tiles_handler" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/place_shield_handler" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/reassign_country_handler" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/restore_area_handler" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/revert_player_handler" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/top_players_handler" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/unban_player_handler" @@ -210,7 +216,8 @@ func NewModule(config Config) cpbootstrap.Module { slog.Int("subjects", bank.Subjects())) } - bombRules := bonuses.NewBombRules(config.Bonus.Bomb, geography.Spacing()) + spacing := geography.Spacing() + bombRules := bonuses.NewBombRules(config.Bonus.Bomb, spacing) var clickUseCase click_usecase.IUseCase = click_usecase.New(tilesChecker, writer, countries) clickUseCase = spread_click.New(clickUseCase, charges, geography, writer, registry) @@ -245,6 +252,7 @@ func NewModule(config Config) cpbootstrap.Module { adminBatch = 256 } pace := clicks.Pacing{Batch: adminBatch, Pause: 50 * time.Millisecond} + areas := clicks.NewAreas(geography, spacing) adminService := planetv1controller.AdminService{ ReassignCountryHandler: reassign_country_handler.New(audit_reassign.New( @@ -264,6 +272,10 @@ func NewModule(config Config) cpbootstrap.Module { GrantChargesHandler: grant_charges_handler.New( audit_grant_charges.New(grant_charges_usecase.New(charges), props.Logger)), GetReplayHandler: get_replay_handler.New(replay_usecase.New(takings, tilesChecker, tilesStorage, clock)), + RestoreAreaHandler: restore_area_handler.New(audit_restore_area.New(restore_area_usecase.New( + takings, tilesStorage, areas, ledger.NewReach(config.Ledger, clock), pace), props.Logger)), + ClearAreaHandler: clear_area_handler.New(audit_clear_area.New( + clear_area_usecase.New(tilesStorage, areas, pace), props.Logger)), } if err := props.AdminRPC.Mount(func(options ...connect.HandlerOption) (string, http.Handler) { diff --git a/apps/backend/internal/support/internal/decisions/decision.go b/apps/backend/internal/support/internal/decisions/decision.go index cdfe08340..b339f7d8b 100644 --- a/apps/backend/internal/support/internal/decisions/decision.go +++ b/apps/backend/internal/support/internal/decisions/decision.go @@ -8,7 +8,6 @@ import ( "time" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/notes" - "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/reports" ) var ( @@ -33,47 +32,48 @@ func (o Outcome) known() bool { } } -// What the operator did about a report, and why. It goes with its report. -type Decision struct { - report reports.ReportID +// What the operator did about a report, of a player or of a place (R, its id), and why. It goes with its report. +type Decision[R comparable] struct { + report R outcome Outcome comment string at time.Time } -func NewDecision(report reports.ReportID, outcome Outcome, comment string, at time.Time) (Decision, error) { - return Snapshot{Report: report, Outcome: outcome, Comment: comment, At: at}.Restore() +func NewDecision[R comparable](report R, outcome Outcome, comment string, at time.Time) (Decision[R], error) { + return Snapshot[R]{Report: report, Outcome: outcome, Comment: comment, At: at}.Restore() } -func (d Decision) TakeSnapshot() Snapshot { - return Snapshot{Report: d.report, Outcome: d.outcome, Comment: d.comment, At: d.at} +func (d Decision[R]) TakeSnapshot() Snapshot[R] { + return Snapshot[R]{Report: d.report, Outcome: d.outcome, Comment: d.comment, At: d.at} } // The state of a Decision, for a store. It holds no rule: Restore does. -type Snapshot struct { - Report reports.ReportID +type Snapshot[R comparable] struct { + Report R Outcome Outcome // Optional. Comment string At time.Time } -func (s Snapshot) Restore() (Decision, error) { +func (s Snapshot[R]) Restore() (Decision[R], error) { + var none R switch { - case s.Report == reports.ReportID{}: - return Decision{}, fmt.Errorf("%w: no report", ErrInvalidDecision) + case s.Report == none: + return Decision[R]{}, fmt.Errorf("%w: no report", ErrInvalidDecision) case s.At.IsZero(): - return Decision{}, fmt.Errorf("%w: no time", ErrInvalidDecision) + return Decision[R]{}, fmt.Errorf("%w: no time", ErrInvalidDecision) case !s.Outcome.known(): - return Decision{}, fmt.Errorf("%w: %q", ErrUnknownOutcome, string(s.Outcome)) + return Decision[R]{}, fmt.Errorf("%w: %q", ErrUnknownOutcome, string(s.Outcome)) } comment, err := commentOf(s.Comment) if err != nil { - return Decision{}, err + return Decision[R]{}, err } - return Decision{report: s.Report, outcome: s.Outcome, comment: comment, at: s.At}, nil + return Decision[R]{report: s.Report, outcome: s.Outcome, comment: comment, at: s.At}, nil } func commentOf(text string) (string, error) { @@ -89,7 +89,7 @@ func commentOf(text string) (string, error) { } // A report decided again keeps the last decision. -type Storage interface { +type Storage[R comparable] interface { // ErrUnknownReport for a report no longer kept. - Save(ctx context.Context, decision Decision) error + Save(ctx context.Context, decision Decision[R]) error } diff --git a/apps/backend/internal/support/internal/decisions/decision_test.go b/apps/backend/internal/support/internal/decisions/decision_test.go index e35912c18..73da5b460 100644 --- a/apps/backend/internal/support/internal/decisions/decision_test.go +++ b/apps/backend/internal/support/internal/decisions/decision_test.go @@ -10,6 +10,7 @@ import ( "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/decisions" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/notes" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/places" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/reports" ) @@ -22,7 +23,7 @@ func TestADecisionKeepsWhatTheOperatorSaid(t *testing.T) { decision, err := decisions.NewDecision(report, decisions.OutcomeActed, " muted for a day\r\n", decidedAt) require.NoError(t, err) - assert.Equal(t, decisions.Snapshot{ + assert.Equal(t, decisions.Snapshot[reports.ReportID]{ Report: report, Outcome: decisions.OutcomeActed, Comment: "muted for a day", At: decidedAt, }, decision.TakeSnapshot()) } @@ -35,7 +36,7 @@ func TestTheCommentIsOptional(t *testing.T) { } func TestASnapshotRestoresTheDecisionItWasTakenFrom(t *testing.T) { - kept := decisions.Snapshot{Report: report, Outcome: decisions.OutcomeDismissed, Comment: "a duel", At: decidedAt} + kept := decisions.Snapshot[reports.ReportID]{Report: report, Outcome: decisions.OutcomeDismissed, Comment: "a duel", At: decidedAt} decision, err := kept.Restore() require.NoError(t, err) @@ -45,23 +46,23 @@ func TestASnapshotRestoresTheDecisionItWasTakenFrom(t *testing.T) { func TestADecisionThatBreaksARuleRestoresNothing(t *testing.T) { for name, broken := range map[string]struct { - snapshot decisions.Snapshot + snapshot decisions.Snapshot[reports.ReportID] err error }{ "no report": { - decisions.Snapshot{Outcome: decisions.OutcomeActed, At: decidedAt}, decisions.ErrInvalidDecision, + decisions.Snapshot[reports.ReportID]{Outcome: decisions.OutcomeActed, At: decidedAt}, decisions.ErrInvalidDecision, }, "no time": { - decisions.Snapshot{Report: report, Outcome: decisions.OutcomeActed}, decisions.ErrInvalidDecision, + decisions.Snapshot[reports.ReportID]{Report: report, Outcome: decisions.OutcomeActed}, decisions.ErrInvalidDecision, }, "no outcome": { - decisions.Snapshot{Report: report, At: decidedAt}, decisions.ErrUnknownOutcome, + decisions.Snapshot[reports.ReportID]{Report: report, At: decidedAt}, decisions.ErrUnknownOutcome, }, "an outcome nobody named": { - decisions.Snapshot{Report: report, Outcome: "banned", At: decidedAt}, decisions.ErrUnknownOutcome, + decisions.Snapshot[reports.ReportID]{Report: report, Outcome: "banned", At: decidedAt}, decisions.ErrUnknownOutcome, }, "a comment too long": { - decisions.Snapshot{ + decisions.Snapshot[reports.ReportID]{ Report: report, Outcome: decisions.OutcomeActed, Comment: strings.Repeat("a", notes.MaxTextLength+1), At: decidedAt, }, notes.ErrInvalidText, @@ -71,3 +72,10 @@ func TestADecisionThatBreaksARuleRestoresNothing(t *testing.T) { require.ErrorIs(t, err, broken.err, name) } } + +func TestAPlaceReportIsDecidedTheSameWay(t *testing.T) { + decision, err := decisions.NewDecision(places.ReportID{15: 1}, decisions.OutcomeActed, "restored the area", decidedAt) + require.NoError(t, err) + + assert.Equal(t, places.ReportID{15: 1}, decision.TakeSnapshot().Report) +} diff --git a/apps/backend/internal/support/internal/decisions/postgres_decision_store/store.go b/apps/backend/internal/support/internal/decisions/postgres_decision_store/store.go index 38a544944..3cf236c93 100644 --- a/apps/backend/internal/support/internal/decisions/postgres_decision_store/store.go +++ b/apps/backend/internal/support/internal/decisions/postgres_decision_store/store.go @@ -10,31 +10,54 @@ import ( "github.com/raphoester/clickplanet.lol-backend/internal/shared/cppg" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/decisions" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/places" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/reports" ) -func New(db cppg.Querier) *Store { - return &Store{db: db} +// The decisions on the reports of players, in report_decisions. +func ForReports(db cppg.Querier) *Store[reports.ReportID] { + return &Store[reports.ReportID]{ + db: db, upsert: upsertInto("report_decisions"), + id: func(report reports.ReportID) uuid.UUID { return uuid.UUID(report) }, + } } -type Store struct { - db cppg.Querier +// The decisions on the reports of places, in place_report_decisions. +func ForPlaces(db cppg.Querier) *Store[places.ReportID] { + return &Store[places.ReportID]{ + db: db, upsert: upsertInto("place_report_decisions"), + id: func(report places.ReportID) uuid.UUID { return uuid.UUID(report) }, + } } -var _ decisions.Storage = (*Store)(nil) +type Store[R comparable] struct { + db cppg.Querier + upsert string + id func(R) uuid.UUID +} + +var ( + _ decisions.Storage[reports.ReportID] = (*Store[reports.ReportID])(nil) + _ decisions.Storage[places.ReportID] = (*Store[places.ReportID])(nil) +) const foreignKeyViolation = "23503" -func (s *Store) Save(ctx context.Context, decision decisions.Decision) error { - kept := decision.TakeSnapshot() - _, err := s.db.ExecContext(ctx, ` - INSERT INTO report_decisions (report_id, outcome, comment, decided_at) VALUES ($1, $2::report_outcome, $3, $4) +func upsertInto(table string) string { + return ` + INSERT INTO ` + table + ` (report_id, outcome, comment, decided_at) VALUES ($1, $2::report_outcome, $3, $4) ON CONFLICT (report_id) DO UPDATE SET outcome = EXCLUDED.outcome, comment = EXCLUDED.comment, decided_at = EXCLUDED.decided_at - `, uuid.UUID(kept.Report), string(kept.Outcome), kept.Comment, kept.At.UTC()) + ` +} + +func (s *Store[R]) Save(ctx context.Context, decision decisions.Decision[R]) error { + kept := decision.TakeSnapshot() + _, err := s.db.ExecContext(ctx, s.upsert, s.id(kept.Report), string(kept.Outcome), kept.Comment, kept.At.UTC()) var pqErr *pq.Error if errors.As(err, &pqErr) && pqErr.Code == foreignKeyViolation { - return fmt.Errorf("%w: %s", decisions.ErrUnknownReport, uuid.UUID(kept.Report)) + return fmt.Errorf("%w: %s", decisions.ErrUnknownReport, s.id(kept.Report)) } if err != nil { return fmt.Errorf("failed to save a decision: %w", err) diff --git a/apps/backend/internal/support/internal/decisions/postgres_decision_store/store_test.go b/apps/backend/internal/support/internal/decisions/postgres_decision_store/store_test.go index ae1eff0d9..7664bf3b1 100644 --- a/apps/backend/internal/support/internal/decisions/postgres_decision_store/store_test.go +++ b/apps/backend/internal/support/internal/decisions/postgres_decision_store/store_test.go @@ -11,6 +11,8 @@ import ( "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/decisions" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/decisions/postgres_decision_store" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/migrations" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/places" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/places/postgres_place_report_store" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/reports" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/reports/postgres_report_store" ) @@ -31,13 +33,13 @@ type testSuite struct { suite.Suite db *cppg.Postgres - store *postgres_decision_store.Store + store *postgres_decision_store.Store[reports.ReportID] reports *postgres_report_store.Store } func (s *testSuite) SetupSuite() { s.db = cppg.StartTestServer(s.T()).OpenSchema(s.T(), "support", migrations.FS) - s.store = postgres_decision_store.New(s.db) + s.store = postgres_decision_store.ForReports(s.db) s.reports = postgres_report_store.New(s.db) } @@ -52,18 +54,18 @@ func (s *testSuite) filed() reports.Report { return filed } -func (s *testSuite) decided(outcome decisions.Outcome, comment string, at time.Time) decisions.Decision { +func (s *testSuite) decided(outcome decisions.Outcome, comment string, at time.Time) decisions.Decision[reports.ReportID] { decision, err := decisions.NewDecision(report, outcome, comment, at) s.Require().NoError(err) return decision } -func (s *testSuite) kept() []decisions.Snapshot { +func (s *testSuite) kept() []decisions.Snapshot[reports.ReportID] { rows, err := s.db.QueryContext(s.T().Context(), `SELECT report_id, outcome, comment, decided_at FROM report_decisions`) s.Require().NoError(err) defer func() { _ = rows.Close() }() - var kept []decisions.Snapshot + var kept []decisions.Snapshot[reports.ReportID] for rows.Next() { var ( id uuid.UUID @@ -71,7 +73,7 @@ func (s *testSuite) kept() []decisions.Snapshot { at time.Time ) s.Require().NoError(rows.Scan(&id, &outcome, &comment, &at)) - kept = append(kept, decisions.Snapshot{ + kept = append(kept, decisions.Snapshot[reports.ReportID]{ Report: reports.ReportID(id), Outcome: decisions.Outcome(outcome), Comment: comment, At: at.UTC(), }) } @@ -84,7 +86,7 @@ func (s *testSuite) TestADecisionIsKeptWithItsReport() { s.Require().NoError(s.store.Save(s.T().Context(), s.decided(decisions.OutcomeActed, "muted", decidedAt))) - s.Equal([]decisions.Snapshot{ + s.Equal([]decisions.Snapshot[reports.ReportID]{ {Report: report, Outcome: decisions.OutcomeActed, Comment: "muted", At: decidedAt}, }, s.kept()) } @@ -95,7 +97,7 @@ func (s *testSuite) TestAReportDecidedAgainKeepsTheLastDecision() { s.Require().NoError(s.store.Save(s.T().Context(), s.decided(decisions.OutcomeDismissed, "", decidedAt))) s.Require().NoError(s.store.Save(s.T().Context(), s.decided(decisions.OutcomeActed, "looked again", decidedAt.Add(time.Hour)))) - s.Equal([]decisions.Snapshot{ + s.Equal([]decisions.Snapshot[reports.ReportID]{ {Report: report, Outcome: decisions.OutcomeActed, Comment: "looked again", At: decidedAt.Add(time.Hour)}, }, s.kept()) } @@ -115,3 +117,24 @@ func (s *testSuite) TestADecisionGoesWithItsReport() { s.Empty(s.kept()) } + +func (s *testSuite) TestAPlaceReportsDecisionIsKeptInItsOwnTableAndGoesWithIt() { + ctx := s.T().Context() + placeStore := postgres_place_report_store.New(s.db) + placed, err := places.NewReport(places.ReportID{15: 7}, ada, 42, places.Complaint{Reason: places.ReasonHate}, createdAt) + s.Require().NoError(err) + s.Require().NoError(placeStore.Save(ctx, placed)) + decision, err := decisions.NewDecision(places.ReportID{15: 7}, decisions.OutcomeActed, "restored the area", decidedAt) + s.Require().NoError(err) + + s.Require().NoError(postgres_decision_store.ForPlaces(s.db).Save(ctx, decision)) + + var outcome string + s.Require().NoError(s.db.QueryRowContext(ctx, `SELECT outcome FROM place_report_decisions`).Scan(&outcome)) + s.Equal("acted", outcome) + s.Empty(s.kept(), "never in the player reports' table") + + unknown, err := decisions.NewDecision(places.ReportID{15: 8}, decisions.OutcomeActed, "", decidedAt) + s.Require().NoError(err) + s.Require().ErrorIs(postgres_decision_store.ForPlaces(s.db).Save(ctx, unknown), decisions.ErrUnknownReport) +} diff --git a/apps/backend/internal/support/internal/decisions/usecases/decide_report_usecase/audit_decide_report/audit.go b/apps/backend/internal/support/internal/decisions/usecases/decide_report_usecase/audit_decide_report/audit.go index 22d11feed..a3762c0e5 100644 --- a/apps/backend/internal/support/internal/decisions/usecases/decide_report_usecase/audit_decide_report/audit.go +++ b/apps/backend/internal/support/internal/decisions/usecases/decide_report_usecase/audit_decide_report/audit.go @@ -2,30 +2,37 @@ package audit_decide_report import ( "context" + "fmt" "log/slog" "time" - "github.com/google/uuid" - "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/decisions/usecases/decide_report_usecase" ) -type UseCase interface { - Execute(ctx context.Context, in decide_report_usecase.In) (time.Time, error) +type Report interface { + comparable + fmt.Stringer +} + +type UseCase[R Report] interface { + Execute(ctx context.Context, in decide_report_usecase.In[R]) (time.Time, error) } -func New(inner UseCase, logger *slog.Logger) *Audited { - return &Audited{inner: inner, logger: logger} +// kind names the report decided in the log line: a player's or a place's. +func New[R Report](inner UseCase[R], kind string, logger *slog.Logger) *Audited[R] { + return &Audited[R]{inner: inner, kind: kind, logger: logger} } -type Audited struct { - inner UseCase +type Audited[R Report] struct { + inner UseCase[R] + kind string logger *slog.Logger } -func (a *Audited) Execute(ctx context.Context, in decide_report_usecase.In) (time.Time, error) { +func (a *Audited[R]) Execute(ctx context.Context, in decide_report_usecase.In[R]) (time.Time, error) { attributes := []any{ - slog.String("report", uuid.UUID(in.Report).String()), + slog.String("kind", a.kind), + slog.String("report", in.Report.String()), slog.String("outcome", string(in.Outcome)), slog.String("comment", in.Comment), } diff --git a/apps/backend/internal/support/internal/decisions/usecases/decide_report_usecase/audit_decide_report/audit_test.go b/apps/backend/internal/support/internal/decisions/usecases/decide_report_usecase/audit_decide_report/audit_test.go index 3861407d4..e310587ac 100644 --- a/apps/backend/internal/support/internal/decisions/usecases/decide_report_usecase/audit_decide_report/audit_test.go +++ b/apps/backend/internal/support/internal/decisions/usecases/decide_report_usecase/audit_decide_report/audit_test.go @@ -21,7 +21,7 @@ type stubUseCase struct { err error } -func (s stubUseCase) Execute(context.Context, decide_report_usecase.In) (time.Time, error) { +func (s stubUseCase) Execute(context.Context, decide_report_usecase.In[reports.ReportID]) (time.Time, error) { return time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC), s.err } @@ -29,8 +29,8 @@ func run(t *testing.T, inner stubUseCase) (string, error) { t.Helper() var logs bytes.Buffer - _, err := audit_decide_report.New(inner, slog.New(slog.NewTextHandler(&logs, nil))).Execute(t.Context(), - decide_report_usecase.In{Report: reports.ReportID{15: 1}, Outcome: decisions.OutcomeActed, Comment: "muted"}) + _, err := audit_decide_report.New[reports.ReportID](inner, "player", slog.New(slog.NewTextHandler(&logs, nil))).Execute(t.Context(), + decide_report_usecase.In[reports.ReportID]{Report: reports.ReportID{15: 1}, Outcome: decisions.OutcomeActed, Comment: "muted"}) return logs.String(), err } @@ -39,7 +39,7 @@ func TestEveryDecisionIsLogged(t *testing.T) { require.NoError(t, err) assert.Contains(t, logs, - `level=WARN msg="admin report decision" report=00000000-0000-0000-0000-000000000001 outcome=acted comment=muted`) + `level=WARN msg="admin report decision" kind=player report=00000000-0000-0000-0000-000000000001 outcome=acted comment=muted`) } func TestARefusedDecisionIsLoggedToo(t *testing.T) { @@ -48,5 +48,5 @@ func TestARefusedDecisionIsLoggedToo(t *testing.T) { logs, err := run(t, stubUseCase{err: cause}) require.ErrorIs(t, err, cause) - assert.Contains(t, logs, `level=WARN msg="admin report decision failed" report=00000000-0000-0000-0000-000000000001`) + assert.Contains(t, logs, `level=WARN msg="admin report decision failed" kind=player report=00000000-0000-0000-0000-000000000001`) } diff --git a/apps/backend/internal/support/internal/decisions/usecases/decide_report_usecase/usecase.go b/apps/backend/internal/support/internal/decisions/usecases/decide_report_usecase/usecase.go index 23ee9f5aa..d2793bc0b 100644 --- a/apps/backend/internal/support/internal/decisions/usecases/decide_report_usecase/usecase.go +++ b/apps/backend/internal/support/internal/decisions/usecases/decide_report_usecase/usecase.go @@ -7,29 +7,28 @@ import ( "github.com/raphoester/clickplanet.lol-backend/internal/shared/cptime" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/decisions" - "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/reports" ) -type Saver interface { - Save(ctx context.Context, decision decisions.Decision) error +type Saver[R comparable] interface { + Save(ctx context.Context, decision decisions.Decision[R]) error } -func New(saver Saver, clock cptime.Clock) *UseCase { - return &UseCase{saver: saver, clock: clock} +func New[R comparable](saver Saver[R], clock cptime.Clock) *UseCase[R] { + return &UseCase[R]{saver: saver, clock: clock} } -type UseCase struct { - saver Saver +type UseCase[R comparable] struct { + saver Saver[R] clock cptime.Clock } -type In struct { - Report reports.ReportID +type In[R comparable] struct { + Report R Outcome decisions.Outcome Comment string } -func (u *UseCase) Execute(ctx context.Context, in In) (time.Time, error) { +func (u *UseCase[R]) Execute(ctx context.Context, in In[R]) (time.Time, error) { decision, err := decisions.NewDecision(in.Report, in.Outcome, in.Comment, u.clock.Now()) if err != nil { return time.Time{}, fmt.Errorf("failed to decide the report: %w", err) diff --git a/apps/backend/internal/support/internal/decisions/usecases/decide_report_usecase/usecase_test.go b/apps/backend/internal/support/internal/decisions/usecases/decide_report_usecase/usecase_test.go index 389a525ec..7c0244e7d 100644 --- a/apps/backend/internal/support/internal/decisions/usecases/decide_report_usecase/usecase_test.go +++ b/apps/backend/internal/support/internal/decisions/usecases/decide_report_usecase/usecase_test.go @@ -18,11 +18,11 @@ import ( var now = time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) type recordedDecisions struct { - saved []decisions.Snapshot + saved []decisions.Snapshot[reports.ReportID] err error } -func (r *recordedDecisions) Save(_ context.Context, decision decisions.Decision) error { +func (r *recordedDecisions) Save(_ context.Context, decision decisions.Decision[reports.ReportID]) error { if r.err != nil { return r.err } @@ -30,22 +30,22 @@ func (r *recordedDecisions) Save(_ context.Context, decision decisions.Decision) return nil } -func decide(t *testing.T, saver *recordedDecisions, in decide_report_usecase.In) (time.Time, error) { +func decide(t *testing.T, saver *recordedDecisions, in decide_report_usecase.In[reports.ReportID]) (time.Time, error) { t.Helper() - return decide_report_usecase.New(saver, cptime.NewFixedClock(now)).Execute(t.Context(), in) //nolint:wrapcheck // the tests read the sentinel. + return decide_report_usecase.New[reports.ReportID](saver, cptime.NewFixedClock(now)).Execute(t.Context(), in) //nolint:wrapcheck // the tests read the sentinel. } func TestADecisionIsKeptAtTheTimeItWasMade(t *testing.T) { saver := &recordedDecisions{} - decidedAt, err := decide(t, saver, decide_report_usecase.In{ + decidedAt, err := decide(t, saver, decide_report_usecase.In[reports.ReportID]{ Report: reports.ReportID{15: 1}, Outcome: decisions.OutcomeActed, Comment: "muted", }) require.NoError(t, err) assert.Equal(t, now, decidedAt) - assert.Equal(t, []decisions.Snapshot{ + assert.Equal(t, []decisions.Snapshot[reports.ReportID]{ {Report: reports.ReportID{15: 1}, Outcome: decisions.OutcomeActed, Comment: "muted", At: now}, }, saver.saved) } @@ -53,7 +53,7 @@ func TestADecisionIsKeptAtTheTimeItWasMade(t *testing.T) { func TestADecisionThatBreaksARuleIsNotKept(t *testing.T) { saver := &recordedDecisions{} - _, err := decide(t, saver, decide_report_usecase.In{Report: reports.ReportID{15: 1}, Outcome: "banned"}) + _, err := decide(t, saver, decide_report_usecase.In[reports.ReportID]{Report: reports.ReportID{15: 1}, Outcome: "banned"}) require.ErrorIs(t, err, decisions.ErrUnknownOutcome) assert.Empty(t, saver.saved) @@ -62,7 +62,7 @@ func TestADecisionThatBreaksARuleIsNotKept(t *testing.T) { func TestADecisionThatCannotBeKeptFails(t *testing.T) { cause := errors.New("postgres is down") - _, err := decide(t, &recordedDecisions{err: cause}, decide_report_usecase.In{ + _, err := decide(t, &recordedDecisions{err: cause}, decide_report_usecase.In[reports.ReportID]{ Report: reports.ReportID{15: 1}, Outcome: decisions.OutcomeDismissed, }) diff --git a/apps/backend/internal/support/internal/migrations/20261020120000_place_reports.down.sql b/apps/backend/internal/support/internal/migrations/20261020120000_place_reports.down.sql new file mode 100644 index 000000000..821e0671f --- /dev/null +++ b/apps/backend/internal/support/internal/migrations/20261020120000_place_reports.down.sql @@ -0,0 +1,5 @@ +DROP TABLE place_report_decisions; + +DROP TABLE place_reports; + +DROP TYPE place_report_reason; diff --git a/apps/backend/internal/support/internal/migrations/20261020120000_place_reports.up.sql b/apps/backend/internal/support/internal/migrations/20261020120000_place_reports.up.sql new file mode 100644 index 000000000..e64585947 --- /dev/null +++ b/apps/backend/internal/support/internal/migrations/20261020120000_place_reports.up.sql @@ -0,0 +1,21 @@ +CREATE TYPE place_report_reason AS ENUM ('hate', 'sexual', 'other'); + +CREATE TABLE place_reports ( + id uuid PRIMARY KEY, + reporter_account_id uuid, + tile_id bigint NOT NULL CHECK (tile_id > 0), + reason place_report_reason NOT NULL, + text text NOT NULL, + created_at timestamptz NOT NULL +); + +CREATE INDEX place_reports_created_at ON place_reports (created_at); + +CREATE INDEX place_reports_reporter ON place_reports (reporter_account_id); + +CREATE TABLE place_report_decisions ( + report_id uuid PRIMARY KEY REFERENCES place_reports (id) ON DELETE CASCADE, + outcome report_outcome NOT NULL, + comment text NOT NULL, + decided_at timestamptz NOT NULL +); diff --git a/apps/backend/internal/support/internal/places/place.go b/apps/backend/internal/support/internal/places/place.go new file mode 100644 index 000000000..e0bfda282 --- /dev/null +++ b/apps/backend/internal/support/internal/places/place.go @@ -0,0 +1,153 @@ +package places + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/notes" +) + +type AccountID = notes.AccountID + +type ReportID uuid.UUID + +func (id ReportID) String() string { + return uuid.UUID(id).String() +} + +// A tile of the drawing reported. The support module does not know the map: the operator's tools check it. +type TileID uint32 + +var ( + ErrInvalidReportID = errors.New("not a report id") + ErrInvalidReport = errors.New("the report is invalid") + ErrUnknownReason = errors.New("not a reason to report a place") +) + +func ReportIDOf(value string) (ReportID, error) { + id, err := uuid.Parse(value) + if err != nil || id == uuid.Nil { + return ReportID{}, fmt.Errorf("%w: %q", ErrInvalidReportID, value) + } + return ReportID(id), nil +} + +type Reason string + +const ( + ReasonHate Reason = "hate" + ReasonSexual Reason = "sexual" + ReasonOther Reason = "other" +) + +func (r Reason) known() bool { + switch r { + case ReasonHate, ReasonSexual, ReasonOther: + return true + default: + return false + } +} + +// What a reporter says of a place. Plain data, a part of a report's snapshot: Restore checks it. +type Complaint struct { + Reason Reason + // Optional. + Text string +} + +func (c Complaint) checked() (Complaint, error) { + if !c.Reason.known() { + return Complaint{}, fmt.Errorf("%w: %q", ErrUnknownReason, string(c.Reason)) + } + if strings.TrimSpace(c.Text) == "" { + return Complaint{Reason: c.Reason}, nil + } + + text, err := notes.TextOf(c.Text) + if err != nil { + return Complaint{}, fmt.Errorf("failed to check the text of the report: %w", err) + } + return Complaint{Reason: c.Reason, Text: text}, nil +} + +// A report of a drawing on the map, by a tile of it. Who sent it goes with its account, and past the retention: the +// report stays, a notice the operator acted on or not. +type Report struct { + id ReportID + reporter AccountID + tile TileID + complaint Complaint + at time.Time +} + +func NewReport(id ReportID, reporter AccountID, tile TileID, complaint Complaint, at time.Time) (Report, error) { + if reporter == (AccountID{}) { + return Report{}, fmt.Errorf("%w: no reporter", ErrInvalidReport) + } + return Snapshot{ID: id, Reporter: reporter, Tile: tile, Complaint: complaint, At: at}.Restore() +} + +func (r Report) Anonymized() Report { + r.reporter = AccountID{} + return r +} + +func AllAnonymized(reports []Report) []Report { + anonymized := make([]Report, 0, len(reports)) + for _, report := range reports { + anonymized = append(anonymized, report.Anonymized()) + } + return anonymized +} + +func (r Report) TakeSnapshot() Snapshot { + return Snapshot{ID: r.id, Reporter: r.reporter, Tile: r.tile, Complaint: r.complaint, At: r.at} +} + +// The state of a Report, for a store. It holds no rule: Restore does. +type Snapshot struct { + ID ReportID + // Zero once anonymized. + Reporter AccountID + Tile TileID + Complaint Complaint + At time.Time +} + +func (s Snapshot) Restore() (Report, error) { + switch { + case s.ID == ReportID{}: + return Report{}, fmt.Errorf("%w: no id", ErrInvalidReport) + case s.Tile == 0: + return Report{}, fmt.Errorf("%w: no tile", ErrInvalidReport) + case s.At.IsZero(): + return Report{}, fmt.Errorf("%w: no time", ErrInvalidReport) + } + + complaint, err := s.Complaint.checked() + if err != nil { + return Report{}, err + } + + return Report{id: s.ID, reporter: s.Reporter, tile: s.Tile, complaint: complaint, at: s.At}, nil +} + +type IDProvider interface { + NewID() (ReportID, error) +} + +// A storage finds reports and saves them. It holds no rule: what changes a report is a method of Report. +type Storage interface { + // A report saved again replaces itself. All of them, or none. + Save(ctx context.Context, reports ...Report) error + // What an account filed, oldest first. + FiledBy(ctx context.Context, reporter AccountID) ([]Report, error) + // The reports filed before the cutoff that still have a reporter, oldest first, limit at most. + WithReporterBefore(ctx context.Context, cutoff time.Time, limit int) ([]Report, error) +} diff --git a/apps/backend/internal/support/internal/places/place_test.go b/apps/backend/internal/support/internal/places/place_test.go new file mode 100644 index 000000000..4a0da4492 --- /dev/null +++ b/apps/backend/internal/support/internal/places/place_test.go @@ -0,0 +1,97 @@ +package places_test + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/notes" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/places" +) + +var ( + ada = places.AccountID{15: 1} + report = places.ReportID{15: 1} + createdAt = time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC) +) + +func TestAReportKeepsWhoSentItWhereAndWhy(t *testing.T) { + kept, err := places.NewReport(report, ada, 42, places.Complaint{Reason: places.ReasonHate, Text: " a swastika \r\n"}, createdAt) + require.NoError(t, err) + + assert.Equal(t, places.Snapshot{ + ID: report, Reporter: ada, Tile: 42, Complaint: places.Complaint{Reason: places.ReasonHate, Text: "a swastika"}, + At: createdAt, + }, kept.TakeSnapshot()) +} + +func TestTheTextIsOptional(t *testing.T) { + kept, err := places.NewReport(report, ada, 42, places.Complaint{Reason: places.ReasonOther, Text: " \n "}, createdAt) + require.NoError(t, err) + + assert.Empty(t, kept.TakeSnapshot().Complaint.Text) +} + +func TestAnAnonymizedReportKeepsAllButItsReporter(t *testing.T) { + kept, err := places.NewReport(report, ada, 42, places.Complaint{Reason: places.ReasonSexual}, createdAt) + require.NoError(t, err) + + anonymized := places.AllAnonymized([]places.Report{kept}) + + require.Len(t, anonymized, 1) + assert.Equal(t, places.Snapshot{ + ID: report, Tile: 42, Complaint: places.Complaint{Reason: places.ReasonSexual}, At: createdAt, + }, anonymized[0].TakeSnapshot()) +} + +func TestAReportSentHasAReporterAndOneReadBackMayNot(t *testing.T) { + _, err := places.NewReport(report, places.AccountID{}, 42, places.Complaint{Reason: places.ReasonHate}, createdAt) + require.ErrorIs(t, err, places.ErrInvalidReport) + + _, err = places.Snapshot{ID: report, Tile: 42, Complaint: places.Complaint{Reason: places.ReasonHate}, At: createdAt}.Restore() + require.NoError(t, err) +} + +func TestASnapshotThatBreaksARuleRestoresNothing(t *testing.T) { + valid := places.Snapshot{ID: report, Reporter: ada, Tile: 42, Complaint: places.Complaint{Reason: places.ReasonHate}, At: createdAt} + + for name, broken := range map[string]struct { + change func(places.Snapshot) places.Snapshot + err error + }{ + "no id": {func(s places.Snapshot) places.Snapshot { s.ID = places.ReportID{}; return s }, places.ErrInvalidReport}, + "no tile": {func(s places.Snapshot) places.Snapshot { s.Tile = 0; return s }, places.ErrInvalidReport}, + "no time": {func(s places.Snapshot) places.Snapshot { s.At = time.Time{}; return s }, places.ErrInvalidReport}, + "no reason": { + func(s places.Snapshot) places.Snapshot { s.Complaint.Reason = ""; return s }, places.ErrUnknownReason, + }, + "a reason nobody named": { + func(s places.Snapshot) places.Snapshot { s.Complaint.Reason = "ugly"; return s }, places.ErrUnknownReason, + }, + "a text too long": { + func(s places.Snapshot) places.Snapshot { + s.Complaint.Text = strings.Repeat("a", notes.MaxTextLength+1) + return s + }, + notes.ErrInvalidText, + }, + } { + _, err := broken.change(valid).Restore() + require.ErrorIs(t, err, broken.err, name) + } +} + +func TestAReportIDIsAUUIDAndNothingElse(t *testing.T) { + id, err := places.ReportIDOf("00000000-0000-0000-0000-000000000001") + require.NoError(t, err) + assert.Equal(t, report, id) + assert.Equal(t, "00000000-0000-0000-0000-000000000001", id.String()) + + for _, value := range []string{"", "not-a-report", "00000000-0000-0000-0000-000000000000"} { + _, err := places.ReportIDOf(value) + require.ErrorIs(t, err, places.ErrInvalidReportID, value) + } +} diff --git a/apps/backend/internal/support/internal/places/postgres_place_report_store/store.go b/apps/backend/internal/support/internal/places/postgres_place_report_store/store.go new file mode 100644 index 000000000..8e7a721b1 --- /dev/null +++ b/apps/backend/internal/support/internal/places/postgres_place_report_store/store.go @@ -0,0 +1,113 @@ +package postgres_place_report_store + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/raphoester/clickplanet.lol-backend/internal/shared/cppg" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/places" +) + +func New(db cppg.QuerierBeginner) *Store { + return &Store{db: db} +} + +type Store struct { + db cppg.QuerierBeginner +} + +var _ places.Storage = (*Store)(nil) + +func (s *Store) Save(ctx context.Context, saved ...places.Report) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin saving place reports: %w", err) + } + defer func() { _ = tx.Rollback() }() + + for _, report := range saved { + kept := report.TakeSnapshot() + if _, err := tx.ExecContext(ctx, ` + INSERT INTO place_reports (id, reporter_account_id, tile_id, reason, text, created_at) + VALUES ($1, $2, $3, $4::place_report_reason, $5, $6) + ON CONFLICT (id) DO UPDATE SET + reporter_account_id = EXCLUDED.reporter_account_id, tile_id = EXCLUDED.tile_id, + reason = EXCLUDED.reason, text = EXCLUDED.text, created_at = EXCLUDED.created_at + `, + uuid.UUID(kept.ID), reporterOf(kept.Reporter), int64(kept.Tile), + string(kept.Complaint.Reason), kept.Complaint.Text, kept.At.UTC(), + ); err != nil { + return fmt.Errorf("failed to upsert a place report: %w", err) + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit the place reports saved: %w", err) + } + return nil +} + +func reporterOf(account places.AccountID) uuid.NullUUID { + return uuid.NullUUID{UUID: uuid.UUID(account), Valid: account != places.AccountID{}} +} + +func (s *Store) FiledBy(ctx context.Context, reporter places.AccountID) ([]places.Report, error) { + return s.restored(ctx, ` + SELECT id, reporter_account_id, tile_id, reason, text, created_at + FROM place_reports + WHERE reporter_account_id = $1 + ORDER BY created_at, id + `, uuid.UUID(reporter)) +} + +func (s *Store) WithReporterBefore(ctx context.Context, cutoff time.Time, limit int) ([]places.Report, error) { + return s.restored(ctx, ` + SELECT id, reporter_account_id, tile_id, reason, text, created_at + FROM place_reports + WHERE reporter_account_id IS NOT NULL AND created_at < $1 + ORDER BY created_at, id + LIMIT $2 + `, cutoff.UTC(), limit) +} + +func (s *Store) restored(ctx context.Context, query string, args ...any) ([]places.Report, error) { + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("failed to read place reports: %w", err) + } + defer func() { _ = rows.Close() }() + + var found []places.Report + for rows.Next() { + var ( + id uuid.UUID + reporter uuid.NullUUID + tile int64 + reason, text string + at time.Time + ) + if err := rows.Scan(&id, &reporter, &tile, &reason, &text, &at); err != nil { + return nil, fmt.Errorf("failed to read a place report: %w", err) + } + + snapshot := places.Snapshot{ + ID: places.ReportID(id), Tile: places.TileID(tile), At: at.UTC(), //nolint:gosec // a tile id, kept from a uint32. + Complaint: places.Complaint{Reason: places.Reason(reason), Text: text}, + } + if reporter.Valid { + snapshot.Reporter = places.AccountID(reporter.UUID) + } + report, err := snapshot.Restore() + if err != nil { + return nil, fmt.Errorf("failed to restore the place report %s: %w", id, err) + } + found = append(found, report) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("failed to read place reports: %w", err) + } + return found, nil +} diff --git a/apps/backend/internal/support/internal/places/postgres_place_report_store/store_test.go b/apps/backend/internal/support/internal/places/postgres_place_report_store/store_test.go new file mode 100644 index 000000000..5cf20e63c --- /dev/null +++ b/apps/backend/internal/support/internal/places/postgres_place_report_store/store_test.go @@ -0,0 +1,105 @@ +package postgres_place_report_store_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/suite" + + "github.com/raphoester/clickplanet.lol-backend/internal/shared/cppg" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/migrations" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/places" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/places/postgres_place_report_store" +) + +func TestRunSuite(t *testing.T) { + suite.Run(t, new(testSuite)) +} + +var ( + ada = places.AccountID{15: 1} + bob = places.AccountID{15: 2} + start = time.Date(2026, 10, 20, 12, 0, 0, 123_456_000, time.UTC) +) + +type testSuite struct { + suite.Suite + + db *cppg.Postgres + store *postgres_place_report_store.Store +} + +func (s *testSuite) SetupSuite() { + s.db = cppg.StartTestServer(s.T()).OpenSchema(s.T(), "support", migrations.FS) + s.store = postgres_place_report_store.New(s.db) +} + +func (s *testSuite) SetupTest() { + s.Require().NoError(s.db.Purge(s.T().Context())) +} + +func (s *testSuite) filed(n byte, reporter places.AccountID, reason places.Reason, at time.Time) places.Report { + report, err := places.NewReport(places.ReportID{15: n}, reporter, 42, places.Complaint{Reason: reason, Text: "a swastika"}, at) + s.Require().NoError(err) + s.Require().NoError(s.store.Save(s.T().Context(), report)) + return report +} + +func snapshots(reports []places.Report) []places.Snapshot { + kept := make([]places.Snapshot, 0, len(reports)) + for _, report := range reports { + kept = append(kept, report.TakeSnapshot()) + } + return kept +} + +func (s *testSuite) TestAReportIsReadBackAsItWasFiledByItsReporter() { + first := s.filed(1, ada, places.ReasonHate, start) + second := s.filed(2, ada, places.ReasonSexual, start.Add(time.Minute)) + s.filed(3, bob, places.ReasonOther, start) + + filed, err := s.store.FiledBy(s.T().Context(), ada) + s.Require().NoError(err) + + s.Equal(snapshots([]places.Report{first, second}), snapshots(filed)) +} + +func (s *testSuite) TestEveryReasonIsKept() { + for n, reason := range []places.Reason{places.ReasonHate, places.ReasonSexual, places.ReasonOther} { + s.filed(byte(n+1), ada, reason, start.Add(time.Duration(n)*time.Second)) + } + + filed, err := s.store.FiledBy(s.T().Context(), ada) + s.Require().NoError(err) + + s.Len(filed, 3) +} + +func (s *testSuite) TestAnAnonymizedReportIsKeptWithNoReporter() { + report := s.filed(1, ada, places.ReasonHate, start) + + s.Require().NoError(s.store.Save(s.T().Context(), report.Anonymized())) + + filed, err := s.store.FiledBy(s.T().Context(), ada) + s.Require().NoError(err) + s.Empty(filed) + + var reporter *string + s.Require().NoError(s.db.QueryRowContext(s.T().Context(), `SELECT reporter_account_id::text FROM place_reports`).Scan(&reporter)) + s.Nil(reporter, "the row stays, with no account") +} + +func (s *testSuite) TestTheReportsPastACutoffWithAReporterAreFoundOldestFirst() { + old := s.filed(1, ada, places.ReasonHate, start) + older := s.filed(2, bob, places.ReasonHate, start.Add(-time.Hour)) + s.filed(3, ada, places.ReasonHate, start.Add(time.Hour)) + s.Require().NoError(s.store.Save(s.T().Context(), s.filed(4, bob, places.ReasonOther, start.Add(-2*time.Hour)).Anonymized())) + + found, err := s.store.WithReporterBefore(s.T().Context(), start.Add(time.Minute), 10) + s.Require().NoError(err) + s.Equal(snapshots([]places.Report{older, old}), snapshots(found)) + + limited, err := s.store.WithReporterBefore(s.T().Context(), start.Add(time.Minute), 1) + s.Require().NoError(err) + s.Equal(snapshots([]places.Report{older}), snapshots(limited)) +} diff --git a/apps/backend/internal/support/internal/places/usecases/anonymize_place_reports_usecase/usecase.go b/apps/backend/internal/support/internal/places/usecases/anonymize_place_reports_usecase/usecase.go new file mode 100644 index 000000000..126ac03e5 --- /dev/null +++ b/apps/backend/internal/support/internal/places/usecases/anonymize_place_reports_usecase/usecase.go @@ -0,0 +1,32 @@ +package anonymize_place_reports_usecase + +import ( + "context" + "fmt" + + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/places" +) + +type Reports interface { + FiledBy(ctx context.Context, reporter places.AccountID) ([]places.Report, error) + Save(ctx context.Context, reports ...places.Report) error +} + +func New(kept Reports) *UseCase { + return &UseCase{reports: kept} +} + +type UseCase struct { + reports Reports +} + +func (u *UseCase) Execute(ctx context.Context, account places.AccountID) error { + filed, err := u.reports.FiledBy(ctx, account) + if err != nil { + return fmt.Errorf("failed to read the places the account reported: %w", err) + } + if err := u.reports.Save(ctx, places.AllAnonymized(filed)...); err != nil { + return fmt.Errorf("failed to anonymize the places the account reported: %w", err) + } + return nil +} diff --git a/apps/backend/internal/support/internal/places/usecases/anonymize_place_reports_usecase/usecase_test.go b/apps/backend/internal/support/internal/places/usecases/anonymize_place_reports_usecase/usecase_test.go new file mode 100644 index 000000000..051527243 --- /dev/null +++ b/apps/backend/internal/support/internal/places/usecases/anonymize_place_reports_usecase/usecase_test.go @@ -0,0 +1,54 @@ +package anonymize_place_reports_usecase_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/places" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/places/usecases/anonymize_place_reports_usecase" +) + +var ada = places.AccountID{15: 1} + +type stubReports struct { + filed []places.Report + saved []places.Snapshot + err error +} + +func (s *stubReports) FiledBy(context.Context, places.AccountID) ([]places.Report, error) { + return s.filed, s.err +} + +func (s *stubReports) Save(_ context.Context, reports ...places.Report) error { + for _, report := range reports { + s.saved = append(s.saved, report.TakeSnapshot()) + } + return nil +} + +func TestTheReportsAnAccountFiledLoseTheirReporterAndStay(t *testing.T) { + at := time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC) + filed, err := places.NewReport(places.ReportID{15: 1}, ada, 42, places.Complaint{Reason: places.ReasonHate}, at) + require.NoError(t, err) + reports := &stubReports{filed: []places.Report{filed}} + + require.NoError(t, anonymize_place_reports_usecase.New(reports).Execute(t.Context(), ada)) + + assert.Equal(t, []places.Snapshot{{ + ID: places.ReportID{15: 1}, Tile: 42, Complaint: places.Complaint{Reason: places.ReasonHate}, At: at, + }}, reports.saved) +} + +func TestAFailedReadAnonymizesNothing(t *testing.T) { + cause := errors.New("postgres is down") + reports := &stubReports{err: cause} + + require.ErrorIs(t, anonymize_place_reports_usecase.New(reports).Execute(t.Context(), ada), cause) + assert.Empty(t, reports.saved) +} diff --git a/apps/backend/internal/support/internal/places/usecases/prune_place_reports_usecase/usecase.go b/apps/backend/internal/support/internal/places/usecases/prune_place_reports_usecase/usecase.go new file mode 100644 index 000000000..539091309 --- /dev/null +++ b/apps/backend/internal/support/internal/places/usecases/prune_place_reports_usecase/usecase.go @@ -0,0 +1,53 @@ +package prune_place_reports_usecase + +import ( + "context" + "fmt" + "time" + + "github.com/raphoester/clickplanet.lol-backend/internal/shared/cptime" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/places" +) + +type Reports interface { + WithReporterBefore(ctx context.Context, cutoff time.Time, limit int) ([]places.Report, error) + Save(ctx context.Context, reports ...places.Report) error +} + +const ( + timeout = 30 * time.Second + batch = 500 +) + +func New(retention time.Duration, clock cptime.Clock, kept Reports) *UseCase { + return &UseCase{retention: retention, clock: clock, reports: kept} +} + +type UseCase struct { + retention time.Duration + clock cptime.Clock + reports Reports +} + +func (u *UseCase) Execute(ctx context.Context) (int64, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + cutoff := u.clock.Now().Add(-u.retention) + + var anonymized int64 + for { + old, err := u.reports.WithReporterBefore(ctx, cutoff, batch) + if err != nil { + return anonymized, fmt.Errorf("failed to read the place reports past retention: %w", err) + } + if len(old) == 0 { + return anonymized, nil + } + + if err := u.reports.Save(ctx, places.AllAnonymized(old)...); err != nil { + return anonymized, fmt.Errorf("failed to anonymize the place reports past retention: %w", err) + } + anonymized += int64(len(old)) + } +} diff --git a/apps/backend/internal/support/internal/places/usecases/prune_place_reports_usecase/usecase_test.go b/apps/backend/internal/support/internal/places/usecases/prune_place_reports_usecase/usecase_test.go new file mode 100644 index 000000000..840123ea9 --- /dev/null +++ b/apps/backend/internal/support/internal/places/usecases/prune_place_reports_usecase/usecase_test.go @@ -0,0 +1,62 @@ +package prune_place_reports_usecase_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/raphoester/clickplanet.lol-backend/internal/shared/cptime" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/places" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/places/usecases/prune_place_reports_usecase" +) + +var now = time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC) + +type stubReports struct { + kept []places.Report + cutoff time.Time +} + +func (s *stubReports) WithReporterBefore(_ context.Context, cutoff time.Time, limit int) ([]places.Report, error) { + s.cutoff = cutoff + var found []places.Report + for _, report := range s.kept { + if snapshot := report.TakeSnapshot(); snapshot.Reporter != (places.AccountID{}) && len(found) < limit { + found = append(found, report) + } + } + return found, nil +} + +func (s *stubReports) Save(_ context.Context, reports ...places.Report) error { + for _, saved := range reports { + for i, report := range s.kept { + if report.TakeSnapshot().ID == saved.TakeSnapshot().ID { + s.kept[i] = saved + } + } + } + return nil +} + +func TestEveryReportPastTheRetentionLosesItsReporter(t *testing.T) { + reports := &stubReports{} + for n := range 3 { + report, err := places.NewReport(places.ReportID{15: byte(n + 1)}, places.AccountID{15: 1}, 42, + places.Complaint{Reason: places.ReasonOther}, now.Add(-100*24*time.Hour)) + require.NoError(t, err) + reports.kept = append(reports.kept, report) + } + + anonymized, err := prune_place_reports_usecase.New(90*24*time.Hour, cptime.NewFixedClock(now), reports).Execute(t.Context()) + require.NoError(t, err) + + assert.Equal(t, int64(3), anonymized) + assert.Equal(t, now.Add(-90*24*time.Hour), reports.cutoff) + for _, report := range reports.kept { + assert.Equal(t, places.AccountID{}, report.TakeSnapshot().Reporter) + } +} diff --git a/apps/backend/internal/support/internal/places/usecases/report_place_usecase/usecase.go b/apps/backend/internal/support/internal/places/usecases/report_place_usecase/usecase.go new file mode 100644 index 000000000..cce5cc6ca --- /dev/null +++ b/apps/backend/internal/support/internal/places/usecases/report_place_usecase/usecase.go @@ -0,0 +1,52 @@ +package report_place_usecase + +import ( + "context" + "fmt" + "time" + + "github.com/raphoester/clickplanet.lol-backend/internal/shared/cptime" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/places" +) + +type Saver interface { + Save(ctx context.Context, reports ...places.Report) error +} + +type In struct { + Reporter places.AccountID + Tile places.TileID + Complaint places.Complaint +} + +const timeout = 5 * time.Second + +func New(ids places.IDProvider, clock cptime.Clock, saver Saver) *UseCase { + return &UseCase{ids: ids, clock: clock, saver: saver} +} + +type UseCase struct { + ids places.IDProvider + clock cptime.Clock + saver Saver +} + +func (u *UseCase) Execute(ctx context.Context, in In) error { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + id, err := u.ids.NewID() + if err != nil { + return fmt.Errorf("failed to draw a report id: %w", err) + } + + report, err := places.NewReport(id, in.Reporter, in.Tile, in.Complaint, u.clock.Now()) + if err != nil { + return fmt.Errorf("cannot take the report: %w", err) + } + + if err := u.saver.Save(ctx, report); err != nil { + return fmt.Errorf("failed to keep the report: %w", err) + } + return nil +} diff --git a/apps/backend/internal/support/internal/places/usecases/report_place_usecase/usecase_test.go b/apps/backend/internal/support/internal/places/usecases/report_place_usecase/usecase_test.go new file mode 100644 index 000000000..9b55b15c6 --- /dev/null +++ b/apps/backend/internal/support/internal/places/usecases/report_place_usecase/usecase_test.go @@ -0,0 +1,83 @@ +package report_place_usecase_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/raphoester/clickplanet.lol-backend/internal/shared/cptime" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/places" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/places/usecases/report_place_usecase" +) + +var ( + now = time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC) + ada = places.AccountID{15: 1} +) + +type sequentialIDs struct{ next byte } + +func (s *sequentialIDs) NewID() (places.ReportID, error) { + s.next++ + return places.ReportID{15: s.next}, nil +} + +type failingIDs struct{} + +func (failingIDs) NewID() (places.ReportID, error) { + return places.ReportID{}, errors.New("no entropy") +} + +type recorded struct { + saved []places.Snapshot + err error +} + +func (r *recorded) Save(_ context.Context, reports ...places.Report) error { + if r.err != nil { + return r.err + } + for _, report := range reports { + r.saved = append(r.saved, report.TakeSnapshot()) + } + return nil +} + +func report(ids places.IDProvider, saver *recorded, in report_place_usecase.In) error { + return report_place_usecase.New(ids, cptime.NewFixedClock(now), saver).Execute(context.Background(), in) //nolint:wrapcheck // the tests read the sentinel. +} + +func TestAReportIsKeptWithAnIDAndTheTimeItCameIn(t *testing.T) { + saver := &recorded{} + + require.NoError(t, report(&sequentialIDs{}, saver, report_place_usecase.In{ + Reporter: ada, Tile: 42, Complaint: places.Complaint{Reason: places.ReasonHate, Text: "a swastika"}, + })) + + assert.Equal(t, []places.Snapshot{{ + ID: places.ReportID{15: 1}, Reporter: ada, Tile: 42, + Complaint: places.Complaint{Reason: places.ReasonHate, Text: "a swastika"}, At: now, + }}, saver.saved) +} + +func TestAReportThatBreaksARuleIsNotKept(t *testing.T) { + saver := &recorded{} + + err := report(&sequentialIDs{}, saver, report_place_usecase.In{Reporter: ada, Tile: 42, Complaint: places.Complaint{Reason: "ugly"}}) + + require.ErrorIs(t, err, places.ErrUnknownReason) + assert.Empty(t, saver.saved) +} + +func TestAReportWithNoIDOrNoStoreFails(t *testing.T) { + in := report_place_usecase.In{Reporter: ada, Tile: 42, Complaint: places.Complaint{Reason: places.ReasonOther}} + + require.Error(t, report(failingIDs{}, &recorded{}, in)) + + cause := errors.New("postgres is down") + require.ErrorIs(t, report(&sequentialIDs{}, &recorded{err: cause}, in), cause) +} diff --git a/apps/backend/internal/support/internal/places/uuid_id_provider/provider.go b/apps/backend/internal/support/internal/places/uuid_id_provider/provider.go new file mode 100644 index 000000000..8b49befc4 --- /dev/null +++ b/apps/backend/internal/support/internal/places/uuid_id_provider/provider.go @@ -0,0 +1,21 @@ +package uuid_id_provider + +import ( + "fmt" + + "github.com/google/uuid" + + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/places" +) + +type Provider struct{} + +var _ places.IDProvider = Provider{} + +func (Provider) NewID() (places.ReportID, error) { + id, err := uuid.NewV7() + if err != nil { + return places.ReportID{}, fmt.Errorf("failed to generate a uuidv7: %w", err) + } + return places.ReportID(id), nil +} diff --git a/apps/backend/internal/support/internal/places/uuid_id_provider/provider_test.go b/apps/backend/internal/support/internal/places/uuid_id_provider/provider_test.go new file mode 100644 index 000000000..ed22a286b --- /dev/null +++ b/apps/backend/internal/support/internal/places/uuid_id_provider/provider_test.go @@ -0,0 +1,21 @@ +package uuid_id_provider_test + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/places/uuid_id_provider" +) + +func TestIDsAreDistinctVersion7UUIDs(t *testing.T) { + first, err := uuid_id_provider.Provider{}.NewID() + require.NoError(t, err) + second, err := uuid_id_provider.Provider{}.NewID() + require.NoError(t, err) + + assert.Equal(t, uuid.Version(7), uuid.UUID(first).Version()) + assert.NotEqual(t, first, second) +} diff --git a/apps/backend/internal/support/internal/reports/report.go b/apps/backend/internal/support/internal/reports/report.go index fc3efe5f1..4b75c872e 100644 --- a/apps/backend/internal/support/internal/reports/report.go +++ b/apps/backend/internal/support/internal/reports/report.go @@ -20,6 +20,10 @@ type ReportID uuid.UUID var ErrInvalidReportID = errors.New("not a report id") +func (id ReportID) String() string { + return uuid.UUID(id).String() +} + func ReportIDOf(value string) (ReportID, error) { id, err := uuid.Parse(value) if err != nil || id == uuid.Nil { diff --git a/apps/backend/internal/support/internal/supportv1controller/decide_place_report_handler/handler.go b/apps/backend/internal/support/internal/supportv1controller/decide_place_report_handler/handler.go new file mode 100644 index 000000000..eba3608a6 --- /dev/null +++ b/apps/backend/internal/support/internal/supportv1controller/decide_place_report_handler/handler.go @@ -0,0 +1,55 @@ +package decide_place_report_handler + +import ( + "context" + "errors" + "time" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/timestamppb" + + supportv1 "github.com/raphoester/clickplanet.lol-backend/generated/proto/support/v1" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/decisions" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/decisions/usecases/decide_report_usecase" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/notes" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/places" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller/reportoutcome" +) + +type UseCase interface { + Execute(ctx context.Context, in decide_report_usecase.In[places.ReportID]) (time.Time, error) +} + +func New(useCase UseCase) DecidePlaceReportHandler { + return DecidePlaceReportHandler{useCase: useCase} +} + +type DecidePlaceReportHandler struct { + useCase UseCase +} + +func (h DecidePlaceReportHandler) DecidePlaceReport( + ctx context.Context, + req *connect.Request[supportv1.DecidePlaceReportRequest], +) (*connect.Response[supportv1.DecidePlaceReportResponse], error) { + report, err := places.ReportIDOf(req.Msg.GetReportId()) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, places.ErrInvalidReportID) + } + + decidedAt, err := h.useCase.Execute(ctx, decide_report_usecase.In[places.ReportID]{ + Report: report, Outcome: reportoutcome.Of(req.Msg.GetOutcome()), Comment: req.Msg.GetComment(), + }) + switch { + case errors.Is(err, decisions.ErrUnknownOutcome): + return nil, connect.NewError(connect.CodeInvalidArgument, decisions.ErrUnknownOutcome) + case errors.Is(err, notes.ErrInvalidText): + return nil, connect.NewError(connect.CodeInvalidArgument, notes.ErrInvalidText) + case errors.Is(err, decisions.ErrUnknownReport): + return nil, connect.NewError(connect.CodeNotFound, decisions.ErrUnknownReport) + case err != nil: + return nil, err //nolint:wrapcheck // the error net answers what is not the caller's fault. + } + + return connect.NewResponse(&supportv1.DecidePlaceReportResponse{DecidedAt: timestamppb.New(decidedAt)}), nil +} diff --git a/apps/backend/internal/support/internal/supportv1controller/decide_place_report_handler/handler_test.go b/apps/backend/internal/support/internal/supportv1controller/decide_place_report_handler/handler_test.go new file mode 100644 index 000000000..71d6213e6 --- /dev/null +++ b/apps/backend/internal/support/internal/supportv1controller/decide_place_report_handler/handler_test.go @@ -0,0 +1,80 @@ +package decide_place_report_handler_test + +import ( + "context" + "fmt" + "testing" + "time" + + "connectrpc.com/connect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + supportv1 "github.com/raphoester/clickplanet.lol-backend/generated/proto/support/v1" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/decisions" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/decisions/usecases/decide_report_usecase" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/notes" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/places" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller/decide_place_report_handler" +) + +type stubUseCase struct { + in *decide_report_usecase.In[places.ReportID] + decidedAt time.Time + err error +} + +func (s *stubUseCase) Execute(_ context.Context, in decide_report_usecase.In[places.ReportID]) (time.Time, error) { + s.in = &in + return s.decidedAt, s.err +} + +const reportID = "00000000-0000-0000-0000-000000000001" + +func decide(t *testing.T, useCase *stubUseCase, req *supportv1.DecidePlaceReportRequest) (*supportv1.DecidePlaceReportResponse, error) { + t.Helper() + + res, err := decide_place_report_handler.New(useCase).DecidePlaceReport(t.Context(), connect.NewRequest(req)) + if err != nil { + return nil, fmt.Errorf("decision refused: %w", err) + } + return res.Msg, nil +} + +func TestTheDecisionReachesTheUseCaseAndWhenComesBack(t *testing.T) { + at := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) + useCase := &stubUseCase{decidedAt: at} + + res, err := decide(t, useCase, &supportv1.DecidePlaceReportRequest{ + ReportId: reportID, Outcome: supportv1.ReportOutcome_REPORT_OUTCOME_DISMISSED, Comment: "a duel", + }) + require.NoError(t, err) + + assert.Equal(t, &decide_report_usecase.In[places.ReportID]{ + Report: places.ReportID{15: 1}, Outcome: decisions.OutcomeDismissed, Comment: "a duel", + }, useCase.in) + assert.Equal(t, at, res.GetDecidedAt().AsTime()) +} + +func TestAnIDThatIsNotAReportIsRefusedBeforeTheUseCase(t *testing.T) { + useCase := &stubUseCase{} + + _, err := decide(t, useCase, &supportv1.DecidePlaceReportRequest{ + ReportId: "not-a-report", Outcome: supportv1.ReportOutcome_REPORT_OUTCOME_ACTED, + }) + + assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err)) + assert.Nil(t, useCase.in) +} + +func TestErrorsMapToTheirCodes(t *testing.T) { + for err, code := range map[error]connect.Code{ + fmt.Errorf("refused: %w", decisions.ErrUnknownOutcome): connect.CodeInvalidArgument, + fmt.Errorf("refused: %w", notes.ErrInvalidText): connect.CodeInvalidArgument, + fmt.Errorf("refused: %w", decisions.ErrUnknownReport): connect.CodeNotFound, + assert.AnError: connect.CodeUnknown, + } { + _, got := decide(t, &stubUseCase{err: err}, &supportv1.DecidePlaceReportRequest{ReportId: reportID}) + assert.Equal(t, code, connect.CodeOf(got), err.Error()) + } +} diff --git a/apps/backend/internal/support/internal/supportv1controller/decide_report_handler/handler.go b/apps/backend/internal/support/internal/supportv1controller/decide_report_handler/handler.go index e0b289a1c..28db6dc7b 100644 --- a/apps/backend/internal/support/internal/supportv1controller/decide_report_handler/handler.go +++ b/apps/backend/internal/support/internal/supportv1controller/decide_report_handler/handler.go @@ -13,10 +13,11 @@ import ( "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/decisions/usecases/decide_report_usecase" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/notes" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/reports" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller/reportoutcome" ) type UseCase interface { - Execute(ctx context.Context, in decide_report_usecase.In) (time.Time, error) + Execute(ctx context.Context, in decide_report_usecase.In[reports.ReportID]) (time.Time, error) } func New(useCase UseCase) DecideReportHandler { @@ -27,11 +28,6 @@ type DecideReportHandler struct { useCase UseCase } -var outcomes = map[supportv1.ReportOutcome]decisions.Outcome{ - supportv1.ReportOutcome_REPORT_OUTCOME_ACTED: decisions.OutcomeActed, - supportv1.ReportOutcome_REPORT_OUTCOME_DISMISSED: decisions.OutcomeDismissed, -} - func (h DecideReportHandler) DecideReport( ctx context.Context, req *connect.Request[supportv1.DecideReportRequest], @@ -41,8 +37,8 @@ func (h DecideReportHandler) DecideReport( return nil, connect.NewError(connect.CodeInvalidArgument, reports.ErrInvalidReportID) } - decidedAt, err := h.useCase.Execute(ctx, decide_report_usecase.In{ - Report: report, Outcome: outcomes[req.Msg.GetOutcome()], Comment: req.Msg.GetComment(), + decidedAt, err := h.useCase.Execute(ctx, decide_report_usecase.In[reports.ReportID]{ + Report: report, Outcome: reportoutcome.Of(req.Msg.GetOutcome()), Comment: req.Msg.GetComment(), }) switch { case errors.Is(err, decisions.ErrUnknownOutcome): diff --git a/apps/backend/internal/support/internal/supportv1controller/decide_report_handler/handler_test.go b/apps/backend/internal/support/internal/supportv1controller/decide_report_handler/handler_test.go index 8814a596b..1329ebf72 100644 --- a/apps/backend/internal/support/internal/supportv1controller/decide_report_handler/handler_test.go +++ b/apps/backend/internal/support/internal/supportv1controller/decide_report_handler/handler_test.go @@ -19,12 +19,12 @@ import ( ) type stubUseCase struct { - in *decide_report_usecase.In + in *decide_report_usecase.In[reports.ReportID] decidedAt time.Time err error } -func (s *stubUseCase) Execute(_ context.Context, in decide_report_usecase.In) (time.Time, error) { +func (s *stubUseCase) Execute(_ context.Context, in decide_report_usecase.In[reports.ReportID]) (time.Time, error) { s.in = &in return s.decidedAt, s.err } @@ -50,28 +50,12 @@ func TestTheDecisionReachesTheUseCaseAndWhenComesBack(t *testing.T) { }) require.NoError(t, err) - assert.Equal(t, &decide_report_usecase.In{ + assert.Equal(t, &decide_report_usecase.In[reports.ReportID]{ Report: reports.ReportID{15: 1}, Outcome: decisions.OutcomeDismissed, Comment: "a duel", }, useCase.in) assert.Equal(t, at, res.GetDecidedAt().AsTime()) } -func TestEveryOutcomeTheProtoNamesIsAnOutcome(t *testing.T) { - for value, name := range supportv1.ReportOutcome_name { - outcome := supportv1.ReportOutcome(value) - if outcome == supportv1.ReportOutcome_REPORT_OUTCOME_UNSPECIFIED { - continue - } - useCase := &stubUseCase{} - - _, err := decide(t, useCase, &supportv1.DecideReportRequest{ReportId: reportID, Outcome: outcome}) - require.NoError(t, err, name) - - _, err = decisions.NewDecision(useCase.in.Report, useCase.in.Outcome, "", time.Now()) - require.NoError(t, err, name) - } -} - func TestAnIDThatIsNotAReportIsRefusedBeforeTheUseCase(t *testing.T) { useCase := &stubUseCase{} diff --git a/apps/backend/internal/support/internal/supportv1controller/interceptors.go b/apps/backend/internal/support/internal/supportv1controller/interceptors.go index 11ebdad9d..7c6a60fa4 100644 --- a/apps/backend/internal/support/internal/supportv1controller/interceptors.go +++ b/apps/backend/internal/support/internal/supportv1controller/interceptors.go @@ -39,6 +39,7 @@ func NewSessionInterceptor(verifier cpconnect.SessionVerifier, clock cptime.Cloc cpconnect.Attested(supportv1connect.SupportServiceSuggestFeatureProcedure), cpconnect.Attested(supportv1connect.SupportServiceReportBugProcedure), cpconnect.Attested(supportv1connect.SupportServiceReportPlayerProcedure), + cpconnect.Attested(supportv1connect.SupportServiceReportPlaceProcedure), cpconnect.Attested(supportv1connect.SupportServiceCreateUploadProcedure), ) } @@ -48,6 +49,7 @@ func NewRateLimitInterceptor(limiter cpconnect.Limiter) connect.Interceptor { supportv1connect.SupportServiceSuggestFeatureProcedure, supportv1connect.SupportServiceReportBugProcedure, supportv1connect.SupportServiceReportPlayerProcedure, + supportv1connect.SupportServiceReportPlaceProcedure, ) } diff --git a/apps/backend/internal/support/internal/supportv1controller/list_place_reports_handler/handler.go b/apps/backend/internal/support/internal/supportv1controller/list_place_reports_handler/handler.go new file mode 100644 index 000000000..52e6f4d15 --- /dev/null +++ b/apps/backend/internal/support/internal/supportv1controller/list_place_reports_handler/handler.go @@ -0,0 +1,38 @@ +package list_place_reports_handler + +import ( + "context" + "time" + + "connectrpc.com/connect" + + supportv1 "github.com/raphoester/clickplanet.lol-backend/generated/proto/support/v1" +) + +type Query interface { + PlaceReports(ctx context.Context, since time.Time, limit uint32, openOnly bool) (*supportv1.ListPlaceReportsResponse, error) +} + +func New(query Query) ListPlaceReportsHandler { + return ListPlaceReportsHandler{query: query} +} + +type ListPlaceReportsHandler struct { + query Query +} + +func (h ListPlaceReportsHandler) ListPlaceReports( + ctx context.Context, + req *connect.Request[supportv1.ListPlaceReportsRequest], +) (*connect.Response[supportv1.ListPlaceReportsResponse], error) { + var since time.Time + if asked := req.Msg.GetSince(); asked != nil { + since = asked.AsTime() + } + + reports, err := h.query.PlaceReports(ctx, since, req.Msg.GetLimit(), req.Msg.GetOpenOnly()) + if err != nil { + return nil, err //nolint:wrapcheck // the error net answers it. + } + return connect.NewResponse(reports), nil +} diff --git a/apps/backend/internal/support/internal/supportv1controller/list_place_reports_handler/handler_test.go b/apps/backend/internal/support/internal/supportv1controller/list_place_reports_handler/handler_test.go new file mode 100644 index 000000000..bfabcf69c --- /dev/null +++ b/apps/backend/internal/support/internal/supportv1controller/list_place_reports_handler/handler_test.go @@ -0,0 +1,71 @@ +package list_place_reports_handler_test + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "connectrpc.com/connect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + supportv1 "github.com/raphoester/clickplanet.lol-backend/generated/proto/support/v1" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller/list_place_reports_handler" +) + +type stubQuery struct { + answer *supportv1.ListPlaceReportsResponse + err error + + since time.Time + limit uint32 + openOnly bool +} + +func (s *stubQuery) PlaceReports( + _ context.Context, since time.Time, limit uint32, openOnly bool, +) (*supportv1.ListPlaceReportsResponse, error) { + s.since, s.limit, s.openOnly = since, limit, openOnly + return s.answer, s.err +} + +func list(t *testing.T, query *stubQuery, req *supportv1.ListPlaceReportsRequest) (*supportv1.ListPlaceReportsResponse, error) { + t.Helper() + + res, err := list_place_reports_handler.New(query).ListPlaceReports(t.Context(), connect.NewRequest(req)) + if err != nil { + return nil, fmt.Errorf("ListPlaceReports failed: %w", err) + } + return res.Msg, nil +} + +func TestTheWindowReachesTheQueryAndItsAnswerComesBack(t *testing.T) { + since := time.Date(2026, 10, 11, 12, 0, 0, 0, time.UTC) + query := &stubQuery{answer: &supportv1.ListPlaceReportsResponse{Reports: []*supportv1.PlaceReport{{Text: "a swastika"}}}} + + answer, err := list(t, query, &supportv1.ListPlaceReportsRequest{Since: timestamppb.New(since), Limit: 20, OpenOnly: true}) + require.NoError(t, err) + + assert.True(t, since.Equal(query.since)) + assert.Equal(t, uint32(20), query.limit) + assert.True(t, query.openOnly) + assert.Same(t, query.answer, answer) +} + +func TestNoSinceIsEveryReport(t *testing.T) { + query := &stubQuery{answer: &supportv1.ListPlaceReportsResponse{}} + + _, err := list(t, query, &supportv1.ListPlaceReportsRequest{}) + require.NoError(t, err) + + assert.True(t, query.since.IsZero()) +} + +func TestAFailedReadIsLeftToTheErrorNet(t *testing.T) { + _, err := list(t, &stubQuery{err: errors.New("postgres is down")}, &supportv1.ListPlaceReportsRequest{}) + + assert.ErrorContains(t, err, "postgres is down") +} diff --git a/apps/backend/internal/support/internal/supportv1controller/list_place_reports_handler/place_reports_query/query_postgres.go b/apps/backend/internal/support/internal/supportv1controller/list_place_reports_handler/place_reports_query/query_postgres.go new file mode 100644 index 000000000..8b1534a5a --- /dev/null +++ b/apps/backend/internal/support/internal/supportv1controller/list_place_reports_handler/place_reports_query/query_postgres.go @@ -0,0 +1,167 @@ +package place_reports_query + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "google.golang.org/protobuf/types/known/timestamppb" + + playerv1 "github.com/raphoester/clickplanet.lol-backend/generated/proto/player/v1" + supportv1 "github.com/raphoester/clickplanet.lol-backend/generated/proto/support/v1" + "github.com/raphoester/clickplanet.lol-backend/internal/shared/cpcolls" + "github.com/raphoester/clickplanet.lol-backend/internal/shared/cppg" + "github.com/raphoester/clickplanet.lol-backend/internal/shared/cpsession" +) + +type Authors interface { + Authors(ctx context.Context, accounts []cpsession.AccountID) (map[cpsession.AccountID]*playerv1.Author, error) +} + +const ( + DefaultLimit = 100 + MaxLimit = 500 +) + +var ( + ErrUnknownReason = errors.New("a stored place report has a reason the wire cannot carry") + ErrUnknownOutcome = errors.New("a stored decision has an outcome the wire cannot carry") +) + +var reasons = map[string]supportv1.PlaceReportReason{ + "hate": supportv1.PlaceReportReason_PLACE_REPORT_REASON_HATE, + "sexual": supportv1.PlaceReportReason_PLACE_REPORT_REASON_SEXUAL, + "other": supportv1.PlaceReportReason_PLACE_REPORT_REASON_OTHER, +} + +var outcomes = map[string]supportv1.ReportOutcome{ + "acted": supportv1.ReportOutcome_REPORT_OUTCOME_ACTED, + "dismissed": supportv1.ReportOutcome_REPORT_OUTCOME_DISMISSED, +} + +func NewPostgresQuery(db cppg.Querier, authors Authors) *PostgresQuery { + return &PostgresQuery{db: db, authors: authors} +} + +type PostgresQuery struct { + db cppg.Querier + authors Authors +} + +const placeReports = ` + SELECT place_reports.id, reporter_account_id, tile_id, reason, text, created_at, + place_report_decisions.outcome, place_report_decisions.comment, place_report_decisions.decided_at + FROM place_reports + LEFT JOIN place_report_decisions ON place_report_decisions.report_id = place_reports.id + WHERE ($1::timestamptz IS NULL OR created_at >= $1) AND (NOT $3 OR place_report_decisions.report_id IS NULL) + ORDER BY created_at DESC, place_reports.id DESC + LIMIT $2 +` + +type row struct { + report *supportv1.PlaceReport + reporter cpsession.AccountID +} + +// A zero since is every report kept; a zero limit is DefaultLimit, and none passes MaxLimit. +func (q *PostgresQuery) PlaceReports( + ctx context.Context, since time.Time, limit uint32, openOnly bool, +) (*supportv1.ListPlaceReportsResponse, error) { + rows, err := q.rows(ctx, since, limit, openOnly) + if err != nil { + return nil, err + } + + named, err := q.authors.Authors(ctx, reporters(rows)) + if err != nil { + return nil, fmt.Errorf("failed to read who sent the reports: %w", err) + } + + answer := &supportv1.ListPlaceReportsResponse{Reports: make([]*supportv1.PlaceReport, 0, len(rows))} + for _, row := range rows { + row.report.ReporterName = named[row.reporter].GetName() + answer.Reports = append(answer.Reports, row.report) + } + return answer, nil +} + +func (q *PostgresQuery) rows(ctx context.Context, since time.Time, limit uint32, openOnly bool) ([]row, error) { + found, err := q.db.QueryContext(ctx, placeReports, + sql.NullTime{Time: since.UTC(), Valid: !since.IsZero()}, limitOf(limit), openOnly) + if err != nil { + return nil, fmt.Errorf("failed to read the place reports: %w", err) + } + defer func() { _ = found.Close() }() + + var read []row + for found.Next() { + var ( + report = &supportv1.PlaceReport{} + reporter uuid.NullUUID + tile int64 + reason string + at time.Time + outcome, comment sql.NullString + decidedAt sql.NullTime + ) + if err := found.Scan(&report.Id, &reporter, &tile, &reason, &report.Text, &at, + &outcome, &comment, &decidedAt); err != nil { + return nil, fmt.Errorf("failed to read a place report: %w", err) + } + + known, ok := reasons[reason] + if !ok { + return nil, fmt.Errorf("%w: %q", ErrUnknownReason, reason) + } + report.Reason = known + report.TileId = uint32(tile) //nolint:gosec // a tile id, kept from a uint32. + report.CreatedAt = timestamppb.New(at) + if outcome.Valid { + decided, ok := outcomes[outcome.String] + if !ok { + return nil, fmt.Errorf("%w: %q", ErrUnknownOutcome, outcome.String) + } + report.Decision = &supportv1.ReportDecision{ + Outcome: decided, Comment: comment.String, DecidedAt: timestamppb.New(decidedAt.Time), + } + } + + each := row{report: report} + if reporter.Valid { + report.ReporterAccountId = reporter.UUID.String() + each.reporter = cpsession.AccountID(reporter.UUID) + } + read = append(read, each) + } + if err := found.Err(); err != nil { + return nil, fmt.Errorf("failed to read the place reports: %w", err) + } + return read, nil +} + +func reporters(rows []row) []cpsession.AccountID { + seen := cpcolls.NewSet[cpsession.AccountID]() + var accounts []cpsession.AccountID + for _, row := range rows { + if row.reporter == cpsession.NoAccount || seen.Contains(row.reporter) { + continue + } + seen.Add(row.reporter) + accounts = append(accounts, row.reporter) + } + return accounts +} + +func limitOf(asked uint32) int { + switch { + case asked == 0: + return DefaultLimit + case asked > MaxLimit: + return MaxLimit + default: + return int(asked) + } +} diff --git a/apps/backend/internal/support/internal/supportv1controller/list_place_reports_handler/place_reports_query/query_postgres_test.go b/apps/backend/internal/support/internal/supportv1controller/list_place_reports_handler/place_reports_query/query_postgres_test.go new file mode 100644 index 000000000..8a3c1a533 --- /dev/null +++ b/apps/backend/internal/support/internal/supportv1controller/list_place_reports_handler/place_reports_query/query_postgres_test.go @@ -0,0 +1,164 @@ +package place_reports_query_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/suite" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" + + playerv1 "github.com/raphoester/clickplanet.lol-backend/generated/proto/player/v1" + supportv1 "github.com/raphoester/clickplanet.lol-backend/generated/proto/support/v1" + "github.com/raphoester/clickplanet.lol-backend/internal/shared/cppg" + "github.com/raphoester/clickplanet.lol-backend/internal/shared/cpsession" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/decisions" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/decisions/postgres_decision_store" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/migrations" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/places" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/places/postgres_place_report_store" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller/list_place_reports_handler/place_reports_query" +) + +func TestRunSuite(t *testing.T) { + suite.Run(t, new(testSuite)) +} + +type names struct { + shown map[cpsession.AccountID]string + err error + asked [][]cpsession.AccountID +} + +func (n *names) Authors(_ context.Context, accounts []cpsession.AccountID) (map[cpsession.AccountID]*playerv1.Author, error) { + n.asked = append(n.asked, accounts) + if n.err != nil { + return nil, n.err + } + + found := map[cpsession.AccountID]*playerv1.Author{} + for _, account := range accounts { + if name, named := n.shown[account]; named { + found[account] = &playerv1.Author{AccountId: account.String(), Name: name} + } + } + return found, nil +} + +var ( + ada = places.AccountID{15: 1} + start = time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC) +) + +type testSuite struct { + suite.Suite + + db *cppg.Postgres + reports *postgres_place_report_store.Store + names *names + query *place_reports_query.PostgresQuery +} + +func (s *testSuite) SetupSuite() { + s.db = cppg.StartTestServer(s.T()).OpenSchema(s.T(), "support", migrations.FS) + s.reports = postgres_place_report_store.New(s.db) +} + +func (s *testSuite) SetupTest() { + s.Require().NoError(s.db.Purge(s.T().Context())) + s.names = &names{shown: map[cpsession.AccountID]string{ada: "Ada_L"}} + s.query = place_reports_query.NewPostgresQuery(s.db, s.names) +} + +func (s *testSuite) reported(n byte, reason places.Reason, text string, at time.Time) *supportv1.PlaceReport { + id := places.ReportID{15: n} + report, err := places.NewReport(id, ada, places.TileID(40+uint32(n)), places.Complaint{Reason: reason, Text: text}, at) + s.Require().NoError(err) + s.Require().NoError(s.reports.Save(s.T().Context(), report)) + + return &supportv1.PlaceReport{ + Id: uuid.UUID(id).String(), ReporterAccountId: ada.String(), ReporterName: "Ada_L", + TileId: 40 + uint32(n), Text: text, CreatedAt: timestamppb.New(at), + } +} + +func (s *testSuite) decided(n byte, outcome decisions.Outcome, comment string, at time.Time) { + decision, err := decisions.NewDecision(places.ReportID{15: n}, outcome, comment, at) + s.Require().NoError(err) + s.Require().NoError(postgres_decision_store.ForPlaces(s.db).Save(s.T().Context(), decision)) +} + +func (s *testSuite) read(openOnly bool) []*supportv1.PlaceReport { + answer, err := s.query.PlaceReports(s.T().Context(), time.Time{}, 0, openOnly) + s.Require().NoError(err) + return answer.GetReports() +} + +func (s *testSuite) same(want []*supportv1.PlaceReport, got []*supportv1.PlaceReport) { + s.True(proto.Equal(&supportv1.ListPlaceReportsResponse{Reports: want}, &supportv1.ListPlaceReportsResponse{Reports: got}), + "want %v, got %v", want, got) +} + +func (s *testSuite) TestEveryReportComesNewestFirstWithItsReporterNamedAndItsDecision() { + first := s.reported(1, places.ReasonHate, "a swastika", start) + first.Reason = supportv1.PlaceReportReason_PLACE_REPORT_REASON_HATE + second := s.reported(2, places.ReasonSexual, "", start.Add(time.Minute)) + second.Reason = supportv1.PlaceReportReason_PLACE_REPORT_REASON_SEXUAL + s.decided(1, decisions.OutcomeActed, "restored the area", start.Add(time.Hour)) + first.Decision = &supportv1.ReportDecision{ + Outcome: supportv1.ReportOutcome_REPORT_OUTCOME_ACTED, Comment: "restored the area", + DecidedAt: timestamppb.New(start.Add(time.Hour)), + } + + s.same([]*supportv1.PlaceReport{second, first}, s.read(false)) + s.Equal([][]cpsession.AccountID{{ada}}, s.names.asked, "one ask, each account once") +} + +func (s *testSuite) TestTheOpenReportsLeaveOutTheDecidedOnes() { + s.reported(1, places.ReasonHate, "", start) + open := s.reported(2, places.ReasonOther, "", start.Add(time.Minute)) + open.Reason = supportv1.PlaceReportReason_PLACE_REPORT_REASON_OTHER + s.decided(1, decisions.OutcomeDismissed, "", start.Add(time.Hour)) + + s.same([]*supportv1.PlaceReport{open}, s.read(true)) +} + +func (s *testSuite) TestAnAnonymizedReportNamesNobody() { + report, err := places.NewReport(places.ReportID{15: 1}, ada, 42, places.Complaint{Reason: places.ReasonHate}, start) + s.Require().NoError(err) + s.Require().NoError(s.reports.Save(s.T().Context(), report.Anonymized())) + + got := s.read(false) + + s.Require().Len(got, 1) + s.Empty(got[0].GetReporterAccountId()) + s.Empty(got[0].GetReporterName()) + s.Equal([][]cpsession.AccountID{nil}, s.names.asked) +} + +func (s *testSuite) TestAReasonTheDatabaseNamesAndTheWireCannotCarryRefusesTheRead() { + _, err := s.db.ExecContext(s.T().Context(), `ALTER TYPE place_report_reason ADD VALUE IF NOT EXISTS 'boring'`) + s.Require().NoError(err) + _, err = s.db.ExecContext(s.T().Context(), ` + INSERT INTO place_reports (id, reporter_account_id, tile_id, reason, text, created_at) + VALUES ($1, $2, 42, 'boring', '', $3) + `, uuid.New(), uuid.UUID(ada), start) + s.Require().NoError(err) + + _, err = s.query.PlaceReports(s.T().Context(), time.Time{}, 0, false) + + s.Require().ErrorIs(err, place_reports_query.ErrUnknownReason) +} + +func (s *testSuite) TestReportsNobodyCouldNameAreRefused() { + s.reported(1, places.ReasonOther, "", start) + cause := errors.New("the player module is down") + s.names.err = cause + + _, err := s.query.PlaceReports(s.T().Context(), time.Time{}, 0, false) + + s.Require().ErrorIs(err, cause) +} diff --git a/apps/backend/internal/support/internal/supportv1controller/list_reports_handler/reports_query/query_postgres_test.go b/apps/backend/internal/support/internal/supportv1controller/list_reports_handler/reports_query/query_postgres_test.go index c32a762d5..74d78c154 100644 --- a/apps/backend/internal/support/internal/supportv1controller/list_reports_handler/reports_query/query_postgres_test.go +++ b/apps/backend/internal/support/internal/supportv1controller/list_reports_handler/reports_query/query_postgres_test.go @@ -205,7 +205,7 @@ func (s *testSuite) TestAFailedReadIsAnError() { func (s *testSuite) decided(n byte, outcome decisions.Outcome, comment string, at time.Time) { decision, err := decisions.NewDecision(reports.ReportID{15: n}, outcome, comment, at) s.Require().NoError(err) - s.Require().NoError(postgres_decision_store.New(s.db).Save(s.T().Context(), decision)) + s.Require().NoError(postgres_decision_store.ForReports(s.db).Save(s.T().Context(), decision)) } func (s *testSuite) TestADecidedReportCarriesItsDecision() { diff --git a/apps/backend/internal/support/internal/supportv1controller/report_place_handler/handler.go b/apps/backend/internal/support/internal/supportv1controller/report_place_handler/handler.go new file mode 100644 index 000000000..527944027 --- /dev/null +++ b/apps/backend/internal/support/internal/supportv1controller/report_place_handler/handler.go @@ -0,0 +1,60 @@ +package report_place_handler + +import ( + "context" + "errors" + + "connectrpc.com/connect" + + supportv1 "github.com/raphoester/clickplanet.lol-backend/generated/proto/support/v1" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/notes" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/places" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/places/usecases/report_place_usecase" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller/caller" +) + +type UseCase interface { + Execute(ctx context.Context, in report_place_usecase.In) error +} + +func New(useCase UseCase) ReportPlaceHandler { + return ReportPlaceHandler{useCase: useCase} +} + +type ReportPlaceHandler struct { + useCase UseCase +} + +var reasons = map[supportv1.PlaceReportReason]places.Reason{ + supportv1.PlaceReportReason_PLACE_REPORT_REASON_HATE: places.ReasonHate, + supportv1.PlaceReportReason_PLACE_REPORT_REASON_SEXUAL: places.ReasonSexual, + supportv1.PlaceReportReason_PLACE_REPORT_REASON_OTHER: places.ReasonOther, +} + +func (h ReportPlaceHandler) ReportPlace( + ctx context.Context, + req *connect.Request[supportv1.ReportPlaceRequest], +) (*connect.Response[supportv1.ReportPlaceResponse], error) { + account, err := caller.AccountOf(ctx) + if err != nil { + return nil, err //nolint:wrapcheck // already the connect error the caller reads. + } + + err = h.useCase.Execute(ctx, report_place_usecase.In{ + Reporter: account, + Tile: places.TileID(req.Msg.GetTileId()), + Complaint: places.Complaint{Reason: reasons[req.Msg.GetReason()], Text: req.Msg.GetText()}, + }) + switch { + case errors.Is(err, places.ErrUnknownReason): + return nil, connect.NewError(connect.CodeInvalidArgument, places.ErrUnknownReason) + case errors.Is(err, notes.ErrInvalidText): + return nil, connect.NewError(connect.CodeInvalidArgument, notes.ErrInvalidText) + case errors.Is(err, places.ErrInvalidReport): + return nil, connect.NewError(connect.CodeInvalidArgument, places.ErrInvalidReport) + case err != nil: + return nil, err //nolint:wrapcheck // the error net answers what is not the caller's fault. + } + + return connect.NewResponse(&supportv1.ReportPlaceResponse{}), nil +} diff --git a/apps/backend/internal/support/internal/supportv1controller/report_place_handler/handler_test.go b/apps/backend/internal/support/internal/supportv1controller/report_place_handler/handler_test.go new file mode 100644 index 000000000..45dcf1054 --- /dev/null +++ b/apps/backend/internal/support/internal/supportv1controller/report_place_handler/handler_test.go @@ -0,0 +1,93 @@ +package report_place_handler_test + +import ( + "context" + "fmt" + "testing" + "time" + + "connectrpc.com/connect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + supportv1 "github.com/raphoester/clickplanet.lol-backend/generated/proto/support/v1" + "github.com/raphoester/clickplanet.lol-backend/internal/shared/cpctx" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/notes" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/places" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/places/usecases/report_place_usecase" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller/report_place_handler" +) + +var ada = places.AccountID{15: 1} + +type stubUseCase struct { + err error + asked []report_place_usecase.In +} + +func (s *stubUseCase) Execute(_ context.Context, in report_place_usecase.In) error { + s.asked = append(s.asked, in) + return s.err +} + +func report(ctx context.Context, useCase *stubUseCase, msg *supportv1.ReportPlaceRequest) error { + if _, err := report_place_handler.New(useCase).ReportPlace(ctx, connect.NewRequest(msg)); err != nil { + return fmt.Errorf("ReportPlace failed: %w", err) + } + return nil +} + +func asAda(t *testing.T) context.Context { + t.Helper() + + return cpctx.AddAccountToContext(t.Context(), ada.String()) +} + +func TestTheReportReachesTheUseCaseUnderTheCallersAccount(t *testing.T) { + useCase := &stubUseCase{} + + err := report(asAda(t), useCase, &supportv1.ReportPlaceRequest{ + TileId: 42, Reason: supportv1.PlaceReportReason_PLACE_REPORT_REASON_HATE, Text: "a swastika", + }) + require.NoError(t, err) + + assert.Equal(t, []report_place_usecase.In{{ + Reporter: ada, Tile: 42, Complaint: places.Complaint{Reason: places.ReasonHate, Text: "a swastika"}, + }}, useCase.asked) +} + +func TestEveryReasonTheProtoNamesIsAReason(t *testing.T) { + for value, name := range supportv1.PlaceReportReason_name { + reason := supportv1.PlaceReportReason(value) + if reason == supportv1.PlaceReportReason_PLACE_REPORT_REASON_UNSPECIFIED { + continue + } + useCase := &stubUseCase{} + + require.NoError(t, report(asAda(t), useCase, &supportv1.ReportPlaceRequest{TileId: 42, Reason: reason}), name) + + _, err := places.NewReport(places.ReportID{15: 1}, ada, 42, useCase.asked[0].Complaint, time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC)) + require.NoError(t, err, name) + } +} + +func TestNoAccountIsUnauthenticatedAndNothingIsAsked(t *testing.T) { + useCase := &stubUseCase{} + + err := report(t.Context(), useCase, &supportv1.ReportPlaceRequest{TileId: 42}) + + assert.Equal(t, connect.CodeUnauthenticated, connect.CodeOf(err)) + assert.Empty(t, useCase.asked) +} + +func TestErrorsMapToTheirCodes(t *testing.T) { + for err, code := range map[error]connect.Code{ + fmt.Errorf("refused: %w", places.ErrUnknownReason): connect.CodeInvalidArgument, + fmt.Errorf("refused: %w", notes.ErrInvalidText): connect.CodeInvalidArgument, + fmt.Errorf("refused: %w", places.ErrInvalidReport): connect.CodeInvalidArgument, + assert.AnError: connect.CodeUnknown, + } { + got := report(asAda(t), &stubUseCase{err: err}, &supportv1.ReportPlaceRequest{TileId: 42}) + assert.Equal(t, code, connect.CodeOf(got), err.Error()) + } +} diff --git a/apps/backend/internal/support/internal/supportv1controller/reportoutcome/outcome.go b/apps/backend/internal/support/internal/supportv1controller/reportoutcome/outcome.go new file mode 100644 index 000000000..bc1c80527 --- /dev/null +++ b/apps/backend/internal/support/internal/supportv1controller/reportoutcome/outcome.go @@ -0,0 +1,16 @@ +package reportoutcome + +import ( + supportv1 "github.com/raphoester/clickplanet.lol-backend/generated/proto/support/v1" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/decisions" +) + +var outcomes = map[supportv1.ReportOutcome]decisions.Outcome{ + supportv1.ReportOutcome_REPORT_OUTCOME_ACTED: decisions.OutcomeActed, + supportv1.ReportOutcome_REPORT_OUTCOME_DISMISSED: decisions.OutcomeDismissed, +} + +// The wire's outcome as the decision's. One the wire does not name is none, which a decision refuses. +func Of(outcome supportv1.ReportOutcome) decisions.Outcome { + return outcomes[outcome] +} diff --git a/apps/backend/internal/support/internal/supportv1controller/reportoutcome/outcome_test.go b/apps/backend/internal/support/internal/supportv1controller/reportoutcome/outcome_test.go new file mode 100644 index 000000000..f07d5117d --- /dev/null +++ b/apps/backend/internal/support/internal/supportv1controller/reportoutcome/outcome_test.go @@ -0,0 +1,28 @@ +package reportoutcome_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + supportv1 "github.com/raphoester/clickplanet.lol-backend/generated/proto/support/v1" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/decisions" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/reports" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller/reportoutcome" +) + +func TestEveryOutcomeTheProtoNamesIsAnOutcomeOfADecision(t *testing.T) { + for value, name := range supportv1.ReportOutcome_name { + outcome := reportoutcome.Of(supportv1.ReportOutcome(value)) + _, err := decisions.NewDecision(reports.ReportID{15: 1}, outcome, "", time.Now()) + + if supportv1.ReportOutcome(value) == supportv1.ReportOutcome_REPORT_OUTCOME_UNSPECIFIED { + require.ErrorIs(t, err, decisions.ErrUnknownOutcome, name) + continue + } + require.NoError(t, err, name) + assert.NotEmpty(t, outcome, name) + } +} diff --git a/apps/backend/internal/support/internal/supportv1controller/support_service.go b/apps/backend/internal/support/internal/supportv1controller/support_service.go index e5a27dfdb..5fe6fd164 100644 --- a/apps/backend/internal/support/internal/supportv1controller/support_service.go +++ b/apps/backend/internal/support/internal/supportv1controller/support_service.go @@ -3,11 +3,14 @@ package supportv1controller import ( "github.com/raphoester/clickplanet.lol-backend/generated/proto/support/v1/supportv1connect" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller/create_upload_handler" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller/decide_place_report_handler" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller/decide_report_handler" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller/get_attachment_rules_handler" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller/list_notes_handler" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller/list_place_reports_handler" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller/list_reports_handler" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller/report_bug_handler" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller/report_place_handler" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller/report_player_handler" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller/suggest_feature_handler" ) @@ -16,6 +19,7 @@ type SupportService struct { suggest_feature_handler.SuggestFeatureHandler report_bug_handler.ReportBugHandler report_player_handler.ReportPlayerHandler + report_place_handler.ReportPlaceHandler get_attachment_rules_handler.GetAttachmentRulesHandler create_upload_handler.CreateUploadHandler } @@ -26,6 +30,8 @@ type AdminService struct { list_notes_handler.ListNotesHandler list_reports_handler.ListReportsHandler decide_report_handler.DecideReportHandler + list_place_reports_handler.ListPlaceReportsHandler + decide_place_report_handler.DecidePlaceReportHandler } var _ supportv1connect.AdminServiceHandler = AdminService{} diff --git a/apps/backend/internal/support/module.go b/apps/backend/internal/support/module.go index 535fe22e9..9e431ea80 100644 --- a/apps/backend/internal/support/module.go +++ b/apps/backend/internal/support/module.go @@ -32,6 +32,13 @@ import ( "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/notes/usecases/report_bug_usecase" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/notes/usecases/suggest_feature_usecase" note_ids "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/notes/uuid_id_provider" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/places" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/places/postgres_place_report_store" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/places/usecases/anonymize_place_reports_usecase" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/places/usecases/prune_place_reports_usecase" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/places/usecases/report_place_usecase" + place_ids "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/places/uuid_id_provider" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/reports" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/reports/postgres_report_store" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/reports/usecases/forget_reports_usecase" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/reports/usecases/prune_reports_usecase" @@ -41,14 +48,18 @@ import ( "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/subscribers/log_subscriber" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller/create_upload_handler" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller/decide_place_report_handler" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller/decide_report_handler" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller/get_attachment_rules_handler" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller/list_notes_handler" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller/list_notes_handler/notes_query" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller/list_place_reports_handler" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller/list_place_reports_handler/place_reports_query" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller/list_reports_handler" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller/list_reports_handler/reports_query" report_authors "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller/list_reports_handler/reports_query/rpc_player_authors" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller/report_bug_handler" + "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller/report_place_handler" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller/report_player_handler" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/supportv1controller/suggest_feature_handler" "github.com/raphoester/clickplanet.lol-backend/internal/support/internal/uploads" @@ -92,6 +103,7 @@ func build(ctx context.Context, config Config, props cpbootstrap.Props) error { noteStore := postgres_note_store.New(db) reportStore := postgres_report_store.New(db) + placeStore := postgres_place_report_store.New(db) uploadStore := postgres_upload_store.New(db) bucket, err := s3_attachment_bucket.New(config.Attachments.bucket(), config.Attachments.lifetimes(), clock, @@ -107,6 +119,8 @@ func build(ctx context.Context, config Config, props cpbootstrap.Props) error { props.Logger.With(slog.String("pruned", "notes"))) reportPrune := log_prune.New(prune_reports_usecase.New(config.Retention, clock, reportStore), props.Logger.With(slog.String("pruned", "reports"))) + placePrune := log_prune.New(prune_place_reports_usecase.New(config.Retention, clock, placeStore), + props.Logger.With(slog.String("pruned", "place reports"))) unsentDiscard := log_prune.New(discard_unsent_usecase.New(config.Attachments.KeepUnsent, clock, uploadStore, bucket), props.Logger.With(slog.String("pruned", "unsent files"))) @@ -124,9 +138,17 @@ func build(ctx context.Context, config Config, props cpbootstrap.Props) error { return fmt.Errorf("failed to subscribe to auth.v1.AccountDeleted: %w", err) } + placeDeletions, err := cpbootstrap.Subscribe(props.Events, "support-places-accounts", accountDeletedBuffer, + log_subscriber.New(account_deleted_subscriber.New(anonymize_place_reports_usecase.New(placeStore)), props.Logger)) + if err != nil { + _ = db.Close() + return fmt.Errorf("failed to subscribe to auth.v1.AccountDeleted: %w", err) + } + // Not a closer: closers run before the runners stop, and the runners use the pool. props.Runners.Add(cppg.CloseAfter(db, props.Logger, - prune_usecase.NewRunner(config.PruneInterval, notePrune, reportPrune, unsentDiscard), noteDeletions, reportDeletions)) + prune_usecase.NewRunner(config.PruneInterval, notePrune, reportPrune, placePrune, unsentDiscard), + noteDeletions, reportDeletions, placeDeletions)) limiter := cpratelimit.New("support-limiter", config.RateLimiter, clock) props.Runners.Add(limiter) @@ -142,6 +164,7 @@ func build(ctx context.Context, config Config, props cpbootstrap.Props) error { report_bug_usecase.New(note_ids.Provider{}, clock, rules, noteStore, bucket)), ReportPlayerHandler: report_player_handler.New(report_player_usecase.New( report_ids.Provider{}, clock, reportStore)), + ReportPlaceHandler: report_place_handler.New(report_place_usecase.New(place_ids.Provider{}, clock, placeStore)), GetAttachmentRulesHandler: get_attachment_rules_handler.New(rules, shelf), CreateUploadHandler: create_upload_handler.New(create_upload_usecase.New(note_ids.Provider{}, rules, shelf, bucket)), } @@ -157,11 +180,15 @@ func build(ctx context.Context, config Config, props cpbootstrap.Props) error { return fmt.Errorf("failed to mount support.v1.SupportService: %w", err) } + authors := report_authors.New(player) adminService := supportv1controller.AdminService{ ListNotesHandler: list_notes_handler.New(notes_query.NewPostgresQuery(db, bucket)), - ListReportsHandler: list_reports_handler.New(reports_query.NewPostgresQuery(db, report_authors.New(player))), - DecideReportHandler: decide_report_handler.New(audit_decide_report.New( - decide_report_usecase.New(postgres_decision_store.New(db), clock), props.Logger)), + ListReportsHandler: list_reports_handler.New(reports_query.NewPostgresQuery(db, authors)), + DecideReportHandler: decide_report_handler.New(audit_decide_report.New[reports.ReportID]( + decide_report_usecase.New(postgres_decision_store.ForReports(db), clock), "player", props.Logger)), + ListPlaceReportsHandler: list_place_reports_handler.New(place_reports_query.NewPostgresQuery(db, authors)), + DecidePlaceReportHandler: decide_place_report_handler.New(audit_decide_report.New[places.ReportID]( + decide_report_usecase.New(postgres_decision_store.ForPlaces(db), clock), "place", props.Logger)), } if err := props.AdminRPC.Mount(func(options ...connect.HandlerOption) (string, http.Handler) { return supportv1connect.NewAdminServiceHandler(adminService, options...) diff --git a/apps/backoffice/src/fakeAdmin.ts b/apps/backoffice/src/fakeAdmin.ts index 244f6a647..44260d8c9 100644 --- a/apps/backoffice/src/fakeAdmin.ts +++ b/apps/backoffice/src/fakeAdmin.ts @@ -15,6 +15,8 @@ import { import { BanPlayerRequest, BanPlayerResponse, + ClearAreaRequest, + ClearAreaResponse, FindPlayersRequest, FindPlayersResponse, GetReplayRequest, @@ -28,6 +30,8 @@ import { Player, ReassignCountryRequest, ReassignCountryResponse, + RestoreAreaRequest, + RestoreAreaResponse, RevertPlayerRequest, RevertPlayerResponse, TopPlayersRequest, @@ -197,6 +201,14 @@ export class FakeAdmin implements ChatAdmin, PlanetAdmin, PlayerAdmin, SupportAd })) } + restoreArea(request: PartialMessage): Promise { + return this.answer("restoreArea", request, new RestoreAreaResponse({tiles: 37, changed: 12})) + } + + clearArea(request: PartialMessage): Promise { + return this.answer("clearArea", request, new ClearAreaResponse({tiles: 37, owned: 20})) + } + reconcileTitles(request: PartialMessage): Promise { return this.answer("reconcileTitles", request, new ReconcileTitlesResponse({granted: 12, revoked: 3})) } diff --git a/apps/backoffice/src/gen/grpc/planet/v1/admin_connect.ts b/apps/backoffice/src/gen/grpc/planet/v1/admin_connect.ts index a0773eca7..8901d9898 100644 --- a/apps/backoffice/src/gen/grpc/planet/v1/admin_connect.ts +++ b/apps/backoffice/src/gen/grpc/planet/v1/admin_connect.ts @@ -3,7 +3,7 @@ /* eslint-disable */ // @ts-nocheck -import { BanPlayerRequest, BanPlayerResponse, FindPlayersRequest, FindPlayersResponse, GetReplayRequest, GetReplayResponse, GrantChargesRequest, GrantChargesResponse, InspectPlayerRequest, InspectPlayerResponse, PaintRandomTilesRequest, PaintRandomTilesResponse, ReassignCountryRequest, ReassignCountryResponse, RevertPlayerRequest, RevertPlayerResponse, TopPlayersRequest, TopPlayersResponse, UnbanPlayerRequest, UnbanPlayerResponse } from "./admin_pb.js"; +import { BanPlayerRequest, BanPlayerResponse, ClearAreaRequest, ClearAreaResponse, FindPlayersRequest, FindPlayersResponse, GetReplayRequest, GetReplayResponse, GrantChargesRequest, GrantChargesResponse, InspectPlayerRequest, InspectPlayerResponse, PaintRandomTilesRequest, PaintRandomTilesResponse, ReassignCountryRequest, ReassignCountryResponse, RestoreAreaRequest, RestoreAreaResponse, RevertPlayerRequest, RevertPlayerResponse, TopPlayersRequest, TopPlayersResponse, UnbanPlayerRequest, UnbanPlayerResponse } from "./admin_pb.js"; import { MethodKind } from "@bufbuild/protobuf"; /** @@ -104,6 +104,28 @@ export const AdminService = { O: GetReplayResponse, kind: MethodKind.Unary, }, + /** + * Who painted the area around a tile since a time, and what putting the area back as it was then changes. Restores it unless dry_run. + * + * @generated from rpc planet.v1.AdminService.RestoreArea + */ + restoreArea: { + name: "RestoreArea", + I: RestoreAreaRequest, + O: RestoreAreaResponse, + kind: MethodKind.Unary, + }, + /** + * Gives every tile of the area around a tile to nobody, for a drawing older than the ledger keeps. Clears it unless dry_run. + * + * @generated from rpc planet.v1.AdminService.ClearArea + */ + clearArea: { + name: "ClearArea", + I: ClearAreaRequest, + O: ClearAreaResponse, + kind: MethodKind.Unary, + }, } } as const; diff --git a/apps/backoffice/src/gen/grpc/planet/v1/admin_pb.ts b/apps/backoffice/src/gen/grpc/planet/v1/admin_pb.ts index 714230dd9..be3dec82d 100644 --- a/apps/backoffice/src/gen/grpc/planet/v1/admin_pb.ts +++ b/apps/backoffice/src/gen/grpc/planet/v1/admin_pb.ts @@ -7,6 +7,248 @@ import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialM import { Duration, Message, proto3, Timestamp } from "@bufbuild/protobuf"; import { ChargesHeld, GetMapResponse, PlanetEvent } from "./planet_pb.js"; +/** + * @generated from message planet.v1.RestoreAreaRequest + */ +export class RestoreAreaRequest extends Message { + /** + * @generated from field: uint32 tile_id = 1; + */ + tileId = 0; + + /** + * The area is every tile within this many tile spacings of the tile, as a bomb's blast is. 1 to 50. + * + * @generated from field: uint32 rings = 2; + */ + rings = 0; + + /** + * Each tile of the area goes back to what it held then. FailedPrecondition past the ledger's retention. + * + * @generated from field: google.protobuf.Timestamp before = 3; + */ + before?: Timestamp; + + /** + * @generated from field: bool dry_run = 4; + */ + dryRun = false; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "planet.v1.RestoreAreaRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "tile_id", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 2, name: "rings", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 3, name: "before", kind: "message", T: Timestamp }, + { no: 4, name: "dry_run", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): RestoreAreaRequest { + return new RestoreAreaRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): RestoreAreaRequest { + return new RestoreAreaRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): RestoreAreaRequest { + return new RestoreAreaRequest().fromJsonString(jsonString, options); + } + + static equals(a: RestoreAreaRequest | PlainMessage | undefined, b: RestoreAreaRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(RestoreAreaRequest, a, b); + } +} + +/** + * @generated from message planet.v1.RestoreAreaResponse + */ +export class RestoreAreaResponse extends Message { + /** + * In the area. + * + * @generated from field: uint32 tiles = 1; + */ + tiles = 0; + + /** + * That a restore changes: a tile changed since before and not back to what it held then. + * + * @generated from field: uint32 changed = 2; + */ + changed = 0; + + /** + * That changed: a tile somebody took during the restore stays theirs. + * + * @generated from field: uint32 restored = 3; + */ + restored = 0; + + /** + * Who took tiles of the area since before, most takes first, 50 at most. Tiles and takes count the area alone. + * + * @generated from field: repeated planet.v1.Player painters = 4; + */ + painters: Player[] = []; + + /** + * The oldest before the ledger can rewind to. + * + * @generated from field: google.protobuf.Timestamp kept_since = 5; + */ + keptSince?: Timestamp; + + /** + * @generated from field: uint32 painters_total = 6; + */ + paintersTotal = 0; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "planet.v1.RestoreAreaResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "tiles", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 2, name: "changed", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 3, name: "restored", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 4, name: "painters", kind: "message", T: Player, repeated: true }, + { no: 5, name: "kept_since", kind: "message", T: Timestamp }, + { no: 6, name: "painters_total", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): RestoreAreaResponse { + return new RestoreAreaResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): RestoreAreaResponse { + return new RestoreAreaResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): RestoreAreaResponse { + return new RestoreAreaResponse().fromJsonString(jsonString, options); + } + + static equals(a: RestoreAreaResponse | PlainMessage | undefined, b: RestoreAreaResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(RestoreAreaResponse, a, b); + } +} + +/** + * @generated from message planet.v1.ClearAreaRequest + */ +export class ClearAreaRequest extends Message { + /** + * @generated from field: uint32 tile_id = 1; + */ + tileId = 0; + + /** + * As RestoreArea's. + * + * @generated from field: uint32 rings = 2; + */ + rings = 0; + + /** + * @generated from field: bool dry_run = 3; + */ + dryRun = false; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "planet.v1.ClearAreaRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "tile_id", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 2, name: "rings", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 3, name: "dry_run", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ClearAreaRequest { + return new ClearAreaRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ClearAreaRequest { + return new ClearAreaRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ClearAreaRequest { + return new ClearAreaRequest().fromJsonString(jsonString, options); + } + + static equals(a: ClearAreaRequest | PlainMessage | undefined, b: ClearAreaRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(ClearAreaRequest, a, b); + } +} + +/** + * @generated from message planet.v1.ClearAreaResponse + */ +export class ClearAreaResponse extends Message { + /** + * In the area. + * + * @generated from field: uint32 tiles = 1; + */ + tiles = 0; + + /** + * That a flag holds. + * + * @generated from field: uint32 owned = 2; + */ + owned = 0; + + /** + * That were given to nobody: a tile somebody took during the clear stays theirs. + * + * @generated from field: uint32 cleared = 3; + */ + cleared = 0; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "planet.v1.ClearAreaResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "tiles", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 2, name: "owned", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 3, name: "cleared", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ClearAreaResponse { + return new ClearAreaResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ClearAreaResponse { + return new ClearAreaResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ClearAreaResponse { + return new ClearAreaResponse().fromJsonString(jsonString, options); + } + + static equals(a: ClearAreaResponse | PlainMessage | undefined, b: ClearAreaResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(ClearAreaResponse, a, b); + } +} + /** * @generated from message planet.v1.GetReplayRequest */ diff --git a/apps/backoffice/src/gen/grpc/support/v1/admin_connect.ts b/apps/backoffice/src/gen/grpc/support/v1/admin_connect.ts index 2cd0129bb..e7677a118 100644 --- a/apps/backoffice/src/gen/grpc/support/v1/admin_connect.ts +++ b/apps/backoffice/src/gen/grpc/support/v1/admin_connect.ts @@ -3,7 +3,7 @@ /* eslint-disable */ // @ts-nocheck -import { DecideReportRequest, DecideReportResponse, ListNotesRequest, ListNotesResponse, ListReportsRequest, ListReportsResponse } from "./admin_pb.js"; +import { DecidePlaceReportRequest, DecidePlaceReportResponse, DecideReportRequest, DecideReportResponse, ListNotesRequest, ListNotesResponse, ListPlaceReportsRequest, ListPlaceReportsResponse, ListReportsRequest, ListReportsResponse } from "./admin_pb.js"; import { MethodKind } from "@bufbuild/protobuf"; /** @@ -43,6 +43,28 @@ export const AdminService = { O: DecideReportResponse, kind: MethodKind.Unary, }, + /** + * Newest first, as ListReports. + * + * @generated from rpc support.v1.AdminService.ListPlaceReports + */ + listPlaceReports: { + name: "ListPlaceReports", + I: ListPlaceReportsRequest, + O: ListPlaceReportsResponse, + kind: MethodKind.Unary, + }, + /** + * As DecideReport, for a report of a place. + * + * @generated from rpc support.v1.AdminService.DecidePlaceReport + */ + decidePlaceReport: { + name: "DecidePlaceReport", + I: DecidePlaceReportRequest, + O: DecidePlaceReportResponse, + kind: MethodKind.Unary, + }, } } as const; diff --git a/apps/backoffice/src/gen/grpc/support/v1/admin_pb.ts b/apps/backoffice/src/gen/grpc/support/v1/admin_pb.ts index ce82c4f3c..5e4f7bda2 100644 --- a/apps/backoffice/src/gen/grpc/support/v1/admin_pb.ts +++ b/apps/backoffice/src/gen/grpc/support/v1/admin_pb.ts @@ -5,7 +5,7 @@ import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf"; import { Message, proto3, protoInt64, Timestamp } from "@bufbuild/protobuf"; -import { PageContext, ReportReason } from "./support_pb.js"; +import { PageContext, PlaceReportReason, ReportReason } from "./support_pb.js"; /** * @generated from enum support.v1.NoteKind @@ -615,3 +615,268 @@ export class DecideReportResponse extends Message { } } +/** + * @generated from message support.v1.ListPlaceReportsRequest + */ +export class ListPlaceReportsRequest extends Message { + /** + * Unset is every report kept. + * + * @generated from field: google.protobuf.Timestamp since = 1; + */ + since?: Timestamp; + + /** + * Unset is 100; at most 500. + * + * @generated from field: uint32 limit = 2; + */ + limit = 0; + + /** + * Only the reports nobody decided yet. + * + * @generated from field: bool open_only = 3; + */ + openOnly = false; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "support.v1.ListPlaceReportsRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "since", kind: "message", T: Timestamp }, + { no: 2, name: "limit", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 3, name: "open_only", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ListPlaceReportsRequest { + return new ListPlaceReportsRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ListPlaceReportsRequest { + return new ListPlaceReportsRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ListPlaceReportsRequest { + return new ListPlaceReportsRequest().fromJsonString(jsonString, options); + } + + static equals(a: ListPlaceReportsRequest | PlainMessage | undefined, b: ListPlaceReportsRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(ListPlaceReportsRequest, a, b); + } +} + +/** + * @generated from message support.v1.ListPlaceReportsResponse + */ +export class ListPlaceReportsResponse extends Message { + /** + * @generated from field: repeated support.v1.PlaceReport reports = 1; + */ + reports: PlaceReport[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "support.v1.ListPlaceReportsResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "reports", kind: "message", T: PlaceReport, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ListPlaceReportsResponse { + return new ListPlaceReportsResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ListPlaceReportsResponse { + return new ListPlaceReportsResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ListPlaceReportsResponse { + return new ListPlaceReportsResponse().fromJsonString(jsonString, options); + } + + static equals(a: ListPlaceReportsResponse | PlainMessage | undefined, b: ListPlaceReportsResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(ListPlaceReportsResponse, a, b); + } +} + +/** + * @generated from message support.v1.PlaceReport + */ +export class PlaceReport extends Message { + /** + * @generated from field: string id = 1; + */ + id = ""; + + /** + * Empty once the reporter's account is deleted, or past the retention. + * + * @generated from field: string reporter_account_id = 2; + */ + reporterAccountId = ""; + + /** + * The name the game shows for the reporter now; empty for an account it can no longer name. + * + * @generated from field: string reporter_name = 3; + */ + reporterName = ""; + + /** + * @generated from field: uint32 tile_id = 4; + */ + tileId = 0; + + /** + * @generated from field: support.v1.PlaceReportReason reason = 5; + */ + reason = PlaceReportReason.UNSPECIFIED; + + /** + * @generated from field: string text = 6; + */ + text = ""; + + /** + * @generated from field: google.protobuf.Timestamp created_at = 7; + */ + createdAt?: Timestamp; + + /** + * Unset while the report is open. + * + * @generated from field: support.v1.ReportDecision decision = 8; + */ + decision?: ReportDecision; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "support.v1.PlaceReport"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "reporter_account_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "reporter_name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "tile_id", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 5, name: "reason", kind: "enum", T: proto3.getEnumType(PlaceReportReason) }, + { no: 6, name: "text", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 7, name: "created_at", kind: "message", T: Timestamp }, + { no: 8, name: "decision", kind: "message", T: ReportDecision }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): PlaceReport { + return new PlaceReport().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): PlaceReport { + return new PlaceReport().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): PlaceReport { + return new PlaceReport().fromJsonString(jsonString, options); + } + + static equals(a: PlaceReport | PlainMessage | undefined, b: PlaceReport | PlainMessage | undefined): boolean { + return proto3.util.equals(PlaceReport, a, b); + } +} + +/** + * @generated from message support.v1.DecidePlaceReportRequest + */ +export class DecidePlaceReportRequest extends Message { + /** + * @generated from field: string report_id = 1; + */ + reportId = ""; + + /** + * @generated from field: support.v1.ReportOutcome outcome = 2; + */ + outcome = ReportOutcome.UNSPECIFIED; + + /** + * Optional: why, in the operator's words. + * + * @generated from field: string comment = 3; + */ + comment = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "support.v1.DecidePlaceReportRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "report_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "outcome", kind: "enum", T: proto3.getEnumType(ReportOutcome) }, + { no: 3, name: "comment", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): DecidePlaceReportRequest { + return new DecidePlaceReportRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): DecidePlaceReportRequest { + return new DecidePlaceReportRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): DecidePlaceReportRequest { + return new DecidePlaceReportRequest().fromJsonString(jsonString, options); + } + + static equals(a: DecidePlaceReportRequest | PlainMessage | undefined, b: DecidePlaceReportRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(DecidePlaceReportRequest, a, b); + } +} + +/** + * @generated from message support.v1.DecidePlaceReportResponse + */ +export class DecidePlaceReportResponse extends Message { + /** + * @generated from field: google.protobuf.Timestamp decided_at = 1; + */ + decidedAt?: Timestamp; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "support.v1.DecidePlaceReportResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "decided_at", kind: "message", T: Timestamp }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): DecidePlaceReportResponse { + return new DecidePlaceReportResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): DecidePlaceReportResponse { + return new DecidePlaceReportResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): DecidePlaceReportResponse { + return new DecidePlaceReportResponse().fromJsonString(jsonString, options); + } + + static equals(a: DecidePlaceReportResponse | PlainMessage | undefined, b: DecidePlaceReportResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(DecidePlaceReportResponse, a, b); + } +} + diff --git a/apps/backoffice/src/gen/grpc/support/v1/support_connect.ts b/apps/backoffice/src/gen/grpc/support/v1/support_connect.ts index fe0fcf7f0..531bc14fd 100644 --- a/apps/backoffice/src/gen/grpc/support/v1/support_connect.ts +++ b/apps/backoffice/src/gen/grpc/support/v1/support_connect.ts @@ -3,7 +3,7 @@ /* eslint-disable */ // @ts-nocheck -import { CreateUploadRequest, CreateUploadResponse, GetAttachmentRulesRequest, GetAttachmentRulesResponse, ReportBugRequest, ReportBugResponse, ReportPlayerRequest, ReportPlayerResponse, SuggestFeatureRequest, SuggestFeatureResponse } from "./support_pb.js"; +import { CreateUploadRequest, CreateUploadResponse, GetAttachmentRulesRequest, GetAttachmentRulesResponse, ReportBugRequest, ReportBugResponse, ReportPlaceRequest, ReportPlaceResponse, ReportPlayerRequest, ReportPlayerResponse, SuggestFeatureRequest, SuggestFeatureResponse } from "./support_pb.js"; import { MethodIdempotency, MethodKind } from "@bufbuild/protobuf"; /** @@ -39,6 +39,17 @@ export const SupportService = { O: ReportPlayerResponse, kind: MethodKind.Unary, }, + /** + * A drawing on the map, by a tile of it: the operator reads it and may put the area back as it was. + * + * @generated from rpc support.v1.SupportService.ReportPlace + */ + reportPlace: { + name: "ReportPlace", + I: ReportPlaceRequest, + O: ReportPlaceResponse, + kind: MethodKind.Unary, + }, /** * @generated from rpc support.v1.SupportService.GetAttachmentRules */ diff --git a/apps/backoffice/src/gen/grpc/support/v1/support_pb.ts b/apps/backoffice/src/gen/grpc/support/v1/support_pb.ts index 96922e61c..2023c5d57 100644 --- a/apps/backoffice/src/gen/grpc/support/v1/support_pb.ts +++ b/apps/backoffice/src/gen/grpc/support/v1/support_pb.ts @@ -44,6 +44,40 @@ proto3.util.setEnumType(ReportReason, "support.v1.ReportReason", [ { no: 4, name: "REPORT_REASON_OTHER" }, ]); +/** + * @generated from enum support.v1.PlaceReportReason + */ +export enum PlaceReportReason { + /** + * @generated from enum value: PLACE_REPORT_REASON_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * A hateful symbol or hateful words. + * + * @generated from enum value: PLACE_REPORT_REASON_HATE = 1; + */ + HATE = 1, + + /** + * @generated from enum value: PLACE_REPORT_REASON_SEXUAL = 2; + */ + SEXUAL = 2, + + /** + * @generated from enum value: PLACE_REPORT_REASON_OTHER = 3; + */ + OTHER = 3, +} +// Retrieve enum metadata with: proto3.getEnumType(PlaceReportReason) +proto3.util.setEnumType(PlaceReportReason, "support.v1.PlaceReportReason", [ + { no: 0, name: "PLACE_REPORT_REASON_UNSPECIFIED" }, + { no: 1, name: "PLACE_REPORT_REASON_HATE" }, + { no: 2, name: "PLACE_REPORT_REASON_SEXUAL" }, + { no: 3, name: "PLACE_REPORT_REASON_OTHER" }, +]); + /** * @generated from message support.v1.SuggestFeatureRequest */ @@ -591,3 +625,87 @@ export class ReportPlayerResponse extends Message { } } +/** + * @generated from message support.v1.ReportPlaceRequest + */ +export class ReportPlaceRequest extends Message { + /** + * A tile of the drawing, the one the reporter picked. + * + * @generated from field: uint32 tile_id = 1; + */ + tileId = 0; + + /** + * @generated from field: support.v1.PlaceReportReason reason = 2; + */ + reason = PlaceReportReason.UNSPECIFIED; + + /** + * Optional. + * + * @generated from field: string text = 3; + */ + text = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "support.v1.ReportPlaceRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "tile_id", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 2, name: "reason", kind: "enum", T: proto3.getEnumType(PlaceReportReason) }, + { no: 3, name: "text", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ReportPlaceRequest { + return new ReportPlaceRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ReportPlaceRequest { + return new ReportPlaceRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ReportPlaceRequest { + return new ReportPlaceRequest().fromJsonString(jsonString, options); + } + + static equals(a: ReportPlaceRequest | PlainMessage | undefined, b: ReportPlaceRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(ReportPlaceRequest, a, b); + } +} + +/** + * @generated from message support.v1.ReportPlaceResponse + */ +export class ReportPlaceResponse extends Message { + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "support.v1.ReportPlaceResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ReportPlaceResponse { + return new ReportPlaceResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ReportPlaceResponse { + return new ReportPlaceResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ReportPlaceResponse { + return new ReportPlaceResponse().fromJsonString(jsonString, options); + } + + static equals(a: ReportPlaceResponse | PlainMessage | undefined, b: ReportPlaceResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(ReportPlaceResponse, a, b); + } +} + diff --git a/apps/frontend/CLAUDE.md b/apps/frontend/CLAUDE.md index 69350771d..6490fdf3f 100644 --- a/apps/frontend/CLAUDE.md +++ b/apps/frontend/CLAUDE.md @@ -1059,6 +1059,17 @@ the foot of Settings, and a report of another player, from that player's card. report. - The fakes name one account per name (`backends/fakeAccounts.ts`), so a fake chat message, roster line and standing agree on who a player is. +- **A drawing on the map is reported by a tile of it** (`ReportPlace`). "Report + a drawing" sits in the Settings foot beside Feedback (`onReportPlace`): it + closes the sheet, and the globe's next tap picks the tile instead of painting + it (`Globe.setPicking`, `useGlobe`'s `pickTile` and `cancelPick`). + `PlacePicking` says so at the top, with Cancel, under the quiz's banner when + one shows; Escape, the bomb and a sea tap call it off or do nothing. The tile + opens `ReportPlaceModal`, `NoteModal` with three reasons ("A hateful symbol or + words", "Something sexual", "Something else") and optional details. +- **`?tile=` opens the globe on a tile, close up** (`domain/tileLink.ts`, + `lookingAt`, at zoom 8): the operator's page links a reported drawing so. It + is read once, and `main.tsx` takes it out of the address, as `?f=`. **A note carries photos and videos**, an idea as a bug: `FeedbackModal` hands `NoteModal` the backend as `files`, and the picker is under the text whatever diff --git a/apps/frontend/src/app/App.tsx b/apps/frontend/src/app/App.tsx index 8c1091c39..c63a06012 100644 --- a/apps/frontend/src/app/App.tsx +++ b/apps/frontend/src/app/App.tsx @@ -9,6 +9,7 @@ export default function App(props: AppProps) { PageContext, + // Closes what is open and lets the player tap the drawing on the globe. + onReportPlace?: () => void, } const pageAlone = () => pageContextOf(window, NO_GRAPHICS, false) -export default function SettingsPlace({display, sound, support, pageContext = pageAlone}: SettingsPlaceProps) { +export default function SettingsPlace({display, sound, support, pageContext = pageAlone, onReportPlace}: SettingsPlaceProps) { const [open, setOpen] = useState<"feedback" | "about">() const close = () => setOpen(undefined) @@ -47,6 +49,12 @@ export default function SettingsPlace({display, sound, support, pageContext = pa Feedback } + {onReportPlace && } + + +} diff --git a/apps/frontend/src/app/support/ReportPlaceModal.test.tsx b/apps/frontend/src/app/support/ReportPlaceModal.test.tsx new file mode 100644 index 000000000..9bfa438c0 --- /dev/null +++ b/apps/frontend/src/app/support/ReportPlaceModal.test.tsx @@ -0,0 +1,55 @@ +// @vitest-environment jsdom +import {afterEach, describe, expect, it, vi} from "vitest" +import {cleanup, render, screen} from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import {PlaceReport, SupportRateLimitedError} from "../../backends/support.ts" +import ReportPlaceModal from "./ReportPlaceModal.tsx" + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +function setup(reportPlace: (report: PlaceReport) => Promise = vi.fn(async () => undefined)) { + const onClose = vi.fn() + render() + return {reportPlace, onClose, user: userEvent.setup()} +} + +const reason = (name: string) => screen.getByRole("radio", {name}) as HTMLButtonElement +const send = () => screen.getByRole("button", {name: "Send"}) as HTMLButtonElement +const details = () => screen.getByRole("textbox", {name: "Details"}) as HTMLTextAreaElement + +describe("ReportPlaceModal", () => { + it("asks what the drawing is first, and sends nothing before", () => { + setup() + + expect(screen.getByRole("dialog", {name: "Report a drawing"})).not.toBeNull() + expect(screen.getAllByRole("radio").map((option) => option.textContent)) + .toEqual(["A hateful symbol or words", "Something sexual", "Something else"]) + expect(send().disabled).toBe(true) + }) + + it("sends the tile picked, the reason and the details", async () => { + const {reportPlace, user} = setup() + + await user.click(reason("A hateful symbol or words")) + await user.type(details(), " a swastika over Berlin ") + await user.click(send()) + + expect(reportPlace).toHaveBeenCalledWith({tileId: 4242, reason: "hate", text: "a swastika over Berlin"}) + expect(screen.getByRole("status").textContent).toBe("Sent. Thank you!") + }) + + it("says why a report was refused and keeps it to send again", async () => { + const {user} = setup(vi.fn(async () => { + throw new SupportRateLimitedError() + })) + + await user.click(reason("Something else")) + await user.click(send()) + + expect(await screen.findByText("You sent too many. Try again later.")).not.toBeNull() + expect(send().disabled).toBe(false) + }) +}) diff --git a/apps/frontend/src/app/support/ReportPlaceModal.tsx b/apps/frontend/src/app/support/ReportPlaceModal.tsx new file mode 100644 index 000000000..abcaaa8a8 --- /dev/null +++ b/apps/frontend/src/app/support/ReportPlaceModal.tsx @@ -0,0 +1,34 @@ +import {useState} from "react" +import {PlaceReporter, PlaceReportReason} from "../../backends/support.ts" +import Choices, {Choice} from "./Choices.tsx" +import NoteModal from "./NoteModal.tsx" + +export type ReportPlaceModalProps = { + // The tile the player picked in the drawing. + tileId: number + reporter: PlaceReporter + onClose: () => void +} + +const REASONS: Choice[] = [ + {value: "hate", label: "A hateful symbol or words"}, + {value: "sexual", label: "Something sexual"}, + {value: "other", label: "Something else"}, +] + +export default function ReportPlaceModal({tileId, reporter, onClose}: ReportPlaceModalProps) { + const [reason, setReason] = useState() + + const send = async (text: string) => { + if (reason) await reporter.reportPlace({tileId, reason, text}) + } + + return } + ready={reason !== undefined} + optional + onSend={send} + onClose={onClose}/> +} diff --git a/apps/frontend/src/app/viewer/Viewer.css b/apps/frontend/src/app/viewer/Viewer.css index 76d8c8523..83faf65b4 100644 --- a/apps/frontend/src/app/viewer/Viewer.css +++ b/apps/frontend/src/app/viewer/Viewer.css @@ -76,7 +76,8 @@ height: 100vh; } -.viewer-canvas--armed { +.viewer-canvas--armed, +.viewer-canvas--picking { cursor: crosshair; -webkit-touch-callout: none; -webkit-user-select: none; diff --git a/apps/frontend/src/app/viewer/Viewer.tsx b/apps/frontend/src/app/viewer/Viewer.tsx index f2cdba69a..ae9f88604 100644 --- a/apps/frontend/src/app/viewer/Viewer.tsx +++ b/apps/frontend/src/app/viewer/Viewer.tsx @@ -47,6 +47,8 @@ import {GlobeStatus, useGlobe} from './useGlobe.ts'; import {useSound} from '../sound/useSound.ts'; import {useDisplaySettings} from './useDisplaySettings.ts'; import SettingsPlace from "../settings/SettingsPlace.tsx"; +import PlacePicking from "../support/PlacePicking.tsx"; +import ReportPlaceModal from "../support/ReportPlaceModal.tsx"; import AnthemControls from "../anthem/AnthemControls.tsx"; import {useAnthem} from "../anthem/useAnthem.ts"; import {AccountStore} from "../account/accountStore.ts"; @@ -87,6 +89,8 @@ type SheetName = "board" | "chat" | "you" | "share" | "settings" | "season" | "c export type ViewerProps = { sharedCountry?: Country + // The tile the globe opens on: a link to a place. + lookingAt?: number tileClicker: TileClicker ownershipsGetter: OwnershipsGetter updatesListener: UpdatesListener @@ -138,6 +142,7 @@ export default function Viewer(props: ViewerProps) { const [menuTab, setMenuTab] = useState("board") const [seasonOpen, setSeasonOpen] = useState(false) const [clicksOpen, setClicksOpen] = useState(false) + const [reportedTile, setReportedTile] = useState() const [unread, setUnread] = useState(0) const [boardView, setBoardView] = useState("countries") const [accepted] = useState(acceptedClicks) @@ -168,6 +173,9 @@ export default function Viewer(props: ViewerProps) { rules, bombArmed, toggleBomb, + picking, + pickTile, + cancelPick, switches, toggleSwitch, notice, @@ -192,6 +200,7 @@ export default function Viewer(props: ViewerProps) { clickHue: hueOf(color), mapView: display.settings.mapView, rendering: display.settings.rendering, + lookingAt: props.lookingAt, }) const bonusGuide = useBonusGuide() @@ -275,6 +284,10 @@ export default function Viewer(props: ViewerProps) { sound: {settings: sound.settings, onChange: sound.setSettings, preview: sound.preview}, support: props.support, pageContext: () => pageContextOf(window, graphics(), connection === "down"), + onReportPlace: props.support && (() => { + closeSheet() + pickTile(setReportedTile) + }), } const clicks = } + {ready && picking && } + + {reportedTile !== undefined && props.support && setReportedTile(undefined)}/>} + {shot && } diff --git a/apps/frontend/src/app/viewer/globe.ts b/apps/frontend/src/app/viewer/globe.ts index 572994737..1df5f9ec0 100644 --- a/apps/frontend/src/app/viewer/globe.ts +++ b/apps/frontend/src/app/viewer/globe.ts @@ -133,6 +133,9 @@ export function drawsFrame({turned, changed, ambient, at, drawnAt, interactingUn const CLAIM_MARGIN_MS = 2_000 +// Close enough to see a drawing of a few dozen tiles. +const LINKED_ZOOM = 8 + const textureLoader = new THREE.TextureLoader(); export type Shot = { @@ -165,6 +168,10 @@ export type GlobeOptions = { bomber?: Bomber onBombDropped: (drop: BombDrop, land: string | undefined) => void onArmedChange: (armed: boolean) => void + // Whether the next tap picks a tile instead of painting it (setPicking). + onPickingChange?: (picking: boolean) => void + // The tile the camera opens on, close up: a link to a place. + lookingAt?: number shielder?: Shielder onNotice?: (notice: BonusNotice) => void onClickAccepted?: (click: AcceptedClick) => void @@ -181,6 +188,8 @@ export type Globe = { setMapView(view: MapView): void takeReward(claimed: ClaimedBonus): void setArmed(armed: boolean): void + // The next tap on a tile hands it over and paints nothing; Escape, a bomb or undefined calls it off. + setPicking(onPicked: ((tile: number) => void) | undefined): void setSwitch(name: keyof Switches, on: boolean): void setClickHue(hue: number | undefined): void capture(): Promise @@ -217,6 +226,8 @@ export async function createGlobe(options: GlobeOptions): Promise { bomber, onBombDropped, onArmedChange, + onPickingChange = () => {}, + lookingAt, shielder, onNotice = () => {}, onClickAccepted = () => {}, @@ -246,6 +257,11 @@ export async function createGlobe(options: GlobeOptions): Promise { const graphics = graphicsOf(window.location.search, rendering); const {scene, camera, cameraSize, renderer, cleanup} = setupScene(eventTarget, graphics); + if (lookingAt !== undefined && lookingAt >= 1 && lookingAt * 3 <= geometryData.positions.length) { + const at = (lookingAt - 1) * 3 + const [x, y, z] = [geometryData.positions[at], geometryData.positions[at + 1], geometryData.positions[at + 2]] + aim(camera, {direction: {x, y, z}, zoom: LINKED_ZOOM}) + } let mapView: MapView = initialMapView let dirty = true @@ -401,6 +417,7 @@ export async function createGlobe(options: GlobeOptions): Promise { let rules: BonusRules | undefined let armed: {radius: number} | undefined + let picking: ((tile: number) => void) | undefined const hold = new HoldToDrop({holdSeconds: HOLD_TO_DROP_SECONDS, tolerancePx: HOLD_TOLERANCE_PX}) @@ -422,7 +439,22 @@ export async function createGlobe(options: GlobeOptions): Promise { onArmedChange(false) } + const stopPicking = () => { + if (!picking) return + picking = undefined + eventTarget.classList.remove("viewer-canvas--picking") + onPickingChange(false) + } + + const startPicking = (onPicked: (tile: number) => void) => { + disarm() + picking = onPicked + eventTarget.classList.add("viewer-canvas--picking") + onPickingChange(true) + } + const arm = () => { + stopPicking() if (armed || !charges.bomb || !bomber || !rules) return armed = {radius: rules.blastRadius} eventTarget.classList.add("viewer-canvas--armed") @@ -628,7 +660,9 @@ export async function createGlobe(options: GlobeOptions): Promise { }, listenerOptions); window.addEventListener('keydown', (event: KeyboardEvent) => { - if (event.key === "Escape") disarm() + if (event.key !== "Escape") return + disarm() + stopPicking() }, listenerOptions); eventTarget.addEventListener('pointermove', (event: PointerEvent) => { @@ -652,6 +686,15 @@ export async function createGlobe(options: GlobeOptions): Promise { const {x, y} = canvasPosition(event) + if (picking) { + const tile = picker.pick(camera, x, y) + if (tile === undefined) return + const picked = picking + stopPicking() + picked(tile) + return + } + if (offered && monotonicNow() >= offered.expiresAt - CLAIM_MARGIN_MS) { offered = undefined bonusBox.hide() @@ -875,6 +918,7 @@ export async function createGlobe(options: GlobeOptions): Promise { }, takeReward, setArmed: (on: boolean) => on ? arm() : disarm(), + setPicking: (onPicked) => onPicked ? startPicking(onPicked) : stopPicking(), setSwitch: (name: keyof Switches, on: boolean) => { const next = switchesHeld(switched(switches, name, on), charges) if (next[name]) disarm() @@ -891,6 +935,7 @@ export async function createGlobe(options: GlobeOptions): Promise { }), graphics: () => graphicsReportOf(renderer.getContext()), dispose: () => { + stopPicking() lifetime.abort() for (const request of takeCaptureRequests()) { diff --git a/apps/frontend/src/app/viewer/useGlobe.test.tsx b/apps/frontend/src/app/viewer/useGlobe.test.tsx index bf6849af5..e37683d7a 100644 --- a/apps/frontend/src/app/viewer/useGlobe.test.tsx +++ b/apps/frontend/src/app/viewer/useGlobe.test.tsx @@ -27,7 +27,7 @@ function fakeGlobe(): Globe & { graphics: ReturnType, dispose: ReturnType, } { - return {tilesCount: 257_948, setCountry: vi.fn(), takeReward: vi.fn(), setArmed: vi.fn(), setSwitch: vi.fn(), setClickHue: vi.fn(), setMapView: vi.fn(), capture: vi.fn(), graphics: vi.fn(), dispose: vi.fn()} + return {tilesCount: 257_948, setCountry: vi.fn(), takeReward: vi.fn(), setArmed: vi.fn(), setPicking: vi.fn(), setSwitch: vi.fn(), setClickHue: vi.fn(), setMapView: vi.fn(), capture: vi.fn(), graphics: vi.fn(), dispose: vi.fn()} } function Harness(props: {country: typeof FRANCE, backends: ReturnType, onResult: (r: unknown) => void}) { diff --git a/apps/frontend/src/app/viewer/useGlobe.ts b/apps/frontend/src/app/viewer/useGlobe.ts index f157512c5..5185ad927 100644 --- a/apps/frontend/src/app/viewer/useGlobe.ts +++ b/apps/frontend/src/app/viewer/useGlobe.ts @@ -36,10 +36,12 @@ export type UseGlobeOptions = { mapView: MapView // A change rebuilds the globe: antialiasing is fixed when the context is made. rendering: Rendering + // Read when the globe is first made: a link to a place. + lookingAt?: number } export function useGlobe(options: UseGlobeOptions) { - const {container, tileClicker, ownershipsGetter, updatesListener, clickBudget, bonusListener, bomber, shielder, playSound, onClickAccepted, country, clickHue, mapView, rendering} = options + const {container, tileClicker, ownershipsGetter, updatesListener, clickBudget, bonusListener, bomber, shielder, playSound, onClickAccepted, country, clickHue, mapView, rendering, lookingAt} = options const [status, setStatus] = useState({state: 'loading'}) const [tilesCount, setTilesCount] = useState(0) @@ -59,6 +61,7 @@ export function useGlobe(options: UseGlobeOptions) { const [charges, setCharges] = useState(NO_CHARGES) const [rules, setRules] = useState() const [bombArmed, setBombArmed] = useState(false) + const [picking, setPicking] = useState(false) const [switches, setSwitches] = useState(ALL_OFF) const [lastCatch, setLastCatch] = useState() @@ -88,6 +91,7 @@ export function useGlobe(options: UseGlobeOptions) { const initialCountry = useRef(country) const latestClickHue = useRef(clickHue) const latestMapView = useRef(mapView) + const initialLookingAt = useRef(lookingAt) useEffect(() => { const element = container.current @@ -123,6 +127,8 @@ export function useGlobe(options: UseGlobeOptions) { bomber, onBombDropped: recordBomb, onArmedChange: setBombArmed, + onPickingChange: setPicking, + lookingAt: initialLookingAt.current, shielder, onNotice: notify, onClickAccepted, @@ -182,6 +188,8 @@ export function useGlobe(options: UseGlobeOptions) { const dismissAward = useCallback(() => setAward(undefined), []) const toggleBomb = useCallback(() => globeRef.current?.setArmed(!bombArmed), [bombArmed]) + const pickTile = useCallback((onPicked: (tile: number) => void) => globeRef.current?.setPicking(onPicked), []) + const cancelPick = useCallback(() => globeRef.current?.setPicking(undefined), []) const toggleSwitch = useCallback((name: keyof Switches) => globeRef.current?.setSwitch(name, !switches[name]), [switches]) const dismissBomb = useCallback(() => setLastBomb(undefined), []) const dismissFortified = useCallback(() => setLastFortified(undefined), []) @@ -205,6 +213,9 @@ export function useGlobe(options: UseGlobeOptions) { rules, bombArmed, toggleBomb, + picking, + pickTile, + cancelPick, switches, toggleSwitch, notice, diff --git a/apps/frontend/src/backends/fakeSupportBackend.ts b/apps/frontend/src/backends/fakeSupportBackend.ts index 15dde7866..024d4f48f 100644 --- a/apps/frontend/src/backends/fakeSupportBackend.ts +++ b/apps/frontend/src/backends/fakeSupportBackend.ts @@ -1,6 +1,7 @@ import { AttachmentRules, PageContext, + PlaceReport, PlayerReport, SupportBackend, SupportNoSessionError, @@ -17,6 +18,7 @@ type FakeNoteKind = "suggestion" | "bug" export type FakeSupportNote = | {kind: FakeNoteKind, text: string, context: PageContext, attachmentIds: string[]} | {kind: "report", report: PlayerReport} + | {kind: "place", report: PlaceReport} const ANSWER_DELAY_MS = 400 const UPLOAD_STEPS = 8 @@ -80,6 +82,11 @@ export class FakeSupportBackend implements SupportBackend { this.sent.push({kind: "report", report}) } + public async reportPlace(report: PlaceReport): Promise { + await this.answer() + this.sent.push({kind: "place", report}) + } + private async answer(): Promise { await new Promise((resolve) => setTimeout(resolve, this.delayMs)) diff --git a/apps/frontend/src/backends/support.ts b/apps/frontend/src/backends/support.ts index 1ba3a860f..074be6550 100644 --- a/apps/frontend/src/backends/support.ts +++ b/apps/frontend/src/backends/support.ts @@ -59,10 +59,23 @@ export interface PlayerReporter { reportPlayer(report: PlayerReport): Promise } +export type PlaceReportReason = "hate" | "sexual" | "other" + +// A drawing on the map, by the tile the player picked in it. +export type PlaceReport = { + tileId: number + reason: PlaceReportReason + text: string +} + +export interface PlaceReporter { + reportPlace(report: PlaceReport): Promise +} + // What the feedback form asks for: a note of either kind, and its files. export type FeedbackBackend = FeatureSuggester & BugReporter & AttachmentRulesReader & AttachmentUploader -export type SupportBackend = FeedbackBackend & PlayerReporter +export type SupportBackend = FeedbackBackend & PlayerReporter & PlaceReporter export class SupportRateLimitedError extends Error { constructor(options?: {cause?: unknown}) { diff --git a/apps/frontend/src/backends/supportBackend.ts b/apps/frontend/src/backends/supportBackend.ts index dd8d1cee6..068a4ebc0 100644 --- a/apps/frontend/src/backends/supportBackend.ts +++ b/apps/frontend/src/backends/supportBackend.ts @@ -1,13 +1,15 @@ import {Code, ConnectError, createPromiseClient, PromiseClient} from "@connectrpc/connect"; import {createConnectTransport} from "@connectrpc/connect-web"; import {SupportService} from "../gen/grpc/support/v1/support_connect.ts"; -import {ReportReason as ReportReasonPb} from "../gen/grpc/support/v1/support_pb.ts"; +import {PlaceReportReason as PlaceReportReasonPb, ReportReason as ReportReasonPb} from "../gen/grpc/support/v1/support_pb.ts"; import {putFile, PutFile} from "./putFile.ts"; import {SESSION_HEADER, SessionProvider} from "./session.ts"; import { AttachmentRules, NO_ATTACHMENTS, PageContext, + PlaceReport, + PlaceReportReason, PlayerReport, ReportReason, SupportBackend, @@ -35,6 +37,12 @@ const REASONS: Record = { "other": ReportReasonPb.OTHER, } +const PLACE_REASONS: Record = { + "hate": PlaceReportReasonPb.HATE, + "sexual": PlaceReportReasonPb.SEXUAL, + "other": PlaceReportReasonPb.OTHER, +} + export class ConnectSupportBackend implements SupportBackend { constructor( private client: PromiseClient, @@ -82,6 +90,14 @@ export class ConnectSupportBackend implements SupportBackend { }, {headers})) } + public async reportPlace(report: PlaceReport): Promise { + await this.send((headers) => this.client.reportPlace({ + tileId: report.tileId, + reason: PLACE_REASONS[report.reason], + text: report.text, + }, {headers})) + } + // Not retried: a call that landed and lost its answer would be kept twice. private async send(call: (headers: Headers) => Promise): Promise { try { diff --git a/apps/frontend/src/domain/tileLink.test.ts b/apps/frontend/src/domain/tileLink.test.ts new file mode 100644 index 000000000..21b081783 --- /dev/null +++ b/apps/frontend/src/domain/tileLink.test.ts @@ -0,0 +1,22 @@ +import {describe, expect, it} from "vitest" +import {linkedTile, withoutLinkedTile} from "./tileLink.ts" + +describe("linkedTile", () => { + it("reads the tile a link opens on", () => { + expect(linkedTile(new URL("https://clickplanet.lol/?tile=4242"))).toBe(4242) + expect(linkedTile(new URL("https://clickplanet.lol/play?f=fr&tile=%2042%20"))).toBe(42) + }) + + it("reads nothing that is not a tile", () => { + for (const search of ["", "?tile=", "?tile=0", "?tile=-3", "?tile=1.5", "?tile=abc", "?tile=99999999999"]) { + expect(linkedTile(new URL(`https://clickplanet.lol/${search}`)), search).toBeUndefined() + } + }) +}) + +describe("withoutLinkedTile", () => { + it("drops the tile and keeps the rest", () => { + expect(withoutLinkedTile(new URL("https://clickplanet.lol/play?f=fr&tile=42")).href) + .toBe("https://clickplanet.lol/play?f=fr") + }) +}) diff --git a/apps/frontend/src/domain/tileLink.ts b/apps/frontend/src/domain/tileLink.ts new file mode 100644 index 000000000..cfec344e5 --- /dev/null +++ b/apps/frontend/src/domain/tileLink.ts @@ -0,0 +1,16 @@ +export const TILE_PARAM = "tile" + +// A link that opens the globe on a tile: the operator's page sends one to see a reported drawing. +export function linkedTile(url: URL): number | undefined { + const value = url.searchParams.get(TILE_PARAM) + if (value === null || !/^[1-9][0-9]{0,9}$/.test(value.trim())) return undefined + + const tile = Number(value.trim()) + return tile <= 0xFFFFFFFF ? tile : undefined +} + +export function withoutLinkedTile(url: URL): URL { + const stripped = new URL(url) + stripped.searchParams.delete(TILE_PARAM) + return stripped +} diff --git a/apps/frontend/src/gen/grpc/planet/v1/admin_connect.ts b/apps/frontend/src/gen/grpc/planet/v1/admin_connect.ts index a0773eca7..8901d9898 100644 --- a/apps/frontend/src/gen/grpc/planet/v1/admin_connect.ts +++ b/apps/frontend/src/gen/grpc/planet/v1/admin_connect.ts @@ -3,7 +3,7 @@ /* eslint-disable */ // @ts-nocheck -import { BanPlayerRequest, BanPlayerResponse, FindPlayersRequest, FindPlayersResponse, GetReplayRequest, GetReplayResponse, GrantChargesRequest, GrantChargesResponse, InspectPlayerRequest, InspectPlayerResponse, PaintRandomTilesRequest, PaintRandomTilesResponse, ReassignCountryRequest, ReassignCountryResponse, RevertPlayerRequest, RevertPlayerResponse, TopPlayersRequest, TopPlayersResponse, UnbanPlayerRequest, UnbanPlayerResponse } from "./admin_pb.js"; +import { BanPlayerRequest, BanPlayerResponse, ClearAreaRequest, ClearAreaResponse, FindPlayersRequest, FindPlayersResponse, GetReplayRequest, GetReplayResponse, GrantChargesRequest, GrantChargesResponse, InspectPlayerRequest, InspectPlayerResponse, PaintRandomTilesRequest, PaintRandomTilesResponse, ReassignCountryRequest, ReassignCountryResponse, RestoreAreaRequest, RestoreAreaResponse, RevertPlayerRequest, RevertPlayerResponse, TopPlayersRequest, TopPlayersResponse, UnbanPlayerRequest, UnbanPlayerResponse } from "./admin_pb.js"; import { MethodKind } from "@bufbuild/protobuf"; /** @@ -104,6 +104,28 @@ export const AdminService = { O: GetReplayResponse, kind: MethodKind.Unary, }, + /** + * Who painted the area around a tile since a time, and what putting the area back as it was then changes. Restores it unless dry_run. + * + * @generated from rpc planet.v1.AdminService.RestoreArea + */ + restoreArea: { + name: "RestoreArea", + I: RestoreAreaRequest, + O: RestoreAreaResponse, + kind: MethodKind.Unary, + }, + /** + * Gives every tile of the area around a tile to nobody, for a drawing older than the ledger keeps. Clears it unless dry_run. + * + * @generated from rpc planet.v1.AdminService.ClearArea + */ + clearArea: { + name: "ClearArea", + I: ClearAreaRequest, + O: ClearAreaResponse, + kind: MethodKind.Unary, + }, } } as const; diff --git a/apps/frontend/src/gen/grpc/planet/v1/admin_pb.ts b/apps/frontend/src/gen/grpc/planet/v1/admin_pb.ts index d30f51050..5434da9c2 100644 --- a/apps/frontend/src/gen/grpc/planet/v1/admin_pb.ts +++ b/apps/frontend/src/gen/grpc/planet/v1/admin_pb.ts @@ -7,6 +7,248 @@ import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialM import { Duration, Message, proto3, Timestamp } from "@bufbuild/protobuf"; import { ChargesHeld, GetMapResponse, PlanetEvent } from "./planet_pb.js"; +/** + * @generated from message planet.v1.RestoreAreaRequest + */ +export class RestoreAreaRequest extends Message { + /** + * @generated from field: uint32 tile_id = 1; + */ + tileId = 0; + + /** + * The area is every tile within this many tile spacings of the tile, as a bomb's blast is. 1 to 50. + * + * @generated from field: uint32 rings = 2; + */ + rings = 0; + + /** + * Each tile of the area goes back to what it held then. FailedPrecondition past the ledger's retention. + * + * @generated from field: google.protobuf.Timestamp before = 3; + */ + before?: Timestamp; + + /** + * @generated from field: bool dry_run = 4; + */ + dryRun = false; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "planet.v1.RestoreAreaRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "tile_id", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 2, name: "rings", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 3, name: "before", kind: "message", T: Timestamp }, + { no: 4, name: "dry_run", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): RestoreAreaRequest { + return new RestoreAreaRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): RestoreAreaRequest { + return new RestoreAreaRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): RestoreAreaRequest { + return new RestoreAreaRequest().fromJsonString(jsonString, options); + } + + static equals(a: RestoreAreaRequest | PlainMessage | undefined, b: RestoreAreaRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(RestoreAreaRequest, a, b); + } +} + +/** + * @generated from message planet.v1.RestoreAreaResponse + */ +export class RestoreAreaResponse extends Message { + /** + * In the area. + * + * @generated from field: uint32 tiles = 1; + */ + tiles = 0; + + /** + * That a restore changes: a tile changed since before and not back to what it held then. + * + * @generated from field: uint32 changed = 2; + */ + changed = 0; + + /** + * That changed: a tile somebody took during the restore stays theirs. + * + * @generated from field: uint32 restored = 3; + */ + restored = 0; + + /** + * Who took tiles of the area since before, most takes first, 50 at most. Tiles and takes count the area alone. + * + * @generated from field: repeated planet.v1.Player painters = 4; + */ + painters: Player[] = []; + + /** + * The oldest before the ledger can rewind to. + * + * @generated from field: google.protobuf.Timestamp kept_since = 5; + */ + keptSince?: Timestamp; + + /** + * @generated from field: uint32 painters_total = 6; + */ + paintersTotal = 0; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "planet.v1.RestoreAreaResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "tiles", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 2, name: "changed", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 3, name: "restored", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 4, name: "painters", kind: "message", T: Player, repeated: true }, + { no: 5, name: "kept_since", kind: "message", T: Timestamp }, + { no: 6, name: "painters_total", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): RestoreAreaResponse { + return new RestoreAreaResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): RestoreAreaResponse { + return new RestoreAreaResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): RestoreAreaResponse { + return new RestoreAreaResponse().fromJsonString(jsonString, options); + } + + static equals(a: RestoreAreaResponse | PlainMessage | undefined, b: RestoreAreaResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(RestoreAreaResponse, a, b); + } +} + +/** + * @generated from message planet.v1.ClearAreaRequest + */ +export class ClearAreaRequest extends Message { + /** + * @generated from field: uint32 tile_id = 1; + */ + tileId = 0; + + /** + * As RestoreArea's. + * + * @generated from field: uint32 rings = 2; + */ + rings = 0; + + /** + * @generated from field: bool dry_run = 3; + */ + dryRun = false; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "planet.v1.ClearAreaRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "tile_id", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 2, name: "rings", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 3, name: "dry_run", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ClearAreaRequest { + return new ClearAreaRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ClearAreaRequest { + return new ClearAreaRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ClearAreaRequest { + return new ClearAreaRequest().fromJsonString(jsonString, options); + } + + static equals(a: ClearAreaRequest | PlainMessage | undefined, b: ClearAreaRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(ClearAreaRequest, a, b); + } +} + +/** + * @generated from message planet.v1.ClearAreaResponse + */ +export class ClearAreaResponse extends Message { + /** + * In the area. + * + * @generated from field: uint32 tiles = 1; + */ + tiles = 0; + + /** + * That a flag holds. + * + * @generated from field: uint32 owned = 2; + */ + owned = 0; + + /** + * That were given to nobody: a tile somebody took during the clear stays theirs. + * + * @generated from field: uint32 cleared = 3; + */ + cleared = 0; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "planet.v1.ClearAreaResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "tiles", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 2, name: "owned", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 3, name: "cleared", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ClearAreaResponse { + return new ClearAreaResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ClearAreaResponse { + return new ClearAreaResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ClearAreaResponse { + return new ClearAreaResponse().fromJsonString(jsonString, options); + } + + static equals(a: ClearAreaResponse | PlainMessage | undefined, b: ClearAreaResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(ClearAreaResponse, a, b); + } +} + /** * @generated from message planet.v1.GetReplayRequest */ diff --git a/apps/frontend/src/gen/grpc/support/v1/admin_connect.ts b/apps/frontend/src/gen/grpc/support/v1/admin_connect.ts index 2cd0129bb..e7677a118 100644 --- a/apps/frontend/src/gen/grpc/support/v1/admin_connect.ts +++ b/apps/frontend/src/gen/grpc/support/v1/admin_connect.ts @@ -3,7 +3,7 @@ /* eslint-disable */ // @ts-nocheck -import { DecideReportRequest, DecideReportResponse, ListNotesRequest, ListNotesResponse, ListReportsRequest, ListReportsResponse } from "./admin_pb.js"; +import { DecidePlaceReportRequest, DecidePlaceReportResponse, DecideReportRequest, DecideReportResponse, ListNotesRequest, ListNotesResponse, ListPlaceReportsRequest, ListPlaceReportsResponse, ListReportsRequest, ListReportsResponse } from "./admin_pb.js"; import { MethodKind } from "@bufbuild/protobuf"; /** @@ -43,6 +43,28 @@ export const AdminService = { O: DecideReportResponse, kind: MethodKind.Unary, }, + /** + * Newest first, as ListReports. + * + * @generated from rpc support.v1.AdminService.ListPlaceReports + */ + listPlaceReports: { + name: "ListPlaceReports", + I: ListPlaceReportsRequest, + O: ListPlaceReportsResponse, + kind: MethodKind.Unary, + }, + /** + * As DecideReport, for a report of a place. + * + * @generated from rpc support.v1.AdminService.DecidePlaceReport + */ + decidePlaceReport: { + name: "DecidePlaceReport", + I: DecidePlaceReportRequest, + O: DecidePlaceReportResponse, + kind: MethodKind.Unary, + }, } } as const; diff --git a/apps/frontend/src/gen/grpc/support/v1/admin_pb.ts b/apps/frontend/src/gen/grpc/support/v1/admin_pb.ts index 3225f3074..2e9d9d7ec 100644 --- a/apps/frontend/src/gen/grpc/support/v1/admin_pb.ts +++ b/apps/frontend/src/gen/grpc/support/v1/admin_pb.ts @@ -5,7 +5,7 @@ import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf"; import { Message, proto3, protoInt64, Timestamp } from "@bufbuild/protobuf"; -import { PageContext, ReportReason } from "./support_pb.js"; +import { PageContext, PlaceReportReason, ReportReason } from "./support_pb.js"; /** * @generated from enum support.v1.NoteKind @@ -615,3 +615,268 @@ export class DecideReportResponse extends Message { } } +/** + * @generated from message support.v1.ListPlaceReportsRequest + */ +export class ListPlaceReportsRequest extends Message { + /** + * Unset is every report kept. + * + * @generated from field: google.protobuf.Timestamp since = 1; + */ + since?: Timestamp; + + /** + * Unset is 100; at most 500. + * + * @generated from field: uint32 limit = 2; + */ + limit = 0; + + /** + * Only the reports nobody decided yet. + * + * @generated from field: bool open_only = 3; + */ + openOnly = false; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "support.v1.ListPlaceReportsRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "since", kind: "message", T: Timestamp }, + { no: 2, name: "limit", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 3, name: "open_only", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ListPlaceReportsRequest { + return new ListPlaceReportsRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ListPlaceReportsRequest { + return new ListPlaceReportsRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ListPlaceReportsRequest { + return new ListPlaceReportsRequest().fromJsonString(jsonString, options); + } + + static equals(a: ListPlaceReportsRequest | PlainMessage | undefined, b: ListPlaceReportsRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(ListPlaceReportsRequest, a, b); + } +} + +/** + * @generated from message support.v1.ListPlaceReportsResponse + */ +export class ListPlaceReportsResponse extends Message { + /** + * @generated from field: repeated support.v1.PlaceReport reports = 1; + */ + reports: PlaceReport[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "support.v1.ListPlaceReportsResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "reports", kind: "message", T: PlaceReport, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ListPlaceReportsResponse { + return new ListPlaceReportsResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ListPlaceReportsResponse { + return new ListPlaceReportsResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ListPlaceReportsResponse { + return new ListPlaceReportsResponse().fromJsonString(jsonString, options); + } + + static equals(a: ListPlaceReportsResponse | PlainMessage | undefined, b: ListPlaceReportsResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(ListPlaceReportsResponse, a, b); + } +} + +/** + * @generated from message support.v1.PlaceReport + */ +export class PlaceReport extends Message { + /** + * @generated from field: string id = 1; + */ + id = ""; + + /** + * Empty once the reporter's account is deleted, or past the retention. + * + * @generated from field: string reporter_account_id = 2; + */ + reporterAccountId = ""; + + /** + * The name the game shows for the reporter now; empty for an account it can no longer name. + * + * @generated from field: string reporter_name = 3; + */ + reporterName = ""; + + /** + * @generated from field: uint32 tile_id = 4; + */ + tileId = 0; + + /** + * @generated from field: support.v1.PlaceReportReason reason = 5; + */ + reason = PlaceReportReason.UNSPECIFIED; + + /** + * @generated from field: string text = 6; + */ + text = ""; + + /** + * @generated from field: google.protobuf.Timestamp created_at = 7; + */ + createdAt?: Timestamp; + + /** + * Unset while the report is open. + * + * @generated from field: support.v1.ReportDecision decision = 8; + */ + decision?: ReportDecision; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "support.v1.PlaceReport"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "reporter_account_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "reporter_name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "tile_id", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 5, name: "reason", kind: "enum", T: proto3.getEnumType(PlaceReportReason) }, + { no: 6, name: "text", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 7, name: "created_at", kind: "message", T: Timestamp }, + { no: 8, name: "decision", kind: "message", T: ReportDecision }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): PlaceReport { + return new PlaceReport().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): PlaceReport { + return new PlaceReport().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): PlaceReport { + return new PlaceReport().fromJsonString(jsonString, options); + } + + static equals(a: PlaceReport | PlainMessage | undefined, b: PlaceReport | PlainMessage | undefined): boolean { + return proto3.util.equals(PlaceReport, a, b); + } +} + +/** + * @generated from message support.v1.DecidePlaceReportRequest + */ +export class DecidePlaceReportRequest extends Message { + /** + * @generated from field: string report_id = 1; + */ + reportId = ""; + + /** + * @generated from field: support.v1.ReportOutcome outcome = 2; + */ + outcome = ReportOutcome.UNSPECIFIED; + + /** + * Optional: why, in the operator's words. + * + * @generated from field: string comment = 3; + */ + comment = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "support.v1.DecidePlaceReportRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "report_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "outcome", kind: "enum", T: proto3.getEnumType(ReportOutcome) }, + { no: 3, name: "comment", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): DecidePlaceReportRequest { + return new DecidePlaceReportRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): DecidePlaceReportRequest { + return new DecidePlaceReportRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): DecidePlaceReportRequest { + return new DecidePlaceReportRequest().fromJsonString(jsonString, options); + } + + static equals(a: DecidePlaceReportRequest | PlainMessage | undefined, b: DecidePlaceReportRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(DecidePlaceReportRequest, a, b); + } +} + +/** + * @generated from message support.v1.DecidePlaceReportResponse + */ +export class DecidePlaceReportResponse extends Message { + /** + * @generated from field: google.protobuf.Timestamp decided_at = 1; + */ + decidedAt?: Timestamp; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "support.v1.DecidePlaceReportResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "decided_at", kind: "message", T: Timestamp }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): DecidePlaceReportResponse { + return new DecidePlaceReportResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): DecidePlaceReportResponse { + return new DecidePlaceReportResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): DecidePlaceReportResponse { + return new DecidePlaceReportResponse().fromJsonString(jsonString, options); + } + + static equals(a: DecidePlaceReportResponse | PlainMessage | undefined, b: DecidePlaceReportResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(DecidePlaceReportResponse, a, b); + } +} + diff --git a/apps/frontend/src/gen/grpc/support/v1/support_connect.ts b/apps/frontend/src/gen/grpc/support/v1/support_connect.ts index fe0fcf7f0..531bc14fd 100644 --- a/apps/frontend/src/gen/grpc/support/v1/support_connect.ts +++ b/apps/frontend/src/gen/grpc/support/v1/support_connect.ts @@ -3,7 +3,7 @@ /* eslint-disable */ // @ts-nocheck -import { CreateUploadRequest, CreateUploadResponse, GetAttachmentRulesRequest, GetAttachmentRulesResponse, ReportBugRequest, ReportBugResponse, ReportPlayerRequest, ReportPlayerResponse, SuggestFeatureRequest, SuggestFeatureResponse } from "./support_pb.js"; +import { CreateUploadRequest, CreateUploadResponse, GetAttachmentRulesRequest, GetAttachmentRulesResponse, ReportBugRequest, ReportBugResponse, ReportPlaceRequest, ReportPlaceResponse, ReportPlayerRequest, ReportPlayerResponse, SuggestFeatureRequest, SuggestFeatureResponse } from "./support_pb.js"; import { MethodIdempotency, MethodKind } from "@bufbuild/protobuf"; /** @@ -39,6 +39,17 @@ export const SupportService = { O: ReportPlayerResponse, kind: MethodKind.Unary, }, + /** + * A drawing on the map, by a tile of it: the operator reads it and may put the area back as it was. + * + * @generated from rpc support.v1.SupportService.ReportPlace + */ + reportPlace: { + name: "ReportPlace", + I: ReportPlaceRequest, + O: ReportPlaceResponse, + kind: MethodKind.Unary, + }, /** * @generated from rpc support.v1.SupportService.GetAttachmentRules */ diff --git a/apps/frontend/src/gen/grpc/support/v1/support_pb.ts b/apps/frontend/src/gen/grpc/support/v1/support_pb.ts index 5ffb76141..73fdac292 100644 --- a/apps/frontend/src/gen/grpc/support/v1/support_pb.ts +++ b/apps/frontend/src/gen/grpc/support/v1/support_pb.ts @@ -44,6 +44,40 @@ proto3.util.setEnumType(ReportReason, "support.v1.ReportReason", [ { no: 4, name: "REPORT_REASON_OTHER" }, ]); +/** + * @generated from enum support.v1.PlaceReportReason + */ +export enum PlaceReportReason { + /** + * @generated from enum value: PLACE_REPORT_REASON_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * A hateful symbol or hateful words. + * + * @generated from enum value: PLACE_REPORT_REASON_HATE = 1; + */ + HATE = 1, + + /** + * @generated from enum value: PLACE_REPORT_REASON_SEXUAL = 2; + */ + SEXUAL = 2, + + /** + * @generated from enum value: PLACE_REPORT_REASON_OTHER = 3; + */ + OTHER = 3, +} +// Retrieve enum metadata with: proto3.getEnumType(PlaceReportReason) +proto3.util.setEnumType(PlaceReportReason, "support.v1.PlaceReportReason", [ + { no: 0, name: "PLACE_REPORT_REASON_UNSPECIFIED" }, + { no: 1, name: "PLACE_REPORT_REASON_HATE" }, + { no: 2, name: "PLACE_REPORT_REASON_SEXUAL" }, + { no: 3, name: "PLACE_REPORT_REASON_OTHER" }, +]); + /** * @generated from message support.v1.SuggestFeatureRequest */ @@ -591,3 +625,87 @@ export class ReportPlayerResponse extends Message { } } +/** + * @generated from message support.v1.ReportPlaceRequest + */ +export class ReportPlaceRequest extends Message { + /** + * A tile of the drawing, the one the reporter picked. + * + * @generated from field: uint32 tile_id = 1; + */ + tileId = 0; + + /** + * @generated from field: support.v1.PlaceReportReason reason = 2; + */ + reason = PlaceReportReason.UNSPECIFIED; + + /** + * Optional. + * + * @generated from field: string text = 3; + */ + text = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "support.v1.ReportPlaceRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "tile_id", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 2, name: "reason", kind: "enum", T: proto3.getEnumType(PlaceReportReason) }, + { no: 3, name: "text", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ReportPlaceRequest { + return new ReportPlaceRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ReportPlaceRequest { + return new ReportPlaceRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ReportPlaceRequest { + return new ReportPlaceRequest().fromJsonString(jsonString, options); + } + + static equals(a: ReportPlaceRequest | PlainMessage | undefined, b: ReportPlaceRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(ReportPlaceRequest, a, b); + } +} + +/** + * @generated from message support.v1.ReportPlaceResponse + */ +export class ReportPlaceResponse extends Message { + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "support.v1.ReportPlaceResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ReportPlaceResponse { + return new ReportPlaceResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ReportPlaceResponse { + return new ReportPlaceResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ReportPlaceResponse { + return new ReportPlaceResponse().fromJsonString(jsonString, options); + } + + static equals(a: ReportPlaceResponse | PlainMessage | undefined, b: ReportPlaceResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(ReportPlaceResponse, a, b); + } +} + diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx index 3df820386..1c8bb602c 100644 --- a/apps/frontend/src/main.tsx +++ b/apps/frontend/src/main.tsx @@ -30,6 +30,7 @@ import {rememberSignIn} from "./app/account/rememberedSignIn.ts" import SignInGate from "./app/account/SignInGate.tsx" import {callbackOf, CALLBACK_PATH} from "./domain/signInCallback.ts" import {SHARE_FLAG_PARAM, sharedCountry, withoutSharedFlag} from "./domain/shareCard.ts" +import {linkedTile, TILE_PARAM, withoutLinkedTile} from "./domain/tileLink.ts" // Strip the OAuth code and state from the URL before anything else can read or leak them. const callback = callbackOf(new URL(window.location.href)) @@ -37,7 +38,10 @@ if (callback) window.history.replaceState(null, "", CALLBACK_PATH) const page = new URL(window.location.href) const shared = sharedCountry(page) -if (page.searchParams.has(SHARE_FLAG_PARAM)) window.history.replaceState(null, "", withoutSharedFlag(page)) +const lookingAt = linkedTile(page) +if (page.searchParams.has(SHARE_FLAG_PARAM) || page.searchParams.has(TILE_PARAM)) { + window.history.replaceState(null, "", withoutLinkedTile(withoutSharedFlag(page))) +} const connection = new ConnectionHealth() followBrowser(connection, window) @@ -115,6 +119,7 @@ if (import.meta.env.DEV && import.meta.env.VITE_FAKE_BACKEND) { ","outcome":"REPORT_OUTCOME_ACTED","comment":"removed the message"}' http://127.0.0.1:8081/support.v1.AdminService/DecideReport ``` +- A player reports a drawing on the map by a tile of it. Read the open ones: + +```bash +docker compose exec backend wget -qO- --header 'Content-Type: application/json' --post-data '{"openOnly":true}' http://127.0.0.1:8081/support.v1.AdminService/ListPlaceReports +``` + +- `https://clickplanet.lol/?tile=` opens the game on the tile. To see + who painted around it since a time, and what putting it back would change, do + a dry run (`rings` is the size of the area, 1 to 50, as a bomb's 8): + +```bash +docker compose exec backend wget -qO- --header 'Content-Type: application/json' --post-data '{"tileId":,"rings":8,"before":"2026-10-20T10:00:00Z","dryRun":true}' http://127.0.0.1:8081/planet.v1.AdminService/RestoreArea +``` + +- Then the same without `dryRun` puts each tile back as it was then. The ledger + keeps 72 hours: an older `before` is refused. For an older drawing, give the + area to nobody (`ClearArea`, the same `tileId` and `rings`, a dry run first). + Ban the painters the dry run names with `BanPlayer`. +- Close it with `DecidePlaceReport`, as `DecideReport`. - Every removal and every decision is logged: `journalctl CONTAINER_NAME=cp-backend | grep -E "admin chat message removal|admin report decision"`. diff --git a/proto/planet/v1/admin.proto b/proto/planet/v1/admin.proto index 93a9bdf7a..5deba6ddd 100644 --- a/proto/planet/v1/admin.proto +++ b/proto/planet/v1/admin.proto @@ -27,6 +27,51 @@ service AdminService { rpc GrantCharges(GrantChargesRequest) returns (GrantChargesResponse); rpc GetReplay(GetReplayRequest) returns (GetReplayResponse); + + // Who painted the area around a tile since a time, and what putting the area back as it was then changes. Restores it unless dry_run. + rpc RestoreArea(RestoreAreaRequest) returns (RestoreAreaResponse); + + // Gives every tile of the area around a tile to nobody, for a drawing older than the ledger keeps. Clears it unless dry_run. + rpc ClearArea(ClearAreaRequest) returns (ClearAreaResponse); +} + +message RestoreAreaRequest { + uint32 tile_id = 1; + // The area is every tile within this many tile spacings of the tile, as a bomb's blast is. 1 to 50. + uint32 rings = 2; + // Each tile of the area goes back to what it held then. FailedPrecondition past the ledger's retention. + google.protobuf.Timestamp before = 3; + bool dry_run = 4; +} + +message RestoreAreaResponse { + // In the area. + uint32 tiles = 1; + // That a restore changes: a tile changed since before and not back to what it held then. + uint32 changed = 2; + // That changed: a tile somebody took during the restore stays theirs. + uint32 restored = 3; + // Who took tiles of the area since before, most takes first, 50 at most. Tiles and takes count the area alone. + repeated Player painters = 4; + // The oldest before the ledger can rewind to. + google.protobuf.Timestamp kept_since = 5; + uint32 painters_total = 6; +} + +message ClearAreaRequest { + uint32 tile_id = 1; + // As RestoreArea's. + uint32 rings = 2; + bool dry_run = 3; +} + +message ClearAreaResponse { + // In the area. + uint32 tiles = 1; + // That a flag holds. + uint32 owned = 2; + // That were given to nobody: a tile somebody took during the clear stays theirs. + uint32 cleared = 3; } message GetReplayRequest { diff --git a/proto/support/v1/admin.proto b/proto/support/v1/admin.proto index 56dadc913..50e4d8237 100644 --- a/proto/support/v1/admin.proto +++ b/proto/support/v1/admin.proto @@ -13,6 +13,12 @@ service AdminService { // Closes a report with what the operator did about it. A report decided again keeps the last decision. NotFound for a report no longer kept. rpc DecideReport(DecideReportRequest) returns (DecideReportResponse); + + // Newest first, as ListReports. + rpc ListPlaceReports(ListPlaceReportsRequest) returns (ListPlaceReportsResponse); + + // As DecideReport, for a report of a place. + rpc DecidePlaceReport(DecidePlaceReportRequest) returns (DecidePlaceReportResponse); } enum NoteKind { @@ -107,3 +113,41 @@ message DecideReportRequest { message DecideReportResponse { google.protobuf.Timestamp decided_at = 1; } + +message ListPlaceReportsRequest { + // Unset is every report kept. + google.protobuf.Timestamp since = 1; + // Unset is 100; at most 500. + uint32 limit = 2; + // Only the reports nobody decided yet. + bool open_only = 3; +} + +message ListPlaceReportsResponse { + repeated PlaceReport reports = 1; +} + +message PlaceReport { + string id = 1; + // Empty once the reporter's account is deleted, or past the retention. + string reporter_account_id = 2; + // The name the game shows for the reporter now; empty for an account it can no longer name. + string reporter_name = 3; + uint32 tile_id = 4; + PlaceReportReason reason = 5; + string text = 6; + google.protobuf.Timestamp created_at = 7; + // Unset while the report is open. + ReportDecision decision = 8; +} + +message DecidePlaceReportRequest { + string report_id = 1; + ReportOutcome outcome = 2; + // Optional: why, in the operator's words. + string comment = 3; +} + +message DecidePlaceReportResponse { + google.protobuf.Timestamp decided_at = 1; +} diff --git a/proto/support/v1/support.proto b/proto/support/v1/support.proto index eb0dcf294..1d62e082e 100644 --- a/proto/support/v1/support.proto +++ b/proto/support/v1/support.proto @@ -9,6 +9,9 @@ service SupportService { rpc ReportPlayer(ReportPlayerRequest) returns (ReportPlayerResponse); + // A drawing on the map, by a tile of it: the operator reads it and may put the area back as it was. + rpc ReportPlace(ReportPlaceRequest) returns (ReportPlaceResponse); + rpc GetAttachmentRules(GetAttachmentRulesRequest) returns (GetAttachmentRulesResponse) { option idempotency_level = NO_SIDE_EFFECTS; } @@ -99,3 +102,21 @@ message ReportPlayerRequest { } message ReportPlayerResponse {} + +enum PlaceReportReason { + PLACE_REPORT_REASON_UNSPECIFIED = 0; + // A hateful symbol or hateful words. + PLACE_REPORT_REASON_HATE = 1; + PLACE_REPORT_REASON_SEXUAL = 2; + PLACE_REPORT_REASON_OTHER = 3; +} + +message ReportPlaceRequest { + // A tile of the drawing, the one the reporter picked. + uint32 tile_id = 1; + PlaceReportReason reason = 2; + // Optional. + string text = 3; +} + +message ReportPlaceResponse {}