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
153 changes: 0 additions & 153 deletions .cursor/rules

This file was deleted.

5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ SESSION_REVOKED_RETENTION_DAYS=30
SESSION_PURGE_CRON_HOUR=4
SESSION_PURGE_CRON_MINUTE=0

# Broadcast email fan-out (admin announcements with "send email"). Recipients
# are emailed in batches of this size with a pause (seconds) between batches.
BROADCAST_EMAIL_BATCH_SIZE=50
BROADCAST_EMAIL_THROTTLE_SECONDS=1.0

# Sentry (only initialized when ENVIRONMENT != "local")
SENTRY_DSN=

Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ api → services → repositories → models
- `services/` → All business logic. Receives session as argument. May raise `HTTPException` directly.
- `repositories/` → All DB queries. No business rules. Receives session as argument.
- `models/` → SQLAlchemy ORM definitions only. No logic.
- `schemas/` → Pydantic DTOs. Separate `Create`, `Update`, `Response` per domain.
- `schemas/` → Pydantic DTOs **at the API boundary** (request/response). Separate `Create`, `Update`, `Response` per domain. Data that never crosses the API (internal value objects passed between layers) uses a plain `@dataclass`, not a Pydantic schema — e.g. `core/broadcast_templates.py:RenderedContent`, `services/admin/broadcast_email.py:BroadcastEmail`.
- `core/` → Config, JWT auth, bcrypt, exceptions, logging.
- `utils/` → Pure helper functions shared across layers.

Expand All @@ -105,6 +105,7 @@ These are non-negotiable:
- Services never call other services — use shared repository functions or `use_cases/` instead
- No sync DB calls — all queries must be `async/await`
- No raw ORM objects returned from endpoints — always map to Pydantic schemas
- `schemas/` (Pydantic) is for API-boundary DTOs only; internal value objects passed between layers use a plain `@dataclass`, never a Pydantic schema
- No secrets in code — always use `pydantic-settings` and `.env`
- Error messages must ALWAYS come from `app/core/messages/error_message.py`
- Success messages must ALWAYS come from `app/core/messages/success_message.py`
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ This template integrates the best-in-class Python ecosystem tools to provide a s
- **Role-Based Access Control (RBAC):** Three roles — `user` / `admin` / `superadmin`. Superadmins bypass every check and **create/delete admins** and assign their permissions; plain admins are gated per **`resource:action`** permission (`users:read`, `users:write`, `support:update`, …) stored in `admin_permission` and enforced by a single `require_permission(...)` dependency. The **superadmin tier** (promote an admin to superadmin, demote a superadmin, transfer root via email OTP) is reserved for the **root superadmin** (`is_root_superadmin`); the last active superadmin is always protected. See [Admin & RBAC](#-admin--rbac-roles--permissions).
- **Support / Ticketing System:** Full ticket lifecycle — open, reply with image attachments, status/priority, admin assignment — fanned out over a **Redis-backed WebSocket bus** so the ticket thread, the admin queue, and per-user feeds update live across workers. See [Support System & Realtime](#-support-ticketing-system--realtime).
- **Notification System:** Persistent in-app notifications (`notification` table) layered on the same realtime bus — domain events (a support reply, a ticket status change, an RBAC grant change) call a single `notify()` use case that stores the row **and** pushes it to the recipient's `notifications:{id}` feed, so offline users still find it in their inbox on the next fetch. Notification copy is stored as a stable machine `type` + `data` payload (no human text), so the frontend renders it in any locale. REST covers paginated listing (`unread_only` filter), the unread badge count, and mark-(all-)read. See [Notification System](#-notification-system).
- **Broadcast / Announcements:** Admins compose an announcement — from a reusable **template catalog** (typed variables: localized datetimes + per-language text) or **custom** per-language text — and fan it out to an audience (`all` / `active` / by `role`). Each recipient gets a persistent notification via a chunked `notify_many` use case (bulk insert + realtime), and optionally a **multilingual email** sent in throttled batches by an `arq` job. Content is stored language-agnostically so it renders in each user's locale; datetimes are stored UTC. A `show_banner` flag exposes the latest as a top banner (`GET /announcements/active`). Gated by `broadcast:read` / `broadcast:write`.
- **Session / Device Management:** Every login opens a `user_session` row whose id rides in both tokens as the JWT `sid` claim, so a user can list their active devices and revoke any one (or all but the current). Refresh **rotates** the session's `jti` and detects **replay** — a stale refresh token revokes the whole session as compromised; logout and password change revoke sessions too. Revocation kills a session's still-valid access tokens instantly via a Redis `sid` flag, and broadcasts `sessions_revoked` so open tabs drop to login live. Admins can list/terminate a user's sessions, gated by the `users:sessions` permission. A nightly arq job purges stale rows. See [Session Management](#-session--device-management).
- **First Superadmin Seed:** On startup a **root superadmin** (`is_root_superadmin`) is created from `FIRST_SUPERUSER` / `FIRST_SUPERUSER_PASSWORD` if none exists (a matching existing account is promoted instead; older deployments get their oldest superadmin flagged as root) — guaranteeing a root account.
- **Tooling:** [uv](https://docs.astral.sh/uv/) for blazing-fast package management, and [Ruff](https://docs.astral.sh/ruff/) for linting and formatting.
Expand Down
4 changes: 3 additions & 1 deletion REVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ Yeni domain: model → schema → repository → service → route (her katman s
- `model_config = ConfigDict(from_attributes=True)` ORM → Pydantic mapping için zorunlu ([schemas/user.py:22](app/schemas/user.py)).
- Validator hata mesajları **`ErrorMessages.X`**'tan gelir, hardcode yasak ([schemas/user.py:43](app/schemas/user.py)).
- `field_validator` + `@classmethod` paterni ([schemas/user.py:39-44](app/schemas/user.py)).
- **`schemas/` yalnızca API sınırını geçen DTO'lar içindir** (request/response). Katmanlar arası taşınan ama API'yi geçmeyen **iç value object'ler `@dataclass`** olur, Pydantic schema değil — kanıt: [core/broadcast_templates.py](app/core/broadcast_templates.py) `RenderedContent`, [services/admin/broadcast_email.py](app/services/admin/broadcast_email.py) `BroadcastEmail`. Doğrulama/serialize gerektirmeyen iç veriye Pydantic koymak `schemas/`'ı kirletir ve gereksiz overhead getirir.

### 3.5 Error/Success messages — single source of truth
- **Tüm hata mesajları [app/core/messages/error_message.py](app/core/messages/error_message.py)** — `ErrorMessages.X` class attribute olarak.
Expand Down Expand Up @@ -268,6 +269,7 @@ Yeni global concern → kendi modülüne `register_X(app)` / `init_X(app)` ile e
- `field_validator` hata mesajının inline string olması.
- `UserPublic`'e password/hashed_password sızması.
- `model_dump()` yerine `dict()` (Pydantic v2'de deprecated).
- **API sınırını geçmeyen iç value object için Pydantic schema kullanılmış** — `@dataclass` olmalı (`schemas/` yalnızca API DTO'ları; bkz. 3.4 ve `RenderedContent`/`BroadcastEmail`). Tersi de geçerli: gerçek request/response DTO'sunun `@dataclass` ile yazılması (Pydantic validation/serialize kaybı).

### 5.6 Migrations / models
- **Model değişikliği var ama `app/alembic/versions/` altında migration yok.**
Expand Down Expand Up @@ -348,4 +350,4 @@ Bu maddeleri **flag etme**:

---

Son güncelleme: 2026-06-09.
Son güncelleme: 2026-06-14.
62 changes: 62 additions & 0 deletions app/alembic/versions/1c21a0056027_add_announcement_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""add announcement model

Revision ID: 1c21a0056027
Revises: 19b42a1d4b20
Create Date: 2026-06-14 08:44:47.930238

"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision: str = "1c21a0056027"
down_revision: str | Sequence[str] | None = "19b42a1d4b20"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"announcement",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("kind", sa.String(length=20), nullable=False),
sa.Column("template_key", sa.String(length=100), nullable=True),
sa.Column("variables", postgresql.JSON(astext_type=sa.Text()), nullable=False),
sa.Column(
"translations", postgresql.JSON(astext_type=sa.Text()), nullable=False
),
sa.Column("level", sa.String(length=20), nullable=False),
sa.Column("audience", sa.String(length=20), nullable=False),
sa.Column("role_filter", sa.String(length=20), nullable=True),
sa.Column("show_banner", sa.Boolean(), nullable=False),
sa.Column("send_email", sa.Boolean(), nullable=False),
sa.Column("created_by", sa.Uuid(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["created_by"], ["user.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_announcement_banner_created",
"announcement",
["show_banner", "created_at"],
unique=False,
)
op.create_index(
"ix_announcement_created", "announcement", ["created_at"], unique=False
)
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index("ix_announcement_created", table_name="announcement")
op.drop_index("ix_announcement_banner_created", table_name="announcement")
op.drop_table("announcement")
# ### end Alembic commands ###
14 changes: 13 additions & 1 deletion app/api/main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
from fastapi import APIRouter

from app.api.routes import admin, auth, files, health, notifications, support, users
from app.api.routes import (
admin,
announcements,
auth,
files,
health,
notifications,
support,
users,
)

api_router = APIRouter()

Expand All @@ -10,6 +19,9 @@
api_router.include_router(
notifications.router, prefix="/notifications", tags=["notifications"]
)
api_router.include_router(
announcements.router, prefix="/announcements", tags=["announcements"]
)
api_router.include_router(support.router, prefix="/support", tags=["support"])
api_router.include_router(admin.router, prefix="/admin", tags=["admin"])
api_router.include_router(files.router, tags=["files"])
11 changes: 10 additions & 1 deletion app/api/routes/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,15 @@

from fastapi import APIRouter

from app.api.routes.admin import activities, admins, files, stats, support, users
from app.api.routes.admin import (
activities,
admins,
broadcast,
files,
stats,
support,
users,
)

router = APIRouter()
router.include_router(users.router, prefix="/users")
Expand All @@ -15,3 +23,4 @@
router.include_router(support.router, prefix="/support")
router.include_router(activities.router)
router.include_router(stats.router)
router.include_router(broadcast.router)
Loading
Loading