Real /load/index captures ship mission_meta.mission_change_time as a
"yyyy-MM-dd HH:mm:ss" string (see FriendService.DefaultMissionChangeTime for the
canonical game format) but ImportViewerRequest typed it as long? Unix seconds. The
FromUnixTimeSeconds branch had never been reachable with a real capture, and any
loader dump carrying mission_meta 400d the whole request. Change the DTO field to
string? and parse in the controller with DateTime.TryParseExact + invariant culture
(AssumeUniversal | AdjustToUniversal); unparseable values skip cleanly instead of
poisoning the whole viewer import.
Also fix is_received_two_pick_mission: DTO's JsonPropertyName was
has_received_pick_two_mission (never matched the wire key), and the wire ships "1"
/"0" strings not booleans. Rename to the actual wire key, retype to int?, controller
normalizes `hrptm != 0` before assigning to the bool DB property.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Anonymous /admin/import_viewer was safe locally but exposed viewer-import surface
whenever the server was reachable off-box. Gate it on a shared secret carried in
X-Admin-Secret, sourced from Admin:ImportSecret in appsettings (override via
ADMIN__IMPORTSECRET env var). Fails closed: an unconfigured deployment 401s every
request (with a logged warning) instead of leaving the endpoint open.
Implementation is a plain IAuthorizationFilter attribute (RequireAdminSecret) that
runs before model binding — short-circuits the ImportViewerRequest deserialize on
unauthorized calls. Compare uses CryptographicOperations.FixedTimeEquals to avoid
timing signal. A companion Swagger IOperationFilter registers an ApiKey security
definition and attaches the requirement ONLY to actions carrying the attribute, so
the Swagger UI shows an Authorize dialog for admin endpoints without decorating
ordinary game routes with a padlock.
Tests: existing 27 AdminController tests routed through a new
SVSimTestFactory.CreateAdminClient() helper that bakes the header from
appsettings.Testing.json; added two negative-path tests
(missing_secret_header, wrong_secret) — 29/29 passing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Move the "all-time" constant out of GameCalendarService (concrete) into a new
static class GameCalendarPeriods. Consumers using IGameCalendarService no
longer have to reach through the concrete class to name the lifetime bucket.
Migrates 5 callsites (mission progress/assembler, admin, item purchase, tests).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Swaps 6 JstPeriod.* callsites across AdminController (mission counter
resolution for the /admin catalog) and ItemPurchaseController (monthly
reset bucket key + all-time period).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When missions/achievements shrink between re-imports, Pass B and Pass C
now compute the set of (EventKey, Period) counter keys from OLD rows
(before Clear()) and delete any that are not referenced by the new
payload. Prevents stale counters accumulating across import cycles.
TDD: two new failing tests written first (RED), then the fix (GREEN).
All 1383 tests pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pre-materialize ViewerEventCounters before the mission loop so in-flight
Adds are visible to subsequent iterations sharing the same (EventKey, Period);
add optional ImportMission.Slot field so callers can supply weekly slot identity
(2 or 3) rather than relying on the LotType-derived default of 1.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A real /load/index dump emits my_rotation_id as a bare number (0) for
unset MyRotation slots, which 400'd against the string? DTO field
(AllowReadingFromString only covers string->number). FlexibleStringConverter
accepts either form. Also skip empty deck slots (no cards) on import — a
dump carries every slot, mostly empty placeholders the client manages
itself.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extends POST /admin/import_viewer to accept owned_cards (snapshot full-replace).
Unknown card_ids are skipped, counted in skipped_card_count on the response, and
logged server-side. Count is clamped to OwnedCardEntry.MaxCopies (3). Also injects
ILogger into AdminController and adds Cards/Items/Decks to the viewer reload graph.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>