Skip to content
Merged
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
174 changes: 174 additions & 0 deletions backend/app/groups/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
# Groups Service Implementation

This document describes the implementation of the Groups Service API endpoints as specified in the [group-service.md](../../docs/group-service.md) documentation.

## Overview

The Groups Service provides 10 endpoints for managing groups, members, and group operations. All endpoints require authentication via Bearer token.

## Implemented Endpoints

### 1. Create Group
- **POST** `/groups`
- Creates a new group with the authenticated user as admin
- Generates a unique 6-character join code
- **Request Body**: `{name: string, currency?: string, imageUrl?: string}`
- **Response**: Full group object with join code

### 2. List User Groups
- **GET** `/groups`
- Returns all groups where the current user is a member
- **Response**: `{groups: GroupResponse[]}`

### 3. Get Group Details
- **GET** `/groups/{group_id}`
- Returns detailed group information including members
- Only accessible to group members
- **Response**: Full group object

### 4. Update Group Metadata
- **PATCH** `/groups/{group_id}`
- Updates group name and/or image URL
- Only accessible to group admins
- **Request Body**: `{name?: string, imageUrl?: string}`
- **Response**: Updated group object

### 5. Delete Group
- **DELETE** `/groups/{group_id}`
- Permanently deletes a group
- Only accessible to group admins
- **Response**: `{success: boolean, message: string}`

### 6. Join Group by Code
- **POST** `/groups/join`
- Join a group using its join code
- **Request Body**: `{joinCode: string}`
- **Response**: `{group: GroupResponse}`

### 7. Leave Group
- **POST** `/groups/{group_id}/leave`
- Leave a group (balance check to be implemented with expense service)
- **Response**: `{success: boolean, message: string}`

### 8. Get Group Members
- **GET** `/groups/{group_id}/members`
- Returns list of all group members with roles
- Only accessible to group members
- **Response**: Array of member objects

### 9. Update Member Role
- **PATCH** `/groups/{group_id}/members/{member_id}`
- Change a member's role between "admin" and "member"
- Only accessible to group admins
- **Request Body**: `{role: "admin" | "member"}`
- **Response**: `{message: string}`

### 10. Remove Member
- **DELETE** `/groups/{group_id}/members/{member_id}`
- Remove a member from the group
- Only accessible to group admins
- Cannot remove yourself (use leave group instead)
- **Response**: `{success: boolean, message: string}`

## File Structure

```
backend/app/groups/
├── __init__.py
├── schemas.py # Pydantic models for request/response
├── service.py # Business logic and database operations
└── routes.py # FastAPI route handlers
```

## Security and Business Logic

### Admin Protection Logic
- **Last Admin Protection**: Prevents the last admin from demoting themselves or leaving the group
- **Self-Removal Prevention**: Admins cannot remove themselves (must use leave group)
- **Role Validation**: Only allows "admin" or "member" roles

### Error Handling Improvements
- **Group Existence Check**: Properly distinguishes between non-existent groups and access denied
- **Member Validation**: Validates member existence before role changes or removal
- **Balance Integration Ready**: TODOs marked for expense service integration

## Key Features

### Security
- All endpoints require authentication
- Role-based access control (admin vs member permissions)
- Users can only access groups they belong to

### Join Code System
- Unique 6-character alphanumeric codes
- Case-insensitive lookup
- Collision detection and retry logic

### Error Handling
- Proper HTTP status codes
- Descriptive error messages
- Graceful handling of invalid ObjectIds

### Database Operations
- Uses MongoDB with proper ObjectId handling
- Optimized queries with user membership filters
- Atomic operations for member management

## Testing

Comprehensive test suites are provided:
- `tests/groups/test_groups_routes.py` - API endpoint tests
- `tests/groups/test_groups_service.py` - Service layer tests

Run tests with:
```bash
cd backend
pytest tests/groups/ -v
```

## Integration Notes

### With Auth Service
- Uses `get_current_user` dependency for authentication
- Requires valid JWT bearer token

