diff --git a/.githooks/pre-commit b/.githooks/pre-commit index b88d30c8..d6e50639 100644 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -321,6 +321,82 @@ if [ -n "$ENCODING_ISSUES" ]; then echo "" fi +# DIRECT DATABASE ACCESS GUARD +# +# Python services outside core-api must not write directly to the database. +# All mutations must go through core-api internal HTTP endpoints. + +DB_WRITE_VIOLATIONS="" + +while IFS= read -r staged_file; do + [ -z "$staged_file" ] && continue + + case "$staged_file" in + services/billing-service/app/*.py|services/billing-service/app/**/*.py|\ + services/admin-service/app/*.py|services/admin-service/app/**/*.py) + # Skip test files + case "$staged_file" in + */tests/*) continue ;; + esac + + if [ -f "$staged_file" ]; then + if grep -Eq 'session\.(add|commit)\(' "$staged_file"; then + DB_WRITE_VIOLATIONS="$DB_WRITE_VIOLATIONS $staged_file" + fi + fi + ;; + esac +done <= now` + +--- + +## Content roles + +Four roles are available for content lifecycle management: + +| Role | Can do | +|---|---| +| `ContentAuthor` | Create drafts, edit own drafts, submit for review | +| `ContentReviewer` | View review queue, approve or request changes | +| `ContentPublisher` | Publish approved content, schedule, unpublish, archive | +| `ContentAdministrator` | Full access to all content and queues | + +--- + +## API endpoints + +All endpoints are served by the **cms-service**. + +### Public endpoints + +| Method | Path | Description | +|---|---|---| +| `GET` | `/blog` | List published blogs | +| `GET` | `/blog/{slug}` | Get published blog by slug | +| `GET` | `/guides` | List published guides | +| `GET` | `/guides/{slug}` | Get published guide by slug | +| `GET` | `/articles` | List published articles | +| `GET` | `/articles/{slug}` | Get published article by slug | +| `GET` | `/cv-for` | List published CV-For pages | +| `GET` | `/cv-for/{slug}` | Get published CV-For page by slug | +| `GET` | `/content/{type}` | Generic list (type = blog/guides/articles/cv-for) | +| `GET` | `/content/{type}/{slug}` | Generic get | + +**Query parameters for list endpoints** + +| Param | Type | Description | +|---|---|---| +| `category` | string | Filter by category | +| `page` | int (≥1) | Page number (default 1) | +| `page_size` | int (1–100) | Items per page (default 20) | + +Public endpoints return only `Published` items within the active date window. Draft, Review and Archived items are never returned. + +### Admin endpoints (authentication required) + +| Method | Path | Description | +|---|---|---| +| `GET` | `/admin/content` | List all content (all statuses) | +| `GET` | `/admin/content/{id}` | Get item including internal fields | +| `POST` | `/admin/content` | Create a new content item | +| `PUT` | `/admin/content/{id}` | Update a content item | +| `POST` | `/admin/content/{id}/publish` | Publish a content item | +| `POST` | `/admin/content/{id}/archive` | Archive a content item | + +**Admin list query parameters** + +| Param | Alias | Description | +|---|---|---| +| `content_type` | | Filter by content type | +| `category` | | Filter by category | +| `status` | | Filter by status | +| `search` | | Full-text search on title/excerpt | +| `target_keyword` | | Filter by target keyword | +| `page` | | Page number | +| `page_size` | | Items per page | + +### Legacy blog admin endpoints (backward-compatible) + +The original blog admin endpoints remain functional and serve the `BlogPosts` table unchanged: + +| Method | Path | +|---|---| +| `GET` | `/admin/blog` | +| `GET` | `/admin/blog/{slug}` | +| `POST` | `/admin/blog` | +| `PUT` | `/admin/blog/{id}` | +| `DELETE` | `/admin/blog/{id}` | + +--- + +## Migration approach + +The migration `20260530000000_AddContentPlatform` performs the following steps: + +1. Creates the `ContentItems` table with all new fields. +2. Copies all rows from `BlogPosts` into `ContentItems` with: + - `ContentType = 'Blog'` + - `ContentMarkdown` ← `Content` + - Status values mapped: `published` → `Published`, `archived` → `Archived`, `review` → `Review`, anything else → `Draft` + - `ContentSource = 'Manual'` + - `AiGenerated = false` + - All SEO, date and slug fields preserved exactly. + +The original `BlogPosts` and `BlogSources` tables are **not dropped**. The legacy blog endpoints continue to read from `BlogPosts` to ensure zero-downtime backward compatibility. + +### Verification + +After running the migration, confirm: + +```sql +SELECT + (SELECT COUNT(*) FROM "BlogPosts") AS blog_posts_before, + (SELECT COUNT(*) FROM "ContentItems" WHERE "ContentType" = 'Blog') AS blog_posts_after; +``` + +Both counts should be equal. + +--- + +## Sitemap + +All published content items across all content types should be included in the sitemap. Draft, Review and Archived items must be excluded. + +Sitemap paths follow the [URL conventions](#url-conventions) table. The `PublishedAt` date should be used as `` where available, falling back to `UpdatedAt`. + +--- + +## SEO guidance + +- `SeoTitle`: 50–60 characters recommended. +- `SeoDescription`: 150–160 characters recommended. +- `CanonicalUrl`: Set explicitly when syndicating content. +- Each `(ContentType, Slug)` combination is unique — canonical URL collisions are prevented at the data layer. + +--- + +## Adding a new content type + +1. Add the new type name to `VALID_CONTENT_TYPES` in `services/cms-service/app/models/schemas.py`. +2. Add a URL-segment → type mapping entry to `_SLUG_TO_TYPE` in `services/cms-service/app/routers/content.py`. +3. Optionally add dedicated route functions (e.g. `list_published_new_type`) following the existing pattern. +4. No schema or database changes are required — the `ContentItems` table accommodates any content type string. +5. Update this document. + +--- + +## Security + +- Public endpoints never expose `Draft`, `Review` or `Archived` content. +- `EditorialNotes`, `AiPromptVersion` and other internal fields are excluded from public response schemas (`ContentItemSchema`). They are only present in `ContentItemAdminSchema`. +- Admin endpoints require authentication via the existing Curvit auth infrastructure. +- Markdown rendered on the frontend must be sanitised to prevent stored XSS. Use the existing content sanitiser pattern. diff --git a/infrastructure/monitoring/scripts/validate-observability.sh b/infrastructure/monitoring/scripts/validate-observability.sh index ed2a0d41..76ad3b93 100644 --- a/infrastructure/monitoring/scripts/validate-observability.sh +++ b/infrastructure/monitoring/scripts/validate-observability.sh @@ -213,7 +213,7 @@ PY echo "[8/8] Validate compose rendering for dev/staging/prod" ( cd "${ROOT_DIR}" - IMAGE_TAG=sha-local docker compose -f docker-compose.yml config >/dev/null + IMAGE_TAG=sha-local docker compose --env-file .env.example -f docker-compose.yml config >/dev/null IMAGE_TAG=sha-local STAGING_ENV_FILE=environments/staging/.env.example docker compose \ --env-file environments/staging/.env.example \ -f docker-compose.yml -f docker-compose.staging.yml config >/dev/null diff --git a/services/admin-service/app/models/db_models.py b/services/admin-service/app/models/db_models.py index bfb4ec6c..bf7a787d 100644 --- a/services/admin-service/app/models/db_models.py +++ b/services/admin-service/app/models/db_models.py @@ -79,6 +79,7 @@ class DataSubjectRequest(Base): received_at = Column("ReceivedAt", DateTime(timezone=True), nullable=False) due_at = Column("DueAt", DateTime(timezone=True), nullable=False) completed_at = Column("CompletedAt", DateTime(timezone=True), nullable=True) + notes = Column("Notes", String(2000), nullable=True) class RetentionSettings(Base): diff --git a/services/admin-service/app/routers/admin.py b/services/admin-service/app/routers/admin.py index 7d7d5826..af9844be 100644 --- a/services/admin-service/app/routers/admin.py +++ b/services/admin-service/app/routers/admin.py @@ -1,12 +1,10 @@ """Admin API routes for user management, GDPR, and system administration.""" -import json import logging -import uuid from datetime import datetime, timezone, timedelta from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Request, status, Query -from sqlalchemy import delete, select, func, and_, text +from sqlalchemy import select, func, text from sqlalchemy.ext.asyncio import AsyncSession from app.db import get_session @@ -14,7 +12,7 @@ from app.config import get_settings from app.models.db_models import ( UserAccount, DataSubjectRequest, RetentionSettings, - ApplicationErrorLog, SystemSettings, Document, AnalysisJob, QuotaUsageEvent, ActivityAuditLog + ApplicationErrorLog, SystemSettings, Document, AnalysisJob, QuotaUsageEvent ) from app.models.schemas import ( UserAccountAdminSchema, MergedUserAdminSchema, DataSubjectRequestSchema, CreateDSARRequest, @@ -22,6 +20,7 @@ LegacyRetentionSettingsSchema, LegacyUpdateRetentionRequest, ApplicationErrorLogSchema, StatsSchema, UpdatePlanRequest, UpdateUserRestrictionRequest ) +from app.services import core_api_client logger = logging.getLogger(__name__) @@ -33,34 +32,8 @@ LOCAL_APP_ENVS = {"development", "dev", "local", "test", "testing"} -def _record_activity( - session: AsyncSession, - request: Request, - user: dict, - activity_type: str, - details: dict, - *, - user_account_id=None, - reference_id=None, -) -> None: - session.add(ActivityAuditLog( - id=uuid.uuid4(), - service_name="admin-service", - activity_type=activity_type, - actor_type="admin", - actor_subject_id=user.get("sub"), - actor_email=user.get("email"), - user_account_id=user_account_id, - reference_id=reference_id, - request_path=request.url.path, - request_method=request.method, - correlation_id=request.headers.get("x-correlation-id") or request.headers.get("x-request-id"), - details_json=json.dumps(details, default=str, sort_keys=True), - occurred_at=datetime.now(timezone.utc), - )) - - -def _clear_local_sandbox_billing_state_for_free_plan(account: UserAccount, plan_tier: str, app_env: str) -> bool: +def _should_clear_local_sandbox_billing(account: UserAccount, plan_tier: str, app_env: str) -> bool: + """Return True when the account has local-sandbox billing state that should be cleared.""" if plan_tier.lower() != "free" or app_env.lower() not in LOCAL_APP_ENVS: return False @@ -71,17 +44,12 @@ def _clear_local_sandbox_billing_state_for_free_plan(account: UserAccount, plan_ if not local_customer or not local_subscription: return False - had_local_billing_state = any(( + return any(( customer_id, subscription_id, account.stripe_subscription_status, account.subscription_current_period_end, )) - account.stripe_customer_id = None - account.stripe_subscription_id = None - account.stripe_subscription_status = None - account.subscription_current_period_end = None - return had_local_billing_state def _has_active_external_subscription(account: UserAccount) -> bool: @@ -315,7 +283,7 @@ async def update_user_plan( if not account: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_USER_NOT_FOUND) - local_billing_reset = _clear_local_sandbox_billing_state_for_free_plan( + local_billing_reset = _should_clear_local_sandbox_billing( account, req.plan_tier, get_settings().app_env, @@ -326,25 +294,18 @@ async def update_user_plan( detail="Active Stripe subscriptions must be changed through billing operations", ) - account.plan_tier = req.plan_tier - account.updated_at = datetime.now(timezone.utc) - session.add(account) - _record_activity( - session, - request, + if local_billing_reset: + await core_api_client.clear_account_subscription(str(account.id)) + + correlation_id = request.headers.get("x-correlation-id") or request.headers.get("x-request-id") + await core_api_client.update_account_plan( + str(account.id), + req.plan_tier, user, - "admin_user_plan_updated", - { - "targetUserId": str(account.id), - "targetEmail": account.email, - "planTier": account.plan_tier, - "reason": req.reason, - "localBillingReset": local_billing_reset, - }, - user_account_id=account.id, - reference_id=account.id, + request.url.path, + request.method, + correlation_id, ) - await session.commit() await session.refresh(account) logger.info("Updated user plan") @@ -395,23 +356,15 @@ async def toggle_user_restriction( if not account: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_USER_NOT_FOUND) - account.is_restricted = is_restricted - account.updated_at = datetime.now(timezone.utc) - session.add(account) - _record_activity( - session, - request, + correlation_id = request.headers.get("x-correlation-id") or request.headers.get("x-request-id") + await core_api_client.set_account_restriction( + str(account.id), + is_restricted, user, - "admin_user_restriction_updated", - { - "targetUserId": str(account.id), - "targetEmail": account.email, - "isRestricted": is_restricted, - }, - user_account_id=account.id, - reference_id=account.id, + request.url.path, + request.method, + correlation_id, ) - await session.commit() await session.refresh(account) logger.info("Updated user restriction") @@ -459,7 +412,7 @@ async def list_user_dsars( stmt = select(DataSubjectRequest).where( DataSubjectRequest.user_account_id == user_uuid - ).order_by(DataSubjectRequest.created_at.desc()) + ).order_by(DataSubjectRequest.received_at.desc()) result = await session.execute(stmt) dsars = result.scalars().all() @@ -469,8 +422,8 @@ async def list_user_dsars( user_account_id=str(d.user_account_id), type=d.type, status=d.status, - status_details=d.status_details, - created_at=d.created_at, + status_details=d.notes, + created_at=d.received_at, completed_at=d.completed_at, ) for d in dsars @@ -502,42 +455,27 @@ async def create_user_dsar( if not user_result.scalars().first(): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_USER_NOT_FOUND) - dsar = DataSubjectRequest( - user_account_id=user_uuid, - type=req.type, - status="pending", - status_details=req.notes, - created_at=datetime.now(timezone.utc), - ) - - session.add(dsar) - _record_activity( - session, - request, + correlation_id = request.headers.get("x-correlation-id") or request.headers.get("x-request-id") + new_dsar_id = await core_api_client.create_dsar( + str(user_uuid), + req.type, + req.notes, user, - "admin_dsar_created", - { - "targetUserId": user_id, - "dsarId": str(dsar.id), - "type": req.type, - "notes": req.notes, - }, - user_account_id=user_uuid, - reference_id=dsar.id, + request.url.path, + request.method, + correlation_id, ) - await session.commit() - await session.refresh(dsar) logger.info("Created data subject access request") return DataSubjectRequestSchema( - id=str(dsar.id), - user_account_id=str(dsar.user_account_id), - type=dsar.type, - status=dsar.status, - status_details=dsar.status_details, - created_at=dsar.created_at, - completed_at=dsar.completed_at, + id=new_dsar_id, + user_account_id=str(user_uuid), + type=req.type, + status="pending", + status_details=req.notes, + created_at=datetime.now(timezone.utc), + completed_at=None, ) @@ -563,40 +501,28 @@ async def update_dsar( if not dsar: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="DSAR not found") - dsar.status = req.status - if req.status_details: - dsar.status_details = req.status_details - if req.status == "completed": - dsar.completed_at = datetime.now(timezone.utc) - - session.add(dsar) - _record_activity( - session, - request, + correlation_id = request.headers.get("x-correlation-id") or request.headers.get("x-request-id") + await core_api_client.update_dsar( + dsar_id, + req.status, + req.status_details, user, - "admin_dsar_updated", - { - "targetUserId": str(dsar.user_account_id), - "dsarId": str(dsar.id), - "status": req.status, - "statusDetails": req.status_details, - }, - user_account_id=dsar.user_account_id, - reference_id=dsar.id, + request.url.path, + request.method, + correlation_id, ) - await session.commit() - await session.refresh(dsar) logger.info("Updated data subject request status") + completed_at = datetime.now(timezone.utc) if req.status == "completed" else dsar.completed_at return DataSubjectRequestSchema( id=str(dsar.id), user_account_id=str(dsar.user_account_id), type=dsar.type, - status=dsar.status, - status_details=dsar.status_details, - created_at=dsar.created_at, - completed_at=dsar.completed_at, + status=req.status, + status_details=req.status_details if req.status_details else dsar.notes, + created_at=dsar.received_at, + completed_at=completed_at, ) @@ -665,49 +591,23 @@ async def update_retention_settings( if not setting: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_RETENTION_SETTING_NOT_FOUND) - if req.documentRetentionDays is not None: - setting.document_retention_days = req.documentRetentionDays - if req.analysisJobRetentionDays is not None: - setting.analysis_job_retention_days = req.analysisJobRetentionDays - if req.jobSpecRetentionDays is not None: - setting.job_spec_retention_days = req.jobSpecRetentionDays - if req.screeningSessionRetentionDays is not None: - setting.screening_session_retention_days = req.screeningSessionRetentionDays - if req.quotaUsageEventRetentionDays is not None: - setting.quota_usage_event_retention_days = req.quotaUsageEventRetentionDays - if req.completedDsarRetentionDays is not None: - setting.completed_dsar_retention_days = req.completedDsarRetentionDays - if req.billingAuditRetentionDays is not None: - setting.billing_audit_retention_days = req.billingAuditRetentionDays - if req.stripeWebhookRetentionDays is not None: - setting.stripe_webhook_retention_days = req.stripeWebhookRetentionDays - if req.inactiveAccountThresholdDays is not None: - setting.inactive_account_threshold_days = req.inactiveAccountThresholdDays - if req.errorLogRetentionDays is not None: - setting.error_log_retention_days = req.errorLogRetentionDays - - setting.updated_at = datetime.now(timezone.utc) - session.add(setting) - _record_activity( - session, - request, - user, - "admin_retention_settings_updated", - { - "documentRetentionDays": req.documentRetentionDays, - "analysisJobRetentionDays": req.analysisJobRetentionDays, - "jobSpecRetentionDays": req.jobSpecRetentionDays, - "screeningSessionRetentionDays": req.screeningSessionRetentionDays, - "quotaUsageEventRetentionDays": req.quotaUsageEventRetentionDays, - "completedDsarRetentionDays": req.completedDsarRetentionDays, - "billingAuditRetentionDays": req.billingAuditRetentionDays, - "stripeWebhookRetentionDays": req.stripeWebhookRetentionDays, - "inactiveAccountThresholdDays": req.inactiveAccountThresholdDays, - "errorLogRetentionDays": req.errorLogRetentionDays, - }, - reference_id=setting.id, + correlation_id = request.headers.get("x-correlation-id") or request.headers.get("x-request-id") + await core_api_client.update_retention_settings( + document_retention_days=req.documentRetentionDays if req.documentRetentionDays is not None else setting.document_retention_days, + analysis_job_retention_days=req.analysisJobRetentionDays if req.analysisJobRetentionDays is not None else setting.analysis_job_retention_days, + job_spec_retention_days=req.jobSpecRetentionDays if req.jobSpecRetentionDays is not None else setting.job_spec_retention_days, + screening_session_retention_days=req.screeningSessionRetentionDays if req.screeningSessionRetentionDays is not None else setting.screening_session_retention_days, + quota_usage_event_retention_days=req.quotaUsageEventRetentionDays if req.quotaUsageEventRetentionDays is not None else setting.quota_usage_event_retention_days, + completed_dsar_retention_days=req.completedDsarRetentionDays if req.completedDsarRetentionDays is not None else setting.completed_dsar_retention_days, + billing_audit_retention_days=req.billingAuditRetentionDays if req.billingAuditRetentionDays is not None else setting.billing_audit_retention_days, + stripe_webhook_retention_days=req.stripeWebhookRetentionDays if req.stripeWebhookRetentionDays is not None else setting.stripe_webhook_retention_days, + inactive_account_threshold_days=req.inactiveAccountThresholdDays if req.inactiveAccountThresholdDays is not None else setting.inactive_account_threshold_days, + error_log_retention_days=req.errorLogRetentionDays if req.errorLogRetentionDays is not None else setting.error_log_retention_days, + user=user, + request_path=request.url.path, + request_method=request.method, + correlation_id=correlation_id, ) - await session.commit() await session.refresh(setting) logger.info("Updated aggregate retention settings") @@ -744,23 +644,24 @@ async def update_legacy_retention_setting( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_RETENTION_SETTING_NOT_FOUND) field_name = _get_legacy_retention_field(data_type) - setattr(setting, field_name, req.retention_days) - setting.updated_at = datetime.now(timezone.utc) - session.add(setting) - _record_activity( - session, - request, - user, - "admin_legacy_retention_setting_updated", - { - "dataType": data_type, - "fieldName": field_name, - "retentionDays": req.retention_days, - }, - reference_id=setting.id, + correlation_id = request.headers.get("x-correlation-id") or request.headers.get("x-request-id") + await core_api_client.update_retention_settings( + document_retention_days=req.retention_days if field_name == "document_retention_days" else setting.document_retention_days, + analysis_job_retention_days=req.retention_days if field_name == "analysis_job_retention_days" else setting.analysis_job_retention_days, + job_spec_retention_days=req.retention_days if field_name == "job_spec_retention_days" else setting.job_spec_retention_days, + screening_session_retention_days=req.retention_days if field_name == "screening_session_retention_days" else setting.screening_session_retention_days, + quota_usage_event_retention_days=req.retention_days if field_name == "quota_usage_event_retention_days" else setting.quota_usage_event_retention_days, + completed_dsar_retention_days=req.retention_days if field_name == "completed_dsar_retention_days" else setting.completed_dsar_retention_days, + billing_audit_retention_days=req.retention_days if field_name == "billing_audit_retention_days" else setting.billing_audit_retention_days, + stripe_webhook_retention_days=req.retention_days if field_name == "stripe_webhook_retention_days" else setting.stripe_webhook_retention_days, + inactive_account_threshold_days=req.retention_days if field_name == "inactive_account_threshold_days" else setting.inactive_account_threshold_days, + error_log_retention_days=req.retention_days if field_name == "error_log_retention_days" else setting.error_log_retention_days, + user=user, + request_path=request.url.path, + request_method=request.method, + correlation_id=correlation_id, ) - await session.commit() await session.refresh(setting) logger.info("Updated legacy retention setting") @@ -836,17 +737,11 @@ async def get_error_log( @router.delete("/admin/errors/cleanup", tags=["admin"]) async def cleanup_error_logs( older_than_days: Annotated[int, Query(..., alias="olderThanDays", ge=1)], - session: Annotated[AsyncSession, Depends(get_session)], user: Annotated[dict, Depends(require_admin)], ) -> dict[str, int]: """Delete error logs older than a retention cutoff. Admin only.""" - cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days) - result = await session.execute( - delete(ApplicationErrorLog).where(ApplicationErrorLog.created_at < cutoff) - ) - await session.commit() - - return {"deleted": int(result.rowcount or 0)} + deleted = await core_api_client.delete_error_logs(older_than_days) + return {"deleted": deleted} # Statistics endpoints @@ -924,6 +819,7 @@ async def get_system_settings( @router.put("/admin/system-settings", tags=["admin"]) async def update_system_settings( req: dict, + request: Request, session: Annotated[AsyncSession, Depends(get_session)], user: Annotated[dict, Depends(require_admin)], ) -> dict[str, int | str | None]: @@ -935,18 +831,17 @@ async def update_system_settings( if not settings: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="System settings not found") - if "maxCvFileSizeMb" in req: - settings.max_cv_file_size_mb = req["maxCvFileSizeMb"] - if "maxZipFileSizeMb" in req: - settings.max_zip_file_size_mb = req["maxZipFileSizeMb"] - if "maxCandidatesPerSession" in req: - settings.max_candidates_per_session = req["maxCandidatesPerSession"] - if "screeningConcurrency" in req: - settings.screening_concurrency = req["screeningConcurrency"] - - settings.updated_at = datetime.now(timezone.utc) - session.add(settings) - await session.commit() + correlation_id = request.headers.get("x-correlation-id") or request.headers.get("x-request-id") + await core_api_client.update_system_settings( + max_cv_file_size_mb=req.get("maxCvFileSizeMb", settings.max_cv_file_size_mb), + max_zip_file_size_mb=req.get("maxZipFileSizeMb", settings.max_zip_file_size_mb), + max_candidates_per_session=req.get("maxCandidatesPerSession", settings.max_candidates_per_session), + screening_concurrency=req.get("screeningConcurrency", settings.screening_concurrency), + user=user, + request_path=request.url.path, + request_method=request.method, + correlation_id=correlation_id, + ) await session.refresh(settings) return { diff --git a/services/admin-service/app/services/__init__.py b/services/admin-service/app/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/services/admin-service/app/services/core_api_client.py b/services/admin-service/app/services/core_api_client.py new file mode 100644 index 00000000..0270dbbf --- /dev/null +++ b/services/admin-service/app/services/core_api_client.py @@ -0,0 +1,273 @@ +"""HTTP client for core-api internal admin endpoints. + +All write operations that mutate core-api-owned tables (UserAccounts, +DataSubjectRequests, RetentionSettings, SystemSettings, ActivityAuditLogs, +ApplicationErrorLogs) must go through this client instead of touching the +database directly. +""" +from __future__ import annotations + +import logging +from typing import Any + +import httpx + +from app.config import get_settings + +logger = logging.getLogger(__name__) + +# ── Enum helpers ────────────────────────────────────────────────────────────── + +_PLAN_TIER_MAP: dict[str, int] = {"free": 0, "pro": 1, "business": 2} + +_DSAR_TYPE_MAP: dict[str, int] = { + "access": 0, + "erasure": 1, + "rectification": 2, + "portability": 0, # map portability → Access as closest equivalent +} + +_DSAR_STATUS_MAP: dict[str, int] = { + "pending": 0, + "in_progress": 1, + "inprogress": 1, + "completed": 2, +} + + +def _plan_tier_int(plan_tier: str) -> int: + return _PLAN_TIER_MAP[plan_tier.lower()] + + +def _dsar_type_int(request_type: str) -> int: + return _DSAR_TYPE_MAP[request_type.lower()] + + +def _dsar_status_int(status: str) -> int: + return _DSAR_STATUS_MAP[status.lower()] + + +# ── HTTP helpers ────────────────────────────────────────────────────────────── + + +def _get_headers() -> dict[str, str]: + return {"X-Internal-Api-Key": get_settings().internal_api_key} + + +def _base_url() -> str: + return get_settings().core_api_base_url.rstrip("/") + + +def _audit_context( + activity_type: str, + user: dict[str, Any], + request_path: str, + request_method: str, + correlation_id: str | None, +) -> dict[str, Any]: + return { + "activityType": activity_type, + "actorSubjectId": user.get("sub"), + "actorEmail": user.get("email"), + "requestPath": request_path, + "requestMethod": request_method, + "correlationId": correlation_id, + } + + +# ── Account mutations ───────────────────────────────────────────────────────── + + +async def update_account_plan( + account_id: str, + plan_tier: str, + user: dict[str, Any], + request_path: str, + request_method: str, + correlation_id: str | None, +) -> None: + """PATCH /internal/admin/accounts/{accountId}/plan — updates plan tier.""" + body = { + "tier": _plan_tier_int(plan_tier), + **_audit_context("admin_user_plan_updated", user, request_path, request_method, correlation_id), + } + async with httpx.AsyncClient() as client: + resp = await client.patch( + f"{_base_url()}/internal/admin/accounts/{account_id}/plan", + json=body, + headers=_get_headers(), + ) + resp.raise_for_status() + + +async def set_account_restriction( + account_id: str, + is_restricted: bool, + user: dict[str, Any], + request_path: str, + request_method: str, + correlation_id: str | None, +) -> None: + """PATCH /internal/admin/accounts/{accountId}/restriction — toggles restriction.""" + body = { + "isRestricted": is_restricted, + **_audit_context("admin_user_restriction_updated", user, request_path, request_method, correlation_id), + } + async with httpx.AsyncClient() as client: + resp = await client.patch( + f"{_base_url()}/internal/admin/accounts/{account_id}/restriction", + json=body, + headers=_get_headers(), + ) + resp.raise_for_status() + + +async def clear_account_subscription(account_id: str) -> None: + """DELETE /internal/admin/accounts/{accountId}/subscription — clears local sandbox billing state.""" + async with httpx.AsyncClient() as client: + resp = await client.delete( + f"{_base_url()}/internal/admin/accounts/{account_id}/subscription", + headers=_get_headers(), + ) + resp.raise_for_status() + + +# ── DSAR mutations ──────────────────────────────────────────────────────────── + + +async def create_dsar( + user_account_id: str, + request_type: str, + notes: str | None, + user: dict[str, Any], + request_path: str, + request_method: str, + correlation_id: str | None, +) -> str: + """POST /internal/admin/dsar — creates a DSAR and returns the new DSAR id.""" + body = { + "userAccountId": user_account_id, + "requestType": _dsar_type_int(request_type), + "notes": notes, + **_audit_context("admin_dsar_created", user, request_path, request_method, correlation_id), + } + async with httpx.AsyncClient() as client: + resp = await client.post( + f"{_base_url()}/internal/admin/dsar", + json=body, + headers=_get_headers(), + ) + resp.raise_for_status() + return str(resp.json()["id"]) + + +async def update_dsar( + dsar_id: str, + status: str, + notes: str | None, + user: dict[str, Any], + request_path: str, + request_method: str, + correlation_id: str | None, +) -> None: + """PATCH /internal/admin/dsar/{dsarId} — updates DSAR status/notes.""" + body = { + "status": _dsar_status_int(status), + "handledBy": user.get("email") or "admin", + "notes": notes, + **_audit_context("admin_dsar_updated", user, request_path, request_method, correlation_id), + } + async with httpx.AsyncClient() as client: + resp = await client.patch( + f"{_base_url()}/internal/admin/dsar/{dsar_id}", + json=body, + headers=_get_headers(), + ) + resp.raise_for_status() + + +# ── Retention settings ──────────────────────────────────────────────────────── + + +async def update_retention_settings( + document_retention_days: int, + analysis_job_retention_days: int, + job_spec_retention_days: int, + screening_session_retention_days: int, + quota_usage_event_retention_days: int, + completed_dsar_retention_days: int, + billing_audit_retention_days: int, + stripe_webhook_retention_days: int, + inactive_account_threshold_days: int, + error_log_retention_days: int, + user: dict[str, Any], + request_path: str, + request_method: str, + correlation_id: str | None, +) -> None: + """PUT /internal/admin/retention-settings — replaces all retention settings.""" + body = { + "documentRetentionDays": document_retention_days, + "analysisJobRetentionDays": analysis_job_retention_days, + "jobSpecRetentionDays": job_spec_retention_days, + "screeningSessionRetentionDays": screening_session_retention_days, + "quotaUsageEventRetentionDays": quota_usage_event_retention_days, + "completedDsarRetentionDays": completed_dsar_retention_days, + "billingAuditRetentionDays": billing_audit_retention_days, + "stripeWebhookRetentionDays": stripe_webhook_retention_days, + "inactiveAccountThresholdDays": inactive_account_threshold_days, + "errorLogRetentionDays": error_log_retention_days, + **_audit_context("admin_retention_settings_updated", user, request_path, request_method, correlation_id), + } + async with httpx.AsyncClient() as client: + resp = await client.put( + f"{_base_url()}/internal/admin/retention-settings", + json=body, + headers=_get_headers(), + ) + resp.raise_for_status() + + +# ── Error log cleanup ───────────────────────────────────────────────────────── + + +async def delete_error_logs(older_than_days: int) -> int: + """DELETE /internal/admin/error-logs?olderThanDays=N — returns count deleted.""" + async with httpx.AsyncClient() as client: + resp = await client.delete( + f"{_base_url()}/internal/admin/error-logs", + params={"olderThanDays": older_than_days}, + headers=_get_headers(), + ) + resp.raise_for_status() + return int(resp.json().get("deleted", 0)) + + +# ── System settings ─────────────────────────────────────────────────────────── + + +async def update_system_settings( + max_cv_file_size_mb: int, + max_zip_file_size_mb: int, + max_candidates_per_session: int, + screening_concurrency: int, + user: dict[str, Any], + request_path: str, + request_method: str, + correlation_id: str | None, +) -> None: + """PUT /internal/admin/system-settings — replaces system settings.""" + body = { + "maxCvFileSizeMb": max_cv_file_size_mb, + "maxZipFileSizeMb": max_zip_file_size_mb, + "maxCandidatesPerSession": max_candidates_per_session, + "screeningConcurrency": screening_concurrency, + **_audit_context("admin_system_settings_updated", user, request_path, request_method, correlation_id), + } + async with httpx.AsyncClient() as client: + resp = await client.put( + f"{_base_url()}/internal/admin/system-settings", + json=body, + headers=_get_headers(), + ) + resp.raise_for_status() diff --git a/services/billing-service/app/config.py b/services/billing-service/app/config.py index 378dd411..d6cfee5a 100644 --- a/services/billing-service/app/config.py +++ b/services/billing-service/app/config.py @@ -15,6 +15,8 @@ class Settings(BaseServiceSettings): local_payment_base_url: str = "https://api.curvit.local.co.uk" # noqa: S5332 - Development-only internal callback; local sandbox uses the local billing service only. local_payment_webhook_url: str = "http://127.0.0.1:8000/api/v1/billing/webhook" + # noqa: S5332 - Internal service URL, not exposed externally. + core_api_base_url: str = "http://core-api:8080" @property def resolved_payment_provider(self) -> str: diff --git a/services/billing-service/app/routers/billing.py b/services/billing-service/app/routers/billing.py index a116a9d8..99fc3f2e 100644 --- a/services/billing-service/app/routers/billing.py +++ b/services/billing-service/app/routers/billing.py @@ -7,12 +7,8 @@ from fastapi import APIRouter, Depends, HTTPException, status, Request from fastapi.responses import HTMLResponse -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession -from app.db import get_session from app.auth import get_current_user, require_admin -from app.models.db_models import UserAccount, PlanPricingSettings, ProcessedStripeWebhookEvent, BillingAuditLog from app.models.schemas import ( BillingStatusSchema, AdminBillingAuditLogSchema, @@ -44,6 +40,7 @@ BillingOrchestrationService, get_billing_orchestration_service, ) +from app.services import core_api_client from app.config import get_settings logger = logging.getLogger(__name__) @@ -54,40 +51,27 @@ DEFAULT_ADMIN_ACTOR = "admin@curvit.dev" -def _is_sale_active(plan: PlanPricingSettings) -> bool: - if not plan.stripe_sale_price_id or not plan.sale_start or not plan.sale_end: - return False - today = datetime.now(timezone.utc).date() - return plan.sale_start <= today <= plan.sale_end - - -def _to_admin_plan_pricing(plan: PlanPricingSettings) -> AdminPlanPricingSchema: - is_sale_active = _is_sale_active(plan) - active_price_id = ( - plan.stripe_sale_price_id - if is_sale_active and plan.stripe_sale_price_id - else plan.stripe_price_id - ) +def _to_admin_plan_pricing(plan: dict) -> AdminPlanPricingSchema: return AdminPlanPricingSchema( - tier=plan.tier, - stripePriceId=plan.stripe_price_id, - stripeSalePriceId=plan.stripe_sale_price_id, - saleStart=plan.sale_start, - saleEnd=plan.sale_end, - isSaleActive=is_sale_active, - activePriceId=active_price_id, - updatedAt=plan.updated_at, + tier=plan["tier"], + stripePriceId=plan.get("stripePriceId") or plan.get("stripePriceIdMonthly"), + stripeSalePriceId=plan.get("stripeSalePriceId"), + saleStart=plan.get("saleStart"), + saleEnd=plan.get("saleEnd"), + isSaleActive=plan.get("isSaleActive", False), + activePriceId=plan.get("activePriceId"), + updatedAt=plan.get("updatedAt"), ) -def _to_admin_billing_audit_log(log: BillingAuditLog) -> AdminBillingAuditLogSchema: +def _to_admin_billing_audit_log(log: dict) -> AdminBillingAuditLogSchema: return AdminBillingAuditLogSchema( - id=str(log.id), - eventType=log.event_type, - actor=log.actor, - detailsJson=log.details_json, - stripeEventId=log.stripe_event_id, - createdAt=log.created_at, + id=str(log["id"]), + eventType=log["eventType"], + actor=log["actor"], + detailsJson=log["detailsJson"], + stripeEventId=log.get("stripeEventId"), + createdAt=log["createdAt"], ) @@ -100,12 +84,6 @@ def _stripe_timestamp_to_datetime(value) -> datetime | None: return None -async def _account_by_stripe_customer(session: AsyncSession, customer_id: str) -> UserAccount | None: - stmt = select(UserAccount).where(UserAccount.stripe_customer_id == customer_id) - result = await session.execute(stmt) - return result.scalars().first() - - def _subscription_price_id(sub_data: dict) -> str | None: items = sub_data.get("items", {}).get("data", []) return items[0].get("price", {}).get("id") if items else None @@ -137,7 +115,6 @@ def _require_local_payment_sandbox() -> None: @router.get("/billing/status", tags=["billing"]) async def get_billing_status( - session: Annotated[AsyncSession, Depends(get_session)], user: Annotated[dict, Depends(get_current_user)], ) -> BillingStatusSchema: """Get current billing status for the authenticated user.""" @@ -145,25 +122,21 @@ async def get_billing_status( if not subject_id: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_INVALID_TOKEN) - stmt = select(UserAccount).where(UserAccount.subject_id == subject_id) - result = await session.execute(stmt) - account = result.scalars().first() - + account = await core_api_client.get_account_by_subject_id(subject_id) if not account: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found") return BillingStatusSchema( - plan_tier=account.plan_tier, - subscription_status=account.subscription_status, - stripe_customer_id=account.stripe_customer_id, - billing_period_end=account.billing_period_end, + plan_tier=(account["planTier"] or "free").lower(), + subscription_status=account["stripeSubscriptionStatus"], + stripe_customer_id=account["stripeCustomerId"], + billing_period_end=account["subscriptionCurrentPeriodEnd"], ) @router.post("/billing/checkout", tags=["billing"]) async def create_checkout_session( req: CreateCheckoutRequest, - session: Annotated[AsyncSession, Depends(get_session)], user: Annotated[dict, Depends(get_current_user)], stripe_service: Annotated[StripeService, Depends(get_stripe_service)], billing_service: Annotated[BillingOrchestrationService, Depends(get_billing_orchestration_service)], @@ -177,7 +150,6 @@ async def create_checkout_session( raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_INVALID_TOKEN) checkout_url = await billing_service.create_checkout_for_user( - session, stripe_service, subject_id=subject_id, email=email, @@ -191,7 +163,6 @@ async def create_checkout_session( @router.post("/billing/portal", tags=["billing"]) async def create_portal_session( - session: Annotated[AsyncSession, Depends(get_session)], user: Annotated[dict, Depends(get_current_user)], stripe_service: Annotated[StripeService, Depends(get_stripe_service)], billing_service: Annotated[BillingOrchestrationService, Depends(get_billing_orchestration_service)], @@ -203,7 +174,6 @@ async def create_portal_session( raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_INVALID_TOKEN) portal_url = await billing_service.create_portal_for_user( - session, stripe_service, subject_id=subject_id, ) @@ -274,7 +244,6 @@ async def cancel_local_portal_subscription(request: Request) -> HTMLResponse: @router.post("/billing/webhook", status_code=status.HTTP_204_NO_CONTENT, tags=["billing"]) async def handle_stripe_webhook( request: Request, - session: Annotated[AsyncSession, Depends(get_session)], stripe_service: Annotated[StripeService, Depends(get_stripe_service)], ) -> None: """Handle incoming Stripe webhook events.""" @@ -290,59 +259,43 @@ async def handle_stripe_webhook( logger.warning("Webhook verification failed: %s", e) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid signature") - # Check for idempotency event_id = event.get("id") - existing = await session.execute( - select(ProcessedStripeWebhookEvent).where(ProcessedStripeWebhookEvent.stripe_event_id == event_id) - ) - if existing.scalars().first(): - logger.info(f"Webhook already processed: {event_id}") - return - - # Process event event_type = event.get("type") - logger.info(f"Processing webhook: {event_type} ({event_id})") - # Record processed event - processed = ProcessedStripeWebhookEvent(stripe_event_id=event_id, event_type=event_type) - session.add(processed) + # Check idempotency and record the event atomically via core-api + is_new = await core_api_client.record_webhook_event(event_id, event_type) + if not is_new: + logger.info("Webhook already processed: %s", event_id) + return + + logger.info("Processing webhook: %s (%s)", event_type, event_id) - # Handle specific event types if event_type == "checkout.session.completed": - await _handle_checkout_completed(event, session) + await _handle_checkout_completed(event) elif event_type in ("customer.subscription.created", "customer.subscription.updated"): - await _handle_subscription_updated(event, session) + await _handle_subscription_updated(event) elif event_type == "customer.subscription.deleted": - await _handle_subscription_deleted(event, session) + await _handle_subscription_deleted(event) elif event_type == "invoice.paid": - await _handle_invoice_paid(event, session) + await _handle_invoice_paid(event) elif event_type == "charge.succeeded": - await _handle_charge_succeeded(event, session) + await _handle_charge_succeeded(event) elif event_type == "charge.refunded": - await _handle_charge_refunded(event, session) + await _handle_charge_refunded(event) - await session.commit() - -async def _resolve_tier_from_price(session: AsyncSession, price_id: str) -> str | None: +async def _resolve_tier_from_price(price_id: str) -> str | None: """Return the plan tier that owns the given Stripe price ID, or None if not found.""" if get_settings().resolved_payment_provider == "local_sandbox": local_tier = local_tier_from_price_id(price_id) if local_tier: return local_tier - stmt = select(PlanPricingSettings).where( - (PlanPricingSettings.stripe_price_id == price_id) - | (PlanPricingSettings.stripe_price_id_monthly == price_id) - | (PlanPricingSettings.stripe_price_id_annual == price_id) - | (PlanPricingSettings.stripe_sale_price_id == price_id) - ) - result = await session.execute(stmt) - plan = result.scalars().first() - return plan.tier if plan else None + plan = await core_api_client.get_plan_pricing_by_price_id(price_id) + return plan["tier"].lower() if plan else None -async def _handle_checkout_completed(event: dict, session: AsyncSession): +async def _handle_checkout_completed(event: dict): """Handle checkout.session.completed webhook.""" session_data = event.get("data", {}).get("object", {}) customer_id = session_data.get("customer") @@ -353,19 +306,17 @@ async def _handle_checkout_completed(event: dict, session: AsyncSession): logger.warning("Checkout event has no customer_id") return - # Find user account by Stripe customer - stmt = select(UserAccount).where(UserAccount.stripe_customer_id == customer_id) - result = await session.execute(stmt) - account = result.scalars().first() - + account = await core_api_client.get_account_by_stripe_customer(customer_id) if account and subscription_id: - account.stripe_subscription_id = subscription_id - account.subscription_status = "active" - account.updated_at = datetime.now(timezone.utc) - session.add(account) - - # Write audit log - audit = BillingAuditLog( + current_tier = (account["planTier"] or "free").lower() + await core_api_client.update_subscription( + customer_id=customer_id, + subscription_id=subscription_id, + status="active", + tier=current_tier, + current_period_end=None, + ) + await core_api_client.create_billing_audit_log( event_type="checkout_completed", actor="stripe_webhook", details_json=json.dumps({ @@ -373,106 +324,117 @@ async def _handle_checkout_completed(event: dict, session: AsyncSession): "customer_id": customer_id, "payment_intent_id": payment_intent_id, }), - user_account_id=account.id, + user_account_id=account["id"], stripe_event_id=event.get("id"), ) - session.add(audit) - logger.info(f"Updated subscription for customer {customer_id}") + logger.info("Updated subscription for customer %s", customer_id) -async def _handle_subscription_updated(event: dict, session: AsyncSession): +async def _handle_subscription_updated(event: dict): """Handle Stripe subscription create/update events.""" sub_data = event.get("data", {}).get("object", {}) customer_id = sub_data.get("customer") if not customer_id: return - status = sub_data.get("status") + sub_status = sub_data.get("status") current_period_end = sub_data.get("current_period_end") cancel_at = sub_data.get("cancel_at") cancel_at_period_end = bool(sub_data.get("cancel_at_period_end")) price_id = _subscription_price_id(sub_data) - account = await _account_by_stripe_customer(session, customer_id) + subscription_id = sub_data.get("id") + + account = await core_api_client.get_account_by_stripe_customer(customer_id) if not account: return period_end = _stripe_timestamp_to_datetime(current_period_end or cancel_at) has_future_cancel = period_end is not None and period_end > datetime.now(timezone.utc) - if status in ("active", "trialing") and (cancel_at_period_end or (cancel_at and has_future_cancel)): - account.subscription_status = "canceling" + if sub_status in ("active", "trialing") and (cancel_at_period_end or (cancel_at and has_future_cancel)): + effective_status = "canceling" else: - account.subscription_status = status - if period_end: - account.billing_period_end = period_end + effective_status = sub_status + resolved_tier = None if price_id: - resolved_tier = await _resolve_tier_from_price(session, price_id) - if resolved_tier: - account.plan_tier = resolved_tier + resolved_tier = await _resolve_tier_from_price(price_id) - account.updated_at = datetime.now(timezone.utc) - session.add(account) + tier = resolved_tier or (account["planTier"] or "free").lower() + period_end_iso = period_end.isoformat() if period_end else None - audit = BillingAuditLog( + await core_api_client.update_subscription( + customer_id=customer_id, + subscription_id=subscription_id, + status=effective_status, + tier=tier, + current_period_end=period_end_iso, + ) + + await core_api_client.create_billing_audit_log( event_type=_subscription_audit_event_type(event.get("type")), actor="stripe_webhook", details_json=json.dumps({ - "status": status, + "status": sub_status, "current_period_end": current_period_end, "cancel_at": cancel_at, "cancel_at_period_end": cancel_at_period_end, "price_id": price_id, }), - user_account_id=account.id, + user_account_id=account["id"], stripe_event_id=event.get("id"), ) - session.add(audit) - logger.info(f"Updated subscription status for customer {customer_id}: {status}") + logger.info("Updated subscription status for customer %s: %s", customer_id, effective_status) -async def _handle_subscription_deleted(event: dict, session: AsyncSession): +async def _handle_subscription_deleted(event: dict): """Handle customer.subscription.deleted webhook.""" sub_data = event.get("data", {}).get("object", {}) customer_id = sub_data.get("customer") + subscription_id = sub_data.get("id") current_period_end = sub_data.get("current_period_end") cancel_at = sub_data.get("cancel_at") period_end = _stripe_timestamp_to_datetime(current_period_end or cancel_at) paid_through_future = period_end is not None and period_end > datetime.now(timezone.utc) was_scheduled_period_end_cancel = bool(sub_data.get("cancel_at_period_end") or paid_through_future) - if customer_id: - stmt = select(UserAccount).where(UserAccount.stripe_customer_id == customer_id) - result = await session.execute(stmt) - account = result.scalars().first() - - if account: - account.subscription_status = "canceled" - if period_end: - account.billing_period_end = period_end - if not (was_scheduled_period_end_cancel and paid_through_future): - account.plan_tier = "free" - account.updated_at = datetime.now(timezone.utc) - session.add(account) - - # Write audit log - audit = BillingAuditLog( - event_type="subscription_deleted", - actor="stripe_webhook", - details_json=json.dumps({ - "customer_id": customer_id, - "current_period_end": current_period_end, - "cancel_at": cancel_at, - "cancel_at_period_end": was_scheduled_period_end_cancel, - "plan_tier_after": account.plan_tier, - }), - user_account_id=account.id, - stripe_event_id=event.get("id"), - ) - session.add(audit) - logger.info(f"Canceled subscription for customer {customer_id}") - - -async def _handle_invoice_paid(event: dict, session: AsyncSession): + if not customer_id: + return + + account = await core_api_client.get_account_by_stripe_customer(customer_id) + if not account: + return + + tier_after = (account["planTier"] or "free").lower() + if not (was_scheduled_period_end_cancel and paid_through_future): + tier_after = "free" + + period_end_iso = period_end.isoformat() if period_end else None + + await core_api_client.update_subscription( + customer_id=customer_id, + subscription_id=subscription_id, + status="canceled", + tier=tier_after, + current_period_end=period_end_iso, + ) + + await core_api_client.create_billing_audit_log( + event_type="subscription_deleted", + actor="stripe_webhook", + details_json=json.dumps({ + "customer_id": customer_id, + "current_period_end": current_period_end, + "cancel_at": cancel_at, + "cancel_at_period_end": was_scheduled_period_end_cancel, + "plan_tier_after": tier_after, + }), + user_account_id=account["id"], + stripe_event_id=event.get("id"), + ) + logger.info("Canceled subscription for customer %s", customer_id) + + +async def _handle_invoice_paid(event: dict): """Audit a paid Stripe invoice and the payment identifier Stripe exposes.""" invoice_data = event.get("data", {}).get("object", {}) customer_id = _stripe_id(invoice_data.get("customer")) @@ -481,13 +443,11 @@ async def _handle_invoice_paid(event: dict, session: AsyncSession): logger.warning("Paid invoice event has no customer_id") return - stmt = select(UserAccount).where(UserAccount.stripe_customer_id == customer_id) - result = await session.execute(stmt) - account = result.scalars().first() + account = await core_api_client.get_account_by_stripe_customer(customer_id) if not account: return - audit = BillingAuditLog( + await core_api_client.create_billing_audit_log( event_type="invoice_paid", actor="stripe_webhook", details_json=json.dumps({ @@ -498,14 +458,13 @@ async def _handle_invoice_paid(event: dict, session: AsyncSession): "amount_paid": invoice_data.get("amount_paid"), "currency": invoice_data.get("currency"), }), - user_account_id=account.id, + user_account_id=account["id"], stripe_event_id=event.get("id"), ) - session.add(audit) logger.info("Invoice paid for customer %s", customer_id) -async def _handle_charge_succeeded(event: dict, session: AsyncSession): +async def _handle_charge_succeeded(event: dict): """Audit a successful Stripe charge so admins can locate charge IDs for refunds.""" charge_data = event.get("data", {}).get("object", {}) customer_id = _stripe_id(charge_data.get("customer")) @@ -514,13 +473,11 @@ async def _handle_charge_succeeded(event: dict, session: AsyncSession): logger.warning("Successful charge event has no customer_id") return - stmt = select(UserAccount).where(UserAccount.stripe_customer_id == customer_id) - result = await session.execute(stmt) - account = result.scalars().first() + account = await core_api_client.get_account_by_stripe_customer(customer_id) if not account: return - audit = BillingAuditLog( + await core_api_client.create_billing_audit_log( event_type="charge_succeeded", actor="stripe_webhook", details_json=json.dumps({ @@ -529,39 +486,37 @@ async def _handle_charge_succeeded(event: dict, session: AsyncSession): "amount": charge_data.get("amount"), "currency": charge_data.get("currency"), }), - user_account_id=account.id, + user_account_id=account["id"], stripe_event_id=event.get("id"), ) - session.add(audit) logger.info("Charge succeeded for customer %s", customer_id) -async def _handle_charge_refunded(event: dict, session: AsyncSession): +async def _handle_charge_refunded(event: dict): """Handle charge.refunded webhook.""" charge_data = event.get("data", {}).get("object", {}) charge_id = charge_data.get("id") customer_id = charge_data.get("customer") refunded_amount = charge_data.get("amount_refunded", 0) - if customer_id: - stmt = select(UserAccount).where(UserAccount.stripe_customer_id == customer_id) - result = await session.execute(stmt) - account = result.scalars().first() - - if account: - # Write audit log for refund - audit = BillingAuditLog( - event_type="refund_processed", - actor="stripe_webhook", - details_json=json.dumps({ - "charge_id": charge_id, - "refunded_amount": refunded_amount, - }), - user_account_id=account.id, - stripe_event_id=event.get("id"), - ) - session.add(audit) - logger.info(f"Charge refunded: {refunded_amount} cents for customer {customer_id}") + if not customer_id: + return + + account = await core_api_client.get_account_by_stripe_customer(customer_id) + if not account: + return + + await core_api_client.create_billing_audit_log( + event_type="refund_processed", + actor="stripe_webhook", + details_json=json.dumps({ + "charge_id": charge_id, + "refunded_amount": refunded_amount, + }), + user_account_id=account["id"], + stripe_event_id=event.get("id"), + ) + logger.info("Charge refunded: %s cents for customer %s", refunded_amount, customer_id) def _stripe_id(value) -> str | None: @@ -580,14 +535,10 @@ def _stripe_id(value) -> str | None: @router.get("/admin/billing/plan-pricing", tags=["admin"]) async def list_plan_pricing( - session: Annotated[AsyncSession, Depends(get_session)], user: Annotated[dict, Depends(require_admin)], ) -> list[AdminPlanPricingSchema]: """List all plan pricing tiers. Admin only.""" - stmt = select(PlanPricingSettings).order_by(PlanPricingSettings.tier) - result = await session.execute(stmt) - plans = result.scalars().all() - + plans = await core_api_client.get_all_plan_pricing() return [_to_admin_plan_pricing(plan) for plan in plans] @@ -595,53 +546,42 @@ async def list_plan_pricing( async def update_plan_pricing( tier: str, req: UpdatePlanPricingRequest, - session: Annotated[AsyncSession, Depends(get_session)], user: Annotated[dict, Depends(require_admin)], ) -> AdminPlanPricingSchema: """Update plan pricing. Admin only.""" - stmt = select(PlanPricingSettings).where(PlanPricingSettings.tier == tier) - result = await session.execute(stmt) - plan = result.scalars().first() - - if not plan: + existing = await core_api_client.get_plan_pricing_by_tier(tier) + if not existing: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Plan not found") - plan.stripe_price_id = req.stripePriceId - plan.stripe_price_id_monthly = req.stripePriceId - plan.stripe_sale_price_id = req.stripeSalePriceId - plan.sale_start = req.saleStart - plan.sale_end = req.saleEnd - - plan.updated_at = datetime.now(timezone.utc) - session.add(plan) - await session.commit() - await session.refresh(plan) + await core_api_client.update_plan_pricing( + tier=tier, + stripe_price_id=req.stripePriceId, + stripe_sale_price_id=req.stripeSalePriceId, + sale_start=req.saleStart.isoformat() if req.saleStart else None, + sale_end=req.saleEnd.isoformat() if req.saleEnd else None, + ) - logger.info(f"Updated plan pricing: {tier}") + updated = await core_api_client.get_plan_pricing_by_tier(tier) + if not updated: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve updated plan") - return _to_admin_plan_pricing(plan) + logger.info("Updated plan pricing: %s", tier) + return _to_admin_plan_pricing(updated) @router.get("/admin/billing/audit-logs/{user_id}", tags=["admin"]) async def list_billing_audit_logs( user_id: str, - session: Annotated[AsyncSession, Depends(get_session)], user: Annotated[dict, Depends(require_admin)], ) -> list[AdminBillingAuditLogSchema]: """List billing audit log entries for a specific user. Admin only.""" import uuid as uuid_lib try: - user_uuid = uuid_lib.UUID(user_id) + uuid_lib.UUID(user_id) except ValueError: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_INVALID_USER_ID) - stmt = ( - select(BillingAuditLog) - .where(BillingAuditLog.user_account_id == user_uuid) - .order_by(BillingAuditLog.created_at.desc()) - ) - result = await session.execute(stmt) - logs = result.scalars().all() + logs = await core_api_client.get_billing_audit_logs_by_user(user_id) return [_to_admin_billing_audit_log(log) for log in logs] @@ -649,7 +589,6 @@ async def list_billing_audit_logs( async def admin_refund_user( user_id: str, req: AdminRefundRequest, - session: Annotated[AsyncSession, Depends(get_session)], user: Annotated[dict, Depends(require_admin)], stripe_service: Annotated[StripeService, Depends(get_stripe_service)], billing_service: Annotated[BillingOrchestrationService, Depends(get_billing_orchestration_service)], @@ -663,7 +602,6 @@ async def admin_refund_user( try: refund_id = await billing_service.issue_admin_refund( - session, stripe_service, user_id=user_uuid, admin_actor=user.get("email", DEFAULT_ADMIN_ACTOR), @@ -686,7 +624,6 @@ async def admin_refund_user( async def admin_cancel_user_subscription( user_id: str, req: AdminCancelSubscriptionRequest, - session: Annotated[AsyncSession, Depends(get_session)], user: Annotated[dict, Depends(require_admin)], stripe_service: Annotated[StripeService, Depends(get_stripe_service)], billing_service: Annotated[BillingOrchestrationService, Depends(get_billing_orchestration_service)], @@ -700,7 +637,6 @@ async def admin_cancel_user_subscription( try: subscription_id = await billing_service.cancel_admin_subscription( - session, stripe_service, user_id=user_uuid, admin_actor=user.get("email", DEFAULT_ADMIN_ACTOR), @@ -722,7 +658,6 @@ async def admin_cancel_user_subscription( async def admin_change_user_membership( user_id: str, req: AdminChangeMembershipRequest, - session: Annotated[AsyncSession, Depends(get_session)], user: Annotated[dict, Depends(require_admin)], stripe_service: Annotated[StripeService, Depends(get_stripe_service)], billing_service: Annotated[BillingOrchestrationService, Depends(get_billing_orchestration_service)], @@ -736,7 +671,6 @@ async def admin_change_user_membership( try: subscription_id, proration_behavior = await billing_service.change_admin_membership( - session, stripe_service, user_id=user_uuid, admin_actor=user.get("email", DEFAULT_ADMIN_ACTOR), @@ -759,7 +693,6 @@ async def admin_change_user_membership( async def admin_cancel_and_refund_user( user_id: str, req: AdminCancelAndRefundRequest, - session: Annotated[AsyncSession, Depends(get_session)], user: Annotated[dict, Depends(require_admin)], stripe_service: Annotated[StripeService, Depends(get_stripe_service)], billing_service: Annotated[BillingOrchestrationService, Depends(get_billing_orchestration_service)], @@ -773,14 +706,12 @@ async def admin_cancel_and_refund_user( try: subscription_id = await billing_service.cancel_admin_subscription( - session, stripe_service, user_id=user_uuid, admin_actor=user.get("email", DEFAULT_ADMIN_ACTOR), at_period_end=False, ) refund_id = await billing_service.issue_admin_refund( - session, stripe_service, user_id=user_uuid, admin_actor=user.get("email", DEFAULT_ADMIN_ACTOR), diff --git a/services/billing-service/app/services/billing_orchestration_service.py b/services/billing-service/app/services/billing_orchestration_service.py index 28b5d002..fd7d5eab 100644 --- a/services/billing-service/app/services/billing_orchestration_service.py +++ b/services/billing-service/app/services/billing_orchestration_service.py @@ -4,16 +4,13 @@ import json import logging from inspect import isawaitable -from datetime import datetime, timezone from uuid import UUID from fastapi import HTTPException, status -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession from app.config import get_settings -from app.models.db_models import BillingAuditLog, PlanPricingSettings, UserAccount -from app.services.local_payment_sandbox import LocalPlanPricing, local_plan_pricing +from app.services import core_api_client +from app.services.local_payment_sandbox import local_plan_pricing from app.services.stripe_service import StripeService logger = logging.getLogger(__name__) @@ -26,12 +23,43 @@ async def _maybe_await(value): return value +async def _get_account_by_subject_id(subject_id: str) -> dict: + account = await core_api_client.get_account_by_subject_id(subject_id) + if not account: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found") + return account + + +async def _get_account_by_uuid(user_id: UUID) -> dict: + account = await core_api_client.get_account_by_id(str(user_id)) + if not account: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + return account + + +async def _get_plan_pricing(tier: str) -> dict: + """Return plan pricing dict. Falls back to local sandbox pricing for non-production envs.""" + plan = await core_api_client.get_plan_pricing_by_tier(tier) + if plan: + return plan + if get_settings().resolved_payment_provider == "local_sandbox": + local_pricing = local_plan_pricing(tier) + if local_pricing: + return { + "tier": local_pricing.tier, + "stripePriceId": local_pricing.stripe_price_id, + "stripePriceIdMonthly": local_pricing.stripe_price_id_monthly, + "stripePriceIdAnnual": local_pricing.stripe_price_id_annual, + "priceMonthlyCents": None, + } + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Plan not found") + + class BillingOrchestrationService: - """Coordinates DB reads/writes and Stripe actions for billing flows.""" + """Coordinates core-api HTTP calls and Stripe actions for billing flows.""" async def create_checkout_for_user( self, - session: AsyncSession, stripe_service: StripeService, *, subject_id: str, @@ -40,18 +68,20 @@ async def create_checkout_for_user( tier: str, billing_interval: str, ) -> str: - account = await self._get_account_by_subject_id(session, subject_id) - pricing = await self._get_plan_pricing(session, tier) + account = await _get_account_by_subject_id(subject_id) + pricing = await _get_plan_pricing(tier) - if billing_interval == "annual" and pricing.stripe_price_id_annual: - price_id = pricing.stripe_price_id_annual + if billing_interval == "annual" and pricing.get("stripePriceIdAnnual"): + price_id = pricing["stripePriceIdAnnual"] else: - price_id = pricing.stripe_price_id_monthly + price_id = pricing.get("stripePriceIdMonthly") or pricing.get("stripePriceId") if not price_id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Plan is not available for purchase") - customer_id = await _maybe_await(stripe_service.ensure_customer(account.stripe_customer_id, email, name)) + customer_id = await _maybe_await( + stripe_service.ensure_customer(account["stripeCustomerId"], email, name) + ) settings = get_settings() success_url = f"{settings.app_base_url}/settings?checkout=success&session_id={{CHECKOUT_SESSION_ID}}" @@ -60,33 +90,34 @@ async def create_checkout_for_user( # Plan-change path: if the user already has an active subscription, modify it in-place # rather than creating a second subscription. Upgrades invoice prorated access now; # downgrades avoid proration so the next monthly payment is the target plan price. - if account.stripe_subscription_id and account.subscription_status == "active": + if account["stripeSubscriptionId"] and account["stripeSubscriptionStatus"] == "active": + current_tier = (account["planTier"] or "free").lower() proration_behavior = await self._proration_behavior_for_plan_change( - session, - current_tier=account.plan_tier, + current_tier=current_tier, target_tier=tier, target_pricing=pricing, ) stripe_service.change_subscription( - account.stripe_subscription_id, + account["stripeSubscriptionId"], price_id, proration_behavior=proration_behavior, ) - account_changed = False - if account.stripe_customer_id != customer_id: - account.stripe_customer_id = customer_id - account_changed = True + + if account["stripeCustomerId"] != customer_id: + await core_api_client.assign_stripe_customer(account["id"], customer_id) # Real Stripe sends customer.subscription.updated after an in-place # upgrade. The local sandbox has no remote webhook loop, so persist # the accepted tier here for the local UI to reflect the change. - if settings.resolved_payment_provider == "local_sandbox" and account.plan_tier != tier: - account.plan_tier = tier - account_changed = True + if settings.resolved_payment_provider == "local_sandbox" and current_tier != tier: + await core_api_client.update_subscription( + customer_id=customer_id, + subscription_id=account["stripeSubscriptionId"], + status="active", + tier=tier, + current_period_end=None, + ) - if account_changed: - session.add(account) - await session.commit() logger.info( "Changed subscription for user %s: tier=%s proration_behavior=%s", subject_id, @@ -104,23 +135,20 @@ async def create_checkout_for_user( ) ) - if account.stripe_customer_id != customer_id: - account.stripe_customer_id = customer_id - session.add(account) - await session.commit() + if account["stripeCustomerId"] != customer_id: + await core_api_client.assign_stripe_customer(account["id"], customer_id) logger.info("Created checkout session for user %s: tier=%s", subject_id, tier) return checkout_url async def create_portal_for_user( self, - session: AsyncSession, stripe_service: StripeService, *, subject_id: str, ) -> str: - account = await self._get_account_by_subject_id(session, subject_id) - if not account.stripe_customer_id: + account = await _get_account_by_subject_id(subject_id) + if not account["stripeCustomerId"]: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No Stripe customer found") settings = get_settings() @@ -128,7 +156,7 @@ async def create_portal_for_user( portal_url = await _maybe_await( stripe_service.create_portal_session( - customer_id=account.stripe_customer_id, + customer_id=account["stripeCustomerId"], return_url=return_url, ) ) @@ -138,7 +166,6 @@ async def create_portal_for_user( async def issue_admin_refund( self, - session: AsyncSession, stripe_service: StripeService, *, user_id: UUID, @@ -148,8 +175,8 @@ async def issue_admin_refund( payment_intent_id: str | None, reason: str | None, ) -> str: - account = await self._get_account_by_uuid(session, user_id) - if not account.stripe_customer_id: + account = await _get_account_by_uuid(user_id) + if not account["stripeCustomerId"]: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User has no Stripe customer") refund_id = await _maybe_await( @@ -161,169 +188,126 @@ async def issue_admin_refund( ) ) - audit = BillingAuditLog( + await core_api_client.create_billing_audit_log( event_type="admin_refund", actor=admin_actor, - details_json=json.dumps( - { - "refund_id": refund_id, - "amount_cents": amount_cents, - "charge_id": charge_id, - "payment_intent_id": payment_intent_id, - "reason": reason, - } - ), - user_account_id=account.id, + details_json=json.dumps({ + "refund_id": refund_id, + "amount_cents": amount_cents, + "charge_id": charge_id, + "payment_intent_id": payment_intent_id, + "reason": reason, + }), + user_account_id=account["id"], ) - session.add(audit) - await session.commit() logger.info("Admin refund issued: user=%s, refund_id=%s, amount=%s", user_id, refund_id, amount_cents) return refund_id async def cancel_admin_subscription( self, - session: AsyncSession, stripe_service: StripeService, *, user_id: UUID, admin_actor: str, at_period_end: bool, ) -> str: - account = await self._get_account_by_uuid(session, user_id) + account = await _get_account_by_uuid(user_id) terminal_statuses = {"canceled", "incomplete_expired"} - if not account.stripe_subscription_id or (account.subscription_status or "").lower() in terminal_statuses: + sub_status = (account["stripeSubscriptionStatus"] or "").lower() + if not account["stripeSubscriptionId"] or sub_status in terminal_statuses: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User has no cancellable subscription") - stripe_service.cancel_subscription(account.stripe_subscription_id, at_period_end=at_period_end) + stripe_service.cancel_subscription(account["stripeSubscriptionId"], at_period_end=at_period_end) if get_settings().resolved_payment_provider == "local_sandbox" and not at_period_end: - account.subscription_status = "canceled" - account.plan_tier = "free" - session.add(account) + await core_api_client.update_subscription( + customer_id=account["stripeCustomerId"], + subscription_id=account["stripeSubscriptionId"], + status="canceled", + tier="free", + current_period_end=None, + ) - audit = BillingAuditLog( + await core_api_client.create_billing_audit_log( event_type="admin_subscription_cancel_requested", actor=admin_actor, - details_json=json.dumps( - { - "subscription_id": account.stripe_subscription_id, - "at_period_end": at_period_end, - } - ), - user_account_id=account.id, + details_json=json.dumps({ + "subscription_id": account["stripeSubscriptionId"], + "at_period_end": at_period_end, + }), + user_account_id=account["id"], ) - session.add(audit) - await session.commit() logger.info( "Admin subscription cancellation requested: user=%s subscription=%s at_period_end=%s", user_id, - account.stripe_subscription_id, + account["stripeSubscriptionId"], at_period_end, ) - return account.stripe_subscription_id + return account["stripeSubscriptionId"] async def change_admin_membership( self, - session: AsyncSession, stripe_service: StripeService, *, user_id: UUID, admin_actor: str, tier: str, ) -> tuple[str, str]: - account = await self._get_account_by_uuid(session, user_id) + account = await _get_account_by_uuid(user_id) terminal_statuses = {"canceled", "incomplete_expired"} - if not account.stripe_subscription_id or (account.subscription_status or "").lower() in terminal_statuses: + sub_status = (account["stripeSubscriptionStatus"] or "").lower() + if not account["stripeSubscriptionId"] or sub_status in terminal_statuses: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User has no active subscription to change") if tier not in {"pro", "business"}: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Only paid memberships can be selected") - pricing = await self._get_plan_pricing(session, tier) - price_id = pricing.stripe_price_id_monthly + pricing = await _get_plan_pricing(tier) + price_id = pricing.get("stripePriceIdMonthly") or pricing.get("stripePriceId") if not price_id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Plan is not available for monthly billing") + current_tier = (account["planTier"] or "free").lower() proration_behavior = await self._proration_behavior_for_plan_change( - session, - current_tier=account.plan_tier, + current_tier=current_tier, target_tier=tier, target_pricing=pricing, ) stripe_service.change_subscription( - account.stripe_subscription_id, + account["stripeSubscriptionId"], price_id, proration_behavior=proration_behavior, ) - audit = BillingAuditLog( + await core_api_client.create_billing_audit_log( event_type="admin_membership_change_requested", actor=admin_actor, - details_json=json.dumps( - { - "subscription_id": account.stripe_subscription_id, - "current_tier": account.plan_tier, - "target_tier": tier, - "price_id": price_id, - "proration_behavior": proration_behavior, - } - ), - user_account_id=account.id, + details_json=json.dumps({ + "subscription_id": account["stripeSubscriptionId"], + "current_tier": current_tier, + "target_tier": tier, + "price_id": price_id, + "proration_behavior": proration_behavior, + }), + user_account_id=account["id"], ) - session.add(audit) - await session.commit() logger.info( "Admin membership change requested: user=%s subscription=%s tier=%s proration_behavior=%s", user_id, - account.stripe_subscription_id, + account["stripeSubscriptionId"], tier, proration_behavior, ) - return account.stripe_subscription_id, proration_behavior - - async def _get_account_by_subject_id(self, session: AsyncSession, subject_id: str) -> UserAccount: - stmt = select(UserAccount).where(UserAccount.subject_id == subject_id) - result = await session.execute(stmt) - account = result.scalars().first() - - if not account: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found") - - return account - - async def _get_account_by_uuid(self, session: AsyncSession, user_id: UUID) -> UserAccount: - stmt = select(UserAccount).where(UserAccount.id == user_id) - result = await session.execute(stmt) - account = result.scalars().first() - - if not account: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") - - return account - - async def _get_plan_pricing(self, session: AsyncSession, tier: str) -> PlanPricingSettings | LocalPlanPricing: - stmt = select(PlanPricingSettings).where(PlanPricingSettings.tier == tier) - result = await session.execute(stmt) - pricing = result.scalars().first() - - if not pricing: - if get_settings().resolved_payment_provider == "local_sandbox": - local_pricing = local_plan_pricing(tier) - if local_pricing: - return local_pricing - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Plan not found") - - return pricing + return account["stripeSubscriptionId"], proration_behavior async def _proration_behavior_for_plan_change( self, - session: AsyncSession, *, current_tier: str | None, target_tier: str, - target_pricing: PlanPricingSettings | LocalPlanPricing, + target_pricing: dict, ) -> str: if current_tier == target_tier: return "none" @@ -331,12 +315,12 @@ async def _proration_behavior_for_plan_change( current_pricing = None if current_tier: try: - current_pricing = await self._get_plan_pricing(session, current_tier) - except HTTPException: + current_pricing = await core_api_client.get_plan_pricing_by_tier(current_tier) + except Exception: current_pricing = None - current_amount = getattr(current_pricing, "price_monthly_cents", None) - target_amount = getattr(target_pricing, "price_monthly_cents", None) + current_amount = current_pricing.get("priceMonthlyCents") if current_pricing else None + target_amount = target_pricing.get("priceMonthlyCents") if isinstance(current_amount, int) and isinstance(target_amount, int) and current_amount != target_amount: return "always_invoice" if target_amount > current_amount else "none" diff --git a/services/billing-service/app/services/core_api_client.py b/services/billing-service/app/services/core_api_client.py new file mode 100644 index 00000000..0527b023 --- /dev/null +++ b/services/billing-service/app/services/core_api_client.py @@ -0,0 +1,231 @@ +"""HTTP client for core-api internal billing endpoints. + +All write operations that mutate core-api-owned tables (UserAccounts, +BillingAuditLogs, ProcessedStripeWebhookEvents, PlanPricingSettings) must go +through this client instead of touching the database directly. +""" +from __future__ import annotations + +import logging +from typing import Any + +import httpx + +from app.config import get_settings + +logger = logging.getLogger(__name__) + + +def _get_headers() -> dict[str, str]: + return {"X-Internal-Api-Key": get_settings().internal_api_key} + + +def _base_url() -> str: + return get_settings().core_api_base_url.rstrip("/") + + +async def get_account_by_subject_id(subject_id: str) -> dict[str, Any] | None: + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{_base_url()}/internal/billing/accounts/by-subject/{subject_id}", + headers=_get_headers(), + ) + if resp.status_code == 404: + return None + resp.raise_for_status() + return resp.json() + + +async def get_account_by_id(account_id: str) -> dict[str, Any] | None: + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{_base_url()}/internal/billing/accounts/by-id/{account_id}", + headers=_get_headers(), + ) + if resp.status_code == 404: + return None + resp.raise_for_status() + return resp.json() + + +async def get_account_by_stripe_customer(customer_id: str) -> dict[str, Any] | None: + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{_base_url()}/internal/billing/accounts/by-stripe-customer/{customer_id}", + headers=_get_headers(), + ) + if resp.status_code == 404: + return None + resp.raise_for_status() + return resp.json() + + +async def assign_stripe_customer(account_id: str, customer_id: str) -> None: + async with httpx.AsyncClient() as client: + resp = await client.post( + f"{_base_url()}/internal/billing/accounts/{account_id}/stripe-customer", + json={"customerId": customer_id}, + headers=_get_headers(), + ) + resp.raise_for_status() + + +async def update_subscription( + customer_id: str, + subscription_id: str, + status: str, + tier: str, + current_period_end: str | None, +) -> None: + async with httpx.AsyncClient() as client: + resp = await client.put( + f"{_base_url()}/internal/billing/accounts/by-stripe-customer/{customer_id}/subscription", + json={ + "subscriptionId": subscription_id, + "status": status, + "tier": _tier_to_int(tier), + "currentPeriodEnd": current_period_end, + }, + headers=_get_headers(), + ) + resp.raise_for_status() + + +async def update_subscription_status( + customer_id: str, + status: str, + subscription_id: str | None = None, + current_period_end: str | None = None, +) -> None: + async with httpx.AsyncClient() as client: + resp = await client.patch( + f"{_base_url()}/internal/billing/accounts/by-stripe-customer/{customer_id}/subscription-status", + json={ + "status": status, + "subscriptionId": subscription_id, + "currentPeriodEnd": current_period_end, + }, + headers=_get_headers(), + ) + resp.raise_for_status() + + +async def clear_subscription(customer_id: str) -> None: + async with httpx.AsyncClient() as client: + resp = await client.delete( + f"{_base_url()}/internal/billing/accounts/by-stripe-customer/{customer_id}/subscription", + headers=_get_headers(), + ) + resp.raise_for_status() + + +async def record_webhook_event(stripe_event_id: str, event_type: str) -> bool: + """Record a processed Stripe webhook event for idempotency. + + Returns True if the event was newly recorded; False if it was already processed. + """ + async with httpx.AsyncClient() as client: + resp = await client.post( + f"{_base_url()}/internal/billing/webhook-events", + json={"stripeEventId": stripe_event_id, "eventType": event_type}, + headers=_get_headers(), + ) + if resp.status_code == 409: + return False + resp.raise_for_status() + return True + + +async def create_billing_audit_log( + event_type: str, + actor: str, + details_json: str, + user_account_id: str | None = None, + stripe_event_id: str | None = None, +) -> None: + async with httpx.AsyncClient() as client: + resp = await client.post( + f"{_base_url()}/internal/billing/audit-logs", + json={ + "eventType": event_type, + "actor": actor, + "detailsJson": details_json, + "userAccountId": user_account_id, + "stripeEventId": stripe_event_id, + }, + headers=_get_headers(), + ) + resp.raise_for_status() + + +async def get_billing_audit_logs_by_user(user_id: str) -> list[dict[str, Any]]: + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{_base_url()}/internal/billing/audit-logs/by-user/{user_id}", + headers=_get_headers(), + ) + resp.raise_for_status() + return resp.json() + + +async def get_all_plan_pricing() -> list[dict[str, Any]]: + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{_base_url()}/internal/billing/plan-pricing", + headers=_get_headers(), + ) + resp.raise_for_status() + return resp.json() + + +async def get_plan_pricing_by_tier(tier: str) -> dict[str, Any] | None: + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{_base_url()}/internal/billing/plan-pricing/by-tier/{_tier_to_int(tier)}", + headers=_get_headers(), + ) + if resp.status_code == 404: + return None + resp.raise_for_status() + return resp.json() + + +async def get_plan_pricing_by_price_id(price_id: str) -> dict[str, Any] | None: + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{_base_url()}/internal/billing/plan-pricing/by-price-id/{price_id}", + headers=_get_headers(), + ) + if resp.status_code == 404: + return None + resp.raise_for_status() + return resp.json() + + +async def update_plan_pricing( + tier: str, + stripe_price_id: str, + stripe_sale_price_id: str | None, + sale_start: str | None, + sale_end: str | None, +) -> None: + async with httpx.AsyncClient() as client: + resp = await client.put( + f"{_base_url()}/internal/billing/plan-pricing/{_tier_to_int(tier)}", + json={ + "stripePriceId": stripe_price_id, + "stripeSalePriceId": stripe_sale_price_id, + "saleStart": sale_start, + "saleEnd": sale_end, + }, + headers=_get_headers(), + ) + resp.raise_for_status() + + +def _tier_to_int(tier: str) -> int: + """Convert plan tier string to the C# enum integer value.""" + try: + return {"free": 0, "pro": 1, "business": 2}[tier.lower()] + except KeyError as exc: + raise ValueError(f"Unknown plan tier: {tier}") from exc diff --git a/services/cms-service/app/main.py b/services/cms-service/app/main.py index 2b9fb8c5..95aa58c5 100644 --- a/services/cms-service/app/main.py +++ b/services/cms-service/app/main.py @@ -3,6 +3,7 @@ from app.config import get_settings from app.routers.blog import router as blog_router +from app.routers.content import router as content_router from internal_auth import CorrelationIdMiddleware, InternalApiKeyMiddleware from shared_api_key_scheme import setup_api_key_scheme @@ -15,6 +16,7 @@ app.add_middleware(InternalApiKeyMiddleware) app.add_middleware(CorrelationIdMiddleware) app.include_router(blog_router) +app.include_router(content_router) Instrumentator().instrument(app).expose(app) setup_api_key_scheme(app) diff --git a/services/cms-service/app/models/db_models.py b/services/cms-service/app/models/db_models.py index 9e4f08f3..ece12338 100644 --- a/services/cms-service/app/models/db_models.py +++ b/services/cms-service/app/models/db_models.py @@ -1,7 +1,7 @@ """SQLAlchemy ORM models for blog content (read-only schema managed by core-api EF Core).""" from datetime import datetime, timezone from typing import List -from sqlalchemy import String, Column, DateTime, ForeignKey, Text, Boolean +from sqlalchemy import String, Column, DateTime, ForeignKey, Text, Boolean, Integer from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import declarative_base, relationship, Mapped import uuid @@ -17,6 +17,86 @@ def _as_utc(value: datetime | None) -> datetime | None: return value.astimezone(timezone.utc) +class ContentItem(Base): + """Unified content item supporting blogs, guides, articles and CV-For pages.""" + __tablename__ = "ContentItems" + + id = Column("Id", UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + + # Core fields + content_type = Column("ContentType", String(50), nullable=False) + category = Column("Category", String(100), nullable=True) + title = Column("Title", String(250), nullable=False) + slug = Column("Slug", String(250), nullable=False) + excerpt = Column("Excerpt", Text, nullable=True) + content_markdown = Column("ContentMarkdown", Text, nullable=False) + author_name = Column("AuthorName", String(200), nullable=False) + status = Column("Status", String(20), nullable=False, default="Draft") + + # SEO fields + seo_title = Column("SeoTitle", String(250), nullable=True) + seo_description = Column("SeoDescription", String(500), nullable=True) + canonical_url = Column("CanonicalUrl", String(500), nullable=True) + target_keyword = Column("TargetKeyword", String(200), nullable=True) + search_intent = Column("SearchIntent", String(100), nullable=True) + + # Publication window + published_at = Column("PublishedAt", DateTime(timezone=True), nullable=True) + start_date = Column("StartDate", DateTime(timezone=True), nullable=True) + expiry_date = Column("ExpiryDate", DateTime(timezone=True), nullable=True) + reading_time_minutes = Column("ReadingTimeMinutes", Integer, nullable=True) + + # Content provenance + content_source = Column("ContentSource", String(50), nullable=False, default="Manual") + created_by = Column("CreatedBy", String(200), nullable=True) + last_edited_by = Column("LastEditedBy", String(200), nullable=True) + created_from_content_id = Column("CreatedFromContentId", UUID(as_uuid=True), nullable=True) + + # AI fields + ai_generated = Column("AiGenerated", Boolean, nullable=False, default=False) + ai_prompt_version = Column("AiPromptVersion", String(100), nullable=True) + + # Audit / workflow + author_id = Column("AuthorId", String(200), nullable=True) + approved_by = Column("ApprovedBy", String(200), nullable=True) + published_by = Column("PublishedBy", String(200), nullable=True) + last_workflow_action_by = Column("LastWorkflowActionBy", String(200), nullable=True) + last_workflow_action_at = Column("LastWorkflowActionAt", DateTime(timezone=True), nullable=True) + + # Content clusters + cluster_id = Column("ClusterId", UUID(as_uuid=True), nullable=True) + hub_page_id = Column("HubPageId", UUID(as_uuid=True), nullable=True) + primary_topic = Column("PrimaryTopic", String(200), nullable=True) + + # Editorial + editorial_notes = Column("EditorialNotes", Text, nullable=True) + + # Timestamps + created_at = Column( + "CreatedAt", + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + updated_at = Column( + "UpdatedAt", + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + + def is_visible(self) -> bool: + """Check if the content item is currently visible to the public.""" + now = datetime.now(timezone.utc) + start_date = _as_utc(self.start_date) + expiry_date = _as_utc(self.expiry_date) + return ( + self.status == "Published" + and (start_date is None or start_date <= now) + and (expiry_date is None or expiry_date >= now) + ) + + class BlogPost(Base): """A blog post authored by an administrator (read-only schema managed by core-api EF Core).""" __tablename__ = "BlogPosts" diff --git a/services/cms-service/app/models/schemas.py b/services/cms-service/app/models/schemas.py index 1be7628a..81bc380d 100644 --- a/services/cms-service/app/models/schemas.py +++ b/services/cms-service/app/models/schemas.py @@ -4,6 +4,8 @@ from pydantic import BaseModel, ConfigDict, Field +# ── Blog schemas (legacy / backward-compatible) ────────────────────────────── + class BlogSourceSchema(BaseModel): """A source/reference in a blog post.""" id: str @@ -80,3 +82,146 @@ class UpdateBlogPostRequest(BaseModel): canonical_url: str | None = None status: str | None = None sources: List[BlogSourceSchema] = [] + + +# ── ContentItem schemas ─────────────────────────────────────────────────────── + +class ContentItemSummarySchema(BaseModel): + """Lightweight content item summary for list responses.""" + id: str + content_type: str + category: str | None = None + title: str + slug: str + excerpt: str | None = None + author_name: str + status: str + target_keyword: str | None = None + published_at: datetime | None = None + start_date: datetime | None = None + expiry_date: datetime | None = None + reading_time_minutes: int | None = None + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class ContentItemSchema(BaseModel): + """Full content item including markdown body.""" + id: str + content_type: str + category: str | None = None + title: str + slug: str + excerpt: str | None = None + content_markdown: str + author_name: str + status: str + seo_title: str | None = None + seo_description: str | None = None + canonical_url: str | None = None + target_keyword: str | None = None + search_intent: str | None = None + published_at: datetime | None = None + start_date: datetime | None = None + expiry_date: datetime | None = None + reading_time_minutes: int | None = None + content_source: str + primary_topic: str | None = None + cluster_id: str | None = None + hub_page_id: str | None = None + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class ContentItemAdminSchema(ContentItemSchema): + """Full content item for admin responses, including internal/audit fields.""" + author_id: str | None = None + created_by: str | None = None + last_edited_by: str | None = None + approved_by: str | None = None + published_by: str | None = None + last_workflow_action_by: str | None = None + last_workflow_action_at: datetime | None = None + ai_generated: bool = False + ai_prompt_version: str | None = None + editorial_notes: str | None = None + created_from_content_id: str | None = None + + +VALID_CONTENT_TYPES = {"Blog", "Guide", "Article", "CvFor"} +VALID_STATUSES = {"Draft", "Review", "Published", "Archived"} +VALID_CONTENT_SOURCES = {"Manual", "AiGenerated", "AiAssisted", "Imported"} + + +class CreateContentItemRequest(BaseModel): + """Request to create a new content item.""" + content_type: str = Field(..., min_length=1, max_length=50) + category: str | None = Field(None, max_length=100) + title: str = Field(..., min_length=1, max_length=250) + slug: str = Field(..., min_length=1, max_length=250, pattern=r'^[a-z0-9]+(?:-[a-z0-9]+)*$') + excerpt: str | None = None + content_markdown: str = Field(..., min_length=1) + author_name: str = Field(..., min_length=1, max_length=200) + status: str | None = Field(None, max_length=20) + seo_title: str | None = Field(None, max_length=250) + seo_description: str | None = Field(None, max_length=500) + canonical_url: str | None = Field(None, max_length=500) + target_keyword: str | None = Field(None, max_length=200) + search_intent: str | None = Field(None, max_length=100) + start_date: datetime | None = None + expiry_date: datetime | None = None + reading_time_minutes: int | None = None + content_source: str | None = Field(None, max_length=50) + author_id: str | None = Field(None, max_length=200) + primary_topic: str | None = Field(None, max_length=200) + cluster_id: str | None = None + hub_page_id: str | None = None + editorial_notes: str | None = None + ai_generated: bool = False + ai_prompt_version: str | None = Field(None, max_length=100) + created_by: str | None = Field(None, max_length=200) + + +class UpdateContentItemRequest(BaseModel): + """Request to update a content item. All fields are optional.""" + category: str | None = Field(None, max_length=100) + title: str | None = Field(None, min_length=1, max_length=250) + slug: str | None = Field(None, min_length=1, max_length=250, pattern=r'^[a-z0-9]+(?:-[a-z0-9]+)*$') + excerpt: str | None = None + content_markdown: str | None = Field(None, min_length=1) + author_name: str | None = Field(None, min_length=1, max_length=200) + status: str | None = Field(None, max_length=20) + seo_title: str | None = Field(None, max_length=250) + seo_description: str | None = Field(None, max_length=500) + canonical_url: str | None = Field(None, max_length=500) + target_keyword: str | None = Field(None, max_length=200) + search_intent: str | None = Field(None, max_length=100) + start_date: datetime | None = None + expiry_date: datetime | None = None + reading_time_minutes: int | None = None + content_source: str | None = Field(None, max_length=50) + author_id: str | None = Field(None, max_length=200) + primary_topic: str | None = Field(None, max_length=200) + cluster_id: str | None = None + hub_page_id: str | None = None + editorial_notes: str | None = None + ai_generated: bool | None = None + ai_prompt_version: str | None = Field(None, max_length=100) + last_edited_by: str | None = Field(None, max_length=200) + + +class PublishContentItemRequest(BaseModel): + """Request to publish a content item.""" + published_by: str | None = Field(None, max_length=200) + + +class ContentItemListResponse(BaseModel): + """Paginated list of content item summaries.""" + items: List[ContentItemSummarySchema] + total: int + page: int + page_size: int diff --git a/services/cms-service/app/routers/content.py b/services/cms-service/app/routers/content.py new file mode 100644 index 00000000..4498bc76 --- /dev/null +++ b/services/cms-service/app/routers/content.py @@ -0,0 +1,627 @@ +"""Unified content platform API routes. + +Supports content types: Blog, Guide, Article, CvFor. + +Public URL patterns: + /content/blog/{slug} → /blog/{slug} (also served at backward-compat /blog/{slug}) + /content/guides/{slug} → /guides/{slug} + /content/articles/{slug} → /articles/{slug} + /content/cv-for/{slug} → /cv-for/{slug} + +Admin URL patterns: + /admin/content list all (any status, any type) + /admin/content/{id} get by id + /admin/content POST create + /admin/content/{id} PUT update + /admin/content/{id}/publish POST publish + /admin/content/{id}/archive POST archive +""" + +from datetime import datetime, timezone +import logging +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth import require_admin +from app.db import get_session +from app.models.db_models import ContentItem +from app.models.schemas import ( + ContentItemAdminSchema, + ContentItemListResponse, + ContentItemSchema, + ContentItemSummarySchema, + CreateContentItemRequest, + PublishContentItemRequest, + UpdateContentItemRequest, + VALID_CONTENT_TYPES, + VALID_STATUSES, + VALID_CONTENT_SOURCES, +) + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# ── Constants ──────────────────────────────────────────────────────────────── + +ERROR_NOT_FOUND = "Content item not found" +ERROR_INVALID_ID = "Invalid content item ID" +ERROR_DUPLICATE_SLUG = "A content item with this slug already exists for the given content type" +ERROR_INVALID_CONTENT_TYPE = f"Invalid content type. Must be one of: {', '.join(sorted(VALID_CONTENT_TYPES))}" +ERROR_INVALID_STATUS = f"Invalid status. Must be one of: {', '.join(sorted(VALID_STATUSES))}" +ERROR_INVALID_SOURCE = f"Invalid content source. Must be one of: {', '.join(sorted(VALID_CONTENT_SOURCES))}" + +# Map URL path segments to canonical ContentType values +_SLUG_TO_TYPE: dict[str, str] = { + "blog": "Blog", + "guides": "Guide", + "articles": "Article", + "cv-for": "CvFor", +} + + +# ── Helpers ────────────────────────────────────────────────────────────────── + +def _parse_item_id(item_id: str) -> uuid.UUID: + try: + return uuid.UUID(item_id) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_INVALID_ID, + ) from exc + + +def _parse_uuid_field(value: str | None, field_name: str) -> uuid.UUID | None: + """Parse an optional UUID string field, raising 422 on invalid format.""" + if value is None: + return None + try: + return uuid.UUID(value) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Invalid {field_name} format — expected a valid UUID", + ) from exc + + +def _is_visible(item: ContentItem) -> bool: + """Return True only if the item is publicly visible right now.""" + now = datetime.now(timezone.utc) + + def _utc(v: datetime | None) -> datetime | None: + if v is None: + return None + return v if v.tzinfo else v.replace(tzinfo=timezone.utc) + + return ( + item.status == "Published" + and (_utc(item.start_date) is None or _utc(item.start_date) <= now) + and (_utc(item.expiry_date) is None or _utc(item.expiry_date) >= now) + ) + + +def _to_summary(item: ContentItem) -> ContentItemSummarySchema: + return ContentItemSummarySchema( + id=str(item.id), + content_type=item.content_type, + category=item.category, + title=item.title, + slug=item.slug, + excerpt=item.excerpt, + author_name=item.author_name, + status=item.status, + target_keyword=item.target_keyword, + published_at=item.published_at, + start_date=item.start_date, + expiry_date=item.expiry_date, + reading_time_minutes=item.reading_time_minutes, + created_at=item.created_at, + updated_at=item.updated_at, + ) + + +def _to_schema(item: ContentItem) -> ContentItemSchema: + return ContentItemSchema( + id=str(item.id), + content_type=item.content_type, + category=item.category, + title=item.title, + slug=item.slug, + excerpt=item.excerpt, + content_markdown=item.content_markdown, + author_name=item.author_name, + status=item.status, + seo_title=item.seo_title, + seo_description=item.seo_description, + canonical_url=item.canonical_url, + target_keyword=item.target_keyword, + search_intent=item.search_intent, + published_at=item.published_at, + start_date=item.start_date, + expiry_date=item.expiry_date, + reading_time_minutes=item.reading_time_minutes, + content_source=item.content_source, + primary_topic=item.primary_topic, + cluster_id=str(item.cluster_id) if item.cluster_id else None, + hub_page_id=str(item.hub_page_id) if item.hub_page_id else None, + created_at=item.created_at, + updated_at=item.updated_at, + ) + + +def _to_admin_schema(item: ContentItem) -> ContentItemAdminSchema: + return ContentItemAdminSchema( + id=str(item.id), + content_type=item.content_type, + category=item.category, + title=item.title, + slug=item.slug, + excerpt=item.excerpt, + content_markdown=item.content_markdown, + author_name=item.author_name, + status=item.status, + seo_title=item.seo_title, + seo_description=item.seo_description, + canonical_url=item.canonical_url, + target_keyword=item.target_keyword, + search_intent=item.search_intent, + published_at=item.published_at, + start_date=item.start_date, + expiry_date=item.expiry_date, + reading_time_minutes=item.reading_time_minutes, + content_source=item.content_source, + primary_topic=item.primary_topic, + cluster_id=str(item.cluster_id) if item.cluster_id else None, + hub_page_id=str(item.hub_page_id) if item.hub_page_id else None, + created_at=item.created_at, + updated_at=item.updated_at, + author_id=item.author_id, + created_by=item.created_by, + last_edited_by=item.last_edited_by, + approved_by=item.approved_by, + published_by=item.published_by, + last_workflow_action_by=item.last_workflow_action_by, + last_workflow_action_at=item.last_workflow_action_at, + ai_generated=item.ai_generated, + ai_prompt_version=item.ai_prompt_version, + editorial_notes=item.editorial_notes, + created_from_content_id=str(item.created_from_content_id) if item.created_from_content_id else None, + ) + + +async def _get_by_id( + session: AsyncSession, + item_id: uuid.UUID, +) -> ContentItem | None: + result = await session.execute( + select(ContentItem).where(ContentItem.id == item_id) + ) + return result.scalars().first() + + +async def _get_by_type_and_slug( + session: AsyncSession, + content_type: str, + slug: str, +) -> ContentItem | None: + result = await session.execute( + select(ContentItem).where( + ContentItem.content_type == content_type, + ContentItem.slug == slug, + ) + ) + return result.scalars().first() + + +async def _ensure_slug_available( + session: AsyncSession, + content_type: str, + slug: str, + exclude_id: uuid.UUID | None = None, +) -> None: + existing = await _get_by_type_and_slug(session, content_type, slug) + if existing is None: + return + if exclude_id is not None and existing.id == exclude_id: + return + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=ERROR_DUPLICATE_SLUG) + + +# ── Public endpoints ───────────────────────────────────────────────────────── + +@router.get("/content/{type_slug}", tags=["content"]) +async def list_published_content( + type_slug: str, + session: Annotated[AsyncSession, Depends(get_session)], + category: str | None = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), +) -> ContentItemListResponse: + """List published content items for a given content type.""" + content_type = _SLUG_TO_TYPE.get(type_slug) + if content_type is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_INVALID_CONTENT_TYPE) + + now = datetime.now(timezone.utc) + from sqlalchemy import or_ + + filters = [ + ContentItem.content_type == content_type, + ContentItem.status == "Published", + or_(ContentItem.start_date.is_(None), ContentItem.start_date <= now), + or_(ContentItem.expiry_date.is_(None), ContentItem.expiry_date >= now), + ] + if category: + filters.append(ContentItem.category == category) + + count_result = await session.execute( + select(func.count()).select_from(ContentItem).where(*filters) + ) + total = count_result.scalar_one() + + stmt = ( + select(ContentItem) + .where(*filters) + .order_by(ContentItem.published_at.desc().nullslast(), ContentItem.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + result = await session.execute(stmt) + items = result.scalars().all() + + return ContentItemListResponse( + items=[_to_summary(i) for i in items if _is_visible(i)], + total=total, + page=page, + page_size=page_size, + ) + + +@router.get("/content/{type_slug}/{slug}", tags=["content"]) +async def get_published_content( + type_slug: str, + slug: str, + session: Annotated[AsyncSession, Depends(get_session)], +) -> ContentItemSchema: + """Get a published content item by type and slug.""" + content_type = _SLUG_TO_TYPE.get(type_slug) + if content_type is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_INVALID_CONTENT_TYPE) + + item = await _get_by_type_and_slug(session, content_type, slug) + if item is None or not _is_visible(item): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_NOT_FOUND) + return _to_schema(item) + + +# ── Blog backward-compatible public endpoints ───────────────────────────────── +# These serve the same data as /content/blog/* but maintain the existing URL. + +@router.get("/guides", tags=["guides"]) +async def list_published_guides( + session: Annotated[AsyncSession, Depends(get_session)], + category: str | None = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), +) -> ContentItemListResponse: + """List published guides.""" + return await list_published_content("guides", session, category, page, page_size) + + +@router.get("/guides/{slug}", tags=["guides"]) +async def get_published_guide( + slug: str, + session: Annotated[AsyncSession, Depends(get_session)], +) -> ContentItemSchema: + """Get a published guide by slug.""" + return await get_published_content("guides", slug, session) + + +@router.get("/articles", tags=["articles"]) +async def list_published_articles( + session: Annotated[AsyncSession, Depends(get_session)], + category: str | None = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), +) -> ContentItemListResponse: + """List published articles.""" + return await list_published_content("articles", session, category, page, page_size) + + +@router.get("/articles/{slug}", tags=["articles"]) +async def get_published_article( + slug: str, + session: Annotated[AsyncSession, Depends(get_session)], +) -> ContentItemSchema: + """Get a published article by slug.""" + return await get_published_content("articles", slug, session) + + +@router.get("/cv-for", tags=["cv-for"]) +async def list_published_cv_for( + session: Annotated[AsyncSession, Depends(get_session)], + category: str | None = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), +) -> ContentItemListResponse: + """List published CV-For pages.""" + return await list_published_content("cv-for", session, category, page, page_size) + + +@router.get("/cv-for/{slug}", tags=["cv-for"]) +async def get_published_cv_for( + slug: str, + session: Annotated[AsyncSession, Depends(get_session)], +) -> ContentItemSchema: + """Get a published CV-For page by slug.""" + return await get_published_content("cv-for", slug, session) + + +# ── Admin endpoints ─────────────────────────────────────────────────────────── + +@router.get("/admin/content", tags=["admin"]) +async def list_all_content( + session: Annotated[AsyncSession, Depends(get_session)], + user: Annotated[dict, Depends(require_admin)], + content_type: str | None = Query(None), + category: str | None = Query(None), + item_status: str | None = Query(None, alias="status"), + search: str | None = Query(None), + target_keyword: str | None = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), +) -> ContentItemListResponse: + """List all content items (admin). Supports filtering by type, category, status, keyword.""" + filters = [] + if content_type: + filters.append(ContentItem.content_type == content_type) + if category: + filters.append(ContentItem.category == category) + if item_status: + filters.append(ContentItem.status == item_status) + if target_keyword: + filters.append(ContentItem.target_keyword == target_keyword) + if search: + like = f"%{search}%" + filters.append( + ContentItem.title.ilike(like) | ContentItem.excerpt.ilike(like) + ) + + count_result = await session.execute( + select(func.count()).select_from(ContentItem).where(*filters) + ) + total = count_result.scalar_one() + + stmt = ( + select(ContentItem) + .where(*filters) + .order_by(ContentItem.updated_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + result = await session.execute(stmt) + items = result.scalars().all() + + return ContentItemListResponse( + items=[_to_summary(i) for i in items], + total=total, + page=page, + page_size=page_size, + ) + + +@router.get("/admin/content/{item_id}", tags=["admin"]) +async def get_content_item_admin( + item_id: str, + session: Annotated[AsyncSession, Depends(get_session)], + user: Annotated[dict, Depends(require_admin)], +) -> ContentItemAdminSchema: + """Get a content item by ID (admin view including internal fields).""" + item_uuid = _parse_item_id(item_id) + item = await _get_by_id(session, item_uuid) + if item is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_NOT_FOUND) + return _to_admin_schema(item) + + +@router.post("/admin/content", status_code=status.HTTP_201_CREATED, tags=["admin"]) +async def create_content_item( + req: CreateContentItemRequest, + session: Annotated[AsyncSession, Depends(get_session)], + user: Annotated[dict, Depends(require_admin)], +) -> ContentItemAdminSchema: + """Create a new content item.""" + if req.content_type not in VALID_CONTENT_TYPES: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=ERROR_INVALID_CONTENT_TYPE) + if req.status and req.status not in VALID_STATUSES: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=ERROR_INVALID_STATUS) + if req.content_source and req.content_source not in VALID_CONTENT_SOURCES: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=ERROR_INVALID_SOURCE) + + await _ensure_slug_available(session, req.content_type, req.slug) + + now = datetime.now(timezone.utc) + item = ContentItem( + id=uuid.uuid4(), + content_type=req.content_type, + category=req.category, + title=req.title, + slug=req.slug, + excerpt=req.excerpt, + content_markdown=req.content_markdown, + author_name=req.author_name, + status=req.status or "Draft", + seo_title=req.seo_title, + seo_description=req.seo_description, + canonical_url=req.canonical_url, + target_keyword=req.target_keyword, + search_intent=req.search_intent, + start_date=req.start_date, + expiry_date=req.expiry_date, + reading_time_minutes=req.reading_time_minutes, + content_source=req.content_source or "Manual", + author_id=req.author_id, + primary_topic=req.primary_topic, + cluster_id=_parse_uuid_field(req.cluster_id, "cluster_id"), + hub_page_id=_parse_uuid_field(req.hub_page_id, "hub_page_id"), + editorial_notes=req.editorial_notes, + ai_generated=req.ai_generated, + ai_prompt_version=req.ai_prompt_version, + created_by=req.created_by, + last_workflow_action_by=req.created_by, + last_workflow_action_at=now, + created_at=now, + updated_at=now, + ) + + session.add(item) + await session.commit() + await session.refresh(item) + + logger.info("Created content item: %s/%s (id=%s)", item.content_type, item.slug, item.id) + return _to_admin_schema(item) + + +@router.put("/admin/content/{item_id}", tags=["admin"]) +async def update_content_item( + item_id: str, + req: UpdateContentItemRequest, + session: Annotated[AsyncSession, Depends(get_session)], + user: Annotated[dict, Depends(require_admin)], +) -> ContentItemAdminSchema: + """Update a content item.""" + item_uuid = _parse_item_id(item_id) + item = await _get_by_id(session, item_uuid) + if item is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_NOT_FOUND) + + if req.status and req.status not in VALID_STATUSES: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=ERROR_INVALID_STATUS) + if req.content_source and req.content_source not in VALID_CONTENT_SOURCES: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=ERROR_INVALID_SOURCE) + + if req.slug and req.slug != item.slug: + await _ensure_slug_available(session, item.content_type, req.slug, exclude_id=item.id) + + now = datetime.now(timezone.utc) + + if req.category is not None: + item.category = req.category + if req.title: + item.title = req.title + if req.slug: + item.slug = req.slug + if req.excerpt is not None: + item.excerpt = req.excerpt + if req.content_markdown: + item.content_markdown = req.content_markdown + if req.author_name: + item.author_name = req.author_name + if req.status: + item.status = req.status + if req.seo_title is not None: + item.seo_title = req.seo_title + if req.seo_description is not None: + item.seo_description = req.seo_description + if req.canonical_url is not None: + item.canonical_url = req.canonical_url + if req.target_keyword is not None: + item.target_keyword = req.target_keyword + if req.search_intent is not None: + item.search_intent = req.search_intent + if req.start_date is not None: + item.start_date = req.start_date + if req.expiry_date is not None: + item.expiry_date = req.expiry_date + if req.reading_time_minutes is not None: + item.reading_time_minutes = req.reading_time_minutes + if req.content_source: + item.content_source = req.content_source + if req.author_id is not None: + item.author_id = req.author_id + if req.primary_topic is not None: + item.primary_topic = req.primary_topic + if req.cluster_id is not None: + item.cluster_id = _parse_uuid_field(req.cluster_id, "cluster_id") + if req.hub_page_id is not None: + item.hub_page_id = _parse_uuid_field(req.hub_page_id, "hub_page_id") + if req.editorial_notes is not None: + item.editorial_notes = req.editorial_notes + if req.ai_generated is not None: + item.ai_generated = req.ai_generated + if req.ai_prompt_version is not None: + item.ai_prompt_version = req.ai_prompt_version + if req.last_edited_by is not None: + item.last_edited_by = req.last_edited_by + + item.last_workflow_action_by = req.last_edited_by or item.last_workflow_action_by + item.last_workflow_action_at = now + item.updated_at = now + + session.add(item) + await session.commit() + await session.refresh(item) + + logger.info("Updated content item: %s/%s (id=%s)", item.content_type, item.slug, item.id) + return _to_admin_schema(item) + + +@router.post("/admin/content/{item_id}/publish", tags=["admin"]) +async def publish_content_item( + item_id: str, + req: PublishContentItemRequest, + session: Annotated[AsyncSession, Depends(get_session)], + user: Annotated[dict, Depends(require_admin)], +) -> ContentItemAdminSchema: + """Publish a content item.""" + item_uuid = _parse_item_id(item_id) + item = await _get_by_id(session, item_uuid) + if item is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_NOT_FOUND) + + now = datetime.now(timezone.utc) + now = datetime.now(timezone.utc) + item.status = "Published" + item.published_at = item.published_at or now + item.published_by = req.published_by + item.last_workflow_action_by = req.published_by or (user.get("sub") or user.get("email")) + item.last_workflow_action_at = now + + session.add(item) + await session.commit() + await session.refresh(item) + + logger.info("Published content item: %s/%s (id=%s)", item.content_type, item.slug, item.id) + return _to_admin_schema(item) + + +@router.post("/admin/content/{item_id}/archive", tags=["admin"]) +async def archive_content_item( + item_id: str, + session: Annotated[AsyncSession, Depends(get_session)], + user: Annotated[dict, Depends(require_admin)], +) -> ContentItemAdminSchema: + """Archive a content item.""" + item_uuid = _parse_item_id(item_id) + item = await _get_by_id(session, item_uuid) + if item is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_NOT_FOUND) + + actor = user.get("sub") or user.get("email") + now = datetime.now(timezone.utc) + item.status = "Archived" + item.last_workflow_action_by = actor + item.last_workflow_action_at = now + item.updated_at = now + + session.add(item) + await session.commit() + await session.refresh(item) + + logger.info("Archived content item: %s/%s (id=%s)", item.content_type, item.slug, item.id) + return _to_admin_schema(item) diff --git a/services/cms-service/tests/test_content.py b/services/cms-service/tests/test_content.py new file mode 100644 index 00000000..cda0e66c --- /dev/null +++ b/services/cms-service/tests/test_content.py @@ -0,0 +1,403 @@ +"""Tests for the unified content platform API routes.""" +import pytest +import uuid +from datetime import datetime, timezone, timedelta +from unittest.mock import MagicMock +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.db_models import ContentItem +from app.routers.content import _is_visible, _parse_item_id, _to_summary, _to_schema +from fastapi import HTTPException + + +# ── Helper ─────────────────────────────────────────────────────────────────── + +def _make_item(**kwargs) -> ContentItem: + """Create a minimal ContentItem for testing (not persisted).""" + now = datetime.now(timezone.utc) + defaults = { + "id": uuid.uuid4(), + "content_type": "Blog", + "title": "Test Item", + "slug": "test-item", + "content_markdown": "# Test", + "author_name": "Test Author", + "status": "Published", + "content_source": "Manual", + "ai_generated": False, + "created_at": now, + "updated_at": now, + } + defaults.update(kwargs) + item = MagicMock(spec=ContentItem) + for k, v in defaults.items(): + setattr(item, k, v) + return item + + +# ── Unit tests ──────────────────────────────────────────────────────────────── + +class TestParseItemId: + def test_valid_uuid(self): + valid = "550e8400-e29b-41d4-a716-446655440000" + result = _parse_item_id(valid) + assert str(result) == valid + + def test_invalid_uuid_raises_400(self): + with pytest.raises(HTTPException) as exc: + _parse_item_id("not-a-uuid") + assert exc.value.status_code == 400 + + def test_empty_raises_400(self): + with pytest.raises(HTTPException): + _parse_item_id("") + + +class TestIsVisible: + def test_published_no_dates(self): + item = _make_item(status="Published", start_date=None, expiry_date=None) + assert _is_visible(item) is True + + def test_draft_not_visible(self): + item = _make_item(status="Draft", start_date=None, expiry_date=None) + assert _is_visible(item) is False + + def test_archived_not_visible(self): + item = _make_item(status="Archived", start_date=None, expiry_date=None) + assert _is_visible(item) is False + + def test_review_not_visible(self): + item = _make_item(status="Review", start_date=None, expiry_date=None) + assert _is_visible(item) is False + + def test_future_start_date_not_visible(self): + item = _make_item( + status="Published", + start_date=datetime.now(timezone.utc) + timedelta(days=1), + expiry_date=None, + ) + assert _is_visible(item) is False + + def test_past_expiry_not_visible(self): + item = _make_item( + status="Published", + start_date=None, + expiry_date=datetime.now(timezone.utc) - timedelta(days=1), + ) + assert _is_visible(item) is False + + def test_within_date_range(self): + now = datetime.now(timezone.utc) + item = _make_item( + status="Published", + start_date=now - timedelta(days=1), + expiry_date=now + timedelta(days=1), + ) + assert _is_visible(item) is True + + +class TestToSummary: + def test_builds_summary(self): + now = datetime.now(timezone.utc) + item = _make_item( + id=uuid.UUID("550e8400-e29b-41d4-a716-446655440000"), + content_type="Guide", + category="CV Tips", + title="How to write a CV", + slug="how-to-write-a-cv", + excerpt="Short intro", + author_name="Curvit", + status="Published", + target_keyword="CV writing", + published_at=now, + start_date=None, + expiry_date=None, + reading_time_minutes=5, + created_at=now, + updated_at=now, + ) + result = _to_summary(item) + assert result.id == "550e8400-e29b-41d4-a716-446655440000" + assert result.content_type == "Guide" + assert result.category == "CV Tips" + assert result.title == "How to write a CV" + assert result.slug == "how-to-write-a-cv" + assert result.reading_time_minutes == 5 + + +class TestToSchema: + def test_builds_public_schema(self): + now = datetime.now(timezone.utc) + item = _make_item( + content_type="Article", + slug="my-article", + category=None, + excerpt=None, + seo_title="SEO Title", + seo_description="SEO Desc", + canonical_url="https://example.com/articles/my-article", + content_source="Manual", + cluster_id=None, + hub_page_id=None, + primary_topic="Career", + published_at=now, + start_date=None, + expiry_date=None, + reading_time_minutes=3, + search_intent="Informational", + target_keyword="article writing", + ) + result = _to_schema(item) + assert result.content_type == "Article" + assert result.seo_title == "SEO Title" + assert result.canonical_url == "https://example.com/articles/my-article" + + +# ── Integration tests (using in-memory SQLite via conftest) ─────────────────── + +@pytest.mark.asyncio +class TestContentPublicRoutes: + """Tests for public content endpoints.""" + + async def _create_item(self, session: AsyncSession, **kwargs) -> ContentItem: + now = datetime.now(timezone.utc) + defaults = { + "id": uuid.uuid4(), + "content_type": "Guide", + "title": "Test Guide", + "slug": "test-guide", + "content_markdown": "# Guide", + "author_name": "Author", + "status": "Published", + "content_source": "Manual", + "ai_generated": False, + "created_at": now, + "updated_at": now, + } + defaults.update(kwargs) + item = ContentItem(**defaults) + session.add(item) + await session.commit() + await session.refresh(item) + return item + + async def test_list_guides_returns_published(self, client, test_db): + """GET /guides returns only published items.""" + async with test_db() as session: + await self._create_item(session, slug="published-guide", status="Published") + await self._create_item(session, slug="draft-guide", status="Draft") + + response = await client.get("/guides") + assert response.status_code == 200 + data = response.json() + slugs = [i["slug"] for i in data["items"]] + assert "published-guide" in slugs + assert "draft-guide" not in slugs + + async def test_list_guides_excludes_draft_and_archived(self, client, test_db): + """GET /guides excludes Draft, Review and Archived items.""" + async with test_db() as session: + await self._create_item(session, slug="pub", status="Published") + await self._create_item(session, slug="drft", status="Draft") + await self._create_item(session, slug="rev", status="Review") + await self._create_item(session, slug="arch", status="Archived") + + response = await client.get("/guides") + assert response.status_code == 200 + slugs = [i["slug"] for i in response.json()["items"]] + assert slugs == ["pub"] + + async def test_get_guide_by_slug_published(self, client, test_db): + """GET /guides/{slug} returns published guide.""" + async with test_db() as session: + await self._create_item(session, slug="my-guide", status="Published") + + response = await client.get("/guides/my-guide") + assert response.status_code == 200 + assert response.json()["slug"] == "my-guide" + assert response.json()["content_type"] == "Guide" + + async def test_get_guide_draft_returns_404(self, client, test_db): + """GET /guides/{slug} returns 404 for draft.""" + async with test_db() as session: + await self._create_item(session, slug="hidden-guide", status="Draft") + + response = await client.get("/guides/hidden-guide") + assert response.status_code == 404 + + async def test_get_guide_not_found_returns_404(self, client): + """GET /guides/{slug} returns 404 for nonexistent slug.""" + response = await client.get("/guides/no-such-guide") + assert response.status_code == 404 + + async def test_list_articles_returns_published(self, client, test_db): + """GET /articles returns only published articles.""" + async with test_db() as session: + await self._create_item( + session, content_type="Article", slug="my-article", status="Published" + ) + + response = await client.get("/articles") + assert response.status_code == 200 + assert any(i["slug"] == "my-article" for i in response.json()["items"]) + + async def test_list_cv_for_returns_published(self, client, test_db): + """GET /cv-for returns only published CV-For items.""" + async with test_db() as session: + await self._create_item( + session, content_type="CvFor", slug="cv-for-pm", status="Published" + ) + + response = await client.get("/cv-for") + assert response.status_code == 200 + assert any(i["slug"] == "cv-for-pm" for i in response.json()["items"]) + + async def test_content_type_route_guide(self, client, test_db): + """GET /content/guides/{slug} returns published guide.""" + async with test_db() as session: + await self._create_item(session, slug="via-generic", status="Published") + + response = await client.get("/content/guides/via-generic") + assert response.status_code == 200 + assert response.json()["slug"] == "via-generic" + + async def test_content_invalid_type_returns_404(self, client): + """GET /content/unknown/slug returns 404.""" + response = await client.get("/content/unknown/slug") + assert response.status_code == 404 + + async def test_start_date_future_not_visible(self, client, test_db): + """Item with future start_date is not visible publicly.""" + async with test_db() as session: + await self._create_item( + session, + slug="future-guide", + status="Published", + start_date=datetime.now(timezone.utc) + timedelta(days=7), + ) + + response = await client.get("/guides/future-guide") + assert response.status_code == 404 + + async def test_expiry_date_past_not_visible(self, client, test_db): + """Item with past expiry_date is not visible publicly.""" + async with test_db() as session: + await self._create_item( + session, + slug="expired-guide", + status="Published", + expiry_date=datetime.now(timezone.utc) - timedelta(days=1), + ) + + response = await client.get("/guides/expired-guide") + assert response.status_code == 404 + + async def test_list_with_category_filter(self, client, test_db): + """GET /guides?category=X returns only items in that category.""" + async with test_db() as session: + await self._create_item( + session, slug="guide-a", status="Published", category="CV Tips" + ) + await self._create_item( + session, slug="guide-b", status="Published", category="Interview" + ) + + response = await client.get("/guides?category=CV Tips") + assert response.status_code == 200 + slugs = [i["slug"] for i in response.json()["items"]] + assert "guide-a" in slugs + assert "guide-b" not in slugs + + async def test_list_pagination(self, client, test_db): + """List endpoints respect page/page_size params.""" + async with test_db() as session: + for i in range(5): + await self._create_item(session, slug=f"guide-{i}", status="Published") + + response = await client.get("/guides?page=1&page_size=2") + assert response.status_code == 200 + data = response.json() + assert len(data["items"]) == 2 + assert data["page"] == 1 + assert data["page_size"] == 2 + assert data["total"] >= 5 + + +@pytest.mark.asyncio +class TestAdminContentRoutes: + """Tests for admin content endpoints (auth guards).""" + + async def test_admin_list_requires_auth(self, client): + """GET /admin/content requires authentication.""" + response = await client.get("/admin/content") + assert response.status_code == 401 + + async def test_admin_get_requires_auth(self, client): + """GET /admin/content/{id} requires authentication.""" + response = await client.get(f"/admin/content/{uuid.uuid4()}") + assert response.status_code == 401 + + async def test_admin_create_requires_auth(self, client): + """POST /admin/content requires authentication.""" + response = await client.post("/admin/content", json={}) + assert response.status_code == 401 + + async def test_admin_update_requires_auth(self, client): + """PUT /admin/content/{id} requires authentication.""" + response = await client.put(f"/admin/content/{uuid.uuid4()}", json={}) + assert response.status_code == 401 + + async def test_admin_publish_requires_auth(self, client): + """POST /admin/content/{id}/publish requires authentication.""" + response = await client.post(f"/admin/content/{uuid.uuid4()}/publish", json={}) + assert response.status_code == 401 + + async def test_admin_archive_requires_auth(self, client): + """POST /admin/content/{id}/archive requires authentication.""" + response = await client.post(f"/admin/content/{uuid.uuid4()}/archive") + assert response.status_code == 401 + + +@pytest.mark.asyncio +class TestSlugUniquenessPerType: + """Slug must be unique within a content type but can repeat across types.""" + + async def _create_item(self, session: AsyncSession, **kwargs) -> ContentItem: + now = datetime.now(timezone.utc) + defaults = { + "id": uuid.uuid4(), + "content_type": "Blog", + "title": "Test", + "slug": "test-slug", + "content_markdown": "# Content", + "author_name": "Author", + "status": "Published", + "content_source": "Manual", + "ai_generated": False, + "created_at": now, + "updated_at": now, + } + defaults.update(kwargs) + item = ContentItem(**defaults) + session.add(item) + await session.commit() + await session.refresh(item) + return item + + async def test_same_slug_different_types_both_visible(self, client, test_db): + """Same slug can exist for different content types.""" + async with test_db() as session: + await self._create_item( + session, content_type="Guide", slug="shared-slug", status="Published" + ) + await self._create_item( + session, content_type="Article", slug="shared-slug", status="Published" + ) + + guide_response = await client.get("/guides/shared-slug") + article_response = await client.get("/articles/shared-slug") + + assert guide_response.status_code == 200 + assert article_response.status_code == 200 + assert guide_response.json()["content_type"] == "Guide" + assert article_response.json()["content_type"] == "Article" diff --git a/services/core-api/src/Curvit.Api/Controllers/AccountSettingsController.cs b/services/core-api/src/Curvit.Api/Controllers/AccountSettingsController.cs index 41a58fe0..b25b3e8b 100644 --- a/services/core-api/src/Curvit.Api/Controllers/AccountSettingsController.cs +++ b/services/core-api/src/Curvit.Api/Controllers/AccountSettingsController.cs @@ -1,17 +1,16 @@ -using System.Security.Claims; +using System.Security.Claims; using Curvit.Api.Validation; -using Curvit.Infrastructure.Persistence; +using Curvit.Application.Interfaces; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; -using Microsoft.EntityFrameworkCore; namespace Curvit.Api.Controllers; [ApiController] [Route("api/v1/account")] [Authorize] -public sealed class AccountSettingsController(CurvitDbContext db) : ControllerBase +public sealed class AccountSettingsController(IAuthUserRepository authUserRepo) : ControllerBase { [HttpGet("settings")] [EnableRateLimiting(RateLimitingConfiguration.AccountRead)] @@ -25,14 +24,14 @@ public async Task GetSettings(CancellationToken cancellationToken if (string.IsNullOrEmpty(sub)) return Unauthorized(); - var user = await db.AuthUsers.FirstOrDefaultAsync(candidate => candidate.Id == sub, cancellationToken); - if (user is null) + var displayName = await authUserRepo.FindDisplayNameAsync(sub, cancellationToken); + if (displayName is null) return NotFound(); - var hasPassword = await db.UserPasswords.AnyAsync(candidate => candidate.UserId == sub, cancellationToken); + var hasPassword = await authUserRepo.HasPasswordAsync(sub, cancellationToken); return Ok(new { - displayName = user.Name ?? string.Empty, + displayName, hasPassword, }); } @@ -54,13 +53,10 @@ public async Task UpdateDisplayName([FromBody] UpdateDisplayNameR if (string.IsNullOrWhiteSpace(displayName) || displayName.Length > 100) return BadRequest(new { error = "Display name must be between 1 and 100 characters." }); - var user = await db.AuthUsers.FirstOrDefaultAsync(candidate => candidate.Id == sub, cancellationToken); - if (user is null) + var updated = await authUserRepo.UpdateDisplayNameAsync(sub, displayName, cancellationToken); + if (!updated) return NotFound(); - user.Name = displayName; - await db.SaveChangesAsync(cancellationToken); - return NoContent(); } @@ -80,18 +76,14 @@ public async Task UpdatePassword([FromBody] UpdatePasswordRequest if (!PasswordPolicy.IsValid(body.NewPassword)) return BadRequest(new { error = "short" }); - var userPassword = await db.UserPasswords.FirstOrDefaultAsync(candidate => candidate.UserId == sub, cancellationToken); - if (userPassword is null) + var hasPassword = await authUserRepo.HasPasswordAsync(sub, cancellationToken); + if (!hasPassword) return BadRequest(new { error = "no-password" }); - var valid = BCrypt.Net.BCrypt.Verify(body.CurrentPassword, userPassword.Hash); - if (!valid) + var updated = await authUserRepo.VerifyAndUpdatePasswordAsync(sub, body.CurrentPassword, body.NewPassword, cancellationToken); + if (!updated) return BadRequest(new { error = "wrong" }); - userPassword.Hash = BCrypt.Net.BCrypt.HashPassword(body.NewPassword); - userPassword.UpdatedAt = DateTimeOffset.UtcNow; - await db.SaveChangesAsync(cancellationToken); - return NoContent(); } } diff --git a/services/core-api/src/Curvit.Api/Controllers/AdminDataExportController.cs b/services/core-api/src/Curvit.Api/Controllers/AdminDataExportController.cs index a462ca82..1c28c9da 100644 --- a/services/core-api/src/Curvit.Api/Controllers/AdminDataExportController.cs +++ b/services/core-api/src/Curvit.Api/Controllers/AdminDataExportController.cs @@ -1,11 +1,10 @@ -using Curvit.Api.Auditing; +using Curvit.Api.Auditing; using Curvit.Application.Commands.ExportAccountData; -using Curvit.Infrastructure.Persistence; +using Curvit.Application.Interfaces; using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; -using Microsoft.EntityFrameworkCore; namespace Curvit.Api.Controllers; @@ -13,17 +12,14 @@ namespace Curvit.Api.Controllers; [Route("api/v1/admin/users")] [Authorize(Policy = "Administrator")] [EnableRateLimiting(RateLimitingConfiguration.StrictAdmin)] -public sealed class AdminDataExportController(IMediator mediator, CurvitDbContext db, ActivityAuditLogger activityAuditLogger) : ControllerBase +public sealed class AdminDataExportController(IMediator mediator, IUserAccountRepository userAccountRepo, ActivityAuditLogger activityAuditLogger) : ControllerBase { [HttpGet("{userId:guid}/data-export")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task ExportUserData(Guid userId, CancellationToken cancellationToken) { - var subjectId = await db.UserAccounts - .Where(account => account.Id == userId) - .Select(account => account.SubjectId) - .FirstOrDefaultAsync(cancellationToken); + var subjectId = await userAccountRepo.FindSubjectIdByIdAsync(userId, cancellationToken); if (string.IsNullOrWhiteSpace(subjectId)) return NotFound(); diff --git a/services/core-api/src/Curvit.Api/Controllers/ConfigController.cs b/services/core-api/src/Curvit.Api/Controllers/ConfigController.cs index b0cfd3bd..35a8cbb4 100644 --- a/services/core-api/src/Curvit.Api/Controllers/ConfigController.cs +++ b/services/core-api/src/Curvit.Api/Controllers/ConfigController.cs @@ -1,10 +1,9 @@ -using Curvit.Domain.Entities; +using Curvit.Domain.Entities; using Curvit.Api.Auditing; -using Curvit.Infrastructure.Persistence; +using Curvit.Application.Interfaces; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; -using Microsoft.EntityFrameworkCore; using System.Text.Json; using System.Text.Json.Nodes; @@ -17,20 +16,20 @@ namespace Curvit.Api.Controllers; [ApiController] [Route("api/v1/config")] [EnableRateLimiting(RateLimitingConfiguration.GeneralApi)] -public sealed class ConfigController(CurvitDbContext db, ActivityAuditLogger activityAuditLogger) : ControllerBase +public sealed class ConfigController(IAppConfigRepository appConfigRepo, ActivityAuditLogger activityAuditLogger) : ControllerBase { private const string PollingIntervalKey = "statusPollingIntervalMs"; private const int DefaultPollingIntervalMs = 3000; private const int MinPollingIntervalMs = 1000; private const int MaxPollingIntervalMs = 15000; - // ── GET /api/v1/config/feature-flags ───────────────────────────────────── + // ── GET /api/v1/config/feature-flags ───────────────────────────────────── [HttpGet("feature-flags")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task GetFeatureFlags(CancellationToken cancellationToken) { - var config = await db.AppConfigs.FirstOrDefaultAsync(cancellationToken) + var config = await appConfigRepo.FindAsync(cancellationToken) ?? AppConfig.Defaults(); var raw = config.FeatureFlagsJson ?? "{}"; @@ -43,15 +42,10 @@ public async Task GetFeatureFlags(CancellationToken cancellationT [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task UpdateFeatureFlags([FromBody] JsonElement flags, CancellationToken cancellationToken) { - var config = await db.AppConfigs.FirstOrDefaultAsync(cancellationToken); - if (config is null) - { - config = AppConfig.Defaults(); - db.AppConfigs.Add(config); - } + var config = await appConfigRepo.GetOrCreateAsync(cancellationToken); config.UpdateFeatureFlags(flags.GetRawText()); - await db.SaveChangesAsync(cancellationToken); + await appConfigRepo.SaveChangesAsync(cancellationToken); await activityAuditLogger.LogHttpActivityAsync( HttpContext, @@ -66,13 +60,13 @@ await activityAuditLogger.LogHttpActivityAsync( return NoContent(); } - // ── GET /api/v1/config/plan-config ──────────────────────────────────────── + // ── GET /api/v1/config/plan-config ──────────────────────────────────────── [HttpGet("plan-config")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task GetPlanConfig(CancellationToken cancellationToken) { - var config = await db.AppConfigs.FirstOrDefaultAsync(cancellationToken) + var config = await appConfigRepo.FindAsync(cancellationToken) ?? AppConfig.Defaults(); var raw = config.PlanConfigJson ?? "{}"; @@ -85,15 +79,10 @@ public async Task GetPlanConfig(CancellationToken cancellationTok [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task UpdatePlanConfig([FromBody] JsonElement planConfig, CancellationToken cancellationToken) { - var config = await db.AppConfigs.FirstOrDefaultAsync(cancellationToken); - if (config is null) - { - config = AppConfig.Defaults(); - db.AppConfigs.Add(config); - } + var config = await appConfigRepo.GetOrCreateAsync(cancellationToken); config.UpdatePlanConfig(planConfig.GetRawText()); - await db.SaveChangesAsync(cancellationToken); + await appConfigRepo.SaveChangesAsync(cancellationToken); await activityAuditLogger.LogHttpActivityAsync( HttpContext, @@ -108,13 +97,13 @@ await activityAuditLogger.LogHttpActivityAsync( return NoContent(); } - // ── GET/PUT /api/v1/config/polling-settings ────────────────────────────── + // ── GET/PUT /api/v1/config/polling-settings ─────────────────────────────── [HttpGet("polling-settings")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task GetPollingSettings(CancellationToken cancellationToken) { - var config = await db.AppConfigs.FirstOrDefaultAsync(cancellationToken) + var config = await appConfigRepo.FindAsync(cancellationToken) ?? AppConfig.Defaults(); var settings = ParseJsonObject(config.FeatureFlagsJson); @@ -138,18 +127,13 @@ public sealed class PollingSettingsRequest [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task UpdatePollingSettings([FromBody] PollingSettingsRequest req, CancellationToken cancellationToken) { - var config = await db.AppConfigs.FirstOrDefaultAsync(cancellationToken); - if (config is null) - { - config = AppConfig.Defaults(); - db.AppConfigs.Add(config); - } + var config = await appConfigRepo.GetOrCreateAsync(cancellationToken); var settings = ParseJsonObject(config.FeatureFlagsJson); settings[PollingIntervalKey] = ClampPollingInterval(req.StatusPollingIntervalMs); config.UpdateFeatureFlags(settings.ToJsonString()); - await db.SaveChangesAsync(cancellationToken); + await appConfigRepo.SaveChangesAsync(cancellationToken); await activityAuditLogger.LogHttpActivityAsync( HttpContext, @@ -184,5 +168,3 @@ private static JsonObject ParseJsonObject(string? raw) private static int ClampPollingInterval(int value) => Math.Clamp(value, MinPollingIntervalMs, MaxPollingIntervalMs); } - - diff --git a/services/core-api/src/Curvit.Api/Controllers/InternalAdminController.cs b/services/core-api/src/Curvit.Api/Controllers/InternalAdminController.cs new file mode 100644 index 00000000..3031ea3a --- /dev/null +++ b/services/core-api/src/Curvit.Api/Controllers/InternalAdminController.cs @@ -0,0 +1,405 @@ +using Curvit.Application.Interfaces; +using Curvit.Domain.Entities; +using Curvit.Domain.ValueObjects; +using Curvit.Infrastructure.Persistence; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.EntityFrameworkCore; + +namespace Curvit.Api.Controllers; + +/// +/// Internal endpoints consumed exclusively by the admin-service. +/// Protected by InternalApiKeyMiddleware (all /internal/* routes). +/// Direct database access is intentional here — this controller is the +/// infrastructure-layer adapter that owns core data on behalf of admin-service. +/// +[ApiController] +[Route("internal/admin")] +[EnableRateLimiting(RateLimitingConfiguration.InternalWorker)] +public sealed class InternalAdminController( + IUserAccountRepository accountRepo, + CurvitDbContext db) : ControllerBase +{ + // ── Account mutations ───────────────────────────────────────────────────── + + [HttpPatch("accounts/{accountId:guid}/plan")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ChangePlan( + Guid accountId, + [FromBody] ChangePlanRequest req, + CancellationToken cancellationToken) + { + var account = await accountRepo.FindByIdAsync(accountId, cancellationToken); + if (account is null) + return NotFound(); + + var previousTier = account.PlanTier; + account.ChangePlan(req.Tier); + await accountRepo.SaveChangesAsync(cancellationToken); + + db.ActivityAuditLogs.Add(ActivityAuditLog.Create(new ActivityAuditLogCreateModel( + ServiceName: "admin-service", + ActivityType: req.ActivityType, + ActorType: "admin", + DetailsJson: System.Text.Json.JsonSerializer.Serialize(new + { + userId = accountId, + previousTier = previousTier.ToString(), + newTier = req.Tier.ToString(), + }), + UserAccountId: accountId, + ReferenceId: accountId, + ActorSubjectId: req.ActorSubjectId, + ActorEmail: req.ActorEmail, + RequestPath: req.RequestPath, + RequestMethod: req.RequestMethod, + CorrelationId: req.CorrelationId))); + await db.SaveChangesAsync(cancellationToken); + + return NoContent(); + } + + [HttpDelete("accounts/{accountId:guid}/subscription")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ClearSubscription( + Guid accountId, + CancellationToken cancellationToken) + { + var account = await accountRepo.FindByIdAsync(accountId, cancellationToken); + if (account is null) + return NotFound(); + + account.ClearSubscription(); + await accountRepo.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + [HttpPatch("accounts/{accountId:guid}/restriction")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetRestriction( + Guid accountId, + [FromBody] SetRestrictionRequest req, + CancellationToken cancellationToken) + { + var account = await accountRepo.FindByIdAsync(accountId, cancellationToken); + if (account is null) + return NotFound(); + + account.SetRestriction(req.IsRestricted); + await accountRepo.SaveChangesAsync(cancellationToken); + + db.ActivityAuditLogs.Add(ActivityAuditLog.Create(new ActivityAuditLogCreateModel( + ServiceName: "admin-service", + ActivityType: req.ActivityType, + ActorType: "admin", + DetailsJson: System.Text.Json.JsonSerializer.Serialize(new + { + userId = accountId, + isRestricted = req.IsRestricted, + }), + UserAccountId: accountId, + ReferenceId: accountId, + ActorSubjectId: req.ActorSubjectId, + ActorEmail: req.ActorEmail, + RequestPath: req.RequestPath, + RequestMethod: req.RequestMethod, + CorrelationId: req.CorrelationId))); + await db.SaveChangesAsync(cancellationToken); + + return NoContent(); + } + + // ── DSAR ────────────────────────────────────────────────────────────────── + + [HttpPost("dsar")] + [ProducesResponseType(StatusCodes.Status201Created)] + public async Task CreateDsar( + [FromBody] CreateDsarRequest req, + CancellationToken cancellationToken) + { + var dsar = DataSubjectRequest.Create(req.UserAccountId, req.RequestType, req.Notes); + db.DataSubjectRequests.Add(dsar); + + db.ActivityAuditLogs.Add(ActivityAuditLog.Create(new ActivityAuditLogCreateModel( + ServiceName: "admin-service", + ActivityType: req.ActivityType, + ActorType: "admin", + DetailsJson: System.Text.Json.JsonSerializer.Serialize(new + { + userId = req.UserAccountId, + requestType = req.RequestType.ToString(), + }), + UserAccountId: req.UserAccountId, + ReferenceId: req.UserAccountId, + ActorSubjectId: req.ActorSubjectId, + ActorEmail: req.ActorEmail, + RequestPath: req.RequestPath, + RequestMethod: req.RequestMethod, + CorrelationId: req.CorrelationId))); + + await db.SaveChangesAsync(cancellationToken); + + return Created(string.Empty, new { id = dsar.Id }); + } + + [HttpPatch("dsar/{dsarId:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdateDsar( + Guid dsarId, + [FromBody] UpdateDsarRequest req, + CancellationToken cancellationToken) + { + var dsar = await db.DataSubjectRequests.FindAsync([dsarId], cancellationToken); + if (dsar is null) + return NotFound(); + + var handledBy = req.HandledBy ?? req.ActorEmail ?? "admin"; + if (req.Status == DsarRequestStatus.InProgress) + dsar.MarkInProgress(handledBy); + else if (req.Status == DsarRequestStatus.Completed) + dsar.MarkCompleted(handledBy); + + if (req.Notes is not null) + dsar.UpdateNotes(req.Notes); + + db.ActivityAuditLogs.Add(ActivityAuditLog.Create(new ActivityAuditLogCreateModel( + ServiceName: "admin-service", + ActivityType: req.ActivityType, + ActorType: "admin", + DetailsJson: System.Text.Json.JsonSerializer.Serialize(new + { + dsarId, + status = req.Status.ToString(), + }), + UserAccountId: dsar.UserAccountId, + ReferenceId: dsarId, + ActorSubjectId: req.ActorSubjectId, + ActorEmail: req.ActorEmail, + RequestPath: req.RequestPath, + RequestMethod: req.RequestMethod, + CorrelationId: req.CorrelationId))); + + await db.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + // ── Retention settings ──────────────────────────────────────────────────── + + [HttpPut("retention-settings")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task UpdateRetentionSettings( + [FromBody] UpdateRetentionSettingsRequest req, + CancellationToken cancellationToken) + { + var settings = await db.RetentionSettings.FirstOrDefaultAsync(cancellationToken); + if (settings is null) + { + settings = RetentionSettings.Defaults(); + db.RetentionSettings.Add(settings); + } + + settings.Update(new RetentionSettingsUpdate( + req.DocumentRetentionDays, + req.AnalysisJobRetentionDays, + req.JobSpecRetentionDays, + req.ScreeningSessionRetentionDays, + req.QuotaUsageEventRetentionDays, + req.CompletedDsarRetentionDays, + req.BillingAuditRetentionDays, + req.StripeWebhookRetentionDays, + req.InactiveAccountThresholdDays, + req.ErrorLogRetentionDays)); + + db.ActivityAuditLogs.Add(ActivityAuditLog.Create(new ActivityAuditLogCreateModel( + ServiceName: "admin-service", + ActivityType: req.ActivityType, + ActorType: "admin", + DetailsJson: System.Text.Json.JsonSerializer.Serialize(req), + ActorSubjectId: req.ActorSubjectId, + ActorEmail: req.ActorEmail, + RequestPath: req.RequestPath, + RequestMethod: req.RequestMethod, + CorrelationId: req.CorrelationId))); + + await db.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + // ── Error log cleanup ───────────────────────────────────────────────────── + + [HttpDelete("error-logs")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task DeleteOldErrorLogs( + [FromQuery] int olderThanDays, + CancellationToken cancellationToken) + { + var cutoff = DateTimeOffset.UtcNow.AddDays(-olderThanDays); + var deleted = await db.ApplicationErrorLogs + .Where(e => e.OccurredAt < cutoff) + .ExecuteDeleteAsync(cancellationToken); + + return Ok(new { deleted }); + } + + // ── System settings ─────────────────────────────────────────────────────── + + [HttpPut("system-settings")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task UpdateSystemSettings( + [FromBody] UpdateSystemSettingsRequest req, + CancellationToken cancellationToken) + { + var settings = await db.SystemSettings.FirstOrDefaultAsync(cancellationToken); + if (settings is null) + { + settings = SystemSettings.Defaults(); + db.SystemSettings.Add(settings); + } + + settings.Update(req.MaxCvFileSizeMb, req.MaxZipFileSizeMb, req.MaxCandidatesPerSession, req.ScreeningConcurrency); + + db.ActivityAuditLogs.Add(ActivityAuditLog.Create(new ActivityAuditLogCreateModel( + ServiceName: "admin-service", + ActivityType: req.ActivityType, + ActorType: "admin", + DetailsJson: System.Text.Json.JsonSerializer.Serialize(new + { + maxCvFileSizeMb = req.MaxCvFileSizeMb, + maxZipFileSizeMb = req.MaxZipFileSizeMb, + maxCandidatesPerSession = req.MaxCandidatesPerSession, + screeningConcurrency = req.ScreeningConcurrency, + }), + ActorSubjectId: req.ActorSubjectId, + ActorEmail: req.ActorEmail, + RequestPath: req.RequestPath, + RequestMethod: req.RequestMethod, + CorrelationId: req.CorrelationId))); + + await db.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + // ── Audit log ───────────────────────────────────────────────────────────── + + [HttpPost("audit-logs")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task CreateAuditLog( + [FromBody] CreateAdminAuditLogRequest req, + CancellationToken cancellationToken) + { + db.ActivityAuditLogs.Add(ActivityAuditLog.Create(new ActivityAuditLogCreateModel( + ServiceName: req.ServiceName, + ActivityType: req.ActivityType, + ActorType: req.ActorType, + DetailsJson: req.DetailsJson, + UserAccountId: req.UserAccountId, + ReferenceId: req.ReferenceId, + ActorSubjectId: req.ActorSubjectId, + ActorEmail: req.ActorEmail, + RequestPath: req.RequestPath, + RequestMethod: req.RequestMethod, + CorrelationId: req.CorrelationId))); + await db.SaveChangesAsync(cancellationToken); + return NoContent(); + } +} + +// ── Shared audit context ────────────────────────────────────────────────────── + +public abstract record AdminAuditContext( + string ActivityType, + string? ActorSubjectId, + string? ActorEmail, + string? RequestPath, + string? RequestMethod, + string? CorrelationId); + +// ── Request models ──────────────────────────────────────────────────────────── + +public sealed record ChangePlanRequest( + PlanTier Tier, + string ActivityType, + string? ActorSubjectId, + string? ActorEmail, + string? RequestPath, + string? RequestMethod, + string? CorrelationId) : AdminAuditContext(ActivityType, ActorSubjectId, ActorEmail, RequestPath, RequestMethod, CorrelationId); + +public sealed record SetRestrictionRequest( + bool IsRestricted, + string ActivityType, + string? ActorSubjectId, + string? ActorEmail, + string? RequestPath, + string? RequestMethod, + string? CorrelationId) : AdminAuditContext(ActivityType, ActorSubjectId, ActorEmail, RequestPath, RequestMethod, CorrelationId); + +public sealed record CreateDsarRequest( + Guid UserAccountId, + DsarRequestType RequestType, + string? Notes, + string ActivityType, + string? ActorSubjectId, + string? ActorEmail, + string? RequestPath, + string? RequestMethod, + string? CorrelationId) : AdminAuditContext(ActivityType, ActorSubjectId, ActorEmail, RequestPath, RequestMethod, CorrelationId); + +public sealed record UpdateDsarRequest( + DsarRequestStatus Status, + string? HandledBy, + string? Notes, + string ActivityType, + string? ActorSubjectId, + string? ActorEmail, + string? RequestPath, + string? RequestMethod, + string? CorrelationId) : AdminAuditContext(ActivityType, ActorSubjectId, ActorEmail, RequestPath, RequestMethod, CorrelationId); + +public sealed record UpdateRetentionSettingsRequest( + int DocumentRetentionDays, + int AnalysisJobRetentionDays, + int JobSpecRetentionDays, + int ScreeningSessionRetentionDays, + int QuotaUsageEventRetentionDays, + int CompletedDsarRetentionDays, + int BillingAuditRetentionDays, + int StripeWebhookRetentionDays, + int InactiveAccountThresholdDays, + int ErrorLogRetentionDays, + string ActivityType, + string? ActorSubjectId, + string? ActorEmail, + string? RequestPath, + string? RequestMethod, + string? CorrelationId) : AdminAuditContext(ActivityType, ActorSubjectId, ActorEmail, RequestPath, RequestMethod, CorrelationId); + +public sealed record UpdateSystemSettingsRequest( + int MaxCvFileSizeMb, + int MaxZipFileSizeMb, + int MaxCandidatesPerSession, + int ScreeningConcurrency, + string ActivityType, + string? ActorSubjectId, + string? ActorEmail, + string? RequestPath, + string? RequestMethod, + string? CorrelationId) : AdminAuditContext(ActivityType, ActorSubjectId, ActorEmail, RequestPath, RequestMethod, CorrelationId); + +public sealed record CreateAdminAuditLogRequest( + string ServiceName, + string ActivityType, + string ActorType, + string DetailsJson, + Guid? UserAccountId, + Guid? ReferenceId, + string? ActorSubjectId, + string? ActorEmail, + string? RequestPath, + string? RequestMethod, + string? CorrelationId); diff --git a/services/core-api/src/Curvit.Api/Controllers/InternalBillingController.cs b/services/core-api/src/Curvit.Api/Controllers/InternalBillingController.cs new file mode 100644 index 00000000..fca53078 --- /dev/null +++ b/services/core-api/src/Curvit.Api/Controllers/InternalBillingController.cs @@ -0,0 +1,297 @@ +using Curvit.Application.Interfaces; +using Curvit.Domain.Entities; +using Curvit.Domain.ValueObjects; +using Curvit.Infrastructure.Persistence; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.EntityFrameworkCore; +using System.Text.Json; + +namespace Curvit.Api.Controllers; + +/// +/// Internal endpoints consumed exclusively by the billing-service. +/// Protected by InternalApiKeyMiddleware (all /internal/* routes). +/// Direct database access is intentional here — this controller is the +/// infrastructure-layer adapter that owns billing data on behalf of billing-service. +/// +[ApiController] +[Route("internal/billing")] +[EnableRateLimiting(RateLimitingConfiguration.InternalWorker)] +public sealed class InternalBillingController( + IUserAccountRepository accountRepo, + IPlanPricingRepository planPricingRepo, + CurvitDbContext db) : ControllerBase +{ + // ── Account lookups ─────────────────────────────────────────────────────── + + [HttpGet("accounts/by-subject/{subjectId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetAccountBySubjectId(string subjectId, CancellationToken cancellationToken) + { + var account = await accountRepo.FindBySubjectIdAsync(subjectId, cancellationToken); + return account is null ? NotFound() : Ok(ToAccountDto(account)); + } + + [HttpGet("accounts/by-id/{accountId:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetAccountById(Guid accountId, CancellationToken cancellationToken) + { + var account = await accountRepo.FindByIdAsync(accountId, cancellationToken); + return account is null ? NotFound() : Ok(ToAccountDto(account)); + } + + [HttpGet("accounts/by-stripe-customer/{customerId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetAccountByStripeCustomer(string customerId, CancellationToken cancellationToken) + { + var account = await accountRepo.FindByStripeCustomerIdAsync(customerId, cancellationToken); + return account is null ? NotFound() : Ok(ToAccountDto(account)); + } + + // ── Account mutations ───────────────────────────────────────────────────── + + [HttpPost("accounts/{accountId:guid}/stripe-customer")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task AssignStripeCustomer( + Guid accountId, + [FromBody] AssignStripeCustomerRequest req, + CancellationToken cancellationToken) + { + var account = await accountRepo.FindByIdAsync(accountId, cancellationToken); + if (account is null) + return NotFound(); + + account.AssignStripeCustomerId(req.CustomerId); + await accountRepo.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + [HttpPut("accounts/by-stripe-customer/{customerId}/subscription")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdateSubscription( + string customerId, + [FromBody] UpdateSubscriptionRequest req, + CancellationToken cancellationToken) + { + var account = await accountRepo.FindByStripeCustomerIdAsync(customerId, cancellationToken); + if (account is null) + return NotFound(); + + account.ApplySubscriptionUpdate(req.SubscriptionId, req.Status, req.Tier, req.CurrentPeriodEnd); + await accountRepo.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + [HttpPatch("accounts/by-stripe-customer/{customerId}/subscription-status")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdateSubscriptionStatus( + string customerId, + [FromBody] UpdateSubscriptionStatusRequest req, + CancellationToken cancellationToken) + { + var account = await accountRepo.FindByStripeCustomerIdAsync(customerId, cancellationToken); + if (account is null) + return NotFound(); + + account.UpdateSubscriptionStatus(req.Status, req.SubscriptionId, req.CurrentPeriodEnd); + await accountRepo.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + [HttpDelete("accounts/by-stripe-customer/{customerId}/subscription")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ClearSubscription(string customerId, CancellationToken cancellationToken) + { + var account = await accountRepo.FindByStripeCustomerIdAsync(customerId, cancellationToken); + if (account is null) + return NotFound(); + + account.ClearSubscription(); + await accountRepo.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + // ── Webhook idempotency ─────────────────────────────────────────────────── + + /// + /// Checks whether a Stripe event has already been processed and, if not, records it atomically. + /// Returns 204 on first insertion; 409 if already processed (caller should skip processing). + /// + [HttpPost("webhook-events")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task RecordWebhookEvent( + [FromBody] RecordWebhookEventRequest req, + CancellationToken cancellationToken) + { + var exists = await db.ProcessedStripeWebhookEvents + .AnyAsync(e => e.StripeEventId == req.StripeEventId, cancellationToken); + + if (exists) + return Conflict(); + + db.ProcessedStripeWebhookEvents.Add( + ProcessedStripeWebhookEvent.Create(req.StripeEventId, req.EventType)); + await db.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + // ── Billing audit logs ──────────────────────────────────────────────────── + + [HttpGet("audit-logs/by-user/{userId:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task GetAuditLogsByUser(Guid userId, CancellationToken cancellationToken) + { + var logs = await db.BillingAuditLogs + .Where(l => l.UserAccountId == userId) + .OrderByDescending(l => l.CreatedAt) + .Select(l => new + { + id = l.Id, + eventType = l.EventType, + actor = l.Actor, + detailsJson = l.DetailsJson, + stripeEventId = l.StripeEventId, + createdAt = l.CreatedAt, + }) + .ToListAsync(cancellationToken); + return Ok(logs); + } + + [HttpPost("audit-logs")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task CreateAuditLog( + [FromBody] CreateBillingAuditLogRequest req, + CancellationToken cancellationToken) + { + db.BillingAuditLogs.Add(BillingAuditLog.Create( + req.EventType, + req.Actor, + req.DetailsJson, + req.UserAccountId, + req.StripeEventId)); + await db.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + // ── Plan pricing ────────────────────────────────────────────────────────── + + [HttpGet("plan-pricing")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task GetAllPlanPricing(CancellationToken cancellationToken) + { + var plans = await planPricingRepo.GetAllAsync(cancellationToken); + return Ok(plans.Select(ToPlanPricingDto)); + } + + [HttpGet("plan-pricing/by-tier/{tier}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetPlanPricingByTier(PlanTier tier, CancellationToken cancellationToken) + { + var plan = await planPricingRepo.FindByTierAsync(tier, cancellationToken); + return plan is null ? NotFound() : Ok(ToPlanPricingDto(plan)); + } + + [HttpGet("plan-pricing/by-price-id/{priceId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetPlanPricingByPriceId(string priceId, CancellationToken cancellationToken) + { + var plan = await planPricingRepo.FindByPriceIdAsync(priceId, cancellationToken); + return plan is null ? NotFound() : Ok(ToPlanPricingDto(plan)); + } + + [HttpPut("plan-pricing/{tier}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdatePlanPricing( + PlanTier tier, + [FromBody] UpdatePlanPricingRequest req, + CancellationToken cancellationToken) + { + var plan = await planPricingRepo.FindByTierAsync(tier, cancellationToken); + if (plan is null) + return NotFound(); + + plan.Update(req.StripePriceId, req.StripeSalePriceId, req.SaleStart, req.SaleEnd); + await planPricingRepo.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + // ── Projection helpers ──────────────────────────────────────────────────── + + private static object ToAccountDto(UserAccount a) => new + { + id = a.Id, + subjectId = a.SubjectId, + email = a.Email, + planTier = a.PlanTier.ToString(), + stripeCustomerId = a.StripeCustomerId, + stripeSubscriptionId = a.StripeSubscriptionId, + stripeSubscriptionStatus = a.StripeSubscriptionStatus, + subscriptionCurrentPeriodEnd = a.SubscriptionCurrentPeriodEnd, + isRestricted = a.IsRestricted, + }; + + private static object ToPlanPricingDto(PlanPricingSettings p) => new + { + id = p.Id, + tier = p.Tier.ToString(), + name = p.Name, + description = p.Description, + priceMonthlyCents = p.PriceMonthlyCents, + priceAnnualCents = p.PriceAnnualCents, + stripePriceId = p.StripePriceId, + stripeSalePriceId = p.StripeSalePriceId, + stripePriceIdMonthly = p.StripePriceIdMonthly, + stripePriceIdAnnual = p.StripePriceIdAnnual, + saleStart = p.SaleStart, + saleEnd = p.SaleEnd, + activePriceId = p.GetActivePriceId(), + isSaleActive = p.IsSaleActive(), + maxDocumentsPerMonth = p.MaxDocumentsPerMonth, + maxAnalysesPerMonth = p.MaxAnalysesPerMonth, + maxScreeningBatchSize = p.MaxScreeningBatchSize, + canExportData = p.CanExportData, + canBulkScreen = p.CanBulkScreen, + }; +} + +// ── Request models ──────────────────────────────────────────────────────────── + +public sealed record AssignStripeCustomerRequest(string CustomerId); + +public sealed record UpdateSubscriptionRequest( + string SubscriptionId, + string Status, + PlanTier Tier, + DateTimeOffset? CurrentPeriodEnd); + +public sealed record UpdateSubscriptionStatusRequest( + string Status, + string? SubscriptionId = null, + DateTimeOffset? CurrentPeriodEnd = null); + +public sealed record RecordWebhookEventRequest(string StripeEventId, string EventType); + +public sealed record CreateBillingAuditLogRequest( + string EventType, + string Actor, + string DetailsJson, + Guid? UserAccountId = null, + string? StripeEventId = null); + +public sealed record UpdatePlanPricingRequest( + string StripePriceId, + string? StripeSalePriceId, + DateOnly? SaleStart, + DateOnly? SaleEnd); diff --git a/services/core-api/src/Curvit.Api/Controllers/ScreeningController.cs b/services/core-api/src/Curvit.Api/Controllers/ScreeningController.cs index 89a0f635..88cd2f23 100644 --- a/services/core-api/src/Curvit.Api/Controllers/ScreeningController.cs +++ b/services/core-api/src/Curvit.Api/Controllers/ScreeningController.cs @@ -7,12 +7,10 @@ using Curvit.Application.Queries.GetScreeningSession; using Curvit.Application.Queries.ListScreeningSessions; using Curvit.Domain.Entities; -using Curvit.Infrastructure.Persistence; using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; -using Microsoft.EntityFrameworkCore; namespace Curvit.Api.Controllers; @@ -20,7 +18,7 @@ namespace Curvit.Api.Controllers; [Route("api/v1/screening-sessions")] [Authorize] [EnableRateLimiting(RateLimitingConfiguration.FileUpload)] -public sealed class ScreeningController(IMediator mediator, CurvitDbContext db, ActivityAuditLogger activityAuditLogger) : ControllerBase +public sealed class ScreeningController(IMediator mediator, IUserAccountRepository userAccountRepo, IDpaRepository dpaRepo, ISystemSettingsRepository systemSettingsRepo, ActivityAuditLogger activityAuditLogger) : ControllerBase { private const string CurrentDpaVersion = "1.0"; @@ -184,13 +182,12 @@ public async Task AcceptDpa(CancellationToken cancellationToken) if (user is null) return Unauthorized(); - var existing = await db.DpaAcceptances - .AnyAsync(d => d.UserAccountId == user.Id && d.DpaVersion == CurrentDpaVersion, cancellationToken); + var existing = await dpaRepo.HasVersionAsync(user.Id, CurrentDpaVersion, cancellationToken); if (!existing) { - db.DpaAcceptances.Add(DpaAcceptance.Create(user.Id, CurrentDpaVersion)); - await db.SaveChangesAsync(cancellationToken); + await dpaRepo.AddAsync(DpaAcceptance.Create(user.Id, CurrentDpaVersion), cancellationToken); + await dpaRepo.SaveChangesAsync(cancellationToken); } return NoContent(); @@ -201,11 +198,11 @@ public async Task AcceptDpa(CancellationToken cancellationToken) ?? User.FindFirstValue(ClaimTypes.NameIdentifier); private Task GetCurrentUserAsync(string subjectId, CancellationToken cancellationToken) => - db.UserAccounts.FirstOrDefaultAsync(user => user.SubjectId == subjectId, cancellationToken); + userAccountRepo.FindBySubjectIdAsync(subjectId, cancellationToken); private async Task ValidateDpaAcceptedAsync(Guid userId, CancellationToken cancellationToken) { - var hasDpa = await db.DpaAcceptances.AnyAsync(dpa => dpa.UserAccountId == userId, cancellationToken); + var hasDpa = await dpaRepo.HasAnyAsync(userId, cancellationToken); if (hasDpa) return null; @@ -227,8 +224,8 @@ public async Task AcceptDpa(CancellationToken cancellationToken) }); } - private async Task GetSystemSettingsAsync(CancellationToken cancellationToken) => - await db.SystemSettings.FirstOrDefaultAsync(cancellationToken) ?? SystemSettings.Defaults(); + private Task GetSystemSettingsAsync(CancellationToken cancellationToken) => + systemSettingsRepo.GetOrDefaultAsync(cancellationToken); private async Task BuildCandidateFilesAsync( IFormFileCollection candidateCvFiles, @@ -350,11 +347,7 @@ private static bool IsZipUpload(IFormFile file) => private static long GetBytesFromMb(int megabytes) => (long)megabytes * 1024 * 1024; private Task GetLatestDpaAcceptanceAsync(Guid userId, CancellationToken cancellationToken) => - db.DpaAcceptances - .Where(d => d.UserAccountId == userId) - .OrderByDescending(d => d.AcceptedAt) - .Select(d => new DpaAcceptanceStatus(d.DpaVersion, d.AcceptedAt)) - .FirstOrDefaultAsync(cancellationToken); + dpaRepo.GetLatestAsync(userId, cancellationToken); private static object ToSessionSummary(ScreeningSessionDto dto) => new { @@ -440,7 +433,7 @@ private static bool IsZipUpload(IFormFile file) => } private sealed record CandidateFileCollectionResult(List Files, IActionResult? Error); - private sealed record DpaAcceptanceStatus(string DpaVersion, DateTimeOffset AcceptedAt); + } diff --git a/services/core-api/src/Curvit.Api/Program.cs b/services/core-api/src/Curvit.Api/Program.cs index afac7248..416526d8 100644 --- a/services/core-api/src/Curvit.Api/Program.cs +++ b/services/core-api/src/Curvit.Api/Program.cs @@ -96,6 +96,11 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/services/core-api/src/Curvit.Application/Interfaces/IAppConfigRepository.cs b/services/core-api/src/Curvit.Application/Interfaces/IAppConfigRepository.cs new file mode 100644 index 00000000..123d2e58 --- /dev/null +++ b/services/core-api/src/Curvit.Application/Interfaces/IAppConfigRepository.cs @@ -0,0 +1,12 @@ +using Curvit.Domain.Entities; + +namespace Curvit.Application.Interfaces; + +public interface IAppConfigRepository +{ + /// Returns the persisted config row, or null if no row exists yet. Use for read-only paths. + Task FindAsync(CancellationToken cancellationToken = default); + /// Returns the persisted config row, creating a new tracked default row if absent. Use for write paths. + Task GetOrCreateAsync(CancellationToken cancellationToken = default); + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/services/core-api/src/Curvit.Application/Interfaces/IAuthUserRepository.cs b/services/core-api/src/Curvit.Application/Interfaces/IAuthUserRepository.cs new file mode 100644 index 00000000..1e3c9334 --- /dev/null +++ b/services/core-api/src/Curvit.Application/Interfaces/IAuthUserRepository.cs @@ -0,0 +1,9 @@ +namespace Curvit.Application.Interfaces; + +public interface IAuthUserRepository +{ + Task FindDisplayNameAsync(string userId, CancellationToken cancellationToken = default); + Task HasPasswordAsync(string userId, CancellationToken cancellationToken = default); + Task UpdateDisplayNameAsync(string userId, string displayName, CancellationToken cancellationToken = default); + Task VerifyAndUpdatePasswordAsync(string userId, string currentPassword, string newPassword, CancellationToken cancellationToken = default); +} diff --git a/services/core-api/src/Curvit.Application/Interfaces/IDpaRepository.cs b/services/core-api/src/Curvit.Application/Interfaces/IDpaRepository.cs new file mode 100644 index 00000000..cb5e1abd --- /dev/null +++ b/services/core-api/src/Curvit.Application/Interfaces/IDpaRepository.cs @@ -0,0 +1,14 @@ +using Curvit.Domain.Entities; + +namespace Curvit.Application.Interfaces; + +public interface IDpaRepository +{ + Task HasVersionAsync(Guid userAccountId, string version, CancellationToken cancellationToken = default); + Task HasAnyAsync(Guid userAccountId, CancellationToken cancellationToken = default); + Task GetLatestAsync(Guid userAccountId, CancellationToken cancellationToken = default); + Task AddAsync(DpaAcceptance acceptance, CancellationToken cancellationToken = default); + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} + +public sealed record DpaAcceptanceStatus(string DpaVersion, DateTimeOffset AcceptedAt); diff --git a/services/core-api/src/Curvit.Application/Interfaces/ISystemSettingsRepository.cs b/services/core-api/src/Curvit.Application/Interfaces/ISystemSettingsRepository.cs new file mode 100644 index 00000000..568e3c01 --- /dev/null +++ b/services/core-api/src/Curvit.Application/Interfaces/ISystemSettingsRepository.cs @@ -0,0 +1,8 @@ +using Curvit.Domain.Entities; + +namespace Curvit.Application.Interfaces; + +public interface ISystemSettingsRepository +{ + Task GetOrDefaultAsync(CancellationToken cancellationToken = default); +} diff --git a/services/core-api/src/Curvit.Application/Interfaces/IUserAccountRepository.cs b/services/core-api/src/Curvit.Application/Interfaces/IUserAccountRepository.cs index bc9d4b93..05d53099 100644 --- a/services/core-api/src/Curvit.Application/Interfaces/IUserAccountRepository.cs +++ b/services/core-api/src/Curvit.Application/Interfaces/IUserAccountRepository.cs @@ -7,6 +7,7 @@ public interface IUserAccountRepository Task FindBySubjectIdAsync(string subjectId, CancellationToken cancellationToken = default); Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default); Task FindByStripeCustomerIdAsync(string customerId, CancellationToken cancellationToken = default); + Task FindSubjectIdByIdAsync(Guid id, CancellationToken cancellationToken = default); Task AddAsync(UserAccount account, CancellationToken cancellationToken = default); Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/services/core-api/src/Curvit.Domain/Entities/ContentItem.cs b/services/core-api/src/Curvit.Domain/Entities/ContentItem.cs new file mode 100644 index 00000000..22c155cb --- /dev/null +++ b/services/core-api/src/Curvit.Domain/Entities/ContentItem.cs @@ -0,0 +1,61 @@ +namespace Curvit.Domain.Entities; + +/// +/// Unified content item supporting blogs, guides, articles and CV-For pages. +/// +public sealed class ContentItem +{ + public Guid Id { get; init; } + + // ── Core fields ────────────────────────────────────────────────────────── + public string ContentType { get; set; } = string.Empty; + public string? Category { get; set; } + public string Title { get; set; } = string.Empty; + public string Slug { get; set; } = string.Empty; + public string? Excerpt { get; set; } + public string ContentMarkdown { get; set; } = string.Empty; + public string AuthorName { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + + // ── SEO fields ─────────────────────────────────────────────────────────── + public string? SeoTitle { get; set; } + public string? SeoDescription { get; set; } + public string? CanonicalUrl { get; set; } + public string? TargetKeyword { get; set; } + public string? SearchIntent { get; set; } + + // ── Publication window ─────────────────────────────────────────────────── + public DateTimeOffset? PublishedAt { get; set; } + public DateTimeOffset? StartDate { get; set; } + public DateTimeOffset? ExpiryDate { get; set; } + public int? ReadingTimeMinutes { get; set; } + + // ── Content provenance ─────────────────────────────────────────────────── + public string ContentSource { get; set; } = "Manual"; + public string? CreatedBy { get; set; } + public string? LastEditedBy { get; set; } + public Guid? CreatedFromContentId { get; set; } + + // ── AI fields ──────────────────────────────────────────────────────────── + public bool AiGenerated { get; set; } + public string? AiPromptVersion { get; set; } + + // ── Audit / workflow ───────────────────────────────────────────────────── + public string? AuthorId { get; set; } + public string? ApprovedBy { get; set; } + public string? PublishedBy { get; set; } + public string? LastWorkflowActionBy { get; set; } + public DateTimeOffset? LastWorkflowActionAt { get; set; } + + // ── Content clusters ───────────────────────────────────────────────────── + public Guid? ClusterId { get; set; } + public Guid? HubPageId { get; set; } + public string? PrimaryTopic { get; set; } + + // ── Editorial ──────────────────────────────────────────────────────────── + public string? EditorialNotes { get; set; } + + // ── Timestamps ─────────────────────────────────────────────────────────── + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset UpdatedAt { get; set; } +} diff --git a/services/core-api/src/Curvit.Domain/Entities/UserAccount.cs b/services/core-api/src/Curvit.Domain/Entities/UserAccount.cs index ce66d07e..dca0dcf5 100644 --- a/services/core-api/src/Curvit.Domain/Entities/UserAccount.cs +++ b/services/core-api/src/Curvit.Domain/Entities/UserAccount.cs @@ -152,6 +152,20 @@ public void ClearSubscription() SubscriptionCurrentPeriodEnd = null; UpdatedAt = DateTimeOffset.UtcNow; } + + // ── Admin mutators ───────────────────────────────────────────────── + + public void ChangePlan(PlanTier tier) + { + PlanTier = tier; + UpdatedAt = DateTimeOffset.UtcNow; + } + + public void SetRestriction(bool isRestricted) + { + IsRestricted = isRestricted; + UpdatedAt = DateTimeOffset.UtcNow; + } } diff --git a/services/core-api/src/Curvit.Infrastructure/Curvit.Infrastructure.csproj b/services/core-api/src/Curvit.Infrastructure/Curvit.Infrastructure.csproj index ed4c4c5d..1247b97f 100644 --- a/services/core-api/src/Curvit.Infrastructure/Curvit.Infrastructure.csproj +++ b/services/core-api/src/Curvit.Infrastructure/Curvit.Infrastructure.csproj @@ -6,6 +6,7 @@ true + diff --git a/services/core-api/src/Curvit.Infrastructure/Migrations/20260530000000_AddContentPlatform.cs b/services/core-api/src/Curvit.Infrastructure/Migrations/20260530000000_AddContentPlatform.cs new file mode 100644 index 00000000..45c9c429 --- /dev/null +++ b/services/core-api/src/Curvit.Infrastructure/Migrations/20260530000000_AddContentPlatform.cs @@ -0,0 +1,121 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Curvit.Infrastructure.Migrations +{ + /// + public partial class AddContentPlatform : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ContentItems", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ContentType = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Category = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + Title = table.Column(type: "character varying(250)", maxLength: 250, nullable: false), + Slug = table.Column(type: "character varying(250)", maxLength: 250, nullable: false), + Excerpt = table.Column(type: "text", nullable: true), + ContentMarkdown = table.Column(type: "text", nullable: false), + AuthorName = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Draft"), + SeoTitle = table.Column(type: "character varying(250)", maxLength: 250, nullable: true), + SeoDescription = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + CanonicalUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + TargetKeyword = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + SearchIntent = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + PublishedAt = table.Column(type: "timestamp with time zone", nullable: true), + StartDate = table.Column(type: "timestamp with time zone", nullable: true), + ExpiryDate = table.Column(type: "timestamp with time zone", nullable: true), + ReadingTimeMinutes = table.Column(type: "integer", nullable: true), + ContentSource = table.Column(type: "character varying(50)", maxLength: 50, nullable: false, defaultValue: "Manual"), + CreatedBy = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + LastEditedBy = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + CreatedFromContentId = table.Column(type: "uuid", nullable: true), + AiGenerated = table.Column(type: "boolean", nullable: false, defaultValue: false), + AiPromptVersion = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + AuthorId = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + ApprovedBy = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + PublishedBy = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + LastWorkflowActionBy = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + LastWorkflowActionAt = table.Column(type: "timestamp with time zone", nullable: true), + ClusterId = table.Column(type: "uuid", nullable: true), + HubPageId = table.Column(type: "uuid", nullable: true), + PrimaryTopic = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + EditorialNotes = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_ContentItems", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_ContentItems_ContentType_Slug", + table: "ContentItems", + columns: new[] { "ContentType", "Slug" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ContentItems_ContentType_Status_Dates", + table: "ContentItems", + columns: new[] { "ContentType", "Status", "StartDate", "ExpiryDate" }); + + migrationBuilder.CreateIndex( + name: "IX_ContentItems_Status_PublishedAt", + table: "ContentItems", + columns: new[] { "Status", "PublishedAt" }); + + // Migrate existing blog posts into the unified ContentItems table. + // Status values are mapped: 'published' → 'Published', 'draft' → 'Draft', + // 'archived' → 'Archived'. Any other value defaults to 'Draft'. + migrationBuilder.Sql(@" + INSERT INTO ""ContentItems"" ( + ""Id"", ""ContentType"", ""Title"", ""Slug"", ""Excerpt"", + ""ContentMarkdown"", ""AuthorName"", ""Status"", + ""SeoTitle"", ""SeoDescription"", ""CanonicalUrl"", + ""PublishedAt"", ""StartDate"", ""ExpiryDate"", + ""ContentSource"", ""AiGenerated"", + ""CreatedAt"", ""UpdatedAt"" + ) + SELECT + ""Id"", + 'Blog', + ""Title"", + ""Slug"", + ""Excerpt"", + ""Content"", + ""AuthorName"", + CASE ""Status"" + WHEN 'published' THEN 'Published' + WHEN 'archived' THEN 'Archived' + WHEN 'review' THEN 'Review' + ELSE 'Draft' + END, + ""SeoTitle"", + ""SeoDescription"", + ""CanonicalUrl"", + CASE WHEN ""Status"" = 'published' THEN ""StartDate"" ELSE NULL END, + ""StartDate"", + ""ExpiryDate"", + 'Manual', + false, + ""CreatedAt"", + ""UpdatedAt"" + FROM ""BlogPosts""; + "); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "ContentItems"); + } + } +} diff --git a/services/core-api/src/Curvit.Infrastructure/Migrations/CurvitDbContextModelSnapshot.cs b/services/core-api/src/Curvit.Infrastructure/Migrations/CurvitDbContextModelSnapshot.cs index 2091aefe..1174ac00 100644 --- a/services/core-api/src/Curvit.Infrastructure/Migrations/CurvitDbContextModelSnapshot.cs +++ b/services/core-api/src/Curvit.Infrastructure/Migrations/CurvitDbContextModelSnapshot.cs @@ -443,6 +443,160 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("BlogSources"); }); + modelBuilder.Entity("Curvit.Domain.Entities.ContentItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AiGenerated") + .HasColumnType("boolean"); + + b.Property("AiPromptVersion") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ApprovedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("AuthorId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("AuthorName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CanonicalUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClusterId") + .HasColumnType("uuid"); + + b.Property("ContentMarkdown") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentSource") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasDefaultValue("Manual"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedFromContentId") + .HasColumnType("uuid"); + + b.Property("EditorialNotes") + .HasColumnType("text"); + + b.Property("Excerpt") + .HasColumnType("text"); + + b.Property("ExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("HubPageId") + .HasColumnType("uuid"); + + b.Property("LastEditedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LastWorkflowActionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastWorkflowActionBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PrimaryTopic") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PublishedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReadingTimeMinutes") + .HasColumnType("integer"); + + b.Property("SearchIntent") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SeoDescription") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SeoTitle") + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Draft"); + + b.Property("TargetKeyword") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ContentType", "Slug") + .IsUnique() + .HasDatabaseName("IX_ContentItems_ContentType_Slug"); + + b.HasIndex("ContentType", "Status", "StartDate", "ExpiryDate") + .HasDatabaseName("IX_ContentItems_ContentType_Status_Dates"); + + b.HasIndex("Status", "PublishedAt") + .HasDatabaseName("IX_ContentItems_Status_PublishedAt"); + + b.ToTable("ContentItems"); + }); + modelBuilder.Entity("Curvit.Domain.Entities.CandidateResult", b => { b.Property("Id") diff --git a/services/core-api/src/Curvit.Infrastructure/Persistence/Configurations/ContentItemConfiguration.cs b/services/core-api/src/Curvit.Infrastructure/Persistence/Configurations/ContentItemConfiguration.cs new file mode 100644 index 00000000..a972a279 --- /dev/null +++ b/services/core-api/src/Curvit.Infrastructure/Persistence/Configurations/ContentItemConfiguration.cs @@ -0,0 +1,73 @@ +using Curvit.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Curvit.Infrastructure.Persistence.Configurations; + +public sealed class ContentItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Id); + + builder.Property(x => x.ContentType) + .IsRequired() + .HasMaxLength(50); + + builder.Property(x => x.Category) + .HasMaxLength(100); + + builder.Property(x => x.Title) + .IsRequired() + .HasMaxLength(250); + + builder.Property(x => x.Slug) + .IsRequired() + .HasMaxLength(250); + + // Slug must be unique within a content type + builder.HasIndex(x => new { x.ContentType, x.Slug }) + .IsUnique() + .HasDatabaseName("IX_ContentItems_ContentType_Slug"); + + builder.Property(x => x.ContentMarkdown) + .IsRequired(); + + builder.Property(x => x.AuthorName) + .IsRequired() + .HasMaxLength(200); + + builder.Property(x => x.Status) + .IsRequired() + .HasMaxLength(20) + .HasDefaultValue("Draft"); + + builder.Property(x => x.SeoTitle).HasMaxLength(250); + builder.Property(x => x.SeoDescription).HasMaxLength(500); + builder.Property(x => x.CanonicalUrl).HasMaxLength(500); + builder.Property(x => x.TargetKeyword).HasMaxLength(200); + builder.Property(x => x.SearchIntent).HasMaxLength(100); + + builder.Property(x => x.ContentSource) + .IsRequired() + .HasMaxLength(50) + .HasDefaultValue("Manual"); + + builder.Property(x => x.CreatedBy).HasMaxLength(200); + builder.Property(x => x.LastEditedBy).HasMaxLength(200); + + builder.Property(x => x.AuthorId).HasMaxLength(200); + builder.Property(x => x.ApprovedBy).HasMaxLength(200); + builder.Property(x => x.PublishedBy).HasMaxLength(200); + builder.Property(x => x.LastWorkflowActionBy).HasMaxLength(200); + + builder.Property(x => x.PrimaryTopic).HasMaxLength(200); + builder.Property(x => x.AiPromptVersion).HasMaxLength(100); + + builder.HasIndex(x => new { x.ContentType, x.Status, x.StartDate, x.ExpiryDate }) + .HasDatabaseName("IX_ContentItems_ContentType_Status_Dates"); + + builder.HasIndex(x => new { x.Status, x.PublishedAt }) + .HasDatabaseName("IX_ContentItems_Status_PublishedAt"); + } +} diff --git a/services/core-api/src/Curvit.Infrastructure/Persistence/CurvitDbContext.cs b/services/core-api/src/Curvit.Infrastructure/Persistence/CurvitDbContext.cs index 060a785f..3396bf0d 100644 --- a/services/core-api/src/Curvit.Infrastructure/Persistence/CurvitDbContext.cs +++ b/services/core-api/src/Curvit.Infrastructure/Persistence/CurvitDbContext.cs @@ -12,6 +12,7 @@ public sealed class CurvitDbContext(DbContextOptions options) : public DbSet QuotaUsageEvents => Set(); public DbSet BlogPosts => Set(); public DbSet BlogSources => Set(); + public DbSet ContentItems => Set(); public DbSet CannedResponses => Set(); public DbSet ContactMessages => Set(); public DbSet Documents => Set(); diff --git a/services/core-api/src/Curvit.Infrastructure/Persistence/Repositories/AppConfigRepository.cs b/services/core-api/src/Curvit.Infrastructure/Persistence/Repositories/AppConfigRepository.cs new file mode 100644 index 00000000..79c4e46b --- /dev/null +++ b/services/core-api/src/Curvit.Infrastructure/Persistence/Repositories/AppConfigRepository.cs @@ -0,0 +1,26 @@ +using Curvit.Application.Interfaces; +using Curvit.Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Curvit.Infrastructure.Persistence.Repositories; + +public sealed class AppConfigRepository(CurvitDbContext db) : IAppConfigRepository +{ + public Task FindAsync(CancellationToken cancellationToken = default) + => db.AppConfigs.FirstOrDefaultAsync(cancellationToken); + + public async Task GetOrCreateAsync(CancellationToken cancellationToken = default) + { + var config = await db.AppConfigs.FirstOrDefaultAsync(cancellationToken); + if (config is null) + { + config = AppConfig.Defaults(); + db.AppConfigs.Add(config); + } + + return config; + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => db.SaveChangesAsync(cancellationToken); +} diff --git a/services/core-api/src/Curvit.Infrastructure/Persistence/Repositories/AuthUserRepository.cs b/services/core-api/src/Curvit.Infrastructure/Persistence/Repositories/AuthUserRepository.cs new file mode 100644 index 00000000..b17bbd69 --- /dev/null +++ b/services/core-api/src/Curvit.Infrastructure/Persistence/Repositories/AuthUserRepository.cs @@ -0,0 +1,51 @@ +using Curvit.Application.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace Curvit.Infrastructure.Persistence.Repositories; + +public sealed class AuthUserRepository(CurvitDbContext db) : IAuthUserRepository +{ + public Task FindDisplayNameAsync(string userId, CancellationToken cancellationToken = default) + => db.AuthUsers + .Where(u => u.Id == userId) + .Select(u => u.Name ?? string.Empty) + .FirstOrDefaultAsync(cancellationToken); + + public Task HasPasswordAsync(string userId, CancellationToken cancellationToken = default) + => db.UserPasswords.AnyAsync(p => p.UserId == userId, cancellationToken); + + public async Task UpdateDisplayNameAsync( + string userId, + string displayName, + CancellationToken cancellationToken = default) + { + var user = await db.AuthUsers.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken); + if (user is null) + return false; + + user.Name = displayName; + await db.SaveChangesAsync(cancellationToken); + return true; + } + + public async Task VerifyAndUpdatePasswordAsync( + string userId, + string currentPassword, + string newPassword, + CancellationToken cancellationToken = default) + { + var userPassword = await db.UserPasswords + .FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken); + + if (userPassword is null) + return false; + + if (!BCrypt.Net.BCrypt.Verify(currentPassword, userPassword.Hash)) + return false; + + userPassword.Hash = BCrypt.Net.BCrypt.HashPassword(newPassword); + userPassword.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(cancellationToken); + return true; + } +} diff --git a/services/core-api/src/Curvit.Infrastructure/Persistence/Repositories/DpaRepository.cs b/services/core-api/src/Curvit.Infrastructure/Persistence/Repositories/DpaRepository.cs new file mode 100644 index 00000000..da0372be --- /dev/null +++ b/services/core-api/src/Curvit.Infrastructure/Persistence/Repositories/DpaRepository.cs @@ -0,0 +1,27 @@ +using Curvit.Application.Interfaces; +using Curvit.Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Curvit.Infrastructure.Persistence.Repositories; + +public sealed class DpaRepository(CurvitDbContext db) : IDpaRepository +{ + public Task HasVersionAsync(Guid userAccountId, string version, CancellationToken cancellationToken = default) + => db.DpaAcceptances.AnyAsync(d => d.UserAccountId == userAccountId && d.DpaVersion == version, cancellationToken); + + public Task HasAnyAsync(Guid userAccountId, CancellationToken cancellationToken = default) + => db.DpaAcceptances.AnyAsync(d => d.UserAccountId == userAccountId, cancellationToken); + + public Task GetLatestAsync(Guid userAccountId, CancellationToken cancellationToken = default) + => db.DpaAcceptances + .Where(d => d.UserAccountId == userAccountId) + .OrderByDescending(d => d.AcceptedAt) + .Select(d => new DpaAcceptanceStatus(d.DpaVersion, d.AcceptedAt)) + .FirstOrDefaultAsync(cancellationToken); + + public async Task AddAsync(DpaAcceptance acceptance, CancellationToken cancellationToken = default) + => await db.DpaAcceptances.AddAsync(acceptance, cancellationToken); + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => db.SaveChangesAsync(cancellationToken); +} diff --git a/services/core-api/src/Curvit.Infrastructure/Persistence/Repositories/SystemSettingsRepository.cs b/services/core-api/src/Curvit.Infrastructure/Persistence/Repositories/SystemSettingsRepository.cs new file mode 100644 index 00000000..cbb2fbd5 --- /dev/null +++ b/services/core-api/src/Curvit.Infrastructure/Persistence/Repositories/SystemSettingsRepository.cs @@ -0,0 +1,11 @@ +using Curvit.Application.Interfaces; +using Curvit.Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Curvit.Infrastructure.Persistence.Repositories; + +public sealed class SystemSettingsRepository(CurvitDbContext db) : ISystemSettingsRepository +{ + public async Task GetOrDefaultAsync(CancellationToken cancellationToken = default) + => await db.SystemSettings.FirstOrDefaultAsync(cancellationToken) ?? SystemSettings.Defaults(); +} diff --git a/services/core-api/src/Curvit.Infrastructure/Persistence/Repositories/UserAccountRepository.cs b/services/core-api/src/Curvit.Infrastructure/Persistence/Repositories/UserAccountRepository.cs index 8b03d628..1dd2fd1b 100644 --- a/services/core-api/src/Curvit.Infrastructure/Persistence/Repositories/UserAccountRepository.cs +++ b/services/core-api/src/Curvit.Infrastructure/Persistence/Repositories/UserAccountRepository.cs @@ -15,6 +15,12 @@ public sealed class UserAccountRepository(CurvitDbContext db) : IUserAccountRepo public Task FindByStripeCustomerIdAsync(string customerId, CancellationToken cancellationToken = default) => db.UserAccounts.FirstOrDefaultAsync(u => u.StripeCustomerId == customerId, cancellationToken); + public Task FindSubjectIdByIdAsync(Guid id, CancellationToken cancellationToken = default) + => db.UserAccounts + .Where(u => u.Id == id) + .Select(u => u.SubjectId) + .FirstOrDefaultAsync(cancellationToken); + public async Task AddAsync(UserAccount account, CancellationToken cancellationToken = default) => await db.UserAccounts.AddAsync(account, cancellationToken);