cbaebcf649
- 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
140 lines
7.5 KiB
Markdown
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.
|