destinyebuka commited on
Commit
4e15311
·
1 Parent(s): fbe31d6
app/database.py CHANGED
@@ -79,7 +79,13 @@ async def ensure_indexes():
79
  await users_col.create_index("role")
80
  await users_col.create_index("isActive")
81
 
82
- # OTP indexes
 
 
 
 
 
 
83
  await otps_col.create_index("identifier")
84
  await otps_col.create_index("purpose")
85
  await otps_col.create_index([("createdAt", 1)], expireAfterSeconds=900) # 15 min TTL
@@ -123,6 +129,12 @@ async def ensure_chat_indexes():
123
  # Conversations indexes
124
  # Unique index on participants: ONE conversation per user pair (not per listing)
125
  # This enables DM-style conversations where User A & B have a single thread
 
 
 
 
 
 
126
  await conversations_col.create_index(
127
  "participants",
128
  unique=True
 
79
  await users_col.create_index("role")
80
  await users_col.create_index("isActive")
81
 
82
+ # OTP indexes - handle TTL index conflict by dropping if options differ
83
+ try:
84
+ # Try to drop existing TTL index with different options
85
+ await otps_col.drop_index("createdAt_1")
86
+ except Exception:
87
+ pass # Index doesn't exist, that's fine
88
+
89
  await otps_col.create_index("identifier")
90
  await otps_col.create_index("purpose")
91
  await otps_col.create_index([("createdAt", 1)], expireAfterSeconds=900) # 15 min TTL
 
129
  # Conversations indexes
130
  # Unique index on participants: ONE conversation per user pair (not per listing)
131
  # This enables DM-style conversations where User A & B have a single thread
132
+ # Drop existing non-unique index if it exists to recreate with unique constraint
133
+ try:
134
+ await conversations_col.drop_index("participants_1")
135
+ except Exception:
136
+ pass # Index doesn't exist, that's fine
137
+
138
  await conversations_col.create_index(
139
  "participants",
140
  unique=True
app/models/conversation.py CHANGED
@@ -7,6 +7,15 @@ from typing import Optional
7
  class Conversation:
8
  """Conversation model for MongoDB"""
9
 
 
 
 
 
 
 
 
 
 
10
  @staticmethod
11
  def create_document(
12
  listing_id: str,
@@ -41,15 +50,25 @@ class Conversation:
41
  if not conversation_doc:
42
  return None
43
 
 
 
 
 
 
 
 
 
 
44
  return {
45
  "id": str(conversation_doc.get("_id", "")),
46
  "listing_id": conversation_doc.get("listing_id"),
47
  "participants": conversation_doc.get("participants", []),
48
  "listing_title": conversation_doc.get("listing_title"),
49
  "listing_image": conversation_doc.get("listing_image"),
50
- "last_message": conversation_doc.get("last_message"),
51
  "unread_count": conversation_doc.get("unread_count", {}),
52
  "status": conversation_doc.get("status", "active"),
53
- "created_at": conversation_doc.get("created_at"),
54
- "updated_at": conversation_doc.get("updated_at"),
55
  }
 
 
7
  class Conversation:
8
  """Conversation model for MongoDB"""
9
 
10
+ @staticmethod
11
+ def _serialize_datetime(dt) -> Optional[str]:
12
+ """Convert datetime to ISO string, handling None values"""
13
+ if dt is None:
14
+ return None
15
+ if isinstance(dt, datetime):
16
+ return dt.isoformat()
17
+ return dt # Already a string or other serializable type
18
+
19
  @staticmethod
20
  def create_document(
21
  listing_id: str,
 
50
  if not conversation_doc:
51
  return None
52
 
53
+ # Serialize last_message with proper datetime handling
54
+ last_message = conversation_doc.get("last_message")
55
+ if last_message:
56
+ last_message = {
57
+ "text": last_message.get("text"),
58
+ "sender_id": last_message.get("sender_id"),
59
+ "timestamp": Conversation._serialize_datetime(last_message.get("timestamp")),
60
+ }
61
+
62
  return {
63
  "id": str(conversation_doc.get("_id", "")),
64
  "listing_id": conversation_doc.get("listing_id"),
65
  "participants": conversation_doc.get("participants", []),
66
  "listing_title": conversation_doc.get("listing_title"),
67
  "listing_image": conversation_doc.get("listing_image"),
68
+ "last_message": last_message,
69
  "unread_count": conversation_doc.get("unread_count", {}),
70
  "status": conversation_doc.get("status", "active"),
71
+ "created_at": Conversation._serialize_datetime(conversation_doc.get("created_at")),
72
+ "updated_at": Conversation._serialize_datetime(conversation_doc.get("updated_at")),
73
  }
74
+
app/models/message.py CHANGED
@@ -2,7 +2,12 @@
2
  # app/models/message.py - Message Model
3
  # ============================================================
4
  from datetime import datetime
5
- from typing import Optional
 
 
 
 
 
6
 
7
  class Message:
8
  """Message model for MongoDB"""
@@ -31,26 +36,125 @@ class Message:
31
  "property_card": property_card, # {listing_id, title, price, currency, bedrooms, bathrooms, location, image_url, listing_type}
32
  "is_read": False,
33
  "read_at": None,
 
 
 
 
 
 
 
 
 
34
  "created_at": now,
35
  }
36
 
37
  @staticmethod
38
- def format_response(message_doc: dict) -> dict:
39
- """Format message document for API response"""
40
- if not message_doc:
41
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
  return {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  "id": str(message_doc.get("_id", "")),
45
  "conversation_id": message_doc.get("conversation_id"),
46
  "sender_id": message_doc.get("sender_id"),
47
  "sender_name": message_doc.get("sender_name"),
48
  "sender_avatar": message_doc.get("sender_avatar"),
49
  "message_type": message_doc.get("message_type"),
50
- "content": message_doc.get("content"),
51
- "media": message_doc.get("media"),
52
- "property_card": message_doc.get("property_card"),
53
  "is_read": message_doc.get("is_read", False),
54
- "read_at": message_doc.get("read_at"),
55
- "created_at": message_doc.get("created_at"),
 
 
 
 
 
 
 
 
 
 
 
56
  }
 
 
 
 
 
2
  # app/models/message.py - Message Model
3
  # ============================================================
4
  from datetime import datetime
5
+ from typing import Optional, List, Dict
6
+
7
+ # Time windows for message actions (in minutes)
8
+ EDIT_WINDOW_MINUTES = 15 # Can edit within 15 minutes
9
+ DELETE_FOR_EVERYONE_WINDOW_MINUTES = 60 # Can delete for everyone within 1 hour
10
+
11
 
12
  class Message:
13
  """Message model for MongoDB"""
 
36
  "property_card": property_card, # {listing_id, title, price, currency, bedrooms, bathrooms, location, image_url, listing_type}
37
  "is_read": False,
38
  "read_at": None,
39
+ # Edit tracking
40
+ "is_edited": False,
41
+ "edited_at": None,
42
+ # Deletion tracking
43
+ "is_deleted": False, # Deleted for everyone
44
+ "deleted_at": None,
45
+ "deleted_for": [], # List of user_ids who deleted this message for themselves
46
+ # Reactions: {emoji: [user_id1, user_id2, ...]}
47
+ "reactions": {},
48
  "created_at": now,
49
  }
50
 
51
  @staticmethod
52
+ def _serialize_datetime(dt) -> Optional[str]:
53
+ """Convert datetime to ISO string, handling None values"""
54
+ if dt is None:
55
  return None
56
+ if isinstance(dt, datetime):
57
+ return dt.isoformat()
58
+ return dt # Already a string or other serializable type
59
+
60
+ @staticmethod
61
+ def _calculate_action_availability(
62
+ message_doc: dict,
63
+ for_user_id: Optional[str] = None
64
+ ) -> dict:
65
+ """
66
+ Calculate whether edit and delete-for-everyone actions are available.
67
+
68
+ Returns:
69
+ dict with can_edit and can_delete_for_everyone flags
70
+ """
71
+ is_deleted = message_doc.get("is_deleted", False)
72
+ sender_id = message_doc.get("sender_id")
73
+ message_type = message_doc.get("message_type")
74
+ created_at = message_doc.get("created_at")
75
+
76
+ # Default: no actions available
77
+ can_edit = False
78
+ can_delete_for_everyone = False
79
+
80
+ # If message is deleted, no actions available
81
+ if is_deleted:
82
+ return {"can_edit": False, "can_delete_for_everyone": False}
83
+
84
+ # Only sender can edit/delete for everyone
85
+ is_sender = for_user_id and sender_id == for_user_id
86
+
87
+ if is_sender and created_at:
88
+ now = datetime.utcnow()
89
+ minutes_since = (now - created_at).total_seconds() / 60
90
+
91
+ # Edit: only text messages, within 15 minutes
92
+ if message_type == "text" and minutes_since <= EDIT_WINDOW_MINUTES:
93
+ can_edit = True
94
+
95
+ # Delete for everyone: within 1 hour
96
+ if minutes_since <= DELETE_FOR_EVERYONE_WINDOW_MINUTES:
97
+ can_delete_for_everyone = True
98
 
99
  return {
100
+ "can_edit": can_edit,
101
+ "can_delete_for_everyone": can_delete_for_everyone,
102
+ }
103
+
104
+ @staticmethod
105
+ def format_response(message_doc: dict, for_user_id: Optional[str] = None) -> Optional[dict]:
106
+ """
107
+ Format message document for API response.
108
+
109
+ Args:
110
+ message_doc: The message document from MongoDB
111
+ for_user_id: If provided, checks if message is deleted for this user
112
+ and calculates action availability
113
+
114
+ Returns:
115
+ Formatted message dict, or None if deleted for the requesting user
116
+ """
117
+ if not message_doc:
118
+ return None
119
+
120
+ # If message is deleted for everyone, show placeholder
121
+ is_deleted = message_doc.get("is_deleted", False)
122
+
123
+ # If deleted specifically for this user, don't return the message
124
+ deleted_for = message_doc.get("deleted_for", [])
125
+ if for_user_id and for_user_id in deleted_for:
126
+ return None # Message was "deleted for me" by this user
127
+
128
+ # Calculate action availability for frontend
129
+ action_availability = Message._calculate_action_availability(message_doc, for_user_id)
130
+
131
+ # Build response
132
+ response = {
133
  "id": str(message_doc.get("_id", "")),
134
  "conversation_id": message_doc.get("conversation_id"),
135
  "sender_id": message_doc.get("sender_id"),
136
  "sender_name": message_doc.get("sender_name"),
137
  "sender_avatar": message_doc.get("sender_avatar"),
138
  "message_type": message_doc.get("message_type"),
139
+ "content": message_doc.get("content") if not is_deleted else None,
140
+ "media": message_doc.get("media") if not is_deleted else None,
141
+ "property_card": message_doc.get("property_card") if not is_deleted else None,
142
  "is_read": message_doc.get("is_read", False),
143
+ "read_at": Message._serialize_datetime(message_doc.get("read_at")),
144
+ # Edit fields
145
+ "is_edited": message_doc.get("is_edited", False),
146
+ "edited_at": Message._serialize_datetime(message_doc.get("edited_at")),
147
+ # Deletion status
148
+ "is_deleted": is_deleted,
149
+ "deleted_at": Message._serialize_datetime(message_doc.get("deleted_at")) if is_deleted else None,
150
+ # Reactions
151
+ "reactions": message_doc.get("reactions", {}),
152
+ "created_at": Message._serialize_datetime(message_doc.get("created_at")),
153
+ # Action availability flags for frontend UI
154
+ "can_edit": action_availability["can_edit"],
155
+ "can_delete_for_everyone": action_availability["can_delete_for_everyone"],
156
  }
157
+
158
+ return response
159
+
160
+
app/routes/conversations.py CHANGED
@@ -275,3 +275,180 @@ async def get_bulk_online_status(
275
  "success": True,
276
  "data": statuses,
277
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
  "success": True,
276
  "data": statuses,
277
  }
278
+
279
+
280
+ # ============================================================
281
+ # MESSAGE ACTIONS: Edit, Delete, Reactions, Clear Chat
282
+ # ============================================================
283
+
284
+ class EditMessageDto(BaseModel):
285
+ content: str
286
+
287
+
288
+ class ReactionDto(BaseModel):
289
+ emoji: str
290
+
291
+
292
+ @router.put("/{conversation_id}/messages/{message_id}", status_code=status.HTTP_200_OK)
293
+ async def edit_message(
294
+ conversation_id: str,
295
+ message_id: str,
296
+ dto: EditMessageDto,
297
+ current_user: dict = Depends(get_current_user),
298
+ ):
299
+ """
300
+ Edit a message (REST fallback).
301
+
302
+ - Only the sender can edit their own message
303
+ - Edit window: 15 minutes after sending
304
+ - Only text messages can be edited
305
+
306
+ The edit is broadcast to all participants via WebSocket if they're connected.
307
+ """
308
+ user_id = current_user.get("user_id") or current_user.get("sub")
309
+
310
+ if not user_id:
311
+ raise HTTPException(
312
+ status_code=status.HTTP_401_UNAUTHORIZED,
313
+ detail="Could not extract user ID from token"
314
+ )
315
+
316
+ logger.info(f"Edit message: conversation={conversation_id}, message={message_id}, user={user_id}")
317
+
318
+ result = await conversation_service.edit_message(
319
+ conversation_id=conversation_id,
320
+ message_id=message_id,
321
+ user_id=user_id,
322
+ new_content=dto.content,
323
+ )
324
+
325
+ return result
326
+
327
+
328
+ @router.delete("/{conversation_id}/messages/{message_id}", status_code=status.HTTP_200_OK)
329
+ async def delete_message(
330
+ conversation_id: str,
331
+ message_id: str,
332
+ delete_for: str = Query("me", regex="^(everyone|me)$"),
333
+ current_user: dict = Depends(get_current_user),
334
+ ):
335
+ """
336
+ Delete a message (REST fallback).
337
+
338
+ - **delete_for=everyone**: Delete for all participants (sender only, within 1 hour)
339
+ - **delete_for=me**: Delete only for yourself
340
+
341
+ The deletion is broadcast via WebSocket if deleting for everyone.
342
+ """
343
+ user_id = current_user.get("user_id") or current_user.get("sub")
344
+
345
+ if not user_id:
346
+ raise HTTPException(
347
+ status_code=status.HTTP_401_UNAUTHORIZED,
348
+ detail="Could not extract user ID from token"
349
+ )
350
+
351
+ logger.info(f"Delete message: conversation={conversation_id}, message={message_id}, for={delete_for}, user={user_id}")
352
+
353
+ result = await conversation_service.delete_message(
354
+ conversation_id=conversation_id,
355
+ message_id=message_id,
356
+ user_id=user_id,
357
+ delete_for=delete_for,
358
+ )
359
+
360
+ return result
361
+
362
+
363
+ @router.post("/{conversation_id}/messages/{message_id}/reactions", status_code=status.HTTP_200_OK)
364
+ async def add_reaction(
365
+ conversation_id: str,
366
+ message_id: str,
367
+ dto: ReactionDto,
368
+ current_user: dict = Depends(get_current_user),
369
+ ):
370
+ """
371
+ Add an emoji reaction to a message (REST fallback).
372
+
373
+ The reaction is broadcast to all participants via WebSocket.
374
+ """
375
+ user_id = current_user.get("user_id") or current_user.get("sub")
376
+
377
+ if not user_id:
378
+ raise HTTPException(
379
+ status_code=status.HTTP_401_UNAUTHORIZED,
380
+ detail="Could not extract user ID from token"
381
+ )
382
+
383
+ logger.info(f"Add reaction: conversation={conversation_id}, message={message_id}, emoji={dto.emoji}, user={user_id}")
384
+
385
+ result = await conversation_service.add_reaction(
386
+ conversation_id=conversation_id,
387
+ message_id=message_id,
388
+ user_id=user_id,
389
+ emoji=dto.emoji,
390
+ )
391
+
392
+ return result
393
+
394
+
395
+ @router.delete("/{conversation_id}/messages/{message_id}/reactions/{emoji}", status_code=status.HTTP_200_OK)
396
+ async def remove_reaction(
397
+ conversation_id: str,
398
+ message_id: str,
399
+ emoji: str,
400
+ current_user: dict = Depends(get_current_user),
401
+ ):
402
+ """
403
+ Remove an emoji reaction from a message (REST fallback).
404
+
405
+ The removal is broadcast to all participants via WebSocket.
406
+ """
407
+ user_id = current_user.get("user_id") or current_user.get("sub")
408
+
409
+ if not user_id:
410
+ raise HTTPException(
411
+ status_code=status.HTTP_401_UNAUTHORIZED,
412
+ detail="Could not extract user ID from token"
413
+ )
414
+
415
+ logger.info(f"Remove reaction: conversation={conversation_id}, message={message_id}, emoji={emoji}, user={user_id}")
416
+
417
+ result = await conversation_service.remove_reaction(
418
+ conversation_id=conversation_id,
419
+ message_id=message_id,
420
+ user_id=user_id,
421
+ emoji=emoji,
422
+ )
423
+
424
+ return result
425
+
426
+
427
+ @router.delete("/{conversation_id}/clear", status_code=status.HTTP_200_OK)
428
+ async def clear_chat(
429
+ conversation_id: str,
430
+ current_user: dict = Depends(get_current_user),
431
+ ):
432
+ """
433
+ Clear all messages in a conversation for the current user only.
434
+
435
+ Other participants will still see the messages.
436
+ This operation is NOT broadcast to other users.
437
+ """
438
+ user_id = current_user.get("user_id") or current_user.get("sub")
439
+
440
+ if not user_id:
441
+ raise HTTPException(
442
+ status_code=status.HTTP_401_UNAUTHORIZED,
443
+ detail="Could not extract user ID from token"
444
+ )
445
+
446
+ logger.info(f"Clear chat: conversation={conversation_id}, user={user_id}")
447
+
448
+ result = await conversation_service.clear_chat(
449
+ conversation_id=conversation_id,
450
+ user_id=user_id,
451
+ )
452
+
453
+ return result
454
+
app/routes/websocket_chat.py CHANGED
@@ -106,6 +106,11 @@ async def websocket_chat_endpoint(websocket: WebSocket, token: str = Query(...))
106
  - typing: User is typing
107
  - mark_read: Mark messages as read
108
  - heartbeat: Keep connection alive
 
 
 
 
 
109
 
110
  Message Types (Server -> Client):
111
  - new_message: New message received
@@ -113,6 +118,11 @@ async def websocket_chat_endpoint(websocket: WebSocket, token: str = Query(...))
113
  - message_read: Messages were read
114
  - user_status_changed: User came online/offline
115
  - heartbeat_ack: Response to heartbeat
 
 
 
 
 
116
  """
117
 
118
  # Verify JWT token
@@ -188,6 +198,23 @@ async def websocket_chat_endpoint(websocket: WebSocket, token: str = Query(...))
188
  "action": "online_status",
189
  "statuses": statuses
190
  })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
 
192
  except WebSocketDisconnect:
193
  chat_manager.disconnect(websocket)
@@ -397,6 +424,388 @@ async def broadcast_user_status(user_id: str, is_online: bool):
397
  await chat_manager.send_to_user(participant_id, status_message)
398
 
399
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
400
  # Export manager for use in other modules
401
  def get_chat_manager():
402
  return chat_manager
 
 
106
  - typing: User is typing
107
  - mark_read: Mark messages as read
108
  - heartbeat: Keep connection alive
109
+ - edit_message: Edit a message (sender only)
110
+ - delete_message: Delete a message (for everyone or for me)
111
+ - add_reaction: Add emoji reaction to a message
112
+ - remove_reaction: Remove emoji reaction from a message
113
+ - clear_chat: Clear all messages for current user
114
 
115
  Message Types (Server -> Client):
116
  - new_message: New message received
 
118
  - message_read: Messages were read
119
  - user_status_changed: User came online/offline
120
  - heartbeat_ack: Response to heartbeat
121
+ - message_edited: A message was edited
122
+ - message_deleted: A message was deleted for everyone
123
+ - reaction_added: Reaction added to a message
124
+ - reaction_removed: Reaction removed from a message
125
+ - chat_cleared: Chat was cleared (confirmation)
126
  """
127
 
128
  # Verify JWT token
 
198
  "action": "online_status",
199
  "statuses": statuses
200
  })
