feat: Introduce FastAPI and Vue.js guidelines, enhance API structure, and add caching support
Some checks failed
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Failing after 1m24s

This commit adds new guidelines for FastAPI and Vue.js development, emphasizing best practices for component structure, API performance, and data handling. It also introduces caching mechanisms using Redis for improved performance and updates the API structure to streamline authentication and user management. Additionally, new endpoints for categories and time entries are implemented, enhancing the overall functionality of the application.
This commit is contained in:
mohamad 2025-06-09 21:02:51 +02:00
parent bbe3b3a493
commit f49e15c05c
68 changed files with 3110 additions and 509 deletions

32
.cursor/rules/fastapi.mdc Normal file
View File

@ -0,0 +1,32 @@
---
description:
globs:
alwaysApply: true
---
# FastAPI-Specific Guidelines:
- Use functional components (plain functions) and Pydantic models for input validation and response schemas.
- Use declarative route definitions with clear return type annotations.
- Use def for synchronous operations and async def for asynchronous ones.
- Minimize @app.on_event("startup") and @app.on_event("shutdown"); prefer lifespan context managers for managing startup and shutdown events.
- Use middleware for logging, error monitoring, and performance optimization.
- Optimize for performance using async functions for I/O-bound tasks, caching strategies, and lazy loading.
- Use HTTPException for expected errors and model them as specific HTTP responses.
- Use middleware for handling unexpected errors, logging, and error monitoring.
- Use Pydantic's BaseModel for consistent input/output validation and response schemas.
Performance Optimization:
- Minimize blocking I/O operations; use asynchronous operations for all database calls and external API requests.
- Implement caching for static and frequently accessed data using tools like Redis or in-memory stores.
- Optimize data serialization and deserialization with Pydantic.
- Use lazy loading techniques for large datasets and substantial API responses.
Key Conventions
1. Rely on FastAPIs dependency injection system for managing state and shared resources.
2. Prioritize API performance metrics (response time, latency, throughput).
3. Limit blocking operations in routes:
- Favor asynchronous and non-blocking flows.
- Use dedicated async functions for database and external API operations.
- Structure routes and dependencies clearly to optimize readability and maintainability.
Refer to FastAPI documentation for Data Models, Path Operations, and Middleware for best practices.

37
.cursor/rules/vue.mdc Normal file
View File

@ -0,0 +1,37 @@
---
description:
globs:
alwaysApply: true
---
You have extensive expertise in Vue 3, TypeScript, Node.js, Vite, Vue Router, Pinia, VueUse, and CSS. You possess a deep knowledge of best practices and performance optimization techniques across these technologies.
Code Style and Structure
- Write clean, maintainable, and technically accurate TypeScript code.
- Emphasize iteration and modularization and minimize code duplication.
- Prefer Composition API <script setup> style.
- Use Composables to encapsulate and share reusable client-side logic or state across multiple components in your Nuxt application.
Fetching Data
1. Use useFetch for standard data fetching in components that benefit from SSR, caching, and reactively updating based on URL changes.
2. Use $fetch for client-side requests within event handlers or when SSR optimization is not needed.
3. Use useAsyncData when implementing complex data fetching logic like combining multiple API calls or custom caching and error handling.
4. Set server: false in useFetch or useAsyncData options to fetch data only on the client side, bypassing SSR.
5. Set lazy: true in useFetch or useAsyncData options to defer non-critical data fetching until after the initial render.
Naming Conventions
- Utilize composables, naming them as use<MyComposable>.
- Use **PascalCase** for component file names (e.g., components/MyComponent.vue).
- Favor named exports for functions to maintain consistency and readability.
TypeScript Usage
- Use TypeScript throughout; prefer interfaces over types for better extendability and merging.
- Avoid enums, opting for maps for improved type safety and flexibility.
- Use functional components with TypeScript interfaces.
UI and Styling.
- Implement responsive design; use a mobile-first approach.

View File

