Wip stage M4
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { LOCALES } from '@sport/types';
|
||||
|
||||
import { genderTargetSchema, sportTypeSchema } from './catalog';
|
||||
import { anyIdSchema, skuSchema, slugSchema } from './common';
|
||||
import { offsetPageQuerySchema } from './pagination';
|
||||
|
||||
/**
|
||||
* Admin catalog write schemas.
|
||||
*
|
||||
* Money stays an integer in minor units all the way from the form to the
|
||||
* database (ADR-0011) — there is deliberately no decimal input anywhere in this
|
||||
* file, because the moment one exists someone will round it.
|
||||
*/
|
||||
|
||||
const localeKeySchema = z.enum(LOCALES);
|
||||
|
||||
/** At least the default locale must be present, or the product has no name. */
|
||||
function requiredTranslations<T extends z.ZodTypeAny>(fields: T) {
|
||||
return z
|
||||
.record(localeKeySchema, fields)
|
||||
.refine((value) => Object.keys(value).length > 0, 'Provide content for at least one language');
|
||||
}
|
||||
|
||||
export const productTranslationSchema = z.object({
|
||||
name: z.string().trim().min(1).max(255),
|
||||
/** Generated from the name when omitted; see `slugify`. */
|
||||
slug: slugSchema.optional(),
|
||||
shortDescription: z.string().trim().max(500).nullish(),
|
||||
description: z.string().trim().max(20_000).nullish(),
|
||||
metaTitle: z.string().trim().max(255).nullish(),
|
||||
metaDescription: z.string().trim().max(500).nullish(),
|
||||
});
|
||||
|
||||
export const productOptionValueInputSchema = z.object({
|
||||
/** Present when editing an existing value; absent when adding one. */
|
||||
id: anyIdSchema.optional(),
|
||||
value: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(80)
|
||||
.regex(/^[a-z0-9][a-z0-9._-]*$/, 'Use lowercase letters, digits, . _ -'),
|
||||
position: z.coerce.number().int().min(0).default(0),
|
||||
swatchHex: z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/, 'Use a hex colour like #1a2b3c')
|
||||
.nullish(),
|
||||
labels: requiredTranslations(z.string().trim().min(1).max(80)),
|
||||
});
|
||||
|
||||
export const productOptionInputSchema = z.object({
|
||||
id: anyIdSchema.optional(),
|
||||
key: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(40)
|
||||
.regex(/^[a-z][a-z0-9_]*$/, 'Use lowercase letters, digits and underscores'),
|
||||
position: z.coerce.number().int().min(0).default(0),
|
||||
names: requiredTranslations(z.string().trim().min(1).max(60)),
|
||||
values: z.array(productOptionValueInputSchema).min(1, 'An option needs at least one value'),
|
||||
});
|
||||
|
||||
export const productAttributeInputSchema = z.object({
|
||||
key: z.string().trim().min(1).max(60),
|
||||
position: z.coerce.number().int().min(0).default(0),
|
||||
translations: requiredTranslations(
|
||||
z.object({
|
||||
label: z.string().trim().min(1).max(120),
|
||||
value: z.string().trim().min(1).max(500),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const createProductSchema = z.object({
|
||||
translations: requiredTranslations(productTranslationSchema),
|
||||
brandId: anyIdSchema.nullish(),
|
||||
primaryCategoryId: anyIdSchema.nullish(),
|
||||
genderTargets: z.array(genderTargetSchema).default([]),
|
||||
sportTypes: z.array(sportTypeSchema).default([]),
|
||||
collectionIds: z.array(anyIdSchema).default([]),
|
||||
/** Seeds the generated SKUs. Falls back to the slug when omitted. */
|
||||
skuPrefix: z.string().trim().max(24).optional(),
|
||||
options: z.array(productOptionInputSchema).default([]),
|
||||
attributes: z.array(productAttributeInputSchema).default([]),
|
||||
/** Applied to every generated variant; per-variant edits come after. */
|
||||
basePriceAmount: z.int().min(0).default(0),
|
||||
});
|
||||
|
||||
export const updateProductSchema = createProductSchema.partial().extend({
|
||||
status: z.enum(['DRAFT', 'ACTIVE', 'ARCHIVED']).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Per-variant edits.
|
||||
*
|
||||
* Stock is deliberately NOT settable here — it moves through the inventory
|
||||
* endpoint so that every change lands in the movement ledger. A price edit and
|
||||
* a stock correction are different events with different audit needs.
|
||||
*/
|
||||
export const updateVariantSchema = z.object({
|
||||
sku: skuSchema.optional(),
|
||||
barcode: z.string().trim().max(64).nullish(),
|
||||
priceAmount: z.int().min(0).optional(),
|
||||
salePriceAmount: z.int().min(0).nullish(),
|
||||
compareAtAmount: z.int().min(0).nullish(),
|
||||
costAmount: z.int().min(0).nullish(),
|
||||
weightGrams: z.int().min(0).nullish(),
|
||||
status: z.enum(['ACTIVE', 'ARCHIVED']).optional(),
|
||||
});
|
||||
|
||||
export const bulkUpdateVariantsSchema = z.object({
|
||||
variants: z
|
||||
.array(updateVariantSchema.extend({ id: anyIdSchema }))
|
||||
.min(1)
|
||||
.max(500),
|
||||
});
|
||||
|
||||
export const productImagesSchema = z.object({
|
||||
images: z
|
||||
.array(
|
||||
z.object({
|
||||
mediaId: anyIdSchema,
|
||||
position: z.coerce.number().int().min(0),
|
||||
/** Ties the image to a colourway so the gallery swaps on selection. */
|
||||
optionValueId: anyIdSchema.nullish(),
|
||||
}),
|
||||
)
|
||||
.max(50),
|
||||
});
|
||||
|
||||
export const adminProductListQuerySchema = offsetPageQuerySchema.extend({
|
||||
q: z.string().trim().max(120).optional(),
|
||||
status: z.enum(['DRAFT', 'ACTIVE', 'ARCHIVED']).optional(),
|
||||
brandId: anyIdSchema.optional(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Media
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const presignUploadSchema = z.object({
|
||||
filename: z.string().trim().min(1).max(255),
|
||||
mimeType: z.string().trim().min(1).max(120),
|
||||
sizeBytes: z
|
||||
.int()
|
||||
.min(1)
|
||||
.max(25 * 1024 * 1024),
|
||||
prefix: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^[a-z0-9/-]+$/, 'Use lowercase letters, digits, / and -')
|
||||
.default('products'),
|
||||
});
|
||||
|
||||
/** Called after the browser has PUT the bytes to object storage. */
|
||||
export const registerMediaSchema = z.object({
|
||||
storageKey: z.string().trim().min(1).max(512),
|
||||
mimeType: z.string().trim().min(1).max(120),
|
||||
sizeBytes: z.int().min(1),
|
||||
width: z.int().min(1).nullish(),
|
||||
height: z.int().min(1).nullish(),
|
||||
altText: z.string().trim().max(255).nullish(),
|
||||
blurDataUrl: z.string().max(4096).nullish(),
|
||||
});
|
||||
|
||||
export const mediaListQuerySchema = offsetPageQuerySchema.extend({
|
||||
q: z.string().trim().max(120).optional(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inventory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const STOCK_REASONS = [
|
||||
'PURCHASE_RECEIPT',
|
||||
'RETURN',
|
||||
'MANUAL_ADJUSTMENT',
|
||||
'STOCK_TAKE',
|
||||
'TRANSFER_IN',
|
||||
'TRANSFER_OUT',
|
||||
'DAMAGE',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* A stock adjustment is a *delta*, not a new absolute value — except for a
|
||||
* stock take, which is the one case where an operator genuinely knows the
|
||||
* count. Modelling both as deltas would make a stock take require mental
|
||||
* arithmetic against a number that may have changed since the page loaded.
|
||||
*/
|
||||
export const adjustStockSchema = z
|
||||
.object({
|
||||
variantId: anyIdSchema,
|
||||
locationId: anyIdSchema.optional(),
|
||||
reason: z.enum(STOCK_REASONS),
|
||||
quantityDelta: z.int().optional(),
|
||||
/** Stock take only: the counted quantity. */
|
||||
countedQuantity: z.int().min(0).optional(),
|
||||
note: z.string().trim().max(500).nullish(),
|
||||
})
|
||||
.refine(
|
||||
(value) =>
|
||||
value.reason === 'STOCK_TAKE'
|
||||
? value.countedQuantity !== undefined
|
||||
: value.quantityDelta !== undefined && value.quantityDelta !== 0,
|
||||
'Provide a non-zero quantityDelta, or countedQuantity for a stock take',
|
||||
);
|
||||
|
||||
export const inventoryListQuerySchema = offsetPageQuerySchema.extend({
|
||||
q: z.string().trim().max(120).optional(),
|
||||
lowStockOnly: z.coerce.boolean().optional(),
|
||||
});
|
||||
|
||||
export type CreateProductInput = z.output<typeof createProductSchema>;
|
||||
export type UpdateProductInput = z.output<typeof updateProductSchema>;
|
||||
export type ProductOptionInput = z.output<typeof productOptionInputSchema>;
|
||||
export type BulkUpdateVariantsInput = z.output<typeof bulkUpdateVariantsSchema>;
|
||||
export type ProductImagesInput = z.output<typeof productImagesSchema>;
|
||||
export type AdminProductListQuery = z.output<typeof adminProductListQuerySchema>;
|
||||
export type PresignUploadInput = z.output<typeof presignUploadSchema>;
|
||||
export type RegisterMediaInput = z.output<typeof registerMediaSchema>;
|
||||
export type MediaListQuery = z.output<typeof mediaListQuerySchema>;
|
||||
export type AdjustStockInput = z.output<typeof adjustStockSchema>;
|
||||
export type InventoryListQuery = z.output<typeof inventoryListQuerySchema>;
|
||||
@@ -16,3 +16,4 @@ export * from './pagination';
|
||||
export * from './auth';
|
||||
export * from './catalog';
|
||||
export * from './users';
|
||||
export * from './catalog-admin';
|
||||
|
||||
Reference in New Issue
Block a user