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 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;