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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions TODO_PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# PR-004: Attachments + Link Detection - Implementation Plan

## Overview

Implement task attachments with automatic URL detection, normalization, and deduplication. Supports provider registry pattern for future integration extensibility (Gmail, GitHub, etc.).

## Dependencies

- **PR-002**: Task CRUD API (tasks must exist before attachments can reference them)

## Implementation Todos

### Database & Models

1. [ ] Create `Attachment` model in `backend/models/attachment.py` (priority: high)
- Fields: id, task_id (FK), type, reference, title, content, created_at, updated_at
- Unique constraint on (task_id, reference) for dedup

2. [ ] Create Alembic migration for attachments table (priority: high)
- Add foreign key constraint with cascade delete
- Ensure PRAGMA foreign_keys=ON is set in migration

3. [ ] Create `AttachmentCreate`, `AttachmentResponse` schemas in `backend/schemas/` (priority: high)

### Services

4. [ ] Create `AttachmentService` in `backend/services/` (priority: high)
- `create_attachment()` with dedup check
- `list_attachments()` by task_id
- `delete_attachment()` by id
- Ensure proper async session handling

5. [ ] Implement `LinkDetectionService` in `backend/services/` (priority: high)
- URL parsing from text (title/description)
- Provider registry pattern with `match_url()` and `normalize()` hooks
- Implement GitHub, Gmail, and generic URL providers

6. [ ] Add attachment auto-detection hook to `TaskService` (priority: medium)
- Call `LinkDetectionService` on task create/update
- Create attachments for detected URLs automatically

### API Endpoints

7. [ ] Create attachment router in `backend/api/v1/attachments.py` (priority: high)
- `POST /api/v1/tasks/{id}/attachments` - create attachment
- `GET /api/v1/tasks/{id}/attachments` - list attachments
- `DELETE /api/v1/attachments/{attachment_id}` - delete attachment

8. [ ] Register attachment router in `backend/api/v1/__init__.py` (priority: high)

9. [ ] Add 409 conflict response for duplicate references (priority: medium)
- Return appropriate error message on unique constraint violation

### Testing

10. [ ] Write unit tests for `AttachmentService` (priority: high)
- Test create/list/delete with proper session handling
- Test dedup logic on (task_id, reference) constraint

11. [ ] Write unit tests for `LinkDetectionService` (priority: high)
- Test URL parsing from various text formats
- Test provider matching and normalization
- Test GitHub/Gmail provider implementations

12. [ ] Write API tests for attachment CRUD endpoints (priority: high)
- Test all endpoints with valid/invalid data
- Test 404 errors for missing task/attachment
- Test 409 for duplicate attachments

13. [ ] Write integration tests for auto-detection (priority: medium)
- Create task with URLs in title/description
- Verify attachments are created automatically
- Test duplicate handling on task updates

### Documentation

14. [ ] Update API reference docs with attachment endpoints (priority: medium)
- Add to `docs/01-design/API_REFERENCE.md`

15. [ ] Update `docs/01-design/DESIGN_DATA.md` with Attachment model (priority: low)

## Testing Plan

### Unit Tests
- `AttachmentService`: create, list, delete, dedup
- `LinkDetectionService`: URL parsing, provider matching, normalization
- Provider implementations: GitHub, Gmail, generic

### Integration Tests
- API endpoints: CRUD operations, error handling
- Auto-detection flow: task create/update triggers attachment creation

### Manual Tests
- Create task with URLs in description; verify attachments created
- Paste duplicate links; verify no duplicate attachments
- Test different URL formats (with/without tracking params)

## Notes

- Follow database patterns from `docs/02-implementation/AGENT_GUIDE.md`
- Use async/await for all service and API methods
- Ensure proper foreign key handling in SQLite
- No external fetching in this PR - only URL normalization and storage
131 changes: 131 additions & 0 deletions backend/api/v1/attachments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""Attachment API endpoints.

Author:
Raymond Christopher (raymond.christopher@gdplabs.id)
"""

from __future__ import annotations

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

from backend.database import get_db
from backend.schemas.attachment import AttachmentCreate, AttachmentListResponse, AttachmentResponse
from backend.schemas.task import ErrorResponse
from backend.services.attachment_service import (
AttachmentNotFoundError,
DuplicateAttachmentError,
create_attachment,
delete_attachment,
get_attachment,
list_attachments,
)
from backend.services.task_service import TaskNotFoundError
from backend.services.task_service import get_task as get_task_service

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


@router.post("", response_model=AttachmentResponse, status_code=status.HTTP_201_CREATED)
async def create_attachment_endpoint(
attachment_data: AttachmentCreate, session: AsyncSession = Depends(get_db)
) -> AttachmentResponse:
"""Create a new attachment.

Args:
attachment_data: Attachment creation data.
session: Database session.

Returns:
AttachmentResponse: Created attachment.

Raises:
HTTPException: 409 if duplicate attachment, 404 if task not found.
"""
try:
await get_task_service(session, attachment_data.task_id)
except TaskNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ErrorResponse(error="Task not found", code=exc.code).model_dump(),
) from exc

try:
return await create_attachment(session, attachment_data)
except DuplicateAttachmentError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=ErrorResponse(error=exc.message, code=exc.code).model_dump()
) from exc


@router.get("/task/{task_id}", response_model=AttachmentListResponse)
async def list_attachments_endpoint(
task_id: str,
limit: int = Query(50, ge=1, description="Maximum number of results"),
offset: int = Query(0, ge=0, description="Pagination offset"),
session: AsyncSession = Depends(get_db),
) -> AttachmentListResponse:
"""List attachments for a task with pagination.

