Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
10 changes: 10 additions & 0 deletions .snyk
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,13 @@ ignore:
reason: 'Transitive dependency in express, @docusaurus/core, @apollo/server, apollo-link-rest; not exploitable in current usage.'
expires: '2026-01-19T00:00:00.000Z'
created: '2026-01-05T09:39:00.000Z'
'SNYK-JS-PNPMNPMCONF-14897556':
- '@docusaurus/core@3.9.2 > update-notifier@6.0.2 > latest-version@7.0.0 > package-json@8.1.1 > registry-auth-token@5.1.0 > @pnpm/npm-conf@2.3.1':
reason: 'Deep transitive dependency in Docusaurus; command injection vulnerability not exploitable in docs build context (static site generator, no untrusted input). Upgrade path blocked until Docusaurus updates update-notifier. Will reassess when Docusaurus 3.10+ is available.'
expires: '2026-07-08T00:00:00.000Z'
created: '2026-01-09T00:00:00.000Z'
- '@docusaurus/preset-classic@3.9.2 > @docusaurus/core@3.9.2 > update-notifier@6.0.2 > latest-version@7.0.0 > package-json@8.1.1 > registry-auth-token@5.1.0 > @pnpm/npm-conf@2.3.1':
reason: 'Deep transitive dependency in Docusaurus; command injection vulnerability not exploitable in docs build context (static site generator, no untrusted input). Upgrade path blocked until Docusaurus updates update-notifier. Will reassess when Docusaurus 3.10+ is available.'
expires: '2026-07-08T00:00:00.000Z'
created: '2026-01-09T00:00:00.000Z'

568 changes: 360 additions & 208 deletions apps/api/src/cellix.ts

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { ServiceMessagingMock } from '@sthrift/messaging-service-mock';

import { graphHandlerCreator } from '@sthrift/graphql';
import { restHandlerCreator } from '@sthrift/rest';
import { expiredListingDeletionHandlerCreator } from './timers/expired-listing-deletion-handler.ts';

import type {PaymentService} from '@cellix/payment-service';
import { PaymentServiceMock } from '@sthrift/payment-service-mock';
Expand Down Expand Up @@ -69,6 +70,10 @@ Cellix.initializeInfrastructureServices<ApiContextSpec, ApplicationServices>(
? serviceRegistry.getInfrastructureService<PaymentService>(PaymentServiceMock)
: serviceRegistry.getInfrastructureService<PaymentService>(PaymentServiceCybersource);

const blobStorageService = serviceRegistry.getInfrastructureService<ServiceBlobStorage>(
ServiceBlobStorage,
);

const { domainDataSource } = dataSourcesFactory.withSystemPassport();
RegisterEventHandlers(domainDataSource);

Expand All @@ -80,6 +85,15 @@ Cellix.initializeInfrastructureServices<ApiContextSpec, ApplicationServices>(
),
paymentService,
messagingService,
blobStorageService,
listingDeletionConfig: {
// biome-ignore lint/complexity/useLiteralKeys: TypeScript requires bracket notation for process.env
archivalMonths: Number(process.env['LISTING_ARCHIVAL_MONTHS']) || 6,
// biome-ignore lint/complexity/useLiteralKeys: TypeScript requires bracket notation for process.env
batchSize: Number(process.env['LISTING_DELETION_BATCH_SIZE']) || 100,
// biome-ignore lint/complexity/useLiteralKeys: TypeScript requires bracket notation for process.env
blobContainerName: process.env['LISTING_IMAGES_CONTAINER'] || 'listing-images',
},
};
})
.initializeApplicationServices((context) =>
Expand All @@ -98,4 +112,9 @@ Cellix.initializeInfrastructureServices<ApiContextSpec, ApplicationServices>(
{ route: '{communityId}/{role}/{memberId}/{*rest}' },
restHandlerCreator,
)
.registerAzureFunctionTimerHandler(
'processExpiredListingDeletions',
'0 0 2 * * *',
expiredListingDeletionHandlerCreator,
)
.startUp();
122 changes: 122 additions & 0 deletions apps/api/src/timers/expired-listing-deletion-handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Timer, InvocationContext } from '@azure/functions';
import type { ApplicationServicesFactory } from '@sthrift/application-services';
import { expiredListingDeletionHandlerCreator } from './expired-listing-deletion-handler.ts';

// Mock OpenTelemetry
vi.mock('@opentelemetry/api', () => ({
trace: {
getTracer: () => ({
startActiveSpan: vi.fn(async (_name, callback) => {
const mockSpan = {
setAttribute: vi.fn(),
setStatus: vi.fn(),
recordException: vi.fn(),
end: vi.fn(),
};
return await callback(mockSpan);
}),
}),
},
SpanStatusCode: {
OK: 1,
ERROR: 2,
},
}));

