From 9d9b3359b07d408e73b069578ff7890aea19b03a Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Mon, 11 May 2026 07:19:04 +0900 Subject: [PATCH] =?UTF-8?q?feat(events):=20PR-1=20Events=20Core=20?= =?UTF-8?q?=E2=80=94=20schema=20+=20ORM=20+=20=EC=B5=9C=EC=86=8C=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 개인 운영 로그 / 일정 / 할 일 / 회고용 1차 컨테이너 도메인 신설. plan: ~/.claude/plans/beszel-tingly-sloth.md (라운드 12 v6). Schema: - enum 5종 (event_kind / event_status / event_source / event_actor / history_change_kind) - events 테이블: kind(task|calendar_event|activity_log) + lifecycle 7-state status - events_history: lifecycle op 자동 기록, FK RESTRICT (이력은 시점 사실) - CHECK: calendar_event → start_at NOT NULL / activity_log → started_at|ended_at NOT NULL - partial unique (source, source_ref) — 외부 source dedup (PR-4 활용) - partial index (active status / activity_log timeline) API: - POST /api/events (kind=activity_log shortcut: status=done + ended_at=now() default) - GET /api/events/{id} | /api/events?kind&status&from&to&project_tag&source - PATCH /api/events/{id} (extra=forbid + 시간 필드 변경 시 reschedule history) - POST /api/events/{id}/{complete,cancel,defer,reactivate} (history 자동) - GET /api/events/today (Asia/Seoul default, deferred 는 defer_until<=now() 만) - GET /api/events/inbox | /api/events/activity?from&to 제외 (PR-2~5 또는 백로그): - DELETE (회고 데이터 → /cancel 일관화) - log shortcut / upcoming endpoint (POST + GET ?from&to 로 흡수) - /ingest (PR-4 MailPlus forward 시 정확한 요구로 추가) - iCal export / ntfy 알림 / recurrence / 일반 edit history --- app/api/events.py | 646 ++++++++++++++++++ app/main.py | 2 + app/models/event.py | 113 +++ app/models/event_history.py | 43 ++ migrations/239_events_enum_kind.sql | 6 + migrations/240_events_enum_status.sql | 18 + migrations/241_events_enum_source.sql | 12 + migrations/242_events_enum_actor.sql | 10 + .../243_events_enum_history_change_kind.sql | 17 + migrations/244_events_table.sql | 38 ++ migrations/245_events_source_ref_uq.sql | 7 + migrations/246_events_idx_active.sql | 7 + migrations/247_events_idx_activity.sql | 7 + migrations/248_events_history_table.sql | 15 + migrations/249_events_history_idx.sql | 5 + 15 files changed, 946 insertions(+) create mode 100644 app/api/events.py create mode 100644 app/models/event.py create mode 100644 app/models/event_history.py create mode 100644 migrations/239_events_enum_kind.sql create mode 100644 migrations/240_events_enum_status.sql create mode 100644 migrations/241_events_enum_source.sql create mode 100644 migrations/242_events_enum_actor.sql create mode 100644 migrations/243_events_enum_history_change_kind.sql create mode 100644 migrations/244_events_table.sql create mode 100644 migrations/245_events_source_ref_uq.sql create mode 100644 migrations/246_events_idx_active.sql create mode 100644 migrations/247_events_idx_activity.sql create mode 100644 migrations/248_events_history_table.sql create mode 100644 migrations/249_events_history_idx.sql diff --git a/app/api/events.py b/app/api/events.py new file mode 100644 index 0000000..c90ec57 --- /dev/null +++ b/app/api/events.py @@ -0,0 +1,646 @@ +"""events API — 개인 운영 로그 / 일정 / 할 일 / 회고 (PR-1). + +PR-1 scope (plan beszel-tingly-sloth.md v6): +- POST /api/events (kind=task/calendar_event/activity_log) +- GET /api/events/{id} +- GET /api/events?kind&status&from&to&project_tag&source +- PATCH /api/events/{id} (허용 필드만, 시간 필드 변경 시 reschedule history) +- POST /api/events/{id}/complete | /cancel | /defer | /reactivate +- GET /api/events/today (timezone 정책 적용) +- GET /api/events/inbox +- GET /api/events/activity?from&to + +PR-1 제외: DELETE / log shortcut / upcoming / ingest / iCal / ntfy. +""" + +import json +import logging +from datetime import date, datetime, timedelta, timezone +from typing import Annotated, Any +from zoneinfo import ZoneInfo + +from fastapi import APIRouter, Body, Depends, HTTPException, Query +from pydantic import BaseModel, Field +from sqlalchemy import and_, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user +from core.database import get_session +from models.event import Event +from models.event_history import EventHistory +from models.user import User + +logger = logging.getLogger(__name__) + +router = APIRouter() + +DEFAULT_TIMEZONE = "Asia/Seoul" + +# PATCH 허용 필드 — status/completed_at/cancelled_at/defer_until/source/source_ref/ +# raw_metadata/user_id/created_by 는 lifecycle endpoint 또는 시스템 결정. +PATCH_ALLOWED_FIELDS = { + "title", + "description", + "due_at", + "start_at", + "end_at", + "started_at", + "ended_at", + "all_day", + "timezone", + "priority", + "project_tag", + "tags", + "memo_document_id", +} +# 시간 필드 변경 시 reschedule history 1건 자동 기록 (defer_until 은 /defer 전용). +RESCHEDULE_TIME_FIELDS = { + "due_at", + "start_at", + "end_at", + "started_at", + "ended_at", + "all_day", + "timezone", +} + + +# ─── 스키마 ─── + + +class EventCreate(BaseModel): + title: str + description: str | None = None + kind: str # task | calendar_event | activity_log + status: str | None = None # 미지정 시 kind 별 default + due_at: datetime | None = None + start_at: datetime | None = None + end_at: datetime | None = None + started_at: datetime | None = None + ended_at: datetime | None = None + all_day: bool = False + timezone: str | None = None + priority: int | None = None + project_tag: str | None = None + tags: list[Any] = Field(default_factory=list) + memo_document_id: int | None = None + source: str = "manual" + source_ref: str | None = None + raw_metadata: dict[str, Any] = Field(default_factory=dict) + + +class EventPatch(BaseModel): + """PATCH 허용 필드만. status/completed_at 등 lifecycle 필드는 명시 거부.""" + + title: str | None = None + description: str | None = None + due_at: datetime | None = None + start_at: datetime | None = None + end_at: datetime | None = None + started_at: datetime | None = None + ended_at: datetime | None = None + all_day: bool | None = None + timezone: str | None = None + priority: int | None = None + project_tag: str | None = None + tags: list[Any] | None = None + memo_document_id: int | None = None + + model_config = {"extra": "forbid"} # 허용 외 필드 → 422 + + +class DeferRequest(BaseModel): + defer_until: datetime + + +class EventResponse(BaseModel): + id: int + title: str + description: str | None + kind: str + status: str + due_at: datetime | None + start_at: datetime | None + end_at: datetime | None + started_at: datetime | None + ended_at: datetime | None + all_day: bool + timezone: str | None + defer_until: datetime | None + completed_at: datetime | None + cancelled_at: datetime | None + priority: int | None + project_tag: str | None + tags: list[Any] + source: str + source_ref: str | None + raw_metadata: dict[str, Any] + memo_document_id: int | None + user_id: int + created_by: str + created_at: datetime + updated_at: datetime + + +class EventListResponse(BaseModel): + items: list[EventResponse] + total: int + + +# ─── 헬퍼 ─── + + +def _to_response(ev: Event) -> EventResponse: + return EventResponse.model_validate(ev, from_attributes=True) + + +def _serialize_for_history(ev: Event) -> dict[str, Any]: + """events_history.before/after 용 dict snapshot (JSON 친화).""" + payload: dict[str, Any] = {} + for col in ( + "id", + "title", + "description", + "kind", + "status", + "due_at", + "start_at", + "end_at", + "started_at", + "ended_at", + "all_day", + "timezone", + "defer_until", + "completed_at", + "cancelled_at", + "priority", + "project_tag", + "tags", + "source", + "source_ref", + "raw_metadata", + "memo_document_id", + "user_id", + "created_by", + ): + v = getattr(ev, col, None) + if isinstance(v, datetime): + payload[col] = v.isoformat() + else: + payload[col] = v + return payload + + +def _actor_for_user(user: User) -> str: + """사용자 직접 호출 = manual. 향후 이드/email_ingest 는 service token 분기 (PR-3).""" + return "manual" + + +async def _record_history( + session: AsyncSession, + *, + event: Event, + change_kind: str, + changed_by: str, + before: dict[str, Any] | None, + after: dict[str, Any], +) -> None: + history = EventHistory( + event_id=event.id, + changed_by=changed_by, + change_kind=change_kind, + before=before, + after=after, + ) + session.add(history) + + +async def _load_owned( + session: AsyncSession, event_id: int, user: User +) -> Event: + ev = await session.get(Event, event_id) + if ev is None or ev.user_id != user.id: + raise HTTPException(status_code=404, detail="event not found") + return ev + + +def _resolve_timezone(tz_name: str | None) -> ZoneInfo: + try: + return ZoneInfo(tz_name or DEFAULT_TIMEZONE) + except Exception: + raise HTTPException(status_code=400, detail=f"invalid timezone: {tz_name}") + + +def _local_day_bounds(tz_name: str | None) -> tuple[datetime, datetime, datetime]: + """today 의 [start_utc, end_utc) + now_utc 반환.""" + tz = _resolve_timezone(tz_name) + now_local = datetime.now(tz) + today_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0) + tomorrow_local = today_local + timedelta(days=1) + return ( + today_local.astimezone(timezone.utc), + tomorrow_local.astimezone(timezone.utc), + now_local.astimezone(timezone.utc), + ) + + +def _apply_activity_log_defaults(payload: dict[str, Any]) -> None: + """빠른 행동 기록 5초 UX — kind=activity_log 시 status/시간 default.""" + if payload.get("kind") != "activity_log": + return + now = datetime.now(timezone.utc) + if not payload.get("status"): + payload["status"] = "done" + if payload.get("ended_at") is None: + payload["ended_at"] = now + if payload.get("started_at") is None: + payload["started_at"] = payload["ended_at"] + if payload.get("status") == "done": + payload.setdefault("completed_at", now) + + +def _apply_kind_default_status(payload: dict[str, Any]) -> None: + """kind 별 status default 보정.""" + if payload.get("status"): + return + kind = payload.get("kind") + if kind == "calendar_event": + payload["status"] = "scheduled" + elif kind == "task": + payload["status"] = "inbox" + + +# ─── Create ─── + + +@router.post("/", response_model=EventResponse, status_code=201) +async def create_event( + body: EventCreate, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """events 생성. kind=activity_log 면 status=done/ended_at=now() default.""" + payload = body.model_dump(exclude_none=False) + _apply_activity_log_defaults(payload) + _apply_kind_default_status(payload) + + if payload["kind"] not in ("task", "calendar_event", "activity_log"): + raise HTTPException(status_code=400, detail="invalid kind") + + actor = _actor_for_user(user) + ev = Event( + title=payload["title"], + description=payload.get("description"), + kind=payload["kind"], + status=payload.get("status") or "inbox", + due_at=payload.get("due_at"), + start_at=payload.get("start_at"), + end_at=payload.get("end_at"), + started_at=payload.get("started_at"), + ended_at=payload.get("ended_at"), + all_day=payload.get("all_day") or False, + timezone=payload.get("timezone"), + completed_at=payload.get("completed_at"), + priority=payload.get("priority"), + project_tag=payload.get("project_tag"), + tags=payload.get("tags") or [], + source=payload.get("source") or "manual", + source_ref=payload.get("source_ref"), + raw_metadata=payload.get("raw_metadata") or {}, + memo_document_id=payload.get("memo_document_id"), + user_id=user.id, + created_by=actor, + ) + session.add(ev) + await session.flush() + + await _record_history( + session, + event=ev, + change_kind="create", + changed_by=actor, + before=None, + after=_serialize_for_history(ev), + ) + await session.commit() + await session.refresh(ev) + return _to_response(ev) + + +# ─── List / Get ─── + + +@router.get("/", response_model=EventListResponse) +async def list_events( + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], + kind: str | None = Query(None), + status: str | None = Query(None, description="comma-separated list"), + from_: datetime | None = Query(None, alias="from"), + to: datetime | None = Query(None), + project_tag: str | None = Query(None), + source: str | None = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), +): + """events 목록 — current_user.id 자동 필터. upcoming 은 ?from=now&to=now+7d 로.""" + where = [Event.user_id == user.id] + if kind: + where.append(Event.kind == kind) + if status: + statuses = [s.strip() for s in status.split(",") if s.strip()] + if statuses: + where.append(Event.status.in_(statuses)) + if project_tag: + where.append(Event.project_tag == project_tag) + if source: + where.append(Event.source == source) + if from_ is not None: + # task: due_at, calendar_event: start_at, activity_log: started_at + where.append( + or_( + Event.due_at >= from_, + Event.start_at >= from_, + Event.started_at >= from_, + ) + ) + if to is not None: + where.append( + or_( + Event.due_at < to, + Event.start_at < to, + Event.started_at < to, + ) + ) + + base = select(Event).where(and_(*where)) + total_q = await session.execute( + select(Event.id).where(and_(*where)) + ) + total = len(total_q.scalars().all()) + + rows = await session.execute( + base.order_by(Event.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + items = [_to_response(e) for e in rows.scalars().all()] + return EventListResponse(items=items, total=total) + + +@router.get("/today", response_model=EventListResponse) +async def list_today( + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], + timezone: str | None = Query(None, description="기본 Asia/Seoul"), +): + """오늘 해야 할 것 / 예정된 것. timezone 적용. + + 포함: task(due_at today) / calendar_event(start_at today) / activity_log(started_at today) + status: inbox/next/scheduled/in_progress 또는 deferred (defer_until <= now() 일 때만). + """ + start_utc, end_utc, now_utc = _local_day_bounds(timezone) + + today_clause = or_( + and_(Event.kind == "task", Event.due_at >= start_utc, Event.due_at < end_utc), + and_( + Event.kind == "calendar_event", + Event.start_at >= start_utc, + Event.start_at < end_utc, + ), + and_( + Event.kind == "activity_log", + Event.started_at >= start_utc, + Event.started_at < end_utc, + ), + ) + active_clause = or_( + Event.status.in_(("inbox", "next", "scheduled", "in_progress")), + and_(Event.status == "deferred", Event.defer_until <= now_utc), + ) + rows = await session.execute( + select(Event) + .where(Event.user_id == user.id, today_clause, active_clause) + .order_by(Event.start_at.asc(), Event.due_at.asc(), Event.started_at.asc()) + ) + items = [_to_response(e) for e in rows.scalars().all()] + return EventListResponse(items=items, total=len(items)) + + +@router.get("/inbox", response_model=EventListResponse) +async def list_inbox( + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """Inbox — 아직 정리 안 된 것.""" + rows = await session.execute( + select(Event) + .where(Event.user_id == user.id, Event.status == "inbox") + .order_by(Event.created_at.desc()) + ) + items = [_to_response(e) for e in rows.scalars().all()] + return EventListResponse(items=items, total=len(items)) + + +@router.get("/activity", response_model=EventListResponse) +async def list_activity( + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], + from_: datetime | None = Query(None, alias="from"), + to: datetime | None = Query(None), +): + """Activity timeline — 한 일 (kind=activity_log + status=done). Today 와 분리.""" + where = [ + Event.user_id == user.id, + Event.kind == "activity_log", + Event.status == "done", + ] + if from_ is not None: + where.append(Event.started_at >= from_) + if to is not None: + where.append(Event.started_at < to) + rows = await session.execute( + select(Event).where(and_(*where)).order_by(Event.started_at.desc()) + ) + items = [_to_response(e) for e in rows.scalars().all()] + return EventListResponse(items=items, total=len(items)) + + +@router.get("/{event_id}", response_model=EventResponse) +async def get_event( + event_id: int, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + ev = await _load_owned(session, event_id, user) + return _to_response(ev) + + +# ─── PATCH ─── + + +@router.patch("/{event_id}", response_model=EventResponse) +async def patch_event( + event_id: int, + body: EventPatch, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """PATCH — 허용 필드만. 시간 필드 변경 시 reschedule history 자동 기록. + + status/completed_at/cancelled_at/defer_until 등 lifecycle 필드는 별 endpoint 강제. + """ + ev = await _load_owned(session, event_id, user) + + patch = body.model_dump(exclude_unset=True) + if not patch: + return _to_response(ev) + + # 안전 검사 — extra=forbid 로 막혀 있지만 한 번 더. + for k in patch: + if k not in PATCH_ALLOWED_FIELDS: + raise HTTPException(status_code=400, detail=f"field not patchable: {k}") + + time_changed = any(k in RESCHEDULE_TIME_FIELDS for k in patch) + before_snapshot = _serialize_for_history(ev) if time_changed else None + + for k, v in patch.items(): + setattr(ev, k, v) + await session.flush() + + if time_changed: + actor = _actor_for_user(user) + await _record_history( + session, + event=ev, + change_kind="reschedule", + changed_by=actor, + before=before_snapshot, + after=_serialize_for_history(ev), + ) + + await session.commit() + await session.refresh(ev) + return _to_response(ev) + + +# ─── Lifecycle ─── + + +async def _transition( + session: AsyncSession, + *, + event: Event, + change_kind: str, + new_status: str, + user: User, + extra_apply: dict[str, Any] | None = None, +) -> Event: + actor = _actor_for_user(user) + before = _serialize_for_history(event) + event.status = new_status + if extra_apply: + for k, v in extra_apply.items(): + setattr(event, k, v) + await session.flush() + await _record_history( + session, + event=event, + change_kind=change_kind, + changed_by=actor, + before=before, + after=_serialize_for_history(event), + ) + return event + + +@router.post("/{event_id}/complete", response_model=EventResponse) +async def complete_event( + event_id: int, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + ev = await _load_owned(session, event_id, user) + now = datetime.now(timezone.utc) + await _transition( + session, + event=ev, + change_kind="complete", + new_status="done", + user=user, + extra_apply={"completed_at": now}, + ) + await session.commit() + await session.refresh(ev) + return _to_response(ev) + + +@router.post("/{event_id}/cancel", response_model=EventResponse) +async def cancel_event( + event_id: int, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + ev = await _load_owned(session, event_id, user) + now = datetime.now(timezone.utc) + await _transition( + session, + event=ev, + change_kind="cancel", + new_status="cancelled", + user=user, + extra_apply={"cancelled_at": now}, + ) + await session.commit() + await session.refresh(ev) + return _to_response(ev) + + +@router.post("/{event_id}/defer", response_model=EventResponse) +async def defer_event( + event_id: int, + body: DeferRequest, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + ev = await _load_owned(session, event_id, user) + await _transition( + session, + event=ev, + change_kind="defer", + new_status="deferred", + user=user, + extra_apply={"defer_until": body.defer_until}, + ) + await session.commit() + await session.refresh(ev) + return _to_response(ev) + + +@router.post("/{event_id}/reactivate", response_model=EventResponse) +async def reactivate_event( + event_id: int, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """완료/취소/연기 해제 — kind 따라 기본 status 복귀. + + task: inbox, calendar_event: scheduled, activity_log: done 유지 안 함 (activity_log 는 done 이 자연 상태이므로 reactivate 적용 X → 400). + """ + ev = await _load_owned(session, event_id, user) + if ev.kind == "activity_log": + raise HTTPException( + status_code=400, detail="activity_log 는 reactivate 대상 아님" + ) + new_status = "scheduled" if ev.kind == "calendar_event" else "inbox" + await _transition( + session, + event=ev, + change_kind="reactivate", + new_status=new_status, + user=user, + extra_apply={"completed_at": None, "cancelled_at": None, "defer_until": None}, + ) + await session.commit() + await session.refresh(ev) + return _to_response(ev) diff --git a/app/main.py b/app/main.py index 7add9dc..1c7bcb1 100644 --- a/app/main.py +++ b/app/main.py @@ -14,6 +14,7 @@ from api.digest import router as digest_router from api.document_notes import router as document_notes_router from api.document_reads import router as document_reads_router from api.documents import router as documents_router +from api.events import router as events_router from api.library import router as library_router from api.memos import router as memos_router from api.news import router as news_router @@ -129,6 +130,7 @@ app.include_router(document_notes_router, prefix="/api/documents", tags=["docume app.include_router(search_router, prefix="/api/search", tags=["search"]) app.include_router(memos_router, prefix="/api/memos", tags=["memos"]) +app.include_router(events_router, prefix="/api/events", tags=["events"]) app.include_router(dashboard_router, prefix="/api/dashboard", tags=["dashboard"]) app.include_router(library_router, prefix="/api/library", tags=["library"]) app.include_router(news_router, prefix="/api/news", tags=["news"]) diff --git a/app/models/event.py b/app/models/event.py new file mode 100644 index 0000000..3d52790 --- /dev/null +++ b/app/models/event.py @@ -0,0 +1,113 @@ +"""events 1차 컨테이너 ORM (개인 운영 로그 / 일정 / 할 일 / 회고) + +PR-1 (migrations 239~247) 의 본체. kind enum 으로 task/calendar_event/activity_log +세 변형을 통합 관리. memo_document_id 는 메모 link (optional). +""" + +from datetime import datetime +from typing import Any + +from sqlalchemy import ( + BigInteger, + Boolean, + DateTime, + ForeignKey, + SmallInteger, + String, + Text, +) +from sqlalchemy.dialects.postgresql import ENUM as PgEnum +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from core.database import Base + +# Postgres enum 재선언 X (create_type=False) — migration 239~243 이 권위. +EventKindEnum = PgEnum( + "task", + "calendar_event", + "activity_log", + name="event_kind", + create_type=False, +) +EventStatusEnum = PgEnum( + "inbox", + "next", + "scheduled", + "in_progress", + "done", + "cancelled", + "deferred", + name="event_status", + create_type=False, +) +EventSourceEnum = PgEnum( + "manual", + "memo", + "email", + "chat", + "webhook", + "git_commit", + "claude_code", + name="event_source", + create_type=False, +) +EventActorEnum = PgEnum( + "manual", + "eid", + "email_ingest", + "system", + name="event_actor", + create_type=False, +) + + +class Event(Base): + __tablename__ = "events" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + title: Mapped[str] = mapped_column(Text, nullable=False) + description: Mapped[str | None] = mapped_column(Text) + kind: Mapped[str] = mapped_column(EventKindEnum, nullable=False) + status: Mapped[str] = mapped_column(EventStatusEnum, nullable=False, default="inbox") + + # 시간 필드 — kind 별 의미가 다름 (CHECK 제약은 migration 244) + due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + start_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + end_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + all_day: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + timezone: Mapped[str | None] = mapped_column(Text) + + # lifecycle + defer_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + priority: Mapped[int | None] = mapped_column(SmallInteger) + project_tag: Mapped[str | None] = mapped_column(String(64)) + tags: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list) + + # 출처 / 외부 식별자 + source: Mapped[str] = mapped_column(EventSourceEnum, nullable=False, default="manual") + source_ref: Mapped[str | None] = mapped_column(Text) + raw_metadata: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict) + + # 메모 link (optional, ON DELETE SET NULL) + memo_document_id: Mapped[int | None] = mapped_column( + BigInteger, ForeignKey("documents.id", ondelete="SET NULL") + ) + + # 인증 / actor + user_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("users.id"), nullable=False + ) + created_by: Mapped[str] = mapped_column(EventActorEnum, nullable=False, default="manual") + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=datetime.now, nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=datetime.now, onupdate=datetime.now, nullable=False + ) diff --git a/app/models/event_history.py b/app/models/event_history.py new file mode 100644 index 0000000..866e9eb --- /dev/null +++ b/app/models/event_history.py @@ -0,0 +1,43 @@ +"""events_history ORM — events 의 lifecycle 변경 이력 (append-only). + +PR-1 (migrations 248~249). FK ON DELETE RESTRICT 로 부모 events row 직접 삭제 차단 +(feedback_history_table_fk_restrict.md — 이력은 시점 사실). +""" + +from datetime import datetime +from typing import Any + +from sqlalchemy import BigInteger, DateTime, ForeignKey +from sqlalchemy.dialects.postgresql import ENUM as PgEnum +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from core.database import Base +from models.event import EventActorEnum + +HistoryChangeKindEnum = PgEnum( + "create", + "reschedule", + "defer", + "reactivate", + "complete", + "cancel", + name="history_change_kind", + create_type=False, +) + + +class EventHistory(Base): + __tablename__ = "events_history" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + event_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("events.id", ondelete="RESTRICT"), nullable=False + ) + changed_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=datetime.now, nullable=False + ) + changed_by: Mapped[str] = mapped_column(EventActorEnum, nullable=False) + change_kind: Mapped[str] = mapped_column(HistoryChangeKindEnum, nullable=False) + before: Mapped[dict[str, Any] | None] = mapped_column(JSONB) + after: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) diff --git a/migrations/239_events_enum_kind.sql b/migrations/239_events_enum_kind.sql new file mode 100644 index 0000000..198a3ff --- /dev/null +++ b/migrations/239_events_enum_kind.sql @@ -0,0 +1,6 @@ +-- events 도메인 PR-1 (1/11) — event_kind enum +-- task: 해야 할 일 (due_at/start_at/end_at 자유 조합 허용) +-- calendar_event: 시간 블록이 있는 일정 (start_at 필수) +-- activity_log: 이미 한 행동 기록 (started_at 또는 ended_at 필수) + +CREATE TYPE event_kind AS ENUM ('task', 'calendar_event', 'activity_log'); diff --git a/migrations/240_events_enum_status.sql b/migrations/240_events_enum_status.sql new file mode 100644 index 0000000..2eb1a8d --- /dev/null +++ b/migrations/240_events_enum_status.sql @@ -0,0 +1,18 @@ +-- events 도메인 PR-1 (2/11) — event_status enum +-- inbox: 아직 정리 안 됨 +-- next: 다음 행동으로 선정됨 (시간 미정) +-- scheduled: 시간/날짜가 잡힘 +-- in_progress: 진행 중 +-- done: 완료 +-- cancelled: 취소 +-- deferred: 특정 시점 전까지 숨김 (defer_until 사용) + +CREATE TYPE event_status AS ENUM ( + 'inbox', + 'next', + 'scheduled', + 'in_progress', + 'done', + 'cancelled', + 'deferred' +); diff --git a/migrations/241_events_enum_source.sql b/migrations/241_events_enum_source.sql new file mode 100644 index 0000000..2cca1d7 --- /dev/null +++ b/migrations/241_events_enum_source.sql @@ -0,0 +1,12 @@ +-- events 도메인 PR-1 (3/11) — event_source enum +-- 데이터가 어디서 왔는가 (created_by 와는 별 축) + +CREATE TYPE event_source AS ENUM ( + 'manual', + 'memo', + 'email', + 'chat', + 'webhook', + 'git_commit', + 'claude_code' +); diff --git a/migrations/242_events_enum_actor.sql b/migrations/242_events_enum_actor.sql new file mode 100644 index 0000000..da10744 --- /dev/null +++ b/migrations/242_events_enum_actor.sql @@ -0,0 +1,10 @@ +-- events 도메인 PR-1 (4/11) — event_actor enum +-- 어떤 actor/process 가 row 를 만들었는가 (created_by + events_history.changed_by 공용) +-- source 와 분리: source=email + created_by=email_ingest 같은 직교 축 + +CREATE TYPE event_actor AS ENUM ( + 'manual', + 'eid', + 'email_ingest', + 'system' +); diff --git a/migrations/243_events_enum_history_change_kind.sql b/migrations/243_events_enum_history_change_kind.sql new file mode 100644 index 0000000..aab7cca --- /dev/null +++ b/migrations/243_events_enum_history_change_kind.sql @@ -0,0 +1,17 @@ +-- events 도메인 PR-1 (5/11) — history_change_kind enum +-- events_history 의 lifecycle 변경 유형 +-- create: 신규 생성 +-- reschedule: 시간 필드 변경 (due_at/start_at/end_at/started_at/ended_at/timezone/all_day) +-- defer: /defer endpoint 호출 (defer_until 설정) +-- reactivate: 완료/취소/연기 해제 +-- complete: /complete endpoint 호출 +-- cancel: /cancel endpoint 호출 + +CREATE TYPE history_change_kind AS ENUM ( + 'create', + 'reschedule', + 'defer', + 'reactivate', + 'complete', + 'cancel' +); diff --git a/migrations/244_events_table.sql b/migrations/244_events_table.sql new file mode 100644 index 0000000..316ea32 --- /dev/null +++ b/migrations/244_events_table.sql @@ -0,0 +1,38 @@ +-- events 도메인 PR-1 (6/11) — events 1차 컨테이너 테이블 +-- 개인 운영 로그 / 일정 / 할 일 / 회고용 activity database 의 본체. +-- 기존 documents/tasks/ask_events 와 직교한 새 도메인. + +CREATE TABLE IF NOT EXISTS events ( + id BIGSERIAL PRIMARY KEY, + title TEXT NOT NULL, + description TEXT, -- markdown + kind event_kind NOT NULL, + status event_status NOT NULL DEFAULT 'inbox', + due_at TIMESTAMPTZ, -- task 위주 + start_at TIMESTAMPTZ, -- calendar_event 위주 + end_at TIMESTAMPTZ, -- calendar_event 위주 + started_at TIMESTAMPTZ, -- 실제 수행 시각 (activity_log 위주) + ended_at TIMESTAMPTZ, -- 실제 수행 종료 + all_day BOOLEAN NOT NULL DEFAULT false, + timezone TEXT, + defer_until TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + cancelled_at TIMESTAMPTZ, + priority SMALLINT CHECK (priority BETWEEN 1 AND 4), + project_tag VARCHAR(64), + tags JSONB NOT NULL DEFAULT '[]'::jsonb, + source event_source NOT NULL DEFAULT 'manual', + source_ref TEXT, -- Message-ID 등 (TEXT, 충분한 길이) + raw_metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + memo_document_id BIGINT REFERENCES documents(id) ON DELETE SET NULL, + user_id BIGINT NOT NULL REFERENCES users(id), + created_by event_actor NOT NULL DEFAULT 'manual', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + -- CHECK 제약 (라운드 10: task 제약 제거 + activity_log 완화 — 미래 확장 막지 않게) + CONSTRAINT events_calendar_event_requires_start + CHECK (kind <> 'calendar_event' OR start_at IS NOT NULL), + CONSTRAINT events_activity_log_requires_time + CHECK (kind <> 'activity_log' OR started_at IS NOT NULL OR ended_at IS NOT NULL) +); diff --git a/migrations/245_events_source_ref_uq.sql b/migrations/245_events_source_ref_uq.sql new file mode 100644 index 0000000..188cd0f --- /dev/null +++ b/migrations/245_events_source_ref_uq.sql @@ -0,0 +1,7 @@ +-- events 도메인 PR-1 (7/11) — partial unique on (source, source_ref) +-- 외부 source dedup (이메일 Message-ID, git commit hash, webhook event_id 등). +-- PR-1 에서는 schema 만 박고 dedup 동작 검증은 PR-4 MailPlus ingest 시. + +CREATE UNIQUE INDEX IF NOT EXISTS events_source_ref_uq + ON events (source, source_ref) + WHERE source_ref IS NOT NULL; diff --git a/migrations/246_events_idx_active.sql b/migrations/246_events_idx_active.sql new file mode 100644 index 0000000..0ca414e --- /dev/null +++ b/migrations/246_events_idx_active.sql @@ -0,0 +1,7 @@ +-- events 도메인 PR-1 (8/11) — active events partial index +-- Today/Upcoming/Inbox view 의 핵심 인덱스. +-- done/cancelled 는 활성 list 에서 빠지므로 partial 로 사이즈 절감. + +CREATE INDEX IF NOT EXISTS idx_events_active + ON events (user_id, due_at, start_at) + WHERE status IN ('inbox', 'next', 'scheduled', 'deferred'); diff --git a/migrations/247_events_idx_activity.sql b/migrations/247_events_idx_activity.sql new file mode 100644 index 0000000..d5027f6 --- /dev/null +++ b/migrations/247_events_idx_activity.sql @@ -0,0 +1,7 @@ +-- events 도메인 PR-1 (9/11) — activity_log timeline 전용 partial index +-- Activity 탭 (한 일 / 회고) view 핵심. +-- events_history 와 이름 분리 (라운드 11 — idx_events_history_user 가 아닌 events 본 테이블 index). + +CREATE INDEX IF NOT EXISTS idx_events_activity_user_started + ON events (user_id, started_at DESC) + WHERE kind = 'activity_log'; diff --git a/migrations/248_events_history_table.sql b/migrations/248_events_history_table.sql new file mode 100644 index 0000000..1c2ef5d --- /dev/null +++ b/migrations/248_events_history_table.sql @@ -0,0 +1,15 @@ +-- events 도메인 PR-1 (10/11) — events_history 변경 이력 테이블 +-- lifecycle op 자동 기록 (create / reschedule / defer / reactivate / complete / cancel). +-- 일반 PATCH (title/description 등) 의 history 는 v1 X (폭증 회피). +-- ON DELETE RESTRICT: 이력은 시점 사실 → 부모 events row 직접 삭제 차단 +-- (feedback_history_table_fk_restrict.md). events 자체는 /cancel 로 soft-cancel. + +CREATE TABLE IF NOT EXISTS events_history ( + id BIGSERIAL PRIMARY KEY, + event_id BIGINT NOT NULL REFERENCES events(id) ON DELETE RESTRICT, + changed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + changed_by event_actor NOT NULL, + change_kind history_change_kind NOT NULL, + before JSONB, -- create 시 NULL + after JSONB NOT NULL +); diff --git a/migrations/249_events_history_idx.sql b/migrations/249_events_history_idx.sql new file mode 100644 index 0000000..f0f5b7c --- /dev/null +++ b/migrations/249_events_history_idx.sql @@ -0,0 +1,5 @@ +-- events 도메인 PR-1 (11/11) — events_history (event_id, changed_at) index +-- 일정 상세 페이지의 history timeline 조회 핵심. + +CREATE INDEX IF NOT EXISTS idx_events_history_event + ON events_history (event_id, changed_at); -- 2.52.0