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.

at cd7cada2f86b4e866a15b4323bb8d6d7ab5bba8b 312 lines 8.9 kB view raw
1import { and, eq } from "drizzle-orm"; 2import { Hono } from "hono"; 3import { HTTPException } from "hono/http-exception"; 4import { describeRoute, resolver, validator } from "hono-openapi"; 5import * as v from "valibot"; 6import db from "../database"; 7import { integrationTable } from "../database/schema"; 8import { 9 type DiscordConfig, 10 defaultDiscordEvents, 11 normalizeDiscordConfig, 12 validateDiscordConfig, 13} from "../plugins/discord/config"; 14import { discordIntegrationSchema } from "../schemas"; 15import { workspaceAccess } from "../utils/workspace-access-middleware"; 16 17const discordIntegration = new Hono<{ 18 Variables: { 19 userId: string; 20 workspaceId: string; 21 apiKey?: { 22 id: string; 23 userId: string; 24 enabled: boolean; 25 }; 26 }; 27}>(); 28 29function maskWebhookUrl(value: string): string { 30 try { 31 const url = new URL(value); 32 const parts = url.pathname.split("/").filter(Boolean); 33 const last = parts[parts.length - 1] ?? ""; 34 const maskedLast = 35 last.length > 8 ? `${last.slice(0, 4)}${last.slice(-4)}` : "••••"; 36 return `${url.origin}/${parts.slice(0, -1).join("/")}/${maskedLast}`; 37 } catch { 38 return "Configured"; 39 } 40} 41 42function toResponse(integration: { 43 id: string; 44 projectId: string; 45 config: string; 46 isActive: boolean | null; 47 createdAt: Date; 48 updatedAt: Date; 49}) { 50 const config = normalizeDiscordConfig( 51 JSON.parse(integration.config) as DiscordConfig, 52 ); 53 54 return { 55 id: integration.id, 56 projectId: integration.projectId, 57 channelName: config.channelName ?? null, 58 webhookConfigured: Boolean(config.webhookUrl), 59 maskedWebhookUrl: config.webhookUrl 60 ? maskWebhookUrl(config.webhookUrl) 61 : "", 62 events: { 63 ...defaultDiscordEvents, 64 ...(config.events ?? {}), 65 }, 66 isActive: integration.isActive, 67 createdAt: integration.createdAt, 68 updatedAt: integration.updatedAt, 69 }; 70} 71 72async function getDiscordIntegration(projectId: string) { 73 const integration = await db.query.integrationTable.findFirst({ 74 where: and( 75 eq(integrationTable.projectId, projectId), 76 eq(integrationTable.type, "discord"), 77 ), 78 }); 79 80 if (!integration) { 81 return null; 82 } 83 84 return toResponse(integration); 85} 86 87const discordEventsSchema = v.object({ 88 taskCreated: v.optional(v.boolean()), 89 taskStatusChanged: v.optional(v.boolean()), 90 taskPriorityChanged: v.optional(v.boolean()), 91 taskTitleChanged: v.optional(v.boolean()), 92 taskDescriptionChanged: v.optional(v.boolean()), 93 taskCommentCreated: v.optional(v.boolean()), 94}); 95 96const nullableDiscordIntegrationSchema = v.nullable(discordIntegrationSchema); 97 98discordIntegration 99 .get( 100 "/project/:projectId", 101 describeRoute({ 102 operationId: "getDiscordIntegration", 103 tags: ["Discord"], 104 description: "Get Discord integration for a project", 105 responses: { 106 200: { 107 description: "Discord integration details", 108 content: { 109 "application/json": { 110 schema: resolver(nullableDiscordIntegrationSchema), 111 }, 112 }, 113 }, 114 }, 115 }), 116 validator("param", v.object({ projectId: v.string() })), 117 workspaceAccess.fromProject("projectId"), 118 async (c) => { 119 const { projectId } = c.req.valid("param"); 120 const integration = await getDiscordIntegration(projectId); 121 return c.json(integration); 122 }, 123 ) 124 .post( 125 "/project/:projectId", 126 describeRoute({ 127 operationId: "createDiscordIntegration", 128 tags: ["Discord"], 129 description: "Create or replace a Discord integration for a project", 130 responses: { 131 200: { 132 description: "Discord integration created successfully", 133 content: { 134 "application/json": { 135 schema: resolver(discordIntegrationSchema), 136 }, 137 }, 138 }, 139 }, 140 }), 141 validator("param", v.object({ projectId: v.string() })), 142 validator( 143 "json", 144 v.object({ 145 webhookUrl: v.pipe(v.string(), v.minLength(1)), 146 channelName: v.optional(v.string()), 147 events: v.optional(discordEventsSchema), 148 }), 149 ), 150 workspaceAccess.fromProject("projectId"), 151 async (c) => { 152 const { projectId } = c.req.valid("param"); 153 const body = c.req.valid("json"); 154 155 const config = normalizeDiscordConfig({ 156 webhookUrl: body.webhookUrl, 157 channelName: body.channelName, 158 events: body.events, 159 }); 160 161 const validation = await validateDiscordConfig(config); 162 if (!validation.valid) { 163 throw new HTTPException(400, { 164 message: validation.errors?.join(", ") ?? "Invalid config", 165 }); 166 } 167 168 await db 169 .insert(integrationTable) 170 .values({ 171 projectId, 172 type: "discord", 173 config: JSON.stringify(config), 174 isActive: true, 175 }) 176 .onConflictDoUpdate({ 177 target: [integrationTable.projectId, integrationTable.type], 178 set: { 179 config: JSON.stringify(config), 180 isActive: true, 181 updatedAt: new Date(), 182 }, 183 }); 184 185 const integration = await getDiscordIntegration(projectId); 186 return c.json(integration); 187 }, 188 ) 189 .patch( 190 "/project/:projectId", 191 describeRoute({ 192 operationId: "updateDiscordIntegration", 193 tags: ["Discord"], 194 description: "Update Discord integration settings", 195 responses: { 196 200: { 197 description: "Discord integration updated successfully", 198 content: { 199 "application/json": { schema: resolver(discordIntegrationSchema) }, 200 }, 201 }, 202 }, 203 }), 204 validator("param", v.object({ projectId: v.string() })), 205 validator( 206 "json", 207 v.object({ 208 webhookUrl: v.optional(v.string()), 209 channelName: v.optional(v.nullable(v.string())), 210 isActive: v.optional(v.boolean()), 211 events: v.optional(discordEventsSchema), 212 }), 213 ), 214 workspaceAccess.fromProject("projectId"), 215 async (c) => { 216 const { projectId } = c.req.valid("param"); 217 const body = c.req.valid("json"); 218 219 const existing = await db.query.integrationTable.findFirst({ 220 where: and( 221 eq(integrationTable.projectId, projectId), 222 eq(integrationTable.type, "discord"), 223 ), 224 }); 225 226 if (!existing) { 227 throw new HTTPException(404, { 228 message: "Discord integration not found", 229 }); 230 } 231 232 const currentConfig = normalizeDiscordConfig( 233 JSON.parse(existing.config) as DiscordConfig, 234 ); 235 const nextConfig = normalizeDiscordConfig({ 236 webhookUrl: body.webhookUrl?.trim() || currentConfig.webhookUrl, 237 channelName: 238 body.channelName === undefined 239 ? currentConfig.channelName 240 : (body.channelName ?? undefined), 241 events: { 242 ...(currentConfig.events ?? {}), 243 ...(body.events ?? {}), 244 }, 245 }); 246 247 const validation = await validateDiscordConfig(nextConfig); 248 if (!validation.valid) { 249 throw new HTTPException(400, { 250 message: validation.errors?.join(", ") ?? "Invalid config", 251 }); 252 } 253 254 await db 255 .update(integrationTable) 256 .set({ 257 config: JSON.stringify(nextConfig), 258 isActive: 259 body.isActive !== undefined 260 ? body.isActive 261 : (existing.isActive ?? true), 262 updatedAt: new Date(), 263 }) 264 .where(eq(integrationTable.id, existing.id)); 265 266 const integration = await getDiscordIntegration(projectId); 267 return c.json(integration); 268 }, 269 ) 270 .delete( 271 "/project/:projectId", 272 describeRoute({ 273 operationId: "deleteDiscordIntegration", 274 tags: ["Discord"], 275 description: "Delete Discord integration for a project", 276 responses: { 277 200: { 278 description: "Discord integration deleted successfully", 279 content: { 280 "application/json": { 281 schema: resolver(v.object({ success: v.boolean() })), 282 }, 283 }, 284 }, 285 }, 286 }), 287 validator("param", v.object({ projectId: v.string() })), 288 workspaceAccess.fromProject("projectId"), 289 async (c) => { 290 const { projectId } = c.req.valid("param"); 291 292 const existing = await db.query.integrationTable.findFirst({ 293 where: and( 294 eq(integrationTable.projectId, projectId), 295 eq(integrationTable.type, "discord"), 296 ), 297 }); 298 299 if (!existing) { 300 throw new HTTPException(404, { 301 message: "Discord integration not found", 302 }); 303 } 304 305 await db 306 .delete(integrationTable) 307 .where(eq(integrationTable.id, existing.id)); 308 return c.json({ success: true }); 309 }, 310 ); 311 312export default discordIntegration;