feat(api): add owner-scoped documents metadata index
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace JobTrackerApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/documents")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public sealed class DocumentsController(DocumentsIndexService documents, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<DocumentsPageDto>> List(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 25,
|
||||
[FromQuery] string? q = null,
|
||||
[FromQuery] string type = "all",
|
||||
[FromQuery] string state = "all",
|
||||
[FromQuery] string visibility = "all",
|
||||
[FromQuery] int? jobApplicationId = null,
|
||||
[FromQuery] string sort = "date-desc",
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var owner = currentUser.UserId;
|
||||
if (owner is null) return Unauthorized();
|
||||
if (page is < 1 or > 10000 || pageSize is < 1 or > 100 || q?.Length > 200 || jobApplicationId is <= 0)
|
||||
return BadRequest("Invalid document pagination or search parameters.");
|
||||
if (type is not ("all" or "cv" or "cover-letter" or "application-material" or "tailored-cv" or "attachment" or "submitted-package" or "email-draft")
|
||||
|| state is not ("all" or "current" or "draft" or "frozen")
|
||||
|| visibility is not ("all" or "private" or "public")
|
||||
|| sort is not ("date-desc" or "date-asc" or "name-asc" or "name-desc"))
|
||||
return BadRequest("Unsupported document filter or sort.");
|
||||
|
||||
return Ok(await documents.GetAsync(owner, page, pageSize, q, type, state, visibility, jobApplicationId, sort, cancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,7 @@ builder.Services.AddScoped<IApplicationTimelineService, ApplicationTimelineServi
|
||||
builder.Services.AddScoped<IApplicationIntelligenceService, ApplicationIntelligenceService>();
|
||||
builder.Services.AddScoped<IApplicationAssetsService, ApplicationAssetsService>();
|
||||
builder.Services.AddScoped<ISubmittedApplicationPackageService, SubmittedApplicationPackageService>();
|
||||
builder.Services.AddScoped<DocumentsIndexService>();
|
||||
builder.Services.AddScoped<IInterviewPrepService, InterviewPrepService>();
|
||||
|
||||
builder.Services.AddSingleton<AppPaths>();
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
using System.Globalization;
|
||||
using JobTrackerApi.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed record DocumentsPageDto(IReadOnlyList<DocumentIndexItemDto> Items, int Page, int PageSize, int Total, int TotalPages);
|
||||
|
||||
public sealed record DocumentIndexItemDto(
|
||||
string Id,
|
||||
string ResourceId,
|
||||
string Type,
|
||||
string Name,
|
||||
string State,
|
||||
string? Visibility,
|
||||
int? JobApplicationId,
|
||||
string? JobTitle,
|
||||
string? CompanyName,
|
||||
bool? JobIsDeleted,
|
||||
string? RecordedAt,
|
||||
string DateKind,
|
||||
long? Version,
|
||||
int? HistoryCount,
|
||||
int? AttachmentCount,
|
||||
int? CvVariantId,
|
||||
string? CvVariantName,
|
||||
int? CvVariantVersion,
|
||||
string? FileType,
|
||||
long? FileSize,
|
||||
string? Purpose,
|
||||
bool? UseForAi,
|
||||
string? Provider,
|
||||
string SourceHref,
|
||||
string? DownloadHref,
|
||||
string? PublicHref,
|
||||
bool? HasCurrentContent);
|
||||
|
||||
public sealed class DocumentsIndexService(JobTrackerContext db)
|
||||
{
|
||||
public async Task<DocumentsPageDto> GetAsync(string owner, int page, int pageSize, string? q,
|
||||
string type, string state, string visibility, int? jobApplicationId, string sort, CancellationToken ct)
|
||||
{
|
||||
var pattern = (q ?? "").Trim().ToLowerInvariant().Replace("!", "!!").Replace("%", "!%").Replace("_", "!_");
|
||||
object[] parameters = [owner, $"%{pattern}%", type, state, visibility, jobApplicationId ?? 0, pageSize, (page - 1) * pageSize];
|
||||
var sqlite = db.Database.IsSqlite();
|
||||
var count = await db.Database.SqlQueryRaw<int>(BuildSql(sqlite, true, sort), parameters).ToListAsync(ct);
|
||||
var rows = await db.Database.SqlQueryRaw<DocumentRow>(BuildSql(sqlite, false, sort), parameters).ToListAsync(ct);
|
||||
var total = count.Single();
|
||||
return new DocumentsPageDto(rows.Select(ToDto).ToArray(), page, pageSize, total, (int)Math.Ceiling(total / (double)pageSize));
|
||||
}
|
||||
|
||||
private static string BuildSql(bool sqlite, bool count, string sort)
|
||||
{
|
||||
const string history = "FROM `CoverLetterVersions` h WHERE h.OwnerUserId = {0} AND h.JobApplicationId = j.Id";
|
||||
var letterDate = $"(SELECT CAST(h.CreatedAtUtc AS CHAR) {history} ORDER BY h.Version DESC, h.Id DESC LIMIT 1)";
|
||||
var letterCount = $"(SELECT COUNT(*) {history})";
|
||||
var letterVersion = $"(SELECT MAX(h.Version) {history})";
|
||||
var letterContent = HasContent("j.CoverLetterText");
|
||||
var answers = HasAnswers(sqlite);
|
||||
var recruiter = HasContent("j.RecruiterMessageDraft");
|
||||
var tailored = HasContent("j.TailoredCvText");
|
||||
var sources = $$"""
|
||||
SELECT 'cv' AS Type, CAST(v.Id AS CHAR) AS ResourceId, v.Name AS Name, 'current' AS State,
|
||||
CASE WHEN v.IsPublic = 1 THEN 'public' ELSE 'private' END AS Visibility,
|
||||
j.Id AS JobApplicationId, j.JobTitle AS JobTitle, c.Name AS CompanyName, j.IsDeleted AS JobIsDeleted,
|
||||
CAST(v.UpdatedAtUtc AS CHAR) AS RawRecordedAt, 'updated-utc' AS DateKind,
|
||||
v.Version AS Version, NULL AS HistoryCount, NULL AS AttachmentCount,
|
||||
NULL AS CvVariantId, NULL AS CvVariantName, NULL AS CvVariantVersion,
|
||||
NULL AS FileType, NULL AS FileSize, NULL AS Purpose, NULL AS UseForAi, NULL AS Provider,
|
||||
v.PublicSlug AS PublicSlug, NULL AS HasCurrentContent
|
||||
FROM `CvVariants` v
|
||||
LEFT JOIN `JobApplications` j ON j.Id = v.JobApplicationId AND j.OwnerUserId = {0}
|
||||
LEFT JOIN `Companies` c ON c.Id = j.CompanyId AND c.OwnerUserId = {0}
|
||||
WHERE v.OwnerUserId = {0}
|
||||
UNION ALL
|
||||
SELECT 'cover-letter', CAST(j.Id AS CHAR), j.JobTitle, 'draft', NULL,
|
||||
j.Id, j.JobTitle, c.Name, j.IsDeleted, {{letterDate}},
|
||||
CASE WHEN {{letterDate}} IS NULL THEN 'unknown' ELSE 'history-recorded-utc' END,
|
||||
{{letterVersion}}, {{letterCount}}, NULL, NULL, NULL, NULL,
|
||||
NULL, NULL, NULL, NULL, NULL, NULL, CASE WHEN {{letterContent}} THEN 1 ELSE 0 END
|
||||
FROM `JobApplications` j
|
||||
LEFT JOIN `Companies` c ON c.Id = j.CompanyId AND c.OwnerUserId = {0}
|
||||
WHERE j.OwnerUserId = {0} AND ({{letterContent}} OR {{letterCount}} > 0)
|
||||
UNION ALL
|
||||
SELECT 'application-material', CAST(j.Id AS CHAR), j.JobTitle, 'draft', NULL,
|
||||
j.Id, j.JobTitle, c.Name, j.IsDeleted, NULL, 'unknown',
|
||||
NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL
|
||||
FROM `JobApplications` j
|
||||
LEFT JOIN `Companies` c ON c.Id = j.CompanyId AND c.OwnerUserId = {0}
|
||||
WHERE j.OwnerUserId = {0} AND ({{answers}} OR {{recruiter}})
|
||||
UNION ALL
|
||||
SELECT 'tailored-cv', CAST(j.Id AS CHAR), j.JobTitle, 'draft', NULL,
|
||||
j.Id, j.JobTitle, c.Name, j.IsDeleted,
|
||||
CAST(COALESCE(d.LastEditedAtUtc, d.LastGeneratedAtUtc, j.TailoredCvUpdatedAt) AS CHAR),
|
||||
CASE WHEN COALESCE(d.LastEditedAtUtc, d.LastGeneratedAtUtc, j.TailoredCvUpdatedAt) IS NULL THEN 'unknown' ELSE 'updated-utc' END,
|
||||
NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL
|
||||
FROM `JobApplications` j
|
||||
LEFT JOIN `Companies` c ON c.Id = j.CompanyId AND c.OwnerUserId = {0}
|
||||
LEFT JOIN `TailoredCvDrafts` d ON d.JobApplicationId = j.Id AND d.OwnerUserId = {0}
|
||||
WHERE j.OwnerUserId = {0} AND (d.Id IS NOT NULL OR {{tailored}})
|
||||
UNION ALL
|
||||
SELECT 'attachment', CAST(a.Id AS CHAR), a.FileName, 'current', NULL,
|
||||
j.Id, j.JobTitle, c.Name, j.IsDeleted, CAST(a.UploadDate AS CHAR), 'uploaded-local',
|
||||
NULL, NULL, NULL, NULL, NULL, NULL, a.FileType, a.FileSize, a.Purpose, a.UseForAi, NULL, NULL, NULL
|
||||
FROM `Attachments` a
|
||||
JOIN `JobApplications` j ON j.Id = a.JobApplicationId AND j.OwnerUserId = {0}
|
||||
LEFT JOIN `Companies` c ON c.Id = j.CompanyId AND c.OwnerUserId = {0}
|
||||
UNION ALL
|
||||
SELECT 'submitted-package', CAST(p.Id AS CHAR), COALESCE(p.CvVariantName, j.JobTitle), 'frozen', NULL,
|
||||
j.Id, j.JobTitle, c.Name, j.IsDeleted, CAST(p.CreatedAtUtc AS CHAR), 'captured-utc',
|
||||
p.Version, NULL, (SELECT COUNT(*) FROM `SubmittedPackageAttachments` a WHERE a.SubmittedApplicationPackageId = p.Id),
|
||||
p.CvVariantId, p.CvVariantName, p.CvVariantVersion, NULL, NULL, NULL, NULL, NULL, NULL, NULL
|
||||
FROM `SubmittedApplicationPackages` p
|
||||
JOIN `JobApplications` j ON j.Id = p.JobApplicationId AND j.OwnerUserId = {0}
|
||||
LEFT JOIN `Companies` c ON c.Id = j.CompanyId AND c.OwnerUserId = {0}
|
||||
WHERE p.OwnerUserId = {0}
|
||||
UNION ALL
|
||||
SELECT 'email-draft', CAST(d.Id AS CHAR), d.Subject, 'draft', NULL,
|
||||
j.Id, j.JobTitle, c.Name, j.IsDeleted, CAST(d.UpdatedAtUtc AS CHAR), 'updated-utc',
|
||||
d.Revision, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, d.Provider, NULL, NULL
|
||||
FROM `EmailDrafts` d
|
||||
JOIN `JobApplications` j ON j.Id = d.JobApplicationId AND j.OwnerUserId = {0}
|
||||
LEFT JOIN `Companies` c ON c.Id = j.CompanyId AND c.OwnerUserId = {0}
|
||||
WHERE d.OwnerUserId = {0}
|
||||
""";
|
||||
var filtered = $$"""
|
||||
FROM ({{sources}}) documents
|
||||
WHERE ({2} = 'all' OR Type = {2})
|
||||
AND ({3} = 'all' OR State = {3})
|
||||
AND ({4} = 'all' OR (Type = 'cv' AND Visibility = {4}))
|
||||
AND ({5} = 0 OR JobApplicationId = {5})
|
||||
AND ({{Fold("Name")}} LIKE {1} ESCAPE '!'
|
||||
OR {{Fold("JobTitle")}} LIKE {1} ESCAPE '!'
|
||||
OR {{Fold("CompanyName")}} LIKE {1} ESCAPE '!'
|
||||
OR {{Fold("CvVariantName")}} LIKE {1} ESCAPE '!')
|
||||
""";
|
||||
if (count) return $"SELECT COUNT(*) AS Value {filtered}";
|
||||
var order = sort switch
|
||||
{
|
||||
"date-desc" => "(DateSortTicks IS NULL), DateSortTicks DESC",
|
||||
"date-asc" => "(DateSortTicks IS NULL), DateSortTicks ASC",
|
||||
"name-asc" => $"{Fold("Name")} ASC",
|
||||
"name-desc" => $"{Fold("Name")} DESC",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(sort))
|
||||
};
|
||||
return $$"""
|
||||
SELECT documents.*, {{DateTicks(sqlite)}} AS DateSortTicks {{filtered}}
|
||||
ORDER BY {{order}}, Type ASC, ResourceId ASC
|
||||
LIMIT {6} OFFSET {7}
|
||||
""";
|
||||
}
|
||||
|
||||
private static string Fold(string value) => $"REPLACE(REPLACE(REPLACE(LOWER(COALESCE({value}, '')), 'Æ', 'æ'), 'Ø', 'ø'), 'Å', 'å')";
|
||||
|
||||
private static string HasContent(string value)
|
||||
{
|
||||
// SQL TRIM does not implement .NET's Unicode whitespace semantics.
|
||||
var expression = $"COALESCE({value}, '')";
|
||||
foreach (var character in "\t\n\v\f\r \u0085\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000")
|
||||
{
|
||||
// EF formats SQL line endings; CHAR preserves the exact control whitespace being removed.
|
||||
var needle = character is >= '\t' and <= '\r' ? $"CHAR({(int)character})" : $"'{character}'";
|
||||
expression = $"REPLACE({expression}, {needle}, '')";
|
||||
}
|
||||
return $"LENGTH({expression}) > 0";
|
||||
}
|
||||
|
||||
private static string HasAnswers(bool sqlite)
|
||||
{
|
||||
string Position(string needle, string value) => sqlite ? $"INSTR({value}, '{needle}')"
|
||||
: $"LOCATE(CONVERT('{needle}' USING utf8mb4) COLLATE utf8mb4_bin, {value})";
|
||||
const string startMarker = "<<<APPLICATION_ANSWER_DRAFT>>>";
|
||||
const string endMarker = "<<<END_APPLICATION_ANSWER_DRAFT>>>";
|
||||
const string legacyPrefix = "application answer draft:";
|
||||
// Keep character offsets aligned while matching modern markers with ordinal case sensitivity.
|
||||
var notes = sqlite ? "COALESCE(j.Notes, '')" : "CONVERT(COALESCE(j.Notes, '') USING utf8mb4) COLLATE utf8mb4_bin";
|
||||
var start = Position(startMarker, notes);
|
||||
var end = Position(endMarker, notes);
|
||||
var legacy = Position(legacyPrefix, $"LOWER({notes})" + (sqlite ? "" : " COLLATE utf8mb4_bin"));
|
||||
return $"CASE WHEN {start} > 0 AND {end} > {start} THEN {HasContent($"SUBSTR({notes}, {start} + {startMarker.Length}, {end} - {start} - {startMarker.Length})")} "
|
||||
+ $"WHEN {legacy} > 0 THEN {HasContent($"SUBSTR({notes}, {legacy} + {legacyPrefix.Length})")} ELSE 0 END";
|
||||
}
|
||||
|
||||
private static string DateTicks(bool sqlite)
|
||||
{
|
||||
string ticks;
|
||||
if (sqlite)
|
||||
{
|
||||
// Split whole seconds from seven fractional digits; SQLite's fractional date arithmetic rounds.
|
||||
const string offsetLength = "CASE WHEN SUBSTR(RawRecordedAt, -6, 1) IN ('+', '-') THEN 6 WHEN SUBSTR(RawRecordedAt, -1) = 'Z' THEN 1 ELSE 0 END";
|
||||
const string offset = "CASE WHEN SUBSTR(RawRecordedAt, -6, 1) IN ('+', '-') THEN SUBSTR(RawRecordedAt, -6) ELSE '+00:00' END";
|
||||
var fraction = $"CASE WHEN SUBSTR(RawRecordedAt, 20, 1) = '.' THEN SUBSTR(RawRecordedAt, 21, LENGTH(RawRecordedAt) - 20 - ({offsetLength})) ELSE '0' END";
|
||||
ticks = $"CAST(STRFTIME('%s', SUBSTR(RawRecordedAt, 1, 19) || ({offset})) AS INTEGER) * 10000000 "
|
||||
+ $"+ CAST(SUBSTR(({fraction}) || '0000000', 1, 7) AS INTEGER)";
|
||||
}
|
||||
else ticks = "TIMESTAMPDIFF(MICROSECOND, '1970-01-01 00:00:00', RawRecordedAt) * 10";
|
||||
return $"CASE WHEN DateKind IN ('unknown', 'uploaded-local') OR RawRecordedAt IS NULL THEN NULL ELSE {ticks} END";
|
||||
}
|
||||
|
||||
private static DocumentIndexItemDto ToDto(DocumentRow row)
|
||||
{
|
||||
var resourceId = row.Type == "email-draft" ? Guid.Parse(row.ResourceId).ToString("D") : row.ResourceId;
|
||||
var source = row.Type switch
|
||||
{
|
||||
"cv" => $"/career/builder/{resourceId}",
|
||||
"attachment" => $"/jobs/{row.JobApplicationId}?section=documents",
|
||||
"cover-letter" => $"/jobs/{row.JobApplicationId}?section=application&sub=letter",
|
||||
"application-material" => $"/jobs/{row.JobApplicationId}?section=application&sub=answers",
|
||||
"tailored-cv" => $"/jobs/{row.JobApplicationId}?section=application&sub=cv",
|
||||
"submitted-package" => $"/jobs/{row.JobApplicationId}?section=application&sub=submission",
|
||||
"email-draft" => "/correspondence",
|
||||
_ => throw new InvalidOperationException("Unsupported document source.")
|
||||
};
|
||||
var recordedAt = row.DateSortTicks.HasValue
|
||||
? DateTimeOffset.UnixEpoch.AddTicks(row.DateSortTicks.Value).UtcDateTime.ToString("O", CultureInfo.InvariantCulture)
|
||||
: row.DateKind == "uploaded-local" && row.RawRecordedAt is not null
|
||||
? DateTime.Parse(row.RawRecordedAt, CultureInfo.InvariantCulture).ToString("yyyy-MM-dd'T'HH:mm:ss.fffffff", CultureInfo.InvariantCulture)
|
||||
: null;
|
||||
return new DocumentIndexItemDto($"{row.Type}:{resourceId}", resourceId, row.Type, row.Name, row.State, row.Visibility,
|
||||
row.JobApplicationId, row.JobTitle, row.CompanyName, row.JobIsDeleted, recordedAt, row.DateKind, row.Version,
|
||||
row.HistoryCount, row.AttachmentCount, row.CvVariantId, row.CvVariantName, row.CvVariantVersion,
|
||||
row.FileType, row.FileSize, row.Purpose, row.UseForAi, row.Provider, source,
|
||||
row.Type == "attachment" ? $"/api/attachments/download/{resourceId}" : null,
|
||||
row.Type == "cv" && row.Visibility == "public" && !string.IsNullOrWhiteSpace(row.PublicSlug) ? $"/cv/{Uri.EscapeDataString(row.PublicSlug)}" : null,
|
||||
row.HasCurrentContent);
|
||||
}
|
||||
|
||||
private sealed class DocumentRow
|
||||
{
|
||||
public string Type { get; set; } = "";
|
||||
public string ResourceId { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public string State { get; set; } = "";
|
||||
public string? Visibility { get; set; }
|
||||
public int? JobApplicationId { get; set; }
|
||||
public string? JobTitle { get; set; }
|
||||
public string? CompanyName { get; set; }
|
||||
public bool? JobIsDeleted { get; set; }
|
||||
public string? RawRecordedAt { get; set; }
|
||||
public string DateKind { get; set; } = "";
|
||||
public long? Version { get; set; }
|
||||
public int? HistoryCount { get; set; }
|
||||
public int? AttachmentCount { get; set; }
|
||||
public int? CvVariantId { get; set; }
|
||||
public string? CvVariantName { get; set; }
|
||||
public int? CvVariantVersion { get; set; }
|
||||
public string? FileType { get; set; }
|
||||
public long? FileSize { get; set; }
|
||||
public string? Purpose { get; set; }
|
||||
public bool? UseForAi { get; set; }
|
||||
public string? Provider { get; set; }
|
||||
public string? PublicSlug { get; set; }
|
||||
public bool? HasCurrentContent { get; set; }
|
||||
public long? DateSortTicks { get; set; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user