refactor: Clean up code and improve API structure

This commit is contained in:
mohamad 2025-06-09 15:14:34 +02:00
parent 10845d2e5f
commit 8a0457aeec
63 changed files with 579 additions and 2666 deletions

View File

@ -1,12 +1,5 @@
# app/api/api_router.py
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1.api import api_router_v1
from app.api.v1.api import api_router_v1 # Import the v1 router
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(api_router_v1, prefix="/v1")
# Include versioned routers here, adding the /api prefix
api_router.include_router(api_router_v1, prefix="/v1") # Mounts v1 endpoints under /api/v1/...
# Add other API versions later
# e.g., api_router.include_router(api_router_v2, prefix="/v2")

View File

@ -19,30 +19,26 @@ async def google_callback(request: Request, db: AsyncSession = Depends(get_trans
token_data = await oauth.google.authorize_access_token(request) token_data = await oauth.google.authorize_access_token(request)
user_info = await oauth.google.parse_id_token(request, token_data) user_info = await oauth.google.parse_id_token(request, token_data)
# Check if user exists
existing_user = (await db.execute(select(User).where(User.email == user_info['email']))).scalar_one_or_none() existing_user = (await db.execute(select(User).where(User.email == user_info['email']))).scalar_one_or_none()
user_to_login = existing_user user_to_login = existing_user
if not existing_user: if not existing_user:
# Create new user
new_user = User( new_user = User(
email=user_info['email'], email=user_info['email'],
name=user_info.get('name', user_info.get('email')), name=user_info.get('name', user_info.get('email')),
is_verified=True, # Email is verified by Google is_verified=True,
is_active=True is_active=True
) )
db.add(new_user) db.add(new_user)
await db.flush() # Use flush instead of commit since we're in a transaction await db.flush()
user_to_login = new_user user_to_login = new_user
# Generate JWT tokens using the new backend
access_strategy = get_jwt_strategy() access_strategy = get_jwt_strategy()
refresh_strategy = get_refresh_jwt_strategy() refresh_strategy = get_refresh_jwt_strategy()
access_token = await access_strategy.write_token(user_to_login) access_token = await access_strategy.write_token(user_to_login)
refresh_token = await refresh_strategy.write_token(user_to_login) refresh_token = await refresh_strategy.write_token(user_to_login)
# Redirect to frontend with tokens
redirect_url = f"{settings.FRONTEND_URL}/auth/callback?access_token={access_token}&refresh_token={refresh_token}" redirect_url = f"{settings.FRONTEND_URL}/auth/callback?access_token={access_token}&refresh_token={refresh_token}"
return RedirectResponse(url=redirect_url) return RedirectResponse(url=redirect_url)
@ -62,12 +58,10 @@ async def apple_callback(request: Request, db: AsyncSession = Depends(get_transa
if 'email' not in user_info: if 'email' not in user_info:
return RedirectResponse(url=f"{settings.FRONTEND_URL}/auth/callback?error=apple_email_missing") return RedirectResponse(url=f"{settings.FRONTEND_URL}/auth/callback?error=apple_email_missing")
# Check if user exists
existing_user = (await db.execute(select(User).where(User.email == user_info['email']))).scalar_one_or_none() existing_user = (await db.execute(select(User).where(User.email == user_info['email']))).scalar_one_or_none()
user_to_login = existing_user user_to_login = existing_user
if not existing_user: if not existing_user:
# Create new user
name_info = user_info.get('name', {}) name_info = user_info.get('name', {})
first_name = name_info.get('firstName', '') first_name = name_info.get('firstName', '')
last_name = name_info.get('lastName', '') last_name = name_info.get('lastName', '')
@ -76,21 +70,19 @@ async def apple_callback(request: Request, db: AsyncSession = Depends(get_transa
new_user = User( new_user = User(
email=user_info['email'], email=user_info['email'],
name=full_name, name=full_name,
is_verified=True, # Email is verified by Apple is_verified=True,
is_active=True is_active=True
) )
db.add(new_user) db.add(new_user)
await db.flush() # Use flush instead of commit since we're in a transaction await db.flush()
user_to_login = new_user user_to_login = new_user
# Generate JWT tokens using the new backend
access_strategy = get_jwt_strategy() access_strategy = get_jwt_strategy()
refresh_strategy = get_refresh_jwt_strategy() refresh_strategy = get_refresh_jwt_strategy()
access_token = await access_strategy.write_token(user_to_login) access_token = await access_strategy.write_token(user_to_login)
refresh_token = await refresh_strategy.write_token(user_to_login) refresh_token = await refresh_strategy.write_token(user_to_login)
# Redirect to frontend with tokens
redirect_url = f"{settings.FRONTEND_URL}/auth/callback?access_token={access_token}&refresh_token={refresh_token}" redirect_url = f"{settings.FRONTEND_URL}/auth/callback?access_token={access_token}&refresh_token={refresh_token}"
return RedirectResponse(url=redirect_url) return RedirectResponse(url=redirect_url)
@ -113,7 +105,6 @@ async def refresh_jwt_token(request: Request):
access_strategy = get_jwt_strategy() access_strategy = get_jwt_strategy()
access_token = await access_strategy.write_token(user) access_token = await access_strategy.write_token(user)
# Optionally, issue a new refresh token (rotation)
new_refresh_token = await refresh_strategy.write_token(user) new_refresh_token = await refresh_strategy.write_token(user)
return JSONResponse({ return JSONResponse({
"access_token": access_token, "access_token": access_token,

View File

@ -23,5 +23,3 @@ api_router_v1.include_router(costs.router, prefix="/costs", tags=["Costs"])
api_router_v1.include_router(financials.router, prefix="/financials", tags=["Financials"]) api_router_v1.include_router(financials.router, prefix="/financials", tags=["Financials"])
api_router_v1.include_router(chores.router, prefix="/chores", tags=["Chores"]) api_router_v1.include_router(chores.router, prefix="/chores", tags=["Chores"])
api_router_v1.include_router(oauth.router, prefix="/auth", tags=["Auth"]) api_router_v1.include_router(oauth.router, prefix="/auth", tags=["Auth"])
# Add other v1 endpoint routers here later
# e.g., api_router_v1.include_router(users.router, prefix="/users", tags=["Users"])

View File

@ -20,7 +20,6 @@ from app.core.exceptions import ChoreNotFoundError, PermissionDeniedError, Group
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
# Add this new endpoint before the personal chores section
@router.get( @router.get(
"/all", "/all",
response_model=PyList[ChorePublic], response_model=PyList[ChorePublic],
@ -28,13 +27,12 @@ router = APIRouter()
tags=["Chores"] tags=["Chores"]
) )
async def list_all_chores( async def list_all_chores(
db: AsyncSession = Depends(get_session), # Use read-only session for GET db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user), current_user: UserModel = Depends(current_active_user),
): ):
"""Retrieves all chores (personal and group) for the current user in a single optimized request.""" """Retrieves all chores (personal and group) for the current user in a single optimized request."""
logger.info(f"User {current_user.email} listing all their chores") logger.info(f"User {current_user.email} listing all their chores")
# Use the optimized function that reduces database queries
all_chores = await crud_chore.get_all_user_chores(db=db, user_id=current_user.id) all_chores = await crud_chore.get_all_user_chores(db=db, user_id=current_user.id)
return all_chores return all_chores
@ -135,14 +133,12 @@ async def delete_personal_chore(
"""Deletes a personal chore for the current user.""" """Deletes a personal chore for the current user."""
logger.info(f"User {current_user.email} deleting personal chore ID: {chore_id}") logger.info(f"User {current_user.email} deleting personal chore ID: {chore_id}")
try: try:
# First, verify it's a personal chore belonging to the user
chore_to_delete = await crud_chore.get_chore_by_id(db, chore_id) chore_to_delete = await crud_chore.get_chore_by_id(db, chore_id)
if not chore_to_delete or chore_to_delete.type != ChoreTypeEnum.personal or chore_to_delete.created_by_id != current_user.id: if not chore_to_delete or chore_to_delete.type != ChoreTypeEnum.personal or chore_to_delete.created_by_id != current_user.id:
raise ChoreNotFoundError(chore_id=chore_id, detail="Personal chore not found or not owned by user.") raise ChoreNotFoundError(chore_id=chore_id, detail="Personal chore not found or not owned by user.")
success = await crud_chore.delete_chore(db=db, chore_id=chore_id, user_id=current_user.id, group_id=None) success = await crud_chore.delete_chore(db=db, chore_id=chore_id, user_id=current_user.id, group_id=None)
if not success: if not success:
# This case should be rare if the above check passes and DB is consistent
raise ChoreNotFoundError(chore_id=chore_id) raise ChoreNotFoundError(chore_id=chore_id)
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
except ChoreNotFoundError as e: except ChoreNotFoundError as e:
@ -156,7 +152,6 @@ async def delete_personal_chore(
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)
# --- Group Chores Endpoints --- # --- Group Chores Endpoints ---
# (These would be similar to what you might have had before, but now explicitly part of this router)
@router.post( @router.post(
"/groups/{group_id}/chores", "/groups/{group_id}/chores",
@ -235,7 +230,6 @@ async def update_group_chore(
if chore_in.group_id is not None and chore_in.group_id != group_id: if chore_in.group_id is not None and chore_in.group_id != group_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Chore's group_id if provided must match path group_id ({group_id}).") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Chore's group_id if provided must match path group_id ({group_id}).")
# Ensure chore_in has the correct type for the CRUD operation
chore_payload = chore_in.model_copy(update={"type": ChoreTypeEnum.group, "group_id": group_id} if chore_in.type is None else {"group_id": group_id}) chore_payload = chore_in.model_copy(update={"type": ChoreTypeEnum.group, "group_id": group_id} if chore_in.type is None else {"group_id": group_id})
try: try:
@ -271,15 +265,12 @@ async def delete_group_chore(
"""Deletes a chore from a group, ensuring user has permission.""" """Deletes a chore from a group, ensuring user has permission."""
logger.info(f"User {current_user.email} deleting chore ID {chore_id} from group {group_id}") logger.info(f"User {current_user.email} deleting chore ID {chore_id} from group {group_id}")
try: try:
# Verify chore exists and belongs to the group before attempting deletion via CRUD
# This gives a more precise error if the chore exists but isn't in this group.
chore_to_delete = await crud_chore.get_chore_by_id_and_group(db, chore_id, group_id, current_user.id) # checks permission too chore_to_delete = await crud_chore.get_chore_by_id_and_group(db, chore_id, group_id, current_user.id) # checks permission too
if not chore_to_delete : # get_chore_by_id_and_group will raise PermissionDeniedError if user not member if not chore_to_delete : # get_chore_by_id_and_group will raise PermissionDeniedError if user not member
raise ChoreNotFoundError(chore_id=chore_id, group_id=group_id) raise ChoreNotFoundError(chore_id=chore_id, group_id=group_id)
success = await crud_chore.delete_chore(db=db, chore_id=chore_id, user_id=current_user.id, group_id=group_id) success = await crud_chore.delete_chore(db=db, chore_id=chore_id, user_id=current_user.id, group_id=group_id)
if not success: if not success:
# This case should be rare if the above check passes and DB is consistent
raise ChoreNotFoundError(chore_id=chore_id, group_id=group_id) raise ChoreNotFoundError(chore_id=chore_id, group_id=group_id)
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
except ChoreNotFoundError as e: except ChoreNotFoundError as e:
@ -331,7 +322,7 @@ async def create_chore_assignment(
) )
async def list_my_assignments( async def list_my_assignments(
include_completed: bool = False, include_completed: bool = False,
db: AsyncSession = Depends(get_session), # Use read-only session for GET db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user), current_user: UserModel = Depends(current_active_user),
): ):
"""Retrieves all chore assignments for the current user.""" """Retrieves all chore assignments for the current user."""
@ -350,7 +341,7 @@ async def list_my_assignments(
) )
async def list_chore_assignments( async def list_chore_assignments(
chore_id: int, chore_id: int,
db: AsyncSession = Depends(get_session), # Use read-only session for GET db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user), current_user: UserModel = Depends(current_active_user),
): ):
"""Retrieves all assignments for a specific chore.""" """Retrieves all assignments for a specific chore."""
@ -471,7 +462,6 @@ async def get_chore_history(
current_user: UserModel = Depends(current_active_user), current_user: UserModel = Depends(current_active_user),
): ):
"""Retrieves the history of a specific chore.""" """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) chore = await crud_chore.get_chore_by_id(db, chore_id)
if not chore: if not chore:
raise ChoreNotFoundError(chore_id=chore_id) raise ChoreNotFoundError(chore_id=chore_id)
@ -503,10 +493,9 @@ async def get_chore_assignment_history(
if not assignment: if not assignment:
raise ChoreNotFoundError(assignment_id=assignment_id) 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) chore = await crud_chore.get_chore_by_id(db, assignment.chore_id)
if not chore: if not chore:
raise ChoreNotFoundError(chore_id=assignment.chore_id) # Should not happen if assignment exists raise ChoreNotFoundError(chore_id=assignment.chore_id)
if chore.type == ChoreTypeEnum.personal and chore.created_by_id != current_user.id: 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.") raise PermissionDeniedError("You can only view history for assignments of your own personal chores.")

View File

@ -1,4 +1,4 @@
# app/api/v1/endpoints/costs.py
import logging import logging
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select from sqlalchemy import select
@ -18,14 +18,14 @@ from app.models import (
UserGroup as UserGroupModel, UserGroup as UserGroupModel,
SplitTypeEnum, SplitTypeEnum,
ExpenseSplit as ExpenseSplitModel, ExpenseSplit as ExpenseSplitModel,
Settlement as SettlementModel, SettlementActivity as SettlementActivityModel,
SettlementActivity as SettlementActivityModel # Added Settlement as SettlementModel
) )
from app.schemas.cost import ListCostSummary, GroupBalanceSummary, UserCostShare, UserBalanceDetail, SuggestedSettlement from app.schemas.cost import ListCostSummary, GroupBalanceSummary, UserCostShare, UserBalanceDetail, SuggestedSettlement
from app.schemas.expense import ExpenseCreate from app.schemas.expense import ExpenseCreate
from app.crud import list as crud_list from app.crud import list as crud_list
from app.crud import expense as crud_expense from app.crud import expense as crud_expense
from app.core.exceptions import ListNotFoundError, ListPermissionError, UserNotFoundError, GroupNotFoundError from app.core.exceptions import ListNotFoundError, ListPermissionError, GroupNotFoundError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()

View File

@ -1,4 +1,3 @@
# app/api/v1/endpoints/groups.py
import logging import logging
from typing import List from typing import List
@ -7,11 +6,11 @@ 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
from app.schemas.group import GroupCreate, GroupPublic, GroupScheduleGenerateRequest 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
from app.schemas.list import ListPublic, ListDetail from app.schemas.list import ListDetail
from app.schemas.chore import ChoreHistoryPublic, ChoreAssignmentPublic from app.schemas.chore import ChoreHistoryPublic, ChoreAssignmentPublic
from app.schemas.user import UserPublic from app.schemas.user import UserPublic
from app.crud import group as crud_group from app.crud import group as crud_group
@ -46,8 +45,6 @@ async def create_group(
"""Creates a new group, adding the creator as the owner.""" """Creates a new group, adding the creator as the owner."""
logger.info(f"User {current_user.email} creating group: {group_in.name}") logger.info(f"User {current_user.email} creating group: {group_in.name}")
created_group = await crud_group.create_group(db=db, group_in=group_in, creator_id=current_user.id) created_group = await crud_group.create_group(db=db, group_in=group_in, creator_id=current_user.id)
# Load members explicitly if needed for the response (optional here)
# created_group = await crud_group.get_group_by_id(db, created_group.id)
return created_group return created_group
@ -58,7 +55,7 @@ async def create_group(
tags=["Groups"] tags=["Groups"]
) )
async def read_user_groups( async def read_user_groups(
db: AsyncSession = Depends(get_session), # Use read-only session for GET db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user), current_user: UserModel = Depends(current_active_user),
): ):
"""Retrieves all groups the current user is a member of.""" """Retrieves all groups the current user is a member of."""
@ -75,12 +72,11 @@ async def read_user_groups(
) )
async def read_group( async def read_group(
group_id: int, group_id: int,
db: AsyncSession = Depends(get_session), # Use read-only session for GET db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user), current_user: UserModel = Depends(current_active_user),
): ):
"""Retrieves details for a specific group, including members, if the user is part of it.""" """Retrieves details for a specific group, including members, if the user is part of it."""
logger.info(f"User {current_user.email} requesting details for group ID: {group_id}") logger.info(f"User {current_user.email} requesting details for group ID: {group_id}")
# Check if user is a member first
is_member = await crud_group.is_user_member(db=db, group_id=group_id, user_id=current_user.id) is_member = await crud_group.is_user_member(db=db, group_id=group_id, user_id=current_user.id)
if not is_member: if not is_member:
logger.warning(f"Access denied: User {current_user.email} not member of group {group_id}") logger.warning(f"Access denied: User {current_user.email} not member of group {group_id}")
@ -101,13 +97,12 @@ async def read_group(
) )
async def read_group_members( async def read_group_members(
group_id: int, group_id: int,
db: AsyncSession = Depends(get_session), # Use read-only session for GET db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user), current_user: UserModel = Depends(current_active_user),
): ):
"""Retrieves all members of a specific group, if the user is part of it.""" """Retrieves all members of a specific group, if the user is part of it."""
logger.info(f"User {current_user.email} requesting members for group ID: {group_id}") logger.info(f"User {current_user.email} requesting members for group ID: {group_id}")
# Check if user is a member first
is_member = await crud_group.is_user_member(db=db, group_id=group_id, user_id=current_user.id) is_member = await crud_group.is_user_member(db=db, group_id=group_id, user_id=current_user.id)
if not is_member: if not is_member:
logger.warning(f"Access denied: User {current_user.email} not member of group {group_id}") logger.warning(f"Access denied: User {current_user.email} not member of group {group_id}")
@ -118,7 +113,6 @@ async def read_group_members(
logger.error(f"Group {group_id} requested by member {current_user.email} not found (data inconsistency?)") logger.error(f"Group {group_id} requested by member {current_user.email} not found (data inconsistency?)")
raise GroupNotFoundError(group_id) raise GroupNotFoundError(group_id)
# Extract and return just the user information from member associations
return [member_assoc.user for member_assoc in group.member_associations] return [member_assoc.user for member_assoc in group.member_associations]
@router.post( @router.post(
@ -136,12 +130,10 @@ async def create_group_invite(
logger.info(f"User {current_user.email} attempting to create invite for group {group_id}") logger.info(f"User {current_user.email} attempting to create invite for group {group_id}")
user_role = await crud_group.get_user_role_in_group(db, group_id=group_id, user_id=current_user.id) user_role = await crud_group.get_user_role_in_group(db, group_id=group_id, user_id=current_user.id)
# --- Permission Check (MVP: Owner only) ---
if user_role != UserRoleEnum.owner: if user_role != UserRoleEnum.owner:
logger.warning(f"Permission denied: User {current_user.email} (role: {user_role}) cannot create invite for group {group_id}") logger.warning(f"Permission denied: User {current_user.email} (role: {user_role}) cannot create invite for group {group_id}")
raise GroupPermissionError(group_id, "create invites") raise GroupPermissionError(group_id, "create invites")
# Check if group exists (implicitly done by role check, but good practice)
group = await crud_group.get_group_by_id(db, group_id) group = await crud_group.get_group_by_id(db, group_id)
if not group: if not group:
raise GroupNotFoundError(group_id) raise GroupNotFoundError(group_id)
@ -149,7 +141,6 @@ async def create_group_invite(
invite = await crud_invite.create_invite(db=db, group_id=group_id, creator_id=current_user.id) invite = await crud_invite.create_invite(db=db, group_id=group_id, creator_id=current_user.id)
if not invite: if not invite:
logger.error(f"Failed to generate unique invite code for group {group_id}") logger.error(f"Failed to generate unique invite code for group {group_id}")
# This case should ideally be covered by exceptions from create_invite now
raise InviteCreationError(group_id) raise InviteCreationError(group_id)
logger.info(f"User {current_user.email} created invite code for group {group_id}") logger.info(f"User {current_user.email} created invite code for group {group_id}")
@ -163,26 +154,20 @@ async def create_group_invite(
) )
async def get_group_active_invite( async def get_group_active_invite(
group_id: int, group_id: int,
db: AsyncSession = Depends(get_session), # Use read-only session for GET db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user), current_user: UserModel = Depends(current_active_user),
): ):
"""Retrieves the active invite code for the group. Requires group membership (owner/admin to be stricter later if needed).""" """Retrieves the active invite code for the group. Requires group membership (owner/admin to be stricter later if needed)."""
logger.info(f"User {current_user.email} attempting to get active invite for group {group_id}") logger.info(f"User {current_user.email} attempting to get active invite for group {group_id}")
# Permission check: Ensure user is a member of the group to view invite code
# Using get_user_role_in_group which also checks membership indirectly
user_role = await crud_group.get_user_role_in_group(db, group_id=group_id, user_id=current_user.id) user_role = await crud_group.get_user_role_in_group(db, group_id=group_id, user_id=current_user.id)
if user_role is None: # Not a member if user_role is None: # Not a member
logger.warning(f"Permission denied: User {current_user.email} is not a member of group {group_id} and cannot view invite code.") logger.warning(f"Permission denied: User {current_user.email} is not a member of group {group_id} and cannot view invite code.")
# More specific error or let GroupPermissionError handle if we want to be generic
raise GroupMembershipError(group_id, "view invite code for this group (not a member)") raise GroupMembershipError(group_id, "view invite code for this group (not a member)")
# Fetch the active invite for the group
invite = await crud_invite.get_active_invite_for_group(db, group_id=group_id) invite = await crud_invite.get_active_invite_for_group(db, group_id=group_id)
if not invite: if not invite:
# This case means no active (non-expired, active=true) invite exists.
# The frontend can then prompt to generate one.
logger.info(f"No active invite code found for group {group_id} when requested by {current_user.email}") logger.info(f"No active invite code found for group {group_id} when requested by {current_user.email}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
@ -190,7 +175,7 @@ async def get_group_active_invite(
) )
logger.info(f"User {current_user.email} retrieved active invite code for group {group_id}") logger.info(f"User {current_user.email} retrieved active invite code for group {group_id}")
return invite # Pydantic will convert InviteModel to InviteCodePublic return invite
@router.delete( @router.delete(
"/{group_id}/leave", "/{group_id}/leave",
@ -210,27 +195,22 @@ async def leave_group(
if user_role is None: if user_role is None:
raise GroupMembershipError(group_id, "leave (you are not a member)") raise GroupMembershipError(group_id, "leave (you are not a member)")
# Check if owner is the last member
if user_role == UserRoleEnum.owner: if user_role == UserRoleEnum.owner:
member_count = await crud_group.get_group_member_count(db, group_id) member_count = await crud_group.get_group_member_count(db, group_id)
if member_count <= 1: if member_count <= 1:
# Delete the group since owner is the last member
logger.info(f"Owner {current_user.email} is the last member. Deleting group {group_id}") logger.info(f"Owner {current_user.email} is the last member. Deleting group {group_id}")
await crud_group.delete_group(db, group_id) await crud_group.delete_group(db, group_id)
return Message(detail="Group deleted as you were the last member") return Message(detail="Group deleted as you were the last member")
# Proceed with removal for non-owner or if there are other members
deleted = await crud_group.remove_user_from_group(db, group_id=group_id, user_id=current_user.id) deleted = await crud_group.remove_user_from_group(db, group_id=group_id, user_id=current_user.id)
if not deleted: if not deleted:
# Should not happen if role check passed, but handle defensively
logger.error(f"Failed to remove user {current_user.email} from group {group_id} despite being a member.") logger.error(f"Failed to remove user {current_user.email} from group {group_id} despite being a member.")
raise GroupOperationError("Failed to leave group") raise GroupOperationError("Failed to leave group")
logger.info(f"User {current_user.email} successfully left group {group_id}") logger.info(f"User {current_user.email} successfully left group {group_id}")
return Message(detail="Successfully left the group") return Message(detail="Successfully left the group")
# --- Optional: Remove Member Endpoint ---
@router.delete( @router.delete(
"/{group_id}/members/{user_id_to_remove}", "/{group_id}/members/{user_id_to_remove}",
response_model=Message, response_model=Message,
@ -247,21 +227,17 @@ async def remove_group_member(
logger.info(f"Owner {current_user.email} attempting to remove user {user_id_to_remove} from group {group_id}") logger.info(f"Owner {current_user.email} attempting to remove user {user_id_to_remove} from group {group_id}")
owner_role = await crud_group.get_user_role_in_group(db, group_id=group_id, user_id=current_user.id) owner_role = await crud_group.get_user_role_in_group(db, group_id=group_id, user_id=current_user.id)
# --- Permission Check ---
if owner_role != UserRoleEnum.owner: if owner_role != UserRoleEnum.owner:
logger.warning(f"Permission denied: User {current_user.email} (role: {owner_role}) cannot remove members from group {group_id}") logger.warning(f"Permission denied: User {current_user.email} (role: {owner_role}) cannot remove members from group {group_id}")
raise GroupPermissionError(group_id, "remove members") raise GroupPermissionError(group_id, "remove members")
# Prevent owner removing themselves via this endpoint
if current_user.id == user_id_to_remove: if current_user.id == user_id_to_remove:
raise GroupValidationError("Owner cannot remove themselves using this endpoint. Use 'Leave Group' instead.") raise GroupValidationError("Owner cannot remove themselves using this endpoint. Use 'Leave Group' instead.")
# Check if target user is actually in the group
target_role = await crud_group.get_user_role_in_group(db, group_id=group_id, user_id=user_id_to_remove) target_role = await crud_group.get_user_role_in_group(db, group_id=group_id, user_id=user_id_to_remove)
if target_role is None: if target_role is None:
raise GroupMembershipError(group_id, "remove this user (they are not a member)") raise GroupMembershipError(group_id, "remove this user (they are not a member)")
# Proceed with removal
deleted = await crud_group.remove_user_from_group(db, group_id=group_id, user_id=user_id_to_remove) deleted = await crud_group.remove_user_from_group(db, group_id=group_id, user_id=user_id_to_remove)
if not deleted: if not deleted:
@ -279,19 +255,17 @@ async def remove_group_member(
) )
async def read_group_lists( async def read_group_lists(
group_id: int, group_id: int,
db: AsyncSession = Depends(get_session), # Use read-only session for GET db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user), current_user: UserModel = Depends(current_active_user),
): ):
"""Retrieves all lists belonging to a specific group, if the user is a member.""" """Retrieves all lists belonging to a specific group, if the user is a member."""
logger.info(f"User {current_user.email} requesting lists for group ID: {group_id}") logger.info(f"User {current_user.email} requesting lists for group ID: {group_id}")
# Check if user is a member first
is_member = await crud_group.is_user_member(db=db, group_id=group_id, user_id=current_user.id) is_member = await crud_group.is_user_member(db=db, group_id=group_id, user_id=current_user.id)
if not is_member: if not is_member:
logger.warning(f"Access denied: User {current_user.email} not member of group {group_id}") logger.warning(f"Access denied: User {current_user.email} not member of group {group_id}")
raise GroupMembershipError(group_id, "view group lists") raise GroupMembershipError(group_id, "view group lists")
# Get all lists for the user and filter by group_id
lists = await crud_list.get_lists_for_user(db=db, user_id=current_user.id) 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] group_lists = [list for list in lists if list.group_id == group_id]
@ -311,7 +285,6 @@ async def generate_group_chore_schedule(
): ):
"""Generates a round-robin chore schedule for a group.""" """Generates a round-robin chore schedule for a group."""
logger.info(f"User {current_user.email} generating chore schedule for group {group_id}") 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): if not await crud_group.is_user_member(db, group_id, current_user.id):
raise GroupMembershipError(group_id, "generate chore schedule for this group") raise GroupMembershipError(group_id, "generate chore schedule for this group")
@ -342,7 +315,6 @@ async def get_group_chore_history(
): ):
"""Retrieves all chore-related history for a specific group.""" """Retrieves all chore-related history for a specific group."""
logger.info(f"User {current_user.email} requesting chore history for group {group_id}") 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): if not await crud_group.is_user_member(db, group_id, current_user.id):
raise GroupMembershipError(group_id, "view chore history for this group") raise GroupMembershipError(group_id, "view chore history for this group")

View File

@ -1,4 +1,3 @@
# app/api/v1/endpoints/health.py
import logging import logging
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -7,7 +6,6 @@ from sqlalchemy.sql import text
from app.database import get_transactional_session from app.database import get_transactional_session
from app.schemas.health import HealthStatus from app.schemas.health import HealthStatus
from app.core.exceptions import DatabaseConnectionError from app.core.exceptions import DatabaseConnectionError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -22,17 +20,9 @@ async def check_health(db: AsyncSession = Depends(get_transactional_session)):
""" """
Health check endpoint. Verifies API reachability and database connection. Health check endpoint. Verifies API reachability and database connection.
""" """
try: result = await db.execute(text("SELECT 1"))
# Try executing a simple query to check DB connection if result.scalar_one() == 1:
result = await db.execute(text("SELECT 1")) logger.info("Health check successful: Database connection verified.")
if result.scalar_one() == 1: return HealthStatus(status="ok", database="connected")
logger.info("Health check successful: Database connection verified.") logger.error("Health check failed: Database connection check returned unexpected result.")
return HealthStatus(status="ok", database="connected") raise DatabaseConnectionError("Unexpected result from database connection check")
else:
# This case should ideally not happen with 'SELECT 1'
logger.error("Health check failed: Database connection check returned unexpected result.")
raise DatabaseConnectionError("Unexpected result from database connection check")
except Exception as e:
logger.error(f"Health check failed: Database connection error - {e}", exc_info=True)
raise DatabaseConnectionError(str(e))

View File

@ -1,21 +1,16 @@
# app/api/v1/endpoints/invites.py
import logging import logging
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_transactional_session from app.database import get_transactional_session
from app.auth import current_active_user from app.auth import current_active_user
from app.models import User as UserModel, UserRoleEnum from app.models import User as UserModel
from app.schemas.invite import InviteAccept from app.schemas.invite import InviteAccept
from app.schemas.message import Message
from app.schemas.group import GroupPublic from app.schemas.group import GroupPublic
from app.crud import invite as crud_invite from app.crud import invite as crud_invite
from app.crud import group as crud_group from app.crud import group as crud_group
from app.core.exceptions import ( from app.core.exceptions import (
InviteNotFoundError, InviteNotFoundError,
InviteExpiredError,
InviteAlreadyUsedError,
InviteCreationError,
GroupNotFoundError, GroupNotFoundError,
GroupMembershipError, GroupMembershipError,
GroupOperationError GroupOperationError
@ -25,7 +20,7 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@router.post( @router.post(
"/accept", # Route relative to prefix "/invites" "/accept",
response_model=GroupPublic, response_model=GroupPublic,
summary="Accept Group Invite", summary="Accept Group Invite",
tags=["Invites"] tags=["Invites"]
@ -37,42 +32,33 @@ async def accept_invite(
): ):
"""Accepts a group invite using the provided invite code.""" """Accepts a group invite using the provided invite code."""
logger.info(f"User {current_user.email} attempting to accept invite code: {invite_in.code}") logger.info(f"User {current_user.email} attempting to accept invite code: {invite_in.code}")
# Get the invite - this function should only return valid, active invites
invite = await crud_invite.get_active_invite_by_code(db, code=invite_in.code) invite = await crud_invite.get_active_invite_by_code(db, code=invite_in.code)
if not invite: if not invite:
logger.warning(f"Invalid or inactive invite code attempted by user {current_user.email}: {invite_in.code}") logger.warning(f"Invalid or inactive invite code attempted by user {current_user.email}: {invite_in.code}")
# We can use a more generic error or a specific one. InviteNotFound is reasonable.
raise InviteNotFoundError(invite_in.code) raise InviteNotFoundError(invite_in.code)
# Check if group still exists
group = await crud_group.get_group_by_id(db, group_id=invite.group_id) group = await crud_group.get_group_by_id(db, group_id=invite.group_id)
if not group: if not group:
logger.error(f"Group {invite.group_id} not found for invite {invite_in.code}") logger.error(f"Group {invite.group_id} not found for invite {invite_in.code}")
raise GroupNotFoundError(invite.group_id) raise GroupNotFoundError(invite.group_id)
# Check if user is already a member
is_member = await crud_group.is_user_member(db, group_id=invite.group_id, user_id=current_user.id) is_member = await crud_group.is_user_member(db, group_id=invite.group_id, user_id=current_user.id)
if is_member: if is_member:
logger.warning(f"User {current_user.email} already a member of group {invite.group_id}") logger.warning(f"User {current_user.email} already a member of group {invite.group_id}")
raise GroupMembershipError(invite.group_id, "join (already a member)") raise GroupMembershipError(invite.group_id, "join (already a member)")
# Add user to the group
added_to_group = await crud_group.add_user_to_group(db, group_id=invite.group_id, user_id=current_user.id) added_to_group = await crud_group.add_user_to_group(db, group_id=invite.group_id, user_id=current_user.id)
if not added_to_group: if not added_to_group:
logger.error(f"Failed to add user {current_user.email} to group {invite.group_id} during invite acceptance.") logger.error(f"Failed to add user {current_user.email} to group {invite.group_id} during invite acceptance.")
# This could be a race condition or other issue, treat as an operational error.
raise GroupOperationError("Failed to add user to group.") raise GroupOperationError("Failed to add user to group.")
# Deactivate the invite so it cannot be used again
await crud_invite.deactivate_invite(db, invite=invite) await crud_invite.deactivate_invite(db, invite=invite)
logger.info(f"User {current_user.email} successfully joined group {invite.group_id} via invite {invite_in.code}") logger.info(f"User {current_user.email} successfully joined group {invite.group_id} via invite {invite_in.code}")
# Re-fetch the group to get the updated member list
updated_group = await crud_group.get_group_by_id(db, group_id=invite.group_id) updated_group = await crud_group.get_group_by_id(db, group_id=invite.group_id)
if not updated_group: if not updated_group:
# This should ideally not happen as we found it before
logger.error(f"Could not re-fetch group {invite.group_id} after user {current_user.email} joined.") logger.error(f"Could not re-fetch group {invite.group_id} after user {current_user.email} joined.")
raise GroupNotFoundError(invite.group_id) raise GroupNotFoundError(invite.group_id)

View File

@ -1,4 +1,4 @@
# app/api/v1/endpoints/items.py
import logging import logging
from typing import List as PyList, Optional from typing import List as PyList, Optional
@ -6,21 +6,17 @@ from fastapi import APIRouter, Depends, HTTPException, status, Response, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_transactional_session from app.database import get_transactional_session
from app.auth import current_active_user
# --- Import Models Correctly ---
from app.models import User as UserModel from app.models import User as UserModel
from app.models import Item as ItemModel # <-- IMPORT Item and alias it from app.models import Item as ItemModel
# --- End Import Models ---
from app.schemas.item import ItemCreate, ItemUpdate, ItemPublic from app.schemas.item import ItemCreate, ItemUpdate, ItemPublic
from app.crud import item as crud_item from app.crud import item as crud_item
from app.crud import list as crud_list from app.crud import list as crud_list
from app.core.exceptions import ItemNotFoundError, ListPermissionError, ConflictError from app.core.exceptions import ItemNotFoundError, ListPermissionError, ConflictError
from app.auth import current_active_user
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
# --- Helper Dependency for Item Permissions ---
# Now ItemModel is defined before being used as a type hint
async def get_item_and_verify_access( async def get_item_and_verify_access(
item_id: int, item_id: int,
db: AsyncSession = Depends(get_transactional_session), db: AsyncSession = Depends(get_transactional_session),
@ -31,19 +27,15 @@ async def get_item_and_verify_access(
if not item_db: if not item_db:
raise ItemNotFoundError(item_id) raise ItemNotFoundError(item_id)
# Check permission on the parent list
try: try:
await crud_list.check_list_permission(db=db, list_id=item_db.list_id, user_id=current_user.id) await crud_list.check_list_permission(db=db, list_id=item_db.list_id, user_id=current_user.id)
except ListPermissionError as e: except ListPermissionError as e:
# Re-raise with a more specific message
raise ListPermissionError(item_db.list_id, "access this item's list") raise ListPermissionError(item_db.list_id, "access this item's list")
return item_db return item_db
# --- Endpoints ---
@router.post( @router.post(
"/lists/{list_id}/items", # Nested under lists "/lists/{list_id}/items",
response_model=ItemPublic, response_model=ItemPublic,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
summary="Add Item to List", summary="Add Item to List",
@ -56,13 +48,11 @@ async def create_list_item(
current_user: UserModel = Depends(current_active_user), current_user: UserModel = Depends(current_active_user),
): ):
"""Adds a new item to a specific list. User must have access to the list.""" """Adds a new item to a specific list. User must have access to the list."""
user_email = current_user.email # Access email attribute before async operations user_email = current_user.email
logger.info(f"User {user_email} adding item to list {list_id}: {item_in.name}") logger.info(f"User {user_email} adding item to list {list_id}: {item_in.name}")
# Verify user has access to the target list
try: try:
await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id) await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id)
except ListPermissionError as e: except ListPermissionError as e:
# Re-raise with a more specific message
raise ListPermissionError(list_id, "add items to this list") raise ListPermissionError(list_id, "add items to this list")
created_item = await crud_item.create_item( created_item = await crud_item.create_item(
@ -73,7 +63,7 @@ async def create_list_item(
@router.get( @router.get(
"/lists/{list_id}/items", # Nested under lists "/lists/{list_id}/items",
response_model=PyList[ItemPublic], response_model=PyList[ItemPublic],
summary="List Items in List", summary="List Items in List",
tags=["Items"] tags=["Items"]
@ -82,16 +72,13 @@ async def read_list_items(
list_id: int, list_id: int,
db: AsyncSession = Depends(get_transactional_session), db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user), current_user: UserModel = Depends(current_active_user),
# Add sorting/filtering params later if needed: sort_by: str = 'created_at', order: str = 'asc'
): ):
"""Retrieves all items for a specific list if the user has access.""" """Retrieves all items for a specific list if the user has access."""
user_email = current_user.email # Access email attribute before async operations user_email = current_user.email
logger.info(f"User {user_email} listing items for list {list_id}") logger.info(f"User {user_email} listing items for list {list_id}")
# Verify user has access to the list
try: try:
await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id) await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id)
except ListPermissionError as e: except ListPermissionError as e:
# Re-raise with a more specific message
raise ListPermissionError(list_id, "view items in this list") raise ListPermissionError(list_id, "view items in this list")
items = await crud_item.get_items_by_list_id(db=db, list_id=list_id) items = await crud_item.get_items_by_list_id(db=db, list_id=list_id)
@ -99,7 +86,7 @@ async def read_list_items(
@router.put( @router.put(
"/lists/{list_id}/items/{item_id}", # Nested under lists "/lists/{list_id}/items/{item_id}",
response_model=ItemPublic, response_model=ItemPublic,
summary="Update Item", summary="Update Item",
tags=["Items"], tags=["Items"],
@ -111,9 +98,9 @@ async def update_item(
list_id: int, list_id: int,
item_id: int, item_id: int,
item_in: ItemUpdate, item_in: ItemUpdate,
item_db: ItemModel = Depends(get_item_and_verify_access), # Use dependency to get item and check list access item_db: ItemModel = Depends(get_item_and_verify_access),
db: AsyncSession = Depends(get_transactional_session), db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user), # Need user ID for completed_by current_user: UserModel = Depends(current_active_user),
): ):
""" """
Updates an item's details (name, quantity, is_complete, price). Updates an item's details (name, quantity, is_complete, price).
@ -122,9 +109,8 @@ async def update_item(
If the version does not match, a 409 Conflict is returned. If the version does not match, a 409 Conflict is returned.
Sets/unsets `completed_by_id` based on `is_complete` flag. Sets/unsets `completed_by_id` based on `is_complete` flag.
""" """
user_email = current_user.email # Access email attribute before async operations user_email = current_user.email
logger.info(f"User {user_email} attempting to update item ID: {item_id} with version {item_in.version}") logger.info(f"User {user_email} attempting to update item ID: {item_id} with version {item_in.version}")
# Permission check is handled by get_item_and_verify_access dependency
try: try:
updated_item = await crud_item.update_item( updated_item = await crud_item.update_item(
@ -141,7 +127,7 @@ async def update_item(
@router.delete( @router.delete(
"/lists/{list_id}/items/{item_id}", # Nested under lists "/lists/{list_id}/items/{item_id}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
summary="Delete Item", summary="Delete Item",
tags=["Items"], tags=["Items"],
@ -153,18 +139,16 @@ async def delete_item(
list_id: int, list_id: int,
item_id: int, item_id: int,
expected_version: Optional[int] = Query(None, description="The expected version of the item to delete for optimistic locking."), expected_version: Optional[int] = Query(None, description="The expected version of the item to delete for optimistic locking."),
item_db: ItemModel = Depends(get_item_and_verify_access), # Use dependency to get item and check list access item_db: ItemModel = Depends(get_item_and_verify_access),
db: AsyncSession = Depends(get_transactional_session), db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user), # Log who deleted it current_user: UserModel = Depends(current_active_user),
): ):
""" """
Deletes an item. User must have access to the list the item belongs to. Deletes an item. User must have access to the list the item belongs to.
If `expected_version` is provided and does not match the item's current version, If `expected_version` is provided and does not match the item's current version,
a 409 Conflict is returned. a 409 Conflict is returned.
""" """
user_email = current_user.email # Access email attribute before async operations user_email = current_user.email
logger.info(f"User {user_email} attempting to delete item ID: {item_id}, expected version: {expected_version}")
# Permission check is handled by get_item_and_verify_access dependency
if expected_version is not None and item_db.version != expected_version: if expected_version is not None and item_db.version != expected_version:
logger.warning( logger.warning(

View File

@ -1,34 +1,27 @@
# app/api/v1/endpoints/lists.py
import logging import logging
from typing import List as PyList, Optional # Alias for Python List type hint from typing import List as PyList, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Response, Query
from fastapi import APIRouter, Depends, HTTPException, status, Response, Query # Added Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_transactional_session from app.database import get_transactional_session
from app.auth import current_active_user from app.auth import current_active_user
from app.models import User as UserModel from app.models import User as UserModel
from app.schemas.list import ListCreate, ListUpdate, ListPublic, ListDetail from app.schemas.list import ListCreate, ListUpdate, ListPublic, ListDetail
from app.schemas.message import Message # For simple responses
from app.crud import list as crud_list from app.crud import list as crud_list
from app.crud import group as crud_group # Need for group membership check from app.crud import group as crud_group
from app.schemas.list import ListStatus, ListStatusWithId from app.schemas.list import ListStatus, ListStatusWithId
from app.schemas.expense import ExpensePublic # Import ExpensePublic from app.schemas.expense import ExpensePublic
from app.core.exceptions import ( from app.core.exceptions import (
GroupMembershipError, GroupMembershipError,
ListNotFoundError, ConflictError,
ListPermissionError, DatabaseIntegrityError
ListStatusNotFoundError,
ConflictError, # Added ConflictError
DatabaseIntegrityError # Added DatabaseIntegrityError
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@router.post( @router.post(
"", # Route relative to prefix "/lists" "",
response_model=ListPublic, # Return basic list info on creation response_model=ListPublic,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
summary="Create New List", summary="Create New List",
tags=["Lists"], tags=["Lists"],
@ -53,7 +46,6 @@ async def create_list(
logger.info(f"User {current_user.email} creating list: {list_in.name}") logger.info(f"User {current_user.email} creating list: {list_in.name}")
group_id = list_in.group_id group_id = list_in.group_id
# Permission Check: If sharing with a group, verify membership
if group_id: if group_id:
is_member = await crud_group.is_user_member(db, group_id=group_id, user_id=current_user.id) is_member = await crud_group.is_user_member(db, group_id=group_id, user_id=current_user.id)
if not is_member: if not is_member:
@ -65,9 +57,7 @@ async def create_list(
logger.info(f"List '{created_list.name}' (ID: {created_list.id}) created successfully for user {current_user.email}.") logger.info(f"List '{created_list.name}' (ID: {created_list.id}) created successfully for user {current_user.email}.")
return created_list return created_list
except DatabaseIntegrityError as e: except DatabaseIntegrityError as e:
# Check if this is a unique constraint violation
if "unique constraint" in str(e).lower(): if "unique constraint" in str(e).lower():
# Find the existing list with the same name in the group
existing_list = await crud_list.get_list_by_name_and_group( existing_list = await crud_list.get_list_by_name_and_group(
db=db, db=db,
name=list_in.name, name=list_in.name,
@ -81,20 +71,18 @@ async def create_list(
detail=f"A list named '{list_in.name}' already exists in this group.", detail=f"A list named '{list_in.name}' already exists in this group.",
headers={"X-Existing-List": str(existing_list.id)} headers={"X-Existing-List": str(existing_list.id)}
) )
# If it's not a unique constraint or we couldn't find the existing list, re-raise
raise raise
@router.get( @router.get(
"", # Route relative to prefix "/lists" "",
response_model=PyList[ListDetail], # Return a list of detailed list info including items response_model=PyList[ListDetail],
summary="List Accessible Lists", summary="List Accessible Lists",
tags=["Lists"] tags=["Lists"]
) )
async def read_lists( async def read_lists(
db: AsyncSession = Depends(get_transactional_session), db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user), current_user: UserModel = Depends(current_active_user),
# Add pagination parameters later if needed: skip: int = 0, limit: int = 100
): ):
""" """
Retrieves lists accessible to the current user: Retrieves lists accessible to the current user:
@ -128,7 +116,6 @@ async def read_lists_statuses(
statuses = await crud_list.get_lists_statuses_by_ids(db=db, list_ids=ids, user_id=current_user.id) statuses = await crud_list.get_lists_statuses_by_ids(db=db, list_ids=ids, user_id=current_user.id)
# The CRUD function returns a list of Row objects, so we map them to the Pydantic model
return [ return [
ListStatusWithId( ListStatusWithId(
id=s.id, id=s.id,
@ -141,7 +128,7 @@ async def read_lists_statuses(
@router.get( @router.get(
"/{list_id}", "/{list_id}",
response_model=ListDetail, # Return detailed list info including items response_model=ListDetail,
summary="Get List Details", summary="Get List Details",
tags=["Lists"] tags=["Lists"]
) )
@ -155,17 +142,16 @@ async def read_list(
if the user has permission (creator or group member). if the user has permission (creator or group member).
""" """
logger.info(f"User {current_user.email} requesting details for list ID: {list_id}") logger.info(f"User {current_user.email} requesting details for list ID: {list_id}")
# The check_list_permission function will raise appropriate exceptions
list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id) list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id)
return list_db return list_db
@router.put( @router.put(
"/{list_id}", "/{list_id}",
response_model=ListPublic, # Return updated basic info response_model=ListPublic,
summary="Update List", summary="Update List",
tags=["Lists"], tags=["Lists"],
responses={ # Add 409 to responses responses={
status.HTTP_409_CONFLICT: {"description": "Conflict: List has been modified by someone else"} status.HTTP_409_CONFLICT: {"description": "Conflict: List has been modified by someone else"}
} }
) )
@ -188,22 +174,20 @@ async def update_list(
updated_list = await crud_list.update_list(db=db, list_db=list_db, list_in=list_in) updated_list = await crud_list.update_list(db=db, list_db=list_db, list_in=list_in)
logger.info(f"List {list_id} updated successfully by user {current_user.email} to version {updated_list.version}.") logger.info(f"List {list_id} updated successfully by user {current_user.email} to version {updated_list.version}.")
return updated_list return updated_list
except ConflictError as e: # Catch and re-raise as HTTPException for proper FastAPI response except ConflictError as e:
logger.warning(f"Conflict updating list {list_id} for user {current_user.email}: {str(e)}") logger.warning(f"Conflict updating list {list_id} for user {current_user.email}: {str(e)}")
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
except Exception as e: # Catch other potential errors from crud operation except Exception as e:
logger.error(f"Error updating list {list_id} for user {current_user.email}: {str(e)}") logger.error(f"Error updating list {list_id} for user {current_user.email}: {str(e)}")
# Consider a more generic error, but for now, let's keep it specific if possible
# Re-raising might be better if crud layer already raises appropriate HTTPExceptions
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred while updating the list.") raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred while updating the list.")
@router.delete( @router.delete(
"/{list_id}", "/{list_id}",
status_code=status.HTTP_204_NO_CONTENT, # Standard for successful DELETE with no body status_code=status.HTTP_204_NO_CONTENT,
summary="Delete List", summary="Delete List",
tags=["Lists"], tags=["Lists"],
responses={ # Add 409 to responses responses={
status.HTTP_409_CONFLICT: {"description": "Conflict: List has been modified, cannot delete specified version"} status.HTTP_409_CONFLICT: {"description": "Conflict: List has been modified, cannot delete specified version"}
} }
) )
@ -219,7 +203,6 @@ async def delete_list(
a 409 Conflict is returned. a 409 Conflict is returned.
""" """
logger.info(f"User {current_user.email} attempting to delete list ID: {list_id}, expected version: {expected_version}") logger.info(f"User {current_user.email} attempting to delete list ID: {list_id}, expected version: {expected_version}")
# Use the helper, requiring creator permission
list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id, require_creator=True) list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id, require_creator=True)
if expected_version is not None and list_db.version != expected_version: if expected_version is not None and list_db.version != expected_version:
@ -253,7 +236,6 @@ async def read_list_status(
if the user has permission (creator or group member). if the user has permission (creator or group member).
""" """
logger.info(f"User {current_user.email} requesting status for list ID: {list_id}") logger.info(f"User {current_user.email} requesting status for list ID: {list_id}")
# The check_list_permission is not needed here as get_list_status handles not found
await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id) await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id)
return await crud_list.get_list_status(db=db, list_id=list_id) return await crud_list.get_list_status(db=db, list_id=list_id)
@ -278,9 +260,7 @@ async def read_list_expenses(
logger.info(f"User {current_user.email} requesting expenses for list ID: {list_id}") logger.info(f"User {current_user.email} requesting expenses for list ID: {list_id}")
# Check if user has permission to access this list
await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id) await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id)
# Get expenses for this list
expenses = await crud_expense.get_expenses_for_list(db, list_id=list_id, skip=skip, limit=limit) expenses = await crud_expense.get_expenses_for_list(db, list_id=list_id, skip=skip, limit=limit)
return expenses return expenses

View File

@ -1,17 +1,12 @@
import logging import logging
from typing import List from fastapi import APIRouter, Depends, UploadFile, File
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, status
from google.api_core import exceptions as google_exceptions
from app.auth import current_active_user from app.auth import current_active_user
from app.models import User as UserModel from app.models import User as UserModel
from app.schemas.ocr import OcrExtractResponse from app.schemas.ocr import OcrExtractResponse
from app.core.gemini import GeminiOCRService, gemini_initialization_error from app.core.gemini import GeminiOCRService, gemini_initialization_error
from app.core.exceptions import ( from app.core.exceptions import (
OCRServiceUnavailableError, OCRServiceUnavailableError,
OCRServiceConfigError, OCRServiceConfigError,
OCRUnexpectedError,
OCRQuotaExceededError, OCRQuotaExceededError,
InvalidFileTypeError, InvalidFileTypeError,
FileTooLargeError, FileTooLargeError,
@ -37,26 +32,22 @@ async def ocr_extract_items(
Accepts an image upload, sends it to Gemini Flash with a prompt Accepts an image upload, sends it to Gemini Flash with a prompt
to extract shopping list items, and returns the parsed items. to extract shopping list items, and returns the parsed items.
""" """
# Check if Gemini client initialized correctly
if gemini_initialization_error: if gemini_initialization_error:
logger.error("OCR endpoint called but Gemini client failed to initialize.") logger.error("OCR endpoint called but Gemini client failed to initialize.")
raise OCRServiceUnavailableError(gemini_initialization_error) raise OCRServiceUnavailableError(gemini_initialization_error)
logger.info(f"User {current_user.email} uploading image '{image_file.filename}' for OCR extraction.") logger.info(f"User {current_user.email} uploading image '{image_file.filename}' for OCR extraction.")
# --- File Validation ---
if image_file.content_type not in settings.ALLOWED_IMAGE_TYPES: if image_file.content_type not in settings.ALLOWED_IMAGE_TYPES:
logger.warning(f"Invalid file type uploaded by {current_user.email}: {image_file.content_type}") logger.warning(f"Invalid file type uploaded by {current_user.email}: {image_file.content_type}")
raise InvalidFileTypeError() raise InvalidFileTypeError()
# Simple size check
contents = await image_file.read() contents = await image_file.read()
if len(contents) > settings.MAX_FILE_SIZE_MB * 1024 * 1024: if len(contents) > settings.MAX_FILE_SIZE_MB * 1024 * 1024:
logger.warning(f"File too large uploaded by {current_user.email}: {len(contents)} bytes") logger.warning(f"File too large uploaded by {current_user.email}: {len(contents)} bytes")
raise FileTooLargeError() raise FileTooLargeError()
try: try:
# Use the ocr_service instance instead of the standalone function
extracted_items = await ocr_service.extract_items(image_data=contents) extracted_items = await ocr_service.extract_items(image_data=contents)
logger.info(f"Successfully extracted {len(extracted_items)} items for user {current_user.email}.") logger.info(f"Successfully extracted {len(extracted_items)} items for user {current_user.email}.")
@ -72,5 +63,4 @@ async def ocr_extract_items(
raise OCRProcessingError(str(e)) raise OCRProcessingError(str(e))
finally: finally:
# Ensure file handle is closed
await image_file.close() await image_file.close()

View File

@ -21,11 +21,9 @@ from .database import get_session
from .models import User from .models import User
from .config import settings from .config import settings
# OAuth2 configuration
config = Config('.env') config = Config('.env')
oauth = OAuth(config) oauth = OAuth(config)
# Google OAuth2 setup
oauth.register( oauth.register(
name='google', name='google',
server_metadata_url='https://accounts.google.com/.well-known/openid-configuration', server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
@ -35,7 +33,6 @@ oauth.register(
} }
) )
# Apple OAuth2 setup
oauth.register( oauth.register(
name='apple', name='apple',
server_metadata_url='https://appleid.apple.com/.well-known/openid-configuration', server_metadata_url='https://appleid.apple.com/.well-known/openid-configuration',
@ -45,13 +42,11 @@ oauth.register(
} }
) )
# Custom Bearer Response with Refresh Token
class BearerResponseWithRefresh(BaseModel): class BearerResponseWithRefresh(BaseModel):
access_token: str access_token: str
refresh_token: str refresh_token: str
token_type: str = "bearer" token_type: str = "bearer"
# Custom Bearer Transport that supports refresh tokens
class BearerTransportWithRefresh(BearerTransport): class BearerTransportWithRefresh(BearerTransport):
async def get_login_response(self, token: str, refresh_token: str = None) -> Response: async def get_login_response(self, token: str, refresh_token: str = None) -> Response:
if refresh_token: if refresh_token:
@ -61,14 +56,12 @@ class BearerTransportWithRefresh(BearerTransport):
token_type="bearer" token_type="bearer"
) )
else: else:
# Fallback to standard response if no refresh token
bearer_response = { bearer_response = {
"access_token": token, "access_token": token,
"token_type": "bearer" "token_type": "bearer"
} }
return JSONResponse(bearer_response.dict() if hasattr(bearer_response, 'dict') else bearer_response) return JSONResponse(bearer_response.dict() if hasattr(bearer_response, 'dict') else bearer_response)
# Custom Authentication Backend with Refresh Token Support
class AuthenticationBackendWithRefresh(AuthenticationBackend): class AuthenticationBackendWithRefresh(AuthenticationBackend):
def __init__( def __init__(
self, self,
@ -83,7 +76,6 @@ class AuthenticationBackendWithRefresh(AuthenticationBackend):
self.get_refresh_strategy = get_refresh_strategy self.get_refresh_strategy = get_refresh_strategy
async def login(self, strategy, user) -> Response: async def login(self, strategy, user) -> Response:
# Generate both access and refresh tokens
access_token = await strategy.write_token(user) access_token = await strategy.write_token(user)
refresh_strategy = self.get_refresh_strategy() refresh_strategy = self.get_refresh_strategy()
refresh_token = await refresh_strategy.write_token(user) refresh_token = await refresh_strategy.write_token(user)
@ -124,17 +116,14 @@ async def get_user_db(session: AsyncSession = Depends(get_session)):
async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)): async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)):
yield UserManager(user_db) yield UserManager(user_db)
# Updated transport with refresh token support
bearer_transport = BearerTransportWithRefresh(tokenUrl="auth/jwt/login") bearer_transport = BearerTransportWithRefresh(tokenUrl="auth/jwt/login")
def get_jwt_strategy() -> JWTStrategy: def get_jwt_strategy() -> JWTStrategy:
return JWTStrategy(secret=settings.SECRET_KEY, lifetime_seconds=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60) return JWTStrategy(secret=settings.SECRET_KEY, lifetime_seconds=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60)
def get_refresh_jwt_strategy() -> JWTStrategy: def get_refresh_jwt_strategy() -> JWTStrategy:
# Refresh tokens last longer - 7 days
return JWTStrategy(secret=settings.SECRET_KEY, lifetime_seconds=7 * 24 * 60 * 60) return JWTStrategy(secret=settings.SECRET_KEY, lifetime_seconds=7 * 24 * 60 * 60)
# Updated auth backend with refresh token support
auth_backend = AuthenticationBackendWithRefresh( auth_backend = AuthenticationBackendWithRefresh(
name="jwt", name="jwt",
transport=bearer_transport, transport=bearer_transport,

View File

@ -1,28 +1,20 @@
from typing import Dict, Any
from app.config import settings from app.config import settings
# API Version
API_VERSION = "v1" API_VERSION = "v1"
# API Prefix
API_PREFIX = f"/api/{API_VERSION}" API_PREFIX = f"/api/{API_VERSION}"
# API Endpoints
class APIEndpoints: class APIEndpoints:
# Auth
AUTH = { AUTH = {
"LOGIN": "/auth/login", "LOGIN": "/auth/login",
"SIGNUP": "/auth/signup", "SIGNUP": "/auth/signup",
"REFRESH_TOKEN": "/auth/refresh-token", "REFRESH_TOKEN": "/auth/refresh-token",
} }
# Users
USERS = { USERS = {
"PROFILE": "/users/profile", "PROFILE": "/users/profile",
"UPDATE_PROFILE": "/users/profile", "UPDATE_PROFILE": "/users/profile",
} }
# Lists
LISTS = { LISTS = {
"BASE": "/lists", "BASE": "/lists",
"BY_ID": "/lists/{id}", "BY_ID": "/lists/{id}",
@ -30,7 +22,6 @@ class APIEndpoints:
"ITEM": "/lists/{list_id}/items/{item_id}", "ITEM": "/lists/{list_id}/items/{item_id}",
} }
# Groups
GROUPS = { GROUPS = {
"BASE": "/groups", "BASE": "/groups",
"BY_ID": "/groups/{id}", "BY_ID": "/groups/{id}",
@ -38,7 +29,6 @@ class APIEndpoints:
"MEMBERS": "/groups/{group_id}/members", "MEMBERS": "/groups/{group_id}/members",
} }
# Invites
INVITES = { INVITES = {
"BASE": "/invites", "BASE": "/invites",
"BY_ID": "/invites/{id}", "BY_ID": "/invites/{id}",
@ -46,12 +36,10 @@ class APIEndpoints:
"DECLINE": "/invites/{id}/decline", "DECLINE": "/invites/{id}/decline",
} }
# OCR
OCR = { OCR = {
"PROCESS": "/ocr/process", "PROCESS": "/ocr/process",
} }
# Financials
FINANCIALS = { FINANCIALS = {
"EXPENSES": "/financials/expenses", "EXPENSES": "/financials/expenses",
"EXPENSE": "/financials/expenses/{id}", "EXPENSE": "/financials/expenses/{id}",
@ -59,12 +47,10 @@ class APIEndpoints:
"SETTLEMENT": "/financials/settlements/{id}", "SETTLEMENT": "/financials/settlements/{id}",
} }
# Health
HEALTH = { HEALTH = {
"CHECK": "/health", "CHECK": "/health",
} }
# API Metadata
API_METADATA = { API_METADATA = {
"title": settings.API_TITLE, "title": settings.API_TITLE,
"description": settings.API_DESCRIPTION, "description": settings.API_DESCRIPTION,
@ -74,7 +60,6 @@ API_METADATA = {
"redoc_url": settings.API_REDOC_URL, "redoc_url": settings.API_REDOC_URL,
} }
# API Tags
API_TAGS = [ API_TAGS = [
{"name": "Authentication", "description": "Authentication and authorization endpoints"}, {"name": "Authentication", "description": "Authentication and authorization endpoints"},
{"name": "Users", "description": "User management endpoints"}, {"name": "Users", "description": "User management endpoints"},
@ -86,7 +71,7 @@ API_TAGS = [
{"name": "Health", "description": "Health check endpoints"}, {"name": "Health", "description": "Health check endpoints"},
] ]
# Helper function to get full API URL
def get_api_url(endpoint: str, **kwargs) -> str: def get_api_url(endpoint: str, **kwargs) -> str:
""" """
Get the full API URL for an endpoint. Get the full API URL for an endpoint.

View File

@ -48,7 +48,6 @@ def calculate_next_due_date(
today = date.today() today = date.today()
reference_future_date = max(today, base_date) reference_future_date = max(today, base_date)
# This loop ensures the next_due date is always in the future relative to the reference_future_date.
while next_due <= reference_future_date: while next_due <= reference_future_date:
current_base_for_recalc = next_due current_base_for_recalc = next_due
@ -70,9 +69,7 @@ def calculate_next_due_date(
else: # Should not be reached else: # Should not be reached
break break
# Safety break: if date hasn't changed, interval is zero or logic error.
if next_due == current_base_for_recalc: if next_due == current_base_for_recalc:
# Log error ideally, then advance by one day to prevent infinite loop.
next_due += timedelta(days=1) next_due += timedelta(days=1)
break break

View File

@ -362,4 +362,3 @@ class PermissionDeniedError(HTTPException):
detail=detail detail=detail
) )
# Financials & Cost Splitting specific errors

View File

@ -1,8 +1,6 @@
# app/core/gemini.py
import logging import logging
from typing import List from typing import List
import google.generativeai as genai import google.generativeai as genai
from google.generativeai.types import HarmCategory, HarmBlockThreshold # For safety settings
from google.api_core import exceptions as google_exceptions from google.api_core import exceptions as google_exceptions
from app.config import settings from app.config import settings
from app.core.exceptions import ( from app.core.exceptions import (
@ -15,15 +13,12 @@ from app.core.exceptions import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# --- Global variable to hold the initialized model client ---
gemini_flash_client = None gemini_flash_client = None
gemini_initialization_error = None # Store potential init error gemini_initialization_error = None
# --- Configure and Initialize ---
try: try:
if settings.GEMINI_API_KEY: if settings.GEMINI_API_KEY:
genai.configure(api_key=settings.GEMINI_API_KEY) genai.configure(api_key=settings.GEMINI_API_KEY)
# Initialize the specific model we want to use
gemini_flash_client = genai.GenerativeModel( gemini_flash_client = genai.GenerativeModel(
model_name=settings.GEMINI_MODEL_NAME, model_name=settings.GEMINI_MODEL_NAME,
generation_config=genai.types.GenerationConfig( generation_config=genai.types.GenerationConfig(
@ -32,18 +27,15 @@ try:
) )
logger.info(f"Gemini AI client initialized successfully for model '{settings.GEMINI_MODEL_NAME}'.") logger.info(f"Gemini AI client initialized successfully for model '{settings.GEMINI_MODEL_NAME}'.")
else: else:
# Store error if API key is missing
gemini_initialization_error = "GEMINI_API_KEY not configured. Gemini client not initialized." gemini_initialization_error = "GEMINI_API_KEY not configured. Gemini client not initialized."
logger.error(gemini_initialization_error) logger.error(gemini_initialization_error)
except Exception as e: except Exception as e:
# Catch any other unexpected errors during initialization
gemini_initialization_error = f"Failed to initialize Gemini AI client: {e}" gemini_initialization_error = f"Failed to initialize Gemini AI client: {e}"
logger.exception(gemini_initialization_error) # Log full traceback logger.exception(gemini_initialization_error)
gemini_flash_client = None # Ensure client is None on error gemini_flash_client = None
# --- Function to get the client (optional, allows checking error) ---
def get_gemini_client(): def get_gemini_client():
""" """
Returns the initialized Gemini client instance. Returns the initialized Gemini client instance.
@ -52,23 +44,172 @@ def get_gemini_client():
if gemini_initialization_error: if gemini_initialization_error:
raise OCRServiceConfigError() raise OCRServiceConfigError()
if gemini_flash_client is None: if gemini_flash_client is None:
# This case should ideally be covered by the check above, but as a safeguard:
raise OCRServiceConfigError() raise OCRServiceConfigError()
return gemini_flash_client return gemini_flash_client
# Define the prompt as a constant
OCR_ITEM_EXTRACTION_PROMPT = """ OCR_ITEM_EXTRACTION_PROMPT = """
Extract the shopping list items from this image. **ROLE & GOAL**
List each distinct item on a new line.
Ignore prices, quantities, store names, discounts, taxes, totals, and other non-item text. You are an expert AI assistant specializing in Optical Character Recognition (OCR) and structured data extraction. Your primary function is to act as a "Shopping List Digitizer."
Focus only on the names of the products or items to be purchased.
If the image does not appear to be a shopping list or receipt, state that clearly. Your goal is to meticulously analyze the provided image of a shopping list, which is likely handwritten, and convert it into a structured, machine-readable JSON format. You must be accurate, infer context where necessary, and handle the inherent ambiguities of handwriting and informal list-making.
Example output for a grocery list:
Milk **INPUT**
Eggs
Bread You will receive a single image (`[Image]`). This image contains a shopping list. It may be:
Apples * Neatly written or very messy.
Organic Bananas * On lined paper, a whiteboard, a napkin, or a dedicated notepad.
* Containing doodles, stains, or other visual noise.
* Using various formats (bullet points, numbered lists, columns, simple line breaks).
* could be in English or in German.
**CORE TASK: STEP-BY-STEP ANALYSIS**
Follow these steps precisely:
1. **Initial Image Analysis & OCR:**
* Perform an advanced OCR scan on the entire image to transcribe all visible text.
* Pay close attention to the spatial layout. Identify headings, columns, and line items. Note which text elements appear to be grouped together.
2. **Item Identification & Filtering:**
* Differentiate between actual list items and non-item elements.
* **INCLUDE:** Items intended for purchase.
* **EXCLUDE:** List titles (e.g., "GROCERIES," "Target List"), dates, doodles, unrelated notes, or stray marks. Capture the list title separately if one exists.
3. **Detailed Extraction for Each Item:**
For every single item you identify, extract the following attributes. If an attribute is not present, use `null`.
* `item_name` (string): The primary name of the product.
* **Standardize:** Normalize the name. (e.g., "B. Powder" -> "Baking Powder", "A. Juice" -> "Apple Juice").
* **Contextual Guessing:** If a word is poorly written, use the context of a shopping list to make an educated guess. (e.g., "Ciffee" is almost certainly "Coffee").
* `quantity` (number or string): The amount needed.
* If a number is present (e.g., "**2** milks"), extract the number `2`.
* If it's a word (e.g., "**a dozen** eggs"), extract the string `"a dozen"`.
* If no quantity is specified (e.g., "Bread"), infer a default quantity of `1`.
* `unit` (string): The unit of measurement or packaging.
* Examples: "kg", "lbs", "liters", "gallons", "box", "can", "bag", "bunch".
* Infer where possible (e.g., for "2 Milks," the unit could be inferred as "cartons" or "gallons" depending on regional context, but it's safer to leave it `null` if not explicitly stated).
* `notes` (string): Any additional descriptive text.
* Examples: "low-sodium," "organic," "brand name (Tide)," "for the cake," "get the ripe ones."
* `category` (string): Infer a logical category for the item.
* Use common grocery store categories: `Produce`, `Dairy & Eggs`, `Meat & Seafood`, `Pantry`, `Frozen`, `Bakery`, `Beverages`, `Household`, `Personal Care`.
* If the list itself has category headings (e.g., a "DAIRY" section), use those first.
* `original_text` (string): Provide the exact, unaltered text that your OCR transcribed for this entire line item. This is crucial for verification.
* `is_crossed_out` (boolean): Set to `true` if the item is struck through, crossed out, or clearly marked as completed. Otherwise, set to `false`.
**HANDLING AMBIGUITIES AND EDGE CASES**
* **Illegible Text:** If a line or word is completely unreadable, set `item_name` to `"UNREADABLE"` and place the garbled OCR attempt in the `original_text` field.
* **Abbreviations:** Expand common shopping list abbreviations (e.g., "OJ" -> "Orange Juice", "TP" -> "Toilet Paper", "AVOs" -> "Avocados", "G. Beef" -> "Ground Beef").
* **Implicit Items:** If a line is vague like "Snacks for kids," list it as is. Do not invent specific items.
* **Multi-item Lines:** If a line contains multiple items (e.g., "Onions, Garlic, Ginger"), split them into separate item objects.
**OUTPUT FORMAT**
Your final output MUST be a single JSON object with the following structure. Do not include any explanatory text before or after the JSON block.
```json
{
"list_title": "string or null",
"items": [
{
"item_name": "string",
"quantity": "number or string",
"unit": "string or null",
"category": "string",
"notes": "string or null",
"original_text": "string",
"is_crossed_out": "boolean"
}
],
"summary": {
"total_items": "integer",
"unread_items": "integer",
"crossed_out_items": "integer"
}
}
```
**EXAMPLE WALKTHROUGH**
* **IF THE IMAGE SHOWS:** A crumpled sticky note with the title "Stuff for tonight" and the items:
* `2x Chicken Breasts`
* `~~Baguette~~` (this item is crossed out)
* `Salad mix (bag)`
* `Tomatos` (misspelled)
* `Choc Ice Cream`
* **YOUR JSON OUTPUT SHOULD BE:**
```json
{
"list_title": "Stuff for tonight",
"items": [
{
"item_name": "Chicken Breasts",
"quantity": 2,
"unit": null,
"category": "Meat & Seafood",
"notes": null,
"original_text": "2x Chicken Breasts",
"is_crossed_out": false
},
{
"item_name": "Baguette",
"quantity": 1,
"unit": null,
"category": "Bakery",
"notes": null,
"original_text": "Baguette",
"is_crossed_out": true
},
{
"item_name": "Salad Mix",
"quantity": 1,
"unit": "bag",
"category": "Produce",
"notes": null,
"original_text": "Salad mix (bag)",
"is_crossed_out": false
},
{
"item_name": "Tomatoes",
"quantity": 1,
"unit": null,
"category": "Produce",
"notes": null,
"original_text": "Tomatos",
"is_crossed_out": false
},
{
"item_name": "Chocolate Ice Cream",
"quantity": 1,
"unit": null,
"category": "Frozen",
"notes": null,
"original_text": "Choc Ice Cream",
"is_crossed_out": false
}
],
"summary": {
"total_items": 5,
"unread_items": 0,
"crossed_out_items": 1
}
}
```
**FINAL INSTRUCTION**
If the image provided is not a shopping list or is completely blank/unintelligible, respond with a JSON object where the `items` array is empty and add a note in the `list_title` field, such as "Image does not appear to be a shopping list."
Now, analyze the provided image and generate the JSON output.
""" """
async def extract_items_from_image_gemini(image_bytes: bytes, mime_type: str = "image/jpeg") -> List[str]: async def extract_items_from_image_gemini(image_bytes: bytes, mime_type: str = "image/jpeg") -> List[str]:
@ -92,29 +233,22 @@ async def extract_items_from_image_gemini(image_bytes: bytes, mime_type: str = "
try: try:
client = get_gemini_client() # Raises OCRServiceConfigError if not initialized client = get_gemini_client() # Raises OCRServiceConfigError if not initialized
# Prepare image part for multimodal input
image_part = { image_part = {
"mime_type": mime_type, "mime_type": mime_type,
"data": image_bytes "data": image_bytes
} }
# Prepare the full prompt content
prompt_parts = [ prompt_parts = [
settings.OCR_ITEM_EXTRACTION_PROMPT, # Text prompt first settings.OCR_ITEM_EXTRACTION_PROMPT,
image_part # Then the image image_part
] ]
logger.info("Sending image to Gemini for item extraction...") logger.info("Sending image to Gemini for item extraction...")
# Make the API call
# Use generate_content_async for async FastAPI
response = await client.generate_content_async(prompt_parts) response = await client.generate_content_async(prompt_parts)
# --- Process the response ---
# Check for safety blocks or lack of content
if not response.candidates or not response.candidates[0].content.parts: if not response.candidates or not response.candidates[0].content.parts:
logger.warning("Gemini response blocked or empty.", extra={"response": response}) logger.warning("Gemini response blocked or empty.", extra={"response": response})
# Check finish_reason if available
finish_reason = response.candidates[0].finish_reason if response.candidates else 'UNKNOWN' finish_reason = response.candidates[0].finish_reason if response.candidates else 'UNKNOWN'
safety_ratings = response.candidates[0].safety_ratings if response.candidates else 'N/A' safety_ratings = response.candidates[0].safety_ratings if response.candidates else 'N/A'
if finish_reason == 'SAFETY': if finish_reason == 'SAFETY':
@ -122,18 +256,13 @@ async def extract_items_from_image_gemini(image_bytes: bytes, mime_type: str = "
else: else:
raise OCRUnexpectedError() raise OCRUnexpectedError()
# Extract text - assumes the first part of the first candidate is the text response raw_text = response.text
raw_text = response.text # response.text is a shortcut for response.candidates[0].content.parts[0].text
logger.info("Received raw text from Gemini.") logger.info("Received raw text from Gemini.")
# logger.debug(f"Gemini Raw Text:\n{raw_text}") # Optional: Log full response text
# Parse the text response
items = [] items = []
for line in raw_text.splitlines(): # Split by newline for line in raw_text.splitlines():
cleaned_line = line.strip() # Remove leading/trailing whitespace cleaned_line = line.strip()
# Basic filtering: ignore empty lines and potential non-item lines if cleaned_line and len(cleaned_line) > 1:
if cleaned_line and len(cleaned_line) > 1: # Ignore very short lines too?
# Add more sophisticated filtering if needed (e.g., regex, keyword check)
items.append(cleaned_line) items.append(cleaned_line)
logger.info(f"Extracted {len(items)} potential items.") logger.info(f"Extracted {len(items)} potential items.")
@ -145,12 +274,9 @@ async def extract_items_from_image_gemini(image_bytes: bytes, mime_type: str = "
raise OCRQuotaExceededError() raise OCRQuotaExceededError()
raise OCRServiceUnavailableError() raise OCRServiceUnavailableError()
except (OCRServiceConfigError, OCRQuotaExceededError, OCRServiceUnavailableError, OCRProcessingError, OCRUnexpectedError): except (OCRServiceConfigError, OCRQuotaExceededError, OCRServiceUnavailableError, OCRProcessingError, OCRUnexpectedError):
# Re-raise specific OCR exceptions
raise raise
except Exception as e: except Exception as e:
# Catch other unexpected errors during generation or processing
logger.error(f"Unexpected error during Gemini item extraction: {e}", exc_info=True) logger.error(f"Unexpected error during Gemini item extraction: {e}", exc_info=True)
# Wrap in a custom exception
raise OCRUnexpectedError() raise OCRUnexpectedError()
class GeminiOCRService: class GeminiOCRService:
@ -186,27 +312,22 @@ class GeminiOCRService:
OCRUnexpectedError: For any other unexpected errors. OCRUnexpectedError: For any other unexpected errors.
""" """
try: try:
# Create image part
image_parts = [{"mime_type": mime_type, "data": image_data}] image_parts = [{"mime_type": mime_type, "data": image_data}]
# Generate content
response = await self.model.generate_content_async( response = await self.model.generate_content_async(
contents=[settings.OCR_ITEM_EXTRACTION_PROMPT, *image_parts] contents=[settings.OCR_ITEM_EXTRACTION_PROMPT, *image_parts]
) )
# Process response
if not response.text: if not response.text:
logger.warning("Gemini response is empty") logger.warning("Gemini response is empty")
raise OCRUnexpectedError() raise OCRUnexpectedError()
# Check for safety blocks
if hasattr(response, 'candidates') and response.candidates and hasattr(response.candidates[0], 'finish_reason'): if hasattr(response, 'candidates') and response.candidates and hasattr(response.candidates[0], 'finish_reason'):
finish_reason = response.candidates[0].finish_reason finish_reason = response.candidates[0].finish_reason
if finish_reason == 'SAFETY': if finish_reason == 'SAFETY':
safety_ratings = response.candidates[0].safety_ratings if hasattr(response.candidates[0], 'safety_ratings') else 'N/A' safety_ratings = response.candidates[0].safety_ratings if hasattr(response.candidates[0], 'safety_ratings') else 'N/A'
raise OCRProcessingError(f"Gemini response blocked due to safety settings. Ratings: {safety_ratings}") raise OCRProcessingError(f"Gemini response blocked due to safety settings. Ratings: {safety_ratings}")
# Split response into lines and clean up
items = [] items = []
for line in response.text.splitlines(): for line in response.text.splitlines():
cleaned_line = line.strip() cleaned_line = line.strip()
@ -222,7 +343,6 @@ class GeminiOCRService:
raise OCRQuotaExceededError() raise OCRQuotaExceededError()
raise OCRServiceUnavailableError() raise OCRServiceUnavailableError()
except (OCRServiceConfigError, OCRQuotaExceededError, OCRServiceUnavailableError, OCRProcessingError, OCRUnexpectedError): except (OCRServiceConfigError, OCRQuotaExceededError, OCRServiceUnavailableError, OCRProcessingError, OCRUnexpectedError):
# Re-raise specific OCR exceptions
raise raise
except Exception as e: except Exception as e:
logger.error(f"Unexpected error during Gemini item extraction: {e}", exc_info=True) logger.error(f"Unexpected error during Gemini item extraction: {e}", exc_info=True)

View File

@ -2,7 +2,6 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
from apscheduler.executors.pool import ThreadPoolExecutor from apscheduler.executors.pool import ThreadPoolExecutor
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from app.config import settings from app.config import settings
from app.jobs.recurring_expenses import generate_recurring_expenses from app.jobs.recurring_expenses import generate_recurring_expenses
from app.db.session import async_session from app.db.session import async_session
@ -10,11 +9,8 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Convert async database URL to sync URL for APScheduler
# Replace postgresql+asyncpg:// with postgresql://
sync_db_url = settings.DATABASE_URL.replace('postgresql+asyncpg://', 'postgresql://') sync_db_url = settings.DATABASE_URL.replace('postgresql+asyncpg://', 'postgresql://')
# Configure the scheduler
jobstores = { jobstores = {
'default': SQLAlchemyJobStore(url=sync_db_url) 'default': SQLAlchemyJobStore(url=sync_db_url)
} }
@ -36,7 +32,10 @@ scheduler = AsyncIOScheduler(
) )
async def run_recurring_expenses_job(): async def run_recurring_expenses_job():
"""Wrapper function to run the recurring expenses job with a database session.""" """Wrapper function to run the recurring expenses job with a database session.
This function is used to generate recurring expenses for the user.
"""
try: try:
async with async_session() as session: async with async_session() as session:
await generate_recurring_expenses(session) await generate_recurring_expenses(session)
@ -47,7 +46,6 @@ async def run_recurring_expenses_job():
def init_scheduler(): def init_scheduler():
"""Initialize and start the scheduler.""" """Initialize and start the scheduler."""
try: try:
# Add the recurring expenses job
scheduler.add_job( scheduler.add_job(
run_recurring_expenses_job, run_recurring_expenses_job,
trigger=CronTrigger(hour=0, minute=0), # Run at midnight UTC trigger=CronTrigger(hour=0, minute=0), # Run at midnight UTC
@ -56,7 +54,6 @@ def init_scheduler():
replace_existing=True replace_existing=True
) )
# Start the scheduler
scheduler.start() scheduler.start()
logger.info("Scheduler started successfully") logger.info("Scheduler started successfully")
except Exception as e: except Exception as e:

View File

@ -1,20 +1,5 @@
# app/core/security.py
from datetime import datetime, timedelta, timezone
from typing import Any, Union, Optional
from jose import JWTError, jwt
from passlib.context import CryptContext from passlib.context import CryptContext
from app.config import settings # Import settings from config
# --- Password Hashing ---
# These functions are used for password hashing and verification
# They complement FastAPI-Users but provide direct access to the underlying password functionality
# when needed outside of the FastAPI-Users authentication flow.
# Configure passlib context
# Using bcrypt as the default hashing scheme
# 'deprecated="auto"' will automatically upgrade hashes if needed on verification
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool: def verify_password(plain_password: str, hashed_password: str) -> bool:
@ -33,7 +18,6 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
try: try:
return pwd_context.verify(plain_password, hashed_password) return pwd_context.verify(plain_password, hashed_password)
except Exception: except Exception:
# Handle potential errors during verification (e.g., invalid hash format)
return False return False
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
@ -48,26 +32,4 @@ def hash_password(password: str) -> str:
Returns: Returns:
The resulting hash string. The resulting hash string.
""" """
return pwd_context.hash(password) return pwd_context.hash(password)
# --- JSON Web Tokens (JWT) ---
# FastAPI-Users now handles all JWT token creation and validation.
# The code below is commented out because FastAPI-Users provides these features.
# It's kept for reference in case a custom implementation is needed later.
# Example of a potential future implementation:
# def get_subject_from_token(token: str) -> Optional[str]:
# """
# Extract the subject (user ID) from a JWT token.
# This would be used if we need to validate tokens outside of FastAPI-Users flow.
# For now, use fastapi_users.current_user dependency instead.
# """
# # This would need to use FastAPI-Users' token verification if ever implemented
# # For example, by decoding the token using the strategy from the auth backend
# try:
# payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
# return payload.get("sub")
# except JWTError:
# return None
# return None

View File

@ -18,16 +18,14 @@ logger = logging.getLogger(__name__)
async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]: async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]:
"""Gets all chores (personal and group) for a user in optimized queries.""" """Gets all chores (personal and group) for a user in optimized queries."""
# Get personal chores query
personal_chores_query = ( personal_chores_query = (
select(Chore) select(Chore)
.where( .where(
Chore.created_by_id == user_id, Chore.created_by_id == user_id,
Chore.type == ChoreTypeEnum.personal Chore.type == ChoreTypeEnum.personal
) )
) )
# Get user's group IDs first
user_groups_result = await db.execute( user_groups_result = await db.execute(
select(UserGroup.group_id).where(UserGroup.user_id == user_id) select(UserGroup.group_id).where(UserGroup.user_id == user_id)
) )
@ -35,7 +33,6 @@ async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]:
all_chores = [] all_chores = []
# Execute personal chores query
personal_result = await db.execute( personal_result = await db.execute(
personal_chores_query personal_chores_query
.options( .options(
@ -48,7 +45,6 @@ async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]:
) )
all_chores.extend(personal_result.scalars().all()) all_chores.extend(personal_result.scalars().all())
# If user has groups, get all group chores in one query
if user_group_ids: if user_group_ids:
group_chores_result = await db.execute( group_chores_result = await db.execute(
select(Chore) select(Chore)
@ -76,12 +72,10 @@ async def create_chore(
group_id: Optional[int] = None group_id: Optional[int] = None
) -> Chore: ) -> Chore:
"""Creates a new chore, either personal or within a specific group.""" """Creates a new chore, either personal or within a specific group."""
# Use the transaction pattern from the FastAPI strategy
async with db.begin_nested() if db.in_transaction() else db.begin(): async with db.begin_nested() if db.in_transaction() else db.begin():
if chore_in.type == ChoreTypeEnum.group: if chore_in.type == ChoreTypeEnum.group:
if not group_id: if not group_id:
raise ValueError("group_id is required for group chores") raise ValueError("group_id is required for group chores")
# Validate group existence and user membership
group = await get_group_by_id(db, group_id) group = await get_group_by_id(db, group_id)
if not group: if not group:
raise GroupNotFoundError(group_id) raise GroupNotFoundError(group_id)
@ -97,14 +91,12 @@ async def create_chore(
created_by_id=user_id, created_by_id=user_id,
) )
# Specific check for custom frequency
if chore_in.frequency == ChoreFrequencyEnum.custom and chore_in.custom_interval_days is None: if chore_in.frequency == ChoreFrequencyEnum.custom and chore_in.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.")
db.add(db_chore) db.add(db_chore)
await db.flush() # Get the ID for the chore await db.flush()
# Log history
await create_chore_history_entry( await create_chore_history_entry(
db, db,
chore_id=db_chore.id, chore_id=db_chore.id,
@ -115,7 +107,6 @@ async def create_chore(
) )
try: try:
# Load relationships for the response with eager loading
result = await db.execute( result = await db.execute(
select(Chore) select(Chore)
.where(Chore.id == db_chore.id) .where(Chore.id == db_chore.id)
@ -221,10 +212,8 @@ 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)} 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 db_chore.type == ChoreTypeEnum.group:
if not group_id: if not group_id:
raise ValueError("group_id is required for group chores") raise ValueError("group_id is required for group chores")
@ -232,7 +221,7 @@ async def update_chore(
raise PermissionDeniedError(detail=f"User {user_id} not a member of group {group_id}") raise PermissionDeniedError(detail=f"User {user_id} not a member of group {group_id}")
if db_chore.group_id != group_id: if db_chore.group_id != group_id:
raise ChoreNotFoundError(chore_id, group_id) raise ChoreNotFoundError(chore_id, group_id)
else: # personal chore else:
if group_id: if group_id:
raise ValueError("group_id must be None for personal chores") raise ValueError("group_id must be None for personal chores")
if db_chore.created_by_id != user_id: if db_chore.created_by_id != user_id:
@ -240,7 +229,6 @@ async def update_chore(
update_data = chore_in.model_dump(exclude_unset=True) update_data = chore_in.model_dump(exclude_unset=True)
# Handle type change
if 'type' in update_data: if 'type' in update_data:
new_type = update_data['type'] new_type = update_data['type']
if new_type == ChoreTypeEnum.group and not group_id: if new_type == ChoreTypeEnum.group and not group_id:
@ -275,7 +263,6 @@ 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 = {} changes = {}
for field, old_value in original_data.items(): for field, old_value in original_data.items():
new_value = getattr(db_chore, field) new_value = getattr(db_chore, field)
@ -293,7 +280,7 @@ async def update_chore(
) )
try: try:
await db.flush() # Flush changes within the transaction await db.flush()
result = await db.execute( result = await db.execute(
select(Chore) select(Chore)
.where(Chore.id == db_chore.id) .where(Chore.id == db_chore.id)
@ -322,7 +309,6 @@ 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( await create_chore_history_entry(
db, db,
chore_id=chore_id, chore_id=chore_id,
@ -332,7 +318,6 @@ async def delete_chore(
event_data={"chore_name": db_chore.name} event_data={"chore_name": db_chore.name}
) )
# Check permissions
if db_chore.type == ChoreTypeEnum.group: if db_chore.type == ChoreTypeEnum.group:
if not group_id: if not group_id:
raise ValueError("group_id is required for group chores") raise ValueError("group_id is required for group chores")
@ -348,7 +333,7 @@ async def delete_chore(
try: try:
await db.delete(db_chore) await db.delete(db_chore)
await db.flush() # Ensure deletion is processed within the transaction await db.flush()
return True return True
except Exception as e: except Exception as e:
logger.error(f"Error deleting chore {chore_id}: {e}", exc_info=True) logger.error(f"Error deleting chore {chore_id}: {e}", exc_info=True)
@ -363,27 +348,23 @@ async def create_chore_assignment(
) -> ChoreAssignment: ) -> ChoreAssignment:
"""Creates a new chore assignment. User must be able to manage the chore.""" """Creates a new chore assignment. User must be able to manage the chore."""
async with db.begin_nested() if db.in_transaction() else db.begin(): async with db.begin_nested() if db.in_transaction() else db.begin():
# Get the chore and validate permissions
chore = await get_chore_by_id(db, assignment_in.chore_id) chore = await get_chore_by_id(db, assignment_in.chore_id)
if not chore: if not chore:
raise ChoreNotFoundError(chore_id=assignment_in.chore_id) raise ChoreNotFoundError(chore_id=assignment_in.chore_id)
# Check permissions to assign this chore
if chore.type == ChoreTypeEnum.personal: if chore.type == ChoreTypeEnum.personal:
if chore.created_by_id != user_id: if chore.created_by_id != user_id:
raise PermissionDeniedError(detail="Only the creator can assign personal chores") raise PermissionDeniedError(detail="Only the creator can assign personal chores")
else: # group chore else: # group chore
if not await is_user_member(db, chore.group_id, user_id): if not await is_user_member(db, chore.group_id, user_id):
raise PermissionDeniedError(detail=f"User {user_id} not a member of group {chore.group_id}") raise PermissionDeniedError(detail=f"User {user_id} not a member of group {chore.group_id}")
# For group chores, check if assignee is also a group member
if not await is_user_member(db, chore.group_id, assignment_in.assigned_to_user_id): if not await is_user_member(db, chore.group_id, assignment_in.assigned_to_user_id):
raise PermissionDeniedError(detail=f"Cannot assign chore to user {assignment_in.assigned_to_user_id} who is not a group member") raise PermissionDeniedError(detail=f"Cannot assign chore to user {assignment_in.assigned_to_user_id} who is not a group member")
db_assignment = ChoreAssignment(**assignment_in.model_dump(exclude_unset=True)) db_assignment = ChoreAssignment(**assignment_in.model_dump(exclude_unset=True))
db.add(db_assignment) db.add(db_assignment)
await db.flush() # Get the ID for the assignment await db.flush()
# Log history
await create_assignment_history_entry( await create_assignment_history_entry(
db, db,
assignment_id=db_assignment.id, assignment_id=db_assignment.id,
@ -393,7 +374,6 @@ async def create_chore_assignment(
) )
try: try:
# 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)
@ -450,12 +430,11 @@ async def get_chore_assignments(
chore = await get_chore_by_id(db, chore_id) chore = await get_chore_by_id(db, chore_id)
if not chore: if not chore:
raise ChoreNotFoundError(chore_id=chore_id) raise ChoreNotFoundError(chore_id=chore_id)
# Check permissions
if chore.type == ChoreTypeEnum.personal: if chore.type == ChoreTypeEnum.personal:
if chore.created_by_id != user_id: if chore.created_by_id != user_id:
raise PermissionDeniedError(detail="Can only view assignments for own personal chores") raise PermissionDeniedError(detail="Can only view assignments for own personal chores")
else: # group chore else:
if not await is_user_member(db, chore.group_id, user_id): if not await is_user_member(db, chore.group_id, user_id):
raise PermissionDeniedError(detail=f"User {user_id} not a member of group {chore.group_id}") raise PermissionDeniedError(detail=f"User {user_id} not a member of group {chore.group_id}")
@ -487,11 +466,10 @@ async def update_chore_assignment(
if not chore: if not chore:
raise ChoreNotFoundError(chore_id=db_assignment.chore_id) raise ChoreNotFoundError(chore_id=db_assignment.chore_id)
# Check permissions - only assignee can complete, but chore managers can reschedule
can_manage = False can_manage = False
if chore.type == ChoreTypeEnum.personal: if chore.type == ChoreTypeEnum.personal:
can_manage = chore.created_by_id == user_id can_manage = chore.created_by_id == user_id
else: # group chore else:
can_manage = await is_user_member(db, chore.group_id, user_id) can_manage = await is_user_member(db, chore.group_id, user_id)
can_complete = db_assignment.assigned_to_user_id == user_id can_complete = db_assignment.assigned_to_user_id == user_id
@ -501,7 +479,6 @@ async def update_chore_assignment(
original_assignee = db_assignment.assigned_to_user_id original_assignee = db_assignment.assigned_to_user_id
original_due_date = db_assignment.due_date original_due_date = db_assignment.due_date
# 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")
@ -515,7 +492,6 @@ async def update_chore_assignment(
raise PermissionDeniedError(detail="Only chore managers can reassign assignments") 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']}) 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: if 'is_complete' in update_data:
if update_data['is_complete'] and not db_assignment.is_complete: if update_data['is_complete'] and not db_assignment.is_complete:
update_data['completed_at'] = datetime.utcnow() update_data['completed_at'] = datetime.utcnow()
@ -531,13 +507,11 @@ async def update_chore_assignment(
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) 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(): for field, value in update_data.items():
setattr(db_assignment, field, value) setattr(db_assignment, field, value)
try: try:
await db.flush() await db.flush()
# 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)
@ -563,7 +537,6 @@ 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( await create_assignment_history_entry(
db, db,
assignment_id=assignment_id, assignment_id=assignment_id,
@ -572,22 +545,20 @@ async def delete_chore_assignment(
event_data={"unassigned_user_id": db_assignment.assigned_to_user_id} 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) 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)
# Check permissions
if chore.type == ChoreTypeEnum.personal: if chore.type == ChoreTypeEnum.personal:
if chore.created_by_id != user_id: if chore.created_by_id != user_id:
raise PermissionDeniedError(detail="Only the creator can delete personal chore assignments") raise PermissionDeniedError(detail="Only the creator can delete personal chore assignments")
else: # group chore else:
if not await is_user_member(db, chore.group_id, user_id): if not await is_user_member(db, chore.group_id, user_id):
raise PermissionDeniedError(detail=f"User {user_id} not a member of group {chore.group_id}") raise PermissionDeniedError(detail=f"User {user_id} not a member of group {chore.group_id}")
try: try:
await db.delete(db_assignment) await db.delete(db_assignment)
await db.flush() # Ensure deletion is processed within the transaction await db.flush()
return True return True
except Exception as e: except Exception as e:
logger.error(f"Error deleting chore assignment {assignment_id}: {e}", exc_info=True) logger.error(f"Error deleting chore assignment {assignment_id}: {e}", exc_info=True)

View File

@ -1,15 +1,14 @@
# app/crud/group.py
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from sqlalchemy.orm import selectinload # For eager loading members from sqlalchemy.orm import selectinload
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
from typing import Optional, List from typing import Optional, List
from sqlalchemy import delete, func from sqlalchemy import delete, func
import logging # Add logging import import logging
from app.models import User as UserModel, Group as GroupModel, UserGroup as UserGroupModel from app.models import User as UserModel, Group as GroupModel, UserGroup as UserGroupModel
from app.schemas.group import GroupCreate from app.schemas.group import GroupCreate
from app.models import UserRoleEnum # Import enum from app.models import UserRoleEnum
from app.core.exceptions import ( from app.core.exceptions import (
GroupOperationError, GroupOperationError,
GroupNotFoundError, GroupNotFoundError,

View File

@ -1,4 +1,3 @@
# be/app/crud/history.py
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@ -76,7 +75,7 @@ async def get_group_chore_history(db: AsyncSession, group_id: int) -> List[Chore
.where(ChoreHistory.group_id == group_id) .where(ChoreHistory.group_id == group_id)
.options( .options(
selectinload(ChoreHistory.changed_by_user), selectinload(ChoreHistory.changed_by_user),
selectinload(ChoreHistory.chore) # Also load chore info if available selectinload(ChoreHistory.chore)
) )
.order_by(ChoreHistory.timestamp.desc()) .order_by(ChoreHistory.timestamp.desc())
) )

View File

@ -1,26 +1,24 @@
# app/crud/invite.py import logging
import logging # Add logging import
import secrets import secrets
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from sqlalchemy.orm import selectinload # Ensure selectinload is imported from sqlalchemy.orm import selectinload
from sqlalchemy import delete # Import delete statement from sqlalchemy import delete
from sqlalchemy.exc import SQLAlchemyError, OperationalError, IntegrityError from sqlalchemy.exc import SQLAlchemyError, OperationalError, IntegrityError
from typing import Optional from typing import Optional
from app.models import Invite as InviteModel, Group as GroupModel, User as UserModel # Import related models for selectinload from app.models import Invite as InviteModel, Group as GroupModel, User as UserModel
from app.core.exceptions import ( from app.core.exceptions import (
DatabaseConnectionError, DatabaseConnectionError,
DatabaseIntegrityError, DatabaseIntegrityError,
DatabaseQueryError, DatabaseQueryError,
DatabaseTransactionError, DatabaseTransactionError,
InviteOperationError # Add new specific exception InviteOperationError
) )
logger = logging.getLogger(__name__) # Initialize logger logger = logging.getLogger(__name__)
# Invite codes should be reasonably unique, but handle potential collision
MAX_CODE_GENERATION_ATTEMPTS = 5 MAX_CODE_GENERATION_ATTEMPTS = 5
async def deactivate_all_active_invites_for_group(db: AsyncSession, group_id: int): async def deactivate_all_active_invites_for_group(db: AsyncSession, group_id: int):
@ -35,15 +33,13 @@ async def deactivate_all_active_invites_for_group(db: AsyncSession, group_id: in
active_invites = result.scalars().all() active_invites = result.scalars().all()
if not active_invites: if not active_invites:
return # No active invites to deactivate return
for invite in active_invites: for invite in active_invites:
invite.is_active = False invite.is_active = False
db.add(invite) db.add(invite)
await db.flush() # Flush changes within this transaction block await db.flush()
# await db.flush() # Removed: Rely on caller to flush/commit
# No explicit commit here, assuming it's part of a larger transaction or caller handles commit.
except OperationalError as e: except OperationalError as e:
logger.error(f"Database connection error deactivating invites for group {group_id}: {str(e)}", exc_info=True) logger.error(f"Database connection error deactivating invites for group {group_id}: {str(e)}", exc_info=True)
raise DatabaseConnectionError(f"DB connection error deactivating invites for group {group_id}: {str(e)}") raise DatabaseConnectionError(f"DB connection error deactivating invites for group {group_id}: {str(e)}")
@ -51,12 +47,11 @@ async def deactivate_all_active_invites_for_group(db: AsyncSession, group_id: in
logger.error(f"Unexpected SQLAlchemy error deactivating invites for group {group_id}: {str(e)}", exc_info=True) logger.error(f"Unexpected SQLAlchemy error deactivating invites for group {group_id}: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"DB transaction error deactivating invites for group {group_id}: {str(e)}") raise DatabaseTransactionError(f"DB transaction error deactivating invites for group {group_id}: {str(e)}")
async def create_invite(db: AsyncSession, group_id: int, creator_id: int, expires_in_days: int = 365 * 100) -> Optional[InviteModel]: # Default to 100 years async def create_invite(db: AsyncSession, group_id: int, creator_id: int, expires_in_days: int = 365 * 100) -> Optional[InviteModel]:
"""Creates a new invite code for a group, deactivating any existing active ones for that group first.""" """Creates a new invite code for a group, deactivating any existing active ones for that group first."""
try: try:
async with db.begin_nested() if db.in_transaction() else db.begin(): async with db.begin_nested() if db.in_transaction() else db.begin():
# Deactivate existing active invites for this group
await deactivate_all_active_invites_for_group(db, group_id) await deactivate_all_active_invites_for_group(db, group_id)
expires_at = datetime.now(timezone.utc) + timedelta(days=expires_in_days) expires_at = datetime.now(timezone.utc) + timedelta(days=expires_in_days)
@ -101,7 +96,7 @@ async def create_invite(db: AsyncSession, group_id: int, creator_id: int, expire
raise InviteOperationError("Failed to load invite after creation and flush.") raise InviteOperationError("Failed to load invite after creation and flush.")
return loaded_invite return loaded_invite
except InviteOperationError: # Already specific, re-raise except InviteOperationError:
raise raise
except IntegrityError as e: except IntegrityError as e:
logger.error(f"Database integrity error during invite creation for group {group_id}: {str(e)}", exc_info=True) logger.error(f"Database integrity error during invite creation for group {group_id}: {str(e)}", exc_info=True)
@ -121,13 +116,12 @@ async def get_active_invite_for_group(db: AsyncSession, group_id: int) -> Option
select(InviteModel).where( select(InviteModel).where(
InviteModel.group_id == group_id, InviteModel.group_id == group_id,
InviteModel.is_active == True, InviteModel.is_active == True,
InviteModel.expires_at > now # Still respect expiry, even if very long InviteModel.expires_at > now
) )
.order_by(InviteModel.created_at.desc()) # Get the most recent one if multiple (should not happen)
.limit(1) .limit(1)
.options( .options(
selectinload(InviteModel.group), # Eager load group selectinload(InviteModel.group),
selectinload(InviteModel.creator) # Eager load creator selectinload(InviteModel.creator)
) )
) )
result = await db.execute(stmt) result = await db.execute(stmt)
@ -166,10 +160,9 @@ async def deactivate_invite(db: AsyncSession, invite: InviteModel) -> InviteMode
try: try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction: async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
invite.is_active = False invite.is_active = False
db.add(invite) # Add to session to track change db.add(invite)
await db.flush() # Persist is_active change await db.flush()
# Re-fetch with relationships
stmt = ( stmt = (
select(InviteModel) select(InviteModel)
.where(InviteModel.id == invite.id) .where(InviteModel.id == invite.id)
@ -181,7 +174,7 @@ async def deactivate_invite(db: AsyncSession, invite: InviteModel) -> InviteMode
result = await db.execute(stmt) result = await db.execute(stmt)
updated_invite = result.scalar_one_or_none() updated_invite = result.scalar_one_or_none()
if updated_invite is None: # Should not happen as invite is passed in if updated_invite is None:
raise InviteOperationError("Failed to load invite after deactivation.") raise InviteOperationError("Failed to load invite after deactivation.")
return updated_invite return updated_invite
@ -192,8 +185,3 @@ async def deactivate_invite(db: AsyncSession, invite: InviteModel) -> InviteMode
logger.error(f"Unexpected SQLAlchemy error deactivating invite: {str(e)}", exc_info=True) logger.error(f"Unexpected SQLAlchemy error deactivating invite: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"DB transaction error deactivating invite: {str(e)}") raise DatabaseTransactionError(f"DB transaction error deactivating invite: {str(e)}")
# Ensure InviteOperationError is defined in app.core.exceptions
# Example: class InviteOperationError(AppException): pass
# Optional: Function to periodically delete old, inactive invites
# async def cleanup_old_invites(db: AsyncSession, older_than_days: int = 30): ...

View File

@ -1,15 +1,14 @@
# app/crud/item.py
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from sqlalchemy.orm import selectinload # Ensure selectinload is imported from sqlalchemy.orm import selectinload
from sqlalchemy import delete as sql_delete, update as sql_update # Use aliases from sqlalchemy import delete as sql_delete, update as sql_update
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
from typing import Optional, List as PyList from typing import Optional, List as PyList
from datetime import datetime, timezone from datetime import datetime, timezone
import logging # Add logging import import logging
from sqlalchemy import func from sqlalchemy import func
from app.models import Item as ItemModel, User as UserModel # Import UserModel for type hints if needed for selectinload from app.models import Item as ItemModel, User as UserModel
from app.schemas.item import ItemCreate, ItemUpdate from app.schemas.item import ItemCreate, ItemUpdate
from app.core.exceptions import ( from app.core.exceptions import (
ItemNotFoundError, ItemNotFoundError,
@ -18,16 +17,15 @@ from app.core.exceptions import (
DatabaseQueryError, DatabaseQueryError,
DatabaseTransactionError, DatabaseTransactionError,
ConflictError, ConflictError,
ItemOperationError # Add if specific item operation errors are needed ItemOperationError
) )
logger = logging.getLogger(__name__) # Initialize logger logger = logging.getLogger(__name__)
async def create_item(db: AsyncSession, item_in: ItemCreate, list_id: int, user_id: int) -> ItemModel: async def create_item(db: AsyncSession, item_in: ItemCreate, list_id: int, user_id: int) -> ItemModel:
"""Creates a new item record for a specific list, setting its position.""" """Creates a new item record for a specific list, setting its position."""
try: try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction: async with db.begin_nested() if db.in_transaction() else db.begin() as transaction: # Start transaction
# Get the current max position in the list
max_pos_stmt = select(func.max(ItemModel.position)).where(ItemModel.list_id == list_id) max_pos_stmt = select(func.max(ItemModel.position)).where(ItemModel.list_id == list_id)
max_pos_result = await db.execute(max_pos_stmt) max_pos_result = await db.execute(max_pos_stmt)
max_pos = max_pos_result.scalar_one_or_none() or 0 max_pos = max_pos_result.scalar_one_or_none() or 0
@ -38,26 +36,24 @@ async def create_item(db: AsyncSession, item_in: ItemCreate, list_id: int, user_
list_id=list_id, list_id=list_id,
added_by_id=user_id, added_by_id=user_id,
is_complete=False, is_complete=False,
position=max_pos + 1 # Set the new position position=max_pos + 1
) )
db.add(db_item) db.add(db_item)
await db.flush() # Assigns ID await db.flush()
# Re-fetch with relationships
stmt = ( stmt = (
select(ItemModel) select(ItemModel)
.where(ItemModel.id == db_item.id) .where(ItemModel.id == db_item.id)
.options( .options(
selectinload(ItemModel.added_by_user), selectinload(ItemModel.added_by_user),
selectinload(ItemModel.completed_by_user) # Will be None but loads relationship selectinload(ItemModel.completed_by_user)
) )
) )
result = await db.execute(stmt) result = await db.execute(stmt)
loaded_item = result.scalar_one_or_none() loaded_item = result.scalar_one_or_none()
if loaded_item is None: if loaded_item is None:
# await transaction.rollback() # Redundant, context manager handles rollback on exception raise ItemOperationError("Failed to load item after creation.")
raise ItemOperationError("Failed to load item after creation.") # Define ItemOperationError
return loaded_item return loaded_item
except IntegrityError as e: except IntegrityError as e:
@ -69,8 +65,6 @@ async def create_item(db: AsyncSession, item_in: ItemCreate, list_id: int, user_
except SQLAlchemyError as e: except SQLAlchemyError as e:
logger.error(f"Unexpected SQLAlchemy error during item creation: {str(e)}", exc_info=True) logger.error(f"Unexpected SQLAlchemy error during item creation: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Failed to create item: {str(e)}") raise DatabaseTransactionError(f"Failed to create item: {str(e)}")
# Removed generic Exception block as SQLAlchemyError should cover DB issues,
# and context manager handles rollback.
async def get_items_by_list_id(db: AsyncSession, list_id: int) -> PyList[ItemModel]: async def get_items_by_list_id(db: AsyncSession, list_id: int) -> PyList[ItemModel]:
"""Gets all items belonging to a specific list, ordered by creation time.""" """Gets all items belonging to a specific list, ordered by creation time."""
@ -100,7 +94,7 @@ async def get_item_by_id(db: AsyncSession, item_id: int) -> Optional[ItemModel]:
.options( .options(
selectinload(ItemModel.added_by_user), selectinload(ItemModel.added_by_user),
selectinload(ItemModel.completed_by_user), selectinload(ItemModel.completed_by_user),
selectinload(ItemModel.list) # Often useful to get the parent list selectinload(ItemModel.list)
) )
) )
result = await db.execute(stmt) result = await db.execute(stmt)
@ -113,7 +107,7 @@ async def get_item_by_id(db: AsyncSession, item_id: int) -> Optional[ItemModel]:
async def update_item(db: AsyncSession, item_db: ItemModel, item_in: ItemUpdate, user_id: int) -> ItemModel: async def update_item(db: AsyncSession, item_db: ItemModel, item_in: ItemUpdate, user_id: int) -> ItemModel:
"""Updates an existing item record, checking for version conflicts and handling reordering.""" """Updates an existing item record, checking for version conflicts and handling reordering."""
try: try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction: async with db.begin_nested() if db.in_transaction() else db.begin() as transaction: # Start transaction
if item_db.version != item_in.version: if item_db.version != item_in.version:
raise ConflictError( raise ConflictError(
f"Item '{item_db.name}' (ID: {item_db.id}) has been modified. " f"Item '{item_db.name}' (ID: {item_db.id}) has been modified. "
@ -122,31 +116,23 @@ async def update_item(db: AsyncSession, item_db: ItemModel, item_in: ItemUpdate,
update_data = item_in.model_dump(exclude_unset=True, exclude={'version'}) update_data = item_in.model_dump(exclude_unset=True, exclude={'version'})
# --- Handle Reordering ---
if 'position' in update_data: if 'position' in update_data:
new_position = update_data.pop('position') # Remove from update_data to handle separately new_position = update_data.pop('position')
# We need the full list to reorder, making sure it's loaded and ordered
list_id = item_db.list_id list_id = item_db.list_id
stmt = select(ItemModel).where(ItemModel.list_id == list_id).order_by(ItemModel.position.asc(), ItemModel.created_at.asc()) stmt = select(ItemModel).where(ItemModel.list_id == list_id).order_by(ItemModel.position.asc(), ItemModel.created_at.asc())
result = await db.execute(stmt) result = await db.execute(stmt)
items_in_list = result.scalars().all() items_in_list = result.scalars().all()
# Find the item to move
item_to_move = next((it for it in items_in_list if it.id == item_db.id), None) item_to_move = next((it for it in items_in_list if it.id == item_db.id), None)
if item_to_move: if item_to_move:
items_in_list.remove(item_to_move) items_in_list.remove(item_to_move)
# Insert at the new position (adjust for 1-based index from frontend)
# Clamp position to be within bounds
insert_pos = max(0, min(new_position - 1, len(items_in_list))) insert_pos = max(0, min(new_position - 1, len(items_in_list)))
items_in_list.insert(insert_pos, item_to_move) items_in_list.insert(insert_pos, item_to_move)
# Re-assign positions
for i, item in enumerate(items_in_list): for i, item in enumerate(items_in_list):
item.position = i + 1 item.position = i + 1
# --- End Handle Reordering ---
if 'is_complete' in update_data: if 'is_complete' in update_data:
if update_data['is_complete'] is True: if update_data['is_complete'] is True:
if item_db.completed_by_id is None: if item_db.completed_by_id is None:
@ -158,10 +144,9 @@ async def update_item(db: AsyncSession, item_db: ItemModel, item_in: ItemUpdate,
setattr(item_db, key, value) setattr(item_db, key, value)
item_db.version += 1 item_db.version += 1
db.add(item_db) # Mark as dirty db.add(item_db)
await db.flush() await db.flush()
# Re-fetch with relationships
stmt = ( stmt = (
select(ItemModel) select(ItemModel)
.where(ItemModel.id == item_db.id) .where(ItemModel.id == item_db.id)
@ -174,8 +159,7 @@ async def update_item(db: AsyncSession, item_db: ItemModel, item_in: ItemUpdate,
result = await db.execute(stmt) result = await db.execute(stmt)
updated_item = result.scalar_one_or_none() updated_item = result.scalar_one_or_none()
if updated_item is None: # Should not happen if updated_item is None:
# Rollback will be handled by context manager on raise
raise ItemOperationError("Failed to load item after update.") raise ItemOperationError("Failed to load item after update.")
return updated_item return updated_item
@ -185,7 +169,7 @@ async def update_item(db: AsyncSession, item_db: ItemModel, item_in: ItemUpdate,
except OperationalError as e: except OperationalError as e:
logger.error(f"Database connection error while updating item: {str(e)}", exc_info=True) logger.error(f"Database connection error while updating item: {str(e)}", exc_info=True)
raise DatabaseConnectionError(f"Database connection error while updating item: {str(e)}") raise DatabaseConnectionError(f"Database connection error while updating item: {str(e)}")
except ConflictError: # Re-raise ConflictError, rollback handled by context manager except ConflictError:
raise raise
except SQLAlchemyError as e: except SQLAlchemyError as e:
logger.error(f"Unexpected SQLAlchemy error during item update: {str(e)}", exc_info=True) logger.error(f"Unexpected SQLAlchemy error during item update: {str(e)}", exc_info=True)
@ -196,14 +180,9 @@ async def delete_item(db: AsyncSession, item_db: ItemModel) -> None:
try: try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction: async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
await db.delete(item_db) await db.delete(item_db)
# await transaction.commit() # Removed
# No return needed for None
except OperationalError as e: except OperationalError as e:
logger.error(f"Database connection error while deleting item: {str(e)}", exc_info=True) logger.error(f"Database connection error while deleting item: {str(e)}", exc_info=True)
raise DatabaseConnectionError(f"Database connection error while deleting item: {str(e)}") raise DatabaseConnectionError(f"Database connection error while deleting item: {str(e)}")
except SQLAlchemyError as e: except SQLAlchemyError as e:
logger.error(f"Unexpected SQLAlchemy error while deleting item: {str(e)}", exc_info=True) logger.error(f"Unexpected SQLAlchemy error while deleting item: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Failed to delete item: {str(e)}") raise DatabaseTransactionError(f"Failed to delete item: {str(e)}")
# Ensure ItemOperationError is defined in app.core.exceptions if used
# Example: class ItemOperationError(AppException): pass

View File

@ -1,11 +1,10 @@
# app/crud/list.py
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from sqlalchemy.orm import selectinload, joinedload from sqlalchemy.orm import selectinload, joinedload
from sqlalchemy import or_, and_, delete as sql_delete, func as sql_func, desc from sqlalchemy import or_, and_, delete as sql_delete, func as sql_func, desc
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
from typing import Optional, List as PyList from typing import Optional, List as PyList
import logging # Add logging import import logging
from app.schemas.list import ListStatus from app.schemas.list import ListStatus
from app.models import List as ListModel, UserGroup as UserGroupModel, Item as ItemModel from app.models import List as ListModel, UserGroup as UserGroupModel, Item as ItemModel
@ -22,12 +21,12 @@ from app.core.exceptions import (
ListOperationError ListOperationError
) )
logger = logging.getLogger(__name__) # Initialize logger logger = logging.getLogger(__name__)
async def create_list(db: AsyncSession, list_in: ListCreate, creator_id: int) -> ListModel: async def create_list(db: AsyncSession, list_in: ListCreate, creator_id: int) -> ListModel:
"""Creates a new list record.""" """Creates a new list record."""
try: try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction: async with db.begin_nested() if db.in_transaction() else db.begin() as transaction: # Start transaction
db_list = ListModel( db_list = ListModel(
name=list_in.name, name=list_in.name,
description=list_in.description, description=list_in.description,
@ -36,16 +35,14 @@ async def create_list(db: AsyncSession, list_in: ListCreate, creator_id: int) ->
is_complete=False is_complete=False
) )
db.add(db_list) db.add(db_list)
await db.flush() # Assigns ID await db.flush()
# Re-fetch with relationships for the response
stmt = ( stmt = (
select(ListModel) select(ListModel)
.where(ListModel.id == db_list.id) .where(ListModel.id == db_list.id)
.options( .options(
selectinload(ListModel.creator), selectinload(ListModel.creator),
selectinload(ListModel.group) selectinload(ListModel.group)
# selectinload(ListModel.items) # Optionally add if items are always needed in response
) )
) )
result = await db.execute(stmt) result = await db.execute(stmt)
@ -129,7 +126,7 @@ async def update_list(db: AsyncSession, list_db: ListModel, list_in: ListUpdate)
"""Updates an existing list record, checking for version conflicts.""" """Updates an existing list record, checking for version conflicts."""
try: try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction: async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
if list_db.version != list_in.version: # list_db here is the one passed in, pre-loaded by API layer if list_db.version != list_in.version:
raise ConflictError( raise ConflictError(
f"List '{list_db.name}' (ID: {list_db.id}) has been modified. " f"List '{list_db.name}' (ID: {list_db.id}) has been modified. "
f"Your version is {list_in.version}, current version is {list_db.version}. Please refresh." f"Your version is {list_in.version}, current version is {list_db.version}. Please refresh."
@ -145,20 +142,18 @@ async def update_list(db: AsyncSession, list_db: ListModel, list_in: ListUpdate)
db.add(list_db) # Add the already attached list_db to mark it dirty for the session db.add(list_db) # Add the already attached list_db to mark it dirty for the session
await db.flush() await db.flush()
# Re-fetch with relationships for the response
stmt = ( stmt = (
select(ListModel) select(ListModel)
.where(ListModel.id == list_db.id) .where(ListModel.id == list_db.id)
.options( .options(
selectinload(ListModel.creator), selectinload(ListModel.creator),
selectinload(ListModel.group) selectinload(ListModel.group)
# selectinload(ListModel.items) # Optionally add if items are always needed in response
) )
) )
result = await db.execute(stmt) result = await db.execute(stmt)
updated_list = result.scalar_one_or_none() updated_list = result.scalar_one_or_none()
if updated_list is None: # Should not happen if updated_list is None:
raise ListOperationError("Failed to load list after update.") raise ListOperationError("Failed to load list after update.")
return updated_list return updated_list
@ -177,7 +172,7 @@ async def update_list(db: AsyncSession, list_db: ListModel, list_in: ListUpdate)
async def delete_list(db: AsyncSession, list_db: ListModel) -> None: async def delete_list(db: AsyncSession, list_db: ListModel) -> None:
"""Deletes a list record. Version check should be done by the caller (API endpoint).""" """Deletes a list record. Version check should be done by the caller (API endpoint)."""
try: try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction: # Standardize transaction async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
await db.delete(list_db) await db.delete(list_db)
except OperationalError as e: except OperationalError as e:
logger.error(f"Database connection error while deleting list: {str(e)}", exc_info=True) logger.error(f"Database connection error while deleting list: {str(e)}", exc_info=True)
@ -257,7 +252,6 @@ async def get_list_by_name_and_group(
Used for conflict resolution when creating lists. Used for conflict resolution when creating lists.
""" """
try: try:
# Base query for the list itself
base_query = select(ListModel).where(ListModel.name == name) base_query = select(ListModel).where(ListModel.name == name)
if group_id is not None: if group_id is not None:
@ -265,7 +259,6 @@ async def get_list_by_name_and_group(
else: else:
base_query = base_query.where(ListModel.group_id.is_(None)) base_query = base_query.where(ListModel.group_id.is_(None))
# Add eager loading for common relationships
base_query = base_query.options( base_query = base_query.options(
selectinload(ListModel.creator), selectinload(ListModel.creator),
selectinload(ListModel.group) selectinload(ListModel.group)
@ -277,19 +270,17 @@ async def get_list_by_name_and_group(
if not target_list: if not target_list:
return None return None
# Permission check
is_creator = target_list.created_by_id == user_id is_creator = target_list.created_by_id == user_id
if is_creator: if is_creator:
return target_list return target_list
if target_list.group_id: if target_list.group_id:
from app.crud.group import is_user_member # Assuming this is a quick check not needing its own transaction from app.crud.group import is_user_member
is_member_of_group = await is_user_member(db, group_id=target_list.group_id, user_id=user_id) is_member_of_group = await is_user_member(db, group_id=target_list.group_id, user_id=user_id)
if is_member_of_group: if is_member_of_group:
return target_list return target_list
# If not creator and (not a group list or not a member of the group list)
return None return None
except OperationalError as e: except OperationalError as e:
@ -305,22 +296,17 @@ async def get_lists_statuses_by_ids(db: AsyncSession, list_ids: PyList[int], use
if not list_ids: if not list_ids:
return [] return []
try: try:
# First, get the groups the user is a member of
group_ids_result = await db.execute( group_ids_result = await db.execute(
select(UserGroupModel.group_id).where(UserGroupModel.user_id == user_id) select(UserGroupModel.group_id).where(UserGroupModel.user_id == user_id)
) )
user_group_ids = group_ids_result.scalars().all() user_group_ids = group_ids_result.scalars().all()
# Build the permission logic
permission_filter = or_( permission_filter = or_(
# User is the creator of the list
and_(ListModel.created_by_id == user_id, ListModel.group_id.is_(None)), and_(ListModel.created_by_id == user_id, ListModel.group_id.is_(None)),
# List belongs to a group the user is a member of
ListModel.group_id.in_(user_group_ids) ListModel.group_id.in_(user_group_ids)
) )
# Main query to get list data and item counts
query = ( query = (
select( select(
ListModel.id, ListModel.id,
@ -340,11 +326,7 @@ async def get_lists_statuses_by_ids(db: AsyncSession, list_ids: PyList[int], use
result = await db.execute(query) result = await db.execute(query)
# The result will be rows of (id, updated_at, item_count). return result.all()
# We need to verify that all requested list_ids that the user *should* have access to are present.
# The filter in the query already handles permissions.
return result.all() # Returns a list of Row objects with id, updated_at, item_count
except OperationalError as e: except OperationalError as e:
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}") raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")

View File

@ -1,13 +1,10 @@
# be/app/crud/schedule.py
import logging import logging
from datetime import date, timedelta from datetime import date, timedelta
from typing import List from typing import List
from itertools import cycle from itertools import cycle
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from app.models import Chore, ChoreAssignment, UserGroup, ChoreTypeEnum, ChoreHistoryEventTypeEnum
from app.models import Chore, Group, User, ChoreAssignment, UserGroup, ChoreTypeEnum, ChoreHistoryEventTypeEnum
from app.crud.group import get_group_by_id from app.crud.group import get_group_by_id
from app.crud.history import create_chore_history_entry from app.crud.history import create_chore_history_entry
from app.core.exceptions import GroupNotFoundError, ChoreOperationError from app.core.exceptions import GroupNotFoundError, ChoreOperationError
@ -20,7 +17,7 @@ async def generate_group_chore_schedule(
group_id: int, group_id: int,
start_date: date, start_date: date,
end_date: date, end_date: date,
user_id: int, # The user initiating the action user_id: int,
member_ids: List[int] = None member_ids: List[int] = None
) -> List[ChoreAssignment]: ) -> List[ChoreAssignment]:
""" """
@ -34,7 +31,6 @@ async def generate_group_chore_schedule(
raise GroupNotFoundError(group_id) raise GroupNotFoundError(group_id)
if not member_ids: if not member_ids:
# If no members are specified, use all members from the group
members_result = await db.execute( members_result = await db.execute(
select(UserGroup.user_id).where(UserGroup.group_id == group_id) select(UserGroup.user_id).where(UserGroup.group_id == group_id)
) )
@ -43,7 +39,6 @@ async def generate_group_chore_schedule(
if not member_ids: if not member_ids:
raise ChoreOperationError("Cannot generate schedule with no members.") raise ChoreOperationError("Cannot generate schedule with no members.")
# Fetch all chores belonging to this group
chores_result = await db.execute( chores_result = await db.execute(
select(Chore).where(Chore.group_id == group_id, Chore.type == ChoreTypeEnum.group) select(Chore).where(Chore.group_id == group_id, Chore.type == ChoreTypeEnum.group)
) )
@ -58,16 +53,7 @@ async def generate_group_chore_schedule(
current_date = start_date current_date = start_date
while current_date <= end_date: while current_date <= end_date:
for chore in group_chores: 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: 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( existing_assignment_result = await db.execute(
select(ChoreAssignment.id) select(ChoreAssignment.id)
.where(ChoreAssignment.chore_id == chore.id, ChoreAssignment.due_date == chore.next_due_date) .where(ChoreAssignment.chore_id == chore.id, ChoreAssignment.due_date == chore.next_due_date)
@ -82,7 +68,7 @@ async def generate_group_chore_schedule(
assignment = ChoreAssignment( assignment = ChoreAssignment(
chore_id=chore.id, chore_id=chore.id,
assigned_to_user_id=assigned_to_user_id, assigned_to_user_id=assigned_to_user_id,
due_date=chore.next_due_date, # Assign on the chore's own next_due_date due_date=chore.next_due_date,
is_complete=False is_complete=False
) )
db.add(assignment) db.add(assignment)
@ -95,10 +81,9 @@ async def generate_group_chore_schedule(
logger.info(f"No new assignments were generated for group {group_id} in the specified date range.") logger.info(f"No new assignments were generated for group {group_id} in the specified date range.")
return [] return []
# Log a single group-level event for the schedule generation
await create_chore_history_entry( await create_chore_history_entry(
db, db,
chore_id=None, # This is a group-level event chore_id=None,
group_id=group_id, group_id=group_id,
changed_by_user_id=user_id, changed_by_user_id=user_id,
event_type=ChoreHistoryEventTypeEnum.SCHEDULE_GENERATED, event_type=ChoreHistoryEventTypeEnum.SCHEDULE_GENERATED,
@ -112,8 +97,6 @@ async def generate_group_chore_schedule(
await db.flush() 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: for assign in new_assignments:
await db.refresh(assign) await db.refresh(assign)

View File

@ -1,4 +1,3 @@
# app/crud/settlement.py
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from sqlalchemy.orm import selectinload, joinedload from sqlalchemy.orm import selectinload, joinedload
@ -7,7 +6,7 @@ from sqlalchemy.exc import SQLAlchemyError, OperationalError, IntegrityError
from decimal import Decimal, ROUND_HALF_UP from decimal import Decimal, ROUND_HALF_UP
from typing import List as PyList, Optional, Sequence from typing import List as PyList, Optional, Sequence
from datetime import datetime, timezone from datetime import datetime, timezone
import logging # Add logging import import logging
from app.models import ( from app.models import (
Settlement as SettlementModel, Settlement as SettlementModel,
@ -28,7 +27,7 @@ from app.core.exceptions import (
ConflictError ConflictError
) )
logger = logging.getLogger(__name__) # Initialize logger logger = logging.getLogger(__name__)
async def create_settlement(db: AsyncSession, settlement_in: SettlementCreate, current_user_id: int) -> SettlementModel: async def create_settlement(db: AsyncSession, settlement_in: SettlementCreate, current_user_id: int) -> SettlementModel:
"""Creates a new settlement record.""" """Creates a new settlement record."""
@ -49,13 +48,6 @@ async def create_settlement(db: AsyncSession, settlement_in: SettlementCreate, c
if not group: if not group:
raise GroupNotFoundError(settlement_in.group_id) raise GroupNotFoundError(settlement_in.group_id)
# Permission check example (can be in API layer too)
# if current_user_id not in [payer.id, payee.id]:
# is_member_stmt = select(UserGroupModel.id).where(UserGroupModel.group_id == group.id, UserGroupModel.user_id == current_user_id).limit(1)
# is_member_result = await db.execute(is_member_stmt)
# if not is_member_result.scalar_one_or_none():
# raise InvalidOperationError("Settlement recorder must be part of the group or one of the parties.")
db_settlement = SettlementModel( db_settlement = SettlementModel(
group_id=settlement_in.group_id, group_id=settlement_in.group_id,
paid_by_user_id=settlement_in.paid_by_user_id, paid_by_user_id=settlement_in.paid_by_user_id,
@ -68,7 +60,6 @@ async def create_settlement(db: AsyncSession, settlement_in: SettlementCreate, c
db.add(db_settlement) db.add(db_settlement)
await db.flush() await db.flush()
# Re-fetch with relationships
stmt = ( stmt = (
select(SettlementModel) select(SettlementModel)
.where(SettlementModel.id == db_settlement.id) .where(SettlementModel.id == db_settlement.id)
@ -87,8 +78,6 @@ async def create_settlement(db: AsyncSession, settlement_in: SettlementCreate, c
return loaded_settlement return loaded_settlement
except (UserNotFoundError, GroupNotFoundError, InvalidOperationError) as e: except (UserNotFoundError, GroupNotFoundError, InvalidOperationError) as e:
# These are validation errors, re-raise them.
# If a transaction was started, context manager handles rollback.
raise raise
except IntegrityError as e: except IntegrityError as e:
logger.error(f"Database integrity error during settlement creation: {str(e)}", exc_info=True) logger.error(f"Database integrity error during settlement creation: {str(e)}", exc_info=True)
@ -115,10 +104,8 @@ async def get_settlement_by_id(db: AsyncSession, settlement_id: int) -> Optional
) )
return result.scalars().first() return result.scalars().first()
except OperationalError as e: except OperationalError as e:
# Optional: logger.warning or info if needed for read operations
raise DatabaseConnectionError(f"DB connection error fetching settlement: {str(e)}") raise DatabaseConnectionError(f"DB connection error fetching settlement: {str(e)}")
except SQLAlchemyError as e: except SQLAlchemyError as e:
# Optional: logger.warning or info if needed for read operations
raise DatabaseQueryError(f"DB query error fetching settlement: {str(e)}") raise DatabaseQueryError(f"DB query error fetching settlement: {str(e)}")
async def get_settlements_for_group(db: AsyncSession, group_id: int, skip: int = 0, limit: int = 100) -> Sequence[SettlementModel]: async def get_settlements_for_group(db: AsyncSession, group_id: int, skip: int = 0, limit: int = 100) -> Sequence[SettlementModel]:
@ -183,10 +170,6 @@ async def update_settlement(db: AsyncSession, settlement_db: SettlementModel, se
""" """
try: try:
async with db.begin_nested() if db.in_transaction() else db.begin(): async with db.begin_nested() if db.in_transaction() else db.begin():
# Ensure the settlement_db passed is managed by the current session if not already.
# This is usually true if fetched by an endpoint dependency using the same session.
# If not, `db.add(settlement_db)` might be needed before modification if it's detached.
if not hasattr(settlement_db, 'version') or not hasattr(settlement_in, 'version'): if not hasattr(settlement_db, 'version') or not hasattr(settlement_in, 'version'):
raise InvalidOperationError("Version field is missing in model or input for optimistic locking.") raise InvalidOperationError("Version field is missing in model or input for optimistic locking.")
@ -204,22 +187,14 @@ async def update_settlement(db: AsyncSession, settlement_db: SettlementModel, se
if field in allowed_to_update: if field in allowed_to_update:
setattr(settlement_db, field, value) setattr(settlement_db, field, value)
updated_something = True updated_something = True
# Silently ignore fields not allowed to update or raise error:
# else:
# raise InvalidOperationError(f"Field '{field}' cannot be updated.")
if not updated_something and not settlement_in.model_fields_set.intersection(allowed_to_update): if not updated_something and not settlement_in.model_fields_set.intersection(allowed_to_update):
# No updatable fields were actually provided, or they didn't change
# Still, we might want to return the re-loaded settlement if version matched.
pass pass
settlement_db.version += 1 settlement_db.version += 1
settlement_db.updated_at = datetime.now(timezone.utc) # Ensure model has this field settlement_db.updated_at = datetime.now(timezone.utc)
db.add(settlement_db) # Mark as dirty
await db.flush() await db.flush()
# Re-fetch with relationships
stmt = ( stmt = (
select(SettlementModel) select(SettlementModel)
.where(SettlementModel.id == settlement_db.id) .where(SettlementModel.id == settlement_db.id)
@ -233,11 +208,11 @@ async def update_settlement(db: AsyncSession, settlement_db: SettlementModel, se
result = await db.execute(stmt) result = await db.execute(stmt)
updated_settlement = result.scalar_one_or_none() updated_settlement = result.scalar_one_or_none()
if updated_settlement is None: # Should not happen if updated_settlement is None:
raise SettlementOperationError("Failed to load settlement after update.") raise SettlementOperationError("Failed to load settlement after update.")
return updated_settlement return updated_settlement
except ConflictError as e: # ConflictError should be defined in exceptions except ConflictError as e:
raise raise
except InvalidOperationError as e: except InvalidOperationError as e:
raise raise
@ -261,13 +236,13 @@ async def delete_settlement(db: AsyncSession, settlement_db: SettlementModel, ex
async with db.begin_nested() if db.in_transaction() else db.begin(): async with db.begin_nested() if db.in_transaction() else db.begin():
if expected_version is not None: if expected_version is not None:
if not hasattr(settlement_db, 'version') or settlement_db.version != expected_version: if not hasattr(settlement_db, 'version') or settlement_db.version != expected_version:
raise ConflictError( # Make sure ConflictError is defined raise ConflictError(
f"Settlement (ID: {settlement_db.id}) cannot be deleted. " f"Settlement (ID: {settlement_db.id}) cannot be deleted. "
f"Expected version {expected_version} does not match current version {settlement_db.version}. Please refresh." f"Expected version {expected_version} does not match current version {settlement_db.version}. Please refresh."
) )
await db.delete(settlement_db) await db.delete(settlement_db)
except ConflictError as e: # ConflictError should be defined except ConflictError as e:
raise raise
except OperationalError as e: except OperationalError as e:
logger.error(f"Database connection error during settlement deletion: {str(e)}", exc_info=True) logger.error(f"Database connection error during settlement deletion: {str(e)}", exc_info=True)
@ -275,7 +250,3 @@ async def delete_settlement(db: AsyncSession, settlement_db: SettlementModel, ex
except SQLAlchemyError as e: except SQLAlchemyError as e:
logger.error(f"Unexpected SQLAlchemy error during settlement deletion: {str(e)}", exc_info=True) logger.error(f"Unexpected SQLAlchemy error during settlement deletion: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"DB transaction error during settlement deletion: {str(e)}") raise DatabaseTransactionError(f"DB transaction error during settlement deletion: {str(e)}")
# Ensure SettlementOperationError and ConflictError are defined in app.core.exceptions
# Example: class SettlementOperationError(AppException): pass
# Example: class ConflictError(AppException): status_code = 409

View File

@ -14,9 +14,7 @@ from app.models import (
ExpenseSplitStatusEnum, ExpenseSplitStatusEnum,
ExpenseOverallStatusEnum, ExpenseOverallStatusEnum,
) )
# Placeholder for Pydantic schema - actual schema definition is a later step from pydantic import BaseModel
# from app.schemas.settlement_activity import SettlementActivityCreate # Assuming this path
from pydantic import BaseModel # Using pydantic BaseModel directly for the placeholder
class SettlementActivityCreatePlaceholder(BaseModel): class SettlementActivityCreatePlaceholder(BaseModel):
@ -26,8 +24,7 @@ class SettlementActivityCreatePlaceholder(BaseModel):
paid_at: Optional[datetime] = None paid_at: Optional[datetime] = None
class Config: class Config:
orm_mode = True # Pydantic V1 style orm_mode orm_mode = True
# from_attributes = True # Pydantic V2 style
async def update_expense_split_status(db: AsyncSession, expense_split_id: int) -> Optional[ExpenseSplit]: async def update_expense_split_status(db: AsyncSession, expense_split_id: int) -> Optional[ExpenseSplit]:
@ -35,7 +32,6 @@ async def update_expense_split_status(db: AsyncSession, expense_split_id: int) -
Updates the status of an ExpenseSplit based on its settlement activities. Updates the status of an ExpenseSplit based on its settlement activities.
Also updates the overall status of the parent Expense. Also updates the overall status of the parent Expense.
""" """
# Fetch the ExpenseSplit with its related settlement_activities and the parent expense
result = await db.execute( result = await db.execute(
select(ExpenseSplit) select(ExpenseSplit)
.options( .options(
@ -47,18 +43,13 @@ async def update_expense_split_status(db: AsyncSession, expense_split_id: int) -
expense_split = result.scalar_one_or_none() expense_split = result.scalar_one_or_none()
if not expense_split: if not expense_split:
# Or raise an exception, depending on desired error handling
return None return None
# Calculate total_paid from all settlement_activities for that split
total_paid = sum(activity.amount_paid for activity in expense_split.settlement_activities) total_paid = sum(activity.amount_paid for activity in expense_split.settlement_activities)
total_paid = Decimal(total_paid).quantize(Decimal("0.01")) # Ensure two decimal places total_paid = Decimal(total_paid).quantize(Decimal("0.01"))
# Compare total_paid with ExpenseSplit.owed_amount
if total_paid >= expense_split.owed_amount: if total_paid >= expense_split.owed_amount:
expense_split.status = ExpenseSplitStatusEnum.paid expense_split.status = ExpenseSplitStatusEnum.paid
# Set paid_at to the latest relevant SettlementActivity or current time
# For simplicity, let's find the latest paid_at from activities, or use now()
latest_paid_at = None latest_paid_at = None
if expense_split.settlement_activities: if expense_split.settlement_activities:
latest_paid_at = max(act.paid_at for act in expense_split.settlement_activities if act.paid_at) latest_paid_at = max(act.paid_at for act in expense_split.settlement_activities if act.paid_at)
@ -66,13 +57,13 @@ async def update_expense_split_status(db: AsyncSession, expense_split_id: int) -
expense_split.paid_at = latest_paid_at if latest_paid_at else datetime.now(timezone.utc) expense_split.paid_at = latest_paid_at if latest_paid_at else datetime.now(timezone.utc)
elif total_paid > 0: elif total_paid > 0:
expense_split.status = ExpenseSplitStatusEnum.partially_paid expense_split.status = ExpenseSplitStatusEnum.partially_paid
expense_split.paid_at = None # Clear paid_at if not fully paid expense_split.paid_at = None
else: # total_paid == 0 else: # total_paid == 0
expense_split.status = ExpenseSplitStatusEnum.unpaid expense_split.status = ExpenseSplitStatusEnum.unpaid
expense_split.paid_at = None # Clear paid_at expense_split.paid_at = None
await db.flush() await db.flush()
await db.refresh(expense_split, attribute_names=['status', 'paid_at', 'expense']) # Refresh to get updated data and related expense await db.refresh(expense_split, attribute_names=['status', 'paid_at', 'expense'])
return expense_split return expense_split
@ -81,18 +72,16 @@ async def update_expense_overall_status(db: AsyncSession, expense_id: int) -> Op
""" """
Updates the overall_status of an Expense based on the status of its splits. Updates the overall_status of an Expense based on the status of its splits.
""" """
# Fetch the Expense with its related splits
result = await db.execute( result = await db.execute(
select(Expense).options(selectinload(Expense.splits)).where(Expense.id == expense_id) select(Expense).options(selectinload(Expense.splits)).where(Expense.id == expense_id)
) )
expense = result.scalar_one_or_none() expense = result.scalar_one_or_none()
if not expense: if not expense:
# Or raise an exception
return None return None
if not expense.splits: # No splits, should not happen for a valid expense but handle defensively if not expense.splits:
expense.overall_settlement_status = ExpenseOverallStatusEnum.unpaid # Or some other default/error state expense.overall_settlement_status = ExpenseOverallStatusEnum.unpaid
await db.flush() await db.flush()
await db.refresh(expense) await db.refresh(expense)
return expense return expense
@ -107,14 +96,14 @@ async def update_expense_overall_status(db: AsyncSession, expense_id: int) -> Op
num_paid_splits += 1 num_paid_splits += 1
elif split.status == ExpenseSplitStatusEnum.partially_paid: elif split.status == ExpenseSplitStatusEnum.partially_paid:
num_partially_paid_splits += 1 num_partially_paid_splits += 1
else: # unpaid else:
num_unpaid_splits += 1 num_unpaid_splits += 1
if num_paid_splits == num_splits: if num_paid_splits == num_splits:
expense.overall_settlement_status = ExpenseOverallStatusEnum.paid expense.overall_settlement_status = ExpenseOverallStatusEnum.paid
elif num_unpaid_splits == num_splits: elif num_unpaid_splits == num_splits:
expense.overall_settlement_status = ExpenseOverallStatusEnum.unpaid expense.overall_settlement_status = ExpenseOverallStatusEnum.unpaid
else: # Mix of paid, partially_paid, or unpaid but not all unpaid/paid else:
expense.overall_settlement_status = ExpenseOverallStatusEnum.partially_paid expense.overall_settlement_status = ExpenseOverallStatusEnum.partially_paid
await db.flush() await db.flush()
@ -130,43 +119,33 @@ async def create_settlement_activity(
""" """
Creates a new settlement activity, then updates the parent expense split and expense statuses. Creates a new settlement activity, then updates the parent expense split and expense statuses.
""" """
# Validate ExpenseSplit
split_result = await db.execute(select(ExpenseSplit).where(ExpenseSplit.id == settlement_activity_in.expense_split_id)) split_result = await db.execute(select(ExpenseSplit).where(ExpenseSplit.id == settlement_activity_in.expense_split_id))
expense_split = split_result.scalar_one_or_none() expense_split = split_result.scalar_one_or_none()
if not expense_split: if not expense_split:
# Consider raising an HTTPException in an API layer return None
return None # ExpenseSplit not found
# Validate User (paid_by_user_id)
user_result = await db.execute(select(User).where(User.id == settlement_activity_in.paid_by_user_id)) user_result = await db.execute(select(User).where(User.id == settlement_activity_in.paid_by_user_id))
paid_by_user = user_result.scalar_one_or_none() paid_by_user = user_result.scalar_one_or_none()
if not paid_by_user: if not paid_by_user:
return None # User not found return None # User not found
# Create SettlementActivity instance
db_settlement_activity = SettlementActivity( db_settlement_activity = SettlementActivity(
expense_split_id=settlement_activity_in.expense_split_id, expense_split_id=settlement_activity_in.expense_split_id,
paid_by_user_id=settlement_activity_in.paid_by_user_id, paid_by_user_id=settlement_activity_in.paid_by_user_id,
amount_paid=settlement_activity_in.amount_paid, amount_paid=settlement_activity_in.amount_paid,
paid_at=settlement_activity_in.paid_at if settlement_activity_in.paid_at else datetime.now(timezone.utc), paid_at=settlement_activity_in.paid_at if settlement_activity_in.paid_at else datetime.now(timezone.utc),
created_by_user_id=current_user_id # The user recording the activity created_by_user_id=current_user_id
) )
db.add(db_settlement_activity) db.add(db_settlement_activity)
await db.flush() # Flush to get the ID for db_settlement_activity await db.flush()
# Update statuses # Update statuses
updated_split = await update_expense_split_status(db, expense_split_id=db_settlement_activity.expense_split_id) updated_split = await update_expense_split_status(db, expense_split_id=db_settlement_activity.expense_split_id)
if updated_split and updated_split.expense_id: if updated_split and updated_split.expense_id:
await update_expense_overall_status(db, expense_id=updated_split.expense_id) await update_expense_overall_status(db, expense_id=updated_split.expense_id)
else: else:
# This case implies update_expense_split_status returned None or expense_id was missing. pass
# This could be a problem, consider logging or raising an error.
# For now, the transaction would roll back if an exception is raised.
# If not raising, the overall status update might be skipped.
pass # Or handle error
await db.refresh(db_settlement_activity, attribute_names=['split', 'payer', 'creator']) # Refresh to load relationships
return db_settlement_activity return db_settlement_activity
@ -180,9 +159,9 @@ async def get_settlement_activity_by_id(
result = await db.execute( result = await db.execute(
select(SettlementActivity) select(SettlementActivity)
.options( .options(
selectinload(SettlementActivity.split).selectinload(ExpenseSplit.expense), # Load split and its parent expense selectinload(SettlementActivity.split).selectinload(ExpenseSplit.expense),
selectinload(SettlementActivity.payer), # Load the user who paid selectinload(SettlementActivity.payer),
selectinload(SettlementActivity.creator) # Load the user who created the record selectinload(SettlementActivity.creator)
) )
.where(SettlementActivity.id == settlement_activity_id) .where(SettlementActivity.id == settlement_activity_id)
) )
@ -199,8 +178,8 @@ async def get_settlement_activities_for_split(
select(SettlementActivity) select(SettlementActivity)
.where(SettlementActivity.expense_split_id == expense_split_id) .where(SettlementActivity.expense_split_id == expense_split_id)
.options( .options(
selectinload(SettlementActivity.payer), # Load the user who paid selectinload(SettlementActivity.payer),
selectinload(SettlementActivity.creator) # Load the user who created the record selectinload(SettlementActivity.creator)
) )
.order_by(SettlementActivity.paid_at.desc(), SettlementActivity.created_at.desc()) .order_by(SettlementActivity.paid_at.desc(), SettlementActivity.created_at.desc())
.offset(skip) .offset(skip)

View File

@ -1,12 +1,11 @@
# app/crud/user.py
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from sqlalchemy.orm import selectinload # Ensure selectinload is imported from sqlalchemy.orm import selectinload
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
from typing import Optional from typing import Optional
import logging # Add logging import import logging
from app.models import User as UserModel, UserGroup as UserGroupModel, Group as GroupModel # Import related models for selectinload from app.models import User as UserModel, UserGroup as UserGroupModel
from app.schemas.user import UserCreate from app.schemas.user import UserCreate
from app.core.security import hash_password from app.core.security import hash_password
from app.core.exceptions import ( from app.core.exceptions import (
@ -16,23 +15,19 @@ from app.core.exceptions import (
DatabaseIntegrityError, DatabaseIntegrityError,
DatabaseQueryError, DatabaseQueryError,
DatabaseTransactionError, DatabaseTransactionError,
UserOperationError # Add if specific user operation errors are needed UserOperationError
) )
logger = logging.getLogger(__name__) # Initialize logger logger = logging.getLogger(__name__)
async def get_user_by_email(db: AsyncSession, email: str) -> Optional[UserModel]: async def get_user_by_email(db: AsyncSession, email: str) -> Optional[UserModel]:
"""Fetches a user from the database by email, with common relationships.""" """Fetches a user from the database by email, with common relationships."""
try: try:
# db.begin() is not strictly necessary for a single read, but ensures atomicity if multiple reads were added.
# For a single select, it can be omitted if preferred, session handles connection.
stmt = ( stmt = (
select(UserModel) select(UserModel)
.filter(UserModel.email == email) .filter(UserModel.email == email)
.options( .options(
selectinload(UserModel.group_associations).selectinload(UserGroupModel.group), # Groups user is member of selectinload(UserModel.group_associations).selectinload(UserGroupModel.group),
selectinload(UserModel.created_groups) # Groups user created
# Add other relationships as needed by UserPublic schema
) )
) )
result = await db.execute(stmt) result = await db.execute(stmt)
@ -51,27 +46,25 @@ async def create_user(db: AsyncSession, user_in: UserCreate) -> UserModel:
_hashed_password = hash_password(user_in.password) _hashed_password = hash_password(user_in.password)
db_user = UserModel( db_user = UserModel(
email=user_in.email, email=user_in.email,
hashed_password=_hashed_password, # Field name in model is hashed_password hashed_password=_hashed_password,
name=user_in.name name=user_in.name
) )
db.add(db_user) db.add(db_user)
await db.flush() # Flush to get DB-generated values like ID await db.flush()
# Re-fetch with relationships
stmt = ( stmt = (
select(UserModel) select(UserModel)
.where(UserModel.id == db_user.id) .where(UserModel.id == db_user.id)
.options( .options(
selectinload(UserModel.group_associations).selectinload(UserGroupModel.group), selectinload(UserModel.group_associations).selectinload(UserGroupModel.group),
selectinload(UserModel.created_groups) selectinload(UserModel.created_groups)
# Add other relationships as needed by UserPublic schema
) )
) )
result = await db.execute(stmt) result = await db.execute(stmt)
loaded_user = result.scalar_one_or_none() loaded_user = result.scalar_one_or_none()
if loaded_user is None: if loaded_user is None:
raise UserOperationError("Failed to load user after creation.") # Define UserOperationError raise UserOperationError("Failed to load user after creation.")
return loaded_user return loaded_user
except IntegrityError as e: except IntegrityError as e:
@ -84,7 +77,4 @@ async def create_user(db: AsyncSession, user_in: UserCreate) -> UserModel:
raise DatabaseConnectionError(f"Database connection error during user creation: {str(e)}") raise DatabaseConnectionError(f"Database connection error during user creation: {str(e)}")
except SQLAlchemyError as e: except SQLAlchemyError as e:
logger.error(f"Unexpected SQLAlchemy error during user creation for email '{user_in.email}': {str(e)}", exc_info=True) logger.error(f"Unexpected SQLAlchemy error during user creation for email '{user_in.email}': {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Failed to create user due to other DB error: {str(e)}") raise DatabaseTransactionError(f"Failed to create user due to other DB error: {str(e)}")
# Ensure UserOperationError is defined in app.core.exceptions if used
# Example: class UserOperationError(AppException): pass

View File

@ -1,24 +1,18 @@
# app/database.py
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker, declarative_base from sqlalchemy.orm import sessionmaker, declarative_base
from app.config import settings from app.config import settings
# Ensure DATABASE_URL is set before proceeding
if not settings.DATABASE_URL: if not settings.DATABASE_URL:
raise ValueError("DATABASE_URL is not configured in settings.") raise ValueError("DATABASE_URL is not configured in settings.")
# Create the SQLAlchemy async engine
# pool_recycle=3600 helps prevent stale connections on some DBs
engine = create_async_engine( engine = create_async_engine(
settings.DATABASE_URL, settings.DATABASE_URL,
echo=False, # Disable SQL query logging for production (use DEBUG log level to enable) echo=False,
future=True, # Use SQLAlchemy 2.0 style features future=True,
pool_recycle=3600, # Optional: recycle connections after 1 hour pool_recycle=3600,
pool_pre_ping=True # Add this line to ensure connections are live pool_pre_ping=True
) )
# Create a configured "Session" class
# expire_on_commit=False prevents attributes from expiring after commit
AsyncSessionLocal = sessionmaker( AsyncSessionLocal = sessionmaker(
bind=engine, bind=engine,
class_=AsyncSession, class_=AsyncSession,
@ -27,10 +21,8 @@ AsyncSessionLocal = sessionmaker(
autocommit=False, autocommit=False,
) )
# Base class for our ORM models
Base = declarative_base() Base = declarative_base()
# Dependency to get DB session in path operations
async def get_session() -> AsyncSession: # type: ignore async def get_session() -> AsyncSession: # type: ignore
""" """
Dependency function that yields an AsyncSession for read-only operations. Dependency function that yields an AsyncSession for read-only operations.
@ -38,7 +30,6 @@ async def get_session() -> AsyncSession: # type: ignore
""" """
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
yield session yield session
# The 'async with' block handles session.close() automatically.
async def get_transactional_session() -> AsyncSession: # type: ignore async def get_transactional_session() -> AsyncSession: # type: ignore
""" """
@ -51,7 +42,5 @@ async def get_transactional_session() -> AsyncSession: # type: ignore
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
async with session.begin(): async with session.begin():
yield session yield session
# Transaction is automatically committed on success or rolled back on exception
# Alias for backward compatibility
get_db = get_session get_db = get_session

View File

@ -1,4 +1,2 @@
from app.database import AsyncSessionLocal from app.database import AsyncSessionLocal
# Export the async session factory
async_session = AsyncSessionLocal async_session = AsyncSessionLocal

View File

@ -15,18 +15,15 @@ async def generate_recurring_expenses(db: AsyncSession) -> None:
Should be run daily to check for and create new recurring expenses. Should be run daily to check for and create new recurring expenses.
""" """
try: try:
# Get all active recurring expenses that need to be generated
now = datetime.utcnow() now = datetime.utcnow()
query = select(Expense).join(RecurrencePattern).where( query = select(Expense).join(RecurrencePattern).where(
and_( and_(
Expense.is_recurring == True, Expense.is_recurring == True,
Expense.next_occurrence <= now, Expense.next_occurrence <= now,
# Check if we haven't reached max occurrences
( (
(RecurrencePattern.max_occurrences == None) | (RecurrencePattern.max_occurrences == None) |
(RecurrencePattern.max_occurrences > 0) (RecurrencePattern.max_occurrences > 0)
), ),
# Check if we haven't reached end date
( (
(RecurrencePattern.end_date == None) | (RecurrencePattern.end_date == None) |
(RecurrencePattern.end_date > now) (RecurrencePattern.end_date > now)
@ -54,12 +51,10 @@ async def _generate_next_occurrence(db: AsyncSession, expense: Expense) -> None:
if not pattern: if not pattern:
return return
# Calculate next occurrence date
next_date = _calculate_next_occurrence(expense.next_occurrence, pattern) next_date = _calculate_next_occurrence(expense.next_occurrence, pattern)
if not next_date: if not next_date:
return return
# Create new expense based on template
new_expense = ExpenseCreate( new_expense = ExpenseCreate(
description=expense.description, description=expense.description,
total_amount=expense.total_amount, total_amount=expense.total_amount,
@ -70,14 +65,12 @@ async def _generate_next_occurrence(db: AsyncSession, expense: Expense) -> None:
group_id=expense.group_id, group_id=expense.group_id,
item_id=expense.item_id, item_id=expense.item_id,
paid_by_user_id=expense.paid_by_user_id, paid_by_user_id=expense.paid_by_user_id,
is_recurring=False, # Generated expenses are not recurring is_recurring=False,
splits_in=None # Will be generated based on split_type splits_in=None
) )
# Create the new expense
created_expense = await create_expense(db, new_expense, expense.created_by_user_id) created_expense = await create_expense(db, new_expense, expense.created_by_user_id)
# Update the original expense
expense.last_occurrence = next_date expense.last_occurrence = next_date
expense.next_occurrence = _calculate_next_occurrence(next_date, pattern) expense.next_occurrence = _calculate_next_occurrence(next_date, pattern)
@ -98,7 +91,6 @@ def _calculate_next_occurrence(current_date: datetime, pattern: RecurrencePatter
if not pattern.days_of_week: if not pattern.days_of_week:
return current_date + timedelta(weeks=pattern.interval) return current_date + timedelta(weeks=pattern.interval)
# Find next day of week
current_weekday = current_date.weekday() current_weekday = current_date.weekday()
next_weekday = min((d for d in pattern.days_of_week if d > current_weekday), next_weekday = min((d for d in pattern.days_of_week if d > current_weekday),
default=min(pattern.days_of_week)) default=min(pattern.days_of_week))
@ -108,7 +100,6 @@ def _calculate_next_occurrence(current_date: datetime, pattern: RecurrencePatter
return current_date + timedelta(days=days_ahead) return current_date + timedelta(days=days_ahead)
elif pattern.type == 'monthly': elif pattern.type == 'monthly':
# Add months to current date
year = current_date.year + (current_date.month + pattern.interval - 1) // 12 year = current_date.year + (current_date.month + pattern.interval - 1) // 12
month = (current_date.month + pattern.interval - 1) % 12 + 1 month = (current_date.month + pattern.interval - 1) % 12 + 1
return current_date.replace(year=year, month=month) return current_date.replace(year=year, month=month)

View File

@ -1,60 +1,36 @@
# app/main.py
import logging import logging
import uvicorn from fastapi import FastAPI
from fastapi import FastAPI, HTTPException, Depends, status, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
import sentry_sdk import sentry_sdk
from sentry_sdk.integrations.fastapi import FastApiIntegration from sentry_sdk.integrations.fastapi import FastApiIntegration
from fastapi_users.authentication import JWTStrategy
from pydantic import BaseModel
from jose import jwt, JWTError
from sqlalchemy.ext.asyncio import AsyncEngine
from alembic.config import Config
from alembic import command
import os import os
import sys import sys
from app.api.api_router import api_router from app.api.api_router import api_router
from app.config import settings from app.config import settings
from app.core.api_config import API_METADATA, API_TAGS from app.core.api_config import API_METADATA, API_TAGS
from app.auth import fastapi_users, auth_backend, get_refresh_jwt_strategy, get_jwt_strategy from app.auth import fastapi_users, auth_backend
from app.models import User
from app.api.auth.oauth import router as oauth_router
from app.schemas.user import UserPublic, UserCreate, UserUpdate from app.schemas.user import UserPublic, UserCreate, UserUpdate
from app.core.scheduler import init_scheduler, shutdown_scheduler from app.core.scheduler import init_scheduler, shutdown_scheduler
from app.database import get_session
from sqlalchemy import select
# Response model for refresh endpoint
class RefreshResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
# Initialize Sentry only if DSN is provided
if settings.SENTRY_DSN: if settings.SENTRY_DSN:
sentry_sdk.init( sentry_sdk.init(
dsn=settings.SENTRY_DSN, dsn=settings.SENTRY_DSN,
integrations=[ integrations=[
FastApiIntegration(), FastApiIntegration(),
], ],
# Adjust traces_sample_rate for production
traces_sample_rate=0.1 if settings.is_production else 1.0, traces_sample_rate=0.1 if settings.is_production else 1.0,
environment=settings.ENVIRONMENT, environment=settings.ENVIRONMENT,
# Enable PII data only in development
send_default_pii=not settings.is_production send_default_pii=not settings.is_production
) )
# --- Logging Setup ---
logging.basicConfig( logging.basicConfig(
level=getattr(logging, settings.LOG_LEVEL), level=getattr(logging, settings.LOG_LEVEL),
format=settings.LOG_FORMAT format=settings.LOG_FORMAT
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# --- FastAPI App Instance ---
# Create API metadata with environment-dependent settings
api_metadata = { api_metadata = {
**API_METADATA, **API_METADATA,
"docs_url": settings.docs_url, "docs_url": settings.docs_url,
@ -67,13 +43,11 @@ app = FastAPI(
openapi_tags=API_TAGS openapi_tags=API_TAGS
) )
# Add session middleware for OAuth
app.add_middleware( app.add_middleware(
SessionMiddleware, SessionMiddleware,
secret_key=settings.SESSION_SECRET_KEY secret_key=settings.SESSION_SECRET_KEY
) )
# --- CORS Middleware ---
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=settings.cors_origins_list, allow_origins=settings.cors_origins_list,
@ -82,82 +56,7 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
expose_headers=["*"] expose_headers=["*"]
) )
# --- End CORS Middleware ---
# Refresh token endpoint
@app.post("/auth/jwt/refresh", response_model=RefreshResponse, tags=["auth"])
async def refresh_jwt_token(
request: Request,
refresh_strategy: JWTStrategy = Depends(get_refresh_jwt_strategy),
access_strategy: JWTStrategy = Depends(get_jwt_strategy),
):
"""
Refresh access token using a valid refresh token.
Send refresh token in Authorization header: Bearer <refresh_token>
"""
try:
# Get refresh token from Authorization header
authorization = request.headers.get("Authorization")
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh token missing or invalid format",
headers={"WWW-Authenticate": "Bearer"},
)
refresh_token = authorization.split(" ")[1]
# Validate refresh token and get user data
try:
# Decode the refresh token to get the user identifier
payload = jwt.decode(refresh_token, settings.SECRET_KEY, algorithms=["HS256"])
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
)
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
)
# Get user from database
async with get_session() as session:
result = await session.execute(select(User).where(User.id == int(user_id)))
user = result.scalar_one_or_none()
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive",
)
# Generate new tokens
new_access_token = await access_strategy.write_token(user)
new_refresh_token = await refresh_strategy.write_token(user)
return RefreshResponse(
access_token=new_access_token,
refresh_token=new_refresh_token,
token_type="bearer"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error refreshing token: {e}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
)
# --- Include API Routers ---
# Include OAuth routes first (no auth required)
app.include_router(oauth_router, prefix="/auth", tags=["auth"])
# Include FastAPI-Users routes
app.include_router( app.include_router(
fastapi_users.get_auth_router(auth_backend), fastapi_users.get_auth_router(auth_backend),
prefix="/auth/jwt", prefix="/auth/jwt",
@ -184,11 +83,8 @@ app.include_router(
tags=["users"], tags=["users"],
) )
# Include your API router
app.include_router(api_router, prefix=settings.API_PREFIX) app.include_router(api_router, prefix=settings.API_PREFIX)
# --- End Include API Routers ---
# Health check endpoint
@app.get("/health", tags=["Health"]) @app.get("/health", tags=["Health"])
async def health_check(): async def health_check():
""" """
@ -200,7 +96,6 @@ async def health_check():
"version": settings.API_VERSION "version": settings.API_VERSION
} }
# --- Root Endpoint (Optional - outside the main API structure) ---
@app.get("/", tags=["Root"]) @app.get("/", tags=["Root"])
async def read_root(): async def read_root():
""" """
@ -213,21 +108,17 @@ async def read_root():
"environment": settings.ENVIRONMENT, "environment": settings.ENVIRONMENT,
"version": settings.API_VERSION "version": settings.API_VERSION
} }
# --- End Root Endpoint ---
async def run_migrations(): async def run_migrations():
"""Run database migrations.""" """Run database migrations."""
try: try:
logger.info("Running database migrations...") logger.info("Running database migrations...")
# Get the absolute path to the alembic directory
base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
alembic_path = os.path.join(base_path, 'alembic') alembic_path = os.path.join(base_path, 'alembic')
# Add alembic directory to Python path
if alembic_path not in sys.path: if alembic_path not in sys.path:
sys.path.insert(0, alembic_path) sys.path.insert(0, alembic_path)
# Import and run migrations
from migrations import run_migrations as run_db_migrations from migrations import run_migrations as run_db_migrations
await run_db_migrations() await run_db_migrations()
@ -240,11 +131,7 @@ async def run_migrations():
async def startup_event(): async def startup_event():
"""Initialize services on startup.""" """Initialize services on startup."""
logger.info(f"Application startup in {settings.ENVIRONMENT} environment...") logger.info(f"Application startup in {settings.ENVIRONMENT} environment...")
# Run database migrations
# await run_migrations() # await run_migrations()
# Initialize scheduler
init_scheduler() init_scheduler()
logger.info("Application startup complete.") logger.info("Application startup complete.")
@ -252,15 +139,5 @@ async def startup_event():
async def shutdown_event(): async def shutdown_event():
"""Cleanup services on shutdown.""" """Cleanup services on shutdown."""
logger.info("Application shutdown: Disconnecting from database...") logger.info("Application shutdown: Disconnecting from database...")
# await database.engine.dispose() # Close connection pool
shutdown_scheduler() shutdown_scheduler()
logger.info("Application shutdown complete.") logger.info("Application shutdown complete.")
# --- End Events ---
# --- Direct Run (for simple local testing if needed) ---
# It's better to use `uvicorn app.main:app --reload` from the terminal
# if __name__ == "__main__":
# logger.info("Starting Uvicorn server directly from main.py")
# uvicorn.run(app, host="0.0.0.0", port=8000)
# ------------------------------------------------------

View File

@ -1,4 +1,3 @@
# app/models.py
import enum import enum
import secrets import secrets
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@ -14,16 +13,14 @@ from sqlalchemy import (
UniqueConstraint, UniqueConstraint,
Index, Index,
DDL, DDL,
event,
delete,
func, func,
text as sa_text, text as sa_text,
Text, # <-- Add Text for description Text,
Numeric, # <-- Add Numeric for price Numeric,
CheckConstraint, CheckConstraint,
Date # Added Date for Chore model Date
) )
from sqlalchemy.orm import relationship, backref from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from .database import Base from .database import Base
@ -82,7 +79,6 @@ class ChoreHistoryEventTypeEnum(str, enum.Enum):
UNASSIGNED = "unassigned" UNASSIGNED = "unassigned"
REASSIGNED = "reassigned" REASSIGNED = "reassigned"
SCHEDULE_GENERATED = "schedule_generated" SCHEDULE_GENERATED = "schedule_generated"
# Add more specific events as needed
DUE_DATE_CHANGED = "due_date_changed" DUE_DATE_CHANGED = "due_date_changed"
DETAILS_CHANGED = "details_changed" DETAILS_CHANGED = "details_changed"
@ -103,34 +99,20 @@ class User(Base):
created_groups = relationship("Group", back_populates="creator") created_groups = relationship("Group", back_populates="creator")
group_associations = relationship("UserGroup", back_populates="user", cascade="all, delete-orphan") group_associations = relationship("UserGroup", back_populates="user", cascade="all, delete-orphan")
created_invites = relationship("Invite", back_populates="creator") created_invites = relationship("Invite", back_populates="creator")
created_lists = relationship("List", foreign_keys="List.created_by_id", back_populates="creator")
# --- NEW Relationships for Lists/Items --- added_items = relationship("Item", foreign_keys="Item.added_by_id", back_populates="added_by_user")
created_lists = relationship("List", foreign_keys="List.created_by_id", back_populates="creator") # Link List.created_by_id -> User completed_items = relationship("Item", foreign_keys="Item.completed_by_id", back_populates="completed_by_user")
added_items = relationship("Item", foreign_keys="Item.added_by_id", back_populates="added_by_user") # Link Item.added_by_id -> User
completed_items = relationship("Item", foreign_keys="Item.completed_by_id", back_populates="completed_by_user") # Link Item.completed_by_id -> User
# --- End NEW Relationships ---
# --- Relationships for Cost Splitting ---
expenses_paid = relationship("Expense", foreign_keys="Expense.paid_by_user_id", back_populates="paid_by_user", cascade="all, delete-orphan") expenses_paid = relationship("Expense", foreign_keys="Expense.paid_by_user_id", back_populates="paid_by_user", cascade="all, delete-orphan")
expenses_created = relationship("Expense", foreign_keys="Expense.created_by_user_id", back_populates="created_by_user", cascade="all, delete-orphan") expenses_created = relationship("Expense", foreign_keys="Expense.created_by_user_id", back_populates="created_by_user", cascade="all, delete-orphan")
expense_splits = relationship("ExpenseSplit", foreign_keys="ExpenseSplit.user_id", back_populates="user", cascade="all, delete-orphan") expense_splits = relationship("ExpenseSplit", foreign_keys="ExpenseSplit.user_id", back_populates="user", cascade="all, delete-orphan")
settlements_made = relationship("Settlement", foreign_keys="Settlement.paid_by_user_id", back_populates="payer", cascade="all, delete-orphan") settlements_made = relationship("Settlement", foreign_keys="Settlement.paid_by_user_id", back_populates="payer", cascade="all, delete-orphan")
settlements_received = relationship("Settlement", foreign_keys="Settlement.paid_to_user_id", back_populates="payee", cascade="all, delete-orphan") settlements_received = relationship("Settlement", foreign_keys="Settlement.paid_to_user_id", back_populates="payee", cascade="all, delete-orphan")
settlements_created = relationship("Settlement", foreign_keys="Settlement.created_by_user_id", back_populates="created_by_user", cascade="all, delete-orphan") settlements_created = relationship("Settlement", foreign_keys="Settlement.created_by_user_id", back_populates="created_by_user", cascade="all, delete-orphan")
# --- End Relationships for Cost Splitting ---
# --- Relationships for Chores ---
created_chores = relationship("Chore", foreign_keys="[Chore.created_by_id]", back_populates="creator") created_chores = relationship("Chore", foreign_keys="[Chore.created_by_id]", back_populates="creator")
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 ---
# --- History Relationships ---
chore_history_entries = relationship("ChoreHistory", back_populates="changed_by_user", cascade="all, delete-orphan") 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") assignment_history_entries = relationship("ChoreAssignmentHistory", back_populates="changed_by_user", cascade="all, delete-orphan")
# --- End History Relationships ---
# --- Group Model ---
class Group(Base): class Group(Base):
__tablename__ = "groups" __tablename__ = "groups"
@ -139,30 +121,16 @@ class Group(Base):
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# --- Relationships ---
creator = relationship("User", back_populates="created_groups") creator = relationship("User", back_populates="created_groups")
member_associations = relationship("UserGroup", back_populates="group", cascade="all, delete-orphan") member_associations = relationship("UserGroup", back_populates="group", cascade="all, delete-orphan")
invites = relationship("Invite", back_populates="group", cascade="all, delete-orphan") invites = relationship("Invite", back_populates="group", cascade="all, delete-orphan")
# --- NEW Relationship for Lists --- lists = relationship("List", back_populates="group", cascade="all, delete-orphan")
lists = relationship("List", back_populates="group", cascade="all, delete-orphan") # Link List.group_id -> Group
# --- End NEW Relationship ---
# --- Relationships for Cost Splitting ---
expenses = relationship("Expense", foreign_keys="Expense.group_id", back_populates="group", cascade="all, delete-orphan") expenses = relationship("Expense", foreign_keys="Expense.group_id", back_populates="group", cascade="all, delete-orphan")
settlements = relationship("Settlement", foreign_keys="Settlement.group_id", back_populates="group", cascade="all, delete-orphan") settlements = relationship("Settlement", foreign_keys="Settlement.group_id", back_populates="group", cascade="all, delete-orphan")
# --- End Relationships for Cost Splitting ---
# --- Relationship for Chores ---
chores = relationship("Chore", back_populates="group", cascade="all, delete-orphan") 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") chore_history = relationship("ChoreHistory", back_populates="group", cascade="all, delete-orphan")
# --- End History Relationships ---
# --- UserGroup Association Model ---
class UserGroup(Base): class UserGroup(Base):
__tablename__ = "user_groups" __tablename__ = "user_groups"
__table_args__ = (UniqueConstraint('user_id', 'group_id', name='uq_user_group'),) __table_args__ = (UniqueConstraint('user_id', 'group_id', name='uq_user_group'),)
@ -176,8 +144,6 @@ class UserGroup(Base):
user = relationship("User", back_populates="group_associations") user = relationship("User", back_populates="group_associations")
group = relationship("Group", back_populates="member_associations") group = relationship("Group", back_populates="member_associations")
# --- Invite Model ---
class Invite(Base): class Invite(Base):
__tablename__ = "invites" __tablename__ = "invites"
__table_args__ = ( __table_args__ = (
@ -196,36 +162,30 @@ class Invite(Base):
creator = relationship("User", back_populates="created_invites") creator = relationship("User", back_populates="created_invites")
# === NEW: List Model ===
class List(Base): class List(Base):
__tablename__ = "lists" __tablename__ = "lists"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True, nullable=False) name = Column(String, index=True, nullable=False)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) # Who created this list created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
group_id = Column(Integer, ForeignKey("groups.id"), nullable=True) # Which group it belongs to (NULL if personal) group_id = Column(Integer, ForeignKey("groups.id"), nullable=True)
is_complete = Column(Boolean, default=False, nullable=False) is_complete = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
version = Column(Integer, nullable=False, default=1, server_default='1') version = Column(Integer, nullable=False, default=1, server_default='1')
# --- Relationships --- creator = relationship("User", back_populates="created_lists")
creator = relationship("User", back_populates="created_lists") # Link to User.created_lists group = relationship("Group", back_populates="lists")
group = relationship("Group", back_populates="lists") # Link to Group.lists
items = relationship( items = relationship(
"Item", "Item",
back_populates="list", back_populates="list",
cascade="all, delete-orphan", cascade="all, delete-orphan",
order_by="Item.position.asc(), Item.created_at.asc()" # Default order by position, then creation order_by="Item.position.asc(), Item.created_at.asc()"
) )
# --- Relationships for Cost Splitting ---
expenses = relationship("Expense", foreign_keys="Expense.list_id", back_populates="list", cascade="all, delete-orphan") expenses = relationship("Expense", foreign_keys="Expense.list_id", back_populates="list", cascade="all, delete-orphan")
# --- End Relationships for Cost Splitting ---
# === NEW: Item Model ===
class Item(Base): class Item(Base):
__tablename__ = "items" __tablename__ = "items"
__table_args__ = ( __table_args__ = (
@ -233,31 +193,24 @@ class Item(Base):
) )
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
list_id = Column(Integer, ForeignKey("lists.id", ondelete="CASCADE"), nullable=False) # Belongs to which list list_id = Column(Integer, ForeignKey("lists.id", ondelete="CASCADE"), nullable=False)
name = Column(String, index=True, nullable=False) name = Column(String, index=True, nullable=False)
quantity = Column(String, nullable=True) # Flexible quantity (e.g., "1", "2 lbs", "a bunch") quantity = Column(String, nullable=True)
is_complete = Column(Boolean, default=False, nullable=False) is_complete = Column(Boolean, default=False, nullable=False)
price = Column(Numeric(10, 2), nullable=True) # For cost splitting later (e.g., 12345678.99) price = Column(Numeric(10, 2), nullable=True)
position = Column(Integer, nullable=False, server_default='0') # For ordering position = Column(Integer, nullable=False, server_default='0')
added_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) # Who added this item added_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
completed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Who marked it complete completed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
version = Column(Integer, nullable=False, default=1, server_default='1') version = Column(Integer, nullable=False, default=1, server_default='1')
# --- Relationships --- # --- Relationships ---
list = relationship("List", back_populates="items") # Link to List.items list = relationship("List", back_populates="items")
added_by_user = relationship("User", foreign_keys=[added_by_id], back_populates="added_items") # Link to User.added_items added_by_user = relationship("User", foreign_keys=[added_by_id], back_populates="added_items")
completed_by_user = relationship("User", foreign_keys=[completed_by_id], back_populates="completed_items") # Link to User.completed_items completed_by_user = relationship("User", foreign_keys=[completed_by_id], back_populates="completed_items")
expenses = relationship("Expense", back_populates="item")
# --- Relationships for Cost Splitting ---
# If an item directly results in an expense, or an expense can be tied to an item.
expenses = relationship("Expense", back_populates="item") # An item might have multiple associated expenses
# --- End Relationships for Cost Splitting ---
# === NEW Models for Advanced Cost Splitting ===
class Expense(Base): class Expense(Base):
__tablename__ = "expenses" __tablename__ = "expenses"
@ -268,7 +221,6 @@ class Expense(Base):
expense_date = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) expense_date = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
split_type = Column(SAEnum(SplitTypeEnum, name="splittypeenum", create_type=True), nullable=False) split_type = Column(SAEnum(SplitTypeEnum, name="splittypeenum", create_type=True), nullable=False)
# Foreign Keys
list_id = Column(Integer, ForeignKey("lists.id"), nullable=True, index=True) list_id = Column(Integer, ForeignKey("lists.id"), nullable=True, index=True)
group_id = Column(Integer, ForeignKey("groups.id"), nullable=True, index=True) group_id = Column(Integer, ForeignKey("groups.id"), nullable=True, index=True)
item_id = Column(Integer, ForeignKey("items.id"), nullable=True) item_id = Column(Integer, ForeignKey("items.id"), nullable=True)
@ -279,7 +231,6 @@ class Expense(Base):
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
version = Column(Integer, nullable=False, default=1, server_default='1') version = Column(Integer, nullable=False, default=1, server_default='1')
# Relationships
paid_by_user = relationship("User", foreign_keys=[paid_by_user_id], back_populates="expenses_paid") paid_by_user = relationship("User", foreign_keys=[paid_by_user_id], back_populates="expenses_paid")
created_by_user = relationship("User", foreign_keys=[created_by_user_id], back_populates="expenses_created") created_by_user = relationship("User", foreign_keys=[created_by_user_id], back_populates="expenses_created")
list = relationship("List", foreign_keys=[list_id], back_populates="expenses") list = relationship("List", foreign_keys=[list_id], back_populates="expenses")
@ -289,7 +240,6 @@ class Expense(Base):
parent_expense = relationship("Expense", remote_side=[id], back_populates="child_expenses") parent_expense = relationship("Expense", remote_side=[id], back_populates="child_expenses")
child_expenses = relationship("Expense", back_populates="parent_expense") child_expenses = relationship("Expense", back_populates="parent_expense")
overall_settlement_status = Column(SAEnum(ExpenseOverallStatusEnum, name="expenseoverallstatusenum", create_type=True), nullable=False, server_default=ExpenseOverallStatusEnum.unpaid.value, default=ExpenseOverallStatusEnum.unpaid) overall_settlement_status = Column(SAEnum(ExpenseOverallStatusEnum, name="expenseoverallstatusenum", create_type=True), nullable=False, server_default=ExpenseOverallStatusEnum.unpaid.value, default=ExpenseOverallStatusEnum.unpaid)
# --- Recurrence fields ---
is_recurring = Column(Boolean, default=False, nullable=False) is_recurring = Column(Boolean, default=False, nullable=False)
recurrence_pattern_id = Column(Integer, ForeignKey("recurrence_patterns.id"), nullable=True) recurrence_pattern_id = Column(Integer, ForeignKey("recurrence_patterns.id"), nullable=True)
recurrence_pattern = relationship("RecurrencePattern", back_populates="expenses", uselist=False) # One-to-one recurrence_pattern = relationship("RecurrencePattern", back_populates="expenses", uselist=False) # One-to-one
@ -298,7 +248,6 @@ class Expense(Base):
last_occurrence = Column(DateTime(timezone=True), nullable=True) last_occurrence = Column(DateTime(timezone=True), nullable=True)
__table_args__ = ( __table_args__ = (
# Ensure at least one context is provided
CheckConstraint('(item_id IS NOT NULL) OR (list_id IS NOT NULL) OR (group_id IS NOT NULL)', name='chk_expense_context'), CheckConstraint('(item_id IS NOT NULL) OR (list_id IS NOT NULL) OR (group_id IS NOT NULL)', name='chk_expense_context'),
) )
@ -320,14 +269,12 @@ class ExpenseSplit(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
# Relationships
expense = relationship("Expense", back_populates="splits") expense = relationship("Expense", back_populates="splits")
user = relationship("User", foreign_keys=[user_id], back_populates="expense_splits") user = relationship("User", foreign_keys=[user_id], back_populates="expense_splits")
settlement_activities = relationship("SettlementActivity", back_populates="split", cascade="all, delete-orphan") settlement_activities = relationship("SettlementActivity", back_populates="split", cascade="all, delete-orphan")
# New fields for tracking payment status
status = Column(SAEnum(ExpenseSplitStatusEnum, name="expensesplitstatusenum", create_type=True), nullable=False, server_default=ExpenseSplitStatusEnum.unpaid.value, default=ExpenseSplitStatusEnum.unpaid) status = Column(SAEnum(ExpenseSplitStatusEnum, name="expensesplitstatusenum", create_type=True), nullable=False, server_default=ExpenseSplitStatusEnum.unpaid.value, default=ExpenseSplitStatusEnum.unpaid)
paid_at = Column(DateTime(timezone=True), nullable=True) # Timestamp when the split was fully paid paid_at = Column(DateTime(timezone=True), nullable=True)
class Settlement(Base): class Settlement(Base):
__tablename__ = "settlements" __tablename__ = "settlements"
@ -345,33 +292,28 @@ class Settlement(Base):
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
version = Column(Integer, nullable=False, default=1, server_default='1') version = Column(Integer, nullable=False, default=1, server_default='1')
# Relationships
group = relationship("Group", foreign_keys=[group_id], back_populates="settlements") group = relationship("Group", foreign_keys=[group_id], back_populates="settlements")
payer = relationship("User", foreign_keys=[paid_by_user_id], back_populates="settlements_made") payer = relationship("User", foreign_keys=[paid_by_user_id], back_populates="settlements_made")
payee = relationship("User", foreign_keys=[paid_to_user_id], back_populates="settlements_received") payee = relationship("User", foreign_keys=[paid_to_user_id], back_populates="settlements_received")
created_by_user = relationship("User", foreign_keys=[created_by_user_id], back_populates="settlements_created") created_by_user = relationship("User", foreign_keys=[created_by_user_id], back_populates="settlements_created")
__table_args__ = ( __table_args__ = (
# Ensure payer and payee are different users
CheckConstraint('paid_by_user_id != paid_to_user_id', name='chk_settlement_different_users'), CheckConstraint('paid_by_user_id != paid_to_user_id', name='chk_settlement_different_users'),
) )
# Potential future: PaymentMethod model, etc.
class SettlementActivity(Base): class SettlementActivity(Base):
__tablename__ = "settlement_activities" __tablename__ = "settlement_activities"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
expense_split_id = Column(Integer, ForeignKey("expense_splits.id"), nullable=False, index=True) expense_split_id = Column(Integer, ForeignKey("expense_splits.id"), nullable=False, index=True)
paid_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) # User who made this part of the payment paid_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
paid_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) paid_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
amount_paid = Column(Numeric(10, 2), nullable=False) amount_paid = Column(Numeric(10, 2), nullable=False)
created_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) # User who recorded this activity created_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
# --- Relationships ---
split = relationship("ExpenseSplit", back_populates="settlement_activities") split = relationship("ExpenseSplit", back_populates="settlement_activities")
payer = relationship("User", foreign_keys=[paid_by_user_id], backref="made_settlement_activities") payer = relationship("User", foreign_keys=[paid_by_user_id], backref="made_settlement_activities")
creator = relationship("User", foreign_keys=[created_by_user_id], backref="created_settlement_activities") creator = relationship("User", foreign_keys=[created_by_user_id], backref="created_settlement_activities")
@ -395,15 +337,14 @@ class Chore(Base):
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
frequency = Column(SAEnum(ChoreFrequencyEnum, name="chorefrequencyenum", create_type=True), nullable=False) frequency = Column(SAEnum(ChoreFrequencyEnum, name="chorefrequencyenum", create_type=True), nullable=False)
custom_interval_days = Column(Integer, nullable=True) # Only if frequency is 'custom' custom_interval_days = Column(Integer, nullable=True)
next_due_date = Column(Date, nullable=False) # Changed to Date next_due_date = Column(Date, nullable=False)
last_completed_at = Column(DateTime(timezone=True), nullable=True) last_completed_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
# --- Relationships ---
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")
@ -418,14 +359,13 @@ class ChoreAssignment(Base):
chore_id = Column(Integer, ForeignKey("chores.id", ondelete="CASCADE"), nullable=False, index=True) chore_id = Column(Integer, ForeignKey("chores.id", ondelete="CASCADE"), nullable=False, index=True)
assigned_to_user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) assigned_to_user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
due_date = Column(Date, nullable=False) # Specific due date for this instance, changed to Date due_date = Column(Date, nullable=False)
is_complete = Column(Boolean, default=False, nullable=False) is_complete = Column(Boolean, default=False, nullable=False)
completed_at = Column(DateTime(timezone=True), nullable=True) completed_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
# --- 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") history = relationship("ChoreAssignmentHistory", back_populates="assignment", cascade="all, delete-orphan")
@ -437,21 +377,14 @@ class RecurrencePattern(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
type = Column(SAEnum(RecurrenceTypeEnum, name="recurrencetypeenum", create_type=True), nullable=False) type = Column(SAEnum(RecurrenceTypeEnum, name="recurrencetypeenum", create_type=True), nullable=False)
interval = Column(Integer, default=1, nullable=False) # e.g., every 1 day, every 2 weeks interval = Column(Integer, default=1, nullable=False)
days_of_week = Column(String, nullable=True) # For weekly recurrences, e.g., "MON,TUE,FRI" days_of_week = Column(String, nullable=True)
# day_of_month = Column(Integer, nullable=True) # For monthly on a specific day
# week_of_month = Column(Integer, nullable=True) # For monthly on a specific week (e.g., 2nd week)
# month_of_year = Column(Integer, nullable=True) # For yearly recurrences
end_date = Column(DateTime(timezone=True), nullable=True) end_date = Column(DateTime(timezone=True), nullable=True)
max_occurrences = Column(Integer, nullable=True) max_occurrences = Column(Integer, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
# Relationship back to Expenses that use this pattern (could be one-to-many if patterns are shared)
# However, the current CRUD implies one RecurrencePattern per Expense if recurring.
# If a pattern can be shared, this would be a one-to-many (RecurrencePattern to many Expenses).
# For now, assuming one-to-one as implied by current Expense.recurrence_pattern relationship setup.
expenses = relationship("Expense", back_populates="recurrence_pattern") expenses = relationship("Expense", back_populates="recurrence_pattern")
@ -464,13 +397,12 @@ class ChoreHistory(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
chore_id = Column(Integer, ForeignKey("chores.id", ondelete="CASCADE"), nullable=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 group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=True, index=True)
event_type = Column(SAEnum(ChoreHistoryEventTypeEnum, name="chorehistoryeventtypeenum", create_type=True), nullable=False) 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'} event_data = Column(JSONB, nullable=True)
changed_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Nullable if system-generated changed_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# --- Relationships ---
chore = relationship("Chore", back_populates="history") chore = relationship("Chore", back_populates="history")
group = relationship("Group", back_populates="chore_history") group = relationship("Group", back_populates="chore_history")
changed_by_user = relationship("User", back_populates="chore_history_entries") changed_by_user = relationship("User", back_populates="chore_history_entries")
@ -480,11 +412,10 @@ class ChoreAssignmentHistory(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
assignment_id = Column(Integer, ForeignKey("chore_assignments.id", ondelete="CASCADE"), nullable=False, 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_type = Column(SAEnum(ChoreHistoryEventTypeEnum, name="chorehistoryeventtypeenum", create_type=True), nullable=False)
event_data = Column(JSONB, nullable=True) event_data = Column(JSONB, nullable=True)
changed_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True) changed_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# --- Relationships ---
assignment = relationship("ChoreAssignment", back_populates="history") assignment = relationship("ChoreAssignment", back_populates="history")
changed_by_user = relationship("User", back_populates="assignment_history_entries") changed_by_user = relationship("User", back_populates="assignment_history_entries")

View File

@ -1,13 +1,7 @@
# app/schemas/auth.py from pydantic import BaseModel
from pydantic import BaseModel, EmailStr
from app.config import settings from app.config import settings
class Token(BaseModel): class Token(BaseModel):
access_token: str access_token: str
refresh_token: str # Added refresh token refresh_token: str
token_type: str = settings.TOKEN_TYPE # Use configured token type token_type: str = settings.TOKEN_TYPE
# Optional: If you preferred not to use OAuth2PasswordRequestForm
# class UserLogin(BaseModel):
# email: EmailStr
# password: str

View File

@ -1,18 +1,12 @@
from datetime import date, datetime from datetime import date, datetime
from typing import Optional, List, Any from typing import Optional, List, Any
from pydantic import BaseModel, ConfigDict, field_validator from pydantic import BaseModel, ConfigDict, field_validator
from ..models import ChoreFrequencyEnum, ChoreTypeEnum, ChoreHistoryEventTypeEnum
from .user import UserPublic
# 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, ChoreHistoryEventTypeEnum # For UserPublic relation
from .user import UserPublic # For embedding user information
# Forward declaration for circular dependencies
class ChoreAssignmentPublic(BaseModel): class ChoreAssignmentPublic(BaseModel):
pass pass
# History Schemas
class ChoreHistoryPublic(BaseModel): class ChoreHistoryPublic(BaseModel):
id: int id: int
event_type: ChoreHistoryEventTypeEnum event_type: ChoreHistoryEventTypeEnum
@ -32,7 +26,6 @@ class ChoreAssignmentHistoryPublic(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
# Chore Schemas
class ChoreBase(BaseModel): class ChoreBase(BaseModel):
name: str name: str
description: Optional[str] = None description: Optional[str] = None

View File

@ -4,10 +4,10 @@ from decimal import Decimal
class UserCostShare(BaseModel): class UserCostShare(BaseModel):
user_id: int user_id: int
user_identifier: str # Name or email user_identifier: str
items_added_value: Decimal = Decimal("0.00") # Total value of items this user added items_added_value: Decimal = Decimal("0.00")
amount_due: Decimal # The user's share of the total cost (for equal split, this is total_cost / num_users) amount_due: Decimal
balance: Decimal # items_added_value - amount_due balance: Decimal
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@ -23,19 +23,19 @@ class ListCostSummary(BaseModel):
class UserBalanceDetail(BaseModel): class UserBalanceDetail(BaseModel):
user_id: int user_id: int
user_identifier: str # Name or email user_identifier: str
total_paid_for_expenses: Decimal = Decimal("0.00") total_paid_for_expenses: Decimal = Decimal("0.00")
total_share_of_expenses: Decimal = Decimal("0.00") total_share_of_expenses: Decimal = Decimal("0.00")
total_settlements_paid: Decimal = Decimal("0.00") total_settlements_paid: Decimal = Decimal("0.00")
total_settlements_received: Decimal = Decimal("0.00") total_settlements_received: Decimal = Decimal("0.00")
net_balance: Decimal = Decimal("0.00") # (paid_for_expenses + settlements_received) - (share_of_expenses + settlements_paid) net_balance: Decimal = Decimal("0.00")
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
class SuggestedSettlement(BaseModel): class SuggestedSettlement(BaseModel):
from_user_id: int from_user_id: int
from_user_identifier: str # Name or email of payer from_user_identifier: str
to_user_id: int to_user_id: int
to_user_identifier: str # Name or email of payee to_user_identifier: str
amount: Decimal amount: Decimal
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@ -45,11 +45,5 @@ class GroupBalanceSummary(BaseModel):
overall_total_expenses: Decimal = Decimal("0.00") overall_total_expenses: Decimal = Decimal("0.00")
overall_total_settlements: Decimal = Decimal("0.00") overall_total_settlements: Decimal = Decimal("0.00")
user_balances: List[UserBalanceDetail] user_balances: List[UserBalanceDetail]
# Optional: Could add a list of suggested settlements to zero out balances
suggested_settlements: Optional[List[SuggestedSettlement]] = None suggested_settlements: Optional[List[SuggestedSettlement]] = None
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
# class SuggestedSettlement(BaseModel):
# from_user_id: int
# to_user_id: int
# amount: Decimal

View File

@ -1,19 +1,11 @@
# app/schemas/expense.py
from pydantic import BaseModel, ConfigDict, validator, Field from pydantic import BaseModel, ConfigDict, validator, Field
from typing import List, Optional, Dict, Any from typing import List, Optional
from decimal import Decimal from decimal import Decimal
from datetime import datetime from datetime import datetime
from app.models import SplitTypeEnum, ExpenseSplitStatusEnum, ExpenseOverallStatusEnum
from app.schemas.user import UserPublic
from app.schemas.settlement_activity import SettlementActivityPublic
# Assuming SplitTypeEnum is accessible here, e.g., from app.models or app.core.enums
# For now, let's redefine it or import it if models.py is parsable by Pydantic directly
# If it's from app.models, you might need to make app.models.SplitTypeEnum Pydantic-compatible or map it.
# For simplicity during schema definition, I'll redefine a string enum here.
# In a real setup, ensure this aligns with the SQLAlchemy enum in models.py.
from app.models import SplitTypeEnum, ExpenseSplitStatusEnum, ExpenseOverallStatusEnum # Try importing directly
from app.schemas.user import UserPublic # For user details in responses
from app.schemas.settlement_activity import SettlementActivityPublic # For settlement activities
# --- ExpenseSplit Schemas ---
class ExpenseSplitBase(BaseModel): class ExpenseSplitBase(BaseModel):
user_id: int user_id: int
owed_amount: Decimal owed_amount: Decimal
@ -21,20 +13,19 @@ class ExpenseSplitBase(BaseModel):
share_units: Optional[int] = None share_units: Optional[int] = None
class ExpenseSplitCreate(ExpenseSplitBase): class ExpenseSplitCreate(ExpenseSplitBase):
pass # All fields from base are needed for creation pass
class ExpenseSplitPublic(ExpenseSplitBase): class ExpenseSplitPublic(ExpenseSplitBase):
id: int id: int
expense_id: int expense_id: int
user: Optional[UserPublic] = None # If we want to nest user details user: Optional[UserPublic] = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
status: ExpenseSplitStatusEnum # New field status: ExpenseSplitStatusEnum
paid_at: Optional[datetime] = None # New field paid_at: Optional[datetime] = None
settlement_activities: List[SettlementActivityPublic] = [] # New field settlement_activities: List[SettlementActivityPublic] = []
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
# --- Expense Schemas ---
class RecurrencePatternBase(BaseModel): class RecurrencePatternBase(BaseModel):
type: str = Field(..., description="Type of recurrence: daily, weekly, monthly, yearly") type: str = Field(..., description="Type of recurrence: daily, weekly, monthly, yearly")
interval: int = Field(..., description="Interval of recurrence (e.g., every X days/weeks/months/years)") interval: int = Field(..., description="Interval of recurrence (e.g., every X days/weeks/months/years)")
@ -63,16 +54,13 @@ class ExpenseBase(BaseModel):
expense_date: Optional[datetime] = None expense_date: Optional[datetime] = None
split_type: SplitTypeEnum split_type: SplitTypeEnum
list_id: Optional[int] = None list_id: Optional[int] = None
group_id: Optional[int] = None # Should be present if list_id is not, and vice-versa group_id: Optional[int] = None
item_id: Optional[int] = None item_id: Optional[int] = None
paid_by_user_id: int paid_by_user_id: int
is_recurring: bool = Field(False, description="Whether this is a recurring expense") is_recurring: bool = Field(False, description="Whether this is a recurring expense")
recurrence_pattern: Optional[RecurrencePatternCreate] = Field(None, description="Recurrence pattern for recurring expenses") recurrence_pattern: Optional[RecurrencePatternCreate] = Field(None, description="Recurrence pattern for recurring expenses")
class ExpenseCreate(ExpenseBase): class ExpenseCreate(ExpenseBase):
# For EQUAL split, splits are generated. For others, they might be provided.
# This logic will be in the CRUD: if split_type is EXACT_AMOUNTS, PERCENTAGE, SHARES,
# then 'splits_in' should be provided.
splits_in: Optional[List[ExpenseSplitCreate]] = None splits_in: Optional[List[ExpenseSplitCreate]] = None
@validator('total_amount') @validator('total_amount')
@ -81,8 +69,6 @@ class ExpenseCreate(ExpenseBase):
raise ValueError('Total amount must be positive') raise ValueError('Total amount must be positive')
return v return v
# Basic validation: if list_id is None, group_id must be provided.
# More complex cross-field validation might be needed.
@validator('group_id', always=True) @validator('group_id', always=True)
def check_list_or_group_id(cls, v, values): def check_list_or_group_id(cls, v, values):
if values.get('list_id') is None and v is None: if values.get('list_id') is None and v is None:
@ -105,10 +91,8 @@ class ExpenseUpdate(BaseModel):
split_type: Optional[SplitTypeEnum] = None split_type: Optional[SplitTypeEnum] = None
list_id: Optional[int] = None list_id: Optional[int] = None
group_id: Optional[int] = None group_id: Optional[int] = None
item_id: Optional[int] = None item_id: Optional[int] = None
# paid_by_user_id is usually not updatable directly to maintain integrity. version: int
# Updating splits would be a more complex operation, potentially a separate endpoint or careful logic.
version: int # For optimistic locking
is_recurring: Optional[bool] = None is_recurring: Optional[bool] = None
recurrence_pattern: Optional[RecurrencePatternUpdate] = None recurrence_pattern: Optional[RecurrencePatternUpdate] = None
next_occurrence: Optional[datetime] = None next_occurrence: Optional[datetime] = None
@ -120,11 +104,8 @@ class ExpensePublic(ExpenseBase):
version: int version: int
created_by_user_id: int created_by_user_id: int
splits: List[ExpenseSplitPublic] = [] splits: List[ExpenseSplitPublic] = []
paid_by_user: Optional[UserPublic] = None # If nesting user details paid_by_user: Optional[UserPublic] = None
overall_settlement_status: ExpenseOverallStatusEnum # New field overall_settlement_status: ExpenseOverallStatusEnum
# list: Optional[ListPublic] # If nesting list details
# group: Optional[GroupPublic] # If nesting group details
# item: Optional[ItemPublic] # If nesting item details
is_recurring: bool is_recurring: bool
next_occurrence: Optional[datetime] next_occurrence: Optional[datetime]
last_occurrence: Optional[datetime] last_occurrence: Optional[datetime]
@ -133,7 +114,6 @@ class ExpensePublic(ExpenseBase):
generated_expenses: List['ExpensePublic'] = [] generated_expenses: List['ExpensePublic'] = []
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
# --- Settlement Schemas ---
class SettlementBase(BaseModel): class SettlementBase(BaseModel):
group_id: int group_id: int
paid_by_user_id: int paid_by_user_id: int
@ -159,8 +139,7 @@ class SettlementUpdate(BaseModel):
amount: Optional[Decimal] = None amount: Optional[Decimal] = None
settlement_date: Optional[datetime] = None settlement_date: Optional[datetime] = None
description: Optional[str] = None description: Optional[str] = None
# group_id, paid_by_user_id, paid_to_user_id are typically not updatable. version: int
version: int # For optimistic locking
class SettlementPublic(SettlementBase): class SettlementPublic(SettlementBase):
id: int id: int
@ -168,13 +147,4 @@ class SettlementPublic(SettlementBase):
updated_at: datetime updated_at: datetime
version: int version: int
created_by_user_id: int created_by_user_id: int
# payer: Optional[UserPublic] # If we want to include payer details model_config = ConfigDict(from_attributes=True)
# payee: Optional[UserPublic] # If we want to include payee details
# group: Optional[GroupPublic] # If we want to include group details
model_config = ConfigDict(from_attributes=True)
# Placeholder for nested schemas (e.g., UserPublic) if needed
# from app.schemas.user import UserPublic
# from app.schemas.list import ListPublic
# from app.schemas.group import GroupPublic
# from app.schemas.item import ItemPublic

View File

@ -1,22 +1,17 @@
# app/schemas/group.py
from pydantic import BaseModel, ConfigDict, computed_field from pydantic import BaseModel, ConfigDict, computed_field
from datetime import datetime, date from datetime import datetime, date
from typing import Optional, List from typing import Optional, List
from .user import UserPublic
from .chore import ChoreHistoryPublic
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): class GroupCreate(BaseModel):
name: str name: str
# New schema for generating a schedule
class GroupScheduleGenerateRequest(BaseModel): class GroupScheduleGenerateRequest(BaseModel):
start_date: date start_date: date
end_date: date end_date: date
member_ids: Optional[List[int]] = None # Optional: if not provided, use all members member_ids: Optional[List[int]] = None
# Properties to return to client
class GroupPublic(BaseModel): class GroupPublic(BaseModel):
id: int id: int
name: str name: str
@ -34,7 +29,6 @@ class GroupPublic(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
# Properties for UserGroup association
class UserGroupPublic(BaseModel): class UserGroupPublic(BaseModel):
id: int id: int
user_id: int user_id: int
@ -45,9 +39,4 @@ class UserGroupPublic(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
# Properties stored in DB (if needed, often GroupPublic is sufficient)
# class GroupInDB(GroupPublic):
# pass
# We need to rebuild GroupPublic to resolve the forward reference to UserGroupPublic
GroupPublic.model_rebuild() GroupPublic.model_rebuild()

View File

@ -1,4 +1,4 @@
# app/schemas/health.py
from pydantic import BaseModel from pydantic import BaseModel
from app.config import settings from app.config import settings
@ -6,5 +6,5 @@ class HealthStatus(BaseModel):
""" """
Response model for the health check endpoint. Response model for the health check endpoint.
""" """
status: str = settings.HEALTH_STATUS_OK # Use configured default value status: str = settings.HEALTH_STATUS_OK
database: str database: str

View File

@ -1,12 +1,9 @@
# app/schemas/invite.py
from pydantic import BaseModel from pydantic import BaseModel
from datetime import datetime from datetime import datetime
# Properties to receive when accepting an invite
class InviteAccept(BaseModel): class InviteAccept(BaseModel):
code: str code: str
# Properties to return when an invite is created
class InviteCodePublic(BaseModel): class InviteCodePublic(BaseModel):
code: str code: str
expires_at: datetime expires_at: datetime

View File

@ -1,10 +1,8 @@
# app/schemas/item.py
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from decimal import Decimal from decimal import Decimal
# Properties to return to client
class ItemPublic(BaseModel): class ItemPublic(BaseModel):
id: int id: int
list_id: int list_id: int
@ -19,19 +17,14 @@ class ItemPublic(BaseModel):
version: int version: int
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
# Properties to receive via API on creation
class ItemCreate(BaseModel): class ItemCreate(BaseModel):
name: str name: str
quantity: Optional[str] = None quantity: Optional[str] = None
# list_id will be from path param
# added_by_id will be from current_user
# Properties to receive via API on update
class ItemUpdate(BaseModel): class ItemUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None
quantity: Optional[str] = None quantity: Optional[str] = None
is_complete: Optional[bool] = None is_complete: Optional[bool] = None
price: Optional[Decimal] = None # Price added here for update price: Optional[Decimal] = None
position: Optional[int] = None # For reordering position: Optional[int] = None
version: int version: int
# completed_by_id will be set internally if is_complete is true

View File

@ -1,25 +1,20 @@
# app/schemas/list.py
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from datetime import datetime from datetime import datetime
from typing import Optional, List from typing import Optional, List
from .item import ItemPublic # Import item schema for nesting from .item import ItemPublic
# Properties to receive via API on creation
class ListCreate(BaseModel): class ListCreate(BaseModel):
name: str name: str
description: Optional[str] = None description: Optional[str] = None
group_id: Optional[int] = None # Optional for sharing group_id: Optional[int] = None
# Properties to receive via API on update
class ListUpdate(BaseModel): class ListUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
is_complete: Optional[bool] = None is_complete: Optional[bool] = None
version: int # Client must provide the version for updates version: int
# Potentially add group_id update later if needed
# Base properties returned by API (common fields)
class ListBase(BaseModel): class ListBase(BaseModel):
id: int id: int
name: str name: str
@ -29,17 +24,15 @@ class ListBase(BaseModel):
is_complete: bool is_complete: bool
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
version: int # Include version in responses version: int
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
# Properties returned when listing lists (no items)
class ListPublic(ListBase): class ListPublic(ListBase):
pass # Inherits all from ListBase pass
# Properties returned for a single list detail (includes items)
class ListDetail(ListBase): class ListDetail(ListBase):
items: List[ItemPublic] = [] # Include list of items items: List[ItemPublic] = []
class ListStatus(BaseModel): class ListStatus(BaseModel):
updated_at: datetime updated_at: datetime

View File

@ -1,4 +1,3 @@
# app/schemas/message.py
from pydantic import BaseModel from pydantic import BaseModel
class Message(BaseModel): class Message(BaseModel):

View File

@ -1,6 +1,5 @@
# app/schemas/ocr.py
from pydantic import BaseModel from pydantic import BaseModel
from typing import List from typing import List
class OcrExtractResponse(BaseModel): class OcrExtractResponse(BaseModel):
extracted_items: List[str] # A list of potential item names extracted_items: List[str]

View File

@ -3,7 +3,7 @@ from typing import Optional, List
from decimal import Decimal from decimal import Decimal
from datetime import datetime from datetime import datetime
from app.schemas.user import UserPublic # Assuming UserPublic is defined here from app.schemas.user import UserPublic
class SettlementActivityBase(BaseModel): class SettlementActivityBase(BaseModel):
expense_split_id: int expense_split_id: int
@ -21,23 +21,13 @@ class SettlementActivityCreate(SettlementActivityBase):
class SettlementActivityPublic(SettlementActivityBase): class SettlementActivityPublic(SettlementActivityBase):
id: int id: int
created_by_user_id: int # User who recorded this activity created_by_user_id: int
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
payer: Optional[UserPublic] = None # User who made this part of the payment payer: Optional[UserPublic] = None
creator: Optional[UserPublic] = None # User who recorded this activity creator: Optional[UserPublic] = None
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
# Schema for updating a settlement activity (if needed in the future)
# class SettlementActivityUpdate(BaseModel):
# amount_paid: Optional[Decimal] = None
# paid_at: Optional[datetime] = None
# @field_validator('amount_paid')
# @classmethod
# def amount_must_be_positive_if_provided(cls, v: Optional[Decimal]) -> Optional[Decimal]:
# if v is not None and v <= Decimal("0"):
# raise ValueError("Amount paid must be a positive value.")
# return v

View File

@ -1,14 +1,11 @@
# app/schemas/user.py
from pydantic import BaseModel, EmailStr, ConfigDict from pydantic import BaseModel, EmailStr, ConfigDict
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
# Shared properties
class UserBase(BaseModel): class UserBase(BaseModel):
email: EmailStr email: EmailStr
name: Optional[str] = None name: Optional[str] = None
# Properties to receive via API on creation
class UserCreate(UserBase): class UserCreate(UserBase):
password: str password: str
@ -22,26 +19,22 @@ class UserCreate(UserBase):
"is_verified": False "is_verified": False
} }
# Properties to receive via API on update
class UserUpdate(UserBase): class UserUpdate(UserBase):
password: Optional[str] = None password: Optional[str] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
is_superuser: Optional[bool] = None is_superuser: Optional[bool] = None
is_verified: Optional[bool] = None is_verified: Optional[bool] = None
# Properties stored in DB
class UserInDBBase(UserBase): class UserInDBBase(UserBase):
id: int id: int
password_hash: str password_hash: str
created_at: datetime created_at: datetime
model_config = ConfigDict(from_attributes=True) # Use orm_mode in Pydantic v1 model_config = ConfigDict(from_attributes=True)
# Additional properties to return via API (excluding password)
class UserPublic(UserBase): class UserPublic(UserBase):
id: int id: int
created_at: datetime created_at: datetime
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
# Full user model including hashed password (for internal use/reading from DB)
class User(UserInDBBase): class User(UserInDBBase):
pass pass

View File

@ -2,7 +2,7 @@
export const API_VERSION = 'v1' export const API_VERSION = 'v1'
// API Base URL // API Base URL
export const API_BASE_URL = (window as any).ENV?.VITE_API_URL || 'https://mitlistbe.mohamad.dev' export const API_BASE_URL = (window as any).ENV?.VITE_API_URL || 'http://localhost:8000'
// API Endpoints // API Endpoints
export const API_ENDPOINTS = { export const API_ENDPOINTS = {
@ -33,6 +33,7 @@ export const API_ENDPOINTS = {
BASE: '/lists', BASE: '/lists',
BY_ID: (id: string) => `/lists/${id}`, BY_ID: (id: string) => `/lists/${id}`,
STATUS: (id: string) => `/lists/${id}/status`, STATUS: (id: string) => `/lists/${id}/status`,
STATUSES: '/lists/statuses',
ITEMS: (listId: string) => `/lists/${listId}/items`, ITEMS: (listId: string) => `/lists/${listId}/items`,
ITEM: (listId: string, itemId: string) => `/lists/${listId}/items/${itemId}`, ITEM: (listId: string, itemId: string) => `/lists/${listId}/items/${itemId}`,
EXPENSES: (listId: string) => `/lists/${listId}/expenses`, EXPENSES: (listId: string) => `/lists/${listId}/expenses`,

View File

@ -7,7 +7,6 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
// No specific logic for AuthLayout
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -15,13 +14,12 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
min-height: 100vh;
background-color: var(--bg-color-page, #f0f2f5); background-color: var(--bg-color-page, #f0f2f5);
} }
.auth-page-container { .auth-page-container {
width: 100%; width: 100%;
max-width: 450px; // Max width for login/signup forms max-width: 450px;
padding: 2rem; padding: 2rem;
} }
</style> </style>

View File

@ -4,9 +4,7 @@
<header class="app-header"> <header class="app-header">
<div class="toolbar-title">mitlist</div> <div class="toolbar-title">mitlist</div>
<!-- Group all authenticated controls for cleaner conditional rendering -->
<div v-if="authStore.isAuthenticated" class="header-controls"> <div v-if="authStore.isAuthenticated" class="header-controls">
<!-- Add Menu -->
<div class="control-item"> <div class="control-item">
<button ref="addMenuTrigger" class="icon-button" :class="{ 'is-active': addMenuOpen }" <button ref="addMenuTrigger" class="icon-button" :class="{ 'is-active': addMenuOpen }"
:aria-expanded="addMenuOpen" aria-controls="add-menu-dropdown" aria-label="Add new list or group" :aria-expanded="addMenuOpen" aria-controls="add-menu-dropdown" aria-label="Add new list or group"
@ -23,7 +21,6 @@
</Transition> </Transition>
</div> </div>
<!-- Language Menu -->
<div class="control-item"> <div class="control-item">
<button ref="languageMenuTrigger" class="icon-button language-button" <button ref="languageMenuTrigger" class="icon-button language-button"
:class="{ 'is-active': languageMenuOpen }" :aria-expanded="languageMenuOpen" :class="{ 'is-active': languageMenuOpen }" :aria-expanded="languageMenuOpen"
@ -44,12 +41,10 @@
</Transition> </Transition>
</div> </div>
<!-- User Menu -->
<div class="control-item"> <div class="control-item">
<button ref="userMenuTrigger" class="icon-button user-menu-button" :class="{ 'is-active': userMenuOpen }" <button ref="userMenuTrigger" class="icon-button user-menu-button" :class="{ 'is-active': userMenuOpen }"
:aria-expanded="userMenuOpen" aria-controls="user-menu-dropdown" aria-label="User menu" :aria-expanded="userMenuOpen" aria-controls="user-menu-dropdown" aria-label="User menu"
@click="toggleUserMenu"> @click="toggleUserMenu">
<!-- Show user avatar if available, otherwise a fallback icon -->
<img v-if="authStore.user?.avatarUrl" :src="authStore.user.avatarUrl" alt="User Avatar" <img v-if="authStore.user?.avatarUrl" :src="authStore.user.avatarUrl" alt="User Avatar"
class="user-avatar" /> class="user-avatar" />
<span v-else class="material-icons">account_circle</span> <span v-else class="material-icons">account_circle</span>
@ -67,9 +62,7 @@
</div> </div>
</header> </header>
<!-- =================================================================
MAIN CONTENT
================================================================== -->
<main class="page-container"> <main class="page-container">
<router-view v-slot="{ Component, route }"> <router-view v-slot="{ Component, route }">
<keep-alive v-if="route.meta.keepAlive"> <keep-alive v-if="route.meta.keepAlive">
@ -81,32 +74,24 @@
<OfflineIndicator /> <OfflineIndicator />
<!-- =================================================================
FOOTER NAVIGATION
Improved with more semantic router-links and better active state styling.
================================================================== -->
<footer class="app-footer"> <footer class="app-footer">
<nav class="tabs"> <nav class="tabs">
<router-link to="/lists" class="tab-item" active-class="active"> <router-link to="/lists" class="tab-item" active-class="active">
<span class="material-icons">list</span> <span class="material-icons">list</span>
<span class="tab-text">Lists</span> <span class="tab-text">Lists</span>
</router-link> </router-link>
<!-- Use a RouterLink for semantics and a11y, but keep custom click handler -->
<router-link to="/groups" class="tab-item" :class="{ 'active': $route.path.startsWith('/groups') }" <router-link to="/groups" class="tab-item" :class="{ 'active': $route.path.startsWith('/groups') }"
@click.prevent="navigateToGroups"> @click.prevent="navigateToGroups">
<span class="material-icons">group</span> <span class="material-icons">group</span>
<span class="tab-text">Groups</span> <span class="tab-text">Groups</span>
</router-link> </router-link>
<router-link to="/chores" class="tab-item" active-class="active"> <router-link to="/chores" class="tab-item" active-class="active">
<span class="material-icons">task_alt</span> <!-- More appropriate icon for chores --> <span class="material-icons">task_alt</span>
<span class="tab-text">Chores</span> <span class="tab-text">Chores</span>
</router-link> </router-link>
</nav> </nav>
</footer> </footer>
<!-- =================================================================
MODALS
================================================================== -->
<CreateListModal v-model="showCreateListModal" @created="handleListCreated" /> <CreateListModal v-model="showCreateListModal" @created="handleListCreated" />
<CreateGroupModal v-model="showCreateGroupModal" @created="handleGroupCreated" /> <CreateGroupModal v-model="showCreateGroupModal" @created="handleGroupCreated" />
</div> </div>
@ -124,7 +109,6 @@ import CreateListModal from '@/components/CreateListModal.vue';
import CreateGroupModal from '@/components/CreateGroupModal.vue'; import CreateGroupModal from '@/components/CreateGroupModal.vue';
import { onClickOutside } from '@vueuse/core'; import { onClickOutside } from '@vueuse/core';
// Store and Router setup
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const authStore = useAuthStore(); const authStore = useAuthStore();
@ -132,30 +116,24 @@ const notificationStore = useNotificationStore();
const groupStore = useGroupStore(); const groupStore = useGroupStore();
const { t, locale } = useI18n(); const { t, locale } = useI18n();
// --- Dropdown Logic (Re-integrated from composable) ---
// 1. Add Menu Dropdown
const addMenuOpen = ref(false); const addMenuOpen = ref(false);
const addMenuDropdown = ref<HTMLElement | null>(null); const addMenuDropdown = ref<HTMLElement | null>(null);
const addMenuTrigger = ref<HTMLElement | null>(null); const addMenuTrigger = ref<HTMLElement | null>(null);
const toggleAddMenu = () => { addMenuOpen.value = !addMenuOpen.value; }; const toggleAddMenu = () => { addMenuOpen.value = !addMenuOpen.value; };
onClickOutside(addMenuDropdown, () => { addMenuOpen.value = false; }, { ignore: [addMenuTrigger] }); onClickOutside(addMenuDropdown, () => { addMenuOpen.value = false; }, { ignore: [addMenuTrigger] });
// 2. Language Menu Dropdown
const languageMenuOpen = ref(false); const languageMenuOpen = ref(false);
const languageMenuDropdown = ref<HTMLElement | null>(null); const languageMenuDropdown = ref<HTMLElement | null>(null);
const languageMenuTrigger = ref<HTMLElement | null>(null); const languageMenuTrigger = ref<HTMLElement | null>(null);
const toggleLanguageMenu = () => { languageMenuOpen.value = !languageMenuOpen.value; }; const toggleLanguageMenu = () => { languageMenuOpen.value = !languageMenuOpen.value; };
onClickOutside(languageMenuDropdown, () => { languageMenuOpen.value = false; }, { ignore: [languageMenuTrigger] }); onClickOutside(languageMenuDropdown, () => { languageMenuOpen.value = false; }, { ignore: [languageMenuTrigger] });
// 3. User Menu Dropdown
const userMenuOpen = ref(false); const userMenuOpen = ref(false);
const userMenuDropdown = ref<HTMLElement | null>(null); const userMenuDropdown = ref<HTMLElement | null>(null);
const userMenuTrigger = ref<HTMLElement | null>(null); const userMenuTrigger = ref<HTMLElement | null>(null);
const toggleUserMenu = () => { userMenuOpen.value = !userMenuOpen.value; }; const toggleUserMenu = () => { userMenuOpen.value = !userMenuOpen.value; };
onClickOutside(userMenuDropdown, () => { userMenuOpen.value = false; }, { ignore: [userMenuTrigger] }); onClickOutside(userMenuDropdown, () => { userMenuOpen.value = false; }, { ignore: [userMenuTrigger] });
// --- Language Selector Logic ---
const availableLanguages = computed(() => ({ const availableLanguages = computed(() => ({
en: t('languageSelector.languages.en'), en: t('languageSelector.languages.en'),
de: t('languageSelector.languages.de'), de: t('languageSelector.languages.de'),
@ -168,24 +146,23 @@ const currentLanguageCode = computed(() => locale.value);
const changeLanguage = (languageCode: string) => { const changeLanguage = (languageCode: string) => {
locale.value = languageCode; locale.value = languageCode;
localStorage.setItem('language', languageCode); localStorage.setItem('language', languageCode);
languageMenuOpen.value = false; // Close menu on selection languageMenuOpen.value = false;
notificationStore.addNotification({ notificationStore.addNotification({
type: 'success', type: 'success',
message: `Language changed to ${availableLanguages.value[languageCode as keyof typeof availableLanguages.value]}`, message: `Language changed to ${availableLanguages.value[languageCode as keyof typeof availableLanguages.value]}`,
}); });
}; };
// --- Modal Handling ---
const showCreateListModal = ref(false); const showCreateListModal = ref(false);
const showCreateGroupModal = ref(false); const showCreateGroupModal = ref(false);
const handleAddList = () => { const handleAddList = () => {
addMenuOpen.value = false; // Close menu addMenuOpen.value = false;
showCreateListModal.value = true; showCreateListModal.value = true;
}; };
const handleAddGroup = () => { const handleAddGroup = () => {
addMenuOpen.value = false; // Close menu addMenuOpen.value = false;
showCreateGroupModal.value = true; showCreateGroupModal.value = true;
}; };
@ -197,13 +174,12 @@ const handleListCreated = (newList: any) => {
const handleGroupCreated = (newGroup: any) => { const handleGroupCreated = (newGroup: any) => {
notificationStore.addNotification({ message: `Group '${newGroup.name}' created successfully`, type: 'success' }); notificationStore.addNotification({ message: `Group '${newGroup.name}' created successfully`, type: 'success' });
showCreateGroupModal.value = false; showCreateGroupModal.value = false;
groupStore.fetchGroups(); // Refresh groups after creation groupStore.fetchGroups();
}; };
// --- User and Navigation Logic ---
const handleLogout = async () => { const handleLogout = async () => {
try { try {
userMenuOpen.value = false; // Close menu userMenuOpen.value = false;
authStore.logout(); authStore.logout();
notificationStore.addNotification({ type: 'success', message: 'Logged out successfully' }); notificationStore.addNotification({ type: 'success', message: 'Logged out successfully' });
await router.push('/auth/login'); await router.push('/auth/login');
@ -224,9 +200,7 @@ const navigateToGroups = () => {
} }
}; };
// --- App Initialization ---
onMounted(async () => { onMounted(async () => {
// Fetch essential data for authenticated users
if (authStore.isAuthenticated) { if (authStore.isAuthenticated) {
try { try {
await authStore.fetchCurrentUser(); await authStore.fetchCurrentUser();
@ -236,7 +210,6 @@ onMounted(async () => {
} }
} }
// Load saved language preference
const savedLanguage = localStorage.getItem('language'); const savedLanguage = localStorage.getItem('language');
if (savedLanguage && Object.keys(availableLanguages.value).includes(savedLanguage)) { if (savedLanguage && Object.keys(availableLanguages.value).includes(savedLanguage)) {
locale.value = savedLanguage; locale.value = savedLanguage;
@ -245,14 +218,13 @@ onMounted(async () => {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
// Using Google's outlined icons for a lighter feel
@import url('https://fonts.googleapis.com/icon?family=Material+Icons|Material+Icons+Outlined'); @import url('https://fonts.googleapis.com/icon?family=Material+Icons|Material+Icons+Outlined');
.main-layout { .main-layout {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100vh; min-height: 100vh;
background-color: #f9f9f9; // A slightly off-white background for the main page background-color: #f9f9f9;
} }
.app-header { .app-header {
@ -287,7 +259,7 @@ onMounted(async () => {
.icon-button { .icon-button {
background: none; background: none;
border: 1px solid transparent; // Prevents layout shift on hover border: 1px solid transparent;
color: var(--primary); color: var(--primary);
cursor: pointer; cursor: pointer;
padding: 0.5rem; padding: 0.5rem;
@ -325,7 +297,7 @@ onMounted(async () => {
} }
.user-menu-button { .user-menu-button {
padding: 0; // Remove padding if image is used padding: 0;
width: 40px; width: 40px;
height: 40px; height: 40px;
overflow: hidden; overflow: hidden;
@ -340,14 +312,14 @@ onMounted(async () => {
.dropdown-menu { .dropdown-menu {
position: absolute; position: absolute;
right: 0; right: 0;
top: calc(100% + 8px); // A bit more space top: calc(100% + 8px);
background-color: white; background-color: white;
border: 1px solid #ddd; border: 1px solid #ddd;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 180px; min-width: 180px;
z-index: 101; z-index: 101;
overflow: hidden; // To respect child border-radius overflow: hidden;
a { a {
display: block; display: block;
@ -395,7 +367,6 @@ onMounted(async () => {
font-weight: 500; font-weight: 500;
} }
/* Dropdown transition */
.dropdown-fade-enter-active, .dropdown-fade-enter-active,
.dropdown-fade-leave-active { .dropdown-fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease; transition: opacity 0.2s ease, transform 0.2s ease;
@ -435,7 +406,7 @@ onMounted(async () => {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #757575; // Softer default color color: #757575;
text-decoration: none; text-decoration: none;
padding: 0.5rem 0; padding: 0.5rem 0;
gap: 4px; gap: 4px;

View File

@ -523,7 +523,7 @@ const getDueDateStatus = (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>
@ -544,7 +544,7 @@ const getDueDateStatus = (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>
@ -553,7 +553,7 @@ const getDueDateStatus = (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>
@ -578,7 +578,7 @@ const getDueDateStatus = (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>
@ -603,7 +603,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
<div class="detail-item"> <div class="detail-item">
<span class="label">Created by:</span> <span class="label">Created by:</span>
<span class="value">{{ selectedChore.creator?.name || selectedChore.creator?.email || 'Unknown' <span class="value">{{ selectedChore.creator?.name || selectedChore.creator?.email || 'Unknown'
}}</span> }}</span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<span class="label">Due date:</span> <span class="label">Due date:</span>
@ -635,7 +635,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
<div v-for="assignment in selectedChoreAssignments" :key="assignment.id" class="assignment-item"> <div v-for="assignment in selectedChoreAssignments" :key="assignment.id" class="assignment-item">
<div class="assignment-main"> <div class="assignment-main">
<span class="assigned-user">{{ assignment.assigned_user?.name || assignment.assigned_user?.email <span class="assigned-user">{{ assignment.assigned_user?.name || assignment.assigned_user?.email
}}</span> }}</span>
<span class="assignment-status" :class="{ completed: assignment.is_complete }"> <span class="assignment-status" :class="{ completed: assignment.is_complete }">
{{ assignment.is_complete ? '✅ Completed' : '⏳ Pending' }} {{ assignment.is_complete ? '✅ Completed' : '⏳ Pending' }}
</span> </span>

View File

@ -12,7 +12,6 @@
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const { t } = useI18n(); const { t } = useI18n();
// No script logic needed for this simple page
</script> </script>
<style scoped> <style scoped>
@ -20,16 +19,16 @@ const { t } = useI18n();
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 100vh; /* Fallback for browsers that don't support dvh */ min-height: 100vh;
min-height: 100dvh; min-height: 100dvh;
background-color: var(--secondary-accent); /* Light Blue */ background-color: var(--secondary-accent);
color: var(--dark); color: var(--dark);
padding: 2rem; padding: 2rem;
font-family: "Patrick Hand", cursive; font-family: "Patrick Hand", cursive;
} }
.error-code { .error-code {
font-size: clamp(15vh, 25vw, 30vh); /* Responsive font size */ font-size: clamp(15vh, 25vw, 30vh);
font-weight: bold; font-weight: bold;
color: var(--primary); color: var(--primary);
line-height: 1; line-height: 1;
@ -39,14 +38,16 @@ const { t } = useI18n();
.error-message { .error-message {
font-size: clamp(1.5rem, 4vw, 2.5rem); font-size: clamp(1.5rem, 4vw, 2.5rem);
opacity: 0.8; opacity: 0.8;
margin-top: -1rem; /* Adjust based on font size */ margin-top: -1rem;
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.btn-primary { .btn-primary {
/* Ensure primary button styles are applied if not already by global .btn */
background-color: var(--primary); background-color: var(--primary);
color: var(--dark); color: var(--dark);
} }
.mt-3 { margin-top: 1.5rem; }
.mt-3 {
margin-top: 1.5rem;
}
</style> </style>

View File

@ -42,7 +42,6 @@
+ +
</button> </button>
<!-- Invite Members Popup -->
<div v-show="showInviteUI" ref="inviteUIRef" class="invite-popup"> <div v-show="showInviteUI" ref="inviteUIRef" class="invite-popup">
<div class="popup-header"> <div class="popup-header">
<VHeading :level="3" class="!m-0 !p-0 !border-none">{{ t('groupDetailPage.invites.title') }} <VHeading :level="3" class="!m-0 !p-0 !border-none">{{ t('groupDetailPage.invites.title') }}
@ -72,14 +71,12 @@
</div> </div>
<div class="neo-section-cntainer"> <div class="neo-section-cntainer">
<!-- Lists Section -->
<div class="neo-section"> <div class="neo-section">
<ChoresPage :group-id="groupId" /> <ChoresPage :group-id="groupId" />
<ListsPage :group-id="groupId" /> <ListsPage :group-id="groupId" />
</div> </div>
<!-- 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">
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.expenses.title') }}</VHeading> <VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.expenses.title') }}</VHeading>
@ -181,7 +178,6 @@
<!-- Group Activity Log Section -->
<div class="mt-4 neo-section"> <div class="mt-4 neo-section">
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.activityLog.title') }}</VHeading> <VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.activityLog.title') }}</VHeading>
<div v-if="groupHistoryLoading" class="text-center"> <div v-if="groupHistoryLoading" class="text-center">
@ -199,7 +195,6 @@
<VAlert v-else type="info" :message="t('groupDetailPage.groupNotFound')" /> <VAlert v-else type="info" :message="t('groupDetailPage.groupNotFound')" />
<!-- Settle Share Modal -->
<VModal v-model="showSettleModal" :title="t('groupDetailPage.settleShareModal.title')" <VModal v-model="showSettleModal" :title="t('groupDetailPage.settleShareModal.title')"
@update:modelValue="!$event && closeSettleShareModal()" size="md"> @update:modelValue="!$event && closeSettleShareModal()" size="md">
<template #default> <template #default>
@ -224,18 +219,16 @@
<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> </template>
</VModal> </VModal>
<!-- Enhanced Chore Detail Modal -->
<VModal v-model="showChoreDetailModal" :title="selectedChore?.name" size="lg"> <VModal v-model="showChoreDetailModal" :title="selectedChore?.name" size="lg">
<template #default> <template #default>
<div v-if="selectedChore" class="chore-detail-content"> <div v-if="selectedChore" class="chore-detail-content">
<!-- Chore Overview -->
<div class="chore-overview-section"> <div class="chore-overview-section">
<div class="chore-status-summary"> <div class="chore-status-summary">
<div class="status-badges"> <div class="status-badges">
@ -249,7 +242,7 @@
<div class="meta-item"> <div class="meta-item">
<span class="label">Created by:</span> <span class="label">Created by:</span>
<span class="value">{{ selectedChore.creator?.name || selectedChore.creator?.email || 'Unknown' <span class="value">{{ selectedChore.creator?.name || selectedChore.creator?.email || 'Unknown'
}}</span> }}</span>
</div> </div>
<div class="meta-item"> <div class="meta-item">
<span class="label">Created:</span> <span class="label">Created:</span>
@ -274,7 +267,6 @@
</div> </div>
</div> </div>
<!-- Current Assignments -->
<div class="assignments-section"> <div class="assignments-section">
<VHeading :level="4">{{ t('groupDetailPage.choreDetailModal.assignmentsTitle') }}</VHeading> <VHeading :level="4">{{ t('groupDetailPage.choreDetailModal.assignmentsTitle') }}</VHeading>
<div v-if="loadingAssignments" class="loading-assignments"> <div v-if="loadingAssignments" class="loading-assignments">
@ -284,7 +276,6 @@
<div v-else-if="selectedChoreAssignments.length > 0" class="assignments-list"> <div v-else-if="selectedChoreAssignments.length > 0" class="assignments-list">
<div v-for="assignment in selectedChoreAssignments" :key="assignment.id" class="assignment-card"> <div v-for="assignment in selectedChoreAssignments" :key="assignment.id" class="assignment-card">
<template v-if="editingAssignment?.id === assignment.id"> <template v-if="editingAssignment?.id === assignment.id">
<!-- Inline Editing UI -->
<div class="editing-assignment"> <div class="editing-assignment">
<VFormField label="Assigned to:"> <VFormField label="Assigned to:">
<VSelect v-if="group?.members" <VSelect v-if="group?.members"
@ -339,7 +330,6 @@
<p v-else class="no-assignments">{{ t('groupDetailPage.choreDetailModal.noAssignments') }}</p> <p v-else class="no-assignments">{{ t('groupDetailPage.choreDetailModal.noAssignments') }}</p>
</div> </div>
<!-- Assignment History -->
<div <div
v-if="selectedChore.assignments && selectedChore.assignments.some(a => a.history && a.history.length > 0)" v-if="selectedChore.assignments && selectedChore.assignments.some(a => a.history && a.history.length > 0)"
class="assignment-history-section"> class="assignment-history-section">
@ -358,7 +348,6 @@
</div> </div>
</div> </div>
<!-- Chore History -->
<div class="chore-history-section"> <div class="chore-history-section">
<VHeading :level="4">{{ t('groupDetailPage.choreDetailModal.choreHistoryTitle') }}</VHeading> <VHeading :level="4">{{ t('groupDetailPage.choreDetailModal.choreHistoryTitle') }}</VHeading>
<div v-if="selectedChore.history && selectedChore.history.length > 0" class="history-timeline"> <div v-if="selectedChore.history && selectedChore.history.length > 0" class="history-timeline">
@ -374,7 +363,6 @@
</template> </template>
</VModal> </VModal>
<!-- Generate Schedule Modal -->
<VModal v-model="showGenerateScheduleModal" :title="t('groupDetailPage.generateScheduleModal.title')"> <VModal v-model="showGenerateScheduleModal" :title="t('groupDetailPage.generateScheduleModal.title')">
<VFormField :label="t('groupDetailPage.generateScheduleModal.startDateLabel')"> <VFormField :label="t('groupDetailPage.generateScheduleModal.startDateLabel')">
<VInput type="date" v-model="scheduleForm.start_date" /> <VInput type="date" v-model="scheduleForm.start_date" />
@ -382,7 +370,6 @@
<VFormField :label="t('groupDetailPage.generateScheduleModal.endDateLabel')"> <VFormField :label="t('groupDetailPage.generateScheduleModal.endDateLabel')">
<VInput type="date" v-model="scheduleForm.end_date" /> <VInput type="date" v-model="scheduleForm.end_date" />
</VFormField> </VFormField>
<!-- Member selection can be added here if desired -->
<template #footer> <template #footer>
<VButton @click="showGenerateScheduleModal = false" variant="neutral">{{ t('shared.cancel') }}</VButton> <VButton @click="showGenerateScheduleModal = false" variant="neutral">{{ t('shared.cancel') }}</VButton>
<VButton @click="handleGenerateSchedule" :disabled="generatingSchedule">{{ <VButton @click="handleGenerateSchedule" :disabled="generatingSchedule">{{
@ -396,10 +383,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed, reactive } 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 { apiClient, API_ENDPOINTS } from '@/config/api';
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming path
import { useClipboard, useStorage } from '@vueuse/core'; import { useClipboard, useStorage } from '@vueuse/core';
import ListsPage from './ListsPage.vue'; // Import ListsPage import ListsPage from './ListsPage.vue';
import { useNotificationStore } from '@/stores/notifications'; import { useNotificationStore } from '@/stores/notifications';
import { choreService } from '../services/choreService' import { choreService } from '../services/choreService'
import type { Chore, ChoreFrequency, ChoreAssignment, ChoreHistory, ChoreAssignmentHistory } from '../types/chore' import type { Chore, ChoreFrequency, ChoreAssignment, ChoreHistory, ChoreAssignmentHistory } from '../types/chore'
@ -423,13 +409,12 @@ import VIcon from '@/components/valerie/VIcon.vue';
import VModal from '@/components/valerie/VModal.vue'; import VModal from '@/components/valerie/VModal.vue';
import VSelect from '@/components/valerie/VSelect.vue'; import VSelect from '@/components/valerie/VSelect.vue';
import { onClickOutside } from '@vueuse/core' import { onClickOutside } from '@vueuse/core'
import { groupService } from '../services/groupService'; // New service import { groupService } from '../services/groupService';
import ChoresPage from './ChoresPage.vue'; import ChoresPage from './ChoresPage.vue';
const { t } = useI18n(); const { t } = useI18n();
// Caching setup const CACHE_DURATION = 5 * 60 * 1000;
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
interface CachedGroup { group: Group; timestamp: number; } interface CachedGroup { group: Group; timestamp: number; }
const cachedGroups = useStorage<Record<string, CachedGroup>>('cached-groups-v1', {}); const cachedGroups = useStorage<Record<string, CachedGroup>>('cached-groups-v1', {});
@ -437,9 +422,6 @@ const cachedGroups = useStorage<Record<string, CachedGroup>>('cached-groups-v1',
interface CachedChores { chores: Chore[]; timestamp: number; } interface CachedChores { chores: Chore[]; timestamp: number; }
const cachedUpcomingChores = useStorage<Record<string, CachedChores>>('cached-group-chores-v1', {}); const cachedUpcomingChores = useStorage<Record<string, CachedChores>>('cached-group-chores-v1', {});
// interface CachedExpenses { expenses: Expense[]; timestamp: number; }
// const cachedRecentExpenses = useStorage<Record<string, CachedExpenses>>('cached-group-expenses-v1', {});
interface Group { interface Group {
id: string | number; id: string | number;
name: string; name: string;
@ -456,9 +438,6 @@ const props = defineProps<{
id: string; id: string;
}>(); }>();
// const route = useRoute();
// const $q = useQuasar(); // Not used anymore
const notificationStore = useNotificationStore(); const notificationStore = useNotificationStore();
const group = ref<Group | null>(null); const group = ref<Group | null>(null);
const loading = ref(true); const loading = ref(true);
@ -484,29 +463,24 @@ onClickOutside(inviteUIRef, () => {
showInviteUI.value = false showInviteUI.value = false
}, { ignore: [addMemberButtonRef] }) }, { ignore: [addMemberButtonRef] })
// groupId is directly from props.id now, which comes from the route path param
const groupId = computed(() => props.id); const groupId = computed(() => props.id);
const { copy, copied, isSupported: clipboardIsSupported } = useClipboard({ const { copy, copied, isSupported: clipboardIsSupported } = useClipboard({
source: computed(() => inviteCode.value || '') source: computed(() => inviteCode.value || '')
}); });
// Chores state
const upcomingChores = ref<Chore[]>([]) const upcomingChores = ref<Chore[]>([])
// Add new state for expenses
const recentExpenses = ref<Expense[]>([]) const recentExpenses = ref<Expense[]>([])
const expandedExpenses = ref<Set<number>>(new Set()); const expandedExpenses = ref<Set<number>>(new Set());
const authStore = useAuthStore(); const authStore = useAuthStore();
// Settle Share Modal State
const showSettleModal = ref(false); const showSettleModal = ref(false);
const selectedSplitForSettlement = ref<ExpenseSplit | null>(null); const selectedSplitForSettlement = ref<ExpenseSplit | null>(null);
const settleAmount = ref<string>(''); 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 showChoreDetailModal = ref(false);
const selectedChore = ref<Chore | null>(null); const selectedChore = ref<Chore | null>(null);
const editingAssignment = ref<Partial<ChoreAssignment> | null>(null); const editingAssignment = ref<Partial<ChoreAssignment> | null>(null);
@ -554,25 +528,22 @@ const getApiErrorMessage = (err: unknown, fallbackMessageKey: string): string =>
const fetchActiveInviteCode = async () => { const fetchActiveInviteCode = async () => {
if (!groupId.value) return; if (!groupId.value) return;
// Consider adding a loading state for this fetch if needed, e.g., initialInviteCodeLoading
try { try {
const response = await apiClient.get(API_ENDPOINTS.GROUPS.GET_ACTIVE_INVITE(String(groupId.value))); const response = await apiClient.get(API_ENDPOINTS.GROUPS.GET_ACTIVE_INVITE(String(groupId.value)));
if (response.data && response.data.code) { if (response.data && response.data.code) {
inviteCode.value = response.data.code; inviteCode.value = response.data.code;
inviteExpiresAt.value = response.data.expires_at; // Store expiry inviteExpiresAt.value = response.data.expires_at;
} else { } else {
inviteCode.value = null; // No active code found inviteCode.value = null;
inviteExpiresAt.value = null; inviteExpiresAt.value = null;
} }
} catch (err: any) { } catch (err: any) {
if (err.response && err.response.status === 404) { if (err.response && err.response.status === 404) {
inviteCode.value = null; // Explicitly set to null on 404 inviteCode.value = null;
inviteExpiresAt.value = null; inviteExpiresAt.value = null;
// Optional: notify user or set a flag to show "generate one" message more prominently
console.info(t('groupDetailPage.console.noActiveInvite')); console.info(t('groupDetailPage.console.noActiveInvite'));
} else { } else {
const message = err instanceof Error ? err.message : t('groupDetailPage.errors.failedToFetchActiveInvite'); const message = err instanceof Error ? err.message : t('groupDetailPage.errors.failedToFetchActiveInvite');
// error.value = message; // This would display a large error banner, might be too much
console.error('Error fetching active invite code:', err); console.error('Error fetching active invite code:', err);
notificationStore.addNotification({ message, type: 'error' }); notificationStore.addNotification({ message, type: 'error' });
} }
@ -584,41 +555,33 @@ const fetchGroupDetails = async () => {
const groupIdStr = String(groupId.value); const groupIdStr = String(groupId.value);
const cached = cachedGroups.value[groupIdStr]; const cached = cachedGroups.value[groupIdStr];
// If we have any cached data (even stale), show it first to avoid loading spinner.
if (cached) { if (cached) {
group.value = cached.group; group.value = cached.group;
loading.value = false; loading.value = false;
} else { } else {
// Only show loading spinner if there is no cached data at all.
loading.value = true; loading.value = true;
} }
// Reset error state for the new fetch attempt
error.value = null; error.value = null;
try { try {
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BY_ID(groupIdStr)); const response = await apiClient.get(API_ENDPOINTS.GROUPS.BY_ID(groupIdStr));
group.value = response.data; group.value = response.data;
// Update cache on successful fetch
cachedGroups.value[groupIdStr] = { cachedGroups.value[groupIdStr] = {
group: response.data, group: response.data,
timestamp: Date.now(), timestamp: Date.now(),
}; };
} catch (err: unknown) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : t('groupDetailPage.errors.failedToFetchGroupDetails'); const message = err instanceof Error ? err.message : t('groupDetailPage.errors.failedToFetchGroupDetails');
// Only show the main error banner if we have no data at all to show
if (!group.value) { if (!group.value) {
error.value = message; error.value = message;
} }
console.error('Error fetching group details:', err); console.error('Error fetching group details:', err);
// Always show a notification for failures, even background ones
notificationStore.addNotification({ message, type: 'error' }); notificationStore.addNotification({ message, type: 'error' });
} finally { } finally {
// If we were showing the loader, hide it.
if (loading.value) { if (loading.value) {
loading.value = false; loading.value = false;
} }
} }
// Fetch active invite code after group details are loaded or retrieved from cache
await fetchActiveInviteCode(); await fetchActiveInviteCode();
}; };
@ -630,10 +593,9 @@ const generateInviteCode = async () => {
const response = await apiClient.post(API_ENDPOINTS.GROUPS.CREATE_INVITE(String(groupId.value))); const response = await apiClient.post(API_ENDPOINTS.GROUPS.CREATE_INVITE(String(groupId.value)));
if (response.data && response.data.code) { if (response.data && response.data.code) {
inviteCode.value = response.data.code; inviteCode.value = response.data.code;
inviteExpiresAt.value = response.data.expires_at; // Update with new expiry inviteExpiresAt.value = response.data.expires_at;
notificationStore.addNotification({ message: t('groupDetailPage.notifications.generateInviteSuccess'), type: 'success' }); notificationStore.addNotification({ message: t('groupDetailPage.notifications.generateInviteSuccess'), type: 'success' });
} else { } else {
// Should not happen if POST is successful and returns the code
throw new Error(t('groupDetailPage.invites.errors.newDataInvalid')); throw new Error(t('groupDetailPage.invites.errors.newDataInvalid'));
} }
} catch (err: unknown) { } catch (err: unknown) {
@ -654,16 +616,12 @@ const copyInviteCodeHandler = async () => {
if (copied.value) { if (copied.value) {
copySuccess.value = true; copySuccess.value = true;
setTimeout(() => (copySuccess.value = false), 2000); setTimeout(() => (copySuccess.value = false), 2000);
// Optionally, notify success via store if preferred over inline message
// notificationStore.addNotification({ message: 'Invite code copied!', type: 'info' });
} else { } else {
notificationStore.addNotification({ message: t('groupDetailPage.notifications.copyInviteFailed'), type: 'error' }); notificationStore.addNotification({ message: t('groupDetailPage.notifications.copyInviteFailed'), type: 'error' });
} }
}; };
const canRemoveMember = (member: GroupMember): boolean => { const canRemoveMember = (member: GroupMember): boolean => {
// Simplification: For now, assume a user with role 'owner' can remove anyone but another owner.
// A real implementation would check the current user's ID against the member to prevent self-removal.
const isOwner = group.value?.members?.find(m => m.id === member.id)?.role === 'owner'; const isOwner = group.value?.members?.find(m => m.id === member.id)?.role === 'owner';
return !isOwner; return !isOwner;
}; };
@ -674,7 +632,6 @@ const removeMember = async (memberId: number) => {
removingMember.value = memberId; removingMember.value = memberId;
try { try {
await apiClient.delete(API_ENDPOINTS.GROUPS.MEMBER(String(groupId.value), String(memberId))); await apiClient.delete(API_ENDPOINTS.GROUPS.MEMBER(String(groupId.value), String(memberId)));
// Refresh group details to update the members list
await fetchGroupDetails(); await fetchGroupDetails();
notificationStore.addNotification({ notificationStore.addNotification({
message: t('groupDetailPage.notifications.removeMemberSuccess'), message: t('groupDetailPage.notifications.removeMemberSuccess'),
@ -689,7 +646,6 @@ const removeMember = async (memberId: number) => {
} }
}; };
// Chores methods
const loadUpcomingChores = async () => { const loadUpcomingChores = async () => {
if (!groupId.value) return if (!groupId.value) return
const groupIdStr = String(groupId.value); const groupIdStr = String(groupId.value);
@ -744,7 +700,7 @@ const getChoreStatusInfo = (chore: Chore) => {
const formatFrequency = (frequency: ChoreFrequency) => { const formatFrequency = (frequency: ChoreFrequency) => {
const options: Record<ChoreFrequency, string> = { const options: Record<ChoreFrequency, string> = {
one_time: t('choresPage.frequencyOptions.oneTime'), // Reusing existing keys one_time: t('choresPage.frequencyOptions.oneTime'),
daily: t('choresPage.frequencyOptions.daily'), daily: t('choresPage.frequencyOptions.daily'),
weekly: t('choresPage.frequencyOptions.weekly'), weekly: t('choresPage.frequencyOptions.weekly'),
monthly: t('choresPage.frequencyOptions.monthly'), monthly: t('choresPage.frequencyOptions.monthly'),
@ -758,13 +714,12 @@ const getFrequencyBadgeVariant = (frequency: ChoreFrequency): BadgeVariant => {
one_time: 'neutral', one_time: 'neutral',
daily: 'info', daily: 'info',
weekly: 'success', weekly: 'success',
monthly: 'accent', // Using accent for purple as an example monthly: 'accent',
custom: 'warning' custom: 'warning'
}; };
return colorMap[frequency] || 'secondary'; return colorMap[frequency] || 'secondary';
}; };
// Add new methods for expenses
const loadRecentExpenses = async () => { const loadRecentExpenses = async () => {
if (!groupId.value) return if (!groupId.value) return
try { try {
@ -783,12 +738,7 @@ const formatAmount = (amount: string) => {
} }
const formatSplitType = (type: string) => { const formatSplitType = (type: string) => {
// Assuming 'type' is like 'exact_amounts' or 'item_based'
const key = `groupDetailPage.expenses.splitTypes.${type.toLowerCase().replace(/_([a-z])/g, g => g[1].toUpperCase())}`; const key = `groupDetailPage.expenses.splitTypes.${type.toLowerCase().replace(/_([a-z])/g, g => g[1].toUpperCase())}`;
// This creates keys like 'groupDetailPage.expenses.splitTypes.exactAmounts'
// Check if translation exists, otherwise fallback to a simple formatted string
// For simplicity in this subtask, we'll assume keys will be added.
// A more robust solution would check i18n.global.te(key) or have a fallback.
return t(key); return t(key);
}; };
@ -796,9 +746,9 @@ const getSplitTypeBadgeVariant = (type: string): BadgeVariant => {
const colorMap: Record<string, BadgeVariant> = { const colorMap: Record<string, BadgeVariant> = {
equal: 'info', equal: 'info',
exact_amounts: 'success', exact_amounts: 'success',
percentage: 'accent', // Using accent for purple percentage: 'accent',
shares: 'warning', shares: 'warning',
item_based: 'secondary', // Using secondary for teal as an example item_based: 'secondary',
}; };
return colorMap[type] || 'neutral'; return colorMap[type] || 'neutral';
}; };
@ -942,7 +892,6 @@ const toggleMemberMenu = (memberId: number) => {
activeMemberMenu.value = null; activeMemberMenu.value = null;
} else { } else {
activeMemberMenu.value = memberId; activeMemberMenu.value = memberId;
// Close invite UI if it's open
showInviteUI.value = false; showInviteUI.value = false;
} }
}; };
@ -950,7 +899,7 @@ const toggleMemberMenu = (memberId: number) => {
const toggleInviteUI = () => { const toggleInviteUI = () => {
showInviteUI.value = !showInviteUI.value; showInviteUI.value = !showInviteUI.value;
if (showInviteUI.value) { if (showInviteUI.value) {
activeMemberMenu.value = null; // Close any open member menu activeMemberMenu.value = null;
} }
}; };
@ -958,7 +907,6 @@ const openChoreDetailModal = async (chore: Chore) => {
selectedChore.value = chore; selectedChore.value = chore;
showChoreDetailModal.value = true; showChoreDetailModal.value = true;
// Load assignments for this chore
loadingAssignments.value = true; loadingAssignments.value = true;
try { try {
selectedChoreAssignments.value = await choreService.getChoreAssignments(chore.id); selectedChoreAssignments.value = await choreService.getChoreAssignments(chore.id);
@ -972,7 +920,6 @@ const openChoreDetailModal = async (chore: Chore) => {
loadingAssignments.value = false; loadingAssignments.value = false;
} }
// Optionally lazy load history if not already loaded with the chore
if (!chore.history || chore.history.length === 0) { if (!chore.history || chore.history.length === 0) {
try { try {
const history = await choreService.getChoreHistory(chore.id); const history = await choreService.getChoreHistory(chore.id);
@ -1008,8 +955,7 @@ const saveAssignmentEdit = async (assignmentId: number) => {
due_date: editingAssignment.value.due_date, due_date: editingAssignment.value.due_date,
assigned_to_user_id: editingAssignment.value.assigned_to_user_id assigned_to_user_id: editingAssignment.value.assigned_to_user_id
}); });
// Update local state loadUpcomingChores();
loadUpcomingChores(); // Re-fetch all chores to get updates
cancelAssignmentEdit(); cancelAssignmentEdit();
notificationStore.addNotification({ message: 'Assignment updated', type: 'success' }); notificationStore.addNotification({ message: 'Assignment updated', type: 'success' });
} catch (error) { } catch (error) {
@ -1023,7 +969,7 @@ const handleGenerateSchedule = async () => {
await groupService.generateSchedule(String(groupId.value), scheduleForm); await groupService.generateSchedule(String(groupId.value), scheduleForm);
notificationStore.addNotification({ message: 'Schedule generated successfully', type: 'success' }); notificationStore.addNotification({ message: 'Schedule generated successfully', type: 'success' });
showGenerateScheduleModal.value = false; showGenerateScheduleModal.value = false;
loadUpcomingChores(); // Refresh the chore list loadUpcomingChores();
} catch (error) { } catch (error) {
notificationStore.addNotification({ message: 'Failed to generate schedule', type: 'error' }); notificationStore.addNotification({ message: 'Failed to generate schedule', type: 'error' });
} finally { } finally {

View File

@ -1,14 +1,10 @@
<template> <template>
<main class="container page-padding"> <main class="container page-padding">
<!-- <h1 class="mb-3">Your Groups</h1> -->
<!-- Initial Loading Spinner -->
<div v-if="isInitiallyLoading && groups.length === 0 && !fetchError" class="text-center my-5"> <div v-if="isInitiallyLoading && groups.length === 0 && !fetchError" class="text-center my-5">
<p>{{ t('groupsPage.loadingText', 'Loading groups...') }}</p> <p>{{ t('groupsPage.loadingText', 'Loading groups...') }}</p>
<span class="spinner-dots-lg" role="status"><span /><span /><span /></span> <span class="spinner-dots-lg" role="status"><span /><span /><span /></span>
</div> </div>
<!-- Error Display -->
<div v-else-if="fetchError" class="alert alert-error mb-3" role="alert"> <div v-else-if="fetchError" class="alert alert-error mb-3" role="alert">
<div class="alert-content"> <div class="alert-content">
<svg class="icon" aria-hidden="true"> <svg class="icon" aria-hidden="true">
@ -20,7 +16,6 @@
t('groupsPage.retryButton') }}</button> t('groupsPage.retryButton') }}</button>
</div> </div>
<!-- Empty State: show if not initially loading, no error, and groups genuinely empty -->
<div v-else-if="!isInitiallyLoading && groups.length === 0" class="card empty-state-card"> <div v-else-if="!isInitiallyLoading && groups.length === 0" class="card empty-state-card">
<svg class="icon icon-lg" aria-hidden="true"> <svg class="icon icon-lg" aria-hidden="true">
<use xlink:href="#icon-clipboard" /> <use xlink:href="#icon-clipboard" />
@ -35,7 +30,6 @@
</button> </button>
</div> </div>
<!-- Groups List -->
<div v-else-if="groups.length > 0" class="mb-3"> <div v-else-if="groups.length > 0" class="mb-3">
<div class="neo-groups-grid"> <div class="neo-groups-grid">
<div v-for="group in groups" :key="group.id" class="neo-group-card" @click="selectGroup(group)"> <div v-for="group in groups" :key="group.id" class="neo-group-card" @click="selectGroup(group)">
@ -55,7 +49,6 @@
</div> </div>
</div> </div>
<!-- Create or Join Group Dialog -->
<div v-if="showCreateGroupDialog" class="modal-backdrop open" @click.self="closeCreateGroupDialog"> <div v-if="showCreateGroupDialog" class="modal-backdrop open" @click.self="closeCreateGroupDialog">
<div class="modal-container" ref="createGroupModalRef" role="dialog" aria-modal="true" <div class="modal-container" ref="createGroupModalRef" role="dialog" aria-modal="true"
aria-labelledby="createGroupTitle"> aria-labelledby="createGroupTitle">
@ -70,7 +63,6 @@
</button> </button>
</div> </div>
<!-- Tabs -->
<div class="modal-tabs"> <div class="modal-tabs">
<button @click="activeTab = 'create'" :class="{ 'active': activeTab === 'create' }"> <button @click="activeTab = 'create'" :class="{ 'active': activeTab === 'create' }">
{{ t('groupsPage.createDialog.createButton') }} {{ t('groupsPage.createDialog.createButton') }}
@ -80,7 +72,6 @@
</button> </button>
</div> </div>
<!-- Create Form -->
<form v-if="activeTab === 'create'" @submit.prevent="handleCreateGroup"> <form v-if="activeTab === 'create'" @submit.prevent="handleCreateGroup">
<div class="modal-body"> <div class="modal-body">
<div class="form-group"> <div class="form-group">
@ -101,7 +92,6 @@
</div> </div>
</form> </form>
<!-- Join Form -->
<form v-if="activeTab === 'join'" @submit.prevent="handleJoinGroup"> <form v-if="activeTab === 'join'" @submit.prevent="handleJoinGroup">
<div class="modal-body"> <div class="modal-body">
<div class="form-group"> <div class="form-group">
@ -124,7 +114,6 @@
</div> </div>
</div> </div>
<!-- Create List Modal -->
<CreateListModal v-model="showCreateListModal" :groups="availableGroupsForModal" @created="onListCreated" /> <CreateListModal v-model="showCreateListModal" :groups="availableGroupsForModal" @created="onListCreated" />
</main> </main>
</template> </template>
@ -156,7 +145,7 @@ const router = useRouter();
const notificationStore = useNotificationStore(); const notificationStore = useNotificationStore();
const groups = ref<Group[]>([]); const groups = ref<Group[]>([]);
const fetchError = ref<string | null>(null); const fetchError = ref<string | null>(null);
const isInitiallyLoading = ref(true); // Added for managing initial load state const isInitiallyLoading = ref(true);
const showCreateGroupDialog = ref(false); const showCreateGroupDialog = ref(false);
const newGroupName = ref(''); const newGroupName = ref('');
@ -175,56 +164,44 @@ const joinGroupFormError = ref<string | null>(null);
const showCreateListModal = ref(false); const showCreateListModal = ref(false);
const availableGroupsForModal = ref<{ label: string; value: number; }[]>([]); const availableGroupsForModal = ref<{ label: string; value: number; }[]>([]);
// Cache groups in localStorage
const cachedGroups = useStorage<Group[]>('cached-groups', []); const cachedGroups = useStorage<Group[]>('cached-groups', []);
const cachedTimestamp = useStorage<number>('cached-groups-timestamp', 0); const cachedTimestamp = useStorage<number>('cached-groups-timestamp', 0);
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds const CACHE_DURATION = 5 * 60 * 1000;
// Attempt to initialize groups from valid cache
const now = Date.now(); const now = Date.now();
if (cachedGroups.value && (now - cachedTimestamp.value) < CACHE_DURATION) { if (cachedGroups.value && (now - cachedTimestamp.value) < CACHE_DURATION) {
if (cachedGroups.value.length > 0) { if (cachedGroups.value.length > 0) {
groups.value = JSON.parse(JSON.stringify(cachedGroups.value)); // Deep copy for safety from potential proxy issues groups.value = JSON.parse(JSON.stringify(cachedGroups.value));
isInitiallyLoading.value = false;
} else {
groups.value = [];
isInitiallyLoading.value = false; isInitiallyLoading.value = false;
} else { // Valid cache, but it's empty
groups.value = []; // Ensure it's an empty array
isInitiallyLoading.value = false; // We know it's empty, not "loading"
} }
} }
// If cache is stale or not present, groups.value remains [], and isInitiallyLoading remains true.
// Fetch fresh data from API
const fetchGroups = async (isRetryAttempt = false) => { const fetchGroups = async (isRetryAttempt = false) => {
// If it's a retry triggered by user AND the list is currently empty, set loading to true to show spinner.
// Or, if it's the very first load (isInitiallyLoading is still true) AND list is empty (no cache hit).
if ((isRetryAttempt && groups.value.length === 0) || (isInitiallyLoading.value && groups.value.length === 0)) { if ((isRetryAttempt && groups.value.length === 0) || (isInitiallyLoading.value && groups.value.length === 0)) {
isInitiallyLoading.value = true; isInitiallyLoading.value = true;
} }
// If groups.value has items (from cache), isInitiallyLoading is false, and this fetch acts as a background update. fetchError.value = null;
fetchError.value = null; // Clear previous error before new attempt
try { try {
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE); const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE);
const freshGroups = response.data as Group[]; const freshGroups = response.data as Group[];
groups.value = freshGroups; groups.value = freshGroups;
// Update cache
cachedGroups.value = freshGroups; cachedGroups.value = freshGroups;
cachedTimestamp.value = Date.now(); cachedTimestamp.value = Date.now();
} catch (err: any) { } catch (err: any) {
let message = t('groupsPage.errors.fetchFailed'); let message = t('groupsPage.errors.fetchFailed');
// Attempt to get a more specific error message from the API response
if (err.response && err.response.data && err.response.data.detail) { if (err.response && err.response.data && err.response.data.detail) {
message = err.response.data.detail; message = err.response.data.detail;
} else if (err.message) { } else if (err.message) {
message = err.message; message = err.message;
} }
fetchError.value = message; fetchError.value = message;
// If fetch fails, groups.value will retain its current state (either from cache or empty).
// The template will then show the error message.
} finally { } finally {
isInitiallyLoading.value = false; // Mark loading as complete for this attempt isInitiallyLoading.value = false;
} }
}; };
@ -243,7 +220,7 @@ watch(activeTab, (newTab) => {
}); });
const openCreateGroupDialog = () => { const openCreateGroupDialog = () => {
activeTab.value = 'create'; // Default to create tab activeTab.value = 'create';
newGroupName.value = ''; newGroupName.value = '';
createGroupFormError.value = null; createGroupFormError.value = null;
inviteCodeToJoin.value = ''; inviteCodeToJoin.value = '';
@ -277,7 +254,6 @@ const handleCreateGroup = async () => {
groups.value.push(newGroup); groups.value.push(newGroup);
closeCreateGroupDialog(); closeCreateGroupDialog();
notificationStore.addNotification({ message: t('groupsPage.notifications.groupCreatedSuccess', { groupName: newGroup.name }), type: 'success' }); notificationStore.addNotification({ message: t('groupsPage.notifications.groupCreatedSuccess', { groupName: newGroup.name }), type: 'success' });
// Update cache
cachedGroups.value = groups.value; cachedGroups.value = groups.value;
cachedTimestamp.value = Date.now(); cachedTimestamp.value = Date.now();
} else { } else {
@ -302,28 +278,19 @@ const handleJoinGroup = async () => {
joinGroupFormError.value = null; joinGroupFormError.value = null;
joiningGroup.value = true; joiningGroup.value = true;
try { try {
const response = await apiClient.post(API_ENDPOINTS.INVITES.ACCEPT, { const response = await apiClient.post(
code: inviteCodeToJoin.value.trim() API_ENDPOINTS.INVITES.ACCEPT(inviteCodeToJoin.value.trim()),
}); { code: inviteCodeToJoin.value.trim() }
const joinedGroup = response.data as Group; // Adjust based on actual API response for joined group );
if (joinedGroup && joinedGroup.id && joinedGroup.name) { const joinedGroup = response.data as Group;
// Check if group already in list to prevent duplicates if API returns the group info if (!groups.value.find(g => g.id === joinedGroup.id)) {
if (!groups.value.find(g => g.id === joinedGroup.id)) { groups.value.push(joinedGroup);
groups.value.push(joinedGroup);
}
inviteCodeToJoin.value = '';
notificationStore.addNotification({ message: t('groupsPage.notifications.joinSuccessNamed', { groupName: joinedGroup.name }), type: 'success' });
// Update cache
cachedGroups.value = groups.value;
cachedTimestamp.value = Date.now();
closeCreateGroupDialog();
} else {
// If API returns only success message, re-fetch groups
await fetchGroups(); // Refresh the list of groups
inviteCodeToJoin.value = '';
notificationStore.addNotification({ message: t('groupsPage.notifications.joinSuccessGeneric'), type: 'success' });
closeCreateGroupDialog();
} }
inviteCodeToJoin.value = '';
notificationStore.addNotification({ message: t('groupsPage.notifications.joinSuccessNamed', { groupName: joinedGroup.name }), type: 'success' });
cachedGroups.value = groups.value;
cachedTimestamp.value = Date.now();
closeCreateGroupDialog();
} catch (error: any) { } catch (error: any) {
const message = error.response?.data?.detail || (error instanceof Error ? error.message : t('groupsPage.errors.joinFailed')); const message = error.response?.data?.detail || (error instanceof Error ? error.message : t('groupsPage.errors.joinFailed'));
joinGroupFormError.value = message; joinGroupFormError.value = message;
@ -339,7 +306,6 @@ const selectGroup = (group: Group) => {
}; };
const openCreateListDialog = (group: Group) => { const openCreateListDialog = (group: Group) => {
// Ensure we have the latest groups data
fetchGroups().then(() => { fetchGroups().then(() => {
availableGroupsForModal.value = [{ availableGroupsForModal.value = [{
label: group.name, label: group.name,
@ -354,14 +320,10 @@ const onListCreated = (newList: any) => {
message: t('groupsPage.notifications.listCreatedSuccess', { listName: newList.name }), message: t('groupsPage.notifications.listCreatedSuccess', { listName: newList.name }),
type: 'success' type: 'success'
}); });
// Optionally refresh the groups list to show the new list fetchGroups();
fetchGroups(); // Refresh data, isRetryAttempt will be false
}; };
onMounted(() => { onMounted(() => {
// groups might have been populated from cache synchronously above.
// isInitiallyLoading reflects whether cache was used or if we need to show a spinner.
// Call fetchGroups to get fresh data or perform initial load if cache was missed.
fetchGroups(); fetchGroups();
}); });
</script> </script>
@ -389,7 +351,6 @@ onMounted(() => {
margin-left: 0.5rem; margin-left: 0.5rem;
} }
/* Responsive grid for cards */
.neo-groups-grid { .neo-groups-grid {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -399,7 +360,6 @@ onMounted(() => {
margin-bottom: 2rem; margin-bottom: 2rem;
} }
/* Card styles */
.neo-group-card, .neo-group-card,
.neo-create-group-card { .neo-create-group-card {
border-radius: 18px; border-radius: 18px;
@ -429,7 +389,6 @@ onMounted(() => {
.neo-group-header { .neo-group-header {
font-weight: 900; font-weight: 900;
font-size: 1.25rem; font-size: 1.25rem;
/* margin-bottom: 1rem; */
letter-spacing: 0.5px; letter-spacing: 0.5px;
text-transform: none; text-transform: none;
} }
@ -489,7 +448,6 @@ details[open] .expand-icon {
cursor: pointer; cursor: pointer;
} }
/* Modal Tabs */
.modal-tabs { .modal-tabs {
display: flex; display: flex;
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
@ -515,7 +473,6 @@ details[open] .expand-icon {
font-weight: 600; font-weight: 600;
} }
/* Responsive adjustments */
@media (max-width: 900px) { @media (max-width: 900px) {
.neo-groups-grid { .neo-groups-grid {
gap: 1.2rem; gap: 1.2rem;

View File

@ -379,10 +379,10 @@
<template #footer> <template #footer>
<VButton variant="neutral" @click="closeSettleShareModal">{{ <VButton variant="neutral" @click="closeSettleShareModal">{{
$t('listDetailPage.modals.settleShare.cancelButton') $t('listDetailPage.modals.settleShare.cancelButton')
}}</VButton> }}</VButton>
<VButton variant="primary" @click="handleConfirmSettle">{{ <VButton variant="primary" @click="handleConfirmSettle">{{
$t('listDetailPage.modals.settleShare.confirmButton') $t('listDetailPage.modals.settleShare.confirmButton')
}}</VButton> }}</VButton>
</template> </template>
</VModal> </VModal>
@ -696,9 +696,8 @@ const onAddItem = async () => {
} }
addingItem.value = true; addingItem.value = true;
// Create optimistic item
const optimisticItem: ItemWithUI = { const optimisticItem: ItemWithUI = {
id: Date.now(), // Temporary ID id: Date.now(),
name: itemName, name: itemName,
quantity: typeof newItem.value.quantity === 'string' ? Number(newItem.value.quantity) : (newItem.value.quantity || null), quantity: typeof newItem.value.quantity === 'string' ? Number(newItem.value.quantity) : (newItem.value.quantity || null),
is_complete: false, is_complete: false,
@ -713,10 +712,8 @@ const onAddItem = async () => {
swiped: false swiped: false
}; };
// Add item optimistically to the list
list.value.items.push(optimisticItem); list.value.items.push(optimisticItem);
// Clear input immediately for better UX
newItem.value.name = ''; newItem.value.name = '';
if (itemNameInputRef.value?.$el) { if (itemNameInputRef.value?.$el) {
(itemNameInputRef.value.$el as HTMLElement).focus(); (itemNameInputRef.value.$el as HTMLElement).focus();
@ -760,7 +757,6 @@ const onAddItem = async () => {
); );
const addedItem = response.data as Item; const addedItem = response.data as Item;
// Replace optimistic item with real item from server
const index = list.value.items.findIndex(i => i.id === optimisticItem.id); const index = list.value.items.findIndex(i => i.id === optimisticItem.id);
if (index !== -1) { if (index !== -1) {
list.value.items[index] = processListItems([addedItem])[0]; list.value.items[index] = processListItems([addedItem])[0];
@ -768,7 +764,6 @@ const onAddItem = async () => {
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemAddedSuccess'), type: 'success' }); notificationStore.addNotification({ message: t('listDetailPage.notifications.itemAddedSuccess'), type: 'success' });
} catch (err) { } catch (err) {
// Remove optimistic item on error
list.value.items = list.value.items.filter(i => i.id !== optimisticItem.id); list.value.items = list.value.items.filter(i => i.id !== optimisticItem.id);
notificationStore.addNotification({ notificationStore.addNotification({
message: getApiErrorMessage(err, 'listDetailPage.errors.addItemFailed'), message: getApiErrorMessage(err, 'listDetailPage.errors.addItemFailed'),
@ -789,11 +784,10 @@ const updateItem = async (item: ItemWithUI, newCompleteStatus: boolean) => {
if (newCompleteStatus && !originalCompleteStatus) { if (newCompleteStatus && !originalCompleteStatus) {
item.showFirework = true; item.showFirework = true;
setTimeout(() => { setTimeout(() => {
// Check if item still exists and is part of the current list before resetting
if (list.value && list.value.items.find(i => i.id === item.id)) { if (list.value && list.value.items.find(i => i.id === item.id)) {
item.showFirework = false; item.showFirework = false;
} }
}, 700); // Duration of firework animation (must match CSS) }, 700);
} }
}; };
@ -810,8 +804,8 @@ const updateItem = async (item: ItemWithUI, newCompleteStatus: boolean) => {
} }
}); });
item.updating = false; item.updating = false;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' }); // Optimistic UI notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' });
triggerFirework(); // Trigger firework for offline success triggerFirework();
return; return;
} }
@ -822,9 +816,9 @@ const updateItem = async (item: ItemWithUI, newCompleteStatus: boolean) => {
); );
item.version++; item.version++;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' }); notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' });
triggerFirework(); // Trigger firework for online success triggerFirework();
} catch (err) { } catch (err) {
item.is_complete = originalCompleteStatus; // Revert optimistic update item.is_complete = originalCompleteStatus;
notificationStore.addNotification({ message: getApiErrorMessage(err, 'listDetailPage.errors.updateItemFailed'), type: 'error' }); notificationStore.addNotification({ message: getApiErrorMessage(err, 'listDetailPage.errors.updateItemFailed'), type: 'error' });
} finally { } finally {
item.updating = false; item.updating = false;
@ -834,11 +828,11 @@ const updateItem = async (item: ItemWithUI, newCompleteStatus: boolean) => {
const updateItemPrice = async (item: ItemWithUI) => { const updateItemPrice = async (item: ItemWithUI) => {
if (!list.value || !item.is_complete) return; if (!list.value || !item.is_complete) return;
const newPrice = item.priceInput !== undefined && String(item.priceInput).trim() !== '' ? parseFloat(String(item.priceInput)) : null; const newPrice = item.priceInput !== undefined && String(item.priceInput).trim() !== '' ? parseFloat(String(item.priceInput)) : null;
if (item.price === newPrice?.toString()) return; // No change if (item.price === newPrice?.toString()) return;
item.updating = true; item.updating = true;
const originalPrice = item.price; const originalPrice = item.price;
const originalPriceInput = item.priceInput; const originalPriceInput = item.priceInput;
item.price = newPrice?.toString() || null; // Optimistic update item.price = newPrice?.toString() || null;
if (!isOnline.value) { if (!isOnline.value) {
offlineStore.addAction({ offlineStore.addAction({
@ -847,14 +841,14 @@ const updateItemPrice = async (item: ItemWithUI) => {
listId: String(list.value.id), listId: String(list.value.id),
itemId: String(item.id), itemId: String(item.id),
data: { data: {
price: newPrice ?? null, // Ensure null is sent if cleared price: newPrice ?? null,
completed: item.is_complete // Keep completion status completed: item.is_complete
}, },
version: item.version version: item.version
} }
}); });
item.updating = false; item.updating = false;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' }); // Optimistic UI notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' });
return; return;
} }
@ -866,7 +860,7 @@ const updateItemPrice = async (item: ItemWithUI) => {
item.version++; item.version++;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' }); notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' });
} catch (err) { } catch (err) {
item.price = originalPrice; // Revert optimistic update item.price = originalPrice;
item.priceInput = originalPriceInput; item.priceInput = originalPriceInput;
notificationStore.addNotification({ message: getApiErrorMessage(err, 'listDetailPage.errors.updateItemPriceFailed'), type: 'error' }); notificationStore.addNotification({ message: getApiErrorMessage(err, 'listDetailPage.errors.updateItemPriceFailed'), type: 'error' });
} finally { } finally {
@ -877,7 +871,7 @@ const updateItemPrice = async (item: ItemWithUI) => {
const deleteItem = async (item: ItemWithUI) => { const deleteItem = async (item: ItemWithUI) => {
if (!list.value) return; if (!list.value) return;
item.deleting = true; item.deleting = true;
const originalItems = [...list.value.items]; // For potential revert const originalItems = [...list.value.items];
if (!isOnline.value) { if (!isOnline.value) {
offlineStore.addAction({ offlineStore.addAction({
@ -887,7 +881,7 @@ const deleteItem = async (item: ItemWithUI) => {
itemId: String(item.id) itemId: String(item.id)
} }
}); });
list.value.items = list.value.items.filter(i => i.id !== item.id); // Optimistic UI list.value.items = list.value.items.filter(i => i.id !== item.id);
item.deleting = false; item.deleting = false;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemDeleteSuccess'), type: 'success' }); notificationStore.addNotification({ message: t('listDetailPage.notifications.itemDeleteSuccess'), type: 'success' });
return; return;
@ -898,7 +892,7 @@ const deleteItem = async (item: ItemWithUI) => {
list.value.items = list.value.items.filter(i => i.id !== item.id); list.value.items = list.value.items.filter(i => i.id !== item.id);
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemDeleteSuccess'), type: 'success' }); notificationStore.addNotification({ message: t('listDetailPage.notifications.itemDeleteSuccess'), type: 'success' });
} catch (err) { } catch (err) {
list.value.items = originalItems; // Revert optimistic UI list.value.items = originalItems;
notificationStore.addNotification({ message: getApiErrorMessage(err, 'listDetailPage.errors.deleteItemFailed'), type: 'error' }); notificationStore.addNotification({ message: getApiErrorMessage(err, 'listDetailPage.errors.deleteItemFailed'), type: 'error' });
} finally { } finally {
item.deleting = false; item.deleting = false;
@ -916,13 +910,13 @@ const confirmDeleteItem = (item: ItemWithUI) => {
const openOcrDialog = () => { const openOcrDialog = () => {
ocrItems.value = []; ocrItems.value = [];
ocrError.value = null; ocrError.value = null;
resetOcrFileDialog(); // From useFileDialog resetOcrFileDialog();
showOcrDialogState.value = true; showOcrDialogState.value = true;
nextTick(() => { nextTick(() => {
if (ocrFileInputRef.value && ocrFileInputRef.value.$el) { if (ocrFileInputRef.value && ocrFileInputRef.value.$el) {
const inputElement = ocrFileInputRef.value.$el.querySelector('input[type="file"]') || ocrFileInputRef.value.$el; const inputElement = ocrFileInputRef.value.$el.querySelector('input[type="file"]') || ocrFileInputRef.value.$el;
if (inputElement) (inputElement as HTMLInputElement).value = ''; if (inputElement) (inputElement as HTMLInputElement).value = '';
} else if (ocrFileInputRef.value) { // Native input } else if (ocrFileInputRef.value) {
(ocrFileInputRef.value as any).value = ''; (ocrFileInputRef.value as any).value = '';
} }
}); });
@ -966,11 +960,10 @@ const handleOcrUpload = async (file: File) => {
ocrError.value = getApiErrorMessage(err, 'listDetailPage.errors.ocrFailed'); ocrError.value = getApiErrorMessage(err, 'listDetailPage.errors.ocrFailed');
} finally { } finally {
ocrLoading.value = false; ocrLoading.value = false;
// Reset file input
if (ocrFileInputRef.value && ocrFileInputRef.value.$el) { if (ocrFileInputRef.value && ocrFileInputRef.value.$el) {
const inputElement = ocrFileInputRef.value.$el.querySelector('input[type="file"]') || ocrFileInputRef.value.$el; const inputElement = ocrFileInputRef.value.$el.querySelector('input[type="file"]') || ocrFileInputRef.value.$el;
if (inputElement) (inputElement as HTMLInputElement).value = ''; if (inputElement) (inputElement as HTMLInputElement).value = '';
} else if (ocrFileInputRef.value) { // Native input } else if (ocrFileInputRef.value) {
(ocrFileInputRef.value as any).value = ''; (ocrFileInputRef.value as any).value = '';
} }
} }
@ -985,7 +978,7 @@ const addOcrItems = async () => {
if (!item.name.trim()) continue; if (!item.name.trim()) continue;
const response = await apiClient.post( const response = await apiClient.post(
API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)), API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)),
{ name: item.name, quantity: "1" } // Default quantity 1 { name: item.name, quantity: "1" }
); );
const addedItem = response.data as Item; const addedItem = response.data as Item;
list.value.items.push(processListItems([addedItem])[0]); list.value.items.push(processListItems([addedItem])[0]);
@ -1062,19 +1055,17 @@ const getStatusClass = (status: ExpenseSplitStatusEnum | ExpenseOverallStatusEnu
return ''; return '';
}; };
// Keyboard shortcut
useEventListener(window, 'keydown', (event: KeyboardEvent) => { useEventListener(window, 'keydown', (event: KeyboardEvent) => {
if (event.key === 'n' && !event.ctrlKey && !event.metaKey && !event.altKey) { if (event.key === 'n' && !event.ctrlKey && !event.metaKey && !event.altKey) {
const activeElement = document.activeElement; const activeElement = document.activeElement;
if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')) { if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')) {
return; // Don't interfere with typing return;
} }
// Check if any modal is open, if so, don't trigger
if (showOcrDialogState.value || showCostSummaryDialog.value || showSettleModal.value || showCreateExpenseForm.value) { if (showOcrDialogState.value || showCostSummaryDialog.value || showSettleModal.value || showCreateExpenseForm.value) {
return; return;
} }
event.preventDefault(); event.preventDefault();
if (itemNameInputRef.value?.$el) { // Focus the add item input if (itemNameInputRef.value?.$el) {
(itemNameInputRef.value.$el as HTMLElement).focus(); (itemNameInputRef.value.$el as HTMLElement).focus();
} }
} }
@ -1086,7 +1077,7 @@ onMounted(() => {
error.value = null; error.value = null;
if (!route.params.id) { if (!route.params.id) {
error.value = t('listDetailPage.errors.fetchFailed'); // Generic error if no ID error.value = t('listDetailPage.errors.fetchFailed');
pageInitialLoad.value = false; pageInitialLoad.value = false;
listDetailStore.setError(t('listDetailPage.errors.fetchFailed')); listDetailStore.setError(t('listDetailPage.errors.fetchFailed'));
return; return;
@ -1126,23 +1117,20 @@ onUnmounted(() => {
}); });
const startItemEdit = (item: ItemWithUI) => { const startItemEdit = (item: ItemWithUI) => {
// Ensure other items are not in edit mode (optional, but good for UX)
list.value?.items.forEach(i => { if (i.id !== item.id) i.isEditing = false; }); list.value?.items.forEach(i => { if (i.id !== item.id) i.isEditing = false; });
item.isEditing = true; item.isEditing = true;
item.editName = item.name; item.editName = item.name;
item.editQuantity = item.quantity ?? ''; // Use empty string for VInput if null/undefined item.editQuantity = item.quantity ?? '';
}; };
const cancelItemEdit = (item: ItemWithUI) => { const cancelItemEdit = (item: ItemWithUI) => {
item.isEditing = false; item.isEditing = false;
// editName and editQuantity are transient, no need to reset them to anything,
// as they are re-initialized in startItemEdit.
}; };
const saveItemEdit = async (item: ItemWithUI) => { const saveItemEdit = async (item: ItemWithUI) => {
if (!list.value || !item.editName || String(item.editName).trim() === '') { if (!list.value || !item.editName || String(item.editName).trim() === '') {
notificationStore.addNotification({ notificationStore.addNotification({
message: t('listDetailPage.notifications.enterItemName'), // Re-use existing translation message: t('listDetailPage.notifications.enterItemName'),
type: 'warning' type: 'warning'
}); });
return; return;
@ -1152,12 +1140,9 @@ const saveItemEdit = async (item: ItemWithUI) => {
name: String(item.editName).trim(), name: String(item.editName).trim(),
quantity: item.editQuantity ? String(item.editQuantity) : null, quantity: item.editQuantity ? String(item.editQuantity) : null,
version: item.version, version: item.version,
// Ensure completed status is preserved if it's part of the update endpoint implicitly or explicitly
// If your API updates 'completed' status too, you might need to send item.is_complete
// For now, assuming API endpoint for item update only takes name, quantity, version.
}; };
item.updating = true; // Use existing flag for visual feedback item.updating = true;
try { try {
const response = await apiClient.put( const response = await apiClient.put(
@ -1166,26 +1151,24 @@ const saveItemEdit = async (item: ItemWithUI) => {
); );
const updatedItemFromApi = response.data as Item; const updatedItemFromApi = response.data as Item;
// Update the original item with new data from API
item.name = updatedItemFromApi.name; item.name = updatedItemFromApi.name;
item.quantity = updatedItemFromApi.quantity; item.quantity = updatedItemFromApi.quantity;
item.version = updatedItemFromApi.version; item.version = updatedItemFromApi.version;
item.is_complete = updatedItemFromApi.is_complete; // Ensure this is updated if API returns it item.is_complete = updatedItemFromApi.is_complete;
item.price = updatedItemFromApi.price; // And price item.price = updatedItemFromApi.price;
item.updated_at = updatedItemFromApi.updated_at; item.updated_at = updatedItemFromApi.updated_at;
item.isEditing = false; // Exit edit mode item.isEditing = false;
notificationStore.addNotification({ notificationStore.addNotification({
message: t('listDetailPage.notifications.itemUpdatedSuccess'), // Re-use message: t('listDetailPage.notifications.itemUpdatedSuccess'),
type: 'success' type: 'success'
}); });
} catch (err) { } catch (err) {
notificationStore.addNotification({ notificationStore.addNotification({
message: getApiErrorMessage(err, 'listDetailPage.errors.updateItemFailed'), // Re-use message: getApiErrorMessage(err, 'listDetailPage.errors.updateItemFailed'),
type: 'error' type: 'error'
}); });
// Optionally, keep item.isEditing = true so user can correct or cancel
} finally { } finally {
item.updating = false; item.updating = false;
} }
@ -1285,17 +1268,15 @@ const handleCheckboxChange = (item: ItemWithUI, event: Event) => {
const handleDragEnd = async (evt: any) => { const handleDragEnd = async (evt: any) => {
if (!list.value || evt.oldIndex === evt.newIndex) return; if (!list.value || evt.oldIndex === evt.newIndex) return;
const originalList = [...list.value.items]; // Store original order const originalList = [...list.value.items];
const item = list.value.items[evt.newIndex]; const item = list.value.items[evt.newIndex];
const newPosition = evt.newIndex + 1; // Assuming backend uses 1-based indexing for position const newPosition = evt.newIndex + 1;
try { try {
// The v-model on draggable has already updated the list.value.items order optimistically.
await apiClient.put( await apiClient.put(
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)), API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
{ position: newPosition, version: item.version } { position: newPosition, version: item.version }
); );
// On success, we need to update the version of the moved item
const updatedItemInList = list.value.items.find(i => i.id === item.id); const updatedItemInList = list.value.items.find(i => i.id === item.id);
if (updatedItemInList) { if (updatedItemInList) {
updatedItemInList.version++; updatedItemInList.version++;
@ -1305,7 +1286,6 @@ const handleDragEnd = async (evt: any) => {
type: 'success' type: 'success'
}); });
} catch (err) { } catch (err) {
// Revert the order on error
list.value.items = originalList; list.value.items = originalList;
notificationStore.addNotification({ notificationStore.addNotification({
message: getApiErrorMessage(err, 'listDetailPage.errors.reorderItemFailed'), message: getApiErrorMessage(err, 'listDetailPage.errors.reorderItemFailed'),
@ -1321,8 +1301,6 @@ const toggleExpense = (expenseId: number) => {
if (newSet.has(expenseId)) { if (newSet.has(expenseId)) {
newSet.delete(expenseId); newSet.delete(expenseId);
} else { } else {
// Optional: collapse others when one is opened
// newSet.clear();
newSet.add(expenseId); newSet.add(expenseId);
} }
expandedExpenses.value = newSet; expandedExpenses.value = newSet;
@ -1335,8 +1313,6 @@ const isExpenseExpanded = (expenseId: number) => {
</script> </script>
<style scoped> <style scoped>
/* Existing styles */
.neo-expenses-section { .neo-expenses-section {
padding: 0; padding: 0;
margin-top: 1.2rem; margin-top: 1.2rem;
@ -1344,7 +1320,6 @@ const isExpenseExpanded = (expenseId: number) => {
.neo-expense-list { .neo-expense-list {
background-color: rgb(255, 248, 240); background-color: rgb(255, 248, 240);
/* Container for expense items */
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
border: 1px solid #f0e5d8; border: 1px solid #f0e5d8;

View File

@ -1,7 +1,5 @@
<template> <template>
<main class="container page-padding"> <main class="container page-padding">
<!-- <h1 class="mb-3">{{ pageTitle }}</h1> -->
<VAlert v-if="error" type="error" :message="error" class="mb-3" :closable="false"> <VAlert v-if="error" type="error" :message="error" class="mb-3" :closable="false">
<template #actions> <template #actions>
<VButton variant="danger" size="sm" @click="fetchListsAndGroups">{{ t('listsPage.retryButton') }}</VButton> <VButton variant="danger" size="sm" @click="fetchListsAndGroups">{{ t('listsPage.retryButton') }}</VButton>
@ -70,12 +68,12 @@
import { ref, onMounted, computed, watch, onUnmounted, nextTick } from 'vue'; import { ref, onMounted, computed, watch, onUnmounted, nextTick } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Adjust path as needed import { apiClient, API_ENDPOINTS } from '@/config/api';
import CreateListModal from '@/components/CreateListModal.vue'; // Adjust path as needed import CreateListModal from '@/components/CreateListModal.vue';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import VAlert from '@/components/valerie/VAlert.vue'; // Adjust path as needed import VAlert from '@/components/valerie/VAlert.vue';
import VCard from '@/components/valerie/VCard.vue'; // Adjust path as needed import VCard from '@/components/valerie/VCard.vue';
import VButton from '@/components/valerie/VButton.vue'; // Adjust path as needed import VButton from '@/components/valerie/VButton.vue';
const { t } = useI18n(); const { t } = useI18n();
@ -187,13 +185,12 @@ const fetchAllAccessibleGroups = async () => {
allFetchedGroups.value = (response.data as Group[]); allFetchedGroups.value = (response.data as Group[]);
} catch (err) { } catch (err) {
console.error('Failed to fetch groups for modal:', err); console.error('Failed to fetch groups for modal:', err);
// Not critical for page load, modal might not show groups
} }
}; };
const cachedLists = useStorage<(List & { items: Item[] })[]>('cached-lists', []); const cachedLists = useStorage<(List & { items: Item[] })[]>('cached-lists', []);
const cachedTimestamp = useStorage<number>('cached-lists-timestamp', 0); const cachedTimestamp = useStorage<number>('cached-lists-timestamp', 0);
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes const CACHE_DURATION = 5 * 60 * 1000;
const loadCachedData = () => { const loadCachedData = () => {
const now = Date.now(); const now = Date.now();
@ -257,7 +254,6 @@ const onListCreated = (newList: List & { items: Item[] }) => {
}); });
cachedLists.value = JSON.parse(JSON.stringify(lists.value)); cachedLists.value = JSON.parse(JSON.stringify(lists.value));
cachedTimestamp.value = Date.now(); cachedTimestamp.value = Date.now();
// Consider animating new list card in if desired
}; };
const toggleItem = async (list: List, item: Item) => { const toggleItem = async (list: List, item: Item) => {
@ -379,7 +375,7 @@ const navigateToList = (listId: number) => {
}; };
sessionStorage.setItem('listDetailShell', JSON.stringify(listShell)); sessionStorage.setItem('listDetailShell', JSON.stringify(listShell));
} }
router.push({ name: 'ListDetail', params: { id: listId } }); // Ensure 'ListDetail' route exists router.push({ name: 'ListDetail', params: { id: listId } });
}; };
const prefetchListDetails = async (listId: number) => { const prefetchListDetails = async (listId: number) => {
@ -431,10 +427,7 @@ const refetchList = async (listId: number) => {
const listIndex = lists.value.findIndex(l => l.id === listId); const listIndex = lists.value.findIndex(l => l.id === listId);
if (listIndex !== -1) { if (listIndex !== -1) {
// Use direct assignment for better reactivity
lists.value[listIndex] = { ...updatedList, items: updatedList.items || [] }; lists.value[listIndex] = { ...updatedList, items: updatedList.items || [] };
// Update cache
cachedLists.value = JSON.parse(JSON.stringify(lists.value)); cachedLists.value = JSON.parse(JSON.stringify(lists.value));
cachedTimestamp.value = Date.now(); cachedTimestamp.value = Date.now();
} else { } else {
@ -491,7 +484,7 @@ const checkForUpdates = async () => {
const startPolling = () => { const startPolling = () => {
stopPolling(); stopPolling();
pollingInterval.value = setInterval(checkForUpdates, 15000); // Poll every 15 seconds pollingInterval.value = setInterval(checkForUpdates, 15000);
}; };
const stopPolling = () => { const stopPolling = () => {
@ -549,7 +542,6 @@ onUnmounted(() => {
</script> </script>
<style scoped> <style scoped>
/* Ensure --light is defined in your global styles or here, e.g., :root { --light: #fff; } */
.loading-placeholder { .loading-placeholder {
text-align: center; text-align: center;
padding: 2rem; padding: 2rem;
@ -593,7 +585,6 @@ onUnmounted(() => {
.neo-list-card:hover { .neo-list-card:hover {
transform: translateY(-4px); transform: translateY(-4px);
box-shadow: 6px 10px 0 var(--dark); box-shadow: 6px 10px 0 var(--dark);
/* border-color: var(--secondary); */
} }
.neo-list-card.touch-active { .neo-list-card.touch-active {
@ -638,7 +629,6 @@ onUnmounted(() => {
opacity: 0.6; opacity: 0.6;
} }
/* Custom Checkbox Styles */
.neo-checkbox-label { .neo-checkbox-label {
display: grid; display: grid;
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
@ -718,7 +708,6 @@ onUnmounted(() => {
width: fit-content; width: fit-content;
} }
/* Animated strikethrough line */
.checkbox-text-span::before { .checkbox-text-span::before {
content: ''; content: '';
position: absolute; position: absolute;
@ -732,7 +721,6 @@ onUnmounted(() => {
transition: transform 0.4s cubic-bezier(0.77, 0, .18, 1); transition: transform 0.4s cubic-bezier(0.77, 0, .18, 1);
} }
/* Firework particle container */
.checkbox-text-span::after { .checkbox-text-span::after {
content: ''; content: '';
position: absolute; position: absolute;
@ -747,7 +735,6 @@ onUnmounted(() => {
pointer-events: none; pointer-events: none;
} }
/* Selector fixed to target span correctly */
.neo-checkbox-label input[type="checkbox"]:checked~.checkbox-content .checkbox-text-span { .neo-checkbox-label input[type="checkbox"]:checked~.checkbox-content .checkbox-text-span {
color: var(--dark); color: var(--dark);
opacity: 0.6; opacity: 0.6;
@ -769,7 +756,6 @@ onUnmounted(() => {
position: relative; position: relative;
} }
/* Static strikethrough for items loaded as complete */
.neo-completed-static::before { .neo-completed-static::before {
content: ''; content: '';
position: absolute; position: absolute;
@ -833,7 +819,6 @@ onUnmounted(() => {
background: var(--light); background: var(--light);
transform: translateY(-3px) scale(1.01); transform: translateY(-3px) scale(1.01);
box-shadow: 0px 6px 8px rgba(0, 0, 0, 0.05); box-shadow: 0px 6px 8px rgba(0, 0, 0, 0.05);
/* border-color: var(--secondary); */
color: var(--primary); color: var(--primary);
} }
@ -885,7 +870,6 @@ onUnmounted(() => {
.neo-list-item { .neo-list-item {
margin-bottom: 0.7rem; margin-bottom: 0.7rem;
/* Adjusted for mobile */
} }
} }

View File

@ -21,7 +21,7 @@
:aria-label="t('loginPage.togglePasswordVisibilityLabel')"> :aria-label="t('loginPage.togglePasswordVisibilityLabel')">
<svg class="icon icon-sm"> <svg class="icon icon-sm">
<use :xlink:href="isPwdVisible ? '#icon-settings' : '#icon-settings'"></use> <use :xlink:href="isPwdVisible ? '#icon-settings' : '#icon-settings'"></use>
</svg> <!-- Placeholder for visibility icons --> </svg>
</button> </button>
</div> </div>
<p v-if="formErrors.password" class="form-error-text">{{ formErrors.password }}</p> <p v-if="formErrors.password" class="form-error-text">{{ formErrors.password }}</p>
@ -49,7 +49,7 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/auth'; // Assuming path import { useAuthStore } from '@/stores/auth';
import { useNotificationStore } from '@/stores/notifications'; import { useNotificationStore } from '@/stores/notifications';
import SocialLoginButtons from '@/components/SocialLoginButtons.vue'; import SocialLoginButtons from '@/components/SocialLoginButtons.vue';
@ -88,7 +88,7 @@ const onSubmit = async () => {
return; return;
} }
loading.value = true; loading.value = true;
formErrors.value.general = undefined; // Clear previous general errors formErrors.value.general = undefined;
try { try {
await authStore.login(email.value, password.value); await authStore.login(email.value, password.value);
notificationStore.addNotification({ message: t('loginPage.notifications.loginSuccess'), type: 'success' }); notificationStore.addNotification({ message: t('loginPage.notifications.loginSuccess'), type: 'success' });
@ -108,7 +108,6 @@ const onSubmit = async () => {
<style scoped> <style scoped>
.page-container { .page-container {
min-height: 100vh; min-height: 100vh;
/* dvh for dynamic viewport height */
min-height: 100dvh; min-height: 100dvh;
padding: 1rem; padding: 1rem;
} }
@ -118,8 +117,6 @@ const onSubmit = async () => {
max-width: 400px; max-width: 400px;
} }
/* form-layout, w-full, mt-2, text-center styles were removed as utility classes from Valerie UI are used directly or are available. */
.link-styled { .link-styled {
color: var(--primary); color: var(--primary);
text-decoration: none; text-decoration: none;
@ -139,7 +136,6 @@ const onSubmit = async () => {
} }
.alert.form-error-text { .alert.form-error-text {
/* For general error message */
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@ -151,7 +147,6 @@ const onSubmit = async () => {
.input-with-icon-append .form-input { .input-with-icon-append .form-input {
padding-right: 3rem; padding-right: 3rem;
/* Space for the button */
} }
.icon-append-btn { .icon-append-btn {
@ -160,11 +155,9 @@ const onSubmit = async () => {
top: 0; top: 0;
bottom: 0; bottom: 0;
width: 3rem; width: 3rem;
/* Width of the button */
background: transparent; background: transparent;
border: none; border: none;
border-left: var(--border); border-left: var(--border);
/* Separator line */
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
@ -182,6 +175,4 @@ const onSubmit = async () => {
.icon-append-btn .icon { .icon-append-btn .icon {
margin: 0; margin: 0;
} }
/* Remove default icon margin */
</style> </style>

View File

@ -1,790 +0,0 @@
<template>
<main class="container page-padding">
<div class="page-header">
<h1 class="mb-3">{{ $t('myChoresPage.title') }}</h1>
<div class="header-controls">
<label class="toggle-switch">
<input type="checkbox" v-model="showCompleted" @change="loadAssignments">
<span class="toggle-slider"></span>
{{ $t('myChoresPage.showCompletedToggle') }}
</label>
</div>
</div>
<!-- My Assignments Timeline -->
<div v-if="assignments.length > 0" class="assignments-timeline">
<!-- Overdue Section -->
<div v-if="assignmentsByTimeline.overdue.length > 0" class="timeline-section overdue">
<div class="timeline-header">
<div class="timeline-dot overdue"></div>
<h2 class="timeline-title">{{ $t('myChoresPage.timelineHeaders.overdue') }}</h2>
<span class="timeline-count">{{ assignmentsByTimeline.overdue.length }}</span>
</div>
<div class="timeline-items">
<div v-for="assignment in assignmentsByTimeline.overdue" :key="assignment.id"
class="timeline-assignment-card overdue">
<div class="assignment-timeline-marker"></div>
<div class="assignment-content">
<div class="assignment-header">
<h3>{{ assignment.chore?.name }}</h3>
<div class="assignment-tags">
<span class="chore-type-tag" :class="assignment.chore?.type">
{{ assignment.chore?.type === 'personal' ? $t('myChoresPage.choreCard.personal') : $t('myChoresPage.choreCard.group') }}
</span>
<span class="chore-frequency-tag" :class="assignment.chore?.frequency">
{{ formatFrequency(assignment.chore?.frequency) }}
</span>
</div>
</div>
<div class="assignment-meta">
<div class="assignment-due-date overdue">
<span class="material-icons">schedule</span>
{{ $t('myChoresPage.choreCard.duePrefix') }} {{ formatDate(assignment.due_date) }}
</div>
<div v-if="assignment.chore?.description" class="assignment-description">
{{ assignment.chore.description }}
</div>
</div>
<div class="assignment-actions">
<button class="btn btn-success btn-sm" @click="completeAssignment(assignment)"
:disabled="isCompleting">
<span class="material-icons">check_circle</span>
{{ $t('myChoresPage.choreCard.markCompleteButton') }}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Today Section -->
<div v-if="assignmentsByTimeline.today.length > 0" class="timeline-section today">
<div class="timeline-header">
<div class="timeline-dot today"></div>
<h2 class="timeline-title">{{ $t('myChoresPage.timelineHeaders.today') }}</h2>
<span class="timeline-count">{{ assignmentsByTimeline.today.length }}</span>
</div>
<div class="timeline-items">
<div v-for="assignment in assignmentsByTimeline.today" :key="assignment.id"
class="timeline-assignment-card today">
<div class="assignment-timeline-marker"></div>
<div class="assignment-content">
<div class="assignment-header">
<h3>{{ assignment.chore?.name }}</h3>
<div class="assignment-tags">
<span class="chore-type-tag" :class="assignment.chore?.type">
{{ assignment.chore?.type === 'personal' ? $t('myChoresPage.choreCard.personal') : $t('myChoresPage.choreCard.group') }}
</span>
<span class="chore-frequency-tag" :class="assignment.chore?.frequency">
{{ formatFrequency(assignment.chore?.frequency) }}
</span>
</div>
</div>
<div class="assignment-meta">
<div class="assignment-due-date today">
<span class="material-icons">today</span>
{{ $t('myChoresPage.choreCard.dueToday') }}
</div>
<div v-if="assignment.chore?.description" class="assignment-description">
{{ assignment.chore.description }}
</div>
</div>
<div class="assignment-actions">
<button class="btn btn-success btn-sm" @click="completeAssignment(assignment)"
:disabled="isCompleting">
<span class="material-icons">check_circle</span>
{{ $t('myChoresPage.choreCard.markCompleteButton') }}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- This Week Section -->
<div v-if="assignmentsByTimeline.thisWeek.length > 0" class="timeline-section this-week">
<div class="timeline-header">
<div class="timeline-dot this-week"></div>
<h2 class="timeline-title">{{ $t('myChoresPage.timelineHeaders.thisWeek') }}</h2>
<span class="timeline-count">{{ assignmentsByTimeline.thisWeek.length }}</span>
</div>
<div class="timeline-items">
<div v-for="assignment in assignmentsByTimeline.thisWeek" :key="assignment.id"
class="timeline-assignment-card this-week">
<div class="assignment-timeline-marker"></div>
<div class="assignment-content">
<div class="assignment-header">
<h3>{{ assignment.chore?.name }}</h3>
<div class="assignment-tags">
<span class="chore-type-tag" :class="assignment.chore?.type">
{{ assignment.chore?.type === 'personal' ? $t('myChoresPage.choreCard.personal') : $t('myChoresPage.choreCard.group') }}
</span>
<span class="chore-frequency-tag" :class="assignment.chore?.frequency">
{{ formatFrequency(assignment.chore?.frequency) }}
</span>
</div>
</div>
<div class="assignment-meta">
<div class="assignment-due-date this-week">
<span class="material-icons">date_range</span>
{{ $t('myChoresPage.choreCard.duePrefix') }} {{ formatDate(assignment.due_date) }}
</div>
<div v-if="assignment.chore?.description" class="assignment-description">
{{ assignment.chore.description }}
</div>
</div>
<div class="assignment-actions">
<button class="btn btn-success btn-sm" @click="completeAssignment(assignment)"
:disabled="isCompleting">
<span class="material-icons">check_circle</span>
{{ $t('myChoresPage.choreCard.markCompleteButton') }}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Later Section -->
<div v-if="assignmentsByTimeline.later.length > 0" class="timeline-section later">
<div class="timeline-header">
<div class="timeline-dot later"></div>
<h2 class="timeline-title">{{ $t('myChoresPage.timelineHeaders.later') }}</h2>
<span class="timeline-count">{{ assignmentsByTimeline.later.length }}</span>
</div>
<div class="timeline-items">
<div v-for="assignment in assignmentsByTimeline.later" :key="assignment.id"
class="timeline-assignment-card later">
<div class="assignment-timeline-marker"></div>
<div class="assignment-content">
<div class="assignment-header">
<h3>{{ assignment.chore?.name }}</h3>
<div class="assignment-tags">
<span class="chore-type-tag" :class="assignment.chore?.type">
{{ assignment.chore?.type === 'personal' ? $t('myChoresPage.choreCard.personal') : $t('myChoresPage.choreCard.group') }}
</span>
<span class="chore-frequency-tag" :class="assignment.chore?.frequency">
{{ formatFrequency(assignment.chore?.frequency) }}
</span>
</div>
</div>
<div class="assignment-meta">
<div class="assignment-due-date later">
<span class="material-icons">schedule</span>
{{ $t('myChoresPage.choreCard.duePrefix') }} {{ formatDate(assignment.due_date) }}
</div>
<div v-if="assignment.chore?.description" class="assignment-description">
{{ assignment.chore.description }}
</div>
</div>
<div class="assignment-actions">
<button class="btn btn-success btn-sm" @click="completeAssignment(assignment)"
:disabled="isCompleting">
<span class="material-icons">check_circle</span>
{{ $t('myChoresPage.choreCard.markCompleteButton') }}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Completed Section (if showing completed) -->
<div v-if="showCompleted && assignmentsByTimeline.completed.length > 0" class="timeline-section completed">
<div class="timeline-header">
<div class="timeline-dot completed"></div>
<h2 class="timeline-title">{{ $t('myChoresPage.timelineHeaders.completed') }}</h2>
<span class="timeline-count">{{ assignmentsByTimeline.completed.length }}</span>
</div>
<div class="timeline-items">
<div v-for="assignment in assignmentsByTimeline.completed" :key="assignment.id"
class="timeline-assignment-card completed">
<div class="assignment-timeline-marker"></div>
<div class="assignment-content">
<div class="assignment-header">
<h3>{{ assignment.chore?.name }}</h3>
<div class="assignment-tags">
<span class="chore-type-tag" :class="assignment.chore?.type">
{{ assignment.chore?.type === 'personal' ? $t('myChoresPage.choreCard.personal') : $t('myChoresPage.choreCard.group') }}
</span>
<span class="chore-frequency-tag" :class="assignment.chore?.frequency">
{{ formatFrequency(assignment.chore?.frequency) }}
</span>
</div>
</div>
<div class="assignment-meta">
<div class="assignment-due-date completed">
<span class="material-icons">check_circle</span>
{{ $t('myChoresPage.choreCard.completedPrefix') }} {{ formatDate(assignment.completed_at || assignment.updated_at) }}
</div>
<div v-if="assignment.chore?.description" class="assignment-description">
{{ assignment.chore.description }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else class="card empty-state-card">
<svg class="icon icon-lg" aria-hidden="true">
<use xlink:href="#icon-clipboard" />
</svg>
<h3>{{ $t('myChoresPage.emptyState.title') }}</h3>
<p v-if="showCompleted">{{ $t('myChoresPage.emptyState.noAssignmentsAll') }}</p>
<p v-else>{{ $t('myChoresPage.emptyState.noAssignmentsPending') }}</p>
<router-link to="/chores" class="btn btn-primary mt-2">
<svg class="icon" aria-hidden="true">
<use xlink:href="#icon-eye" />
</svg>
{{ $t('myChoresPage.emptyState.viewAllChoresButton') }}
</router-link>
</div>
</main>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { format } from 'date-fns'
import { choreService } from '../services/choreService'
import { useNotificationStore } from '../stores/notifications'
import type { ChoreAssignment, ChoreFrequency } from '../types/chore'
const { t } = useI18n()
const notificationStore = useNotificationStore()
// State
const assignments = ref<ChoreAssignment[]>([])
const showCompleted = ref(false)
const isCompleting = ref(false)
// Computed property for timeline grouping
const assignmentsByTimeline = computed(() => {
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
const nextWeek = new Date(today)
nextWeek.setDate(nextWeek.getDate() + 7)
const timeline = {
overdue: [] as ChoreAssignment[],
today: [] as ChoreAssignment[],
thisWeek: [] as ChoreAssignment[],
later: [] as ChoreAssignment[],
completed: [] as ChoreAssignment[]
}
assignments.value.forEach(assignment => {
if (assignment.is_complete) {
timeline.completed.push(assignment)
return
}
const dueDate = new Date(assignment.due_date)
const assignmentDate = new Date(dueDate.getFullYear(), dueDate.getMonth(), dueDate.getDate())
if (assignmentDate < today) {
timeline.overdue.push(assignment)
} else if (assignmentDate.getTime() === today.getTime()) {
timeline.today.push(assignment)
} else if (assignmentDate < nextWeek) {
timeline.thisWeek.push(assignment)
} else {
timeline.later.push(assignment)
}
})
// Sort each timeline section
Object.values(timeline).forEach(section => {
section.sort((a, b) => {
const dateA = new Date(a.due_date)
const dateB = new Date(b.due_date)
if (dateA.getTime() !== dateB.getTime()) {
return dateA.getTime() - dateB.getTime()
}
return (a.chore?.name || '').localeCompare(b.chore?.name || '')
})
})
return timeline
})
// frequencyOptions is not directly used for display labels anymore, but can be kept for logic if needed elsewhere.
// const frequencyOptions = [
// { label: 'One Time', value: 'one_time' as ChoreFrequency },
// { label: 'Daily', value: 'daily' as ChoreFrequency },
// { label: 'Weekly', value: 'weekly' as ChoreFrequency },
// { label: 'Monthly', value: 'monthly' as ChoreFrequency },
// { label: 'Custom', value: 'custom' as ChoreFrequency }
// ]
// Methods
const loadAssignments = async () => {
try {
assignments.value = await choreService.getMyAssignments(showCompleted.value)
} catch (error) {
console.error('Failed to load assignments:', error)
notificationStore.addNotification({
message: t('myChoresPage.notifications.loadFailed'),
type: 'error'
})
}
}
const completeAssignment = async (assignment: ChoreAssignment) => {
if (isCompleting.value) return
isCompleting.value = true
try {
await choreService.completeAssignment(assignment.id)
notificationStore.addNotification({
message: t('myChoresPage.notifications.markedComplete', { choreName: assignment.chore?.name || '' }),
type: 'success'
})
// Reload assignments to show updated state
await loadAssignments()
} catch (error) {
console.error('Failed to complete assignment:', error)
notificationStore.addNotification({
message: t('myChoresPage.notifications.markCompleteFailed'),
type: 'error'
})
} finally {
isCompleting.value = false
}
}
const formatDate = (date: string | undefined) => {
if (!date) return t('myChoresPage.dates.unknownDate');
// Attempt to parse and format; date-fns handles various ISO and other formats.
try {
const parsedDate = new Date(date);
// Check if parsedDate is valid
if (isNaN(parsedDate.getTime())) {
// Handle cases like "YYYY-MM-DD" which might be parsed as UTC midnight
// and then potentially displayed incorrectly depending on timezone.
// If the input is just a date string without time, ensure it's treated as local.
if (/^\d{4}-\d{2}-\d{2}$/.test(date)) {
const [year, month, day] = date.split('-').map(Number);
return format(new Date(year, month - 1, day), 'MMM d, yyyy');
}
return t('myChoresPage.dates.invalidDate');
}
return format(parsedDate, 'MMM d, yyyy');
} catch (e) {
// Catch any error during parsing (though Date constructor is quite forgiving)
return t('myChoresPage.dates.invalidDate');
}
}
const formatFrequency = (frequency: ChoreFrequency | undefined) => {
if (!frequency) return t('myChoresPage.frequencies.unknown');
// Assuming keys like myChoresPage.frequencies.one_time, myChoresPage.frequencies.daily
// The ChoreFrequency enum values ('one_time', 'daily', etc.) match the last part of the key.
return t(`myChoresPage.frequencies.${frequency}`);
}
// Lifecycle
onMounted(() => {
loadAssignments()
})
</script>
<style scoped>
.page-padding {
padding: 1rem;
max-width: 800px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.header-controls {
display: flex;
align-items: center;
gap: 1rem;
}
.toggle-switch {
position: relative;
display: inline-block;
width: 60px;
height: 34px;
cursor: pointer;
user-select: none;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
border-radius: 34px;
transition: 0.4s;
border: 2px solid #111;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 4px;
bottom: 4px;
background-color: #111;
border-radius: 50%;
transition: 0.4s;
}
input:checked+.toggle-slider {
background-color: #007bff;
}
input:checked+.toggle-slider:before {
transform: translateX(26px);
background-color: white;
}
.mb-3 {
margin-bottom: 1.5rem;
}
.mt-2 {
margin-top: 1rem;
}
/* Timeline Layout */
.assignments-timeline {
position: relative;
margin-left: 2rem;
}
/* Timeline line */
.assignments-timeline::before {
content: '';
position: absolute;
left: -1rem;
top: 0;
bottom: 0;
width: 4px;
background: #ddd;
border-radius: 2px;
}
/* Timeline Sections */
.timeline-section {
position: relative;
margin-bottom: 2rem;
background: var(--light);
border-radius: 18px;
box-shadow: 6px 6px 0 #111;
border: 3px solid #111;
overflow: hidden;
}
.timeline-section.overdue {
border-color: #dc3545;
box-shadow: 6px 6px 0 #dc3545;
}
.timeline-section.today {
border-color: #007bff;
box-shadow: 6px 6px 0 #007bff;
}
.timeline-section.this-week {
border-color: #28a745;
box-shadow: 6px 6px 0 #28a745;
}
.timeline-section.later {
border-color: #6c757d;
box-shadow: 6px 6px 0 #6c757d;
}
.timeline-section.completed {
border-color: #28a745;
box-shadow: 6px 6px 0 #28a745;
opacity: 0.8;
}
/* Timeline Header */
.timeline-header {
padding: 1.5rem;
border-bottom: 3px solid;
background: #fafafa;
display: flex;
align-items: center;
gap: 1rem;
}
.timeline-dot {
width: 20px;
height: 20px;
border-radius: 50%;
border: 3px solid #111;
background: white;
position: relative;
z-index: 2;
}
.timeline-dot.overdue {
background: #dc3545;
border-color: #dc3545;
}
.timeline-dot.today {
background: #007bff;
border-color: #007bff;
}
.timeline-dot.this-week {
background: #28a745;
border-color: #28a745;
}
.timeline-dot.later {
background: #6c757d;
border-color: #6c757d;
}
.timeline-dot.completed {
background: #28a745;
border-color: #28a745;
}
.timeline-title {
font-size: 1.25rem;
font-weight: 700;
margin: 0;
color: #111;
}
.timeline-count {
background: #111;
color: white;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.875rem;
font-weight: 600;
margin-left: auto;
}
/* Assignment Cards */
.timeline-assignment-card {
position: relative;
padding: 1.5rem;
border-bottom: 2px solid #eee;
background: white;
transition: all 0.2s;
}
.timeline-assignment-card:hover {
background: #f8f9fa;
}
.timeline-assignment-card:last-child {
border-bottom: none;
}
.assignment-timeline-marker {
position: absolute;
left: -2.4rem;
top: 1.5rem;
width: 12px;
height: 12px;
background: #111;
border-radius: 50%;
border: 3px solid white;
z-index: 1;
}
.assignment-content {
padding-left: 0.5rem;
}
.assignment-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
.assignment-header h3 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #111;
}
.assignment-tags {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
margin-left: 1rem;
}
.chore-type-tag,
.chore-frequency-tag {
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
border: 2px solid;
}
.chore-type-tag.personal {
background: #e3f2fd;
color: #1976d2;
border-color: #1976d2;
}
.chore-type-tag.group {
background: #f3e5f5;
color: #7b1fa2;
border-color: #7b1fa2;
}
.chore-frequency-tag {
background: #f5f5f5;
color: #666;
border-color: #ddd;
}
.assignment-meta {
margin-bottom: 1rem;
}
.assignment-due-date {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.assignment-due-date.overdue {
color: #dc3545;
}
.assignment-due-date.today {
color: #007bff;
}
.assignment-due-date.this-week {
color: #28a745;
}
.assignment-due-date.later {
color: #6c757d;
}
.assignment-due-date.completed {
color: #28a745;
}
.assignment-description {
color: #666;
font-size: 0.875rem;
line-height: 1.4;
}
.assignment-actions {
display: flex;
gap: 0.75rem;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
transition: all 0.2s;
text-decoration: none;
border: 2px solid;
font-size: 0.875rem;
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
}
.btn-success {
background: #28a745;
color: white;
border-color: #28a745;
}
.btn-success:hover:not(:disabled) {
background: #218838;
border-color: #218838;
}
.btn-primary {
background: #111;
color: white;
border-color: #111;
}
.btn-primary:hover {
background: #333;
border-color: #333;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.card {
background: white;
border: 3px solid #111;
border-radius: 18px;
box-shadow: 6px 6px 0 #111;
overflow: hidden;
}
.empty-state-card {
text-align: center;
padding: 3rem 2rem;
}
.empty-state-card .icon-lg {
width: 4rem;
height: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-state-card h3 {
margin: 0 0 1rem 0;
color: #111;
}
.empty-state-card p {
margin: 0 0 1rem 0;
color: #666;
}
</style>

View File

@ -1,508 +0,0 @@
<template>
<main class="container page-padding">
<div class="row q-mb-md items-center justify-between">
<h1 class="mb-3">{{ $t('personalChoresPage.title') }}</h1>
<button class="btn btn-primary" @click="openCreateChoreModal">
<span class="material-icons">add</span>
{{ $t('personalChoresPage.newChoreButton') }}
</button>
</div>
<!-- Chores List -->
<div class="neo-grid">
<div v-for="chore in chores" :key="chore.id" class="neo-card">
<div class="neo-card-header">
<div class="row items-center justify-between">
<h3>{{ chore.name }}</h3>
<span class="neo-chore-frequency" :class="chore.frequency">
{{ formatFrequency(chore.frequency) }}
</span>
</div>
</div>
<div class="neo-card-body">
<div class="neo-chore-info">
<div class="neo-chore-due">
{{ $t('personalChoresPage.dates.duePrefix') }}: {{ formatDate(chore.next_due_date) }}
</div>
<div v-if="chore.description" class="neo-chore-description">
{{ chore.description }}
</div>
</div>
<div class="neo-card-actions">
<button class="btn btn-primary btn-sm" @click="openEditChoreModal(chore)">
<span class="material-icons">edit</span>
{{ $t('personalChoresPage.editButton') }}
</button>
<button class="btn btn-danger btn-sm" @click="confirmDeleteChore(chore)">
<span class="material-icons">delete</span>
{{ $t('personalChoresPage.deleteButton') }}
</button>
</div>
</div>
</div>
</div>
<!-- Create/Edit Chore Modal -->
<div v-if="showChoreModal" class="neo-modal">
<div class="neo-modal-content">
<div class="neo-modal-header">
<h3>{{ isEditing ? $t('personalChoresPage.modals.editChoreTitle') : $t('personalChoresPage.modals.newChoreTitle') }}</h3>
<button class="btn btn-neutral btn-icon-only" @click="showChoreModal = false">
<span class="material-icons">close</span>
</button>
</div>
<div class="neo-modal-body">
<form @submit.prevent="onSubmit" class="neo-form">
<div class="neo-form-group">
<label for="name">{{ $t('personalChoresPage.form.nameLabel') }}</label>
<input
id="name"
v-model="choreForm.name"
type="text"
class="neo-input"
required
/>
</div>
<div class="neo-form-group">
<label for="description">{{ $t('personalChoresPage.form.descriptionLabel') }}</label>
<textarea
id="description"
v-model="choreForm.description"
class="neo-input"
rows="3"
></textarea>
</div>
<div class="neo-form-group">
<label for="frequency">{{ $t('personalChoresPage.form.frequencyLabel') }}</label>
<select
id="frequency"
v-model="choreForm.frequency"
class="neo-input"
required
>
<option v-for="option in frequencyOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
<div v-if="choreForm.frequency === 'custom'" class="neo-form-group">
<label for="interval">{{ $t('personalChoresPage.form.intervalLabel') }}</label>
<input
id="interval"
v-model.number="choreForm.custom_interval_days"
type="number"
class="neo-input"
min="1"
required
/>
</div>
<div class="neo-form-group">
<label for="dueDate">{{ $t('personalChoresPage.form.dueDateLabel') }}</label>
<input
id="dueDate"
v-model="choreForm.next_due_date"
type="date"
class="neo-input"
required
/>
</div>
</form>
</div>
<div class="neo-modal-footer">
<button class="btn btn-neutral" @click="showChoreModal = false">{{ $t('personalChoresPage.cancelButton') }}</button>
<button class="btn btn-primary" @click="onSubmit">{{ $t('personalChoresPage.saveButton') }}</button>
</div>
</div>
</div>
<!-- Delete Confirmation Dialog -->
<div v-if="showDeleteDialog" class="neo-modal">
<div class="neo-modal-content">
<div class="neo-modal-header">
<h3>{{ $t('personalChoresPage.modals.deleteChoreTitle') }}</h3>
<button class="btn btn-neutral btn-icon-only" @click="showDeleteDialog = false">
<span class="material-icons">close</span>
</button>
</div>
<div class="neo-modal-body">
<p>{{ $t('personalChoresPage.deleteDialog.confirmationText') }}</p>
</div>
<div class="neo-modal-footer">
<button class="btn btn-neutral" @click="showDeleteDialog = false">{{ $t('personalChoresPage.cancelButton') }}</button>
<button class="btn btn-danger" @click="deleteChore">{{ $t('personalChoresPage.deleteButton') }}</button>
</div>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { format } from 'date-fns'
import { choreService } from '../services/choreService'
import { useNotificationStore } from '../stores/notifications'
import type { Chore, ChoreCreate, ChoreUpdate, ChoreFrequency } from '../types/chore'
const { t } = useI18n()
const notificationStore = useNotificationStore()
// State
const chores = ref<Chore[]>([])
const showChoreModal = ref(false)
const showDeleteDialog = ref(false)
const isEditing = ref(false)
const selectedChore = ref<Chore | null>(null)
const choreForm = ref<ChoreCreate>({
name: '',
description: '',
frequency: 'daily',
custom_interval_days: undefined,
next_due_date: format(new Date(), 'yyyy-MM-dd'),
type: 'personal'
})
const frequencyOptions = computed(() => [
{ label: t('personalChoresPage.frequencies.one_time'), value: 'one_time' as ChoreFrequency },
{ label: t('personalChoresPage.frequencies.daily'), value: 'daily' as ChoreFrequency },
{ label: t('personalChoresPage.frequencies.weekly'), value: 'weekly' as ChoreFrequency },
{ label: t('personalChoresPage.frequencies.monthly'), value: 'monthly' as ChoreFrequency },
{ label: t('personalChoresPage.frequencies.custom'), value: 'custom' as ChoreFrequency }
])
// Methods
const loadChores = async () => {
try {
chores.value = await choreService.getPersonalChores()
} catch (error) {
console.error('Failed to load personal chores:', error)
notificationStore.addNotification({
message: t('personalChoresPage.notifications.loadFailed'),
type: 'error'
})
}
}
const openCreateChoreModal = () => {
isEditing.value = false
choreForm.value = {
name: '',
description: '',
frequency: 'daily',
custom_interval_days: undefined,
next_due_date: format(new Date(), 'yyyy-MM-dd'),
type: 'personal'
}
showChoreModal.value = true
}
const openEditChoreModal = (chore: Chore) => {
isEditing.value = true
selectedChore.value = chore
choreForm.value = { ...chore, type: 'personal' } // Ensure type is personal
showChoreModal.value = true
}
const onSubmit = async () => {
try {
const payload: ChoreCreate | ChoreUpdate = {
...choreForm.value,
type: 'personal' // Always personal for this page
};
if (isEditing.value && selectedChore.value) {
await choreService.updatePersonalChore(selectedChore.value.id, payload as ChoreUpdate)
notificationStore.addNotification({
message: t('personalChoresPage.notifications.updateSuccess'),
type: 'success'
})
} else {
await choreService.createPersonalChore(payload as ChoreCreate)
notificationStore.addNotification({
message: t('personalChoresPage.notifications.createSuccess'),
type: 'success'
})
}
showChoreModal.value = false
loadChores()
} catch (error) {
console.error('Failed to save personal chore:', error)
notificationStore.addNotification({
message: t('personalChoresPage.notifications.saveFailed'), // Generic message
type: 'error'
})
}
}
const confirmDeleteChore = (chore: Chore) => {
selectedChore.value = chore
showDeleteDialog.value = true
}
const deleteChore = async () => {
if (!selectedChore.value) return
try {
await choreService.deletePersonalChore(selectedChore.value.id)
showDeleteDialog.value = false
notificationStore.addNotification({
message: t('personalChoresPage.notifications.deleteSuccess'),
type: 'success'
})
loadChores()
} catch (error) {
console.error('Failed to delete personal chore:', error)
notificationStore.addNotification({
message: t('personalChoresPage.notifications.deleteFailed'),
type: 'error'
})
}
}
const formatDate = (date: string | undefined) => {
if (!date) return ''; // Or perhaps a specific 'Unknown Date' string if desired: t('personalChoresPage.dates.unknownDate')
try {
// Handles both 'YYYY-MM-DD' and full ISO with 'T'
const parsedDate = new Date(date);
if (isNaN(parsedDate.getTime())) {
// Explicitly handle 'YYYY-MM-DD' if new Date() struggles with it directly as local time
if (/^\d{4}-\d{2}-\d{2}$/.test(date)) {
const [year, month, day] = date.split('-').map(Number);
return format(new Date(year, month - 1, day), 'MMM d, yyyy');
}
return t('personalChoresPage.dates.invalidDate');
}
return format(parsedDate, 'MMM d, yyyy');
} catch (e) {
return t('personalChoresPage.dates.invalidDate');
}
}
const formatFrequency = (frequency: ChoreFrequency | undefined) => {
if (!frequency) return t('personalChoresPage.frequencies.unknown');
// Use the value from frequencyOptions which is now translated
const option = frequencyOptions.value.find(opt => opt.value === frequency);
return option ? option.label : t(`personalChoresPage.frequencies.${frequency}`); // Fallback if somehow not in options
}
// Lifecycle
onMounted(() => {
loadChores()
})
</script>
<style scoped>
.page-padding {
padding: 1rem;
max-width: 1200px;
margin: 0 auto;
}
.mb-3 {
margin-bottom: 1.5rem;
}
/* Neo Grid Layout */
.neo-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
margin-bottom: 2rem;
}
/* Neo Card Styles */
.neo-card {
border-radius: 18px;
box-shadow: 6px 6px 0 #111;
background: var(--light);
border: 3px solid #111;
overflow: hidden;
}
.neo-card-header {
padding: 1.5rem;
border-bottom: 3px solid #111;
background: #fafafa;
}
.neo-card-header h3 {
font-weight: 900;
font-size: 1.25rem;
margin: 0;
letter-spacing: 0.5px;
}
.neo-card-body {
padding: 1.5rem;
}
.neo-card-actions {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
/* Chore Info Styles */
.neo-chore-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.neo-chore-due {
font-size: 0.875rem;
color: #666;
}
.neo-chore-description {
margin-top: 0.5rem;
color: #444;
}
.neo-chore-frequency {
font-size: 0.875rem;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-weight: 600;
}
.neo-chore-frequency.one_time { background: #e0e0e0; }
.neo-chore-frequency.daily { background: #bbdefb; color: #1565c0; }
.neo-chore-frequency.weekly { background: #c8e6c9; color: #2e7d32; }
.neo-chore-frequency.monthly { background: #e1bee7; color: #7b1fa2; }
.neo-chore-frequency.custom { background: #ffe0b2; color: #ef6c00; }
/* Modal Styles */
.neo-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.neo-modal-content {
background: white;
border-radius: 18px;
box-shadow: 6px 6px 0 #111;
border: 3px solid #111;
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
}
.neo-modal-header {
padding: 1.5rem;
border-bottom: 3px solid #111;
background: #fafafa;
display: flex;
justify-content: space-between;
align-items: center;
}
.neo-modal-body {
padding: 1.5rem;
}
.neo-modal-footer {
padding: 1.5rem;
border-top: 3px solid #111;
background: #fafafa;
display: flex;
justify-content: flex-end;
gap: 1rem;
}
/* Form Styles */
.neo-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.neo-form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.neo-form-group label {
font-weight: 600;
}
.neo-input {
padding: 0.75rem;
border: 2px solid #111;
border-radius: 8px;
font-size: 1rem;
background: white;
}
.neo-input:focus {
outline: none;
border-color: var(--primary-color);
}
/* Button Styles */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border: 2px solid #111;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: transform 0.1s ease-in-out;
}
.btn:hover {
transform: translateY(-2px);
}
.btn-primary {
background: var(--primary-color);
color: white;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-neutral {
background: #f8f9fa;
color: #111;
}
.btn-sm {
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
.btn-icon-only {
padding: 0.5rem;
}
/* Responsive Adjustments */
@media (max-width: 768px) {
.neo-grid {
grid-template-columns: 1fr;
}
.neo-modal-content {
width: 95%;
}
}
</style>

View File

@ -1,4 +1,3 @@
// src/router/index.ts
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router' import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router'
import routes from './routes' import routes from './routes'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
@ -15,14 +14,13 @@ const router = createRouter({
}) })
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to, from, next) => {
// Auth guard logic
const authStore = useAuthStore() const authStore = useAuthStore()
const isAuthenticated = authStore.isAuthenticated const isAuthenticated = authStore.isAuthenticated
const publicRoutes = ['/auth/login', '/auth/signup', '/auth/callback'] // Added callback route const publicRoutes = ['/auth/login', '/auth/signup', '/auth/callback']
const requiresAuth = !publicRoutes.includes(to.path) const requiresAuth = !publicRoutes.includes(to.path)
if (requiresAuth && !isAuthenticated) { if (requiresAuth && !isAuthenticated) {
next({ path: '/auth/login', query: { redirect: to.fullPath } }) // Fixed login path with leading slash next({ path: '/auth/login', query: { redirect: to.fullPath } })
} else if (!requiresAuth && isAuthenticated) { } else if (!requiresAuth && isAuthenticated) {
next({ path: '/' }) next({ path: '/' })
} else { } else {

View File

@ -1,11 +1,9 @@
// src/router/routes.ts
// Adapt paths to new component locations
import type { RouteRecordRaw } from 'vue-router' import type { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
{ {
path: '/', path: '/',
component: () => import('../layouts/MainLayout.vue'), // Use .. alias component: () => import('../layouts/MainLayout.vue'),
children: [ children: [
{ path: '', redirect: '/lists' }, { path: '', redirect: '/lists' },
{ {
@ -37,7 +35,7 @@ const routes: RouteRecordRaw[] = [
{ {
path: 'groups/:groupId/lists', path: 'groups/:groupId/lists',
name: 'GroupLists', name: 'GroupLists',
component: () => import('../pages/ListsPage.vue'), // Reusing ListsPage component: () => import('../pages/ListsPage.vue'),
props: true, props: true,
meta: { keepAlive: true }, meta: { keepAlive: true },
}, },
@ -60,16 +58,10 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/pages/ChoresPage.vue'), component: () => import('@/pages/ChoresPage.vue'),
meta: { requiresAuth: true, keepAlive: false }, meta: { requiresAuth: true, keepAlive: false },
}, },
{
path: '/personal-chores',
name: 'PersonalChores',
component: () => import('@/pages/PersonalChoresPage.vue'),
meta: { requiresAuth: true, keepAlive: false },
},
], ],
}, },
{ {
path: '/auth', // Group auth routes under a common path for clarity (optional) path: '/auth',
component: () => import('../layouts/AuthLayout.vue'), component: () => import('../layouts/AuthLayout.vue'),
children: [ children: [
{ path: 'login', name: 'Login', component: () => import('../pages/LoginPage.vue') }, { path: 'login', name: 'Login', component: () => import('../pages/LoginPage.vue') },
@ -81,10 +73,10 @@ const routes: RouteRecordRaw[] = [
}, },
], ],
}, },
// { {
// path: '/:catchAll(.*)*', name: '404', path: '/:catchAll(.*)*', name: '404',
// component: () => import('../pages/ErrorNotFound.vue'), component: () => import('../pages/ErrorNotFound.vue'),
// }, },
] ]
export default routes export default routes

View File

@ -1,30 +1,26 @@
import { api } from './api' import { api } from './api'
import type { Chore, ChoreCreate, ChoreUpdate, ChoreType, ChoreAssignment, ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreHistory } 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 { apiClient, API_ENDPOINTS } from '@/config/api' import { apiClient, API_ENDPOINTS } from '@/config/api'
import type { Group } from '@/types/group'
export const choreService = { export const choreService = {
async getAllChores(): Promise<Chore[]> { async getAllChores(): Promise<Chore[]> {
try { try {
// Use the new optimized endpoint that returns all chores in a single request
const response = await api.get('/api/v1/chores/all') const response = await api.get('/api/v1/chores/all')
return response.data return response.data
} catch (error) { } catch (error) {
console.error('Failed to get all chores via optimized endpoint, falling back to individual calls:', error) console.error('Failed to get all chores via optimized endpoint, falling back to individual calls:', error)
// Fallback to the original method if the new endpoint fails
return this.getAllChoresFallback() return this.getAllChoresFallback()
} }
}, },
// Fallback method using individual API calls (kept for compatibility)
async getAllChoresFallback(): Promise<Chore[]> { async getAllChoresFallback(): Promise<Chore[]> {
let allChores: Chore[] = [] let allChores: Chore[] = []
try { try {
const personalChores = await this.getPersonalChores() const personalChores = await this.getPersonalChores()
allChores = allChores.concat(personalChores) allChores = allChores.concat(personalChores)
// Fetch chores for all groups
const userGroups: Group[] = await groupService.getUserGroups() const userGroups: Group[] = await groupService.getUserGroups()
for (const group of userGroups) { for (const group of userGroups) {
try { try {
@ -32,24 +28,20 @@ export const choreService = {
allChores = allChores.concat(groupChores) allChores = allChores.concat(groupChores)
} catch (groupError) { } catch (groupError) {
console.error(`Failed to get chores for group ${group.id} (${group.name}):`, groupError) console.error(`Failed to get chores for group ${group.id} (${group.name}):`, groupError)
// Continue fetching chores for other groups
} }
} }
} catch (error) { } catch (error) {
console.error('Failed to get all chores:', error) console.error('Failed to get all chores:', error)
// Optionally re-throw or handle as per application's error strategy
throw error throw error
} }
return allChores return allChores
}, },
// Group Chores (specific fetch, might still be used internally or for specific group views)
async getChores(groupId: number): Promise<Chore[]> { async getChores(groupId: number): Promise<Chore[]> {
const response = await api.get(`/api/v1/chores/groups/${groupId}/chores`) const response = await api.get(`/api/v1/chores/groups/${groupId}/chores`)
return response.data return response.data
}, },
// Unified createChore
async createChore(chore: ChoreCreate): Promise<Chore> { async createChore(chore: ChoreCreate): Promise<Chore> {
if (chore.type === 'personal') { if (chore.type === 'personal') {
const response = await api.post('/api/v1/chores/personal', chore) const response = await api.post('/api/v1/chores/personal', chore)
@ -62,10 +54,8 @@ export const choreService = {
} }
}, },
// Unified updateChore
async updateChore(choreId: number, chore: ChoreUpdate): Promise<Chore> { async updateChore(choreId: number, chore: ChoreUpdate): Promise<Chore> {
if (chore.type === 'personal') { if (chore.type === 'personal') {
// For personal chores, group_id is not part of the route
const response = await api.put(`/api/v1/chores/personal/${choreId}`, chore) const response = await api.put(`/api/v1/chores/personal/${choreId}`, chore)
return response.data return response.data
} else if (chore.type === 'group' && chore.group_id) { } else if (chore.type === 'group' && chore.group_id) {
@ -79,7 +69,6 @@ export const choreService = {
} }
}, },
// Unified deleteChore
async deleteChore(choreId: number, choreType: ChoreType, groupId?: number): Promise<void> { async deleteChore(choreId: number, choreType: ChoreType, groupId?: number): Promise<void> {
if (choreType === 'personal') { if (choreType === 'personal') {
await api.delete(`/api/v1/chores/personal/${choreId}`) await api.delete(`/api/v1/chores/personal/${choreId}`)
@ -90,95 +79,59 @@ export const choreService = {
} }
}, },
// Personal Chores (specific fetch, used by getAllChores)
async getPersonalChores(): Promise<Chore[]> { async getPersonalChores(): Promise<Chore[]> {
const response = await api.get('/api/v1/chores/personal') const response = await api.get('/api/v1/chores/personal')
return response.data return response.data
}, },
// === CHORE ASSIGNMENT METHODS ===
// Create chore assignment
async createAssignment(assignment: ChoreAssignmentCreate): Promise<ChoreAssignment> { async createAssignment(assignment: ChoreAssignmentCreate): Promise<ChoreAssignment> {
const response = await api.post('/api/v1/chores/assignments', assignment) const response = await api.post('/api/v1/chores/assignments', assignment)
return response.data return response.data
}, },
// Get user's assignments
async getMyAssignments(includeCompleted: boolean = false): Promise<ChoreAssignment[]> { async getMyAssignments(includeCompleted: boolean = false): Promise<ChoreAssignment[]> {
const response = await api.get(`/api/v1/chores/assignments/my?include_completed=${includeCompleted}`) const response = await api.get(`/api/v1/chores/assignments/my?include_completed=${includeCompleted}`)
return response.data return response.data
}, },
// Get assignments for a specific chore
async getChoreAssignments(choreId: number): Promise<ChoreAssignment[]> { async getChoreAssignments(choreId: number): Promise<ChoreAssignment[]> {
const response = await api.get(`/api/v1/chores/chores/${choreId}/assignments`) const response = await api.get(`/api/v1/chores/chores/${choreId}/assignments`)
return response.data return response.data
}, },
// Update assignment
async updateAssignment(assignmentId: number, update: ChoreAssignmentUpdate): Promise<ChoreAssignment> { async updateAssignment(assignmentId: number, update: ChoreAssignmentUpdate): Promise<ChoreAssignment> {
const response = await apiClient.put(`/api/v1/chores/assignments/${assignmentId}`, update) const response = await apiClient.put(`/api/v1/chores/assignments/${assignmentId}`, update)
return response.data return response.data
}, },
// Delete assignment
async deleteAssignment(assignmentId: number): Promise<void> { async deleteAssignment(assignmentId: number): Promise<void> {
await api.delete(`/api/v1/chores/assignments/${assignmentId}`) await api.delete(`/api/v1/chores/assignments/${assignmentId}`)
}, },
// Mark assignment as complete (convenience method)
async completeAssignment(assignmentId: number): Promise<ChoreAssignment> { async completeAssignment(assignmentId: number): Promise<ChoreAssignment> {
const response = await api.patch(`/api/v1/chores/assignments/${assignmentId}/complete`) const response = await api.patch(`/api/v1/chores/assignments/${assignmentId}/complete`)
return response.data return response.data
}, },
// Removed createPersonalChore, updatePersonalChore, deletePersonalChore
// They are merged into the unified methods above.
// Original group-specific methods might be kept if there are pages
// that specifically deal ONLY with a single group's chores and pass groupId.
// For ChoresPage.vue, we'll use the unified methods.
// The original group chore methods are below, we can decide to remove them if
// the unified methods cover all use cases and no other part of the app uses them directly.
// async createChore(groupId: number, chore: ChoreCreate): Promise<Chore> { // Original group create
// const response = await api.post(`/api/v1/chores/groups/${groupId}/chores`, chore)
// return response.data
// },
async _original_updateGroupChore( async _original_updateGroupChore(
groupId: number, groupId: number,
choreId: number, choreId: number,
chore: ChoreUpdate, chore: ChoreUpdate,
): Promise<Chore> { ): Promise<Chore> {
// Renamed original
const response = await api.put(`/api/v1/chores/groups/${groupId}/chores/${choreId}`, chore) const response = await api.put(`/api/v1/chores/groups/${groupId}/chores/${choreId}`, chore)
return response.data return response.data
}, },
async _original_deleteGroupChore(groupId: number, choreId: number): Promise<void> { async _original_deleteGroupChore(groupId: number, choreId: number): Promise<void> {
// Renamed original
await api.delete(`/api/v1/chores/groups/${groupId}/chores/${choreId}`) await api.delete(`/api/v1/chores/groups/${groupId}/chores/${choreId}`)
}, },
// Personal Chores (getPersonalChores is kept as it's used by getAllChores)
// async getPersonalChores(): Promise<Chore[]> { ... }
// async createPersonalChore(chore: ChoreCreate): Promise<Chore> { // Removed
// const response = await api.post('/api/v1/chores/personal', chore)
// return response.data
// },
async _updatePersonalChore(choreId: number, chore: ChoreUpdate): Promise<Chore> { async _updatePersonalChore(choreId: number, chore: ChoreUpdate): Promise<Chore> {
// Renamed original for safety, to be removed
const response = await api.put(`/api/v1/chores/personal/${choreId}`, chore) const response = await api.put(`/api/v1/chores/personal/${choreId}`, chore)
return response.data return response.data
}, },
async _deletePersonalChore(choreId: number): Promise<void> { async _deletePersonalChore(choreId: number): Promise<void> {
// Renamed original for safety, to be removed
await api.delete(`/api/v1/chores/personal/${choreId}`) await api.delete(`/api/v1/chores/personal/${choreId}`)
}, },

View File

@ -2,21 +2,6 @@ import { apiClient, API_ENDPOINTS } from '@/config/api';
import type { Group } from '@/types/group'; import type { Group } from '@/types/group';
import type { ChoreHistory } from '@/types/chore'; import type { ChoreHistory } from '@/types/chore';
// Define Group interface matching backend schema
export interface Group {
id: number
name: string
description?: string
created_at: string
updated_at: string
owner_id: number
members: {
id: number
email: string
role: 'owner' | 'member'
}[]
}
export const groupService = { export const groupService = {
async getUserGroups(): Promise<Group[]> { async getUserGroups(): Promise<Group[]> {
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE); const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE);
@ -32,8 +17,14 @@ export const groupService = {
return response.data; return response.data;
}, },
// Add other group-related service methods here, e.g.: async getGroupDetails(groupId: number): Promise<Group> {
// async getGroupDetails(groupId: number): Promise<Group> { ... } const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE + '/' + groupId);
// async createGroup(groupData: any): Promise<Group> { ... } return response.data;
// async addUserToGroup(groupId: number, userId: number): Promise<void> { ... } },
async createGroup(groupData: Group): Promise<Group> {
const response = await apiClient.post(API_ENDPOINTS.GROUPS.BASE, groupData);
return response.data;
},
} }