PR 3 du chantier admin. Tableau de bord metriques : snapshot de compteurs
+ series temporelles journalieres.
Schema (migration admin_metrics) :
- AnalyticsEvent (type String libre, actorType/actorId sans FK, context Json,
index [type, created_at]) + WorkerHeartbeat (cable en PR 4).
- colonnes createdAt @default(now()) sur UserProfile / Recipe / Planning /
PlanningItem (lecture admin uniquement ; lignes existantes = timestamp de
la migration).
Instrumentation (lib/analytics.service.ts, fire-and-forget) :
- analytics.recordEvent(type, {actorId?, context?}) : retourne void, insert
detache, echec loggue+avale, jamais de latence sur la requete.
- points d'appel : user.signup, recipe.created, recipe.imported,
planning.item_added, tech_step.correction_submitted, shopping_list.viewed.
API : GET /admin/metrics?days= (7-365, defaut 30, requireAdmin) ->
admin-metrics.service.ts. bucketByDay pur (zero-remplissage, teste sans
base). MetricsView dans packages/shared.
Front : DashboardPage (tuiles KPI + un graphe recharts par serie + listes
recettes-par-source / evenements), logique pure dans dashboard.ts, i18n
admin.dashboard.*. AdminApiClient.getMetrics.
reset-db.ts truncate analytics_events + worker_heartbeats.
Tests : Mocha admin-metrics.test.ts (bucketByDay pur x2 verts ; snapshot,
series zero-remplies, event user.signup fire-and-forget) ; Cypress
dashboard.cy.ts (2 verts). specs/backend-architecture.md : section admin.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
135 lines
5 KiB
TypeScript
135 lines
5 KiB
TypeScript
import { HttpError } from "@batch-cooking/error-tools";
|
|
import {
|
|
ErrorCode,
|
|
type LoginInput,
|
|
type SafeUserProfile,
|
|
type SignupInput,
|
|
} from "@batch-cooking/shared";
|
|
import argon2 from "argon2";
|
|
import { env } from "../../config/env.js";
|
|
import { prisma } from "../../db/prisma.js";
|
|
import { analytics } from "../../lib/analytics.service.js";
|
|
import { signAuthToken } from "../../lib/jwt.js";
|
|
import { toSafeProfile } from "../../lib/safe-profile.js";
|
|
import { leaveCurrentHouse } from "../house/house.service.js";
|
|
|
|
/** Result of a successful signup/login: the safe profile plus the signed session JWT to set as a cookie. */
|
|
interface AuthResult {
|
|
/** The authenticated profile, safe to hand back to the client. */
|
|
profile: SafeUserProfile;
|
|
/** Signed session JWT — the caller sets this as the session cookie's value. */
|
|
token: string;
|
|
}
|
|
|
|
// argon2's defaults (64 MB memory, 3 passes) are deliberately expensive —
|
|
// that's the point, for real passwords. In tests we hash/verify dozens of
|
|
// times per run against throwaway data, so a much cheaper cost keeps the
|
|
// suite fast without weakening anything real. Never applies outside
|
|
// NODE_ENV=test.
|
|
const testHashOptions = { memoryCost: 8192, timeCost: 2, parallelism: 1 };
|
|
const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined;
|
|
|
|
/**
|
|
* Creates a profile (`user_profiles`), hashes the password, and issues a
|
|
* session token.
|
|
*
|
|
* @throws {HttpError} `409 EMAIL_ALREADY_IN_USE` if the email is already taken.
|
|
*/
|
|
export async function signup(input: SignupInput): Promise<AuthResult> {
|
|
try {
|
|
const existing = await prisma.userProfile.findUnique({
|
|
where: { email: input.email },
|
|
});
|
|
if (existing) {
|
|
throw new HttpError(409, ErrorCode.EMAIL_ALREADY_IN_USE, "Email already in use");
|
|
}
|
|
|
|
const passwordHash = await argon2.hash(input.password, hashOptions);
|
|
|
|
// No household is created here — it's now an optional step of the
|
|
// onboarding wizard (create or join one, or skip — see
|
|
// `house.service.ts`'s `createHouse`/`joinHouse`), not an implicit side
|
|
// effect of signing up. `houseId` starts out `null`, same as `dietId`.
|
|
const profile = await prisma.userProfile.create({
|
|
data: {
|
|
firstName: input.firstName,
|
|
lastName: input.lastName,
|
|
email: input.email,
|
|
passwordHash,
|
|
},
|
|
});
|
|
|
|
analytics.recordEvent("user.signup", { actorId: profile.id });
|
|
|
|
const token = signAuthToken({
|
|
userProfileId: profile.id,
|
|
tokenVersion: profile.tokenVersion,
|
|
});
|
|
return { profile: toSafeProfile(profile), token };
|
|
} catch (err) {
|
|
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which already
|
|
// logs it, see `error-logger.ts`) is what actually handles it, this
|
|
// service layer just isn't allowed a bare `await` per the repo's
|
|
// async/try-catch convention.
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Permanently deletes a profile, after re-verifying its password —
|
|
* deleting an account is irreversible, so it's gated behind proving the
|
|
* caller still is who the session says they are, same spirit as the
|
|
* password check in {@link login}.
|
|
*
|
|
* If the profile administers a household with other members, adminship is
|
|
* handed off before the profile is deleted (see `house.service.ts`'s
|
|
* `leaveCurrentHouse`); if it's the household's last member, the household
|
|
* itself is deleted along with it. `UserProfileAllergy` rows cascade via
|
|
* the schema's `onDelete: Cascade`.
|
|
*
|
|
* @throws {HttpError} `401 INVALID_CREDENTIALS` if the password is wrong.
|
|
*/
|
|
export async function deleteAccount(profileId: number, password: string): Promise<void> {
|
|
try {
|
|
const profile = await prisma.userProfile.findUnique({
|
|
where: { id: profileId },
|
|
});
|
|
if (!profile || !(await argon2.verify(profile.passwordHash, password))) {
|
|
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid password");
|
|
}
|
|
|
|
if (profile.houseId !== null) {
|
|
await leaveCurrentHouse(profile.id, profile.houseId);
|
|
}
|
|
await prisma.userProfile.delete({ where: { id: profile.id } });
|
|
} catch (err) {
|
|
throw err; // see signup()'s catch comment above
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verifies credentials and issues a fresh session token.
|
|
*
|
|
* @throws {HttpError} `401 INVALID_CREDENTIALS` for either an unknown email
|
|
* or a wrong password — deliberately the same error either way, so a
|
|
* caller can never learn whether a given email has an account.
|
|
*/
|
|
export async function login(input: LoginInput): Promise<AuthResult> {
|
|
try {
|
|
const profile = await prisma.userProfile.findUnique({
|
|
where: { email: input.email },
|
|
});
|
|
|
|
if (!profile || !(await argon2.verify(profile.passwordHash, input.password))) {
|
|
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid email or password");
|
|
}
|
|
|
|
const token = signAuthToken({
|
|
userProfileId: profile.id,
|
|
tokenVersion: profile.tokenVersion,
|
|
});
|
|
return { profile: toSafeProfile(profile), token };
|
|
} catch (err) {
|
|
throw err; // see signup()'s catch comment above
|
|
}
|
|
}
|