### With Expense Service (Future)
- Leave group and remove member operations will check for outstanding balances
- Currently allows operations without balance verification
- TODO comments mark integration points

## API Usage Examples

### Create a Group
```bash
curl -X POST "http://localhost:8000/groups" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"name": "Weekend Trip", "currency": "USD"}'
```

### Join a Group
```bash
curl -X POST "http://localhost:8000/groups/join" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"joinCode": "ABC123"}'
```

### Update Member Role
```bash
curl -X PATCH "http://localhost:8000/groups/{group_id}/members/{member_id}" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"role": "admin"}'
```

## Schema Definitions

All request/response schemas are defined in `schemas.py` with proper validation:
- Field length limits
- Required vs optional fields
- Pattern validation for roles
- Alias support for MongoDB ObjectId fields

The Groups Service is now fully implemented and ready for integration with the frontend and other services.
Empty file added backend/app/groups/__init__.py
Empty file.
127 changes: 127 additions & 0 deletions backend/app/groups/routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
from fastapi import APIRouter, Depends, HTTPException, status
from app.groups.schemas import (
GroupCreateRequest, GroupResponse, GroupListResponse, GroupUpdateRequest,
JoinGroupRequest, JoinGroupResponse, MemberRoleUpdateRequest,
LeaveGroupResponse, DeleteGroupResponse, RemoveMemberResponse
)
from app.groups.service import group_service
from app.auth.security import get_current_user
from typing import Dict, Any, List

router = APIRouter(prefix="/groups", tags=["Groups"])

