From dbc62b98ca7c39e421746d7f4a91d3d70644ed6c Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Wed, 3 Jun 2026 10:57:29 +0200 Subject: [PATCH] perf(chat): index direct_messages + DB-side latest-per-partner query; remove unconditional Lyra welcome-back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getDmConversations: DISTINCT ON (partner) ORDER BY partner, created_at DESC → one row per conversation in a single indexed query instead of fetching up to 500 rows and de-duplicating in JS - add indexes on direct_messages (sender_id,created_at DESC), (receiver_id,created_at DESC), (receiver_id,read_at) — table had none, so every conversation-list load (runs per user on app launch for the badge) was a full-table scan + sort - lyra.tsx: drop the welcome-back greeting that fired on every first coach open per session regardless of protection status/language (always German, unconditional). Endpoint kept for future conditional use Co-Authored-By: Claude Opus 4.8 --- apps/rebreak-native/NEXT_RELEASE.md | 2 + apps/rebreak-native/app/lyra.tsx | 47 +++++------------ .../migration.sql | 18 +++++++ backend/prisma/schema.prisma | 5 ++ backend/server/db/chat.ts | 51 ++++++++++++------- 5 files changed, 71 insertions(+), 52 deletions(-) create mode 100644 backend/prisma/migrations/20260603_dm_conversation_indexes/migration.sql diff --git a/apps/rebreak-native/NEXT_RELEASE.md b/apps/rebreak-native/NEXT_RELEASE.md index f0ec396..efe4767 100644 --- a/apps/rebreak-native/NEXT_RELEASE.md +++ b/apps/rebreak-native/NEXT_RELEASE.md @@ -2,6 +2,8 @@ - DM screen: bottom gap on initial open tightened — the last message now sits directly above the input bar. The keyboard-closed padding was double-counting the input bar's own layout slot, leaving a large empty gap every time you opened a chat - DM image lightbox: photos now actually show rounded corners — the viewer container is sized to the image's real aspect ratio (via onLoad), so the rounding lands on the visible photo instead of the empty letterbox margins of a fixed square +- Lyra coach: removed the "welcome back" greeting that popped up on every first open of the coach each session, regardless of protection status or language (it was always German and unconditional). Will return later only when it's actually warranted +- Chat list performance: the conversation list + unread badge now load via a single indexed query (one row per conversation) instead of pulling up to 500 messages and de-duplicating on the fly — added DB indexes on direct messages. Invisible to users, keeps the chat tab fast as message volume grows ### Features diff --git a/apps/rebreak-native/app/lyra.tsx b/apps/rebreak-native/app/lyra.tsx index 0239f97..d6bbad5 100644 --- a/apps/rebreak-native/app/lyra.tsx +++ b/apps/rebreak-native/app/lyra.tsx @@ -143,7 +143,6 @@ export default function CoachScreen() { const pushMessage = useCoachStore((s) => s.pushMessage); const markFeedbackSaved = useCoachStore((s) => s.markFeedbackSaved); const setThinking = useCoachStore((s) => s.setThinking); - const setWelcomeBackShown = useCoachStore((s) => s.setWelcomeBackShown); const [input, setInput] = useState(''); const [emotion, setEmotion] = useState('idle'); @@ -166,54 +165,34 @@ export default function CoachScreen() { const emotionTimer = useRef | null>(null); const isNearBottomRef = useRef(true); - // Load history + welcome-back. Beide Side-Effects sind store-cached: - // - historyLoaded → kein Re-Fetch + kein Spinner-Blink bei Tab-Wechsel - // - welcomeBackShownThisSession → keine doppelte Lyra-Begrüßung + // Load history. Store-cached (historyLoaded) → kein Re-Fetch + kein + // Spinner-Blink bei Tab-Wechsel. + // NOTE: Welcome-Back-Begrüßung (/api/lyra/welcome-back) wurde entfernt — sie + // erschien bedingungslos bei jedem ersten Coach-Open der Session (immer Deutsch, + // unabhängig von Schutz-Status/Sprache). Re-Enable später nur conditional. useEffect(() => { let cancelled = false; // Aktuelle Werte aus dem Store lesen (statt Closure-Stale beim ersten Render). const snap = useCoachStore.getState(); const needsHistory = !snap.historyLoaded; - const needsWelcomeBack = !snap.welcomeBackShownThisSession; - if (!needsHistory && !needsWelcomeBack) { + if (!needsHistory) { // Coach war diese Session schon offen → instant rendern, kein Spinner. requestAnimationFrame(() => flatRef.current?.scrollToEnd({ animated: false })); return; } async function init() { - // Spinner nur wenn wir wirklich History fetchen müssen (erster Coach-Open). - if (needsHistory) setIsLoading(true); - - if (needsHistory) { - try { - await loadHistory(); - } catch { - // non-fatal - } + setIsLoading(true); + try { + await loadHistory(); + } catch { + // non-fatal } - if (cancelled) return; - - if (needsWelcomeBack) { - try { - const res = await apiFetch<{ message?: string }>('/api/lyra/welcome-back'); - if (!cancelled && res?.message) { - pushMessage({ id: 'wb-' + Date.now(), role: 'assistant', content: res.message }); - } - } catch { - // no welcome-back — silent - } finally { - if (!cancelled) setWelcomeBackShown(true); - } - } - - if (!cancelled) { - if (needsHistory) setIsLoading(false); - requestAnimationFrame(() => flatRef.current?.scrollToEnd({ animated: false })); - } + setIsLoading(false); + requestAnimationFrame(() => flatRef.current?.scrollToEnd({ animated: false })); } init(); diff --git a/backend/prisma/migrations/20260603_dm_conversation_indexes/migration.sql b/backend/prisma/migrations/20260603_dm_conversation_indexes/migration.sql new file mode 100644 index 0000000..336ed57 --- /dev/null +++ b/backend/prisma/migrations/20260603_dm_conversation_indexes/migration.sql @@ -0,0 +1,18 @@ +-- DM-Conversation-Liste + Unread-Badge Performance. +-- Vorher: 0 Indizes auf direct_messages → jeder Conversation-List-Load +-- (läuft bei JEDEM User beim App-Start fürs Tab-Badge, plus Refetch nach +-- Send/Focus/Back-Nav) war ein Full-Table-Scan + Sort. Skaliert nicht. +-- +-- Deploy: pnpm prisma migrate deploy (auf Hetzner, via deploy-from-artifact.sh) + +-- Conversation-Liste: DISTINCT ON (partner) ORDER BY partner, created_at DESC. +-- Ein Index pro Richtung (sender/receiver) deckt das OR-Predicate + Sort ab. +CREATE INDEX IF NOT EXISTS "direct_messages_sender_id_created_at_idx" + ON "rebreak"."direct_messages" ("sender_id", "created_at" DESC); + +CREATE INDEX IF NOT EXISTS "direct_messages_receiver_id_created_at_idx" + ON "rebreak"."direct_messages" ("receiver_id", "created_at" DESC); + +-- Unread-Badge: WHERE receiver_id = $1 AND read_at IS NULL. +CREATE INDEX IF NOT EXISTS "direct_messages_receiver_id_read_at_idx" + ON "rebreak"."direct_messages" ("receiver_id", "read_at"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index bfd2621..33fde05 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -445,6 +445,11 @@ model DirectMessage { likes DirectMessageLike[] reactions DirectMessageReaction[] + // Conversation-Liste (DISTINCT ON partner, neueste zuerst) + Unread-Badge. + // Ohne diese Indizes ist jeder Conversation-Load ein Full-Table-Scan + Sort. + @@index([senderId, createdAt(sort: Desc)]) + @@index([receiverId, createdAt(sort: Desc)]) + @@index([receiverId, readAt]) @@map("direct_messages") @@schema("rebreak") } diff --git a/backend/server/db/chat.ts b/backend/server/db/chat.ts index 0fc28cd..8e48d91 100644 --- a/backend/server/db/chat.ts +++ b/backend/server/db/chat.ts @@ -183,25 +183,40 @@ export async function markDmsAsRead(senderId: string, receiverId: string) { }); } -export async function getDmConversations(userId: string) { +export type DmConversationRow = { + id: string; + senderId: string; + receiverId: string; + content: string; + createdAt: Date; + readAt: Date | null; + attachmentType: string | null; +}; + +export async function getDmConversations(userId: string): Promise { const db = usePrisma(); - // Alle DMs als Sender oder Empfänger, neueste zuerst - return db.directMessage.findMany({ - where: { - OR: [{ senderId: userId }, { receiverId: userId }], - }, - orderBy: { createdAt: "desc" }, - take: 500, - select: { - id: true, - senderId: true, - receiverId: true, - content: true, - createdAt: true, - readAt: true, - attachmentType: true, - }, - }); + // Eine Zeile pro Gesprächspartner: die jeweils NEUESTE DM. Postgres + // DISTINCT ON (partner) + ORDER BY partner, created_at DESC erledigt das + // DB-seitig in EINER Query (index-gestützt), statt 500 Rows zu ziehen und + // in JS zu deduplizieren. Spalten werden auf camelCase aliased, damit die + // Rows shape-kompatibel zur vorherigen Prisma-Selection bleiben. + return db.$queryRaw` + SELECT DISTINCT ON (partner_id) + id, + sender_id AS "senderId", + receiver_id AS "receiverId", + content, + created_at AS "createdAt", + read_at AS "readAt", + attachment_type AS "attachmentType" + FROM ( + SELECT *, + CASE WHEN sender_id = ${userId}::uuid THEN receiver_id ELSE sender_id END AS partner_id + FROM "rebreak"."direct_messages" + WHERE sender_id = ${userId}::uuid OR receiver_id = ${userId}::uuid + ) sub + ORDER BY partner_id, created_at DESC + `; } export async function countUnreadDms(receiverId: string) {