popilot 0.6.0 → 0.7.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.
Files changed (112) hide show
  1. package/bin/cli.mjs +204 -2
  2. package/lib/doctor.mjs +38 -1
  3. package/lib/hydrate.mjs +15 -0
  4. package/lib/scaffold.mjs +5 -0
  5. package/lib/setup-wizard.mjs +35 -2
  6. package/package.json +1 -1
  7. package/scaffold/.context/project.yaml.example +19 -0
  8. package/scaffold/mcp-pm/package.json +19 -0
  9. package/scaffold/mcp-pm/src/api-client.ts +69 -0
  10. package/scaffold/mcp-pm/src/index.ts +660 -0
  11. package/scaffold/mcp-pm/tsconfig.json +14 -0
  12. package/scaffold/pm-api/package.json +21 -0
  13. package/scaffold/pm-api/sql/schema-core.sql +331 -0
  14. package/scaffold/pm-api/sql/schema-docs.sql +25 -0
  15. package/scaffold/pm-api/sql/schema-meetings.sql +17 -0
  16. package/scaffold/pm-api/sql/schema-rewards.sql +16 -0
  17. package/scaffold/pm-api/src/auth.ts +28 -0
  18. package/scaffold/pm-api/src/blockchain/adapter.ts +20 -0
  19. package/scaffold/pm-api/src/blockchain/tron.ts +62 -0
  20. package/scaffold/pm-api/src/db/adapter.ts +36 -0
  21. package/scaffold/pm-api/src/db/turso.ts +147 -0
  22. package/scaffold/pm-api/src/index.ts +114 -0
  23. package/scaffold/pm-api/src/mcp-tools/dashboard.ts +40 -0
  24. package/scaffold/pm-api/src/mcp-tools/epic.ts +67 -0
  25. package/scaffold/pm-api/src/mcp-tools/event.ts +89 -0
  26. package/scaffold/pm-api/src/mcp-tools/index.ts +11 -0
  27. package/scaffold/pm-api/src/mcp-tools/initiative.ts +51 -0
  28. package/scaffold/pm-api/src/mcp-tools/memo.ts +164 -0
  29. package/scaffold/pm-api/src/mcp-tools/notification.ts +37 -0
  30. package/scaffold/pm-api/src/mcp-tools/retro.ts +183 -0
  31. package/scaffold/pm-api/src/mcp-tools/sprint.ts +204 -0
  32. package/scaffold/pm-api/src/mcp-tools/standup.ts +136 -0
  33. package/scaffold/pm-api/src/mcp-tools/story.ts +230 -0
  34. package/scaffold/pm-api/src/mcp-tools/task.ts +187 -0
  35. package/scaffold/pm-api/src/mcp-tools/utils.ts +83 -0
  36. package/scaffold/pm-api/src/mcp.ts +871 -0
  37. package/scaffold/pm-api/src/nudge.ts +283 -0
  38. package/scaffold/pm-api/src/routes/auth.ts +32 -0
  39. package/scaffold/pm-api/src/routes/v2-activity.ts +27 -0
  40. package/scaffold/pm-api/src/routes/v2-admin.ts +165 -0
  41. package/scaffold/pm-api/src/routes/v2-dashboard.ts +189 -0
  42. package/scaffold/pm-api/src/routes/v2-docs.ts +34 -0
  43. package/scaffold/pm-api/src/routes/v2-initiatives.ts +118 -0
  44. package/scaffold/pm-api/src/routes/v2-kickoff.ts +265 -0
  45. package/scaffold/pm-api/src/routes/v2-meetings.ts +324 -0
  46. package/scaffold/pm-api/src/routes/v2-memos.ts +257 -0
  47. package/scaffold/pm-api/src/routes/v2-nav.ts +260 -0
  48. package/scaffold/pm-api/src/routes/v2-notifications.ts +79 -0
  49. package/scaffold/pm-api/src/routes/v2-page-content.ts +35 -0
  50. package/scaffold/pm-api/src/routes/v2-pm.ts +380 -0
  51. package/scaffold/pm-api/src/routes/v2-policy.ts +58 -0
  52. package/scaffold/pm-api/src/routes/v2-retro.ts +221 -0
  53. package/scaffold/pm-api/src/routes/v2-rewards.ts +132 -0
  54. package/scaffold/pm-api/src/routes/v2-scenarios.ts +48 -0
  55. package/scaffold/pm-api/src/routes/v2-search.ts +32 -0
  56. package/scaffold/pm-api/src/routes/v2-standup.ts +127 -0
  57. package/scaffold/pm-api/src/routes/v2-user.ts +38 -0
  58. package/scaffold/pm-api/src/types.ts +11 -0
  59. package/scaffold/pm-api/src/utils/activity.ts +22 -0
  60. package/scaffold/pm-api/src/utils/admin.ts +9 -0
  61. package/scaffold/pm-api/src/utils/agent-notify.ts +62 -0
  62. package/scaffold/pm-api/src/utils/assignee.ts +69 -0
  63. package/scaffold/pm-api/src/utils/db.ts +45 -0
  64. package/scaffold/pm-api/src/utils/initiative.ts +23 -0
  65. package/scaffold/pm-api/src/utils/sprint-lifecycle.ts +96 -0
  66. package/scaffold/pm-api/tsconfig.json +15 -0
  67. package/scaffold/pm-api/wrangler.toml.hbs +11 -0
  68. package/scaffold/spec-site/package-lock.json +40 -0
  69. package/scaffold/spec-site/package.json +4 -1
  70. package/scaffold/spec-site/src/api/types.ts +6 -0
  71. package/scaffold/spec-site/src/components/AppHeader.vue +429 -55
  72. package/scaffold/spec-site/src/components/MemberSelect.vue +48 -0
  73. package/scaffold/spec-site/src/components/NotificationDropdown.vue +116 -0
  74. package/scaffold/spec-site/src/components/SearchModal.vue +102 -0
  75. package/scaffold/spec-site/src/components/VelocityChart.vue +77 -0
  76. package/scaffold/spec-site/src/composables/pmTypes.ts +15 -2
  77. package/scaffold/spec-site/src/composables/useDashboard.ts +221 -0
  78. package/scaffold/spec-site/src/composables/useMediaQuery.ts +28 -0
  79. package/scaffold/spec-site/src/composables/useNotification.ts +200 -0
  80. package/scaffold/spec-site/src/composables/usePmStore.ts +48 -1
  81. package/scaffold/spec-site/src/composables/useRetro.ts +6 -0
  82. package/scaffold/spec-site/src/composables/useStandup.ts +201 -0
  83. package/scaffold/spec-site/src/composables/useTheme.ts +37 -0
  84. package/scaffold/spec-site/src/composables/useUser.ts +19 -1
  85. package/scaffold/spec-site/src/features.ts +108 -0
  86. package/scaffold/spec-site/src/pages/AdminPage.vue +299 -0
  87. package/scaffold/spec-site/src/pages/DashboardPage.vue +650 -0
  88. package/scaffold/spec-site/src/pages/DocsHub.vue +157 -0
  89. package/scaffold/spec-site/src/pages/InboxPage.vue +156 -0
  90. package/scaffold/spec-site/src/pages/MeetingsPage.vue +294 -0
  91. package/scaffold/spec-site/src/pages/MyPage.vue +343 -0
  92. package/scaffold/spec-site/src/pages/RewardsPage.vue +266 -0
  93. package/scaffold/spec-site/src/pages/board/BoardAdmin.vue +422 -0
  94. package/scaffold/spec-site/src/pages/board/BoardEpicSection.vue +54 -0
  95. package/scaffold/spec-site/src/pages/board/BoardPage.vue +884 -0
  96. package/scaffold/spec-site/src/pages/board/BoardStoryCard.vue +67 -0
  97. package/scaffold/spec-site/src/pages/board/BoardTaskItem.vue +52 -0
  98. package/scaffold/spec-site/src/pages/board/MyTasksPage.vue +202 -0
  99. package/scaffold/spec-site/src/pages/board/SprintClose.vue +167 -0
  100. package/scaffold/spec-site/src/pages/board/SprintColumn.vue +49 -0
  101. package/scaffold/spec-site/src/pages/board/SprintKickoff.vue +389 -0
  102. package/scaffold/spec-site/src/pages/board/StatusBadge.vue +52 -0
  103. package/scaffold/spec-site/src/pages/board/StoryDetailPanel.vue +495 -0
  104. package/scaffold/spec-site/src/pages/board/TaskCard.vue +42 -0
  105. package/scaffold/spec-site/src/pages/retro/RetroCard.vue +36 -2
  106. package/scaffold/spec-site/src/pages/retro/RetroHeader.vue +82 -66
  107. package/scaffold/spec-site/src/pages/retro/RetroPage.vue +47 -18
  108. package/scaffold/spec-site/src/pages/standup/StandupEntryCard.vue +551 -0
  109. package/scaffold/spec-site/src/pages/standup/StandupForm.vue +68 -0
  110. package/scaffold/spec-site/src/pages/standup/StandupList.vue +71 -0
  111. package/scaffold/spec-site/src/pages/standup/StandupPage.vue +225 -0
  112. package/scaffold/spec-site/src/router.ts +141 -0
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Proactive Nudge — Cron-triggered check & webhook notification module
3
+ */
4
+
5
+ // ── Types ──
6
+ interface NudgeRule {
7
+ id: string
8
+ label: string
9
+ check: (env: Env) => Promise<NudgeMessage[]>
10
+ }
11
+
12
+ interface NudgeMessage {
13
+ ruleId: string
14
+ title: string
15
+ body: string
16
+ mentions?: string[] // user names
17
+ }
18
+
19
+ interface Env {
20
+ DB_URL: string
21
+ DB_AUTH_TOKEN: string
22
+ NUDGE_WEBHOOK_URL?: string
23
+ }
24
+
25
+ // ── DB helper (standalone fetch for cron context) ──
26
+ async function query(env: Env, sql: string, args: unknown[] = []): Promise<Record<string, unknown>[]> {
27
+ const res = await fetch(`${env.DB_URL}/v2/pipeline`, {
28
+ method: 'POST',
29
+ headers: {
30
+ Authorization: `Bearer ${env.DB_AUTH_TOKEN}`,
31
+ 'Content-Type': 'application/json',
32
+ },
33
+ body: JSON.stringify({
34
+ requests: [
35
+ { type: 'execute', stmt: { sql, args: args.map(a => ({ type: typeof a === 'number' ? 'integer' : 'text', value: String(a) })) } },
36
+ { type: 'close' },
37
+ ],
38
+ }),
39
+ })
40
+ const data = (await res.json()) as { results?: Array<{ response?: { result?: { cols?: Array<{ name: string }>; rows?: unknown[][] } } }> }
41
+ const result = data.results?.[0]?.response?.result
42
+ if (!result?.cols || !result?.rows) return []
43
+ return result.rows.map((row: unknown[]) => {
44
+ const obj: Record<string, unknown> = {}
45
+ result.cols!.forEach((col: { name: string }, i: number) => {
46
+ obj[col.name] = (row[i] as { value?: unknown })?.value ?? row[i]
47
+ })
48
+ return obj
49
+ })
50
+ }
51
+
52
+ // ── Rules ──
53
+
54
+ // Rule 1: review_required memo unanswered for 24h
55
+ const reviewOverdue: NudgeRule = {
56
+ id: 'review_overdue',
57
+ label: 'Review request unanswered for 24h',
58
+ check: async (env) => {
59
+ const rows = await query(env,
60
+ `SELECT id, title, content, assigned_to, created_by, created_at
61
+ FROM memos_v2
62
+ WHERE review_required = 1
63
+ AND status = 'open'
64
+ AND created_at <= datetime('now', '-24 hours')
65
+ LIMIT 10`)
66
+ return rows.map(r => ({
67
+ ruleId: 'review_overdue',
68
+ title: `Pending review: ${r.title || (r.content as string).slice(0, 30)}`,
69
+ body: `${r.created_by} → ${r.assigned_to} | waiting since ${r.created_at}`,
70
+ mentions: r.assigned_to ? [r.assigned_to as string] : [],
71
+ }))
72
+ },
73
+ }
74
+
75
+ // Rule 2: Sprint deadline within 3 days, less than 50% complete
76
+ const sprintDeadline: NudgeRule = {
77
+ id: 'sprint_deadline',
78
+ label: 'Sprint deadline approaching',
79
+ check: async (env) => {
80
+ // Check active sprint
81
+ const sprints = await query(env,
82
+ `SELECT id, end_date FROM nav_sprints WHERE active = 1 LIMIT 1`)
83
+ if (!sprints.length) return []
84
+ const sprint = sprints[0]
85
+ const endDate = new Date(sprint.end_date as string)
86
+ const now = new Date()
87
+ const daysLeft = Math.ceil((endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
88
+ if (daysLeft > 3) return []
89
+
90
+ // Check progress
91
+ const stories = await query(env,
92
+ `SELECT status FROM pm_stories WHERE sprint = ? AND status != 'cancelled'`,
93
+ [sprint.id as string])
94
+ if (!stories.length) return []
95
+ const done = stories.filter(s => s.status === 'done').length
96
+ const total = stories.length
97
+ const pct = Math.round((done / total) * 100)
98
+ if (pct >= 50) return [] // 50%+ is OK
99
+
100
+ return [{
101
+ ruleId: 'sprint_deadline',
102
+ title: `${sprint.id} due in ${daysLeft} days — progress ${pct}%`,
103
+ body: `${done}/${total} stories completed. Due: ${sprint.end_date}`,
104
+ }]
105
+ },
106
+ }
107
+
108
+ // Rule 3: Standup not submitted today
109
+ const standupMissing: NudgeRule = {
110
+ id: 'standup_missing',
111
+ label: 'Standup not submitted',
112
+ check: async (env) => {
113
+ const now = new Date()
114
+ const today = now.toISOString().split('T')[0]
115
+
116
+ // Active sprint
117
+ const sprints = await query(env,
118
+ `SELECT id FROM nav_sprints WHERE active = 1 LIMIT 1`)
119
+ if (!sprints.length) return []
120
+
121
+ // Who submitted today
122
+ const written = await query(env,
123
+ `SELECT DISTINCT user_name FROM pm_standup_entries WHERE entry_date = ?`, [today])
124
+ const writtenSet = new Set(written.map(w => w.user_name as string))
125
+
126
+ // All team members
127
+ const members = await query(env,
128
+ `SELECT DISTINCT user_name FROM auth_tokens WHERE is_active = 1`)
129
+ const missing = members
130
+ .map(m => m.user_name as string)
131
+ .filter(name => !writtenSet.has(name))
132
+
133
+ if (!missing.length) return []
134
+
135
+ // Only trigger in afternoon (give people time to write)
136
+ const hour = now.getUTCHours()
137
+ if (hour < 8) return [] // Before 08:00 UTC
138
+
139
+ return [{
140
+ ruleId: 'standup_missing',
141
+ title: `Standup not submitted: ${missing.length} members`,
142
+ body: `Missing: ${missing.join(', ')}`,
143
+ mentions: missing,
144
+ }]
145
+ },
146
+ }
147
+
148
+ // Rule 4: Task stagnant 3+ days in-progress
149
+ const taskStagnant: NudgeRule = {
150
+ id: 'task_stagnant',
151
+ label: 'Task stagnant 3+ days',
152
+ check: async (env) => {
153
+ const rows = await query(env,
154
+ `SELECT t.id, t.title, t.assignee, t.updated_at, s.title as story_title
155
+ FROM pm_tasks t
156
+ JOIN pm_stories s ON t.story_id = s.id
157
+ JOIN nav_sprints sp ON s.sprint = sp.id AND sp.active = 1
158
+ WHERE t.status = 'in-progress'
159
+ AND t.updated_at <= datetime('now', '-3 days')
160
+ LIMIT 10`)
161
+ return rows.map(r => ({
162
+ ruleId: 'task_stagnant',
163
+ title: `Task stagnant 3+ days: ${(r.title as string).slice(0, 40)}`,
164
+ body: `Assignee: ${r.assignee || 'Unassigned'} | Story: ${r.story_title} | Last update: ${r.updated_at}`,
165
+ mentions: r.assignee ? [r.assignee as string] : [],
166
+ }))
167
+ },
168
+ }
169
+
170
+ // Rule 5: Unresolved blocker
171
+ const blockerOpen: NudgeRule = {
172
+ id: 'blocker_open',
173
+ label: 'Unresolved blocker',
174
+ check: async (env) => {
175
+ const rows = await query(env,
176
+ `SELECT id, title, content, created_by, assigned_to, created_at
177
+ FROM memos_v2
178
+ WHERE memo_type = 'blocker'
179
+ AND status = 'open'
180
+ LIMIT 10`)
181
+ return rows.map(r => ({
182
+ ruleId: 'blocker_open',
183
+ title: `Unresolved blocker: ${r.title || (r.content as string).slice(0, 30)}`,
184
+ body: `Author: ${r.created_by} | Assignee: ${r.assigned_to || 'Unassigned'} | open since ${r.created_at}`,
185
+ mentions: r.assigned_to ? [r.assigned_to as string] : [],
186
+ }))
187
+ },
188
+ }
189
+
190
+ // Rule 6: Sprint daily progress report
191
+ const sprintDailyReport: NudgeRule = {
192
+ id: 'sprint_daily_report',
193
+ label: 'Sprint daily progress',
194
+ check: async (env) => {
195
+ const sprints = await query(env,
196
+ `SELECT id, end_date, theme FROM nav_sprints WHERE active = 1 LIMIT 1`)
197
+ if (!sprints.length) return []
198
+ const sprint = sprints[0]
199
+ const endDate = new Date(sprint.end_date as string)
200
+ const now = new Date()
201
+ const daysLeft = Math.ceil((endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
202
+
203
+ const stories = await query(env,
204
+ `SELECT status FROM pm_stories WHERE sprint = ? AND status != 'cancelled'`,
205
+ [sprint.id as string])
206
+ if (!stories.length) return []
207
+
208
+ const done = stories.filter(s => s.status === 'done').length
209
+ const inProgress = stories.filter(s => s.status === 'in-progress').length
210
+ const total = stories.length
211
+ const pct = Math.round((done / total) * 100)
212
+
213
+ // Only trigger in afternoon
214
+ const hour = now.getUTCHours()
215
+ if (hour < 8) return [] // Before 08:00 UTC
216
+
217
+ return [{
218
+ ruleId: 'sprint_daily_report',
219
+ title: `${sprint.id} Daily Report — ${pct}% completed`,
220
+ body: `completed ${done} / in-progress ${inProgress} / total ${total} | D-${daysLeft} (Due: ${sprint.end_date})`,
221
+ }]
222
+ },
223
+ }
224
+
225
+ const RULES: NudgeRule[] = [reviewOverdue, sprintDeadline, standupMissing, taskStagnant, blockerOpen, sprintDailyReport]
226
+
227
+ // ── Webhook sender ──
228
+ async function sendWebhook(env: Env, messages: NudgeMessage[]): Promise<void> {
229
+ const url = env.NUDGE_WEBHOOK_URL
230
+ if (!url || !messages.length) return
231
+
232
+ // Discord/Slack compatible embed format
233
+ const embeds = messages.map(m => ({
234
+ title: m.title,
235
+ description: m.body,
236
+ color: m.ruleId === 'review_overdue' ? 0xf59e0b
237
+ : m.ruleId === 'sprint_deadline' ? 0xef4444
238
+ : m.ruleId === 'task_stagnant' ? 0xf97316
239
+ : m.ruleId === 'blocker_open' ? 0xdc2626
240
+ : m.ruleId === 'sprint_daily_report' ? 0x8b5cf6
241
+ : 0x3b82f6,
242
+ footer: { text: `nudge:${m.ruleId}` },
243
+ }))
244
+
245
+ // Discord webhook
246
+ await fetch(url, {
247
+ method: 'POST',
248
+ headers: { 'Content-Type': 'application/json' },
249
+ body: JSON.stringify({ embeds }),
250
+ })
251
+ }
252
+
253
+ // ── Nudge log ──
254
+ async function logNudges(env: Env, messages: NudgeMessage[]): Promise<void> {
255
+ for (const m of messages) {
256
+ await query(env,
257
+ `INSERT INTO nudge_log (rule_id, title, body, created_at) VALUES (?, ?, ?, datetime('now'))`,
258
+ [m.ruleId, m.title, m.body])
259
+ }
260
+ }
261
+
262
+ // ── Main entry ──
263
+ export async function handleScheduled(env: Env): Promise<void> {
264
+ const allMessages: NudgeMessage[] = []
265
+
266
+ for (const rule of RULES) {
267
+ try {
268
+ const msgs = await rule.check(env)
269
+ allMessages.push(...msgs)
270
+ } catch (e) {
271
+ console.error(`Nudge rule ${rule.id} failed:`, e)
272
+ }
273
+ }
274
+
275
+ if (allMessages.length > 0) {
276
+ await sendWebhook(env, allMessages)
277
+ await logNudges(env, allMessages)
278
+ }
279
+
280
+ if (allMessages.length > 0) {
281
+ console.log(`Nudge: ${allMessages.length} messages sent (${allMessages.map(m => m.ruleId).join(', ')})`)
282
+ }
283
+ }
@@ -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
+ // POST /api/auth/verify — public (no auth middleware)
8
+ app.post('/verify', async (c) => {
9
+ const body = await c.req.json<{ token?: string }>().catch(() => ({} as { token?: string }))
10
+ const token = body.token
11
+ if (!token) {
12
+ return c.json({ error: 'Missing token' }, 400)
13
+ }
14
+
15
+ const result = await query<{ user_name: string }>(
16
+ `SELECT user_name FROM auth_tokens
17
+ WHERE token = ? AND is_active = 1
18
+ AND (expires_at IS NULL OR expires_at > datetime('now'))`,
19
+ [token],
20
+ )
21
+
22
+ if (result.error) {
23
+ return c.json({ error: 'Verification failed' }, 500)
24
+ }
25
+ if (result.rows.length === 0) {
26
+ return c.json({ error: 'Invalid or expired token' }, 401)
27
+ }
28
+
29
+ return c.json({ userName: result.rows[0].user_name })
30
+ })
31
+
32
+ export default app
@@ -0,0 +1,27 @@
1
+ import { Hono } from 'hono'
2
+ import type { AppEnv } from '../types.js'
3
+ import { queryOrThrow } from '../utils/db.js'
4
+
5
+ const app = new Hono<AppEnv>()
6
+
7
+ // GET / — activity feed
8
+ app.get('/', async (c) => {
9
+ const limit = Number(c.req.query('limit') ?? '50')
10
+ const date = c.req.query('date')
11
+
12
+ let sql = 'SELECT * FROM activity_log'
13
+ const args: (string | number)[] = []
14
+
15
+ if (date) {
16
+ sql += ' WHERE created_at >= ? AND created_at < ?'
17
+ args.push(date, date + 'T23:59:59')
18
+ }
19
+
20
+ sql += ' ORDER BY created_at DESC LIMIT ?'
21
+ args.push(limit)
22
+
23
+ const { rows } = await queryOrThrow(sql, args)
24
+ return c.json({ activities: rows })
25
+ })
26
+
27
+ export default app
@@ -0,0 +1,165 @@
1
+ import { Hono } from 'hono'
2
+ import type { AppEnv } from '../types.js'
3
+ import { queryOrThrow, executeOrThrow } from '../utils/db.js'
4
+
5
+ const app = new Hono<AppEnv>()
6
+
7
+ // ── Members (from members table) ──
8
+
9
+ // GET /members — list from members table (primary), with auth_tokens info joined
10
+ app.get('/members', async (c) => {
11
+ const { rows } = await queryOrThrow(
12
+ `SELECT m.id, m.display_name, m.email, m.role, m.is_active, m.webhook_url, m.wallet_address, m.created_at, m.updated_at
13
+ FROM members m ORDER BY m.is_active DESC, m.display_name`,
14
+ )
15
+ return c.json({ members: rows })
16
+ })
17
+
18
+ // PATCH /members/:id — update display_name, email, role
19
+ app.patch('/members/:id', async (c) => {
20
+ const id = Number(c.req.param('id'))
21
+ const body = await c.req.json<{ display_name?: string; email?: string; role?: string; webhook_url?: string | null; wallet_address?: string | null }>()
22
+ const sets: string[] = []
23
+ const args: (string | number)[] = []
24
+
25
+ if (body.display_name !== undefined) { sets.push('display_name = ?'); args.push(body.display_name) }
26
+ if (body.email !== undefined) { sets.push('email = ?'); args.push(body.email) }
27
+ if (body.role !== undefined) { sets.push('role = ?'); args.push(body.role) }
28
+ if (body.webhook_url !== undefined) { sets.push('webhook_url = ?'); args.push(body.webhook_url ?? '') }
29
+ if (body.wallet_address !== undefined) { sets.push('wallet_address = ?'); args.push(body.wallet_address ?? '') }
30
+ if ((body as any).is_active !== undefined) { sets.push('is_active = ?'); args.push((body as any).is_active ? 1 : 0) }
31
+ if (sets.length === 0) return c.json({ ok: true })
32
+
33
+ sets.push('updated_at = CURRENT_TIMESTAMP')
34
+ args.push(id)
35
+ const { rowsAffected } = await executeOrThrow(`UPDATE members SET ${sets.join(', ')} WHERE id = ?`, args)
36
+ return c.json({ ok: true })
37
+ })
38
+
39
+ // ── Auth Tokens (keep existing endpoints working) ──
40
+
41
+ // POST /members (create auth token — legacy)
42
+ app.post('/members', async (c) => {
43
+ const body = await c.req.json<{
44
+ token: string; userName: string; userEmail?: string; ttlDays?: number
45
+ }>()
46
+
47
+ let sql: string
48
+ let args: (string | null)[]
49
+
50
+ if (body.ttlDays) {
51
+ sql = `INSERT INTO auth_tokens (token, user_name, user_email, expires_at) VALUES (?, ?, ?, datetime('now', '+' || ? || ' days'))`
52
+ args = [body.token, body.userName, body.userEmail ?? null, String(body.ttlDays)]
53
+ } else {
54
+ sql = 'INSERT INTO auth_tokens (token, user_name, user_email) VALUES (?, ?, ?)'
55
+ args = [body.token, body.userName, body.userEmail ?? null]
56
+ }
57
+
58
+ const { rowsAffected } = await executeOrThrow(sql, args)
59
+
60
+ // Also insert into members table (skip if already exists)
61
+ try {
62
+ await executeOrThrow(
63
+ "INSERT INTO members (display_name, email, role, is_active) VALUES (?, ?, 'member', 1)",
64
+ [body.userName, body.userEmail ?? null],
65
+ )
66
+ } catch {
67
+ // Ignore if already exists
68
+ }
69
+
70
+ return c.json({ ok: true }, 201)
71
+ })
72
+
73
+ // PATCH /members/:token/revoke
74
+ app.patch('/members/:token/revoke', async (c) => {
75
+ const token = c.req.param('token')
76
+ const { rowsAffected } = await executeOrThrow(
77
+ 'UPDATE auth_tokens SET is_active = 0 WHERE token = ?',
78
+ [token],
79
+ )
80
+ return c.json({ ok: true })
81
+ })
82
+
83
+ // PATCH /members/:token/activate
84
+ app.patch('/members/:token/activate', async (c) => {
85
+ const token = c.req.param('token')
86
+ const { rowsAffected } = await executeOrThrow(
87
+ 'UPDATE auth_tokens SET is_active = 1 WHERE token = ?',
88
+ [token],
89
+ )
90
+ return c.json({ ok: true })
91
+ })
92
+
93
+ // POST /members/:token/regenerate
94
+ app.post('/members/:token/regenerate', async (c) => {
95
+ const token = c.req.param('token')
96
+ const body = await c.req.json<{ newToken: string }>()
97
+ const { rowsAffected } = await executeOrThrow(
98
+ 'UPDATE auth_tokens SET token = ?, created_at = CURRENT_TIMESTAMP WHERE token = ?',
99
+ [body.newToken, token],
100
+ )
101
+ return c.json({ ok: true })
102
+ })
103
+
104
+ // DELETE /members/:id — deactivate members + auth_tokens (by numeric ID)
105
+ app.delete('/members/:id', async (c) => {
106
+ const id = Number(c.req.param('id'))
107
+ const { rows } = await queryOrThrow<{ display_name: string }>('SELECT display_name FROM members WHERE id = ?', [id])
108
+ if (!rows.length) return c.json({ error: 'Member not found' }, 404)
109
+ await executeOrThrow('UPDATE members SET is_active = 0 WHERE id = ?', [id])
110
+ await executeOrThrow('UPDATE auth_tokens SET is_active = 0 WHERE user_name = ?', [rows[0].display_name])
111
+ return c.json({ ok: true })
112
+ })
113
+
114
+ // ── Spec Rules management ──
115
+
116
+ // DELETE /spec-rules/:pageId/:ruleId
117
+ app.delete('/spec-rules/:pageId/:ruleId', async (c) => {
118
+ const pageId = c.req.param('pageId')
119
+ const ruleId = c.req.param('ruleId')
120
+ const { rowsAffected } = await executeOrThrow(
121
+ 'DELETE FROM spec_rules WHERE page_id = ? AND id = ?',
122
+ [pageId, ruleId],
123
+ )
124
+ return c.json({ ok: true })
125
+ })
126
+
127
+ // PATCH /spec-areas/:pageId/:areaId — update rule_count etc
128
+ app.patch('/spec-areas/:pageId/:areaId', async (c) => {
129
+ const pageId = c.req.param('pageId')
130
+ const areaId = c.req.param('areaId')
131
+ const body = await c.req.json<{ ruleCount?: number; label?: string; shortLabel?: string }>()
132
+ const sets: string[] = []
133
+ const args: (string | number | null)[] = []
134
+ if (body.ruleCount !== undefined) { sets.push('rule_count = ?'); args.push(body.ruleCount) }
135
+ if (body.label !== undefined) { sets.push('label = ?'); args.push(body.label) }
136
+ if (body.shortLabel !== undefined) { sets.push('short_label = ?'); args.push(body.shortLabel) }
137
+ if (sets.length === 0) return c.json({ ok: true })
138
+ args.push(pageId, areaId)
139
+ const { rowsAffected } = await executeOrThrow(`UPDATE spec_areas SET ${sets.join(', ')} WHERE page_id = ? AND area_id = ?`, args)
140
+ return c.json({ ok: true })
141
+ })
142
+
143
+ // ── Settings (key-value) ──
144
+ app.get('/settings', async (c) => {
145
+ const { rows } = await queryOrThrow('SELECT key, value FROM settings')
146
+ const obj: Record<string, string> = {}
147
+ for (const r of rows as Array<{ key: string; value: string }>) obj[r.key] = r.value
148
+ return c.json({ settings: obj })
149
+ })
150
+
151
+ app.put('/settings/:key', async (c) => {
152
+ const key = c.req.param('key')
153
+ const body = await c.req.json<{ value?: string | null }>()
154
+ if (!body.value) {
155
+ await executeOrThrow('DELETE FROM settings WHERE key = ?', [key])
156
+ } else {
157
+ await executeOrThrow(
158
+ "INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
159
+ [key, body.value],
160
+ )
161
+ }
162
+ return c.json({ ok: true })
163
+ })
164
+
165
+ export default app
@@ -0,0 +1,189 @@
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 /unread-memos — unread memos (assigned to me, unanswered)
9
+ // ?review_required=1 to filter approval-pending only
10
+ app.get('/unread-memos', async (c) => {
11
+ const user = c.get('userName')
12
+ const reviewFilter = c.req.query('review_required')
13
+ const memoTypeFilter = c.req.query('memo_type')
14
+
15
+ let sql = `SELECT m.id, m.content, m.memo_type, m.created_by, m.created_at, m.review_required, m.page_id, m.title, m.supersedes_id,
16
+ (SELECT COUNT(*) FROM memo_replies r WHERE r.memo_id = m.id) as reply_count
17
+ FROM memos_v2 m
18
+ WHERE m.assigned_to LIKE '%' || ? || '%'
19
+ AND m.status = 'open'
20
+ AND (SELECT COUNT(*) FROM memo_replies r WHERE r.memo_id = m.id) = 0`
21
+
22
+ const args: (string | number)[] = [user]
23
+
24
+ if (reviewFilter === '1') {
25
+ sql += ` AND m.review_required = 1`
26
+ }
27
+ if (memoTypeFilter) {
28
+ sql += ` AND m.memo_type = ?`
29
+ args.push(memoTypeFilter)
30
+ }
31
+
32
+ sql += ` ORDER BY m.review_required DESC, m.created_at DESC LIMIT 20`
33
+
34
+ const { rows } = await queryOrThrow(sql, args)
35
+ return c.json({ unreadMemos: rows })
36
+ })
37
+
38
+ // GET /sprint-progress — sprint progress (?user= personal filter)
39
+ app.get('/sprint-progress', async (c) => {
40
+ const sprint = c.req.query('sprint')
41
+ const userFilter = c.req.query('user')
42
+ if (!sprint) return c.json({ error: 'sprint required' }, 400)
43
+
44
+ let sql = `SELECT status, COUNT(*) as cnt FROM pm_stories WHERE sprint = ?`
45
+ const args: (string)[] = [sprint]
46
+
47
+ if (userFilter) {
48
+ sql += ` AND assignee LIKE '%' || ? || '%'`
49
+ args.push(userFilter)
50
+ }
51
+
52
+ sql += ` GROUP BY status`
53
+
54
+ const { rows } = await queryOrThrow(sql, args)
55
+
56
+ const statusMap: Record<string, number> = {}
57
+ let total = 0
58
+ for (const row of rows as any[]) {
59
+ statusMap[row.status] = row.cnt
60
+ total += row.cnt
61
+ }
62
+ const done = statusMap['done'] || 0
63
+
64
+ return c.json({
65
+ sprint,
66
+ total,
67
+ done,
68
+ progressPercent: total > 0 ? Math.round(done / total * 100) : 0,
69
+ byStatus: statusMap,
70
+ })
71
+ })
72
+
73
+ // GET /standup-status — today's standup submission status
74
+ app.get('/standup-status', async (c) => {
75
+ const sprint = c.req.query('sprint')
76
+ const date = c.req.query('date')
77
+ if (!sprint || !date) return c.json({ error: 'sprint and date required' }, 400)
78
+
79
+ const { rows } = await queryOrThrow(
80
+ `SELECT user_name, done_text, plan_text FROM pm_standup_entries WHERE sprint = ? AND entry_date = ?`,
81
+ [sprint, date],
82
+ )
83
+
84
+ const written = (rows as any[]).map(r => r.user_name)
85
+ return c.json({ date, written, count: written.length })
86
+ })
87
+
88
+ // GET /my-requests — requests I created
89
+ app.get('/my-requests', async (c) => {
90
+ const user = c.get('userName')
91
+ const { rows } = await queryOrThrow(
92
+ `SELECT id, title, content, memo_type, assigned_to, status, created_at, supersedes_id
93
+ FROM memos_v2
94
+ WHERE created_by = ? AND memo_type IN ('decision', 'feature_request', 'policy_request')
95
+ ORDER BY created_at DESC LIMIT 20`,
96
+ [user],
97
+ )
98
+ return c.json({ myRequests: rows })
99
+ })
100
+
101
+ // GET /active-decisions — active decisions
102
+ app.get('/active-decisions', async (c) => {
103
+ const { rows } = await queryOrThrow(
104
+ `SELECT id, title, content, created_by, assigned_to, created_at, supersedes_id
105
+ FROM memos_v2
106
+ WHERE memo_type = 'decision' AND status = 'open'
107
+ ORDER BY created_at DESC LIMIT 20`,
108
+ )
109
+ return c.json({ decisions: rows })
110
+ })
111
+
112
+ // GET /supersede-chain/:id — trace supersede chain
113
+ app.get('/supersede-chain/:id', async (c) => {
114
+ const id = Number(c.req.param('id'))
115
+ const chain: any[] = []
116
+ let currentId: number | null = id
117
+
118
+ for (let i = 0; i < 10 && currentId; i++) {
119
+ const chainResult: { rows: Record<string, unknown>[] } = await queryOrThrow(
120
+ 'SELECT id, title, content, memo_type, status, created_by, created_at, supersedes_id FROM memos_v2 WHERE id = ?',
121
+ [currentId],
122
+ )
123
+ if (chainResult.rows.length === 0) break
124
+ chain.push(chainResult.rows[0])
125
+ currentId = (chainResult.rows[0] as any).supersedes_id
126
+ }
127
+
128
+ return c.json({ chain: chain.reverse() })
129
+ })
130
+
131
+ // GET /nudge-log — recent Nudge history
132
+ app.get('/nudge-log', async (c) => {
133
+ const limit = parseInt(c.req.query('limit') ?? '20')
134
+ const nudgeRows = await query(
135
+ `SELECT id, rule_id, title, body, created_at FROM nudge_log ORDER BY created_at DESC LIMIT ?`,
136
+ [limit],
137
+ )
138
+ return c.json({ nudges: (nudgeRows as { rows?: unknown[] }).rows ?? [] })
139
+ })
140
+
141
+ // DELETE /nudge-log/:id — delete Nudge log (admin only)
142
+ app.delete('/nudge-log/:id', async (c) => {
143
+ const userName = c.get('userName')
144
+ const { isAdmin } = await import('../utils/admin.js')
145
+ if (!await isAdmin(userName)) return c.json({ error: 'Admin privileges required' }, 403)
146
+ const id = Number(c.req.param('id'))
147
+ const { rowsAffected } = await executeOrThrow('DELETE FROM nudge_log WHERE id = ?', [id])
148
+ return c.json({ ok: true })
149
+ })
150
+
151
+ // GET /my-summary — personal dashboard summary
152
+ app.get('/my-summary', async (c) => {
153
+ const user = c.req.query('user')
154
+ if (!user) return c.json({ error: 'user query param required' }, 400)
155
+
156
+ const [storiesRes, reviewsRes, mentionsRes, memosRes] = await Promise.all([
157
+ queryOrThrow(
158
+ "SELECT id, title, story_points, sprint, status, start_date FROM pm_stories WHERE assignee LIKE ? AND status = 'in-progress'",
159
+ [`%${user}%`],
160
+ ),
161
+ queryOrThrow(
162
+ "SELECT id, title, story_points, status, assignee FROM pm_stories WHERE status IN ('review', 'qa')",
163
+ ),
164
+ queryOrThrow<{ count: number }>(
165
+ "SELECT COUNT(*) as count FROM notifications WHERE user_name = ? AND is_read = 0 AND type = 'mention'",
166
+ [user],
167
+ ),
168
+ queryOrThrow<{ count: number }>(
169
+ "SELECT COUNT(*) as count FROM memos_v2 WHERE assigned_to LIKE ? AND status = 'open'",
170
+ [`%${user}%`],
171
+ ),
172
+ ])
173
+
174
+ // days_in_progress calculation
175
+ const now = new Date()
176
+ const myStories = storiesRes.rows.map((s: any) => ({
177
+ ...s,
178
+ daysInProgress: s.start_date ? Math.floor((now.getTime() - new Date(s.start_date + 'Z').getTime()) / 86400000) : null,
179
+ }))
180
+
181
+ return c.json({
182
+ myStories,
183
+ myReviews: reviewsRes.rows,
184
+ unreadMentions: (mentionsRes.rows[0] as any)?.count ?? 0,
185
+ unansweredMemos: (memosRes.rows[0] as any)?.count ?? 0,
186
+ })
187
+ })
188
+
189
+ export default app