Skip to content

Commit 2b5a106

Browse files
committed
Add API extensions and Canny adapter for API-to-API imports
Extend the REST API to support all operations needed for importing data without direct database access, and add a complete Canny import pipeline that works purely via HTTP. API extensions: - Add createdAt to post, comment, and vote write endpoints (admin-only) - Add X-Import-Mode header: suppresses webhooks/Slack/AI side effects and raises rate limit to 2000 req/min (admin-only) - Add POST /api/v1/posts/:postId/notes for internal notes - Add POST /api/v1/posts/:postId/merge for merge relationships - Add linkedPostIds to POST /api/v1/changelog Canny adapter: - Fix vote.author -> vote.voter field mapping (was silently dropping all votes) - Add QuackbackClient with auth, import mode, retry, rate awareness - Add API-based import orchestrator (users, posts, comments, votes, merges, changelogs) - Add --quackback-url and --quackback-key CLI flags for API mode - Topological sort for comment threading (parents before children) Also adds core importer support for changelogs, notes, and merged post relationships via direct DB mode.
1 parent bbd47cf commit 2b5a106

27 files changed

Lines changed: 1787 additions & 84 deletions

apps/web/src/lib/server/domains/api/__tests__/auth.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ describe('API Auth', () => {
9494
apiKey: mockApiKey,
9595
principalId: mockApiKey.principalId,
9696
role: 'admin',
97+
importMode: false,
9798
})
9899
})
99100

@@ -163,6 +164,7 @@ describe('API Auth', () => {
163164
apiKey: mockApiKey,
164165
principalId: mockApiKey.principalId,
165166
role: 'admin',
167+
importMode: false,
166168
})
167169
})
168170

apps/web/src/lib/server/domains/api/auth.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export interface ApiAuthContext {
2121
principalId: PrincipalId
2222
/** The role of the member who created the key */
2323
role: MemberRole
24+
/** Whether the request is in import mode (suppresses side effects, raises rate limit) */
25+
importMode: boolean
2426
}
2527

