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
34 changes: 32 additions & 2 deletions DocAnalytics.Api/BackgroundServices/ExtractionWorker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ await notifier.NotifyFileStateChangedAsync(file.SiteId,

try
{

var validateStart = DateTime.UtcNow;

// ── SECURITY GATE 1: GuardDuty malware verdict FIRST ──
// Runs BEFORE download/extraction so we never spend Bedrock cost on an unscanned/malicious file.
// GuardDuty writes the tag asynchronously after upload, so poll briefly.
Expand Down Expand Up @@ -133,10 +136,17 @@ await FailFileAsync(db, notifier, file, txn, now,
}
// ─────────────────────────────────────────────────────────────────────────────

var validateEnd = DateTime.UtcNow;
var extractStart = DateTime.UtcNow;


var result = await extractor.ExtractAsync(bytes, detectedType, ct);

var v = validator.Validate(result);

var extractEnd = DateTime.UtcNow;
var loadStart = DateTime.UtcNow;

// ⚠️ GOTCHA #2: idempotent → clear existing line items first
var old = await db.InvoiceLineItems.Where(i => i.FileId == file.Id).ToListAsync(ct);
db.RemoveRange(old);
Expand Down Expand Up @@ -215,18 +225,38 @@ await FailFileAsync(db, notifier, file, txn, now,
file.ExtractionConfidence = v.Confidence;
file.LastUpdatedAt = done;

db.Add(new FileStepHistory
{
Id = Guid.NewGuid(),
FileId = file.Id,
DocumentTypeId = file.DocumentTypeId,
StepName = "Validate",
Status = "Success",
StartedAt = validateStart,
CompletedAt = validateEnd
});
db.Add(new FileStepHistory
{
Id = Guid.NewGuid(),
FileId = file.Id,
DocumentTypeId = file.DocumentTypeId,
StepName = "Extract",
Status = failed ? "Failed" : "Success",
StartedAt = now,
CompletedAt = done,
StartedAt = extractStart,
CompletedAt = extractEnd,
ErrorCode = failed ? v.ErrorCode : null,
ErrorMessage = failed ? "Extraction confidence too low." : null
});
db.Add(new FileStepHistory
{
Id = Guid.NewGuid(),
FileId = file.Id,
DocumentTypeId = file.DocumentTypeId,
StepName = "Load",
Status = failed ? "Failed" : "Success",
StartedAt = loadStart,
CompletedAt = done
});

txn.ProcessingCount = Math.Max(0, txn.ProcessingCount - 1);
if (failed) txn.FailedCount += 1; else txn.CompletedCount += 1;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ public async Task GetStepPercentilesAsync_computes_durations()
new FileStepHistory { Id = Guid.NewGuid(), StepName = "Upload", Status = "Success", StartedAt = start, CompletedAt = start.AddSeconds(10) },
new FileStepHistory { Id = Guid.NewGuid(), StepName = "Upload", Status = "Success", StartedAt = start, CompletedAt = start.AddSeconds(20) }),
};
var result = await Sut(files).GetStepPercentilesAsync();
var result = await Sut(files).GetStepPercentilesAsync(CancellationToken.None);
var upload = result.Single(r => r.Step == "Upload");
Assert.Equal(2, upload.SampleCount);
Assert.InRange(upload.P50Seconds, 10, 20);
Expand Down
45 changes: 26 additions & 19 deletions DocAnalytics.Service/Analytics/AnalyticsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,24 +120,27 @@ public async Task<SeriesDto> GetErrorTrendAsync(DateTime? from, DateTime? to, Ca
}

/// <inheritdoc />
public async Task<List<StepPercentileDto>> GetStepPercentilesAsync(CancellationToken ct = default)
public async Task<List<StepPercentileDto>> GetStepPercentilesAsync(CancellationToken ct)
{
// Drive from Files (ITenantScoped → tenant_id + site_id auto-applied),
// navigate out to its steps → isolation guaranteed without touching FileStepHistory directly.
var raw = await _db.Files
.AsNoTracking()
.SelectMany(f => f.Steps)
.Where(s => s.StartedAt != null && s.CompletedAt != null) // only completed steps
// Navigate through FileRecord (which carries the global tenant+site query filter)
// so we only read steps belonging to the current tenant/site.
var stepData = await _db.Files
.SelectMany(f => f.Steps, (_, s) => s)
.Where(s => s.Status == "Success"
&& s.StartedAt != null
&& s.CompletedAt != null)
.Select(s => new { s.StepName, s.StartedAt, s.CompletedAt })
.ToListAsync(ct);

return raw
.GroupBy(x => x.StepName)
if (!stepData.Any())
return new List<StepPercentileDto>();

return stepData
.GroupBy(s => s.StepName)
.Select(g =>
{
var durations = g
.Select(x => (x.CompletedAt!.Value - x.StartedAt!.Value).TotalSeconds)
.Where(d => d >= 0)
.Select(s => (s.CompletedAt!.Value - s.StartedAt!.Value).TotalSeconds)
.OrderBy(d => d)
.ToList();

Expand All @@ -150,20 +153,23 @@ public async Task<List<StepPercentileDto>> GetStepPercentilesAsync(CancellationT
P99Seconds = Math.Round(Percentile(durations, 0.99), 1),
};
})
.OrderBy(r => StepOrder(r.Step)) // Upload → Validate → Transform → Load
.OrderBy(s => StepOrder(s.Step))
.ToList();
}

// Linear-interpolation percentile (same method Postgres percentile_cont uses).
private static double Percentile(IReadOnlyList<double> sorted, double p)
// Linear interpolation (same as numpy/Excel PERCENTILE.INC)
private static double Percentile(List<double> sorted, double p)
{
if (sorted.Count == 0) return 0;
if (sorted.Count == 1) return sorted[0];
var rank = p * (sorted.Count - 1);
var lo = (int)Math.Floor(rank);
var hi = (int)Math.Ceiling(rank);

double idx = p * (sorted.Count - 1);
int lo = (int)Math.Floor(idx);
int hi = (int)Math.Ceiling(idx);
if (lo == hi) return sorted[lo];
return sorted[lo] + (sorted[hi] - sorted[lo]) * (rank - lo);

double frac = idx - lo;
return sorted[lo] * (1 - frac) + sorted[hi] * frac;
}

private static int StepOrder(string step) => step switch
Expand All @@ -172,8 +178,9 @@ private static double Percentile(IReadOnlyList<double> sorted, double p)
"Validate" => 1,
"Transform" => 2,
"Load" => 3,
_ => 99
_ => 99,
};



}
19 changes: 19 additions & 0 deletions DocAnalytics.Service/Uploads/UploadService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -207,11 +207,30 @@ public async Task<bool> DeleteBatchAsync(Guid batchId, CancellationToken ct = de
foreach (var f in files.Where(f => !string.IsNullOrEmpty(f.StorageKey)))
await _storage.DeleteAsync(f.StorageKey!, ct);

// 2) DB: files cascade to headers/line-items/errors; then the batch itself
// 2) DB: files cascade to headers/line-items/errors; then the batch itself
_db.Files.RemoveRange(files);
_db.Remove(txn);

// ── audit trail: write BEFORE SaveChanges so it commits in the same transaction ──
_db.Add(new DocAnalytics.Domain.Entities.ActivityLog
{
Id = Guid.NewGuid(),
TenantId = txn.TenantId,
SiteId = txn.SiteId,
EventType = "BATCH_DELETED",
EntityType = "Batch",
EntityId = txn.Id,
EntityName = txn.SourceSystem,
OldState = txn.State,
NewState = "Deleted",
TriggeredBy = _me.UserId.ToString(),
CreatedAt = DateTime.UtcNow
});

await _db.SaveChangesAsync(ct);
return true;

}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,23 @@
background: var(--badge-file-bg, rgba(34, 197, 94, 0.12));
color: var(--badge-file-fg, #4ade80);
}

