Skip to content

Commit 5d6687c

Browse files
authored
Implement group management API (#15)
* feat: Implement group management API - Added group creation, retrieval, updating, and deletion endpoints. - Implemented functionality for users to join and leave groups. - Created schemas for group requests and responses using Pydantic. - Developed service layer for group operations, including database interactions. - Added tests for group routes and service methods to ensure functionality. * refactor(tests): update mock implementations in group service tests for improved accuracy * chore: clean up service.py by removing unused code and comments
1 parent e04b044 commit 5d6687c

File tree

10 files changed

+1295
-10
lines changed

10 files changed

+1295
-10
lines changed

backend/app/groups/README.md

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
# Groups Service Implementation
2+
3+
This document describes the implementation of the Groups Service API endpoints as specified in the [group-service.md](../../docs/group-service.md) documentation.
4+
5+
## Overview
6+
7+
The Groups Service provides 10 endpoints for managing groups, members, and group operations. All endpoints require authentication via Bearer token.
8+
9+
## Implemented Endpoints
10+
11+
### 1. Create Group
12+
- **POST** `/groups`
13+
- Creates a new group with the authenticated user as admin
14+
- Generates a unique 6-character join code
15+
- **Request Body**: `{name: string, currency?: string, imageUrl?: string}`
16+
- **Response**: Full group object with join code
17+
18+
### 2. List User Groups
19+
- **GET** `/groups`
20+
- Returns all groups where the current user is a member
21+
- **Response**: `{groups: GroupResponse[]}`
22+
23+
### 3. Get Group Details
24+
- **GET** `/groups/{group_id}`
25+
- Returns detailed group information including members
26+
- Only accessible to group members
27+
- **Response**: Full group object
28+
29+
### 4. Update Group Metadata
30+
- **PATCH** `/groups/{group_id}`
31+
- Updates group name and/or image URL
32+
- Only accessible to group admins
33+
- **Request Body**: `{name?: string, imageUrl?: string}`
34+
- **Response**: Updated group object
35+
36+
### 5. Delete Group
37+
- **DELETE** `/groups/{group_id}`
38+
- Permanently deletes a group
39+
- Only accessible to group admins
40+
- **Response**: `{success: boolean, message: string}`
41+
42+
### 6. Join Group by Code
43+
- **POST** `/groups/join`
44+
- Join a group using its join code
45+
- **Request Body**: `{joinCode: string}`
46+
- **Response**: `{group: GroupResponse}`
47+
48+
### 7. Leave Group
49+
- **POST** `/groups/{group_id}/leave`
50+
- Leave a group (balance check to be implemented with expense service)
51+
- **Response**: `{success: boolean, message: string}`
52+
53+
### 8. Get Group Members
54+
- **GET** `/groups/{group_id}/members`
55+
- Returns list of all group members with roles
56+
- Only accessible to group members
57+
- **Response**: Array of member objects
58+
59+
### 9. Update Member Role
60+
- **PATCH** `/groups/{group_id}/members/{member_id}`
61+
- Change a member's role between "admin" and "member"
62+
- Only accessible to group admins
63+
- **Request Body**: `{role: "admin" | "member"}`
64+
- **Response**: `{message: string}`
65+
66+
### 10. Remove Member
67+
- **DELETE** `/groups/{group_id}/members/{member_id}`
68+
- Remove a member from the group
69+
- Only accessible to group admins
70+
- Cannot remove yourself (use leave group instead)
71+
- **Response**: `{success: boolean, message: string}`
72+
73+
## File Structure
74+
75+
```
76+
backend/app/groups/
77+
├── __init__.py
78+
├── schemas.py # Pydantic models for request/response
79+
├── service.py # Business logic and database operations
80+
└── routes.py # FastAPI route handlers
81+
```
82+
83+
## Security and Business Logic
84+
85+
### Admin Protection Logic
86+
- **Last Admin Protection**: Prevents the last admin from demoting themselves or leaving the group
87+
- **Self-Removal Prevention**: Admins cannot remove themselves (must use leave group)
88+
- **Role Validation**: Only allows "admin" or "member" roles
89+
90+
### Error Handling Improvements
91+
- **Group Existence Check**: Properly distinguishes between non-existent groups and access denied
92+
- **Member Validation**: Validates member existence before role changes or removal
93+
- **Balance Integration Ready**: TODOs marked for expense service integration
94+
95+
## Key Features
96+
97+
### Security
98+
- All endpoints require authentication
99+
- Role-based access control (admin vs member permissions)
100+
- Users can only access groups they belong to
101+
102+
### Join Code System
103+
- Unique 6-character alphanumeric codes
104+
- Case-insensitive lookup
105+
- Collision detection and retry logic
106+
107+
### Error Handling
108+
- Proper HTTP status codes
109+
- Descriptive error messages
110+
- Graceful handling of invalid ObjectIds
111+
112+
### Database Operations
113+
- Uses MongoDB with proper ObjectId handling
114+
- Optimized queries with user membership filters
115+
- Atomic operations for member management
116+
117+
## Testing
118+
119+
Comprehensive test suites are provided:
120+
- `tests/groups/test_groups_routes.py` - API endpoint tests
121+
- `tests/groups/test_groups_service.py` - Service layer tests
122+
123+
Run tests with:
124+
```bash
125+
cd backend
126+
pytest tests/groups/ -v
127+
```
128+
129+
## Integration Notes
130+
131+
### With Auth Service
132+
- Uses `get_current_user` dependency for authentication
133+
- Requires valid JWT bearer token
134+
135+
### With Expense Service (Future)
136+
- Leave group and remove member operations will check for outstanding balances
137+
- Currently allows operations without balance verification
138+
- TODO comments mark integration points
139+
140+
## API Usage Examples
141+
142+
### Create a Group
143+
```bash
144+
curl -X POST "http://localhost:8000/groups" \
145+
-H "Authorization: Bearer <token>" \
146+
-H "Content-Type: application/json" \
147+
-d '{"name": "Weekend Trip", "currency": "USD"}'
148+
```
149+
150+
### Join a Group
151+
```bash
152+
curl -X POST "http://localhost:8000/groups/join" \
153+
-H "Authorization: Bearer <token>" \
154+
-H "Content-Type: application/json" \
155+
-d '{"joinCode": "ABC123"}'
156+
```
157+
158+
### Update Member Role
159+
```bash
160+
curl -X PATCH "http://localhost:8000/groups/{group_id}/members/{member_id}" \
161+
-H "Authorization: Bearer <token>" \
162+
-H "Content-Type: application/json" \
163+
-d '{"role": "admin"}'
164+
```
165+
166+
## Schema Definitions
167+
168+
All request/response schemas are defined in `schemas.py` with proper validation:
169+
- Field length limits
170+
- Required vs optional fields
171+
- Pattern validation for roles
172+
- Alias support for MongoDB ObjectId fields
173+
174+
The Groups Service is now fully implemented and ready for integration with the frontend and other services.

backend/app/groups/__init__.py

Whitespace-only changes.

backend/app/groups/routes.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
from fastapi import APIRouter, Depends, HTTPException, status
2+
from app.groups.schemas import (
3+
GroupCreateRequest, GroupResponse, GroupListResponse, GroupUpdateRequest,
4+
JoinGroupRequest, JoinGroupResponse, MemberRoleUpdateRequest,
5+
LeaveGroupResponse, DeleteGroupResponse, RemoveMemberResponse
6+
)
7+
from app.groups.service import group_service
8+
from app.auth.security import get_current_user
9+
from typing import Dict, Any, List
10+
11+
router = APIRouter(prefix="/groups", tags=["Groups"])
12+
13+
@router.post("", response_model=GroupResponse, status_code=status.HTTP_201_CREATED)
14+
async def create_group(
15+
group_data: GroupCreateRequest,
16+
current_user: Dict[str, Any] = Depends(get_current_user)
17+
):
18+
"""Create a new group"""
19+
group = await group_service.create_group(
20+
group_data.model_dump(exclude_unset=True),
21+
current_user["_id"]
22+
)
23+
if not group:
24+
raise HTTPException(status_code=500, detail="Failed to create group")
25+
return group
26+
27+
@router.get("", response_model=GroupListResponse)
28+
async def list_user_groups(current_user: Dict[str, Any] = Depends(get_current_user)):
29+
"""List all groups the current user belongs to"""
30+
groups = await group_service.get_user_groups(current_user["_id"])
31+
return {"groups": groups}
32+
33+
@router.get("/{group_id}", response_model=GroupResponse)
34+
async def get_group_details(
35+
group_id: str,
36+
current_user: Dict[str, Any] = Depends(get_current_user)
37+
):
38+
"""Get group details including members"""
39+
group = await group_service.get_group_by_id(group_id, current_user["_id"])
40+
if not group:
41+
raise HTTPException(status_code=404, detail="Group not found or access denied")
42+
return group
43+
44+
@router.patch("/{group_id}", response_model=GroupResponse)
45+
async def update_group_metadata(
46+
group_id: str,
47+
updates: GroupUpdateRequest,
48+
current_user: Dict[str, Any] = Depends(get_current_user)
49+
):
50+
"""Update group metadata (admin only)"""
51+
update_data = updates.model_dump(exclude_unset=True)
52+
if not update_data:
53+
raise HTTPException(status_code=400, detail="No update fields provided")
54+
55+
updated_group = await group_service.update_group(group_id, update_data, current_user["_id"])
56+
if not updated_group:
57+
raise HTTPException(status_code=404, detail="Group not found or access denied")
58+
return updated_group
59+
60+
@router.delete("/{group_id}", response_model=DeleteGroupResponse)
61+
async def delete_group(
62+
group_id: str,
63+
current_user: Dict[str, Any] = Depends(get_current_user)
64+
):
65+
"""Delete a group (admin only)"""
66+
deleted = await group_service.delete_group(group_id, current_user["_id"])
67+
if not deleted:
68+
raise HTTPException(status_code=404, detail="Group not found or access denied")
69+
return DeleteGroupResponse(success=True, message="Group deleted successfully")
70+
71+
@router.post("/join", response_model=JoinGroupResponse)
72+
async def join_group_by_code(
73+
join_data: JoinGroupRequest,
74+
current_user: Dict[str, Any] = Depends(get_current_user)
75+
):
76+
"""Join a group using a join code"""
77+
group = await group_service.join_group_by_code(join_data.joinCode, current_user["_id"])
78+
if not group:
79+
raise HTTPException(status_code=404, detail="Invalid join code")
80+
return {"group": group}
81+
82+
@router.post("/{group_id}/leave", response_model=LeaveGroupResponse)
83+
async def leave_group(
84+
group_id: str,
85+
current_user: Dict[str, Any] = Depends(get_current_user)
86+
):
87+
"""Leave a group (only if no outstanding balances)"""
88+
left = await group_service.leave_group(group_id, current_user["_id"])
89+
if not left:
90+
raise HTTPException(status_code=400, detail="Failed to leave group")
91+
return LeaveGroupResponse(success=True, message="Successfully left the group")
92+
93+
@router.get("/{group_id}/members", response_model=List[Dict[str, Any]])
94+
async def get_group_members(
95+
group_id: str,
96+
current_user: Dict[str, Any] = Depends(get_current_user)
97+
):
98+
"""Get list of group members"""
99+
members = await group_service.get_group_members(group_id, current_user["_id"])
100+
return members
101+
102+
@router.patch("/{group_id}/members/{member_id}", response_model=Dict[str, str])
103+
async def update_member_role(
104+
group_id: str,
105+
member_id: str,
106+
role_update: MemberRoleUpdateRequest,
107+
current_user: Dict[str, Any] = Depends(get_current_user)
108+
):
109+
"""Change member role (admin only)"""
110+
updated = await group_service.update_member_role(
111+
group_id, member_id, role_update.role, current_user["_id"]
112+
)
113+
if not updated:
114+
raise HTTPException(status_code=400, detail="Failed to update member role")
115+
return {"message": f"Member role updated to {role_update.role}"}
116+
117+
@router.delete("/{group_id}/members/{member_id}", response_model=RemoveMemberResponse)
118+
async def remove_group_member(
119+
group_id: str,
120+
member_id: str,
121+
current_user: Dict[str, Any] = Depends(get_current_user)
122+
):
123+
"""Remove a member from the group (admin only)"""
124+
removed = await group_service.remove_member(group_id, member_id, current_user["_id"])
125+
if not removed:
126+
raise HTTPException(status_code=400, detail="Failed to remove member")
127+
return RemoveMemberResponse(success=True, message="Member removed successfully")

backend/app/groups/schemas.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from pydantic import BaseModel, Field
2+
from typing import Optional, List
3+
from datetime import datetime
4+
5+
class GroupMember(BaseModel):
6+
userId: str
7+
role: str = "member" # "admin" or "member"
8+
joinedAt: datetime
9+
10+
class GroupCreateRequest(BaseModel):
11+
name: str = Field(..., min_length=1, max_length=100)
12+
currency: Optional[str] = "USD"
13+
imageUrl: Optional[str] = None
14+
15+
class GroupUpdateRequest(BaseModel):
16+
name: Optional[str] = Field(None, min_length=1, max_length=100)
17+
imageUrl: Optional[str] = None
18+
19+
class GroupResponse(BaseModel):
20+
id: str = Field(alias="_id")
21+
name: str
22+
currency: str
23+
joinCode: str
24+
createdBy: str
25+
createdAt: datetime
26+
imageUrl: Optional[str] = None
27+
members: Optional[List[GroupMember]] = []
28+
29+
model_config = {"populate_by_name": True}
30+
31+
class GroupListResponse(BaseModel):
32+
groups: List[GroupResponse]
33+
34+
class JoinGroupRequest(BaseModel):
35+
joinCode: str = Field(..., min_length=1)
36+
37+
class JoinGroupResponse(BaseModel):
38+
group: GroupResponse
39+
40+
class MemberRoleUpdateRequest(BaseModel):
41+
role: str = Field(..., pattern="^(admin|member)$")
42+
43+
class LeaveGroupResponse(BaseModel):
44+
success: bool
45+
message: str
46+
47+
class DeleteGroupResponse(BaseModel):
48+
success: bool
49+
message: str
50+
51+
class RemoveMemberResponse(BaseModel):
52+
success: bool
53+
message: str

0 commit comments

Comments
 (0)