using System.Text.Json;
using System.Text.Json.Serialization;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using Microsoft.EntityFrameworkCore;
namespace DorkNet.Server.Services;
///
/// Single-row Postgres-backed store for the dorm community board state.
///
/// Wire shape mirrors what the watch's
/// CommunityBoard.CommunityBoardDTO.Deserialize reads
/// (Cpp2IL_ISIL/.../CommunityBoard_NestedType_CommunityBoardDTO.txt:460-480):
/// FeaturedPlayer / FeaturedRoomGroup /
/// CurrentAnnouncement / InstagramImages / Videos.
///
/// Pre-PR-3 wrote the JSON to data/community_board.json on disk +
/// kept an in-memory cache. With horizontal scale, an admin edit on
/// replica A wouldn't invalidate replica B's cache — admins would see
/// stale state until the next restart. The single-row table sidesteps
/// the cache-invalidation problem entirely: every read is one cheap
/// SELECT, every write is one UPDATE/INSERT, both visible to all
/// replicas immediately.
///
/// Service is now scoped rather than singleton because it holds
/// a DbContext reference.
///
public class CommunityBoardService(DorkNetDbContext db)
{
/// Fixed PK for the single board row. We pin to id=1 rather
/// than using a singleton-table pattern because EF Core needs a
/// trackable key.
private const int RowId = 1;
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNameCaseInsensitive = true,
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
};
public async Task GetAsync()
{
var row = await db.CommunityBoardRows.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == RowId);
if (row is null) return DefaultState();
try
{
return JsonSerializer.Deserialize(row.Json, JsonOpts)
?? DefaultState();
}
catch (JsonException)
{
// Corrupt row — return default rather than 500. Next admin
// edit will overwrite the bad payload.
return DefaultState();
}
}
public async Task UpdateAsync(CommunityBoardState next)
{
var json = JsonSerializer.Serialize(next, JsonOpts);
var existing = await db.CommunityBoardRows.FirstOrDefaultAsync(c => c.Id == RowId);
if (existing is null)
{
db.CommunityBoardRows.Add(new CommunityBoardEntity
{
Id = RowId,
Json = json,
UpdatedAt = DateTime.UtcNow,
});
}
else
{
existing.Json = json;
existing.UpdatedAt = DateTime.UtcNow;
}
await db.SaveChangesAsync();
return next;
}
private static CommunityBoardState DefaultState() => new()
{
CurrentAnnouncement = new AnnouncementState
{
Message = "Welcome to the private server.",
MoreInfoUrl = string.Empty,
},
};
}
/// Storage shape for the community board. Field names match
/// the watch's CommunityBoardDTO keys (verified in
/// CommunityBoard_NestedType_CommunityBoardDTO.txt) so the same record
/// can be returned verbatim from GET /api/communityboard/v1/current.
public class CommunityBoardState
{
public FeaturedPlayerState? FeaturedPlayer { get; set; }
public FeaturedRoomGroupState? FeaturedRoomGroup { get; set; }
public AnnouncementState? CurrentAnnouncement { get; set; }
public List InstagramImages { get; set; } = new();
public List Videos { get; set; } = new();
}
/// CommunityBoardFeaturedPlayerData: Id / TitleOverride / UrlOverride
/// (CommunityBoard_NestedType_CommunityBoardFeaturedPlayerData.txt:410-420).
public class FeaturedPlayerState
{
public int Id { get; set; }
public string TitleOverride { get; set; } = string.Empty;
public string UrlOverride { get; set; } = string.Empty;
}
/// FeaturedRoomGroupDTO: Name / FeaturedRooms list
/// (Rooms_NestedType_FeaturedRoomGroupDTO.txt:85-90). FeaturedRooms is
/// a list of room ids — the watch fetches each via /api/rooms/v2/{id}.
public class FeaturedRoomGroupState
{
public string Name { get; set; } = string.Empty;
public List FeaturedRooms { get; set; } = new();
}
/// CommunityBoardAnnouncementDTO: Message / MoreInfoUrl
/// (CommunityBoard_NestedType_CommunityBoardAnnouncementDTO.txt:85-90).
public class AnnouncementState
{
public string Message { get; set; } = string.Empty;
public string MoreInfoUrl { get; set; } = string.Empty;
}
/// InstagramImageDTO: ImageName / ImageUrl
/// (CommunityBoard_NestedType_InstagramImageDTO.txt:85-90).
public class InstagramImageState
{
public string ImageName { get; set; } = string.Empty;
public string ImageUrl { get; set; } = string.Empty;
}
/// CommunityBoardVideoData: BlobName / Title / Description /
/// ThumbnailBlobName / SourceUrl
/// (CommunityBoard_NestedType_CommunityBoardVideoData.txt:460-480).
public class VideoState
{
public string BlobName { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string ThumbnailBlobName { get; set; } = string.Empty;
public string SourceUrl { get; set; } = string.Empty;
}