@ -0,0 +1,91 @@
"""feature_updates_phase1
Revision ID: bdf7427ccfa3
Revises: 05bf96a9e18b
Create Date: 2025-06-09 18:00:11.083651
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'bdf7427ccfa3'
down_revision: Union[str, None] = '05bf96a9e18b'
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('financial_audit_log',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('action_type', sa.String(), nullable=False),
sa.Column('entity_type', sa.String(), nullable=False),
sa.Column('entity_id', sa.Integer(), nullable=False),
sa.Column('details', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_financial_audit_log_action_type'), 'financial_audit_log', ['action_type'], unique=False)
op.create_index(op.f('ix_financial_audit_log_id'), 'financial_audit_log', ['id'], unique=False)
op.create_table('categories',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('group_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name', 'user_id', 'group_id', name='uq_category_scope')
)
op.create_index(op.f('ix_categories_id'), 'categories', ['id'], unique=False)
op.create_index(op.f('ix_categories_name'), 'categories', ['name'], unique=False)
op.create_table('time_entries',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('chore_assignment_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('start_time', sa.DateTime(timezone=True), nullable=False),
sa.Column('end_time', sa.DateTime(timezone=True), nullable=True),
sa.Column('duration_seconds', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['chore_assignment_id'], ['chore_assignments.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_time_entries_id'), 'time_entries', ['id'], unique=False)
op.add_column('chores', sa.Column('parent_chore_id', sa.Integer(), nullable=True))
op.create_index(op.f('ix_chores_parent_chore_id'), 'chores', ['parent_chore_id'], unique=False)
op.create_foreign_key(None, 'chores', 'chores', ['parent_chore_id'], ['id'])
op.add_column('items', sa.Column('category_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'items', 'categories', ['category_id'], ['id'])
op.add_column('lists', sa.Column('archived_at', sa.DateTime(timezone=True), nullable=True))
op.create_index(op.f('ix_lists_archived_at'), 'lists', ['archived_at'], unique=False)
op.add_column('users', sa.Column('is_guest', sa.Boolean(), nullable=False, server_default='f'))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'is_guest')
op.drop_index(op.f('ix_lists_archived_at'), table_name='lists')
op.drop_column('lists', 'archived_at')
op.drop_constraint(None, 'items', type_='foreignkey')
op.drop_column('items', 'category_id')
op.drop_constraint(None, 'chores', type_='foreignkey')
op.drop_index(op.f('ix_chores_parent_chore_id'), table_name='chores')
op.drop_column('chores', 'parent_chore_id')
op.drop_index(op.f('ix_time_entries_id'), table_name='time_entries')
op.drop_table('time_entries')
op.drop_index(op.f('ix_categories_name'), table_name='categories')
op.drop_index(op.f('ix_categories_id'), table_name='categories')
op.drop_table('categories')
op.drop_index(op.f('ix_financial_audit_log_id'), table_name='financial_audit_log')
op.drop_index(op.f('ix_financial_audit_log_action_type'), table_name='financial_audit_log')
op.drop_table('financial_audit_log')
# ### end Alembic commands ###

View File

@ -0,0 +1,51 @@
"""add_updated_at_and_version_to_groups
Revision ID: c693ade3601c
Revises: bdf7427ccfa3
Create Date: 2025-06-09 19:22:36.244072
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'c693ade3601c'
down_revision: Union[str, None] = 'bdf7427ccfa3'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('ix_apscheduler_jobs_next_run_time', table_name='apscheduler_jobs')
op.drop_table('apscheduler_jobs')
op.add_column('groups', sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False))
op.add_column('groups', sa.Column('version', sa.Integer(), server_default='1', nullable=False))
op.alter_column('users', 'is_guest',
existing_type=sa.BOOLEAN(),
server_default=None,
existing_nullable=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('users', 'is_guest',
existing_type=sa.BOOLEAN(),
server_default=sa.text('false'),
existing_nullable=False)
op.drop_column('groups', 'version')
op.drop_column('groups', 'updated_at')
op.create_table('apscheduler_jobs',
sa.Column('id', sa.VARCHAR(length=191), autoincrement=False, nullable=False),
sa.Column('next_run_time', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True),
sa.Column('job_state', postgresql.BYTEA(), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('id', name='apscheduler_jobs_pkey')
)
op.create_index('ix_apscheduler_jobs_next_run_time', 'apscheduler_jobs', ['next_run_time'], unique=False)
# ### end Alembic commands ###

55
be/app/api/auth/guest.py Normal file
View File

@ -0,0 +1,55 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
import uuid
from app import models
from app.schemas.user import UserCreate, UserClaim, UserPublic
from app.schemas.token import Token
from app.database import get_session
from app.auth import current_active_user
from app.core.security import create_access_token, get_password_hash
from app.crud import user as crud_user
router = APIRouter()
@router.post("/guest", response_model=Token)
async def create_guest_user(db: AsyncSession = Depends(get_session)):
"""
Creates a new guest user.
"""
guest_email = f"guest_{uuid.uuid4()}@guest.mitlist.app"
guest_password = uuid.uuid4().hex
user_in = UserCreate(email=guest_email, password=guest_password)
user = await crud_user.create_user(db, user_in=user_in, is_guest=True)
access_token = create_access_token(data={"sub": user.email})
return {"access_token": access_token, "token_type": "bearer"}
@router.post("/guest/claim", response_model=UserPublic)
async def claim_guest_account(
claim_in: UserClaim,
db: AsyncSession = Depends(get_session),
current_user: models.User = Depends(current_active_user),
):
"""
Claims a guest account, converting it to a full user.
"""
if not current_user.is_guest:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Not a guest account.")
existing_user = await crud_user.get_user_by_email(db, email=claim_in.email)
if existing_user:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered.")
hashed_password = get_password_hash(claim_in.password)
current_user.email = claim_in.email
current_user.hashed_password = hashed_password
current_user.is_guest = False
current_user.is_verified = False # Require email verification
db.add(current_user)
await db.commit()
await db.refresh(current_user)
return current_user

26
be/app/api/auth/jwt.py Normal file
View File

@ -0,0 +1,26 @@
from fastapi import APIRouter
from app.auth import auth_backend, fastapi_users
from app.schemas.user import UserCreate, UserPublic, UserUpdate
router = APIRouter()
router.include_router(
fastapi_users.get_auth_router(auth_backend),
prefix="/jwt",
tags=["auth"],
)
router.include_router(
fastapi_users.get_register_router(UserPublic, UserCreate),
prefix="",
tags=["auth"],
)
router.include_router(
fastapi_users.get_reset_password_router(),
prefix="",
tags=["auth"],
)
router.include_router(
fastapi_users.get_verify_router(UserPublic),
prefix="",
tags=["auth"],
)

View File

@ -9,7 +9,10 @@ from app.api.v1.endpoints import ocr
from app.api.v1.endpoints import costs
from app.api.v1.endpoints import financials
from app.api.v1.endpoints import chores
from app.api.auth import oauth
from app.api.v1.endpoints import history
from app.api.v1.endpoints import categories
from app.api.v1.endpoints import users
from app.api.auth import oauth, guest, jwt
api_router_v1 = APIRouter()
@ -22,4 +25,9 @@ api_router_v1.include_router(ocr.router, prefix="/ocr", tags=["OCR"])
api_router_v1.include_router(costs.router, prefix="/costs", tags=["Costs"])
api_router_v1.include_router(financials.router, prefix="/financials", tags=["Financials"])
api_router_v1.include_router(chores.router, prefix="/chores", tags=["Chores"])
api_router_v1.include_router(history.router, prefix="/history", tags=["History"])
api_router_v1.include_router(categories.router, prefix="/categories", tags=["Categories"])
api_router_v1.include_router(oauth.router, prefix="/auth", tags=["Auth"])
api_router_v1.include_router(guest.router, prefix="/auth", tags=["Auth"])
api_router_v1.include_router(jwt.router, prefix="/auth", tags=["Auth"])
api_router_v1.include_router(users.router, prefix="/users", tags=["Users"])

View File

@ -0,0 +1,75 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List, Optional
from app import models
from app.schemas.category import CategoryCreate, CategoryUpdate, CategoryPublic
from app.database import get_session
from app.auth import current_active_user
from app.crud import category as crud_category, group as crud_group
router = APIRouter()
@router.post("/", response_model=CategoryPublic)
async def create_category(
category_in: CategoryCreate,
group_id: Optional[int] = None,
db: AsyncSession = Depends(get_session),
current_user: models.User = Depends(current_active_user),
):
if group_id:
is_member = await crud_group.is_user_member(db, user_id=current_user.id, group_id=group_id)
if not is_member:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member of this group")
return await crud_category.create_category(db=db, category_in=category_in, user_id=current_user.id, group_id=group_id)
@router.get("/", response_model=List[CategoryPublic])
async def read_categories(
group_id: Optional[int] = None,
db: AsyncSession = Depends(get_session),
current_user: models.User = Depends(current_active_user),
):
if group_id:
is_member = await crud_group.is_user_member(db, user_id=current_user.id, group_id=group_id)
if not is_member:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member of this group")
return await crud_category.get_group_categories(db=db, group_id=group_id)
return await crud_category.get_user_categories(db=db, user_id=current_user.id)
@router.put("/{category_id}", response_model=CategoryPublic)
async def update_category(
category_id: int,
category_in: CategoryUpdate,
db: AsyncSession = Depends(get_session),
current_user: models.User = Depends(current_active_user),
):
db_category = await crud_category.get_category(db, category_id=category_id)
if not db_category:
raise HTTPException(status_code=404, detail="Category not found")
if db_category.user_id != current_user.id:
if not db_category.group_id:
raise HTTPException(status_code=403, detail="Not your category")
is_member = await crud_group.is_user_member(db, user_id=current_user.id, group_id=db_category.group_id)
if not is_member:
raise HTTPException(status_code=403, detail="Not a member of this group")
return await crud_category.update_category(db=db, db_category=db_category, category_in=category_in)
@router.delete("/{category_id}", response_model=CategoryPublic)
async def delete_category(
category_id: int,
db: AsyncSession = Depends(get_session),
current_user: models.User = Depends(current_active_user),
):
db_category = await crud_category.get_category(db, category_id=category_id)
if not db_category:
raise HTTPException(status_code=404, detail="Category not found")
if db_category.user_id != current_user.id:
if not db_category.group_id:
raise HTTPException(status_code=403, detail="Not your category")
is_member = await crud_group.is_user_member(db, user_id=current_user.id, group_id=db_category.group_id)
if not is_member:
raise HTTPException(status_code=403, detail="Not a member of this group")
return await crud_category.delete_category(db=db, db_category=db_category)

View File

@ -1,18 +1,21 @@
# app/api/v1/endpoints/chores.py
import logging
from typing import List as PyList, Optional
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status, Response
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_transactional_session, get_session
from app.auth import current_active_user
from app.models import User as UserModel, Chore as ChoreModel, ChoreTypeEnum
from app.models import User as UserModel, Chore as ChoreModel, ChoreTypeEnum, TimeEntry
from app.schemas.chore import (
ChoreCreate, ChoreUpdate, ChorePublic,
ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreAssignmentPublic,
ChoreHistoryPublic, ChoreAssignmentHistoryPublic
)
from app.schemas.time_entry import TimeEntryPublic
from app.crud import chore as crud_chore
from app.crud import history as crud_history
from app.core.exceptions import ChoreNotFoundError, PermissionDeniedError, GroupNotFoundError, DatabaseIntegrityError
@ -506,4 +509,123 @@ async def get_chore_assignment_history(
raise PermissionDeniedError("You must be a member of the group to view this assignment's history.")
logger.info(f"User {current_user.email} getting history for assignment {assignment_id}")
return await crud_history.get_assignment_history(db=db, assignment_id=assignment_id)
return await crud_history.get_assignment_history(db=db, assignment_id=assignment_id)
# === TIME ENTRY ENDPOINTS ===
@router.get(
"/assignments/{assignment_id}/time-entries",
response_model=PyList[TimeEntryPublic],
summary="Get Time Entries",
tags=["Time Tracking"]
)
async def get_time_entries_for_assignment(
assignment_id: int,
db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves all time entries for a specific chore assignment."""
assignment = await crud_chore.get_chore_assignment_by_id(db, assignment_id)
if not assignment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Assignment not found")
chore = await crud_chore.get_chore_by_id(db, assignment.chore_id)
if not chore:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chore not found")
# Permission check
if chore.type == ChoreTypeEnum.personal and chore.created_by_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied")
if chore.type == ChoreTypeEnum.group:
is_member = await crud_chore.is_user_member(db, chore.group_id, current_user.id)
if not is_member:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied")
# For now, return time entries for the current user only
time_entries = await db.execute(
select(TimeEntry)
.where(TimeEntry.chore_assignment_id == assignment_id)
.where(TimeEntry.user_id == current_user.id)
.order_by(TimeEntry.start_time.desc())
)
return time_entries.scalars().all()
@router.post(
"/assignments/{assignment_id}/time-entries",
response_model=TimeEntryPublic,
status_code=status.HTTP_201_CREATED,
summary="Start Time Entry",
tags=["Time Tracking"]
)
async def start_time_entry(
assignment_id: int,
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""Starts a new time entry for a chore assignment."""
assignment = await crud_chore.get_chore_assignment_by_id(db, assignment_id)
if not assignment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Assignment not found")
chore = await crud_chore.get_chore_by_id(db, assignment.chore_id)
if not chore:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chore not found")
# Permission check - only assigned user can track time
if assignment.assigned_to_user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only assigned user can track time")
# Check if there's already an active time entry
existing_active = await db.execute(
select(TimeEntry)
.where(TimeEntry.chore_assignment_id == assignment_id)
.where(TimeEntry.user_id == current_user.id)
.where(TimeEntry.end_time.is_(None))
)
if existing_active.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Time entry already active")
# Create new time entry
time_entry = TimeEntry(
chore_assignment_id=assignment_id,
user_id=current_user.id,
start_time=datetime.now(timezone.utc)
)
db.add(time_entry)
await db.commit()
await db.refresh(time_entry)
return time_entry
@router.put(
"/time-entries/{time_entry_id}",
response_model=TimeEntryPublic,
summary="Stop Time Entry",
tags=["Time Tracking"]
)
async def stop_time_entry(
time_entry_id: int,
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""Stops an active time entry."""
time_entry = await db.get(TimeEntry, time_entry_id)
if not time_entry:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Time entry not found")
if time_entry.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied")
if time_entry.end_time:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Time entry already stopped")
# Stop the time entry
end_time = datetime.now(timezone.utc)
time_entry.end_time = end_time
time_entry.duration_seconds = int((end_time - time_entry.start_time).total_seconds())
await db.commit()
await db.refresh(time_entry)
return time_entry

View File

@ -0,0 +1,46 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List
from app import models
from app.schemas.audit import FinancialAuditLogPublic
from app.database import get_session
from app.auth import current_active_user
from app.crud import audit as crud_audit, group as crud_group
router = APIRouter()
@router.get("/financial/group/{group_id}", response_model=List[FinancialAuditLogPublic])
async def read_financial_history_for_group(
group_id: int,
db: AsyncSession = Depends(get_session),
current_user: models.User = Depends(current_active_user),
skip: int = 0,
limit: int = 100,
):
"""
Retrieve financial audit history for a specific group.
"""
is_member = await crud_group.is_user_member(db, group_id=group_id, user_id=current_user.id)
if not is_member:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member of this group")
history = await crud_audit.get_financial_audit_logs_for_group(
db=db, group_id=group_id, skip=skip, limit=limit
)
return history
@router.get("/financial/user/me", response_model=List[FinancialAuditLogPublic])
async def read_financial_history_for_user(
db: AsyncSession = Depends(get_session),
current_user: models.User = Depends(current_active_user),
skip: int = 0,
limit: int = 100,
):
"""
Retrieve financial audit history for the current user.
"""
history = await crud_audit.get_financial_audit_logs_for_user(
db=db, user_id=current_user.id, skip=skip, limit=limit
)
return history

View File

@ -94,6 +94,24 @@ async def read_lists(
return lists
@router.get(
"/archived",
response_model=PyList[ListDetail],
summary="List Archived Lists",
tags=["Lists"]
)
async def read_archived_lists(
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""
Retrieves archived lists for the current user.
"""
logger.info(f"Fetching archived lists for user: {current_user.email}")
lists = await crud_list.get_lists_for_user(db=db, user_id=current_user.id, include_archived=True)
return [l for l in lists if l.archived_at]
@router.get(
"/statuses",
response_model=PyList[ListStatusWithId],
@ -185,29 +203,29 @@ async def update_list(
@router.delete(
"/{list_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete List",
summary="Archive List",
tags=["Lists"],
responses={
status.HTTP_409_CONFLICT: {"description": "Conflict: List has been modified, cannot delete specified version"}
status.HTTP_409_CONFLICT: {"description": "Conflict: List has been modified, cannot archive specified version"}
}
)
async def delete_list(
async def archive_list_endpoint(
list_id: int,
expected_version: Optional[int] = Query(None, description="The expected version of the list to delete for optimistic locking."),
expected_version: Optional[int] = Query(None, description="The expected version of the list to archive for optimistic locking."),
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""
Deletes a list. Requires user to be the creator of the list.
Archives a list. Requires user to be the creator of the list.
If `expected_version` is provided and does not match the list's current version,
a 409 Conflict is returned.
"""
logger.info(f"User {current_user.email} attempting to delete list ID: {list_id}, expected version: {expected_version}")
logger.info(f"User {current_user.email} attempting to archive list ID: {list_id}, expected version: {expected_version}")
list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id, require_creator=True)
if expected_version is not None and list_db.version != expected_version:
logger.warning(
f"Conflict deleting list {list_id} for user {current_user.email}. "
f"Conflict archiving list {list_id} for user {current_user.email}. "
f"Expected version {expected_version}, actual version {list_db.version}."
)
raise HTTPException(
@ -215,11 +233,37 @@ async def delete_list(
detail=f"List has been modified. Expected version {expected_version}, but current version is {list_db.version}. Please refresh."
)
await crud_list.delete_list(db=db, list_db=list_db)
logger.info(f"List {list_id} (version: {list_db.version}) deleted successfully by user {current_user.email}.")
await crud_list.archive_list(db=db, list_db=list_db)
logger.info(f"List {list_id} (version: {list_db.version}) archived successfully by user {current_user.email}.")
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post(
"/{list_id}/unarchive",
response_model=ListPublic,
summary="Unarchive List",
tags=["Lists"]
)
async def unarchive_list_endpoint(
list_id: int,
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""
Restores an archived list.
"""
logger.info(f"User {current_user.email} attempting to unarchive list ID: {list_id}")
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.archived_at:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="List is not archived.")
updated_list = await crud_list.unarchive_list(db=db, list_db=list_db)
logger.info(f"List {list_id} unarchived successfully by user {current_user.email}.")
return updated_list
@router.get(
"/{list_id}/status",
response_model=ListStatus,

View File

@ -0,0 +1,11 @@
from fastapi import APIRouter
from app.auth import fastapi_users
from app.schemas.user import UserPublic, UserUpdate
router = APIRouter()
router.include_router(
fastapi_users.get_users_router(UserPublic, UserUpdate),
prefix="",
tags=["Users"],
)

View File

@ -116,7 +116,7 @@ async def get_user_db(session: AsyncSession = Depends(get_session)):
async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)):
yield UserManager(user_db)
bearer_transport = BearerTransportWithRefresh(tokenUrl="auth/jwt/login")
bearer_transport = BearerTransportWithRefresh(tokenUrl="/api/v1/auth/jwt/login")
def get_jwt_strategy() -> JWTStrategy:
return JWTStrategy(secret=settings.SECRET_KEY, lifetime_seconds=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60)

78
be/app/core/cache.py Normal file
View File

@ -0,0 +1,78 @@
import json
import hashlib
from functools import wraps
from typing import Any, Callable, Optional
from app.core.redis import get_redis
import pickle
def generate_cache_key(func_name: str, args: tuple, kwargs: dict) -> str:
"""Generate a unique cache key based on function name and arguments."""
# Create a string representation of args and kwargs
key_data = {
'function': func_name,
'args': str(args),
'kwargs': str(sorted(kwargs.items()))
}
key_string = json.dumps(key_data, sort_keys=True)
# Use SHA256 hash for consistent, shorter keys
return f"cache:{hashlib.sha256(key_string.encode()).hexdigest()}"
def cache(expire_time: int = 3600, key_prefix: Optional[str] = None):
"""
Decorator to cache function results in Redis.
Args:
expire_time: Expiration time in seconds (default: 1 hour)
key_prefix: Optional prefix for cache keys
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
async def wrapper(*args, **kwargs) -> Any:
redis_client = await get_redis()
# Generate cache key
cache_key = generate_cache_key(func.__name__, args, kwargs)
if key_prefix:
cache_key = f"{key_prefix}:{cache_key}"
try:
# Try to get from cache
cached_result = await redis_client.get(cache_key)
if cached_result:
# Deserialize and return cached result
return pickle.loads(cached_result)
# Cache miss - execute function
result = await func(*args, **kwargs)
# Store result in cache
serialized_result = pickle.dumps(result)
await redis_client.setex(cache_key, expire_time, serialized_result)
return result
except Exception as e:
# If caching fails, still execute the function
print(f"Cache error: {e}")
return await func(*args, **kwargs)
return wrapper
return decorator
async def invalidate_cache_pattern(pattern: str):
"""Invalidate all cache keys matching a pattern."""
redis_client = await get_redis()
try:
keys = await redis_client.keys(pattern)
if keys:
await redis_client.delete(*keys)
except Exception as e:
print(f"Cache invalidation error: {e}")
async def clear_all_cache():
"""Clear all cache entries."""
redis_client = await get_redis()
try:
await redis_client.flushdb()
except Exception as e:
print(f"Cache clear error: {e}")

7
be/app/core/redis.py Normal file
View File

@ -0,0 +1,7 @@
import redis.asyncio as redis
from app.config import settings
redis_pool = redis.from_url(settings.REDIS_URL, encoding="utf-8", decode_responses=True)
async def get_redis():
return redis_pool

View File

@ -1,4 +1,7 @@
from passlib.context import CryptContext
from datetime import datetime, timedelta
from jose import jwt
from typing import Optional
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
@ -32,4 +35,40 @@ def hash_password(password: str) -> str:
Returns:
The resulting hash string.
"""
return pwd_context.hash(password)
return pwd_context.hash(password)
# Alias for compatibility with guest.py
def get_password_hash(password: str) -> str:
"""
Alias for hash_password function for backward compatibility.
Args:
password: The plain text password to hash.
Returns:
The resulting hash string.
"""
return hash_password(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""
Create a JWT access token.
Args:
data: The data to encode in the token (typically {"sub": email}).
expires_delta: Optional custom expiration time.
Returns:
The encoded JWT token.
"""
from app.config import settings
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm="HS256")
return encoded_jwt

77
be/app/crud/audit.py Normal file
View File

@ -0,0 +1,77 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy import union_all, or_
from typing import List, Optional
from app.models import FinancialAuditLog, Base, User, Group, Expense, Settlement
from app.schemas.audit import FinancialAuditLogCreate
async def create_financial_audit_log(
db: AsyncSession,
*,
user_id: int | None,
action_type: str,
entity: Base,
details: dict | None = None
) -> FinancialAuditLog:
log_entry_data = FinancialAuditLogCreate(
user_id=user_id,
action_type=action_type,
entity_type=entity.__class__.__name__,
entity_id=entity.id,
details=details
)
log_entry = FinancialAuditLog(**log_entry_data.dict())
db.add(log_entry)
await db.commit()
await db.refresh(log_entry)
return log_entry
async def get_financial_audit_logs_for_group(db: AsyncSession, *, group_id: int, skip: int = 0, limit: int = 100) -> List[FinancialAuditLog]:
"""
Get financial audit logs for all entities that belong to a specific group.
This includes Expenses and Settlements that are linked to the group.
"""
# Get all expense IDs for this group
expense_ids_query = select(Expense.id).where(Expense.group_id == group_id)
expense_result = await db.execute(expense_ids_query)
expense_ids = [row[0] for row in expense_result.fetchall()]
# Get all settlement IDs for this group
settlement_ids_query = select(Settlement.id).where(Settlement.group_id == group_id)
settlement_result = await db.execute(settlement_ids_query)
settlement_ids = [row[0] for row in settlement_result.fetchall()]
# Build conditions for the audit log query
conditions = []
if expense_ids:
conditions.append(
(FinancialAuditLog.entity_type == 'Expense') &
(FinancialAuditLog.entity_id.in_(expense_ids))
)
if settlement_ids:
conditions.append(
(FinancialAuditLog.entity_type == 'Settlement') &
(FinancialAuditLog.entity_id.in_(settlement_ids))
)
# If no entities exist for this group, return empty list
if not conditions:
return []
# Query audit logs for all relevant entities
query = select(FinancialAuditLog).where(
or_(*conditions)
).order_by(FinancialAuditLog.timestamp.desc()).offset(skip).limit(limit)
result = await db.execute(query)
return result.scalars().all()
async def get_financial_audit_logs_for_user(db: AsyncSession, *, user_id: int, skip: int = 0, limit: int = 100) -> List[FinancialAuditLog]:
result = await db.execute(
select(FinancialAuditLog)
.where(FinancialAuditLog.user_id == user_id)
.order_by(FinancialAuditLog.timestamp.desc())
.offset(skip).limit(limit)
)
return result.scalars().all()

38
be/app/crud/category.py Normal file
View File

@ -0,0 +1,38 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from typing import List, Optional
from app.models import Category
from app.schemas.category import CategoryCreate, CategoryUpdate
async def create_category(db: AsyncSession, category_in: CategoryCreate, user_id: int, group_id: Optional[int] = None) -> Category:
db_category = Category(**category_in.dict(), user_id=user_id, group_id=group_id)
db.add(db_category)
await db.commit()
await db.refresh(db_category)
return db_category
async def get_user_categories(db: AsyncSession, user_id: int) -> List[Category]:
result = await db.execute(select(Category).where(Category.user_id == user_id))
return result.scalars().all()
async def get_group_categories(db: AsyncSession, group_id: int) -> List[Category]:
result = await db.execute(select(Category).where(Category.group_id == group_id))
return result.scalars().all()
async def get_category(db: AsyncSession, category_id: int) -> Optional[Category]:
return await db.get(Category, category_id)
async def update_category(db: AsyncSession, db_category: Category, category_in: CategoryUpdate) -> Category:
update_data = category_in.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(db_category, key, value)
db.add(db_category)
await db.commit()
await db.refresh(db_category)
return db_category
async def delete_category(db: AsyncSession, db_category: Category):
await db.delete(db_category)
await db.commit()
return db_category

View File

@ -39,7 +39,8 @@ async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]:
selectinload(Chore.creator),
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
selectinload(Chore.history)
selectinload(Chore.history),
selectinload(Chore.child_chores)
)
.order_by(Chore.next_due_date, Chore.name)
)
@ -57,7 +58,8 @@ async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]:
selectinload(Chore.group),
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
selectinload(Chore.history)
selectinload(Chore.history),
selectinload(Chore.child_chores)
)
.order_by(Chore.next_due_date, Chore.name)
)
@ -85,8 +87,14 @@ async def create_chore(
if group_id:
raise ValueError("group_id must be None for personal chores")
chore_data = chore_in.model_dump(exclude_unset=True, exclude={'group_id'})
if 'parent_chore_id' in chore_data and chore_data['parent_chore_id']:
parent_chore = await get_chore_by_id(db, chore_data['parent_chore_id'])
if not parent_chore:
raise ChoreNotFoundError(chore_data['parent_chore_id'])
db_chore = Chore(
**chore_in.model_dump(exclude_unset=True, exclude={'group_id'}),
**chore_data,
group_id=group_id,
created_by_id=user_id,
)
@ -115,7 +123,8 @@ async def create_chore(
selectinload(Chore.group),
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
selectinload(Chore.history)
selectinload(Chore.history),
selectinload(Chore.child_chores)
)
)
return result.scalar_one()
@ -133,7 +142,8 @@ async def get_chore_by_id(db: AsyncSession, chore_id: int) -> Optional[Chore]:
selectinload(Chore.group),
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
selectinload(Chore.history)
selectinload(Chore.history),
selectinload(Chore.child_chores)
)
)
return result.scalar_one_or_none()
@ -168,7 +178,8 @@ async def get_personal_chores(
selectinload(Chore.creator),
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
selectinload(Chore.history)
selectinload(Chore.history),
selectinload(Chore.child_chores)
)
.order_by(Chore.next_due_date, Chore.name)
)
@ -193,7 +204,8 @@ async def get_chores_by_group_id(
selectinload(Chore.creator),
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
selectinload(Chore.history)
selectinload(Chore.history),
selectinload(Chore.child_chores)
)
.order_by(Chore.next_due_date, Chore.name)
)
@ -228,6 +240,14 @@ async def update_chore(
raise PermissionDeniedError(detail="Only the creator can update personal chores")
update_data = chore_in.model_dump(exclude_unset=True)
if 'parent_chore_id' in update_data:
if update_data['parent_chore_id']:
parent_chore = await get_chore_by_id(db, update_data['parent_chore_id'])
if not parent_chore:
raise ChoreNotFoundError(update_data['parent_chore_id'])
# Setting parent_chore_id to None is allowed
setattr(db_chore, 'parent_chore_id', update_data['parent_chore_id'])
if 'type' in update_data:
new_type = update_data['type']
@ -289,7 +309,8 @@ async def update_chore(
selectinload(Chore.group),
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
selectinload(Chore.history)
selectinload(Chore.history),
selectinload(Chore.child_chores)
)
)
return result.scalar_one()
@ -379,6 +400,7 @@ async def create_chore_assignment(
.where(ChoreAssignment.id == db_assignment.id)
.options(
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
selectinload(ChoreAssignment.assigned_user),
selectinload(ChoreAssignment.history)
)
@ -395,6 +417,7 @@ async def get_chore_assignment_by_id(db: AsyncSession, assignment_id: int) -> Op
.where(ChoreAssignment.id == assignment_id)
.options(
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
selectinload(ChoreAssignment.assigned_user),
selectinload(ChoreAssignment.history)
)
@ -414,6 +437,7 @@ async def get_user_assignments(
query = query.options(
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
selectinload(ChoreAssignment.assigned_user),
selectinload(ChoreAssignment.history)
).order_by(ChoreAssignment.due_date, ChoreAssignment.id)
@ -443,6 +467,7 @@ async def get_chore_assignments(
.where(ChoreAssignment.chore_id == chore_id)
.options(
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
selectinload(ChoreAssignment.assigned_user),
selectinload(ChoreAssignment.history)
)
@ -456,75 +481,72 @@ async def update_chore_assignment(
assignment_in: ChoreAssignmentUpdate,
user_id: int
) -> Optional[ChoreAssignment]:
"""Updates a chore assignment. Only the assignee can mark it complete."""
"""Updates a chore assignment, e.g., to mark it as complete."""
async with db.begin_nested() if db.in_transaction() else db.begin():
db_assignment = await get_chore_assignment_by_id(db, assignment_id)
if not db_assignment:
raise ChoreNotFoundError(assignment_id=assignment_id)
chore = await get_chore_by_id(db, db_assignment.chore_id)
if not chore:
raise ChoreNotFoundError(chore_id=db_assignment.chore_id)
return None
can_manage = False
if chore.type == ChoreTypeEnum.personal:
can_manage = chore.created_by_id == user_id
else:
can_manage = await is_user_member(db, chore.group_id, user_id)
# Permission Check: only assigned user or group owner can update
is_allowed = db_assignment.assigned_to_user_id == user_id
if not is_allowed and db_assignment.chore.group_id:
user_role = await get_user_role_in_group(db, db_assignment.chore.group_id, user_id)
is_allowed = user_role == UserRoleEnum.owner
can_complete = db_assignment.assigned_to_user_id == user_id
if not is_allowed:
raise PermissionDeniedError("You cannot update this chore assignment.")
original_status = db_assignment.is_complete
update_data = assignment_in.model_dump(exclude_unset=True)
original_assignee = db_assignment.assigned_to_user_id
original_due_date = db_assignment.due_date
if 'is_complete' in update_data and not can_complete:
raise PermissionDeniedError(detail="Only the assignee can mark assignments as complete")
if 'due_date' in update_data and update_data['due_date'] != original_due_date:
if not can_manage:
raise PermissionDeniedError(detail="Only chore managers can reschedule assignments")
await create_assignment_history_entry(db, assignment_id=assignment_id, changed_by_user_id=user_id, event_type=ChoreHistoryEventTypeEnum.DUE_DATE_CHANGED, event_data={"old": original_due_date.isoformat(), "new": update_data['due_date'].isoformat()})
if 'assigned_to_user_id' in update_data and update_data['assigned_to_user_id'] != original_assignee:
if not can_manage:
raise PermissionDeniedError(detail="Only chore managers can reassign assignments")
await create_assignment_history_entry(db, assignment_id=assignment_id, changed_by_user_id=user_id, event_type=ChoreHistoryEventTypeEnum.REASSIGNED, event_data={"old": original_assignee, "new": update_data['assigned_to_user_id']})
if 'is_complete' in update_data:
if update_data['is_complete'] and not db_assignment.is_complete:
update_data['completed_at'] = datetime.utcnow()
chore.last_completed_at = update_data['completed_at']
chore.next_due_date = calculate_next_due_date(
current_due_date=chore.next_due_date,
frequency=chore.frequency,
custom_interval_days=chore.custom_interval_days,
last_completed_date=chore.last_completed_at
)
await create_assignment_history_entry(db, assignment_id=assignment_id, changed_by_user_id=user_id, event_type=ChoreHistoryEventTypeEnum.COMPLETED)
elif not update_data['is_complete'] and db_assignment.is_complete:
update_data['completed_at'] = None
await create_assignment_history_entry(db, assignment_id=assignment_id, changed_by_user_id=user_id, event_type=ChoreHistoryEventTypeEnum.REOPENED)
for field, value in update_data.items():
setattr(db_assignment, field, value)
if 'is_complete' in update_data:
new_status = update_data['is_complete']
history_event = None
if new_status and not original_status:
db_assignment.completed_at = datetime.utcnow()
history_event = ChoreHistoryEventTypeEnum.COMPLETED
# Advance the next_due_date of the parent chore
if db_assignment.chore:
db_assignment.chore.last_completed_at = db_assignment.completed_at
db_assignment.chore.next_due_date = calculate_next_due_date(
db_assignment.chore.frequency,
db_assignment.chore.last_completed_at.date() if db_assignment.chore.last_completed_at else date.today(),
db_assignment.chore.custom_interval_days
)
elif not new_status and original_status:
db_assignment.completed_at = None
history_event = ChoreHistoryEventTypeEnum.REOPENED
# Policy: Do not automatically roll back parent chore's due date.
if history_event:
await create_assignment_history_entry(
db=db,
assignment_id=assignment_id,
changed_by_user_id=user_id,
event_type=history_event,
event_data={"new_status": new_status}
)
await db.flush()
try:
await db.flush()
result = await db.execute(
select(ChoreAssignment)
.where(ChoreAssignment.id == db_assignment.id)
.where(ChoreAssignment.id == assignment_id)
.options(
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
selectinload(ChoreAssignment.assigned_user),
selectinload(ChoreAssignment.history)
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
selectinload(ChoreAssignment.assigned_user)
)
)
return result.scalar_one()
except Exception as e:
logger.error(f"Error updating chore assignment {assignment_id}: {e}", exc_info=True)
raise DatabaseIntegrityError(f"Could not update chore assignment {assignment_id}. Error: {str(e)}")
logger.error(f"Error updating assignment: {e}", exc_info=True)
await db.rollback()
raise DatabaseIntegrityError(f"Could not update assignment. Error: {str(e)}")
async def delete_chore_assignment(
db: AsyncSession,

View File

@ -7,6 +7,7 @@ from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError # A
from decimal import Decimal, ROUND_HALF_UP, InvalidOperation as DecimalInvalidOperation
from typing import Callable, List as PyList, Optional, Sequence, Dict, defaultdict, Any
from datetime import datetime, timezone # Added timezone
import json
from app.models import (
Expense as ExpenseModel,
@ -34,6 +35,7 @@ from app.core.exceptions import (
ExpenseOperationError # Added specific exception
)
from app.models import RecurrencePattern
from app.crud.audit import create_financial_audit_log
# Placeholder for InvalidOperationError if not defined in app.core.exceptions
# This should be a proper HTTPException subclass if used in API layer
@ -215,6 +217,13 @@ async def create_expense(db: AsyncSession, expense_in: ExpenseCreate, current_us
# await transaction.rollback() # Should be handled by context manager
raise ExpenseOperationError("Failed to load expense after creation.")
await create_financial_audit_log(
db=db,
user_id=current_user_id,
action_type="EXPENSE_CREATED",
entity=loaded_expense,
)
# await transaction.commit() # Explicit commit removed, context manager handles it.
return loaded_expense
@ -611,22 +620,28 @@ async def get_user_accessible_expenses(db: AsyncSession, user_id: int, skip: int
)
return result.scalars().all()
async def update_expense(db: AsyncSession, expense_db: ExpenseModel, expense_in: ExpenseUpdate) -> ExpenseModel:
async def update_expense(db: AsyncSession, expense_db: ExpenseModel, expense_in: ExpenseUpdate, current_user_id: int) -> ExpenseModel:
"""
Updates an existing expense.
Only allows updates to description, currency, and expense_date to avoid split complexities.
Requires version matching for optimistic locking.
Updates an expense. For now, only allows simple field updates.
More complex updates (like changing split logic) would require a more sophisticated approach.
"""
if expense_in.version is None:
raise InvalidOperationError("Version is required for updating an expense.")
if expense_db.version != expense_in.version:
raise InvalidOperationError(
f"Expense '{expense_db.description}' (ID: {expense_db.id}) has been modified. "
f"Your version is {expense_in.version}, current version is {expense_db.version}. Please refresh.",
# status_code=status.HTTP_409_CONFLICT # This would be for the API layer to set
f"Expense '{expense_db.description}' (ID: {expense_db.id}) cannot be updated. "
f"Your expected version {expense_in.version} does not match current version {expense_db.version}. Please refresh.",
)
update_data = expense_in.model_dump(exclude_unset=True, exclude={"version"}) # Exclude version itself from data
before_state = {c.name: getattr(expense_db, c.name) for c in expense_db.__table__.columns if c.name in expense_in.dict(exclude_unset=True)}
# A simple way to handle non-serializable types for JSON
for k, v in before_state.items():
if isinstance(v, (datetime, Decimal)):
before_state[k] = str(v)
update_data = expense_in.dict(exclude_unset=True, exclude={"version"})
# Fields that are safe to update without affecting splits or core logic
allowed_to_update = {"description", "currency", "expense_date"}
updated_something = False
@ -635,38 +650,41 @@ async def update_expense(db: AsyncSession, expense_db: ExpenseModel, expense_in:
setattr(expense_db, field, value)
updated_something = True
else:
# If any other field is present in the update payload, it's an invalid operation for this simple update
raise InvalidOperationError(f"Field '{field}' cannot be updated. Only {', '.join(allowed_to_update)} are allowed.")
if not updated_something and not expense_in.model_fields_set.intersection(allowed_to_update):
# No actual updatable fields were provided in the payload, even if others (like version) were.
# This could be a non-issue, or an indication of a misuse of the endpoint.
# For now, if only version was sent, we still increment if it matched.
pass # Or raise InvalidOperationError("No updatable fields provided.")
if not updated_something:
pass
try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
async with db.begin_nested() if db.in_transaction() else db.begin():
expense_db.version += 1
expense_db.updated_at = datetime.now(timezone.utc) # Manually update timestamp
# db.add(expense_db) # Not strictly necessary as expense_db is already tracked by the session
expense_db.updated_at = datetime.now(timezone.utc)
await db.flush() # Persist changes to the DB and run constraints
await db.refresh(expense_db) # Refresh the object from the DB
return expense_db
except InvalidOperationError: # Re-raise validation errors to be handled by the caller
raise
await db.flush()
after_state = {c.name: getattr(expense_db, c.name) for c in expense_db.__table__.columns if c.name in update_data}
for k, v in after_state.items():
if isinstance(v, (datetime, Decimal)):
after_state[k] = str(v)
await create_financial_audit_log(
db=db,
user_id=current_user_id,
action_type="EXPENSE_UPDATED",
entity=expense_db,
details={"before": before_state, "after": after_state}
)
await db.refresh(expense_db)
return expense_db
except IntegrityError as e:
logger.error(f"Database integrity error during expense update for ID {expense_db.id}: {str(e)}", exc_info=True)
# The transaction context manager (begin_nested/begin) handles rollback.
raise DatabaseIntegrityError(f"Failed to update expense ID {expense_db.id} due to database integrity issue.") from e
except SQLAlchemyError as e: # Catch other SQLAlchemy errors
except SQLAlchemyError as e:
logger.error(f"Database transaction error during expense update for ID {expense_db.id}: {str(e)}", exc_info=True)
# The transaction context manager (begin_nested/begin) handles rollback.
raise DatabaseTransactionError(f"Failed to update expense ID {expense_db.id} due to a database transaction error.") from e
# No generic Exception catch here, let other unexpected errors propagate if not SQLAlchemy related.
async def delete_expense(db: AsyncSession, expense_db: ExpenseModel, expected_version: Optional[int] = None) -> None:
async def delete_expense(db: AsyncSession, expense_db: ExpenseModel, current_user_id: int, expected_version: Optional[int] = None) -> None:
"""
Deletes an expense. Requires version matching if expected_version is provided.
Associated ExpenseSplits are cascade deleted by the database foreign key constraint.
@ -675,23 +693,33 @@ async def delete_expense(db: AsyncSession, expense_db: ExpenseModel, expected_ve
raise InvalidOperationError(
f"Expense '{expense_db.description}' (ID: {expense_db.id}) cannot be deleted. "
f"Your expected version {expected_version} does not match current version {expense_db.version}. Please refresh.",
# status_code=status.HTTP_409_CONFLICT
)
try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
async with db.begin_nested() if db.in_transaction() else db.begin():
details = {c.name: getattr(expense_db, c.name) for c in expense_db.__table__.columns}
for k, v in details.items():
if isinstance(v, (datetime, Decimal)):
details[k] = str(v)
expense_id_for_log = expense_db.id
await create_financial_audit_log(
db=db,
user_id=current_user_id,
action_type="EXPENSE_DELETED",
entity=expense_db,
details=details
)
await db.delete(expense_db)
await db.flush() # Ensure the delete operation is sent to the database
except InvalidOperationError: # Re-raise validation errors
raise
await db.flush()
except IntegrityError as e:
logger.error(f"Database integrity error during expense deletion for ID {expense_db.id}: {str(e)}", exc_info=True)
# The transaction context manager (begin_nested/begin) handles rollback.
raise DatabaseIntegrityError(f"Failed to delete expense ID {expense_db.id} due to database integrity issue.") from e
except SQLAlchemyError as e: # Catch other SQLAlchemy errors
logger.error(f"Database transaction error during expense deletion for ID {expense_db.id}: {str(e)}", exc_info=True)
# The transaction context manager (begin_nested/begin) handles rollback.
raise DatabaseTransactionError(f"Failed to delete expense ID {expense_db.id} due to a database transaction error.") from e
logger.error(f"Database integrity error during expense deletion for ID {expense_id_for_log}: {str(e)}", exc_info=True)
raise DatabaseIntegrityError(f"Failed to delete expense ID {expense_id_for_log} due to database integrity issue.") from e
except SQLAlchemyError as e:
logger.error(f"Database transaction error during expense deletion for ID {expense_id_for_log}: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Failed to delete expense ID {expense_id_for_log} due to a database transaction error.") from e
return None
# Note: The InvalidOperationError is a simple ValueError placeholder.

View File

@ -1,13 +1,14 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from sqlalchemy.orm import selectinload, joinedload, contains_eager
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
from typing import Optional, List
from sqlalchemy import delete, func
from typing import Optional, List, Dict, Any, Tuple
from sqlalchemy import delete, func, and_, or_, update, desc
import logging
from datetime import datetime, timezone, timedelta
from app.models import User as UserModel, Group as GroupModel, UserGroup as UserGroupModel
from app.schemas.group import GroupCreate
from app.models import User as UserModel, Group as GroupModel, UserGroup as UserGroupModel, List as ListModel, Chore as ChoreModel, ChoreAssignment as ChoreAssignmentModel
from app.schemas.group import GroupCreate, GroupPublic
from app.models import UserRoleEnum
from app.core.exceptions import (
GroupOperationError,
@ -17,8 +18,10 @@ from app.core.exceptions import (
DatabaseQueryError,
DatabaseTransactionError,
GroupMembershipError,
GroupPermissionError # Import GroupPermissionError
GroupPermissionError,
PermissionDeniedError
)
from app.core.cache import cache
logger = logging.getLogger(__name__) # Initialize logger
@ -88,22 +91,18 @@ async def get_user_groups(db: AsyncSession, user_id: int) -> List[GroupModel]:
except SQLAlchemyError as e:
raise DatabaseQueryError(f"Failed to query user groups: {str(e)}")
@cache(expire_time=1800, key_prefix="group") # Cache for 30 minutes
async def get_group_by_id(db: AsyncSession, group_id: int) -> Optional[GroupModel]:
"""Gets a single group by its ID, optionally loading members."""
try:
result = await db.execute(
select(GroupModel)
.where(GroupModel.id == group_id)
.options(
selectinload(GroupModel.member_associations).selectinload(UserGroupModel.user),
selectinload(GroupModel.chore_history) # Eager load chore history
)
"""Get a group by its ID with caching, including member associations and chore history."""
result = await db.execute(
select(GroupModel)
.where(GroupModel.id == group_id)
.options(
selectinload(GroupModel.member_associations).selectinload(UserGroupModel.user),
selectinload(GroupModel.chore_history)
)
return result.scalars().first()
except OperationalError as e:
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseQueryError(f"Failed to query group: {str(e)}")
)
return result.scalar_one_or_none()
async def is_user_member(db: AsyncSession, group_id: int, user_id: int) -> bool:
"""Checks if a user is a member of a specific group."""

View File

@ -33,6 +33,7 @@ async def create_item(db: AsyncSession, item_in: ItemCreate, list_id: int, user_
db_item = ItemModel(
name=item_in.name,
quantity=item_in.quantity,
category_id=item_in.category_id,
list_id=list_id,
added_by_id=user_id,
is_complete=False,
@ -116,6 +117,9 @@ async def update_item(db: AsyncSession, item_db: ItemModel, item_in: ItemUpdate,
update_data = item_in.model_dump(exclude_unset=True, exclude={'version'})
if 'category_id' in update_data:
item_db.category_id = update_data.pop('category_id')
if 'position' in update_data:
new_position = update_data.pop('position')

View File

@ -5,6 +5,7 @@ from sqlalchemy import or_, and_, delete as sql_delete, func as sql_func, desc
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
from typing import Optional, List as PyList
import logging
from datetime import datetime, timezone
from app.schemas.list import ListStatus
from app.models import List as ListModel, UserGroup as UserGroupModel, Item as ItemModel
@ -62,7 +63,7 @@ async def create_list(db: AsyncSession, list_in: ListCreate, creator_id: int) ->
logger.error(f"Unexpected SQLAlchemy error during list creation: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Failed to create list: {str(e)}")
async def get_lists_for_user(db: AsyncSession, user_id: int) -> PyList[ListModel]:
async def get_lists_for_user(db: AsyncSession, user_id: int, include_archived: bool = False) -> PyList[ListModel]:
"""Gets all lists accessible by a user."""
try:
group_ids_result = await db.execute(
@ -76,19 +77,19 @@ async def get_lists_for_user(db: AsyncSession, user_id: int) -> PyList[ListModel
if user_group_ids:
conditions.append(ListModel.group_id.in_(user_group_ids))
query = (
select(ListModel)
.where(or_(*conditions))
.options(
selectinload(ListModel.creator),
selectinload(ListModel.group),
selectinload(ListModel.items).options(
joinedload(ItemModel.added_by_user),
joinedload(ItemModel.completed_by_user)
)
query = select(ListModel).where(or_(*conditions))
if not include_archived:
query = query.where(ListModel.archived_at.is_(None))
query = query.options(
selectinload(ListModel.creator),
selectinload(ListModel.group),
selectinload(ListModel.items).options(
joinedload(ItemModel.added_by_user),
joinedload(ItemModel.completed_by_user)
)
.order_by(ListModel.updated_at.desc())
)
).order_by(ListModel.updated_at.desc())
result = await db.execute(query)
return result.scalars().all()
@ -169,17 +170,35 @@ async def update_list(db: AsyncSession, list_db: ListModel, list_in: ListUpdate)
logger.error(f"Unexpected SQLAlchemy error during list update: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Failed to update list: {str(e)}")
async def delete_list(db: AsyncSession, list_db: ListModel) -> None:
"""Deletes a list record. Version check should be done by the caller (API endpoint)."""
async def archive_list(db: AsyncSession, list_db: ListModel) -> ListModel:
"""Archives a list record by setting the archived_at timestamp."""
try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
await db.delete(list_db)
list_db.archived_at = datetime.now(timezone.utc)
await db.flush()
await db.refresh(list_db)
return list_db
except OperationalError as e:
logger.error(f"Database connection error while deleting list: {str(e)}", exc_info=True)
raise DatabaseConnectionError(f"Database connection error while deleting list: {str(e)}")
logger.error(f"Database connection error while archiving list: {str(e)}", exc_info=True)
raise DatabaseConnectionError(f"Database connection error while archiving list: {str(e)}")
except SQLAlchemyError as e:
logger.error(f"Unexpected SQLAlchemy error while deleting list: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Failed to delete list: {str(e)}")
logger.error(f"Unexpected SQLAlchemy error while archiving list: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Failed to archive list: {str(e)}")
async def unarchive_list(db: AsyncSession, list_db: ListModel) -> ListModel:
"""Unarchives a list record by setting the archived_at timestamp to None."""
try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
list_db.archived_at = None
await db.flush()
await db.refresh(list_db)
return list_db
except OperationalError as e:
logger.error(f"Database connection error while unarchiving list: {str(e)}", exc_info=True)
raise DatabaseConnectionError(f"Database connection error while unarchiving list: {str(e)}")
except SQLAlchemyError as e:
logger.error(f"Unexpected SQLAlchemy error while unarchiving list: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Failed to unarchive list: {str(e)}")
async def check_list_permission(db: AsyncSession, list_id: int, user_id: int, require_creator: bool = False) -> ListModel:
"""Fetches a list and verifies user permission."""

View File

@ -26,6 +26,7 @@ from app.core.exceptions import (
SettlementOperationError,
ConflictError
)
from app.crud.audit import create_financial_audit_log
logger = logging.getLogger(__name__)
@ -75,6 +76,13 @@ async def create_settlement(db: AsyncSession, settlement_in: SettlementCreate, c
if loaded_settlement is None:
raise SettlementOperationError("Failed to load settlement after creation.")
await create_financial_audit_log(
db=db,
user_id=current_user_id,
action_type="SETTLEMENT_CREATED",
entity=loaded_settlement,
)
return loaded_settlement
except (UserNotFoundError, GroupNotFoundError, InvalidOperationError) as e:
@ -160,7 +168,7 @@ async def get_settlements_involving_user(
raise DatabaseQueryError(f"DB query error fetching user settlements: {str(e)}")
async def update_settlement(db: AsyncSession, settlement_db: SettlementModel, settlement_in: SettlementUpdate) -> SettlementModel:
async def update_settlement(db: AsyncSession, settlement_db: SettlementModel, settlement_in: SettlementUpdate, current_user_id: int) -> SettlementModel:
"""
Updates an existing settlement.
Only allows updates to description and settlement_date.
@ -179,6 +187,11 @@ async def update_settlement(db: AsyncSession, settlement_db: SettlementModel, se
f"Your version {settlement_in.version} does not match current version {settlement_db.version}. Please refresh."
)
before_state = {c.name: getattr(settlement_db, c.name) for c in settlement_db.__table__.columns if c.name in settlement_in.dict(exclude_unset=True)}
for k, v in before_state.items():
if isinstance(v, (datetime, Decimal)):
before_state[k] = str(v)
update_data = settlement_in.model_dump(exclude_unset=True, exclude={"version"})
allowed_to_update = {"description", "settlement_date"}
updated_something = False
@ -210,6 +223,19 @@ async def update_settlement(db: AsyncSession, settlement_db: SettlementModel, se
if updated_settlement is None:
raise SettlementOperationError("Failed to load settlement after update.")
after_state = {c.name: getattr(updated_settlement, c.name) for c in updated_settlement.__table__.columns if c.name in update_data}
for k, v in after_state.items():
if isinstance(v, (datetime, Decimal)):
after_state[k] = str(v)
await create_financial_audit_log(
db=db,
user_id=current_user_id,
action_type="SETTLEMENT_UPDATED",
entity=updated_settlement,
details={"before": before_state, "after": after_state}
)
return updated_settlement
except ConflictError as e:
@ -227,7 +253,7 @@ async def update_settlement(db: AsyncSession, settlement_db: SettlementModel, se
raise DatabaseTransactionError(f"DB transaction error during settlement update: {str(e)}")
async def delete_settlement(db: AsyncSession, settlement_db: SettlementModel, expected_version: Optional[int] = None) -> None:
async def delete_settlement(db: AsyncSession, settlement_db: SettlementModel, current_user_id: int, expected_version: Optional[int] = None) -> None:
"""
Deletes a settlement. Requires version matching if expected_version is provided.
Assumes SettlementModel has a version field.
@ -241,6 +267,19 @@ async def delete_settlement(db: AsyncSession, settlement_db: SettlementModel, ex
f"Expected version {expected_version} does not match current version {settlement_db.version}. Please refresh."
)
details = {c.name: getattr(settlement_db, c.name) for c in settlement_db.__table__.columns}
for k, v in details.items():
if isinstance(v, (datetime, Decimal)):
details[k] = str(v)
await create_financial_audit_log(
db=db,
user_id=current_user_id,
action_type="SETTLEMENT_DELETED",
entity=settlement_db,
details=details
)
await db.delete(settlement_db)
except ConflictError as e:
raise

View File

@ -15,6 +15,7 @@ from app.models import (
ExpenseOverallStatusEnum,
)
from pydantic import BaseModel
from app.crud.audit import create_financial_audit_log
class SettlementActivityCreatePlaceholder(BaseModel):
@ -140,6 +141,13 @@ async def create_settlement_activity(
db.add(db_settlement_activity)
await db.flush()
await create_financial_audit_log(
db=db,
user_id=current_user_id,
action_type="SETTLEMENT_ACTIVITY_CREATED",
entity=db_settlement_activity,
)
# Update statuses
updated_split = await update_expense_split_status(db, expense_split_id=db_settlement_activity.expense_split_id)
if updated_split and updated_split.expense_id:

View File

@ -39,7 +39,7 @@ async def get_user_by_email(db: AsyncSession, email: str) -> Optional[UserModel]
logger.error(f"Unexpected SQLAlchemy error while fetching user by email '{email}': {str(e)}", exc_info=True)
raise DatabaseQueryError(f"Failed to query user: {str(e)}")
async def create_user(db: AsyncSession, user_in: UserCreate) -> UserModel:
async def create_user(db: AsyncSession, user_in: UserCreate, is_guest: bool = False) -> UserModel:
"""Creates a new user record in the database with common relationships loaded."""
try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
@ -47,7 +47,8 @@ async def create_user(db: AsyncSession, user_in: UserCreate) -> UserModel:
db_user = UserModel(
email=user_in.email,
hashed_password=_hashed_password,
name=user_in.name
name=user_in.name,
is_guest=is_guest
)
db.add(db_user)
await db.flush()

View File

@ -57,32 +57,6 @@ app.add_middleware(
expose_headers=["*"]
)
app.include_router(
fastapi_users.get_auth_router(auth_backend),
prefix="/auth/jwt",
tags=["auth"],
)
app.include_router(
fastapi_users.get_register_router(UserPublic, UserCreate),
prefix="/auth",
tags=["auth"],
)
app.include_router(
fastapi_users.get_reset_password_router(),
prefix="/auth",
tags=["auth"],
)
app.include_router(
fastapi_users.get_verify_router(UserPublic),
prefix="/auth",
tags=["auth"],
)
app.include_router(
fastapi_users.get_users_router(UserPublic, UserUpdate),
prefix="/users",
tags=["users"],
)
app.include_router(api_router, prefix=settings.API_PREFIX)
@app.get("/health", tags=["Health"])

View File

@ -93,6 +93,7 @@ class User(Base):
is_active = Column(Boolean, default=True, nullable=False)
is_superuser = Column(Boolean, default=False, nullable=False)
is_verified = Column(Boolean, default=False, nullable=False)
is_guest = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# --- Relationships ---
@ -112,6 +113,9 @@ class User(Base):
assigned_chores = relationship("ChoreAssignment", back_populates="assigned_user", cascade="all, delete-orphan")
chore_history_entries = relationship("ChoreHistory", back_populates="changed_by_user", cascade="all, delete-orphan")
assignment_history_entries = relationship("ChoreAssignmentHistory", back_populates="changed_by_user", cascade="all, delete-orphan")
financial_audit_logs = relationship("FinancialAuditLog", back_populates="user")
time_entries = relationship("TimeEntry", back_populates="user")
categories = relationship("Category", back_populates="user")
class Group(Base):
__tablename__ = "groups"
@ -120,6 +124,8 @@ class Group(Base):
name = Column(String, index=True, nullable=False)
created_by_id = Column(Integer, ForeignKey("users.id"), 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)
version = Column(Integer, nullable=False, default=1, server_default='1')
creator = relationship("User", back_populates="created_groups")
member_associations = relationship("UserGroup", back_populates="group", cascade="all, delete-orphan")
@ -174,6 +180,7 @@ class List(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
version = Column(Integer, nullable=False, default=1, server_default='1')
archived_at = Column(DateTime(timezone=True), nullable=True, index=True)
creator = relationship("User", back_populates="created_lists")
group = relationship("Group", back_populates="lists")
@ -199,6 +206,7 @@ class Item(Base):
is_complete = Column(Boolean, default=False, nullable=False)
price = Column(Numeric(10, 2), nullable=True)
position = Column(Integer, nullable=False, server_default='0')
category_id = Column(Integer, ForeignKey('categories.id'), nullable=True)
added_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
completed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
@ -210,6 +218,7 @@ class Item(Base):
added_by_user = relationship("User", foreign_keys=[added_by_id], back_populates="added_items")
completed_by_user = relationship("User", foreign_keys=[completed_by_id], back_populates="completed_items")
expenses = relationship("Expense", back_populates="item")
category = relationship("Category", back_populates="items")
class Expense(Base):
__tablename__ = "expenses"
@ -248,7 +257,7 @@ class Expense(Base):
last_occurrence = Column(DateTime(timezone=True), nullable=True)
__table_args__ = (
CheckConstraint('(item_id IS NOT NULL) OR (list_id IS NOT NULL) OR (group_id IS NOT NULL)', name='chk_expense_context'),
CheckConstraint('group_id IS NOT NULL OR list_id IS NOT NULL', name='ck_expense_group_or_list'),
)
class ExpenseSplit(Base):
@ -335,6 +344,7 @@ class Chore(Base):
name = Column(String, nullable=False, index=True)
description = Column(Text, nullable=True)
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
parent_chore_id = Column(Integer, ForeignKey('chores.id'), nullable=True, index=True)
frequency = Column(SAEnum(ChoreFrequencyEnum, name="chorefrequencyenum", create_type=True), nullable=False)
custom_interval_days = Column(Integer, nullable=True)
@ -349,6 +359,8 @@ class Chore(Base):
creator = relationship("User", back_populates="created_chores")
assignments = relationship("ChoreAssignment", back_populates="chore", cascade="all, delete-orphan")
history = relationship("ChoreHistory", back_populates="chore", cascade="all, delete-orphan")
parent_chore = relationship("Chore", remote_side=[id], back_populates="child_chores")
child_chores = relationship("Chore", back_populates="parent_chore", cascade="all, delete-orphan")
# --- ChoreAssignment Model ---
@ -369,6 +381,7 @@ class ChoreAssignment(Base):
chore = relationship("Chore", back_populates="assignments")
assigned_user = relationship("User", back_populates="assigned_chores")
history = relationship("ChoreAssignmentHistory", back_populates="assignment", cascade="all, delete-orphan")
time_entries = relationship("TimeEntry", back_populates="assignment", cascade="all, delete-orphan")
# === NEW: RecurrencePattern Model ===
@ -419,3 +432,41 @@ class ChoreAssignmentHistory(Base):
assignment = relationship("ChoreAssignment", back_populates="history")
changed_by_user = relationship("User", back_populates="assignment_history_entries")
# --- New Models from Roadmap ---
class FinancialAuditLog(Base):
__tablename__ = 'financial_audit_log'
id = Column(Integer, primary_key=True, index=True)
timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
user_id = Column(Integer, ForeignKey('users.id'), nullable=True)
action_type = Column(String, nullable=False, index=True)
entity_type = Column(String, nullable=False)
entity_id = Column(Integer, nullable=False)
details = Column(JSONB, nullable=True)
user = relationship("User", back_populates="financial_audit_logs")
class Category(Base):
__tablename__ = 'categories'
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False, index=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=True)
group_id = Column(Integer, ForeignKey('groups.id'), nullable=True)
user = relationship("User", back_populates="categories")
items = relationship("Item", back_populates="category")
__table_args__ = (UniqueConstraint('name', 'user_id', 'group_id', name='uq_category_scope'),)
class TimeEntry(Base):
__tablename__ = 'time_entries'
id = Column(Integer, primary_key=True, index=True)
chore_assignment_id = Column(Integer, ForeignKey('chore_assignments.id', ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
start_time = Column(DateTime(timezone=True), nullable=False)
end_time = Column(DateTime(timezone=True), nullable=True)
duration_seconds = Column(Integer, nullable=True)
assignment = relationship("ChoreAssignment", back_populates="time_entries")
user = relationship("User", back_populates="time_entries")

20
be/app/schemas/audit.py Normal file
View File

@ -0,0 +1,20 @@
from pydantic import BaseModel
from datetime import datetime
from typing import Optional, Dict, Any
class FinancialAuditLogBase(BaseModel):
action_type: str
entity_type: str
entity_id: int
details: Optional[Dict[str, Any]] = None
class FinancialAuditLogCreate(FinancialAuditLogBase):
user_id: Optional[int] = None
class FinancialAuditLogPublic(FinancialAuditLogBase):
id: int
timestamp: datetime
user_id: Optional[int] = None
class Config:
orm_mode = True

View File

@ -0,0 +1,19 @@
from pydantic import BaseModel
from typing import Optional
class CategoryBase(BaseModel):
name: str
class CategoryCreate(CategoryBase):
pass
class CategoryUpdate(CategoryBase):
pass
class CategoryPublic(CategoryBase):
id: int
user_id: Optional[int] = None
group_id: Optional[int] = None
class Config:
orm_mode = True

View File

@ -1,3 +1,4 @@
from __future__ import annotations
from datetime import date, datetime
from typing import Optional, List, Any
from pydantic import BaseModel, ConfigDict, field_validator
@ -55,6 +56,7 @@ class ChoreBase(BaseModel):
class ChoreCreate(ChoreBase):
group_id: Optional[int] = None
parent_chore_id: Optional[int] = None
@field_validator('group_id')
@classmethod
@ -89,11 +91,13 @@ class ChorePublic(ChoreBase):
group_id: Optional[int] = None
created_by_id: int
last_completed_at: Optional[datetime] = None
parent_chore_id: Optional[int] = None
created_at: datetime
updated_at: datetime
creator: Optional[UserPublic] = None # Embed creator UserPublic schema
assignments: List[ChoreAssignmentPublic] = []
history: List[ChoreHistoryPublic] = []
child_chores: List[ChorePublic] = []
model_config = ConfigDict(from_attributes=True)

View File

@ -20,6 +20,7 @@ class ItemPublic(BaseModel):
class ItemCreate(BaseModel):
name: str
quantity: Optional[str] = None
category_id: Optional[int] = None
class ItemUpdate(BaseModel):
name: Optional[str] = None
@ -27,4 +28,5 @@ class ItemUpdate(BaseModel):
is_complete: Optional[bool] = None
price: Optional[Decimal] = None
position: Optional[int] = None
category_id: Optional[int] = None
version: int

View File

@ -0,0 +1,22 @@
from pydantic import BaseModel
from datetime import datetime
from typing import Optional
class TimeEntryBase(BaseModel):
chore_assignment_id: int
start_time: datetime
end_time: Optional[datetime] = None
duration_seconds: Optional[int] = None
class TimeEntryCreate(TimeEntryBase):
pass
class TimeEntryUpdate(BaseModel):
end_time: datetime
class TimeEntryPublic(TimeEntryBase):
id: int
user_id: int
class Config:
orm_mode = True

8
be/app/schemas/token.py Normal file
View File

@ -0,0 +1,8 @@
from pydantic import BaseModel
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
email: str | None = None

View File

@ -25,6 +25,10 @@ class UserUpdate(UserBase):
is_superuser: Optional[bool] = None
is_verified: Optional[bool] = None
class UserClaim(BaseModel):
email: EmailStr
password: str
class UserInDBBase(UserBase):
id: int
password_hash: str

View File

@ -24,4 +24,5 @@ httpx>=0.24.0 # For async HTTP testing
aiosqlite>=0.19.0 # For async SQLite support in tests
# Scheduler
APScheduler==3.10.4
APScheduler==3.10.4
redis>=5.0.0

View File

@ -0,0 +1,47 @@
<template>
<form @submit.prevent="handleSubmit">
<div class="form-group">
<label for="category-name">Category Name</label>
<input type="text" id="category-name" v-model="categoryName" required />
</div>
<div class="form-actions">
<button type="submit" :disabled="loading">
{{ isEditing ? 'Update' : 'Create' }}
</button>
<button type="button" @click="emit('cancel')" :disabled="loading">
Cancel
</button>
</div>
</form>
</template>
<script setup lang="ts">
import { ref, defineProps, defineEmits, onMounted, computed } from 'vue';
import type { Category } from '../stores/categoryStore';
const props = defineProps<{
category?: Category | null;
loading: boolean;
}>();
const emit = defineEmits<{
(e: 'submit', data: { name: string }): void;
(e: 'cancel'): void;
}>();
const categoryName = ref('');
const isEditing = computed(() => !!props.category);
onMounted(() => {
if (props.category) {
categoryName.value = props.category.name;
}
});
const handleSubmit = () => {
if (categoryName.value.trim()) {
emit('submit', { name: categoryName.value.trim() });
}
};
</script>

View File

@ -0,0 +1,128 @@
<template>
<li class="neo-list-item" :class="`status-${getDueDateStatus(chore)}`">
<div class="neo-item-content">
<label class="neo-checkbox-label">
<input type="checkbox" :checked="chore.is_completed" @change="emit('toggle-completion', chore)">
<div class="checkbox-content">
<div class="chore-main-info">
<span class="checkbox-text-span"
:class="{ 'neo-completed-static': chore.is_completed && !chore.updating }">
{{ chore.name }}
</span>
<div class="chore-badges">
<span v-if="chore.type === 'group'" class="badge badge-group">Group</span>
<span v-if="getDueDateStatus(chore) === 'overdue'"
class="badge badge-overdue">Overdue</span>
<span v-if="getDueDateStatus(chore) === 'due-today'" class="badge badge-due-today">Due
Today</span>
</div>
</div>
<div v-if="chore.description" class="chore-description">{{ chore.description }}</div>
<span v-if="chore.subtext" class="item-time">{{ chore.subtext }}</span>
<div v-if="totalTime > 0" class="total-time">
Total Time: {{ formatDuration(totalTime) }}
</div>
</div>
</label>
<div class="neo-item-actions">
<button class="btn btn-sm btn-neutral" @click="toggleTimer" :disabled="chore.is_completed">
{{ isActiveTimer ? 'Stop' : 'Start' }}
</button>
<button class="btn btn-sm btn-neutral" @click="emit('open-details', chore)" title="View Details">
📋
</button>
<button class="btn btn-sm btn-neutral" @click="emit('open-history', chore)" title="View History">
📅
</button>
<button class="btn btn-sm btn-neutral" @click="emit('edit', chore)">
Edit
</button>
<button class="btn btn-sm btn-danger" @click="emit('delete', chore)">
Delete
</button>
</div>
</div>
<ul v-if="chore.child_chores && chore.child_chores.length" class="child-chore-list">
<ChoreItem v-for="child in chore.child_chores" :key="child.id" :chore="child" :time-entries="timeEntries"
:active-timer="activeTimer" @toggle-completion="emit('toggle-completion', $event)"
@edit="emit('edit', $event)" @delete="emit('delete', $event)"
@open-details="emit('open-details', $event)" @open-history="emit('open-history', $event)"
@start-timer="emit('start-timer', $event)"
@stop-timer="(chore, timeEntryId) => emit('stop-timer', chore, timeEntryId)" />
</ul>
</li>
</template>
<script setup lang="ts">
import { defineProps, defineEmits, computed } from 'vue';
import type { ChoreWithCompletion } from '../types/chore';
import type { TimeEntry } from '../stores/timeEntryStore';
import { formatDuration } from '../utils/formatters';
const props = defineProps<{
chore: ChoreWithCompletion;
timeEntries: TimeEntry[];
activeTimer: TimeEntry | null;
}>();
const emit = defineEmits<{
(e: 'toggle-completion', chore: ChoreWithCompletion): void;
(e: 'edit', chore: ChoreWithCompletion): void;
(e: 'delete', chore: ChoreWithCompletion): void;
(e: 'open-details', chore: ChoreWithCompletion): void;
(e: 'open-history', chore: ChoreWithCompletion): void;
(e: 'start-timer', chore: ChoreWithCompletion): void;
(e: 'stop-timer', chore: ChoreWithCompletion, timeEntryId: number): void;
}>();
const isActiveTimer = computed(() => {
return props.activeTimer && props.activeTimer.chore_assignment_id === props.chore.current_assignment_id;
});
const totalTime = computed(() => {
return props.timeEntries.reduce((acc, entry) => acc + (entry.duration_seconds || 0), 0);
});
const toggleTimer = () => {
if (isActiveTimer.value) {
emit('stop-timer', props.chore, props.activeTimer!.id);
} else {
emit('start-timer', props.chore);
}
};
const getDueDateStatus = (chore: ChoreWithCompletion) => {
if (chore.is_completed) return 'completed';
const today = new Date();
today.setHours(0, 0, 0, 0);
const dueDate = new Date(chore.next_due_date);
dueDate.setHours(0, 0, 0, 0);
if (dueDate < today) return 'overdue';
if (dueDate.getTime() === today.getTime()) return 'due-today';
return 'upcoming';
};
</script>
<script lang="ts">
export default {
name: 'ChoreItem'
}
</script>
<style scoped lang="scss">
.child-chore-list {
list-style: none;
padding-left: 2rem;
margin-top: 0.5rem;
border-left: 2px solid #e5e7eb;
}
.total-time {
font-size: 0.8rem;
color: #666;
margin-top: 0.25rem;
}
</style>

View File

@ -189,7 +189,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { apiClient, API_ENDPOINTS } from '@/config/api';
import { apiClient, API_ENDPOINTS } from '@/services/api';
import { useNotificationStore } from '@/stores/notifications';
import { useAuthStore } from '@/stores/auth';
import type { ExpenseCreate, ExpenseSplitCreate } from '@/types/expense';

View File

@ -20,7 +20,7 @@
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue';
import { useVModel } from '@vueuse/core';
import { apiClient, API_ENDPOINTS } from '@/config/api';
import { apiClient, API_ENDPOINTS } from '@/services/api';
import { useNotificationStore } from '@/stores/notifications';
import VModal from '@/components/valerie/VModal.vue';
import VFormField from '@/components/valerie/VFormField.vue';

View File

@ -29,7 +29,7 @@
<script setup lang="ts">
import { ref, watch, nextTick, computed } from 'vue';
import { useVModel } from '@vueuse/core'; // onClickOutside removed
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming this path is correct
import { apiClient, API_ENDPOINTS } from '@/services/api'; // Assuming this path is correct
import { useNotificationStore } from '@/stores/notifications';
import VModal from '@/components/valerie/VModal.vue';
import VFormField from '@/components/valerie/VFormField.vue';
@ -38,6 +38,7 @@ import VTextarea from '@/components/valerie/VTextarea.vue';
import VSelect from '@/components/valerie/VSelect.vue';
import VButton from '@/components/valerie/VButton.vue';
import VSpinner from '@/components/valerie/VSpinner.vue';
import type { Group } from '@/types/group';
const props = defineProps<{
modelValue: boolean;

View File

@ -1,10 +1,5 @@
<template>
<button
:type="type"
:class="buttonClasses"
:disabled="disabled"
@click="handleClick"
>
<button :type="type" :class="buttonClasses" :disabled="disabled" @click="handleClick">
<VIcon v-if="iconLeft && !iconOnly" :name="iconLeft" :size="iconSize" class="mr-1" />
<VIcon v-if="iconOnly && (iconLeft || iconRight)" :name="iconNameForIconOnly" :size="iconSize" />
<span v-if="!iconOnly" :class="{ 'sr-only': iconOnly && (iconLeft || iconRight) }">
@ -15,10 +10,10 @@
</template>
<script lang="ts">
import { defineComponent, computed, PropType } from 'vue';
import { defineComponent, computed, type PropType } from 'vue';
import VIcon from './VIcon.vue'; // Assuming VIcon.vue is in the same directory
type ButtonVariant = 'primary' | 'secondary' | 'neutral' | 'danger';
type ButtonVariant = 'primary' | 'secondary' | 'neutral' | 'danger' | 'success';
type ButtonSize = 'sm' | 'md' | 'lg'; // Added 'lg' for consistency, though not in SCSS class list initially
type ButtonType = 'button' | 'submit' | 'reset';
@ -35,7 +30,7 @@ export default defineComponent({
variant: {
type: String as PropType<ButtonVariant>,
default: 'primary',
validator: (value: string) => ['primary', 'secondary', 'neutral', 'danger'].includes(value),
validator: (value: string) => ['primary', 'secondary', 'neutral', 'danger', 'success'].includes(value),
},
size: {
type: String as PropType<ButtonSize>,
@ -162,6 +157,12 @@ export default defineComponent({
border-color: #dc3545;
}
.btn-success {
background-color: #28a745; // Example success color
color: white;
border-color: #28a745;
}
// Sizes
.btn-sm {
padding: 0.25em 0.5em;
@ -180,9 +181,18 @@ export default defineComponent({
// Icon only
.btn-icon-only {
padding: 0.5em; // Adjust padding for icon-only buttons
// Ensure VIcon fills the space or adjust VIcon size if needed
& .mr-1 { margin-right: 0 !important; } // Remove margin if accidentally applied
& .ml-1 { margin-left: 0 !important; } // Remove margin if accidentally applied
& .mr-1 {
margin-right: 0 !important;
}
// Remove margin if accidentally applied
& .ml-1 {
margin-left: 0 !important;
}
// Remove margin if accidentally applied
}
.sr-only {
@ -201,6 +211,7 @@ export default defineComponent({
.mr-1 {
margin-right: 0.25em;
}
.ml-1 {
margin-left: 0.25em;
}

View File

@ -9,6 +9,7 @@ export const API_ENDPOINTS = {
// Auth
AUTH: {
LOGIN: '/auth/jwt/login',
GUEST: '/auth/guest',
SIGNUP: '/auth/register',
LOGOUT: '/auth/jwt/logout',
REFRESH: '/auth/jwt/refresh',
@ -21,11 +22,11 @@ export const API_ENDPOINTS = {
USERS: {
PROFILE: '/users/me',
UPDATE_PROFILE: '/users/me',
PASSWORD: '/api/v1/users/password',
AVATAR: '/api/v1/users/avatar',
SETTINGS: '/api/v1/users/settings',
NOTIFICATIONS: '/api/v1/users/notifications',
PREFERENCES: '/api/v1/users/preferences',
PASSWORD: '/users/password',
AVATAR: '/users/avatar',
SETTINGS: '/users/settings',
NOTIFICATIONS: '/users/notifications',
PREFERENCES: '/users/preferences',
},
// Lists
@ -42,10 +43,16 @@ export const API_ENDPOINTS = {
COMPLETE: (listId: string) => `/lists/${listId}/complete`,
REOPEN: (listId: string) => `/lists/${listId}/reopen`,
ARCHIVE: (listId: string) => `/lists/${listId}/archive`,
RESTORE: (listId: string) => `/lists/${listId}/restore`,
UNARCHIVE: (listId: string) => `/lists/${listId}/unarchive`,
DUPLICATE: (listId: string) => `/lists/${listId}/duplicate`,
EXPORT: (listId: string) => `/lists/${listId}/export`,
IMPORT: '/lists/import',
ARCHIVED: '/lists/archived',
},
CATEGORIES: {
BASE: '/categories',
BY_ID: (id: string) => `/categories/${id}`,
},
// Groups
@ -129,5 +136,7 @@ export const API_ENDPOINTS = {
HISTORY: (id: number) => `/chores/${id}/history`,
ASSIGNMENTS: (choreId: number) => `/chores/${choreId}/assignments`,
ASSIGNMENT_BY_ID: (id: number) => `/chores/assignments/${id}`,
TIME_ENTRIES: (assignmentId: number) => `/chores/assignments/${assignmentId}/time-entries`,
TIME_ENTRY: (id: number) => `/chores/time-entries/${id}`,
},
}

View File

@ -1,17 +0,0 @@
import { api } from '@/services/api';
import { API_BASE_URL, API_VERSION } from './api-config';
export { API_ENDPOINTS } from './api-config';
// Helper function to get full API URL
export const getApiUrl = (endpoint: string): string => {
return `${API_BASE_URL}/api/${API_VERSION}${endpoint}`;
};
// Helper function to make API calls
export const apiClient = {
get: (endpoint: string, config = {}) => api.get(getApiUrl(endpoint), config),
post: (endpoint: string, data = {}, config = {}) => api.post(getApiUrl(endpoint), data, config),
put: (endpoint: string, data = {}, config = {}) => api.put(getApiUrl(endpoint), data, config),
patch: (endpoint: string, data = {}, config = {}) => api.patch(getApiUrl(endpoint), data, config),
delete: (endpoint: string, config = {}) => api.delete(getApiUrl(endpoint), config),
};

View File

@ -89,6 +89,10 @@
<span class="material-icons">task_alt</span>
<span class="tab-text">Chores</span>
</router-link>
<router-link to="/expenses" class="tab-item" active-class="active">
<span class="material-icons">payments</span>
<span class="tab-text">Expenses</span>
</router-link>
</nav>
</footer>

View File

@ -15,7 +15,9 @@
<form v-else @submit.prevent="onSubmitProfile">
<!-- Profile Section -->
<VCard class="mb-3">
<template #header><VHeading level="3">{{ $t('accountPage.profileSection.header') }}</VHeading></template>
<template #header>
<VHeading level="3">{{ $t('accountPage.profileSection.header') }}</VHeading>
</template>
<div class="flex flex-wrap" style="gap: 1rem;">
<VFormField :label="$t('accountPage.profileSection.nameLabel')" class="flex-grow">
<VInput id="profileName" v-model="profile.name" required />
@ -35,7 +37,9 @@
<!-- Password Section -->
<form @submit.prevent="onChangePassword">
<VCard class="mb-3">
<template #header><VHeading level="3">{{ $t('accountPage.passwordSection.header') }}</VHeading></template>
<template #header>
<VHeading level="3">{{ $t('accountPage.passwordSection.header') }}</VHeading>
</template>
<div class="flex flex-wrap" style="gap: 1rem;">
<VFormField :label="$t('accountPage.passwordSection.currentPasswordLabel')" class="flex-grow">
<VInput type="password" id="currentPassword" v-model="password.current" required />
@ -54,28 +58,33 @@
<!-- Notifications Section -->
<VCard>
<template #header><VHeading level="3">{{ $t('accountPage.notificationsSection.header') }}</VHeading></template>
<template #header>
<VHeading level="3">{{ $t('accountPage.notificationsSection.header') }}</VHeading>
</template>
<VList class="preference-list">
<VListItem class="preference-item">
<div class="preference-label">
<span>{{ $t('accountPage.notificationsSection.emailNotificationsLabel') }}</span>
<small>{{ $t('accountPage.notificationsSection.emailNotificationsDescription') }}</small>
</div>
<VToggleSwitch v-model="preferences.emailNotifications" @change="onPreferenceChange" :label="$t('accountPage.notificationsSection.emailNotificationsLabel')" id="emailNotificationsToggle" />
<VToggleSwitch v-model="preferences.emailNotifications" @change="onPreferenceChange"
:label="$t('accountPage.notificationsSection.emailNotificationsLabel')" id="emailNotificationsToggle" />
</VListItem>
<VListItem class="preference-item">
<div class="preference-label">
<span>{{ $t('accountPage.notificationsSection.listUpdatesLabel') }}</span>
<small>{{ $t('accountPage.notificationsSection.listUpdatesDescription') }}</small>
</div>
<VToggleSwitch v-model="preferences.listUpdates" @change="onPreferenceChange" :label="$t('accountPage.notificationsSection.listUpdatesLabel')" id="listUpdatesToggle"/>
<VToggleSwitch v-model="preferences.listUpdates" @change="onPreferenceChange"
:label="$t('accountPage.notificationsSection.listUpdatesLabel')" id="listUpdatesToggle" />
</VListItem>
<VListItem class="preference-item">
<div class="preference-label">
<span>{{ $t('accountPage.notificationsSection.groupActivitiesLabel') }}</span>
<small>{{ $t('accountPage.notificationsSection.groupActivitiesDescription') }}</small>
</div>
<VToggleSwitch v-model="preferences.groupActivities" @change="onPreferenceChange" :label="$t('accountPage.notificationsSection.groupActivitiesLabel')" id="groupActivitiesToggle"/>
<VToggleSwitch v-model="preferences.groupActivities" @change="onPreferenceChange"
:label="$t('accountPage.notificationsSection.groupActivitiesLabel')" id="groupActivitiesToggle" />
</VListItem>
</VList>
</VCard>
@ -83,9 +92,10 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, onMounted, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming path
import { useAuthStore } from '@/stores/auth';
import { apiClient, API_ENDPOINTS } from '@/services/api'; // Assuming path
import { useNotificationStore } from '@/stores/notifications';
import VHeading from '@/components/valerie/VHeading.vue';
import VSpinner from '@/components/valerie/VSpinner.vue';
@ -130,6 +140,8 @@ const preferences = ref<Preferences>({
groupActivities: true,
});
const authStore = useAuthStore();
const fetchProfile = async () => {
loading.value = true;
error.value = null;

View File

@ -0,0 +1,65 @@
<template>
<div>
<h1>Category Management</h1>
<CategoryForm v-if="showForm" :category="selectedCategory" :loading="loading" @submit="handleFormSubmit"
@cancel="cancelForm" />
<div v-else>
<button @click="showCreateForm">Create Category</button>
<ul v-if="categories.length">
<li v-for="category in categories" :key="category.id">
{{ category.name }}
<button @click="showEditForm(category)">Edit</button>
<button @click="handleDelete(category.id)" :disabled="loading">Delete</button>
</li>
</ul>
<p v-else-if="!loading">No categories found.</p>
</div>
<p v-if="loading">Loading...</p>
<p v-if="error">{{ error }}</p>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useCategoryStore, type Category } from '../stores/categoryStore';
import CategoryForm from '../components/CategoryForm.vue';
import { storeToRefs } from 'pinia';
const categoryStore = useCategoryStore();
const { categories, loading, error } = storeToRefs(categoryStore);
const showForm = ref(false);
const selectedCategory = ref<Category | null>(null);
onMounted(() => {
categoryStore.fetchCategories();
});
const showCreateForm = () => {
selectedCategory.value = null;
showForm.value = true;
};
const showEditForm = (category: Category) => {
selectedCategory.value = category;
showForm.value = true;
};
const cancelForm = () => {
showForm.value = false;
selectedCategory.value = null;
};
const handleFormSubmit = async (data: { name: string }) => {
if (selectedCategory.value) {
await categoryStore.updateCategory(selectedCategory.value.id, data);
} else {
await categoryStore.createCategory(data);
}
cancelForm();
};
const handleDelete = async (id: number) => {
await categoryStore.deleteCategory(id);
};
</script>

View File

@ -4,23 +4,20 @@ import { useI18n } from 'vue-i18n'
import { format, startOfDay, isEqual, isToday as isTodayDate, formatDistanceToNow, parseISO } from 'date-fns'
import { choreService } from '../services/choreService'
import { useNotificationStore } from '../stores/notifications'
import type { Chore, ChoreCreate, ChoreUpdate, ChoreFrequency, ChoreAssignmentUpdate, ChoreAssignment, ChoreHistory } from '../types/chore'
import type { Chore, ChoreCreate, ChoreUpdate, ChoreFrequency, ChoreAssignmentUpdate, ChoreAssignment, ChoreHistory, ChoreWithCompletion } from '../types/chore'
import { groupService } from '../services/groupService'
import { useStorage } from '@vueuse/core'
import ChoreItem from '@/components/ChoreItem.vue';
import { useTimeEntryStore, type TimeEntry } from '../stores/timeEntryStore';
import { storeToRefs } from 'pinia';
import { useAuthStore } from '@/stores/auth';
const { t } = useI18n()
const props = defineProps<{ groupId?: number | string }>();
// Types
interface ChoreWithCompletion extends Chore {
current_assignment_id: number | null;
is_completed: boolean;
completed_at: string | null;
updating: boolean;
assigned_user_name?: string;
completed_by_name?: string;
}
// ChoreWithCompletion is now imported from ../types/chore
interface ChoreFormData {
name: string;
@ -30,6 +27,7 @@ interface ChoreFormData {
next_due_date: string;
type: 'personal' | 'group';
group_id: number | undefined;
parent_chore_id?: number | null;
}
const notificationStore = useNotificationStore()
@ -60,11 +58,26 @@ const initialChoreFormState: ChoreFormData = {
next_due_date: format(new Date(), 'yyyy-MM-dd'),
type: 'personal',
group_id: undefined,
parent_chore_id: null,
}
const choreForm = ref({ ...initialChoreFormState })
const isLoading = ref(true)
const authStore = useAuthStore();
const { isGuest } = storeToRefs(authStore);
const timeEntryStore = useTimeEntryStore();
const { timeEntries, loading: timeEntryLoading, error: timeEntryError } = storeToRefs(timeEntryStore);
const activeTimer = computed(() => {
for (const assignmentId in timeEntries.value) {
const entry = timeEntries.value[assignmentId].find(te => !te.end_time);
if (entry) return entry;
}
return null;
});
const loadChores = async () => {
const now = Date.now();
if (cachedChores.value && cachedChores.value.length > 0 && (now - cachedTimestamp.value) < CACHE_DURATION) {
@ -108,8 +121,16 @@ const loadGroups = async () => {
}
}
const loadTimeEntries = async () => {
chores.value.forEach(chore => {
if (chore.current_assignment_id) {
timeEntryStore.fetchTimeEntriesForAssignment(chore.current_assignment_id);
}
});
};
onMounted(() => {
loadChores()
loadChores().then(loadTimeEntries);
loadGroups()
})
@ -173,17 +194,50 @@ const filteredChores = computed(() => {
return chores.value;
});
const groupedChores = computed(() => {
if (!filteredChores.value) return []
const choresByDate = filteredChores.value.reduce((acc, chore) => {
const dueDate = format(startOfDay(new Date(chore.next_due_date)), 'yyyy-MM-dd')
if (!acc[dueDate]) {
acc[dueDate] = []
const availableParentChores = computed(() => {
return chores.value.filter(c => {
// A chore cannot be its own parent
if (isEditing.value && selectedChore.value && c.id === selectedChore.value.id) {
return false;
}
acc[dueDate].push(chore)
return acc
}, {} as Record<string, ChoreWithCompletion[]>)
// A chore that is already a subtask cannot be a parent
if (c.parent_chore_id) {
return false;
}
// If a group is selected, only show chores from that group or personal chores
if (choreForm.value.group_id) {
return c.group_id === choreForm.value.group_id || c.type === 'personal';
}
// If no group is selected, only show personal chores that are not in a group
return c.type === 'personal' && !c.group_id;
});
});
const groupedChores = computed(() => {
if (!filteredChores.value) return [];
const choreMap = new Map<number, ChoreWithCompletion>();
filteredChores.value.forEach(chore => {
choreMap.set(chore.id, { ...chore, child_chores: [] });
});
const rootChores: ChoreWithCompletion[] = [];
choreMap.forEach(chore => {
if (chore.parent_chore_id && choreMap.has(chore.parent_chore_id)) {
choreMap.get(chore.parent_chore_id)?.child_chores?.push(chore);
} else {
rootChores.push(chore);
}
});
const choresByDate = rootChores.reduce((acc, chore) => {
const dueDate = format(startOfDay(new Date(chore.next_due_date)), 'yyyy-MM-dd');
if (!acc[dueDate]) {
acc[dueDate] = [];
}
acc[dueDate].push(chore);
return acc;
}, {} as Record<string, ChoreWithCompletion[]>);
return Object.keys(choresByDate)
.sort((a, b) => new Date(a).getTime() - new Date(b).getTime())
@ -198,7 +252,7 @@ const groupedChores = computed(() => {
...chore,
subtext: getChoreSubtext(chore)
}))
}
};
});
});
@ -238,6 +292,7 @@ const openEditChoreModal = (chore: ChoreWithCompletion) => {
next_due_date: chore.next_due_date,
type: chore.type,
group_id: chore.group_id ?? undefined,
parent_chore_id: chore.parent_chore_id,
}
showChoreModal.value = true
}
@ -412,10 +467,29 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
if (isEqual(dueDate, today)) return 'due-today';
return 'upcoming';
};
const startTimer = async (chore: ChoreWithCompletion) => {
if (chore.current_assignment_id) {
await timeEntryStore.startTimeEntry(chore.current_assignment_id);
}
};
const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
if (chore.current_assignment_id) {
await timeEntryStore.stopTimeEntry(chore.current_assignment_id, timeEntryId);
}
};
</script>
<template>
<div class="container">
<div v-if="isGuest" class="guest-banner">
<p>
You are using a guest account.
<router-link to="/auth/signup">Sign up</router-link>
to save your data permanently.
</p>
</div>
<header v-if="!props.groupId" class="flex justify-between items-center">
<h1 style="margin-block-start: 0;">{{ t('choresPage.title') }}</h1>
<button class="btn btn-primary" @click="openCreateChoreModal">
@ -444,44 +518,11 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
<h2 class="date-header">{{ formatDateHeader(group.date) }}</h2>
<div class="neo-item-list-container">
<ul class="neo-item-list">
<li v-for="chore in group.chores" :key="chore.id" class="neo-list-item"
:class="`status-${getDueDateStatus(chore)}`">
<div class="neo-item-content">
<label class="neo-checkbox-label">
<input type="checkbox" :checked="chore.is_completed" @change="toggleCompletion(chore)">
<div class="checkbox-content">
<div class="chore-main-info">
<span class="checkbox-text-span"
:class="{ 'neo-completed-static': chore.is_completed && !chore.updating }">
{{ chore.name }}
</span>
<div class="chore-badges">
<span v-if="chore.type === 'group'" class="badge badge-group">Group</span>
<span v-if="getDueDateStatus(chore) === 'overdue'" class="badge badge-overdue">Overdue</span>
<span v-if="getDueDateStatus(chore) === 'due-today'" class="badge badge-due-today">Due
Today</span>
</div>
</div>
<div v-if="chore.description" class="chore-description">{{ chore.description }}</div>
<span v-if="chore.subtext" class="item-time">{{ chore.subtext }}</span>
</div>
</label>
<div class="neo-item-actions">
<button class="btn btn-sm btn-neutral" @click="openChoreDetailModal(chore)" title="View Details">
📋
</button>
<button class="btn btn-sm btn-neutral" @click="openHistoryModal(chore)" title="View History">
📅
</button>
<button class="btn btn-sm btn-neutral" @click="openEditChoreModal(chore)">
{{ t('choresPage.edit', 'Edit') }}
</button>
<button class="btn btn-sm btn-danger" @click="confirmDelete(chore)">
{{ t('choresPage.delete', 'Delete') }}
</button>
</div>
</div>
</li>
<ChoreItem v-for="chore in group.chores" :key="chore.id" :chore="chore"
:time-entries="chore.current_assignment_id ? timeEntries[chore.current_assignment_id] || [] : []"
:active-timer="activeTimer" @toggle-completion="toggleCompletion" @edit="openEditChoreModal"
@delete="confirmDelete" @open-details="openChoreDetailModal" @open-history="openHistoryModal"
@start-timer="startTimer" @stop-timer="stopTimer" />
</ul>
</div>
</div>
@ -523,7 +564,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
</div>
<div v-if="choreForm.frequency === 'custom'" class="form-group">
<label class="form-label" for="chore-interval">{{ t('choresPage.form.interval', 'Interval (days)')
}}</label>
}}</label>
<input id="chore-interval" type="number" v-model.number="choreForm.custom_interval_days"
class="form-input" :placeholder="t('choresPage.form.intervalPlaceholder')" min="1">
</div>
@ -544,16 +585,26 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
</div>
<div v-if="choreForm.type === 'group'" class="form-group">
<label class="form-label" for="chore-group">{{ t('choresPage.form.assignGroup', 'Assign to Group')
}}</label>
}}</label>
<select id="chore-group" v-model="choreForm.group_id" class="form-input">
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="parent-chore">{{ t('choresPage.form.parentChore', 'Parent Chore')
}}</label>
<select id="parent-chore" v-model="choreForm.parent_chore_id" class="form-input">
<option :value="null">None</option>
<option v-for="parent in availableParentChores" :key="parent.id" :value="parent.id">
{{ parent.name }}
</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-neutral" @click="showChoreModal = false">{{
t('choresPage.form.cancel', 'Cancel')
}}</button>
}}</button>
<button type="submit" class="btn btn-primary">{{ isEditing ? t('choresPage.form.save', 'Save Changes') :
t('choresPage.form.create', 'Create') }}</button>
</div>
@ -578,7 +629,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
t('choresPage.deleteConfirm.cancel', 'Cancel') }}</button>
<button type="button" class="btn btn-danger" @click="deleteChore">{{
t('choresPage.deleteConfirm.delete', 'Delete')
}}</button>
}}</button>
</div>
</div>
</div>
@ -603,7 +654,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
<div class="detail-item">
<span class="label">Created by:</span>
<span class="value">{{ selectedChore.creator?.name || selectedChore.creator?.email || 'Unknown'
}}</span>
}}</span>
</div>
<div class="detail-item">
<span class="label">Due date:</span>
@ -635,7 +686,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
<div v-for="assignment in selectedChoreAssignments" :key="assignment.id" class="assignment-item">
<div class="assignment-main">
<span class="assigned-user">{{ assignment.assigned_user?.name || assignment.assigned_user?.email
}}</span>
}}</span>
<span class="assignment-status" :class="{ completed: assignment.is_complete }">
{{ assignment.is_complete ? '✅ Completed' : '⏳ Pending' }}
</span>
@ -693,6 +744,26 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
</template>
<style scoped lang="scss">
.guest-banner {
background-color: #fffbeb;
color: #92400e;
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 1.5rem;
border: 1px solid #fBBF24;
text-align: center;
}
.guest-banner p {
margin: 0;
}
.guest-banner a {
color: #92400e;
text-decoration: underline;
font-weight: bold;
}
.schedule-group {
margin-bottom: 2rem;
position: relative;

View File

@ -0,0 +1,738 @@
<template>
<div class="container">
<header class="flex justify-between items-center">
<h1 style="margin-block-start: 0;">Expenses</h1>
<button @click="openCreateExpenseModal" class="btn btn-primary">
Add Expense
</button>
</header>
<div v-if="loading" class="flex justify-center mt-4">
<div class="spinner-dots">
<span></span>
<span></span>
<span></span>
</div>
</div>
<div v-else-if="error" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative"
role="alert">
<strong class="font-bold">Error:</strong>
<span class="block sm:inline">{{ error }}</span>
</div>
<div v-else-if="expenses.length === 0" class="empty-state-card">
<h3>No Expenses Yet</h3>
<p>Get started by adding your first expense!</p>
<button class="btn btn-primary" @click="openCreateExpenseModal">
Add First Expense
</button>
</div>
<div v-else class="schedule-list">
<div v-for="group in groupedExpenses" :key="group.title" class="schedule-group">
<h2 class="date-header">{{ group.title }}</h2>
<div class="neo-item-list-container">
<ul class="neo-item-list">
<li v-for="expense in group.expenses" :key="expense.id" class="neo-list-item"
:class="{ 'is-expanded': expandedExpenseId === expense.id }">
<div class="neo-item-content">
<div class="flex-grow cursor-pointer" @click="toggleExpenseDetails(expense.id)">
<span class="checkbox-text-span">{{ expense.description }}</span>
<div class="item-subtext">
Paid by {{ expense.paid_by_user?.full_name || expense.paid_by_user?.email ||
'N/A'
}}
· {{ formatCurrency(expense.total_amount, expense.currency) }}
<span :class="getStatusClass(expense.overall_settlement_status)"
class="status-badge">
{{ expense.overall_settlement_status.replace('_', ' ') }}
</span>
</div>
</div>
<div class="neo-item-actions">
<button @click.stop="openEditExpenseModal(expense)"
class="btn btn-sm btn-neutral">Edit</button>
<button @click.stop="handleDeleteExpense(expense.id)"
class="btn btn-sm btn-danger">Delete</button>
</div>
</div>
<div v-if="expandedExpenseId === expense.id"
class="w-full mt-2 pt-2 border-t border-gray-200/50 expanded-details">
<div>
<h3 class="font-semibold text-gray-700 mb-2 text-sm">Splits ({{
expense.split_type.replace('_', ' ') }})</h3>
<ul class="space-y-1">
<li v-for="split in expense.splits" :key="split.id"
class="flex justify-between items-center py-1 text-sm">
<span class="text-gray-600">{{ split.user?.full_name || split.user?.email
|| 'N/A' }} owes</span>
<span class="font-mono text-gray-800 font-semibold">{{
formatCurrency(split.owed_amount, expense.currency) }}</span>
</li>
</ul>
</div>
</div>
</li>
</ul>
</div>
</div>
</div>
<!-- Create/Edit Expense Modal -->
<div v-if="showModal" class="modal-backdrop open" @click.self="closeModal">
<div class="modal-container">
<form @submit.prevent="handleFormSubmit">
<div class="modal-header">
<h3>{{ editingExpense ? 'Edit Expense' : 'Create New Expense' }}</h3>
<button type="button" @click="closeModal" class="close-button">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label" for="description">Description</label>
<input type="text" v-model="formState.description" id="description" class="form-input"
required>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
<div class="form-group">
<label for="total_amount" class="form-label">Total Amount</label>
<input type="number" step="0.01" min="0.01" v-model="formState.total_amount"
id="total_amount" class="form-input" required>
</div>
<div class="form-group">
<label for="currency" class="form-label">Currency</label>
<input type="text" v-model="formState.currency" id="currency" class="form-input"
required>
</div>
<div class="form-group">
<label for="paid_by_user_id" class="form-label">Paid By (User ID)</label>
<input type="number" v-model="formState.paid_by_user_id" id="paid_by_user_id"
class="form-input" required>
</div>
<div class="form-group">
<label for="split_type" class="form-label">Split Type</label>
<select v-model="formState.split_type" id="split_type" class="form-input" required>
<option value="EQUAL">Equal</option>
<option value="EXACT_AMOUNTS">Exact Amounts</option>
<option value="PERCENTAGE">Percentage</option>
<option value="SHARES">Shares</option>
<option value="ITEM_BASED">Item Based</option>
</select>
</div>
<div class="form-group">
<label for="group_id" class="form-label">Group ID (optional)</label>
<input type="number" v-model="formState.group_id" id="group_id" class="form-input">
</div>
<div class="form-group">
<label for="list_id" class="form-label">List ID (optional)</label>
<input type="number" v-model="formState.list_id" id="list_id" class="form-input">
</div>
</div>
<div class="form-group flex items-center mt-4">
<input type="checkbox" v-model="formState.isRecurring" id="is_recurring"
class="h-4 w-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500">
<label for="is_recurring" class="ml-2 block text-sm text-gray-900">This is a
recurring expense</label>
</div>
<!-- Placeholder for recurring pattern form -->
<div v-if="formState.isRecurring" class="md:col-span-2 p-4 bg-gray-50 rounded-md mt-2">
<p class="text-sm text-gray-500">Recurring expense options will be shown here.
</p>
</div>
<!-- Placeholder for splits input form -->
<div v-if="formState.split_type === 'EXACT_AMOUNTS' || formState.split_type === 'PERCENTAGE' || formState.split_type === 'SHARES'"
class="md:col-span-2 p-4 bg-gray-50 rounded-md mt-2">
<p class="text-sm text-gray-500">Inputs for {{ formState.split_type }} splits
will be shown here.</p>
</div>
<div v-if="formError" class="mt-3 bg-red-100 border-l-4 border-red-500 text-red-700 p-3">
<p>{{ formError }}</p>
</div>
</div>
<div class="modal-footer">
<button type="button" @click="closeModal" class="btn btn-neutral">Cancel</button>
<button type="submit" class="btn btn-primary">
{{ editingExpense ? 'Update Expense' : 'Create Expense' }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, reactive, computed } from 'vue'
import { expenseService, type CreateExpenseData, type UpdateExpenseData } from '@/services/expenseService'
// Types are kept local to this component
interface UserPublic {
id: number;
email: string;
full_name?: string;
}
interface ExpenseSplit {
id: number;
expense_id: number;
user_id: number;
owed_amount: string; // Decimal is string
share_percentage?: string;
share_units?: number;
user?: UserPublic;
created_at: string;
updated_at: string;
status: 'unpaid' | 'paid' | 'partially_paid';
paid_at?: string;
}
export type SplitType = 'EQUAL' | 'EXACT_AMOUNTS' | 'PERCENTAGE' | 'SHARES' | 'ITEM_BASED';
interface Expense {
id: number;
description: string;
total_amount: string; // Decimal is string
currency: string;
expense_date?: string;
split_type: SplitType;
list_id?: number;
group_id?: number;
item_id?: number;
paid_by_user_id: number;
is_recurring: boolean;
recurrence_pattern?: any;
created_at: string;
updated_at: string;
version: number;
created_by_user_id: number;
splits: ExpenseSplit[];
paid_by_user?: UserPublic;
overall_settlement_status: 'unpaid' | 'paid' | 'partially_paid';
next_occurrence?: string;
last_occurrence?: string;
parent_expense_id?: number;
generated_expenses: Expense[];
}
const expenses = ref<Expense[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const expandedExpenseId = ref<number | null>(null)
const showModal = ref(false)
const editingExpense = ref<Expense | null>(null)
const formError = ref<string | null>(null)
const initialFormState: CreateExpenseData = {
description: '',
total_amount: '',
currency: 'USD',
split_type: 'EQUAL',
isRecurring: false,
paid_by_user_id: 0, // Should be current user id by default
list_id: undefined,
group_id: undefined,
splits_in: [],
}
const formState = reactive<any>({ ...initialFormState })
onMounted(async () => {
try {
loading.value = true
expenses.value = (await expenseService.getExpenses()) as any as Expense[]
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to fetch expenses.'
console.error(err)
} finally {
loading.value = false
}
})
const groupedExpenses = computed(() => {
if (!expenses.value) return [];
const expensesByDate = expenses.value.reduce((acc, expense) => {
const dateKey = expense.expense_date ? new Date(expense.expense_date).toISOString().split('T')[0] : 'nodate';
if (!acc[dateKey]) {
acc[dateKey] = [];
}
acc[dateKey].push(expense);
return acc;
}, {} as Record<string, Expense[]>);
return Object.keys(expensesByDate)
.sort((a, b) => new Date(b).getTime() - new Date(a).getTime())
.map(dateStr => {
const date = dateStr === 'nodate' ? null : new Date(dateStr);
return {
date,
title: date ? formatDateHeader(date) : 'No Date',
expenses: expensesByDate[dateStr]
};
});
});
const toggleExpenseDetails = (expenseId: number) => {
expandedExpenseId.value = expandedExpenseId.value === expenseId ? null : expenseId
}
const formatDateHeader = (date: Date) => {
const today = new Date()
today.setHours(0, 0, 0, 0)
const itemDate = new Date(date)
itemDate.setHours(0, 0, 0, 0)
const isToday = itemDate.getTime() === today.getTime()
if (isToday) {
return `Today, ${new Intl.DateTimeFormat(undefined, { weekday: 'short', day: 'numeric', month: 'short' }).format(itemDate)}`
}
return new Intl.DateTimeFormat(undefined, { weekday: 'short', day: 'numeric', month: 'short' }).format(itemDate);
}
const formatDate = (dateString?: string | Date) => {
if (!dateString) return 'N/A'
return new Date(dateString).toLocaleDateString(undefined, {
year: 'numeric', month: 'long', day: 'numeric'
})
}
const formatCurrency = (amount: string | number, currency: string = 'USD') => {
const numericAmount = typeof amount === 'string' ? parseFloat(amount) : amount;
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(numericAmount);
}
const getStatusClass = (status: string) => {
const statusMap: Record<string, string> = {
unpaid: 'status-overdue',
partially_paid: 'status-due-today',
paid: 'status-completed',
}
return statusMap[status] || 'bg-gray-100 text-gray-800'
}
const openCreateExpenseModal = () => {
editingExpense.value = null
Object.assign(formState, initialFormState)
// TODO: Set formState.paid_by_user_id to current user's ID
// TODO: Fetch users/groups/lists for dropdowns
showModal.value = true
}
const openEditExpenseModal = (expense: Expense) => {
editingExpense.value = expense
// Map snake_case from Expense to camelCase for CreateExpenseData/UpdateExpenseData
formState.description = expense.description
formState.total_amount = expense.total_amount
formState.currency = expense.currency
formState.split_type = expense.split_type
formState.isRecurring = expense.is_recurring
formState.list_id = expense.list_id
formState.group_id = expense.group_id
formState.item_id = expense.item_id
formState.paid_by_user_id = expense.paid_by_user_id
formState.version = expense.version
// recurrencePattern and splits_in would need more complex mapping
showModal.value = true
}
const closeModal = () => {
showModal.value = false
editingExpense.value = null
formError.value = null
}
const handleFormSubmit = async () => {
formError.value = null
const data: any = { ...formState }
if (data.list_id === '' || data.list_id === null) data.list_id = undefined
if (data.group_id === '' || data.group_id === null) data.group_id = undefined
try {
if (editingExpense.value) {
const updateData: UpdateExpenseData = {
...data,
version: editingExpense.value.version,
}
const updatedExpense = (await expenseService.updateExpense(editingExpense.value.id, updateData)) as any as Expense;
const index = expenses.value.findIndex(e => e.id === updatedExpense.id)
if (index !== -1) {
expenses.value[index] = updatedExpense
}
} else {
const newExpense = (await expenseService.createExpense(data as CreateExpenseData)) as any as Expense;
expenses.value.unshift(newExpense)
}
closeModal()
// re-fetch all expenses to ensure data consistency after create/update
expenses.value = (await expenseService.getExpenses()) as any as Expense[]
} catch (err: any) {
formError.value = err.response?.data?.detail || 'An error occurred during the operation.'
console.error(err)
}
}
const handleDeleteExpense = async (expenseId: number) => {
if (!confirm('Are you sure you want to delete this expense? This action cannot be undone.')) return
try {
// Note: The service deleteExpense could be enhanced to take a version for optimistic locking.
await expenseService.deleteExpense(expenseId)
expenses.value = expenses.value.filter(e => e.id !== expenseId)
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to delete expense.'
console.error(err)
}
}
</script>
<style scoped lang="scss">
.container {
padding: 1rem;
max-width: 1200px;
margin: 0 auto;
}
header {
margin-bottom: 1.5rem;
}
.schedule-list {
margin-top: 1.5rem;
}
.schedule-group {
margin-bottom: 2rem;
position: relative;
}
.date-header {
font-size: clamp(1rem, 4vw, 1.2rem);
font-weight: bold;
color: var(--dark);
text-transform: none;
letter-spacing: normal;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--dark);
position: sticky;
top: 0;
background-color: var(--light);
z-index: 10;
}
.neo-item-list-container {
border: 3px solid #111;
border-radius: 18px;
background: var(--light);
box-shadow: 6px 6px 0 #111;
overflow: hidden;
}
.neo-item-list {
list-style: none;
padding: 0.5rem 1rem;
margin: 0;
}
.neo-list-item {
display: flex;
flex-wrap: wrap;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
position: relative;
transition: background-color 0.2s ease;
}
.neo-list-item:hover {
background-color: #f8f8f8;
}
.neo-list-item:last-child {
border-bottom: none;
}
.neo-item-content {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: 0.5rem;
}
.neo-item-actions {
display: flex;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.2s ease;
margin-left: auto;
.btn {
margin-left: 0.25rem;
}
}
.neo-list-item:hover .neo-item-actions {
opacity: 1;
}
.checkbox-text-span {
position: relative;
transition: color 0.4s ease, opacity 0.4s ease;
width: fit-content;
font-weight: 500;
color: var(--dark);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-subtext {
font-size: 0.8rem;
color: var(--dark);
opacity: 0.7;
margin-top: 0.2rem;
}
.status-badge {
display: inline-block;
padding: 0.1rem 0.5rem;
font-size: 0.7rem;
border-radius: 9999px;
font-weight: 600;
text-transform: capitalize;
margin-left: 0.5rem;
}
.status-unpaid {
background-color: #fef2f2;
color: #991b1b;
}
.status-partially_paid {
background-color: #fffbeb;
color: #92400e;
}
.status-paid {
background-color: #f0fdf4;
color: #166534;
}
.is-expanded {
.expanded-details {
max-height: 500px;
/* or a suitable value */
transition: max-height 0.5s ease-in-out;
}
}
.expanded-details {
padding-left: 1.5rem;
/* Indent details */
}
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
}
.modal-backdrop.open {
opacity: 1;
visibility: visible;
}
.modal-container {
background-color: white;
padding: 1.5rem;
border-radius: 0.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
width: 90%;
max-width: 600px;
transform: translateY(-20px);
transition: transform 0.3s ease;
}
.modal-backdrop.open .modal-container {
transform: translateY(0);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 1rem;
margin-bottom: 1rem;
}
.modal-header h3 {
font-size: 1.25rem;
font-weight: 600;
color: #111827;
margin: 0;
}
.close-button {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #6b7280;
}
.modal-body {
padding-bottom: 1rem;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
}
.form-group {
margin-bottom: 1rem;
}
.form-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
font-size: 0.875rem;
color: #374151;
}
.form-input,
select.form-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
.btn {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-primary {
background-color: #4f46e5;
color: white;
border: 1px solid transparent;
}
.btn-primary:hover {
background-color: #4338ca;
}
.btn-neutral {
background-color: white;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-neutral:hover {
background-color: #f9fafb;
}
.btn-danger {
background-color: #dc2626;
color: white;
border: 1px solid transparent;
}
.btn-danger:hover {
background-color: #b91c1c;
}
.spinner-dots {
display: flex;
justify-content: center;
align-items: center;
}
.spinner-dots span {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #4f46e5;
margin: 0 4px;
animation: spinner-grow 1.4s infinite ease-in-out both;
}
.spinner-dots span:nth-child(1) {
animation-delay: -0.32s;
}
.spinner-dots span:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes spinner-grow {
0%,
80%,
100% {
transform: scale(0);
}
40% {
transform: scale(1);
}
}
.empty-state-card {
text-align: center;
padding: 3rem 1.5rem;
background-color: #f9fafb;
border: 2px dashed #e5e7eb;
border-radius: 0.75rem;
margin-top: 2rem;
}
.empty-state-card h3 {
font-size: 1.25rem;
font-weight: 600;
color: #111827;
}
.empty-state-card p {
margin-top: 0.5rem;
color: #6b7280;
}
.empty-state-card .btn {
margin-top: 1.5rem;
}
</style>

View File

@ -219,10 +219,10 @@
<template #footer>
<VButton variant="neutral" @click="closeSettleShareModal">{{
t('groupDetailPage.settleShareModal.cancelButton')
}}</VButton>
}}</VButton>
<VButton variant="primary" @click="handleConfirmSettle" :disabled="isSettlementLoading">{{
t('groupDetailPage.settleShareModal.confirmButton')
}}</VButton>
}}</VButton>
</template>
</VModal>
@ -242,7 +242,7 @@
<div class="meta-item">
<span class="label">Created by:</span>
<span class="value">{{ selectedChore.creator?.name || selectedChore.creator?.email || 'Unknown'
}}</span>
}}</span>
</div>
<div class="meta-item">
<span class="label">Created:</span>
@ -383,7 +383,7 @@
<script setup lang="ts">
import { ref, onMounted, computed, reactive } from 'vue';
import { useI18n } from 'vue-i18n';
import { apiClient, API_ENDPOINTS } from '@/config/api';
import { apiClient, API_ENDPOINTS } from '@/services/api';
import { useClipboard, useStorage } from '@vueuse/core';
import ListsPage from './ListsPage.vue';
import { useNotificationStore } from '@/stores/notifications';

View File

@ -122,7 +122,7 @@
import { ref, onMounted, nextTick, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { apiClient, API_ENDPOINTS } from '@/config/api';
import { apiClient, API_ENDPOINTS } from '@/services/api';
import { useStorage } from '@vueuse/core';
import { onClickOutside } from '@vueuse/core';
import { useNotificationStore } from '@/stores/notifications';

View File

@ -48,112 +48,127 @@
</div>
</div>
<p v-if="list.description" class="neo-description-internal">{{ list.description }}</p>
<div class="supermarkt-mode-toggle">
<label>
Supermarkt Mode
<VToggleSwitch v-model="supermarktMode" />
</label>
</div>
<VProgressBar v-if="supermarktMode" :value="itemCompletionProgress" class="mt-4" />
</div>
<!-- End Integrated Header -->
<draggable v-model="list.items" item-key="id" handle=".drag-handle" @end="handleDragEnd" :disabled="!isOnline"
class="neo-item-list">
<template #item="{ element: item }">
<li class="neo-list-item"
:class="{ 'bg-gray-100 opacity-70': item.is_complete, 'item-pending-sync': isItemPendingSync(item) }">
<div class="neo-item-content">
<!-- Drag Handle -->
<div class="drag-handle" v-if="isOnline">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="9" cy="12" r="1"></circle>
<circle cx="9" cy="5" r="1"></circle>
<circle cx="9" cy="19" r="1"></circle>
<circle cx="15" cy="12" r="1"></circle>
<circle cx="15" cy="5" r="1"></circle>
<circle cx="15" cy="19" r="1"></circle>
</svg>
</div>
<!-- Content when NOT editing -->
<template v-if="!item.isEditing">
<label class="neo-checkbox-label" @click.stop>
<input type="checkbox" :checked="item.is_complete" @change="handleCheckboxChange(item, $event)" />
<div class="checkbox-content">
<span class="checkbox-text-span"
:class="{ 'neo-completed-static': item.is_complete && !item.updating }">
{{ item.name }}
</span>
<span v-if="item.quantity" class="text-sm text-gray-500 ml-1">× {{ item.quantity }}</span>
<div v-if="item.is_complete" class="neo-price-input">
<VInput type="number" :model-value="item.priceInput || ''"
@update:modelValue="item.priceInput = $event"
:placeholder="$t('listDetailPage.items.pricePlaceholder')" size="sm" class="w-24" step="0.01"
@blur="updateItemPrice(item)"
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()" />
<div v-for="group in groupedItems" :key="group.categoryName" class="category-group"
:class="{ 'highlight': supermarktMode && group.items.some(i => i.is_complete) }">
<h3 v-if="group.items.length" class="category-header">{{ group.categoryName }}</h3>
<draggable v-model="group.items" item-key="id" handle=".drag-handle" @end="handleDragEnd"
:disabled="!isOnline" class="neo-item-list">
<template #item="{ element: item }">
<li class="neo-list-item"
:class="{ 'bg-gray-100 opacity-70': item.is_complete, 'item-pending-sync': isItemPendingSync(item) }">
<div class="neo-item-content">
<!-- Drag Handle -->
<div class="drag-handle" v-if="isOnline">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="9" cy="12" r="1"></circle>
<circle cx="9" cy="5" r="1"></circle>
<circle cx="9" cy="19" r="1"></circle>
<circle cx="15" cy="12" r="1"></circle>
<circle cx="15" cy="5" r="1"></circle>
<circle cx="15" cy="19" r="1"></circle>
</svg>
</div>
<!-- Content when NOT editing -->
<template v-if="!item.isEditing">
<label class="neo-checkbox-label" @click.stop>
<input type="checkbox" :checked="item.is_complete" @change="handleCheckboxChange(item, $event)" />
<div class="checkbox-content">
<span class="checkbox-text-span"
:class="{ 'neo-completed-static': item.is_complete && !item.updating }">
{{ item.name }}
</span>
<span v-if="item.quantity" class="text-sm text-gray-500 ml-1">× {{ item.quantity }}</span>
<div v-if="item.is_complete" class="neo-price-input">
<VInput type="number" :model-value="item.priceInput || ''"
@update:modelValue="item.priceInput = $event"
:placeholder="$t('listDetailPage.items.pricePlaceholder')" size="sm" class="w-24"
step="0.01" @blur="updateItemPrice(item)"
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()" />
</div>
</div>
</label>
<div class="neo-item-actions">
<button class="neo-icon-button neo-edit-button" @click.stop="startItemEdit(item)"
:aria-label="$t('listDetailPage.items.editItemAriaLabel')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
<button class="neo-icon-button neo-delete-button" @click.stop="deleteItem(item)"
:disabled="item.deleting" :aria-label="$t('listDetailPage.items.deleteItemAriaLabel')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"></path>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2">
</path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</button>
</div>
</label>
<div class="neo-item-actions">
<button class="neo-icon-button neo-edit-button" @click.stop="startItemEdit(item)"
:aria-label="$t('listDetailPage.items.editItemAriaLabel')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
<button class="neo-icon-button neo-delete-button" @click.stop="deleteItem(item)"
:disabled="item.deleting" :aria-label="$t('listDetailPage.items.deleteItemAriaLabel')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"></path>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</button>
</div>
</template>
<!-- Content WHEN editing -->
<template v-else>
<div class="inline-edit-form flex-grow flex items-center gap-2">
<VInput type="text" :model-value="item.editName ?? ''" @update:modelValue="item.editName = $event"
required class="flex-grow" size="sm" @keydown.enter.prevent="saveItemEdit(item)"
@keydown.esc.prevent="cancelItemEdit(item)" />
<VInput type="number" :model-value="item.editQuantity || ''"
@update:modelValue="item.editQuantity = $event" min="1" class="w-20" size="sm"
@keydown.enter.prevent="saveItemEdit(item)" @keydown.esc.prevent="cancelItemEdit(item)" />
</div>
<div class="neo-item-actions">
<button class="neo-icon-button neo-save-button" @click.stop="saveItemEdit(item)"
:aria-label="$t('listDetailPage.buttons.saveChanges')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
<polyline points="17 21 17 13 7 13 7 21"></polyline>
<polyline points="7 3 7 8 15 8"></polyline>
</svg>
</button>
<button class="neo-icon-button neo-cancel-button" @click.stop="cancelItemEdit(item)"
:aria-label="$t('listDetailPage.buttons.cancel')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="15" y1="9" x2="9" y2="15"></line>
<line x1="9" y1="9" x2="15" y2="15"></line>
</svg>
</button>
<button class="neo-icon-button neo-delete-button" @click.stop="deleteItem(item)"
:disabled="item.deleting" :aria-label="$t('listDetailPage.items.deleteItemAriaLabel')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"></path>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</button>
</div>
</template>
</div>
</li>
</template>
</draggable>
</template>
<!-- Content WHEN editing -->
<template v-else>
<div class="inline-edit-form flex-grow flex items-center gap-2">
<VInput type="text" :model-value="item.editName ?? ''" @update:modelValue="item.editName = $event"
required class="flex-grow" size="sm" @keydown.enter.prevent="saveItemEdit(item)"
@keydown.esc.prevent="cancelItemEdit(item)" />
<VInput type="number" :model-value="item.editQuantity || ''"
@update:modelValue="item.editQuantity = $event" min="1" class="w-20" size="sm"
@keydown.enter.prevent="saveItemEdit(item)" @keydown.esc.prevent="cancelItemEdit(item)" />
<VSelect :model-value="item.editCategoryId" @update:modelValue="item.editCategoryId = $event"
:options="categoryOptions" placeholder="Category" class="w-40" size="sm" />
</div>
<div class="neo-item-actions">
<button class="neo-icon-button neo-save-button" @click.stop="saveItemEdit(item)"
:aria-label="$t('listDetailPage.buttons.saveChanges')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
<polyline points="17 21 17 13 7 13 7 21"></polyline>
<polyline points="7 3 7 8 15 8"></polyline>
</svg>
</button>
<button class="neo-icon-button neo-cancel-button" @click.stop="cancelItemEdit(item)"
:aria-label="$t('listDetailPage.buttons.cancel')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="15" y1="9" x2="9" y2="15"></line>
<line x1="9" y1="9" x2="15" y2="15"></line>
</svg>
</button>
<button class="neo-icon-button neo-delete-button" @click.stop="deleteItem(item)"
:disabled="item.deleting" :aria-label="$t('listDetailPage.items.deleteItemAriaLabel')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"></path>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2">
</path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</button>
</div>
</template>
</div>
</li>
</template>
</draggable>
</div>
<!-- New Add Item LI, integrated into the list -->
<li class="neo-list-item new-item-input-container">
@ -163,6 +178,8 @@
:placeholder="$t('listDetailPage.items.addItemForm.placeholder')" ref="itemNameInputRef"
:data-list-id="list?.id" @keyup.enter="onAddItem" @blur="handleNewItemBlur" v-model="newItem.name"
@click.stop />
<VSelect v-model="newItem.category_id" :options="categoryOptions" placeholder="Category" class="w-40"
size="sm" />
</label>
</li>
@ -379,10 +396,10 @@
<template #footer>
<VButton variant="neutral" @click="closeSettleShareModal">{{
$t('listDetailPage.modals.settleShare.cancelButton')
}}</VButton>
}}</VButton>
<VButton variant="primary" @click="handleConfirmSettle">{{
$t('listDetailPage.modals.settleShare.confirmButton')
}}</VButton>
}}</VButton>
</template>
</VModal>
@ -393,9 +410,9 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue';
import { useRoute } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { apiClient, API_ENDPOINTS } from '@/config/api';
import { apiClient, API_ENDPOINTS } from '@/services/api';
import { useEventListener, useFileDialog, useNetwork } from '@vueuse/core';
import { useNotificationStore } from '@/stores/notifications';
import { useOfflineStore, type CreateListItemPayload } from '@/stores/offline';
@ -421,7 +438,12 @@ import VInput from '@/components/valerie/VInput.vue';
import VList from '@/components/valerie/VList.vue';
import VListItem from '@/components/valerie/VListItem.vue';
import VCheckbox from '@/components/valerie/VCheckbox.vue';
import VProgressBar from '@/components/valerie/VProgressBar.vue';
import VToggleSwitch from '@/components/valerie/VToggleSwitch.vue';
import draggable from 'vuedraggable';
import { useCategoryStore } from '@/stores/categoryStore';
import { storeToRefs } from 'pinia';
import ExpenseCard from '@/components/ExpenseCard.vue';
const { t } = useI18n();
@ -469,6 +491,7 @@ interface ItemWithUI extends Item {
isEditing?: boolean; // For inline editing state
editName?: string; // Temporary name for inline editing
editQuantity?: number | string | null; // Temporary quantity for inline editing
editCategoryId?: number | null; // Temporary category for inline editing
showFirework?: boolean; // For firework animation
}
@ -523,9 +546,21 @@ const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null);
const lastListUpdate = ref<string | null>(null);
const lastItemCount = ref<number | null>(null);
const newItem = ref<{ name: string; quantity?: number | string }>({ name: '' });
const supermarktMode = ref(false);
const categoryStore = useCategoryStore();
const { categories } = storeToRefs(categoryStore);
const newItem = ref<{ name: string; quantity?: number | string; category_id?: number | null }>({ name: '', category_id: null });
const itemNameInputRef = ref<InstanceType<typeof VInput> | null>(null);
const categoryOptions = computed(() => {
return [
{ label: 'No Category', value: null },
...categories.value.map(c => ({ label: c.name, value: c.id })),
];
});
// OCR
const showOcrDialogState = ref(false);
// const ocrModalRef = ref<HTMLElement | null>(null); // Removed
@ -547,6 +582,12 @@ const listCostSummary = ref<ListCostSummaryData | null>(null);
const costSummaryLoading = ref(false);
const costSummaryError = ref<string | null>(null);
const itemCompletionProgress = computed(() => {
if (!list.value?.items.length) return 0;
const completedCount = list.value.items.filter(i => i.is_complete).length;
return (completedCount / list.value.items.length) * 100;
});
// Settle Share
const authStore = useAuthStore();
const showSettleModal = ref(false);
@ -703,6 +744,7 @@ const onAddItem = async () => {
is_complete: false,
price: null,
version: 1,
category_id: newItem.value.category_id,
updated_at: new Date().toISOString(),
created_at: new Date().toISOString(),
list_id: list.value.id,
@ -715,6 +757,7 @@ const onAddItem = async () => {
list.value.items.push(optimisticItem);
newItem.value.name = '';
newItem.value.category_id = null;
if (itemNameInputRef.value?.$el) {
(itemNameInputRef.value.$el as HTMLElement).focus();
}
@ -733,6 +776,9 @@ const onAddItem = async () => {
offlinePayload.quantity = String(rawQuantity);
}
}
if (newItem.value.category_id) {
offlinePayload.category_id = newItem.value.category_id;
}
offlineStore.addAction({
type: 'create_list_item',
@ -752,7 +798,8 @@ const onAddItem = async () => {
API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)),
{
name: itemName,
quantity: newItem.value.quantity ? String(newItem.value.quantity) : null
quantity: newItem.value.quantity ? String(newItem.value.quantity) : null,
category_id: newItem.value.category_id,
}
);
@ -1105,6 +1152,9 @@ onMounted(() => {
}
}
// Fetch categories relevant to the list (either personal or group)
categoryStore.fetchCategories(list.value?.group_id);
fetchListDetails().then(() => {
startPolling();
});
@ -1121,6 +1171,7 @@ const startItemEdit = (item: ItemWithUI) => {
item.isEditing = true;
item.editName = item.name;
item.editQuantity = item.quantity ?? '';
item.editCategoryId = item.category_id;
};
const cancelItemEdit = (item: ItemWithUI) => {
@ -1140,6 +1191,7 @@ const saveItemEdit = async (item: ItemWithUI) => {
name: String(item.editName).trim(),
quantity: item.editQuantity ? String(item.editQuantity) : null,
version: item.version,
category_id: item.editCategoryId,
};
item.updating = true;
@ -1157,6 +1209,7 @@ const saveItemEdit = async (item: ItemWithUI) => {
item.is_complete = updatedItemFromApi.is_complete;
item.price = updatedItemFromApi.price;
item.updated_at = updatedItemFromApi.updated_at;
item.category_id = updatedItemFromApi.category_id;
item.isEditing = false;
notificationStore.addNotification({
@ -1310,6 +1363,24 @@ const isExpenseExpanded = (expenseId: number) => {
return expandedExpenses.value.has(expenseId);
};
const groupedItems = computed(() => {
if (!list.value?.items) return [];
const groups: Record<string, { categoryName: string; items: ItemWithUI[] }> = {};
list.value.items.forEach(item => {
const categoryId = item.category_id;
const category = categories.value.find(c => c.id === categoryId);
const categoryName = category ? category.name : t('listDetailPage.items.noCategory');
if (!groups[categoryName]) {
groups[categoryName] = { categoryName, items: [] };
}
groups[categoryName].items.push(item);
});
return Object.values(groups);
});
</script>
<style scoped>
@ -1999,4 +2070,18 @@ const isExpenseExpanded = (expenseId: number) => {
background: white;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.category-group {
margin-bottom: 1.5rem;
}
.category-header {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 0.75rem;
}
.category-group.highlight .neo-list-item:not(.is-complete) {
background-color: #e6f7ff;
}
</style>

View File

@ -1,12 +1,17 @@
<template>
<main class="container page-padding">
<div class="flex justify-between items-center mb-4">
<h1 class="text-2xl font-bold">{{ pageTitle }}</h1>
<VToggleSwitch v-model="showArchived" :label="t('listsPage.showArchived')" />
</div>
<VAlert v-if="error" type="error" :message="error" class="mb-3" :closable="false">
<template #actions>
<VButton variant="danger" size="sm" @click="fetchListsAndGroups">{{ t('listsPage.retryButton') }}</VButton>
</template>
</VAlert>
<VCard v-else-if="lists.length === 0 && !loading" variant="empty-state" empty-icon="clipboard"
<VCard v-else-if="filteredLists.length === 0 && !loading" variant="empty-state" empty-icon="clipboard"
:empty-title="t(noListsMessageKey)">
<template #default>
<p v-if="!currentGroupId">{{ t('listsPage.emptyState.personalGlobalInfo') }}</p>
@ -19,17 +24,24 @@
</template>
</VCard>
<div v-else-if="loading && lists.length === 0" class="loading-placeholder">
<div v-else-if="loading && filteredLists.length === 0" class="loading-placeholder">
{{ t('listsPage.loadingLists') }}
</div>
<div v-else>
<div class="neo-lists-grid">
<div v-for="list in lists" :key="list.id" class="neo-list-card"
:class="{ 'touch-active': touchActiveListId === list.id }" @click="navigateToList(list.id)"
@touchstart.passive="handleTouchStart(list.id)" @touchend.passive="handleTouchEnd"
@touchcancel.passive="handleTouchEnd" :data-list-id="list.id">
<div class="neo-list-header">{{ list.name }}</div>
<div v-for="list in filteredLists" :key="list.id" class="neo-list-card"
:class="{ 'touch-active': touchActiveListId === list.id, 'archived': list.archived_at }"
@click="navigateToList(list.id)" @touchstart.passive="handleTouchStart(list.id)"
@touchend.passive="handleTouchEnd" @touchcancel.passive="handleTouchEnd" :data-list-id="list.id">
<div class="neo-list-header">
<span>{{ list.name }}</span>
<div class="actions">
<VButton v-if="!list.archived_at" @click.stop="archiveList(list)" variant="danger" size="sm"
icon="archive" />
<VButton v-else @click.stop="unarchiveList(list)" variant="success" size="sm" icon="unarchive" />
</div>
</div>
<div class="neo-list-desc">{{ list.description || t('listsPage.noDescription') }}</div>
<ul class="neo-item-list">
<li v-for="item in list.items" :key="item.id || item.tempId" class="neo-list-item" :data-item-id="item.id"
@ -44,7 +56,7 @@
</div>
</label>
</li>
<li class="neo-list-item new-item-input-container">
<li v-if="!list.archived_at" class="neo-list-item new-item-input-container">
<label class="neo-checkbox-label">
<input type="checkbox" disabled />
<input type="text" class="neo-new-item-input" :placeholder="t('listsPage.addItemPlaceholder')"
@ -68,12 +80,13 @@
import { ref, onMounted, computed, watch, onUnmounted, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { apiClient, API_ENDPOINTS } from '@/config/api';
import { apiClient, API_ENDPOINTS } from '@/services/api';
import CreateListModal from '@/components/CreateListModal.vue';
import { useStorage } from '@vueuse/core';
import VAlert from '@/components/valerie/VAlert.vue';
import VCard from '@/components/valerie/VCard.vue';
import VButton from '@/components/valerie/VButton.vue';
import VToggleSwitch from '@/components/valerie/VToggleSwitch.vue';
const { t } = useI18n();
@ -95,6 +108,7 @@ interface List {
created_at: string;
version: number;
items: Item[];
archived_at?: string | null;
}
interface Group {
@ -125,6 +139,8 @@ const router = useRouter();
const loading = ref(true);
const error = ref<string | null>(null);
const lists = ref<(List & { items: Item[] })[]>([]);
const archivedLists = ref<List[]>([]);
const haveFetchedArchived = ref(false);
const allFetchedGroups = ref<Group[]>([]);
const currentViewedGroup = ref<Group | null>(null);
const showCreateModal = ref(false);
@ -220,6 +236,18 @@ const fetchLists = async () => {
}
};
const fetchArchivedLists = async () => {
if (haveFetchedArchived.value) return;
try {
const endpoint = API_ENDPOINTS.LISTS.ARCHIVED;
const response = await apiClient.get(endpoint);
archivedLists.value = response.data as List[];
haveFetchedArchived.value = true;
} catch (err) {
console.error('Failed to fetch archived lists:', err);
}
};
const fetchListsAndGroups = async () => {
loading.value = true;
try {
@ -494,6 +522,54 @@ const stopPolling = () => {
}
};
const showArchived = ref(false);
watch(showArchived, (isShowing) => {
if (isShowing) {
fetchArchivedLists();
}
});
const filteredLists = computed(() => {
if (showArchived.value) {
const combined = [...lists.value, ...archivedLists.value];
const uniqueLists = Array.from(new Map(combined.map(l => [l.id, l])).values());
return uniqueLists.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
}
return lists.value.filter(list => !list.archived_at);
});
const archiveList = async (list: List) => {
try {
await apiClient.delete(API_ENDPOINTS.LISTS.BY_ID(list.id.toString()));
list.archived_at = new Date().toISOString();
const listIndex = lists.value.findIndex(l => l.id === list.id);
if (listIndex > -1) {
const [archivedItem] = lists.value.splice(listIndex, 1);
archivedLists.value.push(archivedItem);
}
} catch (error) {
console.error('Failed to archive list', error);
}
};
const unarchiveList = async (list: List) => {
try {
const response = await apiClient.post(API_ENDPOINTS.LISTS.UNARCHIVE(list.id.toString()));
const unarchivedList = response.data as List;
const listIndex = archivedLists.value.findIndex(l => l.id === list.id);
if (listIndex > -1) {
archivedLists.value.splice(listIndex, 1);
}
lists.value.push({ ...unarchivedList, items: unarchivedList.items || [] });
} catch (error) {
console.error('Failed to unarchive list', error);
}
};
onMounted(() => {
loadCachedData();
fetchListsAndGroups().then(() => {
@ -506,6 +582,8 @@ onMounted(() => {
watch(currentGroupId, () => {
loadCachedData();
haveFetchedArchived.value = false;
archivedLists.value = [];
fetchListsAndGroups().then(() => {
if (lists.value.length > 0) {
setupIntersectionObserver();
@ -918,4 +996,14 @@ onUnmounted(() => {
.item-appear {
animation: item-appear 0.35s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
.archived {
opacity: 0.6;
background-color: #f0f0f0;
}
.actions {
display: flex;
gap: 0.5rem;
}
</style>

View File

@ -34,6 +34,12 @@
{{ t('loginPage.loginButton') }}
</button>
<div class="divider my-3">or</div>
<button type="button" class="btn btn-secondary w-full" @click="handleGuestLogin" :disabled="loading">
Continue as Guest
</button>
<div class="text-center mt-2">
<router-link to="/auth/signup" class="link-styled">{{ t('loginPage.signupLink') }}</router-link>
</div>
@ -103,6 +109,24 @@ const onSubmit = async () => {
loading.value = false;
}
};
const handleGuestLogin = async () => {
loading.value = true;
formErrors.value.general = undefined;
try {
await authStore.loginAsGuest();
notificationStore.addNotification({ message: 'Welcome, Guest!', type: 'success' });
const redirectPath = (route.query.redirect as string) || '/';
router.push(redirectPath);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Failed to login as guest.';
formErrors.value.general = message;
console.error(message, error);
notificationStore.addNotification({ message, type: 'error' });
} finally {
loading.value = false;
}
};
</script>
<style scoped>
@ -117,6 +141,30 @@ const onSubmit = async () => {
max-width: 400px;
}
.divider {
display: flex;
align-items: center;
text-align: center;
color: #ccc;
font-size: 0.8em;
text-transform: uppercase;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
border-bottom: 1px solid #eee;
}
.divider:not(:empty)::before {
margin-right: .5em;
}
.divider:not(:empty)::after {
margin-left: .5em;
}
.link-styled {
color: var(--primary);
text-decoration: none;

View File

@ -1,11 +1,13 @@
import { mount, flushPromises } from '@vue/test-utils';
import AccountPage from '../AccountPage.vue'; // Adjust path
import { apiClient, API_ENDPOINTS as MOCK_API_ENDPOINTS } from '@/config/api';
import { apiClient, API_ENDPOINTS as MOCK_API_ENDPOINTS } from '@/services/api';
import { useNotificationStore } from '@/stores/notifications';
import { vi } from 'vitest';
import { useAuthStore } from '@/stores/auth';
import { createTestingPinia } from '@pinia/testing';
// --- Mocks ---
vi.mock('@/config/api', () => ({
vi.mock('@/services/api', () => ({
apiClient: {
get: vi.fn(),
put: vi.fn(),
@ -69,7 +71,7 @@ describe('AccountPage.vue', () => {
describe('Rendering and Initial Data Fetching', () => {
it('renders loading state initially', async () => {
mockApiClient.get.mockImplementationOnce(() => new Promise(() => {})); // Keep it pending
mockApiClient.get.mockImplementationOnce(() => new Promise(() => { })); // Keep it pending
const wrapper = createWrapper();
expect(wrapper.text()).toContain('Loading profile...');
expect(wrapper.find('.spinner-dots').exists()).toBe(true);
@ -128,23 +130,23 @@ describe('AccountPage.vue', () => {
});
it('handles profile update failure', async () => {
const wrapper = createWrapper();
await flushPromises();
mockApiClient.put.mockRejectedValueOnce(new Error('Update failed'));
await wrapper.find('form').trigger('submit.prevent');
await flushPromises();
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Update failed', type: 'error' });
});
const wrapper = createWrapper();
await flushPromises();
mockApiClient.put.mockRejectedValueOnce(new Error('Update failed'));
await wrapper.find('form').trigger('submit.prevent');
await flushPromises();
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Update failed', type: 'error' });
});
});
describe('Change Password Form', () => {
let wrapper: ReturnType<typeof createWrapper>;
beforeEach(async () => {
wrapper = createWrapper();
await flushPromises(); // Initial load
});
wrapper = createWrapper();
await flushPromises(); // Initial load
});
it('changes password successfully', async () => {
await wrapper.find('#currentPassword').setValue('currentPass123');
@ -164,81 +166,81 @@ describe('AccountPage.vue', () => {
expect(wrapper.find<HTMLInputElement>('#currentPassword').element.value).toBe('');
expect(wrapper.find<HTMLInputElement>('#newPassword').element.value).toBe('');
});
it('shows validation error if new password is too short', async () => {
await wrapper.find('#currentPassword').setValue('currentPass123');
await wrapper.find('#newPassword').setValue('short');
const forms = wrapper.findAll('form');
await forms[1].trigger('submit.prevent');
await flushPromises();
expect(mockApiClient.put).not.toHaveBeenCalled();
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({
message: 'New password must be at least 8 characters long.', type: 'warning'
});
});
it('shows validation error if fields are empty', async () => {
const forms = wrapper.findAll('form');
await forms[1].trigger('submit.prevent'); // Submit with empty fields
await flushPromises();
expect(mockApiClient.put).not.toHaveBeenCalled();
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({
message: 'Please fill in both current and new password fields.', type: 'warning'
});
it('shows validation error if new password is too short', async () => {
await wrapper.find('#currentPassword').setValue('currentPass123');
await wrapper.find('#newPassword').setValue('short');
const forms = wrapper.findAll('form');
await forms[1].trigger('submit.prevent');
await flushPromises();
expect(mockApiClient.put).not.toHaveBeenCalled();
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({
message: 'New password must be at least 8 characters long.', type: 'warning'
});
});
it('shows validation error if fields are empty', async () => {
const forms = wrapper.findAll('form');
await forms[1].trigger('submit.prevent'); // Submit with empty fields
await flushPromises();
expect(mockApiClient.put).not.toHaveBeenCalled();
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({
message: 'Please fill in both current and new password fields.', type: 'warning'
});
});
it('handles password change failure', async () => {
await wrapper.find('#currentPassword').setValue('currentPass123');
await wrapper.find('#newPassword').setValue('newPasswordSecure');
mockApiClient.put.mockRejectedValueOnce(new Error('Password change failed'));
const forms = wrapper.findAll('form');
await forms[1].trigger('submit.prevent');
await flushPromises();
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Password change failed', type: 'error' });
});
await wrapper.find('#currentPassword').setValue('currentPass123');
await wrapper.find('#newPassword').setValue('newPasswordSecure');
mockApiClient.put.mockRejectedValueOnce(new Error('Password change failed'));
const forms = wrapper.findAll('form');
await forms[1].trigger('submit.prevent');
await flushPromises();
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Password change failed', type: 'error' });
});
});
describe('Notification Preferences', () => {
it('updates preferences successfully when a toggle is changed', async () => {
const wrapper = createWrapper();
await flushPromises(); // Initial load
const emailNotificationsToggle = wrapper.findAll<HTMLInputElement>('input[type="checkbox"]')[0];
const initialEmailPref = mockPreferencesData.emailNotifications;
expect(emailNotificationsToggle.element.checked).toBe(initialEmailPref);
mockApiClient.put.mockResolvedValueOnce({ data: {} }); // Mock successful preference update
await emailNotificationsToggle.setValue(!initialEmailPref); // This also triggers @change
await flushPromises();
const expectedPreferences = { ...mockPreferencesData, emailNotifications: !initialEmailPref };
expect(mockApiClient.put).toHaveBeenCalledWith(
MOCK_API_ENDPOINTS.USERS.PREFERENCES,
expectedPreferences
);
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Preferences updated successfully', type: 'success' });
});
const wrapper = createWrapper();
await flushPromises(); // Initial load
it('handles preference update failure', async () => {
const wrapper = createWrapper();
await flushPromises();
const listUpdatesToggle = wrapper.findAll<HTMLInputElement>('input[type="checkbox"]')[1];
const initialListPref = mockPreferencesData.listUpdates;
mockApiClient.put.mockRejectedValueOnce(new Error('Pref update failed'));
await listUpdatesToggle.setValue(!initialListPref);
await flushPromises();
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Pref update failed', type: 'error' });
// Optionally, test if the toggle reverts, though the current component code doesn't explicitly do that on error.
});
const emailNotificationsToggle = wrapper.findAll<HTMLInputElement>('input[type="checkbox"]')[0];
const initialEmailPref = mockPreferencesData.emailNotifications;
expect(emailNotificationsToggle.element.checked).toBe(initialEmailPref);
mockApiClient.put.mockResolvedValueOnce({ data: {} }); // Mock successful preference update
await emailNotificationsToggle.setValue(!initialEmailPref); // This also triggers @change
await flushPromises();
const expectedPreferences = { ...mockPreferencesData, emailNotifications: !initialEmailPref };
expect(mockApiClient.put).toHaveBeenCalledWith(
MOCK_API_ENDPOINTS.USERS.PREFERENCES,
expectedPreferences
);
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Preferences updated successfully', type: 'success' });
});
it('handles preference update failure', async () => {
const wrapper = createWrapper();
await flushPromises();
const listUpdatesToggle = wrapper.findAll<HTMLInputElement>('input[type="checkbox"]')[1];
const initialListPref = mockPreferencesData.listUpdates;
mockApiClient.put.mockRejectedValueOnce(new Error('Pref update failed'));
await listUpdatesToggle.setValue(!initialListPref);
await flushPromises();
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Pref update failed', type: 'error' });
// Optionally, test if the toggle reverts, though the current component code doesn't explicitly do that on error.
});
});
});

View File

@ -58,6 +58,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/pages/ChoresPage.vue'),
meta: { requiresAuth: true, keepAlive: false },
},
{
path: '/expenses',
name: 'Expenses',
component: () => import('@/pages/ExpensePage.vue'),
meta: { requiresAuth: true, keepAlive: false },
},
],
},
{

View File

@ -1,5 +1,5 @@
import axios from 'axios'
import { API_BASE_URL, API_ENDPOINTS } from '@/config/api-config' // api-config.ts can be moved to src/config/
import { API_BASE_URL, API_VERSION, API_ENDPOINTS } from '@/config/api-config' // api-config.ts can be moved to src/config/
import router from '@/router' // Import the router instance
import { useAuthStore } from '@/stores/auth' // Import the auth store
import type { SettlementActivityCreate, SettlementActivityPublic } from '@/types/expense' // Import the types for the payload and response
@ -7,7 +7,7 @@ import { stringify } from 'qs';
// Create axios instance
const api = axios.create({
baseURL: API_BASE_URL, // API_BASE_URL should come from env or config
baseURL: `${API_BASE_URL}/api/${API_VERSION}`,
headers: {
'Content-Type': 'application/json',
},

View File

@ -1,13 +1,13 @@
import { api } from './api'
import type { Chore, ChoreCreate, ChoreUpdate, ChoreType, ChoreAssignment, ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreHistory } from '../types/chore'
import { groupService } from './groupService'
import { apiClient, API_ENDPOINTS } from '@/config/api'
import { apiClient, API_ENDPOINTS } from '@/services/api'
import type { Group } from '@/types/group'
export const choreService = {
async getAllChores(): Promise<Chore[]> {
try {
const response = await api.get('/api/v1/chores/all')
const response = await api.get('/chores/all')
return response.data
} catch (error) {
console.error('Failed to get all chores via optimized endpoint, falling back to individual calls:', error)

View File

@ -1,5 +1,5 @@
import { apiClient, API_ENDPOINTS } from '@/config/api';
import type { Group } from '@/types/group';
import { apiClient, API_ENDPOINTS } from '@/services/api';
import type { Group, GroupCreate, GroupUpdate } from '@/types/group';
import type { ChoreHistory } from '@/types/chore';
export const groupService = {

View File

@ -11,6 +11,7 @@ export interface AuthState {
email: string
name: string
id?: string | number
is_guest?: boolean
} | null
}
@ -23,6 +24,7 @@ export const useAuthStore = defineStore('auth', () => {
// Getters
const isAuthenticated = computed(() => !!accessToken.value)
const getUser = computed(() => user.value)
const isGuest = computed(() => user.value?.is_guest ?? false)
// Actions
const setTokens = (tokens: { access_token: string; refresh_token?: string }) => {
@ -109,6 +111,14 @@ export const useAuthStore = defineStore('auth', () => {
return response.data
}
const loginAsGuest = async () => {
const response = await api.post(API_ENDPOINTS.AUTH.GUEST)
const { access_token, refresh_token } = response.data
setTokens({ access_token, refresh_token })
await fetchCurrentUser()
return response.data
}
const signup = async (userData: { name: string; email: string; password: string }) => {
const response = await api.post(API_ENDPOINTS.AUTH.SIGNUP, userData)
return response.data
@ -125,11 +135,13 @@ export const useAuthStore = defineStore('auth', () => {
refreshToken,
isAuthenticated,
getUser,
isGuest,
setTokens,
clearTokens,
setUser,
fetchCurrentUser,
login,
loginAsGuest,
signup,
logout,
}

View File

@ -0,0 +1,86 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { apiClient, API_ENDPOINTS } from '@/services/api';
import type { Category } from '@/types/category';
export interface CategoryCreate {
name: string;
group_id?: number;
}
export const useCategoryStore = defineStore('category', () => {
const categories = ref<Category[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
async function fetchCategories(groupId?: number) {
loading.value = true;
error.value = null;
try {
const endpoint = API_ENDPOINTS.CATEGORIES.BASE;
const params = groupId ? { group_id: groupId } : {};
const response = await apiClient.get(endpoint, { params });
categories.value = response.data;
} catch (e) {
error.value = 'Failed to fetch categories.';
console.error(e);
} finally {
loading.value = false;
}
}
async function createCategory(categoryData: CategoryCreate) {
loading.value = true;
error.value = null;
try {
const response = await apiClient.post(API_ENDPOINTS.CATEGORIES.BASE, categoryData);
categories.value.push(response.data);
} catch (e) {
error.value = 'Failed to create category.';
console.error(e);
} finally {
loading.value = false;
}
}
async function updateCategory(id: number, categoryData: Partial<CategoryCreate>) {
loading.value = true;
error.value = null;
try {
const response = await apiClient.put(API_ENDPOINTS.CATEGORIES.BY_ID(id.toString()), categoryData);
const index = categories.value.findIndex(c => c.id === id);
if (index !== -1) {
categories.value[index] = response.data;
}
} catch (e) {
error.value = 'Failed to update category.';
console.error(e);
} finally {
loading.value = false;
}
}
async function deleteCategory(id: number) {
loading.value = true;
error.value = null;
try {
await apiClient.delete(API_ENDPOINTS.CATEGORIES.BY_ID(id.toString()));
categories.value = categories.value.filter(c => c.id !== id);
} catch (e) {
error.value = 'Failed to delete category.';
console.error(e);
} finally {
loading.value = false;
}
}
return {
categories,
loading,
error,
fetchCategories,
createCategory,
updateCategory,
deleteCategory,
};
});

View File

@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { apiClient, API_ENDPOINTS } from '@/config/api'
import { apiClient, API_ENDPOINTS } from '@/services/api'
import type {
Expense,
ExpenseSplit,

View File

@ -20,7 +20,8 @@ export type CreateListItemPayload = {
name: string
quantity?: number | string
completed?: boolean
price?: number | null /* other item properties */
price?: number | null
category_id?: number | null
}
}
export type UpdateListItemPayload = {

View File

@ -0,0 +1,79 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { apiClient, API_ENDPOINTS } from '@/services/api';
import type { TimeEntry, TimeEntryCreate, TimeEntryUpdate } from '@/types';
export interface TimeEntry {
id: number;
chore_assignment_id: number;
user_id: number;
start_time: string;
end_time?: string | null;
duration_seconds?: number | null;
}
export const useTimeEntryStore = defineStore('timeEntry', () => {
const timeEntries = ref<Record<number, TimeEntry[]>>({});
const loading = ref(false);
const error = ref<string | null>(null);
async function fetchTimeEntriesForAssignment(assignmentId: number) {
loading.value = true;
error.value = null;
try {
const response = await apiClient.get(`${API_ENDPOINTS.CHORES.ASSIGNMENT_BY_ID(assignmentId)}/time-entries`);
timeEntries.value[assignmentId] = response.data;
} catch (e) {
error.value = 'Failed to fetch time entries.';
console.error(e);
} finally {
loading.value = false;
}
}
async function startTimeEntry(assignmentId: number) {
loading.value = true;
error.value = null;
try {
const response = await apiClient.post(`${API_ENDPOINTS.CHORES.ASSIGNMENT_BY_ID(assignmentId)}/time-entries`);
if (!timeEntries.value[assignmentId]) {
timeEntries.value[assignmentId] = [];
}
timeEntries.value[assignmentId].push(response.data);
} catch (e) {
error.value = 'Failed to start time entry.';
console.error(e);
} finally {
loading.value = false;
}
}
async function stopTimeEntry(assignmentId: number, timeEntryId: number) {
loading.value = true;
error.value = null;
try {
const response = await apiClient.put(`${API_ENDPOINTS.CHORES.TIME_ENTRY(timeEntryId)}`);
const assignmentEntries = timeEntries.value[assignmentId];
if (assignmentEntries) {
const index = assignmentEntries.findIndex(te => te.id === timeEntryId);
if (index !== -1) {
assignmentEntries[index] = response.data;
}
}
} catch (e) {
error.value = 'Failed to stop time entry.';
console.error(e);
} finally {
loading.value = false;
}
}
return {
timeEntries,
loading,
error,
fetchTimeEntriesForAssignment,
startTimeEntry,
stopTimeEntry,
};
});

View File

@ -1,4 +1,4 @@
import type { User } from './user'
import type { UserPublic as User } from './user'
export type ChoreFrequency = 'one_time' | 'daily' | 'weekly' | 'monthly' | 'custom'
export type ChoreType = 'personal' | 'group'
@ -69,3 +69,15 @@ export interface ChoreAssignmentHistory {
changed_by_user?: User
timestamp: string
}
export interface ChoreWithCompletion extends Chore {
current_assignment_id: number | null;
is_completed: boolean;
completed_at: string | null;
updating: boolean;
assigned_user_name?: string;
completed_by_name?: string;
parent_chore_id?: number | null;
child_chores?: ChoreWithCompletion[];
subtext?: string;
}

View File

@ -50,16 +50,15 @@ export interface ExpenseSplit {
id: number
expense_id: number
user_id: number
user?: UserPublic | null
owed_amount: string // String representation of Decimal
share_percentage?: string | null
share_units?: number | null
created_at: string
updated_at: string
status: ExpenseSplitStatusEnum
paid_at?: string | null
settlement_activities: SettlementActivity[]
user?: UserPublic | null
}
export interface RecurrencePatternCreate {
@ -124,3 +123,32 @@ export interface Expense {
parentExpenseId?: number
generatedExpenses?: Expense[]
}
export type SplitType = 'EQUAL' | 'EXACT_AMOUNTS' | 'PERCENTAGE' | 'SHARES' | 'ITEM_BASED';
export type SettlementStatus = 'unpaid' | 'paid' | 'partially_paid';
export interface Expense {
id: number;
description: string;
total_amount: string; // Decimal is string
currency: string;
expense_date?: string;
split_type: SplitType;
list_id?: number;
group_id?: number;
item_id?: number;
paid_by_user_id: number;
is_recurring: boolean;
recurrence_pattern?: any;
created_at: string;
updated_at: string;
version: number;
created_by_user_id: number;
splits: ExpenseSplit[];
paid_by_user?: UserPublic;
overall_settlement_status: SettlementStatus;
next_occurrence?: string;
last_occurrence?: string;
parent_expense_id?: number;
generated_expenses: Expense[];
}

View File

@ -5,6 +5,7 @@ export interface Item {
is_complete: boolean
price?: string | null // String representation of Decimal
list_id: number
category_id?: number | null
created_at: string
updated_at: string
version: number

View File

@ -0,0 +1,22 @@
import { format, startOfDay, isEqual, isToday as isTodayDate, formatDistanceToNow, parseISO } from 'date-fns';
export const formatDateHeader = (date: Date) => {
const today = startOfDay(new Date());
const itemDate = startOfDay(date);
if (isEqual(itemDate, today)) {
return `Today, ${format(itemDate, 'eee, d MMM')}`;
}
return format(itemDate, 'eee, d MMM');
};
export const formatDuration = (seconds: number) => {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.round(seconds % 60);
return [
h > 0 ? `${h}h` : '',
m > 0 ? `${m}m` : '',
s > 0 ? `${s}s` : ''
].filter(Boolean).join(' ');
};