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:
parent
e4cdbac7b1
commit
060ef69ca5
|
|
@ -71,3 +71,4 @@ docs/img/qa.jpeg
|
|||
docs/img/sam.jpg
|
||||
ClaudeReport.md
|
||||
ClaudeReport.md
|
||||
ClaudeReport.md
|
||||
|
|
|
|||
|
|
@ -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} {
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
)
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue