feat: Add chore history and scheduling functionality

This commit introduces new models and endpoints for managing chore history and scheduling within the application. Key changes include:

- Added `ChoreHistory` and `ChoreAssignmentHistory` models to track changes and events related to chores and assignments.
- Implemented CRUD operations for chore history in the `history.py` module.
- Created endpoints to retrieve chore and assignment history in the `chores.py` and `groups.py` files.
- Introduced a scheduling feature for group chores, allowing for round-robin assignment generation.
- Updated existing chore and assignment CRUD operations to log history entries for create, update, and delete actions.

This enhancement improves the tracking of chore-related events and facilitates better management of group chore assignments.
This commit is contained in:
mohamad 2025-06-08 01:17:53 +02:00
parent f8788ee42d
commit fb951acb72
19 changed files with 890 additions and 87 deletions

View File

@ -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 ###

View File

@ -8,8 +8,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_transactional_session, get_session from app.database import get_transactional_session, get_session
from app.auth import current_active_user from app.auth import current_active_user
from app.models import User as UserModel, Chore as ChoreModel, ChoreTypeEnum 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 chore as crud_chore
from app.crud import history as crud_history
from app.core.exceptions import ChoreNotFoundError, PermissionDeniedError, GroupNotFoundError, DatabaseIntegrityError from app.core.exceptions import ChoreNotFoundError, PermissionDeniedError, GroupNotFoundError, DatabaseIntegrityError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -451,3 +456,65 @@ async def complete_chore_assignment(
except DatabaseIntegrityError as e: except DatabaseIntegrityError as e:
logger.error(f"DatabaseIntegrityError completing assignment {assignment_id} for {current_user.email}: {e.detail}", exc_info=True) 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) 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)

View File