@router.post("", response_model=GroupResponse, status_code=status.HTTP_201_CREATED)
async def create_group(
group_data: GroupCreateRequest,
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""Create a new group"""
group = await group_service.create_group(
group_data.model_dump(exclude_unset=True),
current_user["_id"]
)
if not group:
raise HTTPException(status_code=500, detail="Failed to create group")

Check warning on line 24 in backend/app/groups/routes.py

View check run for this annotation

Codecov / codecov/patch

backend/app/groups/routes.py#L24

Added line #L24 was not covered by tests
return group

@router.get("", response_model=GroupListResponse)
async def list_user_groups(current_user: Dict[str, Any] = Depends(get_current_user)):
"""List all groups the current user belongs to"""
groups = await group_service.get_user_groups(current_user["_id"])
return {"groups": groups}

@router.get("/{group_id}", response_model=GroupResponse)
async def get_group_details(
group_id: str,
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""Get group details including members"""
group = await group_service.get_group_by_id(group_id, current_user["_id"])
if not group:
raise HTTPException(status_code=404, detail="Group not found or access denied")
return group

@router.patch("/{group_id}", response_model=GroupResponse)
async def update_group_metadata(
group_id: str,
updates: GroupUpdateRequest,
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""Update group metadata (admin only)"""
update_data = updates.model_dump(exclude_unset=True)
if not update_data:
raise HTTPException(status_code=400, detail="No update fields provided")

Check warning on line 53 in backend/app/groups/routes.py

View check run for this annotation

Codecov / codecov/patch

backend/app/groups/routes.py#L53

Added line #L53 was not covered by tests

updated_group = await group_service.update_group(group_id, update_data, current_user["_id"])
if not updated_group:
raise HTTPException(status_code=404, detail="Group not found or access denied")

Check warning on line 57 in backend/app/groups/routes.py

View check run for this annotation

Codecov / codecov/patch

backend/app/groups/routes.py#L57

Added line #L57 was not covered by tests
return updated_group

@router.delete("/{group_id}", response_model=DeleteGroupResponse)
async def delete_group(
group_id: str,
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""Delete a group (admin only)"""
deleted = await group_service.delete_group(group_id, current_user["_id"])
if not deleted:
raise HTTPException(status_code=404, detail="Group not found or access denied")

Check warning on line 68 in backend/app/groups/routes.py

View check run for this annotation

Codecov / codecov/patch

backend/app/groups/routes.py#L68

Added line #L68 was not covered by tests
return DeleteGroupResponse(success=True, message="Group deleted successfully")

@router.post("/join", response_model=JoinGroupResponse)
async def join_group_by_code(
join_data: JoinGroupRequest,
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""Join a group using a join code"""
group = await group_service.join_group_by_code(join_data.joinCode, current_user["_id"])
if not group:
raise HTTPException(status_code=404, detail="Invalid join code")

Check warning on line 79 in backend/app/groups/routes.py

View check run for this annotation

Codecov / codecov/patch

backend/app/groups/routes.py#L79

Added line #L79 was not covered by tests
return {"group": group}

@router.post("/{group_id}/leave", response_model=LeaveGroupResponse)
async def leave_group(
group_id: str,
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""Leave a group (only if no outstanding balances)"""
left = await group_service.leave_group(group_id, current_user["_id"])
if not left:
raise HTTPException(status_code=400, detail="Failed to leave group")

Check warning on line 90 in backend/app/groups/routes.py

View check run for this annotation

Codecov / codecov/patch

backend/app/groups/routes.py#L90

Added line #L90 was not covered by tests
return LeaveGroupResponse(success=True, message="Successfully left the group")

@router.get("/{group_id}/members", response_model=List[Dict[str, Any]])
async def get_group_members(
group_id: str,
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""Get list of group members"""
members = await group_service.get_group_members(group_id, current_user["_id"])
return members

@router.patch("/{group_id}/members/{member_id}", response_model=Dict[str, str])
async def update_member_role(
group_id: str,
member_id: str,
role_update: MemberRoleUpdateRequest,
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""Change member role (admin only)"""
updated = await group_service.update_member_role(
group_id, member_id, role_update.role, current_user["_id"]
)
if not updated:
raise HTTPException(status_code=400, detail="Failed to update member role")

Check warning on line 114 in backend/app/groups/routes.py

View check run for this annotation

Codecov / codecov/patch

backend/app/groups/routes.py#L114

Added line #L114 was not covered by tests
return {"message": f"Member role updated to {role_update.role}"}

@router.delete("/{group_id}/members/{member_id}", response_model=RemoveMemberResponse)
async def remove_group_member(
group_id: str,
member_id: str,
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""Remove a member from the group (admin only)"""
removed = await group_service.remove_member(group_id, member_id, current_user["_id"])
if not removed:
raise HTTPException(status_code=400, detail="Failed to remove member")

Check warning on line 126 in backend/app/groups/routes.py

View check run for this annotation

Codecov / codecov/patch

backend/app/groups/routes.py#L126

Added line #L126 was not covered by tests
return RemoveMemberResponse(success=True, message="Member removed successfully")
53 changes: 53 additions & 0 deletions backend/app/groups/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime

class GroupMember(BaseModel):
userId: str
role: str = "member" # "admin" or "member"
joinedAt: datetime

class GroupCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
currency: Optional[str] = "USD"
imageUrl: Optional[str] = None

class GroupUpdateRequest(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
imageUrl: Optional[str] = None

class GroupResponse(BaseModel):
id: str = Field(alias="_id")
name: str
currency: str
joinCode: str
createdBy: str
createdAt: datetime
imageUrl: Optional[str] = None
members: Optional[List[GroupMember]] = []

model_config = {"populate_by_name": True}

class GroupListResponse(BaseModel):
groups: List[GroupResponse]

class JoinGroupRequest(BaseModel):
joinCode: str = Field(..., min_length=1)

class JoinGroupResponse(BaseModel):
group: GroupResponse

class MemberRoleUpdateRequest(BaseModel):
role: str = Field(..., pattern="^(admin|member)$")

class LeaveGroupResponse(BaseModel):
success: bool
message: str

class DeleteGroupResponse(BaseModel):
success: bool
message: str

class RemoveMemberResponse(BaseModel):
success: bool
message: str
Loading