diff --git a/.env.example b/.env.example index 0c83744..126ea4e 100644 --- a/.env.example +++ b/.env.example @@ -30,3 +30,8 @@ ENV_MAIL_USERNAME=test@example.com ENV_MAIL_PASSWORD=replace_with_local_mail_app_password ENV_MAIL_FROM=test@example.com ENV_SECRET_KEY=replace_with_local_jwt_secret + +# Slack notifications. Disabled by default. Keep real webhook URLs secret. +SLACK_NOTIFICATIONS_ENABLED=false +SLACK_WEBHOOK_URL= +SLACK_NOTIFICATION_TIMEOUT_SECONDS=5 diff --git a/docker-compose.yml b/docker-compose.yml index feae478..76731e8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,9 @@ services: ENV_MAIL_PASSWORD: ${ENV_MAIL_PASSWORD:-replace_with_local_mail_app_password} ENV_MAIL_FROM: ${ENV_MAIL_FROM:-test@example.com} ENV_SECRET_KEY: ${ENV_SECRET_KEY:-replace_with_local_jwt_secret} + SLACK_NOTIFICATIONS_ENABLED: ${SLACK_NOTIFICATIONS_ENABLED:-false} + SLACK_WEBHOOK_URL: ${SLACK_WEBHOOK_URL:-} + SLACK_NOTIFICATION_TIMEOUT_SECONDS: ${SLACK_NOTIFICATION_TIMEOUT_SECONDS:-5} expose: - "8000" depends_on: diff --git a/docs/slack-notifications-admin-guide.md b/docs/slack-notifications-admin-guide.md new file mode 100644 index 0000000..304c422 --- /dev/null +++ b/docs/slack-notifications-admin-guide.md @@ -0,0 +1,153 @@ +# Slack Notifications Setup Guide + +This guide explains how to connect this project to Slack so administrators can receive quick notifications when important activity happens. + +## What This Does + +When Slack notifications are turned on, the system sends a message to one Slack channel when: + +- A new user account is created. +- A logged-in user creates a new prompt. + +The system does not send Slack messages for the three default prompts that are automatically created during signup. + +Slack phone alerts are handled by the Slack mobile app. If the message appears in Slack but not on a phone, check the Slack mobile notification settings for the user and channel. + +## Before You Start + +You need: + +- Administrator access to the Slack workspace that should receive the notifications. +- Permission in Slack to create apps or manage Incoming Webhooks. +- Access to the project configuration, such as the local `.env` file, deployment environment variables, or hosting provider settings. +- Permission to restart or redeploy the backend after changing the settings. + +## Step 1: Create A Slack Webhook + +1. Open `https://api.slack.com/apps` in your browser. +2. Select `Create New App`. +3. Choose `From scratch`. +4. Enter an app name, for example `WebAPI Notifications`. +5. Select the Slack workspace that should receive the messages. +6. Open `Incoming Webhooks` in the Slack app settings. +7. Turn on `Activate Incoming Webhooks`. +8. Select `Add New Webhook to Workspace`. +9. Choose the Slack channel where notifications should appear. +10. Approve the Slack permission request. +11. Copy the webhook URL that Slack creates. + +The webhook URL usually starts with `https://hooks.slack.com/services/`. + +Treat this URL like a password. Anyone who has it may be able to send messages to the selected Slack channel. + +## Step 2: Add Slack Settings To The Project + +The project uses three Slack settings: + +- `SLACK_NOTIFICATIONS_ENABLED`: turns Slack notifications on or off. +- `SLACK_WEBHOOK_URL`: tells the backend which Slack channel webhook to use. +- `SLACK_NOTIFICATION_TIMEOUT_SECONDS`: controls how long the backend waits for Slack before giving up. The normal value is `5`. + +For local Docker Compose or local Python runs, open the root `.env` file. If it does not exist yet, create it from the template: + +```bash +cp .env.example .env +``` + +Add or update these lines in `.env`: + +```env +SLACK_NOTIFICATIONS_ENABLED=true +SLACK_WEBHOOK_URL=https://hooks.slack.com/services/REPLACE/THIS/VALUE +SLACK_NOTIFICATION_TIMEOUT_SECONDS=5 +``` + +Replace `https://hooks.slack.com/services/REPLACE/THIS/VALUE` with the webhook URL copied from Slack. + +For production or another hosted environment, add the same three settings in the place where that environment stores secrets or environment variables. This may be a deployment platform, server control panel, CI/CD secret manager, or hosting provider dashboard. + +Do not put a real Slack webhook URL in Git, screenshots, public tickets, or shared documents. + +## Step 3: Restart Or Redeploy The Backend + +The backend reads these settings when it starts. After changing Slack settings, restart or redeploy the backend. + +For Docker Compose, restart from the repository root: + +```bash +docker compose up --build -d +``` + +If your machine uses the older Compose command, use: + +```bash +docker-compose up --build -d +``` + +For a hosted environment, use the normal deploy or restart process for that environment. + +## Step 4: Send A Test Notification + +To confirm everything works: + +1. Create a test user through the app or through `POST /api/v1/auth/signup`. +2. Confirm one Slack message appears with the title `New user registered`. +3. Log in as that user. +4. Create one prompt through the app or through `POST /api/v1/prompts`. +5. Confirm one Slack message appears with the title `New prompt added`. +6. Confirm signup did not create three extra Slack messages for the default prompts. + +## What Messages Look Like + +New user notifications look like this: + +```text +New user registered +ID: 123 +Username: jane +Email: jane@example.com +Language: en +Role: user +``` + +New prompt notifications look like this: + +```text +New prompt added +Prompt ID: 456 +Owner: jane (123) +Title: My research assistant +Model: gpt +Category: research +Rate: 5 +``` + +Prompt messages do not include the full prompt text. This keeps Slack messages short and avoids exposing large prompt content in the notification channel. + +## Troubleshooting + +If no Slack message appears, confirm `SLACK_NOTIFICATIONS_ENABLED=true`. + +If no Slack message appears, confirm `SLACK_WEBHOOK_URL` starts with `https://hooks.slack.com/services/` and was copied completely. + +If no Slack message appears after editing `.env`, restart or redeploy the backend. + +If messages appear locally but not in production, confirm the production environment has the Slack settings. Local `.env` values do not automatically configure production. + +If messages go to the wrong Slack channel, create a new webhook for the correct channel and replace `SLACK_WEBHOOK_URL` with the new URL. + +If Slack messages appear but phone alerts do not, check the Slack mobile app, channel mute settings, workspace notification settings, and phone notification permissions. + +If Slack is temporarily unavailable, users and prompts should still be saved. Slack delivery is best-effort and should not block normal app activity. + +## Security Tips + +Keep the webhook URL private. + +Do not commit the real webhook URL to Git. + +Do not paste the real webhook URL into public chat, tickets, screenshots, or documentation. + +If the webhook URL is exposed, revoke it in Slack and create a new webhook. + +Use a dedicated Slack channel when possible, so notification access is easy to manage. diff --git a/webapi/README.md b/webapi/README.md index e581909..38f797a 100644 --- a/webapi/README.md +++ b/webapi/README.md @@ -385,6 +385,7 @@ SELECT id, user_id, model_name, category, rate FROM prompts; - JWT and mail settings are read in [`core/config.py`](core/config.py). - `ENV_MAIL_USERNAME`, `ENV_MAIL_PASSWORD`, `ENV_MAIL_FROM`, and `ENV_SECRET_KEY` should come from local `.env`, shell exports, CI secrets, or production secret management. Do not commit real values. - Redis is configured through `REDIS_HOST`, `REDIS_PORT`, and optional `REDIS_PSW` in [`core/config.py`](core/config.py). Local defaults are `127.0.0.1:6379`; Compose sets `REDIS_HOST=redis` for backend containers. +- Slack notifications are configured with `SLACK_NOTIFICATIONS_ENABLED`, `SLACK_WEBHOOK_URL`, and `SLACK_NOTIFICATION_TIMEOUT_SECONDS`. See the administrator setup guide at [`../docs/slack-notifications-admin-guide.md`](../docs/slack-notifications-admin-guide.md). - The backend Docker image includes a deterministic `fastapi_mail/config.py` dependency patch after installing pinned requirements. Scalable configuration approach: diff --git a/webapi/api/endpoints/v1/auths.py b/webapi/api/endpoints/v1/auths.py index 9367a67..8a6bd26 100644 --- a/webapi/api/endpoints/v1/auths.py +++ b/webapi/api/endpoints/v1/auths.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, HTTPException, Depends, Body, Query +from fastapi import APIRouter, HTTPException, Depends, Body, Query, BackgroundTasks from sqlmodel import Session, select from models.user import User from models.prompts import Prompts @@ -17,6 +17,8 @@ from pydantic import BaseModel, Field from schemas.login_schema import LoginRequest from schemas.user_schema import UserCreate, UserRead +from infrastructure.notifications.events import notify_user_created +from infrastructure.notifications.scheduler import schedule_notification router = APIRouter() @@ -31,7 +33,11 @@ class RecoveryRedeemRequest(BaseModel): @router.post("/signup") -def signup(payload: UserCreate, session: Session = Depends(get_session)): +def signup( + payload: UserCreate, + background_tasks: BackgroundTasks, + session: Session = Depends(get_session), +): statement = select(User).where(User.username == payload.username) result = session.exec(statement) user_exists = result.one_or_none() @@ -63,6 +69,7 @@ def signup(payload: UserCreate, session: Session = Depends(get_session)): session.commit() session.refresh(user) + schedule_notification(background_tasks, lambda: notify_user_created(user)) access_token = crear_jwt( data={"sub": user.username, "user_id": user.id, "role": user.role} ) diff --git a/webapi/api/endpoints/v1/prompts.py b/webapi/api/endpoints/v1/prompts.py index ca720a9..7f24acf 100644 --- a/webapi/api/endpoints/v1/prompts.py +++ b/webapi/api/endpoints/v1/prompts.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, HTTPException, Depends, Header +from fastapi import APIRouter, HTTPException, Depends, Header, BackgroundTasks from sqlmodel import Session, select from typing import Optional from models.user import User @@ -6,6 +6,8 @@ from db.db_connection import get_session from auth.auth_service import get_current_db_user from infrastructure.email.smtp_service import send_email +from infrastructure.notifications.events import notify_prompt_created +from infrastructure.notifications.scheduler import schedule_notification from schemas.prompt_schema import PromptCreate router = APIRouter() @@ -13,6 +15,7 @@ @router.post("", response_model=Prompts) async def create_prompt( prompt: PromptCreate, + background_tasks: BackgroundTasks, session: Session = Depends(get_session), current_user: User = Depends(get_current_db_user), send_email_header: Optional[str] = Header("false", alias="send_email") @@ -36,6 +39,7 @@ async def create_prompt( session.add(created_prompt) session.commit() session.refresh(created_prompt) + schedule_notification(background_tasks, lambda: notify_prompt_created(created_prompt, prompt_user)) # Email notification is best-effort and should not block prompt persistence. if str(send_email_header).lower() == "true": diff --git a/webapi/core/config.py b/webapi/core/config.py index 3c83d40..e9280d9 100644 --- a/webapi/core/config.py +++ b/webapi/core/config.py @@ -46,3 +46,16 @@ DB_URL = os.getenv("DB_URL") if not DB_URL: DB_URL = "sqlite:///./crud_data.db" # Default to SQLite if no environment variable is set + + +def _env_bool(name: str, default: bool = False) -> bool: + value = os.getenv(name) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +# Slack notification configuration +SLACK_NOTIFICATIONS_ENABLED = _env_bool("SLACK_NOTIFICATIONS_ENABLED", False) +SLACK_WEBHOOK_URL = os.getenv("SLACK_WEBHOOK_URL", "") +SLACK_NOTIFICATION_TIMEOUT_SECONDS = float(os.getenv("SLACK_NOTIFICATION_TIMEOUT_SECONDS", "5")) diff --git a/webapi/infrastructure/notifications/__init__.py b/webapi/infrastructure/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/webapi/infrastructure/notifications/events.py b/webapi/infrastructure/notifications/events.py new file mode 100644 index 0000000..8aeee34 --- /dev/null +++ b/webapi/infrastructure/notifications/events.py @@ -0,0 +1,28 @@ +from models.prompts import Prompts +from models.user import User +from infrastructure.notifications.slack_service import send_slack_notification + + +async def notify_user_created(user: User) -> None: + text = ( + "New user registered\n" + f"ID: {user.id}\n" + f"Username: {user.username}\n" + f"Email: {user.email}\n" + f"Language: {user.preferred_language}\n" + f"Role: {user.role}" + ) + await send_slack_notification(text) + + +async def notify_prompt_created(prompt: Prompts, user: User) -> None: + text = ( + "New prompt added\n" + f"Prompt ID: {prompt.id}\n" + f"Owner: {user.username} ({user.id})\n" + f"Title: {prompt.title}\n" + f"Model: {prompt.model_name}\n" + f"Category: {prompt.category}\n" + f"Rate: {prompt.rate}" + ) + await send_slack_notification(text) diff --git a/webapi/infrastructure/notifications/scheduler.py b/webapi/infrastructure/notifications/scheduler.py new file mode 100644 index 0000000..2e93f8c --- /dev/null +++ b/webapi/infrastructure/notifications/scheduler.py @@ -0,0 +1,20 @@ +import logging +from collections.abc import Awaitable, Callable + +from fastapi import BackgroundTasks + +logger = logging.getLogger(__name__) + + +async def _run_notification(coro_factory: Callable[[], Awaitable[None]]) -> None: + try: + await coro_factory() + except Exception: + logger.exception("Notification delivery failed") + + +def schedule_notification( + background_tasks: BackgroundTasks, + coro_factory: Callable[[], Awaitable[None]], +) -> None: + background_tasks.add_task(_run_notification, coro_factory) diff --git a/webapi/infrastructure/notifications/slack_service.py b/webapi/infrastructure/notifications/slack_service.py new file mode 100644 index 0000000..7927278 --- /dev/null +++ b/webapi/infrastructure/notifications/slack_service.py @@ -0,0 +1,16 @@ +import httpx + +from core import config + + +async def send_slack_notification(text: str, blocks: list[dict] | None = None) -> None: + if not config.SLACK_NOTIFICATIONS_ENABLED or not config.SLACK_WEBHOOK_URL: + return + + payload: dict[str, object] = {"text": text} + if blocks is not None: + payload["blocks"] = blocks + + async with httpx.AsyncClient(timeout=config.SLACK_NOTIFICATION_TIMEOUT_SECONDS) as client: + response = await client.post(config.SLACK_WEBHOOK_URL, json=payload) + response.raise_for_status() diff --git a/webapi/tests/functional/test_auth_routes.py b/webapi/tests/functional/test_auth_routes.py index 60e47e0..57c7d7c 100644 --- a/webapi/tests/functional/test_auth_routes.py +++ b/webapi/tests/functional/test_auth_routes.py @@ -9,7 +9,22 @@ from auth.auth_service import validar_jwt_raw -def test_signup_success(client, user_payload, db_session): +def test_signup_success(client, user_payload, db_session, monkeypatch): + notifications = [] + + async def fake_notify_user_created(user): + notifications.append( + { + "id": user.id, + "username": user.username, + "email": user.email, + "preferred_language": user.preferred_language, + "role": user.role, + } + ) + + monkeypatch.setattr(auths_module, "notify_user_created", fake_notify_user_created) + response = client.post("/api/v1/auth/signup", json=user_payload) assert response.status_code == 200 @@ -37,6 +52,15 @@ def test_signup_success(client, user_payload, db_session): } assert {prompt.model_name for prompt in seeded_prompts} == {"gpt"} assert {prompt.rate for prompt in seeded_prompts} == {5} + assert notifications == [ + { + "id": created.id, + "username": user_payload["username"], + "email": user_payload["email"], + "preferred_language": "es", + "role": "user", + } + ] def test_signup_rejects_client_supplied_id(client, user_payload, db_session): diff --git a/webapi/tests/functional/test_notifications.py b/webapi/tests/functional/test_notifications.py new file mode 100644 index 0000000..d23114f --- /dev/null +++ b/webapi/tests/functional/test_notifications.py @@ -0,0 +1,84 @@ +import asyncio +import logging + +from infrastructure.notifications import scheduler, slack_service + + +def test_send_slack_notification_disabled_makes_no_http_call(monkeypatch): + calls = [] + + class FakeAsyncClient: + def __init__(self, *args, **kwargs): + calls.append((args, kwargs)) + + monkeypatch.setattr(slack_service.config, "SLACK_NOTIFICATIONS_ENABLED", False) + monkeypatch.setattr(slack_service.config, "SLACK_WEBHOOK_URL", "https://hooks.slack.test/example") + monkeypatch.setattr(slack_service.httpx, "AsyncClient", FakeAsyncClient) + + asyncio.run(slack_service.send_slack_notification("ignored")) + + assert calls == [] + + +def test_send_slack_notification_missing_url_makes_no_http_call(monkeypatch): + calls = [] + + class FakeAsyncClient: + def __init__(self, *args, **kwargs): + calls.append((args, kwargs)) + + monkeypatch.setattr(slack_service.config, "SLACK_NOTIFICATIONS_ENABLED", True) + monkeypatch.setattr(slack_service.config, "SLACK_WEBHOOK_URL", "") + monkeypatch.setattr(slack_service.httpx, "AsyncClient", FakeAsyncClient) + + asyncio.run(slack_service.send_slack_notification("ignored")) + + assert calls == [] + + +def test_send_slack_notification_posts_expected_json(monkeypatch): + calls = [] + + class FakeResponse: + def raise_for_status(self): + calls.append(("raise_for_status",)) + + class FakeAsyncClient: + def __init__(self, timeout): + self.timeout = timeout + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, traceback): + return None + + async def post(self, url, json): + calls.append((url, json, self.timeout)) + return FakeResponse() + + monkeypatch.setattr(slack_service.config, "SLACK_NOTIFICATIONS_ENABLED", True) + monkeypatch.setattr(slack_service.config, "SLACK_WEBHOOK_URL", "https://hooks.slack.test/example") + monkeypatch.setattr(slack_service.config, "SLACK_NOTIFICATION_TIMEOUT_SECONDS", 2.5) + monkeypatch.setattr(slack_service.httpx, "AsyncClient", FakeAsyncClient) + + asyncio.run(slack_service.send_slack_notification("hello", blocks=[{"type": "section"}])) + + assert calls == [ + ( + "https://hooks.slack.test/example", + {"text": "hello", "blocks": [{"type": "section"}]}, + 2.5, + ), + ("raise_for_status",), + ] + + +def test_notification_background_wrapper_logs_and_swallows_errors(caplog): + async def failing_notification(): + raise Exception("slack unavailable") + + with caplog.at_level(logging.ERROR, logger="infrastructure.notifications.scheduler"): + asyncio.run(scheduler._run_notification(lambda: failing_notification())) + + assert "Notification delivery failed" in caplog.text diff --git a/webapi/tests/functional/test_prompts_routes.py b/webapi/tests/functional/test_prompts_routes.py index aaf3e92..c1f7fc7 100644 --- a/webapi/tests/functional/test_prompts_routes.py +++ b/webapi/tests/functional/test_prompts_routes.py @@ -51,7 +51,24 @@ def create_prompt( return prompt -def test_create_prompt_success(client, auth_header, created_user): +def test_create_prompt_success(client, auth_header, created_user, monkeypatch): + notifications = [] + + async def fake_notify_prompt_created(prompt, user): + notifications.append( + { + "prompt_id": prompt.id, + "owner_id": user.id, + "owner_username": user.username, + "title": prompt.title, + "model_name": prompt.model_name, + "category": prompt.category, + "rate": prompt.rate, + } + ) + + monkeypatch.setattr(prompts_module, "notify_prompt_created", fake_notify_prompt_created) + payload = { "user_id": created_user.id, "title": "Answer generator", @@ -67,6 +84,17 @@ def test_create_prompt_success(client, auth_header, created_user): assert response.json()["user_id"] == created_user.id assert response.json()["title"] == "Answer generator" assert response.json()["model_name"] == VALID_MODEL_NAME + assert notifications == [ + { + "prompt_id": response.json()["id"], + "owner_id": created_user.id, + "owner_username": created_user.username, + "title": "Answer generator", + "model_name": VALID_MODEL_NAME, + "category": "qa", + "rate": 5, + } + ] def test_create_prompt_accepts_prompt_text_longer_than_150_chars(client, auth_header, created_user): @@ -219,6 +247,27 @@ async def failing_send_email(*args, **kwargs): assert response.json()["prompt_text"] == "Ignore email failure" +def test_create_prompt_notification_exception_still_success(client, auth_header, created_user, monkeypatch): + async def failing_notify_prompt_created(*args, **kwargs): + raise Exception("slack unavailable") + + monkeypatch.setattr(prompts_module, "notify_prompt_created", failing_notify_prompt_created) + + payload = { + "user_id": created_user.id, + "title": "Slack resilient prompt", + "model_name": VALID_MODEL_NAME, + "prompt_text": "Ignore Slack failure", + "category": "ops", + "rate": 1, + } + + response = client.post("/api/v1/prompts", json=payload, headers=auth_header) + + assert response.status_code == 200 + assert response.json()["title"] == "Slack resilient prompt" + + def test_read_prompts_success(client, auth_header, created_prompt): response = client.get("/api/v1/prompts", headers=auth_header)