kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(api): tighten webhook URL validation and dedupe Discord event handling

Tin 5e834c23 211280ea

+65 -99
+56 -97
apps/api/src/plugins/discord/events.ts
··· 66 66 projectId: string, 67 67 userId: string | null, 68 68 ): Promise<DiscordEventData | null> { 69 - const [taskRow] = await db 69 + const taskPromise = db 70 70 .select({ 71 71 title: taskTable.title, 72 72 number: taskTable.number, ··· 82 82 .where(and(eq(taskTable.id, taskId), eq(projectTable.id, projectId))) 83 83 .limit(1); 84 84 85 - if (!taskRow) { 86 - return null; 87 - } 88 - 89 - const [user] = userId 90 - ? await db 85 + const userPromise = userId 86 + ? db 91 87 .select({ name: userTable.name }) 92 88 .from(userTable) 93 89 .where(eq(userTable.id, userId)) 94 90 .limit(1) 95 - : []; 91 + : Promise.resolve([]); 92 + 93 + const [[taskRow], [user]] = await Promise.all([taskPromise, userPromise]); 94 + 95 + if (!taskRow) { 96 + return null; 97 + } 96 98 97 99 const clientUrl = process.env.KANEO_CLIENT_URL || "http://localhost:5173"; 98 100 const taskUrl = `${clientUrl}/dashboard/workspace/${taskRow.workspaceId}/project/${taskRow.projectId}/task/${taskId}`; ··· 169 171 } 170 172 } 171 173 172 - export async function handleTaskCreated( 173 - event: TaskCreatedEvent, 174 + type DiscordMessageContent = { 175 + title: string; 176 + body: string; 177 + }; 178 + 179 + async function runDiscordHandler( 174 180 context: PluginContext, 181 + event: { 182 + taskId: string; 183 + projectId: string; 184 + userId: string | null; 185 + }, 186 + featureKey: DiscordEventKey, 187 + buildMessage: () => DiscordMessageContent, 175 188 ): Promise<void> { 176 189 const config = normalizeDiscordConfig(context.config as DiscordConfig); 177 - if (!isEnabled(config, "taskCreated")) return; 190 + if (!isEnabled(config, featureKey)) return; 178 191 179 192 const data = await getDiscordEventData( 180 193 event.taskId, ··· 183 196 ); 184 197 if (!data) return; 185 198 186 - await sendDiscordMessage( 187 - config, 188 - "New task created", 189 - `A new task was added: **${event.title}**`, 190 - data, 191 - ); 199 + const { title, body } = buildMessage(); 200 + await sendDiscordMessage(config, title, body, data); 201 + } 202 + 203 + export async function handleTaskCreated( 204 + event: TaskCreatedEvent, 205 + context: PluginContext, 206 + ): Promise<void> { 207 + await runDiscordHandler(context, event, "taskCreated", () => ({ 208 + title: "New task created", 209 + body: `A new task was added: **${event.title}**`, 210 + })); 192 211 } 193 212 194 213 export async function handleTaskStatusChanged( 195 214 event: TaskStatusChangedEvent, 196 215 context: PluginContext, 197 216 ): Promise<void> { 198 - const config = normalizeDiscordConfig(context.config as DiscordConfig); 199 - if (!isEnabled(config, "taskStatusChanged")) return; 200 - 201 - const data = await getDiscordEventData( 202 - event.taskId, 203 - event.projectId, 204 - event.userId, 205 - ); 206 - if (!data) return; 207 - 208 - await sendDiscordMessage( 209 - config, 210 - "Task status changed", 211 - `**${event.title}** moved from **${toSentenceCase(event.oldStatus)}** to **${toSentenceCase(event.newStatus)}**.`, 212 - data, 213 - ); 217 + await runDiscordHandler(context, event, "taskStatusChanged", () => ({ 218 + title: "Task status changed", 219 + body: `**${event.title}** moved from **${toSentenceCase(event.oldStatus)}** to **${toSentenceCase(event.newStatus)}**.`, 220 + })); 214 221 } 215 222 216 223 export async function handleTaskPriorityChanged( 217 224 event: TaskPriorityChangedEvent, 218 225 context: PluginContext, 219 226 ): Promise<void> { 220 - const config = normalizeDiscordConfig(context.config as DiscordConfig); 221 - if (!isEnabled(config, "taskPriorityChanged")) return; 222 - 223 - const data = await getDiscordEventData( 224 - event.taskId, 225 - event.projectId, 226 - event.userId, 227 - ); 228 - if (!data) return; 229 - 230 - await sendDiscordMessage( 231 - config, 232 - "Task priority changed", 233 - `**${event.title}** changed from **${toSentenceCase(event.oldPriority)}** to **${toSentenceCase(event.newPriority)}**.`, 234 - data, 235 - ); 227 + await runDiscordHandler(context, event, "taskPriorityChanged", () => ({ 228 + title: "Task priority changed", 229 + body: `**${event.title}** changed from **${toSentenceCase(event.oldPriority)}** to **${toSentenceCase(event.newPriority)}**.`, 230 + })); 236 231 } 237 232 238 233 export async function handleTaskTitleChanged( 239 234 event: TaskTitleChangedEvent, 240 235 context: PluginContext, 241 236 ): Promise<void> { 242 - const config = normalizeDiscordConfig(context.config as DiscordConfig); 243 - if (!isEnabled(config, "taskTitleChanged")) return; 244 - 245 - const data = await getDiscordEventData( 246 - event.taskId, 247 - event.projectId, 248 - event.userId, 249 - ); 250 - if (!data) return; 251 - 252 - await sendDiscordMessage( 253 - config, 254 - "Task title changed", 255 - `Task renamed from **${truncate(event.oldTitle, 120)}** to **${truncate(event.newTitle, 120)}**.`, 256 - data, 257 - ); 237 + await runDiscordHandler(context, event, "taskTitleChanged", () => ({ 238 + title: "Task title changed", 239 + body: `Task renamed from **${truncate(event.oldTitle, 120)}** to **${truncate(event.newTitle, 120)}**.`, 240 + })); 258 241 } 259 242 260 243 export async function handleTaskDescriptionChanged( 261 244 event: TaskDescriptionChangedEvent, 262 245 context: PluginContext, 263 246 ): Promise<void> { 264 - const config = normalizeDiscordConfig(context.config as DiscordConfig); 265 - if (!isEnabled(config, "taskDescriptionChanged")) return; 266 - 267 - const data = await getDiscordEventData( 268 - event.taskId, 269 - event.projectId, 270 - event.userId, 271 - ); 272 - if (!data) return; 273 - 274 - await sendDiscordMessage( 275 - config, 276 - "Task description changed", 277 - `The task description was updated${event.newDescription ? `: ${truncate(event.newDescription.replace(/\s+/g, " "), 160)}` : "."}`, 278 - data, 279 - ); 247 + await runDiscordHandler(context, event, "taskDescriptionChanged", () => ({ 248 + title: "Task description changed", 249 + body: `The task description was updated${event.newDescription ? `: ${truncate(event.newDescription.replace(/\s+/g, " "), 160)}` : "."}`, 250 + })); 280 251 } 281 252 282 253 export async function handleTaskCommentCreated( 283 254 event: TaskCommentCreatedEvent, 284 255 context: PluginContext, 285 256 ): Promise<void> { 286 - const config = normalizeDiscordConfig(context.config as DiscordConfig); 287 - if (!isEnabled(config, "taskCommentCreated")) return; 288 - 289 - const data = await getDiscordEventData( 290 - event.taskId, 291 - event.projectId, 292 - event.userId, 293 - ); 294 - if (!data) return; 295 - 296 - await sendDiscordMessage( 297 - config, 298 - "New task comment", 299 - truncate(event.comment.replace(/\s+/g, " "), 200), 300 - data, 301 - ); 257 + await runDiscordHandler(context, event, "taskCommentCreated", () => ({ 258 + title: "New task comment", 259 + body: truncate(event.comment.replace(/\s+/g, " "), 200), 260 + })); 302 261 }
+8 -1
apps/api/src/plugins/generic-webhook/config.ts
··· 12 12 export type GenericWebhookEventKey = (typeof genericWebhookEventKeys)[number]; 13 13 14 14 export const genericWebhookConfigSchema = v.object({ 15 - webhookUrl: v.pipe(v.string(), v.url()), 15 + webhookUrl: v.pipe( 16 + v.string(), 17 + v.url(), 18 + v.check((value) => { 19 + const protocol = new URL(value).protocol; 20 + return protocol === "http:" || protocol === "https:"; 21 + }, "Webhook URL must use http or https"), 22 + ), 16 23 secret: v.optional(v.string()), 17 24 health: v.optional( 18 25 v.object({
+1 -1
apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/$projectId/general.tsx
··· 326 326 }} 327 327 modal={true} 328 328 > 329 - <PopoverTrigger> 329 + <PopoverTrigger asChild> 330 330 <Button 331 331 type="button" 332 332 variant="outline"