AP314159 commited on
Commit
9b87a98
·
1 Parent(s): ca56328

initial commit

Browse files
.gitignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ myenv/
2
+ scripts/
3
+ .env
4
+ __pycache__/
Dockerfile ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12.7
2
+ WORKDIR /code
3
+ COPY ./requirements.txt /code/requirements.txt
4
+ RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
5
+
6
+ RUN useradd user
7
+ USER user
8
+
9
+ ENV HOME=/home/user \
10
+ PATH=/home/user/.local/bin:$PATH
11
+
12
+ WORKDIR $HOME/app
13
+
14
+ COPY --chown=user . $HOME/app
15
+
16
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
app/api/v1/api_router.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+ from app.api.v1.endpoints import auth, users, admin, chat, messages
3
+
4
+ api_router = APIRouter()
5
+
6
+ # Add the routers to the main API router
7
+ api_router.include_router(auth.router, prefix="/auth", tags=["Authentication"])
8
+ api_router.include_router(users.router, prefix="/users", tags=["Users"])
9
+ api_router.include_router(admin.router, prefix="/admin", tags=["Admin Management"])
10
+ api_router.include_router(messages.router, prefix="/messages", tags=["Message History"])
11
+ api_router.include_router(chat.router, prefix="/chat", tags=["Real-Time Chat"])
app/api/v1/endpoints/admin.py ADDED
@@ -0,0 +1,414 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from fastapi import APIRouter, Depends, HTTPException, status, Query
3
+ from motor.motor_asyncio import AsyncIOMotorClient
4
+ from sqlalchemy.orm import Session, joinedload
5
+ from typing import List, Union, Optional
6
+ import datetime
7
+
8
+ from app.db.session import get_db, get_mongo_db
9
+ from app.security.dependencies import get_current_user_from_cookie
10
+ from app.security.hashing import Hasher
11
+ from app.models import Admin, User, Group, GroupMember
12
+ from app.schemas.user import UserOut, UserCreate, UserPasswordReset
13
+ from app.schemas.group import GroupCreateWithMembers, GroupWithMembers, GroupOut
14
+ from app.schemas.admin import ConversationSummary
15
+ from app.schemas.message import MessageHistory
16
+ from app.websocket.connection_manager import manager
17
+ from app.cache.group_members import remove_group_from_cache, add_member_to_cache, remove_member_from_cache
18
+
19
+ router = APIRouter()
20
+
21
+ def get_admin_from_dependency(current_entity: Union[User, Admin] = Depends(get_current_user_from_cookie)) -> Admin:
22
+ """
23
+ A helper dependency to ensure the user is an Admin.
24
+ This replaces the old get_current_active_admin.
25
+ """
26
+ if not isinstance(current_entity, Admin):
27
+ raise HTTPException(status_code=403, detail="Operation not permitted. Requires admin privileges.")
28
+ if not current_entity.is_active:
29
+ raise HTTPException(status_code=400, detail="Inactive admin.")
30
+ return current_entity
31
+
32
+ # --- User Management by Admin ---
33
+
34
+ @router.post("/users", response_model=UserOut, status_code=status.HTTP_201_CREATED)
35
+ def create_user_for_admin(
36
+ user_in: UserCreate,
37
+ db: Session = Depends(get_db),
38
+ current_admin: Admin = Depends(get_admin_from_dependency)
39
+ ):
40
+ # ... (logic remains the same)
41
+ full_username = f"{user_in.username}{current_admin.admin_key}"
42
+ db_user = db.query(User).filter(User.username == full_username).first()
43
+ if db_user:
44
+ raise HTTPException(status_code=400, detail=f"Username '{user_in.username}' already exists in your tenant.")
45
+ hashed_password = Hasher.get_password_hash(user_in.password)
46
+ new_user = User(username=full_username, password_hash=hashed_password, admin_id=current_admin.id)
47
+ db.add(new_user)
48
+ db.commit()
49
+ db.refresh(new_user)
50
+ return new_user
51
+
52
+ @router.get("/users/all", response_model=List[UserOut])
53
+ def list_users_for_admin(
54
+ db: Session = Depends(get_db),
55
+ current_admin: Admin = Depends(get_admin_from_dependency)
56
+ ):
57
+ return db.query(User).filter(User.admin_id == current_admin.id).all()
58
+
59
+ @router.get("/users/active", response_model=List[UserOut])
60
+ def list_active_users_for_admin(
61
+ db: Session = Depends(get_db),
62
+ current_admin: Admin = Depends(get_admin_from_dependency)
63
+ ):
64
+ """Lists all active users in the admin's tenant."""
65
+ return db.query(User).filter(User.admin_id == current_admin.id, User.is_active == True).all()
66
+
67
+ @router.get("/users/deactivated", response_model=List[UserOut])
68
+ def list_deactivated_users_for_admin(
69
+ db: Session = Depends(get_db),
70
+ current_admin: Admin = Depends(get_admin_from_dependency)
71
+ ):
72
+ """Lists all deactivated users in the admin's tenant."""
73
+ return db.query(User).filter(User.admin_id == current_admin.id, User.is_active == False).all()
74
+
75
+ @router.get("/users/online", response_model=List[UserOut])
76
+ def get_online_users(
77
+ db: Session = Depends(get_db),
78
+ current_admin: Admin = Depends(get_admin_from_dependency)
79
+ ):
80
+ """Gets a list of all online users within the current admin's tenant."""
81
+ online_connection_ids = manager.get_all_connection_ids()
82
+ online_user_ids = [int(cid.split('-')[1]) for cid in online_connection_ids if cid.startswith('user-')]
83
+
84
+ # Fetch details only for online users that belong to this admin's tenant
85
+ online_users_in_tenant = db.query(User).filter(
86
+ User.id.in_(online_user_ids),
87
+ User.admin_id == current_admin.id
88
+ ).all()
89
+ return online_users_in_tenant
90
+
91
+ @router.patch("/users/{user_id}/deactivate", status_code=status.HTTP_204_NO_CONTENT)
92
+ async def deactivate_user(
93
+ user_id: int,
94
+ db: Session = Depends(get_db),
95
+ current_admin: Admin = Depends(get_admin_from_dependency)
96
+ ):
97
+ # ... (logic remains the same)
98
+ user = db.query(User).filter(User.id == user_id, User.admin_id == current_admin.id).first()
99
+ if not user:
100
+ raise HTTPException(status_code=404, detail="User not found in your tenant.")
101
+ user.is_active = False
102
+ db.commit()
103
+ user_connection_id = f"user-{user.id}"
104
+ if user_connection_id in manager.active_connections:
105
+ logout_command = json.dumps({"event": "force_logout", "reason": "Your account has been deactivated by the administrator."})
106
+ await manager.send_personal_message(logout_command, user_connection_id)
107
+ manager.disconnect(user_connection_id)
108
+ return
109
+
110
+ @router.patch("/users/{user_id}/reactivate", status_code=status.HTTP_204_NO_CONTENT)
111
+ def reactivate_user(
112
+ user_id: int,
113
+ db: Session = Depends(get_db),
114
+ current_admin: Admin = Depends(get_admin_from_dependency)
115
+ ):
116
+ # ... (logic remains the same)
117
+ user = db.query(User).filter(User.id == user_id, User.admin_id == current_admin.id).first()
118
+ if not user:
119
+ raise HTTPException(status_code=404, detail="User not found in your tenant.")
120
+ user.is_active = True
121
+ db.commit()
122
+ return
123
+
124
+ @router.patch("/users/{user_id}/reset-password", status_code=status.HTTP_204_NO_CONTENT)
125
+ async def reset_user_password(
126
+ user_id: int,
127
+ password_data: UserPasswordReset,
128
+ db: Session = Depends(get_db),
129
+ current_admin: Admin = Depends(get_admin_from_dependency)
130
+ ):
131
+ """
132
+ Resets the password for a specific user within the admin's tenant.
133
+ """
134
+ # Find the user and verify they belong to the admin's tenant
135
+ user = db.query(User).filter(User.id == user_id, User.admin_id == current_admin.id).first()
136
+ if not user:
137
+ raise HTTPException(status_code=404, detail="User not found in your tenant.")
138
+
139
+ # Hash the new password and update the user's record
140
+ hashed_password = Hasher.get_password_hash(password_data.new_password)
141
+ user.password_hash = hashed_password
142
+
143
+ db.commit()
144
+ user_connection_id = f"user-{user.id}"
145
+ if user_connection_id in manager.active_connections:
146
+ logout_command = json.dumps({"event": "force_logout", "reason": "Please re-authenticate yourself as admin has reset your password."})
147
+ await manager.send_personal_message(logout_command, user_connection_id)
148
+ manager.disconnect(user_connection_id)
149
+ return
150
+
151
+ # --- Group Management by Admin ---
152
+
153
+ @router.post("/groups", response_model=GroupOut, status_code=status.HTTP_201_CREATED)
154
+ def create_group_for_admin(
155
+ group_in: GroupCreateWithMembers,
156
+ db: Session = Depends(get_db),
157
+ current_admin: Admin = Depends(get_admin_from_dependency)
158
+ ):
159
+ # ... (logic remains the same)
160
+ db_group = db.query(Group).filter(Group.name == group_in.name, Group.admin_id == current_admin.id).first()
161
+ if db_group:
162
+ raise HTTPException(status_code=400, detail=f"Group name '{group_in.name}' already exists in your tenant.")
163
+ new_group = Group(name=group_in.name, admin_id=current_admin.id)
164
+ db.add(new_group)
165
+ db.flush()
166
+ if group_in.members:
167
+ for user_id in group_in.members:
168
+ user = db.query(User).filter(User.id == user_id, User.admin_id == current_admin.id).first()
169
+ if user:
170
+ new_member = GroupMember(group_id=new_group.id, user_id=user.id)
171
+ db.add(new_member)
172
+ add_member_to_cache(new_group.id, f"user-{user.id}")
173
+ db.commit()
174
+ db.refresh(new_group)
175
+ return new_group
176
+
177
+ @router.get("/groups/all", response_model=List[GroupWithMembers])
178
+ def list_groups_with_members_for_admin(
179
+ db: Session = Depends(get_db),
180
+ current_admin: Admin = Depends(get_admin_from_dependency)
181
+ ):
182
+ """Lists all groups in the admin's tenant, including their members."""
183
+ groups = (
184
+ db.query(Group)
185
+ .options(joinedload(Group.members).joinedload(GroupMember.user))
186
+ .filter(Group.admin_id == current_admin.id)
187
+ .all()
188
+ )
189
+
190
+ result = []
191
+ for group in groups:
192
+ members_info = [
193
+ {"user_id": member.user.id, "username": member.user.username}
194
+ for member in group.members
195
+ ]
196
+ result.append({
197
+ "id": group.id,
198
+ "name": group.name,
199
+ "admin_id": group.admin_id,
200
+ "is_active": group.is_active,
201
+ "members": members_info
202
+ })
203
+ return result
204
+
205
+ @router.get("/groups/active", response_model=List[GroupWithMembers])
206
+ def list_active_groups_with_members(
207
+ db: Session = Depends(get_db),
208
+ current_admin: Admin = Depends(get_admin_from_dependency)
209
+ ):
210
+ """Lists all active groups in the admin's tenant, including their members."""
211
+ groups = (
212
+ db.query(Group)
213
+ .options(joinedload(Group.members).joinedload(GroupMember.user))
214
+ .filter(Group.admin_id == current_admin.id, Group.is_active == True)
215
+ .all()
216
+ )
217
+ # ... (response formatting logic remains the same)
218
+ result = []
219
+ for group in groups:
220
+ members_info = [{"user_id": member.user.id, "username": member.user.username} for member in group.members]
221
+ result.append({"id": group.id, "name": group.name, "admin_id": group.admin_id, "is_active": group.is_active, "members": members_info})
222
+ return result
223
+
224
+ @router.get("/groups/deactivated", response_model=List[GroupWithMembers])
225
+ def list_deactivated_groups_with_members(
226
+ db: Session = Depends(get_db),
227
+ current_admin: Admin = Depends(get_admin_from_dependency)
228
+ ):
229
+ """Lists all deactivated groups in the admin's tenant, including their members."""
230
+ groups = (
231
+ db.query(Group)
232
+ .options(joinedload(Group.members).joinedload(GroupMember.user))
233
+ .filter(Group.admin_id == current_admin.id, Group.is_active == False)
234
+ .all()
235
+ )
236
+ # ... (response formatting logic remains the same)
237
+ result = []
238
+ for group in groups:
239
+ members_info = [{"user_id": member.user.id, "username": member.user.username} for member in group.members]
240
+ result.append({"id": group.id, "name": group.name, "admin_id": group.admin_id, "is_active": group.is_active, "members": members_info})
241
+ return result
242
+
243
+ @router.delete("/groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
244
+ def deactivate_group(
245
+ group_id: int,
246
+ db: Session = Depends(get_db),
247
+ current_admin: Admin = Depends(get_admin_from_dependency)
248
+ ):
249
+ group = db.query(Group).filter(Group.id == group_id, Group.admin_id == current_admin.id).first()
250
+ if not group:
251
+ raise HTTPException(status_code=404, detail="Group not found in your tenant.")
252
+ group.is_active = False
253
+ db.commit()
254
+ remove_group_from_cache(group_id)
255
+ return
256
+
257
+ @router.post("/groups/{group_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
258
+ def add_user_to_group(
259
+ group_id: int,
260
+ user_id: int,
261
+ db: Session = Depends(get_db),
262
+ current_admin: Admin = Depends(get_current_user_from_cookie)
263
+ ):
264
+ """
265
+ Add a user from the admin's tenant to a group in the same tenant.
266
+ """
267
+ # Verify the group belongs to the admin
268
+ group = db.query(Group).filter(Group.id == group_id, Group.admin_id == current_admin.id).first()
269
+ if not group:
270
+ raise HTTPException(status_code=404, detail="Group not found in your tenant.")
271
+
272
+ # Verify the user belongs to the admin
273
+ user = db.query(User).filter(User.id == user_id, User.admin_id == current_admin.id).first()
274
+ if not user:
275
+ raise HTTPException(status_code=404, detail="User not found in your tenant.")
276
+
277
+ # Check if the user is already a member
278
+ membership = db.query(GroupMember).filter(GroupMember.group_id == group_id, GroupMember.user_id == user_id).first()
279
+ if membership:
280
+ raise HTTPException(status_code=400, detail="User is already a member of this group.")
281
+
282
+ new_member = GroupMember(group_id=group_id, user_id=user_id)
283
+ db.add(new_member)
284
+ db.commit()
285
+ add_member_to_cache(group_id, f"user-{user_id}")
286
+ return
287
+
288
+ @router.delete("/groups/{group_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
289
+ def remove_user_from_group(
290
+ group_id: int,
291
+ user_id: int,
292
+ db: Session = Depends(get_db),
293
+ current_admin: Admin = Depends(get_current_user_from_cookie)
294
+ ):
295
+ """
296
+ Remove a user from a group.
297
+ """
298
+ # Verify the group belongs to the admin to allow this operation
299
+ group = db.query(Group).filter(Group.id == group_id, Group.admin_id == current_admin.id).first()
300
+ if not group:
301
+ raise HTTPException(status_code=404, detail="Group not found in your tenant.")
302
+
303
+ membership = db.query(GroupMember).filter(GroupMember.group_id == group_id, GroupMember.user_id == user_id).first()
304
+ if not membership:
305
+ raise HTTPException(status_code=404, detail="User is not a member of this group.")
306
+
307
+ db.delete(membership)
308
+ db.commit()
309
+ remove_member_from_cache(group_id, f"user-{user_id}")
310
+ return
311
+
312
+ @router.get("/conversations/users", response_model=List[ConversationSummary])
313
+ async def list_user_to_user_conversations(
314
+ db: Session = Depends(get_db),
315
+ mongo_db: AsyncIOMotorClient = Depends(get_mongo_db),
316
+ current_admin: Admin = Depends(get_admin_from_dependency)
317
+ ):
318
+ """
319
+ Lists all private user-to-user conversations within the admin's tenant,
320
+ sorted by the most recent message.
321
+ """
322
+ # 1. Get all user IDs for the current admin's tenant
323
+ tenant_user_ids = [user.id for user in db.query(User.id).filter(User.admin_id == current_admin.id).all()]
324
+ if not tenant_user_ids:
325
+ return []
326
+
327
+ messages_collection = mongo_db["messages"]
328
+
329
+ # 2. Run the aggregation pipeline in MongoDB
330
+ pipeline = [
331
+ # Match only private messages between users in this tenant
332
+ {"$match": {
333
+ "type": "private",
334
+ "sender.role": "user",
335
+ "receiver.role": "user",
336
+ "sender.id": {"$in": tenant_user_ids},
337
+ "receiver.id": {"$in": tenant_user_ids}
338
+ }},
339
+ # Group by a canonical key (sorted participants)
340
+ {"$group": {
341
+ "_id": {
342
+ "participants": {
343
+ "$sortArray": {"input": ["$sender", "$receiver"], "sortBy": {"id": 1}}
344
+ }
345
+ },
346
+ "last_message_timestamp": {"$max": "$timestamp"},
347
+ "message_count": {"$sum": 1}
348
+ }},
349
+ # Sort conversations by the most recent activity
350
+ {"$sort": {"last_message_timestamp": -1}}
351
+ ]
352
+
353
+ aggregation_result = await messages_collection.aggregate(pipeline).to_list(length=None)
354
+
355
+ # 3. Format the response
356
+ response = []
357
+ for item in aggregation_result:
358
+ participants = item["_id"]["participants"]
359
+ response.append({
360
+ "user_one": {"id": participants[0]["id"], "username": participants[0]["username"]},
361
+ "user_two": {"id": participants[1]["id"], "username": participants[1]["username"]},
362
+ "last_message_timestamp": item["last_message_timestamp"],
363
+ "message_count": item["message_count"]
364
+ })
365
+
366
+ return response
367
+
368
+ @router.get("/messages/users/{user1_id}/{user2_id}", response_model=MessageHistory)
369
+ async def get_user_to_user_message_history(
370
+ user1_id: int,
371
+ user2_id: int,
372
+ before: Optional[str] = Query(None, description="ISO timestamp cursor for pagination"),
373
+ limit: int = Query(50, gt=0, le=100),
374
+ db: Session = Depends(get_db),
375
+ mongo_db: AsyncIOMotorClient = Depends(get_mongo_db),
376
+ current_admin: Admin = Depends(get_admin_from_dependency)
377
+ ):
378
+ """
379
+ Fetches the detailed message history for a specific user-to-user conversation.
380
+ """
381
+ # 1. Security Check: Verify both users belong to the admin's tenant
382
+ users = db.query(User).filter(User.id.in_([user1_id, user2_id]), User.admin_id == current_admin.id).all()
383
+ if len(users) != 2:
384
+ raise HTTPException(status_code=404, detail="One or both users not found in your tenant.")
385
+
386
+ # 2. Build the MongoDB query
387
+ messages_collection = mongo_db["messages"]
388
+ query = {
389
+ "$or": [
390
+ {"sender.id": user1_id, "receiver.id": user2_id},
391
+ {"sender.id": user2_id, "receiver.id": user1_id}
392
+ ],
393
+ "type": "private"
394
+ }
395
+
396
+ if before:
397
+ try:
398
+ cursor_time = datetime.datetime.fromisoformat(before.replace("Z", "+00:00"))
399
+ query["timestamp"] = {"$lt": cursor_time}
400
+ except ValueError:
401
+ raise HTTPException(status_code=400, detail="Invalid 'before' timestamp format.")
402
+
403
+ # 3. Fetch messages
404
+ messages_cursor = messages_collection.find(query).sort("timestamp", -1).limit(limit)
405
+ messages = await messages_cursor.to_list(length=limit)
406
+
407
+ for msg in messages:
408
+ msg["_id"] = str(msg["_id"])
409
+
410
+ next_cursor = None
411
+ if len(messages) == limit:
412
+ next_cursor = messages[-1]['timestamp'].isoformat() + "Z"
413
+
414
+ return {"messages": messages, "next_cursor": next_cursor}
app/api/v1/endpoints/auth.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, status, Response, Request
2
+ from sqlalchemy.orm import Session
3
+
4
+ from app.db.session import get_db
5
+ from app.schemas.user import UserLoginSchema
6
+ from app.security.hashing import Hasher
7
+ from app.security.jwt import create_access_token, create_refresh_token, verify_token
8
+ from app.models import User, Admin, SuperAdmin
9
+
10
+ router = APIRouter()
11
+
12
+ def set_auth_cookies(response: Response, access_token: str, refresh_token: str):
13
+ """Utility function to set auth cookies on a response."""
14
+ response.set_cookie(
15
+ key="access_token", value=access_token, httponly=True, samesite="lax", secure=True
16
+ )
17
+ response.set_cookie(
18
+ key="refresh_token", value=refresh_token, httponly=True, samesite="lax", secure=True
19
+ )
20
+
21
+ def delete_auth_cookies(response: Response):
22
+ """Utility function to delete auth cookies."""
23
+ response.delete_cookie(key="access_token")
24
+ response.delete_cookie(key="refresh_token")
25
+
26
+ @router.post("/login", status_code=status.HTTP_204_NO_CONTENT)
27
+ def login(response: Response, user_credentials: UserLoginSchema, db: Session = Depends(get_db)):
28
+ """
29
+ Handles login and sets HttpOnly cookies for access and refresh tokens.
30
+ Now includes a check to ensure the user/admin is active.
31
+ """
32
+ entity = None
33
+ role = None
34
+ tenant_id = None
35
+
36
+ inactive_exception = HTTPException(
37
+ status_code=status.HTTP_403_FORBIDDEN,
38
+ detail="Your account has been deactivated. Please contact your administrator."
39
+ )
40
+
41
+ # Check across user, admin, and super_admin tables
42
+ user = db.query(User).filter(User.username == user_credentials.username).first()
43
+ if user:
44
+ if not user.is_active:
45
+ raise inactive_exception
46
+ if Hasher.verify_password(user_credentials.password, user.password_hash):
47
+ entity, role, tenant_id = user, "user", user.admin_id
48
+
49
+ if not entity:
50
+ admin = db.query(Admin).filter(Admin.username == user_credentials.username).first()
51
+ if admin:
52
+ if not admin.is_active:
53
+ raise inactive_exception
54
+ if Hasher.verify_password(user_credentials.password, admin.password_hash):
55
+ entity, role, tenant_id = admin, "admin", admin.id
56
+
57
+ if not entity:
58
+ super_admin = db.query(SuperAdmin).filter(SuperAdmin.username == user_credentials.username).first()
59
+ if super_admin and Hasher.verify_password(user_credentials.password, super_admin.password_hash):
60
+ entity, role, tenant_id = super_admin, "super_admin", None
61
+
62
+ if not entity:
63
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password")
64
+
65
+ token_data = {"sub": entity.username, "role": role, "tenant_id": tenant_id}
66
+ access_token = create_access_token(data=token_data)
67
+ refresh_token = create_refresh_token(data=token_data)
68
+
69
+ set_auth_cookies(response, access_token, refresh_token)
70
+ return
71
+
72
+ @router.post("/refresh", status_code=status.HTTP_204_NO_CONTENT)
73
+ def refresh_token(request: Request, response: Response, db: Session = Depends(get_db)):
74
+ """
75
+ Uses the refresh_token from cookies to issue a new access_token.
76
+ Now includes a check to ensure the user/admin is still active.
77
+ """
78
+ refresh_token = request.cookies.get("refresh_token")
79
+ if not refresh_token:
80
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="No refresh token found")
81
+
82
+ credentials_exception = HTTPException(status_code=401, detail="Could not validate refresh token")
83
+ token_data = verify_token(refresh_token, credentials_exception)
84
+
85
+ # *** FIX IS HERE: Verify the user from the token is still active ***
86
+ entity_to_check = None
87
+ if token_data.role == "user":
88
+ entity_to_check = db.query(User).filter(User.username == token_data.username).first()
89
+ elif token_data.role == "admin":
90
+ entity_to_check = db.query(Admin).filter(Admin.username == token_data.username).first()
91
+
92
+ if entity_to_check and not entity_to_check.is_active:
93
+ # If the user has been deactivated, invalidate their session by deleting cookies
94
+ delete_auth_cookies(response)
95
+ raise HTTPException(
96
+ status_code=status.HTTP_403_FORBIDDEN,
97
+ detail="Your account has been deactivated. Session terminated."
98
+ )
99
+
100
+ # Re-create the payload for the new access token
101
+ new_token_data = {"sub": token_data.username, "role": token_data.role, "tenant_id": token_data.tenant_id}
102
+ new_access_token = create_access_token(data=new_token_data)
103
+
104
+ response.set_cookie(key="access_token", value=new_access_token, httponly=True, samesite="lax", secure=True)
105
+ return
106
+
107
+ @router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
108
+ def logout(response: Response):
109
+ """Logs the user out by deleting the auth cookies."""
110
+ delete_auth_cookies(response)
111
+ return
app/api/v1/endpoints/chat.py ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from datetime import datetime
3
+ import pytz
4
+ from typing import Union
5
+ from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Query, HTTPException, BackgroundTasks
6
+ from sqlalchemy.orm import Session
7
+ from motor.motor_asyncio import AsyncIOMotorClient
8
+
9
+ from app.db.session import get_db, get_mongo_db
10
+ from app.security.jwt import verify_token
11
+ from app.models import User, Admin
12
+ from app.websocket.connection_manager import manager
13
+ from app.cache.group_members import get_group_members
14
+ from app.cache.tenant_members import get_tenant_connection_ids
15
+
16
+ router = APIRouter()
17
+
18
+ async def broadcast_presence_update(tenant_id: int, user_id: int, role: str, status: str, db: Session):
19
+ """Broadcasts a user's online/offline status to all users in the same tenant using the cache."""
20
+ # Use the cache to get the list of who to notify
21
+ broadcast_list = get_tenant_connection_ids(tenant_id, db)
22
+
23
+ payload = json.dumps({
24
+ "event": "presence_update",
25
+ "user": {"id": user_id, "role": role},
26
+ "status": status,
27
+ "timestamp": datetime.now(pytz.timezone('Asia/Kolkata')).isoformat()
28
+ })
29
+
30
+ await manager.broadcast_to_users(payload, list(broadcast_list))
31
+
32
+ # --- Background task for database updates on disconnect ---
33
+ def update_last_seen(user_id: int, db: Session):
34
+ try:
35
+ user = db.query(User).filter(User.id == user_id).first()
36
+ if user:
37
+ user.last_seen = datetime.utcnow()
38
+ db.commit()
39
+ finally:
40
+ db.close()
41
+
42
+ async def mark_messages_as_received(user_id: int, user_role: str, db: AsyncIOMotorClient):
43
+ """Background task to update 'sent' messages to 'received'."""
44
+ messages_collection = db["messages"]
45
+ sent_messages_cursor = messages_collection.find({
46
+ "receiver.id": user_id,
47
+ "receiver.role": user_role,
48
+ "status": "sent"
49
+ })
50
+ sent_messages = await sent_messages_cursor.to_list(length=None)
51
+ if not sent_messages: return
52
+
53
+ message_ids_to_update = [msg["_id"] for msg in sent_messages]
54
+
55
+ await messages_collection.update_many(
56
+ {"_id": {"$in": message_ids_to_update}},
57
+ {"$set": {"status": "received"}}
58
+ )
59
+
60
+ for msg in sent_messages:
61
+ sender_connection_id = f"{msg['sender']['role']}-{msg['sender']['id']}"
62
+ status_update_payload = json.dumps({
63
+ "event": "status_update",
64
+ "message_id": str(msg["_id"]),
65
+ "status": "received"
66
+ })
67
+ await manager.send_personal_message(status_update_payload, sender_connection_id)
68
+
69
+
70
+ @router.websocket("/ws")
71
+ async def websocket_endpoint(
72
+ websocket: WebSocket,
73
+ background_tasks: BackgroundTasks,
74
+ db: Session = Depends(get_db),
75
+ mongo_db: AsyncIOMotorClient = Depends(get_mongo_db)
76
+ ):
77
+ try:
78
+ # 1. Extract the access_token from the WebSocket's cookies
79
+ token = websocket.cookies.get("access_token")
80
+ if not token:
81
+ await websocket.close(code=1008)
82
+ return
83
+
84
+ # 2. Verify the token
85
+ credentials_exception = HTTPException(status_code=403)
86
+ token_data = verify_token(token, credentials_exception)
87
+
88
+ # 3. Fetch the user/admin from the database
89
+ entity: Union[User, Admin] = None
90
+ if token_data.role == 'user':
91
+ entity = db.query(User).filter(User.username == token_data.username).first()
92
+ elif token_data.role == 'admin':
93
+ entity = db.query(Admin).filter(Admin.username == token_data.username).first()
94
+
95
+ if not entity:
96
+ await websocket.close(code=1008)
97
+ return
98
+
99
+ except Exception:
100
+ # If any part of auth fails, close the connection
101
+ await websocket.close(code=1008)
102
+ return
103
+
104
+ connection_id_str = f"{token_data.role}-{entity.id}"
105
+ await manager.connect(connection_id_str, websocket)
106
+
107
+ tenant_id = entity.id if isinstance(entity, Admin) else entity.admin_id
108
+
109
+ online_connection_ids = manager.get_all_connection_ids()
110
+ all_tenant_members = get_tenant_connection_ids(tenant_id, db)
111
+
112
+ # 1. Identify which users in the tenant are offline
113
+ offline_user_ids = []
114
+ for member_cid in all_tenant_members:
115
+ if member_cid.startswith('user-') and member_cid not in online_connection_ids:
116
+ offline_user_ids.append(int(member_cid.split('-')[1]))
117
+
118
+ # 2. Query the database for their last_seen timestamps in one go
119
+ offline_users_info = {
120
+ user.id: user.last_seen
121
+ for user in db.query(User.id, User.last_seen).filter(User.id.in_(offline_user_ids)).all()
122
+ }
123
+
124
+ # 3. Build the initial state map with the correct data
125
+ initial_state = {}
126
+ for member_cid in all_tenant_members:
127
+ role, member_id_str = member_cid.split('-')
128
+ member_id = int(member_id_str)
129
+
130
+ if member_id == entity.id and role == token_data.role:
131
+ continue
132
+
133
+ if member_cid in online_connection_ids:
134
+ status = "online"
135
+ last_seen = None
136
+ else:
137
+ status = "offline"
138
+ # Use the fetched timestamp if available
139
+ last_seen = offline_users_info.get(member_id)
140
+
141
+ # Ensure timestamp is in ISO format if it exists
142
+ last_seen_iso = last_seen.isoformat() + "Z" if last_seen else None
143
+ initial_state[member_cid] = {"status": status, "lastSeen": last_seen_iso}
144
+
145
+ await manager.send_personal_message(json.dumps({
146
+ "event": "initial_presence_state",
147
+ "users": initial_state
148
+ }), connection_id_str)
149
+
150
+ # Announce the new user's arrival to everyone else
151
+ await broadcast_presence_update(tenant_id, entity.id, token_data.role, "online", db)
152
+
153
+ background_tasks.add_task(mark_messages_as_received, entity.id, token_data.role, mongo_db)
154
+
155
+ try:
156
+ while True:
157
+ data = await websocket.receive_text()
158
+ message_data = json.loads(data)
159
+ event_type = message_data.get("event", "new_message")
160
+ messages_collection = mongo_db["messages"]
161
+
162
+ if event_type == "messages_read":
163
+ partner = message_data.get("partner")
164
+ if not partner: continue
165
+
166
+ messages_to_update = await messages_collection.find({
167
+ "receiver.id": entity.id, "receiver.role": token_data.role,
168
+ "sender.id": partner["id"], "sender.role": partner["role"],
169
+ "status": {"$in": ["sent", "received"]}
170
+ }).to_list(length=None)
171
+
172
+ if not messages_to_update: continue
173
+
174
+ msg_ids = [msg["_id"] for msg in messages_to_update]
175
+ await messages_collection.update_many(
176
+ {"_id": {"$in": msg_ids}},
177
+ {"$set": {"status": "read"}}
178
+ )
179
+
180
+ partner_connection_id = f"{partner['role']}-{partner['id']}"
181
+ await manager.send_personal_message(json.dumps({
182
+ "event": "status_update",
183
+ "message_ids": [str(mid) for mid in msg_ids],
184
+ "status": "read"
185
+ }), partner_connection_id)
186
+ continue
187
+
188
+ if event_type == "new_message":
189
+ raw_content = message_data.get("content")
190
+ if isinstance(raw_content, dict):
191
+ content_obj = raw_content
192
+ else:
193
+ continue
194
+ mongo_message = {
195
+ "type": message_data.get("type"),
196
+ "sender": {"id": entity.id, "role": token_data.role, "username": entity.username},
197
+ "content": content_obj,
198
+ "timestamp": datetime.now(pytz.utc),
199
+ "status": "sent",
200
+ "is_deleted": False
201
+ }
202
+
203
+ if mongo_message["type"] == "private":
204
+ receiver_info = message_data.get("receiver")
205
+ if not receiver_info: continue
206
+
207
+ mongo_message["receiver"] = {
208
+ "id": receiver_info['id'],
209
+ "role": receiver_info['role'],
210
+ "username": receiver_info['username']
211
+ }
212
+
213
+ result = await messages_collection.insert_one(mongo_message)
214
+ mongo_message["_id"] = str(result.inserted_id)
215
+
216
+ receiver_connection_id = f"{receiver_info['role']}-{receiver_info['id']}"
217
+ await manager.broadcast_to_users(json.dumps(mongo_message, default=str), [receiver_connection_id])
218
+
219
+ elif mongo_message["type"] == "group":
220
+ group_info = message_data.get("group")
221
+ if not group_info: continue
222
+
223
+ mongo_message["group"] = {
224
+ "id": group_info["id"],
225
+ "name": group_info["name"]
226
+ }
227
+
228
+ result = await messages_collection.insert_one(mongo_message)
229
+ mongo_message["_id"] = str(result.inserted_id)
230
+
231
+ member_ids = get_group_members(group_info["id"], db=db)
232
+ connections = set(member_ids)
233
+ connections.discard(connection_id_str)
234
+ await manager.broadcast_to_users(json.dumps(mongo_message, default=str), list(connections))
235
+
236
+ except WebSocketDisconnect:
237
+ manager.disconnect(connection_id_str)
238
+ await broadcast_presence_update(tenant_id, entity.id, token_data.role, "offline", db)
239
+ if isinstance(entity, User):
240
+ # Create a new session for the background task
241
+ db_session_for_task = next(get_db())
242
+ background_tasks.add_task(update_last_seen, entity.id, db_session_for_task)
243
+ except Exception as e:
244
+ print(f"Error in WebSocket: {e}")
245
+ manager.disconnect(connection_id_str)
246
+ await broadcast_presence_update(tenant_id, entity.id, token_data.role, "offline", db)
247
+ if isinstance(entity, User):
248
+ db_session_for_task = next(get_db())
249
+ background_tasks.add_task(update_last_seen, entity.id, db_session_for_task)
250
+
app/api/v1/endpoints/messages.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, Query, Path
2
+ from sqlalchemy.orm import Session
3
+ from motor.motor_asyncio import AsyncIOMotorClient
4
+ from typing import Union, Optional
5
+ import datetime
6
+
7
+ from app.db.session import get_db, get_mongo_db
8
+ from app.security.dependencies import get_current_user_from_cookie
9
+ from app.models import User, Admin, Group, GroupMember
10
+ from app.schemas.message import MessageHistory
11
+
12
+ router = APIRouter()
13
+
14
+ @router.get("/{conversation_type}/{partner_id}", response_model=MessageHistory)
15
+ async def get_message_history(
16
+ conversation_type: str = Path(..., description="Type of conversation: 'private' or 'group'"),
17
+ partner_id: int = Path(..., description="ID of the user, admin, or group"),
18
+ partner_role: Optional[str] = Query(None, description="Role of the partner if private: 'user' or 'admin'"),
19
+ before: Optional[str] = Query(None, description="ISO timestamp cursor for pagination"),
20
+ limit: int = Query(50, gt=0, le=100),
21
+ current_entity: Union[User, Admin] = Depends(get_current_user_from_cookie),
22
+ db: Session = Depends(get_db),
23
+ mongo_db: AsyncIOMotorClient = Depends(get_mongo_db)
24
+ ):
25
+ """
26
+ Fetch the message history for a specific conversation with pagination.
27
+ """
28
+ messages_collection = mongo_db["messages"]
29
+
30
+ entity_id = current_entity.id
31
+ entity_role = "admin" if isinstance(current_entity, Admin) else "user"
32
+
33
+ query = {"type": conversation_type}
34
+
35
+ # --- Authorization and Query Building ---
36
+ if conversation_type == "private":
37
+ if not partner_role:
38
+ raise HTTPException(status_code=400, detail="Partner role is required for private chats.")
39
+
40
+ # Build a query that finds messages between the two parties, regardless of who is sender/receiver
41
+ query["$or"] = [
42
+ {"sender.id": entity_id, "sender.role": entity_role, "receiver.id": partner_id, "receiver.role": partner_role},
43
+ {"sender.id": partner_id, "sender.role": partner_role, "receiver.id": entity_id, "receiver.role": entity_role}
44
+ ]
45
+ elif conversation_type == "group":
46
+ # Verify the current entity is a member of the group
47
+ group = db.query(Group).filter(Group.id == partner_id).first()
48
+ if not group: raise HTTPException(status_code=404, detail="Group not found.")
49
+
50
+ is_member = False
51
+ if isinstance(current_entity, Admin) and current_entity.id == group.admin_id:
52
+ is_member = True
53
+ elif isinstance(current_entity, User):
54
+ membership = db.query(GroupMember).filter(GroupMember.group_id == partner_id, GroupMember.user_id == entity_id).first()
55
+ if membership: is_member = True
56
+
57
+ if not is_member:
58
+ raise HTTPException(status_code=403, detail="You are not a member of this group.")
59
+
60
+ query["group.id"] = partner_id
61
+ else:
62
+ raise HTTPException(status_code=400, detail="Invalid conversation type.")
63
+
64
+ # --- Pagination ---
65
+ if before:
66
+ try:
67
+ # Using timestamp for cursor-based pagination
68
+ cursor_time = datetime.datetime.fromisoformat(before.replace("Z", "+00:00"))
69
+ query["timestamp"] = {"$lt": cursor_time}
70
+ except ValueError:
71
+ raise HTTPException(status_code=400, detail="Invalid 'before' timestamp format.")
72
+
73
+ # --- Fetching Messages ---
74
+ messages_cursor = messages_collection.find(query).sort("timestamp", -1).limit(limit)
75
+ messages = await messages_cursor.to_list(length=limit)
76
+
77
+ # Convert MongoDB's _id to a string for Pydantic
78
+ for msg in messages:
79
+ msg["_id"] = str(msg["_id"])
80
+
81
+ # --- Determine the next cursor ---
82
+ next_cursor = None
83
+ if len(messages) == limit:
84
+ # The timestamp of the last message fetched is the cursor for the next page
85
+ next_cursor = messages[-1]['timestamp'].isoformat() + "Z"
86
+
87
+ return {"messages": messages, "next_cursor": next_cursor}
app/api/v1/endpoints/users.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, Query
2
+ from sqlalchemy.orm import Session
3
+ from motor.motor_asyncio import AsyncIOMotorClient
4
+ from typing import Union
5
+
6
+ from app.db.session import get_db, get_mongo_db
7
+ from app.security.dependencies import get_current_user_from_cookie
8
+ from app.models import User, Admin, Group, GroupMember
9
+ from app.websocket.connection_manager import manager
10
+ from typing import List
11
+ from app.schemas.user import UserOut, AdminOut, SearchResult, ConversationList, ConversationPartner, MeOut, MeProfileOut
12
+
13
+ router = APIRouter()
14
+
15
+ @router.get("/me", response_model=MeProfileOut)
16
+ def read_users_me(
17
+ # Use the new cookie-based dependency here
18
+ current_entity: Union[User, Admin] = Depends(get_current_user_from_cookie)
19
+ ):
20
+ """
21
+ Get the profile of the currently authenticated user or admin.
22
+ Authentication is now handled via HttpOnly cookies.
23
+ """
24
+ if isinstance(current_entity, User):
25
+ creator_name = current_entity.owner_admin.username
26
+ entity_type = "user"
27
+ elif isinstance(current_entity, Admin):
28
+ creator_name = "Super Admin"
29
+ entity_type = "admin"
30
+ else:
31
+ # This case handles if a Super Admin somehow hits this endpoint
32
+ creator_name = "System"
33
+ entity_type = "super_admin"
34
+
35
+ return MeProfileOut(
36
+ id=current_entity.id,
37
+ username=current_entity.username,
38
+ type=entity_type,
39
+ created_by=creator_name,
40
+ created_at=current_entity.created_at
41
+ )
42
+
43
+ @router.get("/search", response_model=SearchResult)
44
+ def search_for_entities(
45
+ query: str = Query(..., min_length=1, description="Search query for users, admins, or groups"),
46
+ # current_entity: Union[User, Admin] = Depends(get_current_active_user_or_admin),
47
+ current_entity: Union[User, Admin] = Depends(get_current_user_from_cookie),
48
+ db: Session = Depends(get_db)
49
+ ):
50
+ """
51
+ Search for users, the tenant admin, and groups within the same tenant.
52
+ - For Users: Shows other users in their tenant.
53
+ - For Admins: Shows all users and groups in their tenant.
54
+ """
55
+ if isinstance(current_entity, Admin):
56
+ tenant_id = current_entity.id
57
+ current_id = None
58
+ else: # It's a User
59
+ tenant_id = current_entity.admin_id
60
+ current_id = current_entity.id
61
+
62
+ # --- Search for users ---
63
+ user_query = db.query(User).filter(
64
+ User.username.ilike(f"%{query}%"),
65
+ User.admin_id == tenant_id
66
+ )
67
+ if current_id: # Exclude self from search if the searcher is a user
68
+ user_query = user_query.filter(User.id != current_id)
69
+ found_users = user_query.all()
70
+
71
+ # --- Search for the admin ---
72
+ found_admins = db.query(Admin).filter(
73
+ Admin.username.ilike(f"%{query}%"),
74
+ Admin.id == tenant_id
75
+ ).all()
76
+
77
+ # --- Search for groups ---
78
+ group_query = db.query(Group).filter(
79
+ Group.name.ilike(f"%{query}%"),
80
+ Group.admin_id == tenant_id
81
+ )
82
+ # If the searcher is a standard user, only show groups they are a member of.
83
+ if isinstance(current_entity, User):
84
+ group_query = group_query.join(Group.members).filter(GroupMember.user_id == current_entity.id)
85
+
86
+ found_groups = group_query.all()
87
+
88
+ return {"users": found_users, "admins": found_admins, "groups": found_groups}
89
+
90
+ @router.get("/conversations", response_model=ConversationList)
91
+ async def get_user_conversations(
92
+ # current_entity: Union[User, Admin] = Depends(get_current_active_user_or_admin),
93
+ current_entity: Union[User, Admin] = Depends(get_current_user_from_cookie),
94
+ db: Session = Depends(get_db),
95
+ mongo_db: AsyncIOMotorClient = Depends(get_mongo_db)
96
+ ):
97
+ messages_collection = mongo_db["messages"]
98
+
99
+ entity_id = current_entity.id
100
+ entity_role = "admin" if isinstance(current_entity, Admin) else "user"
101
+ tenant_id = current_entity.id if isinstance(current_entity, Admin) else current_entity.admin_id
102
+
103
+ user_group_ids = []
104
+ if isinstance(current_entity, User):
105
+ memberships = db.query(GroupMember.group_id).filter_by(user_id=entity_id).all()
106
+ user_group_ids = [row.group_id for row in memberships]
107
+ else: # An admin is part of all groups in their tenant
108
+ groups = db.query(Group.id).filter_by(admin_id=tenant_id).all()
109
+ user_group_ids = [row.id for row in groups]
110
+
111
+ pipeline = [
112
+ {"$match": {
113
+ "$or": [
114
+ # Private messages sent to or from the user
115
+ {"sender.id": entity_id, "sender.role": entity_role},
116
+ {"receiver.id": entity_id, "receiver.role": entity_role},
117
+ # Messages from any group the user is a member of
118
+ {"group.id": {"$in": user_group_ids}}
119
+ ]
120
+ }},
121
+ {"$sort": {"timestamp": -1}},
122
+ {"$group": {
123
+ "_id": {
124
+ "$cond": {
125
+ "if": {"$eq": ["$type", "private"]},
126
+ "then": {
127
+ "participants": {
128
+ "$sortArray": { "input": [ "$sender", "$receiver" ], "sortBy": { "id": 1 } }
129
+ }
130
+ },
131
+ "else": {"$concat": ["group-", {"$toString": "$group.id"}]}
132
+ }
133
+ },
134
+ "last_message_doc": {"$first": "$$ROOT"}
135
+ }},
136
+ {"$replaceRoot": {"newRoot": "$last_message_doc"}}
137
+ ]
138
+
139
+ latest_messages = await messages_collection.aggregate(pipeline).to_list(length=None)
140
+
141
+ conversations = []
142
+ # The post-aggregation filtering is no longer needed, as the DB query is now correct.
143
+ for msg in sorted(latest_messages, key=lambda x: x['timestamp'], reverse=True):
144
+ last_message_text = msg.get("content", {}).get("text", "[attachment]")
145
+
146
+ if msg['type'] == 'private':
147
+ partner = msg['receiver'] if msg['sender']['id'] == entity_id and msg['sender']['role'] == entity_role else msg['sender']
148
+ conversations.append(ConversationPartner(
149
+ id=partner['id'], name=partner['username'], type=partner['role'],
150
+ last_message=last_message_text, timestamp=msg['timestamp']
151
+ ))
152
+ elif msg['type'] == 'group':
153
+ group = msg['group']
154
+ conversations.append(ConversationPartner(
155
+ id=group['id'], name=group['name'], type='group',
156
+ last_message=last_message_text, timestamp=msg['timestamp']
157
+ ))
158
+
159
+ return {"conversations": conversations}
app/cache/group_members.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, Set
2
+ from threading import Lock
3
+ from sqlalchemy.orm import Session
4
+ from app.models import GroupMember, Group
5
+
6
+ group_members_cache: Dict[int, Set[str]] = {}
7
+ lock = Lock()
8
+
9
+ def get_group_members(group_id: int, db: Session) -> Set[str]:
10
+ with lock:
11
+ if group_id in group_members_cache:
12
+ return group_members_cache[group_id]
13
+
14
+ group = db.query(Group).get(group_id)
15
+ if not group: return set()
16
+
17
+ members = db.query(GroupMember).filter_by(group_id=group.id).all()
18
+ connection_ids = {f"user-{m.user_id}" for m in members}
19
+ connection_ids.add(f"admin-{group.admin_id}")
20
+
21
+ with lock:
22
+ group_members_cache[group_id] = connection_ids
23
+ return connection_ids
24
+
25
+ def add_member_to_cache(group_id: int, connection_id: str):
26
+ with lock:
27
+ if group_id not in group_members_cache:
28
+ return
29
+ group_members_cache[group_id].add(connection_id)
30
+
31
+ def remove_member_from_cache(group_id: int, connection_id: str):
32
+ with lock:
33
+ if group_id in group_members_cache:
34
+ group_members_cache[group_id].discard(connection_id)
35
+
36
+ def remove_group_from_cache(group_id: int):
37
+ """Removes an entire group from the cache, e.g., when it's deactivated."""
38
+ with lock:
39
+ if group_id in group_members_cache:
40
+ del group_members_cache[group_id]
app/cache/tenant_members.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/cache/tenant_members.py
2
+ from typing import Dict, List, Set
3
+ from threading import Lock
4
+ from sqlalchemy.orm import Session
5
+ from app.models import User, Admin
6
+
7
+ # Cache format: { tenant_id: {"user-1", "user-2", "admin-10"} }
8
+ tenant_members_cache: Dict[int, Set[str]] = {}
9
+ lock = Lock()
10
+
11
+ def get_tenant_connection_ids(tenant_id: int, db: Session) -> Set[str]:
12
+ """
13
+ Returns cached connection IDs for a tenant.
14
+ If not cached, it queries the DB, populates the cache, and returns the data.
15
+ """
16
+ with lock:
17
+ if tenant_id in tenant_members_cache:
18
+ return tenant_members_cache[tenant_id]
19
+
20
+ # --- Not in cache, so load from PostgreSQL ---
21
+ tenant_users = db.query(User.id).filter(User.admin_id == tenant_id).all()
22
+ # The admin is also a member of their own tenant
23
+ tenant_admin = db.query(Admin.id).filter(Admin.id == tenant_id).first()
24
+
25
+ connection_ids = {f"user-{uid}" for uid, in tenant_users}
26
+ if tenant_admin:
27
+ connection_ids.add(f"admin-{tenant_admin.id}")
28
+
29
+ # Populate the cache
30
+ with lock:
31
+ tenant_members_cache[tenant_id] = connection_ids
32
+
33
+ return connection_ids
app/core/config.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/core/config.py
2
+ import os
3
+ from dotenv import load_dotenv
4
+ from pathlib import Path
5
+
6
+ # Load environment variables from .env file
7
+ env_path = Path('.') / '.env'
8
+ load_dotenv(dotenv_path=env_path)
9
+
10
+ class Settings:
11
+ """
12
+ Application settings loaded from environment variables.
13
+ """
14
+ PROJECT_NAME: str = "Multi-Tenant Chat App"
15
+ PROJECT_VERSION: str = "1.0.0"
16
+
17
+ # PostgreSQL (Neon) settings
18
+ POSTGRES_USER: str = os.getenv("POSTGRES_USER", "postgres")
19
+ POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD", "password")
20
+ POSTGRES_SERVER: str = os.getenv("POSTGRES_SERVER", "localhost")
21
+ POSTGRES_PORT: str = os.getenv("POSTGRES_PORT", "5432")
22
+ POSTGRES_DB: str = os.getenv("POSTGRES_DB", "chat_app")
23
+ DATABASE_URL: str = os.getenv("DATABASE_URL")
24
+
25
+ # MongoDB (Atlas) settings
26
+ MONGO_DATABASE_URL: str = os.getenv("MONGO_DATABASE_URL")
27
+ MONGO_DB_NAME: str = os.getenv("MONGO_DB_NAME", "chat_app")
28
+
29
+
30
+ # JWT settings
31
+ SECRET_KEY: str = os.getenv("SECRET_KEY", "a_very_secret_key")
32
+ ALGORITHM: str = os.getenv("ALGORITHM", "HS256")
33
+ ACCESS_TOKEN_EXPIRE_MINUTES: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", 30))
34
+
35
+ # API settings
36
+ API_V1_STR: str = "/api/v1"
37
+
38
+ settings = Settings()
app/db/session.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/db/session.py
2
+ from sqlalchemy import create_engine
3
+ from sqlalchemy.orm import sessionmaker
4
+ from motor.motor_asyncio import AsyncIOMotorClient
5
+ from app.core.config import settings
6
+
7
+ # --- PostgreSQL (SQLAlchemy) Setup ---
8
+ engine = create_engine(
9
+ settings.DATABASE_URL,
10
+ pool_pre_ping=True,
11
+ pool_recycle=1800
12
+ )
13
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
14
+
15
+ def get_db():
16
+ """
17
+ Dependency to get a DB session.
18
+ Ensures the session is always closed after the request.
19
+ """
20
+ db = SessionLocal()
21
+ try:
22
+ yield db
23
+ finally:
24
+ db.close()
25
+
26
+ # --- MongoDB (Motor) Setup ---
27
+ class DataBase:
28
+ client: AsyncIOMotorClient = None
29
+
30
+ db = DataBase()
31
+
32
+ async def get_mongo_db():
33
+ """
34
+ Dependency to get the MongoDB database instance.
35
+ """
36
+ return db.client[settings.MONGO_DB_NAME]
37
+
38
+ async def connect_to_mongo():
39
+ """
40
+ Connect to the MongoDB instance.
41
+ """
42
+ print("Connecting to MongoDB...")
43
+ db.client = AsyncIOMotorClient(settings.MONGO_DATABASE_URL)
44
+ print("Successfully connected to MongoDB.")
45
+
46
+ async def close_mongo_connection():
47
+ """
48
+ Close the MongoDB connection.
49
+ """
50
+ print("Closing MongoDB connection...")
51
+ db.client.close()
52
+ print("MongoDB connection closed.")
app/models/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/models/__init__.py
2
+
3
+ # This makes it easier to import models from other parts of the application.
4
+ # For example: from app.models import User, Admin
5
+
6
+ from .base import Base
7
+ from .super_admin import SuperAdmin
8
+ from .admin import Admin
9
+ from .user import User
10
+ from .group import Group
11
+ from .group_member import GroupMember
app/models/admin.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import Boolean, Column, Integer, String, Text, DateTime
2
+ from sqlalchemy.orm import relationship
3
+ from .base import Base
4
+ import datetime
5
+
6
+ class Admin(Base):
7
+ __tablename__ = 'admins'
8
+ id = Column(Integer, primary_key=True, index=True)
9
+ username = Column(String(50), unique=True, nullable=False)
10
+ password_hash = Column(Text, nullable=False)
11
+ admin_key = Column(Text, unique=True, nullable=False)
12
+ is_active = Column(Boolean, default=True)
13
+ created_at = Column(DateTime, default=datetime.datetime.utcnow)
14
+
15
+ users = relationship("User", back_populates="owner_admin", cascade="all, delete-orphan")
16
+ groups = relationship("Group", back_populates="owner_admin", cascade="all, delete-orphan")
app/models/base.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from sqlalchemy.orm import declarative_base
2
+
3
+ # Create a Base class for all ORM models to inherit from.
4
+ # This allows SQLAlchemy to discover all the models.
5
+ Base = declarative_base()
app/models/group.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import Boolean, Column, Integer, Text, ForeignKey, DateTime, UniqueConstraint
2
+ from sqlalchemy.orm import relationship
3
+ from .base import Base
4
+ import datetime
5
+
6
+ class Group(Base):
7
+ __tablename__ = 'groups'
8
+ id = Column(Integer, primary_key=True, index=True)
9
+ name = Column(Text, nullable=False)
10
+ admin_id = Column(Integer, ForeignKey('admins.id', ondelete="CASCADE"), nullable=False)
11
+ is_active = Column(Boolean, default=True)
12
+ created_at = Column(DateTime, default=datetime.datetime.utcnow)
13
+
14
+ owner_admin = relationship("Admin", back_populates="groups")
15
+ members = relationship("GroupMember", back_populates="group", cascade="all, delete-orphan")
16
+
17
+ __table_args__ = (UniqueConstraint('name', 'admin_id', name='_group_name_admin_uc'),)
app/models/group_member.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import Boolean, Column, Integer, ForeignKey, DateTime
2
+ from sqlalchemy.orm import relationship
3
+ from .base import Base
4
+ import datetime
5
+
6
+ class GroupMember(Base):
7
+ __tablename__ = 'group_members'
8
+ group_id = Column(Integer, ForeignKey('groups.id', ondelete="CASCADE"), primary_key=True)
9
+ user_id = Column(Integer, ForeignKey('users.id', ondelete="CASCADE"), primary_key=True)
10
+ is_admin = Column(Boolean, default=False)
11
+ joined_at = Column(DateTime, default=datetime.datetime.utcnow)
12
+
13
+ group = relationship("Group", back_populates="members")
14
+ user = relationship("User", back_populates="groups")
app/models/super_admin.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import Column, Integer, String, Text, DateTime
2
+ from .base import Base
3
+ import datetime
4
+
5
+ class SuperAdmin(Base):
6
+ __tablename__ = 'super_admin'
7
+ id = Column(Integer, primary_key=True, index=True)
8
+ username = Column(String(50), unique=True, nullable=False)
9
+ password_hash = Column(Text, nullable=False)
10
+ created_at = Column(DateTime, default=datetime.datetime.utcnow)
app/models/user.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import Boolean, Column, Integer, Text, ForeignKey, DateTime
2
+ from sqlalchemy.orm import relationship
3
+ from .base import Base
4
+ import datetime
5
+
6
+ class User(Base):
7
+ __tablename__ = 'users'
8
+ id = Column(Integer, primary_key=True, index=True)
9
+ username = Column(Text, unique=True, nullable=False)
10
+ password_hash = Column(Text, nullable=False)
11
+ admin_id = Column(Integer, ForeignKey('admins.id', ondelete="CASCADE"), nullable=False)
12
+ is_active = Column(Boolean, default=True)
13
+ created_at = Column(DateTime, default=datetime.datetime.utcnow)
14
+ last_seen = Column(DateTime, default=datetime.datetime.utcnow)
15
+
16
+ owner_admin = relationship("Admin", back_populates="users")
17
+ groups = relationship("GroupMember", back_populates="user", cascade="all, delete-orphan")
app/schemas/admin.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import List
3
+ import datetime
4
+
5
+ class AdminUserCreate(BaseModel):
6
+ username: str
7
+ password: str
8
+
9
+ class AdminUserUpdate(BaseModel):
10
+ is_active: bool
11
+
12
+ class OnlineUser(BaseModel):
13
+ id: int
14
+ username: str
15
+ role: str
16
+
17
+ class AllOnlineUsers(BaseModel):
18
+ users: List[OnlineUser]
19
+
20
+ class UserPairInfo(BaseModel):
21
+ id: int
22
+ username: str
23
+
24
+ class ConversationSummary(BaseModel):
25
+ user_one: UserPairInfo
26
+ user_two: UserPairInfo
27
+ last_message_timestamp: datetime.datetime
28
+ message_count: int
app/schemas/group.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import List, Optional
3
+
4
+ class GroupMemberInfo(BaseModel):
5
+ user_id: int
6
+ username: str
7
+
8
+ class GroupWithMembers(BaseModel):
9
+ id: int
10
+ name: str
11
+ admin_id: int
12
+ is_active: bool
13
+ members: List[GroupMemberInfo]
14
+
15
+ class Config:
16
+ orm_mode = True
17
+
18
+ class GroupCreateWithMembers(BaseModel):
19
+ name: str
20
+ members: Optional[List[int]] = []
21
+
22
+ class GroupCreate(BaseModel):
23
+ name: str
24
+
25
+ class GroupOut(BaseModel):
26
+ id: int
27
+ name: str
28
+ admin_id: int
29
+
30
+ class Config:
31
+ orm_mode = True
app/schemas/message.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/schemas/message.py
2
+ from pydantic import BaseModel, Field
3
+ from typing import Optional
4
+ import datetime
5
+
6
+ # --- Nested objects for the main Message schema ---
7
+ class MessageSender(BaseModel):
8
+ id: int
9
+ username: str
10
+ role: str
11
+
12
+ class MessageReceiver(BaseModel):
13
+ id: int
14
+ username: str
15
+ role: str
16
+
17
+ class MessageGroup(BaseModel):
18
+ id: int
19
+ name: str
20
+
21
+ class MessageContent(BaseModel):
22
+ text: Optional[str] = None
23
+ image: Optional[str] = None
24
+ file: Optional[str] = None
25
+
26
+ # --- Main Message Schema for API output ---
27
+ class MessageOut(BaseModel):
28
+ id: str = Field(..., alias="_id") # Use MongoDB's _id
29
+ type: str
30
+ sender: MessageSender
31
+ receiver: Optional[MessageReceiver] = None
32
+ group: Optional[MessageGroup] = None
33
+ content: MessageContent
34
+ timestamp: datetime.datetime
35
+ status: str
36
+
37
+ class Config:
38
+ orm_mode = True
39
+ allow_population_by_field_name = True
40
+ json_encoders = {
41
+ datetime.datetime: lambda dt: dt.isoformat(),
42
+ }
43
+
44
+ # --- Schema for the new message history endpoint response ---
45
+ class MessageHistory(BaseModel):
46
+ messages: list[MessageOut]
47
+ next_cursor: Optional[str] = None
app/schemas/token.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional
2
+ from pydantic import BaseModel
3
+
4
+ class Token(BaseModel):
5
+ access_token: str
6
+ token_type: str
7
+
8
+ class TokenData(BaseModel):
9
+ username: Optional[str] = None
10
+ role: Optional[str] = None
11
+ tenant_id: Optional[int] = None
12
+
13
+ class TokenRequest(BaseModel):
14
+ username: str
15
+ password: str
app/schemas/user.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/schemas/user.py
2
+ from pydantic import BaseModel, Field
3
+ from typing import List, Union
4
+ import datetime
5
+ from .group import GroupOut
6
+
7
+ class UserCreate(BaseModel):
8
+ username: str
9
+ password: str
10
+
11
+ class MeProfileOut(BaseModel):
12
+ id: int
13
+ username: str
14
+ type: str
15
+ created_by: str
16
+ created_at: datetime.datetime
17
+
18
+ class UserOut(BaseModel):
19
+ id: int
20
+ username: str
21
+ type: str = "User"
22
+
23
+ class Config:
24
+ orm_mode = True
25
+
26
+ class AdminOut(BaseModel):
27
+ id: int
28
+ username: str
29
+ type: str = "Admin"
30
+ admin_key: str
31
+
32
+ class Config:
33
+ orm_mode = True
34
+
35
+ # A flexible response model for the /me endpoint
36
+ MeOut = Union[UserOut, AdminOut]
37
+
38
+ # A schema for the search result
39
+ class SearchResult(BaseModel):
40
+ users: List[UserOut]
41
+ admins: List[AdminOut]
42
+ groups: List[GroupOut]
43
+
44
+ # Schemas for the conversation list
45
+ class ConversationPartner(BaseModel):
46
+ id: int
47
+ name: str
48
+ type: str # "user", "group", or "admin"
49
+ last_message: str
50
+ timestamp: datetime.datetime
51
+
52
+ class ConversationList(BaseModel):
53
+ conversations: List[ConversationPartner]
54
+
55
+ class UserLoginSchema(BaseModel):
56
+ username: str
57
+ password: str
58
+
59
+ class UserPasswordReset(BaseModel):
60
+ new_password: str
app/security/dependencies.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import Depends, HTTPException, status, Request
2
+ from sqlalchemy.orm import Session
3
+ from typing import Union
4
+
5
+ from app.db.session import get_db
6
+ from app.security.jwt import verify_token
7
+ from app.models import User, Admin, SuperAdmin
8
+
9
+ def get_current_user_from_cookie(request: Request, db: Session = Depends(get_db)) -> Union[User, Admin, SuperAdmin]:
10
+ """
11
+ New primary dependency to get the current user from the access_token cookie.
12
+ """
13
+ credentials_exception = HTTPException(
14
+ status_code=status.HTTP_401_UNAUTHORIZED,
15
+ detail="Could not validate credentials",
16
+ headers={"WWW-Authenticate": "Bearer"},
17
+ )
18
+
19
+ token = request.cookies.get("access_token")
20
+ if token is None:
21
+ raise credentials_exception
22
+
23
+ token_data = verify_token(token, credentials_exception)
24
+
25
+ # Based on the role in the token, fetch from the correct table
26
+ role = token_data.role
27
+ username = token_data.username
28
+
29
+ user = None
30
+ if role == "user":
31
+ user = db.query(User).filter(User.username == username).first()
32
+ elif role == "admin":
33
+ user = db.query(Admin).filter(Admin.username == username).first()
34
+ elif role == "super_admin":
35
+ user = db.query(SuperAdmin).filter(SuperAdmin.username == username).first()
36
+
37
+ if user is None:
38
+ raise credentials_exception
39
+
40
+ return user
app/security/hashing.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from passlib.context import CryptContext
2
+
3
+ # Use bcrypt for password hashing
4
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
5
+
6
+ class Hasher:
7
+ @staticmethod
8
+ def verify_password(plain_password, hashed_password):
9
+ """Verifies a plain password against a hashed one."""
10
+ return pwd_context.verify(plain_password, hashed_password)
11
+
12
+ @staticmethod
13
+ def get_password_hash(password):
14
+ """Hashes a plain password."""
15
+ return pwd_context.hash(password)
app/security/jwt.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timedelta
2
+ from typing import Optional
3
+ from jose import JWTError, jwt
4
+ from app.core.config import settings
5
+ from app.schemas.token import TokenData
6
+
7
+ ACCESS_TOKEN_EXPIRE_MINUTES = 15 # 15 minutes
8
+ REFRESH_TOKEN_EXPIRE_DAYS = 7 # 7 days
9
+
10
+ def create_access_token(data: dict):
11
+ """Creates a short-lived access token."""
12
+ to_encode = data.copy()
13
+ expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
14
+ to_encode.update({"exp": expire})
15
+ encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
16
+ return encoded_jwt
17
+
18
+ def create_refresh_token(data: dict):
19
+ """Creates a long-lived refresh token."""
20
+ to_encode = data.copy()
21
+ expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
22
+ to_encode.update({"exp": expire})
23
+ encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
24
+ return encoded_jwt
25
+
26
+ def verify_token(token: str, credentials_exception) -> TokenData:
27
+ """Verifies any JWT token and returns its payload."""
28
+ try:
29
+ payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
30
+ username: str = payload.get("sub")
31
+ role: str = payload.get("role")
32
+ tenant_id: int = payload.get("tenant_id")
33
+
34
+ if username is None or role is None:
35
+ raise credentials_exception
36
+
37
+ token_data = TokenData(username=username, role=role, tenant_id=tenant_id)
38
+ return token_data
39
+ except JWTError:
40
+ raise credentials_exception
app/websocket/connection_manager.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/websocket/connection_manager.py
2
+ from fastapi import WebSocket
3
+ from typing import Dict, List, Set
4
+
5
+ class ConnectionManager:
6
+ def __init__(self):
7
+ # Maps user_id to their active WebSocket connection
8
+ self.active_connections: Dict[int, WebSocket] = {}
9
+
10
+ async def connect(self, user_id: int, websocket: WebSocket):
11
+ """Accept a new WebSocket connection."""
12
+ await websocket.accept()
13
+ self.active_connections[user_id] = websocket
14
+
15
+ def disconnect(self, user_id: int):
16
+ """Disconnect a WebSocket."""
17
+ if user_id in self.active_connections:
18
+ del self.active_connections[user_id]
19
+
20
+ async def send_personal_message(self, message: str, user_id: int):
21
+ """Send a message to a specific user."""
22
+ if user_id in self.active_connections:
23
+ websocket = self.active_connections[user_id]
24
+ await websocket.send_text(message)
25
+
26
+ async def broadcast_to_users(self, message: str, user_ids: List[int]):
27
+ """Send a message to a list of specific users."""
28
+ for user_id in user_ids:
29
+ if user_id in self.active_connections:
30
+ await self.send_personal_message(message, user_id)
31
+
32
+ def get_all_connection_ids(self) -> Set[str]:
33
+ """Returns a set of all active connection IDs."""
34
+ return set(self.active_connections.keys())
35
+
36
+ manager = ConnectionManager()
main.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # main.py
2
+ from fastapi import FastAPI
3
+ from fastapi.middleware.cors import CORSMiddleware
4
+ from app.core.config import settings
5
+ from app.api.v1.api_router import api_router
6
+ from app.db.session import close_mongo_connection, connect_to_mongo
7
+
8
+ app = FastAPI(
9
+ title="Multi-Tenant Chat API",
10
+ openapi_url=f"{settings.API_V1_STR}/openapi.json"
11
+ )
12
+
13
+ origins = [
14
+ "http://localhost:3000"
15
+ ]
16
+
17
+ app.add_middleware(
18
+ CORSMiddleware,
19
+ allow_origins=origins,
20
+ allow_credentials=True,
21
+ allow_methods=["*"],
22
+ allow_headers=["*"],
23
+ )
24
+
25
+ @app.on_event("startup")
26
+ async def startup_event():
27
+ """
28
+ Connect to MongoDB on startup.
29
+ """
30
+ await connect_to_mongo()
31
+
32
+ @app.on_event("shutdown")
33
+ async def shutdown_event():
34
+ """
35
+ Close MongoDB connection on shutdown.
36
+ """
37
+ await close_mongo_connection()
38
+
39
+
40
+ # Include the API router
41
+ app.include_router(api_router, prefix=settings.API_V1_STR)
42
+
43
+ @app.get("/")
44
+ def read_root():
45
+ """
46
+ Root endpoint for basic health check.
47
+ """
48
+ return {"message": "Welcome to the Multi-Tenant Chat API"}
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn[standard]
3
+ psycopg2-binary
4
+ SQLAlchemy
5
+ motor
6
+ pydantic[email]
7
+ python-dotenv
8
+ passlib[bcrypt]
9
+ python-jose[cryptography]
10
+ python-multipart
11
+ pytz