2628
/**
@@ -70,6 +72,7 @@ export async function requireApiKey(request: Request): Promise<ApiAuthContext |
7072
apiKey,
7173
principalId: apiKey.principalId,
7274
role,
75+
importMode: false,
7376
}
7477
}
7578

@@ -91,9 +94,10 @@ export async function withApiKeyAuth(
9194
request: Request,
9295
options: { role: AuthLevel }
9396
): Promise<ApiAuthContext | Response> {
94-
// Check rate limit before processing
97+
// Check rate limit before processing (import mode gets higher limit)
9598
const clientIp = getClientIp(request)
96-
const rateLimit = checkRateLimit(clientIp)
99+
const wantsImportMode = request.headers.get('x-import-mode') === 'true'
100+
const rateLimit = checkRateLimit(clientIp, wantsImportMode)
97101

98102
if (!rateLimit.allowed) {
99103
return rateLimitedResponse(rateLimit.retryAfter ?? 60)
@@ -116,5 +120,10 @@ export async function withApiKeyAuth(
116120
return forbiddenResponse('Team member access required for this operation')
117121
}
118122

123+
// Import mode requires admin role
124+
if (wantsImportMode && isAdmin(auth.role)) {
125+
auth.importMode = true
126+
}
127+
119128
return auth
120129
}

apps/web/src/lib/server/domains/api/rate-limit.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const rateLimitStore = new Map<string, RateLimitEntry>()
1919
// Configuration
2020
const WINDOW_MS = 60_000 // 1 minute
2121
const MAX_REQUESTS = 100 // 100 requests per minute per IP
22+
const MAX_REQUESTS_IMPORT = 2000 // 2000 requests per minute per IP in import mode
2223
const MAX_STORE_SIZE = 50_000 // Cap store size to prevent memory exhaustion
2324
const CLEANUP_INTERVAL_MS = 60_000 // Cleanup every minute
2425

@@ -45,14 +46,19 @@ startCleanup()
4546
* Check if a request is rate limited.
4647
*
4748
* @param ip - The client IP address
49+
* @param importMode - Whether the request is in import mode (higher limit)
4850
* @returns Object with allowed flag and remaining requests
4951
*/
50-
export function checkRateLimit(ip: string): {
52+
export function checkRateLimit(
53+
ip: string,
54+
importMode?: boolean
55+
): {
5156
allowed: boolean
5257
remaining: number
5358
retryAfter?: number
5459
} {
5560
const now = Date.now()
61+
const maxRequests = importMode ? MAX_REQUESTS_IMPORT : MAX_REQUESTS
5662
const entry = rateLimitStore.get(ip)
5763

5864
// New IP or window expired - reset
@@ -62,18 +68,18 @@ export function checkRateLimit(ip: string): {
6268
return { allowed: false, remaining: 0, retryAfter: 60 }
6369
}
6470
rateLimitStore.set(ip, { count: 1, windowStart: now })
65-
return { allowed: true, remaining: MAX_REQUESTS - 1 }
71+
return { allowed: true, remaining: maxRequests - 1 }
6672
}
6773

6874
// Within window - increment and check
6975
entry.count++
7076

71-
if (entry.count > MAX_REQUESTS) {
77+
if (entry.count > maxRequests) {
7278
const retryAfter = Math.ceil((entry.windowStart + WINDOW_MS - now) / 1000)
7379
return { allowed: false, remaining: 0, retryAfter }
7480
}
7581

76-
return { allowed: true, remaining: MAX_REQUESTS - entry.count }
82+
return { allowed: true, remaining: maxRequests - entry.count }
7783
}
7884

7985
/**

apps/web/src/lib/server/domains/comments/comment.service.ts

Lines changed: 34 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,8 @@ export async function createComment(
102102
email?: string
103103
displayName?: string
104104
role: 'admin' | 'member' | 'user'
105-
}
105+
},
106+
options?: { skipDispatch?: boolean }
106107
): Promise<CreateCommentResult> {
107108
console.log(
108109
`[domain:comments] createComment: postId=${input.postId}, parentId=${input.parentId ?? 'none'}`
@@ -203,6 +204,7 @@ export async function createComment(
203204
isPrivate,
204205
statusChangeFromId: prevStatus?.id ?? null,
205206
statusChangeToId: newStatus.id,
207+
...(input.createdAt && { createdAt: input.createdAt }),
206208
})
207209
.returning()
208210

@@ -231,6 +233,7 @@ export async function createComment(
231233
principalId: author.principalId,
232234
isTeamMember: authorIsTeamMember,
233235
isPrivate,
236+
...(input.createdAt && { createdAt: input.createdAt }),
234237
})
235238
.returning()
236239

@@ -248,43 +251,45 @@ export async function createComment(
248251
comment = result
249252
}
250253

251-
// Auto-subscribe commenter to the post
252-
if (author.principalId) {
253-
await subscribeToPost(author.principalId, input.postId, 'comment')
254-
}
255-
256-
// Dispatch comment.created event for webhooks, Slack, etc.
257-
const actorName = author.displayName ?? author.name
258-
await dispatchCommentCreated(
259-
buildEventActor(author),
260-
{
261-
id: comment.id,
262-
content: comment.content,
263-
authorName: actorName,
264-
authorEmail: author.email,
265-
isPrivate,
266-
},
267-
{
268-
id: post.id,
269-
title: post.title,
270-
boardId: board.id,
271-
boardSlug: board.slug,
254+
if (!options?.skipDispatch) {
255+
// Auto-subscribe commenter to the post
256+
if (author.principalId) {
257+
await subscribeToPost(author.principalId, input.postId, 'comment')
272258
}
273-
)
274259

275-
// Dispatch status change event if status was changed
276-
if (shouldChangeStatus && previousStatusName && newStatusName) {
277-
await dispatchPostStatusChanged(
260+
// Dispatch comment.created event for webhooks, Slack, etc.
261+
const actorName = author.displayName ?? author.name
262+
await dispatchCommentCreated(
278263
buildEventActor(author),
264+
{
265+
id: comment.id,
266+
content: comment.content,
267+
authorName: actorName,
268+
authorEmail: author.email,
269+
isPrivate,
270+
},
279271
{
280272
id: post.id,
281273
title: post.title,
282274
boardId: board.id,
283275
boardSlug: board.slug,
284-
},
285-
previousStatusName,
286-
newStatusName
276+
}
287277
)
278+
279+
// Dispatch status change event if status was changed
280+
if (shouldChangeStatus && previousStatusName && newStatusName) {
281+
await dispatchPostStatusChanged(
282+
buildEventActor(author),
283+
{
284+
id: post.id,
285+
title: post.title,
286+
boardId: board.id,
287+
boardSlug: board.slug,
288+
},
289+
previousStatusName,
290+
newStatusName
291+
)
292+
}
288293
}
289294

290295
return { comment, post: { id: post.id, title: post.title, boardSlug: board.slug } }

apps/web/src/lib/server/domains/comments/comment.types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export interface CreateCommentInput {
1616
statusId?: StatusId | null
1717
/** Whether this comment is only visible to team members */
1818
isPrivate?: boolean
19+
/** Override creation timestamp (admin-only, for imports) */
20+
createdAt?: Date
1921
}
2022

