Fas 1b punkt 6: First Scan Coach + aktiveringsmätning
This commit is contained in:
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 §13–14): 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 };
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user