11using KeeperData . Bridge . Worker . Tasks ;
22using KeeperData . Core . Database ;
33using KeeperData . Core . ETL . Abstract ;
4+ using KeeperData . Core . ETL . Utils ;
45using KeeperData . Core . Reporting ;
56using KeeperData . Core . Reporting . Dtos ;
67using KeeperData . Infrastructure . Storage ;
@@ -17,6 +18,8 @@ public class ImportController(
1718 ICollectionManagementService collectionManagementService ,
1819 IReportingCollectionManagementService reportingCollectionManagementService ) : ControllerBase
1920{
21+ private readonly RecordIdGenerator _recordIdGenerator = new ( ) ;
22+
2023 /// <summary>
2124 /// Starts a bulk file import process asynchronously.
2225 /// Returns immediately with an import ID once the lock has been acquired.
@@ -244,6 +247,93 @@ public async Task<IActionResult> GetFileReports(Guid importId, CancellationToken
244247 }
245248 }
246249
250+ /// <summary>
251+ /// Gets the paginated lineage events for a specific record in chronological order.
252+ /// Shows the complete history of changes to a record across all imports.
253+ /// The recordId is a SHA256 hash generated by RecordIdGenerator from the composite key parts, making it URL-safe.
254+ /// </summary>
255+ /// <param name="collectionName">The collection name (e.g., "sam_cph_holdings")</param>
256+ /// <param name="recordId">The URL-safe record ID (SHA256 hash generated from primary key parts)</param>
257+ /// <param name="skip">Number of events to skip for pagination (default: 0)</param>
258+ /// <param name="top">Number of events to return (default: 10, max: 100)</param>
259+ /// <param name="cancellationToken">Cancellation token</param>
260+ /// <returns>Paginated list of lineage events</returns>
261+ [ HttpGet ( "lineage/{collectionName}/{recordId}" ) ]
262+ [ ProducesResponseType ( typeof ( PaginatedLineageEvents ) , StatusCodes . Status200OK ) ]
263+ [ ProducesResponseType ( typeof ( ErrorResponse ) , StatusCodes . Status400BadRequest ) ]
264+ [ ProducesResponseType ( typeof ( ErrorResponse ) , StatusCodes . Status404NotFound ) ]
265+ [ ProducesResponseType ( typeof ( ErrorResponse ) , StatusCodes . Status499ClientClosedRequest ) ]
266+ public async Task < IActionResult > GetRecordLineageEvents (
267+ string collectionName ,
268+ string recordId ,
269+ [ FromQuery ] int skip = 0 ,
270+ [ FromQuery ] int top = 10 ,
271+ CancellationToken cancellationToken = default )
272+ {
273+ logger . LogInformation ( "Received request to get lineage events for {CollectionName}/{RecordId} with skip={Skip}, top={Top}" ,
274+ collectionName , recordId , skip , top ) ;
275+
276+ // Validate parameters
277+ if ( skip < 0 )
278+ {
279+ logger . LogWarning ( "Invalid skip parameter: {Skip}" , skip ) ;
280+ return BadRequest ( new ErrorResponse
281+ {
282+ Message = "Skip parameter must be greater than or equal to 0." ,
283+ Timestamp = DateTime . UtcNow
284+ } ) ;
285+ }
286+
287+ if ( top <= 0 || top > 100 )
288+ {
289+ logger . LogWarning ( "Invalid top parameter: {Top}" , top ) ;
290+ return BadRequest ( new ErrorResponse
291+ {
292+ Message = "Top parameter must be between 1 and 100." ,
293+ Timestamp = DateTime . UtcNow
294+ } ) ;
295+ }
296+
297+ // The recordId is a SHA256 hash generated by RecordIdGenerator from the composite key parts.
298+ // ASP.NET Core URL-decodes the route parameter, but since our hash is already URL-safe,
299+ // we can pass it directly to the reporting service.
300+
301+ try
302+ {
303+ var result = await importReportingService . GetRecordLineageEventsPaginatedAsync (
304+ collectionName ,
305+ recordId ,
306+ skip ,
307+ top ,
308+ cancellationToken ) ;
309+
310+ logger . LogInformation ( "Successfully retrieved {Count} of {Total} lineage events for {CollectionName}/{RecordId}" ,
311+ result . Count , result . TotalEvents , collectionName , recordId ) ;
312+
313+ return Ok ( result ) ;
314+ }
315+ catch ( KeyNotFoundException ex )
316+ {
317+ logger . LogWarning ( "Lineage not found for {CollectionName}/{RecordId}: {Message}" ,
318+ collectionName , recordId , ex . Message ) ;
319+ return NotFound ( new ErrorResponse
320+ {
321+ Message = ex . Message ,
322+ Timestamp = DateTime . UtcNow
323+ } ) ;
324+ }
325+ catch ( OperationCanceledException )
326+ {
327+ logger . LogWarning ( "Get lineage events request was cancelled for {CollectionName}/{RecordId}" ,
328+ collectionName , recordId ) ;
329+ return StatusCode ( 499 , new ErrorResponse
330+ {
331+ Message = "Request was cancelled." ,
332+ Timestamp = DateTime . UtcNow
333+ } ) ;
334+ }
335+ }
336+
247337 /// <summary>
248338 /// Deletes a specific MongoDB collection by name.
249339 /// The collection name must be defined in DataSetDefinitions.
@@ -435,4 +525,102 @@ public async Task<IActionResult> DeleteAllReportingCollections(CancellationToken
435525 DeletedAtUtc = result . OperatedAtUtc
436526 } ) ;
437527 }
528+
529+ /// <summary>
530+ /// Generates a URL-safe record ID from composite key parts using SHA256 hashing.
531+ /// This endpoint helps clients construct the recordId needed for lineage queries.
532+ /// </summary>
533+ /// <param name="request">Request containing the key parts to hash</param>
534+ /// <param name="cancellationToken">Cancellation token</param>
535+ /// <returns>Generated record ID hash</returns>
536+ [ HttpPost ( "generate-record-id" ) ]
537+ [ ProducesResponseType ( typeof ( GenerateRecordIdResponse ) , StatusCodes . Status200OK ) ]
538+ [ ProducesResponseType ( typeof ( ErrorResponse ) , StatusCodes . Status400BadRequest ) ]
539+ public IActionResult GenerateRecordId ( [ FromBody ] GenerateRecordIdRequest request , CancellationToken cancellationToken = default )
540+ {
541+ logger . LogInformation ( "Received request to generate record ID from {count} key parts" , request . KeyParts ? . Length ?? 0 ) ;
542+
543+ // Validate request
544+ if ( request . KeyParts == null || request . KeyParts . Length == 0 )
545+ {
546+ logger . LogWarning ( "Invalid request: KeyParts is null or empty" ) ;
547+ return BadRequest ( new ErrorResponse
548+ {
549+ Message = "KeyParts must contain at least one value." ,
550+ Timestamp = DateTime . UtcNow
551+ } ) ;
552+ }
553+
554+ // Check for null or empty values in key parts
555+ for ( int i = 0 ; i < request . KeyParts . Length ; i ++ )
556+ {
557+ if ( string . IsNullOrEmpty ( request . KeyParts [ i ] ) )
558+ {
559+ logger . LogWarning ( "Invalid request: KeyPart at index {index} is null or empty" , i ) ;
560+ return BadRequest ( new ErrorResponse
561+ {
562+ Message = $ "KeyPart at index { i } cannot be null or empty.",
563+ Timestamp = DateTime . UtcNow
564+ } ) ;
565+ }
566+ }
567+
568+ try
569+ {
570+ var recordId = _recordIdGenerator . GenerateId ( request . KeyParts ) ;
571+
572+ logger . LogInformation ( "Successfully generated record ID: {recordId}" , recordId ) ;
573+
574+ return Ok ( new GenerateRecordIdResponse
575+ {
576+ RecordId = recordId ,
577+ KeyParts = request . KeyParts ,
578+ Timestamp = DateTime . UtcNow
579+ } ) ;
580+ }
581+ catch ( Exception ex )
582+ {
583+ logger . LogError ( ex , "Failed to generate record ID" ) ;
584+ return BadRequest ( new ErrorResponse
585+ {
586+ Message = $ "Failed to generate record ID: { ex . Message } ",
587+ Timestamp = DateTime . UtcNow
588+ } ) ;
589+ }
590+ }
591+ }
592+
593+ /// <summary>
594+ /// Request to generate a record ID from composite key parts.
595+ /// </summary>
596+ public record GenerateRecordIdRequest
597+ {
598+ /// <summary>
599+ /// The individual parts of the composite key (in order).
600+ /// Each part will be joined and hashed to create the record ID.
601+ /// </summary>
602+ /// <example>["NORTH", "F001"]</example>
603+ public required string [ ] KeyParts { get ; init ; }
604+ }
605+
606+ /// <summary>
607+ /// Response containing the generated record ID.
608+ /// </summary>
609+ public record GenerateRecordIdResponse
610+ {
611+ /// <summary>
612+ /// The generated URL-safe record ID (SHA256 hash, 43 characters).
613+ /// This ID can be used to query lineage events.
614+ /// </summary>
615+ public required string RecordId { get ; init ; }
616+
617+ /// <summary>
618+ /// The key parts that were used to generate the record ID.
619+ /// </summary>
620+ public required string [ ] KeyParts { get ; init ; }
621+
622+ /// <summary>
623+ /// The timestamp when the ID was generated.
624+ /// </summary>
625+ public DateTime Timestamp { get ; init ; }
438626}
0 commit comments