Coverage for src/alprina_cli/api/routes/team.py: 27%
125 statements
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-14 11:27 +0100
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-14 11:27 +0100
1"""
2Team Management Routes - /v1/team/*
4Handles team member invitations, seat management, and team operations.
5"""
7from fastapi import APIRouter, HTTPException, Depends
8from pydantic import BaseModel, EmailStr, Field
9from typing import List, Dict, Any, Optional
10from datetime import datetime
11from loguru import logger
13from ..services.neon_service import neon_service
14from ..services.polar_service import polar_service
15from ..middleware.auth import get_current_user
17router = APIRouter()
20# ==========================================
21# Request/Response Models
22# ==========================================
24class TeamMemberResponse(BaseModel):
25 id: str
26 email: str
27 role: str
28 joined_at: str
30 class Config:
31 schema_extra = {
32 "example": {
33 "id": "123e4567-e89b-12d3-a456-426614174000",
34 "email": "member@example.com",
35 "role": "member",
36 "joined_at": "2025-11-09T10:00:00Z"
37 }
38 }
41class TeamInviteRequest(BaseModel):
42 email: EmailStr = Field(..., description="Email of person to invite")
43 role: str = Field(default="member", description="Role: 'admin' or 'member'")
45 class Config:
46 schema_extra = {
47 "example": {
48 "email": "newmember@example.com",
49 "role": "member"
50 }
51 }
54class TeamStatsResponse(BaseModel):
55 seats_included: int
56 seats_used: int
57 extra_seats: int
58 total_seats: int
59 available_seats: int
60 can_add_seats: bool
61 billing_period: str
63 class Config:
64 schema_extra = {
65 "example": {
66 "seats_included": 5,
67 "seats_used": 3,
68 "extra_seats": 2,
69 "total_seats": 7,
70 "available_seats": 4,
71 "can_add_seats": True,
72 "billing_period": "monthly"
73 }
74 }
77# ==========================================
78# Team Stats & Members
79# ==========================================
81@router.get("/team/stats", response_model=TeamStatsResponse)
82async def get_team_stats(user: Dict[str, Any] = Depends(get_current_user)):
83 """
84 Get team statistics and seat availability.
86 Returns seat counts and availability for adding more seats.
87 Only available for Team tier users.
88 """
89 if user.get("tier") != "team":
90 raise HTTPException(
91 status_code=403,
92 detail="Team plan required for team management"
93 )
95 seats_included = user.get("seats_included", 5)
96 seats_used = user.get("seats_used", 1)
97 extra_seats = user.get("extra_seats", 0)
98 total_seats = seats_included + extra_seats
99 available_seats = total_seats - seats_used
100 billing_period = user.get("billing_period", "monthly")
102 # Can only add seats on monthly plans
103 can_add_seats = billing_period == "monthly"
105 return TeamStatsResponse(
106 seats_included=seats_included,
107 seats_used=seats_used,
108 extra_seats=extra_seats,
109 total_seats=total_seats,
110 available_seats=available_seats,
111 can_add_seats=can_add_seats,
112 billing_period=billing_period
113 )
116@router.get("/team/members")
117async def list_team_members(user: Dict[str, Any] = Depends(get_current_user)):
118 """
119 List all team members.
121 Returns list of team members with their roles and join dates.
122 Owner (current user) is always included in the list.
123 """
124 if user.get("tier") != "team":
125 raise HTTPException(
126 status_code=403,
127 detail="Team plan required"
128 )
130 # Get team members from database
131 members = await neon_service.get_team_members(user["id"])
133 # Owner is always first
134 owner = {
135 "id": user["id"],
136 "email": user["email"],
137 "role": "owner",
138 "joined_at": user.get("created_at", datetime.utcnow().isoformat())
139 }
141 return {
142 "members": [owner] + members,
143 "total": len(members) + 1,
144 "seats_used": user.get("seats_used", 1),
145 "seats_available": (user.get("seats_included", 5) + user.get("extra_seats", 0)) - user.get("seats_used", 1)
146 }
149# ==========================================
150# Invite Team Member
151# ==========================================
153@router.post("/team/invite")
154async def invite_team_member(
155 invite: TeamInviteRequest,
156 user: Dict[str, Any] = Depends(get_current_user)
157):
158 """
159 Invite a new team member.
161 Checks seat availability and creates invitation.
162 The invited user will receive an email with instructions.
163 """
164 if user.get("tier") != "team":
165 raise HTTPException(
166 status_code=403,
167 detail="Team plan required"
168 )
170 # Check seat availability
171 seats_used = user.get("seats_used", 1)
172 seats_included = user.get("seats_included", 5)
173 extra_seats = user.get("extra_seats", 0)
174 total_seats = seats_included + extra_seats
176 if seats_used >= total_seats:
177 raise HTTPException(
178 status_code=400,
179 detail=f"No available seats. You're using {seats_used}/{total_seats} seats. Please add more seats first."
180 )
182 # Check if user already exists in team
183 existing_member = await neon_service.get_team_member_by_email(user["id"], invite.email)
184 if existing_member:
185 raise HTTPException(
186 status_code=400,
187 detail="User is already a team member"
188 )
190 # Check if email is the owner
191 if invite.email.lower() == user["email"].lower():
192 raise HTTPException(
193 status_code=400,
194 detail="You cannot invite yourself"
195 )
197 # Validate role
198 if invite.role not in ["admin", "member"]:
199 raise HTTPException(
200 status_code=400,
201 detail="Role must be 'admin' or 'member'"
202 )
204 # Create invitation
205 try:
206 invitation = await neon_service.create_team_invitation(
207 owner_id=user["id"],
208 invitee_email=invite.email,
209 role=invite.role
210 )
212 # TODO: Send invitation email
213 logger.info(f"Team invitation created: {invite.email} invited by {user['email']}")
215 return {
216 "message": "Invitation sent successfully",
217 "invitation": {
218 "email": invite.email,
219 "role": invite.role,
220 "invited_at": invitation.get("created_at")
221 }
222 }
224 except Exception as e:
225 logger.error(f"Failed to create team invitation: {e}")
226 raise HTTPException(
227 status_code=500,
228 detail=f"Failed to create invitation: {str(e)}"
229 )
232# ==========================================
233# Add Extra Seat (Monthly Plans Only)
234# ==========================================
236@router.post("/team/seats/add")
237async def add_team_seat(user: Dict[str, Any] = Depends(get_current_user)):
238 """
239 Add an extra seat to team subscription.
241 Costs $9/month per additional seat.
242 Only available for monthly Team plans.
243 Annual plans must upgrade to a higher tier.
244 """
245 if user.get("tier") != "team":
246 raise HTTPException(
247 status_code=403,
248 detail="Team plan required"
249 )
251 billing_period = user.get("billing_period", "monthly")
253 if billing_period != "monthly":
254 raise HTTPException(
255 status_code=400,
256 detail="Cannot add seats to annual plans. Annual plans have fixed seat counts. Please contact support to upgrade your plan."
257 )
259 # Add seat
260 current_extra_seats = user.get("extra_seats", 0)
261 new_extra_seats = current_extra_seats + 1
263 try:
264 # Update user with new seat count
265 await neon_service.update_user(
266 user["id"],
267 {"extra_seats": new_extra_seats}
268 )
270 # TODO: Call Polar API to add subscription item for seat billing
271 # This would add a $9/month charge to their subscription
272 # For now, we track it in the database
274 logger.info(f"Added seat for user {user['id']}: extra_seats={new_extra_seats}")
276 total_seats = user.get("seats_included", 5) + new_extra_seats
277 monthly_cost = new_extra_seats * 9
279 return {
280 "message": "Seat added successfully",
281 "extra_seats": new_extra_seats,
282 "total_seats": total_seats,
283 "monthly_cost": f"${monthly_cost} extra per month",
284 "note": "This will be reflected in your next invoice"
285 }
287 except Exception as e:
288 logger.error(f"Failed to add seat: {e}")
289 raise HTTPException(
290 status_code=500,
291 detail=f"Failed to add seat: {str(e)}"
292 )
295# ==========================================
296# Remove Team Member
297# ==========================================
299@router.delete("/team/members/{member_id}")
300async def remove_team_member(
301 member_id: str,
302 user: Dict[str, Any] = Depends(get_current_user)
303):
304 """
305 Remove a team member.
307 Only the owner can remove team members.
308 Cannot remove the owner (yourself).
309 """
310 if user.get("tier") != "team":
311 raise HTTPException(
312 status_code=403,
313 detail="Team plan required"
314 )
316 # Cannot remove yourself
317 if member_id == user["id"]:
318 raise HTTPException(
319 status_code=400,
320 detail="Cannot remove yourself from the team"
321 )
323 try:
324 # Remove team member
325 removed = await neon_service.remove_team_member(user["id"], member_id)
327 if not removed:
328 raise HTTPException(
329 status_code=404,
330 detail="Team member not found"
331 )
333 # Decrement seats_used
334 current_seats_used = user.get("seats_used", 1)
335 new_seats_used = max(1, current_seats_used - 1)
337 await neon_service.update_user(
338 user["id"],
339 {"seats_used": new_seats_used}
340 )
342 logger.info(f"Removed team member {member_id} from team owned by {user['id']}")
344 return {
345 "message": "Team member removed successfully",
346 "seats_used": new_seats_used
347 }
349 except HTTPException:
350 raise
351 except Exception as e:
352 logger.error(f"Failed to remove team member: {e}")
353 raise HTTPException(
354 status_code=500,
355 detail=f"Failed to remove team member: {str(e)}"
356 )
359# ==========================================
360# Accept Invitation (Public Endpoint)
361# ==========================================
363@router.post("/team/accept/{invitation_token}")
364async def accept_team_invitation(
365 invitation_token: str,
366 user: Dict[str, Any] = Depends(get_current_user)
367):
368 """
369 Accept a team invitation.
371 User must be logged in. The invitation token is sent via email.
372 Once accepted, the user joins the team.
373 """
374 try:
375 # Get invitation
376 invitation = await neon_service.get_team_invitation(invitation_token)
378 if not invitation:
379 raise HTTPException(
380 status_code=404,
381 detail="Invitation not found or expired"
382 )
384 # Verify email matches
385 if invitation["invitee_email"].lower() != user["email"].lower():
386 raise HTTPException(
387 status_code=403,
388 detail="This invitation is for a different email address"
389 )
391 # Add user to team
392 await neon_service.add_team_member(
393 owner_id=invitation["owner_id"],
394 member_id=user["id"],
395 role=invitation["role"]
396 )
398 # Increment team owner's seats_used
399 owner = await neon_service.get_user(invitation["owner_id"])
400 if owner:
401 new_seats_used = owner.get("seats_used", 1) + 1
402 await neon_service.update_user(
403 invitation["owner_id"],
404 {"seats_used": new_seats_used}
405 )
407 # Delete invitation
408 await neon_service.delete_team_invitation(invitation_token)
410 logger.info(f"User {user['id']} accepted team invitation from {invitation['owner_id']}")
412 return {
413 "message": "Successfully joined team",
414 "team_owner": invitation.get("owner_email"),
415 "role": invitation["role"]
416 }
418 except HTTPException:
419 raise
420 except Exception as e:
421 logger.error(f"Failed to accept invitation: {e}")
422 raise HTTPException(
423 status_code=500,
424 detail=f"Failed to accept invitation: {str(e)}"
425 )
428# ==========================================
429# Health Check
430# ==========================================
432@router.get("/team/health")
433async def team_health():
434 """Test endpoint to verify team routes are loaded."""
435 return {
436 "status": "ok",
437 "message": "Team management routes are active",
438 "endpoints": [
439 "GET /team/stats",
440 "GET /team/members",
441 "POST /team/invite",
442 "POST /team/seats/add",
443 "DELETE /team/members/{id}",
444 "POST /team/accept/{token}"
445 ]
446 }