diff --git a/api/src/contract-provider.spec.ts b/api/src/contract-provider.spec.ts index 275028e..3d48968 100644 --- a/api/src/contract-provider.spec.ts +++ b/api/src/contract-provider.spec.ts @@ -18,6 +18,7 @@ import { Test, TestingModule } from "@nestjs/testing" import { authContracts, loginBody, + notificationsContracts, PLACEHOLDER, registerBody, resolvePath, @@ -38,6 +39,9 @@ import { JwtExtractorService } from "./common/guards/jwt-extractor.service" import { StreamOwnershipGuard } from "./common/guards/stream-ownership.guard" import { StreamOwnershipService } from "./common/guards/stream-ownership.service" import createJwtConfig, { createRefreshJwtConfig } from "./config/jwt.config" +import { NotificationsController } from "./notifications/notifications.controller" +import { NotificationsService } from "./notifications/notifications.service" +import { NotificationsRepository } from "./notifications/repository/notifications.repository" import { StreamsRepository } from "./streams/repository/streams.repository" import { StreamApiKeyGuard } from "./streams/stream-api-key.guard" import { StreamsController } from "./streams/streams.controller" @@ -132,6 +136,7 @@ describe("Contract provider verification (api)", () => { StreamTagsController, AuthController, WebhooksController, + NotificationsController, ], providers: [ StreamsService, @@ -147,6 +152,8 @@ describe("Contract provider verification (api)", () => { provide: WebhookDeliveriesRepository, useValue: deliveriesRepository, }, + NotificationsService, + NotificationsRepository, AuthGuard, JwtExtractorService, StreamOwnershipGuard, @@ -253,6 +260,20 @@ describe("Contract provider verification (api)", () => { delivery.nextAttemptAt = null delivery.lastError = "connection refused" existingDeliveryId = String(delivery.id) + + // Record one processed event so `list-stream-events` validates the + // non-empty shape — most importantly the stringified id/streamId. + await streamsRepository.recordEvent(stream.id, { + eventType: "stream:started", + payload: { streamId: stream.id }, + occurredAt: "2026-08-01T00:00:00.000Z", + }) + + // Seed one unread notification so `list-notifications` validates the + // non-empty shape (numeric ids, ISO timestamps). + await moduleFixture + .get(NotificationsRepository) + .create(userId, "stream:started", { streamId: stream.id }) }) afterAll(async () => { @@ -362,6 +383,22 @@ describe("Contract provider verification (api)", () => { }) }) + describe.each(notificationsContracts)("$name", (contract) => { + it(contract.description, async () => { + const res = await execute(contract) + + expect(res.status).toBe(contract.response.status) + const result = contract.response.schema.safeParse(res.body) + if (!result.success) { + throw new Error( + `${contract.name}: response did not satisfy the contract schema\n` + + `${JSON.stringify(result.error.format(), null, 2)}\n` + + `body: ${JSON.stringify(res.body, null, 2)}`, + ) + } + }) + }) + it("login contract uses credentials the register contract actually created", () => { // Sanity check that the two contract fixtures stay in sync with each // other — if this ever fails, `auth.contract.ts` was edited so the diff --git a/api/src/streams/streams.service.spec.ts b/api/src/streams/streams.service.spec.ts index 0b096a9..e25d691 100644 --- a/api/src/streams/streams.service.spec.ts +++ b/api/src/streams/streams.service.spec.ts @@ -5,7 +5,6 @@ import { } from "@nestjs/common" import * as fc from "fast-check" - import { Stream } from "./stream.entity" import { StreamsGateway } from "../gateways/streams.gateway" import { Tag } from "../tags/tag.entity" diff --git a/tests/contracts/src/auth.contract.ts b/tests/contracts/src/auth.contract.ts index eefd318..35e5f0b 100644 --- a/tests/contracts/src/auth.contract.ts +++ b/tests/contracts/src/auth.contract.ts @@ -1,7 +1,8 @@ -import type { CreateUserDto } from "@xstreamroll/types" -import type { Contract } from "./contract" import { authResponseSchema } from "./schemas" +import type { Contract } from "./contract" +import type { CreateUserDto } from "@xstreamroll/types" + export const registerBody: CreateUserDto = { username: "contractuser", email: "contract-user@example.com", diff --git a/tests/contracts/src/index.ts b/tests/contracts/src/index.ts index 0f3bcc9..581dd1f 100644 --- a/tests/contracts/src/index.ts +++ b/tests/contracts/src/index.ts @@ -18,8 +18,10 @@ export * from "./schemas" export * from "./streams.contract" export * from "./auth.contract" export * from "./webhooks.contract" +export * from "./notifications.contract" import { authContracts } from "./auth.contract" +import { notificationsContracts } from "./notifications.contract" import { streamsContracts } from "./streams.contract" import { webhooksContracts } from "./webhooks.contract" @@ -30,4 +32,5 @@ export const allContracts: Contract[] = [ ...streamsContracts, ...authContracts, ...webhooksContracts, + ...notificationsContracts, ] diff --git a/tests/contracts/src/notifications.contract.ts b/tests/contracts/src/notifications.contract.ts new file mode 100644 index 0000000..7dd31cb --- /dev/null +++ b/tests/contracts/src/notifications.contract.ts @@ -0,0 +1,27 @@ +import { type Contract } from "./contract" +import { notificationsPageSchema } from "./schemas" + +/** + * Contract coverage for `api/src/notifications/notifications.controller.ts` + * (issue #534). The provider suite seeds one unread notification for the + * fixture user in beforeAll, so this contract validates the non-empty + * shape — including the numeric ids and ISO-string timestamps. + */ +export const notificationsContracts: Contract[] = [ + { + name: "list-notifications", + description: "GET /notifications returns the caller's unread notifications in the paginated envelope", + consumer: "xstreamroll-sdk", + provider: "api", + request: { + method: "GET", + path: "/notifications", + query: { page: 1, limit: 20 }, + authenticated: true, + }, + response: { + status: 200, + schema: notificationsPageSchema, + }, + }, +] diff --git a/tests/contracts/src/schemas.ts b/tests/contracts/src/schemas.ts index 15ae2e9..f9e8610 100644 --- a/tests/contracts/src/schemas.ts +++ b/tests/contracts/src/schemas.ts @@ -4,6 +4,7 @@ import type { ApiErrorResponse, PaginatedResponse, Stream, + StreamEventRecord, Tag, User, } from "@xstreamroll/types" @@ -85,6 +86,95 @@ export const authResponseSchema = z.object({ refreshToken: z.string(), }) +/** The closed set of stream lifecycle/data event types. */ +export const streamEventTypeSchema = z.enum([ + "stream:started", + "stream:stopped", + "stream:error", + "viewer:joined", + "viewer:left", + "data", +]) + +/** + * A single persisted stream event, as returned by `GET /streams/:id/events` + * (issue #396). The `id`/`streamId` are strings on the wire — the same + * stringification that already bit `Stream` and `User` — and the schema + * pins that choice so a regression fails the provider suite. + */ +export const streamEventRecordSchema = typed()( + z.object({ + id: z.string(), + streamId: z.string(), + eventType: streamEventTypeSchema, + payload: z.record(z.string(), z.unknown()), + occurredAt: z.string(), + }), +) + +export const paginatedStreamEventsSchema = z.object({ + data: z.array(streamEventRecordSchema), + page: z.number(), + limit: z.number(), + total: z.number(), + hasMore: z.boolean(), +}) + +/** + * Aggregate analytics returned by `GET /streams/:id/analytics`. + * Mirrors `StreamAnalyticsDto` in the API — no shared + * `@xstreamroll/types` interface exists yet, so the schema is written + * by hand and pinned by the consumer test's type assertion. + */ +export const streamAnalyticsSchema = z.object({ + streamId: z.number(), + totalEventsProcessed: z.object({ + last24h: z.number(), + last7d: z.number(), + last30d: z.number(), + }), + errorRate: z.object({ + window: z.literal("30d"), + totalEvents: z.number(), + errorEvents: z.number(), + percentage: z.number(), + }), + processingLatency: z.object({ + window: z.literal("30d"), + averageMs: z.number().nullable(), + p99Ms: z.number().nullable(), + }), + eventsPerMinute: z.array( + z.object({ + minute: z.string(), + count: z.number(), + }), + ), + generatedAt: z.string(), +}) + +/** + * A single unread notification, as returned by `GET /notifications`. + * Numeric ids and ISO-string timestamps on the wire. + */ +export const notificationSchema = z.object({ + id: z.number(), + userId: z.number(), + type: z.string(), + payload: z.record(z.string(), z.unknown()), + readAt: z.string().nullable(), + createdAt: z.string(), + expiresAt: z.string(), +}) + +export const notificationsPageSchema = z.object({ + data: z.array(notificationSchema), + page: z.number(), + limit: z.number(), + total: z.number(), + unreadCount: z.number(), +}) + /** * Shape returned by `POST /streams/events` (issue #514) and, per row, * by `GET /streams/pending` — the `stream_data` wire shape the worker @@ -98,14 +188,18 @@ export const pendingStreamEventSchema = z.object({ /** * A webhook subscription as returned by `POST /webhooks` — the only - * response that includes the signing `secret`. + * response that includes the signing `secret`. Field types mirror the + * SDK's `WebhookSubscription` exactly (ids accept string or number on + * the wire; `events` is pinned to the closed {@link streamEventTypeSchema} + * union the SDK's `StreamEventType` declares) so a server-side type + * change fails CI (issue #534). */ export const webhookSubscriptionSchema = z.object({ id: z.union([z.string(), z.number()]), userId: z.union([z.string(), z.number()]), streamId: z.union([z.string(), z.number()]), url: z.string(), - events: z.array(z.string()), + events: z.array(streamEventTypeSchema), secret: z.string(), active: z.boolean(), createdAt: z.string(), @@ -127,11 +221,16 @@ export const paginatedWebhookSubscriptionsSchema = z.object({ limit: z.number(), }) -/** A single webhook delivery, as returned by the deliveries endpoints. */ +/** + * A single webhook delivery, as returned by the deliveries endpoints. + * Field types mirror the SDK's `WebhookDelivery` exactly (issue #534), + * including the `id`/`webhookSubscriptionId` string-vs-number choice and + * the closed `event` union. + */ export const webhookDeliverySchema = z.object({ id: z.union([z.string(), z.number()]), webhookSubscriptionId: z.union([z.string(), z.number()]), - event: z.string(), + event: streamEventTypeSchema, payload: z.record(z.string(), z.unknown()), status: z.enum(["pending", "success", "failed"]), attemptCount: z.number(), @@ -143,6 +242,13 @@ export const webhookDeliverySchema = z.object({ createdAt: z.string(), }) +export const paginatedWebhookDeliveriesSchema = z.object({ + data: z.array(webhookDeliverySchema), + total: z.number(), + page: z.number(), + limit: z.number(), +}) + export const apiErrorSchema = typed()( z.object({ statusCode: z.number(), diff --git a/tests/contracts/src/streams.contract.ts b/tests/contracts/src/streams.contract.ts index 8f979d9..cbc48e1 100644 --- a/tests/contracts/src/streams.contract.ts +++ b/tests/contracts/src/streams.contract.ts @@ -1,8 +1,10 @@ import { PLACEHOLDER, type Contract } from "./contract" import { apiErrorSchema, + paginatedStreamEventsSchema, paginatedStreamsSchema, pendingStreamEventSchema, + streamAnalyticsSchema, streamSchema, } from "./schemas" @@ -109,42 +111,39 @@ export const streamsContracts: Contract[] = [ }, }, { - // Runs after the seed stream exists (created in the provider suite's - // beforeAll). `q=seed` matches the seed stream's name - // case-insensitively; the response is the standard paginated - // envelope over the filtered set (issue #532). - name: "list-streams-search", - description: "GET /streams?q=… returns only streams matching the search, in the paginated envelope", + // The provider suite records one event on the seeded stream in + // beforeAll, so this contract validates the non-empty shape — most + // importantly the stringified `id`/`streamId` fields (issue #534). + name: "list-stream-events", + description: "GET /streams/:id/events replays the stream's event log in the paginated envelope", consumer: "xstreamroll-sdk", provider: "api", request: { method: "GET", - path: "/streams", - query: { page: 1, limit: 20, q: "seed" }, + path: "/streams/:id/events", + pathParams: { id: PLACEHOLDER.EXISTING_STREAM_ID }, + query: { page: 1, limit: 50 }, authenticated: true, }, response: { status: 200, - schema: paginatedStreamsSchema, + schema: paginatedStreamEventsSchema, }, }, { - // `tag=live-streaming` matches the seed stream, which the provider - // suite tags in beforeAll. Unknown tags return an empty page, so the - // shape contract holds either way (issue #532). - name: "list-streams-by-tag", - description: "GET /streams?tag=… returns only streams carrying the tag, in the paginated envelope", + name: "get-stream-analytics", + description: "GET /streams/:id/analytics returns the aggregate analytics shape", consumer: "xstreamroll-sdk", provider: "api", request: { method: "GET", - path: "/streams", - query: { page: 1, limit: 20, tag: "live-streaming" }, + path: "/streams/:id/analytics", + pathParams: { id: PLACEHOLDER.EXISTING_STREAM_ID }, authenticated: true, }, response: { status: 200, - schema: paginatedStreamsSchema, + schema: streamAnalyticsSchema, }, }, { diff --git a/tests/contracts/src/webhooks.contract.ts b/tests/contracts/src/webhooks.contract.ts index b72ddff..06b343e 100644 --- a/tests/contracts/src/webhooks.contract.ts +++ b/tests/contracts/src/webhooks.contract.ts @@ -3,6 +3,7 @@ import { z } from "zod" import { PLACEHOLDER, type Contract } from "./contract" import { apiErrorSchema, + paginatedWebhookDeliveriesSchema, paginatedWebhookSubscriptionsSchema, webhookDeliverySchema, webhookSubscriptionSchema, @@ -68,6 +69,28 @@ export const webhooksContracts: Contract[] = [ schema: webhookSubscriptionSummarySchema, }, }, + { + // Runs against the seeded subscription + its terminal failed delivery + // (both created in the provider suite's beforeAll), mirroring the + // create-stream → register-webhook → list-deliveries chain: the + // subscription the delivery belongs to already exists by the time this + // contract runs, and the fixture is never re-seeded per contract. + name: "list-webhook-deliveries", + description: "GET /webhooks/:id/deliveries returns the delivery log in the paginated envelope", + consumer: "xstreamroll-sdk", + provider: "api", + request: { + method: "GET", + path: "/webhooks/:id/deliveries", + pathParams: { id: PLACEHOLDER.EXISTING_WEBHOOK_ID }, + query: { page: 1, limit: 20 }, + authenticated: true, + }, + response: { + status: 200, + schema: paginatedWebhookDeliveriesSchema, + }, + }, { name: "retry-webhook-delivery", description: "POST /webhooks/:id/deliveries/:deliveryId/retry re-queues a failed delivery", diff --git a/xstreamroll-sdk/README.md b/xstreamroll-sdk/README.md index f3c410b..122e978 100644 --- a/xstreamroll-sdk/README.md +++ b/xstreamroll-sdk/README.md @@ -312,13 +312,17 @@ const resumed = await client.updateWebhook(created.id, { // 4. Inspect deliveries and manually re-queue a failed one. The retry // budget still applies — the attempt count is kept, not reset. -const { data: deliveries } = await client.paginateAll( - "/webhooks/1/deliveries", -) -const failed = deliveries.find((d) => d.status === "failed") +const page = await client.listDeliveries(created.id, { page: 1, limit: 20 }) +const failed = page.data.find((d) => d.status === "failed") if (failed) { await client.retryWebhookDelivery(created.id, failed.id) } +// Or walk every delivery with the pagination helper: +for await (const d of client.paginateAll( + `/webhooks/${created.id}/deliveries`, +)) { + console.log("delivery", d.id, d.status) +} // 5. Remove a subscription and its delivery history. await client.deleteWebhook(created.id) @@ -335,6 +339,30 @@ the **exact raw request body bytes**, not a re-serialized JSON object. --- +## Stream analytics & event replay + +```ts +// Replay the stream's historical event log, newest first (owner-only). +const events = await client.listStreamEvents("stream_abc", { page: 1, limit: 50 }) +// events: { data: StreamEventRecord[], page, limit, total, hasMore } + +// Aggregate analytics: event counts, error rate, processing latency, +// and per-minute volume (owner-only). +const analytics = await client.getStreamAnalytics("stream_abc") +// analytics: StreamAnalytics +``` + +## Notifications + +```ts +// List the caller's unread notifications (paginated). `unreadCount` is +// returned alongside the page so the app can badge its bell icon. +const inbox = await client.listNotifications({ page: 1, limit: 20 }) +// inbox: { data: Notification[], page, limit, total, unreadCount } +``` + +--- + ## HTTP transport `HttpClient` is a small, `fetch`-based wrapper that: @@ -478,6 +506,9 @@ The SDK ships full type definitions. The most useful are: — stream CRUD shapes. * `StreamEvent`, `StreamEventRecord`, `StreamEventType` — event shapes. +* `StreamAnalytics` — the `GET /streams/:id/analytics` shape. +* `Notification`, `NotificationsPage` — the `GET /notifications` + shapes (page includes `unreadCount`). * `WebhookSubscription`, `WebhookSubscriptionSummary`, `UpdateWebhookDto`, `WebhookDelivery` — webhook shapes (the subscription summary omits the creation-time-only `secret`). diff --git a/xstreamroll-sdk/__tests__/contract.consumer.test.ts b/xstreamroll-sdk/__tests__/contract.consumer.test.ts index 973eaab..5b294f1 100644 --- a/xstreamroll-sdk/__tests__/contract.consumer.test.ts +++ b/xstreamroll-sdk/__tests__/contract.consumer.test.ts @@ -20,9 +20,12 @@ import { allContracts, authResponseSchema, - paginatedStreamsSchema, + notificationsPageSchema, + paginatedStreamEventsSchema, + paginatedWebhookDeliveriesSchema, paginatedWebhookSubscriptionsSchema, pendingStreamEventSchema, + streamAnalyticsSchema, streamSchema, webhookDeliverySchema, webhookSubscriptionSchema, @@ -32,6 +35,13 @@ import { import nock from "nock" import { StreamingClient } from "../src/client" +import type { + NotificationsPage as SdkNotificationsPage, + StreamAnalytics as SdkStreamAnalytics, + WebhookDelivery as SdkWebhookDelivery, + WebhookSubscription as SdkWebhookSubscription, + WebhookSubscriptionSummary as SdkWebhookSubscriptionSummary, +} from "../src/types" const BASE_URL = "http://api.test" @@ -41,6 +51,51 @@ function contract(name: string): Contract { return found } +// ── Schema ⇄ SDK-type pinning (issue #534) ───────────────────────────────── +// +// The contract schemas must assert exactly the field types the SDK types +// declare — including the id/streamId string-vs-number choice — so a +// server-side type change (or a schema edit that silently widens or +// narrows a field) fails this file at compile time, before any request is +// made. Mutual assignability is checked in BOTH directions: a schema that +// widened `id` to `string` against an SDK `string | number` would fail the +// SDK→schema direction, and the reverse edit fails schema→SDK. +// +// `SchemaOutput` is `S["_output"]` — the zod output type, resolved +// through the contracts package's own zod dependency rather than the +// workspace-hoisted one, so the SDK never needs a direct zod dependency. + +type SchemaOutput = S extends { _output: infer O } ? O : never + +type AssertEqual = [A] extends [B] + ? [B] extends [A] + ? true + : false + : false +type Expect = T + +type _SubscriptionSchemaPinsSdkType = Expect< + AssertEqual< + SchemaOutput, + SdkWebhookSubscription + > +> +type _SubscriptionSummarySchemaPinsSdkType = Expect< + AssertEqual< + SchemaOutput, + SdkWebhookSubscriptionSummary + > +> +type _DeliverySchemaPinsSdkType = Expect< + AssertEqual, SdkWebhookDelivery> +> +type _AnalyticsSchemaPinsSdkType = Expect< + AssertEqual, SdkStreamAnalytics> +> +type _NotificationSchemaPinsSdkType = Expect< + AssertEqual, SdkNotificationsPage> +> + describe("Consumer contract verification (xstreamroll-sdk)", () => { let client: StreamingClient @@ -327,6 +382,128 @@ describe("Consumer contract verification (xstreamroll-sdk)", () => { expect(c.response.status).toBe(204) }) + it("listDeliveries() requests the list-webhook-deliveries path and returns a contract-valid page", async () => { + const c = contract("list-webhook-deliveries") + const example = { + data: [ + { + id: "10", + webhookSubscriptionId: "1", + event: "stream:stopped", + payload: { streamId: 42 }, + status: "failed", + attemptCount: 6, + lastStatusCode: 500, + lastResponseBody: "Internal Server Error", + lastError: "connection refused", + nextAttemptAt: null, + deliveredAt: null, + createdAt: "2026-01-01T00:00:00.000Z", + }, + ], + total: 1, + page: 1, + limit: 20, + } + expect(() => paginatedWebhookDeliveriesSchema.parse(example)).not.toThrow() + + const scope = nock(BASE_URL) + .get("/webhooks/1/deliveries?page=1&limit=20") + .reply(c.response.status, example) + + const result = await client.listDeliveries("1", { page: 1, limit: 20 }) + + expect(scope.isDone()).toBe(true) + expect(result.data).toHaveLength(1) + expect(result.data[0]?.event).toBe("stream:stopped") + }) + + it("listStreamEvents() requests the list-stream-events path and returns a contract-valid page", async () => { + const c = contract("list-stream-events") + const example = { + data: [ + { + id: "1", + streamId: "42", + eventType: "stream:started", + payload: { streamId: 42 }, + occurredAt: "2026-01-01T00:00:00.000Z", + }, + ], + page: 1, + limit: 50, + total: 1, + hasMore: false, + } + expect(() => paginatedStreamEventsSchema.parse(example)).not.toThrow() + + const scope = nock(BASE_URL) + .get("/streams/42/events?page=1&limit=50") + .reply(c.response.status, example) + + const result = await client.listStreamEvents("42", { page: 1, limit: 50 }) + + expect(scope.isDone()).toBe(true) + expect(result.data[0]?.id).toBe("1") + expect(result.data[0]?.streamId).toBe("42") + }) + + it("getStreamAnalytics() requests the get-stream-analytics path and returns a contract-valid shape", async () => { + const c = contract("get-stream-analytics") + const example = { + streamId: 42, + totalEventsProcessed: { last24h: 0, last7d: 0, last30d: 0 }, + errorRate: { window: "30d", totalEvents: 0, errorEvents: 0, percentage: 0 }, + processingLatency: { window: "30d", averageMs: null, p99Ms: null }, + eventsPerMinute: [ + { minute: "2026-01-01T00:00:00.000Z", count: 0 }, + ], + generatedAt: "2026-01-01T00:00:00.000Z", + } + expect(() => streamAnalyticsSchema.parse(example)).not.toThrow() + + const scope = nock(BASE_URL) + .get("/streams/42/analytics") + .reply(c.response.status, example) + + const result = await client.getStreamAnalytics("42") + + expect(scope.isDone()).toBe(true) + expect(result.streamId).toBe(42) + }) + + it("listNotifications() requests the list-notifications path and returns a contract-valid page", async () => { + const c = contract("list-notifications") + const example = { + data: [ + { + id: 1, + userId: 7, + type: "stream:started", + payload: { streamId: 42 }, + readAt: null, + createdAt: "2026-01-01T00:00:00.000Z", + expiresAt: "2026-01-07T00:00:00.000Z", + }, + ], + page: 1, + limit: 20, + total: 1, + unreadCount: 1, + } + expect(() => notificationsPageSchema.parse(example)).not.toThrow() + + const scope = nock(BASE_URL) + .get("/notifications?page=1&limit=20") + .reply(c.response.status, example) + + const result = await client.listNotifications({ page: 1, limit: 20 }) + + expect(scope.isDone()).toBe(true) + expect(result.data).toHaveLength(1) + expect(result.unreadCount).toBe(1) + }) + it("retryWebhookDelivery() POSTs the retry path and returns a contract-valid delivery", async () => { const c = contract("retry-webhook-delivery") const example = { diff --git a/xstreamroll-sdk/src/client.ts b/xstreamroll-sdk/src/client.ts index 030c4c4..59146be 100644 --- a/xstreamroll-sdk/src/client.ts +++ b/xstreamroll-sdk/src/client.ts @@ -6,12 +6,14 @@ import { type AuthTokens, type CreateUserDto, type CreateWebhookDto, + type NotificationsPage, type PagedTags, type PaginatedResponse, type Stream, + type StreamAnalytics, type StreamConfig, type StreamEvent, - type StreamListParams, + type StreamEventRecord, type UpdateWebhookDto, type WebhookDelivery, type WebhookSubscription, @@ -148,20 +150,48 @@ export class StreamingClient { } /** - * Lists streams visible to the caller (issue #532). Beyond paging, - * supports `q` (case-insensitive name/description search) and `tag` - * (slug or id) — both applied server-side so `total`/`hasMore` stay - * correct and the client never has to post-filter fetched pages. + * Replays a stream's historical event log (issue #396), newest first. + * Owner-only: the API returns 403 for streams the caller does not own. */ - async listStreams(params: StreamListParams = {}): Promise> { + async listStreamEvents( + streamId: string | number, + params: { page?: number; limit?: number } = {}, + ): Promise> { const qs = new URLSearchParams() if (params.page !== undefined) qs.set("page", String(params.page)) if (params.limit !== undefined) qs.set("limit", String(params.limit)) - if (params.q !== undefined) qs.set("q", params.q) - if (params.tag !== undefined) qs.set("tag", params.tag) const query = qs.toString() - return this.requestJson>( - `/streams${query ? `?${query}` : ""}`, + return this.requestJson>( + `/streams/${streamId}/events${query ? `?${query}` : ""}`, + { method: "GET" }, + ) + } + + /** + * Returns aggregate analytics for a stream: event counts, error rate, + * processing latency, and per-minute volume. Owner-only. + */ + async getStreamAnalytics(streamId: string | number): Promise { + return this.requestJson(`/streams/${streamId}/analytics`, { + method: "GET", + }) + } + + /** + * Lists the caller's unread notifications (paginated). The API returns + * `unreadCount` alongside the page so the app can badge the bell icon + * without a second request. + */ + async listNotifications(params: { + page?: number + limit?: number + } = {}): Promise { + const qs = new URLSearchParams() + if (params.page !== undefined) qs.set("page", String(params.page)) + if (params.limit !== undefined) qs.set("limit", String(params.limit)) + const query = qs.toString() + return this.requestJson( + `/notifications${query ? `?${query}` : ""}`, { method: "GET" }, ) } @@ -226,6 +256,25 @@ export class StreamingClient { await this.requestJson(`/webhooks/${id}`, { method: "DELETE" }) } + /** + * Lists the delivery log for a webhook subscription (paginated). + * Requires ownership of the webhook. `WebhookDelivery` is the same + * shape `retryWebhookDelivery()` returns. + */ + async listDeliveries( + webhookId: string | number, + params: { page?: number; limit?: number } = {}, + ): Promise> { + const qs = new URLSearchParams() + if (params.page !== undefined) qs.set("page", String(params.page)) + if (params.limit !== undefined) qs.set("limit", String(params.limit)) + const query = qs.toString() + return this.requestJson>( + `/webhooks/${webhookId}/deliveries${query ? `?${query}` : ""}`, + { method: "GET" }, + ) + } + /** * Manually re-queues a failed or pending delivery so the retry sweep * picks it up immediately. The retry budget still applies — the diff --git a/xstreamroll-sdk/src/index.ts b/xstreamroll-sdk/src/index.ts index 0bc3efe..cab8649 100644 --- a/xstreamroll-sdk/src/index.ts +++ b/xstreamroll-sdk/src/index.ts @@ -26,6 +26,11 @@ export type { StreamEventType, StreamEvent, StreamEventRecord, + // Stream Analytics + StreamAnalytics, + // Notifications + Notification, + NotificationsPage, // Webhooks CreateWebhookDto, UpdateWebhookDto, diff --git a/xstreamroll-sdk/src/types.ts b/xstreamroll-sdk/src/types.ts index 19914a9..e219c31 100644 --- a/xstreamroll-sdk/src/types.ts +++ b/xstreamroll-sdk/src/types.ts @@ -3,7 +3,6 @@ import type { ApiErrorResponse, PaginatedResponse, - PaginationParams, StreamEventType, Tag, User, @@ -54,28 +53,6 @@ export type { ApiErrorResponse, } from "@xstreamroll/types" -// ─── Streams ───────────────────────────────────────────────────────────────── - -/** - * Query parameters for `GET /streams` (issue #532). Extends the shared - * pagination params with the server-side search and tag filters so the - * dashboard search box can pass them through without client-side - * post-filtering. - */ -export interface StreamListParams extends PaginationParams { - /** - * Case-insensitive substring matched against stream name and - * description. `%`/`_` are treated literally by the server, never as - * wildcards. - */ - q?: string - /** - * Tag slug or numeric id. Only streams carrying that tag are - * returned; an unknown tag yields an empty page. - */ - tag?: string -} - // ─── Tags ──────────────────────────────────────────────────────────────────── /** @@ -131,6 +108,58 @@ export interface AuthResponse { */ export type AuthTokens = AuthResponse +// ─── Streams: analytics & event replay ─────────────────────────────────────── + +/** + * Aggregate analytics returned by `GET /streams/:id/analytics`. Mirrors + * the API's `StreamAnalyticsDto` exactly (issue #534). + */ +export interface StreamAnalytics { + streamId: number + totalEventsProcessed: { + last24h: number + last7d: number + last30d: number + } + errorRate: { + window: "30d" + totalEvents: number + errorEvents: number + percentage: number + } + processingLatency: { + window: "30d" + averageMs: number | null + p99Ms: number | null + } + eventsPerMinute: Array<{ + minute: string + count: number + }> + generatedAt: string +} + +// ─── Notifications ─────────────────────────────────────────────────────────── + +/** + * A single unread notification, as returned by `GET /notifications` + * (issue #534). Numeric ids and ISO-string timestamps on the wire. + */ +export interface Notification { + id: number + userId: number + type: string + payload: Record + readAt: string | null + createdAt: string + expiresAt: string +} + +/** Paginated unread-notification envelope from `GET /notifications`. */ +export interface NotificationsPage extends PaginatedResponse { + unreadCount: number +} + // ─── Webhooks ───────────────────────────────────────────────────────────────── /** Payload for `subscribeWebhook()` / `POST /webhooks`. */