dev #6

Merged
yami merged 14 commits from dev into main 2026-08-11 13:14:53 -04:00
Owner
No description provided.
yami added 14 commits 2026-08-11 13:14:24 -04:00
feat: add per-static permission model (Owner/Lead/Member/Viewer)
All checks were successful
Docker image / build (push) Successful in 1m0s
cd85e19be8
Adds StaticMembership table + StaticPermissionService as single source
of truth for static authorization, replacing duplicated claim-check
logic in StaticController/PlayerController. Auto-grants Member role on
player claim, closes previously-unauthenticated gaps on DeleteStatic/
UpdateStatic/UpdateLockParam/SetPGSParam, and adds Members endpoints
for Lead+ to manage roles. Idempotent backfill service syncs existing
ownerIdString/claim data into the new table on startup.
feat: add LootRoadmap master priority list and per-loot-type overrides
All checks were successful
Docker image / build (push) Successful in 15s
23c7af7328
Implements GetRoadmap/SetMasterPriorityList/SetOverride/ClearOverride per
docs/backend-spec-loot-roadmap.md, gated on StaticRole.Member, broadcasting
UpdateLootRoadmap over the loothub group.
GetPendingMigrationsAsync and MigrateAsync are relational-only and throw
on the InMemory provider the integration tests use, so every test in the
suite failed at host startup with:

  Relational-specific methods can only be used when the context is using
  a relational database provider.

IsRelational() alone is not a sufficient guard here: UseLowerCaseNamingConvention()
registers relational services even on top of InMemory, so it reports true.
Check the provider name as well.

Also resolve the context through IDbContextFactory rather than the scoped
DataContext, so a test host that swaps the factory is actually honoured.

On the test side, GearCache is a process-wide static keyed only by gear id
and populated from whichever DataContext reaches it first. With fixtures
seeding separate in-memory databases, one fixture's gear answers another's
lookups. Disable assembly-level parallelization and have the fixture claim
the cache when it seeds.
GetAllTimestampOfStatic ran one query per player and then one more per
acquisition row to resolve its gear, so a static with a full tier of loot
history cost hundreds of round-trips. It now issues two queries and
resolves gear from the existing process-wide GearCache.

GetItemNeedForPlayers resolved the raid tier and its expansion inside the
nested gear-type/player loops, repeating an identical query roughly 90-110
times per request. Hoisted above both loops.

GetStaticByUUID loaded every account in the database with a non-empty
claim string in order to build an eight-entry playerId -> accountId map,
so its cost grew with total signups rather than roster size. It now reads
StaticMemberships on the indexed (StaticId, UserIdString) and fetches only
those accounts. This is sound because every claim path calls
EnsureMinimumMembershipAsync and startup backfills legacy rows.

Smaller items in the same paths: playerList and GetOnlyStaticName no
longer block a thread-pool thread on synchronous EF calls, and AddStatic
no longer materialises every static only to discard it, nor re-reads the
row it just inserted to recover a key EF already populated.

Two behaviour changes worth noting:

- The date-grouping code had two copies of the DTO construction, and the
  branch that opened a new date's bucket omitted isAcquiredFromBook. The
  first drop recorded on any given date therefore always read back as not
  from a book. Unifying the branches fixes that; a test covers it.
- GetOnlyStaticName returns 404 for an unknown static instead of throwing
  into a 500.

Query-count reductions are structural rather than measured: the test suite
runs on the InMemory provider, which emits no DbCommand log. The new tests
verify behaviour is preserved, not the round-trip count.
feat(user): add SavedStaticSummaries endpoint
All checks were successful
Docker image / build (push) Successful in 15s
95724ba738
The saved-statics sidebar needs a name and a tier per saved static and
nothing else. It got there by calling GetUserSavedStatic and then
Static/{uuid} once per result, and Static/{uuid} returns the full
StaticDTO: every player, their whole gear set, and the entire gear option
catalog per slot. Several hundred KB to render a list of names.