201
+
202
+ # === NEW ACTIONS ===
203
+
204
+ elif action == "edit_message":
205
+ await handle_edit_message(user_id, message)
206
+
207
+ elif action == "delete_message":
208
+ await handle_delete_message(user_id, message)
209
+
210
+ elif action == "add_reaction":
211
+ await handle_add_reaction(user_id, message)
212
+
213
+ elif action == "remove_reaction":
214
+ await handle_remove_reaction(user_id, message)
215
+
216
+ elif action == "clear_chat":
217
+ await handle_clear_chat(user_id, websocket, message)
218
 
219
  except WebSocketDisconnect:
220
  chat_manager.disconnect(websocket)
 
424
  await chat_manager.send_to_user(participant_id, status_message)
425
 
426
 
427
+ # ============================================================
428
+ # NEW HANDLERS: Edit, Delete, Reactions, Clear Chat
429
+ # ============================================================
430
+
431
+ async def handle_edit_message(user_id: str, data: dict):
432
+ """
433
+ Handle editing a message via WebSocket.
434
+ Only the sender can edit, within 24 hours, text messages only.
435
+ Broadcasts the edit to all participants.
436
+ """
437
+ conversation_id = data.get("conversation_id")
438
+ message_id = data.get("message_id")
439
+ new_content = data.get("new_content", "").strip()
440
+
441
+ if not conversation_id or not message_id or not new_content:
442
+ return
443
+
444
+ db = await get_db()
445
+
446
+ # Validate IDs
447
+ if not ObjectId.is_valid(conversation_id) or not ObjectId.is_valid(message_id):
448
+ return
449
+
450
+ # Get the message
451
+ message_doc = await db.messages.find_one({"_id": ObjectId(message_id)})
452
+ if not message_doc:
453
+ return
454
+
455
+ # Verify sender is the one editing
456
+ if message_doc.get("sender_id") != user_id:
457
+ logger.warning(f"[Chat WS] User {user_id} tried to edit message they didn't send")
458
+ return
459
+
460
+ # Verify message is in the correct conversation
461
+ if message_doc.get("conversation_id") != conversation_id:
462
+ return
463
+
464
+ # Only allow editing text messages
465
+ if message_doc.get("message_type") != "text":
466
+ logger.warning(f"[Chat WS] Cannot edit non-text message")
467
+ return
468
+
469
+ # Check 15-minute edit window
470
+ created_at = message_doc.get("created_at")
471
+ if created_at:
472
+ minutes_since = (datetime.utcnow() - created_at).total_seconds() / 60
473
+ if minutes_since > 15:
474
+ logger.warning(f"[Chat WS] Edit window expired for message {message_id}")
475
+ return
476
+
477
+ # Cannot edit deleted messages
478
+ if message_doc.get("is_deleted"):
479
+ return
480
+
481
+ # Update the message
482
+ now = datetime.utcnow()
483
+ await db.messages.update_one(
484
+ {"_id": ObjectId(message_id)},
485
+ {
486
+ "$set": {
487
+ "content": new_content,
488
+ "is_edited": True,
489
+ "edited_at": now,
490
+ }
491
+ }
492
+ )
493
+
494
+ # Get conversation for participants
495
+ conversation = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
496
+ if not conversation:
497
+ return
498
+
499
+ # Broadcast to all participants
500
+ edit_broadcast = {
501
+ "action": "message_edited",
502
+ "conversation_id": conversation_id,
503
+ "message_id": message_id,
504
+ "new_content": new_content,
505
+ "edited_at": now.isoformat(),
506
+ "edited_by": user_id,
507
+ }
508
+
509
+ await chat_manager.broadcast_to_conversation(
510
+ conversation_id,
511
+ conversation["participants"],
512
+ edit_broadcast
513
+ )
514
+
515
+ logger.info(f"[Chat WS] Message {message_id} edited by {user_id}")
516
+
517
+
518
+ async def handle_delete_message(user_id: str, data: dict):
519
+ """
520
+ Handle deleting a message via WebSocket.
521
+
522
+ delete_for options:
523
+ - "everyone": Marks message as deleted for all (sender only, within 1 hour)
524
+ - "me": Hides message only for the requesting user
525
+ """
526
+ conversation_id = data.get("conversation_id")
527
+ message_id = data.get("message_id")
528
+ delete_for = data.get("delete_for", "me") # "everyone" or "me"
529
+
530
+ if not conversation_id or not message_id:
531
+ return
532
+
533
+ db = await get_db()
534
+
535
+ # Validate IDs
536
+ if not ObjectId.is_valid(conversation_id) or not ObjectId.is_valid(message_id):
537
+ return
538
+
539
+ # Get the message
540
+ message_doc = await db.messages.find_one({"_id": ObjectId(message_id)})
541
+ if not message_doc:
542
+ return
543
+
544
+ # Verify message is in the correct conversation
545
+ if message_doc.get("conversation_id") != conversation_id:
546
+ return
547
+
548
+ # Get conversation for validation and broadcast
549
+ conversation = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
550
+ if not conversation:
551
+ return
552
+
553
+ # Verify user is a participant
554
+ if user_id not in conversation.get("participants", []):
555
+ return
556
+
557
+ now = datetime.utcnow()
558
+
559
+ if delete_for == "everyone":
560
+ # Only sender can delete for everyone
561
+ if message_doc.get("sender_id") != user_id:
562
+ logger.warning(f"[Chat WS] User {user_id} tried to delete message for everyone but isn't sender")
563
+ return
564
+
565
+ # Check 1-hour delete window
566
+ created_at = message_doc.get("created_at")
567
+ if created_at:
568
+ hours_since = (datetime.utcnow() - created_at).total_seconds() / 3600
569
+ if hours_since > 1:
570
+ logger.warning(f"[Chat WS] Delete-for-everyone window expired for message {message_id}")
571
+ return
572
+
573
+ # Mark as deleted for everyone
574
+ await db.messages.update_one(
575
+ {"_id": ObjectId(message_id)},
576
+ {
577
+ "$set": {
578
+ "is_deleted": True,
579
+ "deleted_at": now,
580
+ }
581
+ }
582
+ )
583
+
584
+ # Broadcast to all participants
585
+ delete_broadcast = {
586
+ "action": "message_deleted",
587
+ "conversation_id": conversation_id,
588
+ "message_id": message_id,
589
+ "deleted_for": "everyone",
590
+ "deleted_at": now.isoformat(),
591
+ "deleted_by": user_id,
592
+ }
593
+
594
+ await chat_manager.broadcast_to_conversation(
595
+ conversation_id,
596
+ conversation["participants"],
597
+ delete_broadcast
598
+ )
599
+
600
+ logger.info(f"[Chat WS] Message {message_id} deleted for everyone by {user_id}")
601
+
602
+ else: # delete_for == "me"
603
+ # Add user to deleted_for list
604
+ await db.messages.update_one(
605
+ {"_id": ObjectId(message_id)},
606
+ {
607
+ "$addToSet": {"deleted_for": user_id}
608
+ }
609
+ )
610
+
611
+ # Only send confirmation to the requesting user (not broadcast)
612
+ delete_confirm = {
613
+ "action": "message_deleted",
614
+ "conversation_id": conversation_id,
615
+ "message_id": message_id,
616
+ "deleted_for": "me",
617
+ "deleted_at": now.isoformat(),
618
+ }
619
+
620
+ await chat_manager.send_to_user(user_id, delete_confirm)
621
+
622
+ logger.info(f"[Chat WS] Message {message_id} deleted for {user_id} only")
623
+
624
+
625
+ async def handle_add_reaction(user_id: str, data: dict):
626
+ """
627
+ Handle adding an emoji reaction to a message.
628
+ Broadcasts the reaction to all participants.
629
+ """
630
+ conversation_id = data.get("conversation_id")
631
+ message_id = data.get("message_id")
632
+ emoji = data.get("emoji", "").strip()
633
+
634
+ if not conversation_id or not message_id or not emoji:
635
+ return
636
+
637
+ db = await get_db()
638
+
639
+ # Validate IDs
640
+ if not ObjectId.is_valid(conversation_id) or not ObjectId.is_valid(message_id):
641
+ return
642
+
643
+ # Get the message
644
+ message_doc = await db.messages.find_one({"_id": ObjectId(message_id)})
645
+ if not message_doc:
646
+ return
647
+
648
+ # Verify message is in the correct conversation
649
+ if message_doc.get("conversation_id") != conversation_id:
650
+ return
651
+
652
+ # Get conversation for validation and broadcast
653
+ conversation = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
654
+ if not conversation:
655
+ return
656
+
657
+ # Verify user is a participant
658
+ if user_id not in conversation.get("participants", []):
659
+ return
660
+
661
+ # Cannot react to deleted messages
662
+ if message_doc.get("is_deleted"):
663
+ return
664
+
665
+ # Add reaction: reactions is a dict like {"👍": ["user1", "user2"]}
666
+ await db.messages.update_one(
667
+ {"_id": ObjectId(message_id)},
668
+ {
669
+ "$addToSet": {f"reactions.{emoji}": user_id}
670
+ }
671
+ )
672
+
673
+ # Broadcast to all participants
674
+ reaction_broadcast = {
675
+ "action": "reaction_added",
676
+ "conversation_id": conversation_id,
677
+ "message_id": message_id,
678
+ "emoji": emoji,
679
+ "user_id": user_id,
680
+ }
681
+
682
+ await chat_manager.broadcast_to_conversation(
683
+ conversation_id,
684
+ conversation["participants"],
685
+ reaction_broadcast
686
+ )
687
+
688
+ logger.info(f"[Chat WS] Reaction {emoji} added to message {message_id} by {user_id}")
689
+
690
+
691
+ async def handle_remove_reaction(user_id: str, data: dict):
692
+ """
693
+ Handle removing an emoji reaction from a message.
694
+ Broadcasts the removal to all participants.
695
+ """
696
+ conversation_id = data.get("conversation_id")
697
+ message_id = data.get("message_id")
698
+ emoji = data.get("emoji", "").strip()
699
+
700
+ if not conversation_id or not message_id or not emoji:
701
+ return
702
+
703
+ db = await get_db()
704
+
705
+ # Validate IDs
706
+ if not ObjectId.is_valid(conversation_id) or not ObjectId.is_valid(message_id):
707
+ return
708
+
709
+ # Get the message
710
+ message_doc = await db.messages.find_one({"_id": ObjectId(message_id)})
711
+ if not message_doc:
712
+ return
713
+
714
+ # Verify message is in the correct conversation
715
+ if message_doc.get("conversation_id") != conversation_id:
716
+ return
717
+
718
+ # Get conversation for validation and broadcast
719
+ conversation = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
720
+ if not conversation:
721
+ return
722
+
723
+ # Verify user is a participant
724
+ if user_id not in conversation.get("participants", []):
725
+ return
726
+
727
+ # Remove user from the emoji's reactions list
728
+ await db.messages.update_one(
729
+ {"_id": ObjectId(message_id)},
730
+ {
731
+ "$pull": {f"reactions.{emoji}": user_id}
732
+ }
733
+ )
734
+
735
+ # Clean up empty reaction arrays (optional optimization)
736
+ # If an emoji has no more users, remove the key
737
+ updated_msg = await db.messages.find_one({"_id": ObjectId(message_id)})
738
+ if updated_msg:
739
+ reactions = updated_msg.get("reactions", {})
740
+ if emoji in reactions and len(reactions[emoji]) == 0:
741
+ await db.messages.update_one(
742
+ {"_id": ObjectId(message_id)},
743
+ {"$unset": {f"reactions.{emoji}": ""}}
744
+ )
745
+
746
+ # Broadcast to all participants
747
+ reaction_broadcast = {
748
+ "action": "reaction_removed",
749
+ "conversation_id": conversation_id,
750
+ "message_id": message_id,
751
+ "emoji": emoji,
752
+ "user_id": user_id,
753
+ }
754
+
755
+ await chat_manager.broadcast_to_conversation(
756
+ conversation_id,
757
+ conversation["participants"],
758
+ reaction_broadcast
759
+ )
760
+
761
+ logger.info(f"[Chat WS] Reaction {emoji} removed from message {message_id} by {user_id}")
762
+
763
+
764
+ async def handle_clear_chat(user_id: str, websocket: WebSocket, data: dict):
765
+ """
766
+ Handle clearing all messages in a conversation for the current user only.
767
+ Other participants still see the messages.
768
+ """
769
+ conversation_id = data.get("conversation_id")
770
+
771
+ if not conversation_id:
772
+ return
773
+
774
+ db = await get_db()
775
+
776
+ # Validate ID
777
+ if not ObjectId.is_valid(conversation_id):
778
+ return
779
+
780
+ # Get conversation for validation
781
+ conversation = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
782
+ if not conversation:
783
+ return
784
+
785
+ # Verify user is a participant
786
+ if user_id not in conversation.get("participants", []):
787
+ return
788
+
789
+ # Add user to deleted_for for ALL messages in this conversation
790
+ result = await db.messages.update_many(
791
+ {"conversation_id": conversation_id},
792
+ {"$addToSet": {"deleted_for": user_id}}
793
+ )
794
+
795
+ # Send confirmation to the requesting user only
796
+ clear_confirm = {
797
+ "action": "chat_cleared",
798
+ "conversation_id": conversation_id,
799
+ "cleared_count": result.modified_count,
800
+ "cleared_at": datetime.utcnow().isoformat(),
801
+ }
802
+
803
+ await websocket.send_json(clear_confirm)
804
+
805
+ logger.info(f"[Chat WS] Chat {conversation_id} cleared for {user_id} ({result.modified_count} messages)")
806
+
807
+
808
  # Export manager for use in other modules
809
  def get_chat_manager():
810
  return chat_manager
811
+
app/services/conversation_service.py CHANGED
@@ -171,8 +171,11 @@ class ConversationService:
171
  detail="You are not a participant in this conversation"
172
  )
173
 
174
- # Build query
175
- query = {"conversation_id": conversation_id}
 
 
 
176
 
177
  if before_id and ObjectId.is_valid(before_id):
178
  query["_id"] = {"$lt": ObjectId(before_id)}
@@ -183,7 +186,10 @@ class ConversationService:
183
 
184
  messages = []
185
  async for doc in cursor:
186
- messages.append(Message.format_response(doc))
 
 
 
187
 
188
  logger.info(f"Found {len(messages)} messages for conversation {conversation_id}")
189
 
@@ -331,7 +337,365 @@ class ConversationService:
331
  "success": True,
332
  "message": "Messages marked as read",
333
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
334
 
335
 
336
  # Singleton instance
337
  conversation_service = ConversationService()
 
 
171
  detail="You are not a participant in this conversation"
172
  )
173
 
174
+ # Build query - exclude messages deleted specifically for this user
175
+ query = {
176
+ "conversation_id": conversation_id,
177
+ "deleted_for": {"$ne": current_user_id}, # Exclude messages deleted for this user
178
+ }
179
 
