diff --git a/.gitignore b/.gitignore index 3e3da891..160a2747 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,4 @@ docs/img/qa.jpeg docs/img/sam.jpg ClaudeReport.md ClaudeReport.md +ClaudeReport.md diff --git a/firestore.rules b/firestore.rules index be87eeea..2583becd 100644 --- a/firestore.rules +++ b/firestore.rules @@ -413,6 +413,39 @@ service cloud.firestore { } } + // Conversations: the Messages inbox. Each conversation (the couple chat or a per-question + // discussion) holds E2E-encrypted messages; only metadata + the encrypted last-message + // preview live on the conversation doc. + match /conversations/{conversationId} { + allow read: if isCouplesMember(coupleId); + // Members may create/merge-update the conversation doc within the allowed shape; any + // last-message preview must be ciphertext (encrypted on-device before write). + allow write: if isCouplesMember(coupleId) + && request.resource.data.keys().hasOnly( + ['type', 'questionId', 'createdAt', 'lastMessageAt', 'lastMessagePreview', 'lastMessageSenderId', 'reads']) + && (!('lastMessagePreview' in request.resource.data) + || isCiphertext(request.resource.data.lastMessagePreview)); + + // Messages: author-only create; text is ciphertext OR it's an image message pointing at + // encrypted Storage bytes; immutable after creation. + match /messages/{messageId} { + allow read: if isCouplesMember(coupleId); + allow create: if isCouplesMember(coupleId) + && coupleEncryptionEnabled(coupleId) + && request.resource.data.authorUserId == request.auth.uid + && request.resource.data.keys().hasOnly(['authorUserId', 'text', 'createdAt', 'type', 'mediaUrl']) + && ( + (request.resource.data.get('type', 'text') == 'image' + && request.resource.data.mediaUrl is string + && request.resource.data.mediaUrl.size() > 0) + || + (request.resource.data.get('type', 'text') == 'text' + && isCiphertext(request.resource.data.text)) + ); + allow update, delete: if false; + } + } + // Date swipes: per-couple, per-date partner swipe state. The action is E2E // ciphertext so the server can't read date preferences; only swipedAt is plaintext. match /date_swipes/{dateIdeaId} { diff --git a/functions/src/questions/onMessageWritten.ts b/functions/src/questions/onMessageWritten.ts index a8d46fab..f2000a53 100644 --- a/functions/src/questions/onMessageWritten.ts +++ b/functions/src/questions/onMessageWritten.ts @@ -3,18 +3,18 @@ import * as admin from 'firebase-admin' /** * Firestore trigger that notifies the other partner when a chat message is - * sent in a question thread. + * sent in a conversation (the couple chat or a per-question discussion). * - * Path: couples/{coupleId}/question_threads/{threadId}/messages/{messageId} + * Path: couples/{coupleId}/conversations/{conversationId}/messages/{messageId} * * Respects the recipient's `notifChatMessage` preference (default: enabled). */ export const onMessageWritten = functions.firestore - .document('couples/{coupleId}/question_threads/{threadId}/messages/{messageId}') + .document('couples/{coupleId}/conversations/{conversationId}/messages/{messageId}') .onCreate(async (snap, context) => { - const { coupleId, threadId, messageId } = context.params as { + const { coupleId, conversationId, messageId } = context.params as { coupleId: string - threadId: string + conversationId: string messageId: string } @@ -39,14 +39,6 @@ export const onMessageWritten = functions.firestore return } - // The conversation deep link + the client's "am I already in this thread?" suppression both - // key off questionId, so resolve it from the thread doc and pass it through. - const threadDoc = await db - .collection('couples').doc(coupleId) - .collection('question_threads').doc(threadId) - .get() - const questionId = (threadDoc.data()?.questionId as string | undefined) ?? '' - const partnerUserDoc = await db.collection('users').doc(partnerId).get() // Respect the partner's notification preference (opt-out; default is enabled). @@ -95,8 +87,7 @@ export const onMessageWritten = functions.firestore data: { type: 'chat_message', couple_id: coupleId, - thread_id: threadId, - ...(questionId ? { question_id: questionId } : {}), + conversation_id: conversationId, ...(authorPhotoUrl ? { sender_avatar_url: authorPhotoUrl } : {}), }, } @@ -119,6 +110,6 @@ export const onMessageWritten = functions.firestore } console.log( - `[onMessageWritten] notified partner ${partnerId} for thread ${threadId} in couple ${coupleId}` + `[onMessageWritten] notified partner ${partnerId} for conversation ${conversationId} in couple ${coupleId}` ) })