- Project setup: package.json, tsconfig.json, jest.config.js, .env.example - Config: env.ts, constants.ts (ALLOWED_UNITS, bcrypt rounds, JWT, deletion windows) - DB: Knex connection, knexfile, migrations 001-006 (users, password_reset_tokens, pantry_items, recipes+recipe_ingredients, shopping_lists+items, deleted_records) - Seeds: 10 seeded recipes with full ingredient lists - Middleware: JWT authMiddleware (validates token + user existence), errorHandler - Utils: Pino logger, JWT sign helper, Zod validators for all request shapes - Services: authService (signup/signin/google/pwd-reset/soft-delete/restore), pantryService (CRUD + case-insensitive duplicate guard), recipeService (browse+filter+scale), shoppingListService (CRUD+merge logic), syncService (delta sync + tombstone cleanup), emailService (SendGrid) - Routes: /v1/auth, /v1/pantry, /v1/recipes, /v1/shopping-lists, /v1/sync - App: Express factory (createApp), server entry point - Jobs: node-cron daily hard-delete + tombstone cleanup - Tests: validators, utils, auth, pantry, recipes, shopping lists, sync
98 lines
3.1 KiB
TypeScript
98 lines
3.1 KiB
TypeScript
import { z } from 'zod';
|
|
import { ALLOWED_UNITS } from '../config/constants';
|
|
|
|
export const signupSchema = z.object({
|
|
email: z.string().email('Invalid email format.'),
|
|
password: z
|
|
.string()
|
|
.min(8, 'Password must be at least 8 characters.')
|
|
.regex(/^[a-zA-Z0-9]+$/, 'Password must be alphanumeric.'),
|
|
name: z.string().min(1, 'Name is required.').max(255),
|
|
});
|
|
|
|
export const signinSchema = z.object({
|
|
email: z.string().email('Invalid email format.'),
|
|
password: z.string().min(1, 'Password is required.'),
|
|
});
|
|
|
|
export const googleAuthSchema = z.object({
|
|
id_token: z.string().min(1, 'id_token is required.'),
|
|
});
|
|
|
|
export const passwordResetRequestSchema = z.object({
|
|
email: z.string().email('Invalid email format.'),
|
|
});
|
|
|
|
export const passwordResetConfirmSchema = z.object({
|
|
token: z.string().min(1, 'Token is required.'),
|
|
new_password: z
|
|
.string()
|
|
.min(8, 'Password must be at least 8 characters.')
|
|
.regex(/^[a-zA-Z0-9]+$/, 'Password must be alphanumeric.'),
|
|
});
|
|
|
|
export const addPantryItemSchema = z.object({
|
|
item_name: z.string().min(1, 'Item name is required.').max(255),
|
|
quantity: z
|
|
.number()
|
|
.int('Quantity must be a whole number.')
|
|
.positive('Quantity must be positive.'),
|
|
});
|
|
|
|
export const updatePantryItemSchema = z.object({
|
|
quantity: z
|
|
.number()
|
|
.int('Quantity must be a whole number.')
|
|
.positive('Quantity must be positive.'),
|
|
last_modified: z.string().datetime().optional(),
|
|
});
|
|
|
|
export const createShoppingListSchema = z.object({
|
|
list_name: z.string().min(1, 'List name is required.').max(255),
|
|
});
|
|
|
|
export const addShoppingListItemSchema = z.object({
|
|
item_name: z.string().min(1, 'Item name is required.').max(255),
|
|
quantity: z.number().positive('Quantity must be positive.'),
|
|
unit: z.enum(ALLOWED_UNITS as [string, ...string[]], {
|
|
errorMap: () => ({ message: `Unit must be one of: ${ALLOWED_UNITS.join(', ')}` }),
|
|
}),
|
|
});
|
|
|
|
export const updateShoppingListItemSchema = z
|
|
.object({
|
|
quantity: z.number().positive('Quantity must be positive.').optional(),
|
|
unit: z
|
|
.enum(ALLOWED_UNITS as [string, ...string[]], {
|
|
errorMap: () => ({ message: `Unit must be one of: ${ALLOWED_UNITS.join(', ')}` }),
|
|
})
|
|
.optional(),
|
|
checked_off: z.boolean().optional(),
|
|
})
|
|
.refine(
|
|
(data) => Object.keys(data).length > 0,
|
|
{ message: 'At least one field must be provided.' }
|
|
);
|
|
|
|
export const addRecipesToListSchema = z.object({
|
|
recipe_ids: z
|
|
.array(z.string().uuid('Each recipe_id must be a valid UUID.'))
|
|
.min(1, 'At least one recipe_id is required.'),
|
|
scale_factor: z.number().int().min(1).max(3).optional().default(1),
|
|
});
|
|
|
|
export const recipeQuerySchema = z.object({
|
|
filter: z.enum(['all', 'can_make', 'can_partially_make']).optional().default('all'),
|
|
page: z.coerce.number().int().positive().optional().default(1),
|
|
limit: z.coerce.number().int().positive().max(50).optional().default(20),
|
|
search: z.string().optional(),
|
|
});
|
|
|
|
export const recipeDetailQuerySchema = z.object({
|
|
scale: z.coerce.number().int().min(1).max(3).optional().default(1),
|
|
});
|
|
|
|
export const syncQuerySchema = z.object({
|
|
since: z.string().datetime('since must be a valid ISO 8601 timestamp.'),
|
|
});
|