Add patch notes and config endpoints

This commit is contained in:
Holden
2026-06-27 11:23:43 -05:00
parent b3028ce741
commit 2ea247a577
14 changed files with 427 additions and 3 deletions

View File

@@ -0,0 +1,8 @@
namespace API.Contracts.Builds.Responses;
public class LatestBuildsResponse
{
public string LatestWindowsVersion { get; set; } = "TBF";
public string LatestLinuxVersion { get; set; } = "TBF";
public string LatestMetaVersion { get; set; } = "TBF";
}

View File

@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace API.Contracts.Config.Responses;
public class MediaPlayerConfigResponse
{
[JsonPropertyName("Enabled")]
public bool Enabled { get; set; }
[JsonPropertyName("Uri")]
public Uri? Uri { get; set; }
}

View File

@@ -0,0 +1,29 @@
using API.Contracts.Builds.Responses;
using Microsoft.AspNetCore.Mvc;
using RecNet.Application.Common.Interfaces;
using RecNet.Domain.PatchNotes;
namespace API.Controllers.Builds;
[ApiController]
[Route("api/[controller]/v1")]
public class BuildsController(IPatchNoteRepository patchNoteRepository, IConfigService configService) : ControllerBase
{
[HttpGet("latest")]
public async Task<ActionResult<LatestBuildsResponse>> GetLatestVersions(CancellationToken ct = default)
{
// If I didn't have a one-day deadline, I wouldn't be storing this as a server config.
// But I am, so this will have to do.
var latestBuilds = await configService.GetAsync(
"Builds:Latest",
new LatestBuildsResponse(),
ct);
return Ok(latestBuilds);
}
// rejecting DDD because I'm lazy :P
[HttpGet("patchNotes")]
public async Task<IReadOnlyList<PatchNote>> GetPatchNotes(CancellationToken ct = default)
=> await patchNoteRepository.GetAllAsync(ct);
}

View File

@@ -1,17 +1,34 @@
using API.Contracts.Config.Responses;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using RecNet.Application.Common.Interfaces; using RecNet.Application.Common.Interfaces;
namespace API.Controllers.Config.V1; namespace API.Controllers.Config.V1;
[Route("api/[controller]/v1")] [Authorize]
[ApiController] [ApiController]
[Route("api/[controller]/v1")]
public class ConfigController(IConfigService configService) : ControllerBase public class ConfigController(IConfigService configService) : ControllerBase
{ {
[HttpGet("motd")] [HttpGet("motd")]
public async Task<ActionResult<string>> GetMotd(CancellationToken ct = default) public async Task<ActionResult<string>> GetMotd(CancellationToken ct = default)
{ {
var motd = await configService.GetAsync("Config:MOTD", "Ten Whole Years!", ct); var motd = await configService.GetAsync(
"Config:MOTD",
"Ten Whole Years!",
ct);
return Ok(motd); return Ok(motd);
} }
}
[HttpGet("media-player")]
public async Task<ActionResult<MediaPlayerConfigResponse>> GetMediaPlayerConfig(CancellationToken ct = default)
{
var motd = await configService.GetAsync(
"Config:MediaPlayerConfig",
new MediaPlayerConfigResponse(),
ct);
return Ok(motd);
}
}

View File

@@ -26,6 +26,7 @@ public class SettingsController(IProfileService profileService) : ControllerBase
{ {
var profileId = User.GetProfileId(); var profileId = User.GetProfileId();
// It's like having CQRS at home!
var updated = await profileService.UpdatePlayerSettingAsync var updated = await profileService.UpdatePlayerSettingAsync
( (
profileId: profileId, profileId: profileId,

View File

@@ -0,0 +1,7 @@
namespace RecNet.Domain.PatchNotes;
public interface IPatchNoteRepository
{
// We are really only resolving them, not modifying them. (use the database like a caveman)
Task<IReadOnlyList<PatchNote>> GetAllAsync(CancellationToken ct = default);
}

View File

@@ -0,0 +1,10 @@
using System.Globalization;
namespace RecNet.Domain.PatchNotes;
public class PatchNote
{
public string Version { get; private set; } = null!;
public string Date { get; private set; } = null!;
public List<string> Changes { get; private set; } = [];
}

View File

@@ -3,6 +3,7 @@ using Microsoft.Extensions.Hosting;
using RecNet.Application.Common.Interfaces; using RecNet.Application.Common.Interfaces;
using RecNet.Domain.Configuration; using RecNet.Domain.Configuration;
using RecNet.Domain.GameVersions; using RecNet.Domain.GameVersions;
using RecNet.Domain.PatchNotes;
using RecNet.Domain.Profiles; using RecNet.Domain.Profiles;
using RecNet.Infrastructure.Persistence; using RecNet.Infrastructure.Persistence;
using RecNet.Infrastructure.Persistence.Repositories; using RecNet.Infrastructure.Persistence.Repositories;
@@ -41,6 +42,7 @@ public static class DependencyInjection
builder.Services.AddScoped<IGameVersionRepository, GameVersionRepository>(); builder.Services.AddScoped<IGameVersionRepository, GameVersionRepository>();
builder.Services.AddScoped<IProfileRepository, ProfileRepository>(); builder.Services.AddScoped<IProfileRepository, ProfileRepository>();
builder.Services.AddScoped<IRelationshipRepository, RelationshipRepository>(); builder.Services.AddScoped<IRelationshipRepository, RelationshipRepository>();
builder.Services.AddScoped<IPatchNoteRepository, PatchNoteRepository>();
builder.Services.AddScoped<IServerConfigRepository, ServerConfigRepository>(); builder.Services.AddScoped<IServerConfigRepository, ServerConfigRepository>();
builder.Services.AddScoped<IConfigService, ConfigService>(); builder.Services.AddScoped<IConfigService, ConfigService>();

View File

@@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using RecNet.Domain.PatchNotes;
namespace RecNet.Infrastructure.Persistence.Configurations;
public class PatchNoteConfiguration : IEntityTypeConfiguration<PatchNote>
{
public void Configure(EntityTypeBuilder<PatchNote> builder)
{
builder.HasKey(x => x.Version);
builder.Property(x => x.Version)
.HasMaxLength(32)
.IsRequired();
builder.Property(x => x.Date)
.HasMaxLength(10)
.IsRequired();
}
}

View File

@@ -1,15 +1,22 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using RecNet.Domain.Configuration; using RecNet.Domain.Configuration;
using RecNet.Domain.GameVersions; using RecNet.Domain.GameVersions;
using RecNet.Domain.PatchNotes;
using RecNet.Domain.Profiles; using RecNet.Domain.Profiles;
namespace RecNet.Infrastructure.Persistence; namespace RecNet.Infrastructure.Persistence;
public class DatabaseContext(DbContextOptions<DatabaseContext> options) : DbContext(options) public class DatabaseContext(DbContextOptions<DatabaseContext> options) : DbContext(options)
{ {
// Profiles
public DbSet<Profile> Profiles => Set<Profile>(); public DbSet<Profile> Profiles => Set<Profile>();
public DbSet<PlayerSetting> PlayerSettings => Set<PlayerSetting>(); public DbSet<PlayerSetting> PlayerSettings => Set<PlayerSetting>();
public DbSet<Relationship> Relationships => Set<Relationship>(); public DbSet<Relationship> Relationships => Set<Relationship>();
// PatchNotes
public DbSet<PatchNote> PatchNotes => Set<PatchNote>();
// Internal
public DbSet<GameVersion> GameVersions => Set<GameVersion>(); public DbSet<GameVersion> GameVersions => Set<GameVersion>();
public DbSet<ServerConfig> ServerConfigs => Set<ServerConfig>(); public DbSet<ServerConfig> ServerConfigs => Set<ServerConfig>();

View File

@@ -0,0 +1,230 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using RecNet.Infrastructure.Persistence;
#nullable disable
namespace RecNet.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(DatabaseContext))]
[Migration("20260627051001_PatchNotes")]
partial class PatchNotes
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("RecNet.Domain.Configuration.ServerConfig", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("jsonb");
b.HasKey("Key");
b.ToTable("ServerConfigs");
});
modelBuilder.Entity("RecNet.Domain.GameVersions.GameVersion", b =>
{
b.Property<string>("Version")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<bool>("IsValid")
.HasColumnType("boolean");
b.HasKey("Version");
b.ToTable("GameVersions");
});
modelBuilder.Entity("RecNet.Domain.PatchNotes.PatchNote", b =>
{
b.Property<string>("Version")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.PrimitiveCollection<List<string>>("Changes")
.IsRequired()
.HasColumnType("text[]");
b.Property<string>("Date")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.HasKey("Version");
b.ToTable("PatchNotes");
});
modelBuilder.Entity("RecNet.Domain.Profiles.PlayerSetting", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("Key")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("text");
b.HasKey("UserId", "Key");
b.ToTable("PlayerSettings");
});
modelBuilder.Entity("RecNet.Domain.Profiles.Profile", b =>
{
b.Property<Guid>("ProfileId")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.PrimitiveCollection<string>("DeviceIds")
.IsRequired()
.HasColumnType("jsonb");
b.Property<bool>("IsBanned")
.HasColumnType("boolean");
b.Property<bool>("IsDeveloper")
.HasColumnType("boolean");
b.Property<bool>("IsModerator")
.HasColumnType("boolean");
b.Property<byte[]>("MetaAuthenticationSecret")
.IsRequired()
.HasColumnType("bytea");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("PlatformId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("ProfileId");
b.HasIndex("Platform", "PlatformId")
.IsUnique();
b.ToTable("Profiles");
});
modelBuilder.Entity("RecNet.Domain.Profiles.Relationship", b =>
{
b.Property<Guid>("OwnerProfileId")
.HasColumnType("uuid");
b.Property<Guid>("ProfileId")
.HasColumnType("uuid");
b.Property<bool>("Ignored")
.HasColumnType("boolean");
b.Property<bool>("Muted")
.HasColumnType("boolean");
b.HasKey("OwnerProfileId", "ProfileId");
b.HasIndex("ProfileId");
b.ToTable("Relationships");
});
modelBuilder.Entity("RecNet.Domain.Profiles.PlayerSetting", b =>
{
b.HasOne("RecNet.Domain.Profiles.Profile", null)
.WithMany("Settings")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("RecNet.Domain.Profiles.Profile", b =>
{
b.OwnsOne("RecNet.Domain.Profiles.Avatar", "Avatar", b1 =>
{
b1.Property<Guid>("ProfileId")
.HasColumnType("uuid");
b1.Property<string>("HairColor")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("OutfitSelections")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("SkinColor")
.IsRequired()
.HasColumnType("text");
b1.HasKey("ProfileId");
b1.ToTable("Profiles");
b1.WithOwner()
.HasForeignKey("ProfileId");
});
b.Navigation("Avatar")
.IsRequired();
});
modelBuilder.Entity("RecNet.Domain.Profiles.Relationship", b =>
{
b.HasOne("RecNet.Domain.Profiles.Profile", null)
.WithMany("Relationships")
.HasForeignKey("OwnerProfileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("RecNet.Domain.Profiles.Profile", null)
.WithMany()
.HasForeignKey("ProfileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("RecNet.Domain.Profiles.Profile", b =>
{
b.Navigation("Relationships");
b.Navigation("Settings");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,35 @@
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RecNet.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class PatchNotes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PatchNotes",
columns: table => new
{
Version = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Date = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
Changes = table.Column<List<string>>(type: "text[]", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PatchNotes", x => x.Version);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PatchNotes");
}
}
}

View File

@@ -1,5 +1,6 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@@ -51,6 +52,26 @@ namespace RecNet.Infrastructure.Persistence.Migrations
b.ToTable("GameVersions"); b.ToTable("GameVersions");
}); });
modelBuilder.Entity("RecNet.Domain.PatchNotes.PatchNote", b =>
{
b.Property<string>("Version")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.PrimitiveCollection<List<string>>("Changes")
.IsRequired()
.HasColumnType("text[]");
b.Property<string>("Date")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.HasKey("Version");
b.ToTable("PatchNotes");
});
modelBuilder.Entity("RecNet.Domain.Profiles.PlayerSetting", b => modelBuilder.Entity("RecNet.Domain.Profiles.PlayerSetting", b =>
{ {
b.Property<Guid>("UserId") b.Property<Guid>("UserId")

View File

@@ -0,0 +1,24 @@
using System.Globalization;
using Microsoft.EntityFrameworkCore;
using RecNet.Domain.PatchNotes;
namespace RecNet.Infrastructure.Persistence.Repositories;
public class PatchNoteRepository(DatabaseContext dbContext) : IPatchNoteRepository
{
public async Task<IReadOnlyList<PatchNote>> GetAllAsync(CancellationToken ct = default)
{
var patchNotes = await dbContext.PatchNotes
.AsNoTracking()
.ToListAsync(ct);
// Please, do NOT do this. You should be storing this value as a DateTimeOffset and then converting it into a DTO.
return patchNotes
.OrderByDescending(e =>
DateTime.ParseExact(
e.Date,
"M/dd/yyyy",
CultureInfo.InvariantCulture))
.ToList();
}
}