Files
Cibello-app/apps/api/src/lib/helpers.ts
T

148 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { EventType } from "@app/shared-types";
import type { NewDomainEvent } from "@app/events";
import { errors } from "./errors.js";
/** Slumpad, läsbar inbjudningskod utan lättförväxlade tecken. */
export function generateInviteCode(): string {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const bytes = randomBytes(8);
let code = "";
for (const b of bytes) code += alphabet[b % alphabet.length];
return code;
}
export function sha256(input: string): string {
return createHash("sha256").update(input).digest("hex");
}
export function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
export function currentMonth(): string {
return new Date().toISOString().slice(0, 7);
}
/** Verifiera hushållsmedlemskap enda vägen till hushållsdata (spec §56). */
export async function requireMembership(
db: Database,
householdId: string,
userId: string,
): Promise<{ role: string; portionFactor: number }> {
const [member] = await db
.select()
.from(schema.householdMembers)
.where(
and(
eq(schema.householdMembers.householdId, householdId),
eq(schema.householdMembers.userId, userId),
),
)
.limit(1);
if (!member) throw errors.forbidden("Du är inte medlem i det här hushållet.");
return { role: member.role, portionFactor: member.portionFactor };
}
/** Skapa hushåll med standard-förvaringsplatser (spec §78). Återanvänds av
* både POST /v1/households och onboarding auto-create. */
export async function createHouseholdWithDefaults(
db: Database,
{
userId,
name,
size,
}: {
userId: string;
name: string;
size?: number;
},
): Promise<{ id: string; name: string }> {
const [household] = await db
.insert(schema.households)
.values({
name,
inviteCode: generateInviteCode(),
size: size ?? null,
})
.returning();
await db.insert(schema.householdMembers).values({
householdId: household!.id,
userId,
role: "owner",
});
await db.insert(schema.storageLocations).values([
{ householdId: household!.id, type: "fridge", name: "Kylen", sortOrder: 0 },
{ householdId: household!.id, type: "freezer", name: "Frysen", sortOrder: 1 },
{ householdId: household!.id, type: "pantry", name: "Skafferiet", sortOrder: 2 },
]);
return { id: household!.id, name: household!.name };
}
/** Hämta användarens aktiva hushåll (första medlemskapet) eller null. */
export async function getActiveHouseholdId(db: Database, userId: string): Promise<string | null> {
const [member] = await db
.select({ householdId: schema.householdMembers.householdId })
.from(schema.householdMembers)
.where(eq(schema.householdMembers.userId, userId))
.orderBy(schema.householdMembers.joinedAt)
.limit(1);
return member?.householdId ?? null;
}
/** Som ovan men kastar 404 om användaren saknar hushåll. */
export async function requireActiveHousehold(db: Database, userId: string): Promise<string> {
const id = await getActiveHouseholdId(db, userId);
if (!id) {
throw errors.notFound("Du har inget hushåll ännu. Skapa ett under Hemma → Hushåll.");
}
return id;
}
/** Skriv domänhändelse till outboxen (spec §55) i samma transaktion när tx skickas in. */
export async function emitEvent<T extends EventType>(
db: Database,
event: NewDomainEvent<T>,
): Promise<void> {
await db.insert(schema.domainEvents).values({
type: event.type,
userId: event.userId ?? null,
householdId: event.householdId ?? null,
payload: event.payload as Record<string, unknown>,
correlationId: event.correlationId ?? null,
});
}
/** Audit-logg (spec §56). */
export async function audit(
db: Database,
entry: {
actorUserId?: string | undefined;
actorType?: "user" | "admin" | "system" | "worker";
action: string;
targetType?: string;
targetId?: string;
metadata?: Record<string, unknown>;
ip?: string | undefined;
correlationId?: string | undefined;
},
): Promise<void> {
await db.insert(schema.auditLogs).values({
actorUserId: entry.actorUserId ?? null,
actorType: entry.actorType ?? "user",
action: entry.action,
targetType: entry.targetType ?? null,
targetId: entry.targetId ?? null,
metadata: entry.metadata ?? null,
ip: entry.ip ?? null,
correlationId: entry.correlationId ?? null,
});
}
export function newCorrelationId(): string {
return randomUUID();
}