popilot 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.mjs +204 -2
- package/lib/doctor.mjs +38 -1
- package/lib/hydrate.mjs +15 -0
- package/lib/scaffold.mjs +5 -0
- package/lib/setup-wizard.mjs +35 -2
- package/package.json +1 -1
- package/scaffold/.context/project.yaml.example +19 -0
- package/scaffold/mcp-notification-server/package.json +18 -0
- package/scaffold/mcp-notification-server/src/index.ts +275 -0
- package/scaffold/mcp-notification-server/src/turso-client.ts +142 -0
- package/scaffold/mcp-notification-server/tsconfig.json +14 -0
- package/scaffold/mcp-pm/package.json +19 -0
- package/scaffold/mcp-pm/src/api-client.ts +69 -0
- package/scaffold/mcp-pm/src/index.ts +660 -0
- package/scaffold/mcp-pm/tsconfig.json +14 -0
- package/scaffold/pm-api/package.json +21 -0
- package/scaffold/pm-api/sql/001-memo-v2.sql +49 -0
- package/scaffold/pm-api/sql/002-notifications.sql +18 -0
- package/scaffold/pm-api/sql/003-content.sql +66 -0
- package/scaffold/pm-api/sql/004-agent-events.sql +21 -0
- package/scaffold/pm-api/sql/005-epic-sprint-decoupling.sql +6 -0
- package/scaffold/pm-api/sql/schema-core.sql +331 -0
- package/scaffold/pm-api/sql/schema-docs.sql +25 -0
- package/scaffold/pm-api/sql/schema-meetings.sql +17 -0
- package/scaffold/pm-api/sql/schema-rewards.sql +16 -0
- package/scaffold/pm-api/src/auth.ts +28 -0
- package/scaffold/pm-api/src/blockchain/adapter.ts +20 -0
- package/scaffold/pm-api/src/blockchain/tron.ts +62 -0
- package/scaffold/pm-api/src/db/adapter.ts +36 -0
- package/scaffold/pm-api/src/db/turso.ts +147 -0
- package/scaffold/pm-api/src/index.ts +114 -0
- package/scaffold/pm-api/src/mcp-tools/dashboard.ts +40 -0
- package/scaffold/pm-api/src/mcp-tools/epic.ts +67 -0
- package/scaffold/pm-api/src/mcp-tools/event.ts +89 -0
- package/scaffold/pm-api/src/mcp-tools/index.ts +11 -0
- package/scaffold/pm-api/src/mcp-tools/initiative.ts +51 -0
- package/scaffold/pm-api/src/mcp-tools/memo.ts +164 -0
- package/scaffold/pm-api/src/mcp-tools/notification.ts +37 -0
- package/scaffold/pm-api/src/mcp-tools/retro.ts +183 -0
- package/scaffold/pm-api/src/mcp-tools/sprint.ts +204 -0
- package/scaffold/pm-api/src/mcp-tools/standup.ts +136 -0
- package/scaffold/pm-api/src/mcp-tools/story.ts +230 -0
- package/scaffold/pm-api/src/mcp-tools/task.ts +187 -0
- package/scaffold/pm-api/src/mcp-tools/utils.ts +83 -0
- package/scaffold/pm-api/src/mcp.ts +871 -0
- package/scaffold/pm-api/src/nudge.ts +283 -0
- package/scaffold/pm-api/src/routes/auth.ts +32 -0
- package/scaffold/pm-api/src/routes/v2-activity.ts +27 -0
- package/scaffold/pm-api/src/routes/v2-admin.ts +165 -0
- package/scaffold/pm-api/src/routes/v2-dashboard.ts +189 -0
- package/scaffold/pm-api/src/routes/v2-docs.ts +34 -0
- package/scaffold/pm-api/src/routes/v2-initiatives.ts +118 -0
- package/scaffold/pm-api/src/routes/v2-kickoff.ts +265 -0
- package/scaffold/pm-api/src/routes/v2-meetings.ts +324 -0
- package/scaffold/pm-api/src/routes/v2-memos.ts +257 -0
- package/scaffold/pm-api/src/routes/v2-nav.ts +260 -0
- package/scaffold/pm-api/src/routes/v2-notifications.ts +79 -0
- package/scaffold/pm-api/src/routes/v2-page-content.ts +35 -0
- package/scaffold/pm-api/src/routes/v2-pm.ts +380 -0
- package/scaffold/pm-api/src/routes/v2-policy.ts +58 -0
- package/scaffold/pm-api/src/routes/v2-retro.ts +221 -0
- package/scaffold/pm-api/src/routes/v2-rewards.ts +132 -0
- package/scaffold/pm-api/src/routes/v2-scenarios.ts +48 -0
- package/scaffold/pm-api/src/routes/v2-search.ts +32 -0
- package/scaffold/pm-api/src/routes/v2-standup.ts +127 -0
- package/scaffold/pm-api/src/routes/v2-user.ts +38 -0
- package/scaffold/pm-api/src/types.ts +11 -0
- package/scaffold/pm-api/src/utils/activity.ts +22 -0
- package/scaffold/pm-api/src/utils/admin.ts +9 -0
- package/scaffold/pm-api/src/utils/agent-notify.ts +62 -0
- package/scaffold/pm-api/src/utils/assignee.ts +69 -0
- package/scaffold/pm-api/src/utils/db.ts +45 -0
- package/scaffold/pm-api/src/utils/initiative.ts +23 -0
- package/scaffold/pm-api/src/utils/retro-link.ts +32 -0
- package/scaffold/pm-api/src/utils/sprint-lifecycle.ts +96 -0
- package/scaffold/pm-api/tsconfig.json +15 -0
- package/scaffold/pm-api/wrangler.toml.hbs +11 -0
- package/scaffold/spec-site/package-lock.json +892 -0
- package/scaffold/spec-site/package.json +15 -1
- package/scaffold/spec-site/src/api/types.ts +6 -0
- package/scaffold/spec-site/src/components/AppHeader.vue +429 -55
- package/scaffold/spec-site/src/components/AuthGate.vue +117 -0
- package/scaffold/spec-site/src/components/BurndownChart.vue +78 -0
- package/scaffold/spec-site/src/components/DocComments.vue +137 -0
- package/scaffold/spec-site/src/components/DocEditor.vue +118 -0
- package/scaffold/spec-site/src/components/DocExportBar.vue +110 -0
- package/scaffold/spec-site/src/components/DocsSidebar.vue +309 -0
- package/scaffold/spec-site/src/components/EmptyState.vue +30 -0
- package/scaffold/spec-site/src/components/ErrorBanner.vue +38 -0
- package/scaffold/spec-site/src/components/Icon.vue +58 -0
- package/scaffold/spec-site/src/components/MemberSelect.vue +48 -0
- package/scaffold/spec-site/src/components/MemoChecklist.vue +88 -0
- package/scaffold/spec-site/src/components/MemoGraph.vue +75 -0
- package/scaffold/spec-site/src/components/MemoItem.vue +353 -0
- package/scaffold/spec-site/src/components/MemoRelations.vue +101 -0
- package/scaffold/spec-site/src/components/MemoTimeline.vue +53 -0
- package/scaffold/spec-site/src/components/MentionInput.vue +174 -0
- package/scaffold/spec-site/src/components/NotificationDropdown.vue +116 -0
- package/scaffold/spec-site/src/components/PriorityBadge.vue +23 -0
- package/scaffold/spec-site/src/components/SearchModal.vue +102 -0
- package/scaffold/spec-site/src/components/SlashCommand.ts +123 -0
- package/scaffold/spec-site/src/components/StateDisplay.vue +54 -0
- package/scaffold/spec-site/src/components/TreeNode.vue +82 -0
- package/scaffold/spec-site/src/components/UserAvatar.vue +24 -0
- package/scaffold/spec-site/src/components/VelocityChart.vue +77 -0
- package/scaffold/spec-site/src/composables/navTypes.ts +3 -0
- package/scaffold/spec-site/src/composables/pmTypes.ts +15 -2
- package/scaffold/spec-site/src/composables/useBottomSheet.ts +103 -0
- package/scaffold/spec-site/src/composables/useDashboard.ts +221 -0
- package/scaffold/spec-site/src/composables/useMediaQuery.ts +28 -0
- package/scaffold/spec-site/src/composables/useMemo.ts +39 -0
- package/scaffold/spec-site/src/composables/useNotification.ts +200 -0
- package/scaffold/spec-site/src/composables/usePmStore.ts +48 -1
- package/scaffold/spec-site/src/composables/useRetro.ts +6 -0
- package/scaffold/spec-site/src/composables/useStandup.ts +201 -0
- package/scaffold/spec-site/src/composables/useTheme.ts +37 -0
- package/scaffold/spec-site/src/composables/useTurso.ts +17 -0
- package/scaffold/spec-site/src/composables/useUser.ts +19 -1
- package/scaffold/spec-site/src/composables/useViewport.ts +26 -0
- package/scaffold/spec-site/src/features.ts +108 -0
- package/scaffold/spec-site/src/mockup/ComponentPalette.vue +61 -0
- package/scaffold/spec-site/src/mockup/MockupCanvas.vue +459 -0
- package/scaffold/spec-site/src/mockup/PropertyPanel.vue +217 -0
- package/scaffold/spec-site/src/mockup/componentCatalog.ts +68 -0
- package/scaffold/spec-site/src/mockup/useScenarios.ts +67 -0
- package/scaffold/spec-site/src/pages/AdminPage.vue +299 -0
- package/scaffold/spec-site/src/pages/DashboardPage.vue +650 -0
- package/scaffold/spec-site/src/pages/DocsEditor.vue +119 -0
- package/scaffold/spec-site/src/pages/DocsHub.vue +157 -0
- package/scaffold/spec-site/src/pages/DocsPage.vue +444 -0
- package/scaffold/spec-site/src/pages/InboxPage.vue +156 -0
- package/scaffold/spec-site/src/pages/MeetingsPage.vue +294 -0
- package/scaffold/spec-site/src/pages/MemosPage.vue +857 -0
- package/scaffold/spec-site/src/pages/MockupEditorPage.vue +611 -0
- package/scaffold/spec-site/src/pages/MockupListPage.vue +121 -0
- package/scaffold/spec-site/src/pages/MockupViewerPage.vue +199 -0
- package/scaffold/spec-site/src/pages/MyPage.vue +343 -0
- package/scaffold/spec-site/src/pages/NotificationSettingsPage.vue +59 -0
- package/scaffold/spec-site/src/pages/RewardsPage.vue +266 -0
- package/scaffold/spec-site/src/pages/SprintAdmin.vue +521 -0
- package/scaffold/spec-site/src/pages/SprintTimeline.vue +159 -0
- package/scaffold/spec-site/src/pages/board/BoardAdmin.vue +422 -0
- package/scaffold/spec-site/src/pages/board/BoardEpicSection.vue +54 -0
- package/scaffold/spec-site/src/pages/board/BoardPage.vue +884 -0
- package/scaffold/spec-site/src/pages/board/BoardStoryCard.vue +67 -0
- package/scaffold/spec-site/src/pages/board/BoardTaskItem.vue +52 -0
- package/scaffold/spec-site/src/pages/board/KanbanBoard.vue +93 -0
- package/scaffold/spec-site/src/pages/board/MyTasksPage.vue +202 -0
- package/scaffold/spec-site/src/pages/board/SprintClose.vue +167 -0
- package/scaffold/spec-site/src/pages/board/SprintColumn.vue +49 -0
- package/scaffold/spec-site/src/pages/board/SprintKickoff.vue +389 -0
- package/scaffold/spec-site/src/pages/board/StatusBadge.vue +52 -0
- package/scaffold/spec-site/src/pages/board/StoryDetailPanel.vue +495 -0
- package/scaffold/spec-site/src/pages/board/TaskCard.vue +42 -0
- package/scaffold/spec-site/src/pages/retro/RetroCard.vue +36 -2
- package/scaffold/spec-site/src/pages/retro/RetroHeader.vue +82 -66
- package/scaffold/spec-site/src/pages/retro/RetroPage.vue +47 -18
- package/scaffold/spec-site/src/pages/standup/StandupEntryCard.vue +551 -0
- package/scaffold/spec-site/src/pages/standup/StandupForm.vue +68 -0
- package/scaffold/spec-site/src/pages/standup/StandupList.vue +71 -0
- package/scaffold/spec-site/src/pages/standup/StandupPage.vue +225 -0
- package/scaffold/spec-site/src/router.ts +141 -0
- package/scaffold/spec-site/src/styles/buttons.css +124 -0
- package/scaffold/spec-site/src/utils/parseMentions.ts +56 -0
- package/scaffold/spec-site/src/utils/timezone.ts +18 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { Hono } from 'hono'
|
|
2
|
+
import type { AppEnv } from '../types.js'
|
|
3
|
+
import { queryOrThrow, executeOrThrow } from '../utils/db.js'
|
|
4
|
+
import { isAdmin } from '../utils/admin.js'
|
|
5
|
+
|
|
6
|
+
const app = new Hono<AppEnv>()
|
|
7
|
+
|
|
8
|
+
// Admin-only middleware for write APIs
|
|
9
|
+
async function requireAdmin(c: any, next: () => Promise<void>) {
|
|
10
|
+
const userName = c.get('userName')
|
|
11
|
+
if (!await isAdmin(userName)) return c.json({ error: 'Admin privileges required' }, 403)
|
|
12
|
+
await next()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// GET / — full history (filterable by member)
|
|
16
|
+
app.get('/', async (c) => {
|
|
17
|
+
const member = c.req.query('member')
|
|
18
|
+
const status = c.req.query('status')
|
|
19
|
+
|
|
20
|
+
let sql = 'SELECT * FROM rewards'
|
|
21
|
+
const conditions: string[] = []
|
|
22
|
+
const args: (string | number)[] = []
|
|
23
|
+
|
|
24
|
+
if (member) { conditions.push('member_name = ?'); args.push(member) }
|
|
25
|
+
if (status) { conditions.push('status = ?'); args.push(status) }
|
|
26
|
+
|
|
27
|
+
if (conditions.length) sql += ' WHERE ' + conditions.join(' AND ')
|
|
28
|
+
sql += ' ORDER BY created_at DESC'
|
|
29
|
+
|
|
30
|
+
const { rows } = await queryOrThrow(sql, args)
|
|
31
|
+
return c.json({ rewards: rows })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// GET /summary — balance summary by member
|
|
35
|
+
app.get('/summary', async (c) => {
|
|
36
|
+
const { rows } = await queryOrThrow(
|
|
37
|
+
`SELECT member_name,
|
|
38
|
+
SUM(CASE WHEN type = 'reward' THEN amount ELSE 0 END) as total_rewards,
|
|
39
|
+
SUM(CASE WHEN type = 'penalty' THEN amount ELSE 0 END) as total_penalties,
|
|
40
|
+
SUM(CASE WHEN type = 'reward' THEN amount ELSE -amount END) as balance,
|
|
41
|
+
SUM(CASE WHEN status = 'pending' AND type = 'reward' THEN amount WHEN status = 'pending' AND type = 'penalty' THEN -amount ELSE 0 END) as pending_balance
|
|
42
|
+
FROM rewards GROUP BY member_name ORDER BY member_name`,
|
|
43
|
+
)
|
|
44
|
+
return c.json({ summary: rows })
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// POST / — register reward/penalty
|
|
48
|
+
app.post('/', requireAdmin, async (c) => {
|
|
49
|
+
const body = await c.req.json<{
|
|
50
|
+
memberName: string
|
|
51
|
+
type: 'reward' | 'penalty'
|
|
52
|
+
amount: number
|
|
53
|
+
reason: string
|
|
54
|
+
issuedBy?: string
|
|
55
|
+
}>()
|
|
56
|
+
|
|
57
|
+
const issuedBy = body.issuedBy || c.get('userName') || 'system'
|
|
58
|
+
|
|
59
|
+
await executeOrThrow(
|
|
60
|
+
`INSERT INTO rewards (member_name, type, amount, reason, status, issued_by)
|
|
61
|
+
VALUES (?, ?, ?, ?, 'pending', ?)`,
|
|
62
|
+
[body.memberName, body.type, body.amount, body.reason, issuedBy],
|
|
63
|
+
)
|
|
64
|
+
return c.json({ ok: true }, 201)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
// PATCH /:id/pay — pending -> paid transition
|
|
68
|
+
app.patch('/:id/pay', requireAdmin, async (c) => {
|
|
69
|
+
const id = Number(c.req.param('id'))
|
|
70
|
+
const body = await c.req.json<{ txHash?: string }>()
|
|
71
|
+
|
|
72
|
+
await executeOrThrow(
|
|
73
|
+
`UPDATE rewards SET status = 'paid', tx_hash = ?, paid_at = CURRENT_TIMESTAMP WHERE id = ? AND status = 'pending'`,
|
|
74
|
+
[body.txHash ?? null, id],
|
|
75
|
+
)
|
|
76
|
+
return c.json({ ok: true })
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// PATCH /batch-pay — batch pay all pending for a member
|
|
80
|
+
app.patch('/batch-pay', requireAdmin, async (c) => {
|
|
81
|
+
const body = await c.req.json<{ memberName: string; txHash?: string }>()
|
|
82
|
+
|
|
83
|
+
const { rowsAffected } = await executeOrThrow(
|
|
84
|
+
`UPDATE rewards SET status = 'paid', tx_hash = ?, paid_at = CURRENT_TIMESTAMP WHERE member_name = ? AND status = 'pending'`,
|
|
85
|
+
[body.txHash ?? null, body.memberName],
|
|
86
|
+
)
|
|
87
|
+
return c.json({ ok: true, paidCount: rowsAffected })
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// DELETE /:id — delete (admin)
|
|
91
|
+
app.delete('/:id', requireAdmin, async (c) => {
|
|
92
|
+
const id = Number(c.req.param('id'))
|
|
93
|
+
await executeOrThrow('DELETE FROM rewards WHERE id = ?', [id])
|
|
94
|
+
return c.json({ ok: true })
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// GET /wallets — bulk wallet balance query via blockchain adapter
|
|
98
|
+
app.get('/wallets', async (c) => {
|
|
99
|
+
const { rows: members } = await queryOrThrow<{ display_name: string; wallet_address: string | null }>(
|
|
100
|
+
"SELECT display_name, wallet_address FROM members WHERE is_active = 1 AND wallet_address IS NOT NULL AND wallet_address != ''",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
const { getBlockchainAdapter } = await import('../blockchain/adapter.js')
|
|
104
|
+
const blockchain = getBlockchainAdapter()
|
|
105
|
+
|
|
106
|
+
const wallets = await Promise.all(members.map(async (m) => {
|
|
107
|
+
try {
|
|
108
|
+
const balance = await blockchain.getBalance(m.wallet_address!)
|
|
109
|
+
return { name: m.display_name, address: m.wallet_address, ...balance }
|
|
110
|
+
} catch {
|
|
111
|
+
return { name: m.display_name, address: m.wallet_address, native: 0, token: 0 }
|
|
112
|
+
}
|
|
113
|
+
}))
|
|
114
|
+
|
|
115
|
+
return c.json({ wallets })
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
// GET /onchain/:address — on-chain balance query via blockchain adapter
|
|
119
|
+
app.get('/onchain/:address', async (c) => {
|
|
120
|
+
const address = c.req.param('address')
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const { getBlockchainAdapter } = await import('../blockchain/adapter.js')
|
|
124
|
+
const blockchain = getBlockchainAdapter()
|
|
125
|
+
const balance = await blockchain.getBalance(address)
|
|
126
|
+
return c.json({ address, ...balance })
|
|
127
|
+
} catch (e) {
|
|
128
|
+
return c.json({ address, native: 0, token: 0, error: 'Blockchain query failed' })
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
export default app
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Hono } from 'hono'
|
|
2
|
+
import type { AppEnv } from '../types.js'
|
|
3
|
+
import { query, execute } from '../db/adapter.js'
|
|
4
|
+
import { queryOrThrow, executeOrThrow } from '../utils/db.js'
|
|
5
|
+
|
|
6
|
+
const app = new Hono<AppEnv>()
|
|
7
|
+
|
|
8
|
+
// GET /:pageId/:sprint
|
|
9
|
+
app.get('/:pageId/:sprint', async (c) => {
|
|
10
|
+
const pageId = c.req.param('pageId')
|
|
11
|
+
const sprint = c.req.param('sprint')
|
|
12
|
+
const { rows } = await queryOrThrow(
|
|
13
|
+
'SELECT * FROM scenario_data WHERE page_id = ? AND sprint = ?',
|
|
14
|
+
[pageId, sprint],
|
|
15
|
+
)
|
|
16
|
+
return c.json({ rows: rows })
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
// PUT /:pageId/:sprint/:scenarioId — UPSERT
|
|
20
|
+
app.put('/:pageId/:sprint/:scenarioId', async (c) => {
|
|
21
|
+
const pageId = c.req.param('pageId')
|
|
22
|
+
const sprint = c.req.param('sprint')
|
|
23
|
+
const scenarioId = c.req.param('scenarioId')
|
|
24
|
+
const body = await c.req.json<{ label: string; dataJson: string; author: string }>()
|
|
25
|
+
|
|
26
|
+
const { rowsAffected } = await executeOrThrow(
|
|
27
|
+
`INSERT INTO scenario_data (page_id, sprint, scenario_id, label, data_json, author)
|
|
28
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
29
|
+
ON CONFLICT (page_id, sprint, scenario_id)
|
|
30
|
+
DO UPDATE SET label = excluded.label, data_json = excluded.data_json, author = excluded.author, updated_at = CURRENT_TIMESTAMP`,
|
|
31
|
+
[pageId, sprint, scenarioId, body.label, body.dataJson, body.author],
|
|
32
|
+
)
|
|
33
|
+
return c.json({ ok: true })
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// DELETE /:pageId/:sprint/:scenarioId
|
|
37
|
+
app.delete('/:pageId/:sprint/:scenarioId', async (c) => {
|
|
38
|
+
const pageId = c.req.param('pageId')
|
|
39
|
+
const sprint = c.req.param('sprint')
|
|
40
|
+
const scenarioId = c.req.param('scenarioId')
|
|
41
|
+
const { rowsAffected } = await executeOrThrow(
|
|
42
|
+
'DELETE FROM scenario_data WHERE page_id = ? AND sprint = ? AND scenario_id = ?',
|
|
43
|
+
[pageId, sprint, scenarioId],
|
|
44
|
+
)
|
|
45
|
+
return c.json({ ok: true })
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
export default app
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Hono } from 'hono'
|
|
2
|
+
import type { AppEnv } from '../types.js'
|
|
3
|
+
import { query } from '../db/adapter.js'
|
|
4
|
+
|
|
5
|
+
const app = new Hono<AppEnv>()
|
|
6
|
+
|
|
7
|
+
// GET / — unified search
|
|
8
|
+
app.get('/', async (c) => {
|
|
9
|
+
const q = c.req.query('q')
|
|
10
|
+
if (!q || q.length < 2) return c.json({ results: [] })
|
|
11
|
+
|
|
12
|
+
const keyword = `%${q}%`
|
|
13
|
+
const perType = 5
|
|
14
|
+
|
|
15
|
+
const [stories, memos, docs, meetings] = await Promise.all([
|
|
16
|
+
query('SELECT id, title, SUBSTR(description, 1, 50) as preview, created_at FROM pm_stories WHERE title LIKE ? OR description LIKE ? ORDER BY created_at DESC LIMIT ?', [keyword, keyword, perType]),
|
|
17
|
+
query('SELECT id, title, SUBSTR(content, 1, 50) as preview, created_at FROM memos_v2 WHERE content LIKE ? OR title LIKE ? ORDER BY created_at DESC LIMIT ?', [keyword, keyword, perType]),
|
|
18
|
+
query('SELECT id, title, SUBSTR(content, 1, 50) as preview, updated_at as created_at FROM docs WHERE title LIKE ? OR content LIKE ? ORDER BY updated_at DESC LIMIT ?', [keyword, keyword, perType]),
|
|
19
|
+
query('SELECT id, title, SUBSTR(summary, 1, 50) as preview, date as created_at FROM meetings WHERE title LIKE ? OR summary LIKE ? ORDER BY date DESC LIMIT ?', [keyword, keyword, perType]),
|
|
20
|
+
])
|
|
21
|
+
|
|
22
|
+
const results = [
|
|
23
|
+
...(stories.rows ?? []).map((r: any) => ({ type: 'story', ...r, url: `/board?story=${r.id}` })),
|
|
24
|
+
...(memos.rows ?? []).map((r: any) => ({ type: 'memo', ...r, url: `/memos/${r.id}` })),
|
|
25
|
+
...(docs.rows ?? []).map((r: any) => ({ type: 'doc', ...r, url: `/docs/${r.id}` })),
|
|
26
|
+
...(meetings.rows ?? []).map((r: any) => ({ type: 'meeting', ...r, url: `/meetings` })),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
return c.json({ results })
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
export default app
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { Hono } from 'hono'
|
|
2
|
+
import type { AppEnv } from '../types.js'
|
|
3
|
+
import { query, execute } from '../db/adapter.js'
|
|
4
|
+
import { queryOrThrow, executeOrThrow } from '../utils/db.js'
|
|
5
|
+
|
|
6
|
+
const app = new Hono<AppEnv>()
|
|
7
|
+
|
|
8
|
+
// GET /entries — sprint optional, date-based primary view
|
|
9
|
+
app.get('/entries', async (c) => {
|
|
10
|
+
const sprint = c.req.query('sprint')
|
|
11
|
+
const date = c.req.query('date')
|
|
12
|
+
|
|
13
|
+
if (date && sprint) {
|
|
14
|
+
const { rows } = await queryOrThrow(
|
|
15
|
+
'SELECT * FROM pm_standup_entries WHERE sprint = ? AND entry_date = ? ORDER BY user_name',
|
|
16
|
+
[sprint, date],
|
|
17
|
+
)
|
|
18
|
+
return c.json({ entries: rows })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (date) {
|
|
22
|
+
const { rows } = await queryOrThrow(
|
|
23
|
+
'SELECT * FROM pm_standup_entries WHERE entry_date = ? ORDER BY user_name',
|
|
24
|
+
[date],
|
|
25
|
+
)
|
|
26
|
+
return c.json({ entries: rows })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (sprint) {
|
|
30
|
+
const { rows } = await queryOrThrow(
|
|
31
|
+
'SELECT * FROM pm_standup_entries WHERE sprint = ? ORDER BY entry_date DESC, user_name LIMIT 50',
|
|
32
|
+
[sprint],
|
|
33
|
+
)
|
|
34
|
+
return c.json({ entries: rows })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return c.json({ error: 'sprint or date query param required' }, 400)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
// PUT /entries — UPSERT
|
|
41
|
+
app.put('/entries', async (c) => {
|
|
42
|
+
const body = await c.req.json<{
|
|
43
|
+
sprint: string; userName: string; date: string
|
|
44
|
+
doneText?: string; planText?: string; planStoryIds?: number[] | string | null; blockers?: string
|
|
45
|
+
}>()
|
|
46
|
+
|
|
47
|
+
// planStoryIds: number[] -> JSON string for storage
|
|
48
|
+
const storyIdsJson = Array.isArray(body.planStoryIds) && body.planStoryIds.length
|
|
49
|
+
? JSON.stringify(body.planStoryIds)
|
|
50
|
+
: (typeof body.planStoryIds === 'string' ? body.planStoryIds : null)
|
|
51
|
+
|
|
52
|
+
const { rowsAffected } = await executeOrThrow(
|
|
53
|
+
`INSERT INTO pm_standup_entries (sprint, user_name, entry_date, done_text, plan_text, plan_story_ids, blockers)
|
|
54
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
55
|
+
ON CONFLICT (sprint, user_name, entry_date)
|
|
56
|
+
DO UPDATE SET done_text = excluded.done_text, plan_text = excluded.plan_text,
|
|
57
|
+
plan_story_ids = excluded.plan_story_ids, blockers = excluded.blockers,
|
|
58
|
+
updated_at = CURRENT_TIMESTAMP`,
|
|
59
|
+
[body.sprint, body.userName, body.date, body.doneText ?? null, body.planText ?? null, storyIdsJson, body.blockers ?? null],
|
|
60
|
+
)
|
|
61
|
+
return c.json({ ok: true })
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// ── Standup Feedback (1:N) ──
|
|
65
|
+
|
|
66
|
+
// GET /feedback?standup_entry_id= or ?sprint=&user=
|
|
67
|
+
app.get('/feedback', async (c) => {
|
|
68
|
+
const entryId = c.req.query('standup_entry_id')
|
|
69
|
+
const sprint = c.req.query('sprint')
|
|
70
|
+
const user = c.req.query('user')
|
|
71
|
+
|
|
72
|
+
if (entryId) {
|
|
73
|
+
const { rows } = await queryOrThrow(
|
|
74
|
+
'SELECT * FROM pm_standup_feedback WHERE standup_entry_id = ? ORDER BY created_at ASC',
|
|
75
|
+
[Number(entryId)],
|
|
76
|
+
)
|
|
77
|
+
return c.json({ feedback: rows })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (sprint && user) {
|
|
81
|
+
const { rows } = await queryOrThrow(
|
|
82
|
+
'SELECT f.* FROM pm_standup_feedback f WHERE f.sprint = ? AND f.target_user = ? ORDER BY f.created_at DESC LIMIT 50',
|
|
83
|
+
[sprint, user],
|
|
84
|
+
)
|
|
85
|
+
return c.json({ feedback: rows })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return c.json({ error: 'standup_entry_id or (sprint + user) query params required' }, 400)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// POST /feedback — add feedback to a standup entry
|
|
92
|
+
app.post('/feedback', async (c) => {
|
|
93
|
+
const body = await c.req.json<{
|
|
94
|
+
standupEntryId: number
|
|
95
|
+
sprint: string
|
|
96
|
+
targetUser: string
|
|
97
|
+
feedbackBy: string
|
|
98
|
+
feedbackText: string
|
|
99
|
+
reviewType?: string
|
|
100
|
+
}>()
|
|
101
|
+
|
|
102
|
+
if (!body.standupEntryId || !body.sprint || !body.targetUser || !body.feedbackBy || !body.feedbackText) {
|
|
103
|
+
return c.json({ error: 'standupEntryId, sprint, targetUser, feedbackBy, feedbackText required' }, 400)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const reviewType = body.reviewType ?? 'comment'
|
|
107
|
+
if (!['comment', 'approve', 'request_changes'].includes(reviewType)) {
|
|
108
|
+
return c.json({ error: 'reviewType must be comment, approve, or request_changes' }, 400)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Verify the standup entry exists
|
|
112
|
+
const entryCheck = await query(
|
|
113
|
+
'SELECT id FROM pm_standup_entries WHERE id = ?',
|
|
114
|
+
[body.standupEntryId],
|
|
115
|
+
)
|
|
116
|
+
if (entryCheck.error) return c.json({ error: entryCheck.error }, 500)
|
|
117
|
+
if (entryCheck.rows.length === 0) return c.json({ error: 'Standup entry not found' }, 404)
|
|
118
|
+
|
|
119
|
+
const { rowsAffected } = await executeOrThrow(
|
|
120
|
+
`INSERT INTO pm_standup_feedback (standup_entry_id, sprint, target_user, feedback_by, feedback_text, review_type)
|
|
121
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
122
|
+
[body.standupEntryId, body.sprint, body.targetUser, body.feedbackBy, body.feedbackText, reviewType],
|
|
123
|
+
)
|
|
124
|
+
return c.json({ ok: true })
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
export default app
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Hono } from 'hono'
|
|
2
|
+
import type { AppEnv } from '../types.js'
|
|
3
|
+
import { query, execute } from '../db/adapter.js'
|
|
4
|
+
import { queryOrThrow, executeOrThrow } from '../utils/db.js'
|
|
5
|
+
|
|
6
|
+
const app = new Hono<AppEnv>()
|
|
7
|
+
|
|
8
|
+
// GET /members — members table based (no duplicates)
|
|
9
|
+
app.get('/members', async (c) => {
|
|
10
|
+
const { rows } = await queryOrThrow(
|
|
11
|
+
'SELECT display_name FROM members WHERE is_active = 1 ORDER BY display_name',
|
|
12
|
+
)
|
|
13
|
+
return c.json({ members: rows.map((r: Record<string, unknown>) => String(r.display_name)) })
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
// POST /activity
|
|
17
|
+
app.post('/activity', async (c) => {
|
|
18
|
+
const body = await c.req.json<{ userName: string }>()
|
|
19
|
+
const { rowsAffected } = await executeOrThrow(
|
|
20
|
+
`INSERT INTO user_activity (user_name, last_seen_at) VALUES (?, CURRENT_TIMESTAMP)
|
|
21
|
+
ON CONFLICT (user_name) DO UPDATE SET last_seen_at = CURRENT_TIMESTAMP`,
|
|
22
|
+
[body.userName],
|
|
23
|
+
)
|
|
24
|
+
return c.json({ ok: true })
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
// POST /memo-seen
|
|
28
|
+
app.post('/memo-seen', async (c) => {
|
|
29
|
+
const body = await c.req.json<{ userName: string }>()
|
|
30
|
+
const { rowsAffected } = await executeOrThrow(
|
|
31
|
+
`INSERT INTO user_activity (user_name, last_memo_seen) VALUES (?, CURRENT_TIMESTAMP)
|
|
32
|
+
ON CONFLICT (user_name) DO UPDATE SET last_memo_seen = CURRENT_TIMESTAMP`,
|
|
33
|
+
[body.userName],
|
|
34
|
+
)
|
|
35
|
+
return c.json({ ok: true })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
export default app
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { execute } from '../db/adapter.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Log an activity entry (fire-and-forget)
|
|
5
|
+
*/
|
|
6
|
+
export async function logActivity(
|
|
7
|
+
actor: string,
|
|
8
|
+
actionType: string,
|
|
9
|
+
targetType: string,
|
|
10
|
+
targetId: string | number,
|
|
11
|
+
targetTitle: string,
|
|
12
|
+
metadata?: Record<string, unknown>,
|
|
13
|
+
): Promise<void> {
|
|
14
|
+
try {
|
|
15
|
+
await execute(
|
|
16
|
+
'INSERT INTO activity_log (actor, action_type, target_type, target_id, target_title, metadata) VALUES (?, ?, ?, ?, ?, ?)',
|
|
17
|
+
[actor, actionType, targetType, String(targetId), targetTitle, metadata ? JSON.stringify(metadata) : null],
|
|
18
|
+
)
|
|
19
|
+
} catch {
|
|
20
|
+
// fire-and-forget
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { query } from '../db/adapter.js'
|
|
2
|
+
|
|
3
|
+
export async function isAdmin(userName: string): Promise<boolean> {
|
|
4
|
+
const result = await query<{ role: string }>(
|
|
5
|
+
"SELECT role FROM members WHERE display_name = ? AND is_active = 1",
|
|
6
|
+
[userName],
|
|
7
|
+
)
|
|
8
|
+
return result.rows[0]?.role === 'admin'
|
|
9
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent/member notification — DB-based webhook_url
|
|
3
|
+
*
|
|
4
|
+
* Sends notifications to the webhook_url registered in members.webhook_url.
|
|
5
|
+
* Members without a webhook_url will not receive notifications.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { query } from '../db/adapter.js'
|
|
9
|
+
|
|
10
|
+
/** Look up webhook_url by display name (DB) */
|
|
11
|
+
export async function resolveWebhookUrl(assignee: string): Promise<string | null> {
|
|
12
|
+
const result = await query<{ webhook_url: string | null }>(
|
|
13
|
+
'SELECT webhook_url FROM members WHERE display_name = ? AND is_active = 1',
|
|
14
|
+
[assignee],
|
|
15
|
+
)
|
|
16
|
+
return result.rows[0]?.webhook_url ?? null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Determine webhook format by URL pattern */
|
|
20
|
+
function getWebhookFormat(url: string): 'discord' | 'google' | 'slack' | 'generic' {
|
|
21
|
+
if (url.includes('/api/webhooks') && (url.includes('discord.com') || url.includes('discordapp.com'))) return 'discord'
|
|
22
|
+
if (url.includes('chat.googleapis.com')) return 'google'
|
|
23
|
+
if (url.includes('hooks.slack.com')) return 'slack'
|
|
24
|
+
return 'generic'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Send a webhook notification to a specific member (Discord/Google Chat/Slack compatible) */
|
|
28
|
+
export async function notifyMember(webhookUrl: string, title: string, description: string, color = 0x3B82F6): Promise<void> {
|
|
29
|
+
try {
|
|
30
|
+
const format = getWebhookFormat(webhookUrl)
|
|
31
|
+
let body: string
|
|
32
|
+
|
|
33
|
+
if (format === 'discord') {
|
|
34
|
+
body = JSON.stringify({ embeds: [{ title, description, color }] })
|
|
35
|
+
} else if (format === 'google') {
|
|
36
|
+
body = JSON.stringify({ text: `*${title}*\n${description}` })
|
|
37
|
+
} else if (format === 'slack') {
|
|
38
|
+
body = JSON.stringify({ text: `*${title}*\n${description}` })
|
|
39
|
+
} else {
|
|
40
|
+
body = JSON.stringify({ title, description })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
await fetch(webhookUrl, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { 'Content-Type': 'application/json' },
|
|
46
|
+
body,
|
|
47
|
+
})
|
|
48
|
+
} catch (_) { /* Failure should not block main logic */ }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Send notification by name (name -> DB webhook_url -> send) */
|
|
52
|
+
export async function notifyByName(assignee: string, title: string, description: string): Promise<boolean> {
|
|
53
|
+
const url = await resolveWebhookUrl(assignee)
|
|
54
|
+
if (!url) return false
|
|
55
|
+
await notifyMember(url, title, description)
|
|
56
|
+
return true
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Send notification to multiple recipients */
|
|
60
|
+
export async function notifyRecipients(assignees: string[], title: string, description: string): Promise<void> {
|
|
61
|
+
await Promise.all(assignees.map(a => notifyByName(a, title, description)))
|
|
62
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { query } from '../db/adapter.js'
|
|
2
|
+
|
|
3
|
+
/** Simple similarity score: shared characters ratio + substring containment */
|
|
4
|
+
export function similarity(a: string, b: string): number {
|
|
5
|
+
const al = a.toLowerCase()
|
|
6
|
+
const bl = b.toLowerCase()
|
|
7
|
+
if (al === bl) return 1
|
|
8
|
+
if (al.includes(bl) || bl.includes(al)) return 0.8
|
|
9
|
+
const setA = new Set(al)
|
|
10
|
+
const setB = new Set(bl)
|
|
11
|
+
let shared = 0
|
|
12
|
+
for (const c of setA) if (setB.has(c)) shared++
|
|
13
|
+
return shared / Math.max(setA.size, setB.size)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Find similar names from a list (threshold: 0.4, max 3) */
|
|
17
|
+
export function findSimilar(name: string, allNames: string[], threshold = 0.4): string[] {
|
|
18
|
+
return allNames
|
|
19
|
+
.map(n => ({ name: n, score: similarity(name, n) }))
|
|
20
|
+
.filter(x => x.score >= threshold)
|
|
21
|
+
.sort((a, b) => b.score - a.score)
|
|
22
|
+
.slice(0, 3)
|
|
23
|
+
.map(x => x.name)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Resolve display_name to member id. Returns null if not found. */
|
|
27
|
+
export async function resolveMemberId(displayName: string): Promise<number | null> {
|
|
28
|
+
const result = await query<{ id: number }>(
|
|
29
|
+
'SELECT id FROM members WHERE display_name = ? AND is_active = 1',
|
|
30
|
+
[displayName],
|
|
31
|
+
)
|
|
32
|
+
return result.rows[0]?.id ?? null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Resolve comma-separated assignee names to comma-separated member ids (same order). Returns null for unresolved names. */
|
|
36
|
+
export async function resolveMemberIds(assignee: string | null | undefined): Promise<(number | null)[]> {
|
|
37
|
+
if (!assignee) return []
|
|
38
|
+
const names = assignee.split(',').map(s => s.trim()).filter(Boolean)
|
|
39
|
+
const ids: (number | null)[] = []
|
|
40
|
+
for (const name of names) {
|
|
41
|
+
ids.push(await resolveMemberId(name))
|
|
42
|
+
}
|
|
43
|
+
return ids
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Validate assignee(s) against members table. Supports comma-separated names. Suggests similar names on mismatch. */
|
|
47
|
+
export async function validateAssignee(assignee: string | null | undefined): Promise<string | null> {
|
|
48
|
+
if (!assignee) return null
|
|
49
|
+
const names = assignee.split(',').map(s => s.trim()).filter(Boolean)
|
|
50
|
+
if (names.length === 0) return null
|
|
51
|
+
|
|
52
|
+
const allMembers = await query<{ display_name: string }>(
|
|
53
|
+
'SELECT display_name FROM members WHERE is_active = 1',
|
|
54
|
+
)
|
|
55
|
+
const allNames = allMembers.rows.map(r => r.display_name)
|
|
56
|
+
const validSet = new Set(allNames)
|
|
57
|
+
const invalid = names.filter(n => !validSet.has(n))
|
|
58
|
+
|
|
59
|
+
if (invalid.length > 0) {
|
|
60
|
+
const suggestions = invalid.map(name => {
|
|
61
|
+
const similar = findSimilar(name, allNames)
|
|
62
|
+
return similar.length
|
|
63
|
+
? `'${name}' — Did you mean: ${similar.join(', ')}?`
|
|
64
|
+
: `'${name}'`
|
|
65
|
+
})
|
|
66
|
+
return `Invalid assignee: ${suggestions.join(' | ')}. Use list_team_members to verify`
|
|
67
|
+
}
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { query, execute, type ArgValue } from '../db/adapter.js'
|
|
2
|
+
|
|
3
|
+
export class DbError extends Error {
|
|
4
|
+
constructor(message: string) {
|
|
5
|
+
super(message)
|
|
6
|
+
this.name = 'DbError'
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function queryOrThrow<T = Record<string, unknown>>(
|
|
11
|
+
sql: string,
|
|
12
|
+
args: ArgValue[] = [],
|
|
13
|
+
): Promise<{ rows: T[] }> {
|
|
14
|
+
const result = await query<T>(sql, args)
|
|
15
|
+
if (result.error) throw new DbError(result.error)
|
|
16
|
+
return { rows: result.rows ?? [] }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function executeOrThrow(
|
|
20
|
+
sql: string,
|
|
21
|
+
args: ArgValue[] = [],
|
|
22
|
+
): Promise<{ rowsAffected: number }> {
|
|
23
|
+
const result = await execute(sql, args)
|
|
24
|
+
if (result.error) throw new DbError(result.error)
|
|
25
|
+
return { rowsAffected: result.rowsAffected }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function buildUpdateQuery(
|
|
29
|
+
fieldMap: Record<string, string>,
|
|
30
|
+
body: Record<string, unknown>,
|
|
31
|
+
): { sets: string[]; args: (string | number | null)[] } | null {
|
|
32
|
+
const sets: string[] = []
|
|
33
|
+
const args: (string | number | null)[] = []
|
|
34
|
+
|
|
35
|
+
for (const [key, col] of Object.entries(fieldMap)) {
|
|
36
|
+
if (body[key] !== undefined) {
|
|
37
|
+
sets.push(`${col} = ?`)
|
|
38
|
+
args.push(body[key] as string | number | null)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (sets.length === 0) return null
|
|
43
|
+
sets.push('updated_at = CURRENT_TIMESTAMP')
|
|
44
|
+
return { sets, args }
|
|
45
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Initiative — pure domain logic
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type InitiativeStatus = 'pending' | 'approved' | 'rejected' | 'deferred'
|
|
6
|
+
|
|
7
|
+
export const VALID_TRANSITIONS: Record<InitiativeStatus, InitiativeStatus[]> = {
|
|
8
|
+
pending: ['approved', 'rejected', 'deferred'],
|
|
9
|
+
approved: [],
|
|
10
|
+
rejected: ['pending'],
|
|
11
|
+
deferred: ['pending'],
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function canTransition(from: InitiativeStatus, to: InitiativeStatus): boolean {
|
|
15
|
+
return VALID_TRANSITIONS[from]?.includes(to) ?? false
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function validateCreate(title: string | undefined, content: string | undefined, author: string | undefined): string | null {
|
|
19
|
+
if (!title?.trim()) return 'title required'
|
|
20
|
+
if (!content?.trim()) return 'content required'
|
|
21
|
+
if (!author?.trim()) return 'author required'
|
|
22
|
+
return null
|
|
23
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retro → Kickoff Link — pure domain logic
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface RetroAction {
|
|
6
|
+
id: number
|
|
7
|
+
content: string
|
|
8
|
+
assignee: string | null
|
|
9
|
+
status: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface BacklogStory {
|
|
13
|
+
title: string
|
|
14
|
+
description: string
|
|
15
|
+
assignee: string | null
|
|
16
|
+
source: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Convert action item → backlog story */
|
|
20
|
+
export function actionToStory(action: RetroAction, sprintId: string): BacklogStory {
|
|
21
|
+
return {
|
|
22
|
+
title: `[Retro] ${action.content.slice(0, 60)}`,
|
|
23
|
+
description: `Created from retro (${sprintId}) action item.\n\nOriginal: ${action.content}`,
|
|
24
|
+
assignee: action.assignee,
|
|
25
|
+
source: `retro:${sprintId}`,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Filter incomplete action items */
|
|
30
|
+
export function getPendingActions(actions: RetroAction[]): RetroAction[] {
|
|
31
|
+
return actions.filter(a => a.status !== 'done')
|
|
32
|
+
}
|