Files
HealthLog/src/lib/mood/create-from-telegram.ts
T
Marc-André Bombeck d0b45e36b3 feat(integrations): remove the moodLog integration
Mood has been a first-class module here for a long time, with entries,
structured tags, rated factors and notes. The bridge to the external moodLog
service had nothing left to add, and its Settings card was withdrawn in
a56e6fe61 on the promise of a dedicated removal. This delivers it.

Removed: the hourly `moodlog-sync` queue, cron and handler binding; the
inbound webhook and its shared-secret handling; the reverse push and its four
call sites; the pull-sync module; the per-user credential routes and the
instance-wide toggle; the entry on the `/api/integrations/status` envelope,
the `IntegrationKey` union, the cadence map and the display-name table; the
boot-time secret rotation pass; 22 message keys per locale.

Migration 0271 drops the five `users` credential columns and
`app_settings.mood_log_global`, clears the ledger row for the provider, and
deletes the retired pg-boss schedule so no cron keeps enqueuing work no
handler drains.

Mood data is untouched. `mood_entries` is not named anywhere in the migration
and cannot be reached from it: the dropped columns carry no index, constraint
or foreign key, and `mood_entries.user_id` references `users(id)`, not any of
them. Rows the bridge imported keep `source = 'MOODLOG'`, and every read path
- list filter, source badge, exports, backups - still resolves that value.

`src/lib/validations/moodlog.ts` held the native mood-entry schemas alongside
the bridge's own; it is now `validations/mood.ts` and carries only the former.
2026-07-25 11:36:18 +02:00

141 lines
4.6 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 { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
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.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;
}