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 defaultGenericWebhookEvents,
10 type GenericWebhookConfig,
11 normalizeGenericWebhookConfig,
12 validateGenericWebhookConfig,
13} from "../plugins/generic-webhook/config";
14import { genericWebhookIntegrationSchema } from "../schemas";
15import { workspaceAccess } from "../utils/workspace-access-middleware";
16
17const genericWebhookIntegration = 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 maskValue(value: string | undefined): string | null {
30 if (!value) return null;
31 return value.length > 8 ? `${value.slice(0, 4)}…${value.slice(-4)}` : "••••";
32}
33
34function toResponse(integration: {
35 id: string;
36 projectId: string;
37 config: string;
38 isActive: boolean | null;
39 createdAt: Date;
40 updatedAt: Date;
41}) {
42 const config = normalizeGenericWebhookConfig(
43 JSON.parse(integration.config) as GenericWebhookConfig,
44 );
45
46 return {
47 id: integration.id,
48 projectId: integration.projectId,
49 webhookConfigured: Boolean(config.webhookUrl),
50 maskedWebhookUrl: maskValue(config.webhookUrl),
51 secretConfigured: Boolean(config.secret),
52 maskedSecret: maskValue(config.secret),
53 events: {
54 ...defaultGenericWebhookEvents,
55 ...(config.events ?? {}),
56 },
57 isActive: integration.isActive,
58 createdAt: integration.createdAt,
59 updatedAt: integration.updatedAt,
60 };
61}
62
63async function getGenericWebhookIntegration(projectId: string) {
64 const integration = await db.query.integrationTable.findFirst({
65 where: and(
66 eq(integrationTable.projectId, projectId),
67 eq(integrationTable.type, "generic-webhook"),
68 ),
69 });
70
71 if (!integration) {
72 return null;
73 }
74
75 return toResponse(integration);
76}
77
78const genericWebhookEventsSchema = v.object({
79 taskCreated: v.optional(v.boolean()),
80 taskStatusChanged: v.optional(v.boolean()),
81 taskPriorityChanged: v.optional(v.boolean()),
82 taskTitleChanged: v.optional(v.boolean()),
83 taskDescriptionChanged: v.optional(v.boolean()),
84 taskCommentCreated: v.optional(v.boolean()),
85});
86
87const nullableGenericWebhookIntegrationSchema = v.nullable(
88 genericWebhookIntegrationSchema,
89);
90
91genericWebhookIntegration
92 .get(
93 "/project/:projectId",
94 describeRoute({
95 operationId: "getGenericWebhookIntegration",
96 tags: ["Generic Webhook"],
97 description: "Get generic outgoing webhook integration for a project",
98 responses: {
99 200: {
100 description: "Generic webhook integration details",
101 content: {
102 "application/json": {
103 schema: resolver(nullableGenericWebhookIntegrationSchema),
104 },
105 },
106 },
107 },
108 }),
109 validator("param", v.object({ projectId: v.string() })),
110 workspaceAccess.fromProject("projectId"),
111 async (c) => {
112 const { projectId } = c.req.valid("param");
113 return c.json(await getGenericWebhookIntegration(projectId));
114 },
115 )
116 .post(
117 "/project/:projectId",
118 describeRoute({
119 operationId: "createGenericWebhookIntegration",
120 tags: ["Generic Webhook"],
121 description: "Create or replace a generic outgoing webhook integration",
122 responses: {
123 200: {
124 description: "Generic webhook integration created successfully",
125 content: {
126 "application/json": {
127 schema: resolver(genericWebhookIntegrationSchema),
128 },
129 },
130 },
131 },
132 }),
133 validator("param", v.object({ projectId: v.string() })),
134 validator(
135 "json",
136 v.object({
137 webhookUrl: v.pipe(v.string(), v.minLength(1)),
138 secret: v.optional(v.string()),
139 events: v.optional(genericWebhookEventsSchema),
140 }),
141 ),
142 workspaceAccess.fromProject("projectId"),
143 async (c) => {
144 const { projectId } = c.req.valid("param");
145 const body = c.req.valid("json");
146
147 const config = normalizeGenericWebhookConfig({
148 webhookUrl: body.webhookUrl,
149 secret: body.secret,
150 events: body.events,
151 });
152
153 const validation = await validateGenericWebhookConfig(config);
154 if (!validation.valid) {
155 throw new HTTPException(400, {
156 message: validation.errors?.join(", ") ?? "Invalid config",
157 });
158 }
159
160 const existing = await db.query.integrationTable.findFirst({
161 where: and(
162 eq(integrationTable.projectId, projectId),
163 eq(integrationTable.type, "generic-webhook"),
164 ),
165 });
166
167 if (existing) {
168 await db
169 .update(integrationTable)
170 .set({
171 config: JSON.stringify(config),
172 isActive: true,
173 updatedAt: new Date(),
174 })
175 .where(eq(integrationTable.id, existing.id));
176 } else {
177 await db.insert(integrationTable).values({
178 projectId,
179 type: "generic-webhook",
180 config: JSON.stringify(config),
181 isActive: true,
182 });
183 }
184
185 return c.json(await getGenericWebhookIntegration(projectId));
186 },
187 )
188 .patch(
189 "/project/:projectId",
190 describeRoute({
191 operationId: "updateGenericWebhookIntegration",
192 tags: ["Generic Webhook"],
193 description: "Update generic outgoing webhook settings",
194 responses: {
195 200: {
196 description: "Generic webhook integration updated successfully",
197 content: {
198 "application/json": {
199 schema: resolver(genericWebhookIntegrationSchema),
200 },
201 },
202 },
203 },
204 }),
205 validator("param", v.object({ projectId: v.string() })),
206 validator(
207 "json",
208 v.object({
209 webhookUrl: v.optional(v.string()),
210 secret: v.optional(v.nullable(v.string())),
211 isActive: v.optional(v.boolean()),
212 events: v.optional(genericWebhookEventsSchema),
213 }),
214 ),
215 workspaceAccess.fromProject("projectId"),
216 async (c) => {
217 const { projectId } = c.req.valid("param");
218 const body = c.req.valid("json");
219
220 const existing = await db.query.integrationTable.findFirst({
221 where: and(
222 eq(integrationTable.projectId, projectId),
223 eq(integrationTable.type, "generic-webhook"),
224 ),
225 });
226
227 if (!existing) {
228 throw new HTTPException(404, {
229 message: "Generic webhook integration not found",
230 });
231 }
232
233 const currentConfig = normalizeGenericWebhookConfig(
234 JSON.parse(existing.config) as GenericWebhookConfig,
235 );
236 const nextConfig = normalizeGenericWebhookConfig({
237 webhookUrl: body.webhookUrl?.trim() || currentConfig.webhookUrl,
238 secret:
239 body.secret === undefined
240 ? currentConfig.secret
241 : (body.secret ?? undefined),
242 events: {
243 ...(currentConfig.events ?? {}),
244 ...(body.events ?? {}),
245 },
246 });
247
248 const validation = await validateGenericWebhookConfig(nextConfig);
249 if (!validation.valid) {
250 throw new HTTPException(400, {
251 message: validation.errors?.join(", ") ?? "Invalid config",
252 });
253 }
254
255 await db
256 .update(integrationTable)
257 .set({
258 config: JSON.stringify(nextConfig),
259 isActive:
260 body.isActive !== undefined
261 ? body.isActive
262 : (existing.isActive ?? true),
263 updatedAt: new Date(),
264 })
265 .where(eq(integrationTable.id, existing.id));
266
267 return c.json(await getGenericWebhookIntegration(projectId));
268 },
269 )
270 .delete(
271 "/project/:projectId",
272 describeRoute({
273 operationId: "deleteGenericWebhookIntegration",
274 tags: ["Generic Webhook"],
275 description: "Delete generic outgoing webhook integration for a project",
276 responses: {
277 200: {
278 description: "Generic webhook 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, "generic-webhook"),
296 ),
297 });
298
299 if (!existing) {
300 throw new HTTPException(404, {
301 message: "Generic webhook integration not found",
302 });
303 }
304
305 await db
306 .delete(integrationTable)
307 .where(eq(integrationTable.id, existing.id));
308
309 return c.json({ success: true });
310 },
311 );
312
313export default genericWebhookIntegration;