feat(data): rename *.Services projects, lift TrackEntity onto BlazorBlocks data layer, regenerate initial Postgres migration

DeepDrftWeb.Services → DeepDrftData; DeepDrftContent.Services → DeepDrftContent.Data.
TrackEntity:BaseEntity, TrackRepository:Repository<>, TrackManager:Manager<>+ITrackService.
Drops DeepDrftModels PagingParameters/PagedResult in favour of Models.Common.* from BlazorBlocks.
InitialCreate migration captures full schema including is_deleted index.
This commit is contained in:
Daniel Harvey
2026-05-18 22:22:09 -04:00
parent 130f1357ec
commit cd700dc758
82 changed files with 511 additions and 600 deletions
+173
View File
@@ -0,0 +1,173 @@
# CLAUDE.md - DeepDrftWeb.Services
Guidance for working in the DeepDrftWeb.Services project (the SQL-side domain logic).
See the root `CLAUDE.md` for full architecture overview. This file covers what is specific to this project.
## One-line purpose
SQL-side domain logic for tracks. EF Core context, configurations, migrations, repository, service, design-time factory. Consumed by both `DeepDrftWeb` (the host) and `DeepDrftCli` (the admin CLI).
## Why this project exists
Separating domain logic from the host so the CLI can reuse `TrackService` / `TrackRepository` / `DeepDrftContext` without referencing the ASP.NET host. The CLI is a local admin tool, not a network client — it needs direct DB access.
**New SQL-side domain code goes here, not in `DeepDrftWeb`.**
## Layout
```
DeepDrftWeb.Services/
├── Data/
│ ├── DeepDrftContext.cs # EF DbContext
│ ├── DeepDrftContextFactory.cs # Design-time factory (hard-codes ../Database/deepdrft.db)
│ └── Configurations/
│ └── TrackConfiguration.cs # EF fluent configuration for TrackEntity
├── Migrations/ # EF-generated migrations (namespace DeepDrftWeb.Migrations)
├── Repositories/
│ └── TrackRepository.cs # Data access layer
├── TrackService.cs # Service orchestrator
└── DeepDrftWeb.Services.csproj
```
## EF DbContext and configuration
`DeepDrftContext` targets SQLite, connection string from `appsettings.json` (`ConnectionStrings:DefaultConnection`). The design-time factory (`DeepDrftContextFactory`) hard-codes `../Database/deepdrft.db` for `dotnet ef` commands, so you can run migrations locally without a full app context.
`TrackConfiguration` uses EF fluent API:
- Table name: `track` (singular)
- Columns: snake_case (`entry_key`, `track_name`, `artist`, `album`, `genre`, `release_date`, `image_path`)
- `EntryKey`: required, max 100 (the FileDatabase vault entry id)
- `TrackName`, `Artist`: required, max 200
- `Album`, `Genre`: optional, max 200 / 100
- `ReleaseDate`: optional `DateOnly`
- `ImagePath`: optional, max 500 (currently a free-form URL string; points to images vault in future)
## Controller → Service → Repository → DbContext shape
- **Service** (public contract): Takes `TrackRepository`, catches exceptions at service boundary, returns `Result` / `ResultContainer<T>`.
- **Repository** (internal): Queries the DbContext. Throws on error (service catches).
- **DbContext** (EF): Directly accessed by repository, never by service (pattern isolation).
Example:
```csharp
// TrackService.GetPaged (public)
public async Task<ResultContainer<PagedResult<TrackEntity>>> GetPaged(
int pageNumber = 1,
int pageSize = 20,
string? sortColumn = null,
bool sortDescending = false)
{
try
{
var parameters = new PagingParameters<TrackEntity>
{
Page = pageNumber,
PageSize = pageSize,
OrderBy = GetOrderExpression(sortColumn), // Maps string to LINQ expression
IsDescending = sortDescending
};
var result = await _repository.GetPagedAsync(parameters);
return ResultContainer<PagedResult<TrackEntity>>.CreatePassResult(result);
}
catch (Exception e)
{
return ResultContainer<PagedResult<TrackEntity>>.CreateFailResult(e.Message);
}
}
```
## Pagination convention
`PagingParameters<T>` holds:
- `Page` (1-based, default 1)
- `PageSize` (default 20, capped at 100)
- `OrderBy: Expression<Func<T, object>>?` (LINQ expression for sorting)
- `IsDescending`
`TrackService.GetPaged` maps a string `sortColumn` (from the API query) to an expression via a switch:
```csharp
private static Expression<Func<TrackEntity, object>> GetOrderExpression(string? sortColumn)
=> sortColumn switch
{
"TrackName" => e => e.TrackName,
"Artist" => e => e.Artist,
"Album" => e => e.Album ?? "", // Nulls sort to end
"Genre" => e => e.Genre ?? "",
"ReleaseDate" => e => e.ReleaseDate ?? DateOnly.MaxValue,
_ => e => e.Id // Default to ID
};
```
Add new sort columns by extending this switch and the corresponding column names in the API.
## EF Migration commands
Run from the solution root:
```bash
# Add a migration
dotnet ef migrations add MigrationName --project DeepDrftWeb.Services --startup-project DeepDrftWeb
# Apply to database
dotnet ef database update --project DeepDrftWeb.Services --startup-project DeepDrftWeb
```
The design-time factory means you can also run `dotnet ef ... --project DeepDrftWeb.Services` standalone for local development (it doesn't need the startup project).
## Migrations namespace
Migrations live in the `DeepDrftWeb.Migrations` namespace (a legacy name retained for history continuity). Migration files are auto-generated and rarely edited by hand.
## Connection string
- **Web side**: `appsettings.json``ConnectionStrings:DefaultConnection`
- **CLI side**: `environment/connections.json``CliSettings:ConnectionString`
- Both point at `../Database/deepdrft.db`
The design-time factory hard-codes the path for `dotnet ef` commands.
## Service registration
In `DeepDrftWeb/Program.cs` and `DeepDrftCli/Program.cs`:
```csharp
services.AddDbContext<DeepDrftContext>(options =>
options.UseSqlite(configuration.GetConnectionString("DefaultConnection")));
services.AddScoped<TrackRepository>();
services.AddScoped<TrackService>();
```
## Important patterns
- **Required properties**: `EntryKey`, `TrackName`, `Artist` are `required` strings. This compile-time guarantee prevents half-built entities from reaching the database.
- **Optional properties**: `Album?`, `Genre?`, `ReleaseDate?`, `ImagePath?` are nullable. Queries must handle nulls (e.g., `Album ?? ""` in the sort expression to push nulls to end).
- **Result types**: Services return `ResultContainer<T>` or `Result` from NetBlocks. No exceptions propagate to callers — the service catches and wraps.
- **Async operations**: All database methods are async (`GetPagedAsync`, `GetByIdAsync`, etc.). Sync is not available.
## Development commands
```bash
# Build
dotnet build DeepDrftWeb.Services
# Add migration (from solution root)
dotnet ef migrations add MigrationName --project DeepDrftWeb.Services --startup-project DeepDrftWeb
# Apply migration
dotnet ef database update --project DeepDrftWeb.Services --startup-project DeepDrftWeb
# Run from CLI (which consumes this service)
dotnet run --project DeepDrftCli -- list
```
## What does NOT live here
- HTTP controllers or middleware
- Blazor components or rendering logic
- FileDatabase or binary content code (that's in `DeepDrftContent.Services`)
- Configuration for the web host (`appsettings.json` stays in `DeepDrftWeb`)
When working with this project, focus on the data layer (repository, service, EF configuration) and ensure all new SQL logic is testable and reusable by both the web host and the CLI.
@@ -0,0 +1,61 @@
using Data.Data.Configurations;
using DeepDrftModels.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace DeepDrftData.Data.Configurations;
public class TrackConfiguration : BaseEntityConfiguration<TrackEntity>
{
public override void Configure(EntityTypeBuilder<TrackEntity> builder)
{
// Wires up Id PK + audit columns (CreatedAt, UpdatedAt, IsDeleted) and the IsDeleted index.
base.Configure(builder);
builder.ToTable("track");
// Map the base audit columns to the snake_case naming the rest of the schema uses.
builder.Property(e => e.Id).HasColumnName("id");
builder.Property(e => e.CreatedAt).HasColumnName("created_at");
builder.Property(e => e.UpdatedAt).HasColumnName("updated_at");
builder.Property(e => e.IsDeleted).HasColumnName("is_deleted");
builder.Property(e => e.EntryKey)
.IsRequired()
.HasMaxLength(100)
.HasColumnName("entry_key");
builder.Property(e => e.TrackName)
.IsRequired()
.HasMaxLength(200)
.HasColumnName("track_name");
builder.Property(e => e.Artist)
.IsRequired()
.HasMaxLength(200)
.HasColumnName("artist");
builder.Property(e => e.Album)
.HasMaxLength(200)
.HasColumnName("album");
builder.Property(e => e.Genre)
.HasMaxLength(100)
.HasColumnName("genre");
builder.Property(e => e.ReleaseDate)
.HasColumnName("release_date");
builder.Property(e => e.ImagePath)
.HasMaxLength(500)
.HasColumnName("image_path");
builder.Property(e => e.CreatedByUserId)
.HasColumnName("created_by_user_id");
// Explicit index on is_deleted so soft-delete global query filters are
// not full table scans. base.Configure may or may not add this depending
// on the BlazorBlocks.Data version; declaring it here guarantees it.
builder.HasIndex(e => e.IsDeleted).HasDatabaseName("IX_track_is_deleted");
}
}
+21
View File
@@ -0,0 +1,21 @@
using DeepDrftModels.Entities;
using DeepDrftData.Data.Configurations;
using Microsoft.EntityFrameworkCore;
namespace DeepDrftData.Data;
public class DeepDrftContext : DbContext
{
public DeepDrftContext(DbContextOptions<DeepDrftContext> options) : base(options)
{
}
public DbSet<TrackEntity> Tracks { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfiguration(new TrackConfiguration());
}
}
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace DeepDrftData.Data;
public class DeepDrftContextFactory : IDesignTimeDbContextFactory<DeepDrftContext>
{
public DeepDrftContext CreateDbContext(string[] args)
{
// For 'dotnet ef' commands, set ConnectionStrings__DefaultConnection in your environment when
// you need to actually hit the database (e.g. `dotnet ef database update`). For model-only
// operations like `migrations add`, the placeholder below is sufficient — EF never connects.
// Example: export ConnectionStrings__DefaultConnection="Host=localhost;Port=5433;Database=postgres;Username=postgres;Password=yourpassword"
var connectionString = Environment.GetEnvironmentVariable("ConnectionStrings__DefaultConnection")
?? "Host=localhost;Port=5433;Database=postgres;Username=postgres;Password=placeholder";
var optionsBuilder = new DbContextOptionsBuilder<DeepDrftContext>();
optionsBuilder.UseNpgsql(connectionString);
return new DeepDrftContext(optionsBuilder.Options);
}
}
+29
View File
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<!-- Npgsql 10.0.1 requires Microsoft.EntityFrameworkCore >= 10.0.4; keep in sync -->
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
<PackageReference Include="Cerebellum.BlazorBlocks.Data" Version="10.3.30" />
<PackageReference Include="Cerebellum.BlazorBlocks.Data.Postgres" Version="10.3.30" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DeepDrftModels\DeepDrftModels.csproj" />
</ItemGroup>
</Project>
+15
View File
@@ -0,0 +1,15 @@
using DeepDrftModels.Entities;
using Models.Common;
using NetBlocks.Models;
namespace DeepDrftData;
public interface ITrackService
{
Task<ResultContainer<TrackEntity?>> GetById(long id);
Task<ResultContainer<List<TrackEntity>>> GetAll();
Task<ResultContainer<PagedResult<TrackEntity>>> GetPaged(int pageNumber, int pageSize, string? sortColumn, bool sortDescending, CancellationToken cancellationToken = default);
Task<ResultContainer<TrackEntity>> Create(TrackEntity newTrack);
Task<ResultContainer<TrackEntity>> Update(TrackEntity track);
Task<Result> Delete(long id);
}
@@ -0,0 +1,102 @@
// <auto-generated />
using System;
using DeepDrftData.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DeepDrftData.Migrations
{
[DbContext(typeof(DeepDrftContext))]
[Migration("20260519021400_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Album")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("album");
b.Property<string>("Artist")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("artist");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<long?>("CreatedByUserId")
.HasColumnType("bigint")
.HasColumnName("created_by_user_id");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entry_key");
b.Property<string>("Genre")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("genre");
b.Property<string>("ImagePath")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("image_path");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<DateOnly?>("ReleaseDate")
.HasColumnType("date")
.HasColumnName("release_date");
b.Property<string>("TrackName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("track_name");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_track_is_deleted");
b.ToTable("track", (string)null);
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,51 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DeepDrftData.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "track",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
entry_key = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
track_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
artist = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
album = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
genre = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
release_date = table.Column<DateOnly>(type: "date", nullable: true),
image_path = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
created_by_user_id = table.Column<long>(type: "bigint", nullable: true),
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_track", x => x.id);
});
migrationBuilder.CreateIndex(
name: "IX_track_is_deleted",
table: "track",
column: "is_deleted");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "track");
}
}
}
@@ -0,0 +1,99 @@
// <auto-generated />
using System;
using DeepDrftData.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DeepDrftData.Migrations
{
[DbContext(typeof(DeepDrftContext))]
partial class DeepDrftContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Album")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("album");
b.Property<string>("Artist")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("artist");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<long?>("CreatedByUserId")
.HasColumnType("bigint")
.HasColumnName("created_by_user_id");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entry_key");
b.Property<string>("Genre")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("genre");
b.Property<string>("ImagePath")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("image_path");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<DateOnly?>("ReleaseDate")
.HasColumnType("date")
.HasColumnName("release_date");
b.Property<string>("TrackName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("track_name");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_track_is_deleted");
b.ToTable("track", (string)null);
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,31 @@
using Data.Data.Repositories;
using Data.Errors;
using DeepDrftData.Data;
using DeepDrftModels.Entities;
using Microsoft.Extensions.Logging;
namespace DeepDrftData.Repositories;
public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
{
public TrackRepository(
DeepDrftContext context,
ILogger<Repository<DeepDrftContext, TrackEntity>> logger,
IDbExceptionClassifier? classifier = null)
: base(context, logger, classifier: classifier)
{
}
protected override void UpdateEntity(TrackEntity target, TrackEntity source)
{
base.UpdateEntity(target, source); // copies CreatedAt, UpdatedAt, IsDeleted
target.EntryKey = source.EntryKey;
target.TrackName = source.TrackName;
target.Artist = source.Artist;
target.Album = source.Album;
target.Genre = source.Genre;
target.ReleaseDate = source.ReleaseDate;
target.ImagePath = source.ImagePath;
target.CreatedByUserId = source.CreatedByUserId;
}
}
+44
View File
@@ -0,0 +1,44 @@
using DeepDrftModels.DTOs;
using DeepDrftModels.Entities;
using Models.Converters;
namespace DeepDrftData;
/// <summary>
/// Static entity ↔ DTO converter consumed by the BlazorBlocks Manager base class.
/// The DTO side mirrors the entity field-for-field; the audit columns
/// (CreatedAt, UpdatedAt) come from BaseEntity / BaseModel.
/// IsDeleted does not round-trip — soft-deleted rows are not exposed via the model.
/// </summary>
public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
{
public static TrackDto Convert(TrackEntity entity) => new()
{
Id = entity.Id,
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt,
EntryKey = entity.EntryKey,
TrackName = entity.TrackName,
Artist = entity.Artist,
Album = entity.Album,
Genre = entity.Genre,
ReleaseDate = entity.ReleaseDate,
ImagePath = entity.ImagePath,
CreatedByUserId = entity.CreatedByUserId
};
public static TrackEntity Convert(TrackDto model) => new()
{
Id = model.Id,
CreatedAt = model.CreatedAt,
UpdatedAt = model.UpdatedAt,
EntryKey = model.EntryKey,
TrackName = model.TrackName,
Artist = model.Artist,
Album = model.Album,
Genre = model.Genre,
ReleaseDate = model.ReleaseDate,
ImagePath = model.ImagePath,
CreatedByUserId = model.CreatedByUserId
};
}
+137
View File
@@ -0,0 +1,137 @@
using Data.Managers;
using DeepDrftData.Repositories;
using DeepDrftModels.DTOs;
using DeepDrftModels.Entities;
using Microsoft.Extensions.Logging;
using Models.Common;
using NetBlocks.Models;
namespace DeepDrftData;
/// <summary>
/// SQL-side track orchestrator built on the BlazorBlocks Manager base. Two surfaces coexist:
///
/// - The DTO-shaped IManager surface (GetById → TrackDto, etc.) inherited from Manager&lt;&gt;.
/// - The entity-shaped ITrackService surface retained for backward compatibility with the
/// web host, CMS, and CLI — all existing controllers and pages inject ITrackService and
/// expect TrackEntity-typed results. The two GetById overloads conflict on signature, so
/// ITrackService.GetById is implemented explicitly; the base Manager.Delete satisfies
/// both interfaces because the signatures align.
/// </summary>
public class TrackManager
: Manager<TrackEntity, TrackDto, TrackRepository, TrackConverter>, ITrackService
{
public TrackManager(
TrackRepository repository,
ILogger<Manager<TrackEntity, TrackDto, TrackRepository, TrackConverter>> logger)
: base(repository, logger)
{
}
// --- ITrackService implementation (entity-space; calls Repository directly) ---
// Explicit interface implementation — IManager.GetById returns ResultContainer<TrackDto>
// (inherited from Manager<>), so this entity-typed overload cannot coexist as a public
// member with the same name. Callers always inject ITrackService, so the explicit impl
// resolves correctly at the call site.
async Task<ResultContainer<TrackEntity?>> ITrackService.GetById(long id)
{
try
{
var entity = await Repository.GetByIdAsync(id);
return ResultContainer<TrackEntity?>.CreatePassResult(entity);
}
catch (Exception e)
{
return ResultContainer<TrackEntity?>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<List<TrackEntity>>> GetAll()
{
try
{
var entities = await Repository.GetAllAsync();
return ResultContainer<List<TrackEntity>>.CreatePassResult(entities.ToList());
}
catch (Exception e)
{
return ResultContainer<List<TrackEntity>>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<PagedResult<TrackEntity>>> GetPaged(
int pageNumber,
int pageSize,
string? sortColumn,
bool sortDescending,
CancellationToken cancellationToken = default)
{
try
{
var parameters = new PagingParameters<TrackEntity>
{
Page = pageNumber,
PageSize = pageSize,
IsDescending = sortDescending,
OrderBy = sortColumn switch
{
"TrackName" => e => e.TrackName,
"Artist" => e => e.Artist,
"Album" => e => (object)(e.Album ?? string.Empty),
"Genre" => e => (object)(e.Genre ?? string.Empty),
"ReleaseDate" => e => (object)(e.ReleaseDate ?? DateOnly.MaxValue),
_ => e => e.Id
}
};
var page = await Repository.GetPagedAsync(parameters);
return ResultContainer<PagedResult<TrackEntity>>.CreatePassResult(page);
}
catch (Exception e)
{
return ResultContainer<PagedResult<TrackEntity>>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<TrackEntity>> Create(TrackEntity newTrack)
{
try
{
var added = await Repository.AddAsync(newTrack);
return ResultContainer<TrackEntity>.CreatePassResult(added);
}
catch (Exception e)
{
return ResultContainer<TrackEntity>.CreateFailResult(e.Message);
}
}
// Manager<>.Update takes TrackDto and returns Result; this Update keeps the
// entity-typed contract callers expect and returns the post-update entity for the
// existing CMS edit flow that reads back the persisted values.
/// <summary>
/// Updates the track's metadata fields and returns the DB-authoritative entity.
/// The caller's <paramref name="track"/> object has its <c>UpdatedAt</c> field
/// mutated in place by <see cref="TrackRepository.UpdateAsync"/>; do not reuse it.
/// </summary>
public async Task<ResultContainer<TrackEntity>> Update(TrackEntity track)
{
try
{
await Repository.UpdateAsync(track);
var updated = await Repository.GetByIdAsync(track.Id);
return updated is not null
? ResultContainer<TrackEntity>.CreatePassResult(updated)
: ResultContainer<TrackEntity>.CreateFailResult("Track not found after update.");
}
catch (Exception e)
{
return ResultContainer<TrackEntity>.CreateFailResult(e.Message);
}
}
// Delete(long) is inherited from Manager<> — its Task<Result> signature already
// satisfies ITrackService.Delete, and the base implementation performs the soft delete
// via Repository.DeleteAsync. No override needed.
}