Args:
task_id: Task ID.
limit: Maximum number of results (default: 50).
offset: Pagination offset (default: 0).
session: Database session.

Returns:
AttachmentListResponse: Paginated attachment list.

Raises:
HTTPException: 404 if task not found.
"""
try:
await get_task_service(session, task_id)
except TaskNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ErrorResponse(error="Task not found", code=exc.code).model_dump(),
) from exc

return await list_attachments(session, task_id, limit, offset)


@router.get("/{attachment_id}", response_model=AttachmentResponse)
async def get_attachment_endpoint(attachment_id: str, session: AsyncSession = Depends(get_db)) -> AttachmentResponse:
"""Get an attachment by ID.

Args:
attachment_id: Attachment ID.
session: Database session.

Returns:
AttachmentResponse: Attachment data.

Raises:
HTTPException: 404 if attachment not found.
"""
try:
return await get_attachment(session, attachment_id)
except AttachmentNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ErrorResponse(error=exc.message, code=exc.code).model_dump()
) from exc


@router.delete("/{attachment_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_attachment_endpoint(attachment_id: str, session: AsyncSession = Depends(get_db)) -> None:
"""Delete an attachment.

Args:
attachment_id: Attachment ID.
session: Database session.

Raises:
HTTPException: 404 if attachment not found.
"""
try:
await delete_attachment(session, attachment_id)
except AttachmentNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ErrorResponse(error=exc.message, code=exc.code).model_dump()
) from exc
33 changes: 33 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

from backend.api.v1 import attachments as attachments_router
from backend.api.v1 import tasks as tasks_router
from backend.api.v1 import telemetry
from backend.config import get_settings
from backend.database import close_db, init_db_async
from backend.logging import setup_logging
from backend.middleware import RequestLoggingMiddleware
from backend.schemas.task import ErrorResponse
from backend.services.attachment_service import AttachmentNotFoundError, DuplicateAttachmentError
from backend.services.task_service import TaskNotFoundError


Expand All @@ -44,6 +46,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:

# Register API routers
app.include_router(tasks_router.router, prefix="/api/v1")
app.include_router(attachments_router.router, prefix="/api/v1")

# Only register telemetry router if enabled
if settings.telemetry_enabled:
Expand All @@ -65,6 +68,36 @@ async def task_not_found_handler(request: Request, exc: TaskNotFoundError) -> JS
return JSONResponse(status_code=404, content=error_response.model_dump())


@app.exception_handler(AttachmentNotFoundError)
async def attachment_not_found_handler(request: Request, exc: AttachmentNotFoundError) -> JSONResponse:
"""Handle AttachmentNotFoundError exceptions.

Args:
request: FastAPI request object.
exc: AttachmentNotFoundError exception.

Returns:
JSONResponse: 404 response with error details.
"""
error_response = ErrorResponse(error=exc.message, code=exc.code)
return JSONResponse(status_code=404, content=error_response.model_dump())


@app.exception_handler(DuplicateAttachmentError)
async def duplicate_attachment_handler(request: Request, exc: DuplicateAttachmentError) -> JSONResponse:
"""Handle DuplicateAttachmentError exceptions.

Args:
request: FastAPI request object.
exc: DuplicateAttachmentError exception.

Returns:
JSONResponse: 409 response with error details.
"""
error_response = ErrorResponse(error=exc.message, code=exc.code)
return JSONResponse(status_code=409, content=error_response.model_dump())


@app.get("/health")
async def health_check() -> dict[str, str]:
"""Health check endpoint."""
Expand Down
26 changes: 26 additions & 0 deletions backend/migrations/versions/002_add_unique_constraint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Add unique constraint on attachments (task_id, reference).

Revision ID: 002_add_unique_constraint
Revises: 001_initial
Create Date: 2025-12-30 00:00:00.000000

"""

from collections.abc import Sequence

from alembic import op

revision: str = "002_add_unique_constraint"
down_revision: str | None = "001_initial"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
with op.batch_alter_table("attachments") as batch_op:
batch_op.create_unique_constraint("uq_attachments_task_reference", ["task_id", "reference"])


def downgrade() -> None:
with op.batch_alter_table("attachments") as batch_op:
batch_op.drop_constraint("uq_attachments_task_reference")
25 changes: 24 additions & 1 deletion backend/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,27 @@
Raymond Christopher (raymond.christopher@gdplabs.id)
"""

__all__: list[str] = []
from backend.schemas.attachment import AttachmentCreate, AttachmentListResponse, AttachmentResponse, AttachmentType
from backend.schemas.task import (
ErrorResponse,
TaskCreate,
TaskListResponse,
TaskPriority,
TaskResponse,
TaskStatus,
TaskUpdate,
)

__all__: list[str] = [
"AttachmentCreate",
"AttachmentListResponse",
"AttachmentResponse",
"AttachmentType",
"ErrorResponse",
"TaskCreate",
"TaskListResponse",
"TaskPriority",
"TaskResponse",
"TaskStatus",
"TaskUpdate",
]
Loading