|
|
# backend/app/models.py
|
|
|
# 주의: `from __future__ import annotations` 를 쓰지 않는다 —
|
|
|
# SQLAlchemy 2.x 가 Relationship 의 list["Project"] 문자열 주석을 해석하지 못해
|
|
|
# 매퍼 초기화에 실패한다(ForwardRef 로 평가되도록 둔다).
|
|
|
from datetime import UTC, date, datetime
|
|
|
from enum import Enum
|
|
|
from typing import Optional
|
|
|
|
|
|
from sqlalchemy import JSON, Column, Text
|
|
|
from sqlmodel import Field, Relationship, SQLModel
|
|
|
|
|
|
|
|
|
def now() -> datetime:
|
|
|
return datetime.now(UTC)
|
|
|
|
|
|
|
|
|
# ---------- enums (값은 CONTRACT 고정) ----------
|
|
|
class TaskStatus(str, Enum):
|
|
|
todo = "todo"
|
|
|
doing = "doing"
|
|
|
waiting = "waiting"
|
|
|
review = "review"
|
|
|
done = "done"
|
|
|
|
|
|
|
|
|
class Prio(str, Enum):
|
|
|
high = "높음"
|
|
|
normal = "보통"
|
|
|
low = "낮음"
|
|
|
|
|
|
|
|
|
class InboxKind(str, Enum):
|
|
|
text = "text"
|
|
|
voice = "voice"
|
|
|
image = "image"
|
|
|
|
|
|
|
|
|
class InboxStatus(str, Enum):
|
|
|
new = "new"
|
|
|
classified = "classified"
|
|
|
confirmed = "confirmed"
|
|
|
dismissed = "dismissed"
|
|
|
|
|
|
|
|
|
class ClsType(str, Enum):
|
|
|
task = "task"
|
|
|
event = "event"
|
|
|
idea = "idea"
|
|
|
|
|
|
|
|
|
class Sphere(str, Enum):
|
|
|
work = "work"
|
|
|
life = "life"
|
|
|
|
|
|
|
|
|
# ---------- core ----------
|
|
|
class Person(SQLModel, table=True):
|
|
|
__tablename__ = "person"
|
|
|
id: str = Field(primary_key=True) # 예: "jiwoo"
|
|
|
name: str
|
|
|
initial: str
|
|
|
color: str # 예: "var(--blue)" / "oklch(0.66 0.13 200)"
|
|
|
is_me: bool = False
|
|
|
email: str = "" # phase-9 메일 발신자 주소
|
|
|
role: str = "" # phase-9 "프로덕트 디자이너" 등
|
|
|
|
|
|
|
|
|
class Folder(SQLModel, table=True):
|
|
|
__tablename__ = "folder"
|
|
|
id: str = Field(primary_key=True) # "work" / "life"
|
|
|
name: str # "업무" / "개인"
|
|
|
tone: str = "ink" # tone 집합: blue|violet|coral|green|amber|ink|faint
|
|
|
icon: str = "folder" # "folder" / "heart"
|
|
|
sort_order: int = 0
|
|
|
is_system: bool = True
|
|
|
|
|
|
projects: list["Project"] = Relationship(back_populates="folder")
|
|
|
|
|
|
|
|
|
class Project(SQLModel, table=True):
|
|
|
__tablename__ = "project"
|
|
|
id: str = Field(primary_key=True) # "biz", "biz-okr", ...
|
|
|
folder_id: str = Field(foreign_key="folder.id")
|
|
|
parent_id: Optional[str] = Field(default=None, foreign_key="project.id") # 무한 중첩
|
|
|
name: str
|
|
|
tone: str = "ink"
|
|
|
sort_order: int = 0
|
|
|
pinned: bool = False # 즐겨찾기
|
|
|
|
|
|
folder: Optional[Folder] = Relationship(back_populates="projects")
|
|
|
parent: Optional["Project"] = Relationship(
|
|
|
back_populates="children",
|
|
|
sa_relationship_kwargs={"remote_side": "Project.id"},
|
|
|
)
|
|
|
children: list["Project"] = Relationship(back_populates="parent")
|
|
|
tasks: list["Task"] = Relationship(back_populates="project")
|
|
|
|
|
|
|
|
|
class Task(SQLModel, table=True):
|
|
|
__tablename__ = "task"
|
|
|
id: str = Field(primary_key=True) # "k1", "kx3", ...
|
|
|
user_id: str = Field(default="jiwoo", foreign_key="person.id", index=True) # phase-15 소유자
|
|
|
project_id: str = Field(foreign_key="project.id")
|
|
|
parent_id: Optional[str] = Field(default=None, foreign_key="task.id") # 무한 중첩 하위작업
|
|
|
title: str
|
|
|
status: TaskStatus = TaskStatus.todo
|
|
|
assignee_id: Optional[str] = Field(default=None, foreign_key="person.id")
|
|
|
due: Optional[date] = None # 시드는 "06-08" → 2026-06-08 로 적재
|
|
|
prio: Prio = Prio.normal
|
|
|
notes: str = "" # HTML 허용
|
|
|
est: str = ""
|
|
|
delegated: bool = False
|
|
|
sort_order: int = 0
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
updated_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
project: Optional[Project] = Relationship(back_populates="tasks")
|
|
|
parent: Optional["Task"] = Relationship(
|
|
|
back_populates="children",
|
|
|
sa_relationship_kwargs={"remote_side": "Task.id"},
|
|
|
)
|
|
|
children: list["Task"] = Relationship(back_populates="parent")
|
|
|
comments: list["TaskComment"] = Relationship(back_populates="task")
|
|
|
# phase-15: user_id 도 person FK 라 assignee 조인을 명시(다중 FK 모호성 해소)
|
|
|
assignee: Optional[Person] = Relationship(
|
|
|
sa_relationship_kwargs={"foreign_keys": "Task.assignee_id"}
|
|
|
)
|
|
|
|
|
|
|
|
|
class TaskComment(SQLModel, table=True):
|
|
|
__tablename__ = "task_comment"
|
|
|
id: str = Field(primary_key=True) # TEXT PK 예: "c1"
|
|
|
task_id: str = Field(foreign_key="task.id")
|
|
|
person_id: str = Field(foreign_key="person.id")
|
|
|
text: str
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
edited_at: Optional[datetime] = None # 수정 시각(없으면 미수정)
|
|
|
|
|
|
task: Optional[Task] = Relationship(back_populates="comments")
|
|
|
person: Optional[Person] = Relationship()
|
|
|
|
|
|
|
|
|
# ---------- inbox (연합의 핵심) ----------
|
|
|
class InboxItem(SQLModel, table=True):
|
|
|
__tablename__ = "inbox_item"
|
|
|
id: str = Field(primary_key=True) # "s1", ...
|
|
|
user_id: str = Field(default="jiwoo", foreign_key="person.id", index=True) # phase-15 소유자
|
|
|
kind: InboxKind = InboxKind.text
|
|
|
raw: str
|
|
|
status: InboxStatus = InboxStatus.new
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
materialized_task_id: Optional[str] = Field(default=None, foreign_key="task.id")
|
|
|
|
|
|
classifications: list["InboxClassification"] = Relationship(back_populates="item")
|
|
|
|
|
|
|
|
|
class InboxClassification(SQLModel, table=True):
|
|
|
__tablename__ = "inbox_classification"
|
|
|
id: str = Field(primary_key=True) # TEXT PK 예: "cls1"
|
|
|
inbox_item_id: str = Field(foreign_key="inbox_item.id") # 1:1 최신
|
|
|
type: ClsType
|
|
|
sphere: Sphere
|
|
|
project_id: Optional[str] = Field(default=None, foreign_key="project.id")
|
|
|
proj_label: str = "" # 예: "개인 › 여행 — 한국"
|
|
|
tone: str = "ink"
|
|
|
due_text: str = "" # 예: "출발 전 · ~6/14"
|
|
|
when_text: str = "" # 예: "오늘 21:00 빈 시간 추천"
|
|
|
extra: str = "" # 예: "가격 추적 알림 켜둠"
|
|
|
reason: str = "" # 사람이 읽는 한국어 설명
|
|
|
confidence: float = 0.0
|
|
|
model: str = "" # 사용된 provider/model 표기
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
item: Optional[InboxItem] = Relationship(back_populates="classifications")
|
|
|
|
|
|
|
|
|
# ---------- 대시보드 읽기전용 시드 ----------
|
|
|
class Event(SQLModel, table=True):
|
|
|
__tablename__ = "event"
|
|
|
id: str = Field(primary_key=True) # TEXT PK 예: "e1"
|
|
|
time: str
|
|
|
title: str
|
|
|
tag: str = ""
|
|
|
dur: str = ""
|
|
|
tone: str = "ink" # tone 키 값('blue' 등). 'var(--blue)' 저장 금지
|
|
|
soon: bool = False
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class Approval(SQLModel, table=True):
|
|
|
"""phase-2 읽기전용 → phase-7 '쓰기 가능'으로 확장.
|
|
|
기존 필드는 유지하고 status/source/rule_id/created_at/executed_at/undone_at 추가."""
|
|
|
|
|
|
__tablename__ = "approval"
|
|
|
id: str = Field(primary_key=True) # "a1", ..., 신규 "ap-xxxx"
|
|
|
user_id: str = Field(default="jiwoo", foreign_key="person.id", index=True) # phase-15 소유자
|
|
|
icon: str
|
|
|
tone: str
|
|
|
risk: str # "low" | "high"
|
|
|
time: str = ""
|
|
|
title: str
|
|
|
detail: str = ""
|
|
|
cta: str = ""
|
|
|
alt: str = ""
|
|
|
undo_label: str = ""
|
|
|
sort_order: int = 0
|
|
|
# --- 신규(phase-7) ---
|
|
|
status: str = "pending" # pending|approved|executed|undone
|
|
|
source: str = "system" # automation|agent|mail|calendar|finance|inbox|life|system
|
|
|
rule_id: Optional[str] = Field(default=None, foreign_key="automation_rule.id")
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
executed_at: Optional[datetime] = None
|
|
|
undone_at: Optional[datetime] = None
|
|
|
|
|
|
|
|
|
class Goal(SQLModel, table=True):
|
|
|
__tablename__ = "goal"
|
|
|
id: str = Field(primary_key=True) # TEXT PK 예: "g1"
|
|
|
title: str
|
|
|
pct: int
|
|
|
sub: str = ""
|
|
|
tone: str = "blue" # tone 키 값('blue' 등). 'var(--blue)' 저장 금지
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class Briefing(SQLModel, table=True):
|
|
|
"""단일 row(id=1). 아침 브리핑 + 날씨/출근/수면 + saved_today/today_routed."""
|
|
|
|
|
|
__tablename__ = "briefing"
|
|
|
id: Optional[int] = Field(default=None, primary_key=True) # 단일 row id=1
|
|
|
today: str = "" # 히어로 날짜 라벨 (예: "6월 7일 일요일")
|
|
|
weather_temp: int = 0 # 현재 기온 (예: 24)
|
|
|
weather_cond: str = "" # "맑음 · 한낮 28°" — "한낮 28°"는 일 최고기온
|
|
|
weather_icon: str = "cloudSun" # 'cloudSun' 저장 → 표시 시 'sun' 으로 매핑
|
|
|
commute: str = ""
|
|
|
sleep: str = ""
|
|
|
note: str = "" # HTML 허용
|
|
|
saved_today: str = "" # "47분"
|
|
|
today_routed: int = 0 # 7
|
|
|
|
|
|
|
|
|
# ==================== phase-7: 자율성 코어 (결재함 + 자동화) ====================
|
|
|
# enum 값은 영문(상태 머신/risk), 표시 라벨은 프론트가 매핑.
|
|
|
|
|
|
|
|
|
class RiskLevel(str, Enum):
|
|
|
low = "low" # 되돌릴 수 있는 일 — mixed 이상 자동 실행
|
|
|
high = "high" # 보내기·결제·타인 전달 — 항상 승인 대기
|
|
|
|
|
|
|
|
|
class ApprovalStatus(str, Enum):
|
|
|
pending = "pending" # 승인 대기
|
|
|
approved = "approved" # high 승인 직후(표시용)
|
|
|
executed = "executed" # 실행 완료(자동/승인)
|
|
|
undone = "undone" # 되돌림
|
|
|
|
|
|
|
|
|
class ApprovalSource(str, Enum):
|
|
|
automation = "automation"
|
|
|
agent = "agent"
|
|
|
mail = "mail"
|
|
|
calendar = "calendar"
|
|
|
finance = "finance"
|
|
|
inbox = "inbox"
|
|
|
life = "life"
|
|
|
system = "system"
|
|
|
|
|
|
|
|
|
class AutonomyLevel(str, Enum):
|
|
|
approval_first = "approval_first" # 승인 우선 (원본 id "approve")
|
|
|
mixed = "mixed" # 혼합 (추천)
|
|
|
full_auto = "full_auto" # 완전 자율 (원본 id "auto")
|
|
|
|
|
|
|
|
|
class AutonomySetting(SQLModel, table=True):
|
|
|
"""단일 row(id="default"). 데모 단일 사용자(지우)의 자율성 레벨."""
|
|
|
|
|
|
__tablename__ = "autonomy_setting"
|
|
|
id: str = Field(default="default", primary_key=True) # 단일 row id="default" (TEXT PK)
|
|
|
level: str = AutonomyLevel.mixed.value # 기본 '혼합'(원본 추천)
|
|
|
updated_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
|
|
|
class ApprovalLog(SQLModel, table=True):
|
|
|
"""결재함 활동 로그 — '밤사이 조용히 한 일'. approve-data.js log 이식."""
|
|
|
|
|
|
__tablename__ = "approval_log"
|
|
|
id: str = Field(primary_key=True) # "alog1"...
|
|
|
time: str # 표시용("08:55","어제 23:10")
|
|
|
text: str
|
|
|
approval_id: Optional[str] = Field(default=None, foreign_key="approval.id")
|
|
|
sort_order: int = 0
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
|
|
|
class AutomationRule(SQLModel, table=True):
|
|
|
__tablename__ = "automation_rule"
|
|
|
id: str = Field(primary_key=True) # "r1"~"r7", 신규 "rule-xxxx"
|
|
|
name: str
|
|
|
cat: str # mail | cal | focus | life
|
|
|
trigger: str
|
|
|
cond: Optional[str] = None # None 이면 조건 칩 미표시
|
|
|
action: str
|
|
|
on: bool = True # 켜짐/꺼짐
|
|
|
last: str = "" # "오늘 09:12"/"방금 만듦"/"지난주 금"
|
|
|
runs: int = 0 # 이번 주 실행 횟수
|
|
|
fresh: bool = False # 막 생성됨 → 프론트 rl-new 애니메이션
|
|
|
source: str = "user" # user | suggestion | example
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
|
|
|
class AutomationSuggestion(SQLModel, table=True):
|
|
|
__tablename__ = "automation_suggestion"
|
|
|
id: str = Field(primary_key=True) # "asug-s1"... (시드 원본 s1/s2)
|
|
|
pattern: str # "월요일 아침마다 ..."
|
|
|
offer_name: str
|
|
|
offer_cat: str
|
|
|
offer_trigger: str
|
|
|
offer_cond: Optional[str] = None
|
|
|
offer_action: str
|
|
|
status: str = "open" # open | accepted | dismissed
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
|
|
|
class AutomationRunLog(SQLModel, table=True):
|
|
|
__tablename__ = "automation_run_log"
|
|
|
id: str = Field(primary_key=True) # "arun1"...
|
|
|
time: str # "12:48"/"어제 18:30"
|
|
|
rule_id: Optional[str] = Field(default=None, foreign_key="automation_rule.id")
|
|
|
rule: str # 규칙명 스냅샷("영수증 자동 정리")
|
|
|
text: str # "점심 결제 13,500원 → 식비로 분류"
|
|
|
undone: bool = False
|
|
|
sort_order: int = 0
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
|
|
|
class AutomationStats(SQLModel, table=True):
|
|
|
"""단일 row(id=1). 페이지 eyebrow 통계. auto-data.js stats 이식."""
|
|
|
|
|
|
__tablename__ = "automation_stats"
|
|
|
id: Optional[int] = Field(default=None, primary_key=True) # 단일 row id=1
|
|
|
active: int = 0 # 활성 규칙 수(파생; 시드값 7)
|
|
|
runs_week: int = 0 # 이번 주 실행(시드 31)
|
|
|
saved: str = "" # "1시간 40분"
|
|
|
|
|
|
|
|
|
# ==================== phase-8: 일정 + 회의 도우미 ====================
|
|
|
|
|
|
|
|
|
class Calendar(SQLModel, table=True):
|
|
|
"""캘린더 카테고리 (cal-data.js: cals)."""
|
|
|
|
|
|
__tablename__ = "calendar"
|
|
|
id: str = Field(primary_key=True) # work/meeting/personal/team/health
|
|
|
name: str # 업무/미팅/개인/팀/건강
|
|
|
tone: str = "ink" # tone 집합
|
|
|
on: bool = True # 기본 표시 (프론트는 localStorage 로 토글 영속)
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class CalEvent(SQLModel, table=True):
|
|
|
"""캘린더 이벤트 (cal-data.js: events) — 시간 그리드용. 대시보드 event 와 별개 테이블."""
|
|
|
|
|
|
__tablename__ = "cal_event"
|
|
|
id: str = Field(primary_key=True) # "e1".."e22"
|
|
|
day: int # 날짜의 '일'(1..31) — 데모 호환. 실데이터는 date 우선.
|
|
|
date: str = "" # 실제 날짜 ISO "YYYY-MM-DD"(외부 동기화). 비면 데모(6월)로 간주.
|
|
|
start: str # "HH:MM"
|
|
|
end: str # "HH:MM"
|
|
|
title: str
|
|
|
cal: str = Field(foreign_key="calendar.id") # 카테고리
|
|
|
loc: str = ""
|
|
|
note: str = ""
|
|
|
soon: bool = False # '곧' 배지
|
|
|
people: str = "" # 콤마구분 문자열(표시용 호환)
|
|
|
rrule: str = "" # iCalendar RRULE (반복 일정), 예 "FREQ=WEEKLY;BYDAY=MO,WE"
|
|
|
reminders: list = Field(default_factory=list, sa_column=Column(JSON)) # [{minutes:int}]
|
|
|
attendees: list = Field(default_factory=list, sa_column=Column(JSON)) # [{email,name,status}]
|
|
|
response_status: str = "" # 내 RSVP: accepted|declined|tentative|needsAction
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class FocusBlock(SQLModel, table=True):
|
|
|
"""집중 모드 블록 (cal-data.js: focusBlocks)."""
|
|
|
|
|
|
__tablename__ = "focus_block"
|
|
|
id: str = Field(primary_key=True) # "f1".."f4"
|
|
|
day: int
|
|
|
start: str
|
|
|
end: str
|
|
|
title: str
|
|
|
type: str = "light" # "light" | "deep"
|
|
|
tag: str = ""
|
|
|
task_id: Optional[str] = Field(default=None, foreign_key="task.id")
|
|
|
auto: bool = True # 아리 자동 배치 여부
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class MeetPhase(str, Enum):
|
|
|
done = "done"
|
|
|
live = "live"
|
|
|
upcoming = "upcoming"
|
|
|
|
|
|
|
|
|
class Meeting(SQLModel, table=True):
|
|
|
"""회의 (cal-data.js: meets[evId]). 표시 전용 중첩 데이터는 *_json TEXT 로 저장."""
|
|
|
|
|
|
__tablename__ = "meeting"
|
|
|
id: str = Field(primary_key=True) # event id 와 동일 키 ("e3","e4","e6","e7")
|
|
|
event_id: str = Field(foreign_key="cal_event.id")
|
|
|
phase: MeetPhase = MeetPhase.upcoming
|
|
|
one_on_one: bool = False
|
|
|
meta: str = ""
|
|
|
starts_in: str = ""
|
|
|
summary_json: str = "[]"
|
|
|
decisions_json: str = "[]"
|
|
|
agenda_json: str = "[]"
|
|
|
last_meeting_json: str = "{}"
|
|
|
last_actions_json: str = "[]"
|
|
|
insights_json: str = "[]"
|
|
|
docs_json: str = "[]"
|
|
|
person_json: str = "{}"
|
|
|
promises_json: str = "[]"
|
|
|
signals_json: str = "[]"
|
|
|
talking_points_json: str = "[]"
|
|
|
|
|
|
actions: list["MeetingAction"] = Relationship(back_populates="meeting")
|
|
|
|
|
|
|
|
|
class MeetingAction(SQLModel, table=True):
|
|
|
"""회의/이벤트 액션 아이템 (작업으로 실체화 대상)."""
|
|
|
|
|
|
__tablename__ = "meeting_action"
|
|
|
id: str = Field(primary_key=True) # "ma1"/"ea1"...
|
|
|
meeting_id: Optional[str] = Field(default=None, foreign_key="meeting.id")
|
|
|
event_id: Optional[str] = Field(default=None, foreign_key="cal_event.id")
|
|
|
source: str = "meeting" # "meeting" | "event"
|
|
|
idx: int = 0 # 회의 내 순번
|
|
|
text: str
|
|
|
who: str = "나" # 담당자 표시명 (나/현우/수아...)
|
|
|
when_text: str = "" # 오늘/내일/이번 주
|
|
|
added: bool = False # 작업으로 보냈는가
|
|
|
materialized_task_id: Optional[str] = Field(default=None, foreign_key="task.id")
|
|
|
sort_order: int = 0
|
|
|
|
|
|
meeting: Optional[Meeting] = Relationship(back_populates="actions")
|
|
|
|
|
|
|
|
|
# ==================== phase-9: 메일 + 알림 ====================
|
|
|
|
|
|
|
|
|
class MailPriority(str, Enum):
|
|
|
high = "높음"
|
|
|
normal = "보통"
|
|
|
low = "낮음"
|
|
|
|
|
|
|
|
|
class NotifyBucket(str, Enum):
|
|
|
now = "now" # 지금 (직접 봐야 함)
|
|
|
later = "later" # 나중에 (묶어서 다이제스트)
|
|
|
held = "held" # 아리가 처리 (자동 · 되돌리기 가능)
|
|
|
|
|
|
|
|
|
# ---------- 메일 (3계정 통합) ----------
|
|
|
class MailAccount(SQLModel, table=True):
|
|
|
__tablename__ = "mail_account"
|
|
|
id: str = Field(primary_key=True) # work | personal | side
|
|
|
name: str
|
|
|
email: str
|
|
|
tone: str = "ink"
|
|
|
kind: str = "" # "Lumi Workspace" | "Google" | "HEY"
|
|
|
unread: int = 0
|
|
|
connector: str = "mock"
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class MailFolder(SQLModel, table=True):
|
|
|
__tablename__ = "mail_folder"
|
|
|
id: str = Field(primary_key=True) # inbox|starred|sent|drafts|archive
|
|
|
name: str
|
|
|
icon: str = "inbox"
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class AccountFolder(SQLModel, table=True):
|
|
|
"""계정별 실제 폴더/라벨(Gmail 라벨·Outlook 메일폴더)을 sync 때 미러링.
|
|
|
사이드바 트리가 계정마다 진짜 폴더 구조를 보여주는 원천."""
|
|
|
|
|
|
__tablename__ = "account_folder"
|
|
|
id: str = Field(primary_key=True) # f"{mail_account}:{slug}"
|
|
|
mail_account: str = Field(foreign_key="mail_account.id", index=True)
|
|
|
slug: str # 필터 키: inbox/spam/trash/archive/sent | cat:promotions | label:<name> | of:<id>
|
|
|
name: str # 표시명(시스템은 한글, 사용자 라벨/커스텀 폴더는 원본)
|
|
|
kind: str = "system" # system | category | label | custom
|
|
|
icon: str = "folder"
|
|
|
provider_id: str = "" # 원격 폴더/라벨 id(Outlook folderId / Gmail labelId)
|
|
|
unread: int = 0 # 안읽음 수(Outlook=서버값, Gmail=동기화분 기준)
|
|
|
total: int = 0
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class Email(SQLModel, table=True):
|
|
|
__tablename__ = "email"
|
|
|
id: str = Field(primary_key=True) # "m1".."m8"
|
|
|
user_id: str = Field(default="jiwoo", foreign_key="person.id", index=True) # phase-15 소유자
|
|
|
account: str = Field(foreign_key="mail_account.id")
|
|
|
from_key: str # people 키 ("hyunwoo" 등). 'from'은 예약어라 from_key
|
|
|
to: str = ""
|
|
|
cc: str = "" # 받은 메일의 참조(콤마 구분)
|
|
|
thread_id: str = "" # Gmail threadId / Outlook conversationId (대화 묶기)
|
|
|
subject: str = ""
|
|
|
time: str = "" # "8분 전"
|
|
|
date: str = "" # "오늘 09:12"
|
|
|
read: bool = False
|
|
|
starred: bool = False
|
|
|
archived: bool = False
|
|
|
has_attach: bool = False
|
|
|
labels: list = Field(default_factory=list, sa_column=Column(JSON))
|
|
|
preview: str = ""
|
|
|
body: list = Field(default_factory=list, sa_column=Column(JSON))
|
|
|
# 원본 HTML 본문(샌드박스 iframe 렌더용; cid: 인라인 이미지는 읽기 시 첨부 URL 로 치환).
|
|
|
body_html: str = Field(default="", sa_column=Column(Text))
|
|
|
attachments: list = Field(default_factory=list, sa_column=Column(JSON))
|
|
|
ai_json: dict = Field(default_factory=dict, sa_column=Column(JSON))
|
|
|
sort_order: int = 0
|
|
|
# 실제 수신 시각(Gmail internalDate / Outlook receivedDateTime). 목록 정렬(최신순)·표시 기준.
|
|
|
received_at: datetime | None = Field(default=None, index=True)
|
|
|
# 메일이 속한 폴더(provider 기준 1개). inbox/spam/trash/archive/sent | of:<id>(Outlook 커스텀).
|
|
|
folder: str = Field(default="inbox", index=True)
|
|
|
folder_name: str = "" # Outlook 커스텀 폴더 표시명(시스템 폴더는 비움 → 표준 한글명 사용)
|
|
|
# Outlook 집중 받은편지함 분류(focused/other). 빈 문자열=미분류(Gmail 등). 받은편지함=focused.
|
|
|
inference: str = ""
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
|
|
|
class Sent(SQLModel, table=True):
|
|
|
__tablename__ = "sent"
|
|
|
id: str = Field(primary_key=True)
|
|
|
account: str = Field(foreign_key="mail_account.id")
|
|
|
to: str = ""
|
|
|
to_key: str = ""
|
|
|
subject: str = ""
|
|
|
time: str = ""
|
|
|
date: str = ""
|
|
|
preview: str = ""
|
|
|
sort_order: int = 0
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
|
|
|
class OutboundMail(SQLModel, table=True):
|
|
|
"""phase-16: 발송 대기 메일. /mail/send 가 결재와 함께 생성, 승인 시 실제 발송.
|
|
|
connector_account_id 가 real+connected 면 Gmail/Graph 로 발송, 아니면 mock(Sent 행)."""
|
|
|
|
|
|
__tablename__ = "outbound_mail"
|
|
|
id: str = Field(primary_key=True) # "ob-xxxx"
|
|
|
approval_id: str = Field(foreign_key="approval.id", index=True)
|
|
|
connector_account_id: Optional[str] = Field(default=None, foreign_key="connector_account.id")
|
|
|
mail_account: str = "" # 발신 MailAccount id(work|personal|ma-xxxx)
|
|
|
to: str = ""
|
|
|
cc: str = "" # 참조(콤마 구분)
|
|
|
bcc: str = "" # 숨은참조(콤마 구분)
|
|
|
subject: str = ""
|
|
|
body: str = ""
|
|
|
# [{name,mime,data_b64}]
|
|
|
attachments: list = Field(default_factory=list, sa_column=Column(JSON))
|
|
|
status: str = "pending" # pending | sent | failed | mock
|
|
|
error_detail: str = ""
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
sent_at: Optional[datetime] = None
|
|
|
|
|
|
|
|
|
class Draft(SQLModel, table=True):
|
|
|
__tablename__ = "draft"
|
|
|
id: str = Field(primary_key=True)
|
|
|
account: str = Field(foreign_key="mail_account.id")
|
|
|
to: str = ""
|
|
|
subject: str = ""
|
|
|
time: str = ""
|
|
|
preview: str = ""
|
|
|
body: str = ""
|
|
|
sort_order: int = 0
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
|
|
|
# ---------- 알림 트리아지 ----------
|
|
|
class Notification(SQLModel, table=True):
|
|
|
__tablename__ = "notification"
|
|
|
id: str = Field(primary_key=True) # "n1".., "l1".., "h1"..
|
|
|
user_id: str = Field(default="jiwoo", foreign_key="person.id", index=True) # phase-15 소유자
|
|
|
bucket: NotifyBucket = NotifyBucket.now
|
|
|
src: str = "app" # mail|cal|chat|fin|app
|
|
|
app: str = ""
|
|
|
title: str = ""
|
|
|
sum: str = "" # 본문 요약 (원본 키 'sum' 보존)
|
|
|
time: str = ""
|
|
|
why: str = "" # 분류 이유 칩 텍스트
|
|
|
tone: str = "ink" # 이유 칩 색 (muted 포함)
|
|
|
act: str = "" # 주요 액션 라벨 (now 전용)
|
|
|
held_by_rule: str = "" # held 일 때 적용된 sender_rule id
|
|
|
sort_order: int = 0
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
|
|
|
class NotificationGuard(SQLModel, table=True):
|
|
|
"""단일 row(id=1). 집중 보호 + 조용한 시간."""
|
|
|
|
|
|
__tablename__ = "notification_guard"
|
|
|
id: Optional[int] = Field(default=None, primary_key=True)
|
|
|
on: bool = True
|
|
|
until: str = "16:00"
|
|
|
held: int = 4
|
|
|
quiet: str = "22:30 – 07:00"
|
|
|
|
|
|
|
|
|
class Digest(SQLModel, table=True):
|
|
|
__tablename__ = "digest"
|
|
|
id: str = Field(primary_key=True) # "dg-am" 등
|
|
|
time: str = ""
|
|
|
name: str = ""
|
|
|
desc: str = ""
|
|
|
count: int = 0
|
|
|
done: bool = False
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class SenderRule(SQLModel, table=True):
|
|
|
__tablename__ = "sender_rule"
|
|
|
id: str = Field(primary_key=True) # "sr1"..
|
|
|
who: str = ""
|
|
|
from_label: str = "" # 원본 'from'
|
|
|
rule: str = ""
|
|
|
bucket: NotifyBucket = NotifyBucket.now
|
|
|
tone: str = "ink"
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class NotifyStats(SQLModel, table=True):
|
|
|
"""단일 row(id=1). 오늘 요약."""
|
|
|
|
|
|
__tablename__ = "notify_stats"
|
|
|
id: Optional[int] = Field(default=None, primary_key=True)
|
|
|
total: int = 0
|
|
|
seen: int = 0
|
|
|
filtered: str = ""
|
|
|
later_more: int = 0
|
|
|
held_more: int = 0
|
|
|
|
|
|
|
|
|
# ==================== phase-10: 리서치 + 여행 ====================
|
|
|
|
|
|
|
|
|
class SourceKind(str, Enum):
|
|
|
pdf = "pdf"
|
|
|
web = "web"
|
|
|
note = "note"
|
|
|
|
|
|
|
|
|
class ResearchCollection(SQLModel, table=True):
|
|
|
__tablename__ = "research_collection"
|
|
|
id: str = Field(primary_key=True) # chip/onb/stroller
|
|
|
user_id: str = Field(default="jiwoo", foreign_key="person.id", index=True) # phase-15 소유자
|
|
|
name: str
|
|
|
tone: str = "ink"
|
|
|
n: int = 0
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class ResearchSource(SQLModel, table=True):
|
|
|
__tablename__ = "research_source"
|
|
|
id: str = Field(primary_key=True) # s1..s5
|
|
|
kind: SourceKind
|
|
|
title: str
|
|
|
from_label: str = "" # 원본 from (응답 alias "from")
|
|
|
col: Optional[str] = Field(default=None, foreign_key="research_collection.id")
|
|
|
learned: bool = False
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class ResearchReport(SQLModel, table=True):
|
|
|
__tablename__ = "research_report"
|
|
|
id: str = Field(primary_key=True) # rp1
|
|
|
title: str
|
|
|
asked: str = ""
|
|
|
meta: str = ""
|
|
|
synthesis: str = "" # HTML
|
|
|
note: str = ""
|
|
|
counts: list = Field(default_factory=list, sa_column=Column(JSON))
|
|
|
cross: list = Field(default_factory=list, sa_column=Column(JSON))
|
|
|
collection_id: Optional[str] = Field(default=None, foreign_key="research_collection.id")
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
|
|
|
class ResearchQA(SQLModel, table=True):
|
|
|
__tablename__ = "knowledge_qa" # CONTRACT 명명
|
|
|
id: str = Field(primary_key=True) # qa1
|
|
|
q: str
|
|
|
a: str = "" # HTML
|
|
|
refs: list = Field(default_factory=list, sa_column=Column(JSON))
|
|
|
collection_id: Optional[str] = Field(default=None, foreign_key="research_collection.id")
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
|
|
|
class ResearchChart(SQLModel, table=True):
|
|
|
__tablename__ = "research_chart"
|
|
|
id: str = Field(primary_key=True) # ch1
|
|
|
title: str
|
|
|
asked: str = ""
|
|
|
unit: str = ""
|
|
|
bars: list = Field(default_factory=list, sa_column=Column(JSON))
|
|
|
insight: str = "" # HTML
|
|
|
caution: str = ""
|
|
|
|
|
|
|
|
|
class RagChunk(SQLModel, table=True):
|
|
|
"""ingest 된 자료의 청크 + 임베딩(vector store)."""
|
|
|
|
|
|
__tablename__ = "rag_chunk"
|
|
|
id: str = Field(primary_key=True) # chk_s1_0
|
|
|
source_id: str = Field(foreign_key="research_source.id")
|
|
|
seq: int = 0
|
|
|
text: str
|
|
|
part_label: str = ""
|
|
|
embedding: list = Field(default_factory=list, sa_column=Column(JSON))
|
|
|
dim: int = 0
|
|
|
model: str = ""
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
|
|
|
# ---------- 여행 ----------
|
|
|
class Trip(SQLModel, table=True):
|
|
|
__tablename__ = "trip"
|
|
|
id: str = Field(primary_key=True) # tp_busan
|
|
|
user_id: str = Field(default="jiwoo", foreign_key="person.id", index=True) # phase-15 소유자
|
|
|
dday: str = ""
|
|
|
title: str = ""
|
|
|
dates: str = ""
|
|
|
purpose: str = ""
|
|
|
brief: str = ""
|
|
|
weather: list = Field(default_factory=list, sa_column=Column(JSON))
|
|
|
expense_budget: int = 0
|
|
|
expense_planned: int = 0
|
|
|
expense_note: str = ""
|
|
|
expense_rows: list = Field(default_factory=list, sa_column=Column(JSON))
|
|
|
|
|
|
|
|
|
class TripRoute(SQLModel, table=True):
|
|
|
__tablename__ = "trip_route"
|
|
|
id: str = Field(primary_key=True) # rt_out/rt_back
|
|
|
trip_id: str = Field(foreign_key="trip.id")
|
|
|
dir: str # out | back
|
|
|
mode: str
|
|
|
from_st: str
|
|
|
ft: str
|
|
|
to_st: str
|
|
|
tt: str
|
|
|
seat: str = ""
|
|
|
note: str = ""
|
|
|
|
|
|
|
|
|
class TripStay(SQLModel, table=True):
|
|
|
__tablename__ = "trip_stay"
|
|
|
id: str = Field(primary_key=True)
|
|
|
trip_id: str = Field(foreign_key="trip.id")
|
|
|
name: str
|
|
|
desc: str = ""
|
|
|
conf: str = ""
|
|
|
|
|
|
|
|
|
class TripPrep(SQLModel, table=True):
|
|
|
__tablename__ = "trip_prep"
|
|
|
id: str = Field(primary_key=True)
|
|
|
trip_id: str = Field(foreign_key="trip.id")
|
|
|
text: str
|
|
|
state: str = "done" # done | doing
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class TripDay(SQLModel, table=True):
|
|
|
__tablename__ = "trip_day"
|
|
|
id: str = Field(primary_key=True) # d1/d2
|
|
|
trip_id: str = Field(foreign_key="trip.id")
|
|
|
tab: str
|
|
|
items: list = Field(default_factory=list, sa_column=Column(JSON))
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class TripChecklist(SQLModel, table=True):
|
|
|
__tablename__ = "trip_checklist"
|
|
|
id: str = Field(primary_key=True) # c1..c7
|
|
|
trip_id: str = Field(foreign_key="trip.id")
|
|
|
group: str
|
|
|
text: str
|
|
|
auto: str = ""
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class SavedTrip(SQLModel, table=True):
|
|
|
__tablename__ = "saved_trip"
|
|
|
id: str = Field(primary_key=True) # sv1..sv3
|
|
|
title: str
|
|
|
tag: str = ""
|
|
|
note: str = ""
|
|
|
now: str = "" # "89,000원"
|
|
|
delta: str = ""
|
|
|
down: bool = True
|
|
|
watch: bool = True
|
|
|
hint: str = ""
|
|
|
spark: list = Field(default_factory=list, sa_column=Column(JSON))
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class TripPlan(SQLModel, table=True):
|
|
|
__tablename__ = "trip_plan"
|
|
|
id: str = Field(primary_key=True) # plan_jeju/plan_tokyo
|
|
|
example: str
|
|
|
idx: int = 0
|
|
|
title: str
|
|
|
meta: str = ""
|
|
|
summary: str = "" # HTML
|
|
|
weather: str = ""
|
|
|
transport: list = Field(default_factory=list, sa_column=Column(JSON))
|
|
|
stay: list = Field(default_factory=list, sa_column=Column(JSON))
|
|
|
days: list = Field(default_factory=list, sa_column=Column(JSON))
|
|
|
budget: dict = Field(default_factory=dict, sa_column=Column(JSON))
|
|
|
checklist: list = Field(default_factory=list, sa_column=Column(JSON))
|
|
|
sources: list = Field(default_factory=list, sa_column=Column(JSON))
|
|
|
|
|
|
|
|
|
# ==================== phase-11: 라이프 케어 (건강·금융·지식) ====================
|
|
|
|
|
|
|
|
|
class ConnectorDomain(str, Enum):
|
|
|
health = "health"
|
|
|
finance = "finance"
|
|
|
knowledge = "knowledge"
|
|
|
# phase-13: 외부 연동 도메인 확장(mail/calendar/chat)
|
|
|
mail = "mail"
|
|
|
calendar = "calendar"
|
|
|
chat = "chat"
|
|
|
|
|
|
|
|
|
class KnowledgeType(str, Enum):
|
|
|
article = "article"
|
|
|
note = "note"
|
|
|
idea = "idea"
|
|
|
highlight = "highlight"
|
|
|
|
|
|
|
|
|
class InsightKind(str, Enum):
|
|
|
alert = "alert"
|
|
|
save = "save"
|
|
|
info = "info"
|
|
|
|
|
|
|
|
|
class ConnectorSource(SQLModel, table=True):
|
|
|
"""좌측 레일 '연결된 데이터'. life-data.js sources 그대로."""
|
|
|
|
|
|
__tablename__ = "connector_source"
|
|
|
id: str = Field(primary_key=True) # apple/watch/fit/woori/...
|
|
|
domain: ConnectorDomain
|
|
|
name: str
|
|
|
kind: str
|
|
|
tone: str = "ink"
|
|
|
on: bool = True
|
|
|
last: str = ""
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class HealthRing(SQLModel, table=True):
|
|
|
__tablename__ = "health_ring"
|
|
|
id: str = Field(primary_key=True)
|
|
|
label: str
|
|
|
val: str
|
|
|
unit: str = ""
|
|
|
goal: str
|
|
|
pct: int = 0
|
|
|
icon: str
|
|
|
tone: str = "coral"
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class HealthVital(SQLModel, table=True):
|
|
|
__tablename__ = "health_vital"
|
|
|
id: str = Field(primary_key=True)
|
|
|
label: str
|
|
|
val: str
|
|
|
unit: str = ""
|
|
|
trend: str = "flat" # up|down|flat
|
|
|
note: str = ""
|
|
|
tone: str = "coral"
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class SleepNight(SQLModel, table=True):
|
|
|
"""단일 row(id=1). 어젯밤 수면 + 7일 추이."""
|
|
|
|
|
|
__tablename__ = "sleep_night"
|
|
|
id: Optional[int] = Field(default=None, primary_key=True)
|
|
|
total: str = ""
|
|
|
score: int = 0
|
|
|
stages_json: str = "[]"
|
|
|
week_json: str = "[]"
|
|
|
note: str = ""
|
|
|
|
|
|
|
|
|
class HealthHabit(SQLModel, table=True):
|
|
|
__tablename__ = "health_habit"
|
|
|
id: str = Field(primary_key=True) # water/stretch/vitamin/meditate
|
|
|
title: str
|
|
|
goal: str
|
|
|
done: int = 0
|
|
|
total: int = 1
|
|
|
streak: int = 0
|
|
|
icon: str
|
|
|
tone: str = "blue"
|
|
|
at: str = ""
|
|
|
automation_rule_id: Optional[str] = Field(default=None, foreign_key="automation_rule.id")
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class FinanceBudget(SQLModel, table=True):
|
|
|
"""단일 row(id=1). 이달 예산."""
|
|
|
|
|
|
__tablename__ = "finance_budget"
|
|
|
id: Optional[int] = Field(default=None, primary_key=True)
|
|
|
spent: int = 0
|
|
|
limit: int = 0
|
|
|
pct: int = 0
|
|
|
delta_pct: int = 0
|
|
|
days_left: int = 0
|
|
|
|
|
|
|
|
|
class FinanceCategory(SQLModel, table=True):
|
|
|
__tablename__ = "finance_category"
|
|
|
id: str = Field(primary_key=True)
|
|
|
name: str
|
|
|
amt: int = 0
|
|
|
pct: int = 0
|
|
|
icon: str
|
|
|
tone: str = "coral"
|
|
|
over: bool = False
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class FinanceSub(SQLModel, table=True):
|
|
|
__tablename__ = "finance_sub"
|
|
|
id: str = Field(primary_key=True)
|
|
|
name: str
|
|
|
date: str = ""
|
|
|
in_days: int = 0
|
|
|
amt: int = 0
|
|
|
icon: str
|
|
|
tone: str = "coral"
|
|
|
note: str = ""
|
|
|
paused: bool = False
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class FinanceInsight(SQLModel, table=True):
|
|
|
__tablename__ = "finance_insight"
|
|
|
id: str = Field(primary_key=True) # ins-shop/ins-netflix/ins-save
|
|
|
kind: InsightKind
|
|
|
icon: str
|
|
|
title: str
|
|
|
body: str
|
|
|
action: str = "" # pause_sub|raise_budget|auto_transfer|""
|
|
|
action_ref: str = ""
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class KnowledgeCollection(SQLModel, table=True):
|
|
|
__tablename__ = "knowledge_collection"
|
|
|
id: str = Field(primary_key=True)
|
|
|
name: str
|
|
|
count: int = 0
|
|
|
tone: str = "blue"
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class KnowledgeItem(SQLModel, table=True):
|
|
|
"""제2의 뇌 스크랩. phase-10 research_source 와 공유 가능."""
|
|
|
|
|
|
__tablename__ = "knowledge_item"
|
|
|
id: str = Field(primary_key=True) # k1..k5
|
|
|
type: KnowledgeType
|
|
|
title: str
|
|
|
src: str = ""
|
|
|
time: str = ""
|
|
|
tone: str = "blue"
|
|
|
icon: str
|
|
|
tags_json: str = "[]"
|
|
|
excerpt: str = ""
|
|
|
ai: str = "" # 아리 제안(HTML)
|
|
|
collection_id: Optional[str] = Field(default=None, foreign_key="knowledge_collection.id")
|
|
|
connector_id: Optional[str] = Field(default=None, foreign_key="connector_source.id")
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
# ==================== phase-12: 여정 ====================
|
|
|
|
|
|
|
|
|
class JourneyLink(SQLModel, table=True):
|
|
|
"""여정 보드의 작업↔사람↔미팅 연결선. journey-clean-data.js links 이식."""
|
|
|
|
|
|
__tablename__ = "journey_link"
|
|
|
id: str = Field(primary_key=True) # jl1..
|
|
|
from_node: str
|
|
|
to_node: str
|
|
|
tone: str = "ink"
|
|
|
dash: bool = False
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
# ==================== phase-13: 외부 연동 & 데이터 커넥터 ====================
|
|
|
class ConnectorMode(str, Enum):
|
|
|
mock = "mock"
|
|
|
csv = "csv"
|
|
|
healthkit = "healthkit"
|
|
|
ics = "ics"
|
|
|
real = "real"
|
|
|
|
|
|
|
|
|
class ConnState(str, Enum):
|
|
|
connected = "connected" # 정상 연결 (라이프 카드 on:true)
|
|
|
disconnected = "disconnected" # 미연결 ("연결 안 됨")
|
|
|
syncing = "syncing" # 동기화 중
|
|
|
error = "error" # 마지막 sync 실패
|
|
|
token_expired = "token_expired" # 권한 만료 → 재인증 필요
|
|
|
|
|
|
|
|
|
class ConnectorAccount(SQLModel, table=True):
|
|
|
"""한 도메인의 한 제공자 계정. 예: mail/work(jiwoo@lumi.co), finance/woori."""
|
|
|
|
|
|
__tablename__ = "connector_account"
|
|
|
id: str = Field(primary_key=True) # "ca-mail-work"
|
|
|
user_id: str = Field(default="jiwoo", foreign_key="person.id") # 단일 데모(phase-15 멀티유저)
|
|
|
domain: ConnectorDomain
|
|
|
mode: ConnectorMode = ConnectorMode.mock
|
|
|
provider: str = "" # "gmail" | "google_calendar" | "notion" | "woori" ...
|
|
|
external_account_id: str = "" # 제공자 계정 식별자
|
|
|
name: str = "" # 표시명 "회사" / "우리카드"
|
|
|
kind: str = "" # "Lumi Workspace" / "신용/체크"
|
|
|
tone: str = "ink" # blue|violet|coral|green|amber|ink|faint
|
|
|
state: ConnState = ConnState.disconnected
|
|
|
last_synced_at: Optional[datetime] = None
|
|
|
last_label: str = "연결 안 됨" # 라이프 카드 last 문구
|
|
|
token_enc: str = "" # 암호화된 토큰 JSON. 평문 저장 금지.
|
|
|
scopes: str = "" # 콤마구분 OAuth 스코프
|
|
|
error_detail: str = ""
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
updated_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
|
|
|
class ConnectorSyncState(SQLModel, table=True):
|
|
|
"""증분 sync 커서. 제공자별 델타 토큰 또는 since 타임스탬프."""
|
|
|
|
|
|
__tablename__ = "connector_sync_state"
|
|
|
id: str = Field(primary_key=True) # "cs-<account_id>"
|
|
|
account_id: str = Field(foreign_key="connector_account.id")
|
|
|
cursor: str = "" # Gmail historyId / Google syncToken / ISO since
|
|
|
page_token: str = "" # 진행 중 페이지네이션 토큰(중단 복구용)
|
|
|
last_full_sync_at: Optional[datetime] = None
|
|
|
last_delta_sync_at: Optional[datetime] = None
|
|
|
items_seen: int = 0
|
|
|
items_upserted: int = 0
|
|
|
|
|
|
|
|
|
class ExternalLink(SQLModel, table=True):
|
|
|
"""외부 제공자 객체 ↔ 내부 엔티티 매핑(중복/충돌 처리의 핵심).
|
|
|
(account_id, external_id) UNIQUE 로 멱등 upsert."""
|
|
|
|
|
|
__tablename__ = "external_link"
|
|
|
id: str = Field(primary_key=True) # "xl-<hash>"
|
|
|
account_id: str = Field(foreign_key="connector_account.id", index=True)
|
|
|
external_id: str = Field(index=True) # 제공자측 고유 id
|
|
|
entity_type: str # "email" | "event" | "finance_tx" | "health_sample" | ...
|
|
|
entity_id: str # 내부 엔티티 PK
|
|
|
etag: str = "" # 변경 감지용
|
|
|
external_updated_at: Optional[datetime] = None
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
|
|
|
class OAuthState(SQLModel, table=True):
|
|
|
"""OAuth Authorization Code 플로우의 CSRF state + PKCE verifier 임시 저장."""
|
|
|
|
|
|
__tablename__ = "oauth_state"
|
|
|
id: str = Field(primary_key=True) # state nonce 자체를 PK 로
|
|
|
domain: ConnectorDomain
|
|
|
provider: str
|
|
|
code_verifier: str = "" # PKCE
|
|
|
redirect_after: str = "/life" # 콜백 후 프론트 복귀 경로
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
|
|
|
class ConnectorAccountLog(SQLModel, table=True):
|
|
|
"""연결/해제/동기화/오류 활동 로그."""
|
|
|
|
|
|
__tablename__ = "connector_account_log"
|
|
|
id: str = Field(primary_key=True) # "cl-xxxx"
|
|
|
account_id: str = Field(foreign_key="connector_account.id")
|
|
|
action: str # "connected"|"disconnected"|"sync"|"error"|"token_refreshed"|"import"
|
|
|
detail: str = ""
|
|
|
items: int = 0
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
|
|
|
class FinanceTx(SQLModel, table=True):
|
|
|
"""금융 거래(카드 명세/계좌 입출금). CSV 임포트·아그리게이터 sync 의 write 타깃."""
|
|
|
|
|
|
__tablename__ = "finance_tx"
|
|
|
id: str = Field(primary_key=True) # "ftx-xxxx"
|
|
|
account_id: str = Field(default="", foreign_key="connector_account.id")
|
|
|
date: str = "" # "2026-06-08" 또는 "오늘"
|
|
|
merchant: str = ""
|
|
|
amount: int = 0 # 정수 원화(음수=환불/입금)
|
|
|
category: str = "" # 식비/쇼핑/교통/카페·간식/구독/문화·여가
|
|
|
category_id: str = "" # finance_category.id (있으면)
|
|
|
account_label: str = "" # "우리카드" 등
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
|
|
|
class HealthSample(SQLModel, table=True):
|
|
|
"""건강 샘플(걸음/수면/심박 등). HealthKit export·Google Fit sync 의 write 타깃."""
|
|
|
|
|
|
__tablename__ = "health_sample"
|
|
|
id: str = Field(primary_key=True) # "hs-xxxx"
|
|
|
account_id: str = Field(default="", foreign_key="connector_account.id")
|
|
|
kind: str = "" # "steps" | "sleep" | "heart_rate" | "exercise"
|
|
|
value: float = 0.0
|
|
|
unit: str = ""
|
|
|
day: str = "" # "2026-06-08"
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
|
|
|
class ChatMessage(SQLModel, table=True):
|
|
|
"""메신저 인바운드 메시지. chat webhook 의 write 타깃 → notification 트리아지."""
|
|
|
|
|
|
__tablename__ = "chat_message"
|
|
|
id: str = Field(primary_key=True) # "cm-xxxx"
|
|
|
account_id: str = Field(default="", foreign_key="connector_account.id")
|
|
|
sender: str = ""
|
|
|
text: str = ""
|
|
|
channel: str = ""
|
|
|
at: str = ""
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
|
|
|
# ==================== phase-14: 능동 에이전트 + 멀티모달 ====================
|
|
|
class ErrandKind(str, Enum):
|
|
|
booking = "booking" # 예약(병원/식당/수리)
|
|
|
refund = "refund" # 환불 요청
|
|
|
cancel = "cancel" # 구독 해지/일시정지 (a6 Netflix)
|
|
|
support = "support" # 고객센터 문의
|
|
|
|
|
|
|
|
|
class ErrandStatus(str, Enum):
|
|
|
planning = "planning"
|
|
|
running = "running"
|
|
|
awaiting_approval = "awaiting_approval" # external_effect → 결재함 대기
|
|
|
done = "done"
|
|
|
failed = "failed"
|
|
|
cancelled = "cancelled"
|
|
|
|
|
|
|
|
|
class StepState(str, Enum):
|
|
|
pending = "pending"
|
|
|
running = "running"
|
|
|
done = "done"
|
|
|
blocked = "blocked" # 승인 대기로 막힘
|
|
|
error = "error"
|
|
|
|
|
|
|
|
|
class ProactiveKind(str, Enum):
|
|
|
schedule = "schedule" # 미팅 연속 → 점심 보호
|
|
|
finance = "finance" # 청구서 이상
|
|
|
focus = "focus" # 딥워크 보호
|
|
|
health = "health" # 건강 코칭
|
|
|
digest = "digest" # 다이제스트/브리핑
|
|
|
|
|
|
|
|
|
class ErrandTask(SQLModel, table=True):
|
|
|
__tablename__ = "errand_task"
|
|
|
id: str = Field(primary_key=True) # "er1".."erN"
|
|
|
kind: str = ErrandKind.cancel.value
|
|
|
title: str
|
|
|
goal: str = ""
|
|
|
target: Optional[str] = None
|
|
|
tone: str = "amber"
|
|
|
status: str = ErrandStatus.planning.value
|
|
|
autonomy_at_start: str = "mixed" # 시작 시점 자율성 스냅샷
|
|
|
approval_id: Optional[str] = Field(default=None, foreign_key="approval.id")
|
|
|
result_summary: Optional[str] = None
|
|
|
model: str = "" # "agent:<model>" | "scripted"
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
updated_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
|
|
|
class ErrandStep(SQLModel, table=True):
|
|
|
"""plan→act(tool)→observe→reflect 한 스텝. 타임라인의 1행."""
|
|
|
|
|
|
__tablename__ = "errand_step"
|
|
|
id: str = Field(primary_key=True) # "er1-s1"
|
|
|
errand_id: str = Field(foreign_key="errand_task.id", index=True)
|
|
|
seq: int
|
|
|
phase: str # "plan"|"act"|"observe"|"reflect"
|
|
|
tool: Optional[str] = None
|
|
|
label: str
|
|
|
detail: Optional[str] = None
|
|
|
external_effect: bool = False # True면 결재함 게이트(발송/결제/해지)
|
|
|
state: str = StepState.pending.value
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
|
|
|
class ProactiveCard(SQLModel, table=True):
|
|
|
__tablename__ = "proactive_card"
|
|
|
id: str = Field(primary_key=True) # "pc1".."pcN"
|
|
|
kind: str = ProactiveKind.schedule.value
|
|
|
icon: str = "spark"
|
|
|
tone: str = "violet"
|
|
|
title: str
|
|
|
why: str = ""
|
|
|
cta: Optional[str] = None
|
|
|
action_kind: Optional[str] = None # focus_block|task|automation|approval|none
|
|
|
action_payload: Optional[str] = None # JSON(text)
|
|
|
detected_at: str = "방금"
|
|
|
status: str = "active" # active|accepted|dismissed
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
|
|
|
class WeeklyReview(SQLModel, table=True):
|
|
|
__tablename__ = "weekly_review"
|
|
|
id: str = Field(primary_key=True) # "wr-2026-w24"
|
|
|
week_label: str
|
|
|
deep_work_h: float = 0.0
|
|
|
meeting_h: float = 0.0
|
|
|
done_count: int = 0
|
|
|
auto_count: int = 0
|
|
|
saved_minutes: int = 0
|
|
|
patterns_json: str = "[]" # [{title, suggest, action_kind, payload}]
|
|
|
note: Optional[str] = None
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
|
|
|
# ==================== phase-15: 인증/멀티유저 + 감사 ====================
|
|
|
# person(=user)을 인증 주체로 쓰고 자격만 분리(시드 person 보존). per-user 스코프는
|
|
|
# 기존 테이블에 user_id 컬럼(기본 "jiwoo")을 더해 일반화한다.
|
|
|
class UserCredential(SQLModel, table=True):
|
|
|
__tablename__ = "user_credential"
|
|
|
id: str = Field(primary_key=True) # "uc-jiwoo"
|
|
|
user_id: str = Field(foreign_key="person.id", index=True, unique=True)
|
|
|
email: str = Field(index=True, unique=True) # 로그인 식별자
|
|
|
password_hash: str # pbkdf2(salt$hash) — 평문 저장 금지
|
|
|
is_active: bool = True
|
|
|
role: str = "member" # member | admin
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
last_login_at: Optional[datetime] = None
|
|
|
|
|
|
|
|
|
class AuthSession(SQLModel, table=True):
|
|
|
__tablename__ = "auth_session"
|
|
|
id: str = Field(primary_key=True) # 세션 id(랜덤 hex)
|
|
|
user_id: str = Field(foreign_key="person.id", index=True)
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
expires_at: datetime
|
|
|
revoked: bool = False
|
|
|
user_agent: Optional[str] = None
|
|
|
ip: Optional[str] = None
|
|
|
|
|
|
|
|
|
class ApiToken(SQLModel, table=True):
|
|
|
__tablename__ = "api_token"
|
|
|
id: str = Field(primary_key=True) # "tok-xxxx"
|
|
|
user_id: str = Field(foreign_key="person.id", index=True)
|
|
|
name: str
|
|
|
token_hash: str # sha256(token) — 평문 저장 금지
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
last_used_at: Optional[datetime] = None
|
|
|
revoked: bool = False
|
|
|
|
|
|
|
|
|
class AuditLog(SQLModel, table=True):
|
|
|
"""보안 감사 로그(인증/권한/민감 동작). approval_log(업무 narrative)와 구분."""
|
|
|
|
|
|
__tablename__ = "audit_log"
|
|
|
id: str = Field(primary_key=True) # "al-xxxx"
|
|
|
user_id: Optional[str] = Field(default=None, foreign_key="person.id", index=True)
|
|
|
action: str # login|logout|export|scope.denied|prompt_injection.suspected ...
|
|
|
target: Optional[str] = None
|
|
|
ip: Optional[str] = None
|
|
|
detail: Optional[str] = None
|
|
|
created_at: datetime = Field(default_factory=now, index=True)
|
|
|
|
|
|
|
|
|
# ---------- 설정(런타임 사용자 환경설정 오버레이) ----------
|
|
|
class AppSetting(SQLModel, table=True):
|
|
|
"""key/value 사용자 설정. .env(읽기전용 ops 구성)와 별개의 런타임 오버레이.
|
|
|
|
|
|
비어 있으면(기본) 앱 동작은 .env/Settings 와 100% 동일하다.
|
|
|
현재 소비처는 LLM 연결(llm.provider|model|host|timeout) — runtime_config 오버레이가 읽는다.
|
|
|
"""
|
|
|
|
|
|
__tablename__ = "app_setting"
|
|
|
key: str = Field(primary_key=True) # 예: "llm.provider"
|
|
|
value: str = "" # 항상 문자열 직렬화(숫자/불리언도 str)
|
|
|
updated_at: datetime = Field(default_factory=now)
|