From e0c9b0a64ee92571e6e61cf967b623222f118c1f Mon Sep 17 00:00:00 2001 From: keep <1603421097@qq.com> Date: Wed, 15 Apr 2026 11:20:42 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=AE=9E?= =?UTF-8?q?=E6=97=B6=E9=80=9A=E7=9F=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端实现通知模块,包括数据库表、API路由和WebSocket支持 - 前端添加通知中心组件,支持实时接收和显示通知 - 新增通知类型枚举,支持信息、成功、警告和错误四种类型 - 实现通知的创建、读取、更新和删除功能 - 添加WebSocket连接管理,实现实时通知推送 - 优化数据库配置,支持SQLite和PostgreSQL - 添加通知标记已读和全部标记已读功能 --- backend/app.db | Bin 0 -> 32768 bytes .../versions/add_notification_table.py | 53 ++ backend/app/api/main.py | 3 +- backend/app/api/routes/notifications.py | 210 ++++++++ backend/app/api/routes/websocket.py | 75 +++ backend/app/core/config.py | 42 +- backend/app/core/db.py | 23 +- backend/app/core/websocket.py | 85 ++++ backend/app/main.py | 15 +- backend/app/models.py | 64 +++ frontend/src/client/index.ts | 4 +- frontend/src/client/notifications.gen.ts | 234 +++++++++ .../Notifications/NotificationCenter.tsx | 466 ++++++++++++++++++ frontend/src/routeTree.gen.ts | 6 +- frontend/src/routes/_layout.tsx | 6 +- 15 files changed, 1249 insertions(+), 37 deletions(-) create mode 100644 backend/app.db create mode 100644 backend/app/alembic/versions/add_notification_table.py create mode 100644 backend/app/api/routes/notifications.py create mode 100644 backend/app/api/routes/websocket.py create mode 100644 backend/app/core/websocket.py create mode 100644 frontend/src/client/notifications.gen.ts create mode 100644 frontend/src/components/Notifications/NotificationCenter.tsx diff --git a/backend/app.db b/backend/app.db new file mode 100644 index 0000000000000000000000000000000000000000..c0d2c359487c8c0892ea6b663c45b15affbfd71f GIT binary patch literal 32768 zcmeI(-%i_B90zbGp$SPxGHFBHo@zl5(SrYnFd<=xDH(}VLb5_{EGM?X5a*BW6xLqR zYI}mcOP^rZdjZ~HFR;t`B&CoJNTo^Dgua%JIrjOJb3Q*i(uvLfUfBsOVs|~)2#COZ z%S0l~1|bZ?#KSZlrmJl#%+PeTMXoaU{EaiqzvX5!e=~{4Z<+KTnLpEm88uyg{C4J> zds?9g0uX=z1Rwwb2tWV=4^QBII-Z!DpO5^i2S&4Pb-ICLJ1rw{x}BT(#8ypJ>ME%# zn`M>UEFp`jWZ(pCi~OwAwsw@-A}>ifqE+ieYn01RNh;}DzHb~|SKwHd&C!CJ{pi6j zz3VFZ@g5vs_bkICo7HMrRkX3*jTRlOKJeO~Mj(Cc-!aK>L;^qFT`TM?uuMH%Y!t&; z)k_tX4rkCaZ$0f>o_<$XFKVTVQadEO>LFQl%;B`Qt2MRsT%+d}2cyP0Qd76pnyPK7 z`-GkwRwh-A6xFgC!fq-1TS`$)m1$3wOD*^VKy>?bOv7NYlsfD-5&-N(YO)8X2FCd@?BzcZPg zaEA>75P$##AOHafKmY;|fB*y_@D&9vxM=c8a{9ZVU3m7BzCzVN|GpFNRi}9GRe>}8>Z`YHmq~Q?X@jBbiQEQt$a%sWHv9d zEu&zutiT%rYuF~cnzts5`_+NW7f&@uKC>&9y2qWCxZcT8wqEZzR^5GNo}K&KZLeNZ z)aQ9oRD_da-)_7y4!u%k$uIZ^yX Any: + """ + Retrieve notifications for the current user. + """ + if current_user.is_superuser: + count_statement = select(func.count()).select_from(Notification) + if unread_only: + count_statement = count_statement.where(Notification.is_read == False) + count = session.exec(count_statement).one() + + unread_count_statement = select(func.count()).select_from(Notification).where( + Notification.is_read == False + ) + unread_count = session.exec(unread_count_statement).one() + + statement = ( + select(Notification) + .order_by(col(Notification.created_at).desc()) + .offset(skip) + .limit(limit) + ) + if unread_only: + statement = statement.where(Notification.is_read == False) + notifications = session.exec(statement).all() + else: + count_statement = ( + select(func.count()) + .select_from(Notification) + .where(Notification.user_id == current_user.id) + ) + if unread_only: + count_statement = count_statement.where(Notification.is_read == False) + count = session.exec(count_statement).one() + + unread_count_statement = ( + select(func.count()) + .select_from(Notification) + .where(Notification.user_id == current_user.id) + .where(Notification.is_read == False) + ) + unread_count = session.exec(unread_count_statement).one() + + statement = ( + select(Notification) + .where(Notification.user_id == current_user.id) + .order_by(col(Notification.created_at).desc()) + .offset(skip) + .limit(limit) + ) + if unread_only: + statement = statement.where(Notification.is_read == False) + notifications = session.exec(statement).all() + + notifications_public = [ + NotificationPublic.model_validate(notification) + for notification in notifications + ] + return NotificationsPublic( + data=notifications_public, count=count, unread_count=unread_count + ) + + +@router.get("/{id}", response_model=NotificationPublic) +def read_notification( + session: SessionDep, current_user: CurrentUser, id: uuid.UUID +) -> Any: + """ + Get notification by ID. + """ + notification = session.get(Notification, id) + if not notification: + raise HTTPException(status_code=404, detail="Notification not found") + if not current_user.is_superuser and (notification.user_id != current_user.id): + raise HTTPException(status_code=403, detail="Not enough permissions") + return notification + + +@router.post("/", response_model=NotificationPublic) +async def create_notification( + *, session: SessionDep, current_user: CurrentUser, notification_in: NotificationCreate +) -> Any: + """ + Create new notification. + Only superusers can create notifications for other users. + Regular users can only create notifications for themselves. + """ + if not current_user.is_superuser and notification_in.user_id != current_user.id: + raise HTTPException( + status_code=403, detail="Not enough permissions to create notifications for other users" + ) + + notification = Notification.model_validate(notification_in) + session.add(notification) + session.commit() + session.refresh(notification) + + notification_public = NotificationPublic.model_validate(notification) + await manager.send_notification(notification_public, notification.user_id) + + return notification + + +@router.put("/{id}", response_model=NotificationPublic) +def update_notification( + *, + session: SessionDep, + current_user: CurrentUser, + id: uuid.UUID, + notification_in: NotificationUpdate, +) -> Any: + """ + Update a notification. + """ + notification = session.get(Notification, id) + if not notification: + raise HTTPException(status_code=404, detail="Notification not found") + if not current_user.is_superuser and (notification.user_id != current_user.id): + raise HTTPException(status_code=403, detail="Not enough permissions") + + update_dict = notification_in.model_dump(exclude_unset=True) + notification.sqlmodel_update(update_dict) + session.add(notification) + session.commit() + session.refresh(notification) + return notification + + +@router.delete("/{id}") +def delete_notification( + session: SessionDep, current_user: CurrentUser, id: uuid.UUID +) -> Message: + """ + Delete a notification. + """ + notification = session.get(Notification, id) + if not notification: + raise HTTPException(status_code=404, detail="Notification not found") + if not current_user.is_superuser and (notification.user_id != current_user.id): + raise HTTPException(status_code=403, detail="Not enough permissions") + session.delete(notification) + session.commit() + return Message(message="Notification deleted successfully") + + +@router.post("/{id}/mark-read", response_model=NotificationPublic) +def mark_notification_read( + session: SessionDep, current_user: CurrentUser, id: uuid.UUID +) -> Any: + """ + Mark a notification as read. + """ + notification = session.get(Notification, id) + if not notification: + raise HTTPException(status_code=404, detail="Notification not found") + if not current_user.is_superuser and (notification.user_id != current_user.id): + raise HTTPException(status_code=403, detail="Not enough permissions") + + notification.is_read = True + session.add(notification) + session.commit() + session.refresh(notification) + return notification + + +@router.post("/mark-all-read", response_model=Message) +def mark_all_notifications_read( + session: SessionDep, current_user: CurrentUser +) -> Message: + """ + Mark all notifications as read for the current user. + """ + statement = select(Notification).where( + Notification.user_id == current_user.id, + Notification.is_read == False, + ) + notifications = session.exec(statement).all() + + for notification in notifications: + notification.is_read = True + session.add(notification) + + session.commit() + return Message(message=f"Marked {len(notifications)} notifications as read") diff --git a/backend/app/api/routes/websocket.py b/backend/app/api/routes/websocket.py new file mode 100644 index 0000000000..228ca648a6 --- /dev/null +++ b/backend/app/api/routes/websocket.py @@ -0,0 +1,75 @@ +import logging +from typing import Annotated +from uuid import UUID + +import jwt +from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect, status +from jwt.exceptions import InvalidTokenError +from pydantic import ValidationError +from sqlmodel import Session + +from app.core import security +from app.core.config import settings +from app.core.db import engine +from app.core.websocket import manager +from app.models import TokenPayload, User + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["websocket"]) + + +def get_user_from_token(token: str) -> User: + try: + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] + ) + token_data = TokenPayload(**payload) + except (InvalidTokenError, ValidationError): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Could not validate credentials", + ) + + with Session(engine) as session: + user = session.get(User, token_data.sub) + if not user: + raise HTTPException(status_code=404, detail="User not found") + if not user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + return user + + +@router.websocket("/ws/notifications") +async def websocket_notifications( + websocket: WebSocket, + token: Annotated[str | None, Query()] = None, +) -> None: + """ + WebSocket endpoint for real-time notifications. + Clients should connect with a valid JWT token as a query parameter. + """ + if not token: + await websocket.close(code=status.WS_1008_POLICY_VIOLATION) + return + + try: + user = get_user_from_token(token) + except HTTPException: + await websocket.close(code=status.WS_1008_POLICY_VIOLATION) + return + + user_id = user.id + await manager.connect(user_id, websocket) + + try: + while True: + data = await websocket.receive_text() + if data == "ping": + await websocket.send_text("pong") + except WebSocketDisconnect: + manager.disconnect(user_id, websocket) + logger.info(f"WebSocket disconnected for user: {user_id}") + except Exception as e: + manager.disconnect(user_id, websocket) + logger.error(f"WebSocket error for user {user_id}: {e}") diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 650b9f7910..f8c919005c 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,3 +1,4 @@ +import os import secrets import warnings from typing import Annotated, Any, Literal @@ -25,14 +26,12 @@ def parse_cors(v: Any) -> list[str] | str: class Settings(BaseSettings): model_config = SettingsConfigDict( - # Use top level .env file (one level above ./backend/) env_file="../.env", env_ignore_empty=True, extra="ignore", ) API_V1_STR: str = "/api/v1" SECRET_KEY: str = secrets.token_urlsafe(32) - # 60 minutes * 24 hours * 8 days = 8 days ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 FRONTEND_HOST: str = "http://localhost:5173" ENVIRONMENT: Literal["local", "staging", "production"] = "local" @@ -41,7 +40,7 @@ class Settings(BaseSettings): list[AnyUrl] | str, BeforeValidator(parse_cors) ] = [] - @computed_field # type: ignore[prop-decorator] + @computed_field @property def all_cors_origins(self) -> list[str]: return [str(origin).rstrip("/") for origin in self.BACKEND_CORS_ORIGINS] + [ @@ -50,22 +49,31 @@ def all_cors_origins(self) -> list[str]: PROJECT_NAME: str SENTRY_DSN: HttpUrl | None = None - POSTGRES_SERVER: str + + USE_SQLITE: bool = True + SQLITE_DATABASE: str = "app.db" + + POSTGRES_SERVER: str = "localhost" POSTGRES_PORT: int = 5432 - POSTGRES_USER: str + POSTGRES_USER: str = "postgres" POSTGRES_PASSWORD: str = "" - POSTGRES_DB: str = "" + POSTGRES_DB: str = "app" - @computed_field # type: ignore[prop-decorator] + @computed_field @property - def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn: - return PostgresDsn.build( - scheme="postgresql+psycopg", - username=self.POSTGRES_USER, - password=self.POSTGRES_PASSWORD, - host=self.POSTGRES_SERVER, - port=self.POSTGRES_PORT, - path=self.POSTGRES_DB, + def SQLALCHEMY_DATABASE_URI(self) -> str: + if self.USE_SQLITE: + db_path = os.path.join(os.path.dirname(__file__), "..", "..", self.SQLITE_DATABASE) + return f"sqlite:///{os.path.abspath(db_path)}" + return str( + PostgresDsn.build( + scheme="postgresql+psycopg", + username=self.POSTGRES_USER, + password=self.POSTGRES_PASSWORD, + host=self.POSTGRES_SERVER, + port=self.POSTGRES_PORT, + path=self.POSTGRES_DB, + ) ) SMTP_TLS: bool = True @@ -85,7 +93,7 @@ def _set_default_emails_from(self) -> Self: EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48 - @computed_field # type: ignore[prop-decorator] + @computed_field @property def emails_enabled(self) -> bool: return bool(self.SMTP_HOST and self.EMAILS_FROM_EMAIL) @@ -116,4 +124,4 @@ def _enforce_non_default_secrets(self) -> Self: return self -settings = Settings() # type: ignore +settings = Settings() diff --git a/backend/app/core/db.py b/backend/app/core/db.py index ba991fb36d..da582d6dc6 100644 --- a/backend/app/core/db.py +++ b/backend/app/core/db.py @@ -1,25 +1,22 @@ -from sqlmodel import Session, create_engine, select +from sqlmodel import Session, SQLModel, create_engine, select from app import crud from app.core.config import settings from app.models import User, UserCreate -engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI)) +connect_args = {} +if settings.USE_SQLITE: + connect_args = {"check_same_thread": False} - -# make sure all SQLModel models are imported (app.models) before initializing DB -# otherwise, SQLModel might fail to initialize relationships properly -# for more details: https://github.com/fastapi/full-stack-fastapi-template/issues/28 +engine = create_engine( + str(settings.SQLALCHEMY_DATABASE_URI), + connect_args=connect_args, +) def init_db(session: Session) -> None: - # Tables should be created with Alembic migrations - # But if you don't want to use migrations, create - # the tables un-commenting the next lines - # from sqlmodel import SQLModel - - # This works because the models are already imported and registered from app.models - # SQLModel.metadata.create_all(engine) + if settings.USE_SQLITE: + SQLModel.metadata.create_all(engine) user = session.exec( select(User).where(User.email == settings.FIRST_SUPERUSER) diff --git a/backend/app/core/websocket.py b/backend/app/core/websocket.py new file mode 100644 index 0000000000..ab2aa983c2 --- /dev/null +++ b/backend/app/core/websocket.py @@ -0,0 +1,85 @@ +import asyncio +from typing import Any +from uuid import UUID + +from fastapi import WebSocket + +from app.models import NotificationPublic + + +class ConnectionManager: + def __init__(self) -> None: + self.active_connections: dict[UUID, list[WebSocket]] = {} + self._lock: asyncio.Lock = asyncio.Lock() + + async def connect(self, user_id: UUID, websocket: WebSocket) -> None: + await websocket.accept() + async with self._lock: + if user_id not in self.active_connections: + self.active_connections[user_id] = [] + self.active_connections[user_id].append(websocket) + + def disconnect(self, user_id: UUID, websocket: WebSocket) -> None: + if user_id in self.active_connections: + try: + self.active_connections[user_id].remove(websocket) + if not self.active_connections[user_id]: + del self.active_connections[user_id] + except ValueError: + pass + + async def send_personal_message( + self, message: dict[str, Any] | str, user_id: UUID + ) -> None: + async with self._lock: + if user_id in self.active_connections: + for websocket in self.active_connections[user_id]: + try: + if isinstance(message, dict): + await websocket.send_json(message) + else: + await websocket.send_text(message) + except Exception: + self.disconnect(user_id, websocket) + + async def send_notification( + self, notification: NotificationPublic, user_id: UUID + ) -> None: + message = { + "type": "notification", + "data": { + "id": str(notification.id), + "title": notification.title, + "message": notification.message, + "notification_type": notification.notification_type, + "is_read": notification.is_read, + "action_url": notification.action_url, + "user_id": str(notification.user_id), + "created_at": ( + notification.created_at.isoformat() + if notification.created_at + else None + ), + "updated_at": ( + notification.updated_at.isoformat() + if notification.updated_at + else None + ), + }, + } + await self.send_personal_message(message, user_id) + + async def broadcast(self, message: dict[str, Any] | str) -> None: + async with self._lock: + for user_id, connections in list(self.active_connections.items()): + for websocket in connections: + try: + if isinstance(message, dict): + await websocket.send_json(message) + else: + await websocket.send_text(message) + except Exception: + self.disconnect(user_id, websocket) + + +manager = ConnectionManager() diff --git a/backend/app/main.py b/backend/app/main.py index 9a95801e74..587a189de2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,10 +1,22 @@ import sentry_sdk +from contextlib import asynccontextmanager + from fastapi import FastAPI from fastapi.routing import APIRoute +from sqlmodel import Session from starlette.middleware.cors import CORSMiddleware from app.api.main import api_router +from app.api.routes.websocket import router as websocket_router from app.core.config import settings +from app.core.db import engine, init_db + + +@asynccontextmanager +async def lifespan(app: FastAPI): + with Session(engine) as session: + init_db(session) + yield def custom_generate_unique_id(route: APIRoute) -> str: @@ -18,9 +30,9 @@ def custom_generate_unique_id(route: APIRoute) -> str: title=settings.PROJECT_NAME, openapi_url=f"{settings.API_V1_STR}/openapi.json", generate_unique_id_function=custom_generate_unique_id, + lifespan=lifespan, ) -# Set all CORS enabled origins if settings.all_cors_origins: app.add_middleware( CORSMiddleware, @@ -31,3 +43,4 @@ def custom_generate_unique_id(route: APIRoute) -> str: ) app.include_router(api_router, prefix=settings.API_V1_STR) +app.include_router(websocket_router) diff --git a/backend/app/models.py b/backend/app/models.py index 0ae3cf6574..8ba8c61ae4 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,5 +1,6 @@ import uuid from datetime import datetime, timezone +from enum import Enum from pydantic import EmailStr from sqlalchemy import DateTime @@ -54,6 +55,7 @@ class User(UserBase, table=True): sa_type=DateTime(timezone=True), # type: ignore ) items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True) + notifications: list["Notification"] = Relationship(back_populates="user", cascade_delete=True) # Properties to return via API, id is always required @@ -127,3 +129,65 @@ class TokenPayload(SQLModel): class NewPassword(SQLModel): token: str new_password: str = Field(min_length=8, max_length=128) + + +class NotificationType(str, Enum): + INFO = "info" + SUCCESS = "success" + WARNING = "warning" + ERROR = "error" + + +class NotificationBase(SQLModel): + title: str = Field(min_length=1, max_length=255) + message: str | None = Field(default=None, max_length=1000) + notification_type: NotificationType = Field(default=NotificationType.INFO) + is_read: bool = Field(default=False) + action_url: str | None = Field(default=None, max_length=500) + + +class NotificationCreate(SQLModel): + title: str = Field(min_length=1, max_length=255) + message: str | None = Field(default=None, max_length=1000) + notification_type: NotificationType = Field(default=NotificationType.INFO) + action_url: str | None = Field(default=None, max_length=500) + user_id: uuid.UUID + + +class NotificationUpdate(SQLModel): + title: str | None = Field(default=None, min_length=1, max_length=255) + message: str | None = Field(default=None, max_length=1000) + is_read: bool | None = None + + +class NotificationMarkRead(SQLModel): + is_read: bool = True + + +class Notification(NotificationBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + created_at: datetime | None = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), + ) + updated_at: datetime | None = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), + ) + user_id: uuid.UUID = Field( + foreign_key="user.id", nullable=False, ondelete="CASCADE" + ) + user: User | None = Relationship(back_populates="notifications") + + +class NotificationPublic(NotificationBase): + id: uuid.UUID + user_id: uuid.UUID + created_at: datetime | None = None + updated_at: datetime | None = None + + +class NotificationsPublic(SQLModel): + data: list[NotificationPublic] + count: int + unread_count: int diff --git a/frontend/src/client/index.ts b/frontend/src/client/index.ts index 50a1dd734c..67bd061d1b 100644 --- a/frontend/src/client/index.ts +++ b/frontend/src/client/index.ts @@ -3,4 +3,6 @@ export { ApiError } from './core/ApiError'; export { CancelablePromise, CancelError } from './core/CancelablePromise'; export { OpenAPI, type OpenAPIConfig } from './core/OpenAPI'; export * from './sdk.gen'; -export * from './types.gen'; \ No newline at end of file +export * from './types.gen'; +// Custom notifications module (manually added) +export * from './notifications.gen'; \ No newline at end of file diff --git a/frontend/src/client/notifications.gen.ts b/frontend/src/client/notifications.gen.ts new file mode 100644 index 0000000000..64db26101e --- /dev/null +++ b/frontend/src/client/notifications.gen.ts @@ -0,0 +1,234 @@ +import type { CancelablePromise } from './core/CancelablePromise'; +import { OpenAPI } from './core/OpenAPI'; +import { request as __request } from './core/request'; + +export type NotificationType = 'info' | 'success' | 'warning' | 'error'; + +export type NotificationPublic = { + title: string; + message?: (string | null); + notification_type: NotificationType; + is_read: boolean; + action_url?: (string | null); + id: string; + user_id: string; + created_at?: (string | null); + updated_at?: (string | null); +}; + +export type NotificationsPublic = { + data: Array; + count: number; + unread_count: number; +}; + +export type NotificationCreate = { + title: string; + message?: (string | null); + notification_type?: NotificationType; + action_url?: (string | null); + user_id: string; +}; + +export type NotificationUpdate = { + title?: (string | null); + message?: (string | null); + is_read?: (boolean | null); +}; + +export type Message = { + message: string; +}; + +export type NotificationsReadNotificationsData = { + skip?: number; + limit?: number; + unread_only?: boolean; +}; + +export type NotificationsReadNotificationsResponse = NotificationsPublic; + +export type NotificationsCreateNotificationData = { + requestBody: NotificationCreate; +}; + +export type NotificationsCreateNotificationResponse = NotificationPublic; + +export type NotificationsReadNotificationData = { + id: string; +}; + +export type NotificationsReadNotificationResponse = NotificationPublic; + +export type NotificationsUpdateNotificationData = { + id: string; + requestBody: NotificationUpdate; +}; + +export type NotificationsUpdateNotificationResponse = NotificationPublic; + +export type NotificationsDeleteNotificationData = { + id: string; +}; + +export type NotificationsDeleteNotificationResponse = Message; + +export type NotificationsMarkNotificationReadData = { + id: string; +}; + +export type NotificationsMarkNotificationReadResponse = NotificationPublic; + +export type NotificationsMarkAllNotificationsReadResponse = Message; + +export class NotificationsService { + /** + * Read Notifications + * Retrieve notifications for the current user. + * @param data The data for the request. + * @param data.skip + * @param data.limit + * @param data.unread_only + * @returns NotificationsPublic Successful Response + * @throws ApiError + */ + public static readNotifications(data: NotificationsReadNotificationsData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/notifications/', + query: { + skip: data.skip, + limit: data.limit, + unread_only: data.unread_only + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Create Notification + * Create new notification. + * Only superusers can create notifications for other users. + * Regular users can only create notifications for themselves. + * @param data The data for the request. + * @param data.requestBody + * @returns NotificationPublic Successful Response + * @throws ApiError + */ + public static createNotification(data: NotificationsCreateNotificationData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/notifications/', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read Notification + * Get notification by ID. + * @param data The data for the request. + * @param data.id + * @returns NotificationPublic Successful Response + * @throws ApiError + */ + public static readNotification(data: NotificationsReadNotificationData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/notifications/{id}', + path: { + id: data.id + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Update Notification + * Update a notification. + * @param data The data for the request. + * @param data.id + * @param data.requestBody + * @returns NotificationPublic Successful Response + * @throws ApiError + */ + public static updateNotification(data: NotificationsUpdateNotificationData): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/api/v1/notifications/{id}', + path: { + id: data.id + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Delete Notification + * Delete a notification. + * @param data The data for the request. + * @param data.id + * @returns Message Successful Response + * @throws ApiError + */ + public static deleteNotification(data: NotificationsDeleteNotificationData): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/notifications/{id}', + path: { + id: data.id + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Mark Notification Read + * Mark a notification as read. + * @param data The data for the request. + * @param data.id + * @returns NotificationPublic Successful Response + * @throws ApiError + */ + public static markNotificationRead(data: NotificationsMarkNotificationReadData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/notifications/{id}/mark-read', + path: { + id: data.id + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Mark All Notifications Read + * Mark all notifications as read for the current user. + * @returns Message Successful Response + * @throws ApiError + */ + public static markAllNotificationsRead(): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/notifications/mark-all-read', + errors: { + 422: 'Validation Error' + } + }); + } +} diff --git a/frontend/src/components/Notifications/NotificationCenter.tsx b/frontend/src/components/Notifications/NotificationCenter.tsx new file mode 100644 index 0000000000..9be7c022db --- /dev/null +++ b/frontend/src/components/Notifications/NotificationCenter.tsx @@ -0,0 +1,466 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { Bell, Check, CheckCheck, Trash2, X } from "lucide-react" +import { useEffect, useRef, useState } from "react" + +import type { NotificationPublic, NotificationsPublic } from "@/client" +import { + NotificationsService, + type NotificationType, +} from "@/client" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet" +import { Skeleton } from "@/components/ui/skeleton" +import useCustomToast from "@/hooks/useCustomToast" +import { cn } from "@/lib/utils" +import { handleError } from "@/utils" + +const getNotificationTypeColor = (type: NotificationType) => { + switch (type) { + case "info": + return "bg-blue-500" + case "success": + return "bg-green-500" + case "warning": + return "bg-yellow-500" + case "error": + return "bg-red-500" + default: + return "bg-gray-500" + } +} + +const getNotificationTypeIcon = (type: NotificationType) => { + switch (type) { + case "info": + return "ℹ️" + case "success": + return "✅" + case "warning": + return "⚠️" + case "error": + return "❌" + default: + return "📢" + } +} + +function NotificationItem({ + notification, + onMarkRead, + onDelete, +}: { + notification: NotificationPublic + onMarkRead: (id: string) => void + onDelete: (id: string) => void +}) { + const formatDate = (dateString: string | null | undefined) => { + if (!dateString) return "" + const date = new Date(dateString) + const now = new Date() + const diff = now.getTime() - date.getTime() + const minutes = Math.floor(diff / 60000) + const hours = Math.floor(diff / 3600000) + const days = Math.floor(diff / 86400000) + + if (minutes < 1) return "Just now" + if (minutes < 60) return `${minutes}m ago` + if (hours < 24) return `${hours}h ago` + if (days < 7) return `${days}d ago` + return date.toLocaleDateString() + } + + return ( +
+
+
+ {!notification.is_read && ( +
+ )} +
+
+
+
+ {getNotificationTypeIcon(notification.notification_type)} +

+ {notification.title} +

+
+ + {formatDate(notification.created_at)} + +
+ {notification.message && ( +

+ {notification.message} +

+ )} +
+ {!notification.is_read && ( + + )} + +
+
+
+ ) +} + +function NotificationSkeleton() { + return ( +
+
+ + +
+
+
+ + +
+ + +
+
+ ) +} + +export function NotificationCenter() { + const [isOpen, setIsOpen] = useState(false) + const [wsConnected, setWsConnected] = useState(false) + const wsRef = useRef(null) + const queryClient = useQueryClient() + const { showSuccessToast, showErrorToast } = useCustomToast() + + const { data: notificationsData, isLoading } = useQuery({ + queryKey: ["notifications"], + queryFn: () => NotificationsService.readNotifications({ skip: 0, limit: 50 }), + refetchInterval: 30000, + }) + + const markReadMutation = useMutation({ + mutationFn: (id: string) => + NotificationsService.markNotificationRead({ id }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["notifications"] }) + showSuccessToast("Notification marked as read") + }, + onError: handleError.bind(showErrorToast), + }) + + const markAllReadMutation = useMutation({ + mutationFn: () => NotificationsService.markAllNotificationsRead(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["notifications"] }) + showSuccessToast("All notifications marked as read") + }, + onError: handleError.bind(showErrorToast), + }) + + const deleteMutation = useMutation({ + mutationFn: (id: string) => + NotificationsService.deleteNotification({ id }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["notifications"] }) + showSuccessToast("Notification deleted") + }, + onError: handleError.bind(showErrorToast), + }) + + useEffect(() => { + const token = localStorage.getItem("access_token") + if (!token) return + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:" + const wsUrl = `${protocol}//${window.location.host}/ws/notifications?token=${token}` + + const connect = () => { + try { + const ws = new WebSocket(wsUrl) + wsRef.current = ws + + ws.onopen = () => { + setWsConnected(true) + } + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data) + if (data.type === "notification") { + queryClient.invalidateQueries({ queryKey: ["notifications"] }) + } + } catch { + // Ignore non-JSON messages + } + } + + ws.onclose = () => { + setWsConnected(false) + setTimeout(connect, 5000) + } + + ws.onerror = () => { + setWsConnected(false) + } + } catch { + setTimeout(connect, 5000) + } + } + + connect() + + return () => { + if (wsRef.current) { + wsRef.current.close() + } + } + }, [queryClient]) + + const unreadCount = notificationsData?.unread_count || 0 + const notifications = notificationsData?.data || [] + + const handleMarkRead = (id: string) => { + markReadMutation.mutate(id) + } + + const handleMarkAllRead = () => { + markAllReadMutation.mutate() + } + + const handleDelete = (id: string) => { + deleteMutation.mutate(id) + } + + return ( + + + + + + + Notifications +
+ {wsConnected && ( + + + Live + + )} + {unreadCount > 0 && ( + + )} +
+
+
+ {isLoading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + )) + ) : notifications.length === 0 ? ( +
+
+ +
+

No notifications

+

+ You don't have any notifications yet +

+
+ ) : ( + notifications.map((notification) => ( + + )) + )} +
+
+
+ ) +} + +export function NotificationDropdown() { + const queryClient = useQueryClient() + const { showSuccessToast, showErrorToast } = useCustomToast() + + const { data: notificationsData, isLoading } = useQuery({ + queryKey: ["notifications"], + queryFn: () => NotificationsService.readNotifications({ skip: 0, limit: 10 }), + refetchInterval: 30000, + }) + + const markReadMutation = useMutation({ + mutationFn: (id: string) => + NotificationsService.markNotificationRead({ id }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["notifications"] }) + }, + onError: handleError.bind(showErrorToast), + }) + + const markAllReadMutation = useMutation({ + mutationFn: () => NotificationsService.markAllNotificationsRead(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["notifications"] }) + showSuccessToast("All notifications marked as read") + }, + onError: handleError.bind(showErrorToast), + }) + + const unreadCount = notificationsData?.unread_count || 0 + const notifications = notificationsData?.data || [] + + return ( + + + + + + + Notifications + {unreadCount > 0 && ( + + )} + + + + {isLoading ? ( + Array.from({ length: 3 }).map((_, i) => ( +
+ + +
+ )) + ) : notifications.length === 0 ? ( +
+ No notifications +
+ ) : ( + notifications.map((notification) => ( + { + if (!notification.is_read) { + markReadMutation.mutate(notification.id) + } + }} + > +
+ + {getNotificationTypeIcon(notification.notification_type)} + {notification.title} + + {!notification.is_read && ( +
+ )} +
+ {notification.message && ( +

+ {notification.message} +

+ )} + + )) + )} + + + + View all notifications + + + + ) +} diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 8849130b4c..08d665fef8 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -65,6 +65,7 @@ const LayoutAdminRoute = LayoutAdminRouteImport.update({ } as any) export interface FileRoutesByFullPath { + '/': typeof LayoutIndexRoute '/login': typeof LoginRoute '/recover-password': typeof RecoverPasswordRoute '/reset-password': typeof ResetPasswordRoute @@ -72,7 +73,6 @@ export interface FileRoutesByFullPath { '/admin': typeof LayoutAdminRoute '/items': typeof LayoutItemsRoute '/settings': typeof LayoutSettingsRoute - '/': typeof LayoutIndexRoute } export interface FileRoutesByTo { '/login': typeof LoginRoute @@ -99,6 +99,7 @@ export interface FileRoutesById { export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: + | '/' | '/login' | '/recover-password' | '/reset-password' @@ -106,7 +107,6 @@ export interface FileRouteTypes { | '/admin' | '/items' | '/settings' - | '/' fileRoutesByTo: FileRoutesByTo to: | '/login' @@ -171,7 +171,7 @@ declare module '@tanstack/react-router' { '/_layout': { id: '/_layout' path: '' - fullPath: '' + fullPath: '/' preLoaderRoute: typeof LayoutRouteImport parentRoute: typeof rootRouteImport } diff --git a/frontend/src/routes/_layout.tsx b/frontend/src/routes/_layout.tsx index 169730546e..eb08a1ba24 100644 --- a/frontend/src/routes/_layout.tsx +++ b/frontend/src/routes/_layout.tsx @@ -1,6 +1,7 @@ import { createFileRoute, Outlet, redirect } from "@tanstack/react-router" import { Footer } from "@/components/Common/Footer" +import { NotificationCenter } from "@/components/Notifications/NotificationCenter" import AppSidebar from "@/components/Sidebar/AppSidebar" import { SidebarInset, @@ -25,8 +26,11 @@ function Layout() { -
+
+
+ +
From 0ffae1fbd718a5f5d8fbdde88067fbd39d3bb06f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 03:22:16 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format=20and=20update?= =?UTF-8?q?=20with=20pre-commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/routes/notifications.py | 14 +- backend/app/api/routes/websocket.py | 10 +- backend/app/core/config.py | 4 +- backend/app/main.py | 2 +- backend/app/models.py | 4 +- frontend/src/client/index.ts | 4 +- frontend/src/client/notifications.gen.ts | 234 ------------------ frontend/src/client/schemas.gen.ts | 200 +++++++++++++++ frontend/src/client/sdk.gen.ts | 151 ++++++++++- frontend/src/client/types.gen.ts | 75 ++++++ .../Notifications/NotificationCenter.tsx | 24 +- 11 files changed, 464 insertions(+), 258 deletions(-) delete mode 100644 frontend/src/client/notifications.gen.ts diff --git a/backend/app/api/routes/notifications.py b/backend/app/api/routes/notifications.py index 41bd390342..970b42cd72 100644 --- a/backend/app/api/routes/notifications.py +++ b/backend/app/api/routes/notifications.py @@ -35,8 +35,10 @@ def read_notifications( count_statement = count_statement.where(Notification.is_read == False) count = session.exec(count_statement).one() - unread_count_statement = select(func.count()).select_from(Notification).where( - Notification.is_read == False + unread_count_statement = ( + select(func.count()) + .select_from(Notification) + .where(Notification.is_read == False) ) unread_count = session.exec(unread_count_statement).one() @@ -104,7 +106,10 @@ def read_notification( @router.post("/", response_model=NotificationPublic) async def create_notification( - *, session: SessionDep, current_user: CurrentUser, notification_in: NotificationCreate + *, + session: SessionDep, + current_user: CurrentUser, + notification_in: NotificationCreate, ) -> Any: """ Create new notification. @@ -113,7 +118,8 @@ async def create_notification( """ if not current_user.is_superuser and notification_in.user_id != current_user.id: raise HTTPException( - status_code=403, detail="Not enough permissions to create notifications for other users" + status_code=403, + detail="Not enough permissions to create notifications for other users", ) notification = Notification.model_validate(notification_in) diff --git a/backend/app/api/routes/websocket.py b/backend/app/api/routes/websocket.py index 228ca648a6..9c41271aad 100644 --- a/backend/app/api/routes/websocket.py +++ b/backend/app/api/routes/websocket.py @@ -1,9 +1,15 @@ import logging from typing import Annotated -from uuid import UUID import jwt -from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect, status +from fastapi import ( + APIRouter, + HTTPException, + Query, + WebSocket, + WebSocketDisconnect, + status, +) from jwt.exceptions import InvalidTokenError from pydantic import ValidationError from sqlmodel import Session diff --git a/backend/app/core/config.py b/backend/app/core/config.py index f8c919005c..94819457f6 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -63,7 +63,9 @@ def all_cors_origins(self) -> list[str]: @property def SQLALCHEMY_DATABASE_URI(self) -> str: if self.USE_SQLITE: - db_path = os.path.join(os.path.dirname(__file__), "..", "..", self.SQLITE_DATABASE) + db_path = os.path.join( + os.path.dirname(__file__), "..", "..", self.SQLITE_DATABASE + ) return f"sqlite:///{os.path.abspath(db_path)}" return str( PostgresDsn.build( diff --git a/backend/app/main.py b/backend/app/main.py index 587a189de2..f48237941a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,6 +1,6 @@ -import sentry_sdk from contextlib import asynccontextmanager +import sentry_sdk from fastapi import FastAPI from fastapi.routing import APIRoute from sqlmodel import Session diff --git a/backend/app/models.py b/backend/app/models.py index 8ba8c61ae4..856684bf98 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -55,7 +55,9 @@ class User(UserBase, table=True): sa_type=DateTime(timezone=True), # type: ignore ) items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True) - notifications: list["Notification"] = Relationship(back_populates="user", cascade_delete=True) + notifications: list["Notification"] = Relationship( + back_populates="user", cascade_delete=True + ) # Properties to return via API, id is always required diff --git a/frontend/src/client/index.ts b/frontend/src/client/index.ts index 67bd061d1b..50a1dd734c 100644 --- a/frontend/src/client/index.ts +++ b/frontend/src/client/index.ts @@ -3,6 +3,4 @@ export { ApiError } from './core/ApiError'; export { CancelablePromise, CancelError } from './core/CancelablePromise'; export { OpenAPI, type OpenAPIConfig } from './core/OpenAPI'; export * from './sdk.gen'; -export * from './types.gen'; -// Custom notifications module (manually added) -export * from './notifications.gen'; \ No newline at end of file +export * from './types.gen'; \ No newline at end of file diff --git a/frontend/src/client/notifications.gen.ts b/frontend/src/client/notifications.gen.ts deleted file mode 100644 index 64db26101e..0000000000 --- a/frontend/src/client/notifications.gen.ts +++ /dev/null @@ -1,234 +0,0 @@ -import type { CancelablePromise } from './core/CancelablePromise'; -import { OpenAPI } from './core/OpenAPI'; -import { request as __request } from './core/request'; - -export type NotificationType = 'info' | 'success' | 'warning' | 'error'; - -export type NotificationPublic = { - title: string; - message?: (string | null); - notification_type: NotificationType; - is_read: boolean; - action_url?: (string | null); - id: string; - user_id: string; - created_at?: (string | null); - updated_at?: (string | null); -}; - -export type NotificationsPublic = { - data: Array; - count: number; - unread_count: number; -}; - -export type NotificationCreate = { - title: string; - message?: (string | null); - notification_type?: NotificationType; - action_url?: (string | null); - user_id: string; -}; - -export type NotificationUpdate = { - title?: (string | null); - message?: (string | null); - is_read?: (boolean | null); -}; - -export type Message = { - message: string; -}; - -export type NotificationsReadNotificationsData = { - skip?: number; - limit?: number; - unread_only?: boolean; -}; - -export type NotificationsReadNotificationsResponse = NotificationsPublic; - -export type NotificationsCreateNotificationData = { - requestBody: NotificationCreate; -}; - -export type NotificationsCreateNotificationResponse = NotificationPublic; - -export type NotificationsReadNotificationData = { - id: string; -}; - -export type NotificationsReadNotificationResponse = NotificationPublic; - -export type NotificationsUpdateNotificationData = { - id: string; - requestBody: NotificationUpdate; -}; - -export type NotificationsUpdateNotificationResponse = NotificationPublic; - -export type NotificationsDeleteNotificationData = { - id: string; -}; - -export type NotificationsDeleteNotificationResponse = Message; - -export type NotificationsMarkNotificationReadData = { - id: string; -}; - -export type NotificationsMarkNotificationReadResponse = NotificationPublic; - -export type NotificationsMarkAllNotificationsReadResponse = Message; - -export class NotificationsService { - /** - * Read Notifications - * Retrieve notifications for the current user. - * @param data The data for the request. - * @param data.skip - * @param data.limit - * @param data.unread_only - * @returns NotificationsPublic Successful Response - * @throws ApiError - */ - public static readNotifications(data: NotificationsReadNotificationsData = {}): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/api/v1/notifications/', - query: { - skip: data.skip, - limit: data.limit, - unread_only: data.unread_only - }, - errors: { - 422: 'Validation Error' - } - }); - } - - /** - * Create Notification - * Create new notification. - * Only superusers can create notifications for other users. - * Regular users can only create notifications for themselves. - * @param data The data for the request. - * @param data.requestBody - * @returns NotificationPublic Successful Response - * @throws ApiError - */ - public static createNotification(data: NotificationsCreateNotificationData): CancelablePromise { - return __request(OpenAPI, { - method: 'POST', - url: '/api/v1/notifications/', - body: data.requestBody, - mediaType: 'application/json', - errors: { - 422: 'Validation Error' - } - }); - } - - /** - * Read Notification - * Get notification by ID. - * @param data The data for the request. - * @param data.id - * @returns NotificationPublic Successful Response - * @throws ApiError - */ - public static readNotification(data: NotificationsReadNotificationData): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/api/v1/notifications/{id}', - path: { - id: data.id - }, - errors: { - 422: 'Validation Error' - } - }); - } - - /** - * Update Notification - * Update a notification. - * @param data The data for the request. - * @param data.id - * @param data.requestBody - * @returns NotificationPublic Successful Response - * @throws ApiError - */ - public static updateNotification(data: NotificationsUpdateNotificationData): CancelablePromise { - return __request(OpenAPI, { - method: 'PUT', - url: '/api/v1/notifications/{id}', - path: { - id: data.id - }, - body: data.requestBody, - mediaType: 'application/json', - errors: { - 422: 'Validation Error' - } - }); - } - - /** - * Delete Notification - * Delete a notification. - * @param data The data for the request. - * @param data.id - * @returns Message Successful Response - * @throws ApiError - */ - public static deleteNotification(data: NotificationsDeleteNotificationData): CancelablePromise { - return __request(OpenAPI, { - method: 'DELETE', - url: '/api/v1/notifications/{id}', - path: { - id: data.id - }, - errors: { - 422: 'Validation Error' - } - }); - } - - /** - * Mark Notification Read - * Mark a notification as read. - * @param data The data for the request. - * @param data.id - * @returns NotificationPublic Successful Response - * @throws ApiError - */ - public static markNotificationRead(data: NotificationsMarkNotificationReadData): CancelablePromise { - return __request(OpenAPI, { - method: 'POST', - url: '/api/v1/notifications/{id}/mark-read', - path: { - id: data.id - }, - errors: { - 422: 'Validation Error' - } - }); - } - - /** - * Mark All Notifications Read - * Mark all notifications as read for the current user. - * @returns Message Successful Response - * @throws ApiError - */ - public static markAllNotificationsRead(): CancelablePromise { - return __request(OpenAPI, { - method: 'POST', - url: '/api/v1/notifications/mark-all-read', - errors: { - 422: 'Validation Error' - } - }); - } -} diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index fb66c1f837..a3d3e8338b 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -226,6 +226,206 @@ export const NewPasswordSchema = { title: 'NewPassword' } as const; +export const NotificationCreateSchema = { + properties: { + title: { + type: 'string', + maxLength: 255, + minLength: 1, + title: 'Title' + }, + message: { + anyOf: [ + { + type: 'string', + maxLength: 1000 + }, + { + type: 'null' + } + ], + title: 'Message' + }, + notification_type: { + '$ref': '#/components/schemas/NotificationType', + default: 'info' + }, + action_url: { + anyOf: [ + { + type: 'string', + maxLength: 500 + }, + { + type: 'null' + } + ], + title: 'Action Url' + }, + user_id: { + type: 'string', + format: 'uuid', + title: 'User Id' + } + }, + type: 'object', + required: ['title', 'user_id'], + title: 'NotificationCreate' +} as const; + +export const NotificationPublicSchema = { + properties: { + title: { + type: 'string', + maxLength: 255, + minLength: 1, + title: 'Title' + }, + message: { + anyOf: [ + { + type: 'string', + maxLength: 1000 + }, + { + type: 'null' + } + ], + title: 'Message' + }, + notification_type: { + '$ref': '#/components/schemas/NotificationType', + default: 'info' + }, + is_read: { + type: 'boolean', + title: 'Is Read', + default: false + }, + action_url: { + anyOf: [ + { + type: 'string', + maxLength: 500 + }, + { + type: 'null' + } + ], + title: 'Action Url' + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + user_id: { + type: 'string', + format: 'uuid', + title: 'User Id' + }, + created_at: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Created At' + }, + updated_at: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Updated At' + } + }, + type: 'object', + required: ['title', 'id', 'user_id'], + title: 'NotificationPublic' +} as const; + +export const NotificationTypeSchema = { + type: 'string', + enum: ['info', 'success', 'warning', 'error'], + title: 'NotificationType' +} as const; + +export const NotificationUpdateSchema = { + properties: { + title: { + anyOf: [ + { + type: 'string', + maxLength: 255, + minLength: 1 + }, + { + type: 'null' + } + ], + title: 'Title' + }, + message: { + anyOf: [ + { + type: 'string', + maxLength: 1000 + }, + { + type: 'null' + } + ], + title: 'Message' + }, + is_read: { + anyOf: [ + { + type: 'boolean' + }, + { + type: 'null' + } + ], + title: 'Is Read' + } + }, + type: 'object', + title: 'NotificationUpdate' +} as const; + +export const NotificationsPublicSchema = { + properties: { + data: { + items: { + '$ref': '#/components/schemas/NotificationPublic' + }, + type: 'array', + title: 'Data' + }, + count: { + type: 'integer', + title: 'Count' + }, + unread_count: { + type: 'integer', + title: 'Unread Count' + } + }, + type: 'object', + required: ['data', 'count', 'unread_count'], + title: 'NotificationsPublic' +} as const; + export const PrivateUserCreateSchema = { properties: { email: { diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index ba79e3f726..ecfd5ee9c1 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen'; +import type { ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, NotificationsReadNotificationsData, NotificationsReadNotificationsResponse, NotificationsCreateNotificationData, NotificationsCreateNotificationResponse, NotificationsReadNotificationData, NotificationsReadNotificationResponse, NotificationsUpdateNotificationData, NotificationsUpdateNotificationResponse, NotificationsDeleteNotificationData, NotificationsDeleteNotificationResponse, NotificationsMarkNotificationReadData, NotificationsMarkNotificationReadResponse, NotificationsMarkAllNotificationsReadResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen'; export class ItemsService { /** @@ -213,6 +213,155 @@ export class LoginService { } } +export class NotificationsService { + /** + * Read Notifications + * Retrieve notifications for the current user. + * @param data The data for the request. + * @param data.skip + * @param data.limit + * @param data.unreadOnly + * @returns NotificationsPublic Successful Response + * @throws ApiError + */ + public static readNotifications(data: NotificationsReadNotificationsData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/notifications/', + query: { + skip: data.skip, + limit: data.limit, + unread_only: data.unreadOnly + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Create Notification + * Create new notification. + * Only superusers can create notifications for other users. + * Regular users can only create notifications for themselves. + * @param data The data for the request. + * @param data.requestBody + * @returns NotificationPublic Successful Response + * @throws ApiError + */ + public static createNotification(data: NotificationsCreateNotificationData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/notifications/', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read Notification + * Get notification by ID. + * @param data The data for the request. + * @param data.id + * @returns NotificationPublic Successful Response + * @throws ApiError + */ + public static readNotification(data: NotificationsReadNotificationData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/notifications/{id}', + path: { + id: data.id + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Update Notification + * Update a notification. + * @param data The data for the request. + * @param data.id + * @param data.requestBody + * @returns NotificationPublic Successful Response + * @throws ApiError + */ + public static updateNotification(data: NotificationsUpdateNotificationData): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/api/v1/notifications/{id}', + path: { + id: data.id + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Delete Notification + * Delete a notification. + * @param data The data for the request. + * @param data.id + * @returns Message Successful Response + * @throws ApiError + */ + public static deleteNotification(data: NotificationsDeleteNotificationData): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/notifications/{id}', + path: { + id: data.id + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Mark Notification Read + * Mark a notification as read. + * @param data The data for the request. + * @param data.id + * @returns NotificationPublic Successful Response + * @throws ApiError + */ + public static markNotificationRead(data: NotificationsMarkNotificationReadData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/notifications/{id}/mark-read', + path: { + id: data.id + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Mark All Notifications Read + * Mark all notifications as read for the current user. + * @returns Message Successful Response + * @throws ApiError + */ + public static markAllNotificationsRead(): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/notifications/mark-all-read' + }); + } +} + export class PrivateService { /** * Create User diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 91b5ba34c2..e7dcf5227c 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -45,6 +45,40 @@ export type NewPassword = { new_password: string; }; +export type NotificationCreate = { + title: string; + message?: (string | null); + notification_type?: NotificationType; + action_url?: (string | null); + user_id: string; +}; + +export type NotificationPublic = { + title: string; + message?: (string | null); + notification_type?: NotificationType; + is_read?: boolean; + action_url?: (string | null); + id: string; + user_id: string; + created_at?: (string | null); + updated_at?: (string | null); +}; + +export type NotificationsPublic = { + data: Array; + count: number; + unread_count: number; +}; + +export type NotificationType = 'info' | 'success' | 'warning' | 'error'; + +export type NotificationUpdate = { + title?: (string | null); + message?: (string | null); + is_read?: (boolean | null); +}; + export type PrivateUserCreate = { email: string; password: string; @@ -171,6 +205,47 @@ export type LoginRecoverPasswordHtmlContentData = { export type LoginRecoverPasswordHtmlContentResponse = (string); +export type NotificationsReadNotificationsData = { + limit?: number; + skip?: number; + unreadOnly?: boolean; +}; + +export type NotificationsReadNotificationsResponse = (NotificationsPublic); + +export type NotificationsCreateNotificationData = { + requestBody: NotificationCreate; +}; + +export type NotificationsCreateNotificationResponse = (NotificationPublic); + +export type NotificationsReadNotificationData = { + id: string; +}; + +export type NotificationsReadNotificationResponse = (NotificationPublic); + +export type NotificationsUpdateNotificationData = { + id: string; + requestBody: NotificationUpdate; +}; + +export type NotificationsUpdateNotificationResponse = (NotificationPublic); + +export type NotificationsDeleteNotificationData = { + id: string; +}; + +export type NotificationsDeleteNotificationResponse = (Message); + +export type NotificationsMarkNotificationReadData = { + id: string; +}; + +export type NotificationsMarkNotificationReadResponse = (NotificationPublic); + +export type NotificationsMarkAllNotificationsReadResponse = (Message); + export type PrivateCreateUserData = { requestBody: PrivateUserCreate; }; diff --git a/frontend/src/components/Notifications/NotificationCenter.tsx b/frontend/src/components/Notifications/NotificationCenter.tsx index 9be7c022db..cce41bbcf5 100644 --- a/frontend/src/components/Notifications/NotificationCenter.tsx +++ b/frontend/src/components/Notifications/NotificationCenter.tsx @@ -1,12 +1,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { Bell, Check, CheckCheck, Trash2, X } from "lucide-react" +import { Bell, Check, CheckCheck, Trash2 } from "lucide-react" import { useEffect, useRef, useState } from "react" import type { NotificationPublic, NotificationsPublic } from "@/client" -import { - NotificationsService, - type NotificationType, -} from "@/client" +import { NotificationsService, type NotificationType } from "@/client" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { @@ -106,7 +103,9 @@ function NotificationItem({
- {getNotificationTypeIcon(notification.notification_type)} + + {getNotificationTypeIcon(notification.notification_type)} +

({ queryKey: ["notifications"], - queryFn: () => NotificationsService.readNotifications({ skip: 0, limit: 50 }), + queryFn: () => + NotificationsService.readNotifications({ skip: 0, limit: 50 }), refetchInterval: 30000, }) @@ -204,8 +204,7 @@ export function NotificationCenter() { }) const deleteMutation = useMutation({ - mutationFn: (id: string) => - NotificationsService.deleteNotification({ id }), + mutationFn: (id: string) => NotificationsService.deleteNotification({ id }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["notifications"] }) showSuccessToast("Notification deleted") @@ -354,7 +353,8 @@ export function NotificationDropdown() { const { data: notificationsData, isLoading } = useQuery({ queryKey: ["notifications"], - queryFn: () => NotificationsService.readNotifications({ skip: 0, limit: 10 }), + queryFn: () => + NotificationsService.readNotifications({ skip: 0, limit: 10 }), refetchInterval: 30000, }) @@ -440,7 +440,9 @@ export function NotificationDropdown() { >
- {getNotificationTypeIcon(notification.notification_type)} + + {getNotificationTypeIcon(notification.notification_type)} + {notification.title} {!notification.is_read && (