.al-evt-deleted {
opacity: 0.55;
text-decoration: line-through;
}

.al-deleted-badge {
display: inline-block;
margin-left: 8px;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 600;
background: rgba(192, 57, 43, 0.12);
color: var(--text-error, #c0392b);
}

:host ::ng-deep .row-orphaned td {
opacity: 0.4;
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ <h2 class="al-title">Activity Log</h2>

<app-data-table
[columns]="columns"
[rowClass]="rowClass"
[rows]="svc.rows()"
[loading]="svc.loading()"
[error]="svc.error()"
Expand All @@ -42,7 +43,12 @@ <h2 class="al-title">Activity Log</h2>
</ng-template>

<ng-template dtCell="event_type" let-row>
<span class="al-evt">{{ eventLabel(row.event_type) }}</span>
<span class="al-evt" [class.al-evt-deleted]="row.event_type === 'BATCH_DELETED'">
{{ eventLabel(row.event_type) }}
</span>
@if (row.event_type === 'BATCH_DELETED') {
<span class="al-deleted-badge">🗑️ Deleted</span>
}
</ng-template>

<!-- Entity column cell override -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,16 @@ export class ActivityLogComponent {
return false;
}

protected readonly rowClass = (row: ActivityLogItem): string =>
row.entity_type === 'File' && !row.batch_id ? 'row-orphaned' : '';

protected readonly eventTypeOptions: FilterOption[] = [
{ value: 'all', label: 'All events' },
{ value: 'FILE_STATE_CHANGED', label: 'File state changed' },
{ value: 'BATCH_SUBMITTED', label: 'Batch submitted' },
{ value: 'BATCH_COMPLETED', label: 'Batch completed' },
{ value: 'BATCH_FAILED', label: 'Batch failed' },
{ value: 'BATCH_DELETED', label: 'Batch deleted' },
];

// 'transition' has no backing field → rendered via the dtCell template (not sortable)
Expand All @@ -87,6 +91,7 @@ export class ActivityLogComponent {
BATCH_SUBMITTED: 'Batch submitted',
BATCH_COMPLETED: 'Batch completed',
BATCH_FAILED: 'Batch failed',
BATCH_DELETED: 'Batch Deleted',
};

constructor() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,11 @@
</tr>
} @else {
@for (row of rows(); track key(row, $index)) {
<tr (click)="rowClick.emit(row)" [class.clickable]="clickable()">
<tr
(click)="rowClick.emit(row)"
[class.clickable]="clickable()"
[ngClass]="rowClass() ? rowClass()!(row) : ''"
>
@for (col of columns(); track col.key) {
<td [style.text-align]="col.align || 'left'">
@if (cellTemplates().get(col.key); as tpl) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
input,
output,
} from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { NgClass, NgTemplateOutlet } from '@angular/common';

export type SortDir = 'asc' | 'desc';
export interface SortState {
Expand All @@ -34,7 +34,7 @@ export class DtCellDirective {

@Component({
selector: 'app-data-table',
imports: [NgTemplateOutlet],
imports: [NgTemplateOutlet, NgClass],

templateUrl: './data-table.component.html',
styleUrl: './data-table.component.css',
Expand All @@ -58,6 +58,8 @@ export class DataTableComponent<T = any> {
readonly clickable = input(false);
readonly rowId = input<((row: T) => string | number) | null>(null);

readonly rowClass = input<((row: T) => string) | null>(null);

readonly sortChange = output<SortState>();
readonly pageChange = output<number>();
readonly pageSizeChange = output<number>();
Expand Down
Loading