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 { publishEvent } from "../events";
9import {
10 normalizeTelegramConfig,
11 telegramEventsSchema,
12 validateTelegramConfig,
13} from "../plugins/telegram/config";
14import { telegramIntegrationSchema } from "../schemas";
15import { workspaceAccess } from "../utils/workspace-access-middleware";
16import {
17 buildNextTelegramConfigFromPatch,
18 getTelegramIntegration,
19 parseTelegramIntegrationConfig,
20 telegramIntegrationPatchBodySchema,
21 toResponse,
22} from "./controllers/telegram-controller";
23
24function safePublishIntegrationEvent(
25 eventName:
26 | "integration.created"
27 | "integration.updated"
28 | "integration.deleted",
29 data: {
30 projectId: string;
31 userId: string;
32 integrationType: "telegram";
33 integrationId: string;
34 apiKeyId?: string;
35 },
36) {
37 void publishEvent(eventName, data).catch((error) => {
38 console.error(`Failed to publish ${eventName}:`, error);
39 });
40}
41
42const telegramIntegration = new Hono<{
43 Variables: {
44 userId: string;
45 workspaceId: string;
46 apiKey?: {
47 id: string;
48 userId: string;
49 enabled: boolean;
50 };
51 };
52}>();
53
54telegramIntegration
55 .get(
56 "/project/:projectId",
57 describeRoute({
58 operationId: "getTelegramIntegration",
59 tags: ["Telegram"],
60 description: "Get Telegram integration for a project",
61 responses: {
62 200: {
63 description: "Telegram integration details",
64 content: {
65 "application/json": { schema: resolver(telegramIntegrationSchema) },
66 },
67 },
68 404: {
69 description: "Telegram integration not found",
70 },
71 },
72 }),
73 validator("param", v.object({ projectId: v.string() })),
74 workspaceAccess.fromProject("projectId"),
75 async (c) => {
76 const { projectId } = c.req.valid("param");
77 const integration = await getTelegramIntegration(projectId);
78 return c.json(integration);
79 },
80 )
81 .post(
82 "/project/:projectId",
83 describeRoute({
84 operationId: "createTelegramIntegration",
85 tags: ["Telegram"],
86 description: "Create or replace a Telegram integration for a project",
87 responses: {
88 200: {
89 description: "Telegram integration created successfully",
90 content: {
91 "application/json": { schema: resolver(telegramIntegrationSchema) },
92 },
93 },
94 },
95 }),
96 validator("param", v.object({ projectId: v.string() })),
97 validator(
98 "json",
99 v.object({
100 botToken: v.pipe(v.string(), v.minLength(1)),
101 chatId: v.pipe(v.string(), v.minLength(1)),
102 threadId: v.optional(v.number()),
103 chatLabel: v.optional(v.string()),
104 events: v.optional(telegramEventsSchema),
105 }),
106 ),
107 workspaceAccess.fromProject("projectId"),
108 async (c) => {
109 const { projectId } = c.req.valid("param");
110 const body = c.req.valid("json");
111
112 const config = normalizeTelegramConfig({
113 botToken: body.botToken,
114 chatId: body.chatId,
115 threadId: body.threadId,
116 chatLabel: body.chatLabel,
117 events: body.events,
118 });
119
120 const validation = validateTelegramConfig(config);
121 if (!validation.valid) {
122 throw new HTTPException(400, {
123 message: validation.errors?.join(", ") ?? "Invalid config",
124 });
125 }
126
127 const priorIntegration = await db.query.integrationTable.findFirst({
128 where: and(
129 eq(integrationTable.projectId, projectId),
130 eq(integrationTable.type, "telegram"),
131 ),
132 columns: { id: true },
133 });
134
135 await db
136 .insert(integrationTable)
137 .values({
138 projectId,
139 type: "telegram",
140 config: JSON.stringify(config),
141 isActive: true,
142 })
143 .onConflictDoUpdate({
144 target: [integrationTable.projectId, integrationTable.type],
145 set: {
146 config: JSON.stringify(config),
147 updatedAt: new Date(),
148 },
149 });
150
151 const integration = await getTelegramIntegration(projectId);
152 if (!integration) {
153 throw new HTTPException(500, {
154 message: "Failed to load Telegram integration after save",
155 });
156 }
157
158 const apiKey = c.get("apiKey");
159 safePublishIntegrationEvent(
160 priorIntegration ? "integration.updated" : "integration.created",
161 {
162 projectId,
163 userId: c.get("userId"),
164 integrationType: "telegram",
165 integrationId: integration.id,
166 ...(apiKey?.id ? { apiKeyId: apiKey.id } : {}),
167 },
168 );
169
170 return c.json(integration);
171 },
172 )
173 .patch(
174 "/project/:projectId",
175 describeRoute({
176 operationId: "updateTelegramIntegration",
177 tags: ["Telegram"],
178 description: "Update Telegram integration settings",
179 responses: {
180 200: {
181 description: "Telegram integration updated successfully",
182 content: {
183 "application/json": { schema: resolver(telegramIntegrationSchema) },
184 },
185 },
186 },
187 }),
188 validator("param", v.object({ projectId: v.string() })),
189 validator("json", telegramIntegrationPatchBodySchema),
190 workspaceAccess.fromProject("projectId"),
191 async (c) => {
192 const { projectId } = c.req.valid("param");
193 const body = c.req.valid("json");
194
195 const existing = await db.query.integrationTable.findFirst({
196 where: and(
197 eq(integrationTable.projectId, projectId),
198 eq(integrationTable.type, "telegram"),
199 ),
200 });
201
202 if (!existing) {
203 throw new HTTPException(404, {
204 message: "Telegram integration not found",
205 });
206 }
207
208 const currentConfig = parseTelegramIntegrationConfig(existing);
209 const nextConfig = normalizeTelegramConfig(
210 buildNextTelegramConfigFromPatch(body, currentConfig),
211 );
212
213 const resolvedIsActive =
214 body.isActive !== undefined
215 ? body.isActive
216 : (existing.isActive ?? true);
217
218 if (
219 JSON.stringify(currentConfig) === JSON.stringify(nextConfig) &&
220 resolvedIsActive === (existing.isActive ?? true)
221 ) {
222 return c.json(toResponse(existing));
223 }
224
225 const validation = validateTelegramConfig(nextConfig);
226 if (!validation.valid) {
227 throw new HTTPException(400, {
228 message: validation.errors?.join(", ") ?? "Invalid config",
229 });
230 }
231
232 await db
233 .update(integrationTable)
234 .set({
235 config: JSON.stringify(nextConfig),
236 isActive: resolvedIsActive,
237 updatedAt: new Date(),
238 })
239 .where(eq(integrationTable.id, existing.id));
240
241 const integration = await getTelegramIntegration(projectId);
242 if (!integration) {
243 throw new HTTPException(500, {
244 message: "Failed to load Telegram integration after update",
245 });
246 }
247
248 const apiKey = c.get("apiKey");
249 safePublishIntegrationEvent("integration.updated", {
250 projectId,
251 userId: c.get("userId"),
252 integrationType: "telegram",
253 integrationId: integration.id,
254 ...(apiKey?.id ? { apiKeyId: apiKey.id } : {}),
255 });
256
257 return c.json(integration);
258 },
259 )
260 .delete(
261 "/project/:projectId",
262 describeRoute({
263 operationId: "deleteTelegramIntegration",
264 tags: ["Telegram"],
265 description: "Delete Telegram integration for a project",
266 responses: {
267 200: {
268 description: "Telegram integration deleted successfully",
269 content: {
270 "application/json": {
271 schema: resolver(v.object({ success: v.boolean() })),
272 },
273 },
274 },
275 },
276 }),
277 validator("param", v.object({ projectId: v.string() })),
278 workspaceAccess.fromProject("projectId"),
279 async (c) => {
280 const { projectId } = c.req.valid("param");
281
282 const existing = await db.query.integrationTable.findFirst({
283 where: and(
284 eq(integrationTable.projectId, projectId),
285 eq(integrationTable.type, "telegram"),
286 ),
287 });
288
289 if (!existing) {
290 throw new HTTPException(404, {
291 message: "Telegram integration not found",
292 });
293 }
294
295 await db
296 .delete(integrationTable)
297 .where(eq(integrationTable.id, existing.id));
298
299 const apiKey = c.get("apiKey");
300 safePublishIntegrationEvent("integration.deleted", {
301 projectId,
302 userId: c.get("userId"),
303 integrationType: "telegram",
304 integrationId: existing.id,
305 ...(apiKey?.id ? { apiKeyId: apiKey.id } : {}),
306 });
307
308 return c.json({ success: true });
309 },
310 );
311
312export default telegramIntegration;