2123
/**

apps/web/src/lib/server/domains/posts/post.service.ts

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ export async function createPost(
6969
name?: string
7070
email?: string
7171
displayName?: string
72-
}
72+
},
73+
options?: { skipDispatch?: boolean }
7374
): Promise<CreatePostResult> {
7475
console.log(`[domain:posts] createPost: boardId=${input.boardId}`)
7576
// Basic validation (also done at action layer, but enforced here for direct service calls)
@@ -132,6 +133,7 @@ export async function createPost(
132133
statusId,
133134
principalId: author.principalId,
134135
widgetMetadata: input.widgetMetadata ?? null,
136+
...(input.createdAt && { createdAt: input.createdAt }),
135137
})
136138
.returning()
137139

@@ -142,28 +144,30 @@ export async function createPost(
142144
await db.insert(postTags).values(input.tagIds.map((tagId) => ({ postId: post.id, tagId })))
143145
}
144146

145-
// Auto-subscribe the author to their own post
146-
await subscribeToPost(author.principalId, post.id, 'author')
147-
148-
// Dispatch post.created event for webhooks, Slack, AI processing, etc.
149-
const actorName = author.displayName ?? author.name
150-
await dispatchPostCreated(buildEventActor(author), {
151-
id: post.id,
152-
title: post.title,
153-
content: post.content,
154-
boardId: post.boardId,
155-
boardSlug: board.slug,
156-
authorEmail: author.email,
157-
authorName: actorName,
158-
voteCount: post.voteCount,
159-
})
147+
if (!options?.skipDispatch) {
148+
// Auto-subscribe the author to their own post
149+
await subscribeToPost(author.principalId, post.id, 'author')
150+
151+
// Dispatch post.created event for webhooks, Slack, AI processing, etc.
152+
const actorName = author.displayName ?? author.name
153+
await dispatchPostCreated(buildEventActor(author), {
154+
id: post.id,
155+
title: post.title,
156+
content: post.content,
157+
boardId: post.boardId,
158+
boardSlug: board.slug,
159+
authorEmail: author.email,
160+
authorName: actorName,
161+
voteCount: post.voteCount,
162+
})
160163

161-
createActivity({
162-
postId: post.id,
163-
principalId: author.principalId,
164-
type: 'post.created',
165-
metadata: { boardName: board.name },
166-
})
164+
createActivity({
165+
postId: post.id,
166+
principalId: author.principalId,
167+
type: 'post.created',
168+
metadata: { boardName: board.name },
169+
})
170+
}
167171

168172
return { ...post, boardSlug: board.slug }
169173
}

apps/web/src/lib/server/domains/posts/post.types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export interface CreatePostInput {
1717
statusId?: StatusId
1818
tagIds?: TagId[]
1919
widgetMetadata?: Record<string, string>
20+
/** Override creation timestamp (admin-only, for imports) */
21+
createdAt?: Date
2022
}
2123

