@buildinternet/releases-api-types 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,156 @@
1
+ import { z } from "zod";
2
+ import { MediaItemSchema, ReleaseTypeSchema } from "./shared.js";
3
+ import { SourceTypeSchema } from "./sources.js";
4
+ import { LookupStatusSchema } from "./lookups.js";
5
+
6
+ /**
7
+ * Org hit on the unified `/v1/search` response. The list is built either
8
+ * from `searchOrgs` LIKE matches or, when `?domain=` resolves, from a
9
+ * single-row "scoped org" projection — both produce the same wire shape.
10
+ *
11
+ * `category` is loose (`z.string().nullable()`) rather than the canonical
12
+ * `CategorySchema` enum because the scoped-org projection passes the raw
13
+ * DB column through without going through `resolveCategoryInput`; legacy
14
+ * rows may carry deprecated slugs that haven't been migrated yet. Tighten
15
+ * once #689 (category overlay backfill) lands across the prod data set.
16
+ */
17
+ export const SearchOrgHitSchema = z.object({
18
+ slug: z.string(),
19
+ name: z.string(),
20
+ domain: z.string().nullable(),
21
+ avatarUrl: z.string().nullable(),
22
+ category: z.string().nullable(),
23
+ });
24
+
25
+ /**
26
+ * Unified catalog entry — either a product row or a standalone source
27
+ * presented as product-shaped. `kind` routes clicks to the right URL but
28
+ * the two forms are otherwise interchangeable for display. `kind` (not
29
+ * `type`) because source rows already carry
30
+ * `type: github|scrape|feed|agent` on the wire.
31
+ */
32
+ export const SearchCatalogHitSchema = z.object({
33
+ slug: z.string(),
34
+ name: z.string(),
35
+ orgSlug: z.string().nullable(),
36
+ orgName: z.string().nullable(),
37
+ // Loose `z.string().nullable()` rather than the canonical `CategorySchema`
38
+ // enum because `foldSourcesIntoCatalog` and the catalog-search handlers
39
+ // pass `s.productCategory` / `o.category` through without validating
40
+ // against `resolveCategoryInput`. Legacy rows may carry deprecated slugs
41
+ // that haven't been migrated yet. Tighten once #689 (category overlay
42
+ // backfill) lands across the prod data set.
43
+ category: z.string().nullable(),
44
+ kind: z.enum(["product", "source"]),
45
+ sourceSlug: z.string().optional(),
46
+ sourceType: SourceTypeSchema.optional(),
47
+ });
48
+
49
+ export const SearchSourceHitSchema = z.object({
50
+ slug: z.string(),
51
+ name: z.string(),
52
+ type: SourceTypeSchema,
53
+ orgSlug: z.string().nullable(),
54
+ orgName: z.string().nullable(),
55
+ productSlug: z.string().nullable(),
56
+ });
57
+
58
+ export const SearchReleaseHitSchema = z.object({
59
+ id: z.string(),
60
+ sourceSlug: z.string(),
61
+ sourceName: z.string(),
62
+ // Loose `z.string()` rather than `SourceTypeSchema` because the
63
+ // release-hit hydration path reads `sources.type` straight from the DB
64
+ // without re-validating; legacy rows occasionally drift from the
65
+ // canonical four-value set. Used by the web byline icon.
66
+ sourceType: z.string().optional(),
67
+ orgSlug: z.string().nullable(),
68
+ orgName: z.string().nullable().optional(),
69
+ version: z.string().nullable(),
70
+ title: z.string(),
71
+ summary: z.string(),
72
+ titleGenerated: z.string().nullable().optional(),
73
+ titleShort: z.string().nullable().optional(),
74
+ content: z.string().optional(),
75
+ media: z.array(MediaItemSchema).optional(),
76
+ publishedAt: z.string().nullable(),
77
+ // Hybrid fusion score; absent on pure-lexical results.
78
+ score: z.number().optional(),
79
+ type: ReleaseTypeSchema.optional(),
80
+ });
81
+
82
+ /**
83
+ * Heading-aware CHANGELOG.md slice returned by hybrid / semantic search.
84
+ * Clients can deep-link to
85
+ * `/source/<sourceSlug>?tab=changelog&offset=<offset>` to read the
86
+ * surrounding file content.
87
+ */
88
+ export const SearchChunkHitSchema = z.object({
89
+ sourceSlug: z.string(),
90
+ sourceName: z.string(),
91
+ orgSlug: z.string().nullable(),
92
+ orgName: z.string().nullable().optional(),
93
+ filePath: z.string(),
94
+ offset: z.number().int().min(0),
95
+ length: z.number().int().min(0),
96
+ heading: z.string().nullable(),
97
+ snippet: z.string(),
98
+ score: z.number(),
99
+ });
100
+
101
+ /**
102
+ * Slim wire payload embedded in a search response when the query is a
103
+ * GitHub `org/repo` coordinate and no existing entity matched. Reuses
104
+ * `LookupStatusSchema` from `./lookups.js` rather than redefining — the
105
+ * thick `POST /v1/lookups` response and the slim search-embedded payload
106
+ * share the same status set.
107
+ */
108
+ export const LookupResultPayloadSchema = z.object({
109
+ status: LookupStatusSchema,
110
+ source: z
111
+ .object({
112
+ id: z.string(),
113
+ slug: z.string(),
114
+ name: z.string(),
115
+ url: z.string(),
116
+ discovery: z.enum(["curated", "agent", "on_demand"]),
117
+ })
118
+ .optional(),
119
+ releases: z
120
+ .array(
121
+ z.object({
122
+ id: z.string(),
123
+ version: z.string().nullable(),
124
+ title: z.string(),
125
+ publishedAt: z.string().nullable(),
126
+ }),
127
+ )
128
+ .optional(),
129
+ // Unambiguous "did you mean" rail; null when the org segment matches
130
+ // multiple curated orgs or none.
131
+ relatedOrg: z
132
+ .object({
133
+ org: z.object({ id: z.string(), slug: z.string(), name: z.string() }),
134
+ sources: z.array(
135
+ z.object({ id: z.string(), slug: z.string(), name: z.string(), url: z.string() }),
136
+ ),
137
+ })
138
+ .nullable(),
139
+ });
140
+
141
+ export const UnifiedSearchResponseSchema = z.object({
142
+ query: z.string(),
143
+ // Normalized `?domain=` echo + resolution outcome. Both absent when no
144
+ // domain filter was applied.
145
+ domain: z.string().optional(),
146
+ domainStatus: z.enum(["matched", "not_found"]).optional(),
147
+ orgs: z.array(SearchOrgHitSchema),
148
+ catalog: z.array(SearchCatalogHitSchema),
149
+ sources: z.array(SearchSourceHitSchema),
150
+ releases: z.array(SearchReleaseHitSchema),
151
+ chunks: z.array(SearchChunkHitSchema).optional(),
152
+ mode: z.enum(["lexical", "semantic", "hybrid"]).optional(),
153
+ degraded: z.boolean().optional(),
154
+ degradedReason: z.string().optional(),
155
+ lookup: LookupResultPayloadSchema.nullable().optional(),
156
+ });
@@ -0,0 +1,36 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Per-source row in `GET /v1/sitemap`. `hasChangelog` and `hasHighlights`
5
+ * are `.optional()` for backwards compatibility — older clients ignore the
6
+ * fields, but the current handler always populates them (#875).
7
+ */
8
+ export const SitemapSourceSchema = z.object({
9
+ orgSlug: z.string(),
10
+ slug: z.string(),
11
+ latestDate: z.string().nullable(),
12
+ /**
13
+ * Whether this source has a stored GitHub CHANGELOG file. Used by the web
14
+ * sitemap to emit `/{org}/{src}/changelog` URLs only for sources where the
15
+ * route resolves.
16
+ */
17
+ hasChangelog: z.boolean().optional(),
18
+ /**
19
+ * Whether this source has any rolling or monthly highlight summaries.
20
+ * Drives `/{org}/{src}/highlights` sitemap emission.
21
+ */
22
+ hasHighlights: z.boolean().optional(),
23
+ });
24
+
25
+ /**
26
+ * Bulk URL payload consumed by the web sitemap generator. Lists every
27
+ * orgs/sources/products/collections slug paired with the timestamp the web
28
+ * uses to drive `<lastmod>`. Soft-deleted / hidden / on_demand rows are
29
+ * excluded.
30
+ */
31
+ export const SitemapPayloadSchema = z.object({
32
+ orgs: z.array(z.object({ slug: z.string(), lastActivity: z.string().nullable() })),
33
+ sources: z.array(SitemapSourceSchema),
34
+ products: z.array(z.object({ orgSlug: z.string(), slug: z.string() })),
35
+ collections: z.array(z.object({ slug: z.string(), updatedAt: z.string() })),
36
+ });
@@ -265,3 +265,294 @@ export const SourceChangelogResponseSchema = z.object({
265
265
  */
266
266
  files: z.array(ChangelogFileSummarySchema),
267
267
  });
268
+
269
+ // ── Source activity (/sources/:slug/activity, /orgs/:orgSlug/sources/:sourceSlug/activity) ──
270
+
271
+ const SourceActivityWeeklyBucketSchema = z.object({
272
+ weekStart: z.string(),
273
+ count: z.number().int().min(0),
274
+ earliestVersion: z.string().nullable(),
275
+ latestVersion: z.string().nullable(),
276
+ });
277
+
278
+ /**
279
+ * Response shape for `GET /v1/sources/:slug/activity` and the org-scoped twin.
280
+ * Returns week-bucketed release counts for the full tracking lifetime of the source.
281
+ * Optional `?from=YYYY-MM-DD` and `?to=YYYY-MM-DD` narrow the range.
282
+ */
283
+ export const SourceActivityResponseSchema = z.object({
284
+ source: z.object({
285
+ slug: z.string(),
286
+ name: z.string(),
287
+ orgSlug: z.string().nullable(),
288
+ orgName: z.string().nullable(),
289
+ }),
290
+ range: z.object({ from: z.string(), to: z.string() }),
291
+ weeklyBuckets: z.array(SourceActivityWeeklyBucketSchema),
292
+ });
293
+
294
+ // ── Source heatmap (/sources/:slug/heatmap, /orgs/:orgSlug/sources/:sourceSlug/heatmap) ──
295
+
296
+ /**
297
+ * Response shape for `GET /v1/sources/:slug/heatmap` and the org-scoped twin.
298
+ * Returns daily release counts for the trailing 365 days — contribution-graph
299
+ * visualization for the source detail page.
300
+ */
301
+ export const SourceHeatmapResponseSchema = z.object({
302
+ source: z.object({ slug: z.string(), name: z.string() }),
303
+ range: z.object({ from: z.string(), to: z.string() }),
304
+ dailyCounts: z.array(z.object({ date: z.string(), count: z.number().int().min(0) })),
305
+ total: z.number().int().min(0),
306
+ });
307
+
308
+ // ── Source known releases (/sources/:slug/known-releases, /orgs/:orgSlug/…) ──
309
+
310
+ /**
311
+ * Single row returned inside `GET /v1/sources/:slug/known-releases`. Minimal
312
+ * identifier set used by the incremental parsing agent to skip already-known
313
+ * versions without fetching full content.
314
+ */
315
+ export const SourceKnownReleaseItemSchema = z.object({
316
+ version: z.string().nullable(),
317
+ title: z.string(),
318
+ publishedAt: z.string().nullable(),
319
+ });
320
+
321
+ /**
322
+ * Response shape for `GET /v1/sources/:slug/known-releases`. Returns the
323
+ * N most-recent non-suppressed releases (default N=10, max 500), ordered
324
+ * by `publishedAt` descending. Accepts `?limit=N`.
325
+ */
326
+ export const SourceKnownReleasesResponseSchema = z.array(SourceKnownReleaseItemSchema);
327
+
328
+ // ── Source recent releases (/sources/:slug/recent-releases, /orgs/:orgSlug/…) ──
329
+
330
+ /**
331
+ * Response shape for `GET /v1/sources/:slug/recent-releases`. Returns the
332
+ * full release rows (non-suppressed, published at or after `?cutoff=`) ordered
333
+ * by `publishedAt` descending. Used by the summarization agent to retrieve the
334
+ * release window it needs to summarize. `?cutoff=` is required (ISO-8601 date
335
+ * string); missing `?cutoff=` returns 400.
336
+ */
337
+ export const SourceRecentReleasesResponseSchema = z.array(
338
+ z.looseObject({
339
+ id: z.string(),
340
+ sourceId: z.string(),
341
+ version: z.string().nullable(),
342
+ title: z.string(),
343
+ content: z.string(),
344
+ url: z.string().nullable(),
345
+ publishedAt: z.string().nullable(),
346
+ fetchedAt: z.string().optional(),
347
+ }),
348
+ );
349
+
350
+ // ── Source sessions (/sources/:slug/sessions) ──
351
+
352
+ /**
353
+ * Response shape for `GET /v1/sources/:slug/sessions`. Returns the active
354
+ * discovery session (if any) touching this source. The session object is the
355
+ * live DO state blob from the `StatusHub` Durable Object. Returns `{ sessions:
356
+ * [] }` when no active session references this source.
357
+ */
358
+ export const SourceSessionsResponseSchema = z.object({
359
+ sessions: z.array(z.record(z.string(), z.unknown())),
360
+ });
361
+
362
+ // ── Source summaries (/sources/:slug/summaries) ──
363
+
364
+ /**
365
+ * Full summary row as stored in `release_summaries`, returned by
366
+ * `GET /v1/sources/:slug/summaries`. Filter by `?type=rolling|monthly`,
367
+ * `?year=`, `?month=`.
368
+ */
369
+ export const SourceSummaryRowSchema = z.object({
370
+ id: z.string().optional(),
371
+ sourceId: z.string(),
372
+ orgId: z.string().nullable(),
373
+ type: z.enum(["rolling", "monthly"]),
374
+ year: z.number().int().nullable(),
375
+ month: z.number().int().nullable(),
376
+ windowDays: z.number().int().nullable(),
377
+ summary: z.string(),
378
+ releaseCount: z.number().int().min(0),
379
+ generatedAt: z.string(),
380
+ });
381
+
382
+ export const SourceSummariesResponseSchema = z.array(SourceSummaryRowSchema);
383
+
384
+ /**
385
+ * Body accepted by `POST /v1/sources/:slug/summaries`.
386
+ *
387
+ * `month` is calendar-bounded (1..12) and `windowDays` must be a positive
388
+ * integer when present — silently dropping wrong-typed values masks client
389
+ * bugs, so the validator rejects them explicitly. `year` is not bound-checked
390
+ * (any cutoff would be arbitrary).
391
+ */
392
+ export const CreateSourceSummaryBodySchema = z.object({
393
+ type: z.enum(["rolling", "monthly"]),
394
+ year: z.number().int().nullable().optional(),
395
+ month: z.number().int().min(1).max(12).nullable().optional(),
396
+ windowDays: z.number().int().min(1).nullable().optional(),
397
+ summary: z.string().min(1),
398
+ releaseCount: z.number().int().min(0),
399
+ });
400
+
401
+ /** Response for `POST /v1/sources/:slug/summaries` (upsert). */
402
+ export const CreateSourceSummaryResponseSchema = z.object({
403
+ ok: z.literal(true),
404
+ });
405
+
406
+ // ── Source fetch trigger (POST /sources/:slug/fetch) ──
407
+
408
+ /**
409
+ * Response shape for `POST /v1/sources/:slug/fetch`. Two branches:
410
+ * - Feed/GitHub/scraped-with-feed sources: `{ fetched: true, releasesInserted, … }`
411
+ * - Scrape/agent sources (flagged for CLI pickup): `{ queued: true, type: "flagged" }`
412
+ */
413
+ export const SourceFetchResponseSchema = z.union([
414
+ z.looseObject({ fetched: z.literal(true) }),
415
+ z.object({ queued: z.literal(true), type: z.string() }),
416
+ ]);
417
+
418
+ // ── Source content-hash check (POST /sources/:slug/content-hash) ──
419
+
420
+ /** Response for `POST /v1/sources/:slug/content-hash`. */
421
+ export const SourceContentHashResponseSchema = z.union([
422
+ z.object({ unchanged: z.literal(true) }),
423
+ z.object({ unchanged: z.literal(false) }),
424
+ ]);
425
+
426
+ /** Body for `POST /v1/sources/:slug/content-hash`. */
427
+ export const SourceContentHashBodySchema = z.object({
428
+ contentHash: z.string().min(1),
429
+ });
430
+
431
+ // ── Changelog token backfill (PATCH /sources/:slug/changelog/tokens) ──
432
+
433
+ /** Response for `PATCH /v1/sources/:slug/changelog/tokens`. */
434
+ export const ChangelogTokensResponseSchema = z.object({
435
+ path: z.string(),
436
+ oldTokens: z.number().int().nullable(),
437
+ tokens: z.number().int().min(0),
438
+ });
439
+
440
+ // ── Source metadata merge (PATCH /sources/:slug/metadata) ──
441
+
442
+ /** Response for `PATCH /v1/sources/:slug/metadata`. Echoes the merged metadata object. */
443
+ export const SourceMetadataResponseSchema = z.object({
444
+ metadata: z.record(z.string(), z.unknown()),
445
+ });
446
+
447
+ // ── Changelog probe (POST /sources/:slug/changelog/probe) ──
448
+
449
+ /** Response for `POST /v1/sources/:slug/changelog/probe`. */
450
+ export const ChangelogProbeResponseSchema = z.object({
451
+ sourceId: z.string(),
452
+ sourceSlug: z.string(),
453
+ url: z.string(),
454
+ paths: z.array(z.string()),
455
+ });
456
+
457
+ // ── Delete source (DELETE /sources/:slug) ──
458
+
459
+ /**
460
+ * Response for `DELETE /v1/sources/:slug`. Two shapes:
461
+ * - Soft delete: `{ deleted: true, deletedAt: "<iso>" }`
462
+ * - Hard delete (`?hard=true`): `{ deleted: true, hard: true }`
463
+ */
464
+ export const DeleteSourceResponseSchema = z.union([
465
+ z.object({ deleted: z.literal(true), deletedAt: z.string() }),
466
+ z.object({ deleted: z.literal(true), hard: z.literal(true) }),
467
+ ]);
468
+
469
+ // ── Delete source releases (DELETE /sources/:slug/releases) ──
470
+
471
+ /**
472
+ * Response for `DELETE /v1/sources/:slug/releases`. Two shapes:
473
+ * - Soft (`?hard` absent): `{ suppressed: N }` — releases hidden, not deleted
474
+ * - Hard (`?hard=true`): `{ deleted: N, hard: true }`
475
+ */
476
+ export const DeleteSourceReleasesResponseSchema = z.union([
477
+ z.object({ suppressed: z.number().int().min(0) }),
478
+ z.object({ deleted: z.number().int().min(0), hard: z.literal(true) }),
479
+ ]);
480
+
481
+ // ── Single release insert (POST /sources/:slug/releases) ──
482
+
483
+ /**
484
+ * Response for `POST /v1/sources/:slug/releases`. Returns the inserted release
485
+ * row (201) or `{ skipped: true }` (200) on URL conflict.
486
+ */
487
+ export const InsertReleaseResponseSchema = z.union([
488
+ z.looseObject({
489
+ id: z.string(),
490
+ sourceId: z.string(),
491
+ title: z.string(),
492
+ }),
493
+ z.object({ skipped: z.literal(true) }),
494
+ ]);
495
+
496
+ // ── Batch release insert (POST /sources/:slug/releases/batch) ──
497
+
498
+ /** Response for `POST /v1/sources/:slug/releases/batch`. */
499
+ export const BatchReleasesResponseSchema = z.object({
500
+ inserted: z.number().int().min(0),
501
+ total: z.number().int().min(0),
502
+ });
503
+
504
+ // ── Oversized changelog files (GET /sources/changelog-files/oversized) ──
505
+
506
+ /**
507
+ * Row returned by `GET /v1/sources/changelog-files/oversized`. Each row is a
508
+ * changelog file whose content length exceeds `?minBytes=` (default 256 KB).
509
+ * Used by `scripts/backfill-changelog-tokens.ts` to find rows with estimated
510
+ * (not exact) token counts.
511
+ */
512
+ export const OversizedChangelogFileRowSchema = z.object({
513
+ sourceId: z.string(),
514
+ sourceSlug: z.string(),
515
+ sourceName: z.string(),
516
+ orgSlug: z.string(),
517
+ path: z.string(),
518
+ filename: z.string(),
519
+ bytes: z.number().int().min(0),
520
+ tokens: z.number().int().nullable(),
521
+ fetchedAt: z.string(),
522
+ });
523
+
524
+ export const OversizedChangelogFilesResponseSchema = z.array(OversizedChangelogFileRowSchema);
525
+
526
+ // ── Fetchable sources (GET /sources/fetchable) ──
527
+
528
+ /**
529
+ * Response for `GET /v1/sources/fetchable`. Returns raw source rows matching
530
+ * the requested fetch mode (`?mode=unfetched|stale|retry_errors|all`).
531
+ * Accepts `?staleHours=N` when `mode=stale`.
532
+ */
533
+ export const FetchableSourcesResponseSchema = z.array(
534
+ z.looseObject({
535
+ id: z.string(),
536
+ slug: z.string(),
537
+ name: z.string(),
538
+ type: SourceTypeSchema,
539
+ url: z.string(),
540
+ metadata: z.string().nullable(),
541
+ }),
542
+ );
543
+
544
+ // ── Feed sources (GET /sources/feeds) ──
545
+
546
+ /**
547
+ * Response for `GET /v1/sources/feeds`. Returns visible source rows where
548
+ * `metadata.feedUrl` is set and `fetchPriority != 'paused'`.
549
+ */
550
+ export const FeedSourcesResponseSchema = FetchableSourcesResponseSchema;
551
+
552
+ // ── Changed sources (GET /sources/changes) ──
553
+
554
+ /**
555
+ * Response for `GET /v1/sources/changes`. Returns visible source rows that
556
+ * have a non-null `changeDetectedAt` — the set flagged for CLI pickup.
557
+ */
558
+ export const ChangedSourcesResponseSchema = FetchableSourcesResponseSchema;
@@ -0,0 +1,62 @@
1
+ import { z } from "zod";
2
+ import { StatsSchema } from "./shared.js";
3
+
4
+ /**
5
+ * Per-source activity row in `GET /v1/stats`. `orgName` is nullable because
6
+ * the source-to-org JOIN is left-joined (legacy orphan sources still exist).
7
+ */
8
+ export const StatsSourceActivitySchema = z.object({
9
+ sourceName: z.string(),
10
+ sourceSlug: z.string(),
11
+ sourceType: z.string(),
12
+ orgName: z.string().nullable(),
13
+ lastFetchedAt: z.string().nullable(),
14
+ totalReleases: z.number(),
15
+ recentReleases: z.number(),
16
+ });
17
+
18
+ /**
19
+ * Recent fetch-log row, joined with source + org for display. `durationMs`
20
+ * and `error` are nullable on the underlying table; `orgName` is nullable
21
+ * for the same orphan-source reason as above.
22
+ */
23
+ export const StatsRecentActivitySchema = z.object({
24
+ sourceName: z.string(),
25
+ sourceSlug: z.string(),
26
+ orgName: z.string().nullable(),
27
+ releasesFound: z.number(),
28
+ releasesInserted: z.number(),
29
+ totalReleases: z.number(),
30
+ status: z.string(),
31
+ durationMs: z.number().nullable(),
32
+ error: z.string().nullable(),
33
+ createdAt: z.string(),
34
+ });
35
+
36
+ /**
37
+ * Response shape for `GET /v1/stats`. The handler returns a hybrid payload —
38
+ * the flat `Stats` counts (legacy `orgs/sources/releases/products` fields used
39
+ * by the web homepage banner) merged with the richer `StatsSummary` shape
40
+ * (period window + totals + sourceHealth + sourceActivity + recentActivity).
41
+ * Modelled with `z.object` over the union of both shapes; consumers may
42
+ * pick either subset.
43
+ */
44
+ export const StatsResponseSchema = StatsSchema.extend({
45
+ period: z.object({
46
+ days: z.number().int().min(1),
47
+ cutoff: z.string(),
48
+ }),
49
+ totals: z.object({
50
+ organizations: z.number().int().min(0),
51
+ sources: z.number().int().min(0),
52
+ releases: z.number().int().min(0),
53
+ releasesInPeriod: z.number().int().min(0),
54
+ }),
55
+ sourceHealth: z.object({
56
+ upToDate: z.number().int().min(0),
57
+ stale: z.number().int().min(0),
58
+ neverFetched: z.number().int().min(0),
59
+ }),
60
+ sourceActivity: z.array(StatsSourceActivitySchema),
61
+ recentActivity: z.array(StatsRecentActivitySchema),
62
+ });
@@ -0,0 +1,110 @@
1
+ import { z } from "zod";
2
+ import { CategorySchema } from "./shared.js";
3
+ import { CollectionReleaseItemSchema } from "./collections.js";
4
+
5
+ /**
6
+ * Org rollup row shared by category and tag detail responses. Slim by
7
+ * design — taxonomy pages link out to the full org detail page rather
8
+ * than embedding it.
9
+ */
10
+ export const TaxonomyOrgSchema = z.object({
11
+ slug: z.string(),
12
+ name: z.string(),
13
+ domain: z.string().nullable(),
14
+ avatarUrl: z.string().nullable(),
15
+ });
16
+
17
+ /** Product rollup row, paired with `TaxonomyOrg`. */
18
+ export const TaxonomyProductSchema = z.object({
19
+ slug: z.string(),
20
+ name: z.string(),
21
+ description: z.string().nullable(),
22
+ orgSlug: z.string(),
23
+ orgName: z.string(),
24
+ });
25
+
26
+ /**
27
+ * Per-category row on `GET /v1/categories`. Categories are a fixed
28
+ * taxonomy (`CATEGORIES` in `@buildinternet/releases-core/categories`),
29
+ * so the response always includes every slug, including ones with zero
30
+ * members. `name` / `description` / `aliases` come from the optional
31
+ * `categories` metadata overlay; `description` is null when no override
32
+ * has been set.
33
+ */
34
+ export const CategoryListItemSchema = z.object({
35
+ slug: CategorySchema,
36
+ name: z.string(),
37
+ description: z.string().nullable(),
38
+ aliases: z.array(z.string()),
39
+ orgCount: z.number().int().min(0),
40
+ productCount: z.number().int().min(0),
41
+ });
42
+
43
+ export const CategoryListResponseSchema = z.array(CategoryListItemSchema);
44
+
45
+ /** Returned by `GET /v1/categories/:slug` for the canonical slug. */
46
+ export const CategoryDetailSchema = z.object({
47
+ slug: CategorySchema,
48
+ name: z.string(),
49
+ description: z.string().nullable(),
50
+ aliases: z.array(z.string()),
51
+ orgs: z.array(TaxonomyOrgSchema),
52
+ products: z.array(TaxonomyProductSchema),
53
+ });
54
+
55
+ /**
56
+ * Body accepted by `PATCH /v1/categories/:slug`. All fields are optional,
57
+ * but the body must set at least one of them — an empty body is rejected.
58
+ * `name` and `description` accept `null` to clear the overlay value;
59
+ * `aliases` replaces the full set (pass `[]` to clear). The row is
60
+ * upserted — categories that have never been customized have no row.
61
+ *
62
+ * `name` is bounded at 200 chars and `description` at 2000. Alias element
63
+ * shape (kebab-case, not a canonical slug, not claimed by another row) is
64
+ * enforced in the handler after the strings are trimmed + lowercased, since
65
+ * those checks depend on runtime state and case-folding.
66
+ */
67
+ export const UpdateCategoryRequestSchema = z
68
+ .object({
69
+ name: z.string().max(200).nullable().optional(),
70
+ description: z.string().max(2000).nullable().optional(),
71
+ aliases: z.array(z.string()).optional(),
72
+ })
73
+ .refine((b) => b.name !== undefined || b.description !== undefined || b.aliases !== undefined, {
74
+ message: "Body must set at least one of `name`, `description`, or `aliases`",
75
+ });
76
+
77
+ /** Response from `PATCH /v1/categories/:slug` — the resolved overlay values. */
78
+ export const UpdateCategoryResponseSchema = z.object({
79
+ slug: CategorySchema,
80
+ name: z.string(),
81
+ description: z.string().nullable(),
82
+ aliases: z.array(z.string()),
83
+ });
84
+
85
+ /**
86
+ * Aggregated release row on `GET /v1/categories/:slug/releases`. Same wire
87
+ * shape as `CollectionReleaseItem` — both surfaces aggregate across orgs and
88
+ * use `formatAggregateReleaseRow` in `workers/api/src/utils.ts` for the
89
+ * formatting. Re-exported here so the two endpoints describe one schema
90
+ * instead of two drifting copies.
91
+ */
92
+ export const CategoryReleaseItemSchema = CollectionReleaseItemSchema;
93
+
94
+ export const CategoryFeedPaginationSchema = z.object({
95
+ nextCursor: z.string().nullable(),
96
+ limit: z.number().int().min(1),
97
+ });
98
+
99
+ export const CategoryReleasesResponseSchema = z.object({
100
+ releases: z.array(CategoryReleaseItemSchema),
101
+ pagination: CategoryFeedPaginationSchema,
102
+ });
103
+
104
+ /** Returned by `GET /v1/tags/:slug`. */
105
+ export const TagDetailSchema = z.object({
106
+ slug: z.string(),
107
+ name: z.string(),
108
+ orgs: z.array(TaxonomyOrgSchema),
109
+ products: z.array(TaxonomyProductSchema),
110
+ });