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 groups
|
||||
from app.api.v1.endpoints import invites
|
||||
from app.api.v1.endpoints import lists
|
||||
from app.api.v1.endpoints import items
|
||||
|
||||
api_router_v1 = APIRouter()
|
||||
|
||||
@ -14,6 +16,7 @@ api_router_v1.include_router(auth.router, prefix="/auth", tags=["Authentication"
|
||||
api_router_v1.include_router(users.router, prefix="/users", tags=["Users"])
|
||||
api_router_v1.include_router(groups.router, prefix="/groups", tags=["Groups"])
|
||||
api_router_v1.include_router(invites.router, prefix="/invites", tags=["Invites"])
|
||||
|
||||
api_router_v1.include_router(lists.router, prefix="/lists", tags=["Lists"])
|
||||
api_router_v1.include_router(items.router, tags=["Items"])
|
||||
# Add other v1 endpoint routers here later
|
||||
# e.g., api_router_v1.include_router(users.router, prefix="/users", tags=["Users"])
|
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
|
||||
import enum
|
||||
import secrets # For generating invite codes
|
||||
from datetime import datetime, timedelta, timezone # For invite expiry
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
@ -10,20 +10,20 @@ from sqlalchemy import (
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Boolean,
|
||||
Enum as SAEnum, # Renamed to avoid clash with Python's enum
|
||||
Enum as SAEnum,
|
||||
UniqueConstraint,
|
||||
Index, # Added for invite code index
|
||||
Index,
|
||||
DDL,
|
||||
event,
|
||||
delete, # Added for potential cascade delete if needed (though FK handles it)
|
||||
func, # Added for func.count()
|
||||
text as sa_text # For raw SQL in index definition if needed
|
||||
delete,
|
||||
func,
|
||||
text as sa_text,
|
||||
Text, # <-- Add Text for description
|
||||
Numeric # <-- Add Numeric for price
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
# Removed func import as it's imported above
|
||||
# from sqlalchemy.sql import func # For server_default=func.now()
|
||||
|
||||
from .database import Base # Import Base from database setup
|
||||
from .database import Base
|
||||
|
||||
# --- Enums ---
|
||||
class UserRoleEnum(enum.Enum):
|
||||
@ -36,25 +36,20 @@ class User(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
email = Column(String, unique=True, index=True, nullable=False)
|
||||
password_hash = Column(String, nullable=False) # Column name used in CRUD
|
||||
password_hash = Column(String, nullable=False)
|
||||
name = Column(String, index=True, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
# --- Relationships ---
|
||||
# Groups created by this user
|
||||
created_groups = relationship("Group", back_populates="creator") # Links to Group.creator
|
||||
created_groups = relationship("Group", back_populates="creator")
|
||||
group_associations = relationship("UserGroup", back_populates="user", cascade="all, delete-orphan")
|
||||
created_invites = relationship("Invite", back_populates="creator")
|
||||
|
||||
# Association object for group membership (many-to-many)
|
||||
group_associations = relationship("UserGroup", back_populates="user", cascade="all, delete-orphan") # Links to UserGroup.user
|
||||
|
||||
# Invites created by this user (one-to-many)
|
||||
created_invites = relationship("Invite", back_populates="creator") # Links to Invite.creator
|
||||
|
||||
# Optional relationships for items/lists (Add later)
|
||||
# added_items = relationship("Item", foreign_keys="[Item.added_by_id]", back_populates="added_by_user")
|
||||
# completed_items = relationship("Item", foreign_keys="[Item.completed_by_id]", back_populates="completed_by_user")
|
||||
# expense_shares = relationship("ExpenseShare", back_populates="user")
|
||||
# created_lists = relationship("List", foreign_keys="[List.created_by_id]", back_populates="creator")
|
||||
# --- NEW Relationships for Lists/Items ---
|
||||
created_lists = relationship("List", foreign_keys="List.created_by_id", back_populates="creator") # Link List.created_by_id -> User
|
||||
added_items = relationship("Item", foreign_keys="Item.added_by_id", back_populates="added_by_user") # Link Item.added_by_id -> User
|
||||
completed_items = relationship("Item", foreign_keys="Item.completed_by_id", back_populates="completed_by_user") # Link Item.completed_by_id -> User
|
||||
# --- End NEW Relationships ---
|
||||
|
||||
|
||||
# --- Group Model ---
|
||||
@ -63,78 +58,88 @@ class Group(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, index=True, nullable=False)
|
||||
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) # FK to User table
|
||||
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
# --- Relationships ---
|
||||
# The user who created this group (many-to-one)
|
||||
creator = relationship("User", back_populates="created_groups") # Links to User.created_groups
|
||||
creator = relationship("User", back_populates="created_groups")
|
||||
member_associations = relationship("UserGroup", back_populates="group", cascade="all, delete-orphan")
|
||||
invites = relationship("Invite", back_populates="group", cascade="all, delete-orphan")
|
||||
|
||||
# Association object for group membership (one-to-many)
|
||||
member_associations = relationship("UserGroup", back_populates="group", cascade="all, delete-orphan") # Links to UserGroup.group
|
||||
|
||||
# Invites belonging to this group (one-to-many)
|
||||
invites = relationship("Invite", back_populates="group", cascade="all, delete-orphan") # Links to Invite.group
|
||||
|
||||
# Lists belonging to this group (Add later)
|
||||
# lists = relationship("List", back_populates="group")
|
||||
# --- NEW Relationship for Lists ---
|
||||
lists = relationship("List", back_populates="group", cascade="all, delete-orphan") # Link List.group_id -> Group
|
||||
# --- End NEW Relationship ---
|
||||
|
||||
|
||||
# --- UserGroup Association Model (Many-to-Many link) ---
|
||||
# --- UserGroup Association Model ---
|
||||
class UserGroup(Base):
|
||||
__tablename__ = "user_groups"
|
||||
# Ensure a user cannot be in the same group twice
|
||||
__table_args__ = (UniqueConstraint('user_id', 'group_id', name='uq_user_group'),)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True) # Surrogate primary key
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) # FK to User
|
||||
group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False) # FK to Group
|
||||
role = Column(SAEnum(UserRoleEnum, name="userroleenum", create_type=True), nullable=False, default=UserRoleEnum.member) # Use Enum, ensure type is created
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False)
|
||||
role = Column(SAEnum(UserRoleEnum, name="userroleenum", create_type=True), nullable=False, default=UserRoleEnum.member)
|
||||
joined_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
# --- Relationships ---
|
||||
# Link back to User (many-to-one from the perspective of this table row)
|
||||
user = relationship("User", back_populates="group_associations") # Links to User.group_associations
|
||||
|
||||
# Link back to Group (many-to-one from the perspective of this table row)
|
||||
group = relationship("Group", back_populates="member_associations") # Links to Group.member_associations
|
||||
user = relationship("User", back_populates="group_associations")
|
||||
group = relationship("Group", back_populates="member_associations")
|
||||
|
||||
|
||||
# --- Invite Model ---
|
||||
class Invite(Base):
|
||||
__tablename__ = "invites"
|
||||
# Ensure unique codes *within active invites* using a partial index (PostgreSQL specific)
|
||||
# If not using PostgreSQL or need simpler logic, a simple unique=True on 'code' works,
|
||||
# but doesn't allow reusing old codes once deactivated.
|
||||
__table_args__ = (
|
||||
Index(
|
||||
'ix_invites_active_code',
|
||||
'code',
|
||||
unique=True,
|
||||
postgresql_where=sa_text('is_active = true') # Partial index condition
|
||||
),
|
||||
Index('ix_invites_active_code', 'code', unique=True, postgresql_where=sa_text('is_active = true')),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
# Generate a secure random code by default
|
||||
code = Column(String, unique=False, index=True, nullable=False, default=lambda: secrets.token_urlsafe(16)) # Index helps lookup, uniqueness handled by partial index
|
||||
group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False) # FK to Group
|
||||
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) # FK to User
|
||||
code = Column(String, unique=False, index=True, nullable=False, default=lambda: secrets.token_urlsafe(16))
|
||||
group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False)
|
||||
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
# Set default expiry (e.g., 7 days from creation)
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc) + timedelta(days=7))
|
||||
is_active = Column(Boolean, default=True, nullable=False) # To mark as used/invalid
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
group = relationship("Group", back_populates="invites")
|
||||
creator = relationship("User", back_populates="created_invites")
|
||||
|
||||
|
||||
# === NEW: List Model ===
|
||||
class List(Base):
|
||||
__tablename__ = "lists"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, index=True, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) # Who created this list
|
||||
group_id = Column(Integer, ForeignKey("groups.id"), nullable=True) # Which group it belongs to (NULL if personal)
|
||||
is_complete = Column(Boolean, default=False, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
# --- Relationships ---
|
||||
# Link back to the Group this invite is for (many-to-one)
|
||||
group = relationship("Group", back_populates="invites") # Links to Group.invites
|
||||
|
||||
# Link back to the User who created the invite (many-to-one)
|
||||
creator = relationship("User", back_populates="created_invites") # Links to User.created_invites
|
||||
creator = relationship("User", back_populates="created_lists") # Link to User.created_lists
|
||||
group = relationship("Group", back_populates="lists") # Link to Group.lists
|
||||
items = relationship("Item", back_populates="list", cascade="all, delete-orphan", order_by="Item.created_at") # Link to Item.list, cascade deletes
|
||||
|
||||
|
||||
# --- Models for Lists, Items, Expenses (Add later) ---
|
||||
# class List(Base): ...
|
||||
# class Item(Base): ...
|
||||
# class Expense(Base): ...
|
||||
# class ExpenseShare(Base): ...
|
||||
# === NEW: Item Model ===
|
||||
class Item(Base):
|
||||
__tablename__ = "items"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
list_id = Column(Integer, ForeignKey("lists.id", ondelete="CASCADE"), nullable=False) # Belongs to which list
|
||||
name = Column(String, index=True, nullable=False)
|
||||
quantity = Column(String, nullable=True) # Flexible quantity (e.g., "1", "2 lbs", "a bunch")
|
||||
is_complete = Column(Boolean, default=False, nullable=False)
|
||||
price = Column(Numeric(10, 2), nullable=True) # For cost splitting later (e.g., 12345678.99)
|
||||
added_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) # Who added this item
|
||||
completed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Who marked it complete
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
# --- Relationships ---
|
||||
list = relationship("List", back_populates="items") # Link to List.items
|
||||
added_by_user = relationship("User", foreign_keys=[added_by_id], back_populates="added_items") # Link to User.added_items
|
||||
completed_by_user = relationship("User", foreign_keys=[completed_by_id], back_populates="completed_items") # Link to User.completed_items
|
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",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"idb": "^8.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^5.2.11",
|
||||
"@sveltejs/kit": "^2.16.0",
|
||||
@ -1527,6 +1530,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/idb": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.2.tgz",
|
||||
"integrity": "sha512-CX70rYhx7GDDQzwwQMDwF6kDRQi5vVs6khHUumDrMecBylKkwvZ8HWvKV08AGb7VbpoGCWUQ4aHzNDgoUiOIUg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/import-meta-resolve": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz",
|
||||
|
@ -27,5 +27,8 @@
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.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>
|
||||
|
||||
<div class="space-y-8">
|
||||
<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 -->
|
||||
<div class="rounded bg-white p-6 shadow">
|
||||
@ -95,6 +104,47 @@
|
||||
{/if}
|
||||
</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 -->
|
||||
<div class="rounded bg-white p-6 shadow">
|
||||
<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 type { GroupPublic } from '$lib/schemas/group'; // Import the Group type
|
||||
import type { PageLoad } from './$types'; // SvelteKit's type for load functions
|
||||
import type { ListPublic } from '$lib/schemas/list';
|
||||
|
||||
// Define the expected shape of the data returned by this load function
|
||||
export interface DashboardLoadData {
|
||||
@ -18,9 +19,11 @@ export const load: PageLoad<DashboardLoadData> = async ({ fetch }) => {
|
||||
console.log('Dashboard page load: Fetching groups...');
|
||||
try {
|
||||
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);
|
||||
return {
|
||||
groups: groups ?? [], // Return empty array if API returns null/undefined
|
||||
lists: lists ?? [],
|
||||
error: null
|
||||
};
|
||||
} 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" />
|
||||
// REMOVED: /// <reference types="@types/workbox-sw" />
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
// Import SvelteKit-provided variables ONLY
|
||||
// This import IS correct - it's provided by SvelteKit
|
||||
import { build, files, version } from '$service-worker';
|
||||
|
||||
declare let self: ServiceWorkerGlobalScope;
|
||||
// Declare 'workbox' as any for now IF TypeScript still complains after removing @types/workbox-sw.
|
||||
// Often, SvelteKit's types are enough, but this is a fallback.
|
||||
declare const workbox: any; // Uncomment this line ONLY if 'Cannot find name workbox' persists
|
||||
// Declare workbox global if needed (if TS complains after removing @types/workbox-sw)
|
||||
declare const workbox: any; // Using 'any' for simplicity if specific types cause issues
|
||||
|
||||
console.log(`[Service Worker] Version: ${version}`);
|
||||
|
||||
// --- Precaching ---
|
||||
// Use the global workbox object (assuming SvelteKit injects it)
|
||||
// --- Precaching Core Assets ---
|
||||
// Cache essential SvelteKit build artifacts and static files
|
||||
workbox.precaching.precacheAndRoute(build);
|
||||
workbox.precaching.precacheAndRoute(files.map(f => ({ url: f, revision: null })));
|
||||
|
||||
// --- Runtime Caching ---
|
||||
// Google Fonts
|
||||
// --- Runtime Caching Strategies ---
|
||||
|
||||
// Example: Cache Google Fonts (Optional)
|
||||
workbox.routing.registerRoute(
|
||||
({ url }) => url.origin === 'https://fonts.googleapis.com' || url.origin === 'https://fonts.gstatic.com',
|
||||
new workbox.strategies.StaleWhileRevalidate({
|
||||
cacheName: 'google-fonts',
|
||||
cacheName: 'google-fonts-cache',
|
||||
plugins: [
|
||||
new workbox.cacheableResponse.CacheableResponse({ statuses: [0, 200] }),
|
||||
new workbox.expiration.ExpirationPlugin({ maxEntries: 20, maxAgeSeconds: 30 * 24 * 60 * 60 }),
|
||||
new workbox.expiration.ExpirationPlugin({ maxEntries: 20, maxAgeSeconds: 30 * 24 * 60 * 60 }), // 30 Days
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
// Images from origin
|
||||
// Example: Cache images from origin (Optional)
|
||||
workbox.routing.registerRoute(
|
||||
({ request, url }) => !!request && request.destination === 'image' && url.origin === self.location.origin,
|
||||
({ request, url }) => request.destination === 'image' && url.origin === self.location.origin,
|
||||
new workbox.strategies.CacheFirst({
|
||||
cacheName: 'images',
|
||||
cacheName: 'images-cache',
|
||||
plugins: [
|
||||
new workbox.cacheableResponse.CacheableResponse({ statuses: [0, 200] }),
|
||||
new workbox.expiration.ExpirationPlugin({
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 30 * 24 * 60 * 60,
|
||||
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 Days
|
||||
purgeOnQuotaError: true,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
// --- Lifecycle ---
|
||||
// --- API GET Request Caching ---
|
||||
|
||||
// Cache the list of lists (NetworkFirst: try network, fallback cache)
|
||||
workbox.routing.registerRoute(
|
||||
({ url, request }) => url.pathname === '/api/v1/lists' && request.method === 'GET',
|
||||
new workbox.strategies.NetworkFirst({
|
||||
cacheName: 'api-lists-cache',
|
||||
plugins: [
|
||||
new workbox.cacheableResponse.CacheableResponse({ statuses: [0, 200] }),
|
||||
new workbox.expiration.ExpirationPlugin({ maxEntries: 10, maxAgeSeconds: 60 * 60 * 24 }) // 1 Day
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
// Cache individual list details (NetworkFirst)
|
||||
workbox.routing.registerRoute(
|
||||
({ url, request }) => /^\/api\/v1\/lists\/\d+$/.test(url.pathname) && request.method === 'GET',
|
||||
new workbox.strategies.NetworkFirst({
|
||||
cacheName: 'api-list-detail-cache',
|
||||
plugins: [
|
||||
new workbox.cacheableResponse.CacheableResponse({ statuses: [0, 200] }),
|
||||
new workbox.expiration.ExpirationPlugin({ maxEntries: 30, maxAgeSeconds: 60 * 60 * 24 }) // 1 Day
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
// Cache list status (StaleWhileRevalidate: serve cache fast, update background)
|
||||
workbox.routing.registerRoute(
|
||||
({ url, request }) => /^\/api\/v1\/lists\/\d+\/status$/.test(url.pathname) && request.method === 'GET',
|
||||
new workbox.strategies.StaleWhileRevalidate({
|
||||
cacheName: 'api-list-status-cache',
|
||||
plugins: [
|
||||
new workbox.cacheableResponse.CacheableResponse({ statuses: [0, 200] }),
|
||||
new workbox.expiration.ExpirationPlugin({ maxEntries: 30, maxAgeSeconds: 60 * 60 }) // 1 Hour
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
// --- Service Worker Lifecycle ---
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('[Service Worker] Install event');
|
||||
// Force activation immediately (use with caution, ensure clients handle updates)
|
||||
// event.waitUntil(self.skipWaiting());
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
const extendableEvent = event as ExtendableEvent;
|
||||
const extendableEvent = event as ExtendableEvent; // Cast for type safety
|
||||
console.log('[Service Worker] Activate event');
|
||||
// Remove outdated caches managed by Workbox's precaching
|
||||
extendableEvent.waitUntil(workbox.precaching.cleanupOutdatedCaches());
|
||||
// event.waitUntil(self.clients.claim());
|
||||
// Take control of uncontrolled clients immediately
|
||||
// extendableEvent.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// Workbox's registered routes handle fetch events automatically.
|
||||
// console.log(`[Service Worker] Fetching: ${event.request.url}`);
|
||||
|
||||
// --- Background Sync Interception (More Advanced - requires Workbox Background Sync module) ---
|
||||
// This is a basic example; a full implementation requires the workbox-background-sync package
|
||||
// and more robust error handling and queue management.
|
||||
// const isApiMutation = event.request.url.startsWith(self.location.origin + '/api/') && event.request.method !== 'GET';
|
||||
// if (isApiMutation) {
|
||||
// // Example using BackgroundSync Queue (conceptual)
|
||||
// // Requires: import { Queue } from 'workbox-background-sync';
|
||||
// // const queue = new Queue('apiSyncQueue');
|
||||
// // event.respondWith(
|
||||
// // fetch(event.request.clone()).catch(() => {
|
||||
// // console.log('[Service Worker] Fetch failed, queueing request:', event.request.url);
|
||||
// // return queue.pushRequest({ request: event.request });
|
||||
// // })
|
||||
// // );
|
||||
// }
|
||||
});
|
||||
|
||||
// Optional: Listen for messages from clients (e.g., trigger sync manually)
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data && event.data.type === 'TRIGGER_SYNC') {
|
||||
console.log('[Service Worker] Received TRIGGER_SYNC message.');
|
||||
// Import and call processSyncQueue if structured that way,
|
||||
// or use Background Sync's replayRequests.
|
||||
// Example: Triggering background sync replay (if using Workbox Background Sync)
|
||||
// const queue = new workbox.backgroundSync.Queue('apiSyncQueue');
|
||||
// event.waitUntil(queue.replayRequests());
|
||||
}
|
||||
});
|
Loading…
Reference in New Issue
Block a user