Skip to content
Open
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
35 changes: 35 additions & 0 deletions PR_DESCRIPTION_SCHEDULED_REPORT_EXPORTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# PR: Scheduled Developer Report Exports to Object Storage

## Summary

Adds a daily export pipeline that materialises `usage_events` into per-developer CSV and JSON artifacts stored in S3-compatible object storage, and exposes a signed download URL endpoint at `GET /api/developers/exports`.

This replaces the previous synchronous export approach which timed out on large date ranges.

## Changes

### New files
- `migrations/0017_developer_exports.sql` — `developer_exports` table with `id`, `developer_id`, `format`, `s3_key`, `exported_at`, `expires_at` and a composite index on `(developer_id, exported_at DESC)`
- `src/services/reportExporter.ts` — `ReportExporterService`, `InMemoryExportStore`, `DeveloperExportStore` interface, `createReportExporterWorker` worker factory
- `src/services/reportExporter.test.ts` — unit tests for service, store, and worker lifecycle

### Modified files
- `src/db/schema.ts` — added `developerExports` Drizzle table definition, `DeveloperExport` and `NewDeveloperExport` types
- `src/routes/developerRoutes.ts` — added `GET /exports` route and extended `DeveloperRoutesDeps` with optional `reportExporterService`
- `src/routes/developerRoutes.test.ts` — added `describe('GET /api/developers/exports')` test block (5 cases)
- `docs/scheduled-exports.md` — updated to document the new table, route, TTL config, daily job interval, and in-memory test adapter

## Test coverage

| Test file | Cases |
|---|---|
| `src/services/reportExporter.test.ts` | 8 (runDailyExports window, empty window, boundary, multi-dev, expired records, valid+expired mix, signed URL, worker lifecycle) |
| `src/routes/developerRoutes.test.ts` | 5 new (401, 403, 200 with records, 200 empty, downloadUrl correctness) |

## Security

- Signed URLs expire per `EXPORT_SIGNED_URL_TTL_SECONDS` (default 900 s)
- S3 credentials are never returned in responses or logged
- Route scopes queries strictly to `developer.user_id` — no cross-tenant reads possible

closes #398
139 changes: 135 additions & 4 deletions docs/scheduled-exports.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,143 @@
# Scheduled usage event exports

This feature adds developer-managed recurring exports of `usage_events` to a user-provided S3-compatible endpoint.
This feature adds developer-managed recurring exports of `usage_events` to a user-provided S3-compatible endpoint, plus a server-managed daily export pipeline that materialises signed download artifacts accessible via `GET /api/developers/exports`.

---

## API

- `GET /api/exports/schedules`
- `POST /api/exports/schedules`
- `PATCH /api/exports/schedules/:scheduleId`
### Schedule management (developer-owned S3 destination)

- `GET /api/exports/schedules` — list the authenticated developer's export schedules (secrets redacted)
- `POST /api/exports/schedules` — create a new export schedule
- `PATCH /api/exports/schedules/:scheduleId` — update an existing schedule

### Materialized export downloads

- `GET /api/developers/exports` — list signed download URLs for pre-materialized daily export artifacts

---

## `developer_exports` table

Persists metadata for scheduled daily CSV/JSON artifacts uploaded to object storage.

| Column | Type | Description |
|---------------|--------|--------------------------------------------------------------|
| `id` | TEXT | UUID v4 primary key, generated at insert time |
| `developer_id`| TEXT | Developer `user_id` (matches `developers.user_id`) |
| `format` | TEXT | `'csv'` or `'json'` (CHECK constraint enforced) |
| `s3_key` | TEXT | Object storage key, e.g. `daily-exports/{devId}/{date}.csv` |
| `exported_at` | TEXT | ISO-8601 UTC timestamp of when the export was created |
| `expires_at` | TEXT | ISO-8601 UTC timestamp; row is treated as expired after this |

Index: `idx_developer_exports_dev_exported ON developer_exports(developer_id, exported_at DESC)` — supports efficient newest-first listing per developer.

Expiry enforcement is application-side: `listByDeveloper` filters out rows where `expires_at <= now`. The database does not auto-delete expired rows.

Migration: `migrations/0017_developer_exports.sql`

---

## `GET /api/developers/exports`

Returns a paginated list of pre-materialized export artifacts for the authenticated developer.

### Query parameters

| Parameter | Type | Default | Description |
|-----------|--------|---------|-------------------------------------|
| `limit` | number | `20` | Max results to return (1–100) |
| `offset` | number | `0` | Pagination offset (≥ 0) |

### Response shape

```json
{
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"format": "csv",
"exportedAt": "2026-06-01T00:00:00.000Z",
"expiresAt": "2026-06-08T00:00:00.000Z",
"downloadUrl": "https://s3.example.com/exports/dev-1/2026-06-01.csv?expires=1234567890&signature=abc123"
}
],
"pagination": {
"limit": 20,
"offset": 0,
"total": 1
}
}
```

