feat(rules+trigger): conversations Firestore rules, onMessageWritten listens on conversations path, gitignore

- firestore.rules: conversations doc read/write rules with ciphertext validation, messages subcollection create rules (image or ciphertext text)
- onMessageWritten: trigger path changed from question_threads to conversations, passes conversation_id in FCM data, removed questionId resolution (no longer needed)
- .gitignore: deduplicate ClaudeReport.md entry
This commit is contained in:
null 2026-06-24 16:14:18 -05:00
parent e4cdbac7b1
commit 060ef69ca5
3 changed files with 41 additions and 16 deletions

1
.gitignore vendored
View File

@ -71,3 +71,4 @@ docs/img/qa.jpeg
docs/img/sam.jpg
ClaudeReport.md
ClaudeReport.md
ClaudeReport.md

View File

@ -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} {

View File

@ -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}`
)
})