diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 91d263b..4ce2a27 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -88,6 +88,37 @@ jobs: - uses: astral-sh/setup-uv@v3 with: version: "0.11.x" + + # Slice 0 integration tests (`db_client` fixture in + # apps/api/tests/conftest.py) need a real Postgres. We start it as a + # docker container with a per-run random password so no literal + # credential lives in source (keeps GitGuardian quiet). The fixture + # drops/recreates tables via Base.metadata per test, so no Alembic + # step is needed here — only the `lockin_test` DB. + - name: Start ephemeral Postgres with random password + run: | + PG_PWD=$(openssl rand -hex 24) + AUTH=$(openssl rand -hex 32) + echo "::add-mask::$PG_PWD" + echo "::add-mask::$AUTH" + docker run -d --name pg \ + -e POSTGRES_USER=lockin \ + -e POSTGRES_PASSWORD="$PG_PWD" \ + -e POSTGRES_DB=lockin \ + -p 5432:5432 \ + postgres:16-alpine + for _ in $(seq 1 30); do + docker exec pg pg_isready -U lockin -d lockin >/dev/null 2>&1 && break + sleep 1 + done + docker exec -e PGPASSWORD="$PG_PWD" pg \ + psql -U lockin -d postgres -c "CREATE DATABASE lockin_test;" + { + echo "PG_PWD=$PG_PWD" + echo "DATABASE_URL=postgresql+asyncpg://lockin:$PG_PWD@localhost:5432/lockin" + echo "AUTH_SECRET=$AUTH" + } >> "$GITHUB_ENV" + - run: pnpm install --frozen-lockfile - name: Regenerate Python event models run: | diff --git a/.graphifyignore b/.graphifyignore new file mode 100644 index 0000000..e237f05 --- /dev/null +++ b/.graphifyignore @@ -0,0 +1,51 @@ +# Node / pnpm +node_modules/ +.next/ +.turbo/ +.parcel-cache/ + +# Build outputs +dist/ +build/ +*.egg-info/ + +# Python envs and caches +.venv/ +venv/ +__pycache__/ +*.pyc +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.hypothesis/ +.uv-cache/ + +# Test / coverage +coverage/ +.coverage +.coverage.* +htmlcov/ + +# Logs and env +*.log +.env +.env.local +.env.*.local + +# Secrets baselines (large generated JSON) +.secrets.baseline +.secrets.baseline.bak + +# IDE / OS +.vscode/ +.idea/ +.DS_Store +Thumbs.db + +# Graphify output +graphify-out/ + +# Storybook static build artifacts — Parcel-bundled vendor JS with mangled +# symbol names; pure noise in the knowledge graph. +packages/ui/storybook-static/ +**/storybook-static/ diff --git a/apps/api/alembic/versions/0003_tasks.py b/apps/api/alembic/versions/0003_tasks.py new file mode 100644 index 0000000..d2e06fa --- /dev/null +++ b/apps/api/alembic/versions/0003_tasks.py @@ -0,0 +1,38 @@ +"""tasks + +Revision ID: 0003 +Revises: 0002 +Create Date: 2026-05-18 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + +from alembic import op + +revision: str = "0003" +down_revision: str | None = "0002" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "tasks", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("user_id", UUID(as_uuid=True), nullable=False, index=True), + sa.Column("title", sa.String(500), nullable=False), + sa.Column("source", sa.String(16), nullable=False, server_default="keyboard"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + ) + + +def downgrade() -> None: + op.drop_table("tasks") diff --git a/apps/api/app/api/v1/deps.py b/apps/api/app/api/v1/deps.py index 6ed0e66..b2bacb8 100644 --- a/apps/api/app/api/v1/deps.py +++ b/apps/api/app/api/v1/deps.py @@ -1,12 +1,14 @@ -"""FastAPI dependency providers: DB sessions, current user, idempotency.""" +"""FastAPI dependency providers: DB sessions, Redis, event publisher.""" from collections.abc import AsyncIterator from typing import Annotated from fastapi import Depends +from redis.asyncio import Redis from sqlalchemy.ext.asyncio import AsyncSession from app.db.session import AsyncSessionLocal +from app.events.publisher import EventPublisher, get_redis async def _db_session() -> AsyncIterator[AsyncSession]: @@ -15,3 +17,21 @@ async def _db_session() -> AsyncIterator[AsyncSession]: DbSession = Annotated[AsyncSession, Depends(_db_session)] + + +async def _redis() -> AsyncIterator[Redis]: + redis = get_redis() + try: + yield redis + finally: + await redis.aclose() + + +RedisDep = Annotated[Redis, Depends(_redis)] + + +async def _event_publisher(redis: RedisDep) -> EventPublisher: + return EventPublisher(redis) + + +EventPublisherDep = Annotated[EventPublisher, Depends(_event_publisher)] diff --git a/apps/api/app/api/v1/router.py b/apps/api/app/api/v1/router.py index 6fc1471..1165fb5 100644 --- a/apps/api/app/api/v1/router.py +++ b/apps/api/app/api/v1/router.py @@ -2,10 +2,11 @@ from fastapi import APIRouter -from app.api.v1.routes import debug, health, me, webauthn +from app.api.v1.routes import debug, health, me, tasks, webauthn api_router = APIRouter(prefix="/v1") api_router.include_router(health.router) api_router.include_router(me.router) +api_router.include_router(tasks.router) api_router.include_router(webauthn.router) api_router.include_router(debug.router) diff --git a/apps/api/app/api/v1/routes/tasks.py b/apps/api/app/api/v1/routes/tasks.py new file mode 100644 index 0000000..3b73970 --- /dev/null +++ b/apps/api/app/api/v1/routes/tasks.py @@ -0,0 +1,51 @@ +"""`/v1/tasks` — create and list a user's captured tasks.""" + +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Response, status + +from app.api.v1.deps import DbSession, EventPublisherDep +from app.core.auth import CurrentUserDep +from app.core.identity import user_uuid +from app.schemas.task import TaskCreate, TaskRead +from app.services.task_service import TaskService + +router = APIRouter(prefix="/tasks", tags=["tasks"]) + + +@router.post("", response_model=TaskRead, status_code=status.HTTP_201_CREATED) +async def create_task( + body: TaskCreate, + user: CurrentUserDep, + session: DbSession, + publisher: EventPublisherDep, +) -> TaskRead: + service = TaskService(session) + task = await service.create( + publisher, + user_id=user_uuid(user.user_id), + title=body.title, + source=body.source, + ) + return TaskRead.model_validate(task) + + +@router.get("", response_model=list[TaskRead]) +async def list_tasks(user: CurrentUserDep, session: DbSession) -> list[TaskRead]: + service = TaskService(session) + tasks = await service.list_for_user(user_uuid(user.user_id)) + return [TaskRead.model_validate(task) for task in tasks] + + +@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_task( + task_id: UUID, + user: CurrentUserDep, + session: DbSession, +) -> Response: + service = TaskService(session) + # Idempotent: 204 whether or not the row existed. + await service.delete(user_id=user_uuid(user.user_id), task_id=task_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/app/core/identity.py b/apps/api/app/core/identity.py new file mode 100644 index 0000000..ec8e48c --- /dev/null +++ b/apps/api/app/core/identity.py @@ -0,0 +1,21 @@ +"""Map an external OAuth subject to a stable internal UUID. + +Auth is JWT-strategy: there is no `users` table, and the identity we receive +is Google's `sub` claim — a numeric string, not a UUID. The event schema and +every per-user table key on `UUID`. `user_uuid` derives a deterministic v5 +UUID from the subject so Postgres rows and the `task.created` event stream +agree on one identifier per user. +""" + +from __future__ import annotations + +from uuid import UUID, uuid5 + +# Fixed namespace for user-identity derivation. Generated once for LockIn. +# NEVER change this value — changing it re-keys every existing user. +_USER_NAMESPACE = UUID("9f2a7c4e-0b1d-4e6a-8c3f-1a2b3c4d5e6f") + + +def user_uuid(subject: str) -> UUID: + """Return the stable internal UUID for an OAuth subject (e.g. Google `sub`).""" + return uuid5(_USER_NAMESPACE, subject) diff --git a/apps/api/app/db/models/__init__.py b/apps/api/app/db/models/__init__.py index 5c71c88..60a937d 100644 --- a/apps/api/app/db/models/__init__.py +++ b/apps/api/app/db/models/__init__.py @@ -6,5 +6,6 @@ """ from app.db.models.credential import WebauthnCredential # noqa: F401 +from app.db.models.task import Task # noqa: F401 -__all__ = ["WebauthnCredential"] +__all__ = ["Task", "WebauthnCredential"] diff --git a/apps/api/app/db/models/task.py b/apps/api/app/db/models/task.py new file mode 100644 index 0000000..322f90f --- /dev/null +++ b/apps/api/app/db/models/task.py @@ -0,0 +1,31 @@ +"""Task rows — a user-captured unit of work. + +Slice 0 keeps this table intentionally minimal (YAGNI). Week 3–4 Scaffolding +owns the expansion (`tenant_id`, `version`, `status`, indexes). Do not add +those columns here. +""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID, uuid4 + +from sqlalchemy import DateTime, String, func +from sqlalchemy.dialects.postgresql import UUID as PgUUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class Task(Base): + __tablename__ = "tasks" + + id: Mapped[UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=uuid4) + user_id: Mapped[UUID] = mapped_column(PgUUID(as_uuid=True), index=True, nullable=False) + title: Mapped[str] = mapped_column(String(500), nullable=False) + source: Mapped[str] = mapped_column(String(16), nullable=False, server_default="keyboard") + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) diff --git a/apps/api/app/schemas/__init__.py b/apps/api/app/schemas/__init__.py index 090145d..79172ce 100644 --- a/apps/api/app/schemas/__init__.py +++ b/apps/api/app/schemas/__init__.py @@ -1 +1 @@ -"""Pydantic v2 DTOs (request/response). Grouped by aggregate, added per slice.""" +"""API request/response Pydantic models. Not ORM models, not event models.""" diff --git a/apps/api/app/schemas/task.py b/apps/api/app/schemas/task.py new file mode 100644 index 0000000..e8cb463 --- /dev/null +++ b/apps/api/app/schemas/task.py @@ -0,0 +1,29 @@ +"""Request/response contracts for the /v1/tasks endpoints.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +TaskSource = Literal["keyboard", "click", "voice", "mcp"] + + +class TaskCreate(BaseModel): + """Body of `POST /v1/tasks`.""" + + title: str = Field(min_length=1, max_length=500) + source: TaskSource = "keyboard" + + +class TaskRead(BaseModel): + """A task as returned by the API. Built from the ORM row.""" + + model_config = ConfigDict(from_attributes=True) + + id: UUID + title: str + source: str + created_at: datetime diff --git a/apps/api/app/services/task_service.py b/apps/api/app/services/task_service.py new file mode 100644 index 0000000..84e37f7 --- /dev/null +++ b/apps/api/app/services/task_service.py @@ -0,0 +1,79 @@ +"""Task creation and listing. + +`create` performs a dual write: the row is committed to Postgres, then a +`task.created` event is published to the `events:tasks` Redis Stream. This is +a deliberate Slice-0 simplification — the transactional outbox pattern lands +in a later slice. The publish happens only after a successful commit. +""" + +from __future__ import annotations + +from uuid import UUID, uuid4 + +from lockin_events import STREAM_TASKS, TaskCreated +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models.task import Task +from app.events.publisher import EventPublisher +from app.schemas.task import TaskSource + + +class TaskService: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def create( + self, + publisher: EventPublisher, + *, + user_id: UUID, + title: str, + source: TaskSource, + ) -> Task: + task = Task(id=uuid4(), user_id=user_id, title=title, source=source) + self._session.add(task) + await self._session.commit() + await self._session.refresh(task) + + event = TaskCreated.model_validate( + { + "event_id": str(uuid4()), + "event_type": "task.created", + "event_version": 1, + "user_id": str(user_id), + "tenant_id": None, + "occurred_at": task.created_at.isoformat(), + "client_idempotency_key": None, + "source": "web", # Slice 0: web path only; MCP/API path sets this in a later slice. + "payload": { + "task_id": str(task.id), + "title": task.title, + "source": task.source, + }, + } + ) + await publisher.publish(STREAM_TASKS, event) + return task + + async def list_for_user(self, user_id: UUID) -> list[Task]: + result = await self._session.execute( + select(Task).where(Task.user_id == user_id).order_by(Task.created_at.desc()) + ) + return list(result.scalars().all()) + + async def delete(self, *, user_id: UUID, task_id: UUID) -> bool: + """Delete a task. Returns False if it does not exist for this user. + + Idempotent: deleting an already-absent task is not an error — the + caller's desired end state (task gone) is satisfied either way. + """ + result = await self._session.execute( + select(Task).where(Task.id == task_id, Task.user_id == user_id) + ) + task = result.scalar_one_or_none() + if task is None: + return False + await self._session.delete(task) + await self._session.commit() + return True diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index c115f2f..65207d6 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -57,6 +57,7 @@ dev = [ "pytest-asyncio>=0.24", "pytest-cov>=6.0", "httpx>=0.28", # also for TestClient + "fakeredis>=2.26", # in-memory Redis (incl. Streams) for tests # Linting and formatting "ruff>=0.8", diff --git a/apps/api/tests/conftest.py b/apps/api/tests/conftest.py index aa7ada6..814948a 100644 --- a/apps/api/tests/conftest.py +++ b/apps/api/tests/conftest.py @@ -2,18 +2,79 @@ from collections.abc import AsyncIterator +import fakeredis.aioredis import pytest_asyncio from httpx import ASGITransport, AsyncClient +from redis.asyncio import Redis +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) +from app.api.v1.deps import _db_session, _redis +from app.core.config import settings +from app.db import models as _models # noqa: F401 -- registers all models on Base.metadata +from app.db.base import Base from app.main import app @pytest_asyncio.fixture async def client() -> AsyncIterator[AsyncClient]: # raise_app_exceptions=False makes uncaught exceptions surface as 500 - # responses, matching production ASGI servers. Without it, ASGITransport - # re-raises into the test, which is the wrong contract for /v1/__debug__/ - # exception-path tests. + # responses, matching production ASGI servers. transport = ASGITransport(app=app, raise_app_exceptions=False) async with AsyncClient(transport=transport, base_url="http://test") as ac: yield ac + + +def _test_db_url() -> str: + """Derive the test DB URL from DATABASE_URL by swapping the database name.""" + base, _, _name = settings.DATABASE_URL.rpartition("/") + if not base: + raise ValueError(f"Cannot derive test DB URL from DATABASE_URL={settings.DATABASE_URL!r}") + return f"{base}/lockin_test" + + +@pytest_asyncio.fixture +async def db_engine() -> AsyncIterator[AsyncEngine]: + engine = create_async_engine(_test_db_url(), future=True) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + yield engine + await engine.dispose() + + +@pytest_asyncio.fixture +async def fake_redis() -> AsyncIterator[fakeredis.aioredis.FakeRedis]: + redis = fakeredis.aioredis.FakeRedis(decode_responses=True) + yield redis + await redis.aclose() + + +@pytest_asyncio.fixture +async def db_client( + db_engine: AsyncEngine, + fake_redis: fakeredis.aioredis.FakeRedis, +) -> AsyncIterator[AsyncClient]: + """An HTTP client whose API uses the test DB and an in-memory Redis.""" + maker = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False) + + async def _override_db() -> AsyncIterator[AsyncSession]: + async with maker() as session: + yield session + + async def _override_redis() -> AsyncIterator[Redis]: + yield fake_redis + + app.dependency_overrides[_db_session] = _override_db + app.dependency_overrides[_redis] = _override_redis + + try: + transport = ASGITransport(app=app, raise_app_exceptions=False) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + finally: + app.dependency_overrides.clear() diff --git a/apps/api/tests/integration/test_tasks.py b/apps/api/tests/integration/test_tasks.py new file mode 100644 index 0000000..11c60ca --- /dev/null +++ b/apps/api/tests/integration/test_tasks.py @@ -0,0 +1,149 @@ +"""Integration tests for the /v1/tasks endpoints. + +These exercise the full slice spine: JWT auth -> DB write -> event publish. +The DB is the `lockin_test` database; Redis is an in-memory fake. +""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime, timedelta + +import fakeredis.aioredis +from httpx import AsyncClient +from jose import jwt # type: ignore[import-untyped] +from lockin_events import STREAM_TASKS + +from app.core.config import settings + + +# The default subject is a realistic Google OAuth `sub` (a numeric-ish +# string, not a UUID). The /v1/tasks route maps it through `user_uuid()`, +# so any stable string works as a subject here. +def _bearer(subject: str = "google-sub-117234567890") -> str: + token = jwt.encode( + { + "user_id": subject, + "email": "muzaffar@example.com", + "providers": ["google"], + "exp": datetime.now(UTC) + timedelta(minutes=5), + }, + settings.JWT_SECRET, + algorithm=settings.JWT_ALG, + ) + return f"Bearer {token}" + + +async def test_create_task_requires_auth(db_client: AsyncClient) -> None: + res = await db_client.post("/v1/tasks", json={"title": "no auth"}) + assert res.status_code == 401 + + +async def test_create_task_rejects_empty_title(db_client: AsyncClient) -> None: + res = await db_client.post( + "/v1/tasks", + headers={"Authorization": _bearer()}, + json={"title": ""}, + ) + assert res.status_code == 422 + + +async def test_create_task_returns_201_with_the_task(db_client: AsyncClient) -> None: + res = await db_client.post( + "/v1/tasks", + headers={"Authorization": _bearer()}, + json={"title": "Finish the spec doc"}, + ) + assert res.status_code == 201 + body = res.json() + assert body["title"] == "Finish the spec doc" + assert body["source"] == "keyboard" + assert "id" in body and "created_at" in body + + +async def test_create_task_emits_task_created_event( + db_client: AsyncClient, + fake_redis: fakeredis.aioredis.FakeRedis, +) -> None: + await db_client.post( + "/v1/tasks", + headers={"Authorization": _bearer()}, + json={"title": "Emit an event"}, + ) + entries = await fake_redis.xrange(STREAM_TASKS) + assert len(entries) == 1 + _entry_id, fields = entries[0] + event = json.loads(fields["data"]) + assert event["event_type"] == "task.created" + assert event["event_version"] == 1 + assert event["payload"]["title"] == "Emit an event" + + +async def test_list_tasks_returns_user_tasks_newest_first(db_client: AsyncClient) -> None: + headers = {"Authorization": _bearer()} + await db_client.post("/v1/tasks", headers=headers, json={"title": "first"}) + await db_client.post("/v1/tasks", headers=headers, json={"title": "second"}) + + # Each POST is a separate transaction, so the two rows get distinct + # `created_at` values; newest-first ordering is therefore deterministic. + res = await db_client.get("/v1/tasks", headers=headers) + assert res.status_code == 200 + titles = [t["title"] for t in res.json()] + assert titles == ["second", "first"] + + +async def test_list_tasks_isolates_by_user(db_client: AsyncClient) -> None: + await db_client.post( + "/v1/tasks", + headers={"Authorization": _bearer("user-a")}, + json={"title": "owned by A"}, + ) + res = await db_client.get( + "/v1/tasks", + headers={"Authorization": _bearer("user-b")}, + ) + assert res.status_code == 200 + assert res.json() == [] + + +async def test_delete_task_is_idempotent(db_client: AsyncClient) -> None: + headers = {"Authorization": _bearer()} + created = await db_client.post("/v1/tasks", headers=headers, json={"title": "doomed"}) + task_id = created.json()["id"] + + first = await db_client.delete(f"/v1/tasks/{task_id}", headers=headers) + assert first.status_code == 204 + + # Deleting again is still 204 — desired end state already holds. + second = await db_client.delete(f"/v1/tasks/{task_id}", headers=headers) + assert second.status_code == 204 + + remaining = await db_client.get("/v1/tasks", headers=headers) + assert remaining.json() == [] + + +async def test_delete_task_requires_auth(db_client: AsyncClient) -> None: + res = await db_client.delete("/v1/tasks/00000000-0000-0000-0000-000000000000") + assert res.status_code == 401 + + +async def test_delete_task_isolates_by_user(db_client: AsyncClient) -> None: + # User A creates a task. + created = await db_client.post( + "/v1/tasks", + headers={"Authorization": _bearer("user-a")}, + json={"title": "owned by A"}, + ) + task_id = created.json()["id"] + + # User B tries to delete it — gets 204 (idempotent) but the row survives. + res = await db_client.delete( + f"/v1/tasks/{task_id}", + headers={"Authorization": _bearer("user-b")}, + ) + assert res.status_code == 204 + + # The task is still there for user A. + remaining = await db_client.get("/v1/tasks", headers={"Authorization": _bearer("user-a")}) + titles = [t["title"] for t in remaining.json()] + assert titles == ["owned by A"] diff --git a/apps/api/tests/unit/test_identity.py b/apps/api/tests/unit/test_identity.py new file mode 100644 index 0000000..2054765 --- /dev/null +++ b/apps/api/tests/unit/test_identity.py @@ -0,0 +1,20 @@ +"""Unit tests for the OAuth-subject → UUID mapping.""" + +from __future__ import annotations + +from uuid import UUID + +from app.core.identity import user_uuid + + +def test_user_uuid_is_stable_for_same_subject() -> None: + assert user_uuid("117234567890") == user_uuid("117234567890") + + +def test_user_uuid_differs_per_subject() -> None: + assert user_uuid("subject-a") != user_uuid("subject-b") + + +def test_user_uuid_returns_a_uuid() -> None: + assert isinstance(user_uuid("117234567890"), UUID) + assert user_uuid("117234567890").version == 5 diff --git a/apps/web/package.json b/apps/web/package.json index e64637d..dfe1d95 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,6 +17,7 @@ "@lockin/shared-types": "workspace:*", "@lockin/ui": "workspace:*", "@sentry/nextjs": "^10.52.0", + "@tanstack/react-query": "^5.100.11", "jose": "^5.9.6", "next": "16.2.6", "next-auth": "5.0.0-beta.31", @@ -25,11 +26,15 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitejs/plugin-react": "^5.2.0", "eslint": "^9", "eslint-config-next": "16.2.6", + "jsdom": "^25.0.1", "rimraf": "^6.0.1", "tailwindcss": "^4", "typescript": "^5", diff --git a/apps/web/src/app/api/tasks/route.ts b/apps/web/src/app/api/tasks/route.ts new file mode 100644 index 0000000..807b6a6 --- /dev/null +++ b/apps/web/src/app/api/tasks/route.ts @@ -0,0 +1,80 @@ +// BFF proxy: the browser calls this same-origin route; it reads the HttpOnly +// NextAuth session-token cookie and forwards it as a Bearer token to the +// FastAPI backend. This keeps the backend URL and the token off the client +// and avoids CORS entirely. + +import { type NextRequest, NextResponse } from "next/server"; + +const API_BASE_URL = process.env.API_BASE_URL ?? "http://localhost:8000"; + +// Must match the cookie name configured in apps/web/src/auth.ts. +const SESSION_COOKIE = + process.env.NODE_ENV === "production" + ? "__Secure-lockin.session-token" + : "lockin.session-token"; + +function bearer(req: NextRequest): string | null { + const token = req.cookies.get(SESSION_COOKIE)?.value; + return token ? `Bearer ${token}` : null; +} + +async function forward( + url: string, + init: RequestInit, +): Promise { + let res: Response; + try { + res = await fetch(url, init); + } catch { + return NextResponse.json({ error: "service_unavailable" }, { status: 503 }); + } + if (res.status === 204) { + return new NextResponse(null, { status: 204 }); + } + const contentType = res.headers.get("content-type") ?? ""; + const payload = contentType.includes("application/json") + ? await res.json() + : { error: "upstream_error" }; + return NextResponse.json(payload, { status: res.status }); +} + +export async function GET(req: NextRequest) { + const auth = bearer(req); + if (!auth) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + return forward(`${API_BASE_URL}/v1/tasks`, { + headers: { Authorization: auth }, + cache: "no-store", + }); +} + +export async function POST(req: NextRequest) { + const auth = bearer(req); + if (!auth) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + const body = await req.text(); + return forward(`${API_BASE_URL}/v1/tasks`, { + method: "POST", + headers: { Authorization: auth, "Content-Type": "application/json" }, + body, + }); +} + +// TODO(week-3-4): when PATCH lands, refactor this BFF to a `[id]/route.ts` +// dynamic segment so the proxy URL matches the backend (DELETE /v1/tasks/{id}). +export async function DELETE(req: NextRequest) { + const auth = bearer(req); + if (!auth) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + const id = req.nextUrl.searchParams.get("id"); + if (!id) { + return NextResponse.json({ error: "missing id" }, { status: 400 }); + } + return forward(`${API_BASE_URL}/v1/tasks/${encodeURIComponent(id)}`, { + method: "DELETE", + headers: { Authorization: auth }, + }); +} diff --git a/apps/web/src/app/dashboard/dashboard-client.tsx b/apps/web/src/app/dashboard/dashboard-client.tsx new file mode 100644 index 0000000..7cf37cb --- /dev/null +++ b/apps/web/src/app/dashboard/dashboard-client.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useState } from "react"; +import { Button, Card, Stack, Text } from "@lockin/ui"; +import { CommandPalette } from "@/components/command-palette"; +import { useCreateTask, useDeleteTask, useTasks } from "@/hooks/use-tasks"; + +export function DashboardClient() { + const [paletteOpen, setPaletteOpen] = useState(false); + const { data: tasks = [], isPending } = useTasks(); + const createTask = useCreateTask(); + const deleteTask = useDeleteTask(); + + return ( +
+ + + Today + + + + + {createTask.isError ? ( + + Couldn't add task: {(createTask.error as Error).message} + + ) : null} + + {deleteTask.isError ? ( + + Couldn't delete task: {(deleteTask.error as Error).message} + + ) : null} + + {isPending ? ( + Loading… + ) : tasks.length === 0 ? ( + + + No tasks yet + Press ⌘K to add your first task. + + + ) : ( + + {tasks.map((task) => ( + +
+ {task.title} + +
+
+ ))} +
+ )} +
+ + createTask.mutate({ title, source: "keyboard" })} + /> +
+ ); +} diff --git a/apps/web/src/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx new file mode 100644 index 0000000..e798403 --- /dev/null +++ b/apps/web/src/app/dashboard/page.tsx @@ -0,0 +1,11 @@ +import { redirect } from "next/navigation"; +import { auth } from "@/auth"; +import { DashboardClient } from "./dashboard-client"; + +export default async function DashboardPage() { + const session = await auth(); + if (!session) { + redirect("/"); + } + return ; +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 976eb90..a11a820 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import { Providers } from "./providers"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -13,8 +14,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "LockIn", + description: "Mood-and-energy-aware productivity agent.", }; export default function RootLayout({ @@ -27,7 +28,9 @@ export default function RootLayout({ lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} > - {children} + + {children} + ); } diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 148e362..1f84adb 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,14 +1,30 @@ +import { redirect } from "next/navigation"; +import { auth, signIn } from "@/auth"; import { Button, Stack, Text } from "@lockin/ui"; -export default function Home() { +export default async function Home() { + const session = await auth(); + if (session) { + redirect("/dashboard"); + } + return (
LockIn - Foundation slice — design system smoke test. - + Mood-aware productivity. Sign in to start. +
{ + "use server"; + await signIn("google", { redirectTo: "/dashboard" }); + }} + > + +
); diff --git a/apps/web/src/app/providers.tsx b/apps/web/src/app/providers.tsx new file mode 100644 index 0000000..aceddbb --- /dev/null +++ b/apps/web/src/app/providers.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useState, type ReactNode } from "react"; + +export function Providers({ children }: { children: ReactNode }) { + const [client] = useState( + () => + new QueryClient({ + defaultOptions: { queries: { staleTime: 30_000, retry: 1 } }, + }), + ); + return {children}; +} diff --git a/apps/web/src/components/command-palette.test.tsx b/apps/web/src/components/command-palette.test.tsx new file mode 100644 index 0000000..8da0040 --- /dev/null +++ b/apps/web/src/components/command-palette.test.tsx @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { CommandPalette } from "./command-palette"; + +describe("CommandPalette", () => { + it("renders nothing when closed", () => { + const { container } = render( + {}} onSubmit={() => {}} />, + ); + expect(container).toBeEmptyDOMElement(); + }); + + it("submits the trimmed title and closes on Enter", () => { + const onSubmit = vi.fn(); + const onOpenChange = vi.fn(); + render(); + + const input = screen.getByPlaceholderText("What needs doing?"); + fireEvent.change(input, { target: { value: " Write the spec " } }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(onSubmit).toHaveBeenCalledWith("Write the spec"); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("ignores a blank title", () => { + const onSubmit = vi.fn(); + render( {}} onSubmit={onSubmit} />); + + const input = screen.getByPlaceholderText("What needs doing?"); + fireEvent.change(input, { target: { value: " " } }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("clears the input when reopened", () => { + const { rerender } = render( + {}} onSubmit={() => {}} />, + ); + const input = screen.getByPlaceholderText("What needs doing?") as HTMLInputElement; + fireEvent.change(input, { target: { value: "draft" } }); + expect(input.value).toBe("draft"); + + rerender( {}} onSubmit={() => {}} />); + rerender( {}} onSubmit={() => {}} />); + const reopened = screen.getByPlaceholderText("What needs doing?") as HTMLInputElement; + expect(reopened.value).toBe(""); + }); +}); diff --git a/apps/web/src/components/command-palette.tsx b/apps/web/src/components/command-palette.tsx new file mode 100644 index 0000000..37d534c --- /dev/null +++ b/apps/web/src/components/command-palette.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Button, Input, Stack, Text } from "@lockin/ui"; + +export interface CommandPaletteProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (title: string) => void; +} + +export function CommandPalette({ open, onOpenChange, onSubmit }: CommandPaletteProps) { + const [value, setValue] = useState(""); + const inputRef = useRef(null); + + useEffect(() => { + function onKey(e: KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + onOpenChange(!open); + } + if (e.key === "Escape") { + onOpenChange(false); + } + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onOpenChange]); + + useEffect(() => { + if (open) { + inputRef.current?.focus(); + } + // Reset the input on close. We put this in the cleanup (not an `else` + // branch) so it runs only when `open` flips true → false, satisfying + // react-hooks/set-state-in-effect. + return () => { + setValue(""); + }; + }, [open]); + + if (!open) { + return null; + } + + function submit() { + const title = value.trim(); + if (!title) { + return; + } + onSubmit(title); + setValue(""); + onOpenChange(false); + } + + return ( +
onOpenChange(false)} + > +
e.stopPropagation()} + > + + + Add a task + + setValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + submit(); + } + }} + /> + + +
+
+ ); +} diff --git a/apps/web/src/hooks/use-tasks.ts b/apps/web/src/hooks/use-tasks.ts new file mode 100644 index 0000000..39bbb10 --- /dev/null +++ b/apps/web/src/hooks/use-tasks.ts @@ -0,0 +1,55 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { TaskCreateRequest, TaskResponse } from "@lockin/shared-types"; + +const TASKS_KEY = ["tasks"] as const; + +async function fetchTasks(): Promise { + const res = await fetch("/api/tasks", { cache: "no-store" }); + if (!res.ok) { + throw new Error(`Failed to load tasks: ${res.status}`); + } + return res.json(); +} + +async function createTask(input: TaskCreateRequest): Promise { + const res = await fetch("/api/tasks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + if (!res.ok) { + throw new Error(`Failed to create task: ${res.status}`); + } + return res.json(); +} + +export function useTasks() { + return useQuery({ queryKey: TASKS_KEY, queryFn: fetchTasks }); +} + +export function useCreateTask() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createTask, + onSuccess: () => queryClient.invalidateQueries({ queryKey: TASKS_KEY }), + }); +} + +async function deleteTask(id: string): Promise { + const res = await fetch(`/api/tasks?id=${encodeURIComponent(id)}`, { + method: "DELETE", + }); + if (!res.ok) { + throw new Error(`Failed to delete task: ${res.status}`); + } +} + +export function useDeleteTask() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: deleteTask, + onSuccess: () => queryClient.invalidateQueries({ queryKey: TASKS_KEY }), + }); +} diff --git a/apps/web/vitest.config.mts b/apps/web/vitest.config.mts new file mode 100644 index 0000000..cc2d9d7 --- /dev/null +++ b/apps/web/vitest.config.mts @@ -0,0 +1,19 @@ +import react from "@vitejs/plugin-react"; +import { resolve } from "node:path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@": resolve(__dirname, "./src"), + "@lockin/ui": resolve(__dirname, "../../packages/ui/src/index.ts"), + }, + }, + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./vitest.setup.ts"], + passWithNoTests: true, + }, +}); diff --git a/apps/web/vitest.setup.ts b/apps/web/vitest.setup.ts new file mode 100644 index 0000000..f149f27 --- /dev/null +++ b/apps/web/vitest.setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/docs/CURRENT_SLICE.md b/docs/CURRENT_SLICE.md index 40ec3bb..1afa7e4 100644 --- a/docs/CURRENT_SLICE.md +++ b/docs/CURRENT_SLICE.md @@ -1,64 +1,24 @@ -# Current Slice — Vertical Slice 0: Auth + Task Capture Spine +# Current Slice — Week 3–4 Scaffolding -> **✅ Foundation status (2026-05-18):** The Week 1–2 Foundation slice is **complete** — all 8 deliverables merged to `main` across PRs #1–#5. Monorepo + Turborepo, event schema v1 (9 types, TS↔Python round-trip), `@lockin/ui` + Storybook, GitHub Actions (PR + staging + prod pipelines), GCP Terraform modules, observability (OTel + Sentry + Grafana), secret management, and the auth foundation (NextAuth v5 + Google OAuth + WebAuthn passkeys) are all on `main`. Remaining items are **operational, not code** — see [`docs/handoffs/week-1-2.md`](handoffs/week-1-2.md) for the ops checklist before this slice ships to a real environment. +> **✅ Slice 0 (Auth + Task Capture Spine) complete (2026-05-18):** Signed-in +> users capture tasks via a Cmd+K palette; tasks persist to Postgres and emit +> `task.created` to the `events:tasks` Redis Stream. Plan + outcome: +> [`docs/superpowers/plans/2026-05-18-slice-0-auth-task-capture-spine.md`](superpowers/plans/2026-05-18-slice-0-auth-task-capture-spine.md). -**Status:** Ready to start — foundation unblocked -**Owner:** [assigned engineer] -**Est. duration:** 3–5 days +**Status:** Ready to start +**Est. duration:** 10 working days ## The Goal -Build the smallest possible end-to-end loop that proves the architecture works. Nothing else until this ships. +Scaffolding — grow the skeleton's organs. See the full handoff for the six +deliverables: Postgres schemas + Alembic migrations, TimescaleDB hypertables, +Redis Streams consumer groups, the API gateway middleware stack, Google +Calendar read-only sync, and the frontend shell. Zero user-facing features. -**User story:** -> As a user, I can sign in with Google, open a Cmd+K command palette, type a task title, press Enter, and see it appear in a simple list. The task persists across page refreshes. +## What's Next -That's it. No calendar sync. No mood widget. No ML. No MCP. No scheduling. - -## Why This Slice First - -If this works end-to-end, everything else layers onto the same spine: -- Auth works → we can add per-user data -- Cmd+K works → we have the primary input surface for all future features -- API + DB write works → we can add event sourcing, then mood, then calendar -- Event emitted → we prove the event-sourced architecture from day one - -If this DOESN'T work, nothing else matters. This is the foundation test. - -## What "Done" Looks Like - -- [ ] `npm run dev` starts the web app at localhost:3000 -- [ ] `docker compose up` starts Postgres + Redis locally -- [ ] `python -m uvicorn app.main:app --reload` starts the API at localhost:8000 -- [ ] User visits localhost:3000, clicks "Sign in with Google", completes OAuth -- [ ] User sees empty dashboard with a prompt: "Press Cmd+K to add a task" -- [ ] User presses Cmd+K, command palette opens, types "Finish spec doc", presses Enter -- [ ] Task appears in a list below the palette -- [ ] User refreshes the page; task is still there -- [ ] In the database: `SELECT * FROM tasks;` shows the task with correct `user_id` -- [ ] In Redis Streams: `XRANGE events:tasks - +` shows a `task.created` event -- [ ] An integration test covers the full flow - -## Architecture Constraints for This Slice - -**DO:** -- Write the event schema for `task.created` in `packages/events/` FIRST, before any code -- Use server actions or tRPC (pick one, document it) for the API call — no separate fetch plumbing yet -- Write the task to Postgres AND emit to Redis Streams (dual write is fine for slice 0; we'll add outbox pattern in slice 2) -- Set up shared TypeScript types between web and API via `packages/shared-types/` - -**DO NOT:** -- Build generic abstractions "for later" — write the concrete code, refactor when we have three use cases -- Add a mood widget, calendar sync, or any other feature -- Skip the event emission "because we're not reading it yet" — the event is the point - -## Stretch Goal (only if truly ahead of schedule) - -Add a `DELETE /tasks/:id` endpoint and a delete button. Emits `task.deleted` event. This forces us to think about idempotency in the simplest possible context. - -## What's Next (slice 1 preview) - -After this ships, slice 1 is: **Google Calendar read-only sync.** The user connects their calendar and sees today's events alongside the task list. No scheduling logic yet — just read-only display. +After Week 3–4 ships, Week 5–6 builds the capture loop (voice + text input, +mood/energy widget, notification permissions, real event instrumentation). --- *Update this file when the slice ships. Archive previous slices in `docs/slices/`.* diff --git a/docs/decisions/2026-05-18-data-layer.md b/docs/decisions/2026-05-18-data-layer.md new file mode 100644 index 0000000..9ac5f71 --- /dev/null +++ b/docs/decisions/2026-05-18-data-layer.md @@ -0,0 +1,28 @@ +# Decision: Frontend data layer + user-identity mapping (Slice 0) + +**Date:** 2026-05-18 +**Status:** Accepted + +## Context + +`CURRENT_SLICE.md` instructed "use server actions or tRPC". The real backend +is FastAPI (Python); tRPC is TypeScript-only and Server Actions run only in +the Next.js runtime — neither calls FastAPI directly. Separately, auth is +JWT-strategy with no `users` table: identity is Google's `sub` (a numeric +string), but the event schema types `user_id` as `UUID`. + +## Decision + +1. **Data layer:** React Query (TanStack Query, already locked in `CLAUDE.md`) + on the client, calling a thin Next.js BFF route handler (`/api/tasks`) that + forwards the HttpOnly session-token cookie as a Bearer token to FastAPI. +2. **Identity:** `app/core/identity.py:user_uuid()` maps an OAuth subject to a + deterministic `uuid5` UUID, used as the key for every per-user row and event. + +## Consequences + +- No CORS surface — the browser only talks to same-origin Next.js routes. +- React Query's mutation primitives are ready for the Week 5 optimistic mood UI. +- The `_USER_NAMESPACE` constant in `identity.py` must never change — doing so + re-keys every user. A real `users` table (P2 team mode) can adopt the same + derived UUID as its primary key with no data migration. diff --git a/docs/superpowers/plans/2026-05-18-slice-0-auth-task-capture-spine.md b/docs/superpowers/plans/2026-05-18-slice-0-auth-task-capture-spine.md new file mode 100644 index 0000000..82a3016 --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-slice-0-auth-task-capture-spine.md @@ -0,0 +1,1740 @@ +# Slice 0 — Auth + Task Capture Spine Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +--- + +## Execution Status — COMPLETE 2026-05-19 + +Executed via subagent-driven development on branch `feat/slice-0-auth-task-capture-spine`. **All 16 tasks + the stretch + the final review are done.** Test suites green: **27 backend pytest** + **4 web vitest** + clean mypy/typecheck/lint. Final review verdict: **Ready to merge**. + +| Task | Status | Final commit | Notes | +|------|--------|--------------|-------| +| 1 — `user_uuid` helper | ✅ Done | `563a292` | + v5-version assertion added in review | +| 2 — `Task` model + migration `0003` | ✅ Done | `5289f7a` | migration code verified; live `alembic upgrade` deferred (see env notes) | +| 3 — `TaskCreate`/`TaskRead` schemas | ✅ Done | `04ecc83` | | +| 4 — Redis + `EventPublisher` deps | ✅ Done | `d547d13` | | +| 5 — `TaskService` dual-write | ✅ Done | `3096939` | `create()` `source` param tightened to `TaskSource` in review | +| 6 — `/v1/tasks` routes | ✅ Done | `462afb8` | | +| 7 — Backend test infrastructure | ✅ Done | `26ad735` | 3 review fixes: root `uv.lock` updated, `try/finally` override cleanup, `app`-name-shadow fix | +| 8 — Integration tests for `/v1/tasks` | ✅ Done | `a6215ce` | 6 tests; full backend suite **24 passing** | +| 9 — Shared TypeScript types | ✅ Done | `5a0b1ba` | | +| 10 — React Query provider + layout | ✅ Done | `c1b72c5` | `@tanstack/react-query@^5.100.11` | +| 11 — BFF route `/api/tasks` | ✅ Done | `259c92a` | hardened in review: non-JSON & unreachable upstream return structured errors | +| 12 — Task data hooks | ✅ Done | `8003a30` | error messages include HTTP status | +| 13 — Command palette + test | ✅ Done | `a03cc2e` | + vitest stack added; `@lockin/ui` aliased to source; 4 tests | +| 14 — Dashboard + landing page | ✅ Done | `fc72483` | `Stack gap={5}` adapted to `gap={6}` (plan typo — invalid value); error UI surfaced | +| 15 — Decision record + handoff | ✅ Done | `557a797` | `docs/decisions/2026-05-18-data-layer.md`; `CURRENT_SLICE.md` advanced to Week 3–4 | +| 16 — (stretch) task deletion | ✅ Done | `a3e73e4` | 3 new tests (idempotency, auth, isolation); `?id=` BFF debt flagged with TODO(week-3-4) | +| Final code review | ✅ Approved | — | "Ready to merge" — only finding was this status-table update | + +**Environment notes (carry forward — important for Tasks 8/14 and CI):** +- Docker Desktop is running; `docker-postgres-1` + `docker-redis-1` are up. +- **A native PostgreSQL 18 on Windows binds `localhost:5432` and shadows the Docker container's published port.** During Task 8 the `lockin` role and the `lockin` + `lockin_test` databases were bootstrapped on the *native* instance (its `pg_hba.conf` was temporarily set to `trust`, then restored to `scram-sha-256` — not committed). All backend tests therefore run against the native Postgres. Task 14's manual smoke test will also hit the native instance; the dev `lockin` DB there still needs `alembic upgrade head` applied before the smoke test. +- The workspace uses a **single root `uv.lock`**; the stale `apps/api/uv.lock` is ignored by uv in workspace mode. + +**Carried-forward review observations (non-blocking, not yet actioned):** +- Migration `0003` (and `0002`) have no DB-side `gen_random_uuid()` default on `id` — ORM supplies `uuid4`; raw-SQL inserts would need a default. Hardening pass, post-Slice-0. +- `TaskService` dual write has no transactional outbox — if the Redis publish fails post-commit the event is lost (documented in the file; outbox is a later slice). +- `created_at`-based ordering has no monotonic tiebreaker — theoretical flake risk if two POSTs share a timestamp (very low; documented in `test_tasks.py`). + +--- + +**Goal:** Build the smallest end-to-end loop that proves the architecture — a signed-in user opens a Cmd+K palette, types a task title, presses Enter, and sees it persist in a list — with the task written to Postgres and a `task.created` event emitted to Redis Streams. + +**Architecture:** Next.js 16 web app authenticates with Google via the existing NextAuth v5 setup. A thin Next.js Route Handler (`/api/tasks`) acts as a BFF proxy: it reads the HS256 session-token cookie and forwards it as a `Bearer` token to the FastAPI backend. FastAPI validates the JWT, a `TaskService` dual-writes to Postgres and publishes a `task.created` event to the `events:tasks` Redis Stream. The frontend uses React Query (TanStack Query) for fetching and optimistic-ready mutations. + +**Tech Stack:** Next.js 16 / React 19, NextAuth v5, TanStack Query v5, FastAPI, SQLAlchemy 2.0 async + asyncpg, Alembic, Redis Streams, `lockin_events` (generated Pydantic event models), pytest + fakeredis, vitest + Testing Library. + +**Data-layer decision:** `CURRENT_SLICE.md` said "server actions or tRPC". Neither reaches the real backend cleanly — the API is FastAPI (Python), so tRPC (TS-only) and Server Actions (Next-runtime-only) cannot call it directly. This plan uses **React Query → Next.js BFF route → FastAPI REST**, consistent with the locked stack (`TanStack Query` in `CLAUDE.md`). Task 14 records this decision. + +**Identity note:** There is no `users` table — auth is JWT-strategy (Google `sub` in the token, no DB sessions). Google's `sub` is a numeric string, not a UUID, but the event schema's `user_id` is typed `UUID`. Task 1 introduces `user_uuid()` (a deterministic `uuid5` map) so the DB and the event stream use one consistent UUID per user. Task 14 records this decision. + +**Out of scope (do not build):** calendar sync, mood/energy widgets, ML, MCP, scheduling logic, idempotency-key storage, the full Week 3–4 column set (`tenant_id`, `version`, `status`). Keep the `tasks` table minimal; Week 3–4 owns its expansion. + +--- + +## Foundation gate (verify before Task 1) + +The code-level foundation is green per `docs/handoffs/week-1-2.md` (tests, typecheck, event round-trip). Before starting, confirm the local stack boots: + +- [ ] `pnpm install` completes +- [ ] `docker compose -f infra/docker/docker-compose.yml up -d` brings up Postgres + Redis +- [ ] `cd apps/api && alembic upgrade head` applies migrations `0001`+`0002` cleanly +- [ ] `make api` (or `python -m uvicorn app.main:app --reload` from `apps/api`) serves `http://localhost:8000/health` +- [ ] `pnpm --filter @lockin/web dev` serves `http://localhost:3000` + +If any fail, fix or escalate before proceeding — do not scaffold on a broken foundation. + +--- + +## File Structure + +**Backend (`apps/api/app/`):** +- `core/identity.py` — *new* — `user_uuid()`: maps OAuth subject → stable UUID. +- `db/models/task.py` — *new* — `Task` ORM model. +- `db/models/__init__.py` — *modify* — register `Task`. +- `alembic/versions/0003_tasks.py` — *new* — `tasks` table migration. +- `schemas/task.py` — *new* — `TaskCreate` / `TaskRead` API contracts. +- `schemas/__init__.py` — *new if absent* — package marker. +- `api/v1/deps.py` — *modify* — add Redis + `EventPublisher` providers. +- `services/task_service.py` — *new* — `TaskService`: create + list, dual-write. +- `api/v1/routes/tasks.py` — *new* — `POST /v1/tasks`, `GET /v1/tasks`. +- `api/v1/router.py` — *modify* — register the tasks router. +- `pyproject.toml` — *modify* — add `fakeredis` dev dependency. + +**Backend tests (`apps/api/tests/`):** +- `conftest.py` — *modify* — `db_engine`, `fake_redis`, `db_client` fixtures. +- `unit/test_identity.py` — *new*. +- `integration/test_tasks.py` — *new*. + +**Shared (`packages/shared-types/src/`):** +- `index.ts` — *modify* — `TaskCreateRequest`, `TaskResponse`. + +**Frontend (`apps/web/`):** +- `package.json` — *modify* — add `@tanstack/react-query` + test deps. +- `src/app/providers.tsx` — *new* — React Query provider. +- `src/app/layout.tsx` — *modify* — wrap children in `Providers`; fix metadata. +- `src/app/page.tsx` — *modify* — landing page sign-in / redirect. +- `src/app/api/tasks/route.ts` — *new* — BFF proxy to FastAPI. +- `src/hooks/use-tasks.ts` — *new* — `useTasks`, `useCreateTask`. +- `src/components/command-palette.tsx` — *new* — Cmd+K modal. +- `src/components/command-palette.test.tsx` — *new*. +- `src/app/dashboard/page.tsx` — *new* — server auth gate. +- `src/app/dashboard/dashboard-client.tsx` — *new* — empty state + list + palette. +- `vitest.config.ts` — *new* — jsdom + React plugin. +- `vitest.setup.ts` — *new* — Testing Library matchers. + +**Docs:** +- `docs/decisions/2026-05-18-data-layer.md` — *new*. +- `docs/CURRENT_SLICE.md` — *modify* — point at Week 3–4 Scaffolding. + +--- + +## Task 1: `user_uuid` identity helper + +**Files:** +- Create: `apps/api/app/core/identity.py` +- Test: `apps/api/tests/unit/test_identity.py` + +- [ ] **Step 1: Write the failing test** + +Create `apps/api/tests/unit/test_identity.py`: + +```python +"""Unit tests for the OAuth-subject → UUID mapping.""" + +from __future__ import annotations + +from uuid import UUID + +from app.core.identity import user_uuid + + +def test_user_uuid_is_stable_for_same_subject() -> None: + assert user_uuid("117234567890") == user_uuid("117234567890") + + +def test_user_uuid_differs_per_subject() -> None: + assert user_uuid("subject-a") != user_uuid("subject-b") + + +def test_user_uuid_returns_a_uuid() -> None: + assert isinstance(user_uuid("117234567890"), UUID) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/api && pytest tests/unit/test_identity.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'app.core.identity'` + +- [ ] **Step 3: Write minimal implementation** + +Create `apps/api/app/core/identity.py`: + +```python +"""Map an external OAuth subject to a stable internal UUID. + +Auth is JWT-strategy: there is no `users` table, and the identity we receive +is Google's `sub` claim — a numeric string, not a UUID. The event schema and +every per-user table key on `UUID`. `user_uuid` derives a deterministic v5 +UUID from the subject so Postgres rows and the `task.created` event stream +agree on one identifier per user. +""" + +from __future__ import annotations + +from uuid import UUID, uuid5 + +# Fixed namespace for user-identity derivation. Generated once for LockIn. +# NEVER change this value — changing it re-keys every existing user. +_USER_NAMESPACE = UUID("9f2a7c4e-0b1d-4e6a-8c3f-1a2b3c4d5e6f") + + +def user_uuid(subject: str) -> UUID: + """Return the stable internal UUID for an OAuth subject (e.g. Google `sub`).""" + return uuid5(_USER_NAMESPACE, subject) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd apps/api && pytest tests/unit/test_identity.py -v` +Expected: PASS — 3 passed + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/app/core/identity.py apps/api/tests/unit/test_identity.py +git commit -m "feat(api): add user_uuid identity helper for OAuth subject mapping" +``` + +--- + +## Task 2: `Task` ORM model + Alembic migration + +**Files:** +- Create: `apps/api/app/db/models/task.py` +- Modify: `apps/api/app/db/models/__init__.py` +- Create: `apps/api/alembic/versions/0003_tasks.py` + +- [ ] **Step 1: Create the ORM model** + +Create `apps/api/app/db/models/task.py`: + +```python +"""Task rows — a user-captured unit of work. + +Slice 0 keeps this table intentionally minimal (YAGNI). Week 3–4 Scaffolding +owns the expansion (`tenant_id`, `version`, `status`, indexes). Do not add +those columns here. +""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID, uuid4 + +from sqlalchemy import DateTime, String, func +from sqlalchemy.dialects.postgresql import UUID as PgUUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class Task(Base): + __tablename__ = "tasks" + + id: Mapped[UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=uuid4) + user_id: Mapped[UUID] = mapped_column(PgUUID(as_uuid=True), index=True, nullable=False) + title: Mapped[str] = mapped_column(String(500), nullable=False) + source: Mapped[str] = mapped_column(String(16), nullable=False, server_default="keyboard") + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) +``` + +- [ ] **Step 2: Register the model so `Base.metadata` and Alembic see it** + +Replace the contents of `apps/api/app/db/models/__init__.py`: + +```python +"""SQLAlchemy ORM models. + +Importing this package side-effects: every model module here is loaded so +``Base.metadata`` sees the tables. Alembic's ``env.py`` imports this package +for the same reason — see the note in ``alembic/env.py``. +""" + +from app.db.models.credential import WebauthnCredential # noqa: F401 +from app.db.models.task import Task # noqa: F401 + +__all__ = ["Task", "WebauthnCredential"] +``` + +- [ ] **Step 3: Create the Alembic migration** + +Create `apps/api/alembic/versions/0003_tasks.py`: + +```python +"""tasks + +Revision ID: 0003 +Revises: 0002 +Create Date: 2026-05-18 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + +from alembic import op + +revision: str = "0003" +down_revision: str | None = "0002" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "tasks", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("user_id", UUID(as_uuid=True), nullable=False, index=True), + sa.Column("title", sa.String(500), nullable=False), + sa.Column("source", sa.String(16), nullable=False, server_default="keyboard"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + ) + + +def downgrade() -> None: + op.drop_table("tasks") +``` + +- [ ] **Step 4: Verify the migration runs both directions** + +Run (with Postgres up from the foundation gate): + +```bash +cd apps/api +alembic upgrade head # expect: ... Running upgrade 0002 -> 0003, tasks +alembic downgrade -1 # expect: ... Running downgrade 0003 -> 0002 +alembic upgrade head # expect: re-applies 0003 cleanly +``` + +Expected: each command exits 0; `tasks` table exists after the final `upgrade`. + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/app/db/models/task.py apps/api/app/db/models/__init__.py apps/api/alembic/versions/0003_tasks.py +git commit -m "feat(api): add tasks table model and migration 0003" +``` + +--- + +## Task 3: API request/response schemas + +**Files:** +- Create: `apps/api/app/schemas/__init__.py` +- Create: `apps/api/app/schemas/task.py` + +- [ ] **Step 1: Create the schemas package marker** + +Create `apps/api/app/schemas/__init__.py`: + +```python +"""API request/response Pydantic models. Not ORM models, not event models.""" +``` + +- [ ] **Step 2: Create the task schemas** + +Create `apps/api/app/schemas/task.py`: + +```python +"""Request/response contracts for the /v1/tasks endpoints.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +TaskSource = Literal["keyboard", "click", "voice", "mcp"] + + +class TaskCreate(BaseModel): + """Body of `POST /v1/tasks`.""" + + title: str = Field(min_length=1, max_length=500) + source: TaskSource = "keyboard" + + +class TaskRead(BaseModel): + """A task as returned by the API. Built from the ORM row.""" + + model_config = ConfigDict(from_attributes=True) + + id: UUID + title: str + source: str + created_at: datetime +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/api/app/schemas/__init__.py apps/api/app/schemas/task.py +git commit -m "feat(api): add TaskCreate/TaskRead API schemas" +``` + +--- + +## Task 4: Redis + EventPublisher dependency providers + +**Files:** +- Modify: `apps/api/app/api/v1/deps.py` + +- [ ] **Step 1: Add the Redis and publisher providers** + +Replace the contents of `apps/api/app/api/v1/deps.py`: + +```python +"""FastAPI dependency providers: DB sessions, Redis, event publisher.""" + +from collections.abc import AsyncIterator +from typing import Annotated + +from fastapi import Depends +from redis.asyncio import Redis +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.session import AsyncSessionLocal +from app.events.publisher import EventPublisher, get_redis + + +async def _db_session() -> AsyncIterator[AsyncSession]: + async with AsyncSessionLocal() as session: + yield session + + +DbSession = Annotated[AsyncSession, Depends(_db_session)] + + +async def _redis() -> AsyncIterator[Redis]: + redis = get_redis() + try: + yield redis + finally: + await redis.aclose() + + +RedisDep = Annotated[Redis, Depends(_redis)] + + +async def _event_publisher(redis: RedisDep) -> EventPublisher: + return EventPublisher(redis) + + +EventPublisherDep = Annotated[EventPublisher, Depends(_event_publisher)] +``` + +- [ ] **Step 2: Verify the API still imports cleanly** + +Run: `cd apps/api && python -c "from app.main import app; print('ok')"` +Expected: `ok` + +- [ ] **Step 3: Commit** + +```bash +git add apps/api/app/api/v1/deps.py +git commit -m "feat(api): add Redis and EventPublisher FastAPI dependencies" +``` + +--- + +## Task 5: `TaskService` — dual-write to Postgres + Redis + +**Files:** +- Create: `apps/api/app/services/task_service.py` + +Tested via the integration tests in Task 8 (it needs a live DB + Redis, which the test fixtures provide). No standalone unit test — a mock-DB unit test here would prove nothing. + +- [ ] **Step 1: Create the service** + +Create `apps/api/app/services/task_service.py`: + +```python +"""Task creation and listing. + +`create` performs a dual write: the row is committed to Postgres, then a +`task.created` event is published to the `events:tasks` Redis Stream. This is +a deliberate Slice-0 simplification — the transactional outbox pattern lands +in a later slice. The publish happens only after a successful commit. +""" + +from __future__ import annotations + +from uuid import UUID, uuid4 + +from lockin_events import STREAM_TASKS, TaskCreated +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models.task import Task +from app.events.publisher import EventPublisher + + +class TaskService: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def create( + self, + publisher: EventPublisher, + *, + user_id: UUID, + title: str, + source: str, + ) -> Task: + task = Task(id=uuid4(), user_id=user_id, title=title, source=source) + self._session.add(task) + await self._session.commit() + await self._session.refresh(task) + + event = TaskCreated.model_validate( + { + "event_id": str(uuid4()), + "event_type": "task.created", + "event_version": 1, + "user_id": str(user_id), + "tenant_id": None, + "occurred_at": task.created_at.isoformat(), + "client_idempotency_key": None, + "source": "web", + "payload": { + "task_id": str(task.id), + "title": task.title, + "source": task.source, + }, + } + ) + await publisher.publish(STREAM_TASKS, event) + return task + + async def list_for_user(self, user_id: UUID) -> list[Task]: + result = await self._session.execute( + select(Task).where(Task.user_id == user_id).order_by(Task.created_at.desc()) + ) + return list(result.scalars().all()) +``` + +- [ ] **Step 2: Verify it imports** + +Run: `cd apps/api && python -c "from app.services.task_service import TaskService; print('ok')"` +Expected: `ok` — confirms `lockin_events` exports `TaskCreated` and `STREAM_TASKS`. + +- [ ] **Step 3: Commit** + +```bash +git add apps/api/app/services/task_service.py +git commit -m "feat(api): add TaskService with Postgres + Redis dual write" +``` + +--- + +## Task 6: `/v1/tasks` routes + +**Files:** +- Create: `apps/api/app/api/v1/routes/tasks.py` +- Modify: `apps/api/app/api/v1/router.py` + +- [ ] **Step 1: Create the routes** + +Create `apps/api/app/api/v1/routes/tasks.py`: + +```python +"""`/v1/tasks` — create and list a user's captured tasks.""" + +from __future__ import annotations + +from fastapi import APIRouter, status + +from app.api.v1.deps import DbSession, EventPublisherDep +from app.core.auth import CurrentUserDep +from app.core.identity import user_uuid +from app.schemas.task import TaskCreate, TaskRead +from app.services.task_service import TaskService + +router = APIRouter(prefix="/tasks", tags=["tasks"]) + + +@router.post("", response_model=TaskRead, status_code=status.HTTP_201_CREATED) +async def create_task( + body: TaskCreate, + user: CurrentUserDep, + session: DbSession, + publisher: EventPublisherDep, +) -> TaskRead: + service = TaskService(session) + task = await service.create( + publisher, + user_id=user_uuid(user.user_id), + title=body.title, + source=body.source, + ) + return TaskRead.model_validate(task) + + +@router.get("", response_model=list[TaskRead]) +async def list_tasks(user: CurrentUserDep, session: DbSession) -> list[TaskRead]: + service = TaskService(session) + tasks = await service.list_for_user(user_uuid(user.user_id)) + return [TaskRead.model_validate(task) for task in tasks] +``` + +- [ ] **Step 2: Register the router** + +Replace the contents of `apps/api/app/api/v1/router.py`: + +```python +"""Aggregates all v1 routers under the /v1 prefix.""" + +from fastapi import APIRouter + +from app.api.v1.routes import debug, health, me, tasks, webauthn + +api_router = APIRouter(prefix="/v1") +api_router.include_router(health.router) +api_router.include_router(me.router) +api_router.include_router(tasks.router) +api_router.include_router(webauthn.router) +api_router.include_router(debug.router) +``` + +- [ ] **Step 3: Verify the app boots and the routes register** + +Run: `cd apps/api && python -c "from app.main import app; print(sorted(r.path for r in app.routes if 'tasks' in r.path))"` +Expected: `['/v1/tasks']` + +- [ ] **Step 4: Commit** + +```bash +git add apps/api/app/api/v1/routes/tasks.py apps/api/app/api/v1/router.py +git commit -m "feat(api): add POST and GET /v1/tasks endpoints" +``` + +--- + +## Task 7: Backend test infrastructure + +**Files:** +- Modify: `apps/api/pyproject.toml` +- Modify: `apps/api/tests/conftest.py` + +- [ ] **Step 1: Add `fakeredis` to dev dependencies** + +In `apps/api/pyproject.toml`, inside `[project.optional-dependencies]` → `dev`, add `fakeredis` after the `httpx` line: + +```toml + "httpx>=0.28", # also for TestClient + "fakeredis>=2.26", # in-memory Redis (incl. Streams) for tests +``` + +- [ ] **Step 2: Install the new dependency** + +Run: `cd apps/api && uv sync --extra dev` +Expected: resolves and installs `fakeredis`. + +- [ ] **Step 3: Create the `lockin_test` database (one-time)** + +Run (Postgres up from the foundation gate): + +```bash +docker compose -f infra/docker/docker-compose.yml exec -T postgres createdb -U lockin lockin_test +``` + +Expected: exits 0 (or "already exists" — harmless; the fixture drops/recreates tables each test). + +- [ ] **Step 4: Extend conftest with DB + Redis fixtures** + +Replace the contents of `apps/api/tests/conftest.py`: + +```python +"""Shared pytest fixtures.""" + +from collections.abc import AsyncIterator + +import fakeredis.aioredis +import pytest_asyncio +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +import app.db.models # noqa: F401 -- registers all models on Base.metadata +from app.api.v1.deps import _db_session, _redis +from app.core.config import settings +from app.db.base import Base +from app.main import app + + +@pytest_asyncio.fixture +async def client() -> AsyncIterator[AsyncClient]: + # raise_app_exceptions=False makes uncaught exceptions surface as 500 + # responses, matching production ASGI servers. + transport = ASGITransport(app=app, raise_app_exceptions=False) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + + +def _test_db_url() -> str: + """Derive the test DB URL from DATABASE_URL by swapping the database name.""" + base, _, _name = settings.DATABASE_URL.rpartition("/") + return f"{base}/lockin_test" + + +@pytest_asyncio.fixture +async def db_engine() -> AsyncIterator[object]: + engine = create_async_engine(_test_db_url(), future=True) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + yield engine + await engine.dispose() + + +@pytest_asyncio.fixture +async def fake_redis() -> AsyncIterator[fakeredis.aioredis.FakeRedis]: + redis = fakeredis.aioredis.FakeRedis(decode_responses=True) + yield redis + await redis.aclose() + + +@pytest_asyncio.fixture +async def db_client( + db_engine: object, + fake_redis: fakeredis.aioredis.FakeRedis, +) -> AsyncIterator[AsyncClient]: + """An HTTP client whose API uses the test DB and an in-memory Redis.""" + maker = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False) + + async def _override_db() -> AsyncIterator[AsyncSession]: + async with maker() as session: + yield session + + async def _override_redis() -> AsyncIterator[fakeredis.aioredis.FakeRedis]: + yield fake_redis + + app.dependency_overrides[_db_session] = _override_db + app.dependency_overrides[_redis] = _override_redis + + transport = ASGITransport(app=app, raise_app_exceptions=False) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + + app.dependency_overrides.clear() +``` + +- [ ] **Step 5: Verify the existing suite still passes** + +Run: `cd apps/api && pytest tests/integration/test_health.py tests/integration/test_webauthn.py -v` +Expected: all pass — the untouched `client` fixture still works. + +- [ ] **Step 6: Commit** + +```bash +git add apps/api/pyproject.toml apps/api/uv.lock apps/api/tests/conftest.py +git commit -m "test(api): add DB and fakeredis fixtures for task endpoint tests" +``` + +--- + +## Task 8: Integration tests for `/v1/tasks` + +**Files:** +- Create: `apps/api/tests/integration/test_tasks.py` + +- [ ] **Step 1: Write the failing tests** + +Create `apps/api/tests/integration/test_tasks.py`: + +```python +"""Integration tests for the /v1/tasks endpoints. + +These exercise the full slice spine: JWT auth -> DB write -> event publish. +The DB is the `lockin_test` database; Redis is an in-memory fake. +""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime, timedelta + +import fakeredis.aioredis +from httpx import AsyncClient +from jose import jwt # type: ignore[import-untyped] +from lockin_events import STREAM_TASKS + +from app.core.config import settings + + +def _bearer(subject: str = "google-sub-117234567890") -> str: + token = jwt.encode( + { + "user_id": subject, + "email": "muzaffar@example.com", + "providers": ["google"], + "exp": datetime.now(UTC) + timedelta(minutes=5), + }, + settings.JWT_SECRET, + algorithm=settings.JWT_ALG, + ) + return f"Bearer {token}" + + +async def test_create_task_requires_auth(db_client: AsyncClient) -> None: + res = await db_client.post("/v1/tasks", json={"title": "no auth"}) + assert res.status_code == 401 + + +async def test_create_task_rejects_empty_title(db_client: AsyncClient) -> None: + res = await db_client.post( + "/v1/tasks", + headers={"Authorization": _bearer()}, + json={"title": ""}, + ) + assert res.status_code == 422 + + +async def test_create_task_returns_201_with_the_task(db_client: AsyncClient) -> None: + res = await db_client.post( + "/v1/tasks", + headers={"Authorization": _bearer()}, + json={"title": "Finish the spec doc"}, + ) + assert res.status_code == 201 + body = res.json() + assert body["title"] == "Finish the spec doc" + assert body["source"] == "keyboard" + assert "id" in body and "created_at" in body + + +async def test_create_task_emits_task_created_event( + db_client: AsyncClient, + fake_redis: fakeredis.aioredis.FakeRedis, +) -> None: + await db_client.post( + "/v1/tasks", + headers={"Authorization": _bearer()}, + json={"title": "Emit an event"}, + ) + entries = await fake_redis.xrange(STREAM_TASKS) + assert len(entries) == 1 + _entry_id, fields = entries[0] + event = json.loads(fields["data"]) + assert event["event_type"] == "task.created" + assert event["event_version"] == 1 + assert event["payload"]["title"] == "Emit an event" + + +async def test_list_tasks_returns_user_tasks_newest_first(db_client: AsyncClient) -> None: + headers = {"Authorization": _bearer()} + await db_client.post("/v1/tasks", headers=headers, json={"title": "first"}) + await db_client.post("/v1/tasks", headers=headers, json={"title": "second"}) + + res = await db_client.get("/v1/tasks", headers=headers) + assert res.status_code == 200 + titles = [t["title"] for t in res.json()] + assert titles == ["second", "first"] + + +async def test_list_tasks_isolates_by_user(db_client: AsyncClient) -> None: + await db_client.post( + "/v1/tasks", + headers={"Authorization": _bearer("user-a")}, + json={"title": "owned by A"}, + ) + res = await db_client.get( + "/v1/tasks", + headers={"Authorization": _bearer("user-b")}, + ) + assert res.status_code == 200 + assert res.json() == [] +``` + +- [ ] **Step 2: Run the tests** + +Run: `cd apps/api && pytest tests/integration/test_tasks.py -v` +Expected: 6 passed. + +- [ ] **Step 3: Commit** + +```bash +git add apps/api/tests/integration/test_tasks.py +git commit -m "test(api): cover /v1/tasks auth, persistence, and event emission" +``` + +--- + +## Task 9: Shared TypeScript types + +**Files:** +- Modify: `packages/shared-types/src/index.ts` + +- [ ] **Step 1: Define the task contracts** + +Replace the contents of `packages/shared-types/src/index.ts`: + +```typescript +// Shared API request/response types. Must stay in sync with +// apps/api/app/schemas/task.py. + +export type TaskSource = "keyboard" | "click" | "voice" | "mcp"; + +export interface TaskCreateRequest { + title: string; + source?: TaskSource; +} + +export interface TaskResponse { + id: string; + title: string; + source: string; + created_at: string; +} +``` + +- [ ] **Step 2: Verify the workspace still typechecks** + +Run: `pnpm --filter @lockin/shared-types typecheck` (or `pnpm typecheck` if the package has no standalone script) +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add packages/shared-types/src/index.ts +git commit -m "feat(shared-types): add TaskCreateRequest and TaskResponse" +``` + +--- + +## Task 10: React Query provider + layout + +**Files:** +- Modify: `apps/web/package.json` +- Create: `apps/web/src/app/providers.tsx` +- Modify: `apps/web/src/app/layout.tsx` + +- [ ] **Step 1: Install React Query** + +Run: `pnpm --filter @lockin/web add @tanstack/react-query` +Expected: `@tanstack/react-query` added to `apps/web/package.json` dependencies. + +- [ ] **Step 2: Create the Providers wrapper** + +Create `apps/web/src/app/providers.tsx`: + +```tsx +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useState, type ReactNode } from "react"; + +export function Providers({ children }: { children: ReactNode }) { + const [client] = useState( + () => + new QueryClient({ + defaultOptions: { queries: { staleTime: 30_000, retry: 1 } }, + }), + ); + return {children}; +} +``` + +- [ ] **Step 3: Wrap the app in Providers and fix metadata** + +Replace the contents of `apps/web/src/app/layout.tsx`: + +```tsx +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; +import { Providers } from "./providers"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "LockIn", + description: "Mood-and-energy-aware productivity agent.", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} +``` + +- [ ] **Step 4: Verify the web app builds** + +Run: `pnpm --filter @lockin/web typecheck` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/package.json apps/web/src/app/providers.tsx apps/web/src/app/layout.tsx pnpm-lock.yaml +git commit -m "feat(web): add React Query provider and fix root metadata" +``` + +--- + +## Task 11: BFF route handler — `/api/tasks` + +**Files:** +- Create: `apps/web/src/app/api/tasks/route.ts` + +- [ ] **Step 1: Create the proxy route** + +Create `apps/web/src/app/api/tasks/route.ts`: + +```ts +// BFF proxy: the browser calls this same-origin route; it reads the HttpOnly +// NextAuth session-token cookie and forwards it as a Bearer token to the +// FastAPI backend. This keeps the backend URL and the token off the client +// and avoids CORS entirely. + +import { type NextRequest, NextResponse } from "next/server"; + +const API_BASE_URL = process.env.API_BASE_URL ?? "http://localhost:8000"; + +// Must match the cookie name configured in apps/web/src/auth.ts. +const SESSION_COOKIE = + process.env.NODE_ENV === "production" + ? "__Secure-lockin.session-token" + : "lockin.session-token"; + +function bearer(req: NextRequest): string | null { + const token = req.cookies.get(SESSION_COOKIE)?.value; + return token ? `Bearer ${token}` : null; +} + +export async function GET(req: NextRequest) { + const auth = bearer(req); + if (!auth) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + const res = await fetch(`${API_BASE_URL}/v1/tasks`, { + headers: { Authorization: auth }, + cache: "no-store", + }); + return NextResponse.json(await res.json(), { status: res.status }); +} + +export async function POST(req: NextRequest) { + const auth = bearer(req); + if (!auth) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + const body = await req.text(); + const res = await fetch(`${API_BASE_URL}/v1/tasks`, { + method: "POST", + headers: { Authorization: auth, "Content-Type": "application/json" }, + body, + }); + return NextResponse.json(await res.json(), { status: res.status }); +} +``` + +- [ ] **Step 2: Verify it typechecks** + +Run: `pnpm --filter @lockin/web typecheck` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/app/api/tasks/route.ts +git commit -m "feat(web): add /api/tasks BFF proxy to the FastAPI backend" +``` + +--- + +## Task 12: Task data hooks + +**Files:** +- Create: `apps/web/src/hooks/use-tasks.ts` + +- [ ] **Step 1: Create the hooks** + +Create `apps/web/src/hooks/use-tasks.ts`: + +```ts +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { TaskCreateRequest, TaskResponse } from "@lockin/shared-types"; + +const TASKS_KEY = ["tasks"] as const; + +async function fetchTasks(): Promise { + const res = await fetch("/api/tasks", { cache: "no-store" }); + if (!res.ok) { + throw new Error("Failed to load tasks"); + } + return res.json(); +} + +async function createTask(input: TaskCreateRequest): Promise { + const res = await fetch("/api/tasks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + if (!res.ok) { + throw new Error("Failed to create task"); + } + return res.json(); +} + +export function useTasks() { + return useQuery({ queryKey: TASKS_KEY, queryFn: fetchTasks }); +} + +export function useCreateTask() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createTask, + onSuccess: () => queryClient.invalidateQueries({ queryKey: TASKS_KEY }), + }); +} +``` + +- [ ] **Step 2: Verify it typechecks** + +Run: `pnpm --filter @lockin/web typecheck` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/hooks/use-tasks.ts +git commit -m "feat(web): add useTasks and useCreateTask query hooks" +``` + +--- + +## Task 13: Command palette component + test + +**Files:** +- Create: `apps/web/src/components/command-palette.tsx` +- Create: `apps/web/vitest.config.ts` +- Create: `apps/web/vitest.setup.ts` +- Modify: `apps/web/package.json` +- Create: `apps/web/src/components/command-palette.test.tsx` + +- [ ] **Step 1: Create the component** + +Create `apps/web/src/components/command-palette.tsx`: + +```tsx +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Button, Input, Stack, Text } from "@lockin/ui"; + +export interface CommandPaletteProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (title: string) => void; +} + +export function CommandPalette({ open, onOpenChange, onSubmit }: CommandPaletteProps) { + const [value, setValue] = useState(""); + const inputRef = useRef(null); + + useEffect(() => { + function onKey(e: KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + onOpenChange(!open); + } + if (e.key === "Escape") { + onOpenChange(false); + } + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onOpenChange]); + + useEffect(() => { + if (open) { + inputRef.current?.focus(); + } + }, [open]); + + if (!open) { + return null; + } + + function submit() { + const title = value.trim(); + if (!title) { + return; + } + onSubmit(title); + setValue(""); + onOpenChange(false); + } + + return ( +
onOpenChange(false)} + > +
e.stopPropagation()} + > + + + Add a task + + setValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + submit(); + } + }} + /> + + +
+
+ ); +} +``` + +- [ ] **Step 2: Add web test dependencies** + +Run: `pnpm --filter @lockin/web add -D @testing-library/react @testing-library/jest-dom jsdom @vitejs/plugin-react` +Expected: the four packages added to `apps/web` devDependencies. + +- [ ] **Step 3: Create the vitest config and setup** + +Create `apps/web/vitest.config.ts`: + +```ts +import react from "@vitejs/plugin-react"; +import { resolve } from "node:path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { "@": resolve(__dirname, "./src") }, + }, + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./vitest.setup.ts"], + passWithNoTests: true, + }, +}); +``` + +Create `apps/web/vitest.setup.ts`: + +```ts +import "@testing-library/jest-dom/vitest"; +``` + +- [ ] **Step 4: Write the component test** + +Create `apps/web/src/components/command-palette.test.tsx`: + +```tsx +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { CommandPalette } from "./command-palette"; + +describe("CommandPalette", () => { + it("renders nothing when closed", () => { + const { container } = render( + {}} onSubmit={() => {}} />, + ); + expect(container).toBeEmptyDOMElement(); + }); + + it("submits the trimmed title and closes on Enter", () => { + const onSubmit = vi.fn(); + const onOpenChange = vi.fn(); + render(); + + const input = screen.getByPlaceholderText("What needs doing?"); + fireEvent.change(input, { target: { value: " Write the spec " } }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(onSubmit).toHaveBeenCalledWith("Write the spec"); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("ignores a blank title", () => { + const onSubmit = vi.fn(); + render( {}} onSubmit={onSubmit} />); + + const input = screen.getByPlaceholderText("What needs doing?"); + fireEvent.change(input, { target: { value: " " } }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(onSubmit).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 5: Run the test** + +Run: `pnpm --filter @lockin/web test` +Expected: 3 passed. + +- [ ] **Step 6: Commit** + +```bash +git add apps/web/src/components/command-palette.tsx apps/web/src/components/command-palette.test.tsx apps/web/vitest.config.ts apps/web/vitest.setup.ts apps/web/package.json pnpm-lock.yaml +git commit -m "feat(web): add Cmd+K command palette with tests" +``` + +--- + +## Task 14: Dashboard route + landing page + +**Files:** +- Create: `apps/web/src/app/dashboard/page.tsx` +- Create: `apps/web/src/app/dashboard/dashboard-client.tsx` +- Modify: `apps/web/src/app/page.tsx` + +- [ ] **Step 1: Create the dashboard server gate** + +Create `apps/web/src/app/dashboard/page.tsx`: + +```tsx +import { redirect } from "next/navigation"; +import { auth } from "@/auth"; +import { DashboardClient } from "./dashboard-client"; + +export default async function DashboardPage() { + const session = await auth(); + if (!session) { + redirect("/"); + } + return ; +} +``` + +- [ ] **Step 2: Create the dashboard client** + +Create `apps/web/src/app/dashboard/dashboard-client.tsx`: + +```tsx +"use client"; + +import { useState } from "react"; +import { Button, Card, Stack, Text } from "@lockin/ui"; +import { CommandPalette } from "@/components/command-palette"; +import { useCreateTask, useTasks } from "@/hooks/use-tasks"; + +export function DashboardClient() { + const [paletteOpen, setPaletteOpen] = useState(false); + const { data: tasks = [], isPending } = useTasks(); + const createTask = useCreateTask(); + + return ( +
+ + + Today + + + + + {isPending ? ( + Loading… + ) : tasks.length === 0 ? ( + + + No tasks yet + Press ⌘K to add your first task. + + + ) : ( + + {tasks.map((task) => ( + + {task.title} + + ))} + + )} + + + createTask.mutate({ title, source: "keyboard" })} + /> +
+ ); +} +``` + +- [ ] **Step 3: Replace the landing page with sign-in / redirect** + +Replace the contents of `apps/web/src/app/page.tsx`: + +```tsx +import { redirect } from "next/navigation"; +import { auth, signIn } from "@/auth"; +import { Button, Stack, Text } from "@lockin/ui"; + +export default async function Home() { + const session = await auth(); + if (session) { + redirect("/dashboard"); + } + + return ( +
+ + + LockIn + + Mood-aware productivity. Sign in to start. +
{ + "use server"; + await signIn("google", { redirectTo: "/dashboard" }); + }} + > + +
+
+
+ ); +} +``` + +- [ ] **Step 4: Verify the web app typechecks and builds** + +Run: `pnpm --filter @lockin/web typecheck` +Expected: no errors. + +- [ ] **Step 5: Manual smoke test of the full loop** + +With Postgres + Redis up, `make api` running, and `pnpm --filter @lockin/web dev` running: + +1. Visit `http://localhost:3000` → see "Sign in with Google". +2. Sign in → land on `/dashboard` with the "No tasks yet" empty state. +3. Press ⌘K (or Ctrl+K) → palette opens. Type `Finish spec doc`, press Enter. +4. Task appears in the list. Refresh the page → task still there. +5. `docker compose -f infra/docker/docker-compose.yml exec -T postgres psql -U lockin -d lockin -c "SELECT id, user_id, title FROM tasks;"` → shows the row. +6. `docker compose -f infra/docker/docker-compose.yml exec -T redis redis-cli XRANGE events:tasks - +` → shows a `task.created` event. + +Expected: all six steps succeed. + +- [ ] **Step 6: Commit** + +```bash +git add apps/web/src/app/dashboard/page.tsx apps/web/src/app/dashboard/dashboard-client.tsx apps/web/src/app/page.tsx +git commit -m "feat(web): add dashboard with task capture loop and sign-in landing" +``` + +--- + +## Task 15: Decision record + slice handoff + +**Files:** +- Create: `docs/decisions/2026-05-18-data-layer.md` +- Modify: `docs/CURRENT_SLICE.md` + +- [ ] **Step 1: Write the decision record** + +Create `docs/decisions/2026-05-18-data-layer.md`: + +```markdown +# Decision: Frontend data layer + user-identity mapping (Slice 0) + +**Date:** 2026-05-18 +**Status:** Accepted + +## Context + +`CURRENT_SLICE.md` instructed "use server actions or tRPC". The real backend +is FastAPI (Python); tRPC is TypeScript-only and Server Actions run only in +the Next.js runtime — neither calls FastAPI directly. Separately, auth is +JWT-strategy with no `users` table: identity is Google's `sub` (a numeric +string), but the event schema types `user_id` as `UUID`. + +## Decision + +1. **Data layer:** React Query (TanStack Query, already locked in `CLAUDE.md`) + on the client, calling a thin Next.js BFF route handler (`/api/tasks`) that + forwards the HttpOnly session-token cookie as a Bearer token to FastAPI. +2. **Identity:** `app/core/identity.py:user_uuid()` maps an OAuth subject to a + deterministic `uuid5` UUID, used as the key for every per-user row and event. + +## Consequences + +- No CORS surface — the browser only talks to same-origin Next.js routes. +- React Query's mutation primitives are ready for the Week 5 optimistic mood UI. +- The `_USER_NAMESPACE` constant in `identity.py` must never change — doing so + re-keys every user. A real `users` table (P2 team mode) can adopt the same + derived UUID as its primary key with no data migration. +``` + +- [ ] **Step 2: Update CURRENT_SLICE.md** + +Replace the contents of `docs/CURRENT_SLICE.md`: + +```markdown +# Current Slice — Week 3–4 Scaffolding + +> **✅ Slice 0 (Auth + Task Capture Spine) complete (2026-05-18):** Signed-in +> users capture tasks via a Cmd+K palette; tasks persist to Postgres and emit +> `task.created` to the `events:tasks` Redis Stream. Plan + outcome: +> [`docs/superpowers/plans/2026-05-18-slice-0-auth-task-capture-spine.md`](superpowers/plans/2026-05-18-slice-0-auth-task-capture-spine.md). + +**Status:** Ready to start +**Est. duration:** 10 working days + +## The Goal + +Scaffolding — grow the skeleton's organs. See the full handoff for the six +deliverables: Postgres schemas + Alembic migrations, TimescaleDB hypertables, +Redis Streams consumer groups, the API gateway middleware stack, Google +Calendar read-only sync, and the frontend shell. Zero user-facing features. + +## What's Next + +After Week 3–4 ships, Week 5–6 builds the capture loop (voice + text input, +mood/energy widget, notification permissions, real event instrumentation). + +--- +*Update this file when the slice ships. Archive previous slices in `docs/slices/`.* +``` + +- [ ] **Step 3: Run the full backend + frontend test suites** + +Run: + +```bash +cd apps/api && pytest -q +cd ../.. && pnpm --filter @lockin/web test && pnpm --filter @lockin/web typecheck +``` + +Expected: all green. + +- [ ] **Step 4: Commit** + +```bash +git add docs/decisions/2026-05-18-data-layer.md docs/CURRENT_SLICE.md +git commit -m "docs: record Slice 0 data-layer decision and advance CURRENT_SLICE" +``` + +--- + +## Task 16 (Stretch — only if ahead of schedule): Delete a task + +Build only if Tasks 1–15 are done and verified. This exercises idempotency thinking in the simplest context. + +**Files:** +- Modify: `apps/api/app/services/task_service.py` +- Modify: `apps/api/app/api/v1/routes/tasks.py` +- Modify: `apps/api/tests/integration/test_tasks.py` +- Modify: `apps/web/src/hooks/use-tasks.ts` +- Modify: `apps/web/src/app/dashboard/dashboard-client.tsx` + +- [ ] **Step 1: Add `delete` to `TaskService`** + +Add this method to `TaskService` in `apps/api/app/services/task_service.py` (after `list_for_user`): + +```python + async def delete(self, *, user_id: UUID, task_id: UUID) -> bool: + """Delete a task. Returns False if it does not exist for this user. + + Idempotent: deleting an already-absent task is not an error — the + caller's desired end state (task gone) is satisfied either way. + """ + result = await self._session.execute( + select(Task).where(Task.id == task_id, Task.user_id == user_id) + ) + task = result.scalar_one_or_none() + if task is None: + return False + await self._session.delete(task) + await self._session.commit() + return True +``` + +- [ ] **Step 2: Add the DELETE route** + +Add to `apps/api/app/api/v1/routes/tasks.py` (after `list_tasks`); add `UUID` to the imports and `Response` to the FastAPI import: + +```python +from uuid import UUID + +from fastapi import APIRouter, Response, status +``` + +```python +@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_task( + task_id: UUID, + user: CurrentUserDep, + session: DbSession, +) -> Response: + service = TaskService(session) + # Idempotent: 204 whether or not the row existed. + await service.delete(user_id=user_uuid(user.user_id), task_id=task_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) +``` + +- [ ] **Step 3: Add the integration test** + +Add to `apps/api/tests/integration/test_tasks.py`: + +```python +async def test_delete_task_is_idempotent(db_client: AsyncClient) -> None: + headers = {"Authorization": _bearer()} + created = await db_client.post("/v1/tasks", headers=headers, json={"title": "doomed"}) + task_id = created.json()["id"] + + first = await db_client.delete(f"/v1/tasks/{task_id}", headers=headers) + assert first.status_code == 204 + + # Deleting again is still 204 — desired end state already holds. + second = await db_client.delete(f"/v1/tasks/{task_id}", headers=headers) + assert second.status_code == 204 + + remaining = await db_client.get("/v1/tasks", headers=headers) + assert remaining.json() == [] +``` + +- [ ] **Step 4: Run the backend tests** + +Run: `cd apps/api && pytest tests/integration/test_tasks.py -v` +Expected: 7 passed. + +- [ ] **Step 5: Add the `useDeleteTask` hook** + +Add to `apps/web/src/hooks/use-tasks.ts` (after `createTask`, and export the hook): + +```ts +async function deleteTask(id: string): Promise { + const res = await fetch(`/api/tasks?id=${encodeURIComponent(id)}`, { + method: "DELETE", + }); + if (!res.ok) { + throw new Error("Failed to delete task"); + } +} + +export function useDeleteTask() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: deleteTask, + onSuccess: () => queryClient.invalidateQueries({ queryKey: TASKS_KEY }), + }); +} +``` + +- [ ] **Step 6: Add a DELETE handler to the BFF route** + +Add to `apps/web/src/app/api/tasks/route.ts`: + +```ts +export async function DELETE(req: NextRequest) { + const auth = bearer(req); + if (!auth) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + const id = req.nextUrl.searchParams.get("id"); + if (!id) { + return NextResponse.json({ error: "missing id" }, { status: 400 }); + } + const res = await fetch(`${API_BASE_URL}/v1/tasks/${id}`, { + method: "DELETE", + headers: { Authorization: auth }, + }); + return new NextResponse(null, { status: res.status }); +} +``` + +- [ ] **Step 7: Add a delete button to the task list** + +In `apps/web/src/app/dashboard/dashboard-client.tsx`, import and use `useDeleteTask`, and replace the task `Card` block: + +```tsx +import { useCreateTask, useDeleteTask, useTasks } from "@/hooks/use-tasks"; +``` + +```tsx + {tasks.map((task) => ( + +
+ {task.title} + +
+
+ ))} +``` + +Add `const deleteTask = useDeleteTask();` next to `const createTask = useCreateTask();`. + +- [ ] **Step 8: Verify and commit** + +Run: `cd apps/api && pytest -q && cd ../.. && pnpm --filter @lockin/web typecheck` +Expected: all green. + +```bash +git add apps/api/app/services/task_service.py apps/api/app/api/v1/routes/tasks.py apps/api/tests/integration/test_tasks.py apps/web/src/hooks/use-tasks.ts apps/web/src/app/api/tasks/route.ts apps/web/src/app/dashboard/dashboard-client.tsx +git commit -m "feat: add idempotent task deletion (stretch)" +``` + +--- + +## Definition of Done + +- [ ] `alembic upgrade head` creates the `tasks` table; `downgrade` removes it. +- [ ] `pytest apps/api` is fully green, including the six `/v1/tasks` integration tests. +- [ ] `pnpm --filter @lockin/web test` and `typecheck` are green. +- [ ] Manual loop (Task 14 Step 5) verified: sign in → ⌘K → type → Enter → task in list → survives refresh → row in Postgres → `task.created` in `events:tasks`. +- [ ] `docs/CURRENT_SLICE.md` points at Week 3–4 Scaffolding. +- [ ] Data-layer + identity decision recorded in `docs/decisions/`. diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 7efc8b8..8b52a7e 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -1,2 +1,19 @@ -// Stub. API request/response types land per slice. -export {}; +// Shared API request/response types. Must stay in sync with +// apps/api/app/schemas/task.py. + +export type TaskSource = "keyboard" | "click" | "voice" | "mcp"; + +export interface TaskCreateRequest { + /** 1–500 characters. Enforced server-side by the API schema. */ + title: string; + source?: TaskSource; +} + +export interface TaskResponse { + id: string; + title: string; + // Intentionally `string`, not `TaskSource`: mirrors the API's forward-compatible + // `str` typing so new source values don't break deserialization. + source: string; + created_at: string; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1303007..57994fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,16 +34,19 @@ importers: version: link:../../packages/ui '@sentry/nextjs': specifier: ^10.52.0 - version: 10.53.1(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.106.2(lightningcss@1.32.0)) + version: 10.53.1(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.106.2(lightningcss@1.32.0)) + '@tanstack/react-query': + specifier: ^5.100.11 + version: 5.100.11(react@19.2.4) jose: specifier: ^5.9.6 version: 5.10.0 next: specifier: 16.2.6 - version: 16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-auth: specifier: 5.0.0-beta.31 - version: 5.0.0-beta.31(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + version: 5.0.0-beta.31(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) react: specifier: 19.2.4 version: 19.2.4 @@ -54,6 +57,12 @@ importers: '@tailwindcss/postcss': specifier: ^4 version: 4.3.0 + '@testing-library/jest-dom': + specifier: ^6.6.3 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@types/node': specifier: ^20 version: 20.19.40 @@ -63,12 +72,18 @@ importers: '@types/react-dom': specifier: ^19 version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^5.2.0 + version: 5.2.0(vite@7.3.3(@types/node@20.19.40)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)) eslint: specifier: ^9 version: 9.39.4(jiti@2.7.0) eslint-config-next: specifier: 16.2.6 version: 16.2.6(@typescript-eslint/parser@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + jsdom: + specifier: ^25.0.1 + version: 25.0.1 rimraf: specifier: ^6.0.1 version: 6.1.3 @@ -1183,6 +1198,9 @@ packages: '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + '@rollup/plugin-commonjs@28.0.1': resolution: {integrity: sha512-+tNWdlWKbpB3WgBN7ijjYkq9X5uhjmcvyjEght4NmH5fAU++zfQzAJ6wumLS+dNcvwEZhKx2Z+skY8m7v0wGSA==} engines: {node: '>=16.0.0 || 14 >= 14.17'} @@ -1640,6 +1658,14 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@tanstack/query-core@5.100.11': + resolution: {integrity: sha512-lmE0994apShXPj8CUxgx4ch5yUJhE9k/+tVwihBvPOyerACWdBocfFg24t8+0RhtlTd7tEgchDkhlCxNssvDxw==} + + '@tanstack/react-query@5.100.11': + resolution: {integrity: sha512-J0f9s5x3LE1450nNNfYx+e/n0DMa0uOBdFJUy5r0RvmsXd4nB/n0rbHtHI1vYXhikNFan+wf51p6Tmp4c8ucrg==} + peerDependencies: + react: ^18 || ^19 + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -1933,6 +1959,12 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitejs/plugin-react@5.2.0': + resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -3443,6 +3475,10 @@ packages: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + react@19.2.4: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} @@ -4991,6 +5027,8 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.27': {} + '@rolldown/pluginutils@1.0.0-rc.3': {} + '@rollup/plugin-commonjs@28.0.1(rollup@4.60.3)': dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.60.3) @@ -5175,7 +5213,7 @@ snapshots: '@sentry/core@10.53.1': {} - '@sentry/nextjs@10.53.1(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.106.2(lightningcss@1.32.0))': + '@sentry/nextjs@10.53.1(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.106.2(lightningcss@1.32.0))': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/semantic-conventions': 1.41.1 @@ -5188,7 +5226,7 @@ snapshots: '@sentry/react': 10.53.1(react@19.2.4) '@sentry/vercel-edge': 10.53.1 '@sentry/webpack-plugin': 5.3.0(webpack@5.106.2(lightningcss@1.32.0)) - next: 16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) rollup: 4.60.3 stacktrace-parser: 0.1.11 transitivePeerDependencies: @@ -5432,6 +5470,13 @@ snapshots: tailwindcss: 4.3.0 vite: 7.3.3(@types/node@20.19.40)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0) + '@tanstack/query-core@5.100.11': {} + + '@tanstack/react-query@5.100.11(react@19.2.4)': + dependencies: + '@tanstack/query-core': 5.100.11 + react: 19.2.4 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 @@ -5737,6 +5782,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitejs/plugin-react@5.2.0(vite@7.3.3(@types/node@20.19.40)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-rc.3 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 7.3.3(@types/node@20.19.40)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0) + transitivePeerDependencies: + - supports-color + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.3 @@ -6363,8 +6420,8 @@ snapshots: '@next/eslint-plugin-next': 16.2.6 eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4(jiti@2.7.0)) @@ -6386,7 +6443,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -6397,22 +6454,22 @@ snapshots: tinyglobby: 0.2.16 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -6423,7 +6480,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) hasown: 2.0.3 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -7156,13 +7213,13 @@ snapshots: neo-async@2.6.2: {} - next-auth@5.0.0-beta.31(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): + next-auth@5.0.0-beta.31(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): dependencies: '@auth/core': 0.41.2 - next: 16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 - next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.2.6 '@swc/helpers': 0.5.15 @@ -7171,7 +7228,7 @@ snapshots: postcss: 8.4.31 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - styled-jsx: 5.1.6(react@19.2.4) + styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4) optionalDependencies: '@next/swc-darwin-arm64': 16.2.6 '@next/swc-darwin-x64': 16.2.6 @@ -7454,6 +7511,8 @@ snapshots: react-refresh@0.17.0: {} + react-refresh@0.18.0: {} + react@19.2.4: {} recast@0.23.11: @@ -7811,10 +7870,12 @@ snapshots: dependencies: js-tokens: 9.0.1 - styled-jsx@5.1.6(react@19.2.4): + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4): dependencies: client-only: 0.0.1 react: 19.2.4 + optionalDependencies: + '@babel/core': 7.29.0 supports-color@7.2.0: dependencies: diff --git a/uv.lock b/uv.lock index 7497682..2a9e7c4 100644 --- a/uv.lock +++ b/uv.lock @@ -603,6 +603,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/79/119091c98e2bf49e24ed9f3ae69f816d715d2904aefa6a2baa039a2ba0b0/ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399", size = 150818, upload-time = "2026-03-26T09:58:15.808Z" }, ] +[[package]] +name = "fakeredis" +version = "2.35.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "redis" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/50/b748233c02fa77e5105238190cc9bb58b852eb1c8b1d0763230d3a5b745a/fakeredis-2.35.1.tar.gz", hash = "sha256:5bae5eba7b9d93cb968944ac40936373cf2397ff71667d4b595df65c3d2e413f", size = 189118, upload-time = "2026-04-12T17:05:58.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/27/b8b057a23f7777177e92d3a602fd866751b6b45014964548997e92e048fd/fakeredis-2.35.1-py3-none-any.whl", hash = "sha256:67d97e11f562b7870e11e5c30cf182270bfb2dd37f6707dba47cc6d91628d1b9", size = 129678, upload-time = "2026-04-12T17:05:56.86Z" }, +] + [[package]] name = "fastapi" version = "0.136.1" @@ -952,6 +965,7 @@ dependencies = [ [package.optional-dependencies] dev = [ + { name = "fakeredis" }, { name = "httpx" }, { name = "mypy" }, { name = "pre-commit" }, @@ -972,6 +986,7 @@ requires-dist = [ { name = "alembic", specifier = ">=1.14" }, { name = "asyncpg", specifier = ">=0.30" }, { name = "authlib", specifier = ">=1.3" }, + { name = "fakeredis", marker = "extra == 'dev'", specifier = ">=2.26" }, { name = "fastapi", specifier = ">=0.115" }, { name = "httpx", specifier = ">=0.28" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28" }, @@ -2310,6 +2325,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + [[package]] name = "sqlalchemy" version = "2.0.49"