### Error responses

| Status | Code | Condition |
|--------|------------------------|------------------------------------------------|
| 401 | `UNAUTHORIZED` | No `x-user-id` / auth header present |
| 403 | `DEVELOPER_NOT_FOUND` | Authenticated user has no developer profile |

### Signed URL TTL

The download URL is generated fresh on every request. The TTL is controlled by:

```bash
EXPORT_SIGNED_URL_TTL_SECONDS=900 # default: 15 minutes
```

Credentials are never stored in the response or logs. The URL is signed using HMAC-SHA256 keyed with the configured S3 secret.

---

## Daily export job

The `ReportExporterService` materialises one CSV and one JSON export per developer per day.

### How it works

1. `runDailyExports(date)` computes the 24-hour UTC window `[date − 1 day, date)`.
2. All usage events in that window are grouped by `developer_id`.
3. For each developer with ≥1 event, two files are uploaded to object storage:
- `daily-exports/{developerId}/{YYYY-MM-DD}.csv`
- `daily-exports/{developerId}/{YYYY-MM-DD}.json`
4. A `DeveloperExportRecord` is written to the store for each file, with `expires_at = date + 7 days`.

### Configuring the interval

```bash
REPORT_EXPORTER_INTERVAL_MS=86400000 # default: 1 day in ms
```

Use `createReportExporterWorker(service, { intervalMs })` to start the background worker. It runs the first tick immediately on `start()`, then repeats on the interval.

---

## In-memory adapter for testing

`InMemoryExportStore` from `src/services/reportExporter.ts` implements `DeveloperExportStore` using a `Map`. It can be used in unit and integration tests without a real database:

```ts
import { InMemoryExportStore, ReportExporterService } from './reportExporter.js';
import { HmacObjectStorageClient } from './scheduledExports.js';

const store = new InMemoryExportStore();
const storage = new HmacObjectStorageClient();
const service = new ReportExporterService(
myUsageEventsRepo,
storage,
store,
{
s3Bucket: 'test-bucket',
s3Endpoint: 'https://s3.test',
s3SecretAccessKey: 'test-secret',
}
);
```

`HmacObjectStorageClient` (from `scheduledExports.ts`) records all uploads in its `.uploads` array and generates deterministic signed URLs — no real S3 connection required.

---

## Behavior

Expand Down
27 changes: 27 additions & 0 deletions migrations/0017_developer_exports.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- Migration: 0017_developer_exports
-- Adds a `developer_exports` table to persist metadata for scheduled daily
-- export artifacts (CSV and JSON) uploaded to object storage.
--
-- Design notes:
-- • `format` is constrained to 'csv' or 'json' via a CHECK constraint.
-- • `s3_key` holds the object storage path, e.g.
-- `daily-exports/{developerId}/{YYYY-MM-DD}.{format}`.
-- • `exported_at` and `expires_at` are ISO-8601 TEXT columns (UTC), consistent
-- with how the application serialises Date values for this feature.
-- • `expires_at` is set to `exported_at + 7 days` by the application layer;
-- the DB does not enforce expiry — the service filters expired rows on read.
-- • The composite index supports the primary query pattern: list all exports
-- for a developer ordered newest-first.

CREATE TABLE IF NOT EXISTS developer_exports (
id TEXT PRIMARY KEY, -- UUID v4 generated at insert time
developer_id TEXT NOT NULL, -- developer user_id (matches developers.user_id)
format TEXT NOT NULL CHECK(format IN ('csv','json')), -- export file format
s3_key TEXT NOT NULL, -- object storage key / path
exported_at TEXT NOT NULL, -- ISO-8601 UTC timestamp of export
expires_at TEXT NOT NULL -- ISO-8601 UTC timestamp; rows valid until this time
);

-- Primary access pattern: list exports for a developer ordered by newest first
CREATE INDEX IF NOT EXISTS idx_developer_exports_dev_exported
ON developer_exports (developer_id, exported_at DESC);
15 changes: 14 additions & 1 deletion src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,17 @@ export type NewCredit = typeof credits.$inferInsert;
export type Api = typeof apis.$inferSelect;
export type NewApi = typeof apis.$inferInsert;
export type ApiEndpoint = typeof apiEndpoints.$inferSelect;
export type NewApiEndpoint = typeof apiEndpoints.$inferInsert;
export type NewApiEndpoint = typeof apiEndpoints.$inferInsert;

