diff --git a/src/API/Contracts/Builds/Responses/LatestBuildsResponse.cs b/src/API/Contracts/Builds/Responses/LatestBuildsResponse.cs new file mode 100644 index 0000000..22af00e --- /dev/null +++ b/src/API/Contracts/Builds/Responses/LatestBuildsResponse.cs @@ -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"; +} \ No newline at end of file diff --git a/src/API/Contracts/Config/Responses/MediaPlayerConfigResponse.cs b/src/API/Contracts/Config/Responses/MediaPlayerConfigResponse.cs new file mode 100644 index 0000000..184b654 --- /dev/null +++ b/src/API/Contracts/Config/Responses/MediaPlayerConfigResponse.cs @@ -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; } +} \ No newline at end of file diff --git a/src/API/Controllers/Builds/BuildsController.cs b/src/API/Controllers/Builds/BuildsController.cs new file mode 100644 index 0000000..b213574 --- /dev/null +++ b/src/API/Controllers/Builds/BuildsController.cs @@ -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> 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> GetPatchNotes(CancellationToken ct = default) + => await patchNoteRepository.GetAllAsync(ct); +} \ No newline at end of file diff --git a/src/API/Controllers/Config/V1/ConfigController.cs b/src/API/Controllers/Config/V1/ConfigController.cs index 3093725..5c0da13 100644 --- a/src/API/Controllers/Config/V1/ConfigController.cs +++ b/src/API/Controllers/Config/V1/ConfigController.cs @@ -1,17 +1,34 @@ +using API.Contracts.Config.Responses; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using RecNet.Application.Common.Interfaces; namespace API.Controllers.Config.V1; -[Route("api/[controller]/v1")] +[Authorize] [ApiController] +[Route("api/[controller]/v1")] public class ConfigController(IConfigService configService) : ControllerBase { [HttpGet("motd")] public async Task> 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); } -} + + [HttpGet("media-player")] + public async Task> GetMediaPlayerConfig(CancellationToken ct = default) + { + var motd = await configService.GetAsync( + "Config:MediaPlayerConfig", + new MediaPlayerConfigResponse(), + ct); + + return Ok(motd); + } +} \ No newline at end of file diff --git a/src/API/Controllers/Settings/V1/SettingsController.cs b/src/API/Controllers/Settings/V1/SettingsController.cs index e8d46ca..87f6b69 100644 --- a/src/API/Controllers/Settings/V1/SettingsController.cs +++ b/src/API/Controllers/Settings/V1/SettingsController.cs @@ -26,6 +26,7 @@ public class SettingsController(IProfileService profileService) : ControllerBase { var profileId = User.GetProfileId(); + // It's like having CQRS at home! var updated = await profileService.UpdatePlayerSettingAsync ( profileId: profileId, diff --git a/src/RecNet.Domain/PatchNotes/IPatchNoteRepository.cs b/src/RecNet.Domain/PatchNotes/IPatchNoteRepository.cs new file mode 100644 index 0000000..e66aea9 --- /dev/null +++ b/src/RecNet.Domain/PatchNotes/IPatchNoteRepository.cs @@ -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> GetAllAsync(CancellationToken ct = default); +} \ No newline at end of file diff --git a/src/RecNet.Domain/PatchNotes/PatchNote.cs b/src/RecNet.Domain/PatchNotes/PatchNote.cs new file mode 100644 index 0000000..9c98f2f --- /dev/null +++ b/src/RecNet.Domain/PatchNotes/PatchNote.cs @@ -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 Changes { get; private set; } = []; +} \ No newline at end of file diff --git a/src/RecNet.Infrastructure/DependencyInjection.cs b/src/RecNet.Infrastructure/DependencyInjection.cs index 4c0a987..ecdcc59 100644 --- a/src/RecNet.Infrastructure/DependencyInjection.cs +++ b/src/RecNet.Infrastructure/DependencyInjection.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Hosting; using RecNet.Application.Common.Interfaces; using RecNet.Domain.Configuration; using RecNet.Domain.GameVersions; +using RecNet.Domain.PatchNotes; using RecNet.Domain.Profiles; using RecNet.Infrastructure.Persistence; using RecNet.Infrastructure.Persistence.Repositories; @@ -41,6 +42,7 @@ public static class DependencyInjection builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/RecNet.Infrastructure/Persistence/Configurations/PatchNoteConfiguration.cs b/src/RecNet.Infrastructure/Persistence/Configurations/PatchNoteConfiguration.cs new file mode 100644 index 0000000..84e2d54 --- /dev/null +++ b/src/RecNet.Infrastructure/Persistence/Configurations/PatchNoteConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Version); + + builder.Property(x => x.Version) + .HasMaxLength(32) + .IsRequired(); + + builder.Property(x => x.Date) + .HasMaxLength(10) + .IsRequired(); + } +} \ No newline at end of file diff --git a/src/RecNet.Infrastructure/Persistence/DatabaseContext.cs b/src/RecNet.Infrastructure/Persistence/DatabaseContext.cs index 7346d90..df2f84f 100644 --- a/src/RecNet.Infrastructure/Persistence/DatabaseContext.cs +++ b/src/RecNet.Infrastructure/Persistence/DatabaseContext.cs @@ -1,15 +1,22 @@ using Microsoft.EntityFrameworkCore; using RecNet.Domain.Configuration; using RecNet.Domain.GameVersions; +using RecNet.Domain.PatchNotes; using RecNet.Domain.Profiles; namespace RecNet.Infrastructure.Persistence; public class DatabaseContext(DbContextOptions options) : DbContext(options) { + // Profiles public DbSet Profiles => Set(); public DbSet PlayerSettings => Set(); public DbSet Relationships => Set(); + + // PatchNotes + public DbSet PatchNotes => Set(); + + // Internal public DbSet GameVersions => Set(); public DbSet ServerConfigs => Set(); diff --git a/src/RecNet.Infrastructure/Persistence/Migrations/20260627051001_PatchNotes.Designer.cs b/src/RecNet.Infrastructure/Persistence/Migrations/20260627051001_PatchNotes.Designer.cs new file mode 100644 index 0000000..edf66fd --- /dev/null +++ b/src/RecNet.Infrastructure/Persistence/Migrations/20260627051001_PatchNotes.Designer.cs @@ -0,0 +1,230 @@ +// +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 + { + /// + 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("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Key"); + + b.ToTable("ServerConfigs"); + }); + + modelBuilder.Entity("RecNet.Domain.GameVersions.GameVersion", b => + { + b.Property("Version") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("IsValid") + .HasColumnType("boolean"); + + b.HasKey("Version"); + + b.ToTable("GameVersions"); + }); + + modelBuilder.Entity("RecNet.Domain.PatchNotes.PatchNote", b => + { + b.Property("Version") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.PrimitiveCollection>("Changes") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Date") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.HasKey("Version"); + + b.ToTable("PatchNotes"); + }); + + modelBuilder.Entity("RecNet.Domain.Profiles.PlayerSetting", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserId", "Key"); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("RecNet.Domain.Profiles.Profile", b => + { + b.Property("ProfileId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection("DeviceIds") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("IsBanned") + .HasColumnType("boolean"); + + b.Property("IsDeveloper") + .HasColumnType("boolean"); + + b.Property("IsModerator") + .HasColumnType("boolean"); + + b.Property("MetaAuthenticationSecret") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("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("OwnerProfileId") + .HasColumnType("uuid"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("Ignored") + .HasColumnType("boolean"); + + b.Property("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("ProfileId") + .HasColumnType("uuid"); + + b1.Property("HairColor") + .IsRequired() + .HasColumnType("text"); + + b1.Property("OutfitSelections") + .IsRequired() + .HasColumnType("text"); + + b1.Property("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 + } + } +} diff --git a/src/RecNet.Infrastructure/Persistence/Migrations/20260627051001_PatchNotes.cs b/src/RecNet.Infrastructure/Persistence/Migrations/20260627051001_PatchNotes.cs new file mode 100644 index 0000000..6e8feb7 --- /dev/null +++ b/src/RecNet.Infrastructure/Persistence/Migrations/20260627051001_PatchNotes.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RecNet.Infrastructure.Persistence.Migrations +{ + /// + public partial class PatchNotes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PatchNotes", + columns: table => new + { + Version = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Date = table.Column(type: "character varying(10)", maxLength: 10, nullable: false), + Changes = table.Column>(type: "text[]", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PatchNotes", x => x.Version); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PatchNotes"); + } + } +} diff --git a/src/RecNet.Infrastructure/Persistence/Migrations/DatabaseContextModelSnapshot.cs b/src/RecNet.Infrastructure/Persistence/Migrations/DatabaseContextModelSnapshot.cs index 6c4e558..cad1027 100644 --- a/src/RecNet.Infrastructure/Persistence/Migrations/DatabaseContextModelSnapshot.cs +++ b/src/RecNet.Infrastructure/Persistence/Migrations/DatabaseContextModelSnapshot.cs @@ -1,5 +1,6 @@ // using System; +using System.Collections.Generic; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -51,6 +52,26 @@ namespace RecNet.Infrastructure.Persistence.Migrations b.ToTable("GameVersions"); }); + modelBuilder.Entity("RecNet.Domain.PatchNotes.PatchNote", b => + { + b.Property("Version") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.PrimitiveCollection>("Changes") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Date") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.HasKey("Version"); + + b.ToTable("PatchNotes"); + }); + modelBuilder.Entity("RecNet.Domain.Profiles.PlayerSetting", b => { b.Property("UserId") diff --git a/src/RecNet.Infrastructure/Persistence/Repositories/PatchNoteRepository.cs b/src/RecNet.Infrastructure/Persistence/Repositories/PatchNoteRepository.cs new file mode 100644 index 0000000..4272a67 --- /dev/null +++ b/src/RecNet.Infrastructure/Persistence/Repositories/PatchNoteRepository.cs @@ -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> 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(); + } +} \ No newline at end of file