@m5kdev/backend 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/LICENSE +621 -0
  2. package/README.md +22 -0
  3. package/package.json +205 -0
  4. package/src/lib/posthog.ts +5 -0
  5. package/src/lib/sentry.ts +8 -0
  6. package/src/modules/access/access.repository.ts +36 -0
  7. package/src/modules/access/access.service.ts +81 -0
  8. package/src/modules/access/access.test.ts +216 -0
  9. package/src/modules/access/access.utils.ts +46 -0
  10. package/src/modules/ai/ai.db.ts +38 -0
  11. package/src/modules/ai/ai.prompt.ts +47 -0
  12. package/src/modules/ai/ai.repository.ts +53 -0
  13. package/src/modules/ai/ai.router.ts +148 -0
  14. package/src/modules/ai/ai.service.ts +310 -0
  15. package/src/modules/ai/ai.trpc.ts +22 -0
  16. package/src/modules/ai/ideogram/ideogram.constants.ts +170 -0
  17. package/src/modules/ai/ideogram/ideogram.dto.ts +64 -0
  18. package/src/modules/ai/ideogram/ideogram.prompt.ts +858 -0
  19. package/src/modules/ai/ideogram/ideogram.repository.ts +39 -0
  20. package/src/modules/ai/ideogram/ideogram.service.ts +14 -0
  21. package/src/modules/auth/auth.db.ts +224 -0
  22. package/src/modules/auth/auth.dto.ts +47 -0
  23. package/src/modules/auth/auth.lib.ts +349 -0
  24. package/src/modules/auth/auth.middleware.ts +62 -0
  25. package/src/modules/auth/auth.repository.ts +672 -0
  26. package/src/modules/auth/auth.service.ts +261 -0
  27. package/src/modules/auth/auth.trpc.ts +208 -0
  28. package/src/modules/auth/auth.utils.ts +117 -0
  29. package/src/modules/base/base.abstract.ts +62 -0
  30. package/src/modules/base/base.dto.ts +206 -0
  31. package/src/modules/base/base.grants.test.ts +861 -0
  32. package/src/modules/base/base.grants.ts +199 -0
  33. package/src/modules/base/base.repository.ts +433 -0
  34. package/src/modules/base/base.service.ts +154 -0
  35. package/src/modules/base/base.types.ts +7 -0
  36. package/src/modules/billing/billing.db.ts +27 -0
  37. package/src/modules/billing/billing.repository.ts +328 -0
  38. package/src/modules/billing/billing.router.ts +77 -0
  39. package/src/modules/billing/billing.service.ts +177 -0
  40. package/src/modules/billing/billing.trpc.ts +17 -0
  41. package/src/modules/clay/clay.repository.ts +29 -0
  42. package/src/modules/clay/clay.service.ts +61 -0
  43. package/src/modules/connect/connect.db.ts +32 -0
  44. package/src/modules/connect/connect.dto.ts +44 -0
  45. package/src/modules/connect/connect.linkedin.ts +70 -0
  46. package/src/modules/connect/connect.oauth.ts +288 -0
  47. package/src/modules/connect/connect.repository.ts +65 -0
  48. package/src/modules/connect/connect.router.ts +76 -0
  49. package/src/modules/connect/connect.service.ts +171 -0
  50. package/src/modules/connect/connect.trpc.ts +26 -0
  51. package/src/modules/connect/connect.types.ts +27 -0
  52. package/src/modules/crypto/crypto.db.ts +15 -0
  53. package/src/modules/crypto/crypto.repository.ts +13 -0
  54. package/src/modules/crypto/crypto.service.ts +57 -0
  55. package/src/modules/email/email.service.ts +222 -0
  56. package/src/modules/file/file.repository.ts +95 -0
  57. package/src/modules/file/file.router.ts +108 -0
  58. package/src/modules/file/file.service.ts +186 -0
  59. package/src/modules/recurrence/recurrence.db.ts +79 -0
  60. package/src/modules/recurrence/recurrence.repository.ts +70 -0
  61. package/src/modules/recurrence/recurrence.service.ts +105 -0
  62. package/src/modules/recurrence/recurrence.trpc.ts +82 -0
  63. package/src/modules/social/social.dto.ts +22 -0
  64. package/src/modules/social/social.linkedin.test.ts +277 -0
  65. package/src/modules/social/social.linkedin.ts +593 -0
  66. package/src/modules/social/social.service.ts +112 -0
  67. package/src/modules/social/social.types.ts +43 -0
  68. package/src/modules/tag/tag.db.ts +41 -0
  69. package/src/modules/tag/tag.dto.ts +18 -0
  70. package/src/modules/tag/tag.repository.ts +222 -0
  71. package/src/modules/tag/tag.service.ts +48 -0
  72. package/src/modules/tag/tag.trpc.ts +62 -0
  73. package/src/modules/uploads/0581796b-8845-420d-bd95-cd7de79f6d37.webm +0 -0
  74. package/src/modules/uploads/33b1e649-6727-4bd0-94d0-a0b363646865.webm +0 -0
  75. package/src/modules/uploads/49a8c4c0-54d7-4c94-bef4-c93c029f9ed0.webm +0 -0
  76. package/src/modules/uploads/50e31e38-a2f0-47ca-8b7d-2d7fcad9267d.webm +0 -0
  77. package/src/modules/uploads/72ac8cf9-c3a7-4cd8-8a78-6d8e137a4c7e.webm +0 -0
  78. package/src/modules/uploads/75293649-d966-46cd-a675-67518958ae9c.png +0 -0
  79. package/src/modules/uploads/88b7b867-ce15-4891-bf73-81305a7de1f7.wav +0 -0
  80. package/src/modules/uploads/a5d6fee8-6a59-42c6-9d4a-ac8a3c5e7245.webm +0 -0
  81. package/src/modules/uploads/c13a9785-ca5a-4983-af30-b338ed76d370.webm +0 -0
  82. package/src/modules/uploads/caa1a5a7-71ba-4381-902d-7e2cafdf6dcb.webm +0 -0
  83. package/src/modules/uploads/cbeb0b81-374d-445b-914b-40ace7c8e031.webm +0 -0
  84. package/src/modules/uploads/d626aa82-b10f-493f-aee7-87bfb3361dfc.webm +0 -0
  85. package/src/modules/uploads/d7de4c16-de0c-495d-9612-e72260a6ecca.png +0 -0
  86. package/src/modules/uploads/e532e38a-6421-400e-8a5f-8e7bc8ce411b.wav +0 -0
  87. package/src/modules/uploads/e86ec867-6adf-4c51-84e0-00b0836625e8.webm +0 -0
  88. package/src/modules/utils/applyPagination.ts +13 -0
  89. package/src/modules/utils/applySorting.ts +21 -0
  90. package/src/modules/utils/getConditionsFromFilters.ts +216 -0
  91. package/src/modules/video/video.service.ts +89 -0
  92. package/src/modules/webhook/webhook.constants.ts +9 -0
  93. package/src/modules/webhook/webhook.db.ts +15 -0
  94. package/src/modules/webhook/webhook.dto.ts +9 -0
  95. package/src/modules/webhook/webhook.repository.ts +68 -0
  96. package/src/modules/webhook/webhook.router.ts +29 -0
  97. package/src/modules/webhook/webhook.service.ts +78 -0
  98. package/src/modules/workflow/workflow.db.ts +29 -0
  99. package/src/modules/workflow/workflow.repository.ts +171 -0
  100. package/src/modules/workflow/workflow.service.ts +56 -0
  101. package/src/modules/workflow/workflow.trpc.ts +26 -0
  102. package/src/modules/workflow/workflow.types.ts +30 -0
  103. package/src/modules/workflow/workflow.utils.ts +259 -0
  104. package/src/test/stubs/utils.ts +2 -0
  105. package/src/trpc/context.ts +21 -0
  106. package/src/trpc/index.ts +3 -0
  107. package/src/trpc/procedures.ts +43 -0
  108. package/src/trpc/utils.ts +20 -0
  109. package/src/types.ts +22 -0
  110. package/src/utils/errors.ts +148 -0
  111. package/src/utils/logger.ts +8 -0
  112. package/src/utils/posthog.ts +43 -0
  113. package/src/utils/types.ts +5 -0
