feat(timeline): add platform timeline ingest and query api
This commit is contained in:
parent
6cb4bbaf02
commit
e377351842
@ -198,6 +198,13 @@ These should be resolved before claiming the ecosystem docs are fully implementa
|
|||||||
- [x] verify that Phase 1 to Phase 3 events render in one unified stream
|
- [x] verify that Phase 1 to Phase 3 events render in one unified stream
|
||||||
Commits:
|
Commits:
|
||||||
- `3f2482b`
|
- `3f2482b`
|
||||||
|
- [x] add first hosted timeline ingest/query API in `platform-service`
|
||||||
|
Commits:
|
||||||
|
- `TBD`
|
||||||
|
Status note:
|
||||||
|
- platform-service now persists timeline items in `timeline_items`
|
||||||
|
- `POST /api/timeline/ingest` accepts canonical ecosystem events and stores unified timeline items
|
||||||
|
- `GET /api/timeline` returns the unified timeline stream with non-admin JWT callers scoped to their own data
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
# Phase 4 Execution Plan
|
# Phase 4 Execution Plan
|
||||||
|
|
||||||
> **Flow:** Shared personal timeline over the first three ecosystem flows
|
> **Flow:** Shared personal timeline over the first three ecosystem flows
|
||||||
> **Status:** Implemented baseline
|
> **Status:** Service baseline implemented
|
||||||
> **Owner:** `learning_ai_common_plat`
|
> **Owner:** `learning_ai_common_plat`
|
||||||
> **Purpose:** Turn the personal timeline PRD into a reusable contract and baseline aggregator so the first three ecosystem flows can be rendered as one coherent activity stream.
|
> **Purpose:** Turn the personal timeline PRD into a reusable contract and baseline aggregator so the first three ecosystem flows can be rendered as one coherent activity stream.
|
||||||
|
|
||||||
@ -14,8 +14,9 @@ Phase 4 defines and implements:
|
|||||||
1. the canonical `TimelineItem` shape
|
1. the canonical `TimelineItem` shape
|
||||||
2. baseline event-to-timeline mapping rules
|
2. baseline event-to-timeline mapping rules
|
||||||
3. a pure aggregator that converts canonical ecosystem events into timeline items
|
3. a pure aggregator that converts canonical ecosystem events into timeline items
|
||||||
|
4. the first hosted platform-service API for timeline ingest and timeline query
|
||||||
|
|
||||||
This phase does not add a hosted UI yet. It establishes the shared data contract first.
|
This phase still does not add a hosted UI. It now establishes both the shared data contract and the first persisted service surface.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -24,6 +25,10 @@ This phase does not add a hosted UI yet. It establishes the shared data contract
|
|||||||
- `packages/events/src/timeline.ts`
|
- `packages/events/src/timeline.ts`
|
||||||
- `packages/events/src/timeline.test.ts`
|
- `packages/events/src/timeline.test.ts`
|
||||||
- `packages/events/src/index.ts`
|
- `packages/events/src/index.ts`
|
||||||
|
- `services/platform-service/src/modules/timeline/types.ts`
|
||||||
|
- `services/platform-service/src/modules/timeline/repository.ts`
|
||||||
|
- `services/platform-service/src/modules/timeline/routes.ts`
|
||||||
|
- `services/platform-service/src/modules/timeline/routes.test.ts`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -47,6 +52,9 @@ This is enough to render:
|
|||||||
## 4. Verification
|
## 4. Verification
|
||||||
|
|
||||||
- `cd learning_ai_common_plat/packages/events && pnpm exec vitest run src/timeline.test.ts`
|
- `cd learning_ai_common_plat/packages/events && pnpm exec vitest run src/timeline.test.ts`
|
||||||
|
- `cd learning_ai_common_plat && pnpm --filter @bytelyst/events build`
|
||||||
|
- `cd learning_ai_common_plat && pnpm --filter @lysnrai/platform-service exec vitest run src/modules/timeline/routes.test.ts src/server.test.ts`
|
||||||
|
- `cd learning_ai_common_plat && pnpm --filter @lysnrai/platform-service exec tsc --noEmit`
|
||||||
|
|
||||||
Observed baseline:
|
Observed baseline:
|
||||||
|
|
||||||
@ -58,6 +66,9 @@ Observed baseline:
|
|||||||
- related event refs
|
- related event refs
|
||||||
- actor type
|
- actor type
|
||||||
- correlation ID
|
- correlation ID
|
||||||
|
- platform-service now exposes `POST /api/timeline/ingest` for canonical ecosystem-event ingestion
|
||||||
|
- platform-service now exposes `GET /api/timeline` for unified timeline query
|
||||||
|
- non-admin JWT callers are scoped to their own timeline while admin and API-key callers can query broader product timelines
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -67,6 +78,7 @@ Observed baseline:
|
|||||||
- [x] define baseline event-to-timeline mapping rules
|
- [x] define baseline event-to-timeline mapping rules
|
||||||
- [x] implement pure timeline aggregation over canonical events
|
- [x] implement pure timeline aggregation over canonical events
|
||||||
- [x] verify that Phase 1 to Phase 3 events render in one unified stream
|
- [x] verify that Phase 1 to Phase 3 events render in one unified stream
|
||||||
|
- [x] add first hosted timeline ingest/query API in platform-service
|
||||||
- [ ] choose first hosted timeline UI
|
- [ ] choose first hosted timeline UI
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -74,3 +86,4 @@ Observed baseline:
|
|||||||
## 6. Commits
|
## 6. Commits
|
||||||
|
|
||||||
- `3f2482b` timeline contract and aggregator baseline
|
- `3f2482b` timeline contract and aggregator baseline
|
||||||
|
- `TBD` platform-service timeline ingest + query baseline
|
||||||
|
|||||||
@ -66,6 +66,8 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
|
|||||||
// Generic orchestration runs
|
// Generic orchestration runs
|
||||||
agent_runs: { partitionKeyPath: '/productId', defaultTtl: 30 * 86400 },
|
agent_runs: { partitionKeyPath: '/productId', defaultTtl: 30 * 86400 },
|
||||||
agent_run_steps: { partitionKeyPath: '/pk', defaultTtl: 30 * 86400 },
|
agent_run_steps: { partitionKeyPath: '/pk', defaultTtl: 30 * 86400 },
|
||||||
|
// Cross-product personal timeline
|
||||||
|
timeline_items: { partitionKeyPath: '/productId', defaultTtl: 90 * 86400 },
|
||||||
// Canonical tenant model
|
// Canonical tenant model
|
||||||
organizations: { partitionKeyPath: '/productId' },
|
organizations: { partitionKeyPath: '/productId' },
|
||||||
workspaces: { partitionKeyPath: '/orgId' },
|
workspaces: { partitionKeyPath: '/orgId' },
|
||||||
|
|||||||
33
services/platform-service/src/modules/timeline/repository.ts
Normal file
33
services/platform-service/src/modules/timeline/repository.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import type { FilterMap } from '@bytelyst/datastore';
|
||||||
|
import { getCollection } from '../../lib/datastore.js';
|
||||||
|
import type { ListTimelineQuery, TimelineItemDoc } from './types.js';
|
||||||
|
|
||||||
|
function collection() {
|
||||||
|
return getCollection<TimelineItemDoc>('timeline_items', '/productId');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertTimelineItems(items: TimelineItemDoc[]): Promise<TimelineItemDoc[]> {
|
||||||
|
const results: TimelineItemDoc[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
results.push(await collection().upsert(item));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listTimelineItems(
|
||||||
|
productId: string,
|
||||||
|
query: ListTimelineQuery
|
||||||
|
): Promise<TimelineItemDoc[]> {
|
||||||
|
const filter: FilterMap = { productId };
|
||||||
|
|
||||||
|
if (query.userId) filter.userId = query.userId;
|
||||||
|
if (query.correlationId) filter.correlationId = query.correlationId;
|
||||||
|
if (query.artifactId) filter.artifactId = query.artifactId;
|
||||||
|
if (query.eventName) filter.eventName = query.eventName;
|
||||||
|
|
||||||
|
return collection().findMany({
|
||||||
|
filter,
|
||||||
|
sort: { occurredAt: -1 },
|
||||||
|
limit: query.limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
188
services/platform-service/src/modules/timeline/routes.test.ts
Normal file
188
services/platform-service/src/modules/timeline/routes.test.ts
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import Fastify from 'fastify';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const repoMock = {
|
||||||
|
upsertTimelineItems: vi.fn(),
|
||||||
|
listTimelineItems: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('./repository.js', () => repoMock);
|
||||||
|
vi.mock('../../lib/api-key-auth.js', () => ({
|
||||||
|
requireJwtOrApiKey: vi.fn(async req => {
|
||||||
|
const roleHeader = req.headers['x-test-role'];
|
||||||
|
const userHeader = req.headers['x-test-user'];
|
||||||
|
const productHeader = req.headers['x-test-product'];
|
||||||
|
req.jwtPayload =
|
||||||
|
typeof userHeader === 'string'
|
||||||
|
? {
|
||||||
|
sub: userHeader,
|
||||||
|
role: typeof roleHeader === 'string' ? roleHeader : undefined,
|
||||||
|
productId: typeof productHeader === 'string' ? productHeader : 'lysnrai',
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
actorId: typeof userHeader === 'string' ? userHeader : 'svc_timeline',
|
||||||
|
productId: typeof productHeader === 'string' ? productHeader : 'lysnrai',
|
||||||
|
source: typeof userHeader === 'string' ? 'jwt' : 'api_key',
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function buildApp() {
|
||||||
|
const { timelineRoutes } = await import('./routes.js');
|
||||||
|
const app = Fastify({ logger: false });
|
||||||
|
await app.register(timelineRoutes, { prefix: '/api' });
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('timelineRoutes', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /timeline/ingest persists canonical events as timeline items', async () => {
|
||||||
|
repoMock.upsertTimelineItems.mockImplementation(async docs => docs);
|
||||||
|
const app = await buildApp();
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/timeline/ingest',
|
||||||
|
headers: {
|
||||||
|
'x-test-user': 'user_1',
|
||||||
|
'x-test-product': 'lysnrai',
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
eventId: 'evt_1',
|
||||||
|
eventName: 'capture.transcript.created',
|
||||||
|
eventVersion: 1,
|
||||||
|
occurredAt: '2026-04-04T10:00:00.000Z',
|
||||||
|
productId: 'lysnrai',
|
||||||
|
sourceSurface: 'voice',
|
||||||
|
actor: { actorType: 'user', actorId: 'user_1' },
|
||||||
|
trace: { correlationId: 'corr_1', causationId: null, parentEventId: null },
|
||||||
|
payload: {
|
||||||
|
artifactId: 'art_1',
|
||||||
|
durationMs: 1200,
|
||||||
|
language: 'en',
|
||||||
|
transcriptSource: 'microphone',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.ingestedCount).toBe(1);
|
||||||
|
expect(body.items[0].title).toBe('Transcript captured');
|
||||||
|
expect(repoMock.upsertTimelineItems).toHaveBeenCalledOnce();
|
||||||
|
expect(repoMock.upsertTimelineItems.mock.calls[0][0][0]).toMatchObject({
|
||||||
|
productId: 'lysnrai',
|
||||||
|
userId: 'user_1',
|
||||||
|
eventId: 'evt_1',
|
||||||
|
visibility: 'private',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /timeline/ingest rejects cross-product events', async () => {
|
||||||
|
const app = await buildApp();
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/timeline/ingest',
|
||||||
|
headers: {
|
||||||
|
'x-test-user': 'user_1',
|
||||||
|
'x-test-product': 'lysnrai',
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
eventId: 'evt_2',
|
||||||
|
eventName: 'artifact.created',
|
||||||
|
eventVersion: 1,
|
||||||
|
occurredAt: '2026-04-04T10:00:00.000Z',
|
||||||
|
productId: 'notelett',
|
||||||
|
sourceSurface: 'notes',
|
||||||
|
actor: { actorType: 'user', actorId: 'user_1' },
|
||||||
|
trace: { correlationId: null, causationId: null, parentEventId: null },
|
||||||
|
payload: {
|
||||||
|
artifactType: 'note',
|
||||||
|
title: 'Quick note',
|
||||||
|
status: 'created',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /timeline scopes non-admin JWT callers to their own timeline', async () => {
|
||||||
|
repoMock.listTimelineItems.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'timeline_evt_1',
|
||||||
|
eventId: 'evt_1',
|
||||||
|
eventName: 'artifact.created',
|
||||||
|
productId: 'lysnrai',
|
||||||
|
userId: 'user_1',
|
||||||
|
orgId: null,
|
||||||
|
artifactId: 'art_1',
|
||||||
|
correlationId: 'corr_1',
|
||||||
|
actorType: 'user',
|
||||||
|
visibility: 'private',
|
||||||
|
occurredAt: '2026-04-04T10:00:00.000Z',
|
||||||
|
item: {
|
||||||
|
itemId: 'timeline_evt_1',
|
||||||
|
occurredAt: '2026-04-04T10:00:00.000Z',
|
||||||
|
eventName: 'artifact.created',
|
||||||
|
productId: 'lysnrai',
|
||||||
|
title: 'Note created',
|
||||||
|
summary: 'note status: created',
|
||||||
|
artifactRefs: ['art_1'],
|
||||||
|
relatedEventIds: [],
|
||||||
|
actorType: 'user',
|
||||||
|
visibility: 'private',
|
||||||
|
correlationId: 'corr_1',
|
||||||
|
},
|
||||||
|
createdAt: '2026-04-04T10:00:00.000Z',
|
||||||
|
updatedAt: '2026-04-04T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const app = await buildApp();
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/timeline?limit=10',
|
||||||
|
headers: {
|
||||||
|
'x-test-user': 'user_1',
|
||||||
|
'x-test-product': 'lysnrai',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(repoMock.listTimelineItems).toHaveBeenCalledWith(
|
||||||
|
'lysnrai',
|
||||||
|
expect.objectContaining({ userId: 'user_1', limit: 10 })
|
||||||
|
);
|
||||||
|
expect(JSON.parse(res.body).count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /timeline forbids non-admin JWT callers from querying another user', async () => {
|
||||||
|
const app = await buildApp();
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/timeline?userId=user_2',
|
||||||
|
headers: {
|
||||||
|
'x-test-user': 'user_1',
|
||||||
|
'x-test-product': 'lysnrai',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
129
services/platform-service/src/modules/timeline/routes.ts
Normal file
129
services/platform-service/src/modules/timeline/routes.ts
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||||
|
import { buildTimelineItem } from '@bytelyst/events';
|
||||||
|
import { requireJwtOrApiKey } from '../../lib/api-key-auth.js';
|
||||||
|
import { BadRequestError, ForbiddenError } from '../../lib/errors.js';
|
||||||
|
import * as repo from './repository.js';
|
||||||
|
import {
|
||||||
|
IngestTimelineEventsSchema,
|
||||||
|
ListTimelineQuerySchema,
|
||||||
|
type TimelineItemDoc,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
function isAdmin(req: FastifyRequest): boolean {
|
||||||
|
const role = req.jwtPayload?.role;
|
||||||
|
return role === 'admin' || role === 'super_admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTimelineUserId(
|
||||||
|
req: FastifyRequest,
|
||||||
|
source: 'jwt' | 'api_key',
|
||||||
|
explicitUserId: string | null | undefined,
|
||||||
|
fallbackUserId: string
|
||||||
|
): string | undefined {
|
||||||
|
if (source === 'api_key') {
|
||||||
|
return explicitUserId ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAdmin(req)) {
|
||||||
|
return explicitUserId ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (explicitUserId && explicitUserId !== fallbackUserId) {
|
||||||
|
throw new ForbiddenError('Non-admin callers can only access their own timeline');
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function timelineRoutes(app: FastifyInstance) {
|
||||||
|
app.post('/timeline/ingest', async req => {
|
||||||
|
const access = await requireJwtOrApiKey(req, {
|
||||||
|
allowJwt: true,
|
||||||
|
apiKeyScopes: ['timeline:write'],
|
||||||
|
apiKeyTokenTypes: ['product_api', 'service_api'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = IngestTimelineEventsSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; '));
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedUserId = parsed.data.userId ?? undefined;
|
||||||
|
const resolvedUserId = resolveTimelineUserId(
|
||||||
|
req,
|
||||||
|
access.source,
|
||||||
|
requestedUserId,
|
||||||
|
access.actorId
|
||||||
|
);
|
||||||
|
const visibilityOverride = parsed.data.visibility;
|
||||||
|
|
||||||
|
const docs: TimelineItemDoc[] = parsed.data.events.map(event => {
|
||||||
|
if (event.productId !== access.productId) {
|
||||||
|
throw new BadRequestError(
|
||||||
|
`Event '${event.eventId}' productId must match authenticated product '${access.productId}'`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = buildTimelineItem(event);
|
||||||
|
const normalizedItem =
|
||||||
|
visibilityOverride === undefined ? item : { ...item, visibility: visibilityOverride };
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: normalizedItem.itemId,
|
||||||
|
productId: access.productId,
|
||||||
|
userId: event.userId ?? resolvedUserId ?? null,
|
||||||
|
orgId: event.orgId ?? null,
|
||||||
|
eventId: event.eventId,
|
||||||
|
eventName: event.eventName,
|
||||||
|
artifactId: event.artifactId ?? null,
|
||||||
|
correlationId: event.trace.correlationId ?? null,
|
||||||
|
actorType: event.actor.actorType,
|
||||||
|
visibility: normalizedItem.visibility,
|
||||||
|
occurredAt: event.occurredAt,
|
||||||
|
item: normalizedItem,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const persisted = await repo.upsertTimelineItems(docs);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ingestedCount: persisted.length,
|
||||||
|
items: persisted.map(doc => doc.item),
|
||||||
|
eventIds: persisted.map(doc => doc.eventId),
|
||||||
|
batchId: `timeline_batch_${crypto.randomUUID()}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/timeline', async req => {
|
||||||
|
const access = await requireJwtOrApiKey(req, {
|
||||||
|
allowJwt: true,
|
||||||
|
apiKeyScopes: ['timeline:read'],
|
||||||
|
apiKeyTokenTypes: ['product_api', 'service_api'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = ListTimelineQuerySchema.safeParse(req.query);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; '));
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveUserId = resolveTimelineUserId(
|
||||||
|
req,
|
||||||
|
access.source,
|
||||||
|
parsed.data.userId,
|
||||||
|
access.actorId
|
||||||
|
);
|
||||||
|
const docs = await repo.listTimelineItems(access.productId, {
|
||||||
|
...parsed.data,
|
||||||
|
userId: effectiveUserId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: docs.map(doc => doc.item),
|
||||||
|
count: docs.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
46
services/platform-service/src/modules/timeline/types.ts
Normal file
46
services/platform-service/src/modules/timeline/types.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
BaseEcosystemEventSchema,
|
||||||
|
TimelineItemSchema,
|
||||||
|
TimelineVisibilitySchema,
|
||||||
|
} from '@bytelyst/events';
|
||||||
|
|
||||||
|
export const TimelineItemDocSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
productId: z.string().min(1),
|
||||||
|
userId: z.string().min(1).nullable().optional(),
|
||||||
|
orgId: z.string().min(1).nullable().optional(),
|
||||||
|
eventId: z.string().min(1),
|
||||||
|
eventName: z.string().min(1),
|
||||||
|
artifactId: z.string().min(1).nullable().optional(),
|
||||||
|
correlationId: z.string().min(1).nullable().optional(),
|
||||||
|
actorType: z.enum(['user', 'agent', 'system', 'device']),
|
||||||
|
visibility: TimelineVisibilitySchema,
|
||||||
|
occurredAt: z.string().datetime(),
|
||||||
|
item: TimelineItemSchema,
|
||||||
|
createdAt: z.string().datetime(),
|
||||||
|
updatedAt: z.string().datetime(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TimelineItemDoc = z.infer<typeof TimelineItemDocSchema> & {
|
||||||
|
_ts?: number;
|
||||||
|
_etag?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IngestTimelineEventsSchema = z.object({
|
||||||
|
userId: z.string().min(1).nullable().optional(),
|
||||||
|
visibility: TimelineVisibilitySchema.optional(),
|
||||||
|
events: z.array(BaseEcosystemEventSchema).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type IngestTimelineEventsInput = z.infer<typeof IngestTimelineEventsSchema>;
|
||||||
|
|
||||||
|
export const ListTimelineQuerySchema = z.object({
|
||||||
|
userId: z.string().min(1).optional(),
|
||||||
|
correlationId: z.string().min(1).optional(),
|
||||||
|
artifactId: z.string().min(1).optional(),
|
||||||
|
eventName: z.string().min(1).optional(),
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ListTimelineQuery = z.infer<typeof ListTimelineQuerySchema>;
|
||||||
@ -46,6 +46,7 @@ vi.mock('./modules/products/cache.js', () => ({ loadProductCache: loadProductCac
|
|||||||
vi.mock('./modules/auth/routes.js', () => ({ authRoutes: vi.fn() }));
|
vi.mock('./modules/auth/routes.js', () => ({ authRoutes: vi.fn() }));
|
||||||
vi.mock('./modules/audit/routes.js', () => ({ auditRoutes: vi.fn() }));
|
vi.mock('./modules/audit/routes.js', () => ({ auditRoutes: vi.fn() }));
|
||||||
vi.mock('./modules/notifications/routes.js', () => ({ notificationRoutes: vi.fn() }));
|
vi.mock('./modules/notifications/routes.js', () => ({ notificationRoutes: vi.fn() }));
|
||||||
|
vi.mock('./modules/timeline/routes.js', () => ({ timelineRoutes: vi.fn() }));
|
||||||
vi.mock('./modules/flags/routes.js', () => ({ flagRoutes: vi.fn() }));
|
vi.mock('./modules/flags/routes.js', () => ({ flagRoutes: vi.fn() }));
|
||||||
vi.mock('./modules/ratelimit/routes.js', () => ({ rateLimitRoutes: vi.fn() }));
|
vi.mock('./modules/ratelimit/routes.js', () => ({ rateLimitRoutes: vi.fn() }));
|
||||||
vi.mock('./modules/blob/routes.js', () => ({ blobRoutes: vi.fn() }));
|
vi.mock('./modules/blob/routes.js', () => ({ blobRoutes: vi.fn() }));
|
||||||
|
|||||||
@ -76,6 +76,7 @@ import { runRoutes } from './modules/runs/routes.js';
|
|||||||
import { statusRoutes } from './modules/status/routes.js';
|
import { statusRoutes } from './modules/status/routes.js';
|
||||||
import { deliveryRoutes } from './modules/delivery/routes.js';
|
import { deliveryRoutes } from './modules/delivery/routes.js';
|
||||||
import { sessionRoutes } from './modules/sessions/routes.js';
|
import { sessionRoutes } from './modules/sessions/routes.js';
|
||||||
|
import { timelineRoutes } from './modules/timeline/routes.js';
|
||||||
import { maintenanceRoutes } from './modules/maintenance/routes.js';
|
import { maintenanceRoutes } from './modules/maintenance/routes.js';
|
||||||
import { exportRoutes } from './modules/exports/routes.js';
|
import { exportRoutes } from './modules/exports/routes.js';
|
||||||
import { ipRuleRoutes } from './modules/ip-rules/routes.js';
|
import { ipRuleRoutes } from './modules/ip-rules/routes.js';
|
||||||
@ -229,6 +230,8 @@ await app.register(statusRoutes, { prefix: '/api' });
|
|||||||
await app.register(deliveryRoutes, { prefix: '/api' });
|
await app.register(deliveryRoutes, { prefix: '/api' });
|
||||||
// Session management
|
// Session management
|
||||||
await app.register(sessionRoutes, { prefix: '/api' });
|
await app.register(sessionRoutes, { prefix: '/api' });
|
||||||
|
// Cross-product timeline ingest + query
|
||||||
|
await app.register(timelineRoutes, { prefix: '/api' });
|
||||||
// Maintenance mode
|
// Maintenance mode
|
||||||
await app.register(maintenanceRoutes, { prefix: '/api' });
|
await app.register(maintenanceRoutes, { prefix: '/api' });
|
||||||
// Data exports
|
// Data exports
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user