Fas 1b punkt 6: First Scan Coach + aktiveringsmätning
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
import * as schema from "./schema/index.js";
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
export interface ActivationState {
|
||||
firstScanCompletedAt: Date | null;
|
||||
fifthItemConfirmedAt: Date | null;
|
||||
firstRecipeRecommendationViewedAt: Date | null;
|
||||
firstRecipeSavedOrStartedAt: Date | null;
|
||||
activatedAt: Date | null;
|
||||
firstCookingSessionCompletedAt: Date | null;
|
||||
inventoryUpdatedAfterCookingAt: Date | null;
|
||||
valueConfirmedAt: Date | null;
|
||||
}
|
||||
|
||||
function milestoneColumn(milestone: keyof ActivationState) {
|
||||
return schema.households[milestone] as (typeof schema.households)["firstScanCompletedAt"];
|
||||
}
|
||||
|
||||
const activationMilestones: Array<keyof ActivationState> = [
|
||||
"firstScanCompletedAt",
|
||||
"fifthItemConfirmedAt",
|
||||
"firstRecipeRecommendationViewedAt",
|
||||
"firstRecipeSavedOrStartedAt",
|
||||
"activatedAt",
|
||||
"firstCookingSessionCompletedAt",
|
||||
"inventoryUpdatedAfterCookingAt",
|
||||
"valueConfirmedAt",
|
||||
];
|
||||
|
||||
function isActivationMilestone(m: string): m is keyof ActivationState {
|
||||
return activationMilestones.includes(m as keyof ActivationState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Markera en milstolpe och utvärdera aktiveringsstatus.
|
||||
* Endast den första bekräftelsen sparas; anrop är idempotenta.
|
||||
*/
|
||||
export async function markMilestone(
|
||||
db: Db,
|
||||
householdId: string,
|
||||
milestone: keyof ActivationState,
|
||||
): Promise<{ wasNew: boolean; activatedNow: boolean; valueConfirmedNow: boolean }> {
|
||||
if (!isActivationMilestone(milestone)) {
|
||||
throw new Error(`Unknown activation milestone: ${milestone}`);
|
||||
}
|
||||
|
||||
const [before] = await db
|
||||
.select({
|
||||
milestoneAt: milestoneColumn(milestone),
|
||||
firstScanCompletedAt: schema.households.firstScanCompletedAt,
|
||||
fifthItemConfirmedAt: schema.households.fifthItemConfirmedAt,
|
||||
firstRecipeRecommendationViewedAt: schema.households.firstRecipeRecommendationViewedAt,
|
||||
firstRecipeSavedOrStartedAt: schema.households.firstRecipeSavedOrStartedAt,
|
||||
activatedAt: schema.households.activatedAt,
|
||||
firstCookingSessionCompletedAt: schema.households.firstCookingSessionCompletedAt,
|
||||
inventoryUpdatedAfterCookingAt: schema.households.inventoryUpdatedAfterCookingAt,
|
||||
valueConfirmedAt: schema.households.valueConfirmedAt,
|
||||
})
|
||||
.from(schema.households)
|
||||
.where(eq(schema.households.id, householdId));
|
||||
|
||||
if (!before) {
|
||||
throw new Error(`Household not found: ${householdId}`);
|
||||
}
|
||||
|
||||
const wasNew = before.milestoneAt == null;
|
||||
const wasActivated = before.activatedAt != null;
|
||||
const wasValueConfirmed = before.valueConfirmedAt != null;
|
||||
|
||||
if (!wasNew) {
|
||||
return { wasNew: false, activatedNow: false, valueConfirmedNow: false };
|
||||
}
|
||||
|
||||
const isActivatedNow =
|
||||
before.firstScanCompletedAt != null &&
|
||||
before.fifthItemConfirmedAt != null &&
|
||||
before.firstRecipeRecommendationViewedAt != null &&
|
||||
(before.firstRecipeSavedOrStartedAt != null || milestone === "firstRecipeSavedOrStartedAt");
|
||||
|
||||
const activatedNow = !wasActivated && isActivatedNow;
|
||||
|
||||
const isValueConfirmedNow =
|
||||
isActivatedNow &&
|
||||
(before.firstCookingSessionCompletedAt != null || milestone === "firstCookingSessionCompletedAt") &&
|
||||
(before.inventoryUpdatedAfterCookingAt != null || milestone === "inventoryUpdatedAfterCookingAt");
|
||||
|
||||
const valueConfirmedNow = !wasValueConfirmed && isValueConfirmedNow;
|
||||
|
||||
const updates: Partial<typeof schema.households.$inferInsert> = {
|
||||
[milestone]: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
if (activatedNow) {
|
||||
updates.activatedAt = new Date();
|
||||
}
|
||||
if (valueConfirmedNow) {
|
||||
updates.valueConfirmedAt = new Date();
|
||||
}
|
||||
|
||||
await db.update(schema.households).set(updates).where(eq(schema.households.id, householdId));
|
||||
|
||||
return { wasNew, activatedNow, valueConfirmedNow };
|
||||
}
|
||||
|
||||
export async function getActivationState(db: Db, householdId: string): Promise<ActivationState> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
firstScanCompletedAt: schema.households.firstScanCompletedAt,
|
||||
fifthItemConfirmedAt: schema.households.fifthItemConfirmedAt,
|
||||
firstRecipeRecommendationViewedAt: schema.households.firstRecipeRecommendationViewedAt,
|
||||
firstRecipeSavedOrStartedAt: schema.households.firstRecipeSavedOrStartedAt,
|
||||
activatedAt: schema.households.activatedAt,
|
||||
firstCookingSessionCompletedAt: schema.households.firstCookingSessionCompletedAt,
|
||||
inventoryUpdatedAfterCookingAt: schema.households.inventoryUpdatedAfterCookingAt,
|
||||
valueConfirmedAt: schema.households.valueConfirmedAt,
|
||||
})
|
||||
.from(schema.households)
|
||||
.where(eq(schema.households.id, householdId));
|
||||
|
||||
return row ?? {
|
||||
firstScanCompletedAt: null,
|
||||
fifthItemConfirmedAt: null,
|
||||
firstRecipeRecommendationViewedAt: null,
|
||||
firstRecipeSavedOrStartedAt: null,
|
||||
activatedAt: null,
|
||||
firstCookingSessionCompletedAt: null,
|
||||
inventoryUpdatedAfterCookingAt: null,
|
||||
valueConfirmedAt: null,
|
||||
};
|
||||
}
|
||||
@@ -3,3 +3,4 @@ export * as schema from "./schema/index.js";
|
||||
export * from "./schema/index.js";
|
||||
export * from "./analytics-gdpr.js";
|
||||
export * from "./release-gates.js";
|
||||
export * from "./activation.js";
|
||||
|
||||
@@ -21,6 +21,24 @@ export const households = pgTable(
|
||||
name: text("name").notNull(),
|
||||
inviteCode: text("invite_code").notNull(),
|
||||
weeklyBudgetMinor: integer("weekly_budget_minor"),
|
||||
/** Fas 1b: aktiveringsmått (spec §4.2) */
|
||||
firstScanCompletedAt: timestamp("first_scan_completed_at", { withTimezone: true }),
|
||||
fifthItemConfirmedAt: timestamp("fifth_item_confirmed_at", { withTimezone: true }),
|
||||
firstRecipeRecommendationViewedAt: timestamp(
|
||||
"first_recipe_recommendation_viewed_at",
|
||||
{ withTimezone: true },
|
||||
),
|
||||
firstRecipeSavedOrStartedAt: timestamp("first_recipe_saved_or_started_at", {
|
||||
withTimezone: true,
|
||||
}),
|
||||
activatedAt: timestamp("activated_at", { withTimezone: true }),
|
||||
firstCookingSessionCompletedAt: timestamp("first_cooking_session_completed_at", {
|
||||
withTimezone: true,
|
||||
}),
|
||||
inventoryUpdatedAfterCookingAt: timestamp("inventory_updated_after_cooking_at", {
|
||||
withTimezone: true,
|
||||
}),
|
||||
valueConfirmedAt: timestamp("value_confirmed_at", { withTimezone: true }),
|
||||
/** ISO 4217. Pengagränsen går vid hushållet (i18n-spec §20) – alla belopp i hushållet tolkas i denna valuta. */
|
||||
currencyCode: varchar("currency_code", { length: 3 }).notNull().default("SEK"),
|
||||
createdAt: createdAt(),
|
||||
|
||||
Reference in New Issue
Block a user