Skip to content

Commit cd6714a

Browse files
zachdunnclaude
andauthored
feat(api,api-types): Zod-derived types for products + 6 route annotations (#759)
Continues the OpenAPI 3.1 spec port from #752 (Sources). Hand-written ProductListItem/ProductDetail/ProductAdoptResult interfaces in api-types.ts move to z.infer aliases backed by schemas/products.ts. CategorySchema lifts from schemas/orgs.ts to schemas/shared.ts for cross-resource reuse. Six routes annotated with describeRoute: GET /v1/products, GET /v1/products/:identifier (dual-registered with org-scoped twin), POST /v1/products, POST /v1/products/adopt, PATCH /v1/products/:slug, DELETE /v1/products/:identifier. PATCH gains a new org-scoped twin /v1/orgs/:orgSlug/products/:productSlug — without it, slug-only callers couldn't PATCH at all post-#698 (BareSlugRejected blocks bare slugs on the legacy path). Drift fixed during port: ProductDetail now correctly includes aliases, embeddedAt, and deletedAt (the GET handler always emits them via the spread of the full row); ProductAdoptResult.product is the raw row, not the inflated ProductDetail (the adopt handler returns the bare insert result). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5b061fc commit cd6714a

5 files changed

Lines changed: 575 additions & 275 deletions

File tree

packages/api-types/src/api-types.ts

Lines changed: 43 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
*/
55

66
import type { z } from "zod";
7-
import type { SourceType } from "@buildinternet/releases-core/source-enums";
87

98
export type {
109
SourceType,
@@ -42,6 +41,20 @@ import type {
4241
ChangelogFileSummarySchema,
4342
SourceChangelogResponseSchema,
4443
} from "./schemas/sources.js";
44+
import type {
45+
ProductRowSchema,
46+
ProductListItemSchema,
47+
ProductListResponseSchema,
48+
ProductDetailSourceSchema,
49+
ProductDetailSchema,
50+
CreateProductBodySchema,
51+
UpdateProductBodySchema,
52+
AdoptProductBodySchema,
53+
ProductAdoptResultSchema,
54+
ProductAdoptDryRunSchema,
55+
ProductAdoptResponseSchema,
56+
ProductDeleteResponseSchema,
57+
} from "./schemas/products.js";
4558

4659
export {
4760
MediaItemSchema,
@@ -53,6 +66,7 @@ export {
5366
ReleaseItemSchema,
5467
ReleaseSummaryItemSchema,
5568
OverviewPageItemSchema,
69+
CategorySchema,
5670
} from "./schemas/shared.js";
5771
export {
5872
OrgListItemSchema,
@@ -75,6 +89,20 @@ export {
7589
ChangelogFileSummarySchema,
7690
SourceChangelogResponseSchema,
7791
} from "./schemas/sources.js";
92+
export {
93+
ProductRowSchema,
94+
ProductListItemSchema,
95+
ProductListResponseSchema,
96+
ProductDetailSourceSchema,
97+
ProductDetailSchema,
98+
CreateProductBodySchema,
99+
UpdateProductBodySchema,
100+
AdoptProductBodySchema,
101+
ProductAdoptResultSchema,
102+
ProductAdoptDryRunSchema,
103+
ProductAdoptResponseSchema,
104+
ProductDeleteResponseSchema,
105+
} from "./schemas/products.js";
78106

79107
// ── Media ──
80108

@@ -529,39 +557,20 @@ export interface OrgReleasesResponse {
529557

530558
// ── Products ──
531559

532-
export interface ProductListItem {
533-
id: string;
534-
name: string;
535-
slug: string;
536-
orgId: string;
537-
url: string | null;
538-
description: string | null;
539-
category: string | null;
540-
createdAt: string;
541-
sourceCount: number;
542-
}
543-
544-
export type ProductListResponse = ListResponse<ProductListItem>;
545-
546-
export interface ProductDetail {
547-
id: string;
548-
name: string;
549-
slug: string;
550-
orgId: string;
551-
url: string | null;
552-
description: string | null;
553-
category: string | null;
554-
createdAt: string;
555-
sources: Array<{ id: string; slug: string; name: string; type: SourceType; url: string }>;
556-
tags: string[];
557-
}
558-
559-
export interface ProductAdoptResult {
560-
product: ProductDetail;
561-
sourcesMoved: number;
562-
accountsMoved: number;
563-
sourceOrgDeleted: string;
564-
}
560+
// `Category` lives in @buildinternet/releases-core/categories — import it from
561+
// there. CategorySchema is re-exported here for OpenAPI / Zod consumers only.
562+
export type ProductRow = z.infer<typeof ProductRowSchema>;
563+
export type ProductListItem = z.infer<typeof ProductListItemSchema>;
564+
export type ProductListResponse = z.infer<typeof ProductListResponseSchema>;
565+
export type ProductDetailSource = z.infer<typeof ProductDetailSourceSchema>;
566+
export type ProductDetail = z.infer<typeof ProductDetailSchema>;
567+
export type CreateProductBody = z.infer<typeof CreateProductBodySchema>;
568+
export type UpdateProductBody = z.infer<typeof UpdateProductBodySchema>;
569+
export type AdoptProductBody = z.infer<typeof AdoptProductBodySchema>;
570+
export type ProductAdoptResult = z.infer<typeof ProductAdoptResultSchema>;
571+
export type ProductAdoptDryRun = z.infer<typeof ProductAdoptDryRunSchema>;
572+
export type ProductAdoptResponse = z.infer<typeof ProductAdoptResponseSchema>;
573+
export type ProductDeleteResponse = z.infer<typeof ProductDeleteResponseSchema>;
565574

566575
// ── Taxonomy (categories + tags) ──
567576

packages/api-types/src/schemas/orgs.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import { z } from "zod";
2-
import { CATEGORIES } from "@buildinternet/releases-core/categories";
3-
import { ListResponseSchema, OverviewPageItemSchema } from "./shared.js";
2+
import { CategorySchema, ListResponseSchema, OverviewPageItemSchema } from "./shared.js";
43
import { SourceListItemSchema } from "./sources.js";
5-
6-
const CategorySchema = z.enum(CATEGORIES);
4+
import { ProductListItemSchema } from "./products.js";
75

86
export const OrgListItemSchema = z.object({
97
id: z.string(),
@@ -50,13 +48,16 @@ export const UpdateOrgBodySchema = z.object({
5048
aliases: z.array(z.string()).optional(),
5149
});
5250

53-
const OrgDetailProductSchema = z.object({
54-
id: z.string(),
55-
slug: z.string(),
56-
name: z.string(),
57-
url: z.string().nullable(),
58-
description: z.string().nullable(),
59-
sourceCount: z.number().int().min(0),
51+
// Org detail's products query selects a strict subset of `ProductListItem` —
52+
// no category/orgId/createdAt because the parent already names the org and
53+
// the org detail UI doesn't surface those columns.
54+
const OrgDetailProductSchema = ProductListItemSchema.pick({
55+
id: true,
56+
slug: true,
57+
name: true,
58+
url: true,
59+
description: true,
60+
sourceCount: true,
6061
});
6162

6263
const OrgDetailPlaybookSchema = z.object({
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { z } from "zod";
2+
import { CategorySchema, ListResponseSchema } from "./shared.js";
3+
import { SourceTypeSchema } from "./sources.js";
4+
5+
/**
6+
* Raw `products` table row, returned by `POST /v1/products` and
7+
* `PATCH /v1/products/:slug`. The OSS CLI types these responses against
8+
* `Product` from `@buildinternet/releases-core/schema` (the drizzle row
9+
* type), so `embeddedAt` and `deletedAt` stay on the wire even though
10+
* they're internal columns. `deletedAt` is always `null` on these paths
11+
* (live rows only); `embeddedAt` reflects when the product was last
12+
* indexed for semantic search.
13+
*/
14+
export const ProductRowSchema = z.object({
15+
id: z.string(),
16+
name: z.string(),
17+
slug: z.string(),
18+
orgId: z.string(),
19+
url: z.string().nullable(),
20+
description: z.string().nullable(),
21+
category: CategorySchema.nullable(),
22+
createdAt: z.string(),
23+
embeddedAt: z.string().nullable(),
24+
deletedAt: z.string().nullable(),
25+
});
26+
27+
/**
28+
* Per-product row returned by `GET /v1/products`. Adds `sourceCount` to
29+
* the row shape but omits the internal `embeddedAt` / `deletedAt` columns
30+
* (the list handler explicitly selects only the user-facing fields).
31+
*/
32+
export const ProductListItemSchema = z.object({
33+
id: z.string(),
34+
name: z.string(),
35+
slug: z.string(),
36+
orgId: z.string(),
37+
url: z.string().nullable(),
38+
description: z.string().nullable(),
39+
category: CategorySchema.nullable(),
40+
createdAt: z.string(),
41+
sourceCount: z.number().int().min(0),
42+
});
43+
44+
export const ProductListResponseSchema = ListResponseSchema(ProductListItemSchema);
45+
46+
/**
47+
* Embedded source row returned in `ProductDetail.sources`. The detail
48+
* handler explicitly selects only this small subset; reuses the shared
49+
* `SourceTypeSchema` enum so source types stay in one place.
50+
*/
51+
export const ProductDetailSourceSchema = z.object({
52+
id: z.string(),
53+
slug: z.string(),
54+
name: z.string(),
55+
type: SourceTypeSchema,
56+
url: z.string(),
57+
});
58+
59+
/**
60+
* Returned by `GET /v1/products/:identifier` (and the org-scoped twin).
61+
* Spreads the raw product row and adds `sources`, `tags`, `aliases`.
62+
*/
63+
export const ProductDetailSchema = ProductRowSchema.extend({
64+
sources: z.array(ProductDetailSourceSchema),
65+
tags: z.array(z.string()),
66+
aliases: z.array(z.string()),
67+
});
68+
69+
/** Body accepted by `POST /v1/products`. */
70+
export const CreateProductBodySchema = z.object({
71+
name: z.string().min(1),
72+
orgId: z.string().optional(),
73+
orgSlug: z.string().optional(),
74+
slug: z.string().optional(),
75+
url: z.string().optional(),
76+
description: z.string().optional(),
77+
category: CategorySchema.optional(),
78+
tags: z.array(z.string()).optional(),
79+
});
80+
81+
/** Body accepted by `PATCH /v1/products/:slug`. */
82+
export const UpdateProductBodySchema = z.object({
83+
name: z.string().optional(),
84+
url: z.string().nullable().optional(),
85+
description: z.string().nullable().optional(),
86+
category: CategorySchema.nullable().optional(),
87+
tags: z.array(z.string()).optional(),
88+
aliases: z.array(z.string()).optional(),
89+
});
90+
91+
/** Body accepted by `POST /v1/products/adopt`. */
92+
export const AdoptProductBodySchema = z.object({
93+
sourceOrgSlug: z.string().min(1),
94+
targetOrgSlug: z.string().min(1),
95+
slug: z.string().optional(),
96+
url: z.string().optional(),
97+
dryRun: z.boolean().optional(),
98+
});
99+
100+
/** Live (non-dryRun) result from `POST /v1/products/adopt`. */
101+
export const ProductAdoptResultSchema = z.object({
102+
product: ProductRowSchema,
103+
sourcesMoved: z.number().int().min(0),
104+
accountsMoved: z.number().int().min(0),
105+
sourceOrgDeleted: z.string(),
106+
});
107+
108+
/** Dry-run preview from `POST /v1/products/adopt` with `dryRun: true`. */
109+
export const ProductAdoptDryRunSchema = z.object({
110+
dryRun: z.literal(true),
111+
product: z.object({
112+
name: z.string(),
113+
slug: z.string(),
114+
url: z.string().nullable(),
115+
orgSlug: z.string(),
116+
}),
117+
sourcesToMove: z.array(z.string()),
118+
sourceOrgToDelete: z.string(),
119+
});
120+
121+
/** Response shape returned by `POST /v1/products/adopt` (union of live + dry-run). */
122+
export const ProductAdoptResponseSchema = z.union([
123+
ProductAdoptResultSchema,
124+
ProductAdoptDryRunSchema,
125+
]);
126+
127+
/** Response shape returned by `DELETE /v1/products/:identifier`. */
128+
export const ProductDeleteResponseSchema = z.object({
129+
deleted: z.literal(true),
130+
hard: z.literal(true).optional(),
131+
deletedAt: z.string().optional(),
132+
});

packages/api-types/src/schemas/shared.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { z } from "zod";
22
import { RELEASE_TYPES } from "@buildinternet/releases-core/schema";
3+
import { CATEGORIES } from "@buildinternet/releases-core/categories";
4+
5+
export const CategorySchema = z.enum(CATEGORIES);
36

47
export const MediaItemSchema = z.object({
58
type: z.enum(["image", "video", "gif"]),

0 commit comments

Comments
 (0)