describe('expiredListingDeletionHandler', () => {
let mockContext: InvocationContext;
let mockTimer: Timer;
let mockFactory: ApplicationServicesFactory;
let mockProcessExpiredDeletions: ReturnType<typeof vi.fn>;

beforeEach(() => {
mockContext = {
log: vi.fn(),
error: vi.fn(),
} as unknown as InvocationContext;

mockTimer = {
isPastDue: false,
schedule: {
adjustForDST: false,
},
scheduleStatus: {
last: new Date().toISOString(),
next: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
},
};

mockProcessExpiredDeletions = vi.fn().mockResolvedValue({
deletedCount: 5,
deletedListingIds: ['id1', 'id2', 'id3', 'id4', 'id5'],
deletedConversationsCount: 10,
deletedImagesCount: 15,
errors: [],
});

mockFactory = {
forSystemTask: vi.fn().mockReturnValue({
Listing: {
ItemListing: {
processExpiredDeletions: mockProcessExpiredDeletions,
},
},
}),
} as unknown as ApplicationServicesFactory;
});

it('should call processExpiredDeletions and log success', async () => {
const handler = expiredListingDeletionHandlerCreator(mockFactory);
await handler(mockTimer, mockContext);

expect(mockFactory.forSystemTask).toHaveBeenCalledOnce();
expect(mockProcessExpiredDeletions).toHaveBeenCalledOnce();
expect(mockContext.log).toHaveBeenCalledWith('ExpiredListingDeletion: Timer triggered');
expect(mockContext.log).toHaveBeenCalledWith(
'ExpiredListingDeletion: Completed - 5 deleted, 0 errors',
);
});

it('should log past due message when timer is past due', async () => {
mockTimer.isPastDue = true;
const handler = expiredListingDeletionHandlerCreator(mockFactory);
await handler(mockTimer, mockContext);

expect(mockContext.log).toHaveBeenCalledWith('ExpiredListingDeletion: Timer is past due');
});

it('should log errors when processExpiredDeletions returns errors', async () => {
const errors = [{ listingId: 'failed-1', error: 'Delete failed' }];
mockProcessExpiredDeletions.mockResolvedValue({
deletedCount: 2,
deletedListingIds: ['id1', 'id2'],
deletedConversationsCount: 4,
deletedImagesCount: 6,
errors,
});

const handler = expiredListingDeletionHandlerCreator(mockFactory);
await handler(mockTimer, mockContext);

expect(mockContext.log).toHaveBeenCalledWith(
'ExpiredListingDeletion: Completed - 2 deleted, 1 errors',
);
expect(mockContext.log).toHaveBeenCalledWith(
`ExpiredListingDeletion: Errors: ${JSON.stringify(errors)}`,
);
});

it('should throw and log error when processExpiredDeletions fails', async () => {
const testError = new Error('Database connection failed');
mockProcessExpiredDeletions.mockRejectedValue(testError);

const handler = expiredListingDeletionHandlerCreator(mockFactory);

await expect(handler(mockTimer, mockContext)).rejects.toThrow('Database connection failed');
expect(mockContext.error).toHaveBeenCalledWith(
'ExpiredListingDeletion: Failed - Database connection failed',
);
});
});
46 changes: 46 additions & 0 deletions apps/api/src/timers/expired-listing-deletion-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { TimerHandler } from '@azure/functions';
import type { ApplicationServicesFactory } from '@sthrift/application-services';
import { trace, SpanStatusCode } from '@opentelemetry/api';

const tracer = trace.getTracer('timer:expired-listing-deletion');

export const expiredListingDeletionHandlerCreator = (
applicationServicesHost: ApplicationServicesFactory,
): TimerHandler => {
return async (timer, context) => {
await tracer.startActiveSpan('processExpiredDeletions', async (span) => {
try {
context.log('ExpiredListingDeletion: Timer triggered');

if (timer.isPastDue) {
context.log('ExpiredListingDeletion: Timer is past due');
}

const systemServices = applicationServicesHost.forSystemTask();
const result = await systemServices.Listing.ItemListing.processExpiredDeletions();

span.setAttribute('deletedCount', result.deletedCount);
span.setAttribute('errorCount', result.errors.length);

context.log(
`ExpiredListingDeletion: Completed - ${result.deletedCount} deleted, ${result.errors.length} errors`,
);

if (result.errors.length > 0) {
context.log(`ExpiredListingDeletion: Errors: ${JSON.stringify(result.errors)}`);
}

span.setStatus({ code: SpanStatusCode.OK });
} catch (error) {
span.setStatus({ code: SpanStatusCode.ERROR });
if (error instanceof Error) {
span.recordException(error);
context.error(`ExpiredListingDeletion: Failed - ${error.message}`);
}
throw error;
} finally {
span.end();
}
});
};
};
Loading
Loading