From 53c7382b888791a618894f017193b669fee32bb8 Mon Sep 17 00:00:00 2001 From: mohamad Date: Mon, 31 Mar 2025 00:07:43 +0200 Subject: [PATCH] end of phase 4 --- .../d25788f63e2c_add_list_and_item_tables.py | 73 +++ be/app/api/v1/api.py | 5 +- be/app/api/v1/endpoints/items.py | 150 ++++++ be/app/api/v1/endpoints/lists.py | 211 ++++++++ be/app/crud/item.py | 67 +++ be/app/crud/list.py | 151 ++++++ be/app/models.py | 149 +++--- be/app/schemas/item.py | 34 ++ be/app/schemas/list.py | 45 ++ fe/package-lock.json | 9 + fe/package.json | 5 +- fe/src/lib/components/ItemDisplay.svelte | 318 ++++++++++++ fe/src/lib/components/ListForm.svelte | 201 ++++++++ fe/src/lib/db.ts | 195 +++++++ fe/src/lib/schemas/item.ts | 27 + fe/src/lib/schemas/list.ts | 35 ++ fe/src/lib/syncService.ts | 154 ++++++ fe/src/routes/(app)/dashboard/+page.svelte | 52 +- fe/src/routes/(app)/dashboard/+page.ts | 3 + .../routes/(app)/lists/[listId]/+page.svelte | 475 ++++++++++++++++++ fe/src/routes/(app)/lists/[listId]/+page.ts | 53 ++ .../(app)/lists/[listId]/edit/+page.svelte | 16 + .../routes/(app)/lists/[listId]/edit/+page.ts | 75 +++ fe/src/routes/(app)/lists/new/+page.svelte | 13 + fe/src/routes/(app)/lists/new/+page.ts | 32 ++ fe/src/service-worker.ts | 109 +++- 26 files changed, 2563 insertions(+), 94 deletions(-) create mode 100644 be/alembic/versions/d25788f63e2c_add_list_and_item_tables.py create mode 100644 be/app/api/v1/endpoints/items.py create mode 100644 be/app/api/v1/endpoints/lists.py create mode 100644 be/app/crud/item.py create mode 100644 be/app/crud/list.py create mode 100644 be/app/schemas/item.py create mode 100644 be/app/schemas/list.py create mode 100644 fe/src/lib/components/ItemDisplay.svelte create mode 100644 fe/src/lib/components/ListForm.svelte create mode 100644 fe/src/lib/db.ts create mode 100644 fe/src/lib/schemas/item.ts create mode 100644 fe/src/lib/schemas/list.ts create mode 100644 fe/src/lib/syncService.ts create mode 100644 fe/src/routes/(app)/lists/[listId]/+page.svelte create mode 100644 fe/src/routes/(app)/lists/[listId]/+page.ts create mode 100644 fe/src/routes/(app)/lists/[listId]/edit/+page.svelte create mode 100644 fe/src/routes/(app)/lists/[listId]/edit/+page.ts create mode 100644 fe/src/routes/(app)/lists/new/+page.svelte create mode 100644 fe/src/routes/(app)/lists/new/+page.ts diff --git a/be/alembic/versions/d25788f63e2c_add_list_and_item_tables.py b/be/alembic/versions/d25788f63e2c_add_list_and_item_tables.py new file mode 100644 index 0000000..3a36da9 --- /dev/null +++ b/be/alembic/versions/d25788f63e2c_add_list_and_item_tables.py @@ -0,0 +1,73 @@ +"""Add list and item tables + +Revision ID: d25788f63e2c +Revises: d90ab7116920 +Create Date: 2025-03-30 19:43:49.925240 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd25788f63e2c' +down_revision: Union[str, None] = 'd90ab7116920' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('lists', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('created_by_id', sa.Integer(), nullable=False), + sa.Column('group_id', sa.Integer(), nullable=True), + sa.Column('is_complete', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_lists_id'), 'lists', ['id'], unique=False) + op.create_index(op.f('ix_lists_name'), 'lists', ['name'], unique=False) + op.create_table('items', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('list_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('quantity', sa.String(), nullable=True), + sa.Column('is_complete', sa.Boolean(), nullable=False), + sa.Column('price', sa.Numeric(precision=10, scale=2), nullable=True), + sa.Column('added_by_id', sa.Integer(), nullable=False), + sa.Column('completed_by_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['added_by_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['completed_by_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['list_id'], ['lists.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_items_id'), 'items', ['id'], unique=False) + op.create_index(op.f('ix_items_name'), 'items', ['name'], unique=False) + op.drop_index('ix_invites_code', table_name='invites') + op.create_index(op.f('ix_invites_code'), 'invites', ['code'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_invites_code'), table_name='invites') + op.create_index('ix_invites_code', 'invites', ['code'], unique=True) + op.drop_index(op.f('ix_items_name'), table_name='items') + op.drop_index(op.f('ix_items_id'), table_name='items') + op.drop_table('items') + op.drop_index(op.f('ix_lists_name'), table_name='lists') + op.drop_index(op.f('ix_lists_id'), table_name='lists') + op.drop_table('lists') + # ### end Alembic commands ### diff --git a/be/app/api/v1/api.py b/be/app/api/v1/api.py index 5616c14..84afb76 100644 --- a/be/app/api/v1/api.py +++ b/be/app/api/v1/api.py @@ -6,6 +6,8 @@ from app.api.v1.endpoints import auth from app.api.v1.endpoints import users from app.api.v1.endpoints import groups from app.api.v1.endpoints import invites +from app.api.v1.endpoints import lists +from app.api.v1.endpoints import items api_router_v1 = APIRouter() @@ -14,6 +16,7 @@ api_router_v1.include_router(auth.router, prefix="/auth", tags=["Authentication" api_router_v1.include_router(users.router, prefix="/users", tags=["Users"]) api_router_v1.include_router(groups.router, prefix="/groups", tags=["Groups"]) api_router_v1.include_router(invites.router, prefix="/invites", tags=["Invites"]) - +api_router_v1.include_router(lists.router, prefix="/lists", tags=["Lists"]) +api_router_v1.include_router(items.router, tags=["Items"]) # Add other v1 endpoint routers here later # e.g., api_router_v1.include_router(users.router, prefix="/users", tags=["Users"]) \ No newline at end of file diff --git a/be/app/api/v1/endpoints/items.py b/be/app/api/v1/endpoints/items.py new file mode 100644 index 0000000..7cff34b --- /dev/null +++ b/be/app/api/v1/endpoints/items.py @@ -0,0 +1,150 @@ +# app/api/v1/endpoints/items.py +import logging +from typing import List as PyList + +from fastapi import APIRouter, Depends, HTTPException, status, Response +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.api.dependencies import get_current_user +# --- Import Models Correctly --- +from app.models import User as UserModel +from app.models import Item as ItemModel # <-- IMPORT Item and alias it +# --- End Import Models --- +from app.schemas.item import ItemCreate, ItemUpdate, ItemPublic +from app.crud import item as crud_item +from app.crud import list as crud_list + +logger = logging.getLogger(__name__) +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( + item_id: int, + db: AsyncSession = Depends(get_db), + current_user: UserModel = Depends(get_current_user) +) -> ItemModel: # Now this type hint is valid + item_db = await crud_item.get_item_by_id(db, item_id=item_id) + if not item_db: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item not found") + + # Check permission on the parent list + list_db = await crud_list.check_list_permission(db=db, list_id=item_db.list_id, user_id=current_user.id) + if not list_db: + # User doesn't have access to the list this item belongs to + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized to access this item's list") + return item_db # Return the fetched item if authorized + + +# --- Endpoints --- + +@router.post( + "/lists/{list_id}/items", # Nested under lists + response_model=ItemPublic, + status_code=status.HTTP_201_CREATED, + summary="Add Item to List", + tags=["Items"] +) +async def create_list_item( + list_id: int, + item_in: ItemCreate, + db: AsyncSession = Depends(get_db), + current_user: UserModel = Depends(get_current_user), +): + """Adds a new item to a specific list. User must have access to the list.""" + logger.info(f"User {current_user.email} adding item to list {list_id}: {item_in.name}") + # Verify user has access to the target list + list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id) + if not list_db: + # Check if list exists at all for correct error code + exists = await crud_list.get_list_by_id(db, list_id) + status_code = status.HTTP_404_NOT_FOUND if not exists else status.HTTP_403_FORBIDDEN + detail = "List not found" if not exists else "You do not have permission to add items to this list" + logger.warning(f"Add item failed for list {list_id} by user {current_user.email}: {detail}") + raise HTTPException(status_code=status_code, detail=detail) + + created_item = await crud_item.create_item( + db=db, item_in=item_in, list_id=list_id, user_id=current_user.id + ) + logger.info(f"Item '{created_item.name}' (ID: {created_item.id}) added to list {list_id} by user {current_user.email}.") + return created_item + + +@router.get( + "/lists/{list_id}/items", # Nested under lists + response_model=PyList[ItemPublic], + summary="List Items in List", + tags=["Items"] +) +async def read_list_items( + list_id: int, + db: AsyncSession = Depends(get_db), + current_user: UserModel = Depends(get_current_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.""" + logger.info(f"User {current_user.email} listing items for list {list_id}") + # Verify user has access to the list + list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id) + if not list_db: + exists = await crud_list.get_list_by_id(db, list_id) + status_code = status.HTTP_404_NOT_FOUND if not exists else status.HTTP_403_FORBIDDEN + detail = "List not found" if not exists else "You do not have permission to view items in this list" + logger.warning(f"List items failed for list {list_id} by user {current_user.email}: {detail}") + raise HTTPException(status_code=status_code, detail=detail) + + items = await crud_item.get_items_by_list_id(db=db, list_id=list_id) + return items + + +@router.put( + "/items/{item_id}", # Operate directly on item ID + response_model=ItemPublic, + summary="Update Item", + tags=["Items"] +) +async def update_item( + item_id: int, # Item ID from path + item_in: ItemUpdate, + item_db: ItemModel = Depends(get_item_and_verify_access), # Use dependency to get item and check list access + db: AsyncSession = Depends(get_db), + current_user: UserModel = Depends(get_current_user), # Need user ID for completed_by +): + """ + Updates an item's details (name, quantity, is_complete, price). + User must have access to the list the item belongs to. + Sets/unsets `completed_by_id` based on `is_complete` flag. + """ + logger.info(f"User {current_user.email} attempting to update item ID: {item_id}") + # Permission check is handled by get_item_and_verify_access dependency + + updated_item = await crud_item.update_item( + db=db, item_db=item_db, item_in=item_in, user_id=current_user.id + ) + logger.info(f"Item {item_id} updated successfully by user {current_user.email}.") + return updated_item + + +@router.delete( + "/items/{item_id}", # Operate directly on item ID + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete Item", + tags=["Items"] +) +async def delete_item( + item_id: int, # Item ID from path + item_db: ItemModel = Depends(get_item_and_verify_access), # Use dependency to get item and check list access + db: AsyncSession = Depends(get_db), + current_user: UserModel = Depends(get_current_user), # Log who deleted it +): + """ + Deletes an item. User must have access to the list the item belongs to. + (MVP: Any member with list access can delete items). + """ + logger.info(f"User {current_user.email} attempting to delete item ID: {item_id}") + # Permission check is handled by get_item_and_verify_access dependency + + await crud_item.delete_item(db=db, item_db=item_db) + logger.info(f"Item {item_id} deleted successfully by user {current_user.email}.") + return Response(status_code=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/be/app/api/v1/endpoints/lists.py b/be/app/api/v1/endpoints/lists.py new file mode 100644 index 0000000..25d833b --- /dev/null +++ b/be/app/api/v1/endpoints/lists.py @@ -0,0 +1,211 @@ +# app/api/v1/endpoints/lists.py +import logging +from typing import List as PyList # Alias for Python List type hint + +from fastapi import APIRouter, Depends, HTTPException, status, Response +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.api.dependencies import get_current_user +from app.models import User as UserModel +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 group as crud_group # Need for group membership check +from app.schemas.list import ListStatus + +logger = logging.getLogger(__name__) +router = APIRouter() + +@router.post( + "", # Route relative to prefix "/lists" + response_model=ListPublic, # Return basic list info on creation + status_code=status.HTTP_201_CREATED, + summary="Create New List", + tags=["Lists"] +) +async def create_list( + list_in: ListCreate, + db: AsyncSession = Depends(get_db), + current_user: UserModel = Depends(get_current_user), +): + """ + Creates a new shopping list. + - If `group_id` is provided, the user must be a member of that group. + - If `group_id` is null, it's a personal list. + """ + logger.info(f"User {current_user.email} creating list: {list_in.name}") + group_id = list_in.group_id + + # Permission Check: If sharing with a group, verify membership + if group_id: + is_member = await crud_group.is_user_member(db, group_id=group_id, user_id=current_user.id) + if not is_member: + logger.warning(f"User {current_user.email} attempted to create list in group {group_id} but is not a member.") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You are not a member of the specified group", + ) + + created_list = await crud_list.create_list(db=db, list_in=list_in, creator_id=current_user.id) + logger.info(f"List '{created_list.name}' (ID: {created_list.id}) created successfully for user {current_user.email}.") + return created_list + + +@router.get( + "", # Route relative to prefix "/lists" + response_model=PyList[ListPublic], # Return a list of basic list info + summary="List Accessible Lists", + tags=["Lists"] +) +async def read_lists( + db: AsyncSession = Depends(get_db), + current_user: UserModel = Depends(get_current_user), + # Add pagination parameters later if needed: skip: int = 0, limit: int = 100 +): + """ + Retrieves lists accessible to the current user: + - Personal lists created by the user. + - Lists belonging to groups the user is a member of. + """ + logger.info(f"Fetching lists accessible to user: {current_user.email}") + lists = await crud_list.get_lists_for_user(db=db, user_id=current_user.id) + return lists + + +@router.get( + "/{list_id}", + response_model=ListDetail, # Return detailed list info including items + summary="Get List Details", + tags=["Lists"] +) +async def read_list( + list_id: int, + db: AsyncSession = Depends(get_db), + current_user: UserModel = Depends(get_current_user), +): + """ + Retrieves details for a specific list, including its items, + if the user has permission (creator or group member). + """ + logger.info(f"User {current_user.email} requesting details for list ID: {list_id}") + # Use the helper to fetch and check permission simultaneously + list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id) + + if not list_db: + # check_list_permission returns None if list not found OR permission denied + # We need to check if the list exists at all to return 404 vs 403 + exists = await crud_list.get_list_by_id(db, list_id) + if not exists: + logger.warning(f"List ID {list_id} not found for request by user {current_user.email}.") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="List not found") + else: + logger.warning(f"Access denied: User {current_user.email} cannot access list {list_id}.") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You do not have permission to access this list") + + # list_db already has items loaded due to check_list_permission + return list_db + + +@router.put( + "/{list_id}", + response_model=ListPublic, # Return updated basic info + summary="Update List", + tags=["Lists"] +) +async def update_list( + list_id: int, + list_in: ListUpdate, + db: AsyncSession = Depends(get_db), + current_user: UserModel = Depends(get_current_user), +): + """ + Updates a list's details (name, description, is_complete). + Requires user to be the creator or a member of the list's group. + (MVP: Allows any member to update these fields). + """ + logger.info(f"User {current_user.email} attempting to update list ID: {list_id}") + list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id) + + if not list_db: + exists = await crud_list.get_list_by_id(db, list_id) + status_code = status.HTTP_404_NOT_FOUND if not exists else status.HTTP_403_FORBIDDEN + detail = "List not found" if not exists else "You do not have permission to update this list" + logger.warning(f"Update failed for list {list_id} by user {current_user.email}: {detail}") + raise HTTPException(status_code=status_code, detail=detail) + + # Prevent changing group_id or creator via this endpoint for simplicity + # if list_in.group_id is not None or list_in.created_by_id is not None: + # raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot change group or creator via this endpoint") + + 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}.") + return updated_list + + +@router.delete( + "/{list_id}", + status_code=status.HTTP_204_NO_CONTENT, # Standard for successful DELETE with no body + summary="Delete List", + tags=["Lists"] +) +async def delete_list( + list_id: int, + db: AsyncSession = Depends(get_db), + current_user: UserModel = Depends(get_current_user), +): + """ + Deletes a list. Requires user to be the creator of the list. + (Alternatively, could allow group owner). + """ + logger.info(f"User {current_user.email} attempting to delete list ID: {list_id}") + # 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) + + if not list_db: + exists = await crud_list.get_list_by_id(db, list_id) + status_code = status.HTTP_404_NOT_FOUND if not exists else status.HTTP_403_FORBIDDEN + detail = "List not found" if not exists else "Only the list creator can delete this list" + logger.warning(f"Delete failed for list {list_id} by user {current_user.email}: {detail}") + raise HTTPException(status_code=status_code, detail=detail) + + await crud_list.delete_list(db=db, list_db=list_db) + logger.info(f"List {list_id} deleted successfully by user {current_user.email}.") + # Return Response with 204 status explicitly if needed, otherwise FastAPI handles it + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get( + "/{list_id}/status", + response_model=ListStatus, + summary="Get List Status (for polling)", + tags=["Lists"] +) +async def read_list_status( + list_id: int, + db: AsyncSession = Depends(get_db), + current_user: UserModel = Depends(get_current_user), +): + """ + Retrieves the last update time for the list and its items, plus item count. + Used for polling to check if a full refresh is needed. + Requires user to have permission to view the list. + """ + # Verify user has access to the list first + list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id) + if not list_db: + # Check if list exists at all for correct error code + exists = await crud_list.get_list_by_id(db, list_id) + status_code = status.HTTP_404_NOT_FOUND if not exists else status.HTTP_403_FORBIDDEN + detail = "List not found" if not exists else "You do not have permission to access this list's status" + logger.warning(f"Status check failed for list {list_id} by user {current_user.email}: {detail}") + raise HTTPException(status_code=status_code, detail=detail) + + # Fetch the status details + list_status = await crud_list.get_list_status(db=db, list_id=list_id) + if not list_status: + # Should not happen if check_list_permission passed, but handle defensively + logger.error(f"Could not retrieve status for list {list_id} even though permission check passed.") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="List status not found") + + return list_status \ No newline at end of file diff --git a/be/app/crud/item.py b/be/app/crud/item.py new file mode 100644 index 0000000..c340665 --- /dev/null +++ b/be/app/crud/item.py @@ -0,0 +1,67 @@ +# app/crud/item.py +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy import delete as sql_delete, update as sql_update # Use aliases +from typing import Optional, List as PyList +from datetime import datetime, timezone + +from app.models import Item as ItemModel +from app.schemas.item import ItemCreate, ItemUpdate + +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.""" + db_item = ItemModel( + name=item_in.name, + quantity=item_in.quantity, + list_id=list_id, + added_by_id=user_id, + is_complete=False # Default on creation + ) + db.add(db_item) + await db.commit() + await db.refresh(db_item) + return db_item + +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.""" + result = await db.execute( + select(ItemModel) + .where(ItemModel.list_id == list_id) + .order_by(ItemModel.created_at.asc()) # Or desc() if preferred + ) + return result.scalars().all() + +async def get_item_by_id(db: AsyncSession, item_id: int) -> Optional[ItemModel]: + """Gets a single item by its ID.""" + result = await db.execute(select(ItemModel).where(ItemModel.id == item_id)) + return result.scalars().first() + +async def update_item(db: AsyncSession, item_db: ItemModel, item_in: ItemUpdate, user_id: int) -> ItemModel: + """Updates an existing item record.""" + update_data = item_in.model_dump(exclude_unset=True) # Get only provided fields + + # Special handling for is_complete + if 'is_complete' in update_data: + if update_data['is_complete'] is True: + # Mark as complete: set completed_by_id if not already set + if item_db.completed_by_id is None: + update_data['completed_by_id'] = user_id + else: + # Mark as incomplete: clear completed_by_id + update_data['completed_by_id'] = None + # Ensure updated_at is refreshed (handled by onupdate in model, but explicit is fine too) + # update_data['updated_at'] = datetime.now(timezone.utc) + + for key, value in update_data.items(): + setattr(item_db, key, value) + + db.add(item_db) # Add to session to track changes + await db.commit() + await db.refresh(item_db) + return item_db + +async def delete_item(db: AsyncSession, item_db: ItemModel) -> None: + """Deletes an item record.""" + await db.delete(item_db) + await db.commit() + return None # Or return True/False \ No newline at end of file diff --git a/be/app/crud/list.py b/be/app/crud/list.py new file mode 100644 index 0000000..de81013 --- /dev/null +++ b/be/app/crud/list.py @@ -0,0 +1,151 @@ +# app/crud/list.py +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy.orm import selectinload, joinedload +from sqlalchemy import or_, and_, delete as sql_delete # Use alias for delete +from typing import Optional, List as PyList # Use alias for List +from sqlalchemy import func as sql_func, desc # Import func and desc + +from app.schemas.list import ListStatus # Import the new schema +from app.models import List as ListModel, UserGroup as UserGroupModel, Item as ItemModel +from app.schemas.list import ListCreate, ListUpdate + +async def create_list(db: AsyncSession, list_in: ListCreate, creator_id: int) -> ListModel: + """Creates a new list record.""" + db_list = ListModel( + name=list_in.name, + description=list_in.description, + group_id=list_in.group_id, + created_by_id=creator_id, + is_complete=False # Default on creation + ) + db.add(db_list) + await db.commit() + await db.refresh(db_list) + return db_list + +async def get_lists_for_user(db: AsyncSession, user_id: int) -> PyList[ListModel]: + """ + Gets all lists accessible by a user: + - Personal lists created by the user (group_id is NULL). + - Lists belonging to groups the user is a member of. + """ + # Get IDs of groups the user is a member of + group_ids_result = await db.execute( + select(UserGroupModel.group_id).where(UserGroupModel.user_id == user_id) + ) + user_group_ids = group_ids_result.scalars().all() + + # Query for lists + query = select(ListModel).where( + or_( + # Personal lists + and_(ListModel.created_by_id == user_id, ListModel.group_id == None), + # Group lists where user is a member + ListModel.group_id.in_(user_group_ids) + ) + ).order_by(ListModel.updated_at.desc()) # Order by most recently updated + + result = await db.execute(query) + return result.scalars().all() + +async def get_list_by_id(db: AsyncSession, list_id: int, load_items: bool = False) -> Optional[ListModel]: + """Gets a single list by ID, optionally loading its items.""" + query = select(ListModel).where(ListModel.id == list_id) + if load_items: + # Eager load items and their creators/completers if needed + query = query.options( + selectinload(ListModel.items) + .options( + joinedload(ItemModel.added_by_user), # Use joinedload for simple FKs + joinedload(ItemModel.completed_by_user) + ) + ) + result = await db.execute(query) + return result.scalars().first() + +async def update_list(db: AsyncSession, list_db: ListModel, list_in: ListUpdate) -> ListModel: + """Updates an existing list record.""" + update_data = list_in.model_dump(exclude_unset=True) # Get only provided fields + for key, value in update_data.items(): + setattr(list_db, key, value) + db.add(list_db) # Add to session to track changes + await db.commit() + await db.refresh(list_db) + return list_db + +async def delete_list(db: AsyncSession, list_db: ListModel) -> None: + """Deletes a list record.""" + # Items should be deleted automatically due to cascade="all, delete-orphan" + # on List.items relationship and ondelete="CASCADE" on Item.list_id FK + await db.delete(list_db) + await db.commit() + return None # Or return True/False if needed + +# --- Helper for Permission Checks --- +async def check_list_permission(db: AsyncSession, list_id: int, user_id: int, require_creator: bool = False) -> Optional[ListModel]: + """ + Fetches a list and verifies user permission. + + Args: + db: Database session. + list_id: The ID of the list to check. + user_id: The ID of the user requesting access. + require_creator: If True, only allows the creator access. + + Returns: + The ListModel if found and permission granted, otherwise None. + (Raising exceptions might be better handled in the endpoint). + """ + list_db = await get_list_by_id(db, list_id=list_id, load_items=True) # Load items for detail/update/delete context + if not list_db: + return None # List not found + + # Check if user is the creator + is_creator = list_db.created_by_id == user_id + + if require_creator: + return list_db if is_creator else None + + # If not requiring creator, check membership if it's a group list + if is_creator: + return list_db # Creator always has access + + if list_db.group_id: + # Check if user is member of the list's group + from app.crud.group import is_user_member # Avoid circular import at top level + is_member = await is_user_member(db, group_id=list_db.group_id, user_id=user_id) + return list_db if is_member else None + else: + # Personal list, not the creator -> no access + return None + +async def get_list_status(db: AsyncSession, list_id: int) -> Optional[ListStatus]: + """ + Gets the update timestamps and item count for a list. + Returns None if the list itself doesn't exist. + """ + # Fetch list updated_at time + list_query = select(ListModel.updated_at).where(ListModel.id == list_id) + list_result = await db.execute(list_query) + list_updated_at = list_result.scalar_one_or_none() + + if list_updated_at is None: + return None # List not found + + # Fetch the latest item update time and count for that list + item_status_query = ( + select( + sql_func.max(ItemModel.updated_at).label("latest_item_updated_at"), + sql_func.count(ItemModel.id).label("item_count") + ) + .where(ItemModel.list_id == list_id) + ) + item_result = await db.execute(item_status_query) + item_status = item_result.first() # Use first() as aggregate always returns one row + + return ListStatus( + list_updated_at=list_updated_at, + latest_item_updated_at=item_status.latest_item_updated_at if item_status else None, + item_count=item_status.item_count if item_status else 0 + ) \ No newline at end of file diff --git a/be/app/models.py b/be/app/models.py index 20fcd54..851b8c0 100644 --- a/be/app/models.py +++ b/be/app/models.py @@ -1,7 +1,7 @@ # app/models.py import enum -import secrets # For generating invite codes -from datetime import datetime, timedelta, timezone # For invite expiry +import secrets +from datetime import datetime, timedelta, timezone from sqlalchemy import ( Column, @@ -10,20 +10,20 @@ from sqlalchemy import ( DateTime, ForeignKey, Boolean, - Enum as SAEnum, # Renamed to avoid clash with Python's enum + Enum as SAEnum, UniqueConstraint, - Index, # Added for invite code index + Index, DDL, event, - delete, # Added for potential cascade delete if needed (though FK handles it) - func, # Added for func.count() - text as sa_text # For raw SQL in index definition if needed + delete, + func, + text as sa_text, + Text, # <-- Add Text for description + Numeric # <-- Add Numeric for price ) from sqlalchemy.orm import relationship -# Removed func import as it's imported above -# from sqlalchemy.sql import func # For server_default=func.now() -from .database import Base # Import Base from database setup +from .database import Base # --- Enums --- class UserRoleEnum(enum.Enum): @@ -36,25 +36,20 @@ class User(Base): id = Column(Integer, primary_key=True, index=True) email = Column(String, unique=True, index=True, nullable=False) - password_hash = Column(String, nullable=False) # Column name used in CRUD + password_hash = Column(String, nullable=False) name = Column(String, index=True, nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) # --- Relationships --- - # Groups created by this user - created_groups = relationship("Group", back_populates="creator") # Links to Group.creator + created_groups = relationship("Group", back_populates="creator") + group_associations = relationship("UserGroup", back_populates="user", cascade="all, delete-orphan") + created_invites = relationship("Invite", back_populates="creator") - # Association object for group membership (many-to-many) - group_associations = relationship("UserGroup", back_populates="user", cascade="all, delete-orphan") # Links to UserGroup.user - - # Invites created by this user (one-to-many) - created_invites = relationship("Invite", back_populates="creator") # Links to Invite.creator - - # Optional relationships for items/lists (Add later) - # 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") - # expense_shares = relationship("ExpenseShare", back_populates="user") - # created_lists = relationship("List", foreign_keys="[List.created_by_id]", back_populates="creator") + # --- NEW Relationships for Lists/Items --- + created_lists = relationship("List", foreign_keys="List.created_by_id", back_populates="creator") # Link List.created_by_id -> 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 --- # --- Group Model --- @@ -63,78 +58,88 @@ class Group(Base): id = Column(Integer, primary_key=True, index=True) name = Column(String, index=True, nullable=False) - created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) # FK to User table + created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) # --- Relationships --- - # The user who created this group (many-to-one) - creator = relationship("User", back_populates="created_groups") # Links to User.created_groups + creator = relationship("User", back_populates="created_groups") + member_associations = relationship("UserGroup", back_populates="group", cascade="all, delete-orphan") + invites = relationship("Invite", back_populates="group", cascade="all, delete-orphan") - # Association object for group membership (one-to-many) - member_associations = relationship("UserGroup", back_populates="group", cascade="all, delete-orphan") # Links to UserGroup.group - - # Invites belonging to this group (one-to-many) - invites = relationship("Invite", back_populates="group", cascade="all, delete-orphan") # Links to Invite.group - - # Lists belonging to this group (Add later) - # lists = relationship("List", back_populates="group") + # --- NEW Relationship for Lists --- + lists = relationship("List", back_populates="group", cascade="all, delete-orphan") # Link List.group_id -> Group + # --- End NEW Relationship --- -# --- UserGroup Association Model (Many-to-Many link) --- +# --- UserGroup Association Model --- class UserGroup(Base): __tablename__ = "user_groups" - # Ensure a user cannot be in the same group twice __table_args__ = (UniqueConstraint('user_id', 'group_id', name='uq_user_group'),) - id = Column(Integer, primary_key=True, index=True) # Surrogate primary key - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) # FK to User - group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False) # FK to Group - role = Column(SAEnum(UserRoleEnum, name="userroleenum", create_type=True), nullable=False, default=UserRoleEnum.member) # Use Enum, ensure type is created + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False) + role = Column(SAEnum(UserRoleEnum, name="userroleenum", create_type=True), nullable=False, default=UserRoleEnum.member) joined_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) - # --- Relationships --- - # Link back to User (many-to-one from the perspective of this table row) - user = relationship("User", back_populates="group_associations") # Links to User.group_associations - - # Link back to Group (many-to-one from the perspective of this table row) - group = relationship("Group", back_populates="member_associations") # Links to Group.member_associations + user = relationship("User", back_populates="group_associations") + group = relationship("Group", back_populates="member_associations") # --- Invite Model --- class Invite(Base): __tablename__ = "invites" - # Ensure unique codes *within active invites* using a partial index (PostgreSQL specific) - # If not using PostgreSQL or need simpler logic, a simple unique=True on 'code' works, - # but doesn't allow reusing old codes once deactivated. __table_args__ = ( - Index( - 'ix_invites_active_code', - 'code', - unique=True, - postgresql_where=sa_text('is_active = true') # Partial index condition - ), + Index('ix_invites_active_code', 'code', unique=True, postgresql_where=sa_text('is_active = true')), ) id = Column(Integer, primary_key=True, index=True) - # Generate a secure random code by default - code = Column(String, unique=False, index=True, nullable=False, default=lambda: secrets.token_urlsafe(16)) # Index helps lookup, uniqueness handled by partial index - group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False) # FK to Group - created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) # FK to User + code = Column(String, unique=False, index=True, nullable=False, default=lambda: secrets.token_urlsafe(16)) + group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False) + created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) - # Set default expiry (e.g., 7 days from creation) expires_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc) + timedelta(days=7)) - is_active = Column(Boolean, default=True, nullable=False) # To mark as used/invalid + is_active = Column(Boolean, default=True, nullable=False) + + group = relationship("Group", back_populates="invites") + creator = relationship("User", back_populates="created_invites") + + +# === NEW: List Model === +class List(Base): + __tablename__ = "lists" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, index=True, nullable=False) + description = Column(Text, nullable=True) + created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) # Who created this list + group_id = Column(Integer, ForeignKey("groups.id"), nullable=True) # Which group it belongs to (NULL if personal) + is_complete = Column(Boolean, default=False, 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) # --- Relationships --- - # Link back to the Group this invite is for (many-to-one) - group = relationship("Group", back_populates="invites") # Links to Group.invites - - # Link back to the User who created the invite (many-to-one) - creator = relationship("User", back_populates="created_invites") # Links to User.created_invites + creator = relationship("User", back_populates="created_lists") # Link to User.created_lists + group = relationship("Group", back_populates="lists") # Link to Group.lists + items = relationship("Item", back_populates="list", cascade="all, delete-orphan", order_by="Item.created_at") # Link to Item.list, cascade deletes -# --- Models for Lists, Items, Expenses (Add later) --- -# class List(Base): ... -# class Item(Base): ... -# class Expense(Base): ... -# class ExpenseShare(Base): ... \ No newline at end of file +# === NEW: Item Model === +class Item(Base): + __tablename__ = "items" + + id = Column(Integer, primary_key=True, index=True) + list_id = Column(Integer, ForeignKey("lists.id", ondelete="CASCADE"), nullable=False) # Belongs to which list + name = Column(String, index=True, nullable=False) + quantity = Column(String, nullable=True) # Flexible quantity (e.g., "1", "2 lbs", "a bunch") + is_complete = Column(Boolean, default=False, nullable=False) + price = Column(Numeric(10, 2), nullable=True) # For cost splitting later (e.g., 12345678.99) + added_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) # Who added this item + completed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Who marked it complete + 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) + + # --- Relationships --- + list = relationship("List", back_populates="items") # Link to List.items + added_by_user = relationship("User", foreign_keys=[added_by_id], back_populates="added_items") # Link to User.added_items + completed_by_user = relationship("User", foreign_keys=[completed_by_id], back_populates="completed_items") # Link to User.completed_items \ No newline at end of file diff --git a/be/app/schemas/item.py b/be/app/schemas/item.py new file mode 100644 index 0000000..1f281d0 --- /dev/null +++ b/be/app/schemas/item.py @@ -0,0 +1,34 @@ +# app/schemas/item.py +from pydantic import BaseModel, ConfigDict +from datetime import datetime +from typing import Optional +from decimal import Decimal + +# Properties to return to client +class ItemPublic(BaseModel): + id: int + list_id: int + name: str + quantity: Optional[str] = None + is_complete: bool + price: Optional[Decimal] = None + added_by_id: int + completed_by_id: Optional[int] = None + created_at: datetime + updated_at: datetime + model_config = ConfigDict(from_attributes=True) + +# Properties to receive via API on creation +class ItemCreate(BaseModel): + name: str + 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): + name: Optional[str] = None + quantity: Optional[str] = None + is_complete: Optional[bool] = None + price: Optional[Decimal] = None # Price added here for update + # completed_by_id will be set internally if is_complete is true \ No newline at end of file diff --git a/be/app/schemas/list.py b/be/app/schemas/list.py new file mode 100644 index 0000000..66006ca --- /dev/null +++ b/be/app/schemas/list.py @@ -0,0 +1,45 @@ +# app/schemas/list.py +from pydantic import BaseModel, ConfigDict +from datetime import datetime +from typing import Optional, List + +from .item import ItemPublic # Import item schema for nesting + +# Properties to receive via API on creation +class ListCreate(BaseModel): + name: str + description: Optional[str] = None + group_id: Optional[int] = None # Optional for sharing + +# Properties to receive via API on update +class ListUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + is_complete: Optional[bool] = None + # Potentially add group_id update later if needed + +# Base properties returned by API (common fields) +class ListBase(BaseModel): + id: int + name: str + description: Optional[str] = None + created_by_id: int + group_id: Optional[int] = None + is_complete: bool + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + +# Properties returned when listing lists (no items) +class ListPublic(ListBase): + pass # Inherits all from ListBase + +# Properties returned for a single list detail (includes items) +class ListDetail(ListBase): + items: List[ItemPublic] = [] # Include list of items + +class ListStatus(BaseModel): + list_updated_at: datetime + latest_item_updated_at: Optional[datetime] = None # Can be null if list has no items + item_count: int \ No newline at end of file diff --git a/fe/package-lock.json b/fe/package-lock.json index 0fa91d6..a0137bb 100644 --- a/fe/package-lock.json +++ b/fe/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "fe", "version": "0.0.1", + "dependencies": { + "idb": "^8.0.2" + }, "devDependencies": { "@sveltejs/adapter-node": "^5.2.11", "@sveltejs/kit": "^2.16.0", @@ -1527,6 +1530,12 @@ "node": ">= 0.4" } }, + "node_modules/idb": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.2.tgz", + "integrity": "sha512-CX70rYhx7GDDQzwwQMDwF6kDRQi5vVs6khHUumDrMecBylKkwvZ8HWvKV08AGb7VbpoGCWUQ4aHzNDgoUiOIUg==", + "license": "ISC" + }, "node_modules/import-meta-resolve": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", diff --git a/fe/package.json b/fe/package.json index 735c0ce..df26301 100644 --- a/fe/package.json +++ b/fe/package.json @@ -27,5 +27,8 @@ "tailwindcss": "^4.0.0", "typescript": "^5.0.0", "vite": "^6.0.0" + }, + "dependencies": { + "idb": "^8.0.2" } -} \ No newline at end of file +} diff --git a/fe/src/lib/components/ItemDisplay.svelte b/fe/src/lib/components/ItemDisplay.svelte new file mode 100644 index 0000000..899ae6b --- /dev/null +++ b/fe/src/lib/components/ItemDisplay.svelte @@ -0,0 +1,318 @@ + + + + +
  • + {#if isEditing} + +
    + + + + +
    + {:else} + +
    + +
    + + {item.name} + + {#if item.quantity} + + Qty: {item.quantity} + + {/if} +
    +
    +
    + + +
    + {/if} +
  • diff --git a/fe/src/lib/components/ListForm.svelte b/fe/src/lib/components/ListForm.svelte new file mode 100644 index 0000000..84331a0 --- /dev/null +++ b/fe/src/lib/components/ListForm.svelte @@ -0,0 +1,201 @@ + + + +
    +

    + {isEditMode ? 'Edit List' : 'Create New List'} +

    + + {#if successMessage} +
    + {successMessage} Redirecting... +
    + {/if} + {#if errorMessage} + + {/if} + +
    + + +
    + +
    + + + + +
    + + + {#if !isEditMode} +
    + + + {#if groups.length === 0} +

    You are not a member of any groups to share with.

    + {/if} +
    + {/if} + +
    + Cancel + +
    +
    diff --git a/fe/src/lib/db.ts b/fe/src/lib/db.ts new file mode 100644 index 0000000..3f1da8c --- /dev/null +++ b/fe/src/lib/db.ts @@ -0,0 +1,195 @@ +// src/lib/db.ts +import { openDB, type IDBPDatabase, type DBSchema } from 'idb'; +import type { ListDetail, ListPublic } from './schemas/list'; // Import your list types +import type { ItemPublic } from './schemas/item'; // Import your item type + +const DB_NAME = 'SharedListsDB'; +const DB_VERSION = 1; // Increment this when changing schema + +// Define the structure for queued actions +export interface SyncAction { + id?: number; // Optional: will be added by IndexedDB autoIncrement + type: 'create_list' | 'update_list' | 'delete_list' | 'create_item' | 'update_item' | 'delete_item'; + payload: any; // Data needed for the API call (e.g., listId, itemId, updateData) + timestamp: number; + tempId?: string; // Optional temporary ID for optimistic UI mapping (e.g., for newly created items) +} + +// Define the database schema using TypeScript interface +interface SharedListsDBSchema extends DBSchema { + lists: { + key: number; // Primary key (list.id) + value: ListDetail; // Store full detail including items + indexes: Record; // Example indexes + }; + items: { + key: number; // Primary key (item.id) + value: ItemPublic; + indexes: Record; // Index by listId is crucial + }; + syncQueue: { + key: number; // Auto-incrementing key + value: SyncAction; + // No indexes needed for simple queue processing + }; +} + +let dbPromise: Promise> | null = null; + +/** Gets the IndexedDB database instance, creating/upgrading if necessary. */ +function getDb(): Promise> { + if (!dbPromise) { + dbPromise = openDB(DB_NAME, DB_VERSION, { + upgrade(db, oldVersion, newVersion, transaction, event) { + console.log(`Upgrading DB from version ${oldVersion} to ${newVersion}`); + + // Create 'lists' store if it doesn't exist + if (!db.objectStoreNames.contains('lists')) { + const listStore = db.createObjectStore('lists', { keyPath: 'id' }); + listStore.createIndex('groupId', 'group_id'); // Index for potential filtering by group + listStore.createIndex('updated_at', 'updated_at'); // Index for sorting/filtering by date + console.log('Created lists object store'); + } + + // Create 'items' store if it doesn't exist + if (!db.objectStoreNames.contains('items')) { + const itemStore = db.createObjectStore('items', { keyPath: 'id' }); + // Crucial index for fetching items belonging to a list + itemStore.createIndex('listId', 'list_id'); + itemStore.createIndex('updated_at', 'updated_at'); // Index for sorting/filtering by date + console.log('Created items object store'); + } + + // Create 'syncQueue' store if it doesn't exist + if (!db.objectStoreNames.contains('syncQueue')) { + // Use autoIncrementing key + db.createObjectStore('syncQueue', { autoIncrement: true, keyPath: 'id' }); + console.log('Created syncQueue object store'); + } + + // --- Handle specific version upgrades --- + // Example: If upgrading from version 1 to 2 + // if (oldVersion < 2) { + // // Make changes needed for version 2 + // const listStore = transaction.objectStore('lists'); + // // listStore.createIndex('newIndex', 'newField'); + // } + // if (oldVersion < 3) { ... } + }, + blocked(currentVersion, blockedVersion, event) { + // Fires if an older version of the DB is open in another tab/window + console.error(`IndexedDB blocked. Current: ${currentVersion}, Blocked: ${blockedVersion}. Close other tabs.`); + alert('Database update blocked. Please close other tabs/windows using this app and refresh.'); + }, + blocking(currentVersion, blockedVersion, event) { + // Fires in the older tab/window that is blocking the upgrade + console.warn(`IndexedDB blocking upgrade. Current: ${currentVersion}, Upgrade: ${blockedVersion}. Closing connection.`); + // Attempt to close the connection in the blocking tab + // db.close(); // 'db' is not available here, need to handle differently if required + }, + terminated() { + // Fires if the browser abruptly terminates the connection (e.g., OS shutdown) + console.error('IndexedDB connection terminated unexpectedly.'); + dbPromise = null; // Reset promise to allow reconnection attempt + }, + }); + } + return dbPromise; +} + +// --- List CRUD Operations --- + +/** Gets a single list (including items) from IndexedDB by ID. */ +export async function getListFromDb(id: number): Promise { + const db = await getDb(); + return db.get('lists', id); +} + +/** Gets all lists stored in IndexedDB. */ +export async function getAllListsFromDb(): Promise { + const db = await getDb(); + // Consider adding sorting or filtering here if needed + return db.getAll('lists'); +} + +/** Adds or updates a list in IndexedDB. */ +export async function putListToDb(list: ListDetail | ListPublic): Promise { + const db = await getDb(); + // Ensure items array exists, even if empty, for ListDetail type consistency + const listToStore: ListDetail = { + ...list, + items: (list as ListDetail).items ?? [] // Default to empty array if items missing + }; + return db.put('lists', listToStore); +} + +/** Deletes a list and its associated items from IndexedDB. */ +export async function deleteListFromDb(id: number): Promise { + const db = await getDb(); + // Use a transaction to delete list and its items atomically + const tx = db.transaction(['lists', 'items'], 'readwrite'); + const listStore = tx.objectStore('lists'); + const itemStore = tx.objectStore('items'); + const itemIndex = itemStore.index('listId'); // Use the index + + // Delete the list itself + await listStore.delete(id); + + // Find and delete all items associated with the list + let cursor = await itemIndex.openCursor(id.toString()); // Open cursor on the index with the listId + while (cursor) { + await cursor.delete(); // Delete the item the cursor points to + cursor = await cursor.continue(); // Move to the next item with the same listId + } + + await tx.done; // Complete the transaction + console.log(`Deleted list ${id} and its items from DB.`); +} + +// --- Item CRUD Operations --- + +/** Gets a single item from IndexedDB by ID. */ +export async function getItemFromDb(id: number): Promise { + const db = await getDb(); + return db.get('items', id); +} + +/** Gets all items for a specific list from IndexedDB using the index. */ +export async function getItemsByListIdFromDb(listId: number): Promise { + const db = await getDb(); + return db.getAllFromIndex('items', 'listId', listId.toString()); +} + +/** Adds or updates an item in IndexedDB. */ +export async function putItemToDb(item: ItemPublic): Promise { + const db = await getDb(); + return db.put('items', item); +} + +/** Deletes an item from IndexedDB by ID. */ +export async function deleteItemFromDb(id: number): Promise { + const db = await getDb(); + return db.delete('items', id); +} + +// --- Sync Queue Operations --- + +/** Adds an action to the synchronization queue. */ +export async function addSyncAction(action: Omit): Promise { + const db = await getDb(); + // Add the action (payload should be serializable) + return db.add('syncQueue', action); +} + +/** Retrieves all actions currently in the synchronization queue. */ +export async function getSyncQueue(): Promise { + const db = await getDb(); + // Fetch all items, default order is by key (insertion order) + return db.getAll('syncQueue'); +} + +/** Deletes a specific action from the synchronization queue by its ID. */ +export async function deleteSyncAction(id: number): Promise { + const db = await getDb(); + return db.delete('syncQueue', id); +} \ No newline at end of file diff --git a/fe/src/lib/schemas/item.ts b/fe/src/lib/schemas/item.ts new file mode 100644 index 0000000..68229db --- /dev/null +++ b/fe/src/lib/schemas/item.ts @@ -0,0 +1,27 @@ + + +// Ensure this interface is exported +export interface ItemPublic { + id: number; + list_id: number; + name: string; + quantity?: string | null; + is_complete: boolean; + price?: number | null; // Or Decimal if using a library + added_by_id: number; + completed_by_id?: number | null; + created_at: string; + updated_at: string; +} + +export interface ItemCreate { + name: string; + quantity?: string | null; +} + +export interface ItemUpdate { + name?: string | null; + quantity?: string | null; + is_complete?: boolean | null; + price?: number | null; // Using number +} \ No newline at end of file diff --git a/fe/src/lib/schemas/list.ts b/fe/src/lib/schemas/list.ts new file mode 100644 index 0000000..3056d01 --- /dev/null +++ b/fe/src/lib/schemas/list.ts @@ -0,0 +1,35 @@ +import type { ItemPublic } from './item'; // Assuming item schema exists and is exported + +export interface ListBase { + id: number; + name: string; + description?: string | null; + created_by_id: number; + group_id?: number | null; + is_complete: boolean; + created_at: string; + updated_at: string; +} +// Export interfaces to make the file a module +export interface ListPublic extends ListBase { } +export interface ListDetail extends ListBase { + items: ItemPublic[]; +} + +export interface ListCreate { + name: string; + description?: string | null; + group_id?: number | null; +} + +export interface ListUpdate { + name?: string | null; + description?: string | null; + is_complete?: boolean | null; +} + +export interface ListStatus { + list_updated_at: string; // Expect string from JSON + latest_item_updated_at?: string | null; // Expect string or null from JSON + item_count: number; +} \ No newline at end of file diff --git a/fe/src/lib/syncService.ts b/fe/src/lib/syncService.ts new file mode 100644 index 0000000..6a88198 --- /dev/null +++ b/fe/src/lib/syncService.ts @@ -0,0 +1,154 @@ +// src/lib/syncService.ts +import { browser } from '$app/environment'; +import { getSyncQueue, deleteSyncAction } from './db'; // Import DB functions +import { apiClient, ApiClientError } from './apiClient'; // Import API client +import { writable, get } from 'svelte/store'; // Import get for reading store value + +// Store for sync status feedback +export const syncStatus = writable<'idle' | 'syncing' | 'error'>('idle'); +export const syncError = writable(null); + +let isSyncing = false; // Prevent concurrent sync runs + +/** + * Processes the offline synchronization queue. + * Fetches actions from IndexedDB and attempts to send them to the API. + * Removes successful actions, handles basic errors/conflicts. + */ +export async function processSyncQueue() { + // Run only in browser, when online, and if not already syncing + if (!browser || !navigator.onLine || isSyncing) { + if (isSyncing) console.log('Sync: Already in progress, skipping.'); + return; + } + + isSyncing = true; + syncStatus.set('syncing'); + syncError.set(null); // Clear previous errors + console.log('Sync: Starting queue processing...'); + + try { + const queue = await getSyncQueue(); + console.log(`Sync: Found ${queue.length} actions in queue.`); + + if (queue.length === 0) { + syncStatus.set('idle'); + isSyncing = false; + return; // Nothing to do + } + + // Process actions one by one (sequential processing) + for (const action of queue) { + // Should always have an ID from IndexedDB autoIncrement + if (!action.id) { + console.error("Sync: Action missing ID, skipping.", action); + continue; + } + + console.log(`Sync: Processing action ID ${action.id}, Type: ${action.type}`); + let success = false; + try { + // --- Perform API call based on action type --- + switch (action.type) { + case 'create_list': + await apiClient.post('/v1/lists', action.payload); + // TODO: Handle mapping tempId if used + break; + case 'update_list': + // Assuming payload is { id: listId, data: ListUpdate } + await apiClient.put(`/v1/lists/${action.payload.id}`, action.payload.data); + break; + case 'delete_list': + // Assuming payload is { id: listId } + await apiClient.delete(`/v1/lists/${action.payload.id}`); + break; + case 'create_item': + // Assuming payload is { listId: number, data: ItemCreate } + await apiClient.post(`/v1/lists/${action.payload.listId}/items`, action.payload.data); + // TODO: Handle mapping tempId if used + break; + case 'update_item': + // Assuming payload is { id: itemId, data: ItemUpdate } + await apiClient.put(`/v1/items/${action.payload.id}`, action.payload.data); + break; + case 'delete_item': + // Assuming payload is { id: itemId } + await apiClient.delete(`/v1/items/${action.payload.id}`); + break; + default: + console.error(`Sync: Unknown action type: ${(action as any).type}`); + // Optionally treat as error or just skip + throw new Error(`Unknown sync action type: ${(action as any).type}`); + } + + success = true; // Mark as successful if API call didn't throw + console.log(`Sync: Action ID ${action.id} (${action.type}) successful.`); + // Remove from queue ONLY on definite success + await deleteSyncAction(action.id); + + } catch (err: any) { + console.error(`Sync: Failed to process action ID ${action.id} (${action.type})`, err); + + // --- Basic Conflict/Error Handling --- + let errorHandled = false; + if (err instanceof ApiClientError) { + if (err.status === 409) { // Example: Conflict + syncError.set(`Sync conflict for ${action.type} (ID: ${action.payload?.id ?? 'N/A'}). Data may be outdated. Please refresh.`); + // Remove conflicting action from queue - requires manual refresh/resolution by user + await deleteSyncAction(action.id); + errorHandled = true; + } else if (err.status >= 400 && err.status < 500 && err.status !== 401) { + // Other client errors (400 Bad Request, 403 Forbidden, 404 Not Found) + // Often mean the action is invalid now (e.g., deleting something already deleted). + syncError.set(`Sync failed for ${action.type} (Error ${err.status}). Action discarded.`); + await deleteSyncAction(action.id); + errorHandled = true; + } + // Note: 401 Unauthorized is handled globally by apiClient, which calls logout. + // Sync might stop if token becomes invalid mid-process. + } + + if (!errorHandled) { + // Network error or Server error (5xx) - Keep in queue and stop processing for now + syncError.set(`Sync failed for ${action.type}. Will retry later.`); + syncStatus.set('error'); // Indicate sync stopped due to error + isSyncing = false; // Allow retry later + return; // Stop processing the rest of the queue + } + } + } // End for loop + + // If loop completed without critical errors + console.log('Sync: Queue processing finished.'); + syncStatus.set('idle'); // Reset status if all processed or handled + + } catch (outerError) { + // Catch errors during queue fetching or unexpected issues in the loop + console.error("Sync: Critical error during queue processing loop.", outerError); + syncError.set("An unexpected error occurred during synchronization."); + syncStatus.set('error'); + } finally { + isSyncing = false; // Ensure this is always reset + // If an error occurred and wasn't handled by stopping, ensure status reflects it + if (get(syncError) && get(syncStatus) !== 'error') { + syncStatus.set('error'); + } + } +} + +// --- Initialize Sync --- + +// Listen for online event to trigger sync +if (browser) { + window.addEventListener('online', processSyncQueue); + // Trigger sync shortly after app loads if online + if (navigator.onLine) { + setTimeout(processSyncQueue, 3000); // Delay 3s + } +} + +// Optional: Add function to manually trigger sync if needed from UI +export function triggerSync() { + console.log("Sync: Manual trigger requested."); + processSyncQueue(); +} \ No newline at end of file diff --git a/fe/src/routes/(app)/dashboard/+page.svelte b/fe/src/routes/(app)/dashboard/+page.svelte index 4a46733..9012989 100644 --- a/fe/src/routes/(app)/dashboard/+page.svelte +++ b/fe/src/routes/(app)/dashboard/+page.svelte @@ -64,7 +64,16 @@
    -

    Your Groups

    +
    +

    Your Groups

    + + + + Create New List + +
    @@ -95,6 +104,47 @@ {/if}
    +
    +

    My Lists

    + {#if loadError} + + {:else if !data.lists || data.lists.length === 0} +

    You haven't created any lists yet.

    + {:else} + + {/if} +
    +

    My Groups

    diff --git a/fe/src/routes/(app)/dashboard/+page.ts b/fe/src/routes/(app)/dashboard/+page.ts index b275e2d..4b5db4a 100644 --- a/fe/src/routes/(app)/dashboard/+page.ts +++ b/fe/src/routes/(app)/dashboard/+page.ts @@ -3,6 +3,7 @@ import { error } from '@sveltejs/kit'; import { apiClient, ApiClientError } from '$lib/apiClient'; import type { GroupPublic } from '$lib/schemas/group'; // Import the Group type import type { PageLoad } from './$types'; // SvelteKit's type for load functions +import type { ListPublic } from '$lib/schemas/list'; // Define the expected shape of the data returned by this load function export interface DashboardLoadData { @@ -18,9 +19,11 @@ export const load: PageLoad = async ({ fetch }) => { console.log('Dashboard page load: Fetching groups...'); try { const groups = await apiClient.get('/v1/groups'); // apiClient adds auth header + const lists = await apiClient.get('/v1/lists'); // apiClient adds auth header console.log('Dashboard page load: Groups fetched successfully', groups); return { groups: groups ?? [], // Return empty array if API returns null/undefined + lists: lists ?? [], error: null }; } catch (err) { diff --git a/fe/src/routes/(app)/lists/[listId]/+page.svelte b/fe/src/routes/(app)/lists/[listId]/+page.svelte new file mode 100644 index 0000000..f02c112 --- /dev/null +++ b/fe/src/routes/(app)/lists/[listId]/+page.svelte @@ -0,0 +1,475 @@ + + + + +{#if $localListStore} + {@const list = $localListStore} + +
    + + {#if $syncStatus === 'syncing'} +
    + Syncing changes... +
    + {:else if $syncStatus === 'error' && $syncError} + + {/if} + + +
    +
    +

    {list.name}

    + {#if list.description} +

    {list.description}

    + {/if} +

    + ID: {list.id} | + {#if list.group_id} + Shared | + {:else} + Personal | + {/if} + Status: {list.is_complete ? 'Complete' : 'In Progress'} | Updated: {new Date( + list.updated_at + ).toLocaleString()} +

    +
    +
    + {#if isRefreshing} + Refreshing... + {/if} + + Edit List Details + +
    +
    + + +
    +

    Add New Item

    +
    +
    + + +
    +
    + + +
    + +
    + {#if addItemError} +

    {addItemError}

    + {/if} +
    + + +
    +

    Items ({list.items?.length ?? 0})

    + {#if itemUpdateError} + + {/if} + {#if list.items && list.items.length > 0} +
      + {#each list.items as item (item.id)} + + {/each} +
    + {:else} +

    This list is empty. Add items above!

    + {/if} +
    + + + +
    +{:else} + +

    Loading list data...

    +{/if} diff --git a/fe/src/routes/(app)/lists/[listId]/+page.ts b/fe/src/routes/(app)/lists/[listId]/+page.ts new file mode 100644 index 0000000..f0f7f34 --- /dev/null +++ b/fe/src/routes/(app)/lists/[listId]/+page.ts @@ -0,0 +1,53 @@ +// src/routes/(app)/lists/[listId]/+page.ts +import { error } from '@sveltejs/kit'; +import { apiClient, ApiClientError } from '$lib/apiClient'; +import type { ListDetail } from '$lib/schemas/list'; +// --- Use the correct generated type --- +import type { PageLoad } from './$types'; // This type includes correctly typed 'params' + +export interface ListDetailPageLoadData { + list: ListDetail; +} + +export const load: PageLoad = async ({ params, fetch }) => { + const listId = params.listId; + console.log(`List Detail page load: Fetching data for list ID: ${listId}`); + + if (!listId || isNaN(parseInt(listId, 10))) { + throw error(400, 'Invalid List ID'); + } + try { + // Fetch the specific list details (expecting items to be included) + // The backend GET /api/v1/lists/{list_id} should return ListDetail schema + const listData = await apiClient.get(`/v1/lists/${listId}`); + + if (!listData) { + // Should not happen if API call was successful, but check defensively + throw error(404, 'List not found (API returned no data)'); + } + + console.log('List Detail page load: Data fetched successfully', listData); + return { + list: listData + }; + } catch (err) { + console.error(`List Detail page load: Failed to fetch list ${listId}:`, err); + if (err instanceof ApiClientError) { + if (err.status === 404) { + throw error(404, 'List not found'); + } + if (err.status === 403) { + // User is authenticated (layout guard passed) but not member/creator + throw error(403, 'Forbidden: You do not have permission to view this list'); + } + // For other API errors (like 500) + throw error(err.status || 500, `API Error: ${err.message}`); + } else if (err instanceof Error) { + // Network or other client errors + throw error(500, `Failed to load list data: ${err.message}`); + } else { + // Unknown error + throw error(500, 'An unexpected error occurred while loading list data.'); + } + } +}; \ No newline at end of file diff --git a/fe/src/routes/(app)/lists/[listId]/edit/+page.svelte b/fe/src/routes/(app)/lists/[listId]/edit/+page.svelte new file mode 100644 index 0000000..f036f48 --- /dev/null +++ b/fe/src/routes/(app)/lists/[listId]/edit/+page.svelte @@ -0,0 +1,16 @@ + + + + diff --git a/fe/src/routes/(app)/lists/[listId]/edit/+page.ts b/fe/src/routes/(app)/lists/[listId]/edit/+page.ts new file mode 100644 index 0000000..190c3af --- /dev/null +++ b/fe/src/routes/(app)/lists/[listId]/edit/+page.ts @@ -0,0 +1,75 @@ +// src/routes/(app)/lists/[listId]/edit/+page.ts +import { error } from '@sveltejs/kit'; +import { apiClient, ApiClientError } from '$lib/apiClient'; +import type { GroupPublic } from '$lib/schemas/group'; +import type { ListPublic } from '$lib/schemas/list'; // Use ListPublic or ListDetail +import type { PageLoad } from './$types'; + +export interface EditListPageLoadData { + list: ListPublic; // Or ListDetail if needed + groups: GroupPublic[]; + error?: string | null; // For group loading errors +} + +// Fetch the specific list to edit AND the user's groups for the dropdown +export const load: PageLoad = async ({ params, fetch }) => { + const listId = params.listId; + console.log(`Edit List page load: Fetching list ${listId} and groups...`); + + if (!listId || isNaN(parseInt(listId, 10))) { + throw error(400, 'Invalid List ID'); + } + + try { + // Fetch list details and groups in parallel + // Use apiClient for automatic auth handling + const [listResult, groupsResult] = await Promise.allSettled([ + apiClient.get(`/v1/lists/${listId}`), // Fetch specific list + apiClient.get('/v1/groups') // Fetch groups for dropdown + ]); + + let listData: ListPublic; + let groupsData: GroupPublic[] = []; + let groupsError: string | null = null; + + // Process list result + if (listResult.status === 'fulfilled' && listResult.value) { + listData = listResult.value; + } else { + // Handle list fetch failure + const reason = listResult.status === 'rejected' ? listResult.reason : new Error('List data missing'); + console.error(`Edit List page load: Failed to fetch list ${listId}:`, reason); + if (reason instanceof ApiClientError) { + if (reason.status === 404) throw error(404, 'List not found'); + if (reason.status === 403) throw error(403, 'Forbidden: You cannot edit this list'); + throw error(reason.status || 500, `API Error loading list: ${reason.message}`); + } + throw error(500, `Failed to load list data: ${reason instanceof Error ? reason.message : 'Unknown error'}`); + } + + // Process groups result (non-critical, form can work without it) + if (groupsResult.status === 'fulfilled' && groupsResult.value) { + groupsData = groupsResult.value; + } else { + const reason = groupsResult.status === 'rejected' ? groupsResult.reason : new Error('Groups data missing'); + console.error('Edit List page load: Failed to fetch groups:', reason); + groupsError = `Failed to load groups for sharing options: ${reason instanceof Error ? reason.message : 'Unknown error'}`; + // Don't throw error here, just pass the message to the component + } + + return { + list: listData, + groups: groupsData, + error: groupsError // Pass group loading error to the page + }; + + } catch (err) { + // Catch errors thrown by Promise.allSettled handling or initial setup + console.error(`Edit List page load: Unexpected error for list ${listId}:`, err); + // Check if it's a SvelteKit error object before re-throwing + if (err instanceof Error && 'status' in err && typeof err.status === 'number') { + throw err; + } + throw error(500, `An unexpected error occurred: ${err instanceof Error ? err.message : 'Unknown error'}`); + } +}; \ No newline at end of file diff --git a/fe/src/routes/(app)/lists/new/+page.svelte b/fe/src/routes/(app)/lists/new/+page.svelte new file mode 100644 index 0000000..951f570 --- /dev/null +++ b/fe/src/routes/(app)/lists/new/+page.svelte @@ -0,0 +1,13 @@ + + + +
    + + + +
    diff --git a/fe/src/routes/(app)/lists/new/+page.ts b/fe/src/routes/(app)/lists/new/+page.ts new file mode 100644 index 0000000..e76f0be --- /dev/null +++ b/fe/src/routes/(app)/lists/new/+page.ts @@ -0,0 +1,32 @@ +// src/routes/(app)/lists/new/+page.ts +import { apiClient, ApiClientError } from '$lib/apiClient'; +import type { GroupPublic } from '$lib/schemas/group'; +import type { PageLoad } from './$types'; + +export interface NewListPageLoadData { + groups: GroupPublic[]; + error?: string | null; +} + +// Fetch groups needed for the dropdown in the form +export const load: PageLoad = async ({ fetch }) => { + console.log('New List page load: Fetching groups...'); + try { + const groups = await apiClient.get('/v1/groups'); + return { + groups: groups ?? [], + error: null + }; + } catch (err) { + console.error('New List page load: Failed to fetch groups:', err); + let errorMessage = 'Failed to load group data for sharing options.'; + // Handle specific errors if needed (e.g., 401 handled globally) + if (err instanceof Error) { + errorMessage = `Error loading groups: ${err.message}`; + } + return { + groups: [], + error: errorMessage + }; + } +}; \ No newline at end of file diff --git a/fe/src/service-worker.ts b/fe/src/service-worker.ts index ce1565a..f7047a0 100644 --- a/fe/src/service-worker.ts +++ b/fe/src/service-worker.ts @@ -1,64 +1,135 @@ +// src/service-worker.ts + /// -// REMOVED: /// /// -// Import SvelteKit-provided variables ONLY +// This import IS correct - it's provided by SvelteKit import { build, files, version } from '$service-worker'; -declare let self: ServiceWorkerGlobalScope; -// Declare 'workbox' as any for now IF TypeScript still complains after removing @types/workbox-sw. -// Often, SvelteKit's types are enough, but this is a fallback. -declare const workbox: any; // Uncomment this line ONLY if 'Cannot find name workbox' persists +// Declare workbox global if needed (if TS complains after removing @types/workbox-sw) +declare const workbox: any; // Using 'any' for simplicity if specific types cause issues console.log(`[Service Worker] Version: ${version}`); -// --- Precaching --- -// Use the global workbox object (assuming SvelteKit injects it) +// --- Precaching Core Assets --- +// Cache essential SvelteKit build artifacts and static files workbox.precaching.precacheAndRoute(build); workbox.precaching.precacheAndRoute(files.map(f => ({ url: f, revision: null }))); -// --- Runtime Caching --- -// Google Fonts +// --- Runtime Caching Strategies --- + +// Example: Cache Google Fonts (Optional) workbox.routing.registerRoute( ({ url }) => url.origin === 'https://fonts.googleapis.com' || url.origin === 'https://fonts.gstatic.com', new workbox.strategies.StaleWhileRevalidate({ - cacheName: 'google-fonts', + cacheName: 'google-fonts-cache', plugins: [ new workbox.cacheableResponse.CacheableResponse({ statuses: [0, 200] }), - new workbox.expiration.ExpirationPlugin({ maxEntries: 20, maxAgeSeconds: 30 * 24 * 60 * 60 }), + new workbox.expiration.ExpirationPlugin({ maxEntries: 20, maxAgeSeconds: 30 * 24 * 60 * 60 }), // 30 Days ], }), ); -// Images from origin +// Example: Cache images from origin (Optional) workbox.routing.registerRoute( - ({ request, url }) => !!request && request.destination === 'image' && url.origin === self.location.origin, + ({ request, url }) => request.destination === 'image' && url.origin === self.location.origin, new workbox.strategies.CacheFirst({ - cacheName: 'images', + cacheName: 'images-cache', plugins: [ new workbox.cacheableResponse.CacheableResponse({ statuses: [0, 200] }), new workbox.expiration.ExpirationPlugin({ maxEntries: 50, - maxAgeSeconds: 30 * 24 * 60 * 60, + maxAgeSeconds: 30 * 24 * 60 * 60, // 30 Days purgeOnQuotaError: true, }), ], }), ); -// --- Lifecycle --- +// --- API GET Request Caching --- + +// Cache the list of lists (NetworkFirst: try network, fallback cache) +workbox.routing.registerRoute( + ({ url, request }) => url.pathname === '/api/v1/lists' && request.method === 'GET', + new workbox.strategies.NetworkFirst({ + cacheName: 'api-lists-cache', + plugins: [ + new workbox.cacheableResponse.CacheableResponse({ statuses: [0, 200] }), + new workbox.expiration.ExpirationPlugin({ maxEntries: 10, maxAgeSeconds: 60 * 60 * 24 }) // 1 Day + ] + }) +); + +// Cache individual list details (NetworkFirst) +workbox.routing.registerRoute( + ({ url, request }) => /^\/api\/v1\/lists\/\d+$/.test(url.pathname) && request.method === 'GET', + new workbox.strategies.NetworkFirst({ + cacheName: 'api-list-detail-cache', + plugins: [ + new workbox.cacheableResponse.CacheableResponse({ statuses: [0, 200] }), + new workbox.expiration.ExpirationPlugin({ maxEntries: 30, maxAgeSeconds: 60 * 60 * 24 }) // 1 Day + ] + }) +); + +// Cache list status (StaleWhileRevalidate: serve cache fast, update background) +workbox.routing.registerRoute( + ({ url, request }) => /^\/api\/v1\/lists\/\d+\/status$/.test(url.pathname) && request.method === 'GET', + new workbox.strategies.StaleWhileRevalidate({ + cacheName: 'api-list-status-cache', + plugins: [ + new workbox.cacheableResponse.CacheableResponse({ statuses: [0, 200] }), + new workbox.expiration.ExpirationPlugin({ maxEntries: 30, maxAgeSeconds: 60 * 60 }) // 1 Hour + ] + }) +); + +// --- Service Worker Lifecycle --- + self.addEventListener('install', (event) => { console.log('[Service Worker] Install event'); + // Force activation immediately (use with caution, ensure clients handle updates) // event.waitUntil(self.skipWaiting()); }); self.addEventListener('activate', (event) => { - const extendableEvent = event as ExtendableEvent; + const extendableEvent = event as ExtendableEvent; // Cast for type safety console.log('[Service Worker] Activate event'); + // Remove outdated caches managed by Workbox's precaching extendableEvent.waitUntil(workbox.precaching.cleanupOutdatedCaches()); - // event.waitUntil(self.clients.claim()); + // Take control of uncontrolled clients immediately + // extendableEvent.waitUntil(self.clients.claim()); }); self.addEventListener('fetch', (event) => { + // Workbox's registered routes handle fetch events automatically. // console.log(`[Service Worker] Fetching: ${event.request.url}`); + + // --- Background Sync Interception (More Advanced - requires Workbox Background Sync module) --- + // This is a basic example; a full implementation requires the workbox-background-sync package + // and more robust error handling and queue management. + // const isApiMutation = event.request.url.startsWith(self.location.origin + '/api/') && event.request.method !== 'GET'; + // if (isApiMutation) { + // // Example using BackgroundSync Queue (conceptual) + // // Requires: import { Queue } from 'workbox-background-sync'; + // // const queue = new Queue('apiSyncQueue'); + // // event.respondWith( + // // fetch(event.request.clone()).catch(() => { + // // console.log('[Service Worker] Fetch failed, queueing request:', event.request.url); + // // return queue.pushRequest({ request: event.request }); + // // }) + // // ); + // } +}); + +// Optional: Listen for messages from clients (e.g., trigger sync manually) +self.addEventListener('message', (event) => { + if (event.data && event.data.type === 'TRIGGER_SYNC') { + console.log('[Service Worker] Received TRIGGER_SYNC message.'); + // Import and call processSyncQueue if structured that way, + // or use Background Sync's replayRequests. + // Example: Triggering background sync replay (if using Workbox Background Sync) + // const queue = new workbox.backgroundSync.Queue('apiSyncQueue'); + // event.waitUntil(queue.replayRequests()); + } }); \ No newline at end of file