Files
headquarter/openspec/changes/notification-center/apply-pr1.md
alex cbaebcf649 feat: notification center backend core (PR-1)
- Add notifications table with Alembic migration
- Notification model with user-scoped indexing and partial index on unread
- NotificationService singleton with create/list/count/mark-read/dismiss
- FastAPI router: GET /notifications, GET /unread, PATCH /{id}/read,
  POST /mark-all-read, DELETE /{id}
- Mute categories filtering from UserConfig
- 13 unit tests for NotificationService
- 10 integration tests for API endpoints
- Updated test_models.py with new table registration

Quality gates: pytest 23 new passed, ruff clean
2026-05-29 12:09:14 +02:00

140 lines
7.5 KiB
Markdown

# 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.