Skip to content

Commit a0aa610

Browse files
authored
Merge pull request #223 from Pseudo-Lab/fix/team-match
refactor(getcloser): team match logic and cancel logic
2 parents c479d8a + a5157b3 commit a0aa610

8 files changed

Lines changed: 255 additions & 59 deletions

File tree

getcloser/backend/app/api/v1/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44
from api.v1.teams import teams
55
from core.dependencies import get_current_user
66
from fastapi import APIRouter, Depends
7+
import core.websocket as websocket
78

89
private_router = APIRouter(
910
dependencies=[Depends(get_current_user)]
1011
)
1112
private_router.include_router(users.router, prefix="/users", tags=["users"])
1213
private_router.include_router(challenges.router, prefix="/challenges", tags=["challenges"])
1314
private_router.include_router(teams.router, prefix="/teams", tags=["teams"])
15+
private_router.include_router(websocket.router, prefix="/ws", tags=["ws"])
1416

1517
public_router = APIRouter()
1618
public_router.include_router(auth.router, prefix="/auth", tags=["auth"])
Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,23 @@
11
from fastapi import APIRouter, Depends
22
from sqlalchemy.orm import Session
33
from core.database import get_db
4-
from schemas.team_schema import TeamCreateRequest, TeamResponse
4+
from schemas.team_schema import TeamCreateRequest, TeamCreateResponse, TeamStatusResponse
55
from services.team_service import create_team
6+
from core.dependencies import get_current_user
7+
from core.websocket import notify_invitation
68

79
router = APIRouter()
810

