From f1dd371a06449d15b7814d16cd8d9a74bf48519d Mon Sep 17 00:00:00 2001 From: Muzaffar-codes07 Date: Tue, 19 May 2026 22:07:22 +0530 Subject: [PATCH 01/33] docs: add Slice 0 auth + task capture spine implementation plan Co-Authored-By: Claude Opus 4.7 --- ...6-05-18-slice-0-auth-task-capture-spine.md | 1702 +++++++++++++++++ 1 file changed, 1702 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-18-slice-0-auth-task-capture-spine.md 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..bb80dd8 --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-slice-0-auth-task-capture-spine.md @@ -0,0 +1,1702 @@ +# 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. + +**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/`. From 5031466215a62f25db095b51d99acffa2305d8f3 Mon Sep 17 00:00:00 2001 From: Muzaffar-codes07 Date: Tue, 19 May 2026 22:10:38 +0530 Subject: [PATCH 02/33] feat(api): add user_uuid identity helper for OAuth subject mapping --- apps/api/app/core/identity.py | 21 +++++++++++++++++++++ apps/api/tests/unit/test_identity.py | 19 +++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 apps/api/app/core/identity.py create mode 100644 apps/api/tests/unit/test_identity.py 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/tests/unit/test_identity.py b/apps/api/tests/unit/test_identity.py new file mode 100644 index 0000000..c3421fe --- /dev/null +++ b/apps/api/tests/unit/test_identity.py @@ -0,0 +1,19 @@ +"""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) From 563a2923d3954d642edac10d48b88dc4d1d6826b Mon Sep 17 00:00:00 2001 From: Muzaffar-codes07 Date: Tue, 19 May 2026 22:13:08 +0530 Subject: [PATCH 03/33] test(api): assert user_uuid returns a v5 UUID --- apps/api/tests/unit/test_identity.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/api/tests/unit/test_identity.py b/apps/api/tests/unit/test_identity.py index c3421fe..2054765 100644 --- a/apps/api/tests/unit/test_identity.py +++ b/apps/api/tests/unit/test_identity.py @@ -17,3 +17,4 @@ def test_user_uuid_differs_per_subject() -> None: def test_user_uuid_returns_a_uuid() -> None: assert isinstance(user_uuid("117234567890"), UUID) + assert user_uuid("117234567890").version == 5 From 5289f7a628239acfccb45b130131ee32e5ecb76e Mon Sep 17 00:00:00 2001 From: Muzaffar-codes07 Date: Tue, 19 May 2026 22:15:37 +0530 Subject: [PATCH 04/33] feat(api): add tasks table model and migration 0003 Co-Authored-By: Claude Sonnet 4.6 --- apps/api/alembic/versions/0003_tasks.py | 38 +++++++++++++++++++++++++ apps/api/app/db/models/__init__.py | 3 +- apps/api/app/db/models/task.py | 31 ++++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 apps/api/alembic/versions/0003_tasks.py create mode 100644 apps/api/app/db/models/task.py 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/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, + ) From 04ecc83f62a2a9d4cfbe4e3172556d8f176eb0ee Mon Sep 17 00:00:00 2001 From: Muzaffar-codes07 Date: Tue, 19 May 2026 22:20:12 +0530 Subject: [PATCH 05/33] feat(api): add TaskCreate/TaskRead API schemas --- apps/api/app/schemas/__init__.py | 2 +- apps/api/app/schemas/task.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 apps/api/app/schemas/task.py 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 From d547d132f06269111f32c320e186b14b354ed1e3 Mon Sep 17 00:00:00 2001 From: Muzaffar-codes07 Date: Tue, 19 May 2026 22:24:53 +0530 Subject: [PATCH 06/33] feat(api): add Redis and EventPublisher FastAPI dependencies --- apps/api/app/api/v1/deps.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) 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)] From 4dc959b59db4b05c8aa75707c7b7b65145821512 Mon Sep 17 00:00:00 2001 From: Muzaffar-codes07 Date: Tue, 19 May 2026 22:28:34 +0530 Subject: [PATCH 07/33] feat(api): add TaskService with Postgres + Redis dual write --- apps/api/app/services/task_service.py | 62 +++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 apps/api/app/services/task_service.py diff --git a/apps/api/app/services/task_service.py b/apps/api/app/services/task_service.py new file mode 100644 index 0000000..6c0a0e6 --- /dev/null +++ b/apps/api/app/services/task_service.py @@ -0,0 +1,62 @@ +"""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()) From 30969398fbdcbe3fb83a4c03428d5a3461d78d38 Mon Sep 17 00:00:00 2001 From: Muzaffar-codes07 Date: Tue, 19 May 2026 22:38:47 +0530 Subject: [PATCH 08/33] refactor(api): tighten TaskService.create source typing to TaskSource Co-Authored-By: Claude Sonnet 4.6 --- apps/api/app/services/task_service.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/api/app/services/task_service.py b/apps/api/app/services/task_service.py index 6c0a0e6..8260f44 100644 --- a/apps/api/app/services/task_service.py +++ b/apps/api/app/services/task_service.py @@ -16,6 +16,7 @@ from app.db.models.task import Task from app.events.publisher import EventPublisher +from app.schemas.task import TaskSource class TaskService: @@ -28,7 +29,7 @@ async def create( *, user_id: UUID, title: str, - source: str, + source: TaskSource, ) -> Task: task = Task(id=uuid4(), user_id=user_id, title=title, source=source) self._session.add(task) @@ -44,7 +45,7 @@ async def create( "tenant_id": None, "occurred_at": task.created_at.isoformat(), "client_idempotency_key": None, - "source": "web", + "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, From 462afb815073687e40725208bb7a863cb921ae62 Mon Sep 17 00:00:00 2001 From: Muzaffar-codes07 Date: Tue, 19 May 2026 22:41:22 +0530 Subject: [PATCH 09/33] feat(api): add POST and GET /v1/tasks endpoints --- apps/api/app/api/v1/router.py | 3 ++- apps/api/app/api/v1/routes/tasks.py | 37 +++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 apps/api/app/api/v1/routes/tasks.py 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..e652fcb --- /dev/null +++ b/apps/api/app/api/v1/routes/tasks.py @@ -0,0 +1,37 @@ +"""`/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] From 874401ffa9ebe485d196a79a6006d0e875ccc943 Mon Sep 17 00:00:00 2001 From: Muzaffar-codes07 Date: Tue, 19 May 2026 22:50:36 +0530 Subject: [PATCH 10/33] test(api): add DB and fakeredis fixtures for task endpoint tests --- apps/api/pyproject.toml | 1 + apps/api/tests/conftest.py | 63 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 61 insertions(+), 3 deletions(-) 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..340f230 100644 --- a/apps/api/tests/conftest.py +++ b/apps/api/tests/conftest.py @@ -2,18 +2,75 @@ from collections.abc import AsyncIterator +import fakeredis.aioredis import pytest_asyncio from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + 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. 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("/") + 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[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() From c9a82cb38c44de6fa74645f06528419ebc48e8a9 Mon Sep 17 00:00:00 2001 From: Muzaffar-codes07 Date: Tue, 19 May 2026 22:57:23 +0530 Subject: [PATCH 11/33] chore(api): lock fakeredis dev dependency in uv.lock Co-Authored-By: Claude Sonnet 4.6 --- uv.lock | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) 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" From ba082b87fe618f135c15cf76686a085ee914cc3a Mon Sep 17 00:00:00 2001 From: Muzaffar-codes07 Date: Tue, 19 May 2026 23:02:31 +0530 Subject: [PATCH 12/33] test(api): harden db_client override cleanup and test-DB url guard - Wrap db_client yield in try/finally so dependency_overrides.clear() is guaranteed to run even when a test body raises (prevents override leaking into later tests via the module-level app singleton) - Narrow _override_redis annotation from FakeRedis to Redis to match the _redis dep it overrides; add `from redis.asyncio import Redis` - Guard _test_db_url() against a malformed DATABASE_URL that would silently produce an invalid connection string Co-Authored-By: Claude Sonnet 4.6 --- apps/api/tests/conftest.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/api/tests/conftest.py b/apps/api/tests/conftest.py index 340f230..7a755d7 100644 --- a/apps/api/tests/conftest.py +++ b/apps/api/tests/conftest.py @@ -5,6 +5,7 @@ import fakeredis.aioredis import pytest_asyncio from httpx import ASGITransport, AsyncClient +from redis.asyncio import Redis from sqlalchemy.ext.asyncio import ( AsyncEngine, AsyncSession, @@ -31,6 +32,8 @@ async def client() -> AsyncIterator[AsyncClient]: 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" @@ -63,14 +66,15 @@ async def _override_db() -> AsyncIterator[AsyncSession]: async with maker() as session: yield session - async def _override_redis() -> AsyncIterator[fakeredis.aioredis.FakeRedis]: + async def _override_redis() -> AsyncIterator[Redis]: 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() + 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() From 26ad7355f165d825660d5b4f0399d6fd6ec89d9e Mon Sep 17 00:00:00 2001 From: Muzaffar-codes07 Date: Tue, 19 May 2026 23:05:52 +0530 Subject: [PATCH 13/33] test(api): fix app-name shadowing in conftest imports Replace `import app.db.models` with `from app.db import models as _models` so the name `app` is not rebound, eliminating 6 mypy errors caused by the module shadowing the FastAPI instance imported from app.main. Co-Authored-By: Claude Sonnet 4.6 --- apps/api/tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/tests/conftest.py b/apps/api/tests/conftest.py index 7a755d7..814948a 100644 --- a/apps/api/tests/conftest.py +++ b/apps/api/tests/conftest.py @@ -13,9 +13,9 @@ 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 import models as _models # noqa: F401 -- registers all models on Base.metadata from app.db.base import Base from app.main import app From 637ffc5ad88ced32ab4f93ebf2a04b1c77b0dadf Mon Sep 17 00:00:00 2001 From: Muzaffar-codes07 Date: Tue, 19 May 2026 23:36:44 +0530 Subject: [PATCH 14/33] test(api): cover /v1/tasks auth, persistence, and event emission Co-Authored-By: Claude Sonnet 4.6 --- apps/api/tests/integration/test_tasks.py | 101 +++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 apps/api/tests/integration/test_tasks.py diff --git a/apps/api/tests/integration/test_tasks.py b/apps/api/tests/integration/test_tasks.py new file mode 100644 index 0000000..796cb4f --- /dev/null +++ b/apps/api/tests/integration/test_tasks.py @@ -0,0 +1,101 @@ +"""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() == [] From a6215cecd55d3ff06f53737893368d8ff5d1b3d1 Mon Sep 17 00:00:00 2001 From: Muzaffar-codes07 Date: Tue, 19 May 2026 23:46:46 +0530 Subject: [PATCH 15/33] test(api): document subject-mapping and ordering assumptions in task tests --- apps/api/tests/integration/test_tasks.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/api/tests/integration/test_tasks.py b/apps/api/tests/integration/test_tasks.py index 796cb4f..1d0918a 100644 --- a/apps/api/tests/integration/test_tasks.py +++ b/apps/api/tests/integration/test_tasks.py @@ -17,6 +17,9 @@ 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( { @@ -81,6 +84,8 @@ async def test_list_tasks_returns_user_tasks_newest_first(db_client: AsyncClient 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()] From e31ca9f6d59e873e2f570100323e15822695023f Mon Sep 17 00:00:00 2001 From: Muzaffar-codes07 Date: Tue, 19 May 2026 23:48:36 +0530 Subject: [PATCH 16/33] feat(shared-types): add TaskCreateRequest and TaskResponse --- packages/shared-types/src/index.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 7efc8b8..f96995a 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -1,2 +1,16 @@ -// 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 { + title: string; + source?: TaskSource; +} + +export interface TaskResponse { + id: string; + title: string; + source: string; + created_at: string; +} From 5a0b1ba14ac520296c06a712287d3c3c4f64b4f9 Mon Sep 17 00:00:00 2001 From: Muzaffar-codes07 Date: Tue, 19 May 2026 23:52:17 +0530 Subject: [PATCH 17/33] docs(shared-types): document title constraint and TaskResponse.source typing --- packages/shared-types/src/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index f96995a..8b52a7e 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -4,6 +4,7 @@ export type TaskSource = "keyboard" | "click" | "voice" | "mcp"; export interface TaskCreateRequest { + /** 1–500 characters. Enforced server-side by the API schema. */ title: string; source?: TaskSource; } @@ -11,6 +12,8 @@ export interface TaskCreateRequest { 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; } From c1b72c57720a33aa1103693b8d54c33746b6ced0 Mon Sep 17 00:00:00 2001 From: Muzaffar-codes07 Date: Tue, 19 May 2026 23:54:28 +0530 Subject: [PATCH 18/33] feat(web): add React Query provider and fix root metadata Co-Authored-By: Claude Sonnet 4.6 --- apps/web/package.json | 1 + apps/web/src/app/layout.tsx | 9 ++++++--- apps/web/src/app/providers.tsx | 14 ++++++++++++++ pnpm-lock.yaml | 34 ++++++++++++++++++++++++++-------- 4 files changed, 47 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/app/providers.tsx diff --git a/apps/web/package.json b/apps/web/package.json index e64637d..b27b6d6 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", 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/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/pnpm-lock.yaml b/pnpm-lock.yaml index 1303007..fc8e967 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@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)) + '@tanstack/react-query': + specifier: ^5.100.11 + version: 5.100.11(react@19.2.4) jose: specifier: ^5.9.6 version: 5.10.0 @@ -1640,6 +1643,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'} @@ -5432,6 +5443,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 @@ -6363,8 +6381,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 +6404,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 +6415,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 +6441,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 From 1efcba796a4e4bad6c25284cfac3270e265d73b5 Mon Sep 17 00:00:00 2001 From: Muzaffar-codes07 Date: Wed, 20 May 2026 12:27:23 +0530 Subject: [PATCH 19/33] docs(plan): record Slice 0 execution status through Task 10 Co-Authored-By: Claude Opus 4.7 --- ...6-05-18-slice-0-auth-task-capture-spine.md | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) 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 index bb80dd8..d4074e6 100644 --- 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 @@ -2,6 +2,44 @@ > **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 — paused 2026-05-19 + +Executing via subagent-driven development on branch `feat/slice-0-auth-task-capture-spine`. **Tasks 1–10 are COMPLETE** (implemented, spec-reviewed, code-quality-reviewed, all review issues resolved). **Tasks 11–16 + final review remain.** Resume at Task 11. + +| 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` | ⬜ Not started | — | next | +| 12 — Task data hooks | ⬜ Not started | — | | +| 13 — Command palette + test | ⬜ Not started | — | | +| 14 — Dashboard + landing page | ⬜ Not started | — | | +| 15 — Decision record + handoff | ⬜ Not started | — | | +| 16 — (stretch) task deletion | ⬜ Not started | — | | +| Final code review | ⬜ Not started | — | | + +**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. From cd6f05ee5350659410a224a197d88b56ec474d8b Mon Sep 17 00:00:00 2001 From: Muzaffar-codes07 Date: Wed, 20 May 2026 12:30:14 +0530 Subject: [PATCH 20/33] feat(web): add /api/tasks BFF proxy to the FastAPI backend --- apps/web/src/app/api/tasks/route.ts | 45 +++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 apps/web/src/app/api/tasks/route.ts 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..27155a2 --- /dev/null +++ b/apps/web/src/app/api/tasks/route.ts @@ -0,0 +1,45 @@ +// 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 }); +} From 259c92a3a670692d630e788568beb067d2f80271 Mon Sep 17 00:00:00 2001 From: Muzaffar-codes07 Date: Wed, 20 May 2026 12:33:22 +0530 Subject: [PATCH 21/33] feat(web): harden /api/tasks BFF against non-JSON and unreachable upstream --- apps/web/src/app/api/tasks/route.ts | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/api/tasks/route.ts b/apps/web/src/app/api/tasks/route.ts index 27155a2..47279c9 100644 --- a/apps/web/src/app/api/tasks/route.ts +++ b/apps/web/src/app/api/tasks/route.ts @@ -18,16 +18,32 @@ function bearer(req: NextRequest): string | null { 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 }); + } + 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 }); } - const res = await fetch(`${API_BASE_URL}/v1/tasks`, { + return forward(`${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) { @@ -36,10 +52,9 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "unauthorized" }, { status: 401 }); } const body = await req.text(); - const res = await fetch(`${API_BASE_URL}/v1/tasks`, { + return forward(`${API_BASE_URL}/v1/tasks`, { method: "POST", headers: { Authorization: auth, "Content-Type": "application/json" }, body, }); - return NextResponse.json(await res.json(), { status: res.status }); } From ac4cbf2316571df23566549f455aeea7f1f3b8e1 Mon Sep 17 00:00:00 2001 From: Muzaffar-codes07 Date: Wed, 20 May 2026 12:35:20 +0530 Subject: [PATCH 22/33] feat(web): add useTasks and useCreateTask query hooks --- apps/web/src/hooks/use-tasks.ts | 38 +++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 apps/web/src/hooks/use-tasks.ts diff --git a/apps/web/src/hooks/use-tasks.ts b/apps/web/src/hooks/use-tasks.ts new file mode 100644 index 0000000..bb61b13 --- /dev/null +++ b/apps/web/src/hooks/use-tasks.ts @@ -0,0 +1,38 @@ +"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 }), + }); +} From 8003a30c496d6e2bdfb6995949ad0719f57b23dd Mon Sep 17 00:00:00 2001 From: Muzaffar-codes07 Date: Wed, 20 May 2026 12:39:17 +0530 Subject: [PATCH 23/33] feat(web): include HTTP status in task-hook error messages --- apps/web/src/hooks/use-tasks.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/hooks/use-tasks.ts b/apps/web/src/hooks/use-tasks.ts index bb61b13..f2a7326 100644 --- a/apps/web/src/hooks/use-tasks.ts +++ b/apps/web/src/hooks/use-tasks.ts @@ -8,7 +8,7 @@ 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"); + throw new Error(`Failed to load tasks: ${res.status}`); } return res.json(); } @@ -20,7 +20,7 @@ async function createTask(input: TaskCreateRequest): Promise { body: JSON.stringify(input), }); if (!res.ok) { - throw new Error("Failed to create task"); + throw new Error(`Failed to create task: ${res.status}`); } return res.json(); } From 03431c7a67bf606fa030033d062618c42987919b Mon Sep 17 00:00:00 2001 From: Muzaffar-codes07 Date: Wed, 20 May 2026 12:42:15 +0530 Subject: [PATCH 24/33] feat(web): add Cmd+K command palette with tests --- apps/web/package.json | 4 + .../src/components/command-palette.test.tsx | 36 ++++++++ apps/web/src/components/command-palette.tsx | 85 +++++++++++++++++++ apps/web/vitest.config.ts | 16 ++++ apps/web/vitest.setup.ts | 1 + pnpm-lock.yaml | 12 +++ 6 files changed, 154 insertions(+) create mode 100644 apps/web/src/components/command-palette.test.tsx create mode 100644 apps/web/src/components/command-palette.tsx create mode 100644 apps/web/vitest.config.ts create mode 100644 apps/web/vitest.setup.ts diff --git a/apps/web/package.json b/apps/web/package.json index b27b6d6..895a75a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -26,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": "^4.3.4", "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/components/command-palette.test.tsx b/apps/web/src/components/command-palette.test.tsx new file mode 100644 index 0000000..9d4cf57 --- /dev/null +++ b/apps/web/src/components/command-palette.test.tsx @@ -0,0 +1,36 @@ +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(); + }); +}); diff --git a/apps/web/src/components/command-palette.tsx b/apps/web/src/components/command-palette.tsx new file mode 100644 index 0000000..4a2bfe8 --- /dev/null +++ b/apps/web/src/components/command-palette.tsx @@ -0,0 +1,85 @@ +"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(); + } + }} + /> + + +
+
+ ); +} diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 0000000..3b60ccc --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,16 @@ +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, + }, +}); 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/pnpm-lock.yaml b/pnpm-lock.yaml index fc8e967..6948171 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,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 @@ -66,12 +72,18 @@ importers: '@types/react-dom': specifier: ^19 version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.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 From a03cc2eca128394957a9001fcca1c068d0630e71 Mon Sep 17 00:00:00 2001 From: Muzaffar-codes07 Date: Wed, 20 May 2026 12:47:09 +0530 Subject: [PATCH 25/33] fix(web): reset palette value on close; alias @lockin/ui in vitest Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/components/command-palette.test.tsx | 14 ++++++++++++++ apps/web/src/components/command-palette.tsx | 2 ++ apps/web/vitest.config.ts | 5 ++++- 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/command-palette.test.tsx b/apps/web/src/components/command-palette.test.tsx index 9d4cf57..8da0040 100644 --- a/apps/web/src/components/command-palette.test.tsx +++ b/apps/web/src/components/command-palette.test.tsx @@ -33,4 +33,18 @@ describe("CommandPalette", () => { 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 index 4a2bfe8..bc1173d 100644 --- a/apps/web/src/components/command-palette.tsx +++ b/apps/web/src/components/command-palette.tsx @@ -30,6 +30,8 @@ export function CommandPalette({ open, onOpenChange, onSubmit }: CommandPaletteP useEffect(() => { if (open) { inputRef.current?.focus(); + } else { + setValue(""); } }, [open]); diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 3b60ccc..cc2d9d7 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -5,7 +5,10 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ plugins: [react()], resolve: { - alias: { "@": resolve(__dirname, "./src") }, + alias: { + "@": resolve(__dirname, "./src"), + "@lockin/ui": resolve(__dirname, "../../packages/ui/src/index.ts"), + }, }, test: { environment: "jsdom", From 4bbdb785b0101f82242c16280a6d83dc0b68bacc Mon Sep 17 00:00:00 2001 From: Muzaffar-codes07 Date: Wed, 20 May 2026 12:53:03 +0530 Subject: [PATCH 26/33] feat(web): add dashboard with task capture loop and sign-in landing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the end-to-end visible loop: landing page redirects signed-in users to /dashboard; dashboard server gate checks auth and delegates to DashboardClient which renders the task list, empty state, and ⌘K command palette. Also fixes a pre-existing lint error in CommandPalette (setState in effect → moved to cleanup return). Co-Authored-By: Claude Sonnet 4.6 --- .../src/app/dashboard/dashboard-client.tsx | 51 +++++++++++++++++++ apps/web/src/app/dashboard/page.tsx | 11 ++++ apps/web/src/app/page.tsx | 22 ++++++-- apps/web/src/components/command-palette.tsx | 5 +- 4 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/app/dashboard/dashboard-client.tsx create mode 100644 apps/web/src/app/dashboard/page.tsx 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..f1d39e1 --- /dev/null +++ b/apps/web/src/app/dashboard/dashboard-client.tsx @@ -0,0 +1,51 @@ +"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" })} + /> +
+ ); +} 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/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/components/command-palette.tsx b/apps/web/src/components/command-palette.tsx index bc1173d..cc35feb 100644 --- a/apps/web/src/components/command-palette.tsx +++ b/apps/web/src/components/command-palette.tsx @@ -30,8 +30,9 @@ export function CommandPalette({ open, onOpenChange, onSubmit }: CommandPaletteP useEffect(() => { if (open) { inputRef.current?.focus(); - } else { - setValue(""); + return () => { + setValue(""); + }; } }, [open]); From fc72483148ee970af78c2d5d30fbecf336366f63 Mon Sep 17 00:00:00 2001 From: Muzaffar-codes07 Date: Wed, 20 May 2026 12:59:10 +0530 Subject: [PATCH 27/33] feat(web): surface task creation errors and document palette reset --- apps/web/src/app/dashboard/dashboard-client.tsx | 6 ++++++ apps/web/src/components/command-palette.tsx | 9 ++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/dashboard/dashboard-client.tsx b/apps/web/src/app/dashboard/dashboard-client.tsx index f1d39e1..66fe4be 100644 --- a/apps/web/src/app/dashboard/dashboard-client.tsx +++ b/apps/web/src/app/dashboard/dashboard-client.tsx @@ -21,6 +21,12 @@ export function DashboardClient() { Add a task — ⌘K + {createTask.isError ? ( + + Couldn't add task: {(createTask.error as Error).message} + + ) : null} + {isPending ? ( Loading… ) : tasks.length === 0 ? ( diff --git a/apps/web/src/components/command-palette.tsx b/apps/web/src/components/command-palette.tsx index cc35feb..37d534c 100644 --- a/apps/web/src/components/command-palette.tsx +++ b/apps/web/src/components/command-palette.tsx @@ -30,10 +30,13 @@ export function CommandPalette({ open, onOpenChange, onSubmit }: CommandPaletteP useEffect(() => { if (open) { inputRef.current?.focus(); - return () => { - setValue(""); - }; } + // 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) { From 557a797e413c52aadda13bb0a76a5dfadecbbbf8 Mon Sep 17 00:00:00 2001 From: Muzaffar-codes07 Date: Wed, 20 May 2026 13:01:23 +0530 Subject: [PATCH 28/33] docs: record Slice 0 data-layer decision and advance CURRENT_SLICE Co-Authored-By: Claude Sonnet 4.6 --- docs/CURRENT_SLICE.md | 68 +++++-------------------- docs/decisions/2026-05-18-data-layer.md | 28 ++++++++++ 2 files changed, 42 insertions(+), 54 deletions(-) create mode 100644 docs/decisions/2026-05-18-data-layer.md 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. From 38f4ca474bad09b26aea41d332d44dddd5389d0f Mon Sep 17 00:00:00 2001 From: Muzaffar-codes07 Date: Wed, 20 May 2026 13:10:04 +0530 Subject: [PATCH 29/33] feat: add idempotent task deletion (stretch) --- apps/api/app/api/v1/routes/tasks.py | 16 +++++++++++++++- apps/api/app/services/task_service.py | 16 ++++++++++++++++ apps/api/tests/integration/test_tasks.py | 16 ++++++++++++++++ apps/web/src/app/api/tasks/route.ts | 18 ++++++++++++++++++ .../web/src/app/dashboard/dashboard-client.tsx | 15 +++++++++++++-- apps/web/src/hooks/use-tasks.ts | 17 +++++++++++++++++ 6 files changed, 95 insertions(+), 3 deletions(-) diff --git a/apps/api/app/api/v1/routes/tasks.py b/apps/api/app/api/v1/routes/tasks.py index e652fcb..3b73970 100644 --- a/apps/api/app/api/v1/routes/tasks.py +++ b/apps/api/app/api/v1/routes/tasks.py @@ -2,7 +2,9 @@ from __future__ import annotations -from fastapi import APIRouter, status +from uuid import UUID + +from fastapi import APIRouter, Response, status from app.api.v1.deps import DbSession, EventPublisherDep from app.core.auth import CurrentUserDep @@ -35,3 +37,15 @@ 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/services/task_service.py b/apps/api/app/services/task_service.py index 8260f44..84e37f7 100644 --- a/apps/api/app/services/task_service.py +++ b/apps/api/app/services/task_service.py @@ -61,3 +61,19 @@ async def list_for_user(self, user_id: UUID) -> list[Task]: 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/tests/integration/test_tasks.py b/apps/api/tests/integration/test_tasks.py index 1d0918a..2835daa 100644 --- a/apps/api/tests/integration/test_tasks.py +++ b/apps/api/tests/integration/test_tasks.py @@ -104,3 +104,19 @@ async def test_list_tasks_isolates_by_user(db_client: AsyncClient) -> None: ) 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() == [] diff --git a/apps/web/src/app/api/tasks/route.ts b/apps/web/src/app/api/tasks/route.ts index 47279c9..3912c24 100644 --- a/apps/web/src/app/api/tasks/route.ts +++ b/apps/web/src/app/api/tasks/route.ts @@ -28,6 +28,9 @@ async function forward( } catch { return NextResponse.json({ error: "service_unavailable" }, { status: 503 }); } + if (res.status === 204 || res.headers.get("content-length") === "0") { + return new NextResponse(null, { status: res.status }); + } const contentType = res.headers.get("content-type") ?? ""; const payload = contentType.includes("application/json") ? await res.json() @@ -58,3 +61,18 @@ export async function POST(req: NextRequest) { body, }); } + +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 index 66fe4be..5961161 100644 --- a/apps/web/src/app/dashboard/dashboard-client.tsx +++ b/apps/web/src/app/dashboard/dashboard-client.tsx @@ -3,12 +3,13 @@ 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"; +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 (
@@ -40,7 +41,17 @@ export function DashboardClient() { {tasks.map((task) => ( - {task.title} +
+ {task.title} + +
))}
diff --git a/apps/web/src/hooks/use-tasks.ts b/apps/web/src/hooks/use-tasks.ts index f2a7326..39bbb10 100644 --- a/apps/web/src/hooks/use-tasks.ts +++ b/apps/web/src/hooks/use-tasks.ts @@ -36,3 +36,20 @@ export function useCreateTask() { 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 }), + }); +} From a3e73e4139192c32606ec7a835557edb69947f29 Mon Sep 17 00:00:00 2001 From: Muzaffar-codes07 Date: Wed, 20 May 2026 13:23:11 +0530 Subject: [PATCH 30/33] feat: harden task deletion with auth and isolation tests, error UI --- apps/api/tests/integration/test_tasks.py | 27 +++++++++++++++++++ apps/web/src/app/api/tasks/route.ts | 6 +++-- .../src/app/dashboard/dashboard-client.tsx | 6 +++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/apps/api/tests/integration/test_tasks.py b/apps/api/tests/integration/test_tasks.py index 2835daa..11c60ca 100644 --- a/apps/api/tests/integration/test_tasks.py +++ b/apps/api/tests/integration/test_tasks.py @@ -120,3 +120,30 @@ async def test_delete_task_is_idempotent(db_client: AsyncClient) -> None: 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/web/src/app/api/tasks/route.ts b/apps/web/src/app/api/tasks/route.ts index 3912c24..807b6a6 100644 --- a/apps/web/src/app/api/tasks/route.ts +++ b/apps/web/src/app/api/tasks/route.ts @@ -28,8 +28,8 @@ async function forward( } catch { return NextResponse.json({ error: "service_unavailable" }, { status: 503 }); } - if (res.status === 204 || res.headers.get("content-length") === "0") { - return new NextResponse(null, { status: res.status }); + if (res.status === 204) { + return new NextResponse(null, { status: 204 }); } const contentType = res.headers.get("content-type") ?? ""; const payload = contentType.includes("application/json") @@ -62,6 +62,8 @@ export async function POST(req: NextRequest) { }); } +// 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) { diff --git a/apps/web/src/app/dashboard/dashboard-client.tsx b/apps/web/src/app/dashboard/dashboard-client.tsx index 5961161..7cf37cb 100644 --- a/apps/web/src/app/dashboard/dashboard-client.tsx +++ b/apps/web/src/app/dashboard/dashboard-client.tsx @@ -28,6 +28,12 @@ export function DashboardClient() { ) : null} + {deleteTask.isError ? ( + + Couldn't delete task: {(deleteTask.error as Error).message} + + ) : null} + {isPending ? ( Loading… ) : tasks.length === 0 ? ( From 56829e5696b5043d34f422ae385d98a26b456468 Mon Sep 17 00:00:00 2001 From: Muzaffar-codes07 Date: Wed, 20 May 2026 13:45:10 +0530 Subject: [PATCH 31/33] docs(plan): mark Slice 0 execution complete Co-Authored-By: Claude Opus 4.7 --- ...26-05-18-slice-0-auth-task-capture-spine.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) 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 index d4074e6..82a3016 100644 --- 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 @@ -4,9 +4,9 @@ --- -## Execution Status — paused 2026-05-19 +## Execution Status — COMPLETE 2026-05-19 -Executing via subagent-driven development on branch `feat/slice-0-auth-task-capture-spine`. **Tasks 1–10 are COMPLETE** (implemented, spec-reviewed, code-quality-reviewed, all review issues resolved). **Tasks 11–16 + final review remain.** Resume at Task 11. +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 | |------|--------|--------------|-------| @@ -20,13 +20,13 @@ Executing via subagent-driven development on branch `feat/slice-0-auth-task-capt | 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` | ⬜ Not started | — | next | -| 12 — Task data hooks | ⬜ Not started | — | | -| 13 — Command palette + test | ⬜ Not started | — | | -| 14 — Dashboard + landing page | ⬜ Not started | — | | -| 15 — Decision record + handoff | ⬜ Not started | — | | -| 16 — (stretch) task deletion | ⬜ Not started | — | | -| Final code review | ⬜ Not started | — | | +| 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. From 39a4d5a211b358369994b39761156a4aa4eb13ca Mon Sep 17 00:00:00 2001 From: Muzaffar-codes07 Date: Fri, 22 May 2026 16:20:15 +0530 Subject: [PATCH 32/33] chore: track .graphifyignore (excludes node_modules, build outputs, Storybook static) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storybook static build artifacts add ~5,800 Parcel-bundled vendor JS nodes to the knowledge graph with mangled symbol names — pure noise. Excluding them yields a 92% node-count reduction and lets the LockIn architecture surface cleanly in graphify-out/graph.html. Co-Authored-By: Claude Opus 4.7 --- .graphifyignore | 51 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .graphifyignore 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/ From e8d3ec187f604e7e17ee77a923430f40774a2658 Mon Sep 17 00:00:00 2001 From: Muzaffar-codes07 Date: Fri, 22 May 2026 16:57:05 +0530 Subject: [PATCH 33/33] fix(ci): unblock PR pipeline for Slice 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CI failures, both fixed in one go (history-clean — no transient literal credentials): 1. js — @vitejs/plugin-react@4.x is CJS but vitest 3.x bundles Vite 7 (ESM-only). Bumped to ^5.0.0 (ESM-native) and renamed vitest.config.ts to .mts so Node loads the config as ESM. (apps/web has no "type": "module", so .ts configs default to CJS and cannot require() ESM.) Linux Node 20 rejects the boundary mismatch that local Windows Node tolerates. 2. python-api — CI workflow had no Postgres, so the Slice 0 db_client fixture (Task 7) couldn't connect to :5432. Start an ephemeral Postgres via docker run inside a step, generate a fresh random password per run (openssl rand), mask it in logs with ::add-mask:: and export DATABASE_URL + AUTH_SECRET through $GITHUB_ENV. No literal credentials in source — GitGuardian-clean. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/pr.yml | 31 +++++++++++ apps/web/package.json | 2 +- .../{vitest.config.ts => vitest.config.mts} | 0 pnpm-lock.yaml | 55 +++++++++++++++---- 4 files changed, 75 insertions(+), 13 deletions(-) rename apps/web/{vitest.config.ts => vitest.config.mts} (100%) 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/apps/web/package.json b/apps/web/package.json index 895a75a..dfe1d95 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -31,7 +31,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", - "@vitejs/plugin-react": "^4.3.4", + "@vitejs/plugin-react": "^5.2.0", "eslint": "^9", "eslint-config-next": "16.2.6", "jsdom": "^25.0.1", diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.mts similarity index 100% rename from apps/web/vitest.config.ts rename to apps/web/vitest.config.mts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6948171..57994fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,7 +34,7 @@ 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) @@ -43,10 +43,10 @@ importers: 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 @@ -73,8 +73,8 @@ importers: specifier: ^19 version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': - specifier: ^4.3.4 - version: 4.7.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)) + 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) @@ -1198,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'} @@ -1956,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==} @@ -3466,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'} @@ -5014,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) @@ -5198,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 @@ -5211,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: @@ -5767,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 @@ -7186,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 @@ -7201,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 @@ -7484,6 +7511,8 @@ snapshots: react-refresh@0.17.0: {} + react-refresh@0.18.0: {} + react@19.2.4: {} recast@0.23.11: @@ -7841,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: