end of phase 4
This commit is contained in:
parent
4fbbe77658
commit
53c7382b88
73
be/alembic/versions/d25788f63e2c_add_list_and_item_tables.py
Normal file
73
be/alembic/versions/d25788f63e2c_add_list_and_item_tables.py
Normal file
@ -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 ###
|
@ -6,6 +6,8 @@ from app.api.v1.endpoints import auth
|
|||||||
from app.api.v1.endpoints import users
|
from app.api.v1.endpoints import users
|
||||||
from app.api.v1.endpoints import groups
|
from app.api.v1.endpoints import groups
|
||||||
from app.api.v1.endpoints import invites
|
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()
|
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(users.router, prefix="/users", tags=["Users"])
|
||||||
api_router_v1.include_router(groups.router, prefix="/groups", tags=["Groups"])
|
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(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
|
# Add other v1 endpoint routers here later
|
||||||
# e.g., api_router_v1.include_router(users.router, prefix="/users", tags=["Users"])
|
# e.g., api_router_v1.include_router(users.router, prefix="/users", tags=["Users"])
|
150
be/app/api/v1/endpoints/items.py
Normal file
150
be/app/api/v1/endpoints/items.py
Normal file
@ -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)
|
211
be/app/api/v1/endpoints/lists.py
Normal file
211
be/app/api/v1/endpoints/lists.py
Normal file
@ -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
|
67
be/app/crud/item.py
Normal file
67
be/app/crud/item.py
Normal file
@ -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
|
151
be/app/crud/list.py
Normal file
151
be/app/crud/list.py
Normal file
@ -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
|
||||||
|
)
|
149
be/app/models.py
149
be/app/models.py
@ -1,7 +1,7 @@
|
|||||||
# app/models.py
|
# app/models.py
|
||||||
import enum
|
import enum
|
||||||
import secrets # For generating invite codes
|
import secrets
|
||||||
from datetime import datetime, timedelta, timezone # For invite expiry
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Column,
|
Column,
|
||||||
@ -10,20 +10,20 @@ from sqlalchemy import (
|
|||||||
DateTime,
|
DateTime,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Boolean,
|
Boolean,
|
||||||
Enum as SAEnum, # Renamed to avoid clash with Python's enum
|
Enum as SAEnum,
|
||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
Index, # Added for invite code index
|
Index,
|
||||||
DDL,
|
DDL,
|
||||||
event,
|
event,
|
||||||
delete, # Added for potential cascade delete if needed (though FK handles it)
|
delete,
|
||||||
func, # Added for func.count()
|
func,
|
||||||
text as sa_text # For raw SQL in index definition if needed
|
text as sa_text,
|
||||||
|
Text, # <-- Add Text for description
|
||||||
|
Numeric # <-- Add Numeric for price
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import relationship
|
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 ---
|
# --- Enums ---
|
||||||
class UserRoleEnum(enum.Enum):
|
class UserRoleEnum(enum.Enum):
|
||||||
@ -36,25 +36,20 @@ class User(Base):
|
|||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
email = Column(String, unique=True, index=True, nullable=False)
|
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)
|
name = Column(String, index=True, nullable=True)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
# --- Relationships ---
|
# --- Relationships ---
|
||||||
# Groups created by this user
|
created_groups = relationship("Group", back_populates="creator")
|
||||||
created_groups = relationship("Group", back_populates="creator") # Links to Group.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)
|
# --- NEW Relationships for Lists/Items ---
|
||||||
group_associations = relationship("UserGroup", back_populates="user", cascade="all, delete-orphan") # Links to UserGroup.user
|
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
|
||||||
# Invites created by this user (one-to-many)
|
completed_items = relationship("Item", foreign_keys="Item.completed_by_id", back_populates="completed_by_user") # Link Item.completed_by_id -> User
|
||||||
created_invites = relationship("Invite", back_populates="creator") # Links to Invite.creator
|
# --- End NEW Relationships ---
|
||||||
|
|
||||||
# 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")
|
|
||||||
|
|
||||||
|
|
||||||
# --- Group Model ---
|
# --- Group Model ---
|
||||||
@ -63,78 +58,88 @@ class Group(Base):
|
|||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
name = Column(String, index=True, nullable=False)
|
name = Column(String, index=True, nullable=False)
|
||||||
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)
|
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
# --- Relationships ---
|
# --- Relationships ---
|
||||||
# The user who created this group (many-to-one)
|
creator = relationship("User", back_populates="created_groups")
|
||||||
creator = relationship("User", back_populates="created_groups") # Links to User.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)
|
# --- NEW Relationship for Lists ---
|
||||||
member_associations = relationship("UserGroup", back_populates="group", cascade="all, delete-orphan") # Links to UserGroup.group
|
lists = relationship("List", back_populates="group", cascade="all, delete-orphan") # Link List.group_id -> Group
|
||||||
|
# --- End NEW Relationship ---
|
||||||
# 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")
|
|
||||||
|
|
||||||
|
|
||||||
# --- UserGroup Association Model (Many-to-Many link) ---
|
# --- UserGroup Association Model ---
|
||||||
class UserGroup(Base):
|
class UserGroup(Base):
|
||||||
__tablename__ = "user_groups"
|
__tablename__ = "user_groups"
|
||||||
# Ensure a user cannot be in the same group twice
|
|
||||||
__table_args__ = (UniqueConstraint('user_id', 'group_id', name='uq_user_group'),)
|
__table_args__ = (UniqueConstraint('user_id', 'group_id', name='uq_user_group'),)
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True) # Surrogate primary key
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) # FK to User
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False) # FK to Group
|
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) # Use Enum, ensure type is created
|
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)
|
joined_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
# --- Relationships ---
|
user = relationship("User", back_populates="group_associations")
|
||||||
# Link back to User (many-to-one from the perspective of this table row)
|
group = relationship("Group", back_populates="member_associations")
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
# --- Invite Model ---
|
# --- Invite Model ---
|
||||||
class Invite(Base):
|
class Invite(Base):
|
||||||
__tablename__ = "invites"
|
__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__ = (
|
__table_args__ = (
|
||||||
Index(
|
Index('ix_invites_active_code', 'code', unique=True, postgresql_where=sa_text('is_active = true')),
|
||||||
'ix_invites_active_code',
|
|
||||||
'code',
|
|
||||||
unique=True,
|
|
||||||
postgresql_where=sa_text('is_active = true') # Partial index condition
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=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))
|
||||||
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)
|
||||||
group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False) # FK to Group
|
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) # FK to User
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), 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))
|
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 ---
|
# --- Relationships ---
|
||||||
# Link back to the Group this invite is for (many-to-one)
|
creator = relationship("User", back_populates="created_lists") # Link to User.created_lists
|
||||||
group = relationship("Group", back_populates="invites") # Links to Group.invites
|
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
|
||||||
# Link back to the User who created the invite (many-to-one)
|
|
||||||
creator = relationship("User", back_populates="created_invites") # Links to User.created_invites
|
|
||||||
|
|
||||||
|
|
||||||
# --- Models for Lists, Items, Expenses (Add later) ---
|
# === NEW: Item Model ===
|
||||||
# class List(Base): ...
|
class Item(Base):
|
||||||
# class Item(Base): ...
|
__tablename__ = "items"
|
||||||
# class Expense(Base): ...
|
|
||||||
# class ExpenseShare(Base): ...
|
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
|
34
be/app/schemas/item.py
Normal file
34
be/app/schemas/item.py
Normal file
@ -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
|
45
be/app/schemas/list.py
Normal file
45
be/app/schemas/list.py
Normal file
@ -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
|
9
fe/package-lock.json
generated
9
fe/package-lock.json
generated
@ -7,6 +7,9 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "fe",
|
"name": "fe",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
|
"dependencies": {
|
||||||
|
"idb": "^8.0.2"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-node": "^5.2.11",
|
"@sveltejs/adapter-node": "^5.2.11",
|
||||||
"@sveltejs/kit": "^2.16.0",
|
"@sveltejs/kit": "^2.16.0",
|
||||||
@ -1527,6 +1530,12 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/import-meta-resolve": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz",
|
||||||
|
@ -27,5 +27,8 @@
|
|||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
"vite": "^6.0.0"
|
"vite": "^6.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"idb": "^8.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
318
fe/src/lib/components/ItemDisplay.svelte
Normal file
318
fe/src/lib/components/ItemDisplay.svelte
Normal file
@ -0,0 +1,318 @@
|
|||||||
|
<!-- src/lib/components/ItemDisplay.svelte -->
|
||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { apiClient, ApiClientError } from '$lib/apiClient';
|
||||||
|
import type { ItemPublic, ItemUpdate } from '$lib/schemas/item';
|
||||||
|
// --- DB and Sync Imports ---
|
||||||
|
import { putItemToDb, deleteItemFromDb, addSyncAction } from '$lib/db';
|
||||||
|
import { processSyncQueue } from '$lib/syncService';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { authStore } from '$lib/stores/authStore'; // Get current user ID
|
||||||
|
import { get } from 'svelte/store'; // Import get
|
||||||
|
// --- End DB and Sync Imports ---
|
||||||
|
|
||||||
|
export let item: ItemPublic;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{
|
||||||
|
itemUpdated: ItemPublic; // Event when item is successfully updated (toggle/edit)
|
||||||
|
itemDeleted: number; // Event when item is successfully deleted (sends ID)
|
||||||
|
updateError: string; // Event to bubble up errors
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// --- Component State ---
|
||||||
|
let isEditing = false;
|
||||||
|
let isToggling = false;
|
||||||
|
let isDeleting = false;
|
||||||
|
let isSavingEdit = false;
|
||||||
|
|
||||||
|
// State for edit form
|
||||||
|
let editName = '';
|
||||||
|
let editQuantity = '';
|
||||||
|
|
||||||
|
// --- Edit Mode ---
|
||||||
|
function startEdit() {
|
||||||
|
if (isEditing) return;
|
||||||
|
editName = item.name;
|
||||||
|
editQuantity = item.quantity ?? '';
|
||||||
|
isEditing = true;
|
||||||
|
dispatch('updateError', ''); // Clear previous errors when starting edit
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
isEditing = false;
|
||||||
|
dispatch('updateError', ''); // Clear errors on cancel too
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API Interactions (Modified for Offline) ---
|
||||||
|
|
||||||
|
async function handleToggleComplete() {
|
||||||
|
if (isToggling || isEditing) return;
|
||||||
|
isToggling = true;
|
||||||
|
dispatch('updateError', '');
|
||||||
|
|
||||||
|
const newStatus = !item.is_complete;
|
||||||
|
const updateData: ItemUpdate = { is_complete: newStatus };
|
||||||
|
const currentUserId = get(authStore).user?.id; // Get user ID synchronously
|
||||||
|
|
||||||
|
// 1. Optimistic DB Update (UI update delegated to parent via event)
|
||||||
|
const optimisticItem = {
|
||||||
|
...item,
|
||||||
|
is_complete: newStatus,
|
||||||
|
// Set completed_by_id based on new status and current user
|
||||||
|
completed_by_id: newStatus ? (currentUserId ?? item.completed_by_id ?? null) : null,
|
||||||
|
updated_at: new Date().toISOString() // Update timestamp locally
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await putItemToDb(optimisticItem);
|
||||||
|
dispatch('itemUpdated', optimisticItem); // Dispatch optimistic update immediately
|
||||||
|
} catch (dbError) {
|
||||||
|
console.error('Optimistic toggle DB update failed:', dbError);
|
||||||
|
dispatch('updateError', 'Failed to save state locally.');
|
||||||
|
isToggling = false;
|
||||||
|
return; // Stop if DB update fails
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Queue or Send API Call
|
||||||
|
console.log(`Toggling item ${item.id} to ${newStatus}`);
|
||||||
|
try {
|
||||||
|
if (browser && !navigator.onLine) {
|
||||||
|
// OFFLINE: Queue action
|
||||||
|
console.log(`Offline: Queuing update for item ${item.id}`);
|
||||||
|
await addSyncAction({
|
||||||
|
type: 'update_item',
|
||||||
|
payload: { id: item.id, data: updateData },
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// ONLINE: Send API call directly
|
||||||
|
const updatedItemFromServer = await apiClient.put<ItemPublic>(
|
||||||
|
`/v1/items/${item.id}`,
|
||||||
|
updateData
|
||||||
|
);
|
||||||
|
// Update DB and dispatch again with potentially more accurate server data
|
||||||
|
await putItemToDb(updatedItemFromServer);
|
||||||
|
dispatch('itemUpdated', updatedItemFromServer);
|
||||||
|
}
|
||||||
|
// Trigger sync if online after queuing or direct call
|
||||||
|
if (browser && navigator.onLine) processSyncQueue();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Toggle item ${item.id} failed:`, err);
|
||||||
|
const errorMsg =
|
||||||
|
err instanceof ApiClientError ? `Error (${err.status}): ${err.message}` : 'Toggle failed';
|
||||||
|
dispatch('updateError', errorMsg);
|
||||||
|
// TODO: Consider reverting optimistic update on error? More complex.
|
||||||
|
// For now, just show error. User might need to manually fix state or refresh.
|
||||||
|
} finally {
|
||||||
|
isToggling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveEdit() {
|
||||||
|
if (!editName.trim()) {
|
||||||
|
dispatch('updateError', 'Item name cannot be empty.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isSavingEdit) return;
|
||||||
|
|
||||||
|
isSavingEdit = true;
|
||||||
|
dispatch('updateError', '');
|
||||||
|
|
||||||
|
const updateData: ItemUpdate = {
|
||||||
|
name: editName.trim(),
|
||||||
|
quantity: editQuantity.trim() || undefined // Send undefined if empty
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Optimistic DB / UI
|
||||||
|
const optimisticItem = {
|
||||||
|
...item,
|
||||||
|
name: updateData.name!,
|
||||||
|
quantity: updateData.quantity ?? null,
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await putItemToDb(optimisticItem);
|
||||||
|
dispatch('itemUpdated', optimisticItem);
|
||||||
|
} catch (dbError) {
|
||||||
|
console.error('Optimistic edit DB update failed:', dbError);
|
||||||
|
dispatch('updateError', 'Failed to save state locally.');
|
||||||
|
isSavingEdit = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Queue or Send API Call
|
||||||
|
console.log(`Saving edits for item ${item.id}`, updateData);
|
||||||
|
try {
|
||||||
|
if (browser && !navigator.onLine) {
|
||||||
|
console.log(`Offline: Queuing update for item ${item.id}`);
|
||||||
|
await addSyncAction({
|
||||||
|
type: 'update_item',
|
||||||
|
payload: { id: item.id, data: updateData },
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const updatedItemFromServer = await apiClient.put<ItemPublic>(
|
||||||
|
`/v1/items/${item.id}`,
|
||||||
|
updateData
|
||||||
|
);
|
||||||
|
await putItemToDb(updatedItemFromServer);
|
||||||
|
dispatch('itemUpdated', updatedItemFromServer); // Update with server data
|
||||||
|
}
|
||||||
|
if (browser && navigator.onLine) processSyncQueue();
|
||||||
|
isEditing = false; // Exit edit mode on success
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Save edit for item ${item.id} failed:`, err);
|
||||||
|
const errorMsg =
|
||||||
|
err instanceof ApiClientError ? `Error (${err.status}): ${err.message}` : 'Save failed';
|
||||||
|
dispatch('updateError', errorMsg);
|
||||||
|
// TODO: Revert optimistic update?
|
||||||
|
} finally {
|
||||||
|
isSavingEdit = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (isDeleting || isEditing) return;
|
||||||
|
|
||||||
|
if (!confirm(`Are you sure you want to delete item "${item.name}"?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isDeleting = true;
|
||||||
|
dispatch('updateError', '');
|
||||||
|
|
||||||
|
const itemIdToDelete = item.id;
|
||||||
|
|
||||||
|
// 1. Optimistic DB / UI
|
||||||
|
try {
|
||||||
|
await deleteItemFromDb(itemIdToDelete);
|
||||||
|
dispatch('itemDeleted', itemIdToDelete); // Notify parent immediately
|
||||||
|
} catch (dbError) {
|
||||||
|
console.error('Optimistic delete DB update failed:', dbError);
|
||||||
|
dispatch('updateError', 'Failed to delete item locally.');
|
||||||
|
isDeleting = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Queue or Send API Call
|
||||||
|
console.log(`Deleting item ${itemIdToDelete}`);
|
||||||
|
try {
|
||||||
|
if (browser && !navigator.onLine) {
|
||||||
|
console.log(`Offline: Queuing delete for item ${itemIdToDelete}`);
|
||||||
|
await addSyncAction({
|
||||||
|
type: 'delete_item',
|
||||||
|
payload: { id: itemIdToDelete },
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await apiClient.delete(`/v1/items/${itemIdToDelete}`);
|
||||||
|
}
|
||||||
|
if (browser && navigator.onLine) processSyncQueue();
|
||||||
|
// Component will be destroyed by parent on success
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Delete item ${itemIdToDelete} failed:`, err);
|
||||||
|
const errorMsg =
|
||||||
|
err instanceof ApiClientError ? `Error (${err.status}): ${err.message}` : 'Delete failed';
|
||||||
|
dispatch('updateError', errorMsg);
|
||||||
|
// If API delete failed, the item was already removed from UI/DB optimistically.
|
||||||
|
// User may need to refresh to see it again if the delete wasn't valid server-side.
|
||||||
|
// For MVP, just show the error.
|
||||||
|
isDeleting = false; // Reset loading state only on error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- TEMPLATE -->
|
||||||
|
<li
|
||||||
|
class="flex items-center justify-between gap-4 rounded border p-3 transition duration-150 ease-in-out hover:bg-gray-50"
|
||||||
|
class:border-gray-200={!isEditing}
|
||||||
|
class:border-blue-400={isEditing}
|
||||||
|
class:opacity-60={item.is_complete && !isEditing}
|
||||||
|
>
|
||||||
|
{#if isEditing}
|
||||||
|
<!-- Edit Mode Form -->
|
||||||
|
<form on:submit|preventDefault={handleSaveEdit} class="flex flex-grow items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={editName}
|
||||||
|
required
|
||||||
|
class="flex-grow rounded border border-gray-300 px-2 py-1 text-sm shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
|
disabled={isSavingEdit}
|
||||||
|
aria-label="Edit item name"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={editQuantity}
|
||||||
|
placeholder="Qty (opt.)"
|
||||||
|
class="w-20 rounded border border-gray-300 px-2 py-1 text-sm shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
|
disabled={isSavingEdit}
|
||||||
|
aria-label="Edit item quantity"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded bg-green-600 px-2 py-1 text-xs text-white hover:bg-green-700 disabled:opacity-50"
|
||||||
|
disabled={isSavingEdit}
|
||||||
|
aria-label="Save changes"
|
||||||
|
>
|
||||||
|
{isSavingEdit ? '...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={cancelEdit}
|
||||||
|
class="rounded bg-gray-500 px-2 py-1 text-xs text-white hover:bg-gray-600"
|
||||||
|
disabled={isSavingEdit}
|
||||||
|
aria-label="Cancel edit"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<!-- Display Mode -->
|
||||||
|
<div class="flex flex-grow items-center gap-3 overflow-hidden">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={item.is_complete}
|
||||||
|
disabled={isToggling || isDeleting}
|
||||||
|
aria-label="Mark {item.name} as {item.is_complete ? 'incomplete' : 'complete'}"
|
||||||
|
class="h-5 w-5 flex-shrink-0 rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
on:change={handleToggleComplete}
|
||||||
|
/>
|
||||||
|
<div class="flex-grow overflow-hidden">
|
||||||
|
<span
|
||||||
|
class="block truncate font-medium text-gray-800"
|
||||||
|
class:line-through={item.is_complete}
|
||||||
|
class:text-gray-500={item.is_complete}
|
||||||
|
title={item.name}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
{#if item.quantity}
|
||||||
|
<span
|
||||||
|
class="block truncate text-sm text-gray-500"
|
||||||
|
class:line-through={item.is_complete}
|
||||||
|
title={item.quantity}
|
||||||
|
>
|
||||||
|
Qty: {item.quantity}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-shrink-0 items-center space-x-2">
|
||||||
|
<button
|
||||||
|
on:click={startEdit}
|
||||||
|
class="rounded p-1 text-xs text-gray-400 hover:bg-gray-200 hover:text-gray-700"
|
||||||
|
title="Edit Item"
|
||||||
|
disabled={isToggling || isDeleting}
|
||||||
|
>
|
||||||
|
✏️
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
on:click={handleDelete}
|
||||||
|
class="rounded p-1 text-xs text-red-400 hover:bg-red-100 hover:text-red-600"
|
||||||
|
title="Delete Item"
|
||||||
|
disabled={isToggling || isDeleting}
|
||||||
|
>
|
||||||
|
{#if isDeleting}⏳{:else}🗑️{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</li>
|
201
fe/src/lib/components/ListForm.svelte
Normal file
201
fe/src/lib/components/ListForm.svelte
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
<!-- src/lib/components/ListForm.svelte -->
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { apiClient, ApiClientError } from '$lib/apiClient';
|
||||||
|
import type { GroupPublic } from '$lib/schemas/group';
|
||||||
|
import type { ListPublic, ListCreate, ListUpdate } from '$lib/schemas/list'; // Import necessary types
|
||||||
|
|
||||||
|
// Props
|
||||||
|
/** Optional existing list data for editing */
|
||||||
|
export let list: ListPublic | null = null;
|
||||||
|
/** Array of user's groups for the dropdown */
|
||||||
|
export let groups: GroupPublic[] = [];
|
||||||
|
/** Optional error message passed from parent (e.g., load error) */
|
||||||
|
export let apiError: string | null = null;
|
||||||
|
|
||||||
|
// Form State
|
||||||
|
let name = '';
|
||||||
|
let description = '';
|
||||||
|
let selectedGroupId: string = 'null'; // Use 'null' string for the "Personal" option value
|
||||||
|
let isLoading = false;
|
||||||
|
let errorMessage: string | null = null;
|
||||||
|
let successMessage: string | null = null;
|
||||||
|
|
||||||
|
// Determine mode and initialize form
|
||||||
|
let isEditMode = false;
|
||||||
|
$: {
|
||||||
|
// Reactive block: runs when props change
|
||||||
|
isEditMode = !!list;
|
||||||
|
// Reset form when list prop changes (navigating between edit pages)
|
||||||
|
// or initialize for creation
|
||||||
|
name = list?.name ?? '';
|
||||||
|
description = list?.description ?? '';
|
||||||
|
// Set dropdown: if list has group_id, convert to string; otherwise, use 'null' string
|
||||||
|
selectedGroupId = list?.group_id != null ? String(list.group_id) : 'null';
|
||||||
|
errorMessage = null; // Clear errors on list change
|
||||||
|
successMessage = null;
|
||||||
|
isLoading = false;
|
||||||
|
console.log('ListForm initialized. Edit mode:', isEditMode, 'List:', list);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local error if apiError prop changes
|
||||||
|
$: if (apiError) errorMessage = apiError;
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!name.trim()) {
|
||||||
|
errorMessage = 'List name cannot be empty.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = true;
|
||||||
|
errorMessage = null;
|
||||||
|
successMessage = null;
|
||||||
|
|
||||||
|
// Prepare data based on create or edit mode
|
||||||
|
const requestBody: ListCreate | ListUpdate = {
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim() || undefined // Send undefined if empty
|
||||||
|
// Only include group_id for creation, not typically editable this way
|
||||||
|
// For edit, we'd usually handle 'is_complete' if needed, but not group_id change here
|
||||||
|
};
|
||||||
|
if (!isEditMode) {
|
||||||
|
(requestBody as ListCreate).group_id =
|
||||||
|
selectedGroupId === 'null' ? null : parseInt(selectedGroupId, 10);
|
||||||
|
}
|
||||||
|
// If editing, you might add other updatable fields like is_complete
|
||||||
|
// if (isEditMode) {
|
||||||
|
// (requestBody as ListUpdate).is_complete = someCheckboxValue;
|
||||||
|
// }
|
||||||
|
|
||||||
|
console.log(`Submitting list data (${isEditMode ? 'Edit' : 'Create'}):`, requestBody);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let resultList: ListPublic;
|
||||||
|
if (isEditMode && list) {
|
||||||
|
// PUT request for updating
|
||||||
|
resultList = await apiClient.put<ListPublic>(`/v1/lists/${list.id}`, requestBody);
|
||||||
|
successMessage = `List "${resultList.name}" updated successfully!`;
|
||||||
|
} else {
|
||||||
|
// POST request for creating
|
||||||
|
resultList = await apiClient.post<ListPublic>('/v1/lists', requestBody);
|
||||||
|
successMessage = `List "${resultList.name}" created successfully!`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('List submission successful:', resultList);
|
||||||
|
|
||||||
|
// Redirect after a short delay to show success message
|
||||||
|
setTimeout(async () => {
|
||||||
|
// Redirect to dashboard after create/edit
|
||||||
|
await goto('/dashboard');
|
||||||
|
// Or redirect to the list detail page after edit?
|
||||||
|
// if (isEditMode) await goto(`/groups/${resultList.id}`); // Need group detail route
|
||||||
|
}, 1000); // 1 second delay
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`List ${isEditMode ? 'update' : 'creation'} failed:`, err);
|
||||||
|
if (err instanceof ApiClientError) {
|
||||||
|
let detail = `Failed to ${isEditMode ? 'update' : 'create'} list.`;
|
||||||
|
if (err.errorData && typeof err.errorData === 'object' && 'detail' in err.errorData) {
|
||||||
|
detail = (err.errorData as { detail: string }).detail; // Use 'as' assertion
|
||||||
|
}
|
||||||
|
errorMessage = `Error (${err.status}): ${detail}`;
|
||||||
|
} else if (err instanceof Error) {
|
||||||
|
errorMessage = `Error: ${err.message}`;
|
||||||
|
} else {
|
||||||
|
errorMessage = 'An unexpected error occurred.';
|
||||||
|
}
|
||||||
|
isLoading = false; // Ensure loading stops on error
|
||||||
|
}
|
||||||
|
// No finally needed here as success leads to navigation
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form on:submit|preventDefault={handleSubmit} class="space-y-4 rounded bg-white p-6 shadow">
|
||||||
|
<h2 class="mb-4 text-xl font-semibold text-gray-700">
|
||||||
|
{isEditMode ? 'Edit List' : 'Create New List'}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{#if successMessage}
|
||||||
|
<div
|
||||||
|
class="mb-4 rounded border border-green-400 bg-green-100 p-3 text-center text-sm text-green-700"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{successMessage} Redirecting...
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if errorMessage}
|
||||||
|
<div
|
||||||
|
class="mb-4 rounded border border-red-400 bg-red-100 p-3 text-center text-sm text-red-700"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="list-name" class="mb-1 block text-sm font-medium text-gray-600">List Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="list-name"
|
||||||
|
bind:value={name}
|
||||||
|
required
|
||||||
|
class="w-full rounded border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-70"
|
||||||
|
disabled={isLoading || !!successMessage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="list-description" class="mb-1 block text-sm font-medium text-gray-600"
|
||||||
|
>Description (Optional)</label
|
||||||
|
>
|
||||||
|
<!-- Corrected textarea tag -->
|
||||||
|
<textarea
|
||||||
|
id="list-description"
|
||||||
|
bind:value={description}
|
||||||
|
rows="3"
|
||||||
|
class="w-full rounded border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-70"
|
||||||
|
disabled={isLoading || !!successMessage}
|
||||||
|
></textarea>
|
||||||
|
<!-- Ensure closing tag -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Only show group selector in create mode -->
|
||||||
|
{#if !isEditMode}
|
||||||
|
<div>
|
||||||
|
<label for="list-group" class="mb-1 block text-sm font-medium text-gray-600"
|
||||||
|
>Share with Group (Optional)</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="list-group"
|
||||||
|
bind:value={selectedGroupId}
|
||||||
|
class="w-full rounded border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-70"
|
||||||
|
disabled={isLoading || !!successMessage}
|
||||||
|
>
|
||||||
|
<option value="null">Personal (No Group)</option>
|
||||||
|
{#each groups as group (group.id)}
|
||||||
|
<option value={String(group.id)}>{group.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{#if groups.length === 0}
|
||||||
|
<p class="mt-1 text-xs text-gray-500">You are not a member of any groups to share with.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end space-x-3 pt-2">
|
||||||
|
<a href="/dashboard" class="text-sm text-gray-600 hover:underline">Cancel</a>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded bg-blue-600 px-4 py-2 font-medium whitespace-nowrap text-white transition duration-150 ease-in-out hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={isLoading || !!successMessage}
|
||||||
|
>
|
||||||
|
{#if isLoading}
|
||||||
|
Saving...
|
||||||
|
{:else if isEditMode}
|
||||||
|
Save Changes
|
||||||
|
{:else}
|
||||||
|
Create List
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
195
fe/src/lib/db.ts
Normal file
195
fe/src/lib/db.ts
Normal file
@ -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<string, string>; // Example indexes
|
||||||
|
};
|
||||||
|
items: {
|
||||||
|
key: number; // Primary key (item.id)
|
||||||
|
value: ItemPublic;
|
||||||
|
indexes: Record<string, string>; // Index by listId is crucial
|
||||||
|
};
|
||||||
|
syncQueue: {
|
||||||
|
key: number; // Auto-incrementing key
|
||||||
|
value: SyncAction;
|
||||||
|
// No indexes needed for simple queue processing
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let dbPromise: Promise<IDBPDatabase<SharedListsDBSchema>> | null = null;
|
||||||
|
|
||||||
|
/** Gets the IndexedDB database instance, creating/upgrading if necessary. */
|
||||||
|
function getDb(): Promise<IDBPDatabase<SharedListsDBSchema>> {
|
||||||
|
if (!dbPromise) {
|
||||||
|
dbPromise = openDB<SharedListsDBSchema>(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<ListDetail | undefined> {
|
||||||
|
const db = await getDb();
|
||||||
|
return db.get('lists', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gets all lists stored in IndexedDB. */
|
||||||
|
export async function getAllListsFromDb(): Promise<ListDetail[]> {
|
||||||
|
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<number> {
|
||||||
|
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<void> {
|
||||||
|
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<ItemPublic | undefined> {
|
||||||
|
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<ItemPublic[]> {
|
||||||
|
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<number> {
|
||||||
|
const db = await getDb();
|
||||||
|
return db.put('items', item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes an item from IndexedDB by ID. */
|
||||||
|
export async function deleteItemFromDb(id: number): Promise<void> {
|
||||||
|
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<SyncAction, 'id'>): Promise<number> {
|
||||||
|
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<SyncAction[]> {
|
||||||
|
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<void> {
|
||||||
|
const db = await getDb();
|
||||||
|
return db.delete('syncQueue', id);
|
||||||
|
}
|
27
fe/src/lib/schemas/item.ts
Normal file
27
fe/src/lib/schemas/item.ts
Normal file
@ -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
|
||||||
|
}
|
35
fe/src/lib/schemas/list.ts
Normal file
35
fe/src/lib/schemas/list.ts
Normal file
@ -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;
|
||||||
|
}
|
154
fe/src/lib/syncService.ts
Normal file
154
fe/src/lib/syncService.ts
Normal file
@ -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<string | null>(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();
|
||||||
|
}
|
@ -64,7 +64,16 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
<h1 class="text-3xl font-bold text-gray-800">Your Groups</h1>
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-800">Your Groups</h1>
|
||||||
|
<!-- Link to create list page -->
|
||||||
|
<a
|
||||||
|
href="/lists/new"
|
||||||
|
class="rounded bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700"
|
||||||
|
>
|
||||||
|
+ Create New List
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Group Creation Section -->
|
<!-- Group Creation Section -->
|
||||||
<div class="rounded bg-white p-6 shadow">
|
<div class="rounded bg-white p-6 shadow">
|
||||||
@ -95,6 +104,47 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded bg-white p-6 shadow">
|
||||||
|
<h2 class="mb-4 text-xl font-semibold text-gray-700">My Lists</h2>
|
||||||
|
{#if loadError}
|
||||||
|
<div class="rounded border border-red-400 bg-red-100 p-4 text-red-700" role="alert">
|
||||||
|
<p class="font-bold">Error Loading Data</p>
|
||||||
|
<p>{loadError}</p>
|
||||||
|
</div>
|
||||||
|
{:else if !data.lists || data.lists.length === 0}
|
||||||
|
<p class="text-gray-500">You haven't created any lists yet.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="space-y-3">
|
||||||
|
{#each data.lists as list (list.id)}
|
||||||
|
<li
|
||||||
|
class="rounded border border-gray-200 p-4 transition duration-150 ease-in-out hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<!-- Make name a link -->
|
||||||
|
<a
|
||||||
|
href="/lists/{list.id}"
|
||||||
|
class="font-medium text-gray-800 hover:text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
{list.name}
|
||||||
|
</a>
|
||||||
|
<!-- ... (shared/personal tags) ... -->
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<!-- Link to Edit Page -->
|
||||||
|
<a href="/lists/{list.id}/edit" class="text-sm text-yellow-600 hover:underline"
|
||||||
|
>Edit</a
|
||||||
|
>
|
||||||
|
<!-- Add Delete button later -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- ... (description, updated date) ... -->
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Group List Section -->
|
<!-- Group List Section -->
|
||||||
<div class="rounded bg-white p-6 shadow">
|
<div class="rounded bg-white p-6 shadow">
|
||||||
<h2 class="mb-4 text-xl font-semibold text-gray-700">My Groups</h2>
|
<h2 class="mb-4 text-xl font-semibold text-gray-700">My Groups</h2>
|
||||||
|
@ -3,6 +3,7 @@ import { error } from '@sveltejs/kit';
|
|||||||
import { apiClient, ApiClientError } from '$lib/apiClient';
|
import { apiClient, ApiClientError } from '$lib/apiClient';
|
||||||
import type { GroupPublic } from '$lib/schemas/group'; // Import the Group type
|
import type { GroupPublic } from '$lib/schemas/group'; // Import the Group type
|
||||||
import type { PageLoad } from './$types'; // SvelteKit's type for load functions
|
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
|
// Define the expected shape of the data returned by this load function
|
||||||
export interface DashboardLoadData {
|
export interface DashboardLoadData {
|
||||||
@ -18,9 +19,11 @@ export const load: PageLoad<DashboardLoadData> = async ({ fetch }) => {
|
|||||||
console.log('Dashboard page load: Fetching groups...');
|
console.log('Dashboard page load: Fetching groups...');
|
||||||
try {
|
try {
|
||||||
const groups = await apiClient.get<GroupPublic[]>('/v1/groups'); // apiClient adds auth header
|
const groups = await apiClient.get<GroupPublic[]>('/v1/groups'); // apiClient adds auth header
|
||||||
|
const lists = await apiClient.get<ListPublic[]>('/v1/lists'); // apiClient adds auth header
|
||||||
console.log('Dashboard page load: Groups fetched successfully', groups);
|
console.log('Dashboard page load: Groups fetched successfully', groups);
|
||||||
return {
|
return {
|
||||||
groups: groups ?? [], // Return empty array if API returns null/undefined
|
groups: groups ?? [], // Return empty array if API returns null/undefined
|
||||||
|
lists: lists ?? [],
|
||||||
error: null
|
error: null
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
475
fe/src/routes/(app)/lists/[listId]/+page.svelte
Normal file
475
fe/src/routes/(app)/lists/[listId]/+page.svelte
Normal file
@ -0,0 +1,475 @@
|
|||||||
|
<!-- src/routes/(app)/lists/[listId]/+page.svelte -->
|
||||||
|
<script lang="ts">
|
||||||
|
// Svelte/SvelteKit Imports
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import type { PageData } from '../$types';
|
||||||
|
|
||||||
|
// Component Imports
|
||||||
|
import ItemDisplay from '$lib/components/ItemDisplay.svelte';
|
||||||
|
|
||||||
|
// Utility/Store Imports
|
||||||
|
import { apiClient, ApiClientError } from '$lib/apiClient';
|
||||||
|
import { authStore } from '$lib/stores/authStore'; // Get current user ID
|
||||||
|
import { get, writable } from 'svelte/store'; // For local reactive list state
|
||||||
|
|
||||||
|
// Schema Imports
|
||||||
|
import type { ItemPublic, ItemCreate, ItemUpdate } from '$lib/schemas/item';
|
||||||
|
import type { ListDetail, ListStatus } from '$lib/schemas/list';
|
||||||
|
|
||||||
|
// --- DB and Sync Imports ---
|
||||||
|
import {
|
||||||
|
getListFromDb,
|
||||||
|
getItemsByListIdFromDb,
|
||||||
|
putListToDb,
|
||||||
|
putItemToDb,
|
||||||
|
deleteItemFromDb,
|
||||||
|
addSyncAction
|
||||||
|
} from '$lib/db';
|
||||||
|
import { syncStatus, syncError, processSyncQueue, triggerSync } from '$lib/syncService';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
// --- End DB and Sync Imports ---
|
||||||
|
|
||||||
|
// --- Props ---
|
||||||
|
export let data: PageData; // Contains initial { list: ListDetail } from server/cache/load
|
||||||
|
|
||||||
|
// --- Local Reactive State ---
|
||||||
|
// Use a writable store locally to manage the list and items for easier updates
|
||||||
|
// Initialize with data from SSR/load function as fallback
|
||||||
|
const localListStore = writable<ListDetail | null>(data.list);
|
||||||
|
|
||||||
|
// --- Add Item State ---
|
||||||
|
let newItemName = '';
|
||||||
|
let newItemQuantity = '';
|
||||||
|
let isAddingItem = false;
|
||||||
|
let addItemError: string | null = null;
|
||||||
|
|
||||||
|
// --- General Item Error Display ---
|
||||||
|
let itemUpdateError: string | null = null;
|
||||||
|
let itemErrorTimeout: ReturnType<typeof setTimeout> | undefined = undefined;
|
||||||
|
|
||||||
|
// --- Polling State ---
|
||||||
|
let pollIntervalId: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let lastKnownStatus: {
|
||||||
|
// Ensure this stores Date objects or null
|
||||||
|
list_updated_at: Date;
|
||||||
|
latest_item_updated_at: Date | null;
|
||||||
|
item_count: number;
|
||||||
|
} | null = null;
|
||||||
|
let isRefreshing = false;
|
||||||
|
const POLLING_INTERVAL_MS = 15000; // Poll every 15 seconds
|
||||||
|
|
||||||
|
// --- Lifecycle ---
|
||||||
|
onMount(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
let listId: number | null = null;
|
||||||
|
try {
|
||||||
|
listId = parseInt($page.params.listId, 10);
|
||||||
|
} catch {
|
||||||
|
/* ignore parsing error */
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!listId) {
|
||||||
|
console.error('List Detail Mount: Invalid or missing listId in params.');
|
||||||
|
// Optionally redirect or show permanent error
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Load from IndexedDB first for faster initial display/offline
|
||||||
|
if (browser) {
|
||||||
|
console.log('List Detail Mount: Loading from IndexedDB for list', listId);
|
||||||
|
const listFromDb = await getListFromDb(listId);
|
||||||
|
if (listFromDb) {
|
||||||
|
console.log('List Detail Mount: Found list in DB', listFromDb);
|
||||||
|
// Items should be part of ListDetail object store
|
||||||
|
if (isMounted) {
|
||||||
|
localListStore.set(listFromDb);
|
||||||
|
initializePollingStatus(listFromDb);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('List Detail Mount: List not found in DB, using SSR/load data.');
|
||||||
|
if (isMounted) {
|
||||||
|
localListStore.set(data.list); // Fallback to initial data
|
||||||
|
initializePollingStatus(data.list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. If online, trigger an API fetch in background to update DB/UI
|
||||||
|
if (navigator.onLine) {
|
||||||
|
console.log('List Detail Mount: Online, fetching fresh data...');
|
||||||
|
fetchAndUpdateList(listId); // Don't await, let it run in background
|
||||||
|
// Also trigger sync queue processing
|
||||||
|
processSyncQueue(); // Don't await
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Start polling
|
||||||
|
startPolling();
|
||||||
|
} else {
|
||||||
|
// Server side: Use data from load function directly
|
||||||
|
if (isMounted) {
|
||||||
|
localListStore.set(data.list);
|
||||||
|
initializePollingStatus(data.list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
stopPolling();
|
||||||
|
clearTimeout(itemErrorTimeout);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper to fetch from API and update DB + Store
|
||||||
|
async function fetchAndUpdateList(listId: number) {
|
||||||
|
isRefreshing = true;
|
||||||
|
try {
|
||||||
|
const freshList = await apiClient.get<ListDetail>(`/v1/lists/${listId}`);
|
||||||
|
await putListToDb(freshList); // Update IndexedDB
|
||||||
|
localListStore.set(freshList); // Update the UI store
|
||||||
|
// No need to re-initialize polling status here, checkListStatus will update it
|
||||||
|
console.log('List Detail: Fetched and updated list', listId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('List Detail: Failed to fetch fresh list data', err);
|
||||||
|
handleItemUpdateError(
|
||||||
|
new CustomEvent('updateError', { detail: 'Failed to refresh list data.' })
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to initialize polling status from ListDetail data
|
||||||
|
function initializePollingStatus(listData: ListDetail | null) {
|
||||||
|
if (!listData) {
|
||||||
|
lastKnownStatus = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const listUpdatedAt = new Date(listData.updated_at);
|
||||||
|
let latestItemUpdate: Date | null = null;
|
||||||
|
if (listData.items && listData.items.length > 0) {
|
||||||
|
const latestDateString = listData.items.reduce(
|
||||||
|
(latest, item) => (item.updated_at > latest ? item.updated_at : latest),
|
||||||
|
listData.items[0].updated_at
|
||||||
|
);
|
||||||
|
latestItemUpdate = new Date(latestDateString);
|
||||||
|
}
|
||||||
|
lastKnownStatus = {
|
||||||
|
list_updated_at: listUpdatedAt,
|
||||||
|
latest_item_updated_at: latestItemUpdate,
|
||||||
|
item_count: listData.items?.length ?? 0
|
||||||
|
};
|
||||||
|
console.log('Polling: Initial/Reset status set', lastKnownStatus);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Polling Init: Error parsing dates', e);
|
||||||
|
lastKnownStatus = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Polling Logic ---
|
||||||
|
function startPolling() {
|
||||||
|
stopPolling();
|
||||||
|
if (!$localListStore) return;
|
||||||
|
console.log(
|
||||||
|
`Polling: Starting polling for list ${$localListStore.id} every ${POLLING_INTERVAL_MS}ms`
|
||||||
|
);
|
||||||
|
pollIntervalId = setInterval(checkListStatus, POLLING_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (pollIntervalId) {
|
||||||
|
clearInterval(pollIntervalId);
|
||||||
|
pollIntervalId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkListStatus() {
|
||||||
|
const currentList = get(localListStore); // Use get for non-reactive access inside async
|
||||||
|
if (!currentList || isRefreshing || !lastKnownStatus || !navigator.onLine) {
|
||||||
|
if (!navigator.onLine) console.log('Polling: Offline, skipping status check.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`Polling: Checking status for list ${currentList.id}`);
|
||||||
|
try {
|
||||||
|
const currentStatus = await apiClient.get<ListStatus>(`/v1/lists/${currentList.id}/status`);
|
||||||
|
const currentListUpdatedAt = new Date(currentStatus.list_updated_at);
|
||||||
|
const currentLatestItemUpdatedAt = currentStatus.latest_item_updated_at
|
||||||
|
? new Date(currentStatus.latest_item_updated_at)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const listChanged =
|
||||||
|
currentListUpdatedAt.getTime() !== lastKnownStatus.list_updated_at.getTime();
|
||||||
|
const itemsChanged =
|
||||||
|
currentLatestItemUpdatedAt?.getTime() !==
|
||||||
|
lastKnownStatus.latest_item_updated_at?.getTime() ||
|
||||||
|
currentStatus.item_count !== lastKnownStatus.item_count;
|
||||||
|
|
||||||
|
if (listChanged || itemsChanged) {
|
||||||
|
console.log('Polling: Change detected!', { listChanged, itemsChanged });
|
||||||
|
await refreshListData(); // Fetch full data
|
||||||
|
// Update known status AFTER successful refresh
|
||||||
|
lastKnownStatus = {
|
||||||
|
list_updated_at: currentListUpdatedAt,
|
||||||
|
latest_item_updated_at: currentLatestItemUpdatedAt,
|
||||||
|
item_count: currentStatus.item_count
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
console.log('Polling: No changes detected.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Polling: Failed to fetch list status:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshListData() {
|
||||||
|
// Refactored to use store value
|
||||||
|
const listId = get(localListStore)?.id;
|
||||||
|
if (listId) {
|
||||||
|
await fetchAndUpdateList(listId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Event Handlers from ItemDisplay ---
|
||||||
|
async function handleItemUpdated(event: CustomEvent<ItemPublic>) {
|
||||||
|
const updatedItem = event.detail;
|
||||||
|
console.log('Parent received itemUpdated:', updatedItem);
|
||||||
|
// Update DB (already done in ItemDisplay optimistic update)
|
||||||
|
// Update store for UI
|
||||||
|
localListStore.update((currentList) => {
|
||||||
|
if (!currentList) return null;
|
||||||
|
const index = currentList.items.findIndex((i) => i.id === updatedItem.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
currentList.items[index] = updatedItem;
|
||||||
|
}
|
||||||
|
return { ...currentList, items: [...currentList.items] };
|
||||||
|
});
|
||||||
|
clearItemError();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleItemDeleted(event: CustomEvent<number>) {
|
||||||
|
const deletedItemId = event.detail;
|
||||||
|
console.log('Parent received itemDeleted:', deletedItemId);
|
||||||
|
// Update DB (already done in ItemDisplay optimistic update)
|
||||||
|
// Update store for UI
|
||||||
|
localListStore.update((currentList) => {
|
||||||
|
if (!currentList) return null;
|
||||||
|
return {
|
||||||
|
...currentList,
|
||||||
|
items: currentList.items.filter((item) => item.id !== deletedItemId)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
clearItemError();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleItemUpdateError(event: CustomEvent<string>) {
|
||||||
|
/* ... (keep existing) ... */
|
||||||
|
}
|
||||||
|
function clearItemError() {
|
||||||
|
/* ... (keep existing) ... */
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Add Item Logic ---
|
||||||
|
async function handleAddItem() {
|
||||||
|
const currentList = get(localListStore); // Use get for non-reactive access
|
||||||
|
if (!newItemName.trim() || !currentList) return;
|
||||||
|
if (isAddingItem) return;
|
||||||
|
|
||||||
|
isAddingItem = true;
|
||||||
|
addItemError = null;
|
||||||
|
clearItemError();
|
||||||
|
|
||||||
|
// 1. Optimistic UI Update with Temporary ID (Using negative random number for simplicity)
|
||||||
|
const tempId = Math.floor(Math.random() * -1000000);
|
||||||
|
const currentUserId = get(authStore).user?.id; // Get current user ID synchronously
|
||||||
|
if (!currentUserId) {
|
||||||
|
addItemError = 'Cannot add item: User not identified.';
|
||||||
|
isAddingItem = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const optimisticItem: ItemPublic = {
|
||||||
|
id: tempId, // Use temporary ID
|
||||||
|
list_id: currentList.id,
|
||||||
|
name: newItemName.trim(),
|
||||||
|
quantity: newItemQuantity.trim() || null,
|
||||||
|
is_complete: false,
|
||||||
|
price: null,
|
||||||
|
added_by_id: currentUserId,
|
||||||
|
completed_by_id: null,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
localListStore.update((list) =>
|
||||||
|
list ? { ...list, items: [...list.items, optimisticItem] } : null
|
||||||
|
);
|
||||||
|
// Note: Cannot add item with temp ID to IndexedDB if keyPath is 'id' and type is number.
|
||||||
|
// For MVP, we skip adding temp items to DB and rely on sync + refresh.
|
||||||
|
|
||||||
|
// 2. Queue Sync Action
|
||||||
|
const actionPayload: ItemCreate = {
|
||||||
|
name: newItemName.trim(),
|
||||||
|
quantity: newItemQuantity.trim() || undefined
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await addSyncAction({
|
||||||
|
type: 'create_item',
|
||||||
|
payload: { listId: currentList.id, data: actionPayload },
|
||||||
|
timestamp: Date.now()
|
||||||
|
// tempId: tempId // Optional: include tempId for mapping later
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Trigger sync if online
|
||||||
|
if (browser && navigator.onLine) processSyncQueue();
|
||||||
|
|
||||||
|
// 4. Clear form
|
||||||
|
newItemName = '';
|
||||||
|
newItemQuantity = '';
|
||||||
|
} catch (dbError) {
|
||||||
|
console.error('Failed to queue add item action:', dbError);
|
||||||
|
addItemError = 'Failed to save item for offline sync.';
|
||||||
|
// Revert optimistic UI update? More complex.
|
||||||
|
localListStore.update((list) =>
|
||||||
|
list ? { ...list, items: list.items.filter((i) => i.id !== tempId) } : null
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
isAddingItem = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Template -->
|
||||||
|
{#if $localListStore}
|
||||||
|
{@const list = $localListStore}
|
||||||
|
<!-- Create local const for easier access -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Sync Status Indicator -->
|
||||||
|
{#if $syncStatus === 'syncing'}
|
||||||
|
<div
|
||||||
|
class="fixed right-4 bottom-4 z-50 animate-pulse rounded bg-blue-100 p-3 text-sm text-blue-700 shadow"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
Syncing changes...
|
||||||
|
</div>
|
||||||
|
{:else if $syncStatus === 'error' && $syncError}
|
||||||
|
<div
|
||||||
|
class="fixed right-4 bottom-4 z-50 rounded bg-red-100 p-3 text-sm text-red-700 shadow"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
Sync Error: {$syncError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- List Header -->
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-4 border-b border-gray-200 pb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-800">{list.name}</h1>
|
||||||
|
{#if list.description}
|
||||||
|
<p class="mt-1 text-base text-gray-600">{list.description}</p>
|
||||||
|
{/if}
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
ID: {list.id} |
|
||||||
|
{#if list.group_id}
|
||||||
|
<span class="font-medium text-purple-600">Shared</span> |
|
||||||
|
{:else}
|
||||||
|
<span class="font-medium text-gray-600">Personal</span> |
|
||||||
|
{/if}
|
||||||
|
Status: {list.is_complete ? 'Complete' : 'In Progress'} | Updated: {new Date(
|
||||||
|
list.updated_at
|
||||||
|
).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex-shrink-0 space-x-2">
|
||||||
|
{#if isRefreshing}
|
||||||
|
<span class="animate-pulse text-sm text-blue-600">Refreshing...</span>
|
||||||
|
{/if}
|
||||||
|
<a
|
||||||
|
href="/lists/{list.id}/edit"
|
||||||
|
class="rounded bg-yellow-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-yellow-600 focus:ring-2 focus:ring-yellow-400 focus:ring-offset-2 focus:outline-none"
|
||||||
|
>
|
||||||
|
Edit List Details
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add New Item Form -->
|
||||||
|
<div class="rounded bg-white p-4 shadow">
|
||||||
|
<h2 class="mb-3 text-lg font-semibold text-gray-700">Add New Item</h2>
|
||||||
|
<form
|
||||||
|
on:submit|preventDefault={handleAddItem}
|
||||||
|
class="flex flex-col gap-3 sm:flex-row sm:items-end"
|
||||||
|
>
|
||||||
|
<div class="flex-grow">
|
||||||
|
<label for="new-item-name" class="sr-only">Item Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="new-item-name"
|
||||||
|
placeholder="Item name (required)"
|
||||||
|
required
|
||||||
|
bind:value={newItemName}
|
||||||
|
class="w-full rounded border border-gray-300 p-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-70"
|
||||||
|
disabled={isAddingItem}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="sm:w-1/4">
|
||||||
|
<label for="new-item-qty" class="sr-only">Quantity</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="new-item-qty"
|
||||||
|
placeholder="Quantity (opt.)"
|
||||||
|
bind:value={newItemQuantity}
|
||||||
|
class="w-full rounded border border-gray-300 p-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-70"
|
||||||
|
disabled={isAddingItem}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded bg-blue-600 px-4 py-2 font-medium whitespace-nowrap text-white shadow-sm transition hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={isAddingItem}
|
||||||
|
>
|
||||||
|
{isAddingItem ? 'Adding...' : 'Add Item'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{#if addItemError}
|
||||||
|
<p class="mt-2 text-sm text-red-600">{addItemError}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Item List Section -->
|
||||||
|
<div class="rounded bg-white p-6 shadow">
|
||||||
|
<h2 class="mb-4 text-xl font-semibold text-gray-700">Items ({list.items?.length ?? 0})</h2>
|
||||||
|
{#if itemUpdateError}
|
||||||
|
<div
|
||||||
|
class="mb-4 rounded border border-red-400 bg-red-100 p-3 text-sm text-red-700"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{itemUpdateError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if list.items && list.items.length > 0}
|
||||||
|
<ul class="space-y-2">
|
||||||
|
{#each list.items as item (item.id)}
|
||||||
|
<ItemDisplay
|
||||||
|
{item}
|
||||||
|
on:itemUpdated={handleItemUpdated}
|
||||||
|
on:itemDeleted={handleItemDeleted}
|
||||||
|
on:updateError={handleItemUpdateError}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<p class="py-4 text-center text-gray-500">This list is empty. Add items above!</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Back Link -->
|
||||||
|
<div class="mt-6 border-t border-gray-200 pt-6">
|
||||||
|
<a href="/dashboard" class="text-sm text-blue-600 hover:underline">← Back to Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Fallback if list data is somehow null/undefined after load function -->
|
||||||
|
<p class="text-center text-gray-500">Loading list data...</p>
|
||||||
|
{/if}
|
53
fe/src/routes/(app)/lists/[listId]/+page.ts
Normal file
53
fe/src/routes/(app)/lists/[listId]/+page.ts
Normal file
@ -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<ListDetailPageLoadData> = 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<ListDetail>(`/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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
16
fe/src/routes/(app)/lists/[listId]/edit/+page.svelte
Normal file
16
fe/src/routes/(app)/lists/[listId]/edit/+page.svelte
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<!-- src/routes/(app)/lists/[listId]/edit/+page.svelte -->
|
||||||
|
<script lang="ts">
|
||||||
|
import ListForm from '$lib/components/ListForm.svelte';
|
||||||
|
import type { PageData } from './$types'; // Type for { list, groups, error }
|
||||||
|
|
||||||
|
export let data: PageData;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-xl">
|
||||||
|
<a href="/dashboard" class="mb-4 inline-block text-sm text-blue-600 hover:underline"
|
||||||
|
>← Back to Dashboard</a
|
||||||
|
>
|
||||||
|
<!-- Pass the fetched list, groups, and potential group load error -->
|
||||||
|
<!-- The 'list' prop tells ListForm it's in edit mode -->
|
||||||
|
<ListForm list={data.list} groups={data.groups} apiError={data.error} />
|
||||||
|
</div>
|
75
fe/src/routes/(app)/lists/[listId]/edit/+page.ts
Normal file
75
fe/src/routes/(app)/lists/[listId]/edit/+page.ts
Normal file
@ -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<EditListPageLoadData> = 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<ListPublic>(`/v1/lists/${listId}`), // Fetch specific list
|
||||||
|
apiClient.get<GroupPublic[]>('/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'}`);
|
||||||
|
}
|
||||||
|
};
|
13
fe/src/routes/(app)/lists/new/+page.svelte
Normal file
13
fe/src/routes/(app)/lists/new/+page.svelte
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!-- src/routes/(app)/lists/new/+page.svelte -->
|
||||||
|
<script lang="ts">
|
||||||
|
import ListForm from '$lib/components/ListForm.svelte';
|
||||||
|
import type { PageData } from './$types'; // Type for { groups, error }
|
||||||
|
|
||||||
|
export let data: PageData;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-xl">
|
||||||
|
<!-- Pass groups and potential load error to the form component -->
|
||||||
|
<!-- 'list' prop is omitted/null, so ListForm knows it's in create mode -->
|
||||||
|
<ListForm groups={data.groups} apiError={data.error} />
|
||||||
|
</div>
|
32
fe/src/routes/(app)/lists/new/+page.ts
Normal file
32
fe/src/routes/(app)/lists/new/+page.ts
Normal file
@ -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<NewListPageLoadData> = async ({ fetch }) => {
|
||||||
|
console.log('New List page load: Fetching groups...');
|
||||||
|
try {
|
||||||
|
const groups = await apiClient.get<GroupPublic[]>('/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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
@ -1,64 +1,135 @@
|
|||||||
|
// src/service-worker.ts
|
||||||
|
|
||||||
/// <reference types="@sveltejs/kit" />
|
/// <reference types="@sveltejs/kit" />
|
||||||
// REMOVED: /// <reference types="@types/workbox-sw" />
|
|
||||||
/// <reference lib="webworker" />
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
// Import SvelteKit-provided variables ONLY
|
// This import IS correct - it's provided by SvelteKit
|
||||||
import { build, files, version } from '$service-worker';
|
import { build, files, version } from '$service-worker';
|
||||||
|
|
||||||
declare let self: ServiceWorkerGlobalScope;
|
// Declare workbox global if needed (if TS complains after removing @types/workbox-sw)
|
||||||
// Declare 'workbox' as any for now IF TypeScript still complains after removing @types/workbox-sw.
|
declare const workbox: any; // Using 'any' for simplicity if specific types cause issues
|
||||||
// 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
|
|
||||||
|
|
||||||
console.log(`[Service Worker] Version: ${version}`);
|
console.log(`[Service Worker] Version: ${version}`);
|
||||||
|
|
||||||
// --- Precaching ---
|
// --- Precaching Core Assets ---
|
||||||
// Use the global workbox object (assuming SvelteKit injects it)
|
// Cache essential SvelteKit build artifacts and static files
|
||||||
workbox.precaching.precacheAndRoute(build);
|
workbox.precaching.precacheAndRoute(build);
|
||||||
workbox.precaching.precacheAndRoute(files.map(f => ({ url: f, revision: null })));
|
workbox.precaching.precacheAndRoute(files.map(f => ({ url: f, revision: null })));
|
||||||
|
|
||||||
// --- Runtime Caching ---
|
// --- Runtime Caching Strategies ---
|
||||||
// Google Fonts
|
|
||||||
|
// Example: Cache Google Fonts (Optional)
|
||||||
workbox.routing.registerRoute(
|
workbox.routing.registerRoute(
|
||||||
({ url }) => url.origin === 'https://fonts.googleapis.com' || url.origin === 'https://fonts.gstatic.com',
|
({ url }) => url.origin === 'https://fonts.googleapis.com' || url.origin === 'https://fonts.gstatic.com',
|
||||||
new workbox.strategies.StaleWhileRevalidate({
|
new workbox.strategies.StaleWhileRevalidate({
|
||||||
cacheName: 'google-fonts',
|
cacheName: 'google-fonts-cache',
|
||||||
plugins: [
|
plugins: [
|
||||||
new workbox.cacheableResponse.CacheableResponse({ statuses: [0, 200] }),
|
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(
|
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({
|
new workbox.strategies.CacheFirst({
|
||||||
cacheName: 'images',
|
cacheName: 'images-cache',
|
||||||
plugins: [
|
plugins: [
|
||||||
new workbox.cacheableResponse.CacheableResponse({ statuses: [0, 200] }),
|
new workbox.cacheableResponse.CacheableResponse({ statuses: [0, 200] }),
|
||||||
new workbox.expiration.ExpirationPlugin({
|
new workbox.expiration.ExpirationPlugin({
|
||||||
maxEntries: 50,
|
maxEntries: 50,
|
||||||
maxAgeSeconds: 30 * 24 * 60 * 60,
|
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 Days
|
||||||
purgeOnQuotaError: true,
|
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) => {
|
self.addEventListener('install', (event) => {
|
||||||
console.log('[Service Worker] Install event');
|
console.log('[Service Worker] Install event');
|
||||||
|
// Force activation immediately (use with caution, ensure clients handle updates)
|
||||||
// event.waitUntil(self.skipWaiting());
|
// event.waitUntil(self.skipWaiting());
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('activate', (event) => {
|
self.addEventListener('activate', (event) => {
|
||||||
const extendableEvent = event as ExtendableEvent;
|
const extendableEvent = event as ExtendableEvent; // Cast for type safety
|
||||||
console.log('[Service Worker] Activate event');
|
console.log('[Service Worker] Activate event');
|
||||||
|
// Remove outdated caches managed by Workbox's precaching
|
||||||
extendableEvent.waitUntil(workbox.precaching.cleanupOutdatedCaches());
|
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) => {
|
self.addEventListener('fetch', (event) => {
|
||||||
|
// Workbox's registered routes handle fetch events automatically.
|
||||||
// console.log(`[Service Worker] Fetching: ${event.request.url}`);
|
// 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());
|
||||||
|
}
|
||||||
});
|
});
|
Loading…
Reference in New Issue
Block a user