Wip stage M4

This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:22 +07:00
parent 5386bc51d1
commit d5cacc1208
113 changed files with 9486 additions and 919 deletions
+226
View File
@@ -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>;
+1
View File
@@ -16,3 +16,4 @@ export * from './pagination';
export * from './auth';
export * from './catalog';
export * from './users';
export * from './catalog-admin';