Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 0 additions & 32 deletions .claude/settings.local.json

This file was deleted.

3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
##
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore

# Claude Code local settings (user-specific permission overrides)
.claude/settings.local.json

# Local appsettings (may contain DB connection strings / secrets)
appsettings.Local.json
appsettings.local.json
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Hangfire;
using Hangfire.PostgreSql;

namespace TextServices.Builder.Api.Configuration;

public static class HangfireServiceCollectionExtensions
{
public static IServiceCollection AddHangfireServices(this IServiceCollection services, IConfiguration configuration)
{
var connectionString = configuration.GetConnectionString("BuilderDb")
?? throw new InvalidOperationException("ConnectionStrings:BuilderDb is required.");

services.AddHangfire(config => config
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UsePostgreSqlStorage(o => o.UseNpgsqlConnection(connectionString)));

services.AddHangfireServer();

return services;
}
}
7 changes: 4 additions & 3 deletions src/TextServices.Builder.Api/Features/Jobs/CreateJob.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Hangfire;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using TextServices.Builder.Api.Configuration;
using TextServices.Builder.Api.Data;
using TextServices.Builder.Api.Jobs;
Expand All @@ -14,7 +15,7 @@ public record CreateJobResult(bool AlreadyExists, JobResponse Response);
public class CreateJobHandler(
BuilderDbContext db,
IBackgroundJobClient hangfire,
TextServicesOptions options)
IOptions<TextServicesOptions> options)
: IRequestHandler<CreateJobRequest, CreateJobResult>
{
public async Task<CreateJobResult> Handle(CreateJobRequest request, CancellationToken ct)
Expand All @@ -24,7 +25,7 @@ public async Task<CreateJobResult> Handle(CreateJobRequest request, Cancellation
if (await db.Jobs.AnyAsync(j => j.Id == instruction.Id, ct))
{
var existing = (await db.Jobs.FindAsync([instruction.Id], ct))!;
return new CreateJobResult(true, JobResponse.From(existing, options));
return new CreateJobResult(true, JobResponse.From(existing, options.Value));
}

string? sourceDataJson = null;
Expand Down Expand Up @@ -54,6 +55,6 @@ public async Task<CreateJobResult> Handle(CreateJobRequest request, Cancellation
job.HangfireJobId = hangfireJobId;
await db.SaveChangesAsync(ct);

return new CreateJobResult(false, JobResponse.From(job, options));
return new CreateJobResult(false, JobResponse.From(job, options.Value));
}
}
7 changes: 4 additions & 3 deletions src/TextServices.Builder.Api/Features/Jobs/DeleteJob.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Hangfire;
using MediatR;
using Microsoft.Extensions.Options;
using TextServices.Builder.Api.Configuration;
using TextServices.Builder.Api.Data;
using TextServices.Storage;
Expand All @@ -13,7 +14,7 @@ public class DeleteJobHandler(
IBackgroundJobClient hangfire,
ITextStore textStore,
IHttpClientFactory httpClientFactory,
TextServicesOptions options,
IOptions<TextServicesOptions> options,
ILogger<DeleteJobHandler> logger)
: IRequestHandler<DeleteJobRequest, bool>
{
Expand All @@ -37,11 +38,11 @@ public async Task<bool> Handle(DeleteJobRequest request, CancellationToken ct)

private async Task InvalidateCacheAsync(string id)
{
if (string.IsNullOrEmpty(options.SearchApiBaseUrl)) return;
if (string.IsNullOrEmpty(options.Value.SearchApiBaseUrl)) return;
try
{
var http = httpClientFactory.CreateClient();
var url = $"{options.SearchApiBaseUrl.TrimEnd('/')}/cache/v1/{id}";
var url = $"{options.Value.SearchApiBaseUrl.TrimEnd('/')}/cache/v1/{id}";
await http.DeleteAsync(url);
}
catch (Exception ex)
Expand Down
5 changes: 3 additions & 2 deletions src/TextServices.Builder.Api/Features/Jobs/GetJob.cs
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
using MediatR;
using Microsoft.Extensions.Options;
using TextServices.Builder.Api.Configuration;
using TextServices.Builder.Api.Data;

namespace TextServices.Builder.Api.Features.Jobs;

public record GetJobRequest(string Id) : IRequest<JobResponse?>;

public class GetJobHandler(BuilderDbContext db, TextServicesOptions options)
public class GetJobHandler(BuilderDbContext db, IOptions<TextServicesOptions> options)
: IRequestHandler<GetJobRequest, JobResponse?>
{
public async Task<JobResponse?> Handle(GetJobRequest request, CancellationToken ct)
{
var job = await db.Jobs.FindAsync([request.Id], ct);
return job == null ? null : JobResponse.From(job, options);
return job == null ? null : JobResponse.From(job, options.Value);
}
}
63 changes: 63 additions & 0 deletions src/TextServices.Builder.Api/Features/Jobs/JobEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using System.ComponentModel.DataAnnotations;
using MediatR;

namespace TextServices.Builder.Api.Features.Jobs;

internal static class JobEndpoints
{
internal static IEndpointRouteBuilder MapJobEndpoints(this IEndpointRouteBuilder routes)
{
routes.MapPost("/textbuilder", async (JobInstruction instruction, ISender sender) =>
{
var validationResults = new List<ValidationResult>();
if (!Validator.TryValidateObject(instruction,
new ValidationContext(instruction), validationResults, validateAllProperties: true))
{
var errors = validationResults
.GroupBy(r => r.MemberNames.FirstOrDefault() ?? string.Empty)
.ToDictionary(
g => g.Key,
g => g.Select(r => r.ErrorMessage ?? "Invalid").ToArray());
return Results.ValidationProblem(errors);
}

var result = await sender.Send(new CreateJobRequest(instruction));

if (result.AlreadyExists)
return Results.Conflict(result.Response);

return Results.Accepted($"/textbuilder/{instruction.Id}", result.Response);
});

routes.MapGet("/textbuilder", async (ISender sender, int page = 1, int pageSize = 20, string? status = null) =>
{
var result = await sender.Send(new ListJobsRequest(page, pageSize, status));
return Results.Ok(result);
});

routes.MapGet("/textbuilder/{**id}", async (string id, ISender sender) =>
{
var response = await sender.Send(new GetJobRequest(id));
return response == null ? Results.NotFound() : Results.Ok(response);
});

routes.MapPut("/textbuilder/{**id}", async (string id, ISender sender) =>
{
var result = await sender.Send(new ReprocessJobRequest(id));
return result.Status switch
{
ReprocessStatus.NotFound => Results.NotFound(),
ReprocessStatus.Conflict => Results.Conflict(result.Response),
_ => Results.Accepted($"/textbuilder/{id}", result.Response),
};
});

routes.MapDelete("/textbuilder/{**id}", async (string id, ISender sender) =>
{
var found = await sender.Send(new DeleteJobRequest(id));
return found ? Results.NoContent() : Results.NotFound();
});

return routes;
}
}
5 changes: 3 additions & 2 deletions src/TextServices.Builder.Api/Features/Jobs/ListJobs.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using TextServices.Builder.Api.Configuration;
using TextServices.Builder.Api.Data;

Expand All @@ -10,7 +11,7 @@ namespace TextServices.Builder.Api.Features.Jobs;
/// <param name="Status">Optional status filter (e.g. "Completed", "Failed").</param>
public record ListJobsRequest(int Page, int PageSize, string? Status) : IRequest<PagedResult<JobResponse>>;

public class ListJobsHandler(BuilderDbContext db, TextServicesOptions options)
public class ListJobsHandler(BuilderDbContext db, IOptions<TextServicesOptions> options)
: IRequestHandler<ListJobsRequest, PagedResult<JobResponse>>
{
public async Task<PagedResult<JobResponse>> Handle(ListJobsRequest request, CancellationToken ct)
Expand All @@ -34,7 +35,7 @@ public async Task<PagedResult<JobResponse>> Handle(ListJobsRequest request, Canc
.Take(pageSize)
.ToListAsync(ct);

var items = entities.Select(j => JobResponse.From(j, options)).ToList();
var items = entities.Select(j => JobResponse.From(j, options.Value)).ToList();

return new PagedResult<JobResponse>(page, pageSize, total, items);
}
Expand Down
11 changes: 6 additions & 5 deletions src/TextServices.Builder.Api/Features/Jobs/ReprocessJob.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Hangfire;
using MediatR;
using Microsoft.Extensions.Options;
using TextServices.Builder.Api.Configuration;
using TextServices.Builder.Api.Data;
using TextServices.Builder.Api.Jobs;
Expand All @@ -25,7 +26,7 @@ public class ReprocessJobHandler(
IBackgroundJobClient hangfire,
ITextStore textStore,
IHttpClientFactory httpClientFactory,
TextServicesOptions options,
IOptions<TextServicesOptions> options,
ILogger<ReprocessJobHandler> logger)
: IRequestHandler<ReprocessJobRequest, ReprocessJobResult>
{
Expand All @@ -37,7 +38,7 @@ public async Task<ReprocessJobResult> Handle(ReprocessJobRequest request, Cancel

// Can't safely re-enqueue while the worker is actively processing.
if (job.Status == JobStatus.Running)
return new ReprocessJobResult(ReprocessStatus.Conflict, JobResponse.From(job, options));
return new ReprocessJobResult(ReprocessStatus.Conflict, JobResponse.From(job, options.Value));

// Remove the old Hangfire job entry (may be queued, awaiting retry, or
// already finished — Delete is a no-op if the job no longer exists).
Expand Down Expand Up @@ -67,16 +68,16 @@ public async Task<ReprocessJobResult> Handle(ReprocessJobRequest request, Cancel
job.HangfireJobId = hangfireJobId;
await db.SaveChangesAsync(ct);

return new ReprocessJobResult(ReprocessStatus.Ok, JobResponse.From(job, options));
return new ReprocessJobResult(ReprocessStatus.Ok, JobResponse.From(job, options.Value));
}

private async Task InvalidateCacheAsync(string id)
{
if (string.IsNullOrEmpty(options.SearchApiBaseUrl)) return;
if (string.IsNullOrEmpty(options.Value.SearchApiBaseUrl)) return;
try
{
var http = httpClientFactory.CreateClient();
var url = $"{options.SearchApiBaseUrl.TrimEnd('/')}/cache/v1/{id}";
var url = $"{options.Value.SearchApiBaseUrl.TrimEnd('/')}/cache/v1/{id}";
await http.DeleteAsync(url);
}
catch (Exception ex)
Expand Down
11 changes: 6 additions & 5 deletions src/TextServices.Builder.Api/Jobs/TextBuildJob.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Text.Json.Nodes;
using System.Xml.Linq;
using Hangfire;
using Microsoft.Extensions.Options;
using TextServices.Builder.Api.Configuration;
using TextServices.Builder.Api.Data;
using TextServices.Builder.Api.Features.Jobs;
Expand Down Expand Up @@ -31,7 +32,7 @@ public class TextBuildJob(
IVttFetcher vttFetcher,
IAnnotationPageFetcher annotationPageFetcher,
ITextStore textStore,
TextServicesOptions options,
IOptions<TextServicesOptions> options,
ILogger<TextBuildJob> logger)
{
private const int ProgressBatchSize = 10;
Expand Down Expand Up @@ -149,11 +150,11 @@ private async Task<IReadOnlyList<PageInstruction>> GetPages(
// hostname pattern and use a higher limit for those.
// Consider deriving the limit from the scheme/host of pages[0].Text, or
// adding a per-host override table to TextServicesOptions.
var semaphore = new SemaphoreSlim(options.MaxConcurrentAltoFetches);
var semaphore = new SemaphoreSlim(options.Value.MaxConcurrentAltoFetches);

var fetchTasks = pages
// pdf-type pages embed an existing PDF; they have no text to build.
// Custom-type pages are generated at PDF render time; no text to fetch.
var fetchTasks = pages
// pdf-type pages embed an existing PDF; they have no text to build.
// Custom-type pages are generated at PDF render time; no text to fetch.
.Where(page => page.Type == null)
.Select(page => IsAnnotationPage(page)
? FetchAnnotationPageWithSemaphoreAsync(page, semaphore, cancellationToken.ShutdownToken)
Expand Down
Loading
Loading