Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
f1dd371
docs: add Slice 0 auth + task capture spine implementation plan
Muzaffar-codes07 May 19, 2026
5031466
feat(api): add user_uuid identity helper for OAuth subject mapping
Muzaffar-codes07 May 19, 2026
563a292
test(api): assert user_uuid returns a v5 UUID
Muzaffar-codes07 May 19, 2026
5289f7a
feat(api): add tasks table model and migration 0003
Muzaffar-codes07 May 19, 2026
04ecc83
feat(api): add TaskCreate/TaskRead API schemas
Muzaffar-codes07 May 19, 2026
d547d13
feat(api): add Redis and EventPublisher FastAPI dependencies
Muzaffar-codes07 May 19, 2026
4dc959b
feat(api): add TaskService with Postgres + Redis dual write
Muzaffar-codes07 May 19, 2026
3096939
refactor(api): tighten TaskService.create source typing to TaskSource
Muzaffar-codes07 May 19, 2026
462afb8
feat(api): add POST and GET /v1/tasks endpoints
Muzaffar-codes07 May 19, 2026
874401f
test(api): add DB and fakeredis fixtures for task endpoint tests
Muzaffar-codes07 May 19, 2026
c9a82cb
chore(api): lock fakeredis dev dependency in uv.lock
Muzaffar-codes07 May 19, 2026
ba082b8
test(api): harden db_client override cleanup and test-DB url guard
Muzaffar-codes07 May 19, 2026
26ad735
test(api): fix app-name shadowing in conftest imports
Muzaffar-codes07 May 19, 2026
637ffc5
test(api): cover /v1/tasks auth, persistence, and event emission
Muzaffar-codes07 May 19, 2026
a6215ce
test(api): document subject-mapping and ordering assumptions in task …
Muzaffar-codes07 May 19, 2026
e31ca9f
feat(shared-types): add TaskCreateRequest and TaskResponse
Muzaffar-codes07 May 19, 2026
5a0b1ba
docs(shared-types): document title constraint and TaskResponse.source…
Muzaffar-codes07 May 19, 2026
c1b72c5
feat(web): add React Query provider and fix root metadata
Muzaffar-codes07 May 19, 2026
1efcba7
docs(plan): record Slice 0 execution status through Task 10
Muzaffar-codes07 May 20, 2026
cd6f05e
feat(web): add /api/tasks BFF proxy to the FastAPI backend
Muzaffar-codes07 May 20, 2026
259c92a
feat(web): harden /api/tasks BFF against non-JSON and unreachable ups…
Muzaffar-codes07 May 20, 2026
ac4cbf2
feat(web): add useTasks and useCreateTask query hooks
Muzaffar-codes07 May 20, 2026
8003a30
feat(web): include HTTP status in task-hook error messages
Muzaffar-codes07 May 20, 2026
03431c7
feat(web): add Cmd+K command palette with tests
Muzaffar-codes07 May 20, 2026
a03cc2e
fix(web): reset palette value on close; alias @lockin/ui in vitest
Muzaffar-codes07 May 20, 2026
4bbdb78
feat(web): add dashboard with task capture loop and sign-in landing
Muzaffar-codes07 May 20, 2026
fc72483
feat(web): surface task creation errors and document palette reset
Muzaffar-codes07 May 20, 2026
557a797
docs: record Slice 0 data-layer decision and advance CURRENT_SLICE
Muzaffar-codes07 May 20, 2026
38f4ca4
feat: add idempotent task deletion (stretch)
Muzaffar-codes07 May 20, 2026
a3e73e4
feat: harden task deletion with auth and isolation tests, error UI
Muzaffar-codes07 May 20, 2026
56829e5
docs(plan): mark Slice 0 execution complete
Muzaffar-codes07 May 20, 2026
39a4d5a
chore: track .graphifyignore (excludes node_modules, build outputs, S…
Muzaffar-codes07 May 22, 2026
e8d3ec1
fix(ci): unblock PR pipeline for Slice 0
Muzaffar-codes07 May 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
51 changes: 51 additions & 0 deletions .graphifyignore
Original file line number Diff line number Diff line change
@@ -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/
38 changes: 38 additions & 0 deletions apps/api/alembic/versions/0003_tasks.py
Original file line number Diff line number Diff line change
@@ -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")
22 changes: 21 additions & 1 deletion apps/api/app/api/v1/deps.py
Original file line number Diff line number Diff line change
@@ -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]:
Expand All @@ -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)]
3 changes: 2 additions & 1 deletion apps/api/app/api/v1/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
51 changes: 51 additions & 0 deletions apps/api/app/api/v1/routes/tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""`/v1/tasks` — create and list a user's captured tasks."""

from __future__ import annotations

from uuid import UUID

from fastapi import APIRouter, Response, status

from app.api.v1.deps import DbSession, EventPublisherDep
from app.core.auth import CurrentUserDep
from app.core.identity import user_uuid
from app.schemas.task import TaskCreate, TaskRead
from app.services.task_service import TaskService

router = APIRouter(prefix="/tasks", tags=["tasks"])


@router.post("", response_model=TaskRead, status_code=status.HTTP_201_CREATED)
async def create_task(
body: TaskCreate,
user: CurrentUserDep,
session: DbSession,
publisher: EventPublisherDep,
) -> TaskRead:
service = TaskService(session)
task = await service.create(
publisher,
user_id=user_uuid(user.user_id),
title=body.title,
source=body.source,
)
return TaskRead.model_validate(task)


@router.get("", response_model=list[TaskRead])
async def list_tasks(user: CurrentUserDep, session: DbSession) -> list[TaskRead]:
service = TaskService(session)
tasks = await service.list_for_user(user_uuid(user.user_id))
return [TaskRead.model_validate(task) for task in tasks]


@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_task(
task_id: UUID,
user: CurrentUserDep,
session: DbSession,
) -> Response:
service = TaskService(session)
# Idempotent: 204 whether or not the row existed.
await service.delete(user_id=user_uuid(user.user_id), task_id=task_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
21 changes: 21 additions & 0 deletions apps/api/app/core/identity.py
Original file line number Diff line number Diff line change
@@ -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)
3 changes: 2 additions & 1 deletion apps/api/app/db/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
31 changes: 31 additions & 0 deletions apps/api/app/db/models/task.py
Original file line number Diff line number Diff line change
@@ -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,
)
2 changes: 1 addition & 1 deletion apps/api/app/schemas/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
"""Pydantic v2 DTOs (request/response). Grouped by aggregate, added per slice."""
"""API request/response Pydantic models. Not ORM models, not event models."""
29 changes: 29 additions & 0 deletions apps/api/app/schemas/task.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading