Fas 2 steg 4: Quick Reconciliation (motor, API, app + 12-språks i18n)
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@app/ai-contracts": "workspace:*",
|
||||
"@app/analytics": "workspace:*",
|
||||
"@app/connectors": "workspace:*",
|
||||
"@app/database": "workspace:*",
|
||||
"@app/events": "workspace:*",
|
||||
@@ -48,6 +49,7 @@
|
||||
"tsup": {
|
||||
"noExternal": [
|
||||
"@app/ai-contracts",
|
||||
"@app/analytics",
|
||||
"@app/connectors",
|
||||
"@app/database",
|
||||
"@app/events",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { Database } from "@app/database";
|
||||
import { schema } from "@app/database";
|
||||
import type { AnalyticsEvent } from "@app/analytics";
|
||||
import type { DecayProfile } from "@app/inventory-engine";
|
||||
import type { EventType } from "@app/shared-types";
|
||||
import type { NewDomainEvent } from "@app/events";
|
||||
@@ -117,6 +118,35 @@ export async function emitEvent<T extends EventType>(
|
||||
});
|
||||
}
|
||||
|
||||
/** Track product analytics server-side if user opted in. */
|
||||
export async function trackProductAnalytics(
|
||||
db: Database,
|
||||
userId: string,
|
||||
event: AnalyticsEvent,
|
||||
): Promise<void> {
|
||||
const optedIn = await db
|
||||
.select({ status: schema.userConsents.status })
|
||||
.from(schema.userConsents)
|
||||
.where(and(eq(schema.userConsents.userId, userId), eq(schema.userConsents.kind, "product_analytics")))
|
||||
.limit(1);
|
||||
if (optedIn[0] && optedIn[0].status !== "granted") return;
|
||||
|
||||
await db.insert(schema.productAnalyticsEvents).values({
|
||||
occurredAt: event.occurredAt ? new Date(event.occurredAt) : new Date(),
|
||||
receivedAt: new Date(),
|
||||
eventName: event.name,
|
||||
anonymousId: event.anonymousId ?? null,
|
||||
sessionId: event.sessionId ?? null,
|
||||
userId,
|
||||
householdId: event.householdId ?? null,
|
||||
appVersion: event.appVersion ?? null,
|
||||
platform: event.platform ?? null,
|
||||
locale: event.locale ?? null,
|
||||
experimentVariant: event.experimentVariant ?? null,
|
||||
properties: event.properties ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
/** Audit-logg (spec §56). */
|
||||
export async function audit(
|
||||
db: Database,
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { and, eq, gte, inArray, sql } from "drizzle-orm";
|
||||
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";
|
||||
import { inventoryReconciliationCompleted, inventoryReconciliationStarted } from "@app/analytics";
|
||||
|
||||
/** Quick Reconciliation (Fas 2 §5.4) */
|
||||
export async function reconciliationRoutes(app: FastifyInstance) {
|
||||
const auth = { preHandler: [app.authenticate] };
|
||||
|
||||
app.post("/v1/reconciliations/start", auth, async (req) => {
|
||||
const householdId = await requireActiveHousehold(app.db, req.userId);
|
||||
await requireMembership(app.db, householdId, req.userId);
|
||||
const input = parse(reconciliationStartInputSchema, req.body);
|
||||
|
||||
const decayProfile = await getActiveDecayProfile(app.db);
|
||||
|
||||
const items = await app.db
|
||||
.select({
|
||||
item: schema.inventoryItems,
|
||||
locationType: schema.storageLocations.type,
|
||||
locationName: schema.storageLocations.name,
|
||||
shelfLife: schema.canonicalIngredients.shelfLifeGuidance,
|
||||
})
|
||||
.from(schema.inventoryItems)
|
||||
.innerJoin(
|
||||
schema.storageLocations,
|
||||
eq(schema.inventoryItems.storageLocationId, schema.storageLocations.id),
|
||||
)
|
||||
.leftJoin(
|
||||
schema.canonicalIngredients,
|
||||
eq(schema.inventoryItems.canonicalIngredientId, schema.canonicalIngredients.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.inventoryItems.householdId, householdId),
|
||||
sql`${schema.inventoryItems.depletedAt} IS NULL`,
|
||||
sql`${schema.inventoryItems.quantity} > 0`,
|
||||
),
|
||||
);
|
||||
|
||||
const itemIds = items.map((r) => r.item.id);
|
||||
|
||||
// Ingredienser i planerade recept närmaste 7 dagarna
|
||||
const plannedRecipeIngredientIds = new Map<string, string[]>();
|
||||
const upcomingEntries = await app.db
|
||||
.select({ recipeId: schema.weekPlanEntries.recipeId, date: schema.weekPlanEntries.date })
|
||||
.from(schema.weekPlanEntries)
|
||||
.innerJoin(schema.weekPlans, eq(schema.weekPlanEntries.weekPlanId, schema.weekPlans.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.weekPlans.householdId, householdId),
|
||||
sql`${schema.weekPlanEntries.date} >= CURRENT_DATE`,
|
||||
sql`${schema.weekPlanEntries.date} <= CURRENT_DATE + INTERVAL '7 days'`,
|
||||
),
|
||||
);
|
||||
|
||||
if (upcomingEntries.length > 0) {
|
||||
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 })
|
||||
.from(schema.recipeIngredients)
|
||||
.where(inArray(schema.recipeIngredients.recipeId, recipeIds));
|
||||
for (const ing of ingredients) {
|
||||
if (!ing.canonicalId) continue;
|
||||
const list = plannedRecipeIngredientIds.get(ing.canonicalId) ?? [];
|
||||
if (!list.includes(ing.recipeId)) list.push(ing.recipeId);
|
||||
plannedRecipeIngredientIds.set(ing.canonicalId, list);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const priceMinorByItemId = new Map<string, number>();
|
||||
const dailyConsumptionRate = new Map<string, number>();
|
||||
const daysLeftByItemId = new Map<string, number | null>();
|
||||
|
||||
for (const r of items) {
|
||||
const expiry = classifyExpiry({
|
||||
bestBeforeDate: r.item.bestBeforeDate,
|
||||
useByDate: r.item.useByDate,
|
||||
openedAt: r.item.openedAt,
|
||||
frozenAt: r.item.frozenAt,
|
||||
thawedAt: r.item.thawedAt,
|
||||
purchasedAt: r.item.purchasedAt,
|
||||
storageLocationType: r.locationType,
|
||||
shelfLifeGuidance: r.shelfLife,
|
||||
});
|
||||
daysLeftByItemId.set(r.item.id, expiry.daysLeft);
|
||||
|
||||
if (r.item.priceMinor != null) {
|
||||
priceMinorByItemId.set(r.item.id, r.item.priceMinor);
|
||||
}
|
||||
|
||||
// Enkel heuristik: senaste 30 dagarnas genomsnittliga dagliga förbrukning
|
||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 86_400_000);
|
||||
const txAgg = await app.db
|
||||
.select({
|
||||
total: sql<number>`COALESCE(SUM(ABS(${schema.inventoryTransactions.quantityDelta})), 0)`,
|
||||
days: sql<number>`GREATEST(1, COUNT(DISTINCT DATE(${schema.inventoryTransactions.createdAt})))`,
|
||||
})
|
||||
.from(schema.inventoryTransactions)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.inventoryTransactions.inventoryItemId, r.item.id),
|
||||
eq(schema.inventoryTransactions.type, "consume"),
|
||||
sql`${schema.inventoryTransactions.createdAt} >= ${thirtyDaysAgo}`,
|
||||
),
|
||||
);
|
||||
const rate = Number(txAgg[0]?.total ?? 0) / Number(txAgg[0]?.days ?? 1);
|
||||
if (rate > 0) dailyConsumptionRate.set(r.item.id, rate);
|
||||
}
|
||||
|
||||
const mappedItems = items.map((r) => {
|
||||
const trust = computeTrust(
|
||||
{
|
||||
confidence: r.item.confidence,
|
||||
verifiedByUser: r.item.verifiedByUser,
|
||||
lastVerifiedAt: r.item.lastVerifiedAt,
|
||||
quantity: r.item.quantity,
|
||||
updatedAt: r.item.updatedAt,
|
||||
},
|
||||
new Date(),
|
||||
decayProfile,
|
||||
);
|
||||
return {
|
||||
id: r.item.id,
|
||||
displayName: r.item.displayName,
|
||||
quantity: r.item.quantity,
|
||||
unit: r.item.unit,
|
||||
locationName: r.locationName,
|
||||
confidence: r.item.confidence,
|
||||
verifiedByUser: r.item.verifiedByUser,
|
||||
lastVerifiedAt: r.item.lastVerifiedAt,
|
||||
updatedAt: r.item.updatedAt,
|
||||
depletedAt: r.item.depletedAt,
|
||||
canonicalIngredientId: r.item.canonicalIngredientId,
|
||||
trustState: trust.state,
|
||||
};
|
||||
});
|
||||
|
||||
const candidates = buildReconciliationCandidates(
|
||||
mappedItems,
|
||||
{
|
||||
plannedRecipeIngredientIds,
|
||||
daysLeftByItemId,
|
||||
dailyConsumptionRate,
|
||||
priceMinorByItemId,
|
||||
},
|
||||
new Date(),
|
||||
input.maxItems ?? 15,
|
||||
);
|
||||
|
||||
await trackProductAnalytics(
|
||||
app.db,
|
||||
req.userId,
|
||||
inventoryReconciliationStarted({
|
||||
householdId,
|
||||
properties: { candidateCount: candidates.length },
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
candidates: candidates.map((c) => ({
|
||||
itemId: c.itemId,
|
||||
displayName: c.displayName,
|
||||
quantity: c.quantity,
|
||||
unit: c.unit,
|
||||
locationName: c.locationName,
|
||||
reasons: c.reasons,
|
||||
suggestedAction: c.suggestedAction,
|
||||
suggestedQuantity: c.suggestedQuantity,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
app.post("/v1/reconciliations/items/:itemId/resolve", auth, async (req) => {
|
||||
const householdId = await requireActiveHousehold(app.db, req.userId);
|
||||
await requireMembership(app.db, householdId, req.userId);
|
||||
|
||||
const params = z.object({ itemId: z.uuid() }).parse(req.params);
|
||||
const input = parse(reconciliationResolveInputSchema, req.body);
|
||||
|
||||
const [item] = await app.db
|
||||
.select()
|
||||
.from(schema.inventoryItems)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.inventoryItems.id, params.itemId),
|
||||
eq(schema.inventoryItems.householdId, householdId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!item) throw errors.notFound("Varan finns inte.");
|
||||
|
||||
const now = new Date();
|
||||
const newQuantity = input.quantity;
|
||||
const quantityChange = newQuantity != null ? newQuantity - item.quantity : 0;
|
||||
|
||||
const update: Partial<typeof schema.inventoryItems.$inferInsert> = {
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
if (input.action === "exists") {
|
||||
update.verifiedByUser = true;
|
||||
update.lastVerifiedAt = now;
|
||||
update.depletedAt = null;
|
||||
if (newQuantity != null) update.quantity = newQuantity;
|
||||
} else if (input.action === "depleted") {
|
||||
update.quantity = 0;
|
||||
update.depletedAt = now;
|
||||
} else {
|
||||
// uncertain: bara registrera en adjustment om användaren justerat mängd
|
||||
if (newQuantity != null) update.quantity = newQuantity;
|
||||
}
|
||||
|
||||
const [updated] = await app.db
|
||||
.update(schema.inventoryItems)
|
||||
.set(update)
|
||||
.where(eq(schema.inventoryItems.id, params.itemId))
|
||||
.returning();
|
||||
if (!updated) throw errors.internal("Kunde inte uppdatera varan.");
|
||||
|
||||
if (input.action === "exists" || quantityChange !== 0) {
|
||||
await app.db.insert(schema.inventoryTransactions).values({
|
||||
inventoryItemId: params.itemId,
|
||||
householdId,
|
||||
actorUserId: req.userId,
|
||||
type: input.action === "exists" ? "correction" : "adjust",
|
||||
quantityDelta: quantityChange,
|
||||
unit: item.unit,
|
||||
note: input.note,
|
||||
});
|
||||
}
|
||||
|
||||
await trackProductAnalytics(
|
||||
app.db,
|
||||
req.userId,
|
||||
inventoryReconciliationCompleted({
|
||||
householdId,
|
||||
properties: { itemId: params.itemId, action: input.action, hadAdjustment: quantityChange !== 0 },
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
itemId: params.itemId,
|
||||
action: input.action,
|
||||
quantity: updated.quantity,
|
||||
verifiedByUser: updated.verifiedByUser,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { mealRoutes } from "./routes/meals.js";
|
||||
import { shoppingRoutes } from "./routes/shopping.js";
|
||||
import { planningRoutes } from "./routes/planning.js";
|
||||
import { recommendationRoutes } from "./routes/recommendations.js";
|
||||
import { reconciliationRoutes } from "./routes/reconciliations.js";
|
||||
import { memoryRoutes } from "./routes/memory.js";
|
||||
import { budgetRoutes } from "./routes/budget.js";
|
||||
import { subscriptionRoutes } from "./routes/subscriptions.js";
|
||||
@@ -81,6 +82,7 @@ export async function buildServer(config: AppConfig) {
|
||||
await app.register(shoppingRoutes);
|
||||
await app.register(planningRoutes);
|
||||
await app.register(recommendationRoutes);
|
||||
await app.register(reconciliationRoutes);
|
||||
await app.register(memoryRoutes);
|
||||
await app.register(budgetRoutes);
|
||||
await app.register(subscriptionRoutes);
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import "./setup-env.js";
|
||||
import { describe, expect, it, beforeAll, afterAll } from "vitest";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { buildServer } from "../src/server.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { createDatabase, closeDatabase, schema } from "@app/database";
|
||||
|
||||
describe("quick reconciliation", () => {
|
||||
const testDb = createDatabase(process.env.TEST_DATABASE_URL!);
|
||||
const config = loadConfig();
|
||||
let app: Awaited<ReturnType<typeof buildServer>>;
|
||||
let token: string;
|
||||
let householdId: string;
|
||||
const email = "recon-test@example.invalid";
|
||||
|
||||
async function cleanup() {
|
||||
const existing = await testDb.db
|
||||
.select({ id: schema.users.id })
|
||||
.from(schema.users)
|
||||
.where(inArray(schema.users.email, [email]));
|
||||
for (const u of existing) {
|
||||
const memberships = await testDb.db
|
||||
.select({ householdId: schema.householdMembers.householdId })
|
||||
.from(schema.householdMembers)
|
||||
.where(eq(schema.householdMembers.userId, u.id));
|
||||
for (const m of memberships) {
|
||||
const items = await testDb.db
|
||||
.select({ id: schema.inventoryItems.id })
|
||||
.from(schema.inventoryItems)
|
||||
.where(eq(schema.inventoryItems.householdId, m.householdId));
|
||||
for (const it of items) {
|
||||
await testDb.db.delete(schema.inventoryTransactions).where(eq(schema.inventoryTransactions.inventoryItemId, it.id));
|
||||
}
|
||||
await testDb.db.delete(schema.inventoryItems).where(eq(schema.inventoryItems.householdId, m.householdId));
|
||||
await testDb.db.delete(schema.storageLocations).where(eq(schema.storageLocations.householdId, m.householdId));
|
||||
await testDb.db.delete(schema.householdMembers).where(eq(schema.householdMembers.householdId, m.householdId));
|
||||
await testDb.db.delete(schema.households).where(eq(schema.households.id, m.householdId));
|
||||
}
|
||||
await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id));
|
||||
await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id));
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanup();
|
||||
app = await buildServer(config);
|
||||
await app.ready();
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/auth/register",
|
||||
payload: { email, password: "Password123!", displayName: "Recon Test" },
|
||||
});
|
||||
const body = JSON.parse(res.body) as { accessToken: string };
|
||||
token = body.accessToken;
|
||||
|
||||
const quick = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/onboarding/quick-start",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { goals: ["less_waste"], precisionMode: "simple" },
|
||||
});
|
||||
householdId = (JSON.parse(quick.body) as { householdId: string }).householdId;
|
||||
|
||||
// Skapa en vara att avstämma
|
||||
const locations = await testDb.db
|
||||
.select({ id: schema.storageLocations.id })
|
||||
.from(schema.storageLocations)
|
||||
.where(eq(schema.storageLocations.householdId, householdId));
|
||||
const [location] = locations;
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/inventory/items",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
displayName: "Mjölk",
|
||||
quantity: 1,
|
||||
unit: "LITER",
|
||||
storageLocationId: location!.id,
|
||||
bestBeforeDate: new Date(Date.now() + 2 * 86_400_000).toISOString().slice(0, 10),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
await closeDatabase();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("starts reconciliation and returns candidates with reasons", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/reconciliations/start",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body) as { candidates: Array<{ itemId: string; reasons: unknown[]; suggestedAction: string }> };
|
||||
expect(body.candidates.length).toBeGreaterThan(0);
|
||||
expect(body.candidates[0]?.reasons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("resolves 'exists' and marks item verified", async () => {
|
||||
const start = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/reconciliations/start",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {},
|
||||
});
|
||||
const { candidates } = JSON.parse(start.body) as { candidates: Array<{ itemId: string }> };
|
||||
const itemId = candidates[0]!.itemId;
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/reconciliations/items/${itemId}/resolve`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { action: "exists", quantity: 0.5 },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body) as { action: string; quantity: number; verifiedByUser: boolean };
|
||||
expect(body.action).toBe("exists");
|
||||
expect(body.quantity).toBe(0.5);
|
||||
expect(body.verifiedByUser).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user