using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using DorkNet.Server.Auth; using DorkNet.Server.Binding; using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; using DorkNet.Server.Services; namespace DorkNet.Server.Controllers.API.Subscriptions; /// /// The creator-subscription mutation pair the 2023-03-21 client hits from a /// profile card's Subscribe button. Both live at the bare two-segment route /// subscription/{accountId} on the Clubs host (enum value 13); the /// route literal is "subscription/{0}" formatted with a boxed /// System.Int32 account id at /// RecNet.Runtime/IKMMOCKDKAF.txt:6551 (POST, verb rdx=2) and /// :6862 (DELETE, verb rdx=4). Callers are the account-model wrappers /// OBEEEEAMLJI.BDJCLAGIHBN/LPFOPPHGDHG(Int32 accountId, …) /// (Assembly-CSharp/OBEEEEAMLJI.txt:644,793) behind /// AGUI/StackedUI/SubscribeConditionalButton, so the path segment is /// always a player account id — never a club id. /// /// The GET side of this surface already exists but lives with the club reads /// (ClubsController: subscription/details/{id}, /// subscription/subscriberCount/{id}, subscription/mine/member) /// and PlayerSubscriptionsController /// (subscription/top/creators/today). Only the two writes were missing, /// which made every subscribe/unsubscribe 404 and surface the client's /// "Failed to subscribe to {0}" / "Failed to unsubscribe from {0}" /// toast (format literals at IKMMOCKDKAF.txt:6663 and the DELETE's /// matching continuation). /// /// Neither response body is parsed: the client's continuation is a bare /// Func<IPCJLCNIBEG<Int64>, …> whose only use is the /// success/failure branch, after which it invalidates its subscription cache /// and re-reads subscription/mine/member + subscription/details. /// Any 2xx is therefore sufficient — but the reads it follows up with must /// reflect the write, which is why these handlers touch BOTH subscription /// tables (see ). /// [ApiController] [Authorize] public class SubscriptionsController( DorkNetDbContext db, ClubService clubs, ILogger logger) : ControllerBase { private long Me => this.RequireCurrentPlayerId(); /// /// Body of the subscribe POST. The client appends exactly one /// form-urlencoded field via BNDIAONDFFF.AFGEDDANEKP("roomId", …) /// (IKMMOCKDKAF.txt:6726 — Move rdx, "roomId"), boxed from an /// ObscuredLong holding the room the player is standing in. The /// preceding compare against -1 (:6713, /// Compare rax, 18446744073709551615 → jump past the append) means /// the field is OMITTED entirely when the player is not in a room, so it /// must be optional. Unsubscribe sends no body at all. /// public sealed class SubscribeRequest { public long RoomId { get; set; } = -1; } /// /// POST /subscription/{accountId} — subscribe to a creator. /// /// Writes two rows, because the 2023 client's post-subscribe refresh reads /// two differently-sourced endpoints and both have to agree: /// * (player→player) is what /// subscription/details/{id}, subscription/subscriberCount/{id}, /// subscription/top/creators/today and the PlayerState reputation /// aggregate all count. This is the canonical row. /// * is what /// subscription/mine/member and /// announcements/v2/subscription/mine/unread read. A creator's /// "creator club" is the club they own (same resolution /// subscription/details uses for its ClubId field — oldest /// id wins), so when the target owns one we mirror the subscription /// there too. Without the mirror the subscribe succeeds but the /// creator never appears in the client's own subscribed list and the /// button flips straight back to "Subscribe" on the next cache refresh. /// Creators with no club get only the canonical row, which is all their /// profile card reads. /// /// Idempotent: re-subscribing is a 200 no-op rather than a unique-index /// violation (both tables carry a unique pair index). /// [HttpPost("/subscription/{accountId:long}")] public async Task Subscribe( long accountId, [ModelBinder(typeof(FormOrJsonModelBinder))] SubscribeRequest req) { var me = Me; if (accountId == me) return BadRequest(new { Error = "cannot_subscribe_to_self" }); var targetExists = await db.Players.AnyAsync(p => p.Id == accountId); if (!targetExists) return NotFound(new { Error = "account_not_found" }); var changed = false; var existing = await db.Subscriptions.FirstOrDefaultAsync( s => s.SubscriberPlayerId == me && s.TargetPlayerId == accountId); if (existing is null) { db.Subscriptions.Add(new SubscriptionEntity { SubscriberPlayerId = me, TargetPlayerId = accountId, }); changed = true; } var creatorClubId = await CreatorClubIdAsync(accountId); if (creatorClubId != 0) { var existingClub = await db.ClubSubscriptions.FirstOrDefaultAsync( s => s.PlayerId == me && s.ClubId == creatorClubId); if (existingClub is null) { db.ClubSubscriptions.Add(new ClubSubscriptionEntity { ClubId = creatorClubId, PlayerId = me, }); changed = true; } } if (changed) await db.SaveChangesAsync(); // roomId is pure attribution — "which room was this creator discovered // in". There is no column for it on SubscriptionEntity and this pass // adds no schema, so it is recorded in the log rather than silently // dropped; the subscription itself is fully persisted above. if (req.RoomId > 0) { logger.LogInformation( "Player {SubscriberId} subscribed to creator {AccountId} from room {RoomId}.", me, accountId, req.RoomId); } return Ok(); } /// /// DELETE /subscription/{accountId} — unsubscribe. Same route /// literal, verb register rdx=4 at /// IKMMOCKDKAF.txt:6864; the request carries no parameters /// (AFGEDDANEKP is never called on the builder before the send), /// so there is nothing to bind. /// /// Removes both rows written by so the canonical /// counts and the subscription/mine/member list drop the creator /// together. Unsubscribing when not subscribed is a 200 no-op — the client /// treats any non-2xx as the "Failed to unsubscribe from {0}" toast, and a /// double-tap of an already-removed subscription is not a failure the /// player should see. /// [HttpDelete("/subscription/{accountId:long}")] public async Task Unsubscribe(long accountId) { var me = Me; var row = await db.Subscriptions.FirstOrDefaultAsync( s => s.SubscriberPlayerId == me && s.TargetPlayerId == accountId); if (row is not null) db.Subscriptions.Remove(row); var creatorClubId = await CreatorClubIdAsync(accountId); if (creatorClubId != 0) { var clubRow = await db.ClubSubscriptions.FirstOrDefaultAsync( s => s.PlayerId == me && s.ClubId == creatorClubId); if (clubRow is not null) db.ClubSubscriptions.Remove(clubRow); } await db.SaveChangesAsync(); return Ok(); } /// /// The club id subscription/details/{accountId} reports as the /// creator's ClubId: the oldest live club the account owns, or 0 /// when they own none. Kept byte-identical to that handler's resolution so /// subscribing writes the row the details card points the player at. /// private async Task CreatorClubIdAsync(long accountId) { var created = await clubs.CreatedByAsync(accountId); return created.Count > 0 ? created.Min(c => c.Id) : 0L; } }