kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
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;