GET api/User/SavedStaticSummaries returns {uuid, name, tier} in one
response, preserving the user's saved order and dropping uuids whose
static no longer exists rather than surfacing them as failures.

One of the tests asserts the payload contains no playersInfoList,
gearOptionPerGearType or currentGearSet, so the endpoint fails loudly if
StaticDTO ever leaks back into it.

The test factory also had to remap the Identity.Application and Bearer
schemes onto the test auth handler, since endpoints that pin their schemes
explicitly are not covered by overriding the default scheme.
Gear options were embedded in StaticDTO once per player. The data is
identical for every player sharing a job, changes only when an admin edits
gear, and is by far the largest part of the payload — yet it was re-sent on
every roster load and on every SignalR-triggered refetch, which during a
raid night is once per recorded drop.

Measured against a seeded 8-job party with a realistic tier catalog:

  static WITH options      raw 151,498 B   brotli 6,613 B
  static WITHOUT options   raw  25,722 B   brotli   811 B
  gear options, 8 jobs     raw 125,576 B   (now fetched once, cacheable)

Cold first load moves roughly the same total bytes, since the options are
still needed once. Every load after that, and every realtime refetch, drops
83% raw / 88% brotli.

New GET api/Gear/Options/{tier}/{job} returns the same per-slot shape the
DTO used to inline, with Cache-Control and an ETag keyed on GearCache.Version
so an admin gear edit invalidates it on the next revalidation instead of
waiting out max-age. GearCache now carries that version counter.

GetStaticByUUID and GetSingletonPlayerInfo take includeGearOptions, default
true, so existing clients are untouched; when false the field is omitted from
the JSON entirely rather than serialised empty, letting a client tell
"fetch these separately" from "this job has none". Building the options is
skipped server-side in that case too.

Tests cover the default-inlined path, the omitted path, the endpoint's shape
and job filtering, and ETag revalidation returning 304.
max-age=3600 meant a client could serve a gear catalog up to an hour out of
date after an admin edit, which is worst exactly when it matters — patch day,
when gear is being added and people are looking at it.

Cache-Control is now `public, no-cache`, which stores the response but
requires revalidation before reuse rather than preventing caching. Combined
with the existing GearCache.Version ETag, an unchanged catalog costs a
bodyless 304 and a changed one is picked up on the very next request, so the
staleness window is gone while the bandwidth saving is essentially kept.

Adds a test that a stale ETag stops validating once GearCache.Invalidate()
runs, which is what every gear mutation path calls. Without that, clients
would keep serving a catalog that no longer matches the database.
feat(static): add batch roster summaries for the statics list page
All checks were successful
Docker image / build (push) Successful in 15s
8b815e3778
The statics list page called GET Static/{uuid} once per saved static and
once per recently-visited one — a full StaticDTO each — to render member
counts, alt counts, unclaimed counts and a row of role pucks. Two queries
now serve the whole page.

GET api/Static/Summaries?uuids=a,b,c returns roster aggregates in the order
requested, dropping uuids whose static no longer exists. Recently-visited
statics live in localStorage rather than on the account, so they need a
batch-by-uuid endpoint; SavedStaticSummaries covers the saved ones and now
shares the same StaticSummaryBuilder, so the two cannot drift.

The summary carries MemberCount, AltCount, OpenClaimCount, the non-alt
roster slots (id, name, job) and every player id. Names and jobs are there
on purpose: the cards render initials and a role puck per slot, and dropping
them would have quietly broken that. Gear is what made StaticDTO too heavy
to fetch per row, and a test asserts it stays out.

PlayerIds lets a client test its own claimed-player set against a static
without the server needing to know that set, preserving how the page filters
recents down to statics the user has not already claimed into.

No authorization: GET Static/{uuid} is already unauthenticated so this
exposes nothing new, but the batch is capped at 50 uuids so it cannot be
turned into a bulk-enumeration tool.
UseDeveloperExceptionPage was registered unconditionally, so any request
that triggered a 500 in production got back the exception message, the full
stack trace and surrounding source lines — file paths, query shapes and
connection errors included. It is now development-only. Without it an
unhandled exception returns a bare 500, which is the safe default.

