From 1d111a031eecff62fff0061a6c2fcec83bc36b15 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Tue, 30 Jun 2026 17:24:59 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20negodata:=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=ED=95=A8=20=E2=80=94=20=EA=B2=AC=EC=A0=81=20=EB=A7=88=EA=B0=90?= =?UTF-8?q?=20=EA=B2=B0=EA=B3=BC(=EB=82=99=EC=B0=B0/=EC=9E=AC=EC=83=9D?= =?UTF-8?q?=EC=84=B1/=EA=B2=B0=EB=A0=AC)=20=EC=9E=90=EB=8F=99=20=ED=86=B5?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - company.notifications 테이블 + NotificationType(SUCCESS/REGENERATED/FAILURE) - 백엔드 알림 조회/읽음 API + close_and_decide 결과 분기마다 알림 생성 - 헤더 알림 벨(안읽음 배지) + 알림 페이지(읽음·딥링크) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../backend/common/database/model/models.py | 13 + negodata/backend/common/enums.py | 9 + negodata/backend/crud/notification_crud.py | 107 ++++++++ negodata/backend/router/router.py | 2 + .../router/v1/notification/notification.py | 32 +++ .../router/v1/notification/protocol.py | 33 +++ negodata/backend/services/notification.py | 88 ++++++ .../backend/services/quotation_service.py | 31 ++- .../front/src/api/generated/model/index.ts | 12 + .../model/listNotificationsParams.ts | 18 ++ .../api/generated/model/notificationData.ts | 22 ++ .../model/notificationDataCreatedAt.ts | 8 + .../generated/model/notificationDataData.ts | 8 + .../generated/model/notificationDataReadAt.ts | 8 + .../model/notificationDataRefQtId.ts | 8 + .../model/notificationDataRefSessionId.ts | 8 + .../api/generated/model/notificationType.ts | 20 ++ .../generated/model/resNotificationList.ts | 19 ++ .../generated/model/resNotificationListMsg.ts | 8 + .../generated/model/resNotificationRead.ts | 13 + .../generated/model/resNotificationReadMsg.ts | 8 + .../generated/notification/notification.ts | 257 ++++++++++++++++++ negodata/front/src/app/router.tsx | 2 + .../components/layout/AuthenticatedLayout.tsx | 1 + .../front/src/components/layout/Layout.tsx | 7 +- .../components/layout/NotificationBell.tsx | 28 ++ negodata/front/src/pages/notifications.tsx | 188 +++++++++++++ negodata/front/src/types.ts | 2 +- postgres-init/01-schema.sql | 19 ++ postgres-init/04-alter.sql | 19 ++ 30 files changed, 995 insertions(+), 3 deletions(-) create mode 100644 negodata/backend/crud/notification_crud.py create mode 100644 negodata/backend/router/v1/notification/notification.py create mode 100644 negodata/backend/router/v1/notification/protocol.py create mode 100644 negodata/backend/services/notification.py create mode 100644 negodata/front/src/api/generated/model/listNotificationsParams.ts create mode 100644 negodata/front/src/api/generated/model/notificationData.ts create mode 100644 negodata/front/src/api/generated/model/notificationDataCreatedAt.ts create mode 100644 negodata/front/src/api/generated/model/notificationDataData.ts create mode 100644 negodata/front/src/api/generated/model/notificationDataReadAt.ts create mode 100644 negodata/front/src/api/generated/model/notificationDataRefQtId.ts create mode 100644 negodata/front/src/api/generated/model/notificationDataRefSessionId.ts create mode 100644 negodata/front/src/api/generated/model/notificationType.ts create mode 100644 negodata/front/src/api/generated/model/resNotificationList.ts create mode 100644 negodata/front/src/api/generated/model/resNotificationListMsg.ts create mode 100644 negodata/front/src/api/generated/model/resNotificationRead.ts create mode 100644 negodata/front/src/api/generated/model/resNotificationReadMsg.ts create mode 100644 negodata/front/src/api/generated/notification/notification.ts create mode 100644 negodata/front/src/components/layout/NotificationBell.tsx create mode 100644 negodata/front/src/pages/notifications.tsx diff --git a/negodata/backend/common/database/model/models.py b/negodata/backend/common/database/model/models.py index 91ff9b4..ff1edbe 100644 --- a/negodata/backend/common/database/model/models.py +++ b/negodata/backend/common/database/model/models.py @@ -65,6 +65,19 @@ class users(MainTableMixin, MAIN_BASE): role = Column(SmallInteger, nullable=False, default=UserRole.USER.value) +class notifications(MainTableMixin, MAIN_BASE): + __tablename__ = "notifications" + __table_args__ = {"schema": "company"} + + notification_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 수신자(users.user_id) = 견적 작성자 + type = Column(SmallInteger, nullable=False) # NotificationType: 1=success(낙찰) 2=regenerated(재생성) 3=failure(결렬) + ref_qt_id = Column(UUID(as_uuid=True), nullable=True) # 관련 견적(quotations.qt_id) + ref_session_id = Column(UUID(as_uuid=True), nullable=True) # 관련 세션(sessions.session_id) + data = Column(JSONB, nullable=True) # 렌더 스냅샷(유형별) + read_at = Column(DateTime(timezone=True), nullable=True) # 읽은 시각(NULL=안읽음) + + class items(MainTableMixin, MAIN_BASE): __tablename__ = "items" __table_args__ = {"schema": "partner"} diff --git a/negodata/backend/common/enums.py b/negodata/backend/common/enums.py index 4d03152..5dcde46 100644 --- a/negodata/backend/common/enums.py +++ b/negodata/backend/common/enums.py @@ -172,6 +172,15 @@ class CloseOutcome(Enum): REGEN_FAILED = "regen_failed" # 재생성 시도했으나 실패 — 원본은 CLOSED 인데 다음 라운드가 없음(체인 끊김, 모니터링 필요) +class NotificationType(CodeEnum): + """company.notifications.type 코드값. 견적 생애 이벤트를 작성자에게 통지. 마감 결과 3종(SUCCESS/REGENERATED/FAILURE)은 close_and_decide 와 1:1. 네이밍은 KTC.""" + + SUCCESS = 1 # 낙찰(단독 최저가) — KTC SUCCESS + REGENERATED = 2 # 다음 라운드 자동 생성(동가/미참여) — KTC 대응어 없어 negodata 유지 + FAILURE = 3 # 결렬: 낙찰 없이 마감(거절/부분/한도) — KTC FAILURE + CREATED = 4 # 견적 생성됨(작성 직후) — 생성 알림 + + class ChatSender(CodeEnum): """negotiation.chats.sender 코드값. 채팅 발신 주체.""" diff --git a/negodata/backend/crud/notification_crud.py b/negodata/backend/crud/notification_crud.py new file mode 100644 index 0000000..05d173c --- /dev/null +++ b/negodata/backend/crud/notification_crud.py @@ -0,0 +1,107 @@ +from abc import ABC, abstractmethod +from typing import Tuple + +from sqlalchemy import select, func, update +from sqlalchemy.ext.asyncio import AsyncSession + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import notifications +from common.enums import ErrorType +from common.logger import LOG + + +# 알림 CRUD. 항상 user_id(수신자)로 스코프한다. +class INotificationCRUD(ABC): + @abstractmethod + async def list_for_user(self, cdb: AsyncSession, user_id, skip, limit) -> Tuple[ErrorType, list, int]: + pass + + @abstractmethod + async def count_unread(self, cdb: AsyncSession, user_id) -> Tuple[ErrorType, int]: + pass + + @abstractmethod + async def mark_read(self, cdb: AsyncSession, user_id, notification_id, ts) -> ErrorType: + pass + + @abstractmethod + async def mark_all_read(self, cdb: AsyncSession, user_id, ts) -> ErrorType: + pass + + +class NotificationCRUD(INotificationCRUD): + async def list_for_user(self, cdb: AsyncSession, user_id, skip, limit) -> Tuple[ErrorType, list, int]: + try: + cnt_err, cnt_rows = await DB_SESSION_MNG.execute( + cdb, + select(func.count()).select_from(notifications).where( + notifications.user_id == user_id, notifications.deleted == False # noqa: E712 + ), + ) + if cnt_err != ErrorType.SUCCESS: + return cnt_err, [], 0 + total = int(cnt_rows[0] or 0) if cnt_rows else 0 + + list_err, rows = await DB_SESSION_MNG.execute( + cdb, + select(notifications) + .where(notifications.user_id == user_id, notifications.deleted == False) # noqa: E712 + .order_by(notifications.created_at.desc()) + .offset(skip) + .limit(limit), + ) + if list_err != ErrorType.SUCCESS: + return list_err, [], 0 + return ErrorType.SUCCESS, list(rows), total + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [], 0 + + async def count_unread(self, cdb: AsyncSession, user_id) -> Tuple[ErrorType, int]: + try: + err_type, rows = await DB_SESSION_MNG.execute( + cdb, + select(func.count()).select_from(notifications).where( + notifications.user_id == user_id, + notifications.read_at.is_(None), + notifications.deleted == False, # noqa: E712 + ), + ) + if err_type != ErrorType.SUCCESS: + return err_type, 0 + return ErrorType.SUCCESS, (int(rows[0] or 0) if rows else 0) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, 0 + + async def mark_read(self, cdb: AsyncSession, user_id, notification_id, ts) -> ErrorType: + try: + query = ( + update(notifications) + .where( + notifications.notification_id == notification_id, + notifications.user_id == user_id, # 남의 알림 못 건드리게 수신자 스코프 + notifications.read_at.is_(None), + ) + .values(read_at=ts, updated_at=ts) + ) + return await DB_SESSION_MNG.add(cdb, query) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED + + async def mark_all_read(self, cdb: AsyncSession, user_id, ts) -> ErrorType: + try: + query = ( + update(notifications) + .where( + notifications.user_id == user_id, + notifications.read_at.is_(None), + notifications.deleted == False, # noqa: E712 + ) + .values(read_at=ts, updated_at=ts) + ) + return await DB_SESSION_MNG.add(cdb, query) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED diff --git a/negodata/backend/router/router.py b/negodata/backend/router/router.py index 14f1762..f2ceabc 100644 --- a/negodata/backend/router/router.py +++ b/negodata/backend/router/router.py @@ -18,6 +18,7 @@ import router.v1.card.card import router.v1.quotation.quotation import router.v1.quotation_setting.quotation_setting import router.v1.dashboard.dashboard +import router.v1.notification.notification API_SERVER_START_TIME = GTime.UTCStr() @@ -71,3 +72,4 @@ app.include_router(router.v1.card.card.router) app.include_router(router.v1.quotation.quotation.router) app.include_router(router.v1.quotation_setting.quotation_setting.router) app.include_router(router.v1.dashboard.dashboard.router) +app.include_router(router.v1.notification.notification.router) diff --git a/negodata/backend/router/v1/notification/notification.py b/negodata/backend/router/v1/notification/notification.py new file mode 100644 index 0000000..6b1d6be --- /dev/null +++ b/negodata/backend/router/v1/notification/notification.py @@ -0,0 +1,32 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends + +from common.models.gmodel import PageParams, UserInfo +from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse +from services.notification import NotificationService +from .protocol import Res_NotificationList, Res_NotificationRead + +# 알림(인박스) 라우터. 항상 로그인 유저(user_info.user_id) 기준으로 조회/처리한다. +router = APIRouter(prefix="/v1/notification", tags=["Notification"], responses={404: {"description": "Not found"}}) + + +@router.get(path="/list", response_model=Res_NotificationList, summary="알림 목록(+안읽음 수)") +async def list_notifications( + service: NotificationService = Depends(), + user_info: UserInfo = Depends(IsValidAccessToken), + pg: PageParams = Depends(), +): + return RemoveNoneResponse(await service.list_notifications(user_info.user_id, pg)) + + +@router.post(path="/read-all", response_model=Res_NotificationRead, summary="전체 읽음 처리") +async def read_all(service: NotificationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): + return RemoveNoneResponse(await service.mark_all_read(user_info.user_id)) + + +@router.post(path="/{notification_id}/read", response_model=Res_NotificationRead, summary="알림 읽음 처리") +async def read_one( + notification_id: UUID, service: NotificationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken) +): + return RemoveNoneResponse(await service.mark_read(user_info.user_id, str(notification_id))) diff --git a/negodata/backend/router/v1/notification/protocol.py b/negodata/backend/router/v1/notification/protocol.py new file mode 100644 index 0000000..93e0eb7 --- /dev/null +++ b/negodata/backend/router/v1/notification/protocol.py @@ -0,0 +1,33 @@ +import uuid +from datetime import datetime +from typing import Any, Optional + +from pydantic import ConfigDict + +from common.enums import NotificationType +from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol + + +class NotificationProtocol(WebPacketProtocol): + pass + + +class NotificationData(WebPacketProtocol): + model_config = ConfigDict(from_attributes=True) + + notification_id: uuid.UUID + type: NotificationType + ref_qt_id: Optional[uuid.UUID] = None + ref_session_id: Optional[uuid.UUID] = None + data: Optional[Any] = None # 렌더 스냅샷(유형별 필드) + read_at: Optional[datetime] = None + created_at: Optional[datetime] = None + + +class Res_NotificationList(Res_PageProtocol): + notifications: list[NotificationData] = [] + unread: int = 0 # 안읽음 총수(헤더 빨콩이) + + +class Res_NotificationRead(Res_WebPacketProtocol): + pass diff --git a/negodata/backend/services/notification.py b/negodata/backend/services/notification.py new file mode 100644 index 0000000..4c366c4 --- /dev/null +++ b/negodata/backend/services/notification.py @@ -0,0 +1,88 @@ +import uuid + +from fastapi import Depends + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import notifications +from common.enums import DBWRType, ErrorType, NotificationType +from common.logger import LOG +from common.models.gmodel import PageParams +from common.utils.gtime import GTime +from crud.notification_crud import INotificationCRUD, NotificationCRUD +from router.v1.notification.protocol import NotificationData, Res_NotificationList, Res_NotificationRead + + +def _as_uuid(v): + return uuid.UUID(v) if isinstance(v, str) else v + + +async def create_notification( + user_id, + ntype: NotificationType, + data: dict, + ref_qt_id=None, + ref_session_id=None, +) -> None: + """알림 1건 기록(인박스). 수신자=user_id(견적 작성자), data=렌더 스냅샷(유형별 필드). + 부가 효과라 실패해도 본 흐름을 막지 않는다(raise 안 함, 로그만).""" + notif = notifications( + user_id=_as_uuid(user_id), + type=ntype.value, + ref_qt_id=_as_uuid(ref_qt_id) if ref_qt_id else None, + ref_session_id=_as_uuid(ref_session_id) if ref_session_id else None, + data=data, + ) + err = await DB_SESSION_MNG.execute_lambda_run( + [notifications.DBType()], + [lambda s: DB_SESSION_MNG.insert(s, notif, raise_error=False)], + ) + if err != ErrorType.SUCCESS: + LOG.e_no_callstack(f"[notify] 알림 기록 실패 user={user_id} type={ntype.name}") + + +class NotificationService: + """알림 조회/읽음 처리(헤더 벨·알림 페이지). 항상 로그인 유저(user_id)로 스코프.""" + + def __init__(self, crud: INotificationCRUD = Depends(NotificationCRUD)): + self.crud = crud + + async def list_notifications(self, user_id: str, pg: PageParams) -> Res_NotificationList: + res = Res_NotificationList(page=pg.page, size=pg.size) + uid = uuid.UUID(user_id) + err_type, rows, total = await DB_SESSION_MNG.execute_lambda( + notifications.DBType(), + DBWRType.DB_READ.value, + lambda s: self.crud.list_for_user(s, uid, pg.skip, pg.size), + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + res.notifications = [NotificationData.model_validate(r) for r in rows] + res.total = total + _e, unread = await DB_SESSION_MNG.execute_lambda( + notifications.DBType(), + DBWRType.DB_READ.value, + lambda s: self.crud.count_unread(s, uid), + ) + res.unread = unread if _e == ErrorType.SUCCESS else 0 + return res + + async def mark_read(self, user_id: str, notification_id: str) -> Res_NotificationRead: + res = Res_NotificationRead() + err_type = await DB_SESSION_MNG.execute_lambda_run( + [notifications.DBType()], + [lambda s: self.crud.mark_read(s, uuid.UUID(user_id), uuid.UUID(notification_id), GTime.UTC())], + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + async def mark_all_read(self, user_id: str) -> Res_NotificationRead: + res = Res_NotificationRead() + err_type = await DB_SESSION_MNG.execute_lambda_run( + [notifications.DBType()], + [lambda s: self.crud.mark_all_read(s, uuid.UUID(user_id), GTime.UTC())], + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res diff --git a/negodata/backend/services/quotation_service.py b/negodata/backend/services/quotation_service.py index c59c902..c7fe91f 100644 --- a/negodata/backend/services/quotation_service.py +++ b/negodata/backend/services/quotation_service.py @@ -7,7 +7,7 @@ from fastapi import Depends from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import quotations, sessions, chats, versions, version_nego_cards, version_wild_cards -from common.enums import CloseOutcome, DBWRType, ErrorType, QuotationStatus, QuotationType, SessionStatus +from common.enums import CloseOutcome, DBWRType, ErrorType, NotificationType, QuotationStatus, QuotationType, SessionStatus from common.logger import LOG from common.models.gmodel import PageParams from common.utils.gtime import GTime @@ -34,6 +34,7 @@ from router.v1.quotation.protocol import ( TargetCandidate, ) from services.email import EmailUnavailable, build_invite_email, send_email +from services.notification import create_notification class QuotationService: @@ -620,6 +621,13 @@ class QuotationService: # 1) 단독 낙찰 → 확정 if winner is not None: await self._award_and_close(qt_uuid, winner) + winner_price = min((int(bp) for _, bp, _ in done if bp is not None), default=None) + await create_notification( + original.user_id, NotificationType.SUCCESS, + {"qt_name": original.name, "qt_number": original.number, + "winner_name": winner["name"], "winner_price": winner_price}, + ref_qt_id=qt_uuid, + ) return CloseOutcome.AWARDED # 동가/미참여 재생성은 사유별 한도(각 1번, 순서 무관) 확인 후 @@ -637,10 +645,21 @@ class QuotationService: f"code={regen.result.code}({regen.result.desc})" ) return CloseOutcome.REGEN_FAILED + await create_notification( + original.user_id, NotificationType.REGENERATED, + {"qt_name": original.name, "qt_number": original.number, "reason": "equal", + "next_round": original.round + 1, "tied_price": equal["price"], "tied_count": len(equal["suppliers"])}, + ref_qt_id=regen.qt_id, + ) return CloseOutcome.REGENERATED # 3) 협상거부 있음 → 마감만 (재생성 안 함) if has_rejected: await self._just_close(qt_uuid) + await create_notification( + original.user_id, NotificationType.FAILURE, + {"qt_name": original.name, "qt_number": original.number, "reason": "rejected"}, + ref_qt_id=qt_uuid, + ) return CloseOutcome.CLOSED # 4) 전원 미참여 → 공급사 전체로 다음 라운드 (체인에 미참여 재생성 이력 없을 때만) if not done and rows and no_part_used < self.MAX_REGEN_PER_CAUSE: @@ -654,9 +673,19 @@ class QuotationService: f"code={regen.result.code}({regen.result.desc})" ) return CloseOutcome.REGEN_FAILED + await create_notification( + original.user_id, NotificationType.REGENERATED, + {"qt_name": original.name, "qt_number": original.number, "reason": "no_show", "next_round": original.round + 1}, + ref_qt_id=regen.qt_id, + ) return CloseOutcome.REGENERATED # 5) 그 외 / 한도 도달 → 마감만 await self._just_close(qt_uuid) + await create_notification( + original.user_id, NotificationType.FAILURE, + {"qt_name": original.name, "qt_number": original.number, "reason": "closed"}, + ref_qt_id=qt_uuid, + ) return CloseOutcome.CLOSED async def _chain_regen_counts(self, number: str, current_round: int) -> tuple[int, int]: diff --git a/negodata/front/src/api/generated/model/index.ts b/negodata/front/src/api/generated/model/index.ts index 8f8ca20..89ffeec 100644 --- a/negodata/front/src/api/generated/model/index.ts +++ b/negodata/front/src/api/generated/model/index.ts @@ -70,9 +70,17 @@ export * from './itemDataUpdatedAt'; export * from './itemDataVatYn'; export * from './listCardsParams'; export * from './listItemsParams'; +export * from './listNotificationsParams'; export * from './listQuotationsParams'; export * from './listSuppliersParams'; export * from './listUsersParams'; +export * from './notificationData'; +export * from './notificationDataCreatedAt'; +export * from './notificationDataData'; +export * from './notificationDataReadAt'; +export * from './notificationDataRefQtId'; +export * from './notificationDataRefSessionId'; +export * from './notificationType'; export * from './quotationCardData'; export * from './quotationCardDataCondition'; export * from './quotationCardDataEditScript'; @@ -261,6 +269,10 @@ export * from './resMeContactNumber'; export * from './resMeEmail'; export * from './resMeMsg'; export * from './resMeName'; +export * from './resNotificationList'; +export * from './resNotificationListMsg'; +export * from './resNotificationRead'; +export * from './resNotificationReadMsg'; export * from './resNotifySessions'; export * from './resNotifySessionsMsg'; export * from './resQuotation'; diff --git a/negodata/front/src/api/generated/model/listNotificationsParams.ts b/negodata/front/src/api/generated/model/listNotificationsParams.ts new file mode 100644 index 0000000..f61f178 --- /dev/null +++ b/negodata/front/src/api/generated/model/listNotificationsParams.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ListNotificationsParams = { +/** + * @minimum 1 + */ +page?: number; +/** + * @minimum 1 + * @maximum 100 + */ +size?: number; +}; diff --git a/negodata/front/src/api/generated/model/notificationData.ts b/negodata/front/src/api/generated/model/notificationData.ts new file mode 100644 index 0000000..802fdca --- /dev/null +++ b/negodata/front/src/api/generated/model/notificationData.ts @@ -0,0 +1,22 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { NotificationType } from './notificationType'; +import type { NotificationDataRefQtId } from './notificationDataRefQtId'; +import type { NotificationDataRefSessionId } from './notificationDataRefSessionId'; +import type { NotificationDataData } from './notificationDataData'; +import type { NotificationDataReadAt } from './notificationDataReadAt'; +import type { NotificationDataCreatedAt } from './notificationDataCreatedAt'; + +export interface NotificationData { + notification_id: string; + type: NotificationType; + ref_qt_id?: NotificationDataRefQtId; + ref_session_id?: NotificationDataRefSessionId; + data?: NotificationDataData; + read_at?: NotificationDataReadAt; + created_at?: NotificationDataCreatedAt; +} diff --git a/negodata/front/src/api/generated/model/notificationDataCreatedAt.ts b/negodata/front/src/api/generated/model/notificationDataCreatedAt.ts new file mode 100644 index 0000000..41c533d --- /dev/null +++ b/negodata/front/src/api/generated/model/notificationDataCreatedAt.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type NotificationDataCreatedAt = string | null; diff --git a/negodata/front/src/api/generated/model/notificationDataData.ts b/negodata/front/src/api/generated/model/notificationDataData.ts new file mode 100644 index 0000000..21fd015 --- /dev/null +++ b/negodata/front/src/api/generated/model/notificationDataData.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type NotificationDataData = unknown | null; diff --git a/negodata/front/src/api/generated/model/notificationDataReadAt.ts b/negodata/front/src/api/generated/model/notificationDataReadAt.ts new file mode 100644 index 0000000..448ac0d --- /dev/null +++ b/negodata/front/src/api/generated/model/notificationDataReadAt.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type NotificationDataReadAt = string | null; diff --git a/negodata/front/src/api/generated/model/notificationDataRefQtId.ts b/negodata/front/src/api/generated/model/notificationDataRefQtId.ts new file mode 100644 index 0000000..3f5aee7 --- /dev/null +++ b/negodata/front/src/api/generated/model/notificationDataRefQtId.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type NotificationDataRefQtId = string | null; diff --git a/negodata/front/src/api/generated/model/notificationDataRefSessionId.ts b/negodata/front/src/api/generated/model/notificationDataRefSessionId.ts new file mode 100644 index 0000000..024106b --- /dev/null +++ b/negodata/front/src/api/generated/model/notificationDataRefSessionId.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type NotificationDataRefSessionId = string | null; diff --git a/negodata/front/src/api/generated/model/notificationType.ts b/negodata/front/src/api/generated/model/notificationType.ts new file mode 100644 index 0000000..6f76b94 --- /dev/null +++ b/negodata/front/src/api/generated/model/notificationType.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +/** + * company.notifications.type 코드값. 견적 생애 이벤트를 작성자에게 통지. 마감 결과 3종(SUCCESS/REGENERATED/FAILURE)은 close_and_decide 와 1:1. 네이밍은 KTC. + */ +export type NotificationType = typeof NotificationType[keyof typeof NotificationType]; + + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const NotificationType = { + SUCCESS: 1, + REGENERATED: 2, + FAILURE: 3, + CREATED: 4, +} as const; diff --git a/negodata/front/src/api/generated/model/resNotificationList.ts b/negodata/front/src/api/generated/model/resNotificationList.ts new file mode 100644 index 0000000..a46b741 --- /dev/null +++ b/negodata/front/src/api/generated/model/resNotificationList.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ErrorInfo } from './errorInfo'; +import type { ResNotificationListMsg } from './resNotificationListMsg'; +import type { NotificationData } from './notificationData'; + +export interface ResNotificationList { + result?: ErrorInfo; + msg?: ResNotificationListMsg; + total?: number; + page?: number; + size?: number; + notifications?: NotificationData[]; + unread?: number; +} diff --git a/negodata/front/src/api/generated/model/resNotificationListMsg.ts b/negodata/front/src/api/generated/model/resNotificationListMsg.ts new file mode 100644 index 0000000..83fbeb8 --- /dev/null +++ b/negodata/front/src/api/generated/model/resNotificationListMsg.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ResNotificationListMsg = string | null; diff --git a/negodata/front/src/api/generated/model/resNotificationRead.ts b/negodata/front/src/api/generated/model/resNotificationRead.ts new file mode 100644 index 0000000..bb005aa --- /dev/null +++ b/negodata/front/src/api/generated/model/resNotificationRead.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ErrorInfo } from './errorInfo'; +import type { ResNotificationReadMsg } from './resNotificationReadMsg'; + +export interface ResNotificationRead { + result?: ErrorInfo; + msg?: ResNotificationReadMsg; +} diff --git a/negodata/front/src/api/generated/model/resNotificationReadMsg.ts b/negodata/front/src/api/generated/model/resNotificationReadMsg.ts new file mode 100644 index 0000000..ecf1e41 --- /dev/null +++ b/negodata/front/src/api/generated/model/resNotificationReadMsg.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ResNotificationReadMsg = string | null; diff --git a/negodata/front/src/api/generated/notification/notification.ts b/negodata/front/src/api/generated/notification/notification.ts new file mode 100644 index 0000000..438880a --- /dev/null +++ b/negodata/front/src/api/generated/notification/notification.ts @@ -0,0 +1,257 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import { + useMutation, + useQuery +} from '@tanstack/react-query'; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + MutationFunction, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseMutationOptions, + UseMutationResult, + UseQueryOptions, + UseQueryResult +} from '@tanstack/react-query'; + +import type { + HTTPValidationError, + ListNotificationsParams, + ResNotificationList, + ResNotificationRead +} from '.././model'; + +import { customFetch } from '../../mutator/custom-fetch'; + + +type SecondParameter unknown> = Parameters[1]; + + + +/** + * @summary 알림 목록(+안읽음 수) + */ +export const listNotifications = ( + params?: ListNotificationsParams, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/notification/list`, method: 'GET', + params, signal + }, + options); + } + + + + +export const getListNotificationsQueryKey = (params?: ListNotificationsParams,) => { + return [ + `/v1/notification/list`, ...(params ? [params]: []) + ] as const; + } + + +export const getListNotificationsQueryOptions = >, TError = void | HTTPValidationError>(params?: ListNotificationsParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListNotificationsQueryKey(params); + + + + const queryFn: QueryFunction>> = ({ signal }) => listNotifications(params, requestOptions, signal); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type ListNotificationsQueryResult = NonNullable>> +export type ListNotificationsQueryError = void | HTTPValidationError + + +export function useListNotifications>, TError = void | HTTPValidationError>( + params: undefined | ListNotificationsParams, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useListNotifications>, TError = void | HTTPValidationError>( + params?: ListNotificationsParams, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useListNotifications>, TError = void | HTTPValidationError>( + params?: ListNotificationsParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary 알림 목록(+안읽음 수) + */ + +export function useListNotifications>, TError = void | HTTPValidationError>( + params?: ListNotificationsParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getListNotificationsQueryOptions(params,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + +/** + * @summary 전체 읽음 처리 + */ +export const readAll = ( + + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/notification/read-all`, method: 'POST', signal + }, + options); + } + + + +export const getReadAllMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,void, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,void, TContext> => { + +const mutationKey = ['readAll']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, void> = () => { + + + return readAll(requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type ReadAllMutationResult = NonNullable>> + + export type ReadAllMutationError = void + + /** + * @summary 전체 읽음 처리 + */ +export const useReadAll = (options?: { mutation?:UseMutationOptions>, TError,void, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + void, + TContext + > => { + + const mutationOptions = getReadAllMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + /** + * @summary 알림 읽음 처리 + */ +export const readOne = ( + notificationId: string, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/notification/${notificationId}/read`, method: 'POST', signal + }, + options); + } + + + +export const getReadOneMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{notificationId: string}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{notificationId: string}, TContext> => { + +const mutationKey = ['readOne']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, {notificationId: string}> = (props) => { + const {notificationId} = props ?? {}; + + return readOne(notificationId,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type ReadOneMutationResult = NonNullable>> + + export type ReadOneMutationError = void | HTTPValidationError + + /** + * @summary 알림 읽음 처리 + */ +export const useReadOne = (options?: { mutation?:UseMutationOptions>, TError,{notificationId: string}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {notificationId: string}, + TContext + > => { + + const mutationOptions = getReadOneMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + \ No newline at end of file diff --git a/negodata/front/src/app/router.tsx b/negodata/front/src/app/router.tsx index f0f3400..b19501d 100644 --- a/negodata/front/src/app/router.tsx +++ b/negodata/front/src/app/router.tsx @@ -11,6 +11,7 @@ import PartnersPage from '../pages/partners'; import QuotationPage from '../pages/quotation'; import CardsPage from '../pages/cards'; import MembersPage from '../pages/members'; +import NotificationsPage from '../pages/notifications'; export const router = createBrowserRouter([ // dev 전용: import.meta.env.DEV가 false인 프로덕션 빌드에선 이 배열 항목과 @@ -59,6 +60,7 @@ export const router = createBrowserRouter([ {path: 'partners', Component: PartnersPage}, {path: 'quotation', Component: QuotationPage}, {path: 'cards', Component: CardsPage}, + {path: 'notifications', Component: NotificationsPage}, { // 최고관리자 전용. 부모 loader 가 initAuth 를 마친 뒤 실행되므로 유저 상태가 복원돼 있다. path: 'members', diff --git a/negodata/front/src/components/layout/AuthenticatedLayout.tsx b/negodata/front/src/components/layout/AuthenticatedLayout.tsx index 6e0d701..9bd8977 100644 --- a/negodata/front/src/components/layout/AuthenticatedLayout.tsx +++ b/negodata/front/src/components/layout/AuthenticatedLayout.tsx @@ -11,6 +11,7 @@ const PAGE_TO_PATH: Record = { QUOTATION: '/quotation', CARDS: '/cards', MEMBERS: '/members', + NOTIFICATIONS: '/notifications', }; export default function AuthenticatedLayout() { diff --git a/negodata/front/src/components/layout/Layout.tsx b/negodata/front/src/components/layout/Layout.tsx index 3fd4f0d..57bec99 100644 --- a/negodata/front/src/components/layout/Layout.tsx +++ b/negodata/front/src/components/layout/Layout.tsx @@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Typography } from '@/components/ui/typography'; import { cn } from '@/lib/utils'; +import { NotificationBell } from './NotificationBell'; import { LayoutDashboard, Briefcase, @@ -49,6 +50,7 @@ const pageLabelMap: Record = { QUOTATION: '견적관리', CARDS: '협상카드관리', MEMBERS: '회원관리', + NOTIFICATIONS: '알림', }; export default function Layout({ children, currentPage, setPage, onLogout }: LayoutProps) { @@ -215,7 +217,10 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay - +
+ + +
{/* Content Body Area */} diff --git a/negodata/front/src/components/layout/NotificationBell.tsx b/negodata/front/src/components/layout/NotificationBell.tsx new file mode 100644 index 0000000..b756e46 --- /dev/null +++ b/negodata/front/src/components/layout/NotificationBell.tsx @@ -0,0 +1,28 @@ +import { Bell } from 'lucide-react'; +import { useNavigate } from 'react-router'; +import { useListNotifications } from '@/api/generated/notification/notification'; + +// 헤더 알림벨. 안읽음 수를 빨간 배지(빨콩이)로 표시하고, 클릭 시 알림 페이지로 이동. +// 30초마다 안읽음 수 재조회(가벼운 폴링). +export function NotificationBell() { + const navigate = useNavigate(); + const { data } = useListNotifications({ size: 1 }, { query: { refetchInterval: 30000 } }); + const unread = data?.unread ?? 0; + + return ( + + ); +} diff --git a/negodata/front/src/pages/notifications.tsx b/negodata/front/src/pages/notifications.tsx new file mode 100644 index 0000000..797b36f --- /dev/null +++ b/negodata/front/src/pages/notifications.tsx @@ -0,0 +1,188 @@ +import { useEffect, useRef, type ReactNode } from 'react'; +import { useNavigate } from 'react-router'; +import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; +import { Trophy, RefreshCw, XCircle, Bell, CheckCheck, FilePlus2 } from 'lucide-react'; +import { PageContainer } from '@/components/layout/PageContainer'; +import { Typography } from '@/components/ui/typography'; +import { cn } from '@/lib/utils'; +import { + listNotifications, + useReadAll, + useReadOne, +} from '@/api/generated/notification/notification'; +import { NotificationType } from '@/api/generated/model/notificationType'; +import type { NotificationData } from '@/api/generated/model/notificationData'; + +const PAGE_SIZE = 20; + +export default function NotificationsPage() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + // offset 페이징(page/size)을 그대로 쓰는 무한 스크롤: page 를 1→2→3 누적. + // 다음 페이지 여부는 응답의 total/page/size 로 판정(page*size < total). + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ + queryKey: ['/v1/notification/list', 'infinite'], + queryFn: ({ pageParam }) => listNotifications({ page: pageParam, size: PAGE_SIZE }), + initialPageParam: 1, + getNextPageParam: (last) => { + const page = last.page ?? 1; + const size = last.size ?? PAGE_SIZE; + const total = last.total ?? 0; + return page * size < total ? page + 1 : undefined; + }, + }); + const readAll = useReadAll(); + const readOne = useReadOne(); + + const items = data?.pages.flatMap((p) => p.notifications ?? []) ?? []; + const unread = data?.pages[0]?.unread ?? 0; + + const invalidate = () => queryClient.invalidateQueries({ queryKey: ['/v1/notification/list'] }); + + const openOne = (n: NotificationData) => { + if (!n.read_at) readOne.mutate({ notificationId: n.notification_id }, { onSuccess: invalidate }); + if (n.ref_qt_id) navigate(`/quotation?detail=${n.ref_qt_id}`); + }; + + // 리스트 끝 sentinel 이 뷰포트에 들어오면 다음 페이지 로드(무한 스크롤). + const sentinelRef = useRef(null); + useEffect(() => { + const el = sentinelRef.current; + if (!el) return; + const io = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage(); + }, + { rootMargin: '120px' }, + ); + io.observe(el); + return () => io.disconnect(); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + return ( + +
+
+ + 알림 + {unread > 0 && ( + {unread} 안읽음 + )} +
+ +
+ +
+ {items.length === 0 ? ( +
+ 알림이 없습니다. +
+ ) : ( + items.map((n) => { + const r = render(n); + const isUnread = !n.read_at; + return ( + + ); + }) + )} +
+ + {hasNextPage && ( +
+ {isFetchingNextPage && ( + 불러오는 중… + )} +
+ )} +
+ ); +} + +// 서버 시각(타임존 표식 없는 UTC) → 한국시간 'YYYY-MM-DD HH:mm'. 실패 시 '-'. +function fmtKst(s?: string | null): string { + if (!s) return '-'; + const iso = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(s) ? s : s + 'Z'; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return '-'; + const p = new Intl.DateTimeFormat('ko-KR', { + timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23', + }).formatToParts(d).reduce((o, x) => ((o[x.type] = x.value), o), {} as Record); + return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}`; +} + +// 견적건명(name, 필수 입력). 비면 번호, 둘 다 없으면 '견적'. +function qtName(d: Record): string { + const name = (d.qt_name as string) || ''; + const num = (d.qt_number as string) || ''; + return name || num || '견적'; +} + +// 알림 1건 → 카드 3단 표기값. +// event : 이벤트 제목(낙찰/재생성/결렬/생성) — 한 줄에 쭉 늘어놓지 않고 분리 +// line : 무슨 견적인지(건명) + 결과 +// number: 견적번호(메타줄 보조 표기). icon/tone 은 유형별 색. +function render(n: NotificationData): { icon: ReactNode; tone: string; event: string; line: string; number: string } { + const d = (n.data ?? {}) as Record; + const name = qtName(d); + const number = (d.qt_number as string) || ''; + switch (n.type) { + case NotificationType.CREATED: + return { icon: , tone: 'text-sky-600', event: '견적 생성', line: name, number }; + case NotificationType.SUCCESS: + return { + icon: , tone: 'text-emerald-600', event: '견적 낙찰', + line: `${name} — ${d.winner_name ?? '-'} ${Number(d.winner_price ?? 0).toLocaleString()}원`, + number, + }; + case NotificationType.REGENERATED: + return { + icon: , tone: 'text-amber-600', + event: `견적 재생성 · ${d.reason === 'equal' ? '동가' : '전원 미참여'}`, + line: `${name} — ${d.next_round ?? ''}차로 다시 생성`, + number, + }; + case NotificationType.FAILURE: + return { + icon: , tone: 'text-rose-600', event: '견적 결렬', + line: `${name} — 낙찰 없이 마감`, + number, + }; + default: + return { icon: , tone: 'text-muted-foreground', event: '견적 알림', line: name, number }; + } +} diff --git a/negodata/front/src/types.ts b/negodata/front/src/types.ts index 0d46dbd..dd611fa 100644 --- a/negodata/front/src/types.ts +++ b/negodata/front/src/types.ts @@ -33,4 +33,4 @@ export interface NegotiationCard { memo?: string; } -export type PageType = 'DASHBOARD' | 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'MEMBERS'; +export type PageType = 'DASHBOARD' | 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'MEMBERS' | 'NOTIFICATIONS'; diff --git a/postgres-init/01-schema.sql b/postgres-init/01-schema.sql index efeb70b..db6afd9 100644 --- a/postgres-init/01-schema.sql +++ b/postgres-init/01-schema.sql @@ -333,11 +333,30 @@ CREATE TABLE IF NOT EXISTS negotiation.results ( deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부 ); +-- ============================================================ +-- company : 알림(인박스) — 협상 이벤트를 견적 작성자(유저)에게 통지 +-- ============================================================ +CREATE TABLE IF NOT EXISTS company.notifications ( + notification_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 알림 식별자(PK) + user_id uuid NOT NULL, -- 수신자(company.users.user_id) = 견적 작성자 + type SMALLINT NOT NULL, -- 알림 유형(NotificationType): 1=success(낙찰), 2=regenerated(재생성), 3=failure(결렬), 4=created(견적생성) + ref_qt_id uuid NULL, -- 관련 견적(quotation.quotations.qt_id), 클릭 시 이동 + ref_session_id uuid NULL, -- 관련 세션(negotiation.sessions.session_id), 공급사 알림만 + data JSONB NULL, -- 렌더 스냅샷(qt_name/qt_number + 유형별 필드). 발생 시점 값 보관(텍스트 불변). + read_at TIMESTAMPTZ NULL, -- 읽은 시각(NULL=안읽음). 행동 필요(검토) 판정 겸용 + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC) + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) + deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부 +); + -- ============================================================ -- 인덱스 (FK 를 걸지 않으므로 조인 컬럼 인덱스를 명시적으로 생성) -- ============================================================ CREATE INDEX IF NOT EXISTS idx_users_company_id ON company.users (company_id); CREATE INDEX IF NOT EXISTS idx_user_tokens_user_id ON company.user_tokens (user_id); +CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON company.notifications (user_id); +CREATE INDEX IF NOT EXISTS idx_notifications_user_unread ON company.notifications (user_id, created_at) WHERE deleted = FALSE AND read_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_notifications_ref_qt_id ON company.notifications (ref_qt_id); CREATE INDEX IF NOT EXISTS idx_supplier_users_supplier_id ON supplier.supplier_users (supplier_id); CREATE INDEX IF NOT EXISTS idx_sut_su_id ON supplier.supplier_user_tokens (su_id); CREATE INDEX IF NOT EXISTS idx_suppliers_company_id ON partner.suppliers (company_id); diff --git a/postgres-init/04-alter.sql b/postgres-init/04-alter.sql index 92de36f..2afaf64 100644 --- a/postgres-init/04-alter.sql +++ b/postgres-init/04-alter.sql @@ -32,3 +32,22 @@ ALTER TABLE card.wild_cards -- ─────────────────────────────────────────────────────────── ALTER TABLE negotiation.sessions ADD COLUMN IF NOT EXISTS email_sent_at TIMESTAMPTZ; + +-- ─────────────────────────────────────────────────────────── +-- [2026-06-30] 알림(인박스): 협상 이벤트를 견적 작성자에게 통지 +-- ─────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS company.notifications ( + notification_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL, -- 수신자(company.users.user_id) = 견적 작성자 + type SMALLINT NOT NULL, -- 알림 유형(NotificationType): 1=success(낙찰), 2=regenerated(재생성), 3=failure(결렬) + ref_qt_id uuid NULL, -- 관련 견적(quotation.quotations.qt_id) + ref_session_id uuid NULL, -- 관련 세션(negotiation.sessions.session_id) + data JSONB NULL, -- 렌더 스냅샷(유형별) + read_at TIMESTAMPTZ NULL, -- 읽은 시각(NULL=안읽음) + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted BOOLEAN NOT NULL DEFAULT FALSE +); +CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON company.notifications (user_id); +CREATE INDEX IF NOT EXISTS idx_notifications_user_unread ON company.notifications (user_id, created_at) WHERE deleted = FALSE AND read_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_notifications_ref_qt_id ON company.notifications (ref_qt_id);