Initial commit (unpacked platform)
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@app/validation",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Zod-scheman för API-kontrakten – delas av api, worker, admin och mobil",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@app/shared-types": "workspace:*",
|
||||
"zod": "^4.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const registerInputSchema = z.object({
|
||||
email: z.string().trim().toLowerCase().max(255).pipe(z.email()),
|
||||
password: z.string().min(10, "Lösenordet måste vara minst 10 tecken").max(200),
|
||||
displayName: z.string().min(1).max(80).trim(),
|
||||
locale: z.string().min(2).max(10).default("sv-SE"),
|
||||
});
|
||||
export type RegisterInput = z.infer<typeof registerInputSchema>;
|
||||
|
||||
export const loginInputSchema = z.object({
|
||||
email: z.string().trim().toLowerCase().pipe(z.email()),
|
||||
password: z.string().min(1).max(200),
|
||||
});
|
||||
export type LoginInput = z.infer<typeof loginInputSchema>;
|
||||
|
||||
export const refreshInputSchema = z.object({
|
||||
refreshToken: z.string().min(10),
|
||||
});
|
||||
export type RefreshInput = z.infer<typeof refreshInputSchema>;
|
||||
|
||||
export const authTokensSchema = z.object({
|
||||
accessToken: z.string(),
|
||||
refreshToken: z.string(),
|
||||
accessTokenExpiresIn: z.number(),
|
||||
});
|
||||
export type AuthTokens = z.infer<typeof authTokensSchema>;
|
||||
|
||||
export const changePasswordInputSchema = z.object({
|
||||
currentPassword: z.string().min(1),
|
||||
newPassword: z.string().min(10).max(200),
|
||||
});
|
||||
export type ChangePasswordInput = z.infer<typeof changePasswordInputSchema>;
|
||||
|
||||
export const forgotPasswordInputSchema = z.object({
|
||||
email: z.string().trim().toLowerCase().pipe(z.email()),
|
||||
});
|
||||
export type ForgotPasswordInput = z.infer<typeof forgotPasswordInputSchema>;
|
||||
|
||||
export const resetPasswordInputSchema = z.object({
|
||||
token: z.string().min(32).max(200),
|
||||
newPassword: z.string().min(10).max(200),
|
||||
});
|
||||
export type ResetPasswordInput = z.infer<typeof resetPasswordInputSchema>;
|
||||
|
||||
export const verifyEmailInputSchema = z.object({
|
||||
token: z.string().min(32).max(200),
|
||||
});
|
||||
export type VerifyEmailInput = z.infer<typeof verifyEmailInputSchema>;
|
||||
|
||||
export const totpVerifyInputSchema = z.object({
|
||||
preAuthToken: z.string().min(10),
|
||||
code: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^\d{6}$/),
|
||||
});
|
||||
export type TotpVerifyInput = z.infer<typeof totpVerifyInputSchema>;
|
||||
|
||||
export const totpCodeInputSchema = z.object({
|
||||
code: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^\d{6}$/),
|
||||
});
|
||||
export type TotpCodeInput = z.infer<typeof totpCodeInputSchema>;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { z } from "zod";
|
||||
import { UNITS } from "@app/shared-types";
|
||||
|
||||
export const uuidSchema = z.uuid();
|
||||
|
||||
export const idParamSchema = z.object({ id: uuidSchema });
|
||||
|
||||
export const dateStringSchema = z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, "Datum måste vara YYYY-MM-DD");
|
||||
|
||||
export const paginationQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(100).default(30),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
});
|
||||
export type PaginationQuery = z.infer<typeof paginationQuerySchema>;
|
||||
|
||||
export const unitSchema = z.enum(UNITS);
|
||||
|
||||
export const quantitySchema = z.number().positive().max(1_000_000);
|
||||
|
||||
export const confidenceSchema = z.number().min(0).max(1);
|
||||
|
||||
/** Standardiserat felsvar från API:t. */
|
||||
export const apiErrorSchema = z.object({
|
||||
error: z.object({
|
||||
code: z.string(),
|
||||
message: z.string(),
|
||||
details: z.unknown().optional(),
|
||||
correlationId: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
export type ApiError = z.infer<typeof apiErrorSchema>;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { z } from "zod";
|
||||
import { HOUSEHOLD_ROLES, STORAGE_LOCATION_TYPES } from "@app/shared-types";
|
||||
import { uuidSchema } from "./common.js";
|
||||
|
||||
export const createHouseholdInputSchema = z.object({
|
||||
name: z.string().min(1).max(80).trim(),
|
||||
weeklyBudgetMinor: z.number().int().min(0).max(10_000_000).optional(),
|
||||
/** ISO 4217 – hushållets valuta (i18n-spec §20). Default SEK i databasen. */
|
||||
currencyCode: z
|
||||
.string()
|
||||
.length(3)
|
||||
.regex(/^[A-Za-z]{3}$/)
|
||||
.transform((v) => v.toUpperCase())
|
||||
.optional(),
|
||||
});
|
||||
export type CreateHouseholdInput = z.infer<typeof createHouseholdInputSchema>;
|
||||
|
||||
export const updateHouseholdInputSchema = createHouseholdInputSchema.partial();
|
||||
export type UpdateHouseholdInput = z.infer<typeof updateHouseholdInputSchema>;
|
||||
|
||||
export const joinHouseholdInputSchema = z.object({
|
||||
inviteCode: z.string().min(4).max(20).trim(),
|
||||
});
|
||||
export type JoinHouseholdInput = z.infer<typeof joinHouseholdInputSchema>;
|
||||
|
||||
export const updateMemberInputSchema = z.object({
|
||||
role: z.enum(HOUSEHOLD_ROLES).optional(),
|
||||
portionFactor: z.number().min(0.1).max(3).optional(),
|
||||
});
|
||||
export type UpdateMemberInput = z.infer<typeof updateMemberInputSchema>;
|
||||
|
||||
export const memberParamSchema = z.object({
|
||||
id: uuidSchema,
|
||||
userId: uuidSchema,
|
||||
});
|
||||
|
||||
export const createStorageLocationInputSchema = z.object({
|
||||
type: z.enum(STORAGE_LOCATION_TYPES),
|
||||
name: z.string().min(1).max(60).trim(),
|
||||
sublocations: z.array(z.string().min(1).max(60)).max(20).default([]),
|
||||
});
|
||||
export type CreateStorageLocationInput = z.infer<typeof createStorageLocationInputSchema>;
|
||||
|
||||
export const updateStorageLocationInputSchema = createStorageLocationInputSchema.partial();
|
||||
export type UpdateStorageLocationInput = z.infer<typeof updateStorageLocationInputSchema>;
|
||||
@@ -0,0 +1,14 @@
|
||||
export * from "./common.js";
|
||||
export * from "./auth.js";
|
||||
export * from "./profile.js";
|
||||
export * from "./household.js";
|
||||
export * from "./inventory.js";
|
||||
export * from "./scans.js";
|
||||
export * from "./recipes.js";
|
||||
export * from "./meals.js";
|
||||
export * from "./shopping.js";
|
||||
export * from "./planning.js";
|
||||
export * from "./recommendations.js";
|
||||
export * from "./memory.js";
|
||||
export * from "./subscriptions.js";
|
||||
export * from "./locale.js";
|
||||
@@ -0,0 +1,45 @@
|
||||
import { z } from "zod";
|
||||
import { DATE_KINDS, INVENTORY_SOURCES, INVENTORY_TRANSACTION_TYPES } from "@app/shared-types";
|
||||
import { dateStringSchema, quantitySchema, unitSchema, uuidSchema } from "./common.js";
|
||||
|
||||
export const createInventoryItemInputSchema = z.object({
|
||||
canonicalIngredientId: z.string().max(80).optional(),
|
||||
productId: uuidSchema.optional(),
|
||||
displayName: z.string().min(1).max(120).trim(),
|
||||
brand: z.string().max(80).optional(),
|
||||
quantity: quantitySchema,
|
||||
unit: unitSchema,
|
||||
storageLocationId: uuidSchema,
|
||||
sublocation: z.string().max(60).optional(),
|
||||
purchasedAt: dateStringSchema.optional(),
|
||||
openedAt: dateStringSchema.optional(),
|
||||
bestBeforeDate: dateStringSchema.optional(),
|
||||
useByDate: dateStringSchema.optional(),
|
||||
dateKind: z.enum(DATE_KINDS).optional(),
|
||||
frozenAt: dateStringSchema.optional(),
|
||||
priceMinor: z.number().int().min(0).max(10_000_000).optional(),
|
||||
source: z.enum(INVENTORY_SOURCES).default("manual_search"),
|
||||
});
|
||||
export type CreateInventoryItemInput = z.infer<typeof createInventoryItemInputSchema>;
|
||||
|
||||
export const updateInventoryItemInputSchema = createInventoryItemInputSchema.partial().extend({
|
||||
verifiedByUser: z.boolean().optional(),
|
||||
});
|
||||
export type UpdateInventoryItemInput = z.infer<typeof updateInventoryItemInputSchema>;
|
||||
|
||||
/** Manuell lagertransaktion, t.ex. "använde 300 g" eller "slängde resten". */
|
||||
export const inventoryTransactionInputSchema = z.object({
|
||||
type: z.enum(INVENTORY_TRANSACTION_TYPES),
|
||||
quantityDelta: z.number().refine((v) => v !== 0, "Delta får inte vara 0"),
|
||||
note: z.string().max(200).optional(),
|
||||
});
|
||||
export type InventoryTransactionInput = z.infer<typeof inventoryTransactionInputSchema>;
|
||||
|
||||
export const inventoryQuerySchema = z.object({
|
||||
storageLocationId: uuidSchema.optional(),
|
||||
expiryStatus: z.enum(["fresh", "use_soon", "expiring", "expired", "unknown"]).optional(),
|
||||
search: z.string().max(80).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(100),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
});
|
||||
export type InventoryQuery = z.infer<typeof inventoryQuerySchema>;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { z } from "zod";
|
||||
import { MEASUREMENT_SYSTEMS, TEMPERATURE_UNITS } from "@app/shared-types";
|
||||
|
||||
/** i18n-spec §6: alla fält oberoende och valfria vid uppdatering. */
|
||||
export const updateLocalePreferencesInputSchema = z.object({
|
||||
languageTag: z
|
||||
.string()
|
||||
.regex(/^[a-z]{2,3}(-[A-Za-z]{2,4})?(-[A-Za-z0-9]{2,8})?$/, "Ogiltig BCP 47-tagg")
|
||||
.optional(),
|
||||
regionCode: z
|
||||
.string()
|
||||
.regex(/^[A-Za-z]{2}$/, "Ogiltig ISO 3166-1 alpha-2-kod")
|
||||
.transform((v) => v.toUpperCase())
|
||||
.optional(),
|
||||
timeZone: z.string().min(1).max(64).optional(),
|
||||
measurementSystem: z.enum(MEASUREMENT_SYSTEMS).optional(),
|
||||
temperatureUnit: z.enum(TEMPERATURE_UNITS).optional(),
|
||||
currencyCode: z
|
||||
.string()
|
||||
.regex(/^[A-Za-z]{3}$/, "Ogiltig ISO 4217-kod")
|
||||
.transform((v) => v.toUpperCase())
|
||||
.optional(),
|
||||
firstDayOfWeek: z.number().int().min(0).max(6).optional(),
|
||||
use24HourTime: z.boolean().optional(),
|
||||
});
|
||||
export type UpdateLocalePreferencesInput = z.infer<typeof updateLocalePreferencesInputSchema>;
|
||||
@@ -0,0 +1,70 @@
|
||||
import { z } from "zod";
|
||||
import { MEAL_LOG_SOURCES, MEAL_TYPES } from "@app/shared-types";
|
||||
import { dateStringSchema, quantitySchema, unitSchema, uuidSchema } from "./common.js";
|
||||
|
||||
const nutritionValuesInputSchema = z.object({
|
||||
kcal: z.number().min(0).max(20000),
|
||||
proteinG: z.number().min(0).max(1000).default(0),
|
||||
carbsG: z.number().min(0).max(2000).default(0),
|
||||
fatG: z.number().min(0).max(1000).default(0),
|
||||
saturatedFatG: z.number().min(0).max(500).default(0),
|
||||
fiberG: z.number().min(0).max(300).default(0),
|
||||
sugarG: z.number().min(0).max(1000).default(0),
|
||||
saltG: z.number().min(0).max(100).default(0),
|
||||
});
|
||||
|
||||
/**
|
||||
* Måltidsloggning (spec §23). Näringsvärden får ALDRIG hittas på av AI:
|
||||
* de kommer från recept (deterministiskt), produkt/streckkod, eller
|
||||
* användarens egen inmatning. Tallriksfoto ger intervall som användaren bekräftar.
|
||||
*/
|
||||
export const logMealInputSchema = z.object({
|
||||
date: dateStringSchema,
|
||||
mealType: z.enum(MEAL_TYPES),
|
||||
source: z.enum(MEAL_LOG_SOURCES),
|
||||
titleSv: z.string().min(1).max(150),
|
||||
recipeId: uuidSchema.optional(),
|
||||
mealBoxId: uuidSchema.optional(),
|
||||
scanJobId: uuidSchema.optional(),
|
||||
portionFraction: z.number().min(0.1).max(5).default(1),
|
||||
/** Vid produkt/fritext: ange mängd + per-100-värden eller direkta värden. */
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
displayName: z.string().min(1).max(120),
|
||||
canonicalIngredientId: z.string().max(80).optional(),
|
||||
productId: uuidSchema.optional(),
|
||||
quantity: quantitySchema.optional(),
|
||||
unit: unitSchema.optional(),
|
||||
nutrition: nutritionValuesInputSchema.optional(),
|
||||
}),
|
||||
)
|
||||
.max(30)
|
||||
.default([]),
|
||||
/** Direkta värden när användaren själv anger, eller bekräftat foto-intervall. */
|
||||
nutritionOverride: nutritionValuesInputSchema.optional(),
|
||||
});
|
||||
export type LogMealInput = z.infer<typeof logMealInputSchema>;
|
||||
|
||||
export const dayQuerySchema = z.object({
|
||||
date: dateStringSchema,
|
||||
});
|
||||
|
||||
export const createMealBoxInputSchema = z.object({
|
||||
recipeId: uuidSchema.optional(),
|
||||
titleSv: z.string().min(1).max(150),
|
||||
portions: z.number().int().min(1).max(24),
|
||||
storageLocationId: uuidSchema,
|
||||
frozen: z.boolean().default(false),
|
||||
cookedAt: dateStringSchema.optional(),
|
||||
reservedForUserId: uuidSchema.optional(),
|
||||
});
|
||||
export type CreateMealBoxInput = z.infer<typeof createMealBoxInputSchema>;
|
||||
|
||||
export const consumeMealBoxInputSchema = z.object({
|
||||
portions: z.number().int().min(1).max(24).default(1),
|
||||
logAsMeal: z.boolean().default(true),
|
||||
mealType: z.enum(MEAL_TYPES).default("lunch"),
|
||||
date: dateStringSchema.optional(),
|
||||
});
|
||||
export type ConsumeMealBoxInput = z.infer<typeof consumeMealBoxInputSchema>;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* "Vad plattformen vet om mig" (spec §32): användaren kan korrigera, pausa och radera.
|
||||
*/
|
||||
export const updateMemoryItemInputSchema = z.object({
|
||||
summarySv: z.string().min(1).max(500).optional(),
|
||||
value: z.unknown().optional(),
|
||||
verified: z.boolean().optional(),
|
||||
paused: z.boolean().optional(),
|
||||
});
|
||||
export type UpdateMemoryItemInput = z.infer<typeof updateMemoryItemInputSchema>;
|
||||
|
||||
export const memoryQuerySchema = z.object({
|
||||
kind: z
|
||||
.enum(["structured_fact", "event", "semantic", "profile_summary", "recipe_memory"])
|
||||
.optional(),
|
||||
includePaused: z.coerce.boolean().default(true),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(100),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
});
|
||||
export type MemoryQuery = z.infer<typeof memoryQuerySchema>;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from "zod";
|
||||
import { MEAL_TYPES } from "@app/shared-types";
|
||||
import { dateStringSchema, uuidSchema } from "./common.js";
|
||||
|
||||
/** Veckoplan (spec §25). */
|
||||
export const generateWeekPlanInputSchema = z.object({
|
||||
weekStartDate: dateStringSchema,
|
||||
daysToPlann: z.number().int().min(1).max(14).optional(),
|
||||
mealTypes: z.array(z.enum(MEAL_TYPES)).min(1).default(["dinner"]),
|
||||
portionsPerMeal: z.number().int().min(1).max(20).optional(),
|
||||
budgetMinorTotal: z.number().int().min(0).max(5_000_000).optional(),
|
||||
/** T.ex. "Sju middagar för fyra personer under 1 000 kr" (spec §26). */
|
||||
noteSv: z.string().max(300).optional(),
|
||||
preferLeftoversFirst: z.boolean().default(true),
|
||||
varietyLevel: z.enum(["low", "medium", "high"]).default("medium"),
|
||||
});
|
||||
export type GenerateWeekPlanInput = z.infer<typeof generateWeekPlanInputSchema>;
|
||||
|
||||
export const updatePlanEntryInputSchema = z.object({
|
||||
date: dateStringSchema.optional(),
|
||||
mealType: z.enum(MEAL_TYPES).optional(),
|
||||
recipeId: uuidSchema.nullable().optional(),
|
||||
mealBoxId: uuidSchema.nullable().optional(),
|
||||
portions: z.number().int().min(1).max(24).optional(),
|
||||
status: z.enum(["planned", "cooked", "skipped", "moved"]).optional(),
|
||||
});
|
||||
export type UpdatePlanEntryInput = z.infer<typeof updatePlanEntryInputSchema>;
|
||||
|
||||
export const weekPlanQuerySchema = z.object({
|
||||
weekStartDate: dateStringSchema.optional(),
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ACTIVITY_LEVELS,
|
||||
ALLERGENS,
|
||||
CONSENT_KINDS,
|
||||
CUISINES,
|
||||
DIET_PATTERNS,
|
||||
EQUIPMENT,
|
||||
GOAL_TYPES,
|
||||
PRECISION_MODES,
|
||||
RELIGIOUS_RULES,
|
||||
SEXES,
|
||||
} from "@app/shared-types";
|
||||
|
||||
/** Hälsoprofil – hanteras separat från hushållsdata (spec §7, §56). */
|
||||
export const updateHealthProfileInputSchema = z.object({
|
||||
birthYear: z.number().int().min(1900).max(2030).optional(),
|
||||
sex: z.enum(SEXES).optional(),
|
||||
heightCm: z.number().min(80).max(250).optional(),
|
||||
weightKg: z.number().min(20).max(400).optional(),
|
||||
targetWeightKg: z.number().min(20).max(400).optional(),
|
||||
activityLevel: z.enum(ACTIVITY_LEVELS).optional(),
|
||||
trainingSessionsPerWeek: z.number().int().min(0).max(21).optional(),
|
||||
trainingTypes: z.array(z.string().max(50)).max(10).optional(),
|
||||
});
|
||||
export type UpdateHealthProfileInput = z.infer<typeof updateHealthProfileInputSchema>;
|
||||
|
||||
export const updatePreferencesInputSchema = z.object({
|
||||
primaryGoal: z.enum(GOAL_TYPES).optional(),
|
||||
goals: z.array(z.enum(GOAL_TYPES)).max(11).optional(),
|
||||
dietPattern: z.enum(DIET_PATTERNS).optional(),
|
||||
religiousRule: z.enum(RELIGIOUS_RULES).optional(),
|
||||
allergens: z.array(z.enum(ALLERGENS)).optional(),
|
||||
intolerances: z.array(z.string().max(60)).max(30).optional(),
|
||||
avoidIngredientIds: z.array(z.string().max(80)).max(100).optional(),
|
||||
favoriteCuisines: z.array(z.enum(CUISINES)).max(19).optional(),
|
||||
dislikedDishes: z.array(z.string().max(80)).max(50).optional(),
|
||||
spiceLevelMax: z.number().int().min(0).max(5).optional(),
|
||||
weeklyBudgetMinor: z.number().int().min(0).max(10_000_000).nullable().optional(),
|
||||
maxCookingMinutesWeekday: z.number().int().min(5).max(360).nullable().optional(),
|
||||
equipment: z.array(z.enum(EQUIPMENT)).optional(),
|
||||
defaultPortions: z.number().int().min(1).max(20).optional(),
|
||||
});
|
||||
export type UpdatePreferencesInput = z.infer<typeof updatePreferencesInputSchema>;
|
||||
|
||||
export const updateMeInputSchema = z.object({
|
||||
displayName: z.string().min(1).max(80).trim().optional(),
|
||||
locale: z.string().min(2).max(10).optional(),
|
||||
precisionMode: z.enum(PRECISION_MODES).optional(),
|
||||
});
|
||||
export type UpdateMeInput = z.infer<typeof updateMeInputSchema>;
|
||||
|
||||
export const consentInputSchema = z.object({
|
||||
kind: z.enum(CONSENT_KINDS),
|
||||
granted: z.boolean(),
|
||||
});
|
||||
export type ConsentInput = z.infer<typeof consentInputSchema>;
|
||||
|
||||
/** Onboarding i ett svep (spec §6): allt är valfritt utom visningsnamnet som redan finns. */
|
||||
export const onboardingInputSchema = z.object({
|
||||
healthProfile: updateHealthProfileInputSchema.optional(),
|
||||
preferences: updatePreferencesInputSchema.optional(),
|
||||
precisionMode: z.enum(PRECISION_MODES).default("simple"),
|
||||
householdChoice: z
|
||||
.discriminatedUnion("kind", [
|
||||
z.object({ kind: z.literal("create"), name: z.string().min(1).max(80) }),
|
||||
z.object({ kind: z.literal("join"), inviteCode: z.string().min(4).max(20) }),
|
||||
z.object({ kind: z.literal("skip") }),
|
||||
])
|
||||
.default({ kind: "skip" }),
|
||||
});
|
||||
export type OnboardingInput = z.infer<typeof onboardingInputSchema>;
|
||||
@@ -0,0 +1,132 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ALLERGENS,
|
||||
COOKING_METHODS,
|
||||
CUISINES,
|
||||
EQUIPMENT,
|
||||
FEEDBACK_TAGS,
|
||||
MEAL_TYPES,
|
||||
RECIPE_DIFFICULTIES,
|
||||
RECIPE_TAGS,
|
||||
} from "@app/shared-types";
|
||||
import { quantitySchema, unitSchema, uuidSchema } from "./common.js";
|
||||
|
||||
export const recipeQuerySchema = z.object({
|
||||
search: z.string().max(120).optional(),
|
||||
cuisine: z.enum(CUISINES).optional(),
|
||||
mealType: z.enum(MEAL_TYPES).optional(),
|
||||
tags: z
|
||||
.union([z.enum(RECIPE_TAGS), z.array(z.enum(RECIPE_TAGS))])
|
||||
.transform((v) => (Array.isArray(v) ? v : [v]))
|
||||
.optional(),
|
||||
method: z.enum(COOKING_METHODS).optional(),
|
||||
maxTotalMinutes: z.coerce.number().int().min(1).max(600).optional(),
|
||||
maxKcalPerPortion: z.coerce.number().int().min(50).max(5000).optional(),
|
||||
minProteinPerPortion: z.coerce.number().int().min(0).max(300).optional(),
|
||||
maxCostMinorPerPortion: z.coerce.number().int().min(0).max(100_000).optional(),
|
||||
difficulty: z.enum(RECIPE_DIFFICULTIES).optional(),
|
||||
excludeAllergens: z
|
||||
.union([z.enum(ALLERGENS), z.array(z.enum(ALLERGENS))])
|
||||
.transform((v) => (Array.isArray(v) ? v : [v]))
|
||||
.optional(),
|
||||
creatorUserId: uuidSchema.optional(),
|
||||
sort: z.enum(["relevance", "rating", "cooked", "newest", "time", "cost"]).default("relevance"),
|
||||
limit: z.coerce.number().int().min(1).max(50).default(20),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
});
|
||||
export type RecipeQuery = z.infer<typeof recipeQuerySchema>;
|
||||
|
||||
const recipeIngredientInputSchema = z.object({
|
||||
canonicalIngredientId: z.string().min(1).max(80),
|
||||
displayNameSv: z.string().min(1).max(120),
|
||||
quantity: quantitySchema,
|
||||
unit: unitSchema,
|
||||
note: z.string().max(120).optional(),
|
||||
optional: z.boolean().default(false),
|
||||
groupName: z.string().max(60).optional(),
|
||||
});
|
||||
|
||||
const recipeStepInputSchema = z.object({
|
||||
instructionSv: z.string().min(3).max(1000),
|
||||
timerSeconds: z.number().int().min(5).max(86_400).optional(),
|
||||
temperatureC: z.number().int().min(30).max(350).optional(),
|
||||
tip: z.string().max(300).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Användarrecept (spec §35): antingen strukturerat direkt, eller fritext som
|
||||
* AI strukturerar (via AAMOS) och användaren sedan granskar.
|
||||
*/
|
||||
export const createUserRecipeInputSchema = z.discriminatedUnion("mode", [
|
||||
z.object({
|
||||
mode: z.literal("structured"),
|
||||
titleSv: z.string().min(3).max(150),
|
||||
descriptionSv: z.string().max(2000).default(""),
|
||||
cuisine: z.enum(CUISINES).default("international"),
|
||||
mealTypes: z.array(z.enum(MEAL_TYPES)).min(1),
|
||||
tags: z.array(z.enum(RECIPE_TAGS)).default([]),
|
||||
methods: z.array(z.enum(COOKING_METHODS)).default([]),
|
||||
equipment: z.array(z.enum(EQUIPMENT)).default([]),
|
||||
difficulty: z.enum(RECIPE_DIFFICULTIES).default("easy"),
|
||||
prepTimeMinutes: z.number().int().min(0).max(600),
|
||||
cookTimeMinutes: z.number().int().min(0).max(1440),
|
||||
portions: z.number().int().min(1).max(24),
|
||||
spiceLevel: z.number().int().min(0).max(5).default(0),
|
||||
ingredients: z.array(recipeIngredientInputSchema).min(1).max(60),
|
||||
steps: z.array(recipeStepInputSchema).min(1).max(40),
|
||||
}),
|
||||
z.object({
|
||||
mode: z.literal("free_text"),
|
||||
text: z.string().min(20).max(8000),
|
||||
}),
|
||||
]);
|
||||
export type CreateUserRecipeInput = z.infer<typeof createUserRecipeInputSchema>;
|
||||
|
||||
export const rateRecipeInputSchema = z.object({
|
||||
stars: z.number().int().min(1).max(5),
|
||||
feedbackTags: z.array(z.enum(FEEDBACK_TAGS)).max(8).default([]),
|
||||
comment: z.string().max(1000).optional(),
|
||||
});
|
||||
export type RateRecipeInput = z.infer<typeof rateRecipeInputSchema>;
|
||||
|
||||
/** "Jag har lagat detta" – kärnflödet som drar lager och loggar måltid (spec §23). */
|
||||
export const cookRecipeInputSchema = z.object({
|
||||
portionsCooked: z.number().int().min(1).max(24),
|
||||
/** Vilka i hushållet som åt, med portionsandel per person. */
|
||||
eaters: z
|
||||
.array(
|
||||
z.object({
|
||||
userId: uuidSchema,
|
||||
portionFraction: z.number().min(0.1).max(3).default(1),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
/** Portioner som blev matlådor (spec §24). */
|
||||
mealBoxPortions: z.number().int().min(0).max(24).default(0),
|
||||
mealBoxStorageLocationId: uuidSchema.optional(),
|
||||
mealBoxFrozen: z.boolean().default(false),
|
||||
/** Dra ingredienser från lagret? Förifyllt förslag visas i appen. */
|
||||
deductInventory: z.boolean().default(true),
|
||||
/** Justeringar av vad som faktiskt användes. */
|
||||
inventoryOverrides: z
|
||||
.array(
|
||||
z.object({
|
||||
canonicalIngredientId: z.string().max(80),
|
||||
quantityUsed: z.number().min(0),
|
||||
unit: unitSchema,
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
date: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.optional(),
|
||||
mealType: z.enum(MEAL_TYPES).default("dinner"),
|
||||
});
|
||||
export type CookRecipeInput = z.infer<typeof cookRecipeInputSchema>;
|
||||
|
||||
export const substitutionQuerySchema = z.object({
|
||||
fromIngredientId: z.string().min(1).max(80),
|
||||
context: z.string().max(60).optional(),
|
||||
});
|
||||
export type SubstitutionQuery = z.infer<typeof substitutionQuerySchema>;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { z } from "zod";
|
||||
import { MEAL_TYPES } from "@app/shared-types";
|
||||
|
||||
/**
|
||||
* "Vad ska vi äta?" (spec §18) + "Jag är sugen på" (spec §19).
|
||||
* Alla parametrar är valfria – motorn använder hushållets kontext som standard.
|
||||
*/
|
||||
export const whatToEatQuerySchema = z.object({
|
||||
mealType: z.enum(MEAL_TYPES).default("dinner"),
|
||||
persons: z.coerce.number().int().min(1).max(20).optional(),
|
||||
maxMinutes: z.coerce.number().int().min(5).max(600).optional(),
|
||||
maxCostMinorPerPortion: z.coerce.number().int().min(0).max(100_000).optional(),
|
||||
/** Fritext eller röst-transkription: "krämigt", "asiatiskt", "under 500 kcal" … */
|
||||
craving: z.string().max(300).optional(),
|
||||
includeLeftovers: z.coerce.boolean().default(true),
|
||||
limit: z.coerce.number().int().min(1).max(20).default(5),
|
||||
});
|
||||
export type WhatToEatQuery = z.infer<typeof whatToEatQuerySchema>;
|
||||
@@ -0,0 +1,70 @@
|
||||
import { z } from "zod";
|
||||
import { SCAN_TYPES } from "@app/shared-types";
|
||||
import {
|
||||
confidenceSchema,
|
||||
dateStringSchema,
|
||||
quantitySchema,
|
||||
unitSchema,
|
||||
uuidSchema,
|
||||
} from "./common.js";
|
||||
|
||||
/** Steg 1: begär signerad uppladdning + skapa jobb (spec §50). */
|
||||
export const createScanInputSchema = z.object({
|
||||
scanType: z.enum(SCAN_TYPES),
|
||||
imageCount: z.number().int().min(0).max(6).default(1),
|
||||
contentType: z
|
||||
.enum(["image/jpeg", "image/png", "image/webp", "image/heic"])
|
||||
.default("image/jpeg"),
|
||||
/** För streckkod behövs ingen bild – koden skickas direkt. */
|
||||
barcode: z
|
||||
.string()
|
||||
.regex(/^\d{8,14}$/)
|
||||
.optional(),
|
||||
/** Kontext som förbättrar analysen, t.ex. recept vid tallriksfoto (spec §22). */
|
||||
context: z
|
||||
.object({
|
||||
recipeId: uuidSchema.optional(),
|
||||
storageLocationId: uuidSchema.optional(),
|
||||
note: z.string().max(300).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
export type CreateScanInput = z.infer<typeof createScanInputSchema>;
|
||||
|
||||
/** Ett AI-identifierat objekt som användaren granskar (spec §10). */
|
||||
export const scanResultItemSchema = z.object({
|
||||
tempId: z.string(),
|
||||
detectedName: z.string(),
|
||||
canonicalIngredientId: z.string().nullable(),
|
||||
brand: z.string().nullable().optional(),
|
||||
estimatedQuantity: z.number().nullable(),
|
||||
unit: unitSchema.nullable(),
|
||||
bestBeforeDate: dateStringSchema.nullable().optional(),
|
||||
confidence: confidenceSchema,
|
||||
requiresConfirmation: z.boolean(),
|
||||
});
|
||||
export type ScanResultItem = z.infer<typeof scanResultItemSchema>;
|
||||
|
||||
/** Steg 3: användaren bekräftar/ändrar innan något skrivs till lagret (spec §10, §61.5). */
|
||||
export const confirmScanInputSchema = z.object({
|
||||
storageLocationId: uuidSchema.optional(),
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
tempId: z.string().optional(),
|
||||
action: z.enum(["accept", "edit", "reject", "add"]),
|
||||
canonicalIngredientId: z.string().max(80).optional(),
|
||||
displayName: z.string().min(1).max(120),
|
||||
brand: z.string().max(80).optional(),
|
||||
quantity: quantitySchema,
|
||||
unit: unitSchema,
|
||||
bestBeforeDate: dateStringSchema.optional(),
|
||||
useByDate: dateStringSchema.optional(),
|
||||
storageLocationId: uuidSchema.optional(),
|
||||
sublocation: z.string().max(60).optional(),
|
||||
priceMinor: z.number().int().min(0).optional(),
|
||||
}),
|
||||
)
|
||||
.max(100),
|
||||
});
|
||||
export type ConfirmScanInput = z.infer<typeof confirmScanInputSchema>;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { z } from "zod";
|
||||
import { STORE_SECTIONS } from "@app/shared-types";
|
||||
import { dateStringSchema, quantitySchema, unitSchema, uuidSchema } from "./common.js";
|
||||
|
||||
export const createShoppingListInputSchema = z.object({
|
||||
name: z.string().min(1).max(80).default("Inköpslista"),
|
||||
weekPlanId: uuidSchema.optional(),
|
||||
/** Generera från veckoplan: dra av det som redan finns hemma (spec §27). */
|
||||
generateFromPlan: z.boolean().default(false),
|
||||
});
|
||||
export type CreateShoppingListInput = z.infer<typeof createShoppingListInputSchema>;
|
||||
|
||||
export const addShoppingItemInputSchema = z.object({
|
||||
displayName: z.string().min(1).max(120),
|
||||
canonicalIngredientId: z.string().max(80).optional(),
|
||||
quantity: quantitySchema.default(1),
|
||||
unit: unitSchema.default("COUNT"),
|
||||
storeSection: z.enum(STORE_SECTIONS).optional(),
|
||||
estimatedPriceMinor: z.number().int().min(0).optional(),
|
||||
});
|
||||
export type AddShoppingItemInput = z.infer<typeof addShoppingItemInputSchema>;
|
||||
|
||||
export const updateShoppingItemInputSchema = z.object({
|
||||
displayName: z.string().min(1).max(120).optional(),
|
||||
quantity: quantitySchema.optional(),
|
||||
unit: unitSchema.optional(),
|
||||
storeSection: z.enum(STORE_SECTIONS).optional(),
|
||||
checked: z.boolean().optional(),
|
||||
});
|
||||
export type UpdateShoppingItemInput = z.infer<typeof updateShoppingItemInputSchema>;
|
||||
|
||||
/** Avsluta köprundan: bockade varor läggs in i lagret (spec §27). */
|
||||
export const completeShoppingInputSchema = z.object({
|
||||
addToInventory: z.boolean().default(true),
|
||||
storageDefaults: z
|
||||
.array(
|
||||
z.object({
|
||||
shoppingListItemId: uuidSchema,
|
||||
storageLocationId: uuidSchema,
|
||||
priceMinor: z.number().int().min(0).optional(),
|
||||
bestBeforeDate: dateStringSchema.optional(),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
defaultStorageLocationId: uuidSchema.optional(),
|
||||
});
|
||||
export type CompleteShoppingInput = z.infer<typeof completeShoppingInputSchema>;
|
||||
@@ -0,0 +1,40 @@
|
||||
import { z } from "zod";
|
||||
import { SUBSCRIPTION_PLANS } from "@app/shared-types";
|
||||
|
||||
/**
|
||||
* Klienten skickar kvitto/token efter köp; backend verifierar mot butiken
|
||||
* och är source of truth (spec §47, §61.14).
|
||||
*/
|
||||
export const verifyPurchaseInputSchema = z.discriminatedUnion("provider", [
|
||||
z.object({
|
||||
provider: z.literal("apple"),
|
||||
/** App Store Server API: signerad transaktion från StoreKit 2. */
|
||||
signedTransaction: z.string().min(10),
|
||||
}),
|
||||
z.object({
|
||||
provider: z.literal("google"),
|
||||
packageName: z.string().min(3),
|
||||
productId: z.string().min(1),
|
||||
purchaseToken: z.string().min(10),
|
||||
}),
|
||||
]);
|
||||
export type VerifyPurchaseInput = z.infer<typeof verifyPurchaseInputSchema>;
|
||||
|
||||
export const restorePurchasesInputSchema = z.object({
|
||||
provider: z.enum(["apple", "google"]),
|
||||
payload: z.string().min(1),
|
||||
});
|
||||
export type RestorePurchasesInput = z.infer<typeof restorePurchasesInputSchema>;
|
||||
|
||||
/** Store-notiser tas emot rå, signaturverifieras och läggs på kö (spec §47). */
|
||||
export const storeNotificationSchema = z.object({
|
||||
raw: z.unknown(),
|
||||
});
|
||||
|
||||
export const adminGrantInputSchema = z.object({
|
||||
userId: z.uuid(),
|
||||
plan: z.enum(SUBSCRIPTION_PLANS),
|
||||
days: z.number().int().min(1).max(3650),
|
||||
reason: z.string().min(3).max(300),
|
||||
});
|
||||
export type AdminGrantInput = z.infer<typeof adminGrantInputSchema>;
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src", "test"],
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user