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.
- 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-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/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/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 +40 -0
- package/scaffold/spec-site/package.json +4 -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/MemberSelect.vue +48 -0
- package/scaffold/spec-site/src/components/NotificationDropdown.vue +116 -0
- package/scaffold/spec-site/src/components/SearchModal.vue +102 -0
- package/scaffold/spec-site/src/components/VelocityChart.vue +77 -0
- package/scaffold/spec-site/src/composables/pmTypes.ts +15 -2
- 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/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/useUser.ts +19 -1
- package/scaffold/spec-site/src/features.ts +108 -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/DocsHub.vue +157 -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/MyPage.vue +343 -0
- package/scaffold/spec-site/src/pages/RewardsPage.vue +266 -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/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
|
@@ -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
|