diff --git a/README.md b/README.md index 85a6b50d..788d0cae 100644 --- a/README.md +++ b/README.md @@ -452,14 +452,22 @@ Each collection-correction run is stored as one audit document. Its `statusHisto links to the current CMR collection record, records the prior and resulting CMR revision IDs, and stores a bounded unified diff between the original native metadata and the corrected writeback payload. +When a publisher event finds no matching collections, the listener stores a no-op audit document +with the published KMS version, original keyword change, `collectionCount: 0`, and a +`no-collections-found` outcome. This keeps published-version audit queries complete without +changing the existing per-collection audit workflow. The audit API is: - `GET /metadata_correction_audit` for newest-first, token-paginated audit searches. Supported - filters include collection, keyword UUID, action, scheme, status, native format, KMS version, - source, and date range. Supplied actions and schemes must be recognized KMS values, limits must + filters include collection, keyword UUID, action, scheme, status, native format, source, and + date range. Supplied actions and schemes must be recognized KMS values, limits must be integers from 1 through 250, and `startDate` must not be after `endDate`. List results contain compact collection, status, and old-to-new keyword path summaries. Add `?includeDiff=true` to include each available native-metadata diff in the list results. +- `GET /metadata_correction_audit/published` for audit documents across all published KMS + versions, or `GET /metadata_correction_audit/published/{versionName}` for one published version. + These are the official published-version reporting endpoints and use the same pagination and + response formats as the general audit search. - `GET /metadata_correction_audit/{runId}` for the complete audit document. Add `?includeDiff=true` when the native-metadata diff is needed; it is omitted by default to keep routine responses small. diff --git a/cdk/app/lib/CmrEventProcessingStack.ts b/cdk/app/lib/CmrEventProcessingStack.ts index 3168c557..9fdb04c3 100644 --- a/cdk/app/lib/CmrEventProcessingStack.ts +++ b/cdk/app/lib/CmrEventProcessingStack.ts @@ -98,6 +98,10 @@ export class CmrEventProcessingStack extends cdk.Stack { prefix: props.prefix, stage: props.stage, keywordEventsTopic: topic, + metadataCorrectionAuditClientSecurityGroup: + props.metadataCorrectionAuditClientSecurityGroup, + metadataCorrectionAuditEnvironment: props.metadataCorrectionAuditEnvironment, + metadataCorrectionAuditSecret: props.metadataCorrectionAuditSecret, metadataCorrectionRequestsTopic: metadataCorrectionSetup.metadataCorrectionRequestsTopic, securityGroup: this.securityGroup, useLocalstack, diff --git a/cdk/app/lib/helper/CmrKeywordEventsListenerSetup.ts b/cdk/app/lib/helper/CmrKeywordEventsListenerSetup.ts index bf5ca2a0..5b3bda76 100644 --- a/cdk/app/lib/helper/CmrKeywordEventsListenerSetup.ts +++ b/cdk/app/lib/helper/CmrKeywordEventsListenerSetup.ts @@ -5,11 +5,16 @@ import * as ec2 from 'aws-cdk-lib/aws-ec2' import * as iam from 'aws-cdk-lib/aws-iam' import * as eventsources from 'aws-cdk-lib/aws-lambda-event-sources' import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs' +import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager' import * as sns from 'aws-cdk-lib/aws-sns' import * as subscriptions from 'aws-cdk-lib/aws-sns-subscriptions' import * as sqs from 'aws-cdk-lib/aws-sqs' import { Construct } from 'constructs' +import { + getDocumentDbCertificateBundling, + getDocumentDbLambdaSecurityGroups +} from './DocumentDbLambdaConfig' import { NODE_LAMBDA_RUNTIME } from './NodeLambdaRuntime' /** @@ -22,6 +27,9 @@ interface CmrKeywordEventsListenerSetupProps { securityGroup: ec2.SecurityGroup stage: string keywordEventsTopic: sns.ITopic + metadataCorrectionAuditClientSecurityGroup?: ec2.ISecurityGroup + metadataCorrectionAuditEnvironment: Record + metadataCorrectionAuditSecret?: secretsmanager.ISecret metadataCorrectionRequestsTopic: sns.ITopic useLocalstack: boolean vpc: ec2.IVpc @@ -49,6 +57,9 @@ export class CmrKeywordEventsListenerSetup extends Construct { cmrBaseUrl, cmrSystemTokenParameterName, keywordEventsTopic, + metadataCorrectionAuditClientSecurityGroup, + metadataCorrectionAuditEnvironment, + metadataCorrectionAuditSecret, metadataCorrectionRequestsTopic, prefix, securityGroup, @@ -78,16 +89,22 @@ export class CmrKeywordEventsListenerSetup extends Construct { ...(cmrSystemTokenParameterName ? { CMR_SYSTEM_TOKEN_PARAMETER_NAME: cmrSystemTokenParameterName } : {}), + ...metadataCorrectionAuditEnvironment, METADATA_CORRECTION_REQUESTS_TOPIC_ARN: metadataCorrectionRequestsTopic.topicArn }, depsLockFilePath: path.join(projectRoot, 'package-lock.json'), projectRoot, + ...getDocumentDbCertificateBundling(metadataCorrectionAuditEnvironment), ...(useLocalstack ? {} : { vpc, vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, - securityGroups: [securityGroup] + securityGroups: getDocumentDbLambdaSecurityGroups({ + clientSecurityGroup: metadataCorrectionAuditClientSecurityGroup, + environment: metadataCorrectionAuditEnvironment, + securityGroup + }) }) }) @@ -97,6 +114,7 @@ export class CmrKeywordEventsListenerSetup extends Construct { this.queue.grantConsumeMessages(this.listenerLambda) metadataCorrectionRequestsTopic.grantPublish(this.listenerLambda) + metadataCorrectionAuditSecret?.grantRead(this.listenerLambda) if (cmrSystemTokenParameterName) { const systemTokenParameterArn = cdk.Stack.of(this).formatArn({ diff --git a/cdk/app/lib/helper/KmsLambdaFunctions.ts b/cdk/app/lib/helper/KmsLambdaFunctions.ts index 923bba38..e87f82f2 100644 --- a/cdk/app/lib/helper/KmsLambdaFunctions.ts +++ b/cdk/app/lib/helper/KmsLambdaFunctions.ts @@ -353,6 +353,32 @@ export class LambdaFunctions { this.props.metadataCorrectionEnvironment || {} // Additional Lambda environment variables ) + this.createApiLambda( + scope, // CDK construct scope + 'getMetadataCorrectionAudit/handler.js', // Lambda handler path + 'get-metadata-correction-audit', // Reuse the audit Lambda + 'getMetadataCorrectionAudit', // Exported handler name + '/metadata_correction_audit/published', // All published versions + 'GET', // HTTP method + false, // Do not use the EDL authorizer + Duration.seconds(30), // Lambda timeout + 1024, // Lambda memory in MB + this.props.metadataCorrectionEnvironment || {} // Additional Lambda environment variables + ) + + this.createApiLambda( + scope, // CDK construct scope + 'getMetadataCorrectionAudit/handler.js', // Lambda handler path + 'get-metadata-correction-audit', // Reuse the audit Lambda + 'getMetadataCorrectionAudit', // Exported handler name + '/metadata_correction_audit/published/{versionName}', // One published KMS version + 'GET', // HTTP method + false, // Do not use the EDL authorizer + Duration.seconds(30), // Lambda timeout + 1024, // Lambda memory in MB + this.props.metadataCorrectionEnvironment || {} // Additional Lambda environment variables + ) + this.createApiLambda( scope, // CDK construct scope 'getMetadataCorrectionAudit/handler.js', // Lambda handler path diff --git a/serverless/src/cmrKeywordEventsListener/__tests__/handler.test.js b/serverless/src/cmrKeywordEventsListener/__tests__/handler.test.js index e0b6c981..8bf92a08 100644 --- a/serverless/src/cmrKeywordEventsListener/__tests__/handler.test.js +++ b/serverless/src/cmrKeywordEventsListener/__tests__/handler.test.js @@ -8,6 +8,9 @@ import { import { getCmrCollectionConceptIds } from '@/shared/getCmrCollectionConceptIds' import { logger } from '@/shared/logger' +import { + persistMetadataCorrectionNoOpAuditLog +} from '@/shared/persistMetadataCorrectionNoOpAuditLog' import { publishMetadataCorrectionRequest } from '@/shared/publishMetadataCorrectionRequest' import { cmrKeywordEventsListener } from '../handler' @@ -58,6 +61,10 @@ vi.mock('@/shared/publishMetadataCorrectionRequest', () => ({ publishMetadataCorrectionRequest: vi.fn() })) +vi.mock('@/shared/persistMetadataCorrectionNoOpAuditLog', () => ({ + persistMetadataCorrectionNoOpAuditLog: vi.fn() +})) + describe('when the CMR keyword events processor is invoked', () => { beforeEach(() => { vi.clearAllMocks() @@ -71,6 +78,12 @@ describe('when the CMR keyword events processor is invoked', () => { message: '{}', topicArn: 'arn:aws:sns:us-east-1:000000000000:kms-dev-metadata-correction-requests.fifo' }) + + vi.mocked(persistMetadataCorrectionNoOpAuditLog).mockResolvedValue({ + runId: 'f3351653-dfc3-47d8-9176-294ea90bc118', + status: 'checked', + created: true + }) }) describe('when the invocation is successful', () => { @@ -225,6 +238,16 @@ describe('when the CMR keyword events processor is invoked', () => { ) expect(publishMetadataCorrectionRequest).not.toHaveBeenCalled() + expect(persistMetadataCorrectionNoOpAuditLog).toHaveBeenCalledWith({ + keywordEvent: { + EventType: 'UPDATED', + Scheme: 'sciencekeywords', + UUID: '1234' + }, + messageId: 'message-123', + publisherMessageId: undefined + }) + expect(result).toEqual({ batchItemFailures: [] }) @@ -262,6 +285,7 @@ describe('when the CMR keyword events processor is invoked', () => { expect(getCmrCollectionConceptIds).not.toHaveBeenCalled() expect(publishMetadataCorrectionRequest).not.toHaveBeenCalled() + expect(persistMetadataCorrectionNoOpAuditLog).not.toHaveBeenCalled() expect(result).toEqual({ batchItemFailures: [] }) @@ -636,6 +660,8 @@ describe('when the CMR keyword events processor is invoked', () => { } }) }) + + expect(persistMetadataCorrectionNoOpAuditLog).not.toHaveBeenCalled() }) }) @@ -660,6 +686,7 @@ describe('when the CMR keyword events processor is invoked', () => { })).rejects.toThrow('SNS unavailable') expect(logger.error).toHaveBeenCalled() + expect(persistMetadataCorrectionNoOpAuditLog).not.toHaveBeenCalled() }) }) }) diff --git a/serverless/src/cmrKeywordEventsListener/handler.js b/serverless/src/cmrKeywordEventsListener/handler.js index 1bfdb832..1ec878b8 100644 --- a/serverless/src/cmrKeywordEventsListener/handler.js +++ b/serverless/src/cmrKeywordEventsListener/handler.js @@ -2,6 +2,9 @@ import { formatKeywordObjectForLog } from '@/shared/formatKeywordObjectForLog' import { getCmrCollectionConceptIds } from '@/shared/getCmrCollectionConceptIds' import { hasMeaningfulKeywordObject } from '@/shared/hasMeaningfulKeywordObject' import { logger } from '@/shared/logger' +import { + persistMetadataCorrectionNoOpAuditLog +} from '@/shared/persistMetadataCorrectionNoOpAuditLog' import { publishMetadataCorrectionRequest } from '@/shared/publishMetadataCorrectionRequest' /** @@ -192,6 +195,12 @@ export const cmrKeywordEventsListener = async (event) => { ) if (collectionConceptIds.length === 0) { + await persistMetadataCorrectionNoOpAuditLog({ + keywordEvent, + messageId, + publisherMessageId: snsEnvelope.MessageId + }) + logger.info( '[consumer] No affected collection concept ids found for keyword event ' + `scheme=${scheme} ` diff --git a/serverless/src/getCapabilities/__tests__/handler.test.js b/serverless/src/getCapabilities/__tests__/handler.test.js index 9c2913be..4931f0a4 100644 --- a/serverless/src/getCapabilities/__tests__/handler.test.js +++ b/serverless/src/getCapabilities/__tests__/handler.test.js @@ -54,6 +54,8 @@ describe('getCapabilities', () => { expect(result.body).toContain(' { ':@': { name: 'get_metadata_correction_audit', href: '/metadata_correction_audit', - params: 'collectionConceptId=&keywordConceptUuid=&action=&scheme=&status=&nativeFormat=&publishedVersionName=&source=&startDate=&endDate=&paginationToken=&includeDiff=&format=&limit=', + params: 'collectionConceptId=&keywordConceptUuid=&action=&scheme=&status=&nativeFormat=&source=&startDate=&endDate=&paginationToken=&includeDiff=&format=&limit=', action: 'GET' } }, @@ -153,6 +153,22 @@ export const getCapabilities = async () => { action: 'GET' } }, + { + ':@': { + name: 'get_metadata_correction_audit_published', + href: '/metadata_correction_audit/published', + params: 'paginationToken=&includeDiff=&format=&limit=', + action: 'GET' + } + }, + { + ':@': { + name: 'get_metadata_correction_audit_published_version', + href: '/metadata_correction_audit/published/{versionName}', + params: 'paginationToken=&includeDiff=&format=&limit=', + action: 'GET' + } + }, { ':@': { name: 'get_concept_versions', diff --git a/serverless/src/getMetadataCorrectionAudit/__tests__/handler.test.js b/serverless/src/getMetadataCorrectionAudit/__tests__/handler.test.js index e79c7386..8398dd0a 100644 --- a/serverless/src/getMetadataCorrectionAudit/__tests__/handler.test.js +++ b/serverless/src/getMetadataCorrectionAudit/__tests__/handler.test.js @@ -73,6 +73,7 @@ describe('getMetadataCorrectionAudit', () => { status: undefined, nativeFormat: undefined, publishedVersionName: undefined, + publishedOnly: false, source: undefined, startDate: undefined, limit: '10' @@ -114,6 +115,113 @@ describe('getMetadataCorrectionAudit', () => { })) }) + test('filters audit documents using the published version path', async () => { + vi.mocked(getMetadataCorrectionAuditLog).mockResolvedValue({ + items: [], + nextPaginationToken: null + }) + + await getMetadataCorrectionAudit({ + resource: '/metadata_correction_audit/published/{versionName}', + pathParameters: { + versionName: 'published-42' + } + }) + + expect(getMetadataCorrectionAuditLog).toHaveBeenCalledWith(expect.objectContaining({ + publishedVersionName: 'published-42', + publishedOnly: true + })) + }) + + test('returns all published versions when the published path omits a version name', async () => { + vi.mocked(getMetadataCorrectionAuditLog).mockResolvedValue({ + items: [], + nextPaginationToken: null + }) + + await getMetadataCorrectionAudit({ + resource: '/metadata_correction_audit/published', + pathParameters: {}, + queryStringParameters: {} + }) + + expect(getMetadataCorrectionAuditLog).toHaveBeenCalledWith(expect.objectContaining({ + publishedVersionName: undefined, + publishedOnly: true + })) + }) + + test('renders all published versions as grouped html tables', async () => { + vi.mocked(getMetadataCorrectionAuditLog).mockResolvedValue({ + items: [{ + runId: 'run-published', + publishedVersionName: 'published-42', + status: 'applied' + }], + nextPaginationToken: null + }) + + const result = await getMetadataCorrectionAudit({ + resource: '/metadata_correction_audit/published', + pathParameters: {}, + queryStringParameters: { + format: 'html' + } + }) + + expect(result.statusCode).toBe(200) + expect(result.body).toContain('Published metadata correction audit') + expect(result.body).toContain('Published Version: published-42') + expect(result.body).toContain('') + expect(result.body).not.toContain('

Published metadata correction audit

') + expect(result.body).not.toContain(' { + vi.mocked(getMetadataCorrectionAuditLog).mockResolvedValue({ + items: [{ + runId: 'run-published', + collectionConceptId: 'C123-PROV', + publishedVersionName: 'published-42', + status: 'applied' + }], + nextPaginationToken: null + }) + + const result = await getMetadataCorrectionAudit({ + resource: '/metadata_correction_audit/published/{versionName}', + pathParameters: { + versionName: 'published-42' + }, + queryStringParameters: { + format: 'html' + } + }) + + expect(result.statusCode).toBe(200) + expect(result.body).toContain('Published Version: published-42') + expect(result.body).toContain('
') + expect(result.body).toContain('') + expect(result.body).toContain('') + }) + + test('directs published version searches to the official published route', async () => { + const result = await getMetadataCorrectionAudit({ + queryStringParameters: { + publishedVersionName: 'published-42' + } + }) + + expect(result.statusCode).toBe(400) + expect(JSON.parse(result.body)).toEqual({ + error: 'Error: Invalid metadata correction audit publishedVersionName: ' + + 'use /metadata_correction_audit/published/{versionName}' + }) + + expect(getMetadataCorrectionAuditLog).not.toHaveBeenCalled() + }) + test('renders a compact html summary with detail links for browser requests', async () => { vi.mocked(getMetadataCorrectionAuditLog).mockResolvedValue({ items: [{ diff --git a/serverless/src/getMetadataCorrectionAudit/handler.js b/serverless/src/getMetadataCorrectionAudit/handler.js index 1833bf28..75a58686 100644 --- a/serverless/src/getMetadataCorrectionAudit/handler.js +++ b/serverless/src/getMetadataCorrectionAudit/handler.js @@ -70,7 +70,6 @@ const HTML_RESPONSE_HEADERS = { * - scheme * - status * - nativeFormat - * - publishedVersionName * - source * - startDate / endDate * - paginationToken @@ -107,7 +106,7 @@ export const getMetadataCorrectionAudit = async (event, context) => { scheme, status, nativeFormat, - publishedVersionName, + publishedVersionName: queryPublishedVersionName, source, startDate, endDate, @@ -116,11 +115,28 @@ export const getMetadataCorrectionAudit = async (event, context) => { format, limit } = event?.queryStringParameters || {} - const runId = event?.pathParameters?.runId + const { + runId, + versionName: pathPublishedVersionName + } = event?.pathParameters || {} + const isPublishedAuditRoute = event?.resource === '/metadata_correction_audit/published' + || event?.resource === '/metadata_correction_audit/published/{versionName}' + || pathPublishedVersionName !== undefined + const publishedVersionName = isPublishedAuditRoute + ? pathPublishedVersionName + : undefined let responseFormat = 'json' try { responseFormat = normalizeResponseFormat(format) + + if (!isPublishedAuditRoute && queryPublishedVersionName !== undefined) { + throw new Error( + 'Invalid metadata correction audit publishedVersionName: ' + + 'use /metadata_correction_audit/published/{versionName}' + ) + } + const requestedIncludeDiff = responseFormat === 'html' && runId ? true : includeDiff const requestedLimit = responseFormat === 'html' && !limit ? '10' : limit @@ -170,6 +186,7 @@ export const getMetadataCorrectionAudit = async (event, context) => { status, nativeFormat, publishedVersionName, + publishedOnly: isPublishedAuditRoute, source, startDate, endDate, @@ -177,6 +194,13 @@ export const getMetadataCorrectionAudit = async (event, context) => { includeDiff: requestedIncludeDiff, limit: requestedLimit }) + let htmlTitle + + if (isPublishedAuditRoute) { + htmlTitle = publishedVersionName + ? `Published metadata correction audit: ${publishedVersionName}` + : 'Published metadata correction audit' + } if (responseFormat === 'html') { return { @@ -187,11 +211,15 @@ export const getMetadataCorrectionAudit = async (event, context) => { }, body: renderMetadataCorrectionAuditHtml({ collectionConceptId, + groupByPublishedVersion: isPublishedAuditRoute, items: auditPage.items, nextPageHref: buildNextPageHref( event?.queryStringParameters, auditPage.nextPaginationToken - ) + ), + showCollectionFilter: !isPublishedAuditRoute, + showPageHeader: !isPublishedAuditRoute, + title: htmlTitle }) } } diff --git a/serverless/src/shared/__tests__/getMetadataCorrectionAuditLog.test.js b/serverless/src/shared/__tests__/getMetadataCorrectionAuditLog.test.js index d0d1f35f..208c4e63 100644 --- a/serverless/src/shared/__tests__/getMetadataCorrectionAuditLog.test.js +++ b/serverless/src/shared/__tests__/getMetadataCorrectionAuditLog.test.js @@ -20,8 +20,12 @@ vi.mock('@/shared/documentDbClient', () => ({ const SUMMARY_PROJECTION = { _id: 1, runId: 1, + recordType: 1, collectionConceptId: 1, collectionUri: 1, + publishedVersionName: 1, + outcome: 1, + collectionCount: 1, status: 1, createdAt: 1, updatedAt: 1, @@ -82,7 +86,7 @@ describe('metadata correction audit queries', () => { keywordConceptUuid: 'keyword-1', limit: '25', nativeFormat: 'UMM', - publishedVersionName: '20.1', + publishedVersionName: ' 20.1 ', scheme: 'dataformat', source: 'cmrKeywordEventsListener', startDate: '2026-09-01', @@ -146,6 +150,61 @@ describe('metadata correction audit queries', () => { }) }) + test('returns no-op publisher events in published-version searches', async () => { + mongoCursor.toArray.mockResolvedValue([{ + _id: DETAIL_RUN_ID, + runId: DETAIL_RUN_ID, + recordType: 'publisherEventNoOp', + publishedVersionName: '20.1', + outcome: 'no-collections-found', + collectionCount: 0, + status: 'checked', + updatedAt: new Date('2026-09-16T12:01:00.000Z'), + corrections: [{ + scheme: 'platforms', + action: 'UPDATED', + oldKeywordPath: 'Platforms > GOSAT', + newKeywordPath: 'Platforms > GOSAT - Test1' + }] + }]) + + const result = await getMetadataCorrectionAuditLog({ + publishedVersionName: '20.1' + }) + + expect(result.items).toEqual([{ + runId: DETAIL_RUN_ID, + recordType: 'publisherEventNoOp', + publishedVersionName: '20.1', + outcome: 'no-collections-found', + collectionCount: 0, + status: 'checked', + updatedAt: new Date('2026-09-16T12:01:00.000Z'), + changes: [{ + scheme: 'platforms', + action: 'UPDATED', + oldKeywordPath: 'Platforms > GOSAT', + newKeywordPath: 'Platforms > GOSAT - Test1' + }] + }]) + }) + + test('returns audit documents from all published versions', async () => { + await getMetadataCorrectionAuditLog({ + publishedOnly: true + }) + + expect(collection.find).toHaveBeenCalledWith( + { + publishedVersionName: { + $exists: true, + $nin: [null, ''] + } + }, + { projection: SUMMARY_PROJECTION } + ) + }) + test('returns a pagination token when another page exists and applies it to the next query', async () => { const documents = [ { @@ -300,6 +359,24 @@ describe('metadata correction audit queries', () => { 'Invalid metadata correction audit includeDiff: expected true or false' ) + await expect(getMetadataCorrectionAuditLog({ + publishedVersionName: { $ne: null } + })).rejects.toThrow( + 'Invalid metadata correction audit published version: expected a nonempty string' + ) + + await expect(getMetadataCorrectionAuditLog({ + publishedVersionName: ' ' + })).rejects.toThrow( + 'Invalid metadata correction audit published version: expected 1 to 256 characters' + ) + + await expect(getMetadataCorrectionAuditLog({ + publishedVersionName: 'v'.repeat(257) + })).rejects.toThrow( + 'Invalid metadata correction audit published version: expected 1 to 256 characters' + ) + const invalidPaginationToken = Buffer.from(JSON.stringify({ createdAt: '2026-09-02T12:00:00.000Z', runId: '' diff --git a/serverless/src/shared/__tests__/persistMetadataCorrectionNoOpAuditLog.test.js b/serverless/src/shared/__tests__/persistMetadataCorrectionNoOpAuditLog.test.js new file mode 100644 index 00000000..968f90d6 --- /dev/null +++ b/serverless/src/shared/__tests__/persistMetadataCorrectionNoOpAuditLog.test.js @@ -0,0 +1,167 @@ +import { + beforeEach, + describe, + expect, + test, + vi +} from 'vitest' + +import { getMetadataCorrectionAuditCollection } from '@/shared/documentDbClient' + +import { persistMetadataCorrectionNoOpAuditLog } from '../persistMetadataCorrectionNoOpAuditLog' + +vi.mock('@/shared/documentDbClient', () => ({ + getMetadataCorrectionAuditCollection: vi.fn() +})) + +const KEYWORD_EVENT = { + EventType: 'UPDATED', + Scheme: 'platforms', + UUID: 'bac2e743-1d02-4868-8bd6-b8b8741e3794', + VersionName: '20.1', + Timestamp: '2026-09-16T12:00:00.000Z', + OldKeywordObject: { + Basis: 'Platforms', + Category: 'Space-based Platforms', + SubCategory: 'Earth Observation Satellites', + ShortName: 'GOSAT' + }, + NewKeywordObject: { + Basis: 'Platforms', + Category: 'Space-based Platforms', + SubCategory: 'Earth Observation Satellites', + ShortName: 'GOSAT - Test1' + } +} + +describe('persistMetadataCorrectionNoOpAuditLog', () => { + let collection + + beforeEach(() => { + vi.clearAllMocks() + collection = { + updateOne: vi.fn().mockResolvedValue({ upsertedCount: 1 }) + } + + vi.mocked(getMetadataCorrectionAuditCollection).mockResolvedValue(collection) + }) + + test('stores a retry-safe no-collections-found audit record', async () => { + const result = await persistMetadataCorrectionNoOpAuditLog({ + keywordEvent: KEYWORD_EVENT, + messageId: 'sqs-message-1', + publisherMessageId: 'sns-message-1', + timestamp: '2026-09-16T12:01:00.000Z' + }) + + expect(result).toEqual({ + runId: expect.any(String), + status: 'checked', + created: true + }) + + expect(collection.updateOne).toHaveBeenCalledWith( + { _id: result.runId }, + { + $set: expect.objectContaining({ + recordType: 'publisherEventNoOp', + collectionCount: 0, + publishedVersionName: '20.1', + outcome: 'no-collections-found', + status: 'checked', + trigger: { + eventType: 'UPDATED', + scheme: 'platforms', + keywordConceptUuid: 'bac2e743-1d02-4868-8bd6-b8b8741e3794', + timestamp: '2026-09-16T12:00:00.000Z' + }, + corrections: [{ + scheme: 'platforms', + action: 'UPDATED', + keywordConceptUuid: 'bac2e743-1d02-4868-8bd6-b8b8741e3794', + oldKeywordObject: KEYWORD_EVENT.OldKeywordObject, + newKeywordObject: KEYWORD_EVENT.NewKeywordObject, + oldKeywordPath: 'Platforms > Space-based Platforms > Earth Observation Satellites > GOSAT', + newKeywordPath: 'Platforms > Space-based Platforms > Earth Observation Satellites > GOSAT - Test1' + }] + }), + $setOnInsert: { + _id: result.runId, + runId: result.runId, + createdAt: new Date('2026-09-16T12:01:00.000Z'), + statusHistory: [{ + status: 'checked', + timestamp: new Date('2026-09-16T12:01:00.000Z'), + outcome: 'no-collections-found' + }] + } + }, + { upsert: true } + ) + }) + + test('uses the same audit id when an AWS message is retried', async () => { + await persistMetadataCorrectionNoOpAuditLog({ + keywordEvent: KEYWORD_EVENT, + publisherMessageId: 'sns-message-1' + }) + + await persistMetadataCorrectionNoOpAuditLog({ + keywordEvent: KEYWORD_EVENT, + publisherMessageId: 'sns-message-1' + }) + + expect(collection.updateOne.mock.calls[0][0]).toEqual( + collection.updateOne.mock.calls[1][0] + ) + }) + + test('uses the listener message id when the publisher message id is unavailable', async () => { + await persistMetadataCorrectionNoOpAuditLog({ + keywordEvent: KEYWORD_EVENT, + messageId: 'sqs-message-1' + }) + + expect(collection.updateOne).toHaveBeenCalledWith( + { _id: expect.any(String) }, + expect.any(Object), + { upsert: true } + ) + }) + + test('falls back to event content and empty paths when optional event data is unavailable', async () => { + const keywordEvent = { + EventType: 'DELETED', + Scheme: 'platforms', + UUID: 'bac2e743-1d02-4868-8bd6-b8b8741e3794' + } + + await persistMetadataCorrectionNoOpAuditLog({ keywordEvent }) + + expect(collection.updateOne).toHaveBeenCalledWith( + { _id: expect.any(String) }, + { + $set: expect.objectContaining({ + publishedVersionName: null, + corrections: [{ + scheme: 'platforms', + action: 'DELETED', + keywordConceptUuid: 'bac2e743-1d02-4868-8bd6-b8b8741e3794', + oldKeywordPath: '', + newKeywordPath: '' + }] + }), + $setOnInsert: expect.any(Object) + }, + { upsert: true } + ) + }) + + test('requires a publisher keyword event', async () => { + await expect(persistMetadataCorrectionNoOpAuditLog({})).rejects.toThrow( + 'Missing keywordEvent for metadata correction no-op audit persistence' + ) + + expect(getMetadataCorrectionAuditCollection).not.toHaveBeenCalled() + }) +}) diff --git a/serverless/src/shared/__tests__/renderMetadataCorrectionAuditHtml.test.js b/serverless/src/shared/__tests__/renderMetadataCorrectionAuditHtml.test.js index 9acabec4..88a3d974 100644 --- a/serverless/src/shared/__tests__/renderMetadataCorrectionAuditHtml.test.js +++ b/serverless/src/shared/__tests__/renderMetadataCorrectionAuditHtml.test.js @@ -61,6 +61,7 @@ describe('renderMetadataCorrectionAuditHtml', () => { items: [{ runId: 'run/summary', collectionConceptId: 'C123-PROV', + publishedVersionName: 'published-42', status: 'applied', changes: [{ scheme: 'platforms', @@ -72,11 +73,76 @@ describe('renderMetadataCorrectionAuditHtml', () => { }) expect(view).toContain('Keyword changes') + expect(view).toContain('Published Version published-42') expect(view).toContain('run/summary') expect(view).not.toContain('Native metadata diff') expect(view).not.toContain('Run details') }) + test('renders a published version report as a collection change table', () => { + const view = renderMetadataCorrectionAuditHtml({ + groupByPublishedVersion: true, + items: [{ + runId: 'run-v42-a', + collectionConceptId: 'C42-A', + publishedVersionName: 'published-42', + status: 'applied', + changes: [{ + scheme: 'platforms', + action: 'replace', + oldKeywordPath: 'Platforms > Old', + newKeywordPath: 'Platforms > New' + }] + }, { + runId: 'run-v42-b', + recordType: 'publisherEventNoOp', + collectionCount: 0, + outcome: 'no-collections-found', + publishedVersionName: 'published-42', + status: 'checked' + }, { + runId: 'run-v41', + publishedVersionName: 'published-41', + status: 'applied' + }] + }) + + expect(view).toContain('Published Version: published-42') + expect(view).toContain('2 audit records') + expect(view).toContain('Published Version: published-41') + expect(view).toContain('1 audit record') + expect(view).toContain('
Collection IDOutcome
') + expect(view).toContain('') + expect(view).toContain('') + expect(view).toContain('C42-A') + expect(view).toContain('Platforms > Old') + expect(view).toContain('Platforms > New') + expect(view).toContain('No collections found') + expect(view).toContain('no-collections-found') + expect(view.indexOf('Published Version: published-42')) + .toBeLessThan(view.indexOf('Published Version: published-41')) + }) + + test('renders failed and unknown published audit values', () => { + const view = renderMetadataCorrectionAuditHtml({ + groupByPublishedVersion: true, + items: [{ + runId: 'run-failed', + collectionConceptId: 'C-FAILED', + publishedVersionName: 'published-failed', + status: 'failed' + }, { + runId: 'run-unknown', + collectionConceptId: 'C-UNKNOWN' + }] + }) + + expect(view).toContain('Published Version: published-failed') + expect(view).toContain('Published Version: Unknown published version') + expect(view).toContain('failed') + expect(view).toContain('unknown') + }) + test('renders complete run context and lifecycle history in detail mode', () => { const view = renderMetadataCorrectionAuditHtml({ detail: true, @@ -131,6 +197,33 @@ describe('renderMetadataCorrectionAuditHtml', () => { expect(view).not.toContain('metadata_correction_audit/run-detail?format=html') }) + test('renders a no-collections-found publisher event clearly', () => { + const view = renderMetadataCorrectionAuditHtml({ + detail: true, + items: [{ + runId: 'f3351653-dfc3-47d8-9176-294ea90bc118', + recordType: 'publisherEventNoOp', + publishedVersionName: '20.1', + collectionCount: 0, + outcome: 'no-collections-found', + status: 'checked', + corrections: [{ + scheme: 'platforms', + action: 'UPDATED', + oldKeywordPath: 'Platforms > GOSAT', + newKeywordPath: 'Platforms > GOSAT - Test1' + }] + }] + }) + + expect(view).toContain('Publisher event: no collections found') + expect(view).toContain('Published KMS version') + expect(view).toContain('20.1') + expect(view).toContain('Collections found') + expect(view).toContain('no-collections-found') + expect(view).toContain('Platforms > GOSAT - Test1') + }) + test('escapes audit content and reports absent and truncated diffs', () => { const view = renderMetadataCorrectionAuditHtml({ detail: true, diff --git a/serverless/src/shared/getMetadataCorrectionAuditLog.js b/serverless/src/shared/getMetadataCorrectionAuditLog.js index 91ec3e93..65ac6ab6 100644 --- a/serverless/src/shared/getMetadataCorrectionAuditLog.js +++ b/serverless/src/shared/getMetadataCorrectionAuditLog.js @@ -7,6 +7,7 @@ import { CSV_FIELDS } from '@/shared/redis-path-store/helpers/constants' const DEFAULT_LIMIT = 100 const MAX_LIMIT = 250 +const MAX_PUBLISHED_VERSION_NAME_LENGTH = 256 const VALID_AUDIT_ACTIONS = new Set([ 'DELETED', 'INSERTED', @@ -22,8 +23,12 @@ const VALID_AUDIT_SCHEMES = new Map([ const AUDIT_SUMMARY_PROJECTION = { _id: 1, runId: 1, + recordType: 1, collectionConceptId: 1, collectionUri: 1, + publishedVersionName: 1, + outcome: 1, + collectionCount: 1, status: 1, createdAt: 1, updatedAt: 1, @@ -142,6 +147,36 @@ const normalizeDate = (value, fieldName) => { return date } +/** + * Validates a published KMS version before using it as a literal DocumentDB filter value. + * + * @example + * normalizePublishedVersionName(' 26.2 ') // '26.2' + * + * @param {unknown} value Published version supplied by the request path. + * @returns {string|undefined} Trimmed version name or undefined when omitted. + */ +const normalizePublishedVersionName = (value) => { + if (value === undefined || value === null) return undefined + + if (typeof value !== 'string') { + throw new Error( + 'Invalid metadata correction audit published version: expected a nonempty string' + ) + } + + const normalizedValue = value.trim() + + if (!normalizedValue || normalizedValue.length > MAX_PUBLISHED_VERSION_NAME_LENGTH) { + throw new Error( + 'Invalid metadata correction audit published version: ' + + `expected 1 to ${MAX_PUBLISHED_VERSION_NAME_LENGTH} characters` + ) + } + + return normalizedValue +} + /** * Decodes the opaque API pagination token into its keyset cursor values. * @@ -217,6 +252,7 @@ const buildAuditQuery = (filters) => { endDate, keywordConceptUuid, nativeFormat, + publishedOnly, publishedVersionName, scheme, source, @@ -237,7 +273,16 @@ const buildAuditQuery = (filters) => { } if (nativeFormat) query.nativeFormat = nativeFormat - if (publishedVersionName) query.publishedVersionName = publishedVersionName + const normalizedPublishedVersionName = normalizePublishedVersionName(publishedVersionName) + if (normalizedPublishedVersionName) { + query.publishedVersionName = normalizedPublishedVersionName + } else if (publishedOnly) { + query.publishedVersionName = { + $exists: true, + $nin: [null, ''] + } + } + const normalizedSchemes = normalizeScheme(scheme) if (normalizedSchemes) { const schemeFilter = normalizedSchemes.length === 1 @@ -372,6 +417,14 @@ const normalizeAuditSummary = (document, includeDiff = false) => ({ changes: Array.isArray(document.corrections) ? document.corrections.map(normalizeAuditChange) : [], + ...(document.recordType ? { recordType: document.recordType } : {}), + ...(document.publishedVersionName + ? { publishedVersionName: document.publishedVersionName } + : {}), + ...(document.outcome ? { outcome: document.outcome } : {}), + ...(document.collectionCount !== undefined + ? { collectionCount: document.collectionCount } + : {}), ...(includeDiff && document.metadataDiff ? { metadataDiff: document.metadataDiff } : {}), ...(document.error?.message ? { errorMessage: document.error.message } : {}) }) diff --git a/serverless/src/shared/persistMetadataCorrectionNoOpAuditLog.js b/serverless/src/shared/persistMetadataCorrectionNoOpAuditLog.js new file mode 100644 index 00000000..80c72d45 --- /dev/null +++ b/serverless/src/shared/persistMetadataCorrectionNoOpAuditLog.js @@ -0,0 +1,141 @@ +import { v5 as uuidv5 } from 'uuid' + +import { getMetadataCorrectionAuditCollection } from '@/shared/documentDbClient' +import { + getKeywordPathFromKeywordObject +} from '@/shared/redis-path-store/getKeywordPathFromKeywordObject' + +/** + * Removes undefined properties while retaining meaningful null and empty values. + * + * @param {Object} value Source object. + * @returns {Object} Object without undefined entries. + */ +const compactObject = (value) => Object.fromEntries( + Object.entries(value).filter(([, entryValue]) => entryValue !== undefined) +) + +/** + * Builds the no-op audit id from an AWS message id so retries update the same document. + * + * @param {Object} params Identifier inputs. + * @param {string} [params.messageId] Listener SQS message id. + * @param {string} [params.publisherMessageId] Original SNS message id. + * @param {Object} params.keywordEvent Original publisher event. + * @returns {string} Retry-stable audit UUID. + */ +const buildNoOpAuditId = ({ + messageId, + publisherMessageId, + keywordEvent +}) => uuidv5( + `kms:metadata-correction:no-op:${publisherMessageId || messageId || JSON.stringify(keywordEvent)}`, + uuidv5.URL +) + +/** + * Stores a publisher event when its CMR lookup returns no collections to evaluate. + * + * Existing collection-level audit records cover every collection that is found. This no-op + * document fills the remaining visibility gap without changing or duplicating that workflow. + * + * @example + * await persistMetadataCorrectionNoOpAuditLog({ + * keywordEvent: { + * EventType: 'UPDATED', + * Scheme: 'platforms', + * UUID: 'bac2e743-1d02-4868-8bd6-b8b8741e3794', + * VersionName: '20.1', + * OldKeywordObject: { ShortName: 'GOSAT' }, + * NewKeywordObject: { ShortName: 'GOSAT - Test1' } + * }, + * publisherMessageId: 'sns-message-1' + * }) + * // { runId: '', status: 'checked', created: true } + * + * @param {Object} params No-op audit values. + * @param {Object} params.keywordEvent Original KMS publisher event. + * @param {string} [params.messageId] Listener SQS message id. + * @param {string} [params.publisherMessageId] Original SNS message id. + * @param {string} [params.timestamp] Processing timestamp override for tests. + * @returns {Promise<{runId: string, status: string, created: boolean}>} Persistence result. + */ +export const persistMetadataCorrectionNoOpAuditLog = async ({ + keywordEvent, + messageId, + publisherMessageId, + timestamp +}) => { + if (!keywordEvent || typeof keywordEvent !== 'object') { + throw new Error('Missing keywordEvent for metadata correction no-op audit persistence') + } + + const collection = await getMetadataCorrectionAuditCollection() + const auditTimestamp = new Date(timestamp || Date.now()) + const runId = buildNoOpAuditId({ + keywordEvent, + messageId, + publisherMessageId + }) + const scheme = keywordEvent.Scheme + const oldKeywordObject = keywordEvent.OldKeywordObject + const newKeywordObject = keywordEvent.NewKeywordObject + const correction = compactObject({ + scheme, + action: keywordEvent.EventType, + keywordConceptUuid: keywordEvent.UUID, + oldKeywordObject, + newKeywordObject, + oldKeywordPath: getKeywordPathFromKeywordObject({ + scheme, + keywordObject: oldKeywordObject + }) || '', + newKeywordPath: getKeywordPathFromKeywordObject({ + scheme, + keywordObject: newKeywordObject + }) || '' + }) + const result = await collection.updateOne( + { _id: runId }, + { + $set: compactObject({ + recordType: 'publisherEventNoOp', + collectionCount: 0, + publishedVersionName: keywordEvent.VersionName || null, + source: 'cmrKeywordEventsListener', + messageId, + publisherMessageId, + trigger: compactObject({ + eventType: keywordEvent.EventType, + scheme, + keywordConceptUuid: keywordEvent.UUID, + timestamp: keywordEvent.Timestamp + }), + corrections: [correction], + outcome: 'no-collections-found', + status: 'checked', + updatedAt: auditTimestamp, + 'timestamps.checkedAt': auditTimestamp + }), + $setOnInsert: { + _id: runId, + runId, + createdAt: auditTimestamp, + statusHistory: [{ + status: 'checked', + timestamp: auditTimestamp, + outcome: 'no-collections-found' + }] + } + }, + { upsert: true } + ) + + return { + runId, + status: 'checked', + created: result.upsertedCount === 1 + } +} + +export default persistMetadataCorrectionNoOpAuditLog diff --git a/serverless/src/shared/renderMetadataCorrectionAuditHtml.js b/serverless/src/shared/renderMetadataCorrectionAuditHtml.js index 32caf5c8..dbcd21ac 100644 --- a/serverless/src/shared/renderMetadataCorrectionAuditHtml.js +++ b/serverless/src/shared/renderMetadataCorrectionAuditHtml.js @@ -50,6 +50,99 @@ const PAGE_STYLES = ` font-size: 1.05rem; } + .version-group { + margin-bottom: 2.5rem; + } + + .version-heading { + display: flex; + align-items: end; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; + padding-bottom: 0.65rem; + border-bottom: 0.2rem solid var(--accent); + } + + .version-heading h2 { + margin: 0; + font-family: Georgia, "Times New Roman", serif; + font-size: clamp(1.4rem, 2.5vw, 2rem); + } + + .version-heading p { + margin: 0; + color: var(--muted); + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + } + + .published-table-wrap { + overflow-x: auto; + border: 1px solid var(--line); + border-radius: 0.75rem; + background: var(--paper); + box-shadow: 0 0.7rem 2rem rgba(20, 47, 60, 0.08); + } + + .published-table { + width: 100%; + min-width: 70rem; + table-layout: fixed; + border-collapse: collapse; + font-size: 0.84rem; + } + + .published-table th, + .published-table td { + padding: 0.8rem; + border-right: 1px solid var(--line); + border-bottom: 1px solid var(--line); + text-align: left; + vertical-align: top; + overflow-wrap: anywhere; + } + + .published-table th:last-child, + .published-table td:last-child { border-right: 0; } + + .published-table tbody tr:last-child td { border-bottom: 0; } + + .published-table th { + background: #e7efed; + color: #36515d; + font-size: 0.7rem; + letter-spacing: 0.06em; + text-transform: uppercase; + } + + .published-table th:nth-child(1) { width: 15%; } + .published-table th:nth-child(2) { width: 9%; } + .published-table th:nth-child(3) { width: 8%; } + .published-table th:nth-child(4), + .published-table th:nth-child(5) { width: 23%; } + .published-table th:nth-child(6) { width: 13%; } + .published-table th:nth-child(7) { width: 9%; } + + .published-table a { + color: var(--accent); + font-weight: 700; + } + + .published-table .previous-path { background: #fff3f1; } + .published-table .updated-path { background: #eef9f4; } + + .outcome-cell .status { display: inline-block; } + + .outcome-detail { + display: block; + margin-top: 0.45rem; + color: var(--muted); + font-size: 0.72rem; + } + .audit-card { margin-bottom: 1.5rem; overflow: hidden; @@ -391,6 +484,10 @@ const renderDetailGrid = (entries) => { * @returns {string} HTML collection heading. */ const renderCollectionHeading = (audit) => { + if (audit.recordType === 'publisherEventNoOp') { + return 'Publisher event: no collections found' + } + const conceptId = displayValue(audit.collectionConceptId, 'Unknown collection') const collectionUri = String(audit.collectionUri || '') @@ -492,6 +589,7 @@ const renderRunDetails = (audit) => { ['Prior CMR revision', audit.priorRevisionId], ['Resulting CMR revision', audit.resultingRevisionId], ['Message ID', audit.messageId], + ['Collections found', audit.collectionCount], ['Created', audit.createdAt instanceof Date ? audit.createdAt.toISOString() : audit.createdAt], ['Updated', audit.updatedAt instanceof Date ? audit.updatedAt.toISOString() : audit.updatedAt] ]) @@ -589,6 +687,9 @@ const renderAuditCard = (audit, { detail }) => { const updatedText = updatedAt ? ` · Updated ${displayValue(updatedAt instanceof Date ? updatedAt.toISOString() : updatedAt)}` : '' + const publishedVersionText = audit.publishedVersionName + ? ` · Published Version ${displayValue(audit.publishedVersionName)}` + : '' const errorMessage = audit.errorMessage || audit.error?.message const errorSection = errorMessage ? `

${displayValue(errorMessage)}

` @@ -599,7 +700,7 @@ const renderAuditCard = (audit, { detail }) => {

${renderCollectionHeading(audit)}

-

Run ${renderRunId(audit, detail)}${updatedText}

+

Run ${renderRunId(audit, detail)}${publishedVersionText}${updatedText}

${displayValue(status)}
@@ -619,6 +720,84 @@ const renderAuditCard = (audit, { detail }) => { ` } +/** + * Renders one table row per changed keyword for a published-version audit document. + * + * @param {Object} audit Audit summary document. + * @returns {string} Published report table rows. + */ +const renderPublishedAuditRows = (audit) => { + const changes = Array.isArray(audit.changes) && audit.changes.length > 0 + ? audit.changes + : [{}] + const status = String(audit.status || 'unknown').toLowerCase() + const statusClass = status === 'failed' ? ' status-failed' : '' + const collection = audit.recordType === 'publisherEventNoOp' + ? 'No collections found' + : renderCollectionHeading(audit) + const outcomeDetail = audit.outcome && audit.outcome !== status + ? `${displayValue(audit.outcome)}` + : '' + + return changes.map((change) => ` +
+ + + + + + + + + `).join('') +} + +/** + * Groups audit rows under their published KMS version. + * + * @param {Array} items Audit summary documents in newest-first order. + * @returns {string} Version-grouped audit tables. + */ +const renderPublishedVersionGroups = (items) => { + const groups = new Map() + + items.forEach((audit) => { + const versionName = audit.publishedVersionName || 'Unknown published version' + const versionItems = groups.get(versionName) || [] + + versionItems.push(audit) + groups.set(versionName, versionItems) + }) + + return [...groups.entries()].map(([versionName, versionItems]) => ` +
+
+

Published Version: ${displayValue(versionName)}

+

${versionItems.length} audit ${versionItems.length === 1 ? 'record' : 'records'}

+
+
+
Collection IDOutcome
${collection}${displayValue(change.scheme, '')}${displayValue(change.action, '')}${displayValue(change.oldKeywordPath, '')}${displayValue(change.newKeywordPath, '')}${displayDate(audit.updatedAt || audit.createdAt)} + ${displayValue(status)} + ${outcomeDetail} +
+ + + + + + + + + + + + ${versionItems.map(renderPublishedAuditRows).join('')} +
Collection IDSchemeActionPrevious keyword pathUpdated keyword pathUpdatedOutcome
+ + + `).join('') +} + /** * Builds a self-contained browser view of metadata-correction audit records. * @@ -634,23 +813,34 @@ const renderAuditCard = (audit, { detail }) => { * @param {Object} params Page data. * @param {string} [params.collectionConceptId] Current collection ID filter. * @param {boolean} [params.detail=false] Whether to render complete run information. + * @param {boolean} [params.groupByPublishedVersion=false] Group summaries under version headings. * @param {Array} [params.items=[]] Audit records to render. * @param {string} [params.message] Optional empty-state or error message. * @param {string} [params.nextPageHref] Link to the next result page. + * @param {boolean} [params.showCollectionFilter=true] Whether to show the collection search form. + * @param {boolean} [params.showPageHeader=true] Whether to show the visible page title and count. * @param {string} [params.title='Metadata correction audit'] Browser page title. * @returns {string} Complete HTML document. */ export const renderMetadataCorrectionAuditHtml = ({ collectionConceptId, detail = false, + groupByPublishedVersion = false, items = [], message, nextPageHref, + showCollectionFilter = true, + showPageHeader = true, title = 'Metadata correction audit' } = {}) => { - const cards = items.length > 0 - ? items.map((audit) => renderAuditCard(audit, { detail })).join('') - : `

${displayValue(message, 'No matching audit records were found.')}

` + let cards = `

${displayValue(message, 'No matching audit records were found.')}

` + + if (items.length > 0) { + cards = groupByPublishedVersion + ? renderPublishedVersionGroups(items) + : items.map((audit) => renderAuditCard(audit, { detail })).join('') + } + const nextPageLink = nextPageHref ? `Next page` : '' @@ -666,9 +856,9 @@ export const renderMetadataCorrectionAuditHtml = ({
-

${displayValue(title)}

-

${items.length} audit ${items.length === 1 ? 'record' : 'records'} on this page

- ${detail ? '' : renderCollectionFilter(collectionConceptId)} + ${showPageHeader ? `

${displayValue(title)}

+

${items.length} audit ${items.length === 1 ? 'record' : 'records'} on this page

` : ''} + ${detail || !showCollectionFilter ? '' : renderCollectionFilter(collectionConceptId)} ${cards} ${nextPageLink}