Skip to content

Commit 0d6f245

Browse files
committed
error page fixes, collaborative protocol page
1 parent cd64945 commit 0d6f245

17 files changed

Lines changed: 4079 additions & 264 deletions

server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { arkMiddleware } from '~/api/ark-middleware.server'
1818
import type { AuthEnv } from '~/api/auth.server'
1919
import { authMiddleware, requireAuth } from '~/api/auth.server'
2020
import _collections from '~/api/collections'
21+
import _discussion from '~/api/discussion'
2122
import _files from '~/api/files'
2223
import * as _health from '~/api/health'
2324
import * as _kfSummary from '~/api/kf-summary'
@@ -177,6 +178,7 @@ const routes = app
177178
.route(...api('/api', './src/api/collections.ts', _collections))
178179
.route(...api('/api/collections', './src/api/versions.ts', _versions))
179180
.route(...api('/api/collections', './src/api/negotiate.ts', _negotiate))
181+
.route(...api('/api', './src/api/discussion.ts', _discussion))
180182

181183
export type AppType = typeof routes
182184

src/api/discussion.ts

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
import { and, desc, eq, isNotNull, isNull, sql } from 'drizzle-orm'
2+
import { Hono } from 'hono'
3+
import { z } from 'zod'
4+
5+
import { db, schema } from '../db/client.server.js'
6+
import { KF_AUTH_INTERNAL_URL } from '../lib/auth.js'
7+
import { type AuthEnv, requireAuth } from './auth.server.js'
8+
9+
const COMMENT_MAX_BYTES = 8192
10+
const RATE_LIMIT_WINDOW_MS = 60_000
11+
const RATE_LIMIT_MAX = 10
12+
const rateBuckets = new Map<string, { count: number; resetAt: number }>()
13+
14+
function checkRateLimit(key: string): boolean {
15+
const now = Date.now()
16+
const bucket = rateBuckets.get(key)
17+
if (!bucket || bucket.resetAt < now) {
18+
rateBuckets.set(key, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS })
19+
return true
20+
}
21+
bucket.count++
22+
return bucket.count <= RATE_LIMIT_MAX
23+
}
24+
25+
async function isSteward(userId: string | undefined): Promise<boolean> {
26+
if (!userId) return false
27+
try {
28+
const [acct] = await db
29+
.select({ accessToken: schema.account.accessToken })
30+
.from(schema.account)
31+
.where(eq(schema.account.userId, userId))
32+
.limit(1)
33+
if (!acct?.accessToken) return false
34+
const res = await fetch(`${KF_AUTH_INTERNAL_URL}/api/auth/oauth2/userinfo`, {
35+
headers: { Authorization: `Bearer ${acct.accessToken}` },
36+
})
37+
if (!res.ok) return false
38+
const profile = await res.json()
39+
return profile.role === 'admin'
40+
} catch {
41+
return false
42+
}
43+
}
44+
45+
const createBody = z.object({
46+
anchor: z.string().min(1).max(200),
47+
quote: z.string().max(2000).optional(),
48+
quoteContext: z.object({ prefix: z.string().max(200), suffix: z.string().max(200) }).optional(),
49+
parentId: z.string().uuid().optional(),
50+
body: z.string().min(1).max(COMMENT_MAX_BYTES),
51+
})
52+
53+
const patchBody = z.object({
54+
body: z.string().min(1).max(COMMENT_MAX_BYTES).optional(),
55+
approve: z.boolean().optional(),
56+
status: z.enum(['open', 'answered', 'decided', 'changed']).optional(),
57+
resolutionNote: z.string().max(2000).optional(),
58+
})
59+
60+
const app = new Hono<AuthEnv>()
61+
.get('/pages/:page/comments', async (c) => {
62+
const page = c.req.param('page')
63+
const userId = c.get('userId')
64+
65+
const comments = await db
66+
.select({
67+
id: schema.pageComments.id,
68+
page: schema.pageComments.page,
69+
anchor: schema.pageComments.anchor,
70+
quote: schema.pageComments.quote,
71+
quoteContext: schema.pageComments.quoteContext,
72+
parentId: schema.pageComments.parentId,
73+
userId: schema.pageComments.userId,
74+
body: schema.pageComments.body,
75+
approvedAt: schema.pageComments.approvedAt,
76+
status: schema.pageComments.status,
77+
resolutionNote: schema.pageComments.resolutionNote,
78+
createdAt: schema.pageComments.createdAt,
79+
editedAt: schema.pageComments.editedAt,
80+
authorName: schema.user.name,
81+
authorImage: schema.user.image,
82+
})
83+
.from(schema.pageComments)
84+
.innerJoin(schema.user, eq(schema.pageComments.userId, schema.user.id))
85+
.where(
86+
and(
87+
eq(schema.pageComments.page, page),
88+
isNull(schema.pageComments.deletedAt),
89+
userId
90+
? sql`(${schema.pageComments.approvedAt} IS NOT NULL OR ${schema.pageComments.userId} = ${userId})`
91+
: isNotNull(schema.pageComments.approvedAt),
92+
),
93+
)
94+
.orderBy(schema.pageComments.createdAt)
95+
96+
const byAnchor: Record<string, typeof comments> = {}
97+
for (const comment of comments) {
98+
const key = comment.anchor
99+
if (!byAnchor[key]) byAnchor[key] = []
100+
byAnchor[key].push(comment)
101+
}
102+
103+
return c.json({ comments: byAnchor })
104+
})
105+
106+
.post('/pages/:page/comments', requireAuth('write'), async (c) => {
107+
const page = c.req.param('page')
108+
const userId = c.get('userId')!
109+
const rateKey = `discussion:${userId}`
110+
if (!checkRateLimit(rateKey)) {
111+
return c.json({ error: 'Rate limit exceeded', statusCode: 429 }, 429)
112+
}
113+
114+
const parsed = createBody.safeParse(await c.req.json())
115+
if (!parsed.success) {
116+
return c.json(
117+
{ error: 'Invalid request', details: parsed.error.flatten(), statusCode: 400 },
118+
400,
119+
)
120+
}
121+
122+
const { anchor, quote, quoteContext, parentId, body } = parsed.data
123+
124+
if (parentId) {
125+
const [parent] = await db
126+
.select({ id: schema.pageComments.id, parentId: schema.pageComments.parentId })
127+
.from(schema.pageComments)
128+
.where(
129+
and(
130+
eq(schema.pageComments.id, parentId),
131+
eq(schema.pageComments.page, page),
132+
isNull(schema.pageComments.deletedAt),
133+
),
134+
)
135+
.limit(1)
136+
if (!parent) {
137+
return c.json({ error: 'Parent comment not found', statusCode: 404 }, 404)
138+
}
139+
if (parent.parentId) {
140+
return c.json({ error: 'Cannot nest replies deeper than one level', statusCode: 400 }, 400)
141+
}
142+
}
143+
144+
const [comment] = await db
145+
.insert(schema.pageComments)
146+
.values({
147+
page,
148+
anchor,
149+
quote: quote ?? null,
150+
quoteContext: quoteContext ?? null,
151+
parentId: parentId ?? null,
152+
userId,
153+
body,
154+
})
155+
.returning()
156+
157+
return c.json({ comment }, 201)
158+
})
159+
160+
.patch('/pages/:page/comments/:id', requireAuth('write'), async (c) => {
161+
const commentId = c.req.param('id')
162+
const userId = c.get('userId')!
163+
164+
const parsed = patchBody.safeParse(await c.req.json())
165+
if (!parsed.success) {
166+
return c.json(
167+
{ error: 'Invalid request', details: parsed.error.flatten(), statusCode: 400 },
168+
400,
169+
)
170+
}
171+
172+
const [existing] = await db
173+
.select()
174+
.from(schema.pageComments)
175+
.where(and(eq(schema.pageComments.id, commentId), isNull(schema.pageComments.deletedAt)))
176+
.limit(1)
177+
178+
if (!existing) {
179+
return c.json({ error: 'Comment not found', statusCode: 404 }, 404)
180+
}
181+
182+
const steward = await isSteward(userId)
183+
const isAuthor = existing.userId === userId
184+
185+
if (parsed.data.body) {
186+
if (!isAuthor) {
187+
return c.json({ error: "Cannot edit another user's comment", statusCode: 403 }, 403)
188+
}
189+
if (existing.approvedAt && !steward) {
190+
return c.json({ error: 'Cannot edit an approved comment', statusCode: 403 }, 403)
191+
}
192+
}
193+
194+
if (
195+
parsed.data.approve !== undefined ||
196+
parsed.data.status ||
197+
parsed.data.resolutionNote !== undefined
198+
) {
199+
if (!steward) {
200+
return c.json({ error: 'Steward access required', statusCode: 403 }, 403)
201+
}
202+
}
203+
204+
const updates: Record<string, any> = {}
205+
if (parsed.data.body) {
206+
updates.body = parsed.data.body
207+
updates.editedAt = new Date()
208+
}
209+
if (parsed.data.approve === true && steward) {
210+
updates.approvedAt = new Date()
211+
updates.approvedBy = userId
212+
}
213+
if (parsed.data.status && steward) {
214+
updates.status = parsed.data.status
215+
}
216+
if (parsed.data.resolutionNote !== undefined && steward) {
217+
updates.resolutionNote = parsed.data.resolutionNote
218+
}
219+
220+
if (Object.keys(updates).length === 0) {
221+
return c.json({ error: 'No changes', statusCode: 400 }, 400)
222+
}
223+
224+
const [updated] = await db
225+
.update(schema.pageComments)
226+
.set(updates)
227+
.where(eq(schema.pageComments.id, commentId))
228+
.returning()
229+
230+
return c.json({ comment: updated })
231+
})
232+
233+
.delete('/pages/:page/comments/:id', requireAuth('write'), async (c) => {
234+
const commentId = c.req.param('id')
235+
const userId = c.get('userId')!
236+
237+
const [existing] = await db
238+
.select({ id: schema.pageComments.id, userId: schema.pageComments.userId })
239+
.from(schema.pageComments)
240+
.where(and(eq(schema.pageComments.id, commentId), isNull(schema.pageComments.deletedAt)))
241+
.limit(1)
242+
243+
if (!existing) {
244+
return c.json({ error: 'Comment not found', statusCode: 404 }, 404)
245+
}
246+
247+
const steward = await isSteward(userId)
248+
if (existing.userId !== userId && !steward) {
249+
return c.json({ error: 'Forbidden', statusCode: 403 }, 403)
250+
}
251+
252+
await db
253+
.update(schema.pageComments)
254+
.set({ deletedAt: new Date() })
255+
.where(eq(schema.pageComments.id, commentId))
256+
257+
return c.json({ ok: true })
258+
})
259+
260+
.get('/admin/discussion', requireAuth('write'), async (c) => {
261+
const userId = c.get('userId')!
262+
const steward = await isSteward(userId)
263+
if (!steward) {
264+
return c.json({ error: 'Steward access required', statusCode: 403 }, 403)
265+
}
266+
267+
const pending = await db
268+
.select({
269+
id: schema.pageComments.id,
270+
page: schema.pageComments.page,
271+
anchor: schema.pageComments.anchor,
272+
quote: schema.pageComments.quote,
273+
body: schema.pageComments.body,
274+
createdAt: schema.pageComments.createdAt,
275+
authorName: schema.user.name,
276+
authorImage: schema.user.image,
277+
})
278+
.from(schema.pageComments)
279+
.innerJoin(schema.user, eq(schema.pageComments.userId, schema.user.id))
280+
.where(and(isNull(schema.pageComments.approvedAt), isNull(schema.pageComments.deletedAt)))
281+
.orderBy(schema.pageComments.createdAt)
282+
283+
const allComments = await db
284+
.select({
285+
id: schema.pageComments.id,
286+
page: schema.pageComments.page,
287+
anchor: schema.pageComments.anchor,
288+
quote: schema.pageComments.quote,
289+
body: schema.pageComments.body,
290+
status: schema.pageComments.status,
291+
resolutionNote: schema.pageComments.resolutionNote,
292+
approvedAt: schema.pageComments.approvedAt,
293+
createdAt: schema.pageComments.createdAt,
294+
authorName: schema.user.name,
295+
})
296+
.from(schema.pageComments)
297+
.innerJoin(schema.user, eq(schema.pageComments.userId, schema.user.id))
298+
.where(and(isNull(schema.pageComments.deletedAt), isNull(schema.pageComments.parentId)))
299+
.orderBy(desc(schema.pageComments.createdAt))
300+
301+
return c.json({ pending, threads: allComments })
302+
})
303+
304+
export default app

src/components/BaseLayout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useAppContext } from '~/lib/app-context'
55

66
export default function BaseLayout({ children }: { children: React.ReactNode }) {
77
const { currentUser, mirrorConfig } = useAppContext()
8+
const isSteward = currentUser?.kfRole === 'admin'
89

910
return (
1011
<>
@@ -49,6 +50,7 @@ export default function BaseLayout({ children }: { children: React.ReactNode })
4950
slug={currentUser.slug}
5051
displayName={currentUser.displayName}
5152
orgs={currentUser.orgs ?? []}
53+
isSteward={isSteward}
5254
/>
5355
) : (
5456
<a href="/login" className="hover:text-ink transition-colors">

0 commit comments

Comments
 (0)