180
  if before_id and ObjectId.is_valid(before_id):
181
  query["_id"] = {"$lt": ObjectId(before_id)}
 
186
 
187
  messages = []
188
  async for doc in cursor:
189
+ # Pass user_id for proper filtering in format_response
190
+ formatted = Message.format_response(doc, for_user_id=current_user_id)
191
+ if formatted: # Only add if not None (handles edge cases)
192
+ messages.append(formatted)
193
 
194
  logger.info(f"Found {len(messages)} messages for conversation {conversation_id}")
195
 
 
337
  "success": True,
338
  "message": "Messages marked as read",
339
  }
340
+
341
+ # ============================================================
342
+ # NEW METHODS: Edit, Delete, Reactions, Clear Chat
343
+ # ============================================================
344
+
345
+ async def edit_message(
346
+ self,
347
+ conversation_id: str,
348
+ message_id: str,
349
+ user_id: str,
350
+ new_content: str,
351
+ ) -> dict:
352
+ """
353
+ Edit a message content.
354
+ Only the sender can edit, within 24 hours, text messages only.
355
+ """
356
+ db = await get_db()
357
+
358
+ # Validate IDs
359
+ if not ObjectId.is_valid(conversation_id) or not ObjectId.is_valid(message_id):
360
+ raise HTTPException(
361
+ status_code=status.HTTP_400_BAD_REQUEST,
362
+ detail="Invalid ID format"
363
+ )
364
+
365
+ # Get the message
366
+ message_doc = await db.messages.find_one({"_id": ObjectId(message_id)})
367
+ if not message_doc:
368
+ raise HTTPException(
369
+ status_code=status.HTTP_404_NOT_FOUND,
370
+ detail="Message not found"
371
+ )
372
+
373
+ # Verify sender
374
+ if message_doc.get("sender_id") != user_id:
375
+ raise HTTPException(
376
+ status_code=status.HTTP_403_FORBIDDEN,
377
+ detail="You can only edit your own messages"
378
+ )
379
+
380
+ # Verify message is in the correct conversation
381
+ if message_doc.get("conversation_id") != conversation_id:
382
+ raise HTTPException(
383
+ status_code=status.HTTP_400_BAD_REQUEST,
384
+ detail="Message does not belong to this conversation"
385
+ )
386
+
387
+ # Only allow editing text messages
388
+ if message_doc.get("message_type") != "text":
389
+ raise HTTPException(
390
+ status_code=status.HTTP_400_BAD_REQUEST,
391
+ detail="Only text messages can be edited"
392
+ )
393
+
394
+ # Check 15-minute edit window
395
+ created_at = message_doc.get("created_at")
396
+ if created_at:
397
+ minutes_since = (datetime.utcnow() - created_at).total_seconds() / 60
398
+ if minutes_since > 15:
399
+ raise HTTPException(
400
+ status_code=status.HTTP_400_BAD_REQUEST,
401
+ detail="Edit window expired (15 minutes)"
402
+ )
403
+
404
+ # Cannot edit deleted messages
405
+ if message_doc.get("is_deleted"):
406
+ raise HTTPException(
407
+ status_code=status.HTTP_400_BAD_REQUEST,
408
+ detail="Cannot edit a deleted message"
409
+ )
410
+
411
+ # Update the message
412
+ now = datetime.utcnow()
413
+ await db.messages.update_one(
414
+ {"_id": ObjectId(message_id)},
415
+ {
416
+ "$set": {
417
+ "content": new_content.strip(),
418
+ "is_edited": True,
419
+ "edited_at": now,
420
+ }
421
+ }
422
+ )
423
+
424
+ logger.info(f"Message {message_id} edited by {user_id}")
425
+
426
+ return {
427
+ "success": True,
428
+ "message_id": message_id,
429
+ "new_content": new_content.strip(),
430
+ "edited_at": now.isoformat(),
431
+ }
432
+
433
+ async def delete_message(
434
+ self,
435
+ conversation_id: str,
436
+ message_id: str,
437
+ user_id: str,
438
+ delete_for: str = "me", # "everyone" or "me"
439
+ ) -> dict:
440
+ """
441
+ Delete a message.
442
+ - "everyone": Only sender, within 1 hour
443
+ - "me": Any participant
444
+ """
445
+ db = await get_db()
446
+
447
+ # Validate IDs
448
+ if not ObjectId.is_valid(conversation_id) or not ObjectId.is_valid(message_id):
449
+ raise HTTPException(
450
+ status_code=status.HTTP_400_BAD_REQUEST,
451
+ detail="Invalid ID format"
452
+ )
453
+
454
+ # Get the message
455
+ message_doc = await db.messages.find_one({"_id": ObjectId(message_id)})
456
+ if not message_doc:
457
+ raise HTTPException(
458
+ status_code=status.HTTP_404_NOT_FOUND,
459
+ detail="Message not found"
460
+ )
461
+
462
+ # Verify message is in the correct conversation
463
+ if message_doc.get("conversation_id") != conversation_id:
464
+ raise HTTPException(
465
+ status_code=status.HTTP_400_BAD_REQUEST,
466
+ detail="Message does not belong to this conversation"
467
+ )
468
+
469
+ # Verify user is a participant
470
+ conversation = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
471
+ if not conversation or user_id not in conversation.get("participants", []):
472
+ raise HTTPException(
473
+ status_code=status.HTTP_403_FORBIDDEN,
474
+ detail="You are not a participant in this conversation"
475
+ )
476
+
477
+ now = datetime.utcnow()
478
+
479
+ if delete_for == "everyone":
480
+ # Only sender can delete for everyone
481
+ if message_doc.get("sender_id") != user_id:
482
+ raise HTTPException(
483
+ status_code=status.HTTP_403_FORBIDDEN,
484
+ detail="Only the sender can delete for everyone"
485
+ )
486
+
487
+ # Check 1-hour delete window
488
+ created_at = message_doc.get("created_at")
489
+ if created_at:
490
+ hours_since = (datetime.utcnow() - created_at).total_seconds() / 3600
491
+ if hours_since > 1:
492
+ raise HTTPException(
493
+ status_code=status.HTTP_400_BAD_REQUEST,
494
+ detail="Delete-for-everyone window expired (1 hour)"
495
+ )
496
+
497
+ # Mark as deleted for everyone
498
+ await db.messages.update_one(
499
+ {"_id": ObjectId(message_id)},
500
+ {
501
+ "$set": {
502
+ "is_deleted": True,
503
+ "deleted_at": now,
504
+ }
505
+ }
506
+ )
507
+
508
+ logger.info(f"Message {message_id} deleted for everyone by {user_id}")
509
+
510
+ return {
511
+ "success": True,
512
+ "message_id": message_id,
513
+ "deleted_for": "everyone",
514
+ "deleted_at": now.isoformat(),
515
+ }
516
+
517
+ else: # delete_for == "me"
518
+ # Add user to deleted_for list
519
+ await db.messages.update_one(
520
+ {"_id": ObjectId(message_id)},
521
+ {"$addToSet": {"deleted_for": user_id}}
522
+ )
523
+
524
+ logger.info(f"Message {message_id} deleted for {user_id} only")
525
+
526
+ return {
527
+ "success": True,
528
+ "message_id": message_id,
529
+ "deleted_for": "me",
530
+ "deleted_at": now.isoformat(),
531
+ }
532
+
533
+ async def add_reaction(
534
+ self,
535
+ conversation_id: str,
536
+ message_id: str,
537
+ user_id: str,
538
+ emoji: str,
539
+ ) -> dict:
540
+ """Add an emoji reaction to a message."""
541
+ db = await get_db()
542
+
543
+ # Validate IDs
544
+ if not ObjectId.is_valid(conversation_id) or not ObjectId.is_valid(message_id):
545
+ raise HTTPException(
546
+ status_code=status.HTTP_400_BAD_REQUEST,
547
+ detail="Invalid ID format"
548
+ )
549
+
550
+ # Get the message
551
+ message_doc = await db.messages.find_one({"_id": ObjectId(message_id)})
552
+ if not message_doc:
553
+ raise HTTPException(
554
+ status_code=status.HTTP_404_NOT_FOUND,
555
+ detail="Message not found"
556
+ )
557
+
558
+ # Verify message is in the correct conversation
559
+ if message_doc.get("conversation_id") != conversation_id:
560
+ raise HTTPException(
561
+ status_code=status.HTTP_400_BAD_REQUEST,
562
+ detail="Message does not belong to this conversation"
563
+ )
564
+
565
+ # Verify user is a participant
566
+ conversation = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
567
+ if not conversation or user_id not in conversation.get("participants", []):
568
+ raise HTTPException(
569
+ status_code=status.HTTP_403_FORBIDDEN,
570
+ detail="You are not a participant in this conversation"
571
+ )
572
+
573
+ # Cannot react to deleted messages
574
+ if message_doc.get("is_deleted"):
575
+ raise HTTPException(
576
+ status_code=status.HTTP_400_BAD_REQUEST,
577
+ detail="Cannot react to a deleted message"
578
+ )
579
+
580
+ # Add reaction
581
+ await db.messages.update_one(
582
+ {"_id": ObjectId(message_id)},
583
+ {"$addToSet": {f"reactions.{emoji}": user_id}}
584
+ )
585
+
586
+ logger.info(f"Reaction {emoji} added to message {message_id} by {user_id}")
587
+
588
+ return {
589
+ "success": True,
590
+ "message_id": message_id,
591
+ "emoji": emoji,
592
+ "user_id": user_id,
593
+ }
594
+
595
+ async def remove_reaction(
596
+ self,
597
+ conversation_id: str,
598
+ message_id: str,
599
+ user_id: str,
600
+ emoji: str,
601
+ ) -> dict:
602
+ """Remove an emoji reaction from a message."""
603
+ db = await get_db()
604
+
605
+ # Validate IDs
606
+ if not ObjectId.is_valid(conversation_id) or not ObjectId.is_valid(message_id):
607
+ raise HTTPException(
608
+ status_code=status.HTTP_400_BAD_REQUEST,
609
+ detail="Invalid ID format"
610
+ )
611
+
612
+ # Get the message
613
+ message_doc = await db.messages.find_one({"_id": ObjectId(message_id)})
614
+ if not message_doc:
615
+ raise HTTPException(
616
+ status_code=status.HTTP_404_NOT_FOUND,
617
+ detail="Message not found"
618
+ )
619
+
620
+ # Verify message is in the correct conversation
621
+ if message_doc.get("conversation_id") != conversation_id:
622
+ raise HTTPException(
623
+ status_code=status.HTTP_400_BAD_REQUEST,
624
+ detail="Message does not belong to this conversation"
625
+ )
626
+
627
+ # Verify user is a participant
628
+ conversation = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
629
+ if not conversation or user_id not in conversation.get("participants", []):
630
+ raise HTTPException(
631
+ status_code=status.HTTP_403_FORBIDDEN,
632
+ detail="You are not a participant in this conversation"
633
+ )
634
+
635
+ # Remove reaction
636
+ await db.messages.update_one(
637
+ {"_id": ObjectId(message_id)},
638
+ {"$pull": {f"reactions.{emoji}": user_id}}
639
+ )
640
+
641
+ # Clean up empty reaction arrays
642
+ updated_msg = await db.messages.find_one({"_id": ObjectId(message_id)})
643
+ if updated_msg:
644
+ reactions = updated_msg.get("reactions", {})
645
+ if emoji in reactions and len(reactions[emoji]) == 0:
646
+ await db.messages.update_one(
647
+ {"_id": ObjectId(message_id)},
648
+ {"$unset": {f"reactions.{emoji}": ""}}
649
+ )
650
+
651
+ logger.info(f"Reaction {emoji} removed from message {message_id} by {user_id}")
652
+
653
+ return {
654
+ "success": True,
655
+ "message_id": message_id,
656
+ "emoji": emoji,
657
+ "user_id": user_id,
658
+ }
659
+
660
+ async def clear_chat(
661
+ self,
662
+ conversation_id: str,
663
+ user_id: str,
664
+ ) -> dict:
665
+ """Clear all messages in a conversation for the current user only."""
666
+ db = await get_db()
667
+
668
+ # Validate ID
669
+ if not ObjectId.is_valid(conversation_id):
670
+ raise HTTPException(
671
+ status_code=status.HTTP_400_BAD_REQUEST,
672
+ detail="Invalid conversation ID format"
673
+ )
674
+
675
+ # Verify user is a participant
676
+ conversation = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
677
+ if not conversation or user_id not in conversation.get("participants", []):
678
+ raise HTTPException(
679
+ status_code=status.HTTP_403_FORBIDDEN,
680
+ detail="You are not a participant in this conversation"
681
+ )
682
+
683
+ # Add user to deleted_for for ALL messages in this conversation
684
+ result = await db.messages.update_many(
685
+ {"conversation_id": conversation_id},
686
+ {"$addToSet": {"deleted_for": user_id}}
687
+ )
688
+
689
+ logger.info(f"Chat {conversation_id} cleared for {user_id} ({result.modified_count} messages)")
690
+
691
+ return {
692
+ "success": True,
693
+ "conversation_id": conversation_id,
694
+ "cleared_count": result.modified_count,
695
+ "cleared_at": datetime.utcnow().isoformat(),
696
+ }
697
 
698
 
699
  # Singleton instance
700
  conversation_service = ConversationService()
701
+