2224
/**

apps/web/src/lib/server/domains/posts/post.voting.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,8 @@ export async function addVoteOnBehalf(
166166
principalId: PrincipalId,
167167
source?: { type: string; externalUrl: string },
168168
feedbackSuggestionId?: string | null,
169-
addedByPrincipalId?: PrincipalId
169+
addedByPrincipalId?: PrincipalId,
170+
createdAt?: Date
170171
): Promise<VoteResult> {
171172
const postUuid = toUuid(postId)
172173
const principalUuid = toUuid(principalId)
@@ -177,6 +178,7 @@ export async function addVoteOnBehalf(
177178
const sourceExternalUrl = source?.externalUrl ?? null
178179
const suggestionUuid = feedbackSuggestionId ? toUuid(feedbackSuggestionId) : null
179180
const addedByUuid = addedByPrincipalId ? toUuid(addedByPrincipalId) : null
181+
const createdAtSql = createdAt ? sql`${createdAt.toISOString()}::timestamptz` : sql`NOW()`
180182

181183
// Single atomic CTE: validate post/board, insert vote (never delete), update count, auto-subscribe
182184
const result = await db.execute<{
@@ -194,8 +196,8 @@ export async function addVoteOnBehalf(
194196
WHERE id = (SELECT board_id FROM post_check)
195197
),
196198
inserted AS (
197-
INSERT INTO ${votes} (id, post_id, principal_id, source_type, source_external_url, feedback_suggestion_id, added_by_principal_id, updated_at)
198-
SELECT ${voteId}::uuid, ${postUuid}::uuid, ${principalUuid}::uuid, ${sourceType}, ${sourceExternalUrl}, ${suggestionUuid}::uuid, ${addedByUuid}::uuid, NOW()
199+
INSERT INTO ${votes} (id, post_id, principal_id, source_type, source_external_url, feedback_suggestion_id, added_by_principal_id, created_at, updated_at)
200+
SELECT ${voteId}::uuid, ${postUuid}::uuid, ${principalUuid}::uuid, ${sourceType}, ${sourceExternalUrl}, ${suggestionUuid}::uuid, ${addedByUuid}::uuid, ${createdAtSql}, ${createdAtSql}
199201
WHERE EXISTS (SELECT 1 FROM post_check)
200202
AND EXISTS (SELECT 1 FROM board_check)
201203
ON CONFLICT (post_id, principal_id) DO NOTHING

apps/web/src/routes/api/v1/changelog/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@ import {
1010
import { listChangelogs, createChangelog } from '@/lib/server/domains/changelog'
1111
import { publishedAtToPublishState } from '@/lib/shared/schemas/changelog'
1212
import { db, principal, eq } from '@/lib/server/db'
13+
import type { PostId } from '@quackback/ids'
1314

1415
// Input validation schema
1516
const createChangelogSchema = z.object({
1617
title: z.string().min(1, 'Title is required').max(200),
1718
content: z.string().min(1, 'Content is required'),
1819
publishedAt: z.string().datetime().optional(),
20+
linkedPostIds: z.array(z.string()).optional(),
1921
})
2022

2123
export const Route = createFileRoute('/api/v1/changelog/')({
@@ -103,6 +105,7 @@ export const Route = createFileRoute('/api/v1/changelog/')({
103105
title: parsed.data.title,
104106
content: parsed.data.content,
105107
publishState,
108+
linkedPostIds: parsed.data.linkedPostIds as PostId[] | undefined,
106109
},
107110
{
108111
principalId: authResult.principalId,

apps/web/src/routes/api/v1/posts/$postId.comments.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const createCommentSchema = z.object({
1515
content: z.string().min(1, 'Content is required').max(5000),
1616
parentId: z.string().optional().nullable(),
1717
isPrivate: z.boolean().optional(),
18+
createdAt: z.string().datetime().optional(),
1819
})
1920

2021
export const Route = createFileRoute('/api/v1/posts/$postId/comments')({
@@ -114,12 +115,19 @@ export const Route = createFileRoute('/api/v1/posts/$postId/comments')({
114115
return badRequestResponse('Principal not found')
115116
}
116117

118+
// Only admins can set createdAt (for imports)
119+
const createdAt =
120+
parsed.data.createdAt && authResult.role === 'admin'
121+
? new Date(parsed.data.createdAt)
122+
: undefined
123+
117124
const result = await createComment(
118125
{
119126
postId: postId as PostId,
120127
content: parsed.data.content,
121128
parentId: parsed.data.parentId as CommentId | undefined,
122129
isPrivate: parsed.data.isPrivate,
130+
createdAt,
123131
},
124132
{
125133
principalId,
@@ -128,7 +136,8 @@ export const Route = createFileRoute('/api/v1/posts/$postId/comments')({
128136
name: principalRecord.user?.name,
129137
email: principalRecord.user?.email ?? undefined,
130138
role: principalRecord.role as 'admin' | 'member' | 'user',
131-
}
139+
},
140+
{ skipDispatch: authResult.importMode }
132141
)
133142

134143
return createdResponse({

0 commit comments

Comments
 (0)