|
| 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 | +}); |
0 commit comments