// Developer exports table — persists metadata for scheduled daily CSV/JSON artifacts
export const developerExports = sqliteTable('developer_exports', {
id: text('id').primaryKey(), // UUID v4 generated at insert time
developer_id: text('developer_id').notNull(), // developer user_id
format: text('format', { enum: ['csv', 'json'] as const }).notNull(), // export file format
s3_key: text('s3_key').notNull(), // object storage key / path
exported_at: text('exported_at').notNull(), // ISO-8601 UTC timestamp of export
expires_at: text('expires_at').notNull(), // ISO-8601 UTC; row valid until this time
});

export type DeveloperExport = typeof developerExports.$inferSelect;
export type NewDeveloperExport = typeof developerExports.$inferInsert;
118 changes: 118 additions & 0 deletions src/routes/developerRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,3 +229,121 @@ describe('PATCH /api/developers/me', () => {
});
});
});

// ─────────────────────────────────────────────
// GET /api/developers/exports
// ─────────────────────────────────────────────

describe('GET /api/developers/exports', () => {
const mockReportExporterService = {
listExportsForDeveloper: jest.fn(),
getSignedUrl: jest.fn(),
};

const exportsApp = express();
exportsApp.use(express.json());
exportsApp.use(
'/api/developers',
createDeveloperRouter({
settlementStore: mockSettlementStore as any,
usageStore: mockUsageStore as any,
developerRepository: mockDeveloperRepository as any,
reportExporterService: mockReportExporterService as any,
}),
);
exportsApp.use(errorHandler);

const baseDeveloper = makeDeveloper({ user_id: 'dev-1' });

beforeEach(() => {
jest.clearAllMocks();
mockDeveloperRepository.findByUserId.mockResolvedValue(baseDeveloper);
mockReportExporterService.listExportsForDeveloper.mockResolvedValue([]);
mockReportExporterService.getSignedUrl.mockReturnValue('https://s3.test/signed-url');
});

it('returns 401 when unauthenticated', async () => {
const res = await request(exportsApp).get('/api/developers/exports');
expect(res.status).toBe(401);
});

it('returns 403 when the user has no developer profile', async () => {
mockDeveloperRepository.findByUserId.mockResolvedValue(undefined);

const res = await request(exportsApp)
.get('/api/developers/exports')
.set('x-user-id', 'no-profile-user');

expect(res.status).toBe(403);
expect(res.body.code).toBe('DEVELOPER_NOT_FOUND');
});

it('returns 200 with paginated data array containing the expected fields', async () => {
const now = new Date('2026-06-01T12:00:00.000Z');
const expires = new Date('2026-06-08T12:00:00.000Z');

const record = {
id: 'rec-1',
developerId: 'dev-1',
format: 'csv',
s3Key: 'daily-exports/dev-1/2026-06-01.csv',
exportedAt: now,
expiresAt: expires,
};

mockReportExporterService.listExportsForDeveloper.mockResolvedValue([record]);
mockReportExporterService.getSignedUrl.mockReturnValue('https://s3.test/signed-url?expires=999');

const res = await request(exportsApp)
.get('/api/developers/exports')
.set('x-user-id', 'dev-1');

expect(res.status).toBe(200);
expect(res.body.data).toHaveLength(1);
expect(res.body.pagination).toMatchObject({ limit: 20, offset: 0, total: 1 });

const item = res.body.data[0];
expect(item).toMatchObject({
id: 'rec-1',
format: 'csv',
exportedAt: now.toISOString(),
expiresAt: expires.toISOString(),
downloadUrl: 'https://s3.test/signed-url?expires=999',
});
});

it('returns 200 with empty data array when listExportsForDeveloper returns []', async () => {
mockReportExporterService.listExportsForDeveloper.mockResolvedValue([]);

const res = await request(exportsApp)
.get('/api/developers/exports')
.set('x-user-id', 'dev-1');

expect(res.status).toBe(200);
expect(res.body.data).toEqual([]);
expect(res.body.pagination).toMatchObject({ total: 0 });
});

it('downloadUrl comes from getSignedUrl return value', async () => {
const record = {
id: 'rec-2',
developerId: 'dev-1',
format: 'json',
s3Key: 'daily-exports/dev-1/2026-06-01.json',
exportedAt: new Date('2026-06-01T12:00:00.000Z'),
expiresAt: new Date('2026-06-08T12:00:00.000Z'),
};

mockReportExporterService.listExportsForDeveloper.mockResolvedValue([record]);
const expectedUrl = 'https://s3.test/specific-signed-url?sig=abc123';
mockReportExporterService.getSignedUrl.mockReturnValue(expectedUrl);

const res = await request(exportsApp)
.get('/api/developers/exports')
.set('x-user-id', 'dev-1');

expect(res.status).toBe(200);
expect(res.body.data[0].downloadUrl).toBe(expectedUrl);
expect(mockReportExporterService.getSignedUrl).toHaveBeenCalledWith(record, expect.any(Number));
});
});
Loading