using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Services;
///
/// Backs the clubs.localhost/* surface used by the 2020.12 watch:
/// * GET /club/home/me — composite home tab payload
/// * GET /club/mine/member — clubs the player is in
/// * GET /club/mine/created — clubs the player created
/// * GET /club/categoryTags — admin-curated tag list
/// * GET /announcements/v2/mine/unread — direct feed
/// * GET /announcements/v2/subscription/mine/unread — subs feed
///
/// Owns no in-memory state — every method round-trips the DbContext so
/// reads stay consistent across replicas. The legacy
/// GroupsController reads + writes the same
/// table, so club rows created via api/groups/v1/ show up here
/// automatically without backfill.
///
public class ClubService(DorkNetDbContext db)
{
/// Clubs the player is a member of (any permissions
/// value above 0 OR a Member=0 row — i.e. presence in the
/// table). The owner's
/// auto-membership row inserted by GroupsController.Create
/// (Permissions=127) also matches.
public Task> MyClubsAsync(long playerId) =>
(from m in db.ClubMemberships
join c in db.Clubs on m.ClubId equals c.Id
where m.PlayerId == playerId && c.State == 0
orderby c.Name
select c).ToListAsync();
/// Clubs whose
/// equals the player. The owner usually also has a membership row,
/// so + this overlap — the watch's
/// "Mine" tab renders the union and de-dupes by ClubId.
public Task> CreatedByAsync(long playerId) =>
db.Clubs
.Where(c => c.CreatorPlayerId == playerId && c.State == 0)
.OrderBy(c => c.Name)
.ToListAsync();
///
/// Unread announcements for clubs the player is a MEMBER of. Drives
/// announcements/v2/mine/unread . The "unread" filter is
/// LEFT JOIN against for
/// the calling player — rows with no read-marker count as unread.
///
public async Task> UnreadDirectAsync(long playerId)
{
var memberClubIds = await db.ClubMemberships
.Where(m => m.PlayerId == playerId)
.Select(m => m.ClubId)
.ToListAsync();
if (memberClubIds.Count == 0) return new();
return await UnreadForClubsAsync(playerId, memberClubIds);
}
///
/// Unread announcements for clubs the player SUBSCRIBES to (rows
/// in ). Drives
/// announcements/v2/subscription/mine/unread . Same unread
/// semantics as the direct feed.
///
public async Task> UnreadSubscriptionAsync(long playerId)
{
var subClubIds = await db.ClubSubscriptions
.Where(s => s.PlayerId == playerId)
.Select(s => s.ClubId)
.ToListAsync();
if (subClubIds.Count == 0) return new();
return await UnreadForClubsAsync(playerId, subClubIds);
}
private async Task> UnreadForClubsAsync(
long playerId, List clubIds)
{
// Pull all candidate announcements + the player's read-markers
// for them in one pass each. EF Core's LEFT-JOIN over a
// subquery generates pathological SQL on SQLite, so the
// anti-join is done in memory after two index-supported reads.
var candidates = await db.ClubAnnouncements
.Where(a => clubIds.Contains(a.ClubId))
.OrderByDescending(a => a.CreatedAt)
.ToListAsync();
if (candidates.Count == 0) return candidates;
var candidateIds = candidates.Select(a => a.Id).ToList();
var readIds = await db.ClubAnnouncementReads
.Where(r => r.PlayerId == playerId && candidateIds.Contains(r.AnnouncementId))
.Select(r => r.AnnouncementId)
.ToListAsync();
var readSet = new HashSet(readIds);
return candidates.Where(a => !readSet.Contains(a.Id)).ToList();
}
///
/// Active category tags in display order. The 2020.12 watch
/// deserialises the response as List<String> , so the
/// controller projects
/// out — the Id/OrderIndex/Active columns are admin-facing only.
///
public Task> CategoryTagsAsync() =>
db.ClubCategoryTags
.Where(t => t.Active)
.OrderBy(t => t.OrderIndex)
.ThenBy(t => t.Name)
.ToListAsync();
///
/// Discover-tab search for GET /club/search . The 2020.12
/// watch can send either a category-only browse request or a text
/// search; the rows are projected by the controller into the
/// CAJHIEENJJD search envelope.
///
public async Task<(List Clubs, int TotalClubs)> SearchAsync(
string? query, string? category, int sort, int count)
{
var take = Math.Clamp(count <= 0 ? 30 : count, 1, 100);
var q = db.Clubs.Where(c => c.State == 0);
if (!string.IsNullOrWhiteSpace(category))
{
var categoryLower = category.Trim().ToLower();
q = q.Where(c => c.Category.ToLower() == categoryLower);
}
if (!string.IsNullOrWhiteSpace(query))
{
var queryLower = query.Trim().ToLower();
q = q.Where(c =>
c.Name.ToLower().Contains(queryLower) ||
c.Description.ToLower().Contains(queryLower));
}
var total = await q.CountAsync();
q = sort switch
{
1 => q.OrderByDescending(c => c.CreatedAt).ThenBy(c => c.Name),
2 => q.OrderBy(c => c.Name),
_ => q.OrderByDescending(c => c.ClubType)
.ThenByDescending(c => c.UpdatedAt)
.ThenBy(c => c.Name),
};
var clubs = await q.Take(take).ToListAsync();
return (clubs, total);
}
///
/// Clubs the player is subscribed to, in the lowercase-key wire
/// shape LNIKPLKOBDK expects per
/// dist/.../IsilDump/Assembly-CSharp/LNIKPLKOBDK.txt:110-124 :
/// {accountId, clubId, subscriberCount} . One row per
/// (player, club) subscription row. subscriberCount is the
/// total subscribers for that club across all players — derived
/// live from the same table so it stays in sync without a cached
/// counter that could drift.
///
public async Task> MySubscriptionsAsync(long playerId)
{
var mine = await db.ClubSubscriptions
.Where(s => s.PlayerId == playerId)
.Select(s => s.ClubId)
.ToListAsync();
if (mine.Count == 0) return new List();
var counts = await db.ClubSubscriptions
.Where(s => mine.Contains(s.ClubId))
.GroupBy(s => s.ClubId)
.Select(g => new { ClubId = g.Key, Count = g.Count() })
.ToListAsync();
var byClub = counts.ToDictionary(c => c.ClubId, c => c.Count);
return mine
.Select(clubId => (object)new
{
accountId = playerId,
clubId = clubId,
subscriberCount = byClub.TryGetValue(clubId, out var c) ? c : 0,
})
.ToList();
}
///
/// Per-player settings key holding the explicitly chosen home club.
/// is the server's general-purpose
/// per-player key/value bag (the same store MatchPlayerController
/// and RoomsController use for stateful scalars), so the 2023
/// client's PUT/DELETE club/home/me pair persists here rather
/// than needing a new column on .
///
public const string HomeClubSettingKey = "club.home";
///
/// Composite home-tab payload for GET /club/home/me . The
/// watch's deserialiser for this endpoint expects a single
/// RecNet.Club object (typeof(System.Action`1<PLILLKHMNDA>)
/// per ISIL) — typically the player's "home club" (set via
/// SetMyHomeClub ) rendered as the headline card. An explicit
/// pick made through always wins; when
/// nothing is stored (or the stored club has since been disbanded) we
/// fall back to the most recently joined club so the tab is never
/// blank for an existing member. Returns null when the player has no
/// club memberships at all — the controller turns that into the
/// ClubId=0 sentinel the watch renders as "no home club".
///
public async Task HomeClubAsync(long playerId)
{
var stored = await db.PlayerSettings.AsNoTracking()
.Where(s => s.PlayerId == playerId && s.Key == HomeClubSettingKey)
.Select(s => s.Value)
.FirstOrDefaultAsync();
if (long.TryParse(stored, out var storedId) && storedId > 0)
{
var chosen = await db.Clubs.FirstOrDefaultAsync(c => c.Id == storedId && c.State == 0);
if (chosen is not null) return chosen;
}
// Most-recent-joined club wins. Owners are members too (the
// /create flow inserts an owner-permissions=127 membership
// row), so a brand-new club creator sees their fresh club here.
var pick = await (from m in db.ClubMemberships
join c in db.Clubs on m.ClubId equals c.Id
where m.PlayerId == playerId && c.State == 0
orderby m.JoinedAt descending
select c).FirstOrDefaultAsync();
return pick;
}
/// Persist the player's explicit home-club pick. Returns false
/// when the club id doesn't resolve to a live club so the caller can 404
/// instead of storing a dangling id.
public async Task SetHomeClubAsync(long playerId, long clubId)
{
if (!await db.Clubs.AnyAsync(c => c.Id == clubId && c.State == 0)) return false;
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == playerId && s.Key == HomeClubSettingKey);
if (row is null)
{
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = playerId,
Key = HomeClubSettingKey,
Value = clubId.ToString(),
});
}
else
{
row.Value = clubId.ToString();
}
await db.SaveChangesAsync();
return true;
}
/// Drop the player's explicit home-club pick. Idempotent —
/// missing row is a no-op, matching the client's fire-and-forget
/// "Remove Home Clubhouse Room" call.
public async Task ClearHomeClubAsync(long playerId)
{
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == playerId && s.Key == HomeClubSettingKey);
if (row is null) return;
db.PlayerSettings.Remove(row);
await db.SaveChangesAsync();
}
///
/// Disband a club — the client's "Delete Club" action. Owner-only
/// (unlike , which co-owners and moderators
/// can drive), and a soft delete: State is stamped with the client's
/// IACDINKNHKB.MarkedForDelete = 1000 so every read path here
/// (all of which filter State == 0 ) stops surfacing it while
/// announcements / memberships stay intact for moderation review.
/// Returns null when the club doesn't exist; throws
/// when the caller isn't the
/// creator.
///
public async Task DisbandAsync(long clubId, long callerId)
{
var club = await db.Clubs.FirstOrDefaultAsync(c => c.Id == clubId && c.State == 0);
if (club is null) return null;
if (club.CreatorPlayerId != callerId) throw new UnauthorizedAccessException();
club.State = 1000; // IACDINKNHKB.MarkedForDelete
club.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return club;
}
/// Every announcement across the clubs the player is a real
/// member of (pending invites/requests = 128 and ban markers = 256 are
/// excluded — they must not leak a club's board). Backs the flat
/// GET /announcements/mine list, which is a different wire shape
/// from the per-club unread rollup.
public async Task> AnnouncementsForMemberClubsAsync(
long playerId, int take = 100)
{
var clubIds = await db.ClubMemberships
.Where(m => m.PlayerId == playerId && (m.Permissions & 384) == 0)
.Select(m => m.ClubId)
.ToListAsync();
return await AnnouncementsAcrossClubsAsync(clubIds, take);
}
/// Every announcement across the clubs the player subscribes
/// to. Backs the flat GET /announcements/subscription/mine
/// list.
public async Task> AnnouncementsForSubscribedClubsAsync(
long playerId, int take = 100)
{
var clubIds = await db.ClubSubscriptions
.Where(s => s.PlayerId == playerId)
.Select(s => s.ClubId)
.ToListAsync();
return await AnnouncementsAcrossClubsAsync(clubIds, take);
}
private async Task> AnnouncementsAcrossClubsAsync(
List clubIds, int take)
{
if (clubIds.Count == 0) return new List();
// Disbanded clubs keep their rows but must not surface on the
// aggregated feed, so the club set is re-filtered on State.
var live = await db.Clubs
.Where(c => clubIds.Contains(c.Id) && c.State == 0)
.Select(c => c.Id)
.ToListAsync();
if (live.Count == 0) return new List();
return await db.ClubAnnouncements
.Where(a => live.Contains(a.ClubId))
.OrderByDescending(a => a.CreatedAt)
.ThenByDescending(a => a.Id)
.Take(Math.Clamp(take, 1, 500))
.ToListAsync();
}
///
/// Upserts a read-marker so subsequent
/// / calls skip the
/// announcement. Idempotent — calling twice is a no-op (the unique
/// index on (AnnouncementId, PlayerId) guarantees one row).
///
public async Task MarkAnnouncementReadAsync(long playerId, long announcementId)
{
var existing = await db.ClubAnnouncementReads
.FirstOrDefaultAsync(r => r.PlayerId == playerId
&& r.AnnouncementId == announcementId);
if (existing is not null) return;
db.ClubAnnouncementReads.Add(new ClubAnnouncementReadEntity
{
AnnouncementId = announcementId,
PlayerId = playerId,
ReadAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
}
///
/// Live count of approved members for a club (used by the wire
/// MemberCount int — see doc).
///
public Task MemberCountAsync(long clubId) =>
db.ClubMemberships.CountAsync(m => m.ClubId == clubId);
/// Subscribers TO a club (everyone who hit subscribe on
/// the club page). Backs /subscription/subscriberCount/{id}
/// when the id resolves to a club.
public Task ClubSubscriberCountAsync(long clubId) =>
db.ClubSubscriptions.CountAsync(s => s.ClubId == clubId);
/// Subscribers TO a player (everyone who hit subscribe on
/// the player's profile — the pre-club subscription model in
/// ). Backs
/// /subscription/subscriberCount/{id} when the id resolves
/// to a player account.
public Task PlayerSubscriberCountAsync(long playerId) =>
db.Subscriptions.CountAsync(s => s.TargetPlayerId == playerId);
/// How many player/profile subscriptions this account has
/// made.
public Task PlayerSubscribedCountAsync(long playerId) =>
db.Subscriptions.CountAsync(s => s.SubscriberPlayerId == playerId);
public Task IsSubscribedToPlayerAsync(long subscriberId, long targetPlayerId) =>
db.Subscriptions.AnyAsync(s => s.SubscriberPlayerId == subscriberId
&& s.TargetPlayerId == targetPlayerId);
///
/// Bulk member-count lookup so the controller can project a list
/// of clubs in one round-trip. Returns a dict keyed by ClubId; any
/// club without members maps to 0 in the caller's projection.
///
public async Task> MemberCountsAsync(IEnumerable clubIds)
{
var ids = clubIds.Distinct().ToList();
if (ids.Count == 0) return new();
var rows = await db.ClubMemberships
.Where(m => ids.Contains(m.ClubId))
.GroupBy(m => m.ClubId)
.Select(g => new { ClubId = g.Key, Count = g.Count() })
.ToListAsync();
return rows.ToDictionary(r => r.ClubId, r => r.Count);
}
/// Single club lookup by id. Returns null for missing
/// or disbanded (State != 0) clubs so the controller surfaces 404
/// before any wire-shape projection runs.
public Task GetByIdAsync(long clubId) =>
db.Clubs.FirstOrDefaultAsync(c => c.Id == clubId && c.State == 0);
/// Single club lookup by exact display name. Used by
/// GET /club?name=... ; case-insensitive matching keeps the
/// browse/name-resolve path stable across SQLite and Postgres
/// collations.
public Task GetByNameAsync(string name)
{
var trimmed = name.Trim().ToLower();
return db.Clubs.FirstOrDefaultAsync(c => c.State == 0 && c.Name.ToLower() == trimmed);
}
/// Single membership row lookup. Used by both the
/// /club/{id}/members/{playerId} read and every member-mutation
/// endpoint to verify the target row exists before acting.
public Task MembershipForAsync(long clubId, long playerId) =>
db.ClubMemberships.FirstOrDefaultAsync(m => m.ClubId == clubId && m.PlayerId == playerId);
/// Bulk member-row lookup for members/bulk . Returns
/// only rows that exist; missing playerIds are silently dropped (the
/// watch handles a shorter list fine — it just renders no row).
public Task> MembershipsBulkAsync(long clubId, IEnumerable playerIds)
{
var ids = playerIds.Distinct().ToList();
if (ids.Count == 0) return Task.FromResult(new List());
return db.ClubMemberships
.Where(m => m.ClubId == clubId && ids.Contains(m.PlayerId))
.ToListAsync();
}
/// List clubs an arbitrary player is a MEMBER of. Drives
/// account/{playerId}/clubs on a profile card; uses the same
/// State=0 filter as the my-clubs read so disbanded clubs stay
/// hidden.
public Task> ClubsForPlayerAsync(long playerId) =>
(from m in db.ClubMemberships
join c in db.Clubs on m.ClubId equals c.Id
where m.PlayerId == playerId && c.State == 0
orderby c.Name
select c).ToListAsync();
///
/// 2020.12 PPGPAHNMGEC MembershipType wire enum verified
/// against dist\RecRoom-2020.12.18-dump\DiffableCs\Assembly-CSharp\PPGPAHNMGEC.cs .
/// These ARE the values the watch decodes — earlier 0..N guesses
/// were wrong: MyMembershipType=4 would have rendered as
/// "Pending_Denied" instead of "Creator" and disabled every owner
/// affordance in the watch UI.
///
public const int MembershipTypeBanned = -1;
public const int MembershipTypeNotMember = 0;
public const int MembershipTypeRequested = 1; // Pending_Requested
public const int MembershipTypeInvited = 2; // Pending_Invited
public const int MembershipTypeDenied = 3; // Pending_Denied
public const int MembershipTypeMember = 10;
public const int MembershipTypeModerator = 20;
public const int MembershipTypeCoOwner = 30;
public const int MembershipTypeOwner = 100; // Creator on the wire
public static int MembershipTypeFromPerms(int perms)
{
// 256 = ban marker (set by /members/ban handler).
if ((perms & 256) != 0) return MembershipTypeBanned;
// 128 = pending (set by /members/invite + /requesttojoin handlers).
// We don't yet distinguish invite-from vs request-to on the perms
// int; surface Requested by default so the caller's "I asked to
// join" UI lights up. (Outgoing invites from owners use the same
// marker; the watch shows them under "pending invites" either
// way because the deserializer only branches on
// Member/Moderator/CoOwner/Creator vs Pending_*.)
if ((perms & 128) != 0) return MembershipTypeRequested;
if (perms >= 127) return MembershipTypeOwner;
if (perms >= 124) return MembershipTypeCoOwner;
if (perms >= 24) return MembershipTypeModerator;
return MembershipTypeMember;
}
/// Inverse of for
/// changetype . Returns the canonical perms int to assign
/// when the caller sets a new role. Owner (Creator=100 on the wire)
/// is intentionally excluded — promotion to Owner is a
/// transfer-ownership flow, not a changetype call.
public static int PermsFromMembershipType(int membershipType) => membershipType switch
{
MembershipTypeCoOwner => 124,
MembershipTypeModerator => 24,
MembershipTypeMember => 0,
_ => 0,
};
///
/// Persist a club-targeted moderation report. Stores into the same
/// table the admin queue reads from, with
/// carrying the clubId (we
/// don't have a separate TargetClubId column yet — the admin SPA
/// renders any non-zero target field on the report card, so this
/// surfaces as a club report there too via the Message prefix).
///
public async Task AddClubReportAsync(long clubId, long reporterId, int category, string message)
{
db.Reports.Add(new ReportEntity
{
ReporterPlayerId = reporterId,
TargetPlayerId = 0,
TargetRoomId = clubId,
Category = category,
Message = $"[club {clubId}] {message ?? string.Empty}",
});
await db.SaveChangesAsync();
}
///
/// Create a new club + the owner's membership row in one go. Per
/// the 2020.12 wire club/create response shape, the caller
/// projects the resulting into the
/// PIHMJGCGNLP envelope via the controller's wire mappers.
/// Returns null when the name collides with an existing club so
/// the caller can surface a 409.
///
public async Task CreateAsync(long creatorId, string name, string? description, string? imageName, string? category)
{
var trimmed = (name ?? string.Empty).Trim();
if (trimmed.Length == 0) return null;
if (await db.Clubs.AnyAsync(c => c.Name == trimmed)) return null;
var club = new ClubEntity
{
Name = trimmed,
Description = description ?? string.Empty,
ImageName = imageName ?? string.Empty,
Category = category ?? string.Empty,
CreatorPlayerId = creatorId,
};
db.Clubs.Add(club);
await db.SaveChangesAsync();
db.ClubMemberships.Add(new ClubMembershipEntity
{
ClubId = club.Id,
PlayerId = creatorId,
Permissions = 127, // Owner
});
await db.SaveChangesAsync();
return club;
}
/// Apply a free-form mutation to a club row after
/// verifying the caller is owner/co-owner. Returns the updated
/// entity on success, null on missing club, or throws
/// when the caller can't
/// modify. Used by every club/{id}/modify* endpoint variant.
public async Task ModifyAsync(long clubId, long callerId, Action mutator)
{
var club = await db.Clubs.FirstOrDefaultAsync(c => c.Id == clubId && c.State == 0);
if (club is null) return null;
if (!await CanManageAsync(clubId, callerId, club))
throw new UnauthorizedAccessException();
mutator(club);
club.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return club;
}
///
/// Check whether the caller can perform admin-class mutations on a
/// club (owner, co-owner, or moderator depending on the action).
/// Owner always wins; co-owner and moderator are looked up via the
/// membership perms int. Used by every club/{id}/modify* and
/// member mutation route.
///
public async Task CanManageAsync(long clubId, long callerId, ClubEntity? club = null)
{
club ??= await db.Clubs.AsNoTracking().FirstOrDefaultAsync(c => c.Id == clubId);
if (club is null) return false;
if (club.CreatorPlayerId == callerId) return true;
var membership = await db.ClubMemberships.AsNoTracking()
.FirstOrDefaultAsync(m => m.ClubId == clubId && m.PlayerId == callerId);
if (membership is null) return false;
// Pending invites/requests (128) and ban markers (256) must NOT
// grant moderation rights. Without this mask a pending invitee
// with perms=128 evaluates `>= 24` true and inherits the ability
// to ban/promote — full moderation hijack via "I'd like to join".
var perms = membership.Permissions;
if ((perms & 128) != 0 || (perms & 256) != 0) return false;
return perms >= 24;
}
/// Upsert a membership row at a target role. Used by
/// invite/request/accept/changetype/directJoin/etc. Returns the
/// row for callers that need to inspect or surface it; for void
/// endpoints the caller just discards the result.
public async Task UpsertMembershipAsync(long clubId, long targetPid, int perms)
{
var row = await db.ClubMemberships
.FirstOrDefaultAsync(m => m.ClubId == clubId && m.PlayerId == targetPid);
if (row is null)
{
row = new ClubMembershipEntity
{
ClubId = clubId,
PlayerId = targetPid,
Permissions = perms,
};
db.ClubMemberships.Add(row);
}
else
{
row.Permissions = perms;
}
await db.SaveChangesAsync();
return row;
}
/// Remove a membership row entirely — used by leave,
/// remove, ban (after stamping a separate ban row if/when we
/// introduce one), declineinvite, denyrequest. Idempotent: missing
/// row → no-op.
public async Task RemoveMembershipAsync(long clubId, long targetPid)
{
var row = await db.ClubMemberships
.FirstOrDefaultAsync(m => m.ClubId == clubId && m.PlayerId == targetPid);
if (row is null) return;
db.ClubMemberships.Remove(row);
await db.SaveChangesAsync();
}
/// List the announcements for a single club in newest-first
/// order. Drives announcements/club/{cid} when the watch
/// renders the club's full announcement feed (not just the unread
/// rollup the home tab uses).
public Task> AnnouncementsForClubAsync(long clubId, int take = 50) =>
db.ClubAnnouncements
.Where(a => a.ClubId == clubId)
.OrderByDescending(a => a.CreatedAt)
.Take(take)
.ToListAsync();
/// Single announcement lookup by (clubId, announcementId).
/// Returns null when either id is unknown so the controller can
/// 404 cleanly.
public Task AnnouncementAsync(long clubId, long announcementId) =>
db.ClubAnnouncements.FirstOrDefaultAsync(a => a.Id == announcementId && a.ClubId == clubId);
/// Create a club announcement. Returns the new row's id, or null
/// when the caller may not manage the club.
public async Task CreateAnnouncementAsync(
long clubId, long callerId, string title, string body, string imageName)
{
if (!await CanManageAsync(clubId, callerId)) return null;
var row = new ClubAnnouncementEntity
{
ClubId = clubId,
AuthorPlayerId = callerId,
Title = Truncate(title, 200),
Body = Truncate(body, 4000),
ImageName = Truncate(imageName, 256),
};
db.ClubAnnouncements.Add(row);
await db.SaveChangesAsync();
return row.Id;
}
/// Edit an existing announcement in place. Null arguments leave
/// the corresponding field untouched. Returns false when the caller may
/// not manage the club or the row is unknown.
public async Task UpdateAnnouncementAsync(
long clubId, long announcementId, long callerId,
string? title, string? body, string? imageName)
{
if (!await CanManageAsync(clubId, callerId)) return false;
var row = await db.ClubAnnouncements
.FirstOrDefaultAsync(a => a.Id == announcementId && a.ClubId == clubId);
if (row is null) return false;
if (title is not null) row.Title = Truncate(title, 200);
if (body is not null) row.Body = Truncate(body, 4000);
if (imageName is not null) row.ImageName = Truncate(imageName, 256);
row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return true;
}
private static string Truncate(string? value, int max)
{
value = (value ?? string.Empty).Trim();
return value.Length <= max ? value : value[..max];
}
/// Delete an announcement after verifying the caller can
/// manage the club. Idempotent: missing row → no-op.
public async Task DeleteAnnouncementAsync(long clubId, long announcementId, long callerId)
{
if (!await CanManageAsync(clubId, callerId)) return false;
var row = await db.ClubAnnouncements
.FirstOrDefaultAsync(a => a.Id == announcementId && a.ClubId == clubId);
if (row is null) return true; // idempotent — caller treats as success
db.ClubAnnouncements.Remove(row);
await db.SaveChangesAsync();
return true;
}
///
/// Idempotent seed of the default category tags. Inserts the
/// canonical Sports / Creative / Roleplay / Hangout / Education
/// tags on first launch; re-running on a populated table bails
/// without touching existing rows so admin edits are never
/// clobbered.
///
public async Task SeedDefaultsAsync()
{
if (await db.ClubCategoryTags.AnyAsync()) return;
var defaults = new (string Name, int Order)[]
{
("Sports", 0),
("Creative", 1),
("Roleplay", 2),
("Hangout", 3),
("Education", 4),
};
foreach (var (name, order) in defaults)
{
db.ClubCategoryTags.Add(new ClubCategoryTagEntity
{
Name = name,
OrderIndex = order,
Active = true,
});
}
await db.SaveChangesAsync();
}
}