perf(chat): index direct_messages + DB-side latest-per-partner query; remove unconditional Lyra welcome-back
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
d54bd06727
commit
dbc62b98ca
@ -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 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
|
- 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
|
### Features
|
||||||
|
|
||||||
|
|||||||
@ -143,7 +143,6 @@ export default function CoachScreen() {
|
|||||||
const pushMessage = useCoachStore((s) => s.pushMessage);
|
const pushMessage = useCoachStore((s) => s.pushMessage);
|
||||||
const markFeedbackSaved = useCoachStore((s) => s.markFeedbackSaved);
|
const markFeedbackSaved = useCoachStore((s) => s.markFeedbackSaved);
|
||||||
const setThinking = useCoachStore((s) => s.setThinking);
|
const setThinking = useCoachStore((s) => s.setThinking);
|
||||||
const setWelcomeBackShown = useCoachStore((s) => s.setWelcomeBackShown);
|
|
||||||
|
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [emotion, setEmotion] = useState<Emotion>('idle');
|
const [emotion, setEmotion] = useState<Emotion>('idle');
|
||||||
@ -166,54 +165,34 @@ export default function CoachScreen() {
|
|||||||
const emotionTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const emotionTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const isNearBottomRef = useRef(true);
|
const isNearBottomRef = useRef(true);
|
||||||
|
|
||||||
// Load history + welcome-back. Beide Side-Effects sind store-cached:
|
// Load history. Store-cached (historyLoaded) → kein Re-Fetch + kein
|
||||||
// - historyLoaded → kein Re-Fetch + kein Spinner-Blink bei Tab-Wechsel
|
// Spinner-Blink bei Tab-Wechsel.
|
||||||
// - welcomeBackShownThisSession → keine doppelte Lyra-Begrüßung
|
// 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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
// Aktuelle Werte aus dem Store lesen (statt Closure-Stale beim ersten Render).
|
// Aktuelle Werte aus dem Store lesen (statt Closure-Stale beim ersten Render).
|
||||||
const snap = useCoachStore.getState();
|
const snap = useCoachStore.getState();
|
||||||
const needsHistory = !snap.historyLoaded;
|
const needsHistory = !snap.historyLoaded;
|
||||||
const needsWelcomeBack = !snap.welcomeBackShownThisSession;
|
|
||||||
|
|
||||||
if (!needsHistory && !needsWelcomeBack) {
|
if (!needsHistory) {
|
||||||
// Coach war diese Session schon offen → instant rendern, kein Spinner.
|
// Coach war diese Session schon offen → instant rendern, kein Spinner.
|
||||||
requestAnimationFrame(() => flatRef.current?.scrollToEnd({ animated: false }));
|
requestAnimationFrame(() => flatRef.current?.scrollToEnd({ animated: false }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
// Spinner nur wenn wir wirklich History fetchen müssen (erster Coach-Open).
|
setIsLoading(true);
|
||||||
if (needsHistory) setIsLoading(true);
|
try {
|
||||||
|
await loadHistory();
|
||||||
if (needsHistory) {
|
} catch {
|
||||||
try {
|
// non-fatal
|
||||||
await loadHistory();
|
|
||||||
} catch {
|
|
||||||
// non-fatal
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
setIsLoading(false);
|
||||||
if (needsWelcomeBack) {
|
requestAnimationFrame(() => flatRef.current?.scrollToEnd({ animated: false }));
|
||||||
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 }));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init();
|
init();
|
||||||
|
|||||||
@ -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");
|
||||||
@ -445,6 +445,11 @@ model DirectMessage {
|
|||||||
likes DirectMessageLike[]
|
likes DirectMessageLike[]
|
||||||
reactions DirectMessageReaction[]
|
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")
|
@@map("direct_messages")
|
||||||
@@schema("rebreak")
|
@@schema("rebreak")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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<DmConversationRow[]> {
|
||||||
const db = usePrisma();
|
const db = usePrisma();
|
||||||
// Alle DMs als Sender oder Empfänger, neueste zuerst
|
// Eine Zeile pro Gesprächspartner: die jeweils NEUESTE DM. Postgres
|
||||||
return db.directMessage.findMany({
|
// DISTINCT ON (partner) + ORDER BY partner, created_at DESC erledigt das
|
||||||
where: {
|
// DB-seitig in EINER Query (index-gestützt), statt 500 Rows zu ziehen und
|
||||||
OR: [{ senderId: userId }, { receiverId: userId }],
|
// in JS zu deduplizieren. Spalten werden auf camelCase aliased, damit die
|
||||||
},
|
// Rows shape-kompatibel zur vorherigen Prisma-Selection bleiben.
|
||||||
orderBy: { createdAt: "desc" },
|
return db.$queryRaw<DmConversationRow[]>`
|
||||||
take: 500,
|
SELECT DISTINCT ON (partner_id)
|
||||||
select: {
|
id,
|
||||||
id: true,
|
sender_id AS "senderId",
|
||||||
senderId: true,
|
receiver_id AS "receiverId",
|
||||||
receiverId: true,
|
content,
|
||||||
content: true,
|
created_at AS "createdAt",
|
||||||
createdAt: true,
|
read_at AS "readAt",
|
||||||
readAt: true,
|
attachment_type AS "attachmentType"
|
||||||
attachmentType: true,
|
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) {
|
export async function countUnreadDms(receiverId: string) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user