Skip to content
Draft
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
575 changes: 359 additions & 216 deletions apps/api/src/cellix.ts

Large diffs are not rendered by default.

66 changes: 66 additions & 0 deletions apps/api/src/features/cleanup-expired-reservation-requests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { InvocationContext, Timer, TimerHandler } from '@azure/functions';
import { trace } from '@opentelemetry/api';
import type { ApplicationServices } from '@sthrift/application-services';
import type { AppHost } from '@sthrift/context-spec';

const tracer = trace.getTracer('timer:cleanup-expired-reservation-requests');

/**
* Timer handler creator for deleting expired reservation requests
* Runs daily at 2 AM UTC per NCRONTAB schedule: "0 0 2 * * *"
*
* Per SRD data retention policy: "Completed Reservation Requests: Any reservation requests in
* the completed state will be deleted after 6 months have passed."
*
* @param applicationServicesHost - Application services host with system-level permissions
* @returns TimerHandler function
*/
export const cleanupExpiredReservationRequestsHandlerCreator = (
applicationServicesHost: AppHost<ApplicationServices>,
): TimerHandler => {
return async (timer: Timer, context: InvocationContext): Promise<void> => {
return await tracer.startActiveSpan(
'cleanupExpiredReservationRequests.handler',
async (span) => {
try {
span.setAttribute('timer.isPastDue', timer.isPastDue);
span.setAttribute('timer.schedule.last', timer.scheduleStatus.last);
span.setAttribute('timer.schedule.next', timer.scheduleStatus.next);

context.log(
'[cleanupExpiredReservationRequests] Timer trigger function started',
{
isPastDue: timer.isPastDue,
last: timer.scheduleStatus.last,
next: timer.scheduleStatus.next,
},
);

// Get application services with system-level permissions
// When forRequest() is called without an auth header, the application services
// factory creates a SystemPassport which has canDeleteRequest=true permission
const app = await applicationServicesHost.forRequest();

// Execute deletion of expired reservation requests
const deletedCount =
await app.ReservationRequest.deleteExpiredReservationRequests();

span.setAttribute('reservation_requests.deleted.count', deletedCount);

context.log(
`[cleanupExpiredReservationRequests] Completed. Deleted ${deletedCount} expired reservation requests`,
);
} catch (error) {
span.recordException(error as Error);
context.error(
'[cleanupExpiredReservationRequests] Error during cleanup:',
error,
);
throw error;
} finally {
span.end();
}
},
);
};
};
73 changes: 43 additions & 30 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,33 @@
import './service-config/otel-starter.ts';

import { Cellix } from './cellix.ts';
import type { ApiContextSpec } from '@sthrift/context-spec';
import type { MessagingService } from '@cellix/messaging-service';
import type { PaymentService } from '@cellix/payment-service';

import {
type ApplicationServices,
buildApplicationServicesFactory,
} from '@sthrift/application-services';
import type { ApiContextSpec } from '@sthrift/context-spec';
import { RegisterEventHandlers } from '@sthrift/event-handler';

import { ServiceMongoose } from '@sthrift/service-mongoose';
import * as MongooseConfig from './service-config/mongoose/index.ts';

import { graphHandlerCreator } from '@sthrift/graphql';
import { ServiceMessagingMock } from '@sthrift/messaging-service-mock';
import { ServiceMessagingTwilio } from '@sthrift/messaging-service-twilio';
import { PaymentServiceCybersource } from '@sthrift/payment-service-cybersource';
import { PaymentServiceMock } from '@sthrift/payment-service-mock';
import { restHandlerCreator } from '@sthrift/rest';
import { ServiceBlobStorage } from '@sthrift/service-blob-storage';

import { ServiceMongoose } from '@sthrift/service-mongoose';
import { ServiceTokenValidation } from '@sthrift/service-token-validation';
import { Cellix } from './cellix.ts';
import { cleanupExpiredReservationRequestsHandlerCreator } from './features/cleanup-expired-reservation-requests.ts';
import * as MongooseConfig from './service-config/mongoose/index.ts';
import * as TokenValidationConfig from './service-config/token-validation/index.ts';

import type { MessagingService } from '@cellix/messaging-service';
import { ServiceMessagingTwilio } from '@sthrift/messaging-service-twilio';
import { ServiceMessagingMock } from '@sthrift/messaging-service-mock';

import { graphHandlerCreator } from '@sthrift/graphql';
import { restHandlerCreator } from '@sthrift/rest';

import type {PaymentService} from '@cellix/payment-service';
import { PaymentServiceMock } from '@sthrift/payment-service-mock';
import { PaymentServiceCybersource } from '@sthrift/payment-service-cybersource';


const { NODE_ENV } = process.env;
const isDevelopment = NODE_ENV === 'development';

Cellix.initializeInfrastructureServices<ApiContextSpec, ApplicationServices>(
(serviceRegistry) => {

serviceRegistry
.registerInfrastructureService(
new ServiceMongoose(
Expand All @@ -47,11 +40,15 @@ Cellix.initializeInfrastructureServices<ApiContextSpec, ApplicationServices>(
new ServiceTokenValidation(TokenValidationConfig.portalTokens),
)
.registerInfrastructureService(
isDevelopment ? new ServiceMessagingMock() : new ServiceMessagingTwilio(),
isDevelopment
? new ServiceMessagingMock()
: new ServiceMessagingTwilio(),
)
.registerInfrastructureService(
isDevelopment ? new PaymentServiceMock() : new PaymentServiceCybersource()
);
isDevelopment
? new PaymentServiceMock()
: new PaymentServiceCybersource(),
);
},
)
.setContext((serviceRegistry) => {
Expand All @@ -62,12 +59,20 @@ Cellix.initializeInfrastructureServices<ApiContextSpec, ApplicationServices>(
);

const messagingService = isDevelopment
? serviceRegistry.getInfrastructureService<MessagingService>(ServiceMessagingMock)
: serviceRegistry.getInfrastructureService<MessagingService>(ServiceMessagingTwilio);

const paymentService = isDevelopment
? serviceRegistry.getInfrastructureService<PaymentService>(PaymentServiceMock)
: serviceRegistry.getInfrastructureService<PaymentService>(PaymentServiceCybersource);
? serviceRegistry.getInfrastructureService<MessagingService>(
ServiceMessagingMock,
)
: serviceRegistry.getInfrastructureService<MessagingService>(
ServiceMessagingTwilio,
);

const paymentService = isDevelopment
? serviceRegistry.getInfrastructureService<PaymentService>(
PaymentServiceMock,
)
: serviceRegistry.getInfrastructureService<PaymentService>(
PaymentServiceCybersource,
);

const { domainDataSource } = dataSourcesFactory.withSystemPassport();
RegisterEventHandlers(domainDataSource);
Expand All @@ -79,7 +84,7 @@ Cellix.initializeInfrastructureServices<ApiContextSpec, ApplicationServices>(
ServiceTokenValidation,
),
paymentService,
messagingService,
messagingService,
};
})
.initializeApplicationServices((context) =>
Expand All @@ -98,4 +103,12 @@ Cellix.initializeInfrastructureServices<ApiContextSpec, ApplicationServices>(
{ route: '{communityId}/{role}/{memberId}/{*rest}' },
restHandlerCreator,
)
.registerAzureFunctionTimerHandler(
'cleanup-expired-reservation-requests',
{
schedule: '0 0 2 * * *', // Daily at 2 AM UTC (NCRONTAB format)
runOnStartup: false,
},
cleanupExpiredReservationRequestsHandlerCreator,
)
.startUp();
83 changes: 83 additions & 0 deletions apps/docs/docs/data-retention-reservation-requests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Data Retention: Expired Reservation Request Deletion

## Overview

This feature implements automatic deletion of expired reservation requests in accordance with the ShareThrift Data Retention Strategy specified in the SRD and BRD documents.

**Retention Policy**: Reservation requests in the CLOSED state (completed) are archived for 6 months and then automatically deleted from the operational database (Azure Cosmos DB, MongoDB API).

## Architecture

### Components

1. **Timer Trigger** (`apps/api/src/features/cleanup-expired-reservation-requests.ts`)
- Azure Function Timer Trigger
- Schedule: Daily at 2 AM UTC (`0 0 2 * * *`)
- Uses system-level permissions for automated cleanup

2. **Application Service** (`packages/sthrift/application-services/src/contexts/reservation-request/reservation-request/delete-expired.ts`)
- Orchestrates the deletion process
- Integrates OpenTelemetry tracing for observability
- Batch processes expired requests with error handling

3. **Repository Method** (`packages/sthrift/persistence/src/datasources/readonly/reservation-request/`)
- `getExpiredClosed()`: Queries for CLOSED requests older than 6 months
- Filter: `state='Closed' AND updatedAt < (now - 6 months)`

4. **Domain Method** (`packages/sthrift/domain/src/domain/contexts/reservation-request/`)
- `requestDelete()`: Marks reservation request for deletion
- Requires `canDeleteRequest` permission (granted to system passport)

### Flow

```
┌─────────────────────┐
│ Timer Trigger │
│ (Daily 2 AM UTC) │
└──────────┬──────────┘
┌─────────────────────┐
│ Application Service │
│ deleteExpiredRR() │
└──────────┬──────────┘
├──► Query expired requests
│ (getExpiredClosed)
├──► For each expired request:
│ ├─► Load aggregate
│ ├─► Call requestDelete()
│ └─► Repository hard deletes
└──► Return count & log metrics
```

## Configuration

### Timer Schedule

The timer is configured with NCRONTAB expression:
```typescript
schedule: '0 0 2 * * *' // Daily at 2 AM UTC
```

To modify the schedule, update `apps/api/src/index.ts`.

### Retention Period

The 6-month retention period is defined in `getExpiredClosed()`. To modify, update the repository method.

## Observability

The deletion process emits OpenTelemetry spans with the following metrics:
- `reservation_requests.expired.count`: Number of expired requests found
- `reservation_requests.deleted.count`: Number successfully deleted

Console logs are emitted at key points for monitoring.

## References

- **BRD**: `documents/share-thrift-brd.md`
- **SRD**: `documents/share-thrift-srd-bronze.md` (Data Retention Strategy)
- Related: Listing deletion (issue #199) - similar pattern
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from 'vitest';
import type { DataSources } from '@sthrift/persistence';
import { beforeEach, describe, expect, it } from 'vitest';
import { ReservationRequest } from './index.ts';

describe('ReservationRequest Context Factory', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
import type { DataSources } from '@sthrift/persistence';
import { ReservationRequest as ReservationRequestApi, type ReservationRequestApplicationService } from './reservation-request/index.ts';
import { deleteExpiredReservationRequests } from './reservation-request/delete-expired.ts';
import {
ReservationRequest as ReservationRequestApi,
type ReservationRequestApplicationService,
} from './reservation-request/index.ts';

export interface ReservationRequestContextApplicationService {
ReservationRequest: ReservationRequestApplicationService;
ReservationRequest: ReservationRequestApplicationService & {
deleteExpiredReservationRequests: () => Promise<number>;
};
}

export const ReservationRequest = (
dataSources: DataSources
dataSources: DataSources,
): ReservationRequestContextApplicationService => {
return {
ReservationRequest: ReservationRequestApi(dataSources),
}
}
return {
ReservationRequest: {
...ReservationRequestApi(dataSources),
deleteExpiredReservationRequests:
deleteExpiredReservationRequests(dataSources),
},
};
};
Loading
Loading