Initial commit (unpacked platform)

This commit is contained in:
Sven (AAMOS AI)
2026-08-05 19:21:11 +07:00
commit ac5340195a
314 changed files with 57584 additions and 0 deletions
+18
View File
@@ -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"
}
}
+66
View File
@@ -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>;
+33
View File
@@ -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>;
+45
View File
@@ -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>;
+14
View File
@@ -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";
+45
View File
@@ -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>;
+26
View File
@@ -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>;
+70
View File
@@ -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>;
+22
View File
@@ -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>;
+31
View File
@@ -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(),
});
+72
View File
@@ -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>;
+132
View File
@@ -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>;
+70
View File
@@ -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>;
+47
View File
@@ -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>;
+40
View File
@@ -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>;
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src", "test"],
"compilerOptions": {
"types": ["node"]
}
}