9-
@router.post("/create", response_model=TeamResponse)
10-
def create_team_route(req: TeamCreateRequest, db: Session = Depends(get_db)):
11-
return create_team(db, req)
11+
@router.post("/create", response_model=TeamCreateResponse)
12+
async def create_team_route(req: TeamCreateRequest, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
13+
res = create_team(db, int(current_user["sub"]), req.member_ids)
14+
return TeamCreateResponse(**res)
15+
16+
@router.post("/{team_id}/cancel")
17+
def cancel_route(team_id: int, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
18+
return cancel_team(db, team_id, current_user["sub"])
19+
20+
@router.get("/{team_id}/status", response_model=TeamStatusResponse)
21+
def status_route(team_id: int, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
22+
res = get_team_status(db, team_id, current_user["sub"])
23+
return TeamStatusResponse(**res)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from fastapi import WebSocket, WebSocketDisconnect, APIRouter, Depends
2+
from core.dependencies import get_current_user
3+
from core.security import verify_access_token
4+
5+
router = APIRouter()
6+
active_sockets: dict[int, WebSocket] = {}
7+
8+
@router.websocket("/invitations")
9+
async def websocket_endpoint(websocket: WebSocket):
10+
try:
11+
user_id = verify_access_token(websocket.headers.get("Sec-Websocket-Protocol"))["sub"]
12+
except HTTPException:
13+
await websocket.close(code=1008)
14+
return
15+
16+
await websocket.accept()
17+
active_sockets[user_id] = websocket
18+
try:
19+
while True:
20+
await websocket.receive_text()
21+
except WebSocketDisconnect:
22+
del active_sockets[user_id]
23+
24+
async def notify_invitation(member_ids, team):
25+
for uid in member_ids:
26+
if uid in active_sockets:
27+
await active_sockets[uid].send_json({
28+
"event": "team_invitation",
29+
"team_id": team.id,
30+
"members": member_ids,
31+
"expires_at": str(team.expires_at)
32+
})

getcloser/backend/app/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
origins = os.getenv("CORS_ORIGINS", "").split(",")
3232
app.add_middleware(
3333
CORSMiddleware,
34-
allow_origins=origins,
34+
allow_origins=["*"],
3535
allow_credentials=True,
3636
allow_methods=["*"],
3737
allow_headers=["*"],
Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,37 @@
11
from core.database import Base
2-
from sqlalchemy import Column, Integer, Boolean, UniqueConstraint
2+
from sqlalchemy import Column, Integer, Boolean, UniqueConstraint, Enum, DateTime, ForeignKey, String
3+
import enum
4+
from datetime import datetime, timedelta
5+
from sqlalchemy.orm import relationship
36

7+
class TeamStatus(str, enum.Enum):
8+
PENDING = "PENDING"
9+
ACTIVE = "ACTIVE"
10+
CANCELLED = "CANCELLED"
11+
FAILED = "FAILED"
12+
413

514
class Team(Base):
615
__tablename__ = "teams"
716

817
id = Column(Integer, primary_key=True, index=True)
9-
team_id = Column(Integer, index=True)
10-
user_id = Column(Integer, index=True)
11-
is_active = Column(Boolean, default=True)
18+
group_hash = Column(String, index=True)
19+
status = Column(Enum(TeamStatus), nullable=False, default=TeamStatus.PENDING)
20+
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
21+
22+
members = relationship("TeamMember", back_populates="team", cascade="all, delete-orphan")
23+
24+
25+
class TeamMember(Base):
26+
__tablename__ = "team_members"
27+
28+
id = Column(Integer, primary_key=True, index=True)
29+
team_id = Column(Integer, ForeignKey("teams.id", ondelete="CASCADE"), index=True, nullable=False)
30+
user_id = Column(Integer, index=True, nullable=False)
31+
confirmed = Column(Boolean, default=False)
32+
33+
team = relationship("Team", back_populates="members")
1234

13-
__table_args__ = (
14-
UniqueConstraint('team_id', 'user_id', name='_team_user_uc'),
35+
__table_args__ = (
36+
UniqueConstraint("team_id", "user_id", name="u_team_user"),
1537
)
Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,17 @@
11
from pydantic import BaseModel, Field
2-
from typing import List
2+
from typing import List, Optional
3+
from datetime import datetime
34

45

56
class TeamCreateRequest(BaseModel):
6-
my_id: int
7-
member_ids: List[int] = Field(..., min_items=4, max_items=4, description="팀원 4명의 ID")
7+
member_ids: List[int] = Field(..., min_items=1, max_items=4, description="팀원 4명의 ID")
88

9-
class Config:
10-
json_schema_extra = {
11-
"example": {
12-
"my_id": 1,
13-
"member_ids": [2, 3, 4, 5]
14-
}
15-
}
9+
class TeamCreateResponse(BaseModel):
10+
team_id: int
11+
status: str
12+
message: str
1613

17-
class TeamResponse(BaseModel):
14+
class TeamStatusResponse(BaseModel):
1815
team_id: int
19-
members_ids: List[int]
16+
status: str
17+
members_ready: List[int]
Lines changed: 164 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,186 @@
11
from models.challenges import UserChallengeStatus
22
from sqlalchemy import func
33
from sqlalchemy.orm import Session
4-
from models.teams import Team
5-
from schemas.team_schema import TeamCreateRequest, TeamResponse
4+
from models.teams import Team, TeamMember, TeamStatus
5+
from schemas.team_schema import TeamCreateRequest
66
from fastapi import HTTPException
7+
from typing import List
8+
import os
9+
from datetime import datetime, timedelta
710

8-
def create_team(db: Session, req: TeamCreateRequest) -> TeamResponse:
9-
all_ids = [req.my_id] + req.member_ids
11+
TEAM_SIZE = int(os.getenv("TEAM_SIZE", "0"))
12+
PENDING_TIMEOUT_MINUTES = int(os.getenv("PENDING_TIMEOUT_MINUTES", "0"))
1013

11-
records = (
12-
db.query(Team.user_id, Team.is_active, UserChallengeStatus.is_correct)
13-
.outerjoin(UserChallengeStatus, Team.user_id == UserChallengeStatus.user_id)
14-
.filter(Team.user_id.in_(all_ids))
14+
def _now():
15+
return datetime.utcnow()
16+
17+
def create_team(db: Session, my_id: int, member_ids: List[int]):
18+
all_ids = sorted([my_id] + member_ids)
19+
20+
if len(all_ids) != TEAM_SIZE:
21+
raise HTTPException(status_code=400, detail="Team must have exactly 5 members (you + 4).")
22+
if len(set(all_ids)) != TEAM_SIZE:
23+
raise HTTPException(status_code=400, detail="Duplicate user IDs in request.")
24+
25+
corrected = db.query(UserChallengeStatus.user_id).filter(
26+
UserChallengeStatus.user_id.in_(all_ids),
27+
UserChallengeStatus.is_correct == True
28+
).all()
29+
30+
if corrected:
31+
corrected_ids = [r[0] for r in corrected]
32+
raise HTTPException(status_code=400, detail=f"Users already corrected: {corrected_ids}")
33+
34+
blocking_users = (
35+
db.query(TeamMember.user_id)
36+
.join(Team)
37+
.filter(
38+
TeamMember.user_id.in_(all_ids),
39+
Team.status == TeamStatus.ACTIVE
40+
)
1541
.all()
1642
)
43+
if blocking_users:
44+
raise HTTPException(status_code=400,
45+
detail=f"Users already in another team: {[u[0] for u in blocking_users]}")
46+
47+
group_hash = "-".join(map(str, all_ids))
1748

18-
active_ids = [r.user_id for r in records if r.is_active]
19-
corrected_ids = [r.user_id for r in records if r.is_correct]
49+
team = (
50+
db.query(Team)
51+
.filter(Team.group_hash == group_hash,
52+
Team.status.in_([TeamStatus.PENDING, TeamStatus.ACTIVE]))
53+
.first()
54+
)
2055

21-
if active_ids:
22-
raise HTTPException(
23-
status_code=400,
24-
detail=f"Users already in active team: {active_ids}",
25-
)
56+
target_team = None
57+
58+
if team and team.status == TeamStatus.PENDING:
59+
if _now() - team.created_at.replace(tzinfo=None) > timedelta(minutes=PENDING_TIMEOUT_MINUTES):
60+
team.status = TeamStatus.CANCELLED
61+
db.commit()
62+
else:
63+
me = (
64+
db.query(TeamMember)
65+
.filter(TeamMember.team_id == team.id, TeamMember.user_id == my_id)
66+
.first()
67+
)
68+
if me:
69+
me.confirmed = True
70+
71+
db.flush()
2672

27-
if corrected_ids:
28-
raise HTTPException(
29-
status_code=400,
30-
detail=f"Users already corrected: {corrected_ids}",
31-
)
73+
ready_count = (
74+
db.query(TeamMember)
75+
.filter(TeamMember.team_id == team.id, TeamMember.confirmed == True)
76+
.count()
77+
)
78+
79+
if ready_count == TEAM_SIZE:
80+
team.status = TeamStatus.ACTIVE
81+
db.commit()
82+
return {"status": "ACTIVE", "team_id": team.id, "message": "Team matched"}
3283

33-
last_team_id = db.query(func.max(Team.team_id)).scalar()
34-
new_team_id = (last_team_id or 0) + 1
84+
db.commit()
85+
return {"status": "PENDING", "team_id": team.id, "message": "Joined pending team request"}
86+
87+
if team and team.status == TeamStatus.ACTIVE:
88+
raise HTTPException(status_code=400, detail="This team is already active.")
89+
90+
team = Team(group_hash=group_hash, status=TeamStatus.PENDING)
91+
db.add(team)
92+
db.flush()
3593

36-
teams_to_add = [
37-
{"team_id": new_team_id, "user_id": uid, "is_active": True}
38-
for uid in all_ids
94+
members = [
95+
TeamMember(team_id=team.id, user_id=uid, confirmed=(uid == my_id))
96+
for uid in all_ids
3997
]
98+
db.bulk_save_objects(members)
99+
db.commit()
100+
101+
return {"status": "PENDING", "team_id": team.id, "message": "New team request created"}
102+
103+
def cancel_team(db: Session, team_id: int, user_id: int):
104+
team = (
105+
db.query(Team)
106+
.filter(
107+
Team.id == team_id,
108+
Team.status == TeamStatus.PENDING
109+
).first()
110+
)
111+
112+
if not team:
113+
raise HTTPException(status_code=400, detail="No pending team found")
40114

41-
db.bulk_insert_mappings(Team, teams_to_add)
115+
member = db.query(TeamMember).filter(
116+
TeamMember.team_id == team_id,
117+
TeamMember.user_id == user_id
118+
).first()
119+
120+
if not member:
121+
raise HTTPException(status_code=403, detail="You are not a member of this team")
122+
123+
if member.confirmed is False:
124+
return {"message": "Already cancelled"}
125+
126+
member.confirmed = False
127+
db.commit()
128+
db.refresh(team)
129+
130+
remaining_confirmed = db.query(TeamMember).filter(
131+
TeamMember.team_id == team_id,
132+
TeamMember.confirmed == True
133+
).count()
134+
135+
if remaining_confirmed == 0:
136+
team.status = TeamStatus.CANCELLED
42137
db.commit()
138+
return {"message": "Team cancelled (last member left)"}
139+
140+
return {"message": "You left the team"}
141+
142+
def get_team_status(db: Session, team_id: int, user_id: int):
143+
last_member_entry = (
144+
db.query(TeamMember)
145+
.join(Team)
146+
.filter(TeamMember.user_id == user_id)
147+
.order_by(Team.created_at.desc())
148+
.first()
149+
)
43150

44-
return TeamResponse(team_id=new_team_id, members_ids=all_ids)
151+
if not last_member_entry:
152+
return {"status": "NONE"}
45153

46-
def dissolve_team_by_user(db: Session, user_id: int):
47-
team_entry = db.query(Team).filter(Team.user_id == user_id, Team.is_active == True).first()
48-
if not team_entry:
49-
raise HTTPException(status_code=400, detail="User not in active team")
154+
team = last_member_entry.team
155+
156+
if team.status == TeamStatus.PENDING:
157+
time_diff = datetime.now() - team.created_at
158+
if time_diff > timedelta(minutes=PENDING_TIMEOUT_MINUTES):
159+
team.status = TeamStatus.CANCELLED
160+
db.commit()
161+
return {"status": "EXPIRED"}
162+
163+
return {
164+
"team_id": team.id,
165+
"status": team.status.value,
166+
"members_ready": [m.user_id for m in team.members if m.confirmed]
167+
}
50168

51-
team_id = team_entry.team_id
52-
team_members = db.query(Team).filter(Team.team_id == team_id, Team.is_active == True).all()
169+
def dissolve_team_by_user(db: Session, user_id: int):
170+
team_entry = (
171+
db.query(Team)
172+
.join(TeamMember)
173+
.filter(
174+
TeamMember.user_id == user_id,
175+
Team.status == TeamStatus.ACTIVE
176+
).first()
177+
)
53178

54-
for member in team_members:
55-
member.is_active = False
179+
if not team_entry:
180+
raise HTTPException(status_code=400, detail="User is not in an active team")
56181

182+
team_entry.status = TeamStatus.FAILED
183+
57184
db.commit()
58-
return {"message": f"Team {team_id} dissolved", "team_id": team_id}
185+
186+
return {"message": f"Team {team_entry.id} dissolved due to quiz failure.", "team_id": team_entry.id}

getcloser/docker-compose.dev.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ services:
3232
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000}
3333
SECRET_KEY: ${SECRET_KEY:-change-me-in-prod}
3434
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
35+
TEAM_SIZE: ${TEAM_SIZE}
36+
PENDING_TIMEOUT_MINUTES: ${PENDING_TIMEOUT_MINUTES}
3537
volumes:
3638
- ./backend/app:/app
3739
depends_on:

0 commit comments

Comments
 (0)