@@ -0,0 +1,216 @@
1
+ import type { QueryFilters } from "@m5kdev/commons/modules/schemas/query.schema";
2
+ import { and, between, eq, gte, inArray, isNotNull, isNull, like, lte, ne, or } from "drizzle-orm";
3
+ import type { SQLiteTableWithColumns } from "drizzle-orm/sqlite-core";
4
+ import { DateTime } from "luxon";
5
+ import type { ConditionBuilder } from "#modules/base/base.repository";
6
+
7
+ type ColumnDataType = "string" | "number" | "date" | "boolean" | "enum";
8
+
9
+ // Helper: Create UTC date boundaries from ISO string
10
+ const getUTCDateBoundaries = (isoString: string) => {
11
+ const dateTime = DateTime.fromISO(isoString, { zone: "utc" });
12
+ return {
13
+ start: dateTime.startOf("day").toJSDate(),
14
+ end: dateTime.endOf("day").toJSDate(),
15
+ };
16
+ };
17
+
18
+ export const getConditionsFromFilters = <T extends SQLiteTableWithColumns<any>>(
19
+ conditions: ConditionBuilder,
20
+ filters: QueryFilters | undefined,
21
+ table: T
22
+ ): ConditionBuilder => {
23
+ if (!filters || filters.length === 0) {
24
+ return conditions;
25
+ }
26
+
27
+ // Process each filter (maximum one filter per column)
28
+ for (const filter of filters) {
29
+ const { columnId, type, method, value, valueTo } = filter;
30
+
31
+ // Get the column from the table using columnId
32
+ const column = (table as any)[columnId];
33
+ if (!column) {
34
+ continue; // Skip if column doesn't exist
35
+ }
36
+
37
+ // Handle isEmpty/isNotEmpty methods (work across types, ignore value)
38
+ if (method === "isEmpty" || method === "isNotEmpty") {
39
+ switch (type as ColumnDataType) {
40
+ case "string":
41
+ case "enum":
42
+ // isEmpty: IS NULL OR = ''
43
+ // isNotEmpty: IS NOT NULL AND != ''
44
+ if (method === "isEmpty") {
45
+ conditions.push(or(isNull(column), eq(column, "")));
46
+ } else {
47
+ conditions.push(and(isNotNull(column), ne(column, "")));
48
+ }
49
+ continue;
50
+ case "number":
51
+ // isEmpty: IS NULL OR = 0
52
+ // isNotEmpty: IS NOT NULL AND != 0
53
+ if (method === "isEmpty") {
54
+ conditions.push(or(isNull(column), eq(column, 0)));
55
+ } else {
56
+ conditions.push(and(isNotNull(column), ne(column, 0)));
57
+ }
58
+ continue;
59
+ case "boolean":
60
+ // Should not happen per plan, but handle gracefully
61
+ continue;
62
+ default:
63
+ continue;
64
+ }
65
+ }
66
+
67
+ // Apply filter based on type and method
68
+ switch (type as ColumnDataType) {
69
+ case "string":
70
+ switch (method) {
71
+ case "contains":
72
+ if (typeof value === "string") {
73
+ conditions.push(like(column, `%${value}%`));
74
+ }
75
+ break;
76
+ case "equals":
77
+ if (typeof value === "string") {
78
+ conditions.push(eq(column, value));
79
+ }
80
+ break;
81
+ case "starts_with":
82
+ if (typeof value === "string") {
83
+ conditions.push(like(column, `${value}%`));
84
+ }
85
+ break;
86
+ case "ends_with":
87
+ if (typeof value === "string") {
88
+ conditions.push(like(column, `%${value}`));
89
+ }
90
+ break;
91
+ case "is_null":
92
+ conditions.push(isNull(column));
93
+ break;
94
+ case "is_not_null":
95
+ conditions.push(isNotNull(column));
96
+ break;
97
+ }
98
+ break;
99
+
100
+ case "number":
101
+ switch (method) {
102
+ case "equals":
103
+ if (typeof value === "number") {
104
+ conditions.push(eq(column, value));
105
+ }
106
+ break;
107
+ case "greater_than":
108
+ if (typeof value === "number") {
109
+ conditions.push(gte(column, value));
110
+ }
111
+ break;
112
+ case "less_than":
113
+ if (typeof value === "number") {
114
+ conditions.push(lte(column, value));
115
+ }
116
+ break;
117
+ case "is_null":
118
+ conditions.push(isNull(column));
119
+ break;
120
+ case "is_not_null":
121
+ conditions.push(isNotNull(column));
122
+ break;
123
+ }
124
+ break;
125
+
126
+ case "date":
127
+ if (typeof value !== "string") break;
128
+
129
+ switch (method) {
130
+ case "on": {
131
+ const { start, end } = getUTCDateBoundaries(value);
132
+ conditions.push(and(gte(column, start), lte(column, end)));
133
+ break;
134
+ }
135
+ case "between":
136
+ if (valueTo) {
137
+ const { start } = getUTCDateBoundaries(value);
138
+ const { end } = getUTCDateBoundaries(valueTo);
139
+ conditions.push(between(column, start, end));
140
+ }
141
+ break;
142
+ case "before": {
143
+ const { end } = getUTCDateBoundaries(value);
144
+ conditions.push(lte(column, end));
145
+ break;
146
+ }
147
+ case "after": {
148
+ const { start } = getUTCDateBoundaries(value);
149
+ conditions.push(gte(column, start));
150
+ break;
151
+ }
152
+ case "intersect": {
153
+ // Interval overlap: [columnId, endColumnId] intersects with [value, valueTo]
154
+ // Logic: columnId <= valueTo AND (endColumnId IS NULL OR endColumnId >= value)
155
+ if (!valueTo || !filter.endColumnId) break;
156
+
157
+ const endColumn = (table as any)[filter.endColumnId];
158
+ if (!endColumn) break;
159
+
160
+ const { start } = getUTCDateBoundaries(value);
161
+ const { end } = getUTCDateBoundaries(valueTo);
162
+
163
+ conditions.push(and(lte(column, end), or(isNull(endColumn), gte(endColumn, start))));
164
+ break;
165
+ }
166
+ case "is_null":
167
+ conditions.push(isNull(column));
168
+ break;
169
+ case "is_not_null":
170
+ conditions.push(isNotNull(column));
171
+ break;
172
+ }
173
+ break;
174
+
175
+ case "boolean":
176
+ switch (method) {
177
+ case "equals":
178
+ if (typeof value === "boolean") {
179
+ conditions.push(eq(column, value));
180
+ }
181
+ break;
182
+ case "is_null":
183
+ conditions.push(isNull(column));
184
+ break;
185
+ case "is_not_null":
186
+ conditions.push(isNotNull(column));
187
+ break;
188
+ }
189
+
190
+ break;
191
+
192
+ case "enum":
193
+ switch (method) {
194
+ case "oneOf":
195
+ if (Array.isArray(value) && value.length > 0) {
196
+ conditions.push(inArray(column, value));
197
+ }
198
+ break;
199
+ case "equals":
200
+ if (typeof value === "string") {
201
+ conditions.push(eq(column, value));
202
+ }
203
+ break;
204
+ case "is_null":
205
+ conditions.push(isNull(column));
206
+ break;
207
+ case "is_not_null":
208
+ conditions.push(isNotNull(column));
209
+ break;
210
+ }
211
+ break;
212
+ }
213
+ }
214
+
215
+ return conditions;
216
+ };
@@ -0,0 +1,89 @@
1
+ import { closeSync, existsSync, mkdirSync, openSync } from "node:fs";
2
+ import path from "node:path";
3
+ //
4
+ import ffbin from "ffmpeg-ffprobe-static";
5
+ import ffmpeg from "fluent-ffmpeg";
6
+ import { err, ok } from "neverthrow";
7
+ import { v4 as uuidv4 } from "uuid";
8
+ import type { ServerResultAsync } from "#modules/base/base.dto";
9
+ import { BaseService } from "#modules/base/base.service";
10
+
11
+ if (!ffbin.ffmpegPath || !ffbin.ffprobePath) {
12
+ throw new Error("FFmpeg or FFprobe not found");
13
+ }
14
+
15
+ ffmpeg.setFfmpegPath(ffbin.ffmpegPath);
16
+ ffmpeg.setFfprobePath(ffbin.ffprobePath);
17
+
18
+ const uploadsDir = path.join(__dirname, "..", "uploads");
19
+ if (!existsSync(uploadsDir)) {
20
+ mkdirSync(uploadsDir, { recursive: true });
21
+ }
22
+
23
+ export class VideoService extends BaseService<never, never> {
24
+ async cut(file: string, start: number, end: number): ServerResultAsync<string> {
25
+ return this.throwableAsync(async () => {
26
+ const duration = end - start;
27
+ const output = path.join(uploadsDir, `${uuidv4()}.mp4`);
28
+ if (!existsSync(output)) {
29
+ closeSync(openSync(output, "w"));
30
+ }
31
+
32
+ await new Promise<void>((resolve, reject) => {
33
+ ffmpeg(file)
34
+ .seekOutput(start)
35
+ .videoCodec("libx264")
36
+ .audioCodec("copy")
37
+ .outputOptions(["-y", "-movflags +faststart"])
38
+ .duration(duration)
39
+ .on("end", () => resolve())
40
+ .on("error", (e: Error, _stdout: string | null, _stderr: string | null) => reject(e))
41
+ .save(output);
42
+ }).catch((error) => err(this.handleUnknownError(error)));
43
+
44
+ return ok(output);
45
+ });
46
+ }
47
+
48
+ async webmToWav(input: string, hz = 48000): ServerResultAsync<string> {
49
+ return this.throwableAsync(async () => {
50
+ const output = path.join(uploadsDir, `${uuidv4()}.wav`);
51
+ if (!existsSync(output)) {
52
+ closeSync(openSync(output, "w"));
53
+ }
54
+ await new Promise<void>((resolve, reject) => {
55
+ ffmpeg(input)
56
+ .noVideo()
57
+ .audioCodec("pcm_s16le") // WAV PCM 16-bit
58
+ .audioFrequency(hz) // 48000 or 44100
59
+ .audioChannels(2) // down/up-mix as needed
60
+ .format("wav")
61
+ .outputOptions(["-y"])
62
+ .on("end", () => resolve())
63
+ .on("error", reject)
64
+ .save(output);
65
+ }).catch((error) => err(this.handleUnknownError(error)));
66
+ return ok(output);
67
+ });
68
+ }
69
+
70
+ async extractAudioMp3(input: string, kbps = 192, streamIndex = 0): ServerResultAsync<string> {
71
+ return this.throwableAsync(async () => {
72
+ const output = path.join(uploadsDir, `${uuidv4()}.mp3`);
73
+ if (!existsSync(output)) {
74
+ closeSync(openSync(output, "w"));
75
+ }
76
+ await new Promise<void>((resolve, reject) => {
77
+ ffmpeg(input)
78
+ .outputOptions(["-y", `-map 0:a:${streamIndex}`])
79
+ .audioCodec("libmp3lame")
80
+ .audioBitrate(kbps)
81
+ .on("end", () => resolve())
82
+ .on("error", reject)
83
+ .save(output);
84
+ }).catch((error) => err(this.handleUnknownError(error)));
85
+
86
+ return ok(output);
87
+ });
88
+ }
89
+ }
@@ -0,0 +1,9 @@
1
+ export const WEBHOOK_STATUS_ENUM = {
2
+ WAITING: "WAITING",
3
+ COMPLETED: "COMPLETED",
4
+ TIMEOUT: "TIMEOUT",
5
+ ERROR_CALLBACK: "ERROR_CALLBACK",
6
+ ERROR_DATA: "ERROR_DATA",
7
+ } as const;
8
+
9
+ export type WebhookStatus = (typeof WEBHOOK_STATUS_ENUM)[keyof typeof WEBHOOK_STATUS_ENUM];
@@ -0,0 +1,15 @@
1
+ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
2
+ import { v4 as uuidv4 } from "uuid";
3
+ import { WEBHOOK_STATUS_ENUM, type WebhookStatus } from "./webhook.constants";
4
+
5
+ export const webhook = sqliteTable("webhook", {
6
+ id: text("id").primaryKey().$default(uuidv4),
7
+ createdAt: integer("created_at", { mode: "timestamp" })
8
+ .notNull()
9
+ .$default(() => new Date()),
10
+ updatedAt: integer("updated_at", { mode: "timestamp" }),
11
+ timeoutSec: integer("timeout_sec").notNull().default(60),
12
+ status: text("status").notNull().default(WEBHOOK_STATUS_ENUM.WAITING).$type<WebhookStatus>(),
13
+ error: text("error"),
14
+ payload: text("payload"),
15
+ });
@@ -0,0 +1,9 @@
1
+ import type { z } from "zod";
2
+ import { createSelectDTO } from "#modules/base/base.dto";
3
+ import { webhook } from "./webhook.db";
4
+
5
+ export const webhookSelectDTO = createSelectDTO(webhook);
6
+
7
+ export const webhookSelectSchema = webhookSelectDTO.schema;
8
+
9
+ export type WebhookSelectOutput = z.infer<typeof webhookSelectSchema>;
@@ -0,0 +1,68 @@
1
+ import type { LibSQLDatabase } from "drizzle-orm/libsql";
2
+ import { err, ok } from "neverthrow";
3
+ import type { ServerResultAsync } from "#modules/base/base.dto";
4
+ import { BaseTableRepository } from "#modules/base/base.repository";
5
+ import { WEBHOOK_STATUS_ENUM } from "./webhook.constants";
6
+ import { webhook } from "./webhook.db";
7
+
8
+ const schema = { webhook };
9
+ type Schema = typeof schema;
10
+ type Orm = LibSQLDatabase<Schema>;
11
+
12
+ export class WebhookRepository extends BaseTableRepository<Orm, Schema, Record<string, never>, Schema["webhook"]> {
13
+ async completed(id: string, payload: unknown, tx?: Orm): ServerResultAsync<void> {
14
+ return this.throwableAsync(async () => {
15
+ const webhook = await this.findById(id, tx);
16
+ if (webhook.isErr()) return err(webhook.error);
17
+ if (!webhook.value) return this.error("NOT_FOUND");
18
+ await this.update(
19
+ {
20
+ id,
21
+ status: WEBHOOK_STATUS_ENUM.COMPLETED,
22
+ payload: JSON.stringify(payload),
23
+ },
24
+ tx
25
+ );
26
+ return ok();
27
+ });
28
+ }
29
+
30
+ async timeout(id: string, tx?: Orm): ServerResultAsync<void> {
31
+ return this.throwableAsync(async () => {
32
+ const webhook = await this.findById(id, tx);
33
+ if (webhook.isErr()) return err(webhook.error);
34
+ if (!webhook.value) return this.error("NOT_FOUND");
35
+ await this.update(
36
+ {
37
+ id,
38
+ status: WEBHOOK_STATUS_ENUM.TIMEOUT,
39
+ error: `Timeout of ${webhook.value.timeoutSec} seconds reached`,
40
+ },
41
+ tx
42
+ );
43
+ return ok();
44
+ });
45
+ }
46
+
47
+ async registerError(
48
+ id: string,
49
+ status: "ERROR_CALLBACK" | "ERROR_DATA",
50
+ error: string,
51
+ tx?: Orm
52
+ ): ServerResultAsync<void> {
53
+ return this.throwableAsync(async () => {
54
+ const webhook = await this.findById(id, tx);
55
+ if (webhook.isErr()) return err(webhook.error);
56
+ if (!webhook.value) return this.error("NOT_FOUND");
57
+ await this.update(
58
+ {
59
+ id,
60
+ status,
61
+ error,
62
+ },
63
+ tx
64
+ );
65
+ return ok();
66
+ });
67
+ }
68
+ }
@@ -0,0 +1,29 @@
1
+ import bodyParser from "body-parser";
2
+ import { Router } from "express";
3
+ import type { WebhookService } from "./webhook.service";
4
+
5
+ export function createWebhookRouter(webhookService: WebhookService): Router {
6
+ const webhookRouter = Router();
7
+
8
+ webhookRouter.post("/:id", bodyParser.json(), async (req, res) => {
9
+ const { authorization } = req.headers;
10
+ if (!authorization) return res.status(401).json({ message: "Missing authorization header" });
11
+ if (typeof authorization !== "string")
12
+ return res.status(401).json({ message: "Authorization header is not a string" });
13
+ if (!authorization.startsWith("Bearer "))
14
+ return res.status(401).json({ message: "Invalid authorization header" });
15
+ const token = authorization.split(" ")[1];
16
+ if (!token) return res.status(401).json({ message: "Missing token" });
17
+ if (token !== process.env.WEBHOOK_SECRET)
18
+ return res.status(401).json({ message: "Invalid token" });
19
+
20
+ const result = await webhookService.completed(req.params.id, req.body);
21
+ if (result.isErr())
22
+ return res
23
+ .status(result.error.getHTTPStatusCode() || 500)
24
+ .json({ message: result.error.message });
25
+ return res.status(200).json({ message: "Webhook completed" });
26
+ });
27
+
28
+ return webhookRouter;
29
+ }
@@ -0,0 +1,78 @@
1
+ import { safeParseJson } from "@m5kdev/commons/utils/json";
2
+ import { err, ok } from "neverthrow";
3
+ import type { ServerResult, ServerResultAsync } from "#modules/base/base.dto";
4
+ import { BaseService } from "#modules/base/base.service";
5
+ import { WEBHOOK_STATUS_ENUM } from "./webhook.constants";
6
+ import type { WebhookRepository } from "./webhook.repository";
7
+
8
+ export class WebhookService extends BaseService<{ webhook: WebhookRepository }, never> {
9
+ async completed(id: string, payload: unknown): ServerResultAsync<void> {
10
+ const result = await this.repository.webhook.completed(id, payload);
11
+ if (result.isErr()) {
12
+ await this.repository.webhook.registerError(
13
+ id,
14
+ WEBHOOK_STATUS_ENUM.ERROR_DATA,
15
+ JSON.stringify(result.error)
16
+ );
17
+ return this.error("INTERNAL_SERVER_ERROR", "Webhook completed failed", {
18
+ cause: result.error,
19
+ });
20
+ }
21
+ return ok();
22
+ }
23
+
24
+ async waitForRequest<T>(callback: (url: string) => any, timeoutSec = 60): ServerResultAsync<T> {
25
+ const webhook = await this.repository.webhook.create({
26
+ timeoutSec,
27
+ });
28
+ if (webhook.isErr()) return Promise.reject(webhook.error);
29
+ const url = `${process.env.NGROK_LOCALHOST_TUNNEL || process.env.VITE_SERVER_URL}/webhook/${webhook.value.id}`;
30
+ try {
31
+ await callback(url);
32
+ } catch (error) {
33
+ await this.repository.webhook.registerError(
34
+ webhook.value.id,
35
+ WEBHOOK_STATUS_ENUM.ERROR_CALLBACK,
36
+ JSON.stringify(error)
37
+ );
38
+ return this.error("INTERNAL_SERVER_ERROR", "Error callback failed", { cause: error });
39
+ }
40
+
41
+ const startTime = new Date(webhook.value.createdAt).getTime();
42
+ const endTime = startTime + timeoutSec * 1000;
43
+
44
+ const promise = await new Promise<ServerResult<T>>((resolve, reject) => {
45
+ const intervalId = setInterval(async () => {
46
+ const currentTime = Date.now();
47
+
48
+ // Check if the timeout is reached
49
+ if (currentTime > endTime) {
50
+ await this.repository.webhook.timeout(webhook.value.id);
51
+ clearInterval(intervalId);
52
+ return reject(this.error("TIMEOUT", "Wait for request timeout"));
53
+ }
54
+ const result = await this.repository.webhook.findById(webhook.value.id);
55
+ if (result.isErr()) {
56
+ clearInterval(intervalId);
57
+ return reject(err(result.error));
58
+ }
59
+
60
+ if (!result.value) {
61
+ clearInterval(intervalId);
62
+ return reject(this.error("NOT_FOUND", "Wait for request failed: cannot find webhook"));
63
+ }
64
+ const { status, payload } = result.value;
65
+ if (status === "COMPLETED") {
66
+ const data = payload ? safeParseJson<T>(payload, payload as T) : (payload as T);
67
+ clearInterval(intervalId);
68
+ return resolve(ok(data));
69
+ }
70
+ if (status !== "WAITING") {
71
+ clearInterval(intervalId);
72
+ return reject(this.error("BAD_REQUEST", "Wait for request failed"));
73
+ }
74
+ }, 1000);
75
+ });
76
+ return promise;
77
+ }
78
+ }
@@ -0,0 +1,29 @@
1
+ import type { WorkflowStatus } from "@m5kdev/commons/modules/workflow/workflow.constants";
2
+ import { integer, sqliteTable as table, text } from "drizzle-orm/sqlite-core";
3
+ import { v4 as uuidv4 } from "uuid";
4
+ import { users } from "#modules/auth/auth.db";
5
+
6
+ export const workflows = table("workflows", {
7
+ id: text("id").primaryKey().$default(uuidv4),
8
+ userId: text("user_id").references(() => users.id, { onDelete: "cascade" }),
9
+ jobId: text("job_id").unique().notNull(),
10
+ jobName: text("job_name").notNull(),
11
+ queueName: text("queue_name").notNull(),
12
+ timeout: integer("timeout"),
13
+ tags: text("tags", { mode: "json" })
14
+ .$default(() => [])
15
+ .$type<string[]>(),
16
+ input: text("input", { mode: "json" }),
17
+ output: text("output", { mode: "json" }),
18
+ status: text("status").notNull().$type<WorkflowStatus>(),
19
+ error: text("error"),
20
+ retries: integer("retries").notNull().default(0),
21
+ finishedAt: integer("finished_at", { mode: "timestamp" }),
22
+ processedAt: integer("processed_at", { mode: "timestamp" }),
23
+ createdAt: integer("created_at", { mode: "timestamp" })
24
+ .notNull()
25
+ .$default(() => new Date()),
26
+ updatedAt: integer("updated_at", { mode: "timestamp" })
27
+ .notNull()
28
+ .$default(() => new Date()),
29
+ });