Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
153 changes: 153 additions & 0 deletions docs/slack-notifications-admin-guide.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions webapi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 9 additions & 2 deletions webapi/api/endpoints/v1/auths.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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()

Expand All @@ -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()
Expand Down Expand Up @@ -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}
)
Expand Down
6 changes: 5 additions & 1 deletion webapi/api/endpoints/v1/prompts.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
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
from models.prompts import Prompts
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()

@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")
Expand All @@ -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":
Expand Down
13 changes: 13 additions & 0 deletions webapi/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Empty file.
28 changes: 28 additions & 0 deletions webapi/infrastructure/notifications/events.py
Original file line number Diff line number Diff line change
@@ -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)
20 changes: 20 additions & 0 deletions webapi/infrastructure/notifications/scheduler.py
Original file line number Diff line number Diff line change
@@ -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)
16 changes: 16 additions & 0 deletions webapi/infrastructure/notifications/slack_service.py
Original file line number Diff line number Diff line change
@@ -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()
26 changes: 25 additions & 1 deletion webapi/tests/functional/test_auth_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
Loading
Loading