# PR-1 Apply Report: Backend Core for Notification Center

## Status: COMPLETE

All 11 tasks for PR-1 (NC-PR1-001 through NC-PR1-011) have been implemented, tested, and validated.

## What Was Implemented

### Database Layer
- **Alembic migration** (`alembic/versions/2026_05_29_add_notifications_table.py`)
  - Creates `notifications` table with all design-spec columns
  - FK `user_id` -> `users.id` with `ON DELETE CASCADE`
  - Index `idx_notifications_user_created_at` on `(user_id, created_at DESC)`
  - Partial index `idx_notifications_user_unread` on `(user_id, read_at)` where `read_at IS NULL`

- **SQLAlchemy model** (`src/models/notification.py`)
  - `Notification` class with `UUIDPrimaryKeyMixin` + `Base`
  - `notification_metadata` attribute mapped to DB column `"metadata"` (avoids SQLAlchemy `Base.metadata` conflict)
  - Exported from `src/models/__init__.py`

### Service Layer
- **NotificationService singleton** (`src/services/notification_service.py`)
  - `create_notification(session, user_id, ...)` — inserts row, returns `Notification`
  - `list_notifications(session, user_id, ...)` — returns `(items, total)` tuple, excludes dismissed, supports `unread_only` and `mute_categories`
  - `get_unread_count(session, user_id)` — counts unread + non-dismissed
  - `mark_read(session, notification_id, user_id)` — sets `read_at = now()`
  - `mark_all_read(session, user_id)` — bulk update, returns count
  - `dismiss(session, notification_id, user_id)` — soft-delete via `dismissed_at = now()`
  - All methods enforce `user_id` filtering; wrong-owner raises `ValueError("Notification not found")`

### API Layer
- **FastAPI router** (`src/api/notifications.py`) mounted at `/notifications`
  - `GET /notifications` — paginated list with `limit`, `offset`, `unread_only` query params; `limit` capped at 100
  - `GET /notifications/unread` — returns `{count: int}`
  - `PATCH /notifications/{id}/read` — marks single notification read
  - `POST /notifications/mark-all-read` — returns `{marked_count: int}`
  - `DELETE /notifications/{id}` — soft-delete (dismiss), returns `204`
  - Reads `notification_mute_categories` from `UserConfig.config` JSON blob and passes to `list_notifications`
  - Returns `404` for non-owned or missing notifications
  - Pydantic `NotificationItem` serializes `notification_metadata` as `"metadata"` via `Field(serialization_alias="metadata")`

### Registration
- Router imported and included in `src/main.py`
- `Notification` model imported in `src/main.py` with `# noqa: F401` for Alembic autogenerate discovery
- `notifications_router` exported from `src/api/__init__.py`

### Tests
- **13 unit tests** (`tests/unit/test_notification_service.py`) covering:
  - Create, list, unread count, mark read, mark all read, dismiss
  - Cross-user isolation, wrong-owner 404-equivalent, mute categories filtering
  - Dismissed excluded from unread count, mark-all-read affects only caller
- **10 integration tests** (`tests/integration/test_notifications_api.py`) covering:
  - Auth requirements, ownership isolation, pagination
  - Mark read / dismiss endpoints and 404 for other users
  - Mute categories filter at API layer

## Changed Files

1. `apps/api/alembic/versions/2026_05_29_add_notifications_table.py` *(new)*
2. `apps/api/src/models/notification.py` *(new)*
3. `apps/api/src/models/__init__.py`
4. `apps/api/src/services/notification_service.py` *(new)*
5. `apps/api/src/api/notifications.py` *(new)*
6. `apps/api/src/api/__init__.py`
7. `apps/api/src/main.py`
8. `apps/api/tests/unit/test_notification_service.py` *(new)*
9. `apps/api/tests/integration/test_notifications_api.py` *(new)*
10. `apps/api/tests/integration/test_models.py`

## Test Evidence

### RED -> GREEN -> TRIANGULATE Cycles

| Cycle | Task | RED | GREEN | Result |
|-------|------|-----|-------|--------|
| 1 | Service unit tests (basic CRUD) | 13 tests written against missing service | Implemented `NotificationService` | 13 passed |
| 2 | Service edge cases | Wrong-owner, mute categories, cross-user tests added | Already green from implementation | 13 passed |
| 3 | API integration tests (basic endpoints) | 10 tests written against missing router | Implemented router + schemas | 10 passed |
| 4 | API edge cases | Pagination, 404 ownership, mute categories at API layer | Already green from implementation | 10 passed |
| 5 | REFACTOR | — | Ruff clean, no regressions | All new files pass ruff |

### Commands Run

```bash
# NotificationService unit tests (13 tests)
cd apps/api && python -m pytest tests/unit/test_notification_service.py -v
# Exit: 0 — 13 passed

# Notifications API integration tests (10 tests)
cd apps/api && python -m pytest tests/integration/test_notifications_api.py -v
# Exit: 0 — 10 passed

# Combined new tests
cd apps/api && python -m pytest tests/unit/test_notification_service.py tests/integration/test_notifications_api.py -v
# Exit: 0 — 23 passed

# Existing unit suite (no regressions from our changes)
cd apps/api && python -m pytest tests/unit/ -v
# Exit: 1 — 223 passed, 4 failed (pre-existing failures in test_config.py and test_git_repository_clone_preflight.py)

# Ruff linting on all new/modified files
cd apps/api && python -m ruff check \
  src/models/notification.py src/models/__init__.py \
  src/services/notification_service.py \
  src/api/notifications.py src/api/__init__.py src/main.py \
  alembic/versions/2026_05_29_add_notifications_table.py \
  tests/unit/test_notification_service.py \
  tests/integration/test_notifications_api.py \
  tests/integration/test_models.py
# Exit: 0 — All checks passed

# Smoke tests
# GET /health -> 200
# GET /notifications (unauthenticated) -> 401
```

## Deviations from Design

1. **SQLAlchemy `metadata` column name conflict:** `Base.metadata` is reserved by SQLAlchemy DeclarativeBase. Used `notification_metadata` as the Python attribute name with DB column name `"metadata"`. In the Pydantic response model, used `Field(serialization_alias="metadata")` so the JSON API still exposes `"metadata"` as specified in the design.

2. **Datetime types in Pydantic schemas:** Used `datetime` instead of `str` for `read_at`, `dismissed_at`, and `created_at` to leverage FastAPI's automatic ISO-8601 serialization.

## Surprises / Decisions

1. **SQLite `func.now()` timestamp resolution:** `test_list_notifications_orders_by_created_at_desc` initially failed because multiple rapid INSERTs received identical timestamps. Fixed by explicitly setting `created_at` offsets in the test after creation.

2. **Pre-existing integration test failures:** Approximately 40 integration tests fail due to missing `asyncpg` module and direct PostgreSQL connection attempts in their custom setup code. These failures are unrelated to our changes.

3. **Pre-existing `test_models.py` outdated:** The `test_expected_tables_are_registered` assertion had a hardcoded set missing many newer tables (including our new `notifications` table). Updated it to include all current tables.

## PR Boundary

This PR covers PR-1 only (NC-PR1-001 through NC-PR1-011). PR-2 (backend integration — wiring lifecycle_hooks.py and health_monitor.py) and PR-3/PR-4 (frontend) are out of scope and await this PR.

## Risks

- **Low:** The migration uses `sa.JSON()` which is compatible with both PostgreSQL and SQLite. The partial index uses `postgresql_where` which is PostgreSQL-specific but safely ignored by SQLite.
- **Low:** `notification_metadata` -> `"metadata"` serialization alias is a new pattern in the codebase but is explicitly tested via integration tests.
- **None:** No changes to existing production code paths; all changes are additive.
