diff --git a/backend/app/routers/applications.py b/backend/app/routers/applications.py index 7c75607c..25db428f 100644 --- a/backend/app/routers/applications.py +++ b/backend/app/routers/applications.py @@ -14,6 +14,9 @@ ApplicationUpdate, ) from app.services.application_service import ApplicationService +from app.models.notification import NotificationType +from app.models.project import Project +from app.services.notification_service import NotificationService router = APIRouter( prefix="/applications", @@ -32,7 +35,7 @@ def create_application( current_user: User = Depends(get_current_user), ): - return ApplicationService.create_application( + created = ApplicationService.create_application( db=db, applicant_id=current_user.id, project_id=application.project_id, @@ -40,6 +43,25 @@ def create_application( application=application, ) + try: + project = db.get(Project, created.project_id) + if project is not None: + NotificationService.notify( + db, + recipient_id=project.owner_id, + sender_id=current_user.id, + type=NotificationType.APPLICATION, + title="New application", + message=f"{current_user.username} applied to your project.", + project_id=created.project_id, + application_id=created.id, + action_url=f"/applications/{created.id}", + ) + except Exception: + db.rollback() + + return created + @router.get( "/{application_id}", @@ -129,6 +151,7 @@ def update_application( def accept_application( application_id: uuid.UUID, db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), ): db_application = ApplicationService.get_application( @@ -142,11 +165,28 @@ def accept_application( detail="Application not found", ) - return ApplicationService.accept_application( + accepted = ApplicationService.accept_application( db, db_application, ) + try: + NotificationService.notify( + db, + recipient_id=db_application.applicant_id, + sender_id=current_user.id, + type=NotificationType.APPLICATION_ACCEPTED, + title="Application accepted", + message="Your application was accepted. 🎉", + project_id=db_application.project_id, + application_id=db_application.id, + action_url=f"/applications/{db_application.id}", + ) + except Exception: + db.rollback() + + return accepted + @router.patch( "/{application_id}/reject", @@ -155,6 +195,7 @@ def accept_application( def reject_application( application_id: uuid.UUID, db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), ): db_application = ApplicationService.get_application( @@ -168,11 +209,28 @@ def reject_application( detail="Application not found", ) - return ApplicationService.reject_application( + rejected = ApplicationService.reject_application( db, db_application, ) + try: + NotificationService.notify( + db, + recipient_id=db_application.applicant_id, + sender_id=current_user.id, + type=NotificationType.APPLICATION_REJECTED, + title="Application rejected", + message="Your application was not accepted this time.", + project_id=db_application.project_id, + application_id=db_application.id, + action_url=f"/applications/{db_application.id}", + ) + except Exception: + db.rollback() + + return rejected + @router.patch( "/{application_id}/withdraw", diff --git a/backend/app/routers/followers.py b/backend/app/routers/followers.py index 91ab3341..60330293 100644 --- a/backend/app/routers/followers.py +++ b/backend/app/routers/followers.py @@ -10,6 +10,8 @@ from app.models.user import User from app.schemas.follower import FollowerResponse from app.services.follower_service import FollowerService +from app.models.notification import NotificationType +from app.services.notification_service import NotificationService router = APIRouter( prefix="/followers", @@ -46,12 +48,27 @@ def follow_user( detail="Already following this user", ) - return FollowerService.follow_user( + follow = FollowerService.follow_user( db, current_user.id, user_id, ) + try: + NotificationService.notify( + db, + recipient_id=user_id, + sender_id=current_user.id, + type=NotificationType.FOLLOW, + title="New follower", + message=f"{current_user.username} started following you.", + action_url=f"/users/{current_user.id}", + ) + except Exception: + db.rollback() + + return follow + @router.delete( "/{user_id}", diff --git a/backend/app/routers/messages.py b/backend/app/routers/messages.py index 963cfdea..caf9e87b 100644 --- a/backend/app/routers/messages.py +++ b/backend/app/routers/messages.py @@ -14,6 +14,11 @@ MessageUpdate, ) from app.services.message_service import MessageService +from sqlalchemy import select + +from app.models.conversation_member import ConversationMember +from app.models.notification import NotificationType +from app.services.notification_service import NotificationService router = APIRouter( prefix="/messages", @@ -32,13 +37,38 @@ def send_message( current_user: User = Depends(get_current_user), ): - return MessageService.send_message( + sent = MessageService.send_message( db=db, conversation_id=message.conversation_id, sender_id=current_user.id, message=message, ) + try: + recipient_ids = db.scalars( + select(ConversationMember.user_id).where( + ConversationMember.conversation_id == message.conversation_id, + ConversationMember.user_id != current_user.id, + ) + ).all() + + for recipient_id in recipient_ids: + NotificationService.notify( + db, + recipient_id=recipient_id, + sender_id=current_user.id, + type=NotificationType.MESSAGE, + title="New message", + message=f"{current_user.username} sent you a message.", + conversation_id=message.conversation_id, + message_id=sent.id, + action_url=f"/conversations/{message.conversation_id}", + ) + except Exception: + db.rollback() + + return sent + @router.get( "/{message_id}", diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py new file mode 100644 index 00000000..fc20f5ed --- /dev/null +++ b/backend/app/schemas/notification.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict + +from app.models.notification import NotificationType + + +# ========================================================== +# Base +# ========================================================== + + +class NotificationBase(BaseModel): + type: NotificationType + title: str + message: str + + action_url: Optional[str] = None + image_url: Optional[str] = None + + project_id: Optional[uuid.UUID] = None + conversation_id: Optional[uuid.UUID] = None + message_id: Optional[uuid.UUID] = None + application_id: Optional[uuid.UUID] = None + + +# ========================================================== +# Create (the existing test endpoint reads notification.recipient_id) +# ========================================================== + + +class NotificationCreate(NotificationBase): + recipient_id: uuid.UUID + + +# ========================================================== +# Update +# ========================================================== + + +class NotificationUpdate(BaseModel): + is_read: Optional[bool] = None + title: Optional[str] = None + message: Optional[str] = None + action_url: Optional[str] = None + image_url: Optional[str] = None + + +# ========================================================== +# Response +# ========================================================== + + +class NotificationResponse(NotificationBase): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + recipient_id: uuid.UUID + sender_id: Optional[uuid.UUID] = None + + is_read: bool + read_at: Optional[datetime] = None + + created_at: datetime + updated_at: datetime \ No newline at end of file diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index f36910c8..5a5e342c 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -6,7 +6,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session -from app.models.notification import Notification +from app.models.notification import Notification, NotificationType from app.schemas.notification import ( NotificationCreate, NotificationUpdate, @@ -46,6 +46,45 @@ def create_notification( return db_notification + @staticmethod + def notify( + db: Session, + *, + recipient_id: uuid.UUID, + sender_id: uuid.UUID | None, + type: NotificationType, + title: str, + message: str, + action_url: str | None = None, + image_url: str | None = None, + project_id: uuid.UUID | None = None, + conversation_id: uuid.UUID | None = None, + message_id: uuid.UUID | None = None, + application_id: uuid.UUID | None = None, + ) -> Notification | None: + if sender_id is not None and recipient_id == sender_id: + return None + + db_notification = Notification( + recipient_id=recipient_id, + sender_id=sender_id, + type=type, + title=title, + message=message, + action_url=action_url, + image_url=image_url, + project_id=project_id, + conversation_id=conversation_id, + message_id=message_id, + application_id=application_id, + ) + + db.add(db_notification) + db.commit() + db.refresh(db_notification) + + return db_notification + @staticmethod def get_notification( db: Session, diff --git a/backend/tests/test_notifications_events.py b/backend/tests/test_notifications_events.py new file mode 100644 index 00000000..f4da9516 --- /dev/null +++ b/backend/tests/test_notifications_events.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from app.database.base import Base +from app.database.session import get_db +from app.main import app + + +engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, +) +TestingSessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) + + +def override_get_db(): + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + + +app.dependency_overrides[get_db] = override_get_db + + +@pytest.fixture(autouse=True) +def setup_db(): + Base.metadata.create_all(bind=engine) + yield + Base.metadata.drop_all(bind=engine) + + +def _register_and_login(client: TestClient, email: str, username: str) -> tuple[str, str]: + client.post("/api/auth/register", json={ + "first_name": username.capitalize(), + "last_name": "User", + "email": email, + "username": username, + "password": "Passw0rd!", + }) + r = client.post("/api/auth/login", json={"email": email, "password": "Passw0rd!"}) + token = r.json()["access_token"] + me = client.get("/api/users/me", headers={"Authorization": f"Bearer {token}"}) + return me.json()["id"], token + + +def test_follow_creates_notification(): + client = TestClient(app) + a_id, a_tok = _register_and_login(client, "a@x.com", "alice") + b_id, b_tok = _register_and_login(client, "b@x.com", "bob") + + r = client.post(f"/followers/{a_id}", headers={"Authorization": f"Bearer {b_tok}"}) + assert r.status_code == 201 + + notifs = client.get("/api/notifications/", headers={"Authorization": f"Bearer {a_tok}"}).json() + assert any(n["type"] == "follow" for n in notifs) + + b_notifs = client.get("/api/notifications/", headers={"Authorization": f"Bearer {b_tok}"}).json() + assert all(n["type"] != "follow" for n in b_notifs) + + +def test_no_self_notification(): + client = TestClient(app) + a_id, a_tok = _register_and_login(client, "c@x.com", "charlie") + + r = client.post(f"/followers/{a_id}", headers={"Authorization": f"Bearer {a_tok}"}) + assert r.status_code == 400 + + +def test_notify_returns_none_when_recipient_is_sender(): + from app.services.notification_service import NotificationService + from app.models.notification import NotificationType + db = TestingSessionLocal() + result = NotificationService.notify( + db, + recipient_id="00000000-0000-0000-0000-000000000001", + sender_id="00000000-0000-0000-0000-000000000001", + type=NotificationType.FOLLOW, + title="Self test", + message="Should not be created.", + ) + assert result is None + db.close() \ No newline at end of file