From f4f0ad72118781b61e9990e342271c0d0d5eae96 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 7 Jul 2026 10:43:14 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(saas):=20AI=20=EB=B8=8C=EB=A6=AC?= =?UTF-8?q?=ED=95=91=20=E2=80=94=20contextual-orchestrator=20=EA=B2=BD?= =?UTF-8?q?=EC=9C=A0=20LLM=20=EC=9D=BC=EC=A0=95=20=EB=B6=84=EC=84=9D=20(sl?= =?UTF-8?q?ice=2055)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주간보고에 LLM 경영진 브리핑을 추가한다. LLM 호출은 contextual-orchestrator (OpenAI 호환 오케스트레이션 프록시, 조직 OPENAI_API_KEY 에이전트 풀)로만 나가며, 자격은 서버 환경변수에만 존재한다. - server/orchestrator.mjs: /v1/chat/completions 클라이언트(Bearer, 60s 타임아웃, strict-validation 대응 — model+messages만 전송). ORCHESTRATOR_URL 미설정 시 결정적 mock으로 전 플로우 테스트 가능. - server/app.mjs: POST /api/projects/:id/ai/brief — 서버가 프로젝트 스냅샷 (가중 계획/실적%, 지연·예정 작업 요약)을 만들어 시스템 프롬프트(지표 근거 강제, 사실 생성 금지)와 함께 전송 → {analysis}. 감사(ai.brief). 멤버 접근. - cloud-sync.js: 주간보고 모달 'AI 요약' 버튼 → 브리핑 블록 렌더(textContent). - tests: mock 브리핑 200/내용/비멤버 404. - docs: api.md AI 섹션 + deploy.md ORCHESTRATOR_URL/TOKEN. Verified: test:api ✓ eval-safe ✓; 실브라우저 — AI 요약 클릭→브리핑 렌더; 그리고 orchestrator #42의 실제 컨테이너와 LIVE 왕복 성공(프로젝트 컨텍스트가 에이전트 풀을 통과해 응답, strict-field 거부 이슈 발견→수정 포함). Stacked on feat/clearfolio-viewer (#272). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018LMoqYsUY6usjNMBjvxCbU --- cloud-sync.js | 25 +++++++++++++++++++++++ docs/api.md | 6 ++++++ docs/deploy.md | 2 ++ server/app.mjs | 45 +++++++++++++++++++++++++++++++++++++++++ server/orchestrator.mjs | 35 ++++++++++++++++++++++++++++++++ tests/api/smoke.mjs | 9 +++++++++ 6 files changed, 122 insertions(+) create mode 100644 server/orchestrator.mjs diff --git a/cloud-sync.js b/cloud-sync.js index 0fc85de1..5109b761 100644 --- a/cloud-sync.js +++ b/cloud-sync.js @@ -675,6 +675,31 @@ function openReportModal() { }); panel.appendChild(copy); + const ai = document.createElement('button'); + ai.type = 'button'; + ai.className = 'secondary-button'; + ai.style.marginLeft = '8px'; + ai.textContent = 'AI 요약'; + ai.addEventListener('click', async () => { + ai.disabled = true; + ai.textContent = '분석 중…'; + try { + const res = await api(`/api/projects/${getProjectId()}/ai/brief`, { method: 'POST' }); + let box = document.getElementById('report-ai'); + if (!box) { + box = document.createElement('pre'); + box.id = 'report-ai'; + box.style.whiteSpace = 'pre-wrap'; + box.style.borderLeft = '3px solid var(--primary, #2563eb)'; + box.style.paddingLeft = '10px'; + panel.insertBefore(box, panel.querySelector('#report-body')); + } + box.textContent = `🤖 AI 브리핑\n${res.analysis}`; + } catch (e) { toast(e.data?.error || e.message); } + finally { ai.disabled = false; ai.textContent = 'AI 요약'; } + }); + panel.appendChild(ai); + const pre = document.createElement('pre'); pre.id = 'report-body'; pre.style.whiteSpace = 'pre-wrap'; diff --git a/docs/api.md b/docs/api.md index 84d8c10d..157d9921 100644 --- a/docs/api.md +++ b/docs/api.md @@ -56,6 +56,12 @@ A PAT acts as your user across all your workspaces. | `GET` | `/api/shared/:token` | **Anonymous** read-only project view (name/baseDate/tasks only) | | `GET` | `/api/projects/:id/calendar.ics` | iCalendar feed of planned tasks (all-day VEVENTs). Calendar apps: pass `?token=` | +## AI (contextual-orchestrator) + +| Method | Path | Purpose | +| --- | --- | --- | +| `POST` | `/api/projects/:id/ai/brief` | 프로젝트 지표 요약 → LLM 경영진 브리핑(일정 판정·리스크·권고). Env: `ORCHESTRATOR_URL/TOKEN` (unset → mock) | + ## Attachments (산출물 — Clearfolio 문서 뷰어) Files attach to a project (optionally a task), convert via diff --git a/docs/deploy.md b/docs/deploy.md index 74921884..de8ade7f 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -24,6 +24,8 @@ persists the database in the `scopeweave-data` volume. | `SCOPEWEAVE_DEV` | no | Must be `1` to enable the dev `activate-pro` endpoint. **Never set in production.** | | `STRIPE_SECRET_KEY`, `STRIPE_PRICE_ID`, `STRIPE_WEBHOOK_SECRET` | for live billing | Enables real Stripe Checkout (`npm i stripe` too). Without them, billing uses the mock path. | | `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URI` | for real SSO | Points the OIDC login at your IdP. Unset → a built-in mock IdP (dev/test only). | +| `ORCHESTRATOR_URL` | for AI 브리핑 | contextual-orchestrator 주소. Unset → deterministic mock. | +| `ORCHESTRATOR_TOKEN` | with URL | orchestrator Bearer 토큰 (`CONTEXTUAL_ORCHESTRATOR_TOKEN`). | | `CLEARFOLIO_URL` | for 산출물 viewer | Clearfolio 문서 뷰어 백엔드 주소. Unset → built-in mock (dev/test). | | `CLEARFOLIO_HMAC_SECRET` | optional | Signs tenant-claim headers (`clearfolio.tenant-claims.hmac-secret`와 동일 값). | | `SCOPEWEAVE_RATE_LIMIT_MAX` (+ `SCOPEWEAVE_RATE_LIMIT_WINDOW_MS`) | recommended | Per-IP fixed-window rate limiting (429 + Retry-After). Off when unset. | diff --git a/server/app.mjs b/server/app.mjs index fe827bf1..e2d992da 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -8,6 +8,7 @@ import { db, rowid } from './db.mjs'; import { hashPassword, verifyPassword, signToken, verifyToken, generateApiToken, hashApiToken } from './auth.mjs'; import { PLANS, planOf, orgUsage, wouldExceed, createCheckout } from './billing.mjs'; import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl } from './clearfolio.mjs'; +import { chat as orchestratorChat } from './orchestrator.mjs'; import { computeEvm } from '../analytics.js'; // pure math, shared with the client const getOrg = (id) => db.prepare('SELECT * FROM orgs WHERE id = ?').get(id); @@ -940,6 +941,50 @@ app.get('/api/orgs/:id/portfolio', requireAuth, (c) => { return c.json({ projects }); }); +// AI 브리핑: 프로젝트 스냅샷(요약 지표 + 지연/차주 작업)을 contextual- +// orchestrator(LLM)로 보내 경영진용 리스크 분석을 생성. 원문 데이터는 서버가 +// 요약해 전송하며, LLM 자격은 서버 환경변수에만 존재. +app.post('/api/projects/:id/ai/brief', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + let tasks = []; + try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } + const today = new Date().toISOString().slice(0, 10); + let wSum = 0, pv = 0, ev = 0; + const late = [], upcoming = []; + for (const t of tasks) { + const w = Number(t.weight) || 1; + wSum += w; + pv += w * ((Number(t.plannedProgress) || 0) / 100); + ev += w * ((Number(t.actualProgress) || 0) / 100); + const name = t.name || t.task || t.activity || t.phase || t.id; + if (t.plannedEndDate && t.plannedEndDate < today && (Number(t.actualProgress) || 0) < 100) { + late.push(`${name}(계획종료 ${t.plannedEndDate}, 실적 ${Number(t.actualProgress) || 0}%${t.owner ? `, ${t.owner}` : ''})`); + } else if (t.plannedStartDate && t.plannedStartDate >= today) { + upcoming.push(`${name}(${t.plannedStartDate} 시작)`); + } + } + const pvPct = wSum ? ((pv / wSum) * 100).toFixed(1) : '0'; + const evPct = wSum ? ((ev / wSum) * 100).toFixed(1) : '0'; + const context = [ + `프로젝트: ${p.name}`, + `작업 수: ${tasks.length} · 계획진척 ${pvPct}% · 실적진척 ${evPct}%`, + `지연 작업(${late.length}): ${late.slice(0, 8).join(' / ') || '없음'}`, + `예정 작업(${upcoming.length}): ${upcoming.slice(0, 5).join(' / ') || '없음'}`, + ].join('\n'); + try { + const analysis = await orchestratorChat([ + { role: 'system', content: '너는 공정관리(schedule control) 전문가다. 주어진 프로젝트 지표를 근거로 한국어 경영진 브리핑을 작성하라: ①일정 상태 한 줄 판정 ②핵심 리스크 2~3개(근거 지표 인용) ③실행 권고 2~3개. 지표에 없는 사실은 만들지 마라.' }, + { role: 'user', content: context }, + ]); + logAudit(p.org_id, uid, 'ai.brief', 'project', p.id, { tasks: tasks.length }); + return c.json({ analysis }); + } catch (e) { + return c.json({ error: `AI 분석 실패: ${e.message}` }, 502); + } +}); + // 산출물 첨부(Clearfolio 통합 문서 뷰어 프록시): 업로드→변환 잡, 목록(+상태 // 갱신), 서명 아티팩트 열람(302), 삭제. 테넌트 = 조직, 브라우저에는 Clearfolio // 자격이 절대 노출되지 않음. diff --git a/server/orchestrator.mjs b/server/orchestrator.mjs new file mode 100644 index 00000000..1205ebe7 --- /dev/null +++ b/server/orchestrator.mjs @@ -0,0 +1,35 @@ +// contextual-orchestrator(LLM 오케스트레이션) 클라이언트. +// 실서버: ORCHESTRATOR_URL + ORCHESTRATOR_TOKEN 설정 시 OpenAI 호환 +// /v1/chat/completions 호출. 미설정 시 결정적 MOCK으로 전 플로우 테스트 가능. +const OC_URL = (process.env.ORCHESTRATOR_URL || '').replace(/\/$/, ''); +const OC_TOKEN = process.env.ORCHESTRATOR_TOKEN || ''; + +export const orchestratorMock = !OC_URL; + +export async function chat(messages) { + if (orchestratorMock) { + const user = messages.filter((m) => m.role === 'user').map((m) => m.content).join('\n'); + return `[mock-orchestrator] 분석 요약: ${user.slice(0, 120)}…에 대한 모의 응답입니다. ` + + '리스크: 지연 작업을 우선 점검하세요. 권고: 임계경로 작업의 담당자 부하를 재배분하세요.'; + } + const ctrl = new AbortController(); + const to = setTimeout(() => ctrl.abort(), 60000); + try { + const res = await fetch(`${OC_URL}/v1/chat/completions`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(OC_TOKEN ? { authorization: `Bearer ${OC_TOKEN}` } : {}), + }, + // orchestrator는 알 수 없는 필드를 거부(strict validation) — model+messages만 전송. + body: JSON.stringify({ model: 'contextual-orchestrator', messages }), + signal: ctrl.signal, + }); + const data = await res.json().catch(() => ({})); + const content = data?.choices?.[0]?.message?.content; + if (!res.ok || !content) throw new Error(data?.error?.message || `orchestrator failed (${res.status})`); + return content; + } finally { + clearTimeout(to); + } +} diff --git a/tests/api/smoke.mjs b/tests/api/smoke.mjs index 6b08bd61..3ba0905d 100644 --- a/tests/api/smoke.mjs +++ b/tests/api/smoke.mjs @@ -547,6 +547,15 @@ assert.equal(portAfter.status, 'delay', 'SPI 0.3 → delay'); r = await req(`/api/orgs/${orgAId}/portfolio`, { headers: oauth }); assert.equal(r.status, 404, 'non-member portfolio → 404'); +// ---- AI 브리핑 (orchestrator mock) ---- +r = await req(`/api/projects/${proj.id}/ai/brief`, { method: 'POST', headers: auth }); +assert.equal(r.status, 200, 'ai brief 200'); +const brief = await r.json(); +assert.ok(brief.analysis.includes('mock-orchestrator'), 'mock analysis returned'); +assert.ok(brief.analysis.length > 40, 'non-trivial analysis'); +r = await req(`/api/projects/${proj.id}/ai/brief`, { method: 'POST', headers: oauth }); +assert.equal(r.status, 404, 'non-member ai brief → 404'); + // ---- 산출물 첨부 (Clearfolio mock) ---- { const fd = new FormData(); From 362fdd3b66be085a97418e4276c21e4e50dc33c4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 7 Jul 2026 17:48:34 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(saas):=20Agile=20+=20Hybrid=20?= =?UTF-8?q?=EB=B0=A9=EB=B2=95=EB=A1=A0=20=E2=80=94=20=EC=8A=A4=ED=94=84?= =?UTF-8?q?=EB=A6=B0=ED=8A=B8=C2=B7=EC=8A=A4=ED=86=A0=EB=A6=AC=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=C2=B7=EB=B2=A8=EB=A1=9C=EC=8B=9C=ED=8B=B0=20?= =?UTF-8?q?(slice=2056)=20(#274)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 32 ++++- cloud-sync.js | 231 +++++++++++++++++++++++++++++++ docs/api.md | 14 ++ package.json | 2 +- server/app.mjs | 44 +++++- server/db.mjs | 12 ++ tests/api/smoke.mjs | 24 ++++ tests/unit/burndown.test.mjs | 28 ++++ tests/unit/sprint-stats.test.mjs | 30 ++++ 9 files changed, 406 insertions(+), 11 deletions(-) create mode 100644 tests/unit/burndown.test.mjs create mode 100644 tests/unit/sprint-stats.test.mjs diff --git a/app.js b/app.js index 6fd028b7..6b4cdc7f 100644 --- a/app.js +++ b/app.js @@ -52,7 +52,9 @@ const EDITABLE_FIELDS = [ 'actualEndDate', 'predecessors', 'budget', - 'actualCost' + 'actualCost', + 'sprint', + 'storyPoints' ]; const CSV_HEADERS = [ @@ -81,7 +83,9 @@ const CSV_HEADERS = [ '__depth', '선행작업', '예산', - '실투입비' + '실투입비', + '스프린트', + '스토리포인트' ]; const CSV_FORMULA_PREFIX_PATTERN = /^\s*[=+\-@|]/; const UNSAFE_JSON_KEYS = new Set(['__proto__', 'constructor', 'prototype']); @@ -102,7 +106,9 @@ const CSV_FIELD_LABELS = Object.freeze(Object.assign(Object.create(null), { actualEndDate: '실적종료일', predecessors: '선행작업', budget: '예산', - actualCost: '실투입비' + actualCost: '실투입비', + sprint: '스프린트', + storyPoints: '스토리포인트' })); const EDITOR_FIELD_TEST_IDS = Object.freeze(Object.assign(Object.create(null), { @@ -120,7 +126,9 @@ const EDITOR_FIELD_TEST_IDS = Object.freeze(Object.assign(Object.create(null), { actualEndDate: 'editor-actual-end', predecessors: 'editor-predecessors', budget: 'editor-budget', - actualCost: 'editor-actual-cost' + actualCost: 'editor-actual-cost', + sprint: 'editor-sprint', + storyPoints: 'editor-story-points' })); const LEGACY_PLANNED_END_FIELD = 'plannedEnd' + 'Ddate'; @@ -710,7 +718,9 @@ function renderEditorRow(anchorId) { renderEditorField('실적종료일', 'actualEndDate', draft.actualEndDate, 'date'), renderEditorField('선행작업', 'predecessors', draft.predecessors, 'text', false, '예: P1000,P2000 (선행 작업 ID/코드, 쉼표 구분)'), renderEditorField('예산', 'budget', draft.budget, 'text', false, '예: 1000000 (원, 비용 EVM용)'), - renderEditorField('실투입비', 'actualCost', draft.actualCost, 'text', false, '예: 850000 (원)') + renderEditorField('실투입비', 'actualCost', draft.actualCost, 'text', false, '예: 850000 (원)'), + renderEditorField('스프린트', 'sprint', draft.sprint, 'text', false, '예: Sprint 3 (Agile/Hybrid)'), + renderEditorField('스토리포인트', 'storyPoints', draft.storyPoints, 'text', false, '예: 5') ].forEach((field) => editorGrid.appendChild(field)); const editorActions = document.createElement('div'); @@ -1125,6 +1135,8 @@ function createEmptyTaskDraft() { predecessors: '', budget: '', actualCost: '', + sprint: '', + storyPoints: '', isSynthetic: false }; } @@ -1611,7 +1623,9 @@ const createNormalizedExternalRecord = (task, defaults = {}) => ({ actualEndDate: task.actualEndDate || '', predecessors: task.predecessors || defaults.predecessors || '', budget: task.budget || defaults.budget || '', - actualCost: task.actualCost || defaults.actualCost || '' + actualCost: task.actualCost || defaults.actualCost || '', + sprint: task.sprint || defaults.sprint || '', + storyPoints: task.storyPoints || defaults.storyPoints || '' }); function getPhaseKey(task, index) { @@ -1758,7 +1772,9 @@ function exportCsv() { task.depth, task.predecessors || '', task.budget || '', - task.actualCost || '' + task.actualCost || '', + task.sprint || '', + task.storyPoints || '' ]; }); @@ -1934,6 +1950,8 @@ function parseCsv(text) { predecessors: validateCsvCell(readCsvCell(cells, headerMap, '선행작업'), 'predecessors'), budget: validateCsvCell(readCsvCell(cells, headerMap, '예산'), 'budget'), actualCost: validateCsvCell(readCsvCell(cells, headerMap, '실투입비'), 'actualCost'), + sprint: validateCsvCell(readCsvCell(cells, headerMap, '스프린트'), 'sprint'), + storyPoints: validateCsvCell(readCsvCell(cells, headerMap, '스토리포인트'), 'storyPoints'), __id: validateCsvId(readCsvCell(cells, headerMap, '__id')), __parentId: validateCsvParentId(readCsvCell(cells, headerMap, '__parentId')), __depth: validateCsvDepth(readCsvCell(cells, headerMap, '__depth')) diff --git a/cloud-sync.js b/cloud-sync.js index 5109b761..b735272b 100644 --- a/cloud-sync.js +++ b/cloud-sync.js @@ -383,6 +383,13 @@ function renderAuthUI() { bar.appendChild(search); if (getProjectId()) { + const spr = document.createElement('button'); + spr.type = 'button'; + spr.className = 'secondary-button'; + spr.textContent = '스프린트'; + spr.addEventListener('click', () => openSprintModal().catch((e) => toast(e.data?.error || e.message))); + bar.appendChild(spr); + const att = document.createElement('button'); att.type = 'button'; att.className = 'secondary-button'; @@ -859,6 +866,230 @@ async function openPortfolioModal() { panel.appendChild(wrap); } +// --------------------------------------------------------------- sprints +// Agile/Hybrid 지표 (순수): 스프린트별 커밋/완료 스토리포인트와 팀 벨로시티. +// 작업 배정 = task.sprint(이름 일치), 추정 = task.storyPoints, 완료 = 실적 100%. +export function computeSprintStats(tasks, sprints, today) { + const leaf = (tasks || []).filter((t) => !t.isSynthetic); + const rows = (sprints || []).map((sp) => { + const mine = leaf.filter((t) => String(t.sprint || '').trim() === sp.name); + const pts = (t) => Number(t.storyPoints) || 0; + const committed = mine.reduce((n, t) => n + pts(t), 0); + const completed = mine.filter((t) => (Number(t.actualProgress) || 0) >= 100).reduce((n, t) => n + pts(t), 0); + const closed = Boolean(sp.endDate && today && sp.endDate < today); + return { id: sp.id, name: sp.name, startDate: sp.startDate, endDate: sp.endDate, goal: sp.goal, taskCount: mine.length, committed, completed, remaining: committed - completed, closed }; + }); + const closedWithWork = rows.filter((r) => r.closed && r.committed > 0); + const velocity = closedWithWork.length + ? closedWithWork.reduce((n, r) => n + r.completed, 0) / closedWithWork.length + : null; + const assigned = new Set(rows.flatMap((r) => [r.name])); + const backlog = leaf.filter((t) => !String(t.sprint || '').trim() || !(sprints || []).some((sp) => sp.name === String(t.sprint).trim())); + return { rows, velocity, backlogCount: backlog.length }; +} + +// 번다운 (순수): 스프린트 기간의 일별 잔여 포인트 — ideal(선형 소진) vs +// actual(완료일 actualEndDate 기준; 완료일 없는 100% 작업은 오늘 완료로 간주). +export function computeBurndown(tasks, sprint, today) { + if (!sprint?.startDate || !sprint?.endDate || sprint.endDate < sprint.startDate) return null; + const leaf = (tasks || []).filter((t) => !t.isSynthetic && String(t.sprint || '').trim() === sprint.name); + const pts = (t) => Number(t.storyPoints) || 0; + const committed = leaf.reduce((n, t) => n + pts(t), 0); + if (committed <= 0) return null; + const days = []; + for (let d = new Date(sprint.startDate); ; d.setDate(d.getDate() + 1)) { + const iso = d.toISOString().slice(0, 10); + days.push(iso); + if (iso >= sprint.endDate) break; + if (days.length > 120) break; // 안전 상한 + } + const n = days.length; + const ideal = days.map((_, i) => committed * (1 - (n === 1 ? 1 : i / (n - 1)))); + const doneAt = (t) => t.actualEndDate || ((Number(t.actualProgress) || 0) >= 100 ? today : null); + const actual = days.map((day) => { + if (today && day > today) return null; // 미래는 미기록 + const burned = leaf.filter((t) => { const d = doneAt(t); return d && d <= day; }).reduce((s2, t) => s2 + pts(t), 0); + return committed - burned; + }); + return { days, committed, ideal, actual }; +} + +function renderBurndownSvg(bd) { + const W = 420, H = 110, PAD = 6; + const n = bd.days.length; + const x = (i) => PAD + (n === 1 ? 0 : (i / (n - 1)) * (W - 2 * PAD)); + const y = (v) => H - PAD - (v / bd.committed) * (H - 2 * PAD); + const NS = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(NS, 'svg'); + svg.setAttribute('viewBox', `0 0 ${W} ${H}`); + svg.setAttribute('role', 'img'); + svg.setAttribute('aria-label', `번다운: 커밋 ${bd.committed}pt`); + svg.style.width = '100%'; + svg.style.maxWidth = '460px'; + const grid = document.createElementNS(NS, 'line'); + grid.setAttribute('x1', PAD); grid.setAttribute('x2', W - PAD); + grid.setAttribute('y1', y(0)); grid.setAttribute('y2', y(0)); + grid.setAttribute('stroke', '#e2e8f0'); + svg.appendChild(grid); + const idealLine = document.createElementNS(NS, 'polyline'); + idealLine.setAttribute('points', bd.ideal.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ')); + idealLine.setAttribute('fill', 'none'); + idealLine.setAttribute('stroke', '#94a3b8'); + idealLine.setAttribute('stroke-dasharray', '4 3'); + svg.appendChild(idealLine); + const actualPts = bd.actual.map((v, i) => (v === null ? null : `${x(i).toFixed(1)},${y(v).toFixed(1)}`)).filter(Boolean); + if (actualPts.length) { + const actualLine = document.createElementNS(NS, 'polyline'); + actualLine.setAttribute('points', actualPts.join(' ')); + actualLine.setAttribute('fill', 'none'); + actualLine.setAttribute('stroke', '#2563eb'); + actualLine.setAttribute('stroke-width', '2'); + svg.appendChild(actualLine); + } + return svg; +} + +const METHODOLOGY_LABELS = { waterfall: 'Waterfall (예측형)', agile: 'Agile (적응형)', hybrid: 'Hybrid (혼합형)' }; + +async function openSprintModal() { + const pid = getProjectId(); + if (!pid) return; + let modal = document.getElementById('sprint-modal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'sprint-modal'; + modal.className = 'modal'; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + const backdrop = document.createElement('div'); + backdrop.className = 'modal-backdrop'; + backdrop.addEventListener('click', () => modal.classList.add('hidden')); + const panel = document.createElement('div'); + panel.className = 'modal-panel'; + panel.id = 'sprint-panel'; + modal.append(backdrop, panel); + document.body.appendChild(modal); + } + modal.classList.remove('hidden'); + const panel = modal.querySelector('#sprint-panel'); + panel.textContent = ''; + + const head = document.createElement('div'); + head.className = 'modal-header'; + const h2 = document.createElement('h2'); + h2.textContent = '스프린트 (Agile / Hybrid)'; + const close = document.createElement('button'); + close.type = 'button'; + close.className = 'icon-button close-button'; + close.setAttribute('aria-label', '스프린트 닫기'); + close.textContent = '✕'; + close.addEventListener('click', () => modal.classList.add('hidden')); + head.append(h2, close); + panel.appendChild(head); + + const data = await api(`/api/projects/${pid}/sprints`); + + // 방법론 선택 — 프로젝트 메타로 저장 + const mLabel = document.createElement('label'); + mLabel.className = 'meta-field'; + const mSpan = document.createElement('span'); + mSpan.textContent = '프로젝트 방법론'; + const mSel = document.createElement('select'); + mSel.className = 'cloud-select'; + mSel.id = 'methodology-select'; + for (const [v, label] of Object.entries(METHODOLOGY_LABELS)) { + const opt = document.createElement('option'); + opt.value = v; + opt.textContent = label; + if (v === (data.methodology || 'waterfall')) opt.selected = true; + mSel.appendChild(opt); + } + mSel.addEventListener('change', async () => { + try { + const cur = await api(`/api/projects/${pid}`); + await api(`/api/projects/${pid}`, { method: 'PUT', body: { methodology: mSel.value, version: cur.version } }); + toast(`방법론: ${METHODOLOGY_LABELS[mSel.value]}`); + } catch (e) { toast(e.data?.error || e.message); } + }); + mLabel.append(mSpan, mSel); + panel.appendChild(mLabel); + + // 지표 + 목록 + const stats = computeSprintStats(host?.getState?.()?.tasks || [], data.sprints, new Date().toISOString().slice(0, 10)); + const summary = document.createElement('p'); + summary.className = 'cpm-summary'; + summary.textContent = `스프린트 ${stats.rows.length}개 · 벨로시티 ${stats.velocity === null ? 'N/A (종료 스프린트 없음)' : stats.velocity.toFixed(1) + 'pt'} · 백로그 ${stats.backlogCount}건`; + panel.appendChild(summary); + + const list = document.createElement('ul'); + list.className = 'team-list'; + for (const r of stats.rows) { + const li = document.createElement('li'); + const who = document.createElement('span'); + who.className = 'team-who'; + const period = r.startDate || r.endDate ? ` (${r.startDate}~${r.endDate})` : ''; + who.textContent = `${r.name}${period} · ${r.taskCount}작업 · ${r.completed}/${r.committed}pt${r.closed ? ' · 종료' : ''}`; + const bdBtn = document.createElement('button'); + bdBtn.type = 'button'; + bdBtn.className = 'secondary-button'; + bdBtn.textContent = '번다운'; + bdBtn.addEventListener('click', () => { + const holder = document.getElementById('burndown-holder'); + holder.textContent = ''; + const bd = computeBurndown(host?.getState?.()?.tasks || [], r, new Date().toISOString().slice(0, 10)); + if (!bd) { holder.textContent = '번다운을 그리려면 스프린트 기간과 스토리포인트가 필요합니다.'; return; } + const cap = document.createElement('p'); + cap.className = 'evm-caption'; + cap.textContent = `${r.name} 번다운 — 커밋 ${bd.committed}pt · 점선=이상적 소진, 실선=실제 잔여`; + holder.append(cap, renderBurndownSvg(bd)); + }); + const del = document.createElement('button'); + del.type = 'button'; + del.className = 'secondary-button team-remove'; + del.textContent = '삭제'; + del.addEventListener('click', () => + api(`/api/projects/${pid}/sprints/${r.id}`, { method: 'DELETE' }) + .then(() => openSprintModal()).catch((e) => toast(e.data?.error || e.message))); + li.append(who, bdBtn, del); + list.appendChild(li); + } + if (!stats.rows.length) { + const li = document.createElement('li'); + li.textContent = '스프린트가 없습니다. 아래에서 추가하세요. (작업 배정: 편집기의 스프린트 필드)'; + list.appendChild(li); + } + panel.appendChild(list); + + const bdHolder = document.createElement('div'); + bdHolder.id = 'burndown-holder'; + panel.appendChild(bdHolder); + + const form = document.createElement('form'); + form.className = 'cloud-form'; + const nameIn = document.createElement('input'); + nameIn.type = 'text'; + nameIn.placeholder = '스프린트 이름 (예: Sprint 3)'; + nameIn.required = true; + const startIn = document.createElement('input'); + startIn.type = 'date'; + const endIn = document.createElement('input'); + endIn.type = 'date'; + const add = document.createElement('button'); + add.type = 'submit'; + add.className = 'primary-button'; + add.textContent = '추가'; + form.append(nameIn, startIn, endIn, add); + form.addEventListener('submit', async (e) => { + e.preventDefault(); + try { + await api(`/api/projects/${pid}/sprints`, { method: 'POST', body: { name: nameIn.value.trim(), startDate: startIn.value, endDate: endIn.value } }); + toast('스프린트를 추가했습니다.'); + openSprintModal(); + } catch (err) { toast(err.data?.error || err.message); } + }); + panel.appendChild(form); +} + // ----------------------------------------------------------- attachments // 산출물 첨부: Clearfolio 통합 문서 뷰어로 업로드/열람. 서버가 프록시하므로 // 브라우저에는 Clearfolio 자격/시크릿이 노출되지 않는다. diff --git a/docs/api.md b/docs/api.md index 157d9921..1c668425 100644 --- a/docs/api.md +++ b/docs/api.md @@ -56,6 +56,20 @@ A PAT acts as your user across all your workspaces. | `GET` | `/api/shared/:token` | **Anonymous** read-only project view (name/baseDate/tasks only) | | `GET` | `/api/projects/:id/calendar.ics` | iCalendar feed of planned tasks (all-day VEVENTs). Calendar apps: pass `?token=` | +## Sprints & Methodology (Agile / Hybrid) + +Projects carry `methodology` (`waterfall` default · `agile` · `hybrid`) — set it +via `PUT /api/projects/:id { methodology }`. Tasks join a sprint by name +(`task.sprint`) and are estimated with `task.storyPoints`; committed/completed +points and velocity are computed client-side (`computeSprintStats`). Hybrid = +waterfall metrics (EVM/CPM) and sprint metrics coexist on the same plan. + +| Method | Path | Purpose | +| --- | --- | --- | +| `POST` | `/api/projects/:id/sprints` | `{ name, startDate?, endDate?, goal? }` (write roles) | +| `GET` | `/api/projects/:id/sprints` | List + project methodology | +| `DELETE` | `/api/projects/:id/sprints/:sid` | Remove a sprint | + ## AI (contextual-orchestrator) | Method | Path | Purpose | diff --git a/package.json b/package.json index 140fe49f..8dafedaa 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "coverage": "node scripts/ci/static_coverage_evidence.mjs coverage", "server": "node server/server.mjs", "test:api": "node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs", - "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs", + "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright test tests/e2e/cloud.spec.js" diff --git a/server/app.mjs b/server/app.mjs index e2d992da..cc5a26e7 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -241,7 +241,7 @@ app.post('/api/projects', requireAuth, async (c) => { app.get('/api/projects/:id', requireAuth, (c) => { const p = projectAccess(c.get('user').sub, c.req.param('id')); if (!p) return c.json({ error: 'not found' }, 404); - return c.json({ id: p.id, name: p.name, orgId: p.org_id, baseDate: p.base_date, tasks: JSON.parse(p.tasks_json), version: p.version }); + return c.json({ id: p.id, name: p.name, orgId: p.org_id, baseDate: p.base_date, methodology: p.methodology || 'waterfall', tasks: JSON.parse(p.tasks_json), version: p.version }); }); app.put('/api/projects/:id', requireAuth, async (c) => { @@ -256,9 +256,10 @@ app.put('/api/projects/:id', requireAuth, async (c) => { } const tasks = Array.isArray(body.tasks) ? body.tasks : JSON.parse(p.tasks_json); const version = p.version + 1; + const methodology = ['waterfall', 'agile', 'hybrid'].includes(body.methodology) ? body.methodology : (p.methodology || 'waterfall'); db.prepare( - "UPDATE projects SET name=?, base_date=?, tasks_json=?, version=?, updated_at=datetime('now') WHERE id=?" - ).run(body.name ?? p.name, body.baseDate ?? p.base_date, JSON.stringify(tasks), version, id); + "UPDATE projects SET name=?, base_date=?, tasks_json=?, version=?, methodology=?, updated_at=datetime('now') WHERE id=?" + ).run(body.name ?? p.name, body.baseDate ?? p.base_date, JSON.stringify(tasks), version, methodology, id); logAudit(p.org_id, uid, 'project.update', 'project', id, { version, tasks: tasks.length }); // Revision history: snapshot every save, keep the last 20 per project. try { @@ -1201,6 +1202,43 @@ app.post('/api/projects/:id/duplicate', requireAuth, async (c) => { return c.json({ id: nid, name: newName, version: 1 }); }); +// -------------------------------------------------------------- sprints +// Agile/Hybrid: 시간상자(스프린트) CRUD. 작업은 task.sprint(이름)로 배정되고 +// task.storyPoints로 추정된다 — 지표(커밋/완료 포인트, 벨로시티)는 클라이언트 +// 순수 함수(computeSprintStats)가 계산한다. +app.post('/api/projects/:id/sprints', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const { name, startDate, endDate, goal } = await c.req.json().catch(() => ({})); + if (!name || !String(name).trim()) return c.json({ error: 'name required' }, 400); + const day = (v) => (/^\d{4}-\d{2}-\d{2}$/.test(String(v || '')) ? v : ''); + const sid = rowid(db.prepare('INSERT INTO sprints(project_id,name,start_date,end_date,goal) VALUES(?,?,?,?,?)') + .run(p.id, String(name).trim().slice(0, 80), day(startDate), day(endDate), String(goal || '').slice(0, 300))); + logAudit(p.org_id, uid, 'sprint.create', 'project', p.id, { sprintId: sid, name }); + return c.json({ id: sid, name: String(name).trim() }); +}); + +app.get('/api/projects/:id/sprints', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const sprints = db.prepare( + 'SELECT id, name, start_date AS startDate, end_date AS endDate, goal FROM sprints WHERE project_id = ? ORDER BY start_date, id' + ).all(p.id); + return c.json({ sprints, methodology: p.methodology || 'waterfall' }); +}); + +app.delete('/api/projects/:id/sprints/:sid', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const info = db.prepare('DELETE FROM sprints WHERE id = ? AND project_id = ?').run(c.req.param('sid'), p.id); + if (!info.changes) return c.json({ error: 'not found' }, 404); + return c.json({ ok: true }); +}); + // ------------------------------------------------------------- baselines // Snapshot a project's current plan as a named baseline (schedule-control: // compare actuals against the frozen plan later). diff --git a/server/db.mjs b/server/db.mjs index 72a5690f..122b70d6 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -42,6 +42,7 @@ CREATE TABLE IF NOT EXISTS projects ( tasks_json TEXT NOT NULL DEFAULT '[]', version INTEGER NOT NULL DEFAULT 1, archived INTEGER NOT NULL DEFAULT 0, + methodology TEXT NOT NULL DEFAULT 'waterfall', created_by INTEGER NOT NULL REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) @@ -128,6 +129,16 @@ CREATE TABLE IF NOT EXISTS comments ( created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_comments_project ON comments(project_id, id); +CREATE TABLE IF NOT EXISTS sprints ( + id INTEGER PRIMARY KEY, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + start_date TEXT NOT NULL DEFAULT '', + end_date TEXT NOT NULL DEFAULT '', + goal TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_sprints_project ON sprints(project_id, id); CREATE TABLE IF NOT EXISTS attachments ( id INTEGER PRIMARY KEY, project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, @@ -164,6 +175,7 @@ CREATE INDEX IF NOT EXISTS idx_invites_token ON invites(token); // Migration for pre-existing DBs: add token_version if missing (idempotent). try { db.exec('ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0'); } catch { /* already there */ } try { db.exec('ALTER TABLE projects ADD COLUMN archived INTEGER NOT NULL DEFAULT 0'); } catch { /* already there */ } +try { db.exec("ALTER TABLE projects ADD COLUMN methodology TEXT NOT NULL DEFAULT 'waterfall'"); } catch { /* already there */ } // node:sqlite returns lastInsertRowid as number|bigint; normalize to Number. export const rowid = (r) => Number(r.lastInsertRowid); diff --git a/tests/api/smoke.mjs b/tests/api/smoke.mjs index 3ba0905d..f92622f5 100644 --- a/tests/api/smoke.mjs +++ b/tests/api/smoke.mjs @@ -547,6 +547,30 @@ assert.equal(portAfter.status, 'delay', 'SPI 0.3 → delay'); r = await req(`/api/orgs/${orgAId}/portfolio`, { headers: oauth }); assert.equal(r.status, 404, 'non-member portfolio → 404'); +// ---- 스프린트 + 방법론 (Agile/Hybrid) ---- +r = await req(`/api/projects/${proj.id}/sprints`, { method: 'POST', headers: auth, body: body({ name: 'Sprint 1', startDate: '2026-06-01', endDate: '2026-06-14' }) }); +assert.equal(r.status, 200, 'sprint create'); +const sp1 = await r.json(); +r = await req(`/api/projects/${proj.id}/sprints`, { headers: auth }); +let spData = await r.json(); +assert.ok(spData.sprints.some((x) => x.id === sp1.id && x.startDate === '2026-06-01'), 'sprint listed'); +assert.equal(spData.methodology, 'waterfall', 'default methodology'); +// 방법론 변경(hybrid) → GET 반영 +let mv = (await (await req(`/api/projects/${proj.id}`, { headers: auth })).json()).version; +r = await req(`/api/projects/${proj.id}`, { method: 'PUT', headers: auth, body: body({ methodology: 'hybrid', version: mv }) }); +assert.equal(r.status, 200); +r = await req(`/api/projects/${proj.id}`, { headers: auth }); +assert.equal((await r.json()).methodology, 'hybrid', 'methodology persisted'); +// 불량 값은 무시(기존 유지) +mv = (await (await req(`/api/projects/${proj.id}`, { headers: auth })).json()).version; +await req(`/api/projects/${proj.id}`, { method: 'PUT', headers: auth, body: body({ methodology: 'chaos', version: mv }) }); +assert.equal((await (await req(`/api/projects/${proj.id}`, { headers: auth })).json()).methodology, 'hybrid', 'invalid methodology ignored'); +// 가드 + 삭제 +r = await req(`/api/projects/${proj.id}/sprints`, { method: 'POST', headers: oauth, body: body({ name: 'X' }) }); +assert.equal(r.status, 404, 'non-member sprint → 404'); +r = await req(`/api/projects/${proj.id}/sprints/${sp1.id}`, { method: 'DELETE', headers: auth }); +assert.equal(r.status, 200, 'sprint delete'); + // ---- AI 브리핑 (orchestrator mock) ---- r = await req(`/api/projects/${proj.id}/ai/brief`, { method: 'POST', headers: auth }); assert.equal(r.status, 200, 'ai brief 200'); diff --git a/tests/unit/burndown.test.mjs b/tests/unit/burndown.test.mjs new file mode 100644 index 00000000..dffa53df --- /dev/null +++ b/tests/unit/burndown.test.mjs @@ -0,0 +1,28 @@ +// 번다운 (순수 로직 in cloud-sync.js). +import assert from 'node:assert'; +import { computeBurndown } from '../../cloud-sync.js'; + +const sprint = { name: 'S1', startDate: '2026-07-01', endDate: '2026-07-05' }; // 5일 +const tasks = [ + { id: 'a', sprint: 'S1', storyPoints: 5, actualProgress: 100, actualEndDate: '2026-07-02' }, + { id: 'b', sprint: 'S1', storyPoints: 3, actualProgress: 100, actualEndDate: '2026-07-04' }, + { id: 'c', sprint: 'S1', storyPoints: 2, actualProgress: 50 }, + { id: 'x', sprint: 'S2', storyPoints: 99 }, // 타 스프린트 제외 + { id: 's', sprint: 'S1', storyPoints: 7, isSynthetic: true }, // 합성 제외 +]; + +const bd = computeBurndown(tasks, sprint, '2026-07-04'); +assert.equal(bd.days.length, 5); +assert.equal(bd.committed, 10, '5+3+2'); +assert.equal(bd.ideal[0], 10); +assert.equal(bd.ideal[4], 0, '이상선은 0으로 소진'); +assert.deepEqual(bd.actual, [10, 5, 5, 2, null], 'D1 잔여10 → D2 a완료(5) → D4 b완료(2) → 미래 null'); + +// 완료일 없는 100% 작업은 today 완료로 간주 +const bd2 = computeBurndown([{ id: 'a', sprint: 'S1', storyPoints: 4, actualProgress: 100 }], sprint, '2026-07-03'); +assert.deepEqual(bd2.actual.slice(0, 3), [4, 4, 0], 'today(D3)에 소진 반영'); + +assert.equal(computeBurndown(tasks, { name: 'S1' }, '2026-07-04'), null, '기간 없으면 null'); +assert.equal(computeBurndown([], sprint, '2026-07-04'), null, '포인트 0이면 null'); + +console.log('✓ burndown tests passed'); diff --git a/tests/unit/sprint-stats.test.mjs b/tests/unit/sprint-stats.test.mjs new file mode 100644 index 00000000..d550ef82 --- /dev/null +++ b/tests/unit/sprint-stats.test.mjs @@ -0,0 +1,30 @@ +// Agile 스프린트 지표 (순수 로직 in cloud-sync.js). +import assert from 'node:assert'; +import { computeSprintStats } from '../../cloud-sync.js'; + +const sprints = [ + { id: 1, name: 'Sprint 1', startDate: '2026-06-01', endDate: '2026-06-14' }, // 종료 + { id: 2, name: 'Sprint 2', startDate: '2026-06-15', endDate: '2026-06-28' }, // 종료 + { id: 3, name: 'Sprint 3', startDate: '2026-07-06', endDate: '2026-07-19' }, // 진행 +]; +const tasks = [ + { id: 'a', sprint: 'Sprint 1', storyPoints: 5, actualProgress: 100 }, + { id: 'b', sprint: 'Sprint 1', storyPoints: 3, actualProgress: 100 }, + { id: 'c', sprint: 'Sprint 2', storyPoints: 8, actualProgress: 100 }, + { id: 'd', sprint: 'Sprint 2', storyPoints: 5, actualProgress: 50 }, // 미완 → 벨로시티 제외 + { id: 'e', sprint: 'Sprint 3', storyPoints: 13, actualProgress: 20 }, + { id: 'f', storyPoints: 8 }, // 백로그(스프린트 없음) + { id: 'g', sprint: '없는스프린트', storyPoints: 2 }, // 백로그(미존재 스프린트) + { id: 's', sprint: 'Sprint 3', storyPoints: 99, isSynthetic: true }, // 합성행 제외 +]; + +const st = computeSprintStats(tasks, sprints, '2026-07-07'); +const [s1, s2, s3] = st.rows; +assert.equal(s1.committed, 8); assert.equal(s1.completed, 8); assert.ok(s1.closed); +assert.equal(s2.committed, 13); assert.equal(s2.completed, 8); assert.equal(s2.remaining, 5); +assert.equal(s3.committed, 13); assert.equal(s3.completed, 0); assert.ok(!s3.closed, '진행 중'); +assert.equal(st.velocity, 8, '(8+8)/2 종료 스프린트 평균'); +assert.equal(st.backlogCount, 2, '미배정 + 미존재 스프린트'); +assert.equal(computeSprintStats([], [], '2026-07-07').velocity, null, '종료 스프린트 없음 → N/A'); + +console.log('✓ sprint-stats tests passed');