Fas 1b punkt 6: First Scan Coach + aktiveringsmätning

This commit is contained in:
Sven (AAMOS AI)
2026-08-06 04:14:49 +07:00
parent 2f3cdf8016
commit bd8df59138
29 changed files with 9378 additions and 29 deletions
+25
View File
@@ -0,0 +1,25 @@
import type { FastifyInstance } from "fastify";
import { getActivationState, schema } from "@app/database";
import { errors } from "../lib/errors.js";
import { getActiveHouseholdId } from "../lib/helpers.js";
/**
* Hushållsaktiveringsstatus (spec §4.2):
* - ACTIVATED_HOUSEHOLD
* - VALUE_CONFIRMED_HOUSEHOLD
*/
export async function activationRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.get("/v1/households/me/activation", auth, async (req) => {
const householdId = await getActiveHouseholdId(app.db, req.userId);
if (!householdId) throw errors.notFound("Aktivt hushåll saknas.");
const state = await getActivationState(app.db, householdId);
return {
...state,
isActivated: state.activatedAt != null,
isValueConfirmed: state.valueConfirmedAt != null,
};
});
}
+22
View File
@@ -200,6 +200,28 @@ export async function adminAnalyticsRoutes(app: FastifyInstance) {
const rows = Array.isArray(result) ? result : result.rows;
return { counts: rows };
});
/** Household activation metrics (Fas 1b). */
app.get("/admin/v1/analytics/activation", admin, async () => {
const result = await app.db.execute(sql`
SELECT
COUNT(*) FILTER (WHERE first_scan_completed_at IS NOT NULL) AS scanned,
COUNT(*) FILTER (WHERE activated_at IS NOT NULL) AS activated,
COUNT(*) FILTER (WHERE value_confirmed_at IS NOT NULL) AS value_confirmed,
COUNT(*) AS total
FROM households
`);
const rows = Array.isArray(result) ? result : result.rows;
const r = rows[0] as Record<string, number>;
return {
totalHouseholds: Number(r.total ?? 0),
scanned: Number(r.scanned ?? 0),
activated: Number(r.activated ?? 0),
valueConfirmed: Number(r.value_confirmed ?? 0),
activationRate: r.total ? Number(r.activated) / Number(r.total) : 0,
valueConfirmationRate: r.total ? Number(r.value_confirmed) / Number(r.total) : 0,
};
});
}
/** Escape a string/date parameter for raw SQL by wrapping it in single quotes.
+15 -1
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from "fastify";
import { and, desc, eq, gt, ilike, inArray, isNull, lte, or, sql } from "drizzle-orm";
import { schema } from "@app/database";
import { markMilestone, schema } from "@app/database";
import {
cookRecipeInputSchema,
createUserRecipeInputSchema,
@@ -226,6 +226,11 @@ export async function recipeRoutes(app: FastifyInstance) {
// Innehållsspråk (i18n-spec §1314): publicerad översättning om användarens
// språk inte är svenska; annars svensk källa. Struktur ändras aldrig.
const householdId = await getActiveHouseholdId(app.db, req.userId);
if (householdId) {
void markMilestone(app.db, householdId, "firstRecipeRecommendationViewedAt");
}
const languageTag = await userLanguageTag(app.db, req.userId);
const translation = await resolveRecipeTranslation(app.db, id, languageTag);
const ingredientNames = await resolveIngredientNames(
@@ -366,6 +371,10 @@ export async function recipeRoutes(app: FastifyInstance) {
.update(schema.recipes)
.set({ favoriteCount: sql`${schema.recipes.favoriteCount} + 1` })
.where(eq(schema.recipes.id, id));
const householdId = await getActiveHouseholdId(app.db, req.userId);
if (householdId) {
void markMilestone(app.db, householdId, "firstRecipeSavedOrStartedAt");
}
return { ok: true };
});
@@ -598,6 +607,11 @@ export async function recipeRoutes(app: FastifyInstance) {
correlationId: req.correlationId,
});
await markMilestone(app.db, householdId, "firstCookingSessionCompletedAt");
if (input.deductInventory && deductions.length > 0) {
await markMilestone(app.db, householdId, "inventoryUpdatedAfterCookingAt");
}
return { ok: true, mealIds, mealBoxId, inventoryDeductions: deductions };
});
+7 -1
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from "fastify";
import { and, eq, gt, isNull } from "drizzle-orm";
import { schema } from "@app/database";
import { markMilestone, schema } from "@app/database";
import type { JobType, ScanType } from "@app/shared-types";
import { confirmScanInputSchema, createScanInputSchema, idParamSchema } from "@app/validation";
import { errors, parse } from "../lib/errors.js";
@@ -200,6 +200,12 @@ export async function scanRoutes(app: FastifyInstance) {
.set({ status: "completed", updatedAt: new Date() })
.where(eq(schema.scanJobs.id, id));
const acceptedCount = input.items.filter((i) => i.action !== "reject").length;
await markMilestone(app.db, householdId, "firstScanCompletedAt");
if (acceptedCount >= 5) {
await markMilestone(app.db, householdId, "fifthItemConfirmedAt");
}
return { ok: true, createdItemIds: created };
});
+2
View File
@@ -26,6 +26,7 @@ import { adminReleaseGateRoutes } from "./routes/admin-release-gates.js";
import { adminWorkersRoutes } from "./routes/admin-workers.js";
import { analyticsRoutes } from "./routes/analytics.js";
import { onboardingRoutes } from "./routes/onboarding.js";
import { activationRoutes } from "./routes/activation.js";
declare module "fastify" {
interface FastifyInstance {
@@ -90,6 +91,7 @@ export async function buildServer(config: AppConfig) {
await app.register(adminWorkersRoutes);
await app.register(analyticsRoutes);
await app.register(onboardingRoutes);
await app.register(activationRoutes);
return app;
}