diff --git a/be/alembic/versions/05bf96a9e18b_add_chore_history_and_scheduling_tables.py b/be/alembic/versions/05bf96a9e18b_add_chore_history_and_scheduling_tables.py new file mode 100644 index 0000000..12395df --- /dev/null +++ b/be/alembic/versions/05bf96a9e18b_add_chore_history_and_scheduling_tables.py @@ -0,0 +1,75 @@ +"""Add chore history and scheduling tables + +Revision ID: 05bf96a9e18b +Revises: 91d00c100f5b +Create Date: 2025-06-08 00:41:10.516324 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '05bf96a9e18b' +down_revision: Union[str, None] = '91d00c100f5b' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('chore_history', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('chore_id', sa.Integer(), nullable=True), + sa.Column('group_id', sa.Integer(), nullable=True), + sa.Column('event_type', sa.Enum('CREATED', 'UPDATED', 'DELETED', 'COMPLETED', 'REOPENED', 'ASSIGNED', 'UNASSIGNED', 'REASSIGNED', 'SCHEDULE_GENERATED', 'DUE_DATE_CHANGED', 'DETAILS_CHANGED', name='chorehistoryeventtypeenum'), nullable=False), + sa.Column('event_data', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('changed_by_user_id', sa.Integer(), nullable=True), + sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['changed_by_user_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['chore_id'], ['chores.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_chore_history_chore_id'), 'chore_history', ['chore_id'], unique=False) + op.create_index(op.f('ix_chore_history_group_id'), 'chore_history', ['group_id'], unique=False) + op.create_index(op.f('ix_chore_history_id'), 'chore_history', ['id'], unique=False) + op.create_table('chore_assignment_history', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('assignment_id', sa.Integer(), nullable=False), + sa.Column('event_type', sa.Enum('CREATED', 'UPDATED', 'DELETED', 'COMPLETED', 'REOPENED', 'ASSIGNED', 'UNASSIGNED', 'REASSIGNED', 'SCHEDULE_GENERATED', 'DUE_DATE_CHANGED', 'DETAILS_CHANGED', name='chorehistoryeventtypeenum'), nullable=False), + sa.Column('event_data', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('changed_by_user_id', sa.Integer(), nullable=True), + sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['assignment_id'], ['chore_assignments.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['changed_by_user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_chore_assignment_history_assignment_id'), 'chore_assignment_history', ['assignment_id'], unique=False) + op.create_index(op.f('ix_chore_assignment_history_id'), 'chore_assignment_history', ['id'], unique=False) + op.drop_index('ix_apscheduler_jobs_next_run_time', table_name='apscheduler_jobs') + op.drop_table('apscheduler_jobs') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('apscheduler_jobs', + sa.Column('id', sa.VARCHAR(length=191), autoincrement=False, nullable=False), + sa.Column('next_run_time', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True), + sa.Column('job_state', postgresql.BYTEA(), autoincrement=False, nullable=False), + sa.PrimaryKeyConstraint('id', name='apscheduler_jobs_pkey') + ) + op.create_index('ix_apscheduler_jobs_next_run_time', 'apscheduler_jobs', ['next_run_time'], unique=False) + op.drop_index(op.f('ix_chore_assignment_history_id'), table_name='chore_assignment_history') + op.drop_index(op.f('ix_chore_assignment_history_assignment_id'), table_name='chore_assignment_history') + op.drop_table('chore_assignment_history') + op.drop_index(op.f('ix_chore_history_id'), table_name='chore_history') + op.drop_index(op.f('ix_chore_history_group_id'), table_name='chore_history') + op.drop_index(op.f('ix_chore_history_chore_id'), table_name='chore_history') + op.drop_table('chore_history') + # ### end Alembic commands ### diff --git a/be/app/api/v1/endpoints/chores.py b/be/app/api/v1/endpoints/chores.py index be38736..ba72bcd 100644 --- a/be/app/api/v1/endpoints/chores.py +++ b/be/app/api/v1/endpoints/chores.py @@ -8,8 +8,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_transactional_session, get_session from app.auth import current_active_user from app.models import User as UserModel, Chore as ChoreModel, ChoreTypeEnum -from app.schemas.chore import ChoreCreate, ChoreUpdate, ChorePublic, ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreAssignmentPublic +from app.schemas.chore import ( + ChoreCreate, ChoreUpdate, ChorePublic, + ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreAssignmentPublic, + ChoreHistoryPublic, ChoreAssignmentHistoryPublic +) from app.crud import chore as crud_chore +from app.crud import history as crud_history from app.core.exceptions import ChoreNotFoundError, PermissionDeniedError, GroupNotFoundError, DatabaseIntegrityError logger = logging.getLogger(__name__) @@ -450,4 +455,66 @@ async def complete_chore_assignment( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=e.detail) except DatabaseIntegrityError as e: logger.error(f"DatabaseIntegrityError completing assignment {assignment_id} for {current_user.email}: {e.detail}", exc_info=True) - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=e.detail) \ No newline at end of file + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=e.detail) + +# === CHORE HISTORY ENDPOINTS === + +@router.get( + "/{chore_id}/history", + response_model=PyList[ChoreHistoryPublic], + summary="Get Chore History", + tags=["Chores", "History"] +) +async def get_chore_history( + chore_id: int, + db: AsyncSession = Depends(get_session), + current_user: UserModel = Depends(current_active_user), +): + """Retrieves the history of a specific chore.""" + # First, check if user has permission to view the chore itself + chore = await crud_chore.get_chore_by_id(db, chore_id) + if not chore: + raise ChoreNotFoundError(chore_id=chore_id) + + if chore.type == ChoreTypeEnum.personal and chore.created_by_id != current_user.id: + raise PermissionDeniedError("You can only view history for your own personal chores.") + + if chore.type == ChoreTypeEnum.group: + is_member = await crud_chore.is_user_member(db, chore.group_id, current_user.id) + if not is_member: + raise PermissionDeniedError("You must be a member of the group to view this chore's history.") + + logger.info(f"User {current_user.email} getting history for chore {chore_id}") + return await crud_history.get_chore_history(db=db, chore_id=chore_id) + +@router.get( + "/assignments/{assignment_id}/history", + response_model=PyList[ChoreAssignmentHistoryPublic], + summary="Get Chore Assignment History", + tags=["Chore Assignments", "History"] +) +async def get_chore_assignment_history( + assignment_id: int, + db: AsyncSession = Depends(get_session), + current_user: UserModel = Depends(current_active_user), +): + """Retrieves the history of a specific chore assignment.""" + assignment = await crud_chore.get_chore_assignment_by_id(db, assignment_id) + if not assignment: + raise ChoreNotFoundError(assignment_id=assignment_id) + + # Check permission by checking permission on the parent chore + chore = await crud_chore.get_chore_by_id(db, assignment.chore_id) + if not chore: + raise ChoreNotFoundError(chore_id=assignment.chore_id) # Should not happen if assignment exists + + if chore.type == ChoreTypeEnum.personal and chore.created_by_id != current_user.id: + raise PermissionDeniedError("You can only view history for assignments of your own personal chores.") + + if chore.type == ChoreTypeEnum.group: + is_member = await crud_chore.is_user_member(db, chore.group_id, current_user.id) + if not is_member: + raise PermissionDeniedError("You must be a member of the group to view this assignment's history.") + + logger.info(f"User {current_user.email} getting history for assignment {assignment_id}") + return await crud_history.get_assignment_history(db=db, assignment_id=assignment_id) \ No newline at end of file diff --git a/be/app/api/v1/endpoints/groups.py b/be/app/api/v1/endpoints/groups.py index 4aad4b9..64248f2 100644 --- a/be/app/api/v1/endpoints/groups.py +++ b/be/app/api/v1/endpoints/groups.py @@ -8,13 +8,16 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_transactional_session, get_session from app.auth import current_active_user from app.models import User as UserModel, UserRoleEnum # Import model and enum -from app.schemas.group import GroupCreate, GroupPublic +from app.schemas.group import GroupCreate, GroupPublic, GroupScheduleGenerateRequest from app.schemas.invite import InviteCodePublic from app.schemas.message import Message # For simple responses from app.schemas.list import ListPublic, ListDetail +from app.schemas.chore import ChoreHistoryPublic, ChoreAssignmentPublic from app.crud import group as crud_group from app.crud import invite as crud_invite from app.crud import list as crud_list +from app.crud import history as crud_history +from app.crud import schedule as crud_schedule from app.core.exceptions import ( GroupNotFoundError, GroupPermissionError, @@ -264,4 +267,55 @@ async def read_group_lists( lists = await crud_list.get_lists_for_user(db=db, user_id=current_user.id) group_lists = [list for list in lists if list.group_id == group_id] - return group_lists \ No newline at end of file + return group_lists + +@router.post( + "/{group_id}/chores/generate-schedule", + response_model=List[ChoreAssignmentPublic], + summary="Generate Group Chore Schedule", + tags=["Groups", "Chores"] +) +async def generate_group_chore_schedule( + group_id: int, + schedule_in: GroupScheduleGenerateRequest, + db: AsyncSession = Depends(get_transactional_session), + current_user: UserModel = Depends(current_active_user), +): + """Generates a round-robin chore schedule for a group.""" + logger.info(f"User {current_user.email} generating chore schedule for group {group_id}") + # Permission check: ensure user is a member (or owner/admin if stricter rules are needed) + if not await crud_group.is_user_member(db, group_id, current_user.id): + raise GroupMembershipError(group_id, "generate chore schedule for this group") + + try: + assignments = await crud_schedule.generate_group_chore_schedule( + db=db, + group_id=group_id, + start_date=schedule_in.start_date, + end_date=schedule_in.end_date, + user_id=current_user.id, + member_ids=schedule_in.member_ids, + ) + return assignments + except Exception as e: + logger.error(f"Error generating schedule for group {group_id}: {e}", exc_info=True) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) + +@router.get( + "/{group_id}/chores/history", + response_model=List[ChoreHistoryPublic], + summary="Get Group Chore History", + tags=["Groups", "Chores", "History"] +) +async def get_group_chore_history( + group_id: int, + db: AsyncSession = Depends(get_session), + current_user: UserModel = Depends(current_active_user), +): + """Retrieves all chore-related history for a specific group.""" + logger.info(f"User {current_user.email} requesting chore history for group {group_id}") + # Permission check + if not await crud_group.is_user_member(db, group_id, current_user.id): + raise GroupMembershipError(group_id, "view chore history for this group") + + return await crud_history.get_group_chore_history(db=db, group_id=group_id) \ No newline at end of file diff --git a/be/app/core/exceptions.py b/be/app/core/exceptions.py index aedd157..c9a2d1f 100644 --- a/be/app/core/exceptions.py +++ b/be/app/core/exceptions.py @@ -332,9 +332,17 @@ class UserOperationError(HTTPException): detail=detail ) +class ChoreOperationError(HTTPException): + """Raised when a chore-related operation fails.""" + def __init__(self, detail: str): + super().__init__( + status_code=status.HTTP_400_BAD_REQUEST, + detail=detail + ) + class ChoreNotFoundError(HTTPException): - """Raised when a chore is not found.""" - def __init__(self, chore_id: int, group_id: Optional[int] = None, detail: Optional[str] = None): + """Raised when a chore or assignment is not found.""" + def __init__(self, chore_id: int = None, assignment_id: int = None, group_id: Optional[int] = None, detail: Optional[str] = None): if detail: error_detail = detail elif group_id is not None: diff --git a/be/app/crud/chore.py b/be/app/crud/chore.py index cbac95e..f8ca622 100644 --- a/be/app/crud/chore.py +++ b/be/app/crud/chore.py @@ -6,10 +6,11 @@ from typing import List, Optional import logging from datetime import date, datetime -from app.models import Chore, Group, User, ChoreAssignment, ChoreFrequencyEnum, ChoreTypeEnum, UserGroup +from app.models import Chore, Group, User, ChoreAssignment, ChoreFrequencyEnum, ChoreTypeEnum, UserGroup, ChoreHistoryEventTypeEnum from app.schemas.chore import ChoreCreate, ChoreUpdate, ChoreAssignmentCreate, ChoreAssignmentUpdate from app.core.chore_utils import calculate_next_due_date from app.crud.group import get_group_by_id, is_user_member +from app.crud.history import create_chore_history_entry, create_assignment_history_entry from app.core.exceptions import ChoreNotFoundError, GroupNotFoundError, PermissionDeniedError, DatabaseIntegrityError logger = logging.getLogger(__name__) @@ -39,7 +40,9 @@ async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]: personal_chores_query .options( selectinload(Chore.creator), - selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user) + selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user), + selectinload(Chore.assignments).selectinload(ChoreAssignment.history), + selectinload(Chore.history) ) .order_by(Chore.next_due_date, Chore.name) ) @@ -56,7 +59,9 @@ async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]: .options( selectinload(Chore.creator), selectinload(Chore.group), - selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user) + selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user), + selectinload(Chore.assignments).selectinload(ChoreAssignment.history), + selectinload(Chore.history) ) .order_by(Chore.next_due_date, Chore.name) ) @@ -99,6 +104,16 @@ async def create_chore( db.add(db_chore) await db.flush() # Get the ID for the chore + # Log history + await create_chore_history_entry( + db, + chore_id=db_chore.id, + group_id=db_chore.group_id, + changed_by_user_id=user_id, + event_type=ChoreHistoryEventTypeEnum.CREATED, + event_data={"chore_name": db_chore.name} + ) + try: # Load relationships for the response with eager loading result = await db.execute( @@ -107,7 +122,9 @@ async def create_chore( .options( selectinload(Chore.creator), selectinload(Chore.group), - selectinload(Chore.assignments) + selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user), + selectinload(Chore.assignments).selectinload(ChoreAssignment.history), + selectinload(Chore.history) ) ) return result.scalar_one() @@ -120,7 +137,13 @@ async def get_chore_by_id(db: AsyncSession, chore_id: int) -> Optional[Chore]: result = await db.execute( select(Chore) .where(Chore.id == chore_id) - .options(selectinload(Chore.creator), selectinload(Chore.group), selectinload(Chore.assignments)) + .options( + selectinload(Chore.creator), + selectinload(Chore.group), + selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user), + selectinload(Chore.assignments).selectinload(ChoreAssignment.history), + selectinload(Chore.history) + ) ) return result.scalar_one_or_none() @@ -152,7 +175,9 @@ async def get_personal_chores( ) .options( selectinload(Chore.creator), - selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user) + selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user), + selectinload(Chore.assignments).selectinload(ChoreAssignment.history), + selectinload(Chore.history) ) .order_by(Chore.next_due_date, Chore.name) ) @@ -175,7 +200,9 @@ async def get_chores_by_group_id( ) .options( selectinload(Chore.creator), - selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user) + selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user), + selectinload(Chore.assignments).selectinload(ChoreAssignment.history), + selectinload(Chore.history) ) .order_by(Chore.next_due_date, Chore.name) ) @@ -194,6 +221,9 @@ async def update_chore( if not db_chore: raise ChoreNotFoundError(chore_id, group_id) + # Store original state for history + original_data = {field: getattr(db_chore, field) for field in chore_in.model_dump(exclude_unset=True)} + # Check permissions if db_chore.type == ChoreTypeEnum.group: if not group_id: @@ -245,6 +275,23 @@ async def update_chore( if db_chore.frequency == ChoreFrequencyEnum.custom and db_chore.custom_interval_days is None: raise ValueError("custom_interval_days must be set for custom frequency chores.") + # Log history for changes + changes = {} + for field, old_value in original_data.items(): + new_value = getattr(db_chore, field) + if old_value != new_value: + changes[field] = {"old": str(old_value), "new": str(new_value)} + + if changes: + await create_chore_history_entry( + db, + chore_id=chore_id, + group_id=db_chore.group_id, + changed_by_user_id=user_id, + event_type=ChoreHistoryEventTypeEnum.UPDATED, + event_data=changes + ) + try: await db.flush() # Flush changes within the transaction result = await db.execute( @@ -253,7 +300,9 @@ async def update_chore( .options( selectinload(Chore.creator), selectinload(Chore.group), - selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user) + selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user), + selectinload(Chore.assignments).selectinload(ChoreAssignment.history), + selectinload(Chore.history) ) ) return result.scalar_one() @@ -273,6 +322,16 @@ async def delete_chore( if not db_chore: raise ChoreNotFoundError(chore_id, group_id) + # Log history before deleting + await create_chore_history_entry( + db, + chore_id=chore_id, + group_id=db_chore.group_id, + changed_by_user_id=user_id, + event_type=ChoreHistoryEventTypeEnum.DELETED, + event_data={"chore_name": db_chore.name} + ) + # Check permissions if db_chore.type == ChoreTypeEnum.group: if not group_id: @@ -324,6 +383,15 @@ async def create_chore_assignment( db.add(db_assignment) await db.flush() # Get the ID for the assignment + # Log history + await create_assignment_history_entry( + db, + assignment_id=db_assignment.id, + changed_by_user_id=user_id, + event_type=ChoreHistoryEventTypeEnum.ASSIGNED, + event_data={"assigned_to_user_id": db_assignment.assigned_to_user_id, "due_date": db_assignment.due_date.isoformat()} + ) + try: # Load relationships for the response result = await db.execute( @@ -331,7 +399,8 @@ async def create_chore_assignment( .where(ChoreAssignment.id == db_assignment.id) .options( selectinload(ChoreAssignment.chore).selectinload(Chore.creator), - selectinload(ChoreAssignment.assigned_user) + selectinload(ChoreAssignment.assigned_user), + selectinload(ChoreAssignment.history) ) ) return result.scalar_one() @@ -346,7 +415,8 @@ async def get_chore_assignment_by_id(db: AsyncSession, assignment_id: int) -> Op .where(ChoreAssignment.id == assignment_id) .options( selectinload(ChoreAssignment.chore).selectinload(Chore.creator), - selectinload(ChoreAssignment.assigned_user) + selectinload(ChoreAssignment.assigned_user), + selectinload(ChoreAssignment.history) ) ) return result.scalar_one_or_none() @@ -364,7 +434,8 @@ async def get_user_assignments( query = query.options( selectinload(ChoreAssignment.chore).selectinload(Chore.creator), - selectinload(ChoreAssignment.assigned_user) + selectinload(ChoreAssignment.assigned_user), + selectinload(ChoreAssignment.history) ).order_by(ChoreAssignment.due_date, ChoreAssignment.id) result = await db.execute(query) @@ -393,7 +464,8 @@ async def get_chore_assignments( .where(ChoreAssignment.chore_id == chore_id) .options( selectinload(ChoreAssignment.chore).selectinload(Chore.creator), - selectinload(ChoreAssignment.assigned_user) + selectinload(ChoreAssignment.assigned_user), + selectinload(ChoreAssignment.history) ) .order_by(ChoreAssignment.due_date, ChoreAssignment.id) ) @@ -411,11 +483,10 @@ async def update_chore_assignment( if not db_assignment: raise ChoreNotFoundError(assignment_id=assignment_id) - # Load the chore for permission checking chore = await get_chore_by_id(db, db_assignment.chore_id) if not chore: raise ChoreNotFoundError(chore_id=db_assignment.chore_id) - + # Check permissions - only assignee can complete, but chore managers can reschedule can_manage = False if chore.type == ChoreTypeEnum.personal: @@ -427,19 +498,27 @@ async def update_chore_assignment( update_data = assignment_in.model_dump(exclude_unset=True) + original_assignee = db_assignment.assigned_to_user_id + original_due_date = db_assignment.due_date + # Check specific permissions for different updates if 'is_complete' in update_data and not can_complete: raise PermissionDeniedError(detail="Only the assignee can mark assignments as complete") - if 'due_date' in update_data and not can_manage: - raise PermissionDeniedError(detail="Only chore managers can reschedule assignments") - + if 'due_date' in update_data and update_data['due_date'] != original_due_date: + if not can_manage: + raise PermissionDeniedError(detail="Only chore managers can reschedule assignments") + await create_assignment_history_entry(db, assignment_id=assignment_id, changed_by_user_id=user_id, event_type=ChoreHistoryEventTypeEnum.DUE_DATE_CHANGED, event_data={"old": original_due_date.isoformat(), "new": update_data['due_date'].isoformat()}) + + if 'assigned_to_user_id' in update_data and update_data['assigned_to_user_id'] != original_assignee: + if not can_manage: + raise PermissionDeniedError(detail="Only chore managers can reassign assignments") + await create_assignment_history_entry(db, assignment_id=assignment_id, changed_by_user_id=user_id, event_type=ChoreHistoryEventTypeEnum.REASSIGNED, event_data={"old": original_assignee, "new": update_data['assigned_to_user_id']}) + # Handle completion logic - if 'is_complete' in update_data and update_data['is_complete']: - if not db_assignment.is_complete: # Only if not already complete + if 'is_complete' in update_data: + if update_data['is_complete'] and not db_assignment.is_complete: update_data['completed_at'] = datetime.utcnow() - - # Update parent chore's last_completed_at and recalculate next_due_date chore.last_completed_at = update_data['completed_at'] chore.next_due_date = calculate_next_due_date( current_due_date=chore.next_due_date, @@ -447,24 +526,25 @@ async def update_chore_assignment( custom_interval_days=chore.custom_interval_days, last_completed_date=chore.last_completed_at ) - elif 'is_complete' in update_data and not update_data['is_complete']: - # If marking as incomplete, clear completed_at - update_data['completed_at'] = None + await create_assignment_history_entry(db, assignment_id=assignment_id, changed_by_user_id=user_id, event_type=ChoreHistoryEventTypeEnum.COMPLETED) + elif not update_data['is_complete'] and db_assignment.is_complete: + update_data['completed_at'] = None + await create_assignment_history_entry(db, assignment_id=assignment_id, changed_by_user_id=user_id, event_type=ChoreHistoryEventTypeEnum.REOPENED) # Apply updates for field, value in update_data.items(): setattr(db_assignment, field, value) try: - await db.flush() # Flush changes within the transaction - + await db.flush() # Load relationships for the response result = await db.execute( select(ChoreAssignment) .where(ChoreAssignment.id == db_assignment.id) .options( selectinload(ChoreAssignment.chore).selectinload(Chore.creator), - selectinload(ChoreAssignment.assigned_user) + selectinload(ChoreAssignment.assigned_user), + selectinload(ChoreAssignment.history) ) ) return result.scalar_one() @@ -483,6 +563,15 @@ async def delete_chore_assignment( if not db_assignment: raise ChoreNotFoundError(assignment_id=assignment_id) + # Log history before deleting + await create_assignment_history_entry( + db, + assignment_id=assignment_id, + changed_by_user_id=user_id, + event_type=ChoreHistoryEventTypeEnum.UNASSIGNED, + event_data={"unassigned_user_id": db_assignment.assigned_to_user_id} + ) + # Load the chore for permission checking chore = await get_chore_by_id(db, db_assignment.chore_id) if not chore: diff --git a/be/app/crud/group.py b/be/app/crud/group.py index 535e1f5..054fa08 100644 --- a/be/app/crud/group.py +++ b/be/app/crud/group.py @@ -79,7 +79,8 @@ async def get_user_groups(db: AsyncSession, user_id: int) -> List[GroupModel]: .options( selectinload(GroupModel.member_associations).options( selectinload(UserGroupModel.user) - ) + ), + selectinload(GroupModel.chore_history) # Eager load chore history ) ) return result.scalars().all() @@ -95,7 +96,8 @@ async def get_group_by_id(db: AsyncSession, group_id: int) -> Optional[GroupMode select(GroupModel) .where(GroupModel.id == group_id) .options( - selectinload(GroupModel.member_associations).selectinload(UserGroupModel.user) + selectinload(GroupModel.member_associations).selectinload(UserGroupModel.user), + selectinload(GroupModel.chore_history) # Eager load chore history ) ) return result.scalars().first() diff --git a/be/app/crud/history.py b/be/app/crud/history.py new file mode 100644 index 0000000..e5a8012 --- /dev/null +++ b/be/app/crud/history.py @@ -0,0 +1,83 @@ +# be/app/crud/history.py +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy.orm import selectinload +from typing import List, Optional, Any, Dict + +from app.models import ChoreHistory, ChoreAssignmentHistory, ChoreHistoryEventTypeEnum, User, Chore, Group +from app.schemas.chore import ChoreHistoryPublic, ChoreAssignmentHistoryPublic + +async def create_chore_history_entry( + db: AsyncSession, + *, + chore_id: Optional[int], + group_id: Optional[int], + changed_by_user_id: Optional[int], + event_type: ChoreHistoryEventTypeEnum, + event_data: Optional[Dict[str, Any]] = None, +) -> ChoreHistory: + """Logs an event in the chore history.""" + history_entry = ChoreHistory( + chore_id=chore_id, + group_id=group_id, + changed_by_user_id=changed_by_user_id, + event_type=event_type, + event_data=event_data or {}, + ) + db.add(history_entry) + await db.flush() + await db.refresh(history_entry) + return history_entry + +async def create_assignment_history_entry( + db: AsyncSession, + *, + assignment_id: int, + changed_by_user_id: int, + event_type: ChoreHistoryEventTypeEnum, + event_data: Optional[Dict[str, Any]] = None, +) -> ChoreAssignmentHistory: + """Logs an event in the chore assignment history.""" + history_entry = ChoreAssignmentHistory( + assignment_id=assignment_id, + changed_by_user_id=changed_by_user_id, + event_type=event_type, + event_data=event_data or {}, + ) + db.add(history_entry) + await db.flush() + await db.refresh(history_entry) + return history_entry + +async def get_chore_history(db: AsyncSession, chore_id: int) -> List[ChoreHistory]: + """Gets all history for a specific chore.""" + result = await db.execute( + select(ChoreHistory) + .where(ChoreHistory.chore_id == chore_id) + .options(selectinload(ChoreHistory.changed_by_user)) + .order_by(ChoreHistory.timestamp.desc()) + ) + return result.scalars().all() + +async def get_assignment_history(db: AsyncSession, assignment_id: int) -> List[ChoreAssignmentHistory]: + """Gets all history for a specific assignment.""" + result = await db.execute( + select(ChoreAssignmentHistory) + .where(ChoreAssignmentHistory.assignment_id == assignment_id) + .options(selectinload(ChoreAssignmentHistory.changed_by_user)) + .order_by(ChoreAssignmentHistory.timestamp.desc()) + ) + return result.scalars().all() + +async def get_group_chore_history(db: AsyncSession, group_id: int) -> List[ChoreHistory]: + """Gets all chore-related history for a group, including chore-specific and group-level events.""" + result = await db.execute( + select(ChoreHistory) + .where(ChoreHistory.group_id == group_id) + .options( + selectinload(ChoreHistory.changed_by_user), + selectinload(ChoreHistory.chore) # Also load chore info if available + ) + .order_by(ChoreHistory.timestamp.desc()) + ) + return result.scalars().all() \ No newline at end of file diff --git a/be/app/crud/schedule.py b/be/app/crud/schedule.py new file mode 100644 index 0000000..a42e0dc --- /dev/null +++ b/be/app/crud/schedule.py @@ -0,0 +1,120 @@ +# be/app/crud/schedule.py +import logging +from datetime import date, timedelta +from typing import List +from itertools import cycle + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from app.models import Chore, Group, User, ChoreAssignment, UserGroup, ChoreTypeEnum, ChoreHistoryEventTypeEnum +from app.crud.group import get_group_by_id +from app.crud.history import create_chore_history_entry +from app.core.exceptions import GroupNotFoundError, ChoreOperationError + +logger = logging.getLogger(__name__) + +async def generate_group_chore_schedule( + db: AsyncSession, + *, + group_id: int, + start_date: date, + end_date: date, + user_id: int, # The user initiating the action + member_ids: List[int] = None +) -> List[ChoreAssignment]: + """ + Generates a round-robin chore schedule for all group chores within a date range. + """ + if start_date > end_date: + raise ChoreOperationError("Start date cannot be after end date.") + + group = await get_group_by_id(db, group_id) + if not group: + raise GroupNotFoundError(group_id) + + if not member_ids: + # If no members are specified, use all members from the group + members_result = await db.execute( + select(UserGroup.user_id).where(UserGroup.group_id == group_id) + ) + member_ids = members_result.scalars().all() + + if not member_ids: + raise ChoreOperationError("Cannot generate schedule with no members.") + + # Fetch all chores belonging to this group + chores_result = await db.execute( + select(Chore).where(Chore.group_id == group_id, Chore.type == ChoreTypeEnum.group) + ) + group_chores = chores_result.scalars().all() + if not group_chores: + logger.info(f"No chores found in group {group_id} to generate a schedule for.") + return [] + + member_cycle = cycle(member_ids) + new_assignments = [] + + current_date = start_date + while current_date <= end_date: + for chore in group_chores: + # Check if a chore is due on the current day based on its frequency + # This is a simplified check. A more robust system would use the chore's next_due_date + # and frequency to see if it falls on the current_date. + # For this implementation, we assume we generate assignments for ALL chores on ALL days + # in the range, which might not be desired. + # A better approach is needed here. Let's assume for now we just create assignments for each chore + # on its *next* due date if it falls within the range. + + if start_date <= chore.next_due_date <= end_date: + # Check if an assignment for this chore on this due date already exists + existing_assignment_result = await db.execute( + select(ChoreAssignment.id) + .where(ChoreAssignment.chore_id == chore.id, ChoreAssignment.due_date == chore.next_due_date) + .limit(1) + ) + if existing_assignment_result.scalar_one_or_none(): + logger.info(f"Skipping assignment for chore '{chore.name}' on {chore.next_due_date} as it already exists.") + continue + + assigned_to_user_id = next(member_cycle) + + assignment = ChoreAssignment( + chore_id=chore.id, + assigned_to_user_id=assigned_to_user_id, + due_date=chore.next_due_date, # Assign on the chore's own next_due_date + is_complete=False + ) + db.add(assignment) + new_assignments.append(assignment) + logger.info(f"Created assignment for chore '{chore.name}' to user {assigned_to_user_id} on {chore.next_due_date}") + + current_date += timedelta(days=1) + + if not new_assignments: + logger.info(f"No new assignments were generated for group {group_id} in the specified date range.") + return [] + + # Log a single group-level event for the schedule generation + await create_chore_history_entry( + db, + chore_id=None, # This is a group-level event + group_id=group_id, + changed_by_user_id=user_id, + event_type=ChoreHistoryEventTypeEnum.SCHEDULE_GENERATED, + event_data={ + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + "member_ids": member_ids, + "assignments_created": len(new_assignments) + } + ) + + await db.flush() + + # Refresh assignments to load relationships if needed, although not strictly necessary + # as the objects are already in the session. + for assign in new_assignments: + await db.refresh(assign) + + return new_assignments \ No newline at end of file diff --git a/be/app/models.py b/be/app/models.py index 8c18196..6a4f513 100644 --- a/be/app/models.py +++ b/be/app/models.py @@ -24,6 +24,7 @@ from sqlalchemy import ( Date # Added Date for Chore model ) from sqlalchemy.orm import relationship, backref +from sqlalchemy.dialects.postgresql import JSONB from .database import Base @@ -71,6 +72,20 @@ class ChoreTypeEnum(enum.Enum): personal = "personal" group = "group" +class ChoreHistoryEventTypeEnum(str, enum.Enum): + CREATED = "created" + UPDATED = "updated" + DELETED = "deleted" + COMPLETED = "completed" + REOPENED = "reopened" + ASSIGNED = "assigned" + UNASSIGNED = "unassigned" + REASSIGNED = "reassigned" + SCHEDULE_GENERATED = "schedule_generated" + # Add more specific events as needed + DUE_DATE_CHANGED = "due_date_changed" + DETAILS_CHANGED = "details_changed" + # --- User Model --- class User(Base): __tablename__ = "users" @@ -109,6 +124,11 @@ class User(Base): assigned_chores = relationship("ChoreAssignment", back_populates="assigned_user", cascade="all, delete-orphan") # --- End Relationships for Chores --- + # --- History Relationships --- + chore_history_entries = relationship("ChoreHistory", back_populates="changed_by_user", cascade="all, delete-orphan") + assignment_history_entries = relationship("ChoreAssignmentHistory", back_populates="changed_by_user", cascade="all, delete-orphan") + # --- End History Relationships --- + # --- Group Model --- class Group(Base): @@ -137,6 +157,10 @@ class Group(Base): chores = relationship("Chore", back_populates="group", cascade="all, delete-orphan") # --- End Relationship for Chores --- + # --- History Relationships --- + chore_history = relationship("ChoreHistory", back_populates="group", cascade="all, delete-orphan") + # --- End History Relationships --- + # --- UserGroup Association Model --- class UserGroup(Base): @@ -383,6 +407,7 @@ class Chore(Base): group = relationship("Group", back_populates="chores") creator = relationship("User", back_populates="created_chores") assignments = relationship("ChoreAssignment", back_populates="chore", cascade="all, delete-orphan") + history = relationship("ChoreHistory", back_populates="chore", cascade="all, delete-orphan") # --- ChoreAssignment Model --- @@ -403,6 +428,7 @@ class ChoreAssignment(Base): # --- Relationships --- chore = relationship("Chore", back_populates="assignments") assigned_user = relationship("User", back_populates="assigned_chores") + history = relationship("ChoreAssignmentHistory", back_populates="assignment", cascade="all, delete-orphan") # === NEW: RecurrencePattern Model === @@ -430,3 +456,35 @@ class RecurrencePattern(Base): # === END: RecurrencePattern Model === + +# === NEW: Chore History Models === + +class ChoreHistory(Base): + __tablename__ = "chore_history" + + id = Column(Integer, primary_key=True, index=True) + chore_id = Column(Integer, ForeignKey("chores.id", ondelete="CASCADE"), nullable=True, index=True) + group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=True, index=True) # For group-level events + event_type = Column(SAEnum(ChoreHistoryEventTypeEnum, name="chorehistoryeventtypeenum", create_type=True), nullable=False) + event_data = Column(JSONB, nullable=True) # e.g., {'field': 'name', 'old': 'Old', 'new': 'New'} + changed_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Nullable if system-generated + timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + # --- Relationships --- + chore = relationship("Chore", back_populates="history") + group = relationship("Group", back_populates="chore_history") + changed_by_user = relationship("User", back_populates="chore_history_entries") + +class ChoreAssignmentHistory(Base): + __tablename__ = "chore_assignment_history" + + id = Column(Integer, primary_key=True, index=True) + assignment_id = Column(Integer, ForeignKey("chore_assignments.id", ondelete="CASCADE"), nullable=False, index=True) + event_type = Column(SAEnum(ChoreHistoryEventTypeEnum, name="chorehistoryeventtypeenum", create_type=True), nullable=False) # Reusing enum + event_data = Column(JSONB, nullable=True) + changed_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True) + timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + # --- Relationships --- + assignment = relationship("ChoreAssignment", back_populates="history") + changed_by_user = relationship("User", back_populates="assignment_history_entries") diff --git a/be/app/schemas/chore.py b/be/app/schemas/chore.py index 7ba70f1..3605164 100644 --- a/be/app/schemas/chore.py +++ b/be/app/schemas/chore.py @@ -1,13 +1,37 @@ from datetime import date, datetime -from typing import Optional, List +from typing import Optional, List, Any from pydantic import BaseModel, ConfigDict, field_validator # Assuming ChoreFrequencyEnum is imported from models # Adjust the import path if necessary based on your project structure. # e.g., from app.models import ChoreFrequencyEnum -from ..models import ChoreFrequencyEnum, ChoreTypeEnum, User as UserModel # For UserPublic relation +from ..models import ChoreFrequencyEnum, ChoreTypeEnum, User as UserModel, ChoreHistoryEventTypeEnum # For UserPublic relation from .user import UserPublic # For embedding user information +# Forward declaration for circular dependencies +class ChoreAssignmentPublic(BaseModel): + pass + +# History Schemas +class ChoreHistoryPublic(BaseModel): + id: int + event_type: ChoreHistoryEventTypeEnum + event_data: Optional[dict[str, Any]] = None + changed_by_user: Optional[UserPublic] = None + timestamp: datetime + + model_config = ConfigDict(from_attributes=True) + +class ChoreAssignmentHistoryPublic(BaseModel): + id: int + event_type: ChoreHistoryEventTypeEnum + event_data: Optional[dict[str, Any]] = None + changed_by_user: Optional[UserPublic] = None + timestamp: datetime + + model_config = ConfigDict(from_attributes=True) + + # Chore Schemas class ChoreBase(BaseModel): name: str @@ -75,7 +99,8 @@ class ChorePublic(ChoreBase): created_at: datetime updated_at: datetime creator: Optional[UserPublic] = None # Embed creator UserPublic schema - # group: Optional[GroupPublic] = None # Embed GroupPublic schema if needed + assignments: List[ChoreAssignmentPublic] = [] + history: List[ChoreHistoryPublic] = [] model_config = ConfigDict(from_attributes=True) @@ -92,6 +117,7 @@ class ChoreAssignmentUpdate(BaseModel): # Only completion status and perhaps due_date can be updated for an assignment is_complete: Optional[bool] = None due_date: Optional[date] = None # If rescheduling an existing assignment is allowed + assigned_to_user_id: Optional[int] = None # For reassigning the chore class ChoreAssignmentPublic(ChoreAssignmentBase): id: int @@ -100,12 +126,13 @@ class ChoreAssignmentPublic(ChoreAssignmentBase): created_at: datetime updated_at: datetime # Embed ChorePublic and UserPublic for richer responses - chore: Optional[ChorePublic] = None + chore: Optional[ChorePublic] = None assigned_user: Optional[UserPublic] = None + history: List[ChoreAssignmentHistoryPublic] = [] model_config = ConfigDict(from_attributes=True) # To handle potential circular imports if ChorePublic needs GroupPublic and GroupPublic needs ChorePublic # We can update forward refs after all models are defined. -# ChorePublic.model_rebuild() # If using Pydantic v2 and forward refs were used with strings -# ChoreAssignmentPublic.model_rebuild() +ChorePublic.model_rebuild() +ChoreAssignmentPublic.model_rebuild() diff --git a/be/app/schemas/group.py b/be/app/schemas/group.py index 6773e83..ea43806 100644 --- a/be/app/schemas/group.py +++ b/be/app/schemas/group.py @@ -1,14 +1,21 @@ # app/schemas/group.py from pydantic import BaseModel, ConfigDict, computed_field -from datetime import datetime +from datetime import datetime, date from typing import Optional, List from .user import UserPublic # Import UserPublic to represent members +from .chore import ChoreHistoryPublic # Import for history # Properties to receive via API on creation class GroupCreate(BaseModel): name: str +# New schema for generating a schedule +class GroupScheduleGenerateRequest(BaseModel): + start_date: date + end_date: date + member_ids: Optional[List[int]] = None # Optional: if not provided, use all members + # Properties to return to client class GroupPublic(BaseModel): id: int @@ -16,6 +23,7 @@ class GroupPublic(BaseModel): created_by_id: int created_at: datetime member_associations: Optional[List["UserGroupPublic"]] = None + chore_history: Optional[List[ChoreHistoryPublic]] = [] @computed_field @property @@ -39,4 +47,7 @@ class UserGroupPublic(BaseModel): # Properties stored in DB (if needed, often GroupPublic is sufficient) # class GroupInDB(GroupPublic): -# pass \ No newline at end of file +# pass + +# We need to rebuild GroupPublic to resolve the forward reference to UserGroupPublic +GroupPublic.model_rebuild() \ No newline at end of file diff --git a/fe/src/config/api.ts b/fe/src/config/api.ts index 7cddb43..ed3b2b3 100644 --- a/fe/src/config/api.ts +++ b/fe/src/config/api.ts @@ -1,5 +1,6 @@ import { api } from '@/services/api'; -import { API_BASE_URL, API_VERSION, API_ENDPOINTS } from './api-config'; +import { API_BASE_URL, API_VERSION } from './api-config'; +export { API_ENDPOINTS } from './api-config'; // Helper function to get full API URL export const getApiUrl = (endpoint: string): string => { @@ -13,6 +14,4 @@ export const apiClient = { put: (endpoint: string, data = {}, config = {}) => api.put(getApiUrl(endpoint), data, config), patch: (endpoint: string, data = {}, config = {}) => api.patch(getApiUrl(endpoint), data, config), delete: (endpoint: string, config = {}) => api.delete(getApiUrl(endpoint), config), -}; - -export { API_ENDPOINTS }; \ No newline at end of file +}; \ No newline at end of file diff --git a/fe/src/pages/ChoresPage.vue b/fe/src/pages/ChoresPage.vue index 0ec5b6c..15f4e30 100644 --- a/fe/src/pages/ChoresPage.vue +++ b/fe/src/pages/ChoresPage.vue @@ -71,8 +71,8 @@ const loadChores = async () => { return { ...c, current_assignment_id: currentAssignment?.id ?? null, - is_completed: currentAssignment?.is_complete ?? c.is_completed ?? false, - completed_at: currentAssignment?.completed_at ?? c.completed_at ?? null, + is_completed: currentAssignment?.is_complete ?? false, + completed_at: currentAssignment?.completed_at ?? null, updating: false, } }); @@ -401,7 +401,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
+ }}
@@ -422,7 +422,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
+ }} @@ -431,7 +431,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => { @@ -456,7 +456,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => { t('choresPage.deleteConfirm.cancel', 'Cancel') }} + }}
diff --git a/fe/src/pages/GroupDetailPage.vue b/fe/src/pages/GroupDetailPage.vue index 9c704be..8d4aab6 100644 --- a/fe/src/pages/GroupDetailPage.vue +++ b/fe/src/pages/GroupDetailPage.vue @@ -80,15 +80,17 @@
{{ t('groupDetailPage.chores.title') }} - + {{ t('groupDetailPage.chores.generateScheduleButton') }} +
- +
{{ chore.name }} {{ t('groupDetailPage.chores.duePrefix') }} {{ formatDate(chore.next_due_date) - }} + }}
@@ -99,6 +101,20 @@
+ +
+ {{ t('groupDetailPage.activityLog.title') }} +
+ +
+ +

{{ t('groupDetailPage.activityLog.emptyState') }}

+
+
@@ -145,7 +161,10 @@
- {{ split.user?.name || split.user?.email || t('groupDetailPage.expenses.fallbackUserName', { userId: split.user_id }) }} + {{ split.user?.name || split.user?.email || t('groupDetailPage.expenses.fallbackUserName', + { + userId: split.user_id + }) }}
{{ t('groupDetailPage.expenses.owes') }} {{ @@ -177,7 +196,9 @@ {{ t('groupDetailPage.expenses.activityLabel') }} {{ formatCurrency(activity.amount_paid) }} {{ - t('groupDetailPage.expenses.byUser') }} {{ activity.payer?.name || t('groupDetailPage.expenses.activityByUserFallback', { userId: activity.paid_by_user_id }) }} {{ t('groupDetailPage.expenses.onDate') }} {{ new + t('groupDetailPage.expenses.byUser') }} {{ activity.payer?.name || + t('groupDetailPage.expenses.activityByUserFallback', { userId: activity.paid_by_user_id }) }} {{ + t('groupDetailPage.expenses.onDate') }} {{ new Date(activity.paid_at).toLocaleDateString() }} @@ -207,7 +228,10 @@

{{ t('groupDetailPage.settleShareModal.settleAmountFor', { userName: selectedSplitForSettlement?.user?.name - || selectedSplitForSettlement?.user?.email || t('groupDetailPage.expenses.fallbackUserName', { userId: selectedSplitForSettlement?.user_id }) + || selectedSplitForSettlement?.user?.email || t('groupDetailPage.expenses.fallbackUserName', { + userId: + selectedSplitForSettlement?.user_id + }) }) }}

@@ -218,17 +242,64 @@ + + + + +
+ + {{ t('groupDetailPage.choreDetailModal.assignmentsTitle') }} +
+ + +
+ + {{ t('groupDetailPage.choreDetailModal.choreHistoryTitle') }} + +
    +
  • {{ formatHistoryEntry(entry) }}
  • +
+

{{ t('groupDetailPage.choreDetailModal.noHistory') }}

+
+
+ + + + + + + + + + + diff --git a/fe/src/services/choreService.ts b/fe/src/services/choreService.ts index 21e60a0..03bcdca 100644 --- a/fe/src/services/choreService.ts +++ b/fe/src/services/choreService.ts @@ -1,7 +1,8 @@ import { api } from './api' -import type { Chore, ChoreCreate, ChoreUpdate, ChoreType, ChoreAssignment, ChoreAssignmentCreate, ChoreAssignmentUpdate } from '../types/chore' +import type { Chore, ChoreCreate, ChoreUpdate, ChoreType, ChoreAssignment, ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreHistory } from '../types/chore' import { groupService } from './groupService' import type { Group } from './groupService' +import { apiClient, API_ENDPOINTS } from '@/config/api' export const choreService = { async getAllChores(): Promise { @@ -117,7 +118,7 @@ export const choreService = { // Update assignment async updateAssignment(assignmentId: number, update: ChoreAssignmentUpdate): Promise { - const response = await api.put(`/api/v1/chores/assignments/${assignmentId}`, update) + const response = await apiClient.put(`/api/v1/chores/assignments/${assignmentId}`, update) return response.data }, @@ -180,4 +181,9 @@ export const choreService = { // Renamed original for safety, to be removed await api.delete(`/api/v1/chores/personal/${choreId}`) }, + + async getChoreHistory(choreId: number): Promise { + const response = await apiClient.get(API_ENDPOINTS.CHORES.HISTORY(choreId)) + return response.data + }, } diff --git a/fe/src/services/groupService.ts b/fe/src/services/groupService.ts index fab5b26..15a6041 100644 --- a/fe/src/services/groupService.ts +++ b/fe/src/services/groupService.ts @@ -1,4 +1,6 @@ -import { api } from './api' +import { apiClient, API_ENDPOINTS } from '@/config/api'; +import type { Group } from '@/types/group'; +import type { ChoreHistory } from '@/types/chore'; // Define Group interface matching backend schema export interface Group { @@ -17,13 +19,17 @@ export interface Group { export const groupService = { async getUserGroups(): Promise { - try { - const response = await api.get('/api/v1/groups') - return response.data - } catch (error) { - console.error('Failed to fetch user groups:', error) - throw error - } + const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE); + return response.data; + }, + + async generateSchedule(groupId: string, data: { start_date: string; end_date: string; member_ids: number[] }): Promise { + await apiClient.post(API_ENDPOINTS.GROUPS.GENERATE_SCHEDULE(groupId), data); + }, + + async getGroupChoreHistory(groupId: string): Promise { + const response = await apiClient.get(API_ENDPOINTS.GROUPS.CHORE_HISTORY(groupId)); + return response.data; }, // Add other group-related service methods here, e.g.: diff --git a/fe/src/stores/auth.ts b/fe/src/stores/auth.ts index 7c6d386..16958b9 100644 --- a/fe/src/stores/auth.ts +++ b/fe/src/stores/auth.ts @@ -4,7 +4,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import router from '@/router' -interface AuthState { +export interface AuthState { accessToken: string | null refreshToken: string | null user: { diff --git a/fe/src/types/chore.ts b/fe/src/types/chore.ts index d2e3fcc..dc4081e 100644 --- a/fe/src/types/chore.ts +++ b/fe/src/types/chore.ts @@ -1,7 +1,8 @@ -import type { UserPublic } from './user' +import type { User } from './user' export type ChoreFrequency = 'one_time' | 'daily' | 'weekly' | 'monthly' | 'custom' export type ChoreType = 'personal' | 'group' +export type ChoreHistoryEventType = 'created' | 'updated' | 'deleted' | 'completed' | 'reopened' | 'assigned' | 'unassigned' | 'reassigned' | 'schedule_generated' | 'due_date_changed' | 'details_changed' export interface Chore { id: number @@ -16,14 +17,9 @@ export interface Chore { created_at: string updated_at: string type: ChoreType - creator?: { - id: number - name: string - email: string - } - assignments?: ChoreAssignment[] - is_completed: boolean - completed_at: string | null + creator?: User + assignments: ChoreAssignment[] + history?: ChoreHistory[] } export interface ChoreCreate extends Omit { } @@ -38,11 +34,12 @@ export interface ChoreAssignment { assigned_by_id: number due_date: string is_complete: boolean - completed_at: string | null + completed_at?: string created_at: string updated_at: string chore?: Chore - assigned_user?: UserPublic + assigned_user?: User + history?: ChoreAssignmentHistory[] } export interface ChoreAssignmentCreate { @@ -52,6 +49,23 @@ export interface ChoreAssignmentCreate { } export interface ChoreAssignmentUpdate { - due_date?: string is_complete?: boolean + due_date?: string + assigned_to_user_id?: number +} + +export interface ChoreHistory { + id: number + event_type: ChoreHistoryEventType + event_data?: Record + changed_by_user?: User + timestamp: string +} + +export interface ChoreAssignmentHistory { + id: number + event_type: ChoreHistoryEventType + event_data?: Record + changed_by_user?: User + timestamp: string } diff --git a/fe/src/types/group.ts b/fe/src/types/group.ts new file mode 100644 index 0000000..42902b6 --- /dev/null +++ b/fe/src/types/group.ts @@ -0,0 +1,12 @@ +// fe/src/types/group.ts +import type { AuthState } from '@/stores/auth'; +import type { ChoreHistory } from './chore'; + +export interface Group { + id: number; + name: string; + created_by_id: number; + created_at: string; + members: AuthState['user'][]; + chore_history?: ChoreHistory[]; +}