ci: trigga på master + formatfix inför Gitea Actions
CI / Typecheck, test & build (push) Failing after 2s
CI / Typecheck, test & build (push) Failing after 2s
This commit is contained in:
@@ -107,7 +107,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
process.exit(1);
|
||||
}
|
||||
if (cfg.AAMOS_MODE === "http" && (!cfg.AAMOS_API_URL || !cfg.AAMOS_API_KEY)) {
|
||||
console.error("SÄKERHETSSTOPP: AAMOS_MODE=http kräver AAMOS_API_URL och AAMOS_API_KEY i produktion.");
|
||||
console.error(
|
||||
"SÄKERHETSSTOPP: AAMOS_MODE=http kräver AAMOS_API_URL och AAMOS_API_KEY i produktion.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!cfg.OPS_TOKEN || cfg.OPS_TOKEN.length < 32 || cfg.OPS_TOKEN.startsWith("dev-ops-token")) {
|
||||
|
||||
+51
-14
@@ -48,7 +48,13 @@ export interface CompleteCookingResult {
|
||||
mealBoxId: string | null;
|
||||
mealBoxMutations: Array<{ mealBoxId: string; deltaPortions: number; frozen: boolean }>;
|
||||
inventoryDeductions: Array<{ itemId: string; quantity: number; unit: string; name: string }>;
|
||||
recipeIngredients: Array<{ canonicalIngredientId: string; displayName: string; quantity: number; unit: string; optional: boolean }>;
|
||||
recipeIngredients: Array<{
|
||||
canonicalIngredientId: string;
|
||||
displayName: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
optional: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,7 +72,8 @@ export async function completeCookingSession(
|
||||
): Promise<CompleteCookingResult & { session: typeof schema.cookingSessions.$inferSelect }> {
|
||||
const plannedPortions = input.portionsCooked ?? session.plannedPortions;
|
||||
const mealBoxPortions = input.mealBoxPortions ?? 0;
|
||||
const actualPortionsEaten = input.actualPortionsEaten ?? Math.max(0, plannedPortions - mealBoxPortions);
|
||||
const actualPortionsEaten =
|
||||
input.actualPortionsEaten ?? Math.max(0, plannedPortions - mealBoxPortions);
|
||||
const leftoverEstimatePortions = input.leftoverEstimatePortions ?? mealBoxPortions;
|
||||
|
||||
if (actualPortionsEaten + leftoverEstimatePortions > plannedPortions) {
|
||||
@@ -151,7 +158,11 @@ export async function completeCookingSession(
|
||||
sessionId: session.id,
|
||||
date,
|
||||
},
|
||||
existing ?? { averageEatenPortions: null, averageLeftoverPortions: null, observationCount: 0 },
|
||||
existing ?? {
|
||||
averageEatenPortions: null,
|
||||
averageLeftoverPortions: null,
|
||||
observationCount: 0,
|
||||
},
|
||||
);
|
||||
|
||||
await app.db
|
||||
@@ -227,7 +238,9 @@ export async function completeCookingSessionCore(
|
||||
actualPortionsEaten + leftoverEstimatePortions,
|
||||
);
|
||||
const factor = consumptionPortions / recipe.portions;
|
||||
const overrides = new Map(input.inventoryOverrides?.map((o) => [o.canonicalIngredientId, o]) ?? []);
|
||||
const overrides = new Map(
|
||||
input.inventoryOverrides?.map((o) => [o.canonicalIngredientId, o]) ?? [],
|
||||
);
|
||||
|
||||
for (const ing of recipe.ingredients) {
|
||||
if (ing.optional) continue;
|
||||
@@ -307,9 +320,7 @@ export async function completeCookingSessionCore(
|
||||
|
||||
// 2. Måltider
|
||||
const eaters =
|
||||
input.eaters && input.eaters.length > 0
|
||||
? input.eaters
|
||||
: [{ userId, portionFraction: 1 }];
|
||||
input.eaters && input.eaters.length > 0 ? input.eaters : [{ userId, portionFraction: 1 }];
|
||||
const mealIds: string[] = [];
|
||||
for (const eater of eaters) {
|
||||
const nutrition = scaleNutrition(recipe.nutritionPerPortion, eater.portionFraction);
|
||||
@@ -396,7 +407,11 @@ export async function completeCookingSessionCore(
|
||||
})
|
||||
.where(eq(schema.mealBoxes.id, existingBox.id));
|
||||
mealBoxId = existingBox.id;
|
||||
mealBoxMutations.push({ mealBoxId: existingBox.id, deltaPortions: leftoverEstimatePortionsForBox, frozen });
|
||||
mealBoxMutations.push({
|
||||
mealBoxId: existingBox.id,
|
||||
deltaPortions: leftoverEstimatePortionsForBox,
|
||||
frozen,
|
||||
});
|
||||
await emitEvent(app.db, {
|
||||
type: "MEAL_BOX_UPDATED",
|
||||
payload: {
|
||||
@@ -427,7 +442,11 @@ export async function completeCookingSessionCore(
|
||||
})
|
||||
.returning();
|
||||
mealBoxId = box!.id;
|
||||
mealBoxMutations.push({ mealBoxId: box!.id, deltaPortions: leftoverEstimatePortionsForBox, frozen });
|
||||
mealBoxMutations.push({
|
||||
mealBoxId: box!.id,
|
||||
deltaPortions: leftoverEstimatePortionsForBox,
|
||||
frozen,
|
||||
});
|
||||
await emitEvent(app.db, {
|
||||
type: "MEAL_BOX_CREATED",
|
||||
payload: { mealBoxId: box!.id, portions: leftoverEstimatePortionsForBox },
|
||||
@@ -468,7 +487,11 @@ export async function completeCookingSessionCore(
|
||||
.where(eq(schema.recipes.id, session.recipeId));
|
||||
await emitEvent(app.db, {
|
||||
type: "RECIPE_COOKED",
|
||||
payload: { recipeId: session.recipeId, portions: portionsCooked, mealBoxPortions: input.mealBoxPortions ?? 0 },
|
||||
payload: {
|
||||
recipeId: session.recipeId,
|
||||
portions: portionsCooked,
|
||||
mealBoxPortions: input.mealBoxPortions ?? 0,
|
||||
},
|
||||
userId,
|
||||
householdId,
|
||||
correlationId,
|
||||
@@ -578,7 +601,11 @@ export async function undoCookingSession(
|
||||
// 2. Återställ inventory-saldon från transaktionerna.
|
||||
for (const itemId of affectedItemIds) {
|
||||
const txs = await app.db
|
||||
.select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit })
|
||||
.select({
|
||||
type: schema.inventoryTransactions.type,
|
||||
quantityDelta: schema.inventoryTransactions.quantityDelta,
|
||||
unit: schema.inventoryTransactions.unit,
|
||||
})
|
||||
.from(schema.inventoryTransactions)
|
||||
.where(eq(schema.inventoryTransactions.inventoryItemId, itemId));
|
||||
const balance = computeBalance(txs);
|
||||
@@ -631,7 +658,11 @@ export async function undoCookingSession(
|
||||
discardedMealBoxIds.push(box.id);
|
||||
await emitEvent(app.db, {
|
||||
type: "MEAL_BOX_DISCARDED",
|
||||
payload: { mealBoxId: box.id, portions: mutation.deltaPortions, source: "cooking_session_undo" },
|
||||
payload: {
|
||||
mealBoxId: box.id,
|
||||
portions: mutation.deltaPortions,
|
||||
source: "cooking_session_undo",
|
||||
},
|
||||
userId,
|
||||
householdId: session.householdId,
|
||||
correlationId,
|
||||
@@ -643,7 +674,11 @@ export async function undoCookingSession(
|
||||
.where(eq(schema.mealBoxes.id, box.id));
|
||||
await emitEvent(app.db, {
|
||||
type: "MEAL_BOX_UPDATED",
|
||||
payload: { mealBoxId: box.id, addedPortions: -mutation.deltaPortions, totalPortions: newPortions },
|
||||
payload: {
|
||||
mealBoxId: box.id,
|
||||
addedPortions: -mutation.deltaPortions,
|
||||
totalPortions: newPortions,
|
||||
},
|
||||
userId,
|
||||
householdId: session.householdId,
|
||||
correlationId,
|
||||
@@ -673,7 +708,9 @@ export async function undoCookingSession(
|
||||
}
|
||||
|
||||
// 5. Ta bort recipe_cooks-raden och backa cookCount.
|
||||
await app.db.delete(schema.recipeCooks).where(eq(schema.recipeCooks.cookingSessionId, session.id));
|
||||
await app.db
|
||||
.delete(schema.recipeCooks)
|
||||
.where(eq(schema.recipeCooks.cookingSessionId, session.id));
|
||||
await app.db
|
||||
.update(schema.recipes)
|
||||
.set({ cookCount: sql`GREATEST(${schema.recipes.cookCount} - 1, 0)` })
|
||||
|
||||
@@ -155,7 +155,10 @@ export function newCorrelationId(): string {
|
||||
* funktionen returnerar alltid en giltig profil (default fallback). */
|
||||
export async function getActiveDecayProfile(db: Database): Promise<DecayProfile> {
|
||||
const [profile] = await db
|
||||
.select({ halfLifeDays: schema.inventoryDecayProfiles.halfLifeDays, staleAfterDays: schema.inventoryDecayProfiles.staleAfterDays })
|
||||
.select({
|
||||
halfLifeDays: schema.inventoryDecayProfiles.halfLifeDays,
|
||||
staleAfterDays: schema.inventoryDecayProfiles.staleAfterDays,
|
||||
})
|
||||
.from(schema.inventoryDecayProfiles)
|
||||
.where(eq(schema.inventoryDecayProfiles.active, true))
|
||||
.orderBy(schema.inventoryDecayProfiles.createdAt)
|
||||
|
||||
@@ -212,7 +212,10 @@ function formatList(items: SearchResultItem[], languageTag: string): string {
|
||||
const loc = formatLocation(item, languageTag);
|
||||
return `• ${item.displayName} ${loc}`;
|
||||
});
|
||||
const prefix = resolve(MULTI_RESULT_PREFIX, languageTag).replace("{{count}}", String(items.length));
|
||||
const prefix = resolve(MULTI_RESULT_PREFIX, languageTag).replace(
|
||||
"{{count}}",
|
||||
String(items.length),
|
||||
);
|
||||
const suffix = resolve(MULTI_RESULT_SUFFIX, languageTag);
|
||||
return `${prefix}\n${parts.join("\n")}\n${suffix}`;
|
||||
}
|
||||
@@ -221,10 +224,7 @@ function formatList(items: SearchResultItem[], languageTag: string): string {
|
||||
* Bygg ett naturligt-språkligt svar för fritextsökning i hushållslagret.
|
||||
* Ingen data fabriceras – allt som visas kommer från `items`.
|
||||
*/
|
||||
export function buildNaturalSearchResponse(
|
||||
items: SearchResultItem[],
|
||||
languageTag: string,
|
||||
): string {
|
||||
export function buildNaturalSearchResponse(items: SearchResultItem[], languageTag: string): string {
|
||||
if (items.length === 0) {
|
||||
return resolve(ZERO_RESULTS, languageTag);
|
||||
}
|
||||
|
||||
@@ -57,14 +57,28 @@ export interface MemoryImpactResult {
|
||||
* andra hushållsmedlemmars personliga data blir aldrig lästa eller returnerade.
|
||||
* - Återanvänder rankAll/scoreCandidate från recommendation-engine.
|
||||
*/
|
||||
export async function computeMemoryImpact(options: MemoryImpactOptions): Promise<MemoryImpactResult> {
|
||||
const { db, userId, memoryItem, mealType = "dinner", persons, maxMinutes, craving, view = "default", limit = 10 } = options;
|
||||
export async function computeMemoryImpact(
|
||||
options: MemoryImpactOptions,
|
||||
): Promise<MemoryImpactResult> {
|
||||
const {
|
||||
db,
|
||||
userId,
|
||||
memoryItem,
|
||||
mealType = "dinner",
|
||||
persons,
|
||||
maxMinutes,
|
||||
craving,
|
||||
view = "default",
|
||||
limit = 10,
|
||||
} = options;
|
||||
|
||||
// R2: personalization-samtycke krävs för impact.
|
||||
const [consent] = await db
|
||||
.select()
|
||||
.from(schema.userConsents)
|
||||
.where(and(eq(schema.userConsents.userId, userId), eq(schema.userConsents.kind, "personalization")))
|
||||
.where(
|
||||
and(eq(schema.userConsents.userId, userId), eq(schema.userConsents.kind, "personalization")),
|
||||
)
|
||||
.limit(1);
|
||||
if (consent?.status !== "granted") {
|
||||
return { memoryItemId: memoryItem.id, personalizationEnabled: false, impacted: [] };
|
||||
|
||||
@@ -141,11 +141,7 @@ export async function syncOnboardingMemory(
|
||||
origin: ONBOARDING_ORIGIN,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
schema.tasteSignals.userId,
|
||||
schema.tasteSignals.axis,
|
||||
schema.tasteSignals.target,
|
||||
],
|
||||
target: [schema.tasteSignals.userId, schema.tasteSignals.axis, schema.tasteSignals.target],
|
||||
set: {
|
||||
direction: signal.direction,
|
||||
strength: TASTE_STRENGTH,
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import fp from "fastify-plugin";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import {
|
||||
MockStorage,
|
||||
AwsStorage,
|
||||
type StorageService,
|
||||
createStorageService,
|
||||
} from "@app/storage";
|
||||
import { MockStorage, AwsStorage, type StorageService, createStorageService } from "@app/storage";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
|
||||
@@ -21,7 +21,9 @@ export async function adminAnalyticsRoutes(app: FastifyInstance) {
|
||||
|
||||
/** Compute a funnel between two UTC dates (YYYY-MM-DD). */
|
||||
app.get("/admin/v1/analytics/funnels/:name", admin, async (req) => {
|
||||
const params = z.object({ name: z.enum(Object.keys(FUNNELS) as [string, ...string[]]) }).parse(req.params);
|
||||
const params = z
|
||||
.object({ name: z.enum(Object.keys(FUNNELS) as [string, ...string[]]) })
|
||||
.parse(req.params);
|
||||
const query = z
|
||||
.object({
|
||||
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
@@ -69,7 +71,10 @@ export async function adminAnalyticsRoutes(app: FastifyInstance) {
|
||||
|
||||
const querySql = `WITH ${ctes.join(", ")} SELECT ${selects.join(", ")}`;
|
||||
const result = await app.db.execute(sql.raw(querySql));
|
||||
const row = (Array.isArray(result) ? result[0] : (result.rows[0] ?? {})) as Record<string, number>;
|
||||
const row = (Array.isArray(result) ? result[0] : (result.rows[0] ?? {})) as Record<
|
||||
string,
|
||||
number
|
||||
>;
|
||||
|
||||
const stepCounts = steps.map((event, i) => ({
|
||||
step: i + 1,
|
||||
@@ -97,7 +102,10 @@ export async function adminAnalyticsRoutes(app: FastifyInstance) {
|
||||
const query = z
|
||||
.object({
|
||||
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
endDate: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.optional(),
|
||||
})
|
||||
.parse(req.query);
|
||||
|
||||
@@ -147,7 +155,12 @@ export async function adminAnalyticsRoutes(app: FastifyInstance) {
|
||||
string,
|
||||
{ cohortSize: number; retention: Array<{ day: number; active: number; rate: number }> }
|
||||
> = {};
|
||||
for (const r of rows as Array<{ cohort_date: string; day: number; cohort_size: number; active_users: number }>) {
|
||||
for (const r of rows as Array<{
|
||||
cohort_date: string;
|
||||
day: number;
|
||||
cohort_size: number;
|
||||
active_users: number;
|
||||
}>) {
|
||||
const key = String(r.cohort_date).slice(0, 10);
|
||||
if (!cohorts[key]) {
|
||||
cohorts[key] = { cohortSize: Number(r.cohort_size), retention: [] };
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { schema, evaluateReleaseGates, seedReleaseGates, summarizeReleaseGates } from "@app/database";
|
||||
import {
|
||||
schema,
|
||||
evaluateReleaseGates,
|
||||
seedReleaseGates,
|
||||
summarizeReleaseGates,
|
||||
} from "@app/database";
|
||||
import { releaseGateUpdateSchema } from "@app/validation";
|
||||
import { z } from "zod";
|
||||
import { errors, parse } from "../lib/errors.js";
|
||||
@@ -40,7 +45,10 @@ export async function adminReleaseGateRoutes(app: FastifyInstance) {
|
||||
|
||||
/** List current gate definitions without re-evaluating. */
|
||||
app.get("/admin/v1/release-gates", admin, async () => {
|
||||
const gates = await app.db.select().from(schema.releaseGates).orderBy(schema.releaseGates.category, schema.releaseGates.gateKey);
|
||||
const gates = await app.db
|
||||
.select()
|
||||
.from(schema.releaseGates)
|
||||
.orderBy(schema.releaseGates.category, schema.releaseGates.gateKey);
|
||||
const summary = summarizeReleaseGates(
|
||||
gates.map((g) => ({
|
||||
gateKey: g.gateKey,
|
||||
|
||||
@@ -64,7 +64,9 @@ export async function adminWorkersRoutes(app: FastifyInstance) {
|
||||
const { id } = req.params as { id: string };
|
||||
const job = await Job.fromId(dlq, id);
|
||||
if (!job) {
|
||||
return reply.status(404).send({ error: { code: "NOT_FOUND", message: "Jobbet finns inte i dead letter queue." } });
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ error: { code: "NOT_FOUND", message: "Jobbet finns inte i dead letter queue." } });
|
||||
}
|
||||
|
||||
const mainQueue = new Queue(JOB_QUEUE_NAME, { connection: app.redis });
|
||||
@@ -82,7 +84,9 @@ export async function adminWorkersRoutes(app: FastifyInstance) {
|
||||
const { id } = req.params as { id: string };
|
||||
const job = await Job.fromId(dlq, id);
|
||||
if (!job) {
|
||||
return reply.status(404).send({ error: { code: "NOT_FOUND", message: "Jobbet finns inte i dead letter queue." } });
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ error: { code: "NOT_FOUND", message: "Jobbet finns inte i dead letter queue." } });
|
||||
}
|
||||
await job.remove();
|
||||
return reply.send({ ok: true, message: "Jobbet har tagits bort från dead letter queue." });
|
||||
|
||||
@@ -24,7 +24,8 @@ export async function analyticsRoutes(app: FastifyInstance) {
|
||||
rateLimit: {
|
||||
max: ANALYTICS_RATE_LIMIT_MAX,
|
||||
timeWindow: "1 minute",
|
||||
keyGenerator: (req) => `analytics:${req.ip}:${(req as unknown as { userId?: string }).userId ?? "anon"}`,
|
||||
keyGenerator: (req) =>
|
||||
`analytics:${req.ip}:${(req as unknown as { userId?: string }).userId ?? "anon"}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -103,7 +103,11 @@ export async function cookingSessionRoutes(app: FastifyInstance) {
|
||||
const params = z.object({ id: z.uuid() }).parse(req.params);
|
||||
const body = z.object({ reason: z.string().max(200).optional() }).parse(req.body ?? {});
|
||||
const session = await getOwnedSession(app, params.id, req.userId);
|
||||
if (session.status === "completed" || session.status === "cancelled" || session.status === "undone") {
|
||||
if (
|
||||
session.status === "completed" ||
|
||||
session.status === "cancelled" ||
|
||||
session.status === "undone"
|
||||
) {
|
||||
throw errors.conflict("Sessionen är redan avslutad.");
|
||||
}
|
||||
|
||||
|
||||
@@ -461,7 +461,7 @@ export async function inventoryRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
const trustState = computeTrust({
|
||||
confidence: updates.confidence as number | undefined ?? item.confidence,
|
||||
confidence: (updates.confidence as number | undefined) ?? item.confidence,
|
||||
verifiedByUser: (updates.verifiedByUser as boolean | undefined) ?? item.verifiedByUser,
|
||||
lastVerifiedAt: (updates.lastVerifiedAt as Date | null | undefined) ?? item.lastVerifiedAt,
|
||||
quantity: (updates.quantity as number | undefined) ?? item.quantity,
|
||||
|
||||
@@ -173,7 +173,9 @@ export async function meRoutes(app: FastifyInstance) {
|
||||
const [row] = await app.db
|
||||
.insert(schema.userTermsConsents)
|
||||
.values({ userId: req.userId, termsVersion: TERMS_VERSION, acceptedAt: now })
|
||||
.onConflictDoNothing({ target: [schema.userTermsConsents.userId, schema.userTermsConsents.termsVersion] })
|
||||
.onConflictDoNothing({
|
||||
target: [schema.userTermsConsents.userId, schema.userTermsConsents.termsVersion],
|
||||
})
|
||||
.returning();
|
||||
return {
|
||||
accepted: true,
|
||||
|
||||
@@ -4,11 +4,7 @@ import { z } from "zod";
|
||||
import { schema } from "@app/database";
|
||||
import { quickStartInputSchema, onboardingInputSchema } from "@app/validation";
|
||||
import { errors, parse } from "../lib/errors.js";
|
||||
import {
|
||||
audit,
|
||||
createHouseholdWithDefaults,
|
||||
getActiveHouseholdId,
|
||||
} from "../lib/helpers.js";
|
||||
import { audit, createHouseholdWithDefaults, getActiveHouseholdId } from "../lib/helpers.js";
|
||||
|
||||
import { t } from "../lib/i18n.js";
|
||||
import { KNOWN_FLAGS } from "@app/feature-flags";
|
||||
@@ -70,11 +66,7 @@ export async function onboardingRoutes(app: FastifyInstance) {
|
||||
const input = parse(quickStartInputSchema, req.body);
|
||||
|
||||
// Normalize goals: multi-select UI sends `goals`; legacy clients send `primaryGoal`.
|
||||
const goals = input.goals?.length
|
||||
? input.goals
|
||||
: input.primaryGoal
|
||||
? [input.primaryGoal]
|
||||
: [];
|
||||
const goals = input.goals?.length ? input.goals : input.primaryGoal ? [input.primaryGoal] : [];
|
||||
const primaryGoal = goals[0];
|
||||
|
||||
// Upsert minimal preferences
|
||||
@@ -262,10 +254,7 @@ export async function onboardingRoutes(app: FastifyInstance) {
|
||||
|
||||
/** POST /v1/onboarding/skip – skip remaining steps (GDPR-friendly, user's choice). */
|
||||
app.post("/v1/onboarding/skip", auth, async (req) => {
|
||||
const body = parse(
|
||||
z.object({ step: z.enum(["b", "c"]).optional() }),
|
||||
req.body,
|
||||
);
|
||||
const body = parse(z.object({ step: z.enum(["b", "c"]).optional() }), req.body);
|
||||
|
||||
const [user] = await app.db
|
||||
.update(schema.users)
|
||||
|
||||
@@ -433,9 +433,16 @@ export async function recipeRoutes(app: FastifyInstance) {
|
||||
})
|
||||
.returning();
|
||||
|
||||
const result = await completeCookingSession(app, session!, req.userId, input, req.correlationId, {
|
||||
emitStartedEvent: true,
|
||||
});
|
||||
const result = await completeCookingSession(
|
||||
app,
|
||||
session!,
|
||||
req.userId,
|
||||
input,
|
||||
req.correlationId,
|
||||
{
|
||||
emitStartedEvent: true,
|
||||
},
|
||||
);
|
||||
|
||||
return { sessionId: session!.id, ...result };
|
||||
});
|
||||
|
||||
@@ -4,11 +4,13 @@ import { schema } from "@app/database";
|
||||
import { z } from "zod";
|
||||
import { buildReconciliationCandidates, classifyExpiry, computeTrust } from "@app/inventory-engine";
|
||||
import { errors, parse } from "../lib/errors.js";
|
||||
import { getActiveDecayProfile, requireActiveHousehold, requireMembership, trackProductAnalytics } from "../lib/helpers.js";
|
||||
import {
|
||||
reconciliationResolveInputSchema,
|
||||
reconciliationStartInputSchema,
|
||||
} from "@app/validation";
|
||||
getActiveDecayProfile,
|
||||
requireActiveHousehold,
|
||||
requireMembership,
|
||||
trackProductAnalytics,
|
||||
} from "../lib/helpers.js";
|
||||
import { reconciliationResolveInputSchema, reconciliationStartInputSchema } from "@app/validation";
|
||||
import { inventoryReconciliationCompleted, inventoryReconciliationStarted } from "@app/analytics";
|
||||
|
||||
/** Quick Reconciliation (Fas 2 §5.4) */
|
||||
@@ -63,10 +65,15 @@ export async function reconciliationRoutes(app: FastifyInstance) {
|
||||
);
|
||||
|
||||
if (upcomingEntries.length > 0) {
|
||||
const recipeIds = [...new Set(upcomingEntries.map((m) => m.recipeId).filter(Boolean))] as string[];
|
||||
const recipeIds = [
|
||||
...new Set(upcomingEntries.map((m) => m.recipeId).filter(Boolean)),
|
||||
] as string[];
|
||||
if (recipeIds.length > 0) {
|
||||
const ingredients = await app.db
|
||||
.select({ recipeId: schema.recipeIngredients.recipeId, canonicalId: schema.recipeIngredients.canonicalIngredientId })
|
||||
.select({
|
||||
recipeId: schema.recipeIngredients.recipeId,
|
||||
canonicalId: schema.recipeIngredients.canonicalIngredientId,
|
||||
})
|
||||
.from(schema.recipeIngredients)
|
||||
.where(inArray(schema.recipeIngredients.recipeId, recipeIds));
|
||||
for (const ing of ingredients) {
|
||||
@@ -255,7 +262,11 @@ export async function reconciliationRoutes(app: FastifyInstance) {
|
||||
req.userId,
|
||||
inventoryReconciliationCompleted({
|
||||
householdId,
|
||||
properties: { itemId: params.itemId, action: input.action, hadAdjustment: quantityChange !== 0 },
|
||||
properties: {
|
||||
itemId: params.itemId,
|
||||
action: input.action,
|
||||
hadAdjustment: quantityChange !== 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -5,7 +5,11 @@ import { diffScans, type ScanDiffRow, computeBalance } from "@app/inventory-engi
|
||||
import { z } from "zod";
|
||||
import type { Unit } from "@app/shared-types";
|
||||
import { errors, parse } from "../lib/errors.js";
|
||||
import { requireActiveHousehold, requireMembership, trackProductAnalytics } from "../lib/helpers.js";
|
||||
import {
|
||||
requireActiveHousehold,
|
||||
requireMembership,
|
||||
trackProductAnalytics,
|
||||
} from "../lib/helpers.js";
|
||||
import { scanDiffApplyInputSchema } from "@app/validation";
|
||||
import { inventoryConflictCreated, inventoryConflictResolved } from "@app/analytics";
|
||||
|
||||
@@ -25,7 +29,11 @@ export async function scanDiffRoutes(app: FastifyInstance) {
|
||||
const householdId = await requireActiveHousehold(app.db, req.userId);
|
||||
await requireMembership(app.db, householdId, req.userId);
|
||||
|
||||
const [job] = await app.db.select().from(schema.scanJobs).where(eq(schema.scanJobs.id, params.id)).limit(1);
|
||||
const [job] = await app.db
|
||||
.select()
|
||||
.from(schema.scanJobs)
|
||||
.where(eq(schema.scanJobs.id, params.id))
|
||||
.limit(1);
|
||||
if (!job || job.userId !== req.userId) throw errors.notFound("Skanningen finns inte.");
|
||||
if (!job.result) throw errors.badRequest("Skanningen har inget resultat än.");
|
||||
|
||||
@@ -57,7 +65,10 @@ export async function scanDiffRoutes(app: FastifyInstance) {
|
||||
kind: row.kind,
|
||||
displayName: row.displayName,
|
||||
previousItemId: row.previousItemId,
|
||||
proposedResolution: row.kind === "vanished" ? "depleted" : { quantity: row.newQuantity, locationId: row.newLocationId },
|
||||
proposedResolution:
|
||||
row.kind === "vanished"
|
||||
? "depleted"
|
||||
: { quantity: row.newQuantity, locationId: row.newLocationId },
|
||||
}));
|
||||
|
||||
return {
|
||||
@@ -96,14 +107,19 @@ export async function scanDiffRoutes(app: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
const [job] = await app.db.select().from(schema.scanJobs).where(eq(schema.scanJobs.id, params.id)).limit(1);
|
||||
const [job] = await app.db
|
||||
.select()
|
||||
.from(schema.scanJobs)
|
||||
.where(eq(schema.scanJobs.id, params.id))
|
||||
.limit(1);
|
||||
if (!job || job.userId !== req.userId) throw errors.notFound("Skanningen finns inte.");
|
||||
|
||||
const appliedItemIds: string[] = [];
|
||||
|
||||
for (const row of input.rows) {
|
||||
if (row.kind === "new_item") {
|
||||
const locationId = row.newLocationId ?? (await defaultLocation(app, householdId, job.scanType));
|
||||
const locationId =
|
||||
row.newLocationId ?? (await defaultLocation(app, householdId, job.scanType));
|
||||
if (!locationId) continue;
|
||||
const [inv] = await app.db
|
||||
.insert(schema.inventoryItems)
|
||||
@@ -219,7 +235,11 @@ export async function scanDiffRoutes(app: FastifyInstance) {
|
||||
.where(eq(schema.inventoryItems.id, itemId))
|
||||
.limit(1);
|
||||
const txs = await app.db
|
||||
.select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit })
|
||||
.select({
|
||||
type: schema.inventoryTransactions.type,
|
||||
quantityDelta: schema.inventoryTransactions.quantityDelta,
|
||||
unit: schema.inventoryTransactions.unit,
|
||||
})
|
||||
.from(schema.inventoryTransactions)
|
||||
.where(eq(schema.inventoryTransactions.inventoryItemId, itemId));
|
||||
const balance = computeBalance(txs);
|
||||
@@ -253,7 +273,12 @@ export async function scanDiffRoutes(app: FastifyInstance) {
|
||||
const conflicts = await app.db
|
||||
.select()
|
||||
.from(schema.inventoryConflicts)
|
||||
.where(and(eq(schema.inventoryConflicts.householdId, householdId), eq(schema.inventoryConflicts.status, "open")))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.inventoryConflicts.householdId, householdId),
|
||||
eq(schema.inventoryConflicts.status, "open"),
|
||||
),
|
||||
)
|
||||
.orderBy(schema.inventoryConflicts.createdAt);
|
||||
return { conflicts };
|
||||
});
|
||||
@@ -278,7 +303,8 @@ export async function scanDiffRoutes(app: FastifyInstance) {
|
||||
.from(schema.inventoryConflicts)
|
||||
.where(eq(schema.inventoryConflicts.id, params.id))
|
||||
.limit(1);
|
||||
if (!conflict || conflict.householdId !== householdId) throw errors.notFound("Konflikten finns inte.");
|
||||
if (!conflict || conflict.householdId !== householdId)
|
||||
throw errors.notFound("Konflikten finns inte.");
|
||||
if (conflict.status !== "open") throw errors.badRequest("Konflikten är redan löst.");
|
||||
|
||||
const resolved = resolveConflictPayload(conflict, body);
|
||||
@@ -349,7 +375,11 @@ function extractObservations(result: Record<string, unknown> | unknown[]): Array
|
||||
useByDate?: string | null;
|
||||
observationConfidence: number;
|
||||
}> {
|
||||
const raw = Array.isArray(result) ? result : Array.isArray(result.items) ? (result.items as unknown[]) : [];
|
||||
const raw = Array.isArray(result)
|
||||
? result
|
||||
: Array.isArray(result.items)
|
||||
? (result.items as unknown[])
|
||||
: [];
|
||||
return raw
|
||||
.map((r) => {
|
||||
const item = r as Record<string, unknown>;
|
||||
@@ -382,18 +412,31 @@ function scanSource(scanType: string) {
|
||||
|
||||
async function defaultLocation(app: FastifyInstance, householdId: string, scanType: string) {
|
||||
const wanted =
|
||||
scanType === "freezer" ? "freezer" : scanType === "pantry" || scanType === "ingredients" ? "pantry" : "fridge";
|
||||
scanType === "freezer"
|
||||
? "freezer"
|
||||
: scanType === "pantry" || scanType === "ingredients"
|
||||
? "pantry"
|
||||
: "fridge";
|
||||
const [loc] = await app.db
|
||||
.select({ id: schema.storageLocations.id })
|
||||
.from(schema.storageLocations)
|
||||
.where(and(eq(schema.storageLocations.householdId, householdId), eq(schema.storageLocations.type, wanted)))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.storageLocations.householdId, householdId),
|
||||
eq(schema.storageLocations.type, wanted),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return loc?.id ?? null;
|
||||
}
|
||||
|
||||
function resolveConflictPayload(
|
||||
conflict: typeof schema.inventoryConflicts.$inferSelect,
|
||||
body: { resolution: "source_a" | "source_b" | "manual"; manualQuantity?: number; manualLocationId?: string },
|
||||
body: {
|
||||
resolution: "source_a" | "source_b" | "manual";
|
||||
manualQuantity?: number;
|
||||
manualLocationId?: string;
|
||||
},
|
||||
): { quantity?: number; locationId?: string; status?: string } {
|
||||
const a = (conflict.payloadA ?? {}) as Record<string, unknown>;
|
||||
const b = (conflict.payloadB ?? {}) as Record<string, unknown>;
|
||||
|
||||
Reference in New Issue
Block a user