diff --git a/.idea/.idea.RecNet/.idea/dataSources.xml b/.idea/.idea.RecNet/.idea/dataSources.xml new file mode 100644 index 0000000..e9d83b7 --- /dev/null +++ b/.idea/.idea.RecNet/.idea/dataSources.xml @@ -0,0 +1,15 @@ + + + + + postgresql + true + org.postgresql.Driver + jdbc:postgresql://localhost:43973/?password=4i6OtfQeYZ3qZ8OMOtak2acY&user=postgres + + + + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/API/API.csproj b/API/API.csproj index 43dbe8c..baef52d 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -10,4 +10,9 @@ + + + + + diff --git a/API/API.http b/API/API.http deleted file mode 100644 index 7a443e3..0000000 --- a/API/API.http +++ /dev/null @@ -1,6 +0,0 @@ -@API_HostAddress = http://localhost:5155 - -GET {{API_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/API/Configurations/RecNetConfiguration.cs b/API/Configurations/RecNetConfiguration.cs new file mode 100644 index 0000000..ae6fb9e --- /dev/null +++ b/API/Configurations/RecNetConfiguration.cs @@ -0,0 +1,6 @@ +namespace API.Configurations; + +public class RecNetConfiguration +{ + public bool UseForwardedHeaders { get; set; } = true; +} \ No newline at end of file diff --git a/API/Contracts/Profiles/Requests/LoginRequest.cs b/API/Contracts/Profiles/Requests/LoginRequest.cs new file mode 100644 index 0000000..91430e4 --- /dev/null +++ b/API/Contracts/Profiles/Requests/LoginRequest.cs @@ -0,0 +1,13 @@ +using RecNet.Domain.Common; + +namespace API.Contracts.Profiles.Requests; + +public class LoginRequest +{ + public required string AppVersion { get; set; } + public required string DeviceId { get; set; } + public required string PlatformAuthentication { get; set; } + public required string PlatformId { get; set; } + public PlatformType PlatformType { get; set; } + public required string Username { get; set; } +} \ No newline at end of file diff --git a/API/Contracts/Profiles/Responses/LoginResponse.cs b/API/Contracts/Profiles/Responses/LoginResponse.cs new file mode 100644 index 0000000..a90745a --- /dev/null +++ b/API/Contracts/Profiles/Responses/LoginResponse.cs @@ -0,0 +1,8 @@ +using RecNet.Application.Profiles; + +namespace API.Contracts.Profiles.Responses; + +public class LoginResponse +{ + public required ProfileDTO Profile { get; set; } +} \ No newline at end of file diff --git a/API/Controllers/Config/V1/ConfigController.cs b/API/Controllers/Config/V1/ConfigController.cs new file mode 100644 index 0000000..62628ca --- /dev/null +++ b/API/Controllers/Config/V1/ConfigController.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Mvc; + +namespace API.Controllers.Config.V1; + +[Route("api/[controller]/v1")] +[ApiController] +public class ConfigController : ControllerBase +{ + // TODO: Resolve from Database + [HttpGet("motd")] + public ActionResult GetMotd() + => Ok("Hello World!"); +} \ No newline at end of file diff --git a/API/Controllers/Profiles/V1/ProfilesController.cs b/API/Controllers/Profiles/V1/ProfilesController.cs new file mode 100644 index 0000000..1c7e272 --- /dev/null +++ b/API/Controllers/Profiles/V1/ProfilesController.cs @@ -0,0 +1,64 @@ +using API.Contracts.Profiles.Responses; +using AutoMapper; +using Microsoft.AspNetCore.Mvc; +using RecNet.Application.Profiles; +using RecNet.Domain.Repositories; +using LoginRequest = API.Contracts.Profiles.Requests.LoginRequest; +using Profile = RecNet.Domain.Entities.Profiles.Profile; + +namespace API.Controllers.Profiles.V1; + +[Route("api/[controller]/v1")] +[ApiController] +public class ProfilesController( + IProfileRepository profileRepository, + IMapper mapper) : ControllerBase +{ + [HttpGet("{id:guid}")] + public async Task> GetProfile( + Guid id, + CancellationToken ct) + { + var profile = await profileRepository.GetByIdAsync(id, ct); + if (profile == null) + return NotFound(); + + return mapper.Map(profile); + } + + [HttpGet("bulk")] + public async Task> GetBulkProfiles( + [FromQuery(Name = "id")] List ids, + CancellationToken ct) + { + var profiles = await profileRepository.GetByIdsAsync(ids, ct); + + return mapper.Map>(profiles); + } + + // TODO: Implement + [HttpPost("login")] + public async Task> Login( + [FromBody] LoginRequest request, + CancellationToken ct) + { + var profile = await profileRepository.GetByPlatform(request.PlatformType, request.PlatformId, ct); + if (profile is null) + { + profile = Profile.Create(request.Username, request.PlatformType, request.PlatformId); + await profileRepository.AddAsync(profile, ct); + } + + profile.AddDeviceId(request.DeviceId); + + if (request.Username != profile.Name) + profile.SetName(request.Username); + + await profileRepository.SaveChangesAsync(ct); + + return new LoginResponse + { + Profile = mapper.Map(profile) + }; + } +} \ No newline at end of file diff --git a/API/Controllers/WeatherForecastController.cs b/API/Controllers/WeatherForecastController.cs deleted file mode 100644 index 143d301..0000000 --- a/API/Controllers/WeatherForecastController.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Microsoft.AspNetCore.Mvc; - -namespace API.Controllers; - -[ApiController] -[Route("[controller]")] -public class WeatherForecastController : ControllerBase -{ - private static readonly string[] Summaries = - [ - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" - ]; - - [HttpGet(Name = "GetWeatherForecast")] - public IEnumerable Get() - { - return Enumerable.Range(1, 5).Select(index => new WeatherForecast - { - Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - TemperatureC = Random.Shared.Next(-20, 55), - Summary = Summaries[Random.Shared.Next(Summaries.Length)] - }) - .ToArray(); - } -} \ No newline at end of file diff --git a/API/Program.cs b/API/Program.cs index 05af6ed..5b2a0f0 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -1,3 +1,9 @@ +using API.Configurations; +using Microsoft.AspNetCore.HttpOverrides; +using RecNet.Application; +using RecNet.Infrastructure; +using RecNet.ServiceDefaults; + namespace API; public class Program @@ -5,28 +11,38 @@ public class Program public static void Main(string[] args) { var builder = WebApplication.CreateBuilder(args); + + var recNetOptions = builder.Configuration.GetSection("RecNet").Get() + ?? new RecNetConfiguration(); - // Add services to the container. + builder.AddServiceDefaults(); + + builder.Services.Configure(options => + { + options.ForwardedHeaders = + ForwardedHeaders.XForwardedFor | + ForwardedHeaders.XForwardedHost | + ForwardedHeaders.XForwardedProto; + options.KnownIPNetworks.Clear(); + options.KnownProxies.Clear(); + }); + + builder.Services.AddApplication(); + builder.AddInfrastructure(); builder.Services.AddControllers(); - // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi - builder.Services.AddOpenApi(); var app = builder.Build(); + + if (recNetOptions.UseForwardedHeaders) + app.UseForwardedHeaders(); - // Configure the HTTP request pipeline. - if (app.Environment.IsDevelopment()) - { - app.MapOpenApi(); - } - - app.UseHttpsRedirection(); - + // app.UseAuthentication(); app.UseAuthorization(); - app.MapControllers(); + app.MapDefaultEndpoints(); app.Run(); } -} \ No newline at end of file +} diff --git a/API/WeatherForecast.cs b/API/WeatherForecast.cs deleted file mode 100644 index 3e5e783..0000000 --- a/API/WeatherForecast.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace API; - -public class WeatherForecast -{ - public DateOnly Date { get; set; } - - public int TemperatureC { get; set; } - - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); - - public string? Summary { get; set; } -} \ No newline at end of file diff --git a/AppHost/AppHost.cs b/AppHost/AppHost.cs index 312d8ca..71f2c6b 100644 --- a/AppHost/AppHost.cs +++ b/AppHost/AppHost.cs @@ -1,8 +1,25 @@ -using Aspire.Hosting; - var builder = DistributedApplication.CreateBuilder(args); -var postgres = builder.AddPostgres("postgres"); +var postgresPassword = builder.AddParameter("postgres-password", secret: true); + +// Infra +var postgres = builder.AddPostgres("postgres") + .WithDataVolume("recnet-postgres-data") + .WithLifetime(ContainerLifetime.Persistent) + .WithPassword(postgresPassword) + .WithHostPort(5432) + .WithPgAdmin(); + var db = postgres.AddDatabase("recnet"); -builder.Build().Run(); \ No newline at end of file +// Services +var migrationService = builder.AddProject("migrationservice") + .WithReference(db) + .WaitFor(db); + +builder.AddProject("api") + .WithReference(db) + .WaitFor(db) + .WaitForCompletion(migrationService); + +builder.Build().Run(); diff --git a/AppHost/AppHost.csproj b/AppHost/AppHost.csproj index 6bd454c..48a4487 100644 --- a/AppHost/AppHost.csproj +++ b/AppHost/AppHost.csproj @@ -12,4 +12,9 @@ + + + + + diff --git a/RecNet.Application/Class1.cs b/RecNet.Application/Class1.cs deleted file mode 100644 index 498e353..0000000 --- a/RecNet.Application/Class1.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace RecNet.Application; - -public class Class1 -{ -} \ No newline at end of file diff --git a/RecNet.Application/Common/Mapping/ApplicationMappingProfile.cs b/RecNet.Application/Common/Mapping/ApplicationMappingProfile.cs new file mode 100644 index 0000000..046bcda --- /dev/null +++ b/RecNet.Application/Common/Mapping/ApplicationMappingProfile.cs @@ -0,0 +1,12 @@ +using AutoMapper; +using RecNet.Application.Profiles; + +namespace RecNet.Application.Common.Mapping; + +public class ApplicationMappingProfile : Profile +{ + public ApplicationMappingProfile() + { + CreateMap(); + } +} \ No newline at end of file diff --git a/RecNet.Application/DependencyInjection.cs b/RecNet.Application/DependencyInjection.cs new file mode 100644 index 0000000..4a2c78c --- /dev/null +++ b/RecNet.Application/DependencyInjection.cs @@ -0,0 +1,13 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace RecNet.Application; + +public static class DependencyInjection +{ + public static IServiceCollection AddApplication(this IServiceCollection services) + { + services.AddAutoMapper(_ => { }, typeof(DependencyInjection).Assembly); + + return services; + } +} \ No newline at end of file diff --git a/RecNet.Application/Profiles/ProfileDto.cs b/RecNet.Application/Profiles/ProfileDto.cs new file mode 100644 index 0000000..47368a4 --- /dev/null +++ b/RecNet.Application/Profiles/ProfileDto.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; +using RecNet.Domain.Common; + +namespace RecNet.Application.Profiles; + +public class ProfileDTO +{ + [JsonPropertyName("ProfileId")] + public Guid ProfileId { get; init; } + + [JsonPropertyName("Name")] + public required string Name { get; init; } + + [JsonPropertyName("Platform")] + + public PlatformType Platform { get; init; } + + [JsonPropertyName("CreatedAt")] + public DateTimeOffset CreatedAt { get; init; } +} \ No newline at end of file diff --git a/RecNet.Application/RecNet.Application.csproj b/RecNet.Application/RecNet.Application.csproj index 237d661..d8ffb53 100644 --- a/RecNet.Application/RecNet.Application.csproj +++ b/RecNet.Application/RecNet.Application.csproj @@ -6,4 +6,12 @@ enable + + + + + + + + diff --git a/RecNet.Domain/Class1.cs b/RecNet.Domain/Class1.cs deleted file mode 100644 index cc441a0..0000000 --- a/RecNet.Domain/Class1.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace RecNet.Domain; - -public class Class1 -{ -} \ No newline at end of file diff --git a/RecNet.Domain/Common/PlatformType.cs b/RecNet.Domain/Common/PlatformType.cs new file mode 100644 index 0000000..cdabf28 --- /dev/null +++ b/RecNet.Domain/Common/PlatformType.cs @@ -0,0 +1,7 @@ +namespace RecNet.Domain.Common; + +public enum PlatformType +{ + Steamworks = 0, + Meta = 1, +} diff --git a/RecNet.Domain/Entities/Profiles/Profile.cs b/RecNet.Domain/Entities/Profiles/Profile.cs new file mode 100644 index 0000000..9832b47 --- /dev/null +++ b/RecNet.Domain/Entities/Profiles/Profile.cs @@ -0,0 +1,62 @@ +using RecNet.Domain.Common; +using RecNet.Domain.Exceptions; + +namespace RecNet.Domain.Entities.Profiles; + +public class Profile +{ + public const int MinNameLength = 3; + public const int MaxNameLength = 32; + + private Profile( + string name, + PlatformType platform, + string platformId) + { + SetName(name); + + Platform = platform; + PlatformId = RequireValue(platformId, nameof(platformId)); + + CreatedAt = DateTimeOffset.UtcNow; + } + + public Guid ProfileId { get; } = Guid.NewGuid(); + + public string Name { get; private set; } = string.Empty; + + public PlatformType Platform { get; private set; } + public string PlatformId { get; private set; } + public List DeviceIds { get; private set; } = []; + + public DateTimeOffset CreatedAt { get; private set; } + + public static Profile Create( + string name, + PlatformType platform, + string platformId) + => new(name, platform, platformId); + + public void SetName(string name) + { + var requiredName = RequireValue(name, nameof(name)); + + if (requiredName.Length is < MinNameLength or > MaxNameLength) + throw new DomainException($"Username must be between {MinNameLength} and {MaxNameLength} characters long."); + + Name = requiredName; + } + + public void AddDeviceId(string deviceId) + { + deviceId = RequireValue(deviceId, nameof(deviceId)); + + if (DeviceIds.Contains(deviceId)) + return; + + DeviceIds.Add(deviceId); + } + + private static string RequireValue(string value, string parameterName) + => !string.IsNullOrWhiteSpace(value) ? value : throw new DomainException($"{parameterName} is required."); +} diff --git a/RecNet.Domain/Exceptions/DomainException.cs b/RecNet.Domain/Exceptions/DomainException.cs new file mode 100644 index 0000000..8107edf --- /dev/null +++ b/RecNet.Domain/Exceptions/DomainException.cs @@ -0,0 +1,15 @@ +namespace RecNet.Domain.Exceptions; + +public class DomainException : Exception +{ + public DomainException() + { } + + public DomainException(string message) + : base(message) + { } + + public DomainException(string message, Exception innerException) + : base(message, innerException) + { } +} \ No newline at end of file diff --git a/RecNet.Domain/RecNet.Domain.csproj b/RecNet.Domain/RecNet.Domain.csproj index 237d661..57d42eb 100644 --- a/RecNet.Domain/RecNet.Domain.csproj +++ b/RecNet.Domain/RecNet.Domain.csproj @@ -6,4 +6,8 @@ enable + + + + diff --git a/RecNet.Domain/Repositories/IProfileRepository.cs b/RecNet.Domain/Repositories/IProfileRepository.cs new file mode 100644 index 0000000..164b05f --- /dev/null +++ b/RecNet.Domain/Repositories/IProfileRepository.cs @@ -0,0 +1,16 @@ +using RecNet.Domain.Common; +using RecNet.Domain.Entities.Profiles; + +namespace RecNet.Domain.Repositories; + +public interface IProfileRepository +{ + Task GetByIdAsync(Guid profileId, CancellationToken ct = default); + Task> GetByIdsAsync(List profileIds, CancellationToken ct = default); + + Task GetByPlatform(PlatformType platform, string platformId, CancellationToken ct = default); + + Task AddAsync(Profile profile, CancellationToken ct = default); + + Task SaveChangesAsync(CancellationToken ct = default); +} diff --git a/RecNet.Infrastructure/DependencyInjection.cs b/RecNet.Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..a5e4f42 --- /dev/null +++ b/RecNet.Infrastructure/DependencyInjection.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using RecNet.Domain.Repositories; +using RecNet.Infrastructure.Persistence; +using RecNet.Infrastructure.Persistence.Repositories; + +namespace RecNet.Infrastructure; + +public static class DependencyInjection +{ + public static TBuilder AddInfrastructure(this TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + builder.AddNpgsqlDbContext("recnet"); + + builder.Services.AddScoped(); + + return builder; + } +} diff --git a/RecNet.Infrastructure/Persistence/Configurations/ProfileConfiguration.cs b/RecNet.Infrastructure/Persistence/Configurations/ProfileConfiguration.cs new file mode 100644 index 0000000..9adf203 --- /dev/null +++ b/RecNet.Infrastructure/Persistence/Configurations/ProfileConfiguration.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using RecNet.Domain.Entities.Profiles; + +namespace RecNet.Infrastructure.Persistence.Configurations; + +public class ProfileConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.ProfileId); + + builder.Property(x => x.Name) + .HasMaxLength(Profile.MaxNameLength) + .IsRequired(); + + builder.Property(x => x.Platform) + .HasConversion() + .HasMaxLength(50) + .IsRequired(); + + builder.Property(x => x.PlatformId) + .HasMaxLength(50) + .IsRequired(); + + builder.Property(x => x.DeviceIds) + .HasColumnType("jsonb"); + + builder.Property(x => x.CreatedAt) + .IsRequired(); + + builder.HasIndex(x => new { x.Platform, x.PlatformId }) + .IsUnique(); + } +} diff --git a/RecNet.Infrastructure/Persistence/DatabaseContext.cs b/RecNet.Infrastructure/Persistence/DatabaseContext.cs index c9235d1..24f11dd 100644 --- a/RecNet.Infrastructure/Persistence/DatabaseContext.cs +++ b/RecNet.Infrastructure/Persistence/DatabaseContext.cs @@ -1,8 +1,14 @@ -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using RecNet.Domain.Entities.Profiles; namespace RecNet.Infrastructure.Persistence; -public class DatabaseContext : DbContext +public class DatabaseContext(DbContextOptions options) : DbContext(options) { - -} \ No newline at end of file + public DbSet Profiles => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfigurationsFromAssembly(typeof(DatabaseContext).Assembly); + } +} diff --git a/RecNet.Infrastructure/Persistence/Migrations/20260618180145_Initial.Designer.cs b/RecNet.Infrastructure/Persistence/Migrations/20260618180145_Initial.Designer.cs new file mode 100644 index 0000000..f22c236 --- /dev/null +++ b/RecNet.Infrastructure/Persistence/Migrations/20260618180145_Initial.Designer.cs @@ -0,0 +1,66 @@ +// +using System; +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("20260618180145_Initial")] + partial class Initial + { + /// + 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.Entities.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("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"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/RecNet.Infrastructure/Persistence/Migrations/20260618180145_Initial.cs b/RecNet.Infrastructure/Persistence/Migrations/20260618180145_Initial.cs new file mode 100644 index 0000000..dc97816 --- /dev/null +++ b/RecNet.Infrastructure/Persistence/Migrations/20260618180145_Initial.cs @@ -0,0 +1,44 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RecNet.Infrastructure.Persistence.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Profiles", + columns: table => new + { + ProfileId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Platform = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + PlatformId = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + DeviceIds = table.Column(type: "jsonb", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Profiles", x => x.ProfileId); + }); + + migrationBuilder.CreateIndex( + name: "IX_Profiles_Platform_PlatformId", + table: "Profiles", + columns: new[] { "Platform", "PlatformId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Profiles"); + } + } +} diff --git a/RecNet.Infrastructure/Persistence/Migrations/DatabaseContextModelSnapshot.cs b/RecNet.Infrastructure/Persistence/Migrations/DatabaseContextModelSnapshot.cs new file mode 100644 index 0000000..3f3d811 --- /dev/null +++ b/RecNet.Infrastructure/Persistence/Migrations/DatabaseContextModelSnapshot.cs @@ -0,0 +1,63 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using RecNet.Infrastructure.Persistence; + +#nullable disable + +namespace RecNet.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(DatabaseContext))] + partial class DatabaseContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("RecNet.Domain.Entities.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("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"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/RecNet.Infrastructure/Persistence/Repositories/ProfileRepository.cs b/RecNet.Infrastructure/Persistence/Repositories/ProfileRepository.cs new file mode 100644 index 0000000..7cb69b4 --- /dev/null +++ b/RecNet.Infrastructure/Persistence/Repositories/ProfileRepository.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore; +using RecNet.Domain.Common; +using RecNet.Domain.Entities.Profiles; +using RecNet.Domain.Repositories; + +namespace RecNet.Infrastructure.Persistence.Repositories; + +public class ProfileRepository(DatabaseContext dbContext) : IProfileRepository +{ + public Task GetByIdAsync( + Guid profileId, + CancellationToken ct = default) + => dbContext.Profiles + .FirstOrDefaultAsync(x => x.ProfileId == profileId, ct); + + public async Task> GetByIdsAsync( + List profileIds, + CancellationToken ct = default) + => await dbContext.Profiles + .Where(x => profileIds.Contains(x.ProfileId)) + .ToListAsync(ct); + + public Task GetByPlatform(PlatformType platform, string platformId, CancellationToken ct = default) + => dbContext.Profiles + .FirstOrDefaultAsync(x => + x.Platform == platform && + x.PlatformId == platformId, + ct); + + public async Task AddAsync( + Profile profile, + CancellationToken ct = default) + => await dbContext.Profiles.AddAsync(profile, ct); + + public Task SaveChangesAsync(CancellationToken ct = default) + => dbContext.SaveChangesAsync(ct); +} \ No newline at end of file diff --git a/RecNet.Infrastructure/RecNet.Infrastructure.csproj b/RecNet.Infrastructure/RecNet.Infrastructure.csproj index f911312..f75a011 100644 --- a/RecNet.Infrastructure/RecNet.Infrastructure.csproj +++ b/RecNet.Infrastructure/RecNet.Infrastructure.csproj @@ -11,7 +11,12 @@ - + + + + + + diff --git a/RecNet.MigrationService/Program.cs b/RecNet.MigrationService/Program.cs index 0592f80..ee1b55c 100644 --- a/RecNet.MigrationService/Program.cs +++ b/RecNet.MigrationService/Program.cs @@ -1,7 +1,16 @@ using RecNet.MigrationService; +using RecNet.Infrastructure.Persistence; +using RecNet.ServiceDefaults; var builder = Host.CreateApplicationBuilder(args); + +builder.AddServiceDefaults(); builder.Services.AddHostedService(); +builder.Services.AddOpenTelemetry() + .WithTracing(tracing => tracing.AddSource(Worker.ActivitySourceName)); + +builder.AddNpgsqlDbContext("recnet"); + var host = builder.Build(); host.Run(); \ No newline at end of file diff --git a/RecNet.MigrationService/RecNet.MigrationService.csproj b/RecNet.MigrationService/RecNet.MigrationService.csproj index cc9059e..8ba8cb0 100644 --- a/RecNet.MigrationService/RecNet.MigrationService.csproj +++ b/RecNet.MigrationService/RecNet.MigrationService.csproj @@ -8,6 +8,15 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/RecNet.MigrationService/Worker.cs b/RecNet.MigrationService/Worker.cs index 82974e2..0326abb 100644 --- a/RecNet.MigrationService/Worker.cs +++ b/RecNet.MigrationService/Worker.cs @@ -1,17 +1,48 @@ +using System.Diagnostics; + +using Microsoft.EntityFrameworkCore; + +using RecNet.Infrastructure.Persistence; + namespace RecNet.MigrationService; -public class Worker(ILogger logger) : BackgroundService +public class Worker( + IServiceProvider serviceProvider, + IHostApplicationLifetime hostApplicationLifetime) : BackgroundService { - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - while (!stoppingToken.IsCancellationRequested) - { - if (logger.IsEnabled(LogLevel.Information)) - { - logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now); - } + public const string ActivitySourceName = "Migrations"; + private static readonly ActivitySource ActivitySource = new(ActivitySourceName); - await Task.Delay(1000, stoppingToken); + protected override async Task ExecuteAsync( + CancellationToken cancellationToken) + { + using var activity = ActivitySource.StartActivity( + "Migrating database", ActivityKind.Client); + + try + { + using var scope = serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + await RunMigrationAsync(dbContext, cancellationToken); } + catch (Exception ex) + { + activity?.AddException(ex); + throw; + } + + hostApplicationLifetime.StopApplication(); + } + + private static async Task RunMigrationAsync( + DatabaseContext dbContext, CancellationToken cancellationToken) + { + var strategy = dbContext.Database.CreateExecutionStrategy(); + await strategy.ExecuteAsync(async () => + { + // Run migration in a transaction to avoid partial migration if it fails. + await dbContext.Database.MigrateAsync(cancellationToken); + }); } } \ No newline at end of file diff --git a/RecNet.ServiceDefaults/Extensions.cs b/RecNet.ServiceDefaults/Extensions.cs index 2cf5f5d..8599588 100644 --- a/RecNet.ServiceDefaults/Extensions.cs +++ b/RecNet.ServiceDefaults/Extensions.cs @@ -2,13 +2,13 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.ServiceDiscovery; using OpenTelemetry; using OpenTelemetry.Metrics; using OpenTelemetry.Trace; -namespace Microsoft.Extensions.Hosting; +namespace RecNet.ServiceDefaults; // Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. // This project should be referenced by each service project in your solution.