feat: implement backend API for Pantree Phase 1 MVP
- 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
This commit is contained in:
97
src/utils/validators.ts
Normal file
97
src/utils/validators.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
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.'),
|
||||
});
|
||||
Reference in New Issue
Block a user