diff --git a/backend/app/routers/connectors.py b/backend/app/routers/connectors.py
index 2d18f23..21160f0 100644
--- a/backend/app/routers/connectors.py
+++ b/backend/app/routers/connectors.py
@@ -2,19 +2,34 @@
# prefix 없음. main.py 에서 include_router(prefix="/api").
import uuid
-from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
+from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, UploadFile
from fastapi.responses import RedirectResponse
-from sqlmodel import Session
+from sqlmodel import Session, select
from ..config import get_settings
from ..connectors import oauth as oauthlib
from ..connectors.registry import ConnectorRegistry
-from ..db import get_session
-from ..models import ConnectorAccount, ConnectorAccountLog, ConnState
-from ..schemas import ConnectorStatusOut, ImportResultOut, OAuthStartOut, SyncResultOut
+from ..db import engine, get_session
+from ..models import ConnectorAccount, ConnectorAccountLog, ConnState, OAuthState
+from ..schemas import (
+ ConnectorProviderOut,
+ ConnectorStatusOut,
+ ImportResultOut,
+ OAuthStartOut,
+ SyncResultOut,
+)
router = APIRouter()
+# phase-16: '계정 추가'가 보여줄 OAuth provider 카탈로그.
+# (domain, provider, label, settings client_id 필드명)
+_OAUTH_PROVIDERS = [
+ ("mail", "gmail", "Gmail", "google_client_id"),
+ ("mail", "outlook", "Outlook", "microsoft_client_id"),
+ ("calendar", "google_calendar", "Google 캘린더", "google_client_id"),
+ ("calendar", "outlook_calendar", "Outlook 캘린더", "microsoft_client_id"),
+]
+
@router.get("/connectors", response_model=list[ConnectorStatusOut])
def list_connectors(domain: str | None = None, s: Session = Depends(get_session)):
@@ -22,6 +37,25 @@ def list_connectors(domain: str | None = None, s: Session = Depends(get_session)
return [ConnectorStatusOut.from_account(a, ConnectorRegistry.mode_for(a.domain)) for a in rows]
+@router.get("/connectors/providers", response_model=list[ConnectorProviderOut])
+def list_providers(domain: str | None = None):
+ """OAuth로 추가 가능한 provider 목록 + 구성 여부(client_id 설정 시 configured=true)."""
+ cfg = get_settings()
+ out = []
+ for dom, provider, label, field in _OAUTH_PROVIDERS:
+ if domain and dom != domain:
+ continue
+ out.append(
+ ConnectorProviderOut(
+ domain=dom,
+ provider=provider,
+ label=label,
+ configured=bool(getattr(cfg, field, "")),
+ )
+ )
+ return out
+
+
@router.post("/connectors/{account_id}/sync", response_model=SyncResultOut)
def sync_one(account_id: str, full: bool = False, s: Session = Depends(get_session)):
a = s.get(ConnectorAccount, account_id)
@@ -42,6 +76,12 @@ def sync_all(s: Session = Depends(get_session)):
# ── OAuth ──
+def _with_query(path: str, extra: str) -> str:
+ """redirect_after 경로에 쿼리 안전 append('/settings?tab=mail' + 'connect=ok')."""
+ sep = "&" if "?" in path else "?"
+ return f"{path}{sep}{extra}"
+
+
@router.get("/connectors/oauth/start", response_model=OAuthStartOut)
def oauth_start(
domain: str, provider: str, redirect_after: str = "/life", s: Session = Depends(get_session)
@@ -49,24 +89,56 @@ def oauth_start(
cfg = get_settings()
if provider in ("gmail", "google_calendar") and not cfg.google_client_id:
raise HTTPException(400, "google_client_id 미설정 — real 연동 비활성")
+ if provider in ("outlook", "outlook_calendar") and not cfg.microsoft_client_id:
+ raise HTTPException(400, "microsoft_client_id 미설정 — real 연동 비활성")
if provider == "notion" and not cfg.notion_client_id:
raise HTTPException(400, "notion_client_id 미설정 — real 연동 비활성")
url = oauthlib.start_oauth(s, domain, provider, redirect_after)
return OAuthStartOut(authorize_url=url)
+def _initial_sync(account_id: str) -> None:
+ """연결 직후 초기 full sync — 백그라운드 실행(대량 메일도 콜백 응답을 막지 않음)."""
+ with Session(engine) as bs:
+ acct = bs.get(ConnectorAccount, account_id)
+ if not acct:
+ return
+ targets = [acct]
+ # 메일과 함께 연결된 캘린더 계정(같은 이메일)도 초기 sync.
+ sibling_provider = {"gmail": "google_calendar", "outlook": "outlook_calendar"}.get(
+ acct.provider
+ )
+ if sibling_provider and acct.external_account_id:
+ sib = bs.exec(
+ select(ConnectorAccount).where(
+ ConnectorAccount.provider == sibling_provider,
+ ConnectorAccount.external_account_id == acct.external_account_id,
+ )
+ ).first()
+ if sib:
+ targets.append(sib)
+ for a in targets:
+ try:
+ ConnectorRegistry.get(bs, a).sync(bs, full=True)
+ except Exception:
+ pass
+
+
@router.get("/connectors/oauth/callback")
-def oauth_callback(code: str, state: str, s: Session = Depends(get_session)):
+def oauth_callback(
+ code: str, state: str, bg: BackgroundTasks, s: Session = Depends(get_session)
+):
origin = get_settings().frontend_origin
+ os_row = s.get(OAuthState, state)
+ redirect_after = os_row.redirect_after if os_row else "/life"
try:
acct = oauthlib.finish_oauth(s, code, state)
except Exception:
- return RedirectResponse(url=f"{origin}/life?connect=error")
- try:
- ConnectorRegistry.get(s, acct).sync(s, full=True) # 연결 직후 1회 초기 sync
- except Exception:
- pass
- return RedirectResponse(url=f"{origin}/life?connect=ok&domain={acct.domain.value}")
+ return RedirectResponse(url=f"{origin}{_with_query(redirect_after, 'connect=error')}")
+ # 초기 sync 는 백그라운드로(받은편지함이 크면 수십 초 소요 → 콜백은 즉시 리다이렉트).
+ bg.add_task(_initial_sync, acct.id)
+ q = f"connect=ok&domain={acct.domain.value}&provider={acct.provider}"
+ return RedirectResponse(url=f"{origin}{_with_query(redirect_after, q)}")
@router.post("/connectors/{account_id}/disconnect", response_model=ConnectorStatusOut)
diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py
index 6582d0d..c7f3391 100644
--- a/backend/app/routers/settings.py
+++ b/backend/app/routers/settings.py
@@ -5,7 +5,6 @@ from sqlmodel import Session
from .. import runtime_config
from ..config import get_settings
from ..db import get_session
-from ..llm.heuristic import HeuristicProvider
from ..llm.ollama import OllamaProvider
from ..llm.provider import LLMProvider
from ..schemas import (
@@ -35,14 +34,9 @@ def get_system(s: Session = Depends(get_session)):
database=_db_dialect(st.database_url),
sync_interval_minutes=st.sync_interval_minutes,
sync_page_size=st.sync_page_size,
- web_search_provider=st.web_search_provider,
connector_modes={
"calendar": st.connector_calendar,
"mail": st.connector_mail,
- "chat": st.connector_chat,
- "finance": st.connector_finance,
- "health": st.connector_health,
- "knowledge": st.connector_knowledge,
},
)
@@ -56,9 +50,6 @@ def _llm_config(s: Session) -> LLMConfigOut:
host=eff.host,
timeout=eff.timeout,
overridden=settings_service.overlay_active(s),
- embed_provider=st.embed_provider,
- embed_model=st.embed_model,
- agent_provider=st.agent_provider,
stt_provider=st.stt_provider,
vision_provider=st.vision_provider,
)
@@ -90,13 +81,8 @@ def test_llm(body: LLMTestIn):
eff = runtime_config.effective_llm()
provider = body.provider or eff.provider
- impl: LLMProvider
- if provider == "heuristic":
- impl = HeuristicProvider()
- else:
- # auto/ollama 모두 ollama 핑으로 테스트(auto 는 실제로 ollama 도달성 확인이 핵심)
- impl = OllamaProvider(host=body.host or eff.host, model=body.model or eff.model)
-
+ # 실 LLM 전용(휴리스틱 제거): 항상 Ollama 도달성으로 테스트.
+ impl: LLMProvider = OllamaProvider(host=body.host or eff.host, model=body.model or eff.model)
h = impl.health()
return LLMTestOut(
reachable=bool(h.get("reachable")),
diff --git a/backend/app/routers/tasks.py b/backend/app/routers/tasks.py
index c05447e..799f422 100644
--- a/backend/app/routers/tasks.py
+++ b/backend/app/routers/tasks.py
@@ -12,12 +12,14 @@ from ..models import Person, Project, Task, TaskComment, TaskStatus
from ..schemas import (
CommentCreate,
CommentOut,
+ CommentPatch,
RiskOut,
ScaffoldOut,
ScaffoldRequest,
TaskCreate,
TaskNode,
TaskPatch,
+ TaskReorderRequest,
)
from ..services.risk import compute_risks
from ..services.scaffold import pick_scaffold, scaffold_create
@@ -186,6 +188,21 @@ def delete_task(tid: str, s: Session = Depends(get_session)):
return {"deleted": tid}
+@router.post("/tasks/reorder")
+def reorder_tasks(body: TaskReorderRequest, s: Session = Depends(get_session)):
+ """같은 컬럼 내 드래그 정렬 — 받은 순서대로 sort_order 재배치."""
+ n = 0
+ for i, tid in enumerate(body.ids):
+ t = s.get(Task, tid)
+ if t:
+ t.sort_order = i
+ t.updated_at = datetime.now(UTC)
+ s.add(t)
+ n += 1
+ s.commit()
+ return {"ok": True, "count": n}
+
+
@router.post("/tasks/{tid}/comments", response_model=CommentOut)
def add_comment(tid: str, body: CommentCreate, s: Session = Depends(get_session)):
if not s.get(Task, tid):
@@ -199,6 +216,29 @@ def add_comment(tid: str, body: CommentCreate, s: Session = Depends(get_session)
return CommentOut.model_validate(c, from_attributes=True)
+@router.patch("/tasks/{tid}/comments/{cid}", response_model=CommentOut)
+def edit_comment(tid: str, cid: str, body: CommentPatch, s: Session = Depends(get_session)):
+ c = s.get(TaskComment, cid)
+ if not c or c.task_id != tid:
+ raise HTTPException(404, "comment not found")
+ c.text = body.text
+ c.edited_at = datetime.now(UTC)
+ s.add(c)
+ s.commit()
+ s.refresh(c)
+ return CommentOut.model_validate(c, from_attributes=True)
+
+
+@router.delete("/tasks/{tid}/comments/{cid}")
+def delete_comment(tid: str, cid: str, s: Session = Depends(get_session)):
+ c = s.get(TaskComment, cid)
+ if not c or c.task_id != tid:
+ raise HTTPException(404, "comment not found")
+ s.delete(c)
+ s.commit()
+ return {"ok": True, "id": cid}
+
+
@router.post("/tasks/{tid}/scaffold", response_model=ScaffoldOut)
def scaffold(tid: str, body: ScaffoldRequest, s: Session = Depends(get_session)):
t = s.get(Task, tid)
diff --git a/backend/tests/test_api_dashboard.py b/backend/tests/test_api_dashboard.py
index b928c1e..a08ab74 100644
--- a/backend/tests/test_api_dashboard.py
+++ b/backend/tests/test_api_dashboard.py
@@ -5,10 +5,10 @@ def test_dashboard(client):
assert d["briefing"]["weather"]["temp"] == 24
assert d["briefing"]["weather"]["icon"] == "sun" # cloudSun 저장 → sun 표시
assert d["saved_today"] == "47분" and d["today_routed"] == 7
- assert any(e["soon"] for e in d["schedule"]) # 14:00 분기 전략 미팅 soon
- assert d["badges"]["appr"] == 4 # high risk 3건 + 심부름 게이트(phase-14)
+ assert isinstance(d["schedule"], list) # phase-16+: 일정 시드 제거 → 빈 일정(연결 전)
+ assert d["badges"]["appr"] == 3 # high risk 3건
assert d["badges"]["noti"] == 6
- assert d["task_summary"]["open_count"] > 0
+ assert d["task_summary"]["open_count"] == 0 # 작업 시드 제거 → 빈 상태
assert len(d["approvals_summary"]) <= 3 # high-risk 만, 최대 3건
assert d["goals"][0]["tone"] == "blue" # tone 키('var(--blue)' 아님)
# inbox_recent 는 평탄화 형태 {id,kind,raw,type,proj_label,tone}
diff --git a/backend/tests/test_api_inbox.py b/backend/tests/test_api_inbox.py
index 01ec824..7a0e305 100644
--- a/backend/tests/test_api_inbox.py
+++ b/backend/tests/test_api_inbox.py
@@ -28,12 +28,12 @@ def test_dismiss(client):
assert client.post(f"/api/inbox/{iid}/dismiss").json()["status"] == "dismissed"
-def test_capture_model_is_heuristic(client):
- # 테스트는 휴리스틱 강제 → model 표기 heuristic
+def test_capture_model_is_fake(client):
+ # 테스트는 결정적 FakeLLM 주입(conftest) → model 표기 fake
cap = client.post(
"/api/inbox/capture", json={"kind": "text", "raw": "수요일 11시 자전거 수리"}
).json()
- assert cap["classification"]["model"] == "heuristic"
+ assert cap["classification"]["model"] == "fake"
def test_confirm_event_no_task(client):
diff --git a/backend/tests/test_api_tasks.py b/backend/tests/test_api_tasks.py
index 19ff9ee..8cd2e96 100644
--- a/backend/tests/test_api_tasks.py
+++ b/backend/tests/test_api_tasks.py
@@ -1,29 +1,46 @@
-def test_tasks_nested_and_filter(client):
+# backend/tests/test_api_tasks.py — 작업 API (phase-16+: 작업 시드 제거 → 테스트가 직접 생성)
+from tests._factories import make_comment, make_task
+
+
+def test_tasks_nested_and_filter(client, session):
+ s, _ = session
+ make_task(s, id="w1", project_id="biz-report", title="부모 작업")
+ make_task(s, id="w1a", project_id="biz-report", parent_id="w1", title="자식 작업")
+ make_comment(s, id="cm1", task_id="w1")
+ make_comment(s, id="cm2", task_id="w1")
+ make_task(s, id="l1", project_id="me", title="라이프 작업")
+
work = client.get("/api/tasks?area=work").json()
- k1 = next(t for t in work if t["id"] == "k1")
- assert k1["children"], "k1 has subtasks"
- assert len(k1["comments"]) == 2
+ w1 = next(t for t in work if t["id"] == "w1")
+ assert w1["children"], "w1 has subtasks"
+ assert len(w1["comments"]) == 2
life = client.get("/api/tasks?area=life").json()
- assert {t["id"] for t in life} >= {"k4", "k20", "k21"}
+ assert {t["id"] for t in life} >= {"l1"}
-def test_task_status_move(client):
- r = client.patch("/api/tasks/k1", json={"status": "review"}).json()
+def test_task_status_move(client, session):
+ s, _ = session
+ make_task(s, id="t1", project_id="biz-report")
+ r = client.patch("/api/tasks/t1", json={"status": "review"}).json()
assert r["status"] == "review"
-def test_task_comment(client):
+def test_task_comment(client, session):
+ s, _ = session
+ make_task(s, id="t1", project_id="biz-report")
c = client.post(
- "/api/tasks/k1/comments", json={"person_id": "minseo", "text": "확인했습니다"}
+ "/api/tasks/t1/comments", json={"person_id": "minseo", "text": "확인했습니다"}
).json()
assert c["text"] == "확인했습니다"
-def test_task_filter_project_subtree(client):
- # biz 프로젝트(+하위)의 작업: k1(biz-report), k6(biz-okr) 등 포함
+def test_task_filter_project_subtree(client, session):
+ s, _ = session
+ make_task(s, id="a1", project_id="biz-report")
+ make_task(s, id="a2", project_id="biz-okr")
r = client.get("/api/tasks?project_id=biz").json()
ids = {t["id"] for t in r}
- assert "k1" in ids and "k6" in ids
+ assert "a1" in ids and "a2" in ids # biz(+하위) 서브트리
def test_task_create_and_delete(client):
@@ -33,18 +50,24 @@ def test_task_create_and_delete(client):
assert client.delete(f"/api/tasks/{tid}").json()["deleted"] == tid
-def test_create_subtask_parent(client):
- body = {"title": "새 하위", "project_id": "biz-report", "parent_id": "k1", "status": "todo"}
+def test_create_subtask_parent(client, session):
+ s, _ = session
+ make_task(s, id="p1", project_id="biz-report", title="부모")
+ body = {"title": "새 하위", "project_id": "biz-report", "parent_id": "p1", "status": "todo"}
child = client.post("/api/tasks", json=body).json()
- parent = client.get("/api/tasks/k1").json()
+ parent = client.get("/api/tasks/p1").json()
assert any(c["id"] == child["id"] for c in parent["children"])
-def test_patch_status_persists(client):
- client.patch("/api/tasks/k4", json={"status": "done"})
- assert client.get("/api/tasks/k4").json()["status"] == "done"
+def test_patch_status_persists(client, session):
+ s, _ = session
+ make_task(s, id="t1", project_id="me")
+ client.patch("/api/tasks/t1", json={"status": "done"})
+ assert client.get("/api/tasks/t1").json()["status"] == "done"
-def test_cannot_parent_to_self_or_descendant(client):
+def test_cannot_parent_to_self_or_descendant(client, session):
+ s, _ = session
+ make_task(s, id="t1", project_id="biz-report")
# 자기 자신을 부모로 → 400 (순환 방지)
- assert client.patch("/api/tasks/k1", json={"parent_id": "k1"}).status_code == 400
+ assert client.patch("/api/tasks/t1", json={"parent_id": "t1"}).status_code == 400
diff --git a/backend/tests/test_approvals_service.py b/backend/tests/test_approvals_service.py
index 43f3198..80e0828 100644
--- a/backend/tests/test_approvals_service.py
+++ b/backend/tests/test_approvals_service.py
@@ -2,8 +2,8 @@
def test_derive_queue_mixed(client):
q = client.get("/api/approvals").json()
assert q["autonomy"] == "mixed"
- # high 3(phase-7) + ap-er1(phase-14 심부름 게이트) = 4 대기, low 3 자동
- assert len(q["pending"]) == 4 and len(q["done"]) == 3
+ # high 3(phase-7) 대기, low 3 자동
+ assert len(q["pending"]) == 3 and len(q["done"]) == 3
assert {a["risk"] for a in q["pending"]} == {"high"}
assert {a["risk"] for a in q["done"]} == {"low"}
@@ -11,14 +11,14 @@ def test_derive_queue_mixed(client):
def test_autonomy_approval_first_all_pending(client):
client.patch("/api/approvals/autonomy", json={"level": "approval_first"})
q = client.get("/api/approvals").json()
- assert len(q["pending"]) == 7 and len(q["done"]) == 0 # +ap-er1(phase-14)
- assert q["badges_appr"] == 7
+ assert len(q["pending"]) == 6 and len(q["done"]) == 0
+ assert q["badges_appr"] == 6
def test_autonomy_full_auto_empty_queue(client):
client.patch("/api/approvals/autonomy", json={"level": "full_auto"})
q = client.get("/api/approvals").json()
- assert len(q["pending"]) == 0 and len(q["done"]) == 7 # 빈 큐(+ap-er1) → appr-empty
+ assert len(q["pending"]) == 0 and len(q["done"]) == 6 # 빈 큐 → appr-empty
def test_invalid_autonomy_422(client):
@@ -37,7 +37,7 @@ def test_approve_and_undo_cycle(client):
def test_approve_all(client):
n = client.post("/api/approvals/approve-all").json()["approved"]
- assert n == 4 # mixed 의 high 3건 + 심부름 게이트 1건(phase-14)
+ assert n == 3 # mixed 의 high 3건
q = client.get("/api/approvals").json()
assert len(q["pending"]) == 0
diff --git a/backend/tests/test_connector_routes.py b/backend/tests/test_connector_routes.py
index e4c9bb6..ad2ccce 100644
--- a/backend/tests/test_connector_routes.py
+++ b/backend/tests/test_connector_routes.py
@@ -1,7 +1,9 @@
# backend/tests/test_connector_routes.py — 라우터 + 로컬 우선 임포트(C10 CSV / C11 ics / C12 disconnect)
+# phase-16+: 메일/일정 mock 계정 시드 제거 → 해당 테스트는 계정을 직접 생성.
import pytest
from app.config import get_settings
+from app.models import ConnectorAccount, ConnectorDomain, ConnState
@pytest.fixture(autouse=True)
@@ -10,44 +12,12 @@ def _clear_settings():
get_settings.cache_clear()
-def test_list_connectors_seed(client):
- rows = client.get("/api/connectors").json()
- assert len(rows) == 14 # mail3+cal1+health3+fin4+kn3
- fit = next(r for r in rows if r["id"] == "ca-health-fit")
- assert fit["on"] is False and fit["last"] == "연결 안 됨"
- woori = next(r for r in rows if r["id"] == "ca-fin-woori")
- assert woori["on"] is True and woori["tone"] == "blue" and woori["kind"] == "신용/체크"
-
-
-def test_list_filter_by_domain(client):
- health = client.get("/api/connectors?domain=health").json()
- assert {r["id"] for r in health} == {"ca-health-apple", "ca-health-watch", "ca-health-fit"}
-
-
-def test_csv_import_finance(client, monkeypatch):
- # C10: 우리카드 CSV → finance_tx (OAuth 불필요). 빈 행은 skip+errors.
- monkeypatch.setenv("CONNECTOR_FINANCE", "csv")
- get_settings.cache_clear()
- csv = (
- "거래일,가맹점,금액,분류\n"
- "2026-06-08,스타벅스 강남,6300,카페·간식\n"
- "2026-06-08,쿠팡,23900,\n"
- "2026-06-07,카카오T 택시,9100,교통\n"
- ",,,\n"
- ).encode()
- r = client.post(
- "/api/connectors/ca-fin-woori/import",
- files={"file": ("woori.csv", csv, "text/csv")},
- )
- assert r.status_code == 200, r.text
- body = r.json()
- assert body["entity_type"] == "finance_tx"
- assert body["imported"] == 3
- assert body["errors"] >= 1 # 빈 행
-
-
-def test_ics_import_calendar(client, monkeypatch):
- # C11: .ics → event (OAuth 불필요).
+def test_ics_import_calendar(client, session, monkeypatch):
+ # C11: .ics → event (OAuth 불필요). 일정 계정을 직접 생성.
+ s, _ = session
+ s.add(ConnectorAccount(id="ca-cal-google", domain=ConnectorDomain.calendar,
+ provider="google_calendar", name="Google 캘린더"))
+ s.commit()
monkeypatch.setenv("CONNECTOR_CALENDAR", "ics")
get_settings.cache_clear()
ics = (
@@ -70,31 +40,18 @@ def test_ics_import_calendar(client, monkeypatch):
assert body["entity_type"] == "event" and body["imported"] == 1
-def test_import_unsupported_connector_400(client):
- # 기본 mock 금융은 import_bytes 미지원 → 400
- r = client.post(
- "/api/connectors/ca-fin-toss/import",
- files={"file": ("x.csv", b"a,b\n1,2\n", "text/csv")},
- )
- assert r.status_code == 400
-
-
-def test_disconnect_purges_token(client):
- # C12: 해제 → 토큰 폐기 + state disconnected (기존 데이터는 보존)
+def test_disconnect_purges_token(client, session):
+ # C12: 해제 → 토큰 폐기 + state disconnected. 연결된 메일 계정을 직접 생성.
+ s, _ = session
+ s.add(ConnectorAccount(id="ca-mail-work", domain=ConnectorDomain.mail, provider="gmail",
+ state=ConnState.connected, external_account_id="me@gmail.com",
+ token_enc="enc", name="me@gmail.com"))
+ s.commit()
r = client.post("/api/connectors/ca-mail-work/disconnect").json()
assert r["state"] == "disconnected" and r["on"] is False and r["last"] == "연결 안 됨"
- # 메일은 그대로 남아있음
- assert len(client.get("/api/mail").json()) > 0 or True
def test_oauth_start_without_credentials_400(client):
# GOOGLE_CLIENT_ID 미설정 → 400 (앱 안 죽음)
r = client.get("/api/connectors/oauth/start?domain=mail&provider=gmail")
assert r.status_code == 400
-
-
-def test_sync_all_skips_disconnected(client):
- out = client.post("/api/connectors/sync-all").json()
- ids = {x["account_id"] for x in out}
- assert "ca-health-fit" not in ids # disconnected 는 건너뜀
- assert "ca-kb" not in ids
diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py
index f2a61d4..1dbce6d 100644
--- a/backend/tests/test_dashboard.py
+++ b/backend/tests/test_dashboard.py
@@ -72,6 +72,6 @@ def test_goals_three_with_tone_key(client):
def test_badges_match_counts(client):
d = client.get("/api/dashboard").json()
b = d["badges"]
- assert b["appr"] == 4 # high 3 + 심부름 게이트 1(phase-14)
+ assert b["appr"] == 3 # high 3건
assert b["task"] == d["task_summary"]["open_count"]
assert b["noti"] == 6
diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py
index 67a810d..5dbfc6b 100644
--- a/backend/tests/test_settings.py
+++ b/backend/tests/test_settings.py
@@ -8,43 +8,36 @@ def test_system_snapshot_readonly(client):
assert d["env"] == "dev"
assert d["auth_enabled"] is False # 데모 기본 off
assert d["database"] == "sqlite"
- assert set(d["connector_modes"]) == {
- "calendar",
- "mail",
- "chat",
- "finance",
- "health",
- "knowledge",
- }
+ assert set(d["connector_modes"]) == {"calendar", "mail"}
# 기본은 전부 mock
assert all(v == "mock" for v in d["connector_modes"].values())
def test_llm_config_defaults_match_env(client):
d = client.get("/api/settings/llm").json()
- assert d["provider"] in ("auto", "ollama", "heuristic")
+ assert d["provider"] == "ollama"
assert d["overridden"] is False # 오버레이 없음 → env 기본
- assert "auto" in d["provider_options"]
+ assert "ollama" in d["provider_options"]
# 보조 프로바이더 노출
- assert "embed_provider" in d and "stt_provider" in d
+ assert "stt_provider" in d and "vision_provider" in d
def test_llm_update_persists_and_applies(client):
- # heuristic 으로 저장 → 영속 + 런타임 반영
- r = client.put("/api/settings/llm", json={"provider": "heuristic", "model": "my-model"})
+ # ollama + 커스텀 모델로 저장 → 영속 + 런타임 반영
+ r = client.put("/api/settings/llm", json={"provider": "ollama", "model": "my-model"})
assert r.status_code == 200
body = r.json()
- assert body["provider"] == "heuristic"
+ assert body["provider"] == "ollama"
assert body["model"] == "my-model"
assert body["overridden"] is True
# 다시 GET 해도 유지(영속)
again = client.get("/api/settings/llm").json()
- assert again["provider"] == "heuristic" and again["model"] == "my-model"
+ assert again["provider"] == "ollama" and again["model"] == "my-model"
# 런타임 오버레이가 실제 provider 선택에 반영되는가(force 없이)
- assert runtime_config.effective_llm().provider == "heuristic"
- assert get_provider().name == "heuristic"
+ assert runtime_config.effective_llm().provider == "ollama"
+ assert get_provider().name == "ollama"
def test_llm_update_reset_to_default(client):
@@ -62,11 +55,13 @@ def test_llm_update_rejects_bad_provider(client):
assert r.status_code == 400
-def test_llm_test_heuristic_reachable(client):
- r = client.post("/api/settings/llm/test", json={"provider": "heuristic"})
+def test_llm_test_uses_ollama(client):
+ # phase-16+: 휴리스틱 옵션 제거 → 연결 테스트는 항상 Ollama 도달성으로 확인.
+ r = client.post("/api/settings/llm/test", json={"provider": "ollama"})
assert r.status_code == 200
d = r.json()
- assert d["reachable"] is True and d["provider"] == "heuristic"
+ assert d["provider"] == "ollama"
+ assert isinstance(d["reachable"], bool) # 실 Ollama 가동 여부에 따라 달라짐(환경 의존)
def test_profile_update(client):
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
index a787de4..6f3fd69 100644
--- a/frontend/app/layout.tsx
+++ b/frontend/app/layout.tsx
@@ -2,6 +2,7 @@
import type { Metadata } from "next";
import { onest, dmMono } from "./fonts";
import { ThemeProvider } from "@/components/ThemeProvider";
+import { DialogProvider } from "@/components/Dialog";
import { Shell } from "@/components/Shell";
import "@/styles/globals.css";
@@ -16,7 +17,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
- {children}
+
+ {children}
+
diff --git a/frontend/app/tasks/[[...slug]]/page.tsx b/frontend/app/tasks/[[...slug]]/page.tsx
new file mode 100644
index 0000000..00d47f6
--- /dev/null
+++ b/frontend/app/tasks/[[...slug]]/page.tsx
@@ -0,0 +1,8 @@
+// frontend/app/tasks/[[...slug]]/page.tsx — 작업 페이지 (옵셔널 캐치올: /tasks, /tasks/)
+// 세그먼트는 TasksClient 가 useParams() 로 읽어 해당 작업 상세를 바로 연다.
+import "@/styles/tasks.css";
+import { TasksClient } from "@/components/tasks/TasksClient";
+
+export default function TasksPage() {
+ return ;
+}
diff --git a/frontend/app/tasks/page.tsx b/frontend/app/tasks/page.tsx
deleted file mode 100644
index 3d004c3..0000000
--- a/frontend/app/tasks/page.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-// frontend/app/tasks/page.tsx
-import "@/styles/tasks.css";
-import { TasksClient } from "@/components/tasks/TasksClient";
-
-export default function TasksPage() {
- return ;
-}
diff --git a/frontend/components/Dialog.tsx b/frontend/components/Dialog.tsx
new file mode 100644
index 0000000..982a471
--- /dev/null
+++ b/frontend/components/Dialog.tsx
@@ -0,0 +1,127 @@
+// frontend/components/Dialog.tsx
+// 앱 전역 통합 다이얼로그 — window.confirm/alert 대체.
+// useDialog().confirm({...}) → Promise, .alert({...}) → Promise.
+"use client";
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
+
+type DialogTone = "danger" | "primary";
+export type DialogOptions = {
+ title?: string;
+ message?: string;
+ confirmText?: string;
+ cancelText?: string; // confirm 전용
+ tone?: DialogTone;
+};
+type DialogState = DialogOptions & { kind: "confirm" | "alert" };
+
+type DialogApi = {
+ confirm: (opts: DialogOptions) => Promise;
+ alert: (opts: DialogOptions) => Promise;
+};
+
+const Ctx = createContext(null);
+
+export function useDialog(): DialogApi {
+ const c = useContext(Ctx);
+ if (!c) throw new Error("useDialog must be used within ");
+ return c;
+}
+
+function DialogModal({
+ state,
+ onClose,
+}: {
+ state: DialogState;
+ onClose: (ok: boolean) => void;
+}) {
+ const isConfirm = state.kind === "confirm";
+ const tone: DialogTone = state.tone ?? (isConfirm ? "danger" : "primary");
+ const okRef = useRef(null);
+
+ useEffect(() => {
+ okRef.current?.focus();
+ const h = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ e.preventDefault();
+ onClose(false);
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ onClose(true);
+ }
+ };
+ window.addEventListener("keydown", h);
+ return () => window.removeEventListener("keydown", h);
+ }, [onClose]);
+
+ return (
+ onClose(false)} role="presentation">
+
e.stopPropagation()}
+ >
+ {state.title &&
{state.title}
}
+ {state.message &&
{state.message}
}
+
+ {isConfirm && (
+
+ )}
+
+
+
+
+ );
+}
+
+export function DialogProvider({ children }: { children: React.ReactNode }) {
+ const [state, setState] = useState(null);
+ const resolver = useRef<((ok: boolean) => void) | null>(null);
+
+ const close = useCallback((ok: boolean) => {
+ resolver.current?.(ok);
+ resolver.current = null;
+ setState(null);
+ }, []);
+
+ const confirm = useCallback(
+ (opts: DialogOptions) =>
+ new Promise((resolve) => {
+ resolver.current = resolve;
+ setState({ kind: "confirm", ...opts });
+ }),
+ [],
+ );
+
+ const alert = useCallback(
+ (opts: DialogOptions) =>
+ new Promise((resolve) => {
+ resolver.current = () => resolve();
+ setState({ kind: "alert", ...opts });
+ }),
+ [],
+ );
+
+ return (
+
+ {children}
+ {state && }
+
+ );
+}
diff --git a/frontend/components/connectors/AddAccountMenu.tsx b/frontend/components/connectors/AddAccountMenu.tsx
new file mode 100644
index 0000000..9cf2a47
--- /dev/null
+++ b/frontend/components/connectors/AddAccountMenu.tsx
@@ -0,0 +1,81 @@
+// frontend/components/connectors/AddAccountMenu.tsx — "계정 추가" OAuth 진입(phase-16)
+// providers() 로 추가 가능한 provider를 받아 메뉴로 보여주고, 선택 시 OAuth 동의 화면으로 리다이렉트.
+"use client";
+import { useEffect, useRef, useState } from "react";
+import { Button } from "@/components/Button";
+import { connectorsApi } from "@/lib/connectors/api";
+import type { ConnectorProvider } from "@/lib/types";
+
+export function AddAccountMenu({
+ domain,
+ redirectAfter,
+ onError,
+}: {
+ domain: string;
+ redirectAfter: string;
+ onError?: (msg: string) => void;
+}) {
+ const [providers, setProviders] = useState([]);
+ const [open, setOpen] = useState(false);
+ const ref = useRef(null);
+
+ useEffect(() => {
+ connectorsApi
+ .providers(domain)
+ .then(setProviders)
+ .catch(() => setProviders([]));
+ }, [domain]);
+
+ useEffect(() => {
+ if (!open) return;
+ const onDoc = (e: MouseEvent) => {
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
+ };
+ document.addEventListener("mousedown", onDoc);
+ return () => document.removeEventListener("mousedown", onDoc);
+ }, [open]);
+
+ if (providers.length === 0) return null;
+
+ const connect = async (p: ConnectorProvider) => {
+ setOpen(false);
+ try {
+ const { authorize_url } = await connectorsApi.oauthStart(domain, p.provider, redirectAfter);
+ window.location.assign(authorize_url); // 외부 OAuth 동의 화면으로 이동
+ } catch {
+ onError?.(`${p.label} 연결을 시작하지 못했어요`);
+ }
+ };
+
+ return (
+
+
+ {open && (
+
+ {providers.map((p) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/frontend/components/inbox/CaptureRow.tsx b/frontend/components/inbox/CaptureRow.tsx
index d7c820e..d0f2ab5 100644
--- a/frontend/components/inbox/CaptureRow.tsx
+++ b/frontend/components/inbox/CaptureRow.tsx
@@ -43,7 +43,7 @@ export default function CaptureRow({
) : c.classification ? (
<>
onReType(c.id)} />
-
+
{canAct && (
)}
+ {addDomain && (
+
toast(m, "coral")}
+ />
+ )}
diff --git a/frontend/components/settings/LlmTab.tsx b/frontend/components/settings/LlmTab.tsx
index 0a3b1ea..3fc0420 100644
--- a/frontend/components/settings/LlmTab.tsx
+++ b/frontend/components/settings/LlmTab.tsx
@@ -17,7 +17,7 @@ const PROVIDER_LABEL: Record = {
export function LlmTab({ toast }: { toast: ToastFn }) {
const { data: cfg, isLoading, refresh } = useLlmConfig();
- const [provider, setProvider] = useState("auto");
+ const [provider, setProvider] = useState("ollama");
const [model, setModel] = useState("");
const [host, setHost] = useState("");
const [timeout, setTimeoutVal] = useState("");
@@ -104,18 +104,14 @@ export function LlmTab({ toast }: { toast: ToastFn }) {
({
+ options={(cfg?.provider_options ?? ["ollama"]).map((p) => ({
id: p,
label: PROVIDER_LABEL[p as LlmProvider] ?? p,
}))}
value={provider}
onChange={(id) => setProvider(id as LlmProvider)}
/>
-
- {provider === "auto" && "Ollama 가 떠 있으면 사용하고, 아니면 규칙 기반으로 폴백해요."}
- {provider === "ollama" && "로컬 Ollama 서버를 사용해요."}
- {isHeuristic && "외부 모델 없이 규칙으로만 동작해요(오프라인·결정적)."}
-
+ 로컬 Ollama 서버를 사용해요.
diff --git a/frontend/components/settings/MailTab.tsx b/frontend/components/settings/MailTab.tsx
index 10c7698..1099887 100644
--- a/frontend/components/settings/MailTab.tsx
+++ b/frontend/components/settings/MailTab.tsx
@@ -12,7 +12,9 @@ export function MailTab({ toast }: { toast: ToastFn }) {
filter={(c) => c.domain === "mail"}
toast={toast}
showSyncAll
- emptyText="연결된 메일 계정이 없어요."
+ emptyText="연결된 메일 계정이 없어요. ‘계정 추가’로 Gmail·Outlook을 연결하세요."
+ addDomain="mail"
+ redirectAfter="/settings?tab=mail"
/>
);
}
diff --git a/frontend/components/settings/SettingsClient.tsx b/frontend/components/settings/SettingsClient.tsx
index 3c9be62..a053c0f 100644
--- a/frontend/components/settings/SettingsClient.tsx
+++ b/frontend/components/settings/SettingsClient.tsx
@@ -74,6 +74,26 @@ export function SettingsClient() {
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 2600);
}, []);
+ // OAuth 콜백 복귀(?connect=ok|error) → 토스트 후 쿼리 정리(tab 은 보존)
+ useEffect(() => {
+ if (typeof window === "undefined") return;
+ const params = new URLSearchParams(window.location.search);
+ const connect = params.get("connect");
+ if (!connect) return;
+ const labels: Record
= {
+ gmail: "Gmail",
+ outlook: "Outlook",
+ google_calendar: "Google 캘린더",
+ outlook_calendar: "Outlook 캘린더",
+ };
+ const name = labels[params.get("provider") ?? ""] ?? "계정";
+ if (connect === "ok") toast(`${name}을(를) 연결했어요`, "green");
+ else toast("연결에 실패했어요. 다시 시도해 주세요", "coral");
+ for (const k of ["connect", "domain", "provider"]) params.delete(k);
+ const qs = params.toString();
+ window.history.replaceState(null, "", window.location.pathname + (qs ? `?${qs}` : ""));
+ }, [toast]);
+
return (
diff --git a/frontend/components/tasks/Comments.tsx b/frontend/components/tasks/Comments.tsx
index e84cde0..6e0c6d1 100644
--- a/frontend/components/tasks/Comments.tsx
+++ b/frontend/components/tasks/Comments.tsx
@@ -26,19 +26,34 @@ export function Comments({
people,
me,
onAdd,
+ onEdit,
+ onDelete,
}: {
list: TaskComment[];
people: Record
;
me: Person;
onAdd: (text: string) => void;
+ onEdit?: (cid: string, text: string) => void;
+ onDelete?: (cid: string) => void;
}) {
const [txt, setTxt] = useState("");
+ const [editId, setEditId] = useState(null);
+ const [editTxt, setEditTxt] = useState("");
const submit = () => {
if (txt.trim()) {
onAdd(txt.trim());
setTxt("");
}
};
+ const startEdit = (c: TaskComment) => {
+ setEditId(c.id);
+ setEditTxt(c.text);
+ };
+ const saveEdit = () => {
+ if (editId && editTxt.trim()) onEdit?.(editId, editTxt.trim());
+ setEditId(null);
+ setEditTxt("");
+ };
return (
@@ -47,6 +62,8 @@ export function Comments({
)}
{list.map((c) => {
const p = people[c.person_id] || me;
+ const mine = c.person_id === me.id;
+ const editing = editId === c.id;
return (
@@ -58,9 +75,46 @@ export function Comments({
{p.name}
{p.is_me ? " (나)" : ""}
- {relTime(c.created_at)}
+
+ {relTime(c.created_at)}
+ {c.edited_at ? " · 수정됨" : ""}
+
+ {mine && onEdit && onDelete && !editing && (
+
+
+
+
+ )}
-
{c.text}
+ {editing ? (
+
+ setEditTxt(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") saveEdit();
+ if (e.key === "Escape") setEditId(null);
+ }}
+ />
+
+
+
+ ) : (
+
{c.text}
+ )}
);
diff --git a/frontend/components/tasks/DetailPanel.tsx b/frontend/components/tasks/DetailPanel.tsx
index 0e47b6a..05cc8af 100644
--- a/frontend/components/tasks/DetailPanel.tsx
+++ b/frontend/components/tasks/DetailPanel.tsx
@@ -1,10 +1,10 @@
// frontend/components/tasks/DetailPanel.tsx — 작업 상세 드로어 (포커스 드릴다운)
"use client";
-import { Fragment, useEffect, useState } from "react";
+import { Fragment, useEffect, useMemo, useState } from "react";
import { Icon } from "@/components/Icon";
import { cx } from "@/lib/cx";
-import { COLUMNS, STATUS_META, dueDay, findPath, flattenTasks, stat } from "@/lib/tasks/tree";
-import type { Person, Project, Status, Task } from "@/lib/types";
+import { COLUMNS, STATUS_META, findPath, flattenTasks, stat } from "@/lib/tasks/tree";
+import type { Person, Prio, Project, Status, Task } from "@/lib/types";
import { Av, Tag } from "./bits";
import { Comments } from "./Comments";
import { RichNotes } from "./RichNotes";
@@ -26,6 +26,20 @@ function suggestCollaborator(tasks: Task[], projectId: string): string | null {
return ppl[0];
}
+/** 우선순위 옵션 (Prio enum 1:1) + 점 색상 */
+const PRIO_OPTS: { id: Prio; c: string }[] = [
+ { id: "높음", c: "var(--coral)" },
+ { id: "보통", c: "var(--amber)" },
+ { id: "낮음", c: "var(--muted)" },
+];
+
+/** 마감일 라벨 — "6월 8일" (월 무관) */
+function dueText(due: string | null): string {
+ if (!due) return "미정";
+ const d = new Date(due);
+ return `${d.getUTCMonth() + 1}월 ${d.getUTCDate()}일`;
+}
+
interface Props {
tasks: Task[];
focusId: string;
@@ -43,20 +57,30 @@ interface Props {
onDelete: (id: string) => void;
onNotes: (id: string, html: string) => void;
onAddComment: (id: string, text: string) => void;
+ onEditComment?: (id: string, cid: string, text: string) => void;
+ onDeleteComment?: (id: string, cid: string) => void;
}
+type MenuKind = "status" | "prio" | "assignee";
+
export function DetailPanel(p: Props) {
- const [menu, setMenu] = useState(false);
+ const [menu, setMenu] = useState
(null);
const [expanded, setExpanded] = useState>({});
const [adding, setAdding] = useState(false);
const [subText, setSubText] = useState("");
+ // 담당자 셀렉터용 인원 목록 (본인 먼저)
+ const peopleList = useMemo(() => {
+ const arr = Object.values(p.people);
+ return arr.sort((a, b) => (a.is_me === b.is_me ? 0 : a.is_me ? -1 : 1));
+ }, [p.people]);
+
const path = findPath(p.tasks, p.focusId) ?? [];
const node = path[path.length - 1];
useEffect(() => {
setAdding(false);
- setMenu(false);
+ setMenu(null);
}, [p.focusId]);
useEffect(() => {
const h = (e: KeyboardEvent) => {
@@ -206,52 +230,150 @@ export function DetailPanel(p: Props) {
people={p.people}
me={p.me}
onAdd={(text) => p.onAddComment(node.id, text)}
+ onEdit={
+ p.onEditComment ? (cid, text) => p.onEditComment!(node.id, cid, text) : undefined
+ }
+ onDelete={p.onDeleteComment ? (cid) => p.onDeleteComment!(node.id, cid) : undefined}
/>
{/* 우측 — 메타 */}
+ {/* 담당자 — 인원 목록에서 선택 */}
담당자
-
-
{person ? person.name : "미정"}
- {person?.is_me ? " (나)" : ""}
+
+
+ {menu === "assignee" && (
+
+ {peopleList.map((pr) => (
+
+ ))}
+ {person && (
+
+ )}
+
+ )}
+
+ {/* 마감일 — 날짜 입력 (비우면 null) */}
마감일
-
{node.due ? `6월 ${dueDay(node.due)}일` : "미정"}
+
+
+ p.onField(node.id, { due: e.target.value ? e.target.value : null })
+ }
+ />
+
+ {dueText(node.due)}
+
+ {node.due && (
+
+ )}
+
+
+ {/* 우선순위 — 드롭다운 */}
우선순위
-
-
{node.prio}
+
+
+ {menu === "prio" && (
+
+ {PRIO_OPTS.map((o) => (
+
+ ))}
+
+ )}
+
+ {/* 상태 — 드롭다운 */}
상태
-