diff --git a/be/alembic/versions/52caaf813ef4_add_claimed_by_user_fields.py b/be/alembic/versions/52caaf813ef4_add_claimed_by_user_fields.py new file mode 100644 index 0000000..75a1234 --- /dev/null +++ b/be/alembic/versions/52caaf813ef4_add_claimed_by_user_fields.py @@ -0,0 +1,32 @@ +"""add claimed_by_user fields + +Revision ID: 52caaf813ef4 +Revises: 7f73c3196b99 +Create Date: 2025-06-28 18:28:08.783756 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '52caaf813ef4' +down_revision: Union[str, None] = '7f73c3196b99' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/be/alembic/versions/7f73c3196b99_add_claim_fields_to_item.py b/be/alembic/versions/7f73c3196b99_add_claim_fields_to_item.py new file mode 100644 index 0000000..3a0f21c --- /dev/null +++ b/be/alembic/versions/7f73c3196b99_add_claim_fields_to_item.py @@ -0,0 +1,135 @@ +"""add claim fields to item + +Revision ID: 7f73c3196b99 +Revises: d5f8a2e4c7b9 +Create Date: 2025-06-28 16:11:49.500494 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '7f73c3196b99' +down_revision: Union[str, None] = 'd5f8a2e4c7b9' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('ix_apscheduler_jobs_next_run_time', table_name='apscheduler_jobs') + op.drop_table('apscheduler_jobs') + op.alter_column('categories', 'is_deleted', + existing_type=sa.BOOLEAN(), + server_default=None, + existing_nullable=False) + op.alter_column('chore_assignments', 'is_deleted', + existing_type=sa.BOOLEAN(), + server_default=None, + existing_nullable=False) + op.alter_column('chores', 'is_deleted', + existing_type=sa.BOOLEAN(), + server_default=None, + existing_nullable=False) + op.alter_column('expense_splits', 'is_deleted', + existing_type=sa.BOOLEAN(), + server_default=None, + existing_nullable=False) + op.alter_column('expenses', 'is_deleted', + existing_type=sa.BOOLEAN(), + server_default=None, + existing_nullable=False) + op.alter_column('groups', 'is_deleted', + existing_type=sa.BOOLEAN(), + server_default=None, + existing_nullable=False) + op.add_column('items', sa.Column('claimed_by_user_id', sa.Integer(), nullable=True)) + op.add_column('items', sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True)) + op.add_column('items', sa.Column('claimed_at', sa.DateTime(timezone=True), nullable=True)) + op.alter_column('items', 'is_deleted', + existing_type=sa.BOOLEAN(), + server_default=None, + existing_nullable=False) + op.create_foreign_key(None, 'items', 'users', ['claimed_by_user_id'], ['id']) + op.alter_column('lists', 'is_deleted', + existing_type=sa.BOOLEAN(), + server_default=None, + existing_nullable=False) + op.alter_column('recurrence_patterns', 'is_deleted', + existing_type=sa.BOOLEAN(), + server_default=None, + existing_nullable=False) + op.alter_column('time_entries', 'is_deleted', + existing_type=sa.BOOLEAN(), + server_default=None, + existing_nullable=False) + op.alter_column('users', 'is_deleted', + existing_type=sa.BOOLEAN(), + server_default=None, + existing_nullable=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('users', 'is_deleted', + existing_type=sa.BOOLEAN(), + server_default=sa.text('false'), + existing_nullable=False) + op.alter_column('time_entries', 'is_deleted', + existing_type=sa.BOOLEAN(), + server_default=sa.text('false'), + existing_nullable=False) + op.alter_column('recurrence_patterns', 'is_deleted', + existing_type=sa.BOOLEAN(), + server_default=sa.text('false'), + existing_nullable=False) + op.alter_column('lists', 'is_deleted', + existing_type=sa.BOOLEAN(), + server_default=sa.text('false'), + existing_nullable=False) + op.drop_constraint(None, 'items', type_='foreignkey') + op.alter_column('items', 'is_deleted', + existing_type=sa.BOOLEAN(), + server_default=sa.text('false'), + existing_nullable=False) + op.drop_column('items', 'claimed_at') + op.drop_column('items', 'completed_at') + op.drop_column('items', 'claimed_by_user_id') + op.alter_column('groups', 'is_deleted', + existing_type=sa.BOOLEAN(), + server_default=sa.text('false'), + existing_nullable=False) + op.alter_column('expenses', 'is_deleted', + existing_type=sa.BOOLEAN(), + server_default=sa.text('false'), + existing_nullable=False) + op.alter_column('expense_splits', 'is_deleted', + existing_type=sa.BOOLEAN(), + server_default=sa.text('false'), + existing_nullable=False) + op.alter_column('chores', 'is_deleted', + existing_type=sa.BOOLEAN(), + server_default=sa.text('false'), + existing_nullable=False) + op.alter_column('chore_assignments', 'is_deleted', + existing_type=sa.BOOLEAN(), + server_default=sa.text('false'), + existing_nullable=False) + op.alter_column('categories', 'is_deleted', + existing_type=sa.BOOLEAN(), + server_default=sa.text('false'), + existing_nullable=False) + op.create_table('apscheduler_jobs', + sa.Column('id', sa.VARCHAR(length=191), autoincrement=False, nullable=False), + sa.Column('next_run_time', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True), + sa.Column('job_state', postgresql.BYTEA(), autoincrement=False, nullable=False), + sa.PrimaryKeyConstraint('id', name='apscheduler_jobs_pkey') + ) + op.create_index('ix_apscheduler_jobs_next_run_time', 'apscheduler_jobs', ['next_run_time'], unique=False) + # ### end Alembic commands ### diff --git a/be/app/api/auth/magic_link.py b/be/app/api/auth/magic_link.py new file mode 100644 index 0000000..c2ab8f1 --- /dev/null +++ b/be/app/api/auth/magic_link.py @@ -0,0 +1,60 @@ +from fastapi import APIRouter, HTTPException, status, Depends, Request, Query +from pydantic import BaseModel, EmailStr +from app.auth import get_user_manager, AuthenticationBackendWithRefresh, bearer_transport, get_jwt_strategy, get_refresh_jwt_strategy +from fastapi.responses import JSONResponse + +router = APIRouter() + +class MagicLinkRequest(BaseModel): + email: EmailStr + +# Path: POST /api/v1/auth/magic-link +@router.post('/magic-link', status_code=status.HTTP_200_OK) +async def send_magic_link(payload: MagicLinkRequest, request: Request, user_manager=Depends(get_user_manager)): + """Generate a one-time magic-link token and *log* it for now. + + In production this should email the user. For Phase-4 backend milestone we + simply issue the verification token and return it in the response so the + frontend can test the flow without an email provider. + """ + # Ensure user exists (create guest if not) + user = await user_manager.get_by_email(payload.email) + if user is None: + # Auto-register guest account (inactive until verified) + user_in = { + 'email': payload.email, + 'password': '', # FastAPI Users requires but we will bypass login + } + # Using UserCreate model generically – relies on fastapi-users internals + try: + user = await user_manager.create(user_in, safe=True, request=request) + except Exception: + raise HTTPException(status_code=400, detail='Unable to create account') + + verification_token = await user_manager.generate_verification_token(user) + + # TODO: send email instead of returning token + return {'detail': 'Magic link generated (token returned for dev)', 'token': verification_token} + + +@router.get('/magic-link/verify') +async def verify_magic_link(token: str = Query(...), request: Request = None, user_manager=Depends(get_user_manager)): + """Verify incoming token and issue standard JWT + refresh tokens.""" + try: + user = await user_manager.verify(token, request) + except Exception: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid or expired token') + + # Issue JWT + refresh using existing backend routines + access_strategy = get_jwt_strategy() + refresh_strategy = get_refresh_jwt_strategy() + access_token = await access_strategy.write_token(user) + refresh_token = await refresh_strategy.write_token(user) + + return JSONResponse( + { + 'access_token': access_token, + 'refresh_token': refresh_token, + 'token_type': 'bearer', + } + ) \ No newline at end of file diff --git a/be/app/api/v1/api.py b/be/app/api/v1/api.py index 4974c40..e85025a 100644 --- a/be/app/api/v1/api.py +++ b/be/app/api/v1/api.py @@ -11,9 +11,18 @@ from app.api.v1.endpoints import financials from app.api.v1.endpoints import chores from app.api.v1.endpoints import history from app.api.v1.endpoints import categories -from app.api.v1.endpoints import users from app.api.auth import oauth, guest, jwt +# WebSocket support +from app.api.v1.endpoints import websocket as ws_endpoint +from app.api.v1.endpoints import users + +# Magic link endpoints +from app.api.auth import magic_link + +# New activity router +from app.api.v1.endpoints import activity + api_router_v1 = APIRouter() api_router_v1.include_router(health.router) @@ -31,3 +40,12 @@ api_router_v1.include_router(oauth.router, prefix="/auth", tags=["Auth"]) api_router_v1.include_router(guest.router, prefix="/auth", tags=["Auth"]) api_router_v1.include_router(jwt.router, prefix="/auth", tags=["Auth"]) api_router_v1.include_router(users.router, prefix="/users", tags=["Users"]) + +# Magic link +api_router_v1.include_router(magic_link.router, prefix="/auth", tags=["Auth"]) + +# New activity router +api_router_v1.include_router(activity.router, tags=["Activity"]) + +# WebSockets (no prefix, standalone path) +api_router_v1.include_router(ws_endpoint.router, tags=["WebSockets"]) diff --git a/be/app/api/v1/endpoints/activity.py b/be/app/api/v1/endpoints/activity.py new file mode 100644 index 0000000..29b6482 --- /dev/null +++ b/be/app/api/v1/endpoints/activity.py @@ -0,0 +1,33 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_session +from app.crud import activity as crud_activity +from app.schemas.activity import PaginatedActivityResponse + +router = APIRouter() + +@router.get( + "/groups/{group_id}/activity", + response_model=PaginatedActivityResponse, + summary="Get group activity feed", + tags=["Activity"], +) +async def get_group_activity( + group_id: int, + limit: int = Query(20, ge=1, le=100), + cursor: Optional[int] = Query(None, description="Unix timestamp for pagination"), + db: AsyncSession = Depends(get_session), +): + """ + Retrieves a paginated feed of recent activities within a group. + """ + activities = await crud_activity.get_group_activity(db, group_id=group_id, limit=limit, cursor=cursor) + + next_cursor = None + if activities and len(activities) == limit: + next_cursor = int(activities[-1].timestamp.timestamp()) + + return PaginatedActivityResponse(items=activities, cursor=next_cursor) \ No newline at end of file diff --git a/be/app/api/v1/endpoints/items.py b/be/app/api/v1/endpoints/items.py index 70aa4fa..eae8b2f 100644 --- a/be/app/api/v1/endpoints/items.py +++ b/be/app/api/v1/endpoints/items.py @@ -1,4 +1,3 @@ - import logging from typing import List as PyList, Optional @@ -13,6 +12,7 @@ from app.crud import item as crud_item from app.crud import list as crud_list from app.core.exceptions import ItemNotFoundError, ListPermissionError, ConflictError from app.auth import current_active_user +from app.core.redis import broadcast_event logger = logging.getLogger(__name__) router = APIRouter() @@ -162,4 +162,85 @@ async def delete_item( await crud_item.delete_item(db=db, item_db=item_db) logger.info(f"Item {item_id} (version {item_db.version}) deleted successfully by user {user_email}.") - return Response(status_code=status.HTTP_204_NO_CONTENT) \ No newline at end of file + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.post( + "/items/{item_id}/claim", + response_model=ItemPublic, + summary="Claim an Item", + tags=["Items"], + responses={ + status.HTTP_409_CONFLICT: {"description": "Item is already claimed or completed"}, + status.HTTP_403_FORBIDDEN: {"description": "User does not have permission to claim this item"} + } +) +async def claim_item( + item_id: int, + db: AsyncSession = Depends(get_transactional_session), + current_user: UserModel = Depends(current_active_user), +): + """Marks an item as claimed by the current user.""" + item_db = await get_item_and_verify_access(item_id, db, current_user) + + if item_db.list.group_id is None: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Items on personal lists cannot be claimed.") + + try: + updated_item = await crud_item.claim_item(db, item=item_db, user_id=current_user.id) + + # Broadcast the event + event = { + "type": "item_claimed", + "payload": { + "list_id": updated_item.list_id, + "item_id": updated_item.id, + "claimed_by": { + "id": current_user.id, + "name": current_user.name + }, + "claimed_at": updated_item.claimed_at.isoformat(), + "version": updated_item.version + } + } + await broadcast_event(f"list_{updated_item.list_id}", event) + + return updated_item + except ConflictError as e: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) + + +@router.delete( + "/items/{item_id}/claim", + response_model=ItemPublic, + summary="Unclaim an Item", + tags=["Items"], + responses={ + status.HTTP_403_FORBIDDEN: {"description": "User cannot unclaim an item they did not claim"} + } +) +async def unclaim_item( + item_id: int, + db: AsyncSession = Depends(get_transactional_session), + current_user: UserModel = Depends(current_active_user), +): + """Removes the current user's claim from an item.""" + item_db = await get_item_and_verify_access(item_id, db, current_user) + + if item_db.claimed_by_user_id != current_user.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only unclaim items that you have claimed.") + + updated_item = await crud_item.unclaim_item(db, item=item_db) + + # Broadcast the event + event = { + "type": "item_unclaimed", + "payload": { + "list_id": updated_item.list_id, + "item_id": updated_item.id, + "version": updated_item.version + } + } + await broadcast_event(f"list_{updated_item.list_id}", event) + + return updated_item \ No newline at end of file diff --git a/be/app/api/v1/endpoints/websocket.py b/be/app/api/v1/endpoints/websocket.py new file mode 100644 index 0000000..7c75f13 --- /dev/null +++ b/be/app/api/v1/endpoints/websocket.py @@ -0,0 +1,77 @@ +from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query, status, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import get_jwt_strategy, get_user_from_token +from app.database import get_transactional_session +from app.crud import list as crud_list +from app.core.redis import subscribe_to_channel, unsubscribe_from_channel +from app.models import User +import asyncio + +router = APIRouter() + +async def _verify_jwt(token: str): + """Return user_id from JWT or None if invalid/expired.""" + strategy = get_jwt_strategy() + try: + # FastAPI Users' JWTStrategy.read_token returns the user ID encoded in the token + user_id = await strategy.read_token(token) + return user_id + except Exception: # pragma: no cover – any decoding/expiration error + return None + + +@router.websocket("/ws/lists/{list_id}") +async def list_websocket_endpoint( + websocket: WebSocket, + list_id: int, + token: str = Query(...), + db: AsyncSession = Depends(get_transactional_session), +): + """Authenticated WebSocket endpoint for a specific list.""" + user = await get_user_from_token(token, db) + if not user: + await websocket.close(code=status.WS_1008_POLICY_VIOLATION) + return + + try: + # Verify the user has access to this list's group + await crud_list.check_list_permission(db, list_id, user.id) + except Exception: + await websocket.close(code=status.WS_1003_UNSUPPORTED_DATA) + return + + await websocket.accept() + + # Subscribe to the list-specific channel + pubsub = await subscribe_to_channel(f"list_{list_id}") + + try: + # Keep the connection alive and forward messages from Redis + while True: + message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=None) + if message and message.get("type") == "message": + await websocket.send_text(message["data"]) + except WebSocketDisconnect: + # Client disconnected + pass + finally: + # Clean up the Redis subscription + await unsubscribe_from_channel(f"list_{list_id}", pubsub) + +@router.websocket("/ws/{household_id}") +async def household_websocket_endpoint( + websocket: WebSocket, + household_id: int, + token: str = Query(...), + db: AsyncSession = Depends(get_transactional_session) +): + """Authenticated WebSocket endpoint for a household.""" + user = await get_user_from_token(token, db) + if not user: + await websocket.close(code=status.WS_1008_POLICY_VIOLATION) + return + + # TODO: Add permission check for household + + await websocket.accept() + # ... rest of household logic \ No newline at end of file diff --git a/be/app/auth.py b/be/app/auth.py index b09d0d0..31ad6c4 100644 --- a/be/app/auth.py +++ b/be/app/auth.py @@ -16,6 +16,7 @@ from starlette.middleware.sessions import SessionMiddleware from starlette.responses import Response from pydantic import BaseModel from fastapi.responses import JSONResponse +from sqlalchemy import select from .database import get_session from .models import User @@ -165,4 +166,35 @@ fastapi_users = FastAPIUsers[User, int]( ) current_active_user = fastapi_users.current_user(active=True) -current_superuser = fastapi_users.current_user(active=True, superuser=True) \ No newline at end of file +current_superuser = fastapi_users.current_user(active=True, superuser=True) + +# --------------------------------------------------------------------------- +# JWT helper function used by WebSocket endpoints +# --------------------------------------------------------------------------- + +async def get_user_from_token(token: str, db: AsyncSession) -> Optional[User]: + """Return the ``User`` associated with a valid *token* or ``None``. + + The function decodes the JWT using the same strategy FastAPI Users uses + elsewhere in the application. If the token is invalid/expired or the user + cannot be found (or is deleted/inactive), ``None`` is returned so the + caller can close the WebSocket with the appropriate code. + """ + + strategy = get_jwt_strategy() + + try: + user_id = await strategy.read_token(token) + except Exception: + # Any decoding/parsing/expiry error – treat as invalid token + return None + + # Fetch the user from the database. We avoid failing hard – return ``None`` + # if the user does not exist or is inactive/deleted. + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if user is None or getattr(user, "is_deleted", False) or not user.is_active: + return None + + return user \ No newline at end of file diff --git a/be/app/core/redis.py b/be/app/core/redis.py index 768b845..db7264b 100644 --- a/be/app/core/redis.py +++ b/be/app/core/redis.py @@ -1,7 +1,51 @@ import redis.asyncio as redis from app.config import settings +import json +from typing import Any, Dict redis_pool = redis.from_url(settings.REDIS_URL, encoding="utf-8", decode_responses=True) async def get_redis(): - return redis_pool \ No newline at end of file + return redis_pool + +# --------------------------------------------------------------------------- +# Helper functions for Pub/Sub messaging +# --------------------------------------------------------------------------- + +async def broadcast_event(channel: str, event: Dict[str, Any]) -> None: + """Serialize the given event as JSON and publish it on the specified Redis channel. + + This is primarily used by API endpoints to notify WebSocket consumers of + real-time changes (e.g. an item being claimed). Consumers subscribe to the + corresponding channel (see ``subscribe_to_channel``) and relay the JSON + payload to connected clients. + """ + + redis_conn = await get_redis() + # NOTE: ``json.dumps`` ensures we only send textual data over the wire + # which plays nicely with WebSocket ``send_text`` in the frontend. + await redis_conn.publish(channel, json.dumps(event)) + +async def subscribe_to_channel(channel: str): + """Return a PubSub instance already subscribed to *channel*. + + The caller is responsible for reading messages from the returned ``pubsub`` + object and for eventually invoking :func:`unsubscribe_from_channel` to + clean up resources. + """ + + redis_conn = await get_redis() + pubsub = redis_conn.pubsub() + await pubsub.subscribe(channel) + return pubsub + +async def unsubscribe_from_channel(channel: str, pubsub) -> None: + """Unsubscribe from *channel* and properly close the PubSub connection.""" + + try: + await pubsub.unsubscribe(channel) + finally: + # Close the pubsub object to release the underlying connection back to + # the pool. We ignore any exceptions here because we are often in a + # ``finally`` block during WebSocket shutdown. + await pubsub.close() \ No newline at end of file diff --git a/be/app/crud/activity.py b/be/app/crud/activity.py new file mode 100644 index 0000000..c2eb8f7 --- /dev/null +++ b/be/app/crud/activity.py @@ -0,0 +1,81 @@ +from datetime import datetime +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy.orm import selectinload +from sqlalchemy import func, text + +from app.models import ChoreHistory, FinancialAuditLog, Item, UserGroup, User +from app.schemas.activity import Activity, ActivityEventType, ActivityUser + +async def get_group_activity(db: AsyncSession, group_id: int, limit: int, cursor: int = None) -> list[Activity]: + + queries = [] + + chore_history_stmt = ( + select(ChoreHistory) + .where(ChoreHistory.group_id == group_id) + .options(selectinload(ChoreHistory.changed_by_user)) + ) + if cursor: + chore_history_stmt = chore_history_stmt.where(ChoreHistory.timestamp < datetime.fromtimestamp(cursor)) + queries.append(chore_history_stmt) + + # FinancialAuditLog has group_id through joins, which is complex. For now, we assume a direct or easy link. + # This might need refinement based on actual relationships. + # This is a placeholder as FinancialAuditLog doesn't have a direct group_id. + # We will need to join through expenses/settlements, etc. + # For now, this part will return empty. + + items_stmt = ( + select(Item) + .join(Item.list) + .where(text("lists.group_id = :group_id")) + .params(group_id=group_id) + .options(selectinload(Item.added_by_user), selectinload(Item.completed_by_user)) + ) + if cursor: + items_stmt = items_stmt.where(Item.created_at < datetime.fromtimestamp(cursor)) + queries.append(items_stmt) + + results = [] + for q in queries: + res = await db.execute(q) + results.extend(res.scalars().all()) + + activities = [] + for item in results: + if isinstance(item, ChoreHistory): + if item.event_type.value == 'completed': + user = item.changed_by_user + activities.append(Activity( + id=f"chore_history-{item.id}", + event_type=ActivityEventType.CHORE_COMPLETED, + timestamp=item.timestamp, + user=ActivityUser(id=user.id, name=user.name), + details={"chore_name": item.event_data.get("name", "Unknown chore")}, + message=f"{user.name or 'User'} completed '{item.event_data.get('name', 'a chore')}'." + )) + elif isinstance(item, Item): + user = item.added_by_user + activities.append(Activity( + id=f"item-added-{item.id}", + event_type=ActivityEventType.ITEM_ADDED, + timestamp=item.created_at, + user=ActivityUser(id=user.id, name=user.name), + details={"item_name": item.name, "list_id": item.list_id}, + message=f"{user.name or 'User'} added '{item.name}' to a list." + )) + if item.is_complete and item.completed_by_user: + c_user = item.completed_by_user + activities.append(Activity( + id=f"item-completed-{item.id}", + event_type=ActivityEventType.ITEM_COMPLETED, + timestamp=item.updated_at, # This is an approximation + user=ActivityUser(id=c_user.id, name=c_user.name), + details={"item_name": item.name, "list_id": item.list_id}, + message=f"{c_user.name or 'User'} purchased '{item.name}'." + )) + + activities.sort(key=lambda x: x.timestamp, reverse=True) + + return activities[:limit] \ No newline at end of file diff --git a/be/app/crud/item.py b/be/app/crud/item.py index f958a43..2e4d866 100644 --- a/be/app/crud/item.py +++ b/be/app/crud/item.py @@ -189,4 +189,27 @@ async def delete_item(db: AsyncSession, item_db: ItemModel) -> None: raise DatabaseConnectionError(f"Database connection error while deleting item: {str(e)}") except SQLAlchemyError as e: logger.error(f"Unexpected SQLAlchemy error while deleting item: {str(e)}", exc_info=True) - raise DatabaseTransactionError(f"Failed to delete item: {str(e)}") \ No newline at end of file + raise DatabaseTransactionError(f"Failed to delete item: {str(e)}") + +async def claim_item(db: AsyncSession, item: ItemModel, user_id: int) -> ItemModel: + """Marks an item as claimed by a user.""" + if item.is_complete: + raise ConflictError("Cannot claim a completed item.") + if item.claimed_by_user_id is not None and item.claimed_by_user_id != user_id: + raise ConflictError("Item is already claimed by another user.") + + item.claimed_by_user_id = user_id + item.claimed_at = datetime.now(timezone.utc) + item.version += 1 + db.add(item) + await db.flush() + await db.refresh(item, attribute_names=['claimed_by_user', 'version', 'claimed_at']) + return item + +async def unclaim_item(db: AsyncSession, item: ItemModel) -> ItemModel: + """Removes a user's claim from an item.""" + item.claimed_by_user_id = None + item.claimed_at = None + item.version += 1 + db.add(item) + await db.flush() \ No newline at end of file diff --git a/be/app/models.py b/be/app/models.py index 613ae4d..c01418c 100644 --- a/be/app/models.py +++ b/be/app/models.py @@ -1,6 +1,7 @@ import enum import secrets from datetime import datetime, timedelta, timezone +from typing import Optional from sqlalchemy import ( Column, @@ -18,7 +19,8 @@ from sqlalchemy import ( Text, Numeric, CheckConstraint, - Date + Date, + Float ) from sqlalchemy.orm import relationship, declared_attr from sqlalchemy.dialects.postgresql import JSONB @@ -115,6 +117,12 @@ class User(Base, SoftDeleteMixin): created_lists = relationship("List", foreign_keys="List.created_by_id", back_populates="creator") added_items = relationship("Item", foreign_keys="Item.added_by_id", back_populates="added_by_user") completed_items = relationship("Item", foreign_keys="Item.completed_by_id", back_populates="completed_by_user") + # Items this user has claimed responsibility for (e.g., grocery list items) + claimed_items = relationship( + "Item", + foreign_keys="Item.claimed_by_user_id", + back_populates="claimed_by_user", + ) expenses_paid = relationship("Expense", foreign_keys="Expense.paid_by_user_id", back_populates="paid_by_user") expenses_created = relationship("Expense", foreign_keys="Expense.created_by_user_id", back_populates="created_by_user") expense_splits = relationship("ExpenseSplit", foreign_keys="ExpenseSplit.user_id", back_populates="user") @@ -220,14 +228,18 @@ class Item(Base, SoftDeleteMixin): category_id = Column(Integer, ForeignKey('categories.id'), nullable=True) added_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) completed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) + claimed_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True) 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) version = Column(Integer, nullable=False, default=1, server_default='1') + completed_at = Column(DateTime(timezone=True), nullable=True) + claimed_at = Column(DateTime(timezone=True), nullable=True) # --- Relationships --- list = relationship("List", back_populates="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") + claimed_by_user = relationship("User", foreign_keys=[claimed_by_user_id], back_populates="claimed_items") expenses = relationship("Expense", back_populates="item") category = relationship("Category", back_populates="items") diff --git a/be/app/schemas/activity.py b/be/app/schemas/activity.py new file mode 100644 index 0000000..f4ca91f --- /dev/null +++ b/be/app/schemas/activity.py @@ -0,0 +1,32 @@ +from pydantic import BaseModel, Field +from datetime import datetime +from enum import Enum +from typing import Optional, Dict, Any, List + +class ActivityEventType(str, Enum): + CHORE_COMPLETED = "chore_completed" + CHORE_CREATED = "chore_created" + EXPENSE_CREATED = "expense_created" + EXPENSE_SETTLED = "expense_settled" + ITEM_ADDED = "item_added" + ITEM_COMPLETED = "item_completed" + USER_JOINED_GROUP = "user_joined_group" + +class ActivityUser(BaseModel): + id: int + name: Optional[str] = None + +class Activity(BaseModel): + id: str = Field(..., description="A unique ID for the activity event, e.g., 'chore_completed-123'") + event_type: ActivityEventType + timestamp: datetime + user: ActivityUser + details: Dict[str, Any] = Field({}, description="Structured data about the event.") + message: str = Field(..., description="A human-readable message, e.g., 'John completed \"Wash dishes\".'") + + class Config: + from_attributes = True + +class PaginatedActivityResponse(BaseModel): + items: List[Activity] + cursor: Optional[int] = Field(None, description="The timestamp of the last item, for cursor-based pagination.") \ No newline at end of file diff --git a/be/done.md b/be/done.md deleted file mode 100644 index e69de29..0000000 diff --git a/docs/inventory-2025-06-28.csv b/docs/inventory-2025-06-28.csv new file mode 100644 index 0000000..5b0fbee --- /dev/null +++ b/docs/inventory-2025-06-28.csv @@ -0,0 +1,48 @@ +path,feature,uses SCSS?,uses Options API?,third-party libs +src/App.vue,Global UI,✔️,❌, +src/layouts/MainLayout.vue,Global UI,✔️,❌, +src/layouts/AuthLayout.vue,Auth,✔️,❌, +src/components/SocialLoginButtons.vue,Auth,❌,❌,vue3-social +src/components/OfflineIndicator.vue,Global UI,❌,❌, +src/components/global/NotificationDisplay.vue,Global UI,✔️,❌, +src/components/EssentialLink.vue,Global UI,❌,❌, +src/components/CategoryForm.vue,Categories,❌,❌, +src/components/SettleShareModal.vue,Lists,❌,❌, +src/components/CreateGroupModal.vue,Groups,❌,❌, +src/components/CreateListModal.vue,Lists,❌,❌, +src/components/CreateExpenseForm.vue,Expenses,❌,❌, +src/components/ChoreItem.vue,Chores,✔️,❌, +src/components/ConflictResolutionDialog.vue,Chores,❌,❌, +src/components/expenses/ExpenseForm.vue,Expenses,❌,❌, +src/components/expenses/ExpenseList.vue,Expenses,❌,❌, +src/components/expenses/RecurrencePatternForm.vue,Expenses,❌,❌, +src/components/list-detail/CostSummaryDialog.vue,Lists,❌,❌, +src/components/list-detail/ExpenseSection.vue,Lists,❌,❌, +src/components/list-detail/ItemsList.vue,Lists,❌,❌, +src/components/list-detail/ListItem.vue,Lists,❌,❌, +src/components/list-detail/OcrDialog.vue,Lists,❌,❌, +src/components/list-detail/SettleShareModal.vue,Lists,❌,❌, +src/pages/LoginPage.vue,Auth,❌,❌, +src/pages/SignupPage.vue,Auth,❌,❌, +src/pages/AuthCallbackPage.vue,Auth,❌,❌, +src/pages/AccountPage.vue,Auth,❌,❌, +src/pages/ChoresPage.vue,Chores,✔️,❌, +src/pages/CategoriesPage.vue,Categories,❌,❌, +src/pages/IndexPage.vue,Global UI,❌,❌, +src/pages/ExpensePage.vue,Expenses,✔️,❌, +src/pages/ExpensesPage.vue,Expenses,❌,❌, +src/pages/GroupsPage.vue,Groups,❌,❌, +src/pages/GroupDetailPage.vue,Groups,❌,❌, +src/pages/ListDetailPage.vue,Lists,❌,❌, +src/pages/ListsPage.vue,Lists,❌,❌, +src/pages/ErrorNotFound.vue,Misc,❌,❌, + +# Pinia Stores +path,feature,uses SCSS?,uses Options API?,third-party libs +src/stores/auth.ts,Auth,,,, +src/stores/categoryStore.ts,Categories,,,, +src/stores/groupStore.ts,Groups,,,, +src/stores/listDetailStore.ts,Lists,,,, +src/stores/offline.ts,Global UI,,,, +src/stores/notifications.ts,Global UI,,,, +src/stores/timeEntryStore.ts,Chores,,,, \ No newline at end of file diff --git a/fe/.storybook/main.ts b/fe/.storybook/main.ts deleted file mode 100644 index f55bbbe..0000000 --- a/fe/.storybook/main.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { StorybookConfig } from '@storybook/vue3-vite'; - -const config: StorybookConfig = { - "stories": [ - "../src/**/*.mdx", - "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)" - ], - "addons": [ - "@storybook/addon-onboarding", - "@storybook/addon-docs" - ], - "framework": { - "name": "@storybook/vue3-vite", - "options": {} - } -}; -export default config; \ No newline at end of file diff --git a/fe/.storybook/preview.ts b/fe/.storybook/preview.ts deleted file mode 100644 index 18167ed..0000000 --- a/fe/.storybook/preview.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Preview } from '@storybook/vue3-vite' - -const preview: Preview = { - parameters: { - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/i, - }, - }, - }, -}; - -export default preview; \ No newline at end of file diff --git a/fe/src/components/AuthenticationSheet.vue b/fe/src/components/AuthenticationSheet.vue new file mode 100644 index 0000000..8d2706c --- /dev/null +++ b/fe/src/components/AuthenticationSheet.vue @@ -0,0 +1,97 @@ + + + + + \ No newline at end of file diff --git a/fe/src/components/ChoreDetailSheet.vue b/fe/src/components/ChoreDetailSheet.vue new file mode 100644 index 0000000..fdb47ea --- /dev/null +++ b/fe/src/components/ChoreDetailSheet.vue @@ -0,0 +1,96 @@ + + + \ No newline at end of file diff --git a/fe/src/components/ChoreItem.vue b/fe/src/components/ChoreItem.vue index f78f8ec..9e31f84 100644 --- a/fe/src/components/ChoreItem.vue +++ b/fe/src/components/ChoreItem.vue @@ -1,52 +1,84 @@