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

1""" 

2Team Management Routes - /v1/team/* 

3 

4Handles team member invitations, seat management, and team operations. 

5""" 

6 

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 

12 

13from ..services.neon_service import neon_service 

14from ..services.polar_service import polar_service 

15from ..middleware.auth import get_current_user 

16 

17router = APIRouter() 

18 

19 

20# ========================================== 

21# Request/Response Models 

22# ========================================== 

23 

24class TeamMemberResponse(BaseModel): 

25 id: str 

26 email: str 

27 role: str 

28 joined_at: str 

29 

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 } 

39 

40 

41class TeamInviteRequest(BaseModel): 

42 email: EmailStr = Field(..., description="Email of person to invite") 

43 role: str = Field(default="member", description="Role: 'admin' or 'member'") 

44 

45 class Config: 

46 schema_extra = { 

47 "example": { 

48 "email": "newmember@example.com", 

49 "role": "member" 

50 } 

51 } 

52 

53 

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 

62 

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 } 

75 

76 

77# ========================================== 

78# Team Stats & Members 

79# ========================================== 

80 

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. 

85  

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 ) 

94 

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") 

101 

102 # Can only add seats on monthly plans 

103 can_add_seats = billing_period == "monthly" 

104 

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 ) 

114 

115 

116@router.get("/team/members") 

117async def list_team_members(user: Dict[str, Any] = Depends(get_current_user)): 

118 """ 

119 List all team members. 

120  

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 ) 

129 

130 # Get team members from database 

131 members = await neon_service.get_team_members(user["id"]) 

132 

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 } 

140 

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 } 

147 

148 

149# ========================================== 

150# Invite Team Member 

151# ========================================== 

152 

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. 

160  

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 ) 

169 

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 

175 

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 ) 

181 

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 ) 

189 

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 ) 

196 

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 ) 

203 

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 ) 

211 

212 # TODO: Send invitation email 

213 logger.info(f"Team invitation created: {invite.email} invited by {user['email']}") 

214 

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 } 

223 

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 ) 

230 

231 

232# ========================================== 

233# Add Extra Seat (Monthly Plans Only) 

234# ========================================== 

235 

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. 

240  

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 ) 

250 

251 billing_period = user.get("billing_period", "monthly") 

252 

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 ) 

258 

259 # Add seat 

260 current_extra_seats = user.get("extra_seats", 0) 

261 new_extra_seats = current_extra_seats + 1 

262 

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 ) 

269 

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 

273 

274 logger.info(f"Added seat for user {user['id']}: extra_seats={new_extra_seats}") 

275 

276 total_seats = user.get("seats_included", 5) + new_extra_seats 

277 monthly_cost = new_extra_seats * 9 

278 

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 } 

286 

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 ) 

293 

294 

295# ========================================== 

296# Remove Team Member 

297# ========================================== 

298 

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. 

306  

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 ) 

315 

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 ) 

322 

323 try: 

324 # Remove team member 

325 removed = await neon_service.remove_team_member(user["id"], member_id) 

326 

327 if not removed: 

328 raise HTTPException( 

329 status_code=404, 

330 detail="Team member not found" 

331 ) 

332 

333 # Decrement seats_used 

334 current_seats_used = user.get("seats_used", 1) 

335 new_seats_used = max(1, current_seats_used - 1) 

336 

337 await neon_service.update_user( 

338 user["id"], 

339 {"seats_used": new_seats_used} 

340 ) 

341 

342 logger.info(f"Removed team member {member_id} from team owned by {user['id']}") 

343 

344 return { 

345 "message": "Team member removed successfully", 

346 "seats_used": new_seats_used 

347 } 

348 

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 ) 

357 

358 

359# ========================================== 

360# Accept Invitation (Public Endpoint) 

361# ========================================== 

362 

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. 

370  

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) 

377 

378 if not invitation: 

379 raise HTTPException( 

380 status_code=404, 

381 detail="Invitation not found or expired" 

382 ) 

383 

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 ) 

390 

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 ) 

397 

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 ) 

406 

407 # Delete invitation 

408 await neon_service.delete_team_invitation(invitation_token) 

409 

410 logger.info(f"User {user['id']} accepted team invitation from {invitation['owner_id']}") 

411 

412 return { 

413 "message": "Successfully joined team", 

414 "team_owner": invitation.get("owner_email"), 

415 "role": invitation["role"] 

416 } 

417 

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 ) 

426 

427 

428# ========================================== 

429# Health Check 

430# ========================================== 

431 

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 }