Files
HealthLog/src/lib/mood/create-from-telegram.ts
T
Marc-André Bombeck 9d2fd1db14 fix(tz): one default zone, and the user's own calendar where Berlin was hard-coded
The status assessments folded their recent days, weeks, months and years on
Berlin days whatever the user's zone, the mood and cycle crosstabs keyed
their metric days in Berlin, the resting-pulse proxy defaulted to Berlin
days, the briefing's opener rotated at Berlin midnight, and every
assessment prompt labelled today's date as Berlin's. They now use the
user's zone. Rollup months and years are labelled by their UTC start,
which is how they are cut; read in a zone west of UTC the label named the
month before.

"Europe/Berlin" was written out in some eighty places. DEFAULT_TIMEZONE in
lib/tz/format is now the one definition: the second copy in mood/date-key,
DISPLAY_TIMEZONE, the Berlin-only day key helpers and the per-route copies
are gone. An in-repo lint rule rejects the literal outside lib/tz and
tests; four areas another change owns are listed as pending.
2026-09-26 16:23:14 +02:00

147 lines
4.9 KiB
TypeScript

/**
* v1.19.0 — server-authoritative mood logging from the Telegram bot.
*
* The HTTP mood-create path (`POST /api/mood-entries`) is bound to a
* cookie/Bearer session via `requireAuth`; the Telegram webhook resolves
* the user from the linked chat instead. This helper holds the shared
* write so the two entry points agree on the canonical shape: the same
* `getScoreForMood` / `moodDateKey` derivation, the same per-user `tz`
* anchoring, the same cache invalidation + DAY-rollup recompute the route
* fires. `source` is pinned to "TELEGRAM"
* (already an accepted free-text source value, no enum migration needed).
*
* The `userId` is NEVER taken from the Telegram payload — the caller
* passes the id it resolved from the stored `telegramChatId` binding.
*/
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import { prisma as defaultPrisma } from "@/lib/db";
import { MOOD_ENUM_BY_SCORE } from "@/lib/mood/labels";
import { getScoreForMood } from "@/lib/validations/mood";
import { deriveA1 } from "@/lib/mood/level-a";
import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { encryptNote } from "@/lib/crypto/note-cipher";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
import { getEvent } from "@/lib/logging/context";
export interface TelegramMoodResult {
/** True when a new row was written; false when the idempotency key already had one. */
created: boolean;
moodEntryId: string;
score: number;
}
/**
* Log a mood entry on behalf of a Telegram-linked user.
*
* Idempotent on `externalId` (the per-tap key the caller derives from
* `telegram:mood:<chatId>:<messageId>:<score>`): a redelivered callback
* update converges onto the same row via the NULL-distinct
* `(userId, source, externalId)` unique instead of minting a duplicate.
*/
export async function logTelegramMood(input: {
userId: string;
score: number;
tz: string | null;
/** Stable per-tap id for Telegram redelivery dedup. */
externalId: string;
note?: string | null;
client?: PrismaClient | Prisma.TransactionClient;
}): Promise<TelegramMoodResult> {
const prisma = (input.client ?? defaultPrisma) as PrismaClient;
const mood = MOOD_ENUM_BY_SCORE[input.score];
if (!mood) {
throw new Error(`Invalid mood score: ${input.score}`);
}
const score = getScoreForMood(mood);
const moodLoggedAt = new Date();
const tz = input.tz ?? DEFAULT_TIMEZONE;
const date = moodDateKey(moodLoggedAt, tz);
const existing = await prisma.moodEntry.findUnique({
where: {
userId_source_externalId: {
userId: input.userId,
source: "TELEGRAM",
externalId: input.externalId,
},
},
select: { id: true },
});
if (existing) {
return { created: false, moodEntryId: existing.id, score };
}
const entry = await prisma.moodEntry.create({
data: {
userId: input.userId,
date,
tz,
mood,
score,
// v1.37 — pleasantness derived from the same label the score comes
// from, so a mood tapped in the chat carries the value a mood typed
// into the web form would. Nothing here can answer the other four.
moodA1: deriveA1(mood),
// v1.23 — encrypt the note at rest; legacy plaintext column nulled.
note: null,
noteEncrypted: encryptNote(input.note ?? null),
source: "TELEGRAM",
externalId: input.externalId,
moodLoggedAt,
},
select: {
id: true,
date: true,
mood: true,
note: true,
noteEncrypted: true,
tags: true,
},
});
invalidateUserMood(input.userId);
// Best-effort rollup refresh — a cache tier, never a write-path invariant.
try {
await recomputeMoodBucketsForEntry(input.userId, date);
} catch (rollupErr) {
getEvent()?.addMeta(
"telegram_mood_rollup_failed",
rollupErr instanceof Error ? rollupErr.message : String(rollupErr),
);
}
return { created: true, moodEntryId: entry.id, score };
}
/**
* Attach a free-text note to an existing Telegram-logged mood entry and
* refresh the derived caches/rollups. Bounded to 500 chars (the same cap
* the mood-create Zod schema enforces) so an oversized reply can't bloat
* the column.
*/
export async function attachTelegramMoodNote(input: {
userId: string;
moodEntryId: string;
note: string;
client?: PrismaClient | Prisma.TransactionClient;
}): Promise<boolean> {
const prisma = (input.client ?? defaultPrisma) as PrismaClient;
const note = input.note.slice(0, 500);
const updated = await prisma.moodEntry.updateMany({
where: {
id: input.moodEntryId,
userId: input.userId,
deletedAt: null,
},
// v1.23 — encrypt the attached note at rest; legacy plaintext column nulled.
data: { note: null, noteEncrypted: encryptNote(note) },
});
if (updated.count === 0) return false;
invalidateUserMood(input.userId);
return true;
}