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.