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 main 325 lines 9.3 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 defaultSlackEvents, 10 normalizeSlackConfig, 11 type SlackConfig, 12 validateSlackConfig, 13} from "../plugins/slack/config"; 14import { slackIntegrationSchema } from "../schemas"; 15import { workspaceAccess } from "../utils/workspace-access-middleware"; 16 17const slackIntegration = 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 = normalizeSlackConfig( 51 JSON.parse(integration.config) as SlackConfig, 52 ); 53 54 return { 55 id: integration.id, 56 projectId: integration.projectId, 57 channelName: config.channelName ?? null, 58 webhookConfigured: Boolean(config.webhookUrl), 59 maskedWebhookUrl: maskWebhookUrl(config.webhookUrl), 60 events: { 61 ...defaultSlackEvents, 62 ...(config.events ?? {}), 63 }, 64 isActive: integration.isActive, 65 createdAt: integration.createdAt, 66 updatedAt: integration.updatedAt, 67 }; 68} 69 70async function getSlackIntegration(projectId: string) { 71 const integration = await db.query.integrationTable.findFirst({ 72 where: and( 73 eq(integrationTable.projectId, projectId), 74 eq(integrationTable.type, "slack"), 75 ), 76 }); 77 78 if (!integration) { 79 return null; 80 } 81 82 return toResponse(integration); 83} 84 85const nullableSlackIntegrationSchema = v.nullable(slackIntegrationSchema); 86 87slackIntegration 88 .get( 89 "/project/:projectId", 90 describeRoute({ 91 operationId: "getSlackIntegration", 92 tags: ["Slack"], 93 description: "Get Slack integration for a project", 94 responses: { 95 200: { 96 description: "Slack integration details", 97 content: { 98 "application/json": { 99 schema: resolver(nullableSlackIntegrationSchema), 100 }, 101 }, 102 }, 103 }, 104 }), 105 validator("param", v.object({ projectId: v.string() })), 106 workspaceAccess.fromProject("projectId"), 107 async (c) => { 108 const { projectId } = c.req.valid("param"); 109 const integration = await getSlackIntegration(projectId); 110 return c.json(integration); 111 }, 112 ) 113 .post( 114 "/project/:projectId", 115 describeRoute({ 116 operationId: "createSlackIntegration", 117 tags: ["Slack"], 118 description: "Create or replace a Slack integration for a project", 119 responses: { 120 200: { 121 description: "Slack integration created successfully", 122 content: { 123 "application/json": { schema: resolver(slackIntegrationSchema) }, 124 }, 125 }, 126 }, 127 }), 128 validator("param", v.object({ projectId: v.string() })), 129 validator( 130 "json", 131 v.object({ 132 webhookUrl: v.pipe(v.string(), v.minLength(1)), 133 channelName: v.optional(v.string()), 134 events: v.optional( 135 v.object({ 136 taskCreated: v.optional(v.boolean()), 137 taskStatusChanged: v.optional(v.boolean()), 138 taskPriorityChanged: v.optional(v.boolean()), 139 taskTitleChanged: v.optional(v.boolean()), 140 taskDescriptionChanged: v.optional(v.boolean()), 141 taskCommentCreated: v.optional(v.boolean()), 142 }), 143 ), 144 }), 145 ), 146 workspaceAccess.fromProject("projectId"), 147 async (c) => { 148 const { projectId } = c.req.valid("param"); 149 const body = c.req.valid("json"); 150 151 const config = normalizeSlackConfig({ 152 webhookUrl: body.webhookUrl, 153 channelName: body.channelName, 154 events: body.events, 155 }); 156 157 const validation = await validateSlackConfig(config); 158 if (!validation.valid) { 159 throw new HTTPException(400, { 160 message: validation.errors?.join(", ") ?? "Invalid config", 161 }); 162 } 163 164 const existing = await db.query.integrationTable.findFirst({ 165 where: and( 166 eq(integrationTable.projectId, projectId), 167 eq(integrationTable.type, "slack"), 168 ), 169 }); 170 171 if (existing) { 172 await db 173 .update(integrationTable) 174 .set({ 175 config: JSON.stringify(config), 176 isActive: true, 177 updatedAt: new Date(), 178 }) 179 .where(eq(integrationTable.id, existing.id)); 180 } else { 181 await db.insert(integrationTable).values({ 182 projectId, 183 type: "slack", 184 config: JSON.stringify(config), 185 isActive: true, 186 }); 187 } 188 189 const integration = await getSlackIntegration(projectId); 190 return c.json(integration); 191 }, 192 ) 193 .patch( 194 "/project/:projectId", 195 describeRoute({ 196 operationId: "updateSlackIntegration", 197 tags: ["Slack"], 198 description: "Update Slack integration settings", 199 responses: { 200 200: { 201 description: "Slack integration updated successfully", 202 content: { 203 "application/json": { schema: resolver(slackIntegrationSchema) }, 204 }, 205 }, 206 }, 207 }), 208 validator("param", v.object({ projectId: v.string() })), 209 validator( 210 "json", 211 v.object({ 212 webhookUrl: v.optional(v.string()), 213 channelName: v.optional(v.nullable(v.string())), 214 isActive: v.optional(v.boolean()), 215 events: v.optional( 216 v.object({ 217 taskCreated: v.optional(v.boolean()), 218 taskStatusChanged: v.optional(v.boolean()), 219 taskPriorityChanged: v.optional(v.boolean()), 220 taskTitleChanged: v.optional(v.boolean()), 221 taskDescriptionChanged: v.optional(v.boolean()), 222 taskCommentCreated: v.optional(v.boolean()), 223 }), 224 ), 225 }), 226 ), 227 workspaceAccess.fromProject("projectId"), 228 async (c) => { 229 const { projectId } = c.req.valid("param"); 230 const body = c.req.valid("json"); 231 232 const existing = await db.query.integrationTable.findFirst({ 233 where: and( 234 eq(integrationTable.projectId, projectId), 235 eq(integrationTable.type, "slack"), 236 ), 237 }); 238 239 if (!existing) { 240 throw new HTTPException(404, { 241 message: "Slack integration not found", 242 }); 243 } 244 245 const currentConfig = normalizeSlackConfig( 246 JSON.parse(existing.config) as SlackConfig, 247 ); 248 const nextConfig = normalizeSlackConfig({ 249 webhookUrl: body.webhookUrl?.trim() || currentConfig.webhookUrl, 250 channelName: 251 body.channelName === undefined 252 ? currentConfig.channelName 253 : (body.channelName ?? undefined), 254 events: { 255 ...(currentConfig.events ?? {}), 256 ...(body.events ?? {}), 257 }, 258 }); 259 260 const validation = await validateSlackConfig(nextConfig); 261 if (!validation.valid) { 262 throw new HTTPException(400, { 263 message: validation.errors?.join(", ") ?? "Invalid config", 264 }); 265 } 266 267 await db 268 .update(integrationTable) 269 .set({ 270 config: JSON.stringify(nextConfig), 271 isActive: 272 body.isActive !== undefined 273 ? body.isActive 274 : (existing.isActive ?? true), 275 updatedAt: new Date(), 276 }) 277 .where(eq(integrationTable.id, existing.id)); 278 279 const integration = await getSlackIntegration(projectId); 280 return c.json(integration); 281 }, 282 ) 283 .delete( 284 "/project/:projectId", 285 describeRoute({ 286 operationId: "deleteSlackIntegration", 287 tags: ["Slack"], 288 description: "Delete Slack integration for a project", 289 responses: { 290 200: { 291 description: "Slack integration deleted successfully", 292 content: { 293 "application/json": { 294 schema: resolver(v.object({ success: v.boolean() })), 295 }, 296 }, 297 }, 298 }, 299 }), 300 validator("param", v.object({ projectId: v.string() })), 301 workspaceAccess.fromProject("projectId"), 302 async (c) => { 303 const { projectId } = c.req.valid("param"); 304 305 const existing = await db.query.integrationTable.findFirst({ 306 where: and( 307 eq(integrationTable.projectId, projectId), 308 eq(integrationTable.type, "slack"), 309 ), 310 }); 311 312 if (!existing) { 313 throw new HTTPException(404, { 314 message: "Slack integration not found", 315 }); 316 } 317 318 await db 319 .delete(integrationTable) 320 .where(eq(integrationTable.id, existing.id)); 321 return c.json({ success: true }); 322 }, 323 ); 324 325export default slackIntegration;