Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions getcloser/backend/app/api/v1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
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)]
)
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"])
Expand Down
24 changes: 20 additions & 4 deletions getcloser/backend/app/api/v1/teams/teams.py
Original file line number Diff line number Diff line change
@@ -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"])
Comment on lines +16 to +18
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

์•ˆ ์“ฐ๋Š” ์ฝ”๋“œ๋Š” ์ง€์›Œ ์ฃผ์„ธ์š”. ๐Ÿ˜‰


@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)
32 changes: 32 additions & 0 deletions getcloser/backend/app/core/websocket.py
Original file line number Diff line number Diff line change
@@ -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)
})
2 changes: 1 addition & 1 deletion getcloser/backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=["*"],
Expand Down
34 changes: 28 additions & 6 deletions getcloser/backend/app/models/teams.py
Original file line number Diff line number Diff line change
@@ -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"),
)
22 changes: 10 additions & 12 deletions getcloser/backend/app/schemas/team_schema.py
Original file line number Diff line number Diff line change
@@ -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]
180 changes: 143 additions & 37 deletions getcloser/backend/app/services/team_service.py
Original file line number Diff line number Diff line change
@@ -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 = (
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my_member ๋ผ๋Š” ํ‘œํ˜„์€ ์ข€ ์–ด์ƒ‰ํ•˜๋„ค์š”. 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

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):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 ๋Š” ์ƒ์ˆ˜๋กœ ๋นผ๋ฉด ์ข‹์„ ๊ฒƒ ๊ฐ™์•„์š”. (PENDING_TIMEOUT_MINUTES ๋ž‘ ๊ฐ™์€ ๊ฑธ๊นŒ์š”? ๐Ÿ‘€)

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}
2 changes: 2 additions & 0 deletions getcloser/docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading