Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions alembic/versions/b5c6d7e8f901_add_reading_activity_and_goals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Add owned, durable goals and append-only activity without changing book data."""

from collections.abc import Sequence

import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

from alembic import op

revision: str = "b5c6d7e8f901"
down_revision: str | Sequence[str] | None = "af0fea8d6317"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
for table in ("reading_goals", "reading_activities", "goal_periods"):
op.create_table(
table,
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("owner_user_id", sa.Uuid(), nullable=False),
sa.Column("payload", postgresql.JSONB(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["owner_user_id"], ["users.user_id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(f"ix_{table}_owner_user_id", table, ["owner_user_id"])


def downgrade() -> None:
for table in ("goal_periods", "reading_activities", "reading_goals"):
op.drop_index(f"ix_{table}_owner_user_id", table_name=table)
op.drop_table(table)
4 changes: 2 additions & 2 deletions deploy/bootstrap-powersync.sql
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'powersync_role')
\gexec
ALTER ROLE powersync_role WITH REPLICATION BYPASSRLS LOGIN PASSWORD :'source_password';
GRANT USAGE ON SCHEMA public TO powersync_role;
GRANT SELECT ON TABLE public.books, public.shelves, public.tags, public.notes, public.annotations, public.bookmarks, public.book_shelves, public.book_tags, public.powersync_demo_items TO powersync_role;
GRANT SELECT ON TABLE public.books, public.shelves, public.tags, public.notes, public.annotations, public.bookmarks, public.book_shelves, public.book_tags, public.reading_goals, public.reading_activities, public.goal_periods, public.powersync_demo_items TO powersync_role;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO powersync_role;
SELECT 'CREATE PUBLICATION powersync' WHERE NOT EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'powersync')
\gexec
ALTER PUBLICATION powersync SET TABLE public.books, public.shelves, public.tags, public.notes, public.annotations, public.bookmarks, public.book_shelves, public.book_tags, public.powersync_demo_items;
ALTER PUBLICATION powersync SET TABLE public.books, public.shelves, public.tags, public.notes, public.annotations, public.bookmarks, public.book_shelves, public.book_tags, public.reading_goals, public.reading_activities, public.goal_periods, public.powersync_demo_items;
37 changes: 37 additions & 0 deletions docs-tracking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Reading tracking contract and rollout

Tracking schema version 1 is advertised additively by `GET /v1/sync/settings`.
Goals/session/statistics REST endpoints now return owned persisted records and
ledger-derived progress. Existing metric strings remain valid; `reading_days` is
additive. Goal definition, activity, and period payloads are validated in the same
transaction/owner lock used by ordinary PowerSync uploads.

`reading_goals`, `reading_activities`, and `goal_periods` store owner-scoped JSONB
payloads with UUID identities. Activity is immutable: correction/undo appends a
reversal referencing an owned original. Retries are idempotent. Shelf membership
and book-title snapshots survive book deletion. Activity/period deletion through
sync is rejected; deleting a goal retains rule/period history. Account deletion
still removes owned data.

Progress unions overlapping time intervals and per-book/day content coverage,
counts distinct confirmed books, and applies creation cutoffs, pauses, captured
IANA timezone, Monday weeks, and historical rule revisions. Period history holds
rules rather than mutable counters, allowing delayed corrections to reproject it.
Shared fixtures in `tests/fixtures/tracking_projections.json` match Dart tests.

## Deployment order

1. Apply Alembic revision `b5c6d7e8f901` (`uv run alembic upgrade head`). This is
additive and preserves existing library tables/data.
2. Add the three tracking tables to the PostgreSQL PowerSync publication and grants.
Updated `deploy/bootstrap-powersync.sql` and `scripts/setup_local_powersync.sh`
include them. Existing deployments must run the updated bootstrap; creating
database tables alone does not update an existing publication.
3. Deploy the updated PowerSync stream configuration and server capability.
4. Release the integrated client after verifying the capability and transport.

Older servers are supported by client local staging; unsupported tracking never
blocks ordinary library uploads. Versions remain unchanged in these development
PRs. Run server tests only against a separate test database; the fixtures recreate
tables. The tracking migration test verifies existing books survive and Alembic
metadata matches the upgraded schema.
146 changes: 24 additions & 122 deletions papyrus/api/routes/goals.py
Original file line number Diff line number Diff line change
@@ -1,139 +1,41 @@
"""Goal routes."""
"""Goal routes backed by owned definitions and real activity."""

from datetime import UTC, date, datetime
from uuid import UUID, uuid4
from typing import Annotated
from uuid import UUID

from fastapi import APIRouter, Response, status
from fastapi import APIRouter, Depends, Response, status
from sqlalchemy.ext.asyncio import AsyncSession

from papyrus.api.deps import CurrentUserId
from papyrus.schemas.goal import (
CreateGoalRequest,
Goal,
GoalList,
GoalType,
TimePeriod,
UpdateGoalRequest,
)
from papyrus.core.database import get_db
from papyrus.schemas.goal import CreateGoalRequest, Goal, GoalList, UpdateGoalRequest
from papyrus.services import goals as service

router = APIRouter()
DBSession = Annotated[AsyncSession, Depends(get_db)]


def _example_goal(goal_id: UUID | None = None) -> Goal:
"""Create an example goal for responses."""
today = date.today()
@router.get("", response_model=GoalList, summary="List all goals")
async def list_goals(user_id: CurrentUserId, db: DBSession, is_active: bool | None = None) -> GoalList:
return GoalList(goals=await service.list_goals(db, user_id, is_active))

return Goal(
goal_id=goal_id or uuid4(),
title="Read 12 books this year",
description="My annual reading goal",
goal_type=GoalType.BOOKS_COUNT,
target_value=12,
current_value=5,
progress_percentage=41.67,
time_period=TimePeriod.YEARLY,
start_date=today.replace(month=1, day=1),
end_date=today.replace(month=12, day=31),
is_active=True,
is_completed=False,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)

@router.post("", response_model=Goal, status_code=status.HTTP_201_CREATED, summary="Create a new goal")
async def create_goal(user_id: CurrentUserId, request: CreateGoalRequest, db: DBSession) -> Goal:
return await service.create_goal(db, user_id, request)

@router.get(
"",
response_model=GoalList,
summary="List all goals",
)
async def list_goals(
user_id: CurrentUserId,
is_active: bool | None = None,
) -> GoalList:
"""Return all goals for the user."""
return GoalList(goals=[_example_goal()])

@router.get("/{goal_id}", response_model=Goal, summary="Get goal details")
async def get_goal(user_id: CurrentUserId, goal_id: UUID, db: DBSession) -> Goal:
return await service.get_goal(db, user_id, goal_id)

@router.post(
"",
response_model=Goal,
status_code=status.HTTP_201_CREATED,
summary="Create a new goal",
)
async def create_goal(
user_id: CurrentUserId,
request: CreateGoalRequest,
) -> Goal:
"""Create a new reading goal."""
return Goal(
goal_id=uuid4(),
title=request.title,
description=request.description,
goal_type=request.goal_type,
target_value=request.target_value,
current_value=0,
progress_percentage=0.0,
time_period=request.time_period,
start_date=request.start_date,
end_date=request.end_date,
is_active=True,
is_completed=False,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)

@router.patch("/{goal_id}", response_model=Goal, summary="Update goal")
async def update_goal(user_id: CurrentUserId, goal_id: UUID, request: UpdateGoalRequest, db: DBSession) -> Goal:
return await service.update_goal(db, user_id, goal_id, request)

@router.get(
"/{goal_id}",
response_model=Goal,
summary="Get goal details",
)
async def get_goal(
user_id: CurrentUserId,
goal_id: UUID,
) -> Goal:
"""Return detailed information about a goal."""
return _example_goal(goal_id)


@router.patch(
"/{goal_id}",
response_model=Goal,
summary="Update goal",
)
async def update_goal(
user_id: CurrentUserId,
goal_id: UUID,
request: UpdateGoalRequest,
) -> Goal:
"""Update goal properties."""
goal = _example_goal(goal_id)

if request.title is not None:
goal.title = request.title

if request.description is not None:
goal.description = request.description

if request.target_value is not None:
goal.target_value = request.target_value

if request.end_date is not None:
goal.end_date = request.end_date

if request.is_active is not None:
goal.is_active = request.is_active

return goal


@router.delete(
"/{goal_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete goal",
)
async def delete_goal(
user_id: CurrentUserId,
goal_id: UUID,
) -> Response:
"""Delete a goal."""
@router.delete("/{goal_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete goal")
async def delete_goal(user_id: CurrentUserId, goal_id: UUID, db: DBSession) -> Response:
await service.delete_goal(db, user_id, goal_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
Loading
Loading