From 2cd71931a39423550dda81e29466c9ce0c3305a1 Mon Sep 17 00:00:00 2001 From: hu6r1s Date: Sat, 29 Nov 2025 22:30:01 +0900 Subject: [PATCH 1/3] fix: team match logic --- getcloser/backend/app/api/v1/__init__.py | 2 + getcloser/backend/app/api/v1/teams/teams.py | 24 ++- getcloser/backend/app/core/websocket.py | 32 ++++ getcloser/backend/app/main.py | 2 +- getcloser/backend/app/models/teams.py | 34 +++- getcloser/backend/app/schemas/team_schema.py | 22 +-- .../backend/app/services/team_service.py | 180 ++++++++++++++---- getcloser/docker-compose.dev.yml | 2 + 8 files changed, 238 insertions(+), 60 deletions(-) create mode 100644 getcloser/backend/app/core/websocket.py diff --git a/getcloser/backend/app/api/v1/__init__.py b/getcloser/backend/app/api/v1/__init__.py index d3d12eb..36b2852 100644 --- a/getcloser/backend/app/api/v1/__init__.py +++ b/getcloser/backend/app/api/v1/__init__.py @@ -4,6 +4,7 @@ from api.v1.teams import teams from core.dependencies import get_current_user from fastapi import APIRouter, Depends +import core.websocket as websocket private_router = APIRouter( dependencies=[Depends(get_current_user)] @@ -11,6 +12,7 @@ private_router.include_router(users.router, prefix="/users", tags=["users"]) private_router.include_router(challenges.router, prefix="/challenges", tags=["challenges"]) private_router.include_router(teams.router, prefix="/teams", tags=["teams"]) +private_router.include_router(websocket.router, prefix="/ws", tags=["ws"]) public_router = APIRouter() public_router.include_router(auth.router, prefix="/auth", tags=["auth"]) diff --git a/getcloser/backend/app/api/v1/teams/teams.py b/getcloser/backend/app/api/v1/teams/teams.py index aa7e6e8..9629c29 100644 --- a/getcloser/backend/app/api/v1/teams/teams.py +++ b/getcloser/backend/app/api/v1/teams/teams.py @@ -1,11 +1,27 @@ from fastapi import APIRouter, Depends from sqlalchemy.orm import Session from core.database import get_db -from schemas.team_schema import TeamCreateRequest, TeamResponse +from schemas.team_schema import TeamCreateRequest, TeamCreateResponse, TeamStatusResponse from services.team_service import create_team +from core.dependencies import get_current_user +from core.websocket import notify_invitation router = APIRouter() -@router.post("/create", response_model=TeamResponse) -def create_team_route(req: TeamCreateRequest, db: Session = Depends(get_db)): - return create_team(db, req) +@router.post("/create", response_model=TeamCreateResponse) +async def create_team_route(req: TeamCreateRequest, db: Session = Depends(get_db), current_user=Depends(get_current_user)): + res = create_team(db, int(current_user["sub"]), req.member_ids) + return TeamCreateResponse(**res) + +# @router.post("/{team_id}/confirm") +# def confirm_route(team_id: int, db: Session = Depends(get_db), current_user=Depends(get_current_user)): +# return confirm_membership(db, team_id, current_user["sub"]) + +@router.post("/{team_id}/cancel") +def cancel_route(team_id: int, db: Session = Depends(get_db), current_user=Depends(get_current_user)): + return cancel_team(db, team_id, current_user["sub"]) + +@router.get("/{team_id}/status", response_model=TeamStatusResponse) +def status_route(team_id: int, db: Session = Depends(get_db), current_user=Depends(get_current_user)): + res = get_team_status(db, team_id, current_user["sub"]) + return TeamStatusResponse(**res) diff --git a/getcloser/backend/app/core/websocket.py b/getcloser/backend/app/core/websocket.py new file mode 100644 index 0000000..55726e0 --- /dev/null +++ b/getcloser/backend/app/core/websocket.py @@ -0,0 +1,32 @@ +from fastapi import WebSocket, WebSocketDisconnect, APIRouter, Depends +from core.dependencies import get_current_user +from core.security import verify_access_token + +router = APIRouter() +active_sockets: dict[int, WebSocket] = {} + +@router.websocket("/invitations") +async def websocket_endpoint(websocket: WebSocket): + try: + user_id = verify_access_token(websocket.headers.get("Sec-Websocket-Protocol"))["sub"] + except HTTPException: + await websocket.close(code=1008) + return + + await websocket.accept() + active_sockets[user_id] = websocket + try: + while True: + await websocket.receive_text() + except WebSocketDisconnect: + del active_sockets[user_id] + +async def notify_invitation(member_ids, team): + for uid in member_ids: + if uid in active_sockets: + await active_sockets[uid].send_json({ + "event": "team_invitation", + "team_id": team.id, + "members": member_ids, + "expires_at": str(team.expires_at) + }) diff --git a/getcloser/backend/app/main.py b/getcloser/backend/app/main.py index c488533..bee5dc5 100644 --- a/getcloser/backend/app/main.py +++ b/getcloser/backend/app/main.py @@ -31,7 +31,7 @@ origins = os.getenv("CORS_ORIGINS", "").split(",") app.add_middleware( CORSMiddleware, - allow_origins=origins, + allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/getcloser/backend/app/models/teams.py b/getcloser/backend/app/models/teams.py index 1e6d50b..b9a57b9 100644 --- a/getcloser/backend/app/models/teams.py +++ b/getcloser/backend/app/models/teams.py @@ -1,15 +1,37 @@ from core.database import Base -from sqlalchemy import Column, Integer, Boolean, UniqueConstraint +from sqlalchemy import Column, Integer, Boolean, UniqueConstraint, Enum, DateTime, ForeignKey, String +import enum +from datetime import datetime, timedelta +from sqlalchemy.orm import relationship +class TeamStatus(str, enum.Enum): + PENDING = "PENDING" + ACTIVE = "ACTIVE" + CANCELLED = "CANCELLED" + FAILED = "FAILED" + class Team(Base): __tablename__ = "teams" id = Column(Integer, primary_key=True, index=True) - team_id = Column(Integer, index=True) - user_id = Column(Integer, index=True) - is_active = Column(Boolean, default=True) + group_hash = Column(String, index=True) + status = Column(Enum(TeamStatus), nullable=False, default=TeamStatus.PENDING) + created_at = Column(DateTime(timezone=True), default=datetime.utcnow) + + members = relationship("TeamMember", back_populates="team", cascade="all, delete-orphan") + + +class TeamMember(Base): + __tablename__ = "team_members" + + id = Column(Integer, primary_key=True, index=True) + team_id = Column(Integer, ForeignKey("teams.id", ondelete="CASCADE"), index=True, nullable=False) + user_id = Column(Integer, index=True, nullable=False) + confirmed = Column(Boolean, default=False) + + team = relationship("Team", back_populates="members") - __table_args__ = ( - UniqueConstraint('team_id', 'user_id', name='_team_user_uc'), + __table_args__ = ( + UniqueConstraint("team_id", "user_id", name="u_team_user"), ) diff --git a/getcloser/backend/app/schemas/team_schema.py b/getcloser/backend/app/schemas/team_schema.py index 2b3dbbe..6bdb8f3 100644 --- a/getcloser/backend/app/schemas/team_schema.py +++ b/getcloser/backend/app/schemas/team_schema.py @@ -1,19 +1,17 @@ from pydantic import BaseModel, Field -from typing import List +from typing import List, Optional +from datetime import datetime class TeamCreateRequest(BaseModel): - my_id: int - member_ids: List[int] = Field(..., min_items=4, max_items=4, description="팀원 4명의 ID") + member_ids: List[int] = Field(..., min_items=1, max_items=4, description="팀원 4명의 ID") - class Config: - json_schema_extra = { - "example": { - "my_id": 1, - "member_ids": [2, 3, 4, 5] - } - } +class TeamCreateResponse(BaseModel): + team_id: int + status: str + message: str -class TeamResponse(BaseModel): +class TeamStatusResponse(BaseModel): team_id: int - members_ids: List[int] + status: str + members_ready: List[int] diff --git a/getcloser/backend/app/services/team_service.py b/getcloser/backend/app/services/team_service.py index e3cb384..b2ff9e2 100644 --- a/getcloser/backend/app/services/team_service.py +++ b/getcloser/backend/app/services/team_service.py @@ -1,58 +1,164 @@ from models.challenges import UserChallengeStatus from sqlalchemy import func from sqlalchemy.orm import Session -from models.teams import Team -from schemas.team_schema import TeamCreateRequest, TeamResponse +from models.teams import Team, TeamMember, TeamStatus +from schemas.team_schema import TeamCreateRequest from fastapi import HTTPException +from typing import List +import os +from datetime import datetime, timedelta -def create_team(db: Session, req: TeamCreateRequest) -> TeamResponse: - all_ids = [req.my_id] + req.member_ids +TEAM_SIZE = int(os.getenv("TEAM_SIZE", "0")) +PENDING_TIMEOUT_MINUTES = int(os.getenv("PENDING_TIMEOUT_MINUTES", "0")) - records = ( - db.query(Team.user_id, Team.is_active, UserChallengeStatus.is_correct) - .outerjoin(UserChallengeStatus, Team.user_id == UserChallengeStatus.user_id) - .filter(Team.user_id.in_(all_ids)) +def _now(): + return datetime.utcnow() + +def create_team(db: Session, my_id: int, member_ids: List[int]): + all_ids = sorted([my_id] + member_ids) + + if len(all_ids) != TEAM_SIZE: + raise HTTPException(status_code=400, detail="Team must have exactly 5 members (you + 4).") + if len(set(all_ids)) != TEAM_SIZE: + raise HTTPException(status_code=400, detail="Duplicate user IDs in request.") + + corrected = db.query(UserChallengeStatus.user_id).filter( + UserChallengeStatus.user_id.in_(all_ids), + UserChallengeStatus.is_correct == True + ).all() + + if corrected: + corrected_ids = [r[0] for r in corrected] + raise HTTPException(status_code=400, detail=f"Users already corrected: {corrected_ids}") + + blocking_users = ( + db.query(TeamMember.user_id) + .join(Team) + .filter( + TeamMember.user_id.in_(all_ids), + Team.status == TeamStatus.ACTIVE + ) .all() ) + if blocking_users: + raise HTTPException(status_code=400, + detail=f"Users already in another team: {[u[0] for u in blocking_users]}") + + group_hash = "-".join(map(str, all_ids)) - active_ids = [r.user_id for r in records if r.is_active] - corrected_ids = [r.user_id for r in records if r.is_correct] + team = ( + db.query(Team) + .filter(Team.group_hash == group_hash, + Team.status.in_([TeamStatus.PENDING, TeamStatus.ACTIVE])) + .first() + ) - if active_ids: - raise HTTPException( - status_code=400, - detail=f"Users already in active team: {active_ids}", - ) + target_team = None + + if team and team.status == TeamStatus.PENDING: + if _now() - team.created_at.replace(tzinfo=None) > timedelta(minutes=PENDING_TIMEOUT_MINUTES): + team.status = TeamStatus.CANCELLED + db.commit() + else: + my_member = ( + db.query(TeamMember) + .filter(TeamMember.team_id == team.id, TeamMember.user_id == my_id) + .first() + ) + if my_member: + my_member.confirmed = True + + db.flush() - if corrected_ids: - raise HTTPException( - status_code=400, - detail=f"Users already corrected: {corrected_ids}", - ) + ready_count = ( + db.query(TeamMember) + .filter(TeamMember.team_id == team.id, TeamMember.confirmed == True) + .count() + ) - last_team_id = db.query(func.max(Team.team_id)).scalar() - new_team_id = (last_team_id or 0) + 1 + if ready_count == TEAM_SIZE: + team.status = TeamStatus.ACTIVE + db.commit() + return {"status": "ACTIVE", "team_id": team.id, "message": "Team matched"} - teams_to_add = [ - {"team_id": new_team_id, "user_id": uid, "is_active": True} - for uid in all_ids - ] + db.commit() + return {"status": "PENDING", "team_id": team.id, "message": "Joined pending team request"} - db.bulk_insert_mappings(Team, teams_to_add) + if team and team.status == TeamStatus.ACTIVE: + raise HTTPException(status_code=400, detail="This team is already active.") + + team = Team(group_hash=group_hash, status=TeamStatus.PENDING) + db.add(team) + db.flush() + + members = [ + TeamMember(team_id=team.id, user_id=uid, confirmed=(uid == my_id)) + for uid in all_ids + ] + db.bulk_save_objects(members) db.commit() - return TeamResponse(team_id=new_team_id, members_ids=all_ids) + return {"status": "PENDING", "team_id": team.id, "message": "New team request created"} -def dissolve_team_by_user(db: Session, user_id: int): - team_entry = db.query(Team).filter(Team.user_id == user_id, Team.is_active == True).first() - if not team_entry: - raise HTTPException(status_code=400, detail="User not in active team") +def cancel_team(db: Session, team_id: int, user_id: int): + team_entry = ( + db.query(Team) + .join(TeamMember) + .filter( + TeamMember.user_id == user_id, + Team.status == TeamStatus.PENDING + ).first() + ) + + if team_entry: + team_entry.status = TeamStatus.CANCELLED + db.commit() + return {"message": "Team request cancelled"} + + raise HTTPException(status_code=400, detail="No pending team found") + +def get_team_status(db: Session, team_id: int, user_id: int): + last_member_entry = ( + db.query(TeamMember) + .join(Team) + .filter(TeamMember.user_id == user_id) + .order_by(Team.created_at.desc()) + .first() + ) + + if not last_member_entry: + return {"status": "NONE"} - team_id = team_entry.team_id - team_members = db.query(Team).filter(Team.team_id == team_id, Team.is_active == True).all() + team = last_member_entry.team - for member in team_members: - member.is_active = False + if team.status == TeamStatus.PENDING: + time_diff = datetime.now() - team.created_at + if time_diff > timedelta(minutes=5): + team.status = TeamStatus.CANCELLED + db.commit() + return {"status": "EXPIRED"} + + return { + "team_id": team.id, + "status": team.status.value, + "members_ready": [m.user_id for m in team.members if m.confirmed] + } + +def dissolve_team_by_user(db: Session, user_id: int): + team_entry = ( + db.query(Team) + .join(TeamMember) + .filter( + TeamMember.user_id == user_id, + Team.status == TeamStatus.ACTIVE + ).first() + ) + + if not team_entry: + raise HTTPException(status_code=400, detail="User is not in an active team") + team_entry.status = TeamStatus.FAILED + db.commit() - return {"message": f"Team {team_id} dissolved", "team_id": team_id} + + return {"message": f"Team {team_entry.id} dissolved due to quiz failure.", "team_id": team_entry.id} diff --git a/getcloser/docker-compose.dev.yml b/getcloser/docker-compose.dev.yml index 0c3b244..5ab3784 100644 --- a/getcloser/docker-compose.dev.yml +++ b/getcloser/docker-compose.dev.yml @@ -32,6 +32,8 @@ services: CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000} SECRET_KEY: ${SECRET_KEY:-change-me-in-prod} ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60} + TEAM_SIZE: ${TEAM_SIZE} + PENDING_TIMEOUT_MINUTES: ${PENDING_TIMEOUT_MINUTES} volumes: - ./backend/app:/app depends_on: From f8f3e49832ace9244f3ac3b2af2719d964fa5091 Mon Sep 17 00:00:00 2001 From: hu6r1s Date: Wed, 3 Dec 2025 10:15:58 +0900 Subject: [PATCH 2/3] refactor: remove not used and change var name --- getcloser/backend/app/api/v1/teams/teams.py | 4 ---- getcloser/backend/app/services/team_service.py | 8 ++++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/getcloser/backend/app/api/v1/teams/teams.py b/getcloser/backend/app/api/v1/teams/teams.py index 9629c29..ac068db 100644 --- a/getcloser/backend/app/api/v1/teams/teams.py +++ b/getcloser/backend/app/api/v1/teams/teams.py @@ -13,10 +13,6 @@ async def create_team_route(req: TeamCreateRequest, db: Session = Depends(get_db res = create_team(db, int(current_user["sub"]), req.member_ids) return TeamCreateResponse(**res) -# @router.post("/{team_id}/confirm") -# def confirm_route(team_id: int, db: Session = Depends(get_db), current_user=Depends(get_current_user)): -# return confirm_membership(db, team_id, current_user["sub"]) - @router.post("/{team_id}/cancel") def cancel_route(team_id: int, db: Session = Depends(get_db), current_user=Depends(get_current_user)): return cancel_team(db, team_id, current_user["sub"]) diff --git a/getcloser/backend/app/services/team_service.py b/getcloser/backend/app/services/team_service.py index b2ff9e2..3fcbf0a 100644 --- a/getcloser/backend/app/services/team_service.py +++ b/getcloser/backend/app/services/team_service.py @@ -60,13 +60,13 @@ def create_team(db: Session, my_id: int, member_ids: List[int]): team.status = TeamStatus.CANCELLED db.commit() else: - my_member = ( + me = ( db.query(TeamMember) .filter(TeamMember.team_id == team.id, TeamMember.user_id == my_id) .first() ) - if my_member: - my_member.confirmed = True + if me: + me.confirmed = True db.flush() @@ -133,7 +133,7 @@ def get_team_status(db: Session, team_id: int, user_id: int): if team.status == TeamStatus.PENDING: time_diff = datetime.now() - team.created_at - if time_diff > timedelta(minutes=5): + if time_diff > timedelta(minutes=PENDING_TIMEOUT_MINUTES): team.status = TeamStatus.CANCELLED db.commit() return {"status": "EXPIRED"} From a5157b321c20a51ca35a2a4ba46ff11d3ffc7010 Mon Sep 17 00:00:00 2001 From: hu6r1s Date: Wed, 3 Dec 2025 11:34:52 +0900 Subject: [PATCH 3/3] refactor: team can logic --- .../backend/app/services/team_service.py | 40 ++++++++++++++----- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/getcloser/backend/app/services/team_service.py b/getcloser/backend/app/services/team_service.py index 3fcbf0a..eca20cb 100644 --- a/getcloser/backend/app/services/team_service.py +++ b/getcloser/backend/app/services/team_service.py @@ -101,22 +101,44 @@ def create_team(db: Session, my_id: int, member_ids: List[int]): return {"status": "PENDING", "team_id": team.id, "message": "New team request created"} def cancel_team(db: Session, team_id: int, user_id: int): - team_entry = ( + team = ( db.query(Team) - .join(TeamMember) .filter( - TeamMember.user_id == user_id, + Team.id == team_id, Team.status == TeamStatus.PENDING ).first() ) - if team_entry: - team_entry.status = TeamStatus.CANCELLED - db.commit() - return {"message": "Team request cancelled"} + if not team: + raise HTTPException(status_code=400, detail="No pending team found") + + member = db.query(TeamMember).filter( + TeamMember.team_id == team_id, + TeamMember.user_id == user_id + ).first() + + if not member: + raise HTTPException(status_code=403, detail="You are not a member of this team") + + if member.confirmed is False: + return {"message": "Already cancelled"} + + member.confirmed = False + db.commit() + db.refresh(team) + + remaining_confirmed = db.query(TeamMember).filter( + TeamMember.team_id == team_id, + TeamMember.confirmed == True + ).count() + + if remaining_confirmed == 0: + team.status = TeamStatus.CANCELLED + db.commit() + return {"message": "Team cancelled (last member left)"} + + return {"message": "You left the team"} - raise HTTPException(status_code=400, detail="No pending team found") - def get_team_status(db: Session, team_id: int, user_id: int): last_member_entry = ( db.query(TeamMember)