Also removes a middleware that ran on every request to register an
OnStarting callback which looped over the response headers into
commented-out Console.WriteLine calls, doing nothing.
Every scheduling and upcoming-sessions read filters on StaticUuid, but the
only index on the table was the automatic one on CategoryId, so those reads
were sequential scans. The table only grows — a recurring session inserts a
row per occurrence.

The migration is additive and index-only, so it is safe to apply while an
older build is still serving. Note that plain CREATE INDEX takes a write
lock for the duration; if raidsessions has grown large, apply it by hand as
CREATE INDEX CONCURRENTLY instead, which cannot run inside the transaction
EF wraps migrations in.
The etro and xivgear BiS imports resolved each slot with its own
context.Gears.FirstOrDefault — roughly eleven synchronous, thread-blocking
database round-trips per import — for data GearCache already holds in
memory.

GearCache gains two secondary indexes over the copy it already loads: one
keyed by (XIVAPI item id, slot) and one by item id alone, matching the two
ways the import branches address a piece. Both are dropped by Invalidate()
alongside the primary index, or an import would keep resolving gear an admin
had just deleted.

Collisions now resolve to the lowest database id. The callers this replaces
used FirstOrDefault with no ordering, so they took whatever the database
happened to return; choosing deterministically is strictly better.
perf(api): raise response compression from Fastest to Optimal
All checks were successful
Docker image / build (push) Successful in 15s
e58a1ce4ad
Measured across this API's real responses — static payloads with and without
gear options, a job's gear options, and batch summaries:

  static (6,726 B raw)   Fastest 714 B / 0.013 ms
                         Optimal 662 B / 0.061 ms    7.3% smaller
  gear options (1,825 B) Fastest 276 B / 0.008 ms
                         Optimal 235 B / 0.027 ms   14.9% smaller

Optimal costs 2-5x the compression time, but that time is fractions of a
millisecond, so it is a clear trade for 7-15% fewer bytes on every response.

SmallestSize is deliberately not used. It reaches only ~19% below Fastest —
a couple of points better than Optimal — at 150-500x the time, 8.6 ms to
compress 10 KB, which extrapolates to around 100 ms on a full static payload.
That is real per-request CPU for a marginal gain.
feat(availability): add ResetStaticAvailability
All checks were successful
Docker image / build (push) Successful in 15s
99097ccd3e
The scheduling tab's reset button has been calling
PUT api/Availability/ResetStaticAvailability/{staticUuid} since it shipped,
but the route was never implemented. availability.service.ts catches the
failure and logs it, so the button has been silently doing nothing in
production — the only endpoint in the scheduling contract that was missing.

Wipes every AvailabilityMark for the static: all accounts, both the typical
and dated scopes. Deliberately broader than ClearMyAvailability, which is
scoped to the caller and one week.

Authorization matches UnclaimStaticOwnerShip/GetOwnerName rather than
StaticRole.Lead. This destroys other members' data with no undo, so it stays
with whoever owns the static. A static with an empty ownerIdString is
rejected too — no owner means nobody is entitled to wipe it, and treating
"" as unrestricted would have made unclaimed statics wipeable by any
authenticated user.

Broadcasts UpdateAvailability on the existing loothub group so connected
clients refetch. The payload is null rather than an account id, since this
is not one person's change; the client handler ignores its arguments and
just refetches, so this is only a hint.

Tests cover the owner path (both accounts and both scopes cleared, and only
for the targeted static), the non-owner rejection, the unowned-static
rejection, and an unknown static. Each seeds its own static so the
destructive case cannot affect the others. Verified the authorization test
is a real guard by removing the check and confirming it, and only it, fails.
yami merged commit ac58e6b245 into main 2026-08-11 13:14:53 -04:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
yami/XIVLoot!6
No description provided.