@ -8,13 +8,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_transactional_session, get_session from app.database import get_transactional_session, get_session
from app.auth import current_active_user from app.auth import current_active_user
from app.models import User as UserModel, UserRoleEnum # Import model and enum 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.invite import InviteCodePublic
from app.schemas.message import Message # For simple responses from app.schemas.message import Message # For simple responses
from app.schemas.list import ListPublic, ListDetail 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 group as crud_group
from app.crud import invite as crud_invite from app.crud import invite as crud_invite
from app.crud import list as crud_list 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 ( from app.core.exceptions import (
GroupNotFoundError, GroupNotFoundError,
GroupPermissionError, GroupPermissionError,
@ -265,3 +268,54 @@ async def read_group_lists(
group_lists = [list for list in lists if list.group_id == group_id] group_lists = [list for list in lists if list.group_id == group_id]
return group_lists 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)

View File

@ -332,9 +332,17 @@ class UserOperationError(HTTPException):
detail=detail 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): class ChoreNotFoundError(HTTPException):
"""Raised when a chore is not found.""" """Raised when a chore or assignment is not found."""
def __init__(self, chore_id: int, group_id: Optional[int] = None, detail: Optional[str] = None): def __init__(self, chore_id: int = None, assignment_id: int = None, group_id: Optional[int] = None, detail: Optional[str] = None):
if detail: if detail:
error_detail = detail error_detail = detail
elif group_id is not None: elif group_id is not None:

View File

@ -6,10 +6,11 @@ from typing import List, Optional
import logging import logging
from datetime import date, datetime 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.schemas.chore import ChoreCreate, ChoreUpdate, ChoreAssignmentCreate, ChoreAssignmentUpdate
from app.core.chore_utils import calculate_next_due_date 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.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 from app.core.exceptions import ChoreNotFoundError, GroupNotFoundError, PermissionDeniedError, DatabaseIntegrityError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -39,7 +40,9 @@ async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]:
personal_chores_query personal_chores_query
.options( .options(
selectinload(Chore.creator), 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) .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( .options(
selectinload(Chore.creator), selectinload(Chore.creator),
selectinload(Chore.group), 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) .order_by(Chore.next_due_date, Chore.name)
) )
@ -99,6 +104,16 @@ async def create_chore(
db.add(db_chore) db.add(db_chore)
await db.flush() # Get the ID for the 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: try:
# Load relationships for the response with eager loading # Load relationships for the response with eager loading
result = await db.execute( result = await db.execute(
@ -107,7 +122,9 @@ async def create_chore(
.options( .options(
selectinload(Chore.creator), selectinload(Chore.creator),
selectinload(Chore.group), 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() 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( result = await db.execute(
select(Chore) select(Chore)
.where(Chore.id == chore_id) .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() return result.scalar_one_or_none()
@ -152,7 +175,9 @@ async def get_personal_chores(
) )
.options( .options(
selectinload(Chore.creator), 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) .order_by(Chore.next_due_date, Chore.name)
) )
@ -175,7 +200,9 @@ async def get_chores_by_group_id(
) )
.options( .options(
selectinload(Chore.creator), 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) .order_by(Chore.next_due_date, Chore.name)
) )
@ -194,6 +221,9 @@ async def update_chore(
if not db_chore: if not db_chore:
raise ChoreNotFoundError(chore_id, group_id) 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 # Check permissions
if db_chore.type == ChoreTypeEnum.group: if db_chore.type == ChoreTypeEnum.group:
if not group_id: 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: 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.") 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: try:
await db.flush() # Flush changes within the transaction await db.flush() # Flush changes within the transaction
result = await db.execute( result = await db.execute(
@ -253,7 +300,9 @@ async def update_chore(
.options( .options(
selectinload(Chore.creator), selectinload(Chore.creator),
selectinload(Chore.group), 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() return result.scalar_one()
@ -273,6 +322,16 @@ async def delete_chore(
if not db_chore: if not db_chore:
raise ChoreNotFoundError(chore_id, group_id) 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 # Check permissions
if db_chore.type == ChoreTypeEnum.group: if db_chore.type == ChoreTypeEnum.group:
if not group_id: if not group_id:
@ -324,6 +383,15 @@ async def create_chore_assignment(
db.add(db_assignment) db.add(db_assignment)
await db.flush() # Get the ID for the 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: try:
# Load relationships for the response # Load relationships for the response
result = await db.execute( result = await db.execute(
@ -331,7 +399,8 @@ async def create_chore_assignment(
.where(ChoreAssignment.id == db_assignment.id) .where(ChoreAssignment.id == db_assignment.id)
.options( .options(
selectinload(ChoreAssignment.chore).selectinload(Chore.creator), selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
selectinload(ChoreAssignment.assigned_user) selectinload(ChoreAssignment.assigned_user),
selectinload(ChoreAssignment.history)
) )
) )
return result.scalar_one() 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) .where(ChoreAssignment.id == assignment_id)
.options( .options(
selectinload(ChoreAssignment.chore).selectinload(Chore.creator), selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
selectinload(ChoreAssignment.assigned_user) selectinload(ChoreAssignment.assigned_user),
selectinload(ChoreAssignment.history)
) )
) )
return result.scalar_one_or_none() return result.scalar_one_or_none()
@ -364,7 +434,8 @@ async def get_user_assignments(
query = query.options( query = query.options(
selectinload(ChoreAssignment.chore).selectinload(Chore.creator), selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
selectinload(ChoreAssignment.assigned_user) selectinload(ChoreAssignment.assigned_user),
selectinload(ChoreAssignment.history)
).order_by(ChoreAssignment.due_date, ChoreAssignment.id) ).order_by(ChoreAssignment.due_date, ChoreAssignment.id)
result = await db.execute(query) result = await db.execute(query)
@ -393,7 +464,8 @@ async def get_chore_assignments(
.where(ChoreAssignment.chore_id == chore_id) .where(ChoreAssignment.chore_id == chore_id)
.options( .options(
selectinload(ChoreAssignment.chore).selectinload(Chore.creator), selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
selectinload(ChoreAssignment.assigned_user) selectinload(ChoreAssignment.assigned_user),
selectinload(ChoreAssignment.history)
) )
.order_by(ChoreAssignment.due_date, ChoreAssignment.id) .order_by(ChoreAssignment.due_date, ChoreAssignment.id)
) )
@ -411,7 +483,6 @@ async def update_chore_assignment(
if not db_assignment: if not db_assignment:
raise ChoreNotFoundError(assignment_id=assignment_id) raise ChoreNotFoundError(assignment_id=assignment_id)
# Load the chore for permission checking
chore = await get_chore_by_id(db, db_assignment.chore_id) chore = await get_chore_by_id(db, db_assignment.chore_id)
if not chore: if not chore:
raise ChoreNotFoundError(chore_id=db_assignment.chore_id) raise ChoreNotFoundError(chore_id=db_assignment.chore_id)
@ -427,19 +498,27 @@ async def update_chore_assignment(
update_data = assignment_in.model_dump(exclude_unset=True) 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 # Check specific permissions for different updates
if 'is_complete' in update_data and not can_complete: if 'is_complete' in update_data and not can_complete:
raise PermissionDeniedError(detail="Only the assignee can mark assignments as complete") raise PermissionDeniedError(detail="Only the assignee can mark assignments as complete")
if 'due_date' in update_data and not can_manage: if 'due_date' in update_data and update_data['due_date'] != original_due_date:
raise PermissionDeniedError(detail="Only chore managers can reschedule assignments") 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 # Handle completion logic
if 'is_complete' in update_data and update_data['is_complete']: if 'is_complete' in update_data:
if not db_assignment.is_complete: # Only if not already complete if update_data['is_complete'] and not db_assignment.is_complete:
update_data['completed_at'] = datetime.utcnow() 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.last_completed_at = update_data['completed_at']
chore.next_due_date = calculate_next_due_date( chore.next_due_date = calculate_next_due_date(
current_due_date=chore.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, custom_interval_days=chore.custom_interval_days,
last_completed_date=chore.last_completed_at last_completed_date=chore.last_completed_at
) )
elif 'is_complete' in update_data and not update_data['is_complete']: await create_assignment_history_entry(db, assignment_id=assignment_id, changed_by_user_id=user_id, event_type=ChoreHistoryEventTypeEnum.COMPLETED)
# If marking as incomplete, clear completed_at elif not update_data['is_complete'] and db_assignment.is_complete:
update_data['completed_at'] = None 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 # Apply updates
for field, value in update_data.items(): for field, value in update_data.items():
setattr(db_assignment, field, value) setattr(db_assignment, field, value)
try: try:
await db.flush() # Flush changes within the transaction await db.flush()
# Load relationships for the response # Load relationships for the response
result = await db.execute( result = await db.execute(
select(ChoreAssignment) select(ChoreAssignment)
.where(ChoreAssignment.id == db_assignment.id) .where(ChoreAssignment.id == db_assignment.id)
.options( .options(
selectinload(ChoreAssignment.chore).selectinload(Chore.creator), selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
selectinload(ChoreAssignment.assigned_user) selectinload(ChoreAssignment.assigned_user),
selectinload(ChoreAssignment.history)
) )
) )
return result.scalar_one() return result.scalar_one()
@ -483,6 +563,15 @@ async def delete_chore_assignment(
if not db_assignment: if not db_assignment:
raise ChoreNotFoundError(assignment_id=assignment_id) 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 # Load the chore for permission checking
chore = await get_chore_by_id(db, db_assignment.chore_id) chore = await get_chore_by_id(db, db_assignment.chore_id)
if not chore: if not chore:

View File

@ -79,7 +79,8 @@ async def get_user_groups(db: AsyncSession, user_id: int) -> List[GroupModel]:
.options( .options(
selectinload(GroupModel.member_associations).options( selectinload(GroupModel.member_associations).options(
selectinload(UserGroupModel.user) selectinload(UserGroupModel.user)
) ),
selectinload(GroupModel.chore_history) # Eager load chore history
) )
) )
return result.scalars().all() return result.scalars().all()
@ -95,7 +96,8 @@ async def get_group_by_id(db: AsyncSession, group_id: int) -> Optional[GroupMode
select(GroupModel) select(GroupModel)
.where(GroupModel.id == group_id) .where(GroupModel.id == group_id)
.options( .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() return result.scalars().first()

83
be/app/crud/history.py Normal file
View File

@ -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()

120
be/app/crud/schedule.py Normal file
View File

@ -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

View File

@ -24,6 +24,7 @@ from sqlalchemy import (
Date # Added Date for Chore model Date # Added Date for Chore model
) )
from sqlalchemy.orm import relationship, backref from sqlalchemy.orm import relationship, backref
from sqlalchemy.dialects.postgresql import JSONB
from .database import Base from .database import Base
@ -71,6 +72,20 @@ class ChoreTypeEnum(enum.Enum):
personal = "personal" personal = "personal"
group = "group" 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 --- # --- User Model ---
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"
@ -109,6 +124,11 @@ class User(Base):
assigned_chores = relationship("ChoreAssignment", back_populates="assigned_user", cascade="all, delete-orphan") assigned_chores = relationship("ChoreAssignment", back_populates="assigned_user", cascade="all, delete-orphan")
# --- End Relationships for Chores --- # --- 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 --- # --- Group Model ---
class Group(Base): class Group(Base):
@ -137,6 +157,10 @@ class Group(Base):
chores = relationship("Chore", back_populates="group", cascade="all, delete-orphan") chores = relationship("Chore", back_populates="group", cascade="all, delete-orphan")
# --- End Relationship for Chores --- # --- End Relationship for Chores ---
# --- History Relationships ---
chore_history = relationship("ChoreHistory", back_populates="group", cascade="all, delete-orphan")
# --- End History Relationships ---
# --- UserGroup Association Model --- # --- UserGroup Association Model ---
class UserGroup(Base): class UserGroup(Base):
@ -383,6 +407,7 @@ class Chore(Base):
group = relationship("Group", back_populates="chores") group = relationship("Group", back_populates="chores")
creator = relationship("User", back_populates="created_chores") creator = relationship("User", back_populates="created_chores")
assignments = relationship("ChoreAssignment", back_populates="chore", cascade="all, delete-orphan") assignments = relationship("ChoreAssignment", back_populates="chore", cascade="all, delete-orphan")
history = relationship("ChoreHistory", back_populates="chore", cascade="all, delete-orphan")
# --- ChoreAssignment Model --- # --- ChoreAssignment Model ---
@ -403,6 +428,7 @@ class ChoreAssignment(Base):
# --- Relationships --- # --- Relationships ---
chore = relationship("Chore", back_populates="assignments") chore = relationship("Chore", back_populates="assignments")
assigned_user = relationship("User", back_populates="assigned_chores") assigned_user = relationship("User", back_populates="assigned_chores")
history = relationship("ChoreAssignmentHistory", back_populates="assignment", cascade="all, delete-orphan")
# === NEW: RecurrencePattern Model === # === NEW: RecurrencePattern Model ===
@ -430,3 +456,35 @@ class RecurrencePattern(Base):
# === END: RecurrencePattern Model === # === 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")

View File

@ -1,13 +1,37 @@
from datetime import date, datetime from datetime import date, datetime
from typing import Optional, List from typing import Optional, List, Any
from pydantic import BaseModel, ConfigDict, field_validator from pydantic import BaseModel, ConfigDict, field_validator
# Assuming ChoreFrequencyEnum is imported from models # Assuming ChoreFrequencyEnum is imported from models
# Adjust the import path if necessary based on your project structure. # Adjust the import path if necessary based on your project structure.
# e.g., from app.models import ChoreFrequencyEnum # 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 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 # Chore Schemas
class ChoreBase(BaseModel): class ChoreBase(BaseModel):
name: str name: str
@ -75,7 +99,8 @@ class ChorePublic(ChoreBase):
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
creator: Optional[UserPublic] = None # Embed creator UserPublic schema 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) 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 # Only completion status and perhaps due_date can be updated for an assignment
is_complete: Optional[bool] = None is_complete: Optional[bool] = None
due_date: Optional[date] = None # If rescheduling an existing assignment is allowed 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): class ChoreAssignmentPublic(ChoreAssignmentBase):
id: int id: int
@ -102,10 +128,11 @@ class ChoreAssignmentPublic(ChoreAssignmentBase):
# Embed ChorePublic and UserPublic for richer responses # Embed ChorePublic and UserPublic for richer responses
chore: Optional[ChorePublic] = None chore: Optional[ChorePublic] = None
assigned_user: Optional[UserPublic] = None assigned_user: Optional[UserPublic] = None
history: List[ChoreAssignmentHistoryPublic] = []
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
# To handle potential circular imports if ChorePublic needs GroupPublic and GroupPublic needs ChorePublic # To handle potential circular imports if ChorePublic needs GroupPublic and GroupPublic needs ChorePublic
# We can update forward refs after all models are defined. # We can update forward refs after all models are defined.
# ChorePublic.model_rebuild() # If using Pydantic v2 and forward refs were used with strings ChorePublic.model_rebuild()
# ChoreAssignmentPublic.model_rebuild() ChoreAssignmentPublic.model_rebuild()

View File

@ -1,14 +1,21 @@
# app/schemas/group.py # app/schemas/group.py
from pydantic import BaseModel, ConfigDict, computed_field from pydantic import BaseModel, ConfigDict, computed_field
from datetime import datetime from datetime import datetime, date
from typing import Optional, List from typing import Optional, List
from .user import UserPublic # Import UserPublic to represent members from .user import UserPublic # Import UserPublic to represent members
from .chore import ChoreHistoryPublic # Import for history
# Properties to receive via API on creation # Properties to receive via API on creation
class GroupCreate(BaseModel): class GroupCreate(BaseModel):
name: str 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 # Properties to return to client
class GroupPublic(BaseModel): class GroupPublic(BaseModel):
id: int id: int
@ -16,6 +23,7 @@ class GroupPublic(BaseModel):
created_by_id: int created_by_id: int
created_at: datetime created_at: datetime
member_associations: Optional[List["UserGroupPublic"]] = None member_associations: Optional[List["UserGroupPublic"]] = None
chore_history: Optional[List[ChoreHistoryPublic]] = []
@computed_field @computed_field
@property @property
@ -40,3 +48,6 @@ class UserGroupPublic(BaseModel):
# Properties stored in DB (if needed, often GroupPublic is sufficient) # Properties stored in DB (if needed, often GroupPublic is sufficient)
# class GroupInDB(GroupPublic): # class GroupInDB(GroupPublic):
# pass # pass
# We need to rebuild GroupPublic to resolve the forward reference to UserGroupPublic
GroupPublic.model_rebuild()

View File

@ -1,5 +1,6 @@
import { api } from '@/services/api'; 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 // Helper function to get full API URL
export const getApiUrl = (endpoint: string): string => { export const getApiUrl = (endpoint: string): string => {
@ -14,5 +15,3 @@ export const apiClient = {
patch: (endpoint: string, data = {}, config = {}) => api.patch(getApiUrl(endpoint), data, config), patch: (endpoint: string, data = {}, config = {}) => api.patch(getApiUrl(endpoint), data, config),
delete: (endpoint: string, config = {}) => api.delete(getApiUrl(endpoint), config), delete: (endpoint: string, config = {}) => api.delete(getApiUrl(endpoint), config),
}; };
export { API_ENDPOINTS };

View File

@ -71,8 +71,8 @@ const loadChores = async () => {
return { return {
...c, ...c,
current_assignment_id: currentAssignment?.id ?? null, current_assignment_id: currentAssignment?.id ?? null,
is_completed: currentAssignment?.is_complete ?? c.is_completed ?? false, is_completed: currentAssignment?.is_complete ?? false,
completed_at: currentAssignment?.completed_at ?? c.completed_at ?? null, completed_at: currentAssignment?.completed_at ?? null,
updating: false, updating: false,
} }
}); });
@ -401,7 +401,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
</div> </div>
<div v-if="choreForm.frequency === 'custom'" class="form-group"> <div v-if="choreForm.frequency === 'custom'" class="form-group">
<label class="form-label" for="chore-interval">{{ t('choresPage.form.interval', 'Interval (days)') <label class="form-label" for="chore-interval">{{ t('choresPage.form.interval', 'Interval (days)')
}}</label> }}</label>
<input id="chore-interval" type="number" v-model.number="choreForm.custom_interval_days" <input id="chore-interval" type="number" v-model.number="choreForm.custom_interval_days"
class="form-input" :placeholder="t('choresPage.form.intervalPlaceholder')" min="1"> class="form-input" :placeholder="t('choresPage.form.intervalPlaceholder')" min="1">
</div> </div>
@ -422,7 +422,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
</div> </div>
<div v-if="choreForm.type === 'group'" class="form-group"> <div v-if="choreForm.type === 'group'" class="form-group">
<label class="form-label" for="chore-group">{{ t('choresPage.form.assignGroup', 'Assign to Group') <label class="form-label" for="chore-group">{{ t('choresPage.form.assignGroup', 'Assign to Group')
}}</label> }}</label>
<select id="chore-group" v-model="choreForm.group_id" class="form-input"> <select id="chore-group" v-model="choreForm.group_id" class="form-input">
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option> <option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select> </select>
@ -431,7 +431,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-neutral" @click="showChoreModal = false">{{ <button type="button" class="btn btn-neutral" @click="showChoreModal = false">{{
t('choresPage.form.cancel', 'Cancel') t('choresPage.form.cancel', 'Cancel')
}}</button> }}</button>
<button type="submit" class="btn btn-primary">{{ isEditing ? t('choresPage.form.save', 'Save Changes') : <button type="submit" class="btn btn-primary">{{ isEditing ? t('choresPage.form.save', 'Save Changes') :
t('choresPage.form.create', 'Create') }}</button> t('choresPage.form.create', 'Create') }}</button>
</div> </div>
@ -456,7 +456,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
t('choresPage.deleteConfirm.cancel', 'Cancel') }}</button> t('choresPage.deleteConfirm.cancel', 'Cancel') }}</button>
<button type="button" class="btn btn-danger" @click="deleteChore">{{ <button type="button" class="btn btn-danger" @click="deleteChore">{{
t('choresPage.deleteConfirm.delete', 'Delete') t('choresPage.deleteConfirm.delete', 'Delete')
}}</button> }}</button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -80,15 +80,17 @@
<div class="mt-4 neo-section"> <div class="mt-4 neo-section">
<div class="flex justify-between items-center w-full mb-2"> <div class="flex justify-between items-center w-full mb-2">
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.chores.title') }}</VHeading> <VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.chores.title') }}</VHeading>
<VButton @click="showGenerateScheduleModal = true">{{ t('groupDetailPage.chores.generateScheduleButton') }}
</VButton>
</div> </div>
<VList v-if="upcomingChores.length > 0"> <VList v-if="upcomingChores.length > 0">
<VListItem v-for="chore in upcomingChores" :key="chore.id" class="flex justify-between items-center"> <VListItem v-for="chore in upcomingChores" :key="chore.id" @click="openChoreDetailModal(chore)"
class="flex justify-between items-center cursor-pointer">
<div class="neo-chore-info"> <div class="neo-chore-info">
<span class="neo-chore-name">{{ chore.name }}</span> <span class="neo-chore-name">{{ chore.name }}</span>
<span class="neo-chore-due">{{ t('groupDetailPage.chores.duePrefix') }} {{ <span class="neo-chore-due">{{ t('groupDetailPage.chores.duePrefix') }} {{
formatDate(chore.next_due_date) formatDate(chore.next_due_date)
}}</span> }}</span>
</div> </div>
<VBadge :text="formatFrequency(chore.frequency)" :variant="getFrequencyBadgeVariant(chore.frequency)" /> <VBadge :text="formatFrequency(chore.frequency)" :variant="getFrequencyBadgeVariant(chore.frequency)" />
</VListItem> </VListItem>
@ -99,6 +101,20 @@
</div> </div>
</div> </div>
<!-- Group Activity Log Section -->
<div class="mt-4 neo-section">
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.activityLog.title') }}</VHeading>
<div v-if="groupHistoryLoading" class="text-center">
<VSpinner />
</div>
<ul v-else-if="groupChoreHistory.length > 0" class="activity-log-list">
<li v-for="entry in groupChoreHistory" :key="entry.id" class="activity-log-item">
{{ formatHistoryEntry(entry) }}
</li>
</ul>
<p v-else>{{ t('groupDetailPage.activityLog.emptyState') }}</p>
</div>
<!-- Expenses Section --> <!-- Expenses Section -->
<div class="mt-4 neo-section"> <div class="mt-4 neo-section">
<div class="flex justify-between items-center w-full mb-2"> <div class="flex justify-between items-center w-full mb-2">
@ -145,7 +161,10 @@
<div class="neo-splits-list"> <div class="neo-splits-list">
<div v-for="split in expense.splits" :key="split.id" class="neo-split-item"> <div v-for="split in expense.splits" :key="split.id" class="neo-split-item">
<div class="split-col split-user"> <div class="split-col split-user">
<strong>{{ split.user?.name || split.user?.email || t('groupDetailPage.expenses.fallbackUserName', { userId: split.user_id }) }}</strong> <strong>{{ split.user?.name || split.user?.email || t('groupDetailPage.expenses.fallbackUserName',
{
userId: split.user_id
}) }}</strong>
</div> </div>
<div class="split-col split-owes"> <div class="split-col split-owes">
{{ t('groupDetailPage.expenses.owes') }} <strong>{{ {{ t('groupDetailPage.expenses.owes') }} <strong>{{
@ -177,7 +196,9 @@
{{ t('groupDetailPage.expenses.activityLabel') }} {{ {{ t('groupDetailPage.expenses.activityLabel') }} {{
formatCurrency(activity.amount_paid) }} 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() }} Date(activity.paid_at).toLocaleDateString() }}
</li> </li>
</ul> </ul>
@ -207,7 +228,10 @@
<div v-else> <div v-else>
<p>{{ t('groupDetailPage.settleShareModal.settleAmountFor', { <p>{{ t('groupDetailPage.settleShareModal.settleAmountFor', {
userName: selectedSplitForSettlement?.user?.name 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
})
}) }}</p> }) }}</p>
<VFormField :label="t('groupDetailPage.settleShareModal.amountLabel')" <VFormField :label="t('groupDetailPage.settleShareModal.amountLabel')"
:error-message="settleAmountError || undefined"> :error-message="settleAmountError || undefined">
@ -218,17 +242,64 @@
<template #footer> <template #footer>
<VButton variant="neutral" @click="closeSettleShareModal">{{ <VButton variant="neutral" @click="closeSettleShareModal">{{
t('groupDetailPage.settleShareModal.cancelButton') t('groupDetailPage.settleShareModal.cancelButton')
}}</VButton> }}</VButton>
<VButton variant="primary" @click="handleConfirmSettle" :disabled="isSettlementLoading">{{ <VButton variant="primary" @click="handleConfirmSettle" :disabled="isSettlementLoading">{{
t('groupDetailPage.settleShareModal.confirmButton') t('groupDetailPage.settleShareModal.confirmButton')
}}</VButton> }}</VButton>
</template>
</VModal>
<!-- Chore Detail Modal -->
<VModal v-model="showChoreDetailModal" :title="selectedChore?.name" size="lg">
<div v-if="selectedChore">
<!-- ... chore details ... -->
<VHeading :level="4">{{ t('groupDetailPage.choreDetailModal.assignmentsTitle') }}</VHeading>
<div v-for="assignment in selectedChore.assignments" :key="assignment.id" class="assignment-row">
<template v-if="editingAssignment?.id === assignment.id">
<!-- Inline Editing UI -->
<VSelect v-if="group && group.members" :options="group.members.map(m => ({ value: m.id, label: m.email }))"
v-model="editingAssignment.assigned_to_user_id" />
<VInput type="date" :model-value="editingAssignment.due_date ?? ''"
@update:model-value="val => editingAssignment && (editingAssignment.due_date = val)" />
<VButton @click="saveAssignmentEdit(assignment.id)" size="sm">{{ t('shared.save') }}</VButton>
<VButton @click="cancelAssignmentEdit" variant="neutral" size="sm">{{ t('shared.cancel') }}</VButton>
</template>
<template v-else>
<span>{{ assignment.assigned_user?.email }} - Due: {{ formatDate(assignment.due_date) }}</span>
<VButton v-if="!assignment.is_complete" @click="startAssignmentEdit(assignment)" size="sm"
variant="neutral">{{ t('shared.edit') }}</VButton>
</template>
</div>
<VHeading :level="4">{{ t('groupDetailPage.choreDetailModal.choreHistoryTitle') }}</VHeading>
<!-- Chore History Display -->
<ul v-if="selectedChore.history && selectedChore.history.length > 0">
<li v-for="entry in selectedChore.history" :key="entry.id">{{ formatHistoryEntry(entry) }}</li>
</ul>
<p v-else>{{ t('groupDetailPage.choreDetailModal.noHistory') }}</p>
</div>
</VModal>
<!-- Generate Schedule Modal -->
<VModal v-model="showGenerateScheduleModal" :title="t('groupDetailPage.generateScheduleModal.title')">
<VFormField :label="t('groupDetailPage.generateScheduleModal.startDateLabel')">
<VInput type="date" v-model="scheduleForm.start_date" />
</VFormField>
<VFormField :label="t('groupDetailPage.generateScheduleModal.endDateLabel')">
<VInput type="date" v-model="scheduleForm.end_date" />
</VFormField>
<!-- Member selection can be added here if desired -->
<template #footer>
<VButton @click="showGenerateScheduleModal = false" variant="neutral">{{ t('shared.cancel') }}</VButton>
<VButton @click="handleGenerateSchedule" :disabled="generatingSchedule">{{
t('groupDetailPage.generateScheduleModal.generateButton') }}</VButton>
</template> </template>
</VModal> </VModal>
</main> </main>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue'; import { ref, onMounted, computed, reactive } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
// import { useRoute } from 'vue-router'; // import { useRoute } from 'vue-router';
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming path import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming path
@ -236,7 +307,7 @@ import { useClipboard, useStorage } from '@vueuse/core';
import ListsPage from './ListsPage.vue'; // Import ListsPage import ListsPage from './ListsPage.vue'; // Import ListsPage
import { useNotificationStore } from '@/stores/notifications'; import { useNotificationStore } from '@/stores/notifications';
import { choreService } from '../services/choreService' import { choreService } from '../services/choreService'
import type { Chore, ChoreFrequency } from '../types/chore' import type { Chore, ChoreFrequency, ChoreAssignment, ChoreHistory, ChoreAssignmentHistory } from '../types/chore'
import { format } from 'date-fns' import { format } from 'date-fns'
import type { Expense, ExpenseSplit, SettlementActivityCreate } from '@/types/expense'; import type { Expense, ExpenseSplit, SettlementActivityCreate } from '@/types/expense';
import { ExpenseOverallStatusEnum, ExpenseSplitStatusEnum } from '@/types/expense'; import { ExpenseOverallStatusEnum, ExpenseSplitStatusEnum } from '@/types/expense';
@ -256,6 +327,7 @@ import VFormField from '@/components/valerie/VFormField.vue';
import VIcon from '@/components/valerie/VIcon.vue'; import VIcon from '@/components/valerie/VIcon.vue';
import VModal from '@/components/valerie/VModal.vue'; import VModal from '@/components/valerie/VModal.vue';
import { onClickOutside } from '@vueuse/core' import { onClickOutside } from '@vueuse/core'
import { groupService } from '../services/groupService'; // New service
const { t } = useI18n(); const { t } = useI18n();
@ -337,6 +409,22 @@ const settleAmount = ref<string>('');
const settleAmountError = ref<string | null>(null); const settleAmountError = ref<string | null>(null);
const isSettlementLoading = ref(false); const isSettlementLoading = ref(false);
// New State
const showChoreDetailModal = ref(false);
const selectedChore = ref<Chore | null>(null);
const editingAssignment = ref<Partial<ChoreAssignment> | null>(null);
const showGenerateScheduleModal = ref(false);
const scheduleForm = reactive({
start_date: '',
end_date: '',
member_ids: []
});
const generatingSchedule = ref(false);
const groupChoreHistory = ref<ChoreHistory[]>([]);
const groupHistoryLoading = ref(false);
const getApiErrorMessage = (err: unknown, fallbackMessageKey: string): string => { const getApiErrorMessage = (err: unknown, fallbackMessageKey: string): string => {
if (err && typeof err === 'object') { if (err && typeof err === 'object') {
if ('response' in err && err.response && typeof err.response === 'object' && 'data' in err.response && err.response.data) { if ('response' in err && err.response && typeof err.response === 'object' && 'data' in err.response && err.response.data) {
@ -557,7 +645,7 @@ const loadRecentExpenses = async () => {
if (!groupId.value) return if (!groupId.value) return
try { try {
const response = await apiClient.get( const response = await apiClient.get(
`${API_ENDPOINTS.FINANCIALS.EXPENSES}?group_id=${groupId.value}&limit=5&detailed=true` `${API_ENDPOINTS.FINANCIALS.EXPENSES}?group_id=${groupId.value || ''}&limit=5&detailed=true`
) )
recentExpenses.value = response.data recentExpenses.value = response.data
} catch (error) { } catch (error) {
@ -742,10 +830,94 @@ const toggleInviteUI = () => {
} }
}; };
const openChoreDetailModal = async (chore: Chore) => {
selectedChore.value = chore;
showChoreDetailModal.value = true;
// Optionally lazy load history if not already loaded with the chore
if (!chore.history || chore.history.length === 0) {
const history = await choreService.getChoreHistory(chore.id);
const choreInList = upcomingChores.value.find(c => c.id === chore.id);
if (choreInList) {
choreInList.history = history;
selectedChore.value = choreInList;
}
}
};
const startAssignmentEdit = (assignment: ChoreAssignment) => {
editingAssignment.value = { ...assignment, due_date: format(new Date(assignment.due_date), 'yyyy-MM-dd') };
};
const cancelAssignmentEdit = () => {
editingAssignment.value = null;
};
const saveAssignmentEdit = async (assignmentId: number) => {
if (!editingAssignment.value || !editingAssignment.value.due_date) {
notificationStore.addNotification({ message: 'Due date is required.', type: 'error' });
return;
}
try {
const updatedAssignment = await choreService.updateAssignment(assignmentId, {
due_date: editingAssignment.value.due_date,
assigned_to_user_id: editingAssignment.value.assigned_to_user_id
});
// Update local state
loadUpcomingChores(); // Re-fetch all chores to get updates
cancelAssignmentEdit();
notificationStore.addNotification({ message: 'Assignment updated', type: 'success' });
} catch (error) {
notificationStore.addNotification({ message: 'Failed to update assignment', type: 'error' });
}
};
const handleGenerateSchedule = async () => {
generatingSchedule.value = true;
try {
await groupService.generateSchedule(String(groupId.value), scheduleForm);
notificationStore.addNotification({ message: 'Schedule generated successfully', type: 'success' });
showGenerateScheduleModal.value = false;
loadUpcomingChores(); // Refresh the chore list
} catch (error) {
notificationStore.addNotification({ message: 'Failed to generate schedule', type: 'error' });
} finally {
generatingSchedule.value = false;
}
};
const loadGroupChoreHistory = async () => {
if (!groupId.value) return;
groupHistoryLoading.value = true;
try {
groupChoreHistory.value = await groupService.getGroupChoreHistory(String(groupId.value));
} catch (err) {
console.error("Failed to load group chore history", err);
notificationStore.addNotification({ message: 'Could not load group activity.', type: 'error' });
} finally {
groupHistoryLoading.value = false;
}
};
const formatHistoryEntry = (entry: ChoreHistory | ChoreAssignmentHistory): string => {
const user = entry.changed_by_user?.email || 'System';
const time = new Date(entry.timestamp).toLocaleString();
let details = '';
if (entry.event_data) {
details = Object.entries(entry.event_data).map(([key, value]) => {
if (typeof value === 'object' && value !== null && 'old' in value && 'new' in value) {
return `${key} changed from '${value.old}' to '${value.new}'`;
}
return `${key}: ${JSON.stringify(value)}`;
}).join(', ');
}
return `${user} ${entry.event_type} on ${time}. Details: ${details}`;
};
onMounted(() => { onMounted(() => {
fetchGroupDetails(); fetchGroupDetails();
loadUpcomingChores(); loadUpcomingChores();
loadRecentExpenses(); loadRecentExpenses();
loadGroupChoreHistory();
}); });
</script> </script>

View File

@ -1,7 +1,8 @@
import { api } from './api' 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 { groupService } from './groupService'
import type { Group } from './groupService' import type { Group } from './groupService'
import { apiClient, API_ENDPOINTS } from '@/config/api'
export const choreService = { export const choreService = {
async getAllChores(): Promise<Chore[]> { async getAllChores(): Promise<Chore[]> {
@ -117,7 +118,7 @@ export const choreService = {
// Update assignment // Update assignment
async updateAssignment(assignmentId: number, update: ChoreAssignmentUpdate): Promise<ChoreAssignment> { async updateAssignment(assignmentId: number, update: ChoreAssignmentUpdate): Promise<ChoreAssignment> {
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 return response.data
}, },
@ -180,4 +181,9 @@ export const choreService = {
// Renamed original for safety, to be removed // Renamed original for safety, to be removed
await api.delete(`/api/v1/chores/personal/${choreId}`) await api.delete(`/api/v1/chores/personal/${choreId}`)
}, },
async getChoreHistory(choreId: number): Promise<ChoreHistory[]> {
const response = await apiClient.get(API_ENDPOINTS.CHORES.HISTORY(choreId))
return response.data
},
} }

View File

@ -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 // Define Group interface matching backend schema
export interface Group { export interface Group {
@ -17,13 +19,17 @@ export interface Group {
export const groupService = { export const groupService = {
async getUserGroups(): Promise<Group[]> { async getUserGroups(): Promise<Group[]> {
try { const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE);
const response = await api.get('/api/v1/groups') return response.data;
return response.data },
} catch (error) {
console.error('Failed to fetch user groups:', error) async generateSchedule(groupId: string, data: { start_date: string; end_date: string; member_ids: number[] }): Promise<void> {
throw error await apiClient.post(API_ENDPOINTS.GROUPS.GENERATE_SCHEDULE(groupId), data);
} },
async getGroupChoreHistory(groupId: string): Promise<ChoreHistory[]> {
const response = await apiClient.get(API_ENDPOINTS.GROUPS.CHORE_HISTORY(groupId));
return response.data;
}, },
// Add other group-related service methods here, e.g.: // Add other group-related service methods here, e.g.:

View File

@ -4,7 +4,7 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import router from '@/router' import router from '@/router'
interface AuthState { export interface AuthState {
accessToken: string | null accessToken: string | null
refreshToken: string | null refreshToken: string | null
user: { user: {

View File

@ -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 ChoreFrequency = 'one_time' | 'daily' | 'weekly' | 'monthly' | 'custom'
export type ChoreType = 'personal' | 'group' 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 { export interface Chore {
id: number id: number
@ -16,14 +17,9 @@ export interface Chore {
created_at: string created_at: string
updated_at: string updated_at: string
type: ChoreType type: ChoreType
creator?: { creator?: User
id: number assignments: ChoreAssignment[]
name: string history?: ChoreHistory[]
email: string
}
assignments?: ChoreAssignment[]
is_completed: boolean
completed_at: string | null
} }
export interface ChoreCreate extends Omit<Chore, 'id'> { } export interface ChoreCreate extends Omit<Chore, 'id'> { }
@ -38,11 +34,12 @@ export interface ChoreAssignment {
assigned_by_id: number assigned_by_id: number
due_date: string due_date: string
is_complete: boolean is_complete: boolean
completed_at: string | null completed_at?: string
created_at: string created_at: string
updated_at: string updated_at: string
chore?: Chore chore?: Chore
assigned_user?: UserPublic assigned_user?: User
history?: ChoreAssignmentHistory[]
} }
export interface ChoreAssignmentCreate { export interface ChoreAssignmentCreate {
@ -52,6 +49,23 @@ export interface ChoreAssignmentCreate {
} }
export interface ChoreAssignmentUpdate { export interface ChoreAssignmentUpdate {
due_date?: string
is_complete?: boolean is_complete?: boolean
due_date?: string
assigned_to_user_id?: number
}
export interface ChoreHistory {
id: number
event_type: ChoreHistoryEventType
event_data?: Record<string, any>
changed_by_user?: User
timestamp: string
}
export interface ChoreAssignmentHistory {
id: number
event_type: ChoreHistoryEventType
event_data?: Record<string, any>
changed_by_user?: User
timestamp: string
} }

12
fe/src/types/group.ts Normal file
View File

@ -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[];
}