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