@nexttylabs/echo 0.2.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 (579) hide show
  1. package/.changeset/README.md +21 -0
  2. package/.changeset/config.json +11 -0
  3. package/.changeset/cozy-ghosts-care.md +5 -0
  4. package/.changeset/sharp-lines-stand.md +5 -0
  5. package/.changeset/sour-doodles-eat.md +5 -0
  6. package/.changeset/tender-moose-shop.md +5 -0
  7. package/.github/pull_request_template.md +13 -0
  8. package/.github/workflows/ci.yml +41 -0
  9. package/.github/workflows/publish.yml +44 -0
  10. package/.github/workflows/release.yml +73 -0
  11. package/AGENTS.md +92 -0
  12. package/CHANGELOG.md +13 -0
  13. package/Dockerfile +57 -0
  14. package/LICENSE +661 -0
  15. package/Makefile +77 -0
  16. package/README.md +198 -0
  17. package/app/(auth)/login/page.tsx +53 -0
  18. package/app/(auth)/register/page.tsx +48 -0
  19. package/app/(auth)/sign-in/page.tsx +22 -0
  20. package/app/(dashboard)/admin/feedback/[id]/edit/page.tsx +103 -0
  21. package/app/(dashboard)/admin/feedback/[id]/page.tsx +154 -0
  22. package/app/(dashboard)/admin/feedback/new/page.tsx +91 -0
  23. package/app/(dashboard)/admin/feedback/page.tsx +81 -0
  24. package/app/(dashboard)/admin/layout.tsx +48 -0
  25. package/app/(dashboard)/analytics/portal/page.tsx +30 -0
  26. package/app/(dashboard)/dashboard/page.tsx +133 -0
  27. package/app/(dashboard)/layout.tsx +69 -0
  28. package/app/(dashboard)/no-access/page.tsx +45 -0
  29. package/app/(dashboard)/settings/access/page.tsx +56 -0
  30. package/app/(dashboard)/settings/api-keys/page.tsx +55 -0
  31. package/app/(dashboard)/settings/appearance/page.tsx +40 -0
  32. package/app/(dashboard)/settings/branding/page.tsx +62 -0
  33. package/app/(dashboard)/settings/changelog/page.tsx +51 -0
  34. package/app/(dashboard)/settings/danger-zone/page.tsx +92 -0
  35. package/app/(dashboard)/settings/feedback/page.tsx +63 -0
  36. package/app/(dashboard)/settings/integrations/page.tsx +94 -0
  37. package/app/(dashboard)/settings/layout.tsx +43 -0
  38. package/app/(dashboard)/settings/modules/page.tsx +54 -0
  39. package/app/(dashboard)/settings/notifications/page.tsx +48 -0
  40. package/app/(dashboard)/settings/organization/page.tsx +104 -0
  41. package/app/(dashboard)/settings/organization/portal/access/page.tsx +22 -0
  42. package/app/(dashboard)/settings/organization/portal/experience/page.tsx +22 -0
  43. package/app/(dashboard)/settings/organization/portal/growth/page.tsx +22 -0
  44. package/app/(dashboard)/settings/organization/portal/layout.tsx +24 -0
  45. package/app/(dashboard)/settings/organization/portal/page.tsx +22 -0
  46. package/app/(dashboard)/settings/organizations/[orgId]/members/page.tsx +69 -0
  47. package/app/(dashboard)/settings/organizations/new/page.tsx +36 -0
  48. package/app/(dashboard)/settings/page.tsx +22 -0
  49. package/app/(dashboard)/settings/portal-access/page.tsx +53 -0
  50. package/app/(dashboard)/settings/portal-branding/page.tsx +59 -0
  51. package/app/(dashboard)/settings/portal-growth/page.tsx +57 -0
  52. package/app/(dashboard)/settings/portal-modules/page.tsx +49 -0
  53. package/app/(dashboard)/settings/portal-resources/page.tsx +66 -0
  54. package/app/(dashboard)/settings/profile/page.tsx +48 -0
  55. package/app/(dashboard)/settings/widgets/page.tsx +63 -0
  56. package/app/(public)/[organizationSlug]/changelog/page.tsx +109 -0
  57. package/app/(public)/[organizationSlug]/feedback/[id]/page.tsx +146 -0
  58. package/app/(public)/[organizationSlug]/page.tsx +160 -0
  59. package/app/(public)/[organizationSlug]/roadmap/page.tsx +142 -0
  60. package/app/(public)/docs/page.tsx +48 -0
  61. package/app/(public)/feedback/[id]/not-found.tsx +33 -0
  62. package/app/(public)/feedback/[id]/page.tsx +102 -0
  63. package/app/(public)/invite/[token]/page.tsx +121 -0
  64. package/app/(public)/page.tsx +22 -0
  65. package/app/(public)/widget/[organizationId]/page.tsx +122 -0
  66. package/app/api/_utils.ts +29 -0
  67. package/app/api/admin/backup/route.ts +72 -0
  68. package/app/api/api-keys/[keyId]/route.ts +92 -0
  69. package/app/api/api-keys/route.ts +116 -0
  70. package/app/api/auth/[...all]/route.ts +21 -0
  71. package/app/api/auth/clear-session/route.ts +43 -0
  72. package/app/api/auth/register/handler.ts +176 -0
  73. package/app/api/auth/register/route.ts +26 -0
  74. package/app/api/docs/route.ts +28 -0
  75. package/app/api/feedback/[id]/comments/[commentId]/route.ts +105 -0
  76. package/app/api/feedback/[id]/comments/route.ts +421 -0
  77. package/app/api/feedback/[id]/duplicates/route.ts +285 -0
  78. package/app/api/feedback/[id]/handler.ts +91 -0
  79. package/app/api/feedback/[id]/processing-status/route.ts +199 -0
  80. package/app/api/feedback/[id]/reclassify/route.ts +145 -0
  81. package/app/api/feedback/[id]/route.ts +511 -0
  82. package/app/api/feedback/[id]/suggest-tags/route.ts +227 -0
  83. package/app/api/feedback/[id]/sync-github/route.ts +52 -0
  84. package/app/api/feedback/[id]/vote/route.ts +431 -0
  85. package/app/api/feedback/bulk/route.ts +212 -0
  86. package/app/api/feedback/handler.ts +138 -0
  87. package/app/api/feedback/route.ts +298 -0
  88. package/app/api/feedback/similar/route.ts +100 -0
  89. package/app/api/health/route.test.ts +64 -0
  90. package/app/api/health/route.ts +92 -0
  91. package/app/api/identify/jwt/route.ts +29 -0
  92. package/app/api/integrations/github/route.ts +196 -0
  93. package/app/api/internal/domain-lookup/route.ts +67 -0
  94. package/app/api/invitations/accept/handler.ts +101 -0
  95. package/app/api/invitations/accept/route.ts +29 -0
  96. package/app/api/notifications/preferences/route.ts +109 -0
  97. package/app/api/organizations/[orgId]/handler.ts +123 -0
  98. package/app/api/organizations/[orgId]/invitations/handler.ts +121 -0
  99. package/app/api/organizations/[orgId]/invitations/route.ts +29 -0
  100. package/app/api/organizations/[orgId]/members/[memberId]/handler.ts +208 -0
  101. package/app/api/organizations/[orgId]/members/[memberId]/route.ts +30 -0
  102. package/app/api/organizations/[orgId]/members/handler.ts +77 -0
  103. package/app/api/organizations/[orgId]/members/route.ts +29 -0
  104. package/app/api/organizations/[orgId]/route.ts +30 -0
  105. package/app/api/organizations/handler.ts +97 -0
  106. package/app/api/organizations/route.ts +29 -0
  107. package/app/api/tags/sync/route.ts +88 -0
  108. package/app/api/upload/handler.ts +79 -0
  109. package/app/api/upload/route.ts +37 -0
  110. package/app/api/v1/feedback/[id]/route.ts +276 -0
  111. package/app/api/v1/feedback/route.ts +250 -0
  112. package/app/api/v1/spec/route.ts +356 -0
  113. package/app/api/webhooks/[webhookId]/route.ts +213 -0
  114. package/app/api/webhooks/github/route.ts +158 -0
  115. package/app/api/webhooks/route.ts +143 -0
  116. package/app/favicon.ico +0 -0
  117. package/app/globals.css +139 -0
  118. package/app/health/route.ts +108 -0
  119. package/app/layout.tsx +60 -0
  120. package/bun.lock +2503 -0
  121. package/components/api/rate-limit-info.tsx +86 -0
  122. package/components/api-keys/api-key-manager.tsx +262 -0
  123. package/components/auth/login-form.tsx +207 -0
  124. package/components/auth/register-form.tsx +230 -0
  125. package/components/comment/comment-form.tsx +111 -0
  126. package/components/comment/internal-notes.tsx +219 -0
  127. package/components/comment/public-comments.tsx +387 -0
  128. package/components/component-example-client-only.tsx +29 -0
  129. package/components/component-example.tsx +519 -0
  130. package/components/dashboard/index.ts +22 -0
  131. package/components/dashboard/organization-switcher.tsx +96 -0
  132. package/components/dashboard/quick-actions.tsx +57 -0
  133. package/components/dashboard/recent-feedback-list.tsx +152 -0
  134. package/components/dashboard/stats-cards.tsx +88 -0
  135. package/components/dashboard/status-chart.tsx +106 -0
  136. package/components/example.tsx +70 -0
  137. package/components/feedback/attachment-list.tsx +103 -0
  138. package/components/feedback/auto-classification-badge.tsx +92 -0
  139. package/components/feedback/classification-override.tsx +64 -0
  140. package/components/feedback/duplicate-suggestions-inline.tsx +158 -0
  141. package/components/feedback/duplicate-suggestions.tsx +188 -0
  142. package/components/feedback/embedded-feedback-form.tsx +439 -0
  143. package/components/feedback/feedback-actions.tsx +160 -0
  144. package/components/feedback/feedback-bulk-actions.tsx +184 -0
  145. package/components/feedback/feedback-detail-view.tsx +321 -0
  146. package/components/feedback/feedback-detail.tsx +305 -0
  147. package/components/feedback/feedback-edit-form.tsx +131 -0
  148. package/components/feedback/feedback-filters.tsx +222 -0
  149. package/components/feedback/feedback-list-controls.tsx +433 -0
  150. package/components/feedback/feedback-list-item.tsx +298 -0
  151. package/components/feedback/feedback-list-skeleton.tsx +49 -0
  152. package/components/feedback/feedback-list.tsx +523 -0
  153. package/components/feedback/feedback-sorter.tsx +117 -0
  154. package/components/feedback/feedback-stats.tsx +124 -0
  155. package/components/feedback/file-upload.tsx +289 -0
  156. package/components/feedback/processing-status.tsx +161 -0
  157. package/components/feedback/status-history.tsx +134 -0
  158. package/components/feedback/status-selector.tsx +153 -0
  159. package/components/feedback/submit-on-behalf-form.tsx +403 -0
  160. package/components/feedback/tag-suggestions.tsx +212 -0
  161. package/components/feedback/vote-button.tsx +113 -0
  162. package/components/feedback/vote-list.tsx +108 -0
  163. package/components/integrations/github-config.tsx +200 -0
  164. package/components/landing/hero.tsx +150 -0
  165. package/components/layout/dashboard-layout.tsx +59 -0
  166. package/components/layout/index.ts +20 -0
  167. package/components/layout/language-switcher.tsx +129 -0
  168. package/components/layout/mobile-sidebar.tsx +66 -0
  169. package/components/layout/sidebar.tsx +279 -0
  170. package/components/portal/changelog-entry.tsx +132 -0
  171. package/components/portal/changelog-list.tsx +85 -0
  172. package/components/portal/contributor-badge.tsx +29 -0
  173. package/components/portal/contributors-sidebar.tsx +98 -0
  174. package/components/portal/create-post-dialog.tsx +247 -0
  175. package/components/portal/feedback-board.tsx +205 -0
  176. package/components/portal/feedback-post-card.tsx +198 -0
  177. package/components/portal/help-center.tsx +169 -0
  178. package/components/portal/leaderboard.tsx +29 -0
  179. package/components/portal/portal-header.tsx +153 -0
  180. package/components/portal/portal-layout.tsx +62 -0
  181. package/components/portal/portal-modules-panel.tsx +118 -0
  182. package/components/portal/portal-nav.tsx +59 -0
  183. package/components/portal/portal-overview.tsx +174 -0
  184. package/components/portal/portal-settings-nav.tsx +62 -0
  185. package/components/portal/portal-settings-shell.tsx +71 -0
  186. package/components/portal/portal-shell.tsx +62 -0
  187. package/components/portal/portal-tab-nav.tsx +77 -0
  188. package/components/portal/project-switcher.tsx +20 -0
  189. package/components/portal/roadmap-board.tsx +82 -0
  190. package/components/portal/roadmap-card.tsx +76 -0
  191. package/components/portal/roadmap-column.tsx +78 -0
  192. package/components/portal/settings-forms/access-form.tsx +194 -0
  193. package/components/portal/settings-forms/copy-form.tsx +95 -0
  194. package/components/portal/settings-forms/index.ts +23 -0
  195. package/components/portal/settings-forms/languages-form.tsx +223 -0
  196. package/components/portal/settings-forms/seo-form.tsx +156 -0
  197. package/components/portal/settings-forms/sharing-form.tsx +155 -0
  198. package/components/portal/settings-forms/theme-form.tsx +104 -0
  199. package/components/settings/api-keys-list.tsx +167 -0
  200. package/components/settings/appearance-form.tsx +71 -0
  201. package/components/settings/index.ts +25 -0
  202. package/components/settings/invite-member-form.tsx +119 -0
  203. package/components/settings/notification-preferences.tsx +174 -0
  204. package/components/settings/organization-form.tsx +165 -0
  205. package/components/settings/organization-members-list.tsx +197 -0
  206. package/components/settings/profile-form.tsx +124 -0
  207. package/components/settings/role-selector.tsx +57 -0
  208. package/components/settings/settings-sidebar.tsx +115 -0
  209. package/components/shared/pagination.tsx +215 -0
  210. package/components/ui/alert-dialog.tsx +201 -0
  211. package/components/ui/alert.tsx +75 -0
  212. package/components/ui/avatar.tsx +126 -0
  213. package/components/ui/badge.tsx +62 -0
  214. package/components/ui/button.tsx +77 -0
  215. package/components/ui/card.tsx +111 -0
  216. package/components/ui/combobox.tsx +311 -0
  217. package/components/ui/dialog.tsx +158 -0
  218. package/components/ui/dropdown-menu.tsx +272 -0
  219. package/components/ui/field.tsx +256 -0
  220. package/components/ui/input-group.tsx +164 -0
  221. package/components/ui/input.tsx +36 -0
  222. package/components/ui/label.tsx +41 -0
  223. package/components/ui/pagination.tsx +142 -0
  224. package/components/ui/select.tsx +202 -0
  225. package/components/ui/separator.tsx +45 -0
  226. package/components/ui/sheet.tsx +151 -0
  227. package/components/ui/skeleton.tsx +32 -0
  228. package/components/ui/switch.tsx +49 -0
  229. package/components/ui/table.tsx +118 -0
  230. package/components/ui/tabs.tsx +107 -0
  231. package/components/ui/textarea.tsx +35 -0
  232. package/components/ui/tooltip.tsx +78 -0
  233. package/components/widget/widget-form.tsx +439 -0
  234. package/components.json +24 -0
  235. package/db/init/01-init.sql +13 -0
  236. package/docker-compose.dev.yml +26 -0
  237. package/docker-compose.yml +98 -0
  238. package/docs/architecture.md +259 -0
  239. package/docs/component-inventory.md +261 -0
  240. package/docs/database-migrations.md +76 -0
  241. package/docs/development-guide.md +209 -0
  242. package/docs/e2e-user-flows.csv +31 -0
  243. package/docs/er-diagram-feedback.mmd +138 -0
  244. package/docs/er-diagram.mmd +281 -0
  245. package/docs/i18n-check-report.md +296 -0
  246. package/docs/index.md +214 -0
  247. package/docs/logic-chain.md +94 -0
  248. package/docs/plans/2026-01-02-database-migration-scripts.md +496 -0
  249. package/docs/plans/2026-01-02-user-login-design.md +37 -0
  250. package/docs/plans/2026-01-02-user-login.md +437 -0
  251. package/docs/plans/2026-01-02-user-registration-design.md +47 -0
  252. package/docs/plans/2026-01-02-user-registration.md +628 -0
  253. package/docs/plans/2026-01-03-roles-permissions-design.md +20 -0
  254. package/docs/plans/2026-01-03-roles-permissions.md +266 -0
  255. package/docs/plans/2026-01-05-authentication-middleware.md +207 -0
  256. package/docs/plans/2026-01-05-member-removal.md +186 -0
  257. package/docs/plans/2026-01-05-organization-creation.md +374 -0
  258. package/docs/plans/2026-01-05-rbac-middleware.md +112 -0
  259. package/docs/plans/2026-01-05-role-configuration.md +441 -0
  260. package/docs/plans/2026-01-06-file-upload-support.md +804 -0
  261. package/docs/plans/2026-01-06-permission-check-hook.md +155 -0
  262. package/docs/plans/2026-01-06-resource-ownership-check.md +231 -0
  263. package/docs/plans/2026-01-07-feedback-tracking-link.md +459 -0
  264. package/docs/plans/2026-01-09-logout-redirect-design.md +52 -0
  265. package/docs/plans/2026-01-09-phase2-3-plan.md +654 -0
  266. package/docs/plans/2026-01-09-portal-execution-plan.md +408 -0
  267. package/docs/plans/2026-01-09-project-delete-feature-design.md +163 -0
  268. package/docs/plans/2026-01-09-project-delete-implementation.md +451 -0
  269. package/docs/plans/2026-01-09-project-edit-delete-design.md +52 -0
  270. package/docs/plans/2026-01-09-settings-center-design.md +114 -0
  271. package/docs/plans/2026-01-09-settings-center.md +948 -0
  272. package/docs/plans/2026-01-10-organization-only-design.md +66 -0
  273. package/docs/plans/2026-01-10-organization-only-implementation.md +433 -0
  274. package/docs/plans/2026-01-10-portal-settings-restructure-plan.md +18 -0
  275. package/docs/plans/2026-01-10-project-settings-tabs-design-implementation.md +296 -0
  276. package/docs/plans/2026-01-14-e2e-playwright-feedback.md +173 -0
  277. package/docs/plans/2026-01-15-feedback-management-org-context-design.md +82 -0
  278. package/docs/plans/2026-01-15-feedback-management-org-context-implementation-plan.md +521 -0
  279. package/docs/plans/2026-01-16-admin-feedback-filters-design.md +75 -0
  280. package/docs/plans/2026-01-16-admin-feedback-filters-implementation.md +293 -0
  281. package/docs/plans/2026-01-16-admin-feedback-route-consolidation.md +180 -0
  282. package/docs/plans/2026-01-16-e2e-test-fixes.md +158 -0
  283. package/docs/plans/2026-01-17-admin-feedback-filters.md +214 -0
  284. package/docs/plans/2026-01-17-admin-feedback-improvements.md +453 -0
  285. package/docs/plans/2026-01-18-changesets-design.md +40 -0
  286. package/docs/product_changes.md +37 -0
  287. package/docs/project-overview.md +159 -0
  288. package/docs/project-scan-report.json +104 -0
  289. package/docs/route-role-visibility.md +51 -0
  290. package/docs/source-tree-analysis.md +150 -0
  291. package/docs/testing/delete-project-manual-tests.md +18 -0
  292. package/docs/user-story-tracking.md +191 -0
  293. package/drizzle.config.ts +32 -0
  294. package/eslint.config.mjs +19 -0
  295. package/hooks/use-permissions.ts +56 -0
  296. package/i18n/config.ts +45 -0
  297. package/i18n/request.ts +28 -0
  298. package/i18n/resolve-locale.ts +38 -0
  299. package/lib/api/errors.ts +62 -0
  300. package/lib/auth/cli-config.ts +35 -0
  301. package/lib/auth/client.ts +20 -0
  302. package/lib/auth/config.ts +55 -0
  303. package/lib/auth/jwt-identity.ts +21 -0
  304. package/lib/auth/org-context.ts +71 -0
  305. package/lib/auth/organization.ts +107 -0
  306. package/lib/auth/permissions.ts +87 -0
  307. package/lib/auth/session.ts +23 -0
  308. package/lib/config/rate-limits.ts +64 -0
  309. package/lib/dashboard/get-dashboard-stats.ts +136 -0
  310. package/lib/db/index.ts +41 -0
  311. package/lib/db/migrate.test.ts +49 -0
  312. package/lib/db/migrate.ts +62 -0
  313. package/lib/db/migrations/.gitkeep +0 -0
  314. package/lib/db/migrations/0000_cynical_gladiator.sql +53 -0
  315. package/lib/db/migrations/0001_wandering_sunfire.sql +27 -0
  316. package/lib/db/migrations/0002_shallow_speedball.sql +1 -0
  317. package/lib/db/migrations/0003_add_org_description.sql +1 -0
  318. package/lib/db/migrations/0003_boring_wild_pack.sql +13 -0
  319. package/lib/db/migrations/0004_windy_tyrannus.sql +27 -0
  320. package/lib/db/migrations/0005_perpetual_doorman.sql +5 -0
  321. package/lib/db/migrations/0006_aberrant_captain_midlands.sql +13 -0
  322. package/lib/db/migrations/0007_clever_captain_cross.sql +14 -0
  323. package/lib/db/migrations/0008_sparkling_pandemic.sql +2 -0
  324. package/lib/db/migrations/0009_happy_black_tom.sql +29 -0
  325. package/lib/db/migrations/0010_kind_junta.sql +8 -0
  326. package/lib/db/migrations/0011_mute_squadron_supreme.sql +25 -0
  327. package/lib/db/migrations/0012_giant_power_man.sql +24 -0
  328. package/lib/db/migrations/0013_damp_titanium_man.sql +17 -0
  329. package/lib/db/migrations/0014_blue_alice.sql +18 -0
  330. package/lib/db/migrations/0015_webhook_tables.sql +41 -0
  331. package/lib/db/migrations/0016_github_integration.sql +30 -0
  332. package/lib/db/migrations/0016_overjoyed_ghost_rider.sql +22 -0
  333. package/lib/db/migrations/0017_slimy_inhumans.sql +6 -0
  334. package/lib/db/migrations/0018_same_spitfire.sql +1 -0
  335. package/lib/db/migrations/0019_jittery_loners.sql +16 -0
  336. package/lib/db/migrations/0019_remove_projects_add_org_settings.sql +14 -0
  337. package/lib/db/migrations/meta/0000_snapshot.json +374 -0
  338. package/lib/db/migrations/meta/0001_snapshot.json +553 -0
  339. package/lib/db/migrations/meta/0002_snapshot.json +560 -0
  340. package/lib/db/migrations/meta/0003_snapshot.json +650 -0
  341. package/lib/db/migrations/meta/0004_snapshot.json +852 -0
  342. package/lib/db/migrations/meta/0005_snapshot.json +900 -0
  343. package/lib/db/migrations/meta/0006_snapshot.json +1011 -0
  344. package/lib/db/migrations/meta/0007_snapshot.json +1125 -0
  345. package/lib/db/migrations/meta/0008_snapshot.json +1146 -0
  346. package/lib/db/migrations/meta/0009_snapshot.json +1386 -0
  347. package/lib/db/migrations/meta/0010_snapshot.json +1419 -0
  348. package/lib/db/migrations/meta/0011_snapshot.json +1615 -0
  349. package/lib/db/migrations/meta/0012_snapshot.json +1805 -0
  350. package/lib/db/migrations/meta/0013_snapshot.json +1948 -0
  351. package/lib/db/migrations/meta/0014_snapshot.json +2082 -0
  352. package/lib/db/migrations/meta/0015_snapshot.json +2476 -0
  353. package/lib/db/migrations/meta/0016_snapshot.json +2633 -0
  354. package/lib/db/migrations/meta/0017_snapshot.json +2680 -0
  355. package/lib/db/migrations/meta/0018_snapshot.json +2686 -0
  356. package/lib/db/migrations/meta/0019_snapshot.json +2741 -0
  357. package/lib/db/migrations/meta/_journal.json +146 -0
  358. package/lib/db/schema/ai-processing.ts +90 -0
  359. package/lib/db/schema/api-keys.ts +61 -0
  360. package/lib/db/schema/attachments.ts +48 -0
  361. package/lib/db/schema/auth.ts +111 -0
  362. package/lib/db/schema/comments.ts +74 -0
  363. package/lib/db/schema/duplicates.ts +80 -0
  364. package/lib/db/schema/feedback.ts +88 -0
  365. package/lib/db/schema/github-integrations.ts +66 -0
  366. package/lib/db/schema/index.ts +35 -0
  367. package/lib/db/schema/invitations.ts +32 -0
  368. package/lib/db/schema/notifications.ts +85 -0
  369. package/lib/db/schema/organization-members.ts +37 -0
  370. package/lib/db/schema/organization-settings.ts +134 -0
  371. package/lib/db/schema/organizations.ts +30 -0
  372. package/lib/db/schema/projects.ts +145 -0
  373. package/lib/db/schema/status-history.ts +63 -0
  374. package/lib/db/schema/tags.ts +194 -0
  375. package/lib/db/schema/user-profiles.ts +31 -0
  376. package/lib/db/schema/votes.ts +60 -0
  377. package/lib/db/schema/webhooks.ts +106 -0
  378. package/lib/feedback/filters.ts +28 -0
  379. package/lib/feedback/find-similar.ts +49 -0
  380. package/lib/feedback/get-feedback-by-id.ts +159 -0
  381. package/lib/feedback/prefill.ts +51 -0
  382. package/lib/http/get-request-url.ts +28 -0
  383. package/lib/integrations/github.ts +159 -0
  384. package/lib/invitations.ts +22 -0
  385. package/lib/logger.test.ts +31 -0
  386. package/lib/logger.ts +58 -0
  387. package/lib/middleware/api-key.ts +126 -0
  388. package/lib/middleware/rate-limit-keys.ts +47 -0
  389. package/lib/middleware/rate-limit.ts +148 -0
  390. package/lib/middleware/rbac.ts +39 -0
  391. package/lib/middleware/request-id.test.ts +28 -0
  392. package/lib/middleware/request-id.ts +30 -0
  393. package/lib/middleware/request-logger.test.ts +36 -0
  394. package/lib/middleware/request-logger.ts +41 -0
  395. package/lib/middleware/with-rate-limit.ts +33 -0
  396. package/lib/portal/analytics.ts +20 -0
  397. package/lib/portal/contributors.ts +27 -0
  398. package/lib/portal/i18n.ts +20 -0
  399. package/lib/portal/leaderboard-settings.ts +20 -0
  400. package/lib/portal/modules.ts +20 -0
  401. package/lib/portal/portal-copy.ts +20 -0
  402. package/lib/portal/public-context.tsx +110 -0
  403. package/lib/portal/seo.ts +20 -0
  404. package/lib/portal/settings-context.ts +56 -0
  405. package/lib/portal/sharing.ts +20 -0
  406. package/lib/portal/sorting.ts +20 -0
  407. package/lib/portal/theme.ts +20 -0
  408. package/lib/services/ai/classifier.ts +296 -0
  409. package/lib/services/ai/duplicate-detector.ts +255 -0
  410. package/lib/services/ai/tag-suggester.ts +108 -0
  411. package/lib/services/api-keys.ts +164 -0
  412. package/lib/services/backup.ts +173 -0
  413. package/lib/services/email/templates.ts +158 -0
  414. package/lib/services/email.ts +68 -0
  415. package/lib/services/github-sync.ts +205 -0
  416. package/lib/services/notifications/index.ts +224 -0
  417. package/lib/services/portal-settings.ts +157 -0
  418. package/lib/swagger/config.ts +296 -0
  419. package/lib/swagger/generate.ts +400 -0
  420. package/lib/upload/file-validator.ts +52 -0
  421. package/lib/upload/storage.ts +59 -0
  422. package/lib/utils/format.ts +26 -0
  423. package/lib/utils/slug.ts +28 -0
  424. package/lib/utils.ts +23 -0
  425. package/lib/validations/auth.ts +56 -0
  426. package/lib/validations/comment.ts +44 -0
  427. package/lib/validations/feedback.ts +51 -0
  428. package/lib/validations/invitations.ts +23 -0
  429. package/lib/validations/organizations.ts +34 -0
  430. package/lib/validations/projects.ts +49 -0
  431. package/lib/validators/feedback.ts +57 -0
  432. package/lib/validators/index.ts +18 -0
  433. package/lib/webhooks/events.ts +73 -0
  434. package/lib/webhooks/index.ts +21 -0
  435. package/lib/webhooks/retry.ts +188 -0
  436. package/lib/webhooks/sender.ts +183 -0
  437. package/lib/webhooks/verify.ts +37 -0
  438. package/lib/workers/feedback-processor.ts +255 -0
  439. package/messages/en.json +965 -0
  440. package/messages/jp.json +862 -0
  441. package/messages/zh-CN.json +855 -0
  442. package/next-env.d.ts +6 -0
  443. package/next.config.ts +66 -0
  444. package/package.json +84 -0
  445. package/playwright.config.ts +44 -0
  446. package/postcss.config.mjs +7 -0
  447. package/proxy.test.ts +131 -0
  448. package/proxy.ts +190 -0
  449. package/public/file.svg +1 -0
  450. package/public/globe.svg +1 -0
  451. package/public/logo-64.svg +5 -0
  452. package/public/logo.svg +5 -0
  453. package/public/next.svg +1 -0
  454. package/public/openapi.json +673 -0
  455. package/public/uploads/.gitkeep +0 -0
  456. package/public/uploads/02695701-ded0-4c81-8a21-9326c1d65448.pdf +1 -0
  457. package/public/uploads/178843ea-2780-48ef-8988-f4cba442e4cb.pdf +1 -0
  458. package/public/uploads/24b0a9ef-da93-49da-934f-637f89c7871d.pdf +1 -0
  459. package/public/uploads/7a11626d-a8e4-4b91-a8eb-20b6213b0a5a.pdf +1 -0
  460. package/public/uploads/b0703f4d-6e7b-4aab-8191-1a7b15f1b8ee.pdf +1 -0
  461. package/public/uploads/c8de0aed-4d3a-44aa-83bb-6594b7a2ddb3.pdf +1 -0
  462. package/public/uploads/e4cce295-0d85-4525-a1b0-a61c45722e26.pdf +1 -0
  463. package/public/uploads/eb4df45e-563c-48b8-9c68-c18212312426.pdf +1 -0
  464. package/public/vercel.svg +1 -0
  465. package/public/widget/embed.js +249 -0
  466. package/public/window.svg +1 -0
  467. package/scripts/backup-db.sh +57 -0
  468. package/scripts/backup-db.ts +24 -0
  469. package/scripts/generate-openapi.ts +22 -0
  470. package/scripts/migration-helper.ts +39 -0
  471. package/scripts/pre-deploy.ts +75 -0
  472. package/scripts/restore-db.sh +60 -0
  473. package/scripts/rollback.ts +72 -0
  474. package/scripts/seed-tags.ts +48 -0
  475. package/tests/api/feedback-bulk.test.ts +47 -0
  476. package/tests/api/feedback-by-id.test.ts +67 -0
  477. package/tests/api/feedback-comments-route-import.test.ts +26 -0
  478. package/tests/api/feedback-create.test.ts +71 -0
  479. package/tests/api/feedback-delete.test.ts +160 -0
  480. package/tests/api/feedback-filter.test.ts +250 -0
  481. package/tests/api/feedback-list.test.ts +234 -0
  482. package/tests/api/feedback-route-assignee-condition.test.ts +32 -0
  483. package/tests/api/feedback-similar.test.ts +46 -0
  484. package/tests/api/feedback-sort.test.ts +261 -0
  485. package/tests/api/feedback-status-enum.test.ts +49 -0
  486. package/tests/api/feedback-status-filter.test.ts +117 -0
  487. package/tests/api/feedback-submit-on-behalf.test.ts +269 -0
  488. package/tests/api/feedback.test.ts +175 -0
  489. package/tests/api/identify-jwt.test.ts +25 -0
  490. package/tests/api/invitation-accept.test.ts +213 -0
  491. package/tests/api/organization-invitations.test.ts +186 -0
  492. package/tests/api/organization-members-list.test.ts +79 -0
  493. package/tests/api/organization-members.test.ts +340 -0
  494. package/tests/api/organizations.test.ts +149 -0
  495. package/tests/api/register.test.ts +112 -0
  496. package/tests/api/upload.test.ts +103 -0
  497. package/tests/api/vote.test.ts +82 -0
  498. package/tests/app/admin-feedback-detail-page.test.tsx +25 -0
  499. package/tests/app/admin-feedback-list-page.test.tsx +25 -0
  500. package/tests/app/admin-feedback-new-page.test.tsx +25 -0
  501. package/tests/app/health-route-helpers.test.ts +27 -0
  502. package/tests/app/login-page.test.ts +26 -0
  503. package/tests/app/portal-page.test.ts +29 -0
  504. package/tests/app/project-portal-overview.test.tsx +25 -0
  505. package/tests/app/widget-page-import.test.ts +25 -0
  506. package/tests/components/create-post-dialog-defaults.test.ts +43 -0
  507. package/tests/components/feedback/duplicate-suggestions-inline.test.tsx +27 -0
  508. package/tests/components/feedback/embedded-feedback-form.test.tsx +96 -0
  509. package/tests/components/feedback/feedback-detail.test.tsx +25 -0
  510. package/tests/components/feedback/feedback-stats.test.tsx +49 -0
  511. package/tests/components/feedback-bulk-actions.test.tsx +39 -0
  512. package/tests/components/feedback-i18n-keys.test.ts +70 -0
  513. package/tests/components/feedback-list-controls-compile.test.ts +25 -0
  514. package/tests/components/feedback-list-controls.test.tsx +204 -0
  515. package/tests/components/feedback-list-item.test.tsx +67 -0
  516. package/tests/components/landing/hero.test.tsx +46 -0
  517. package/tests/components/layout/language-switcher.test.tsx +25 -0
  518. package/tests/components/layout/sidebar.test.tsx +157 -0
  519. package/tests/components/login-form.test.ts +25 -0
  520. package/tests/components/organization-form.test.ts +32 -0
  521. package/tests/components/organization-switcher.test.ts +25 -0
  522. package/tests/components/pagination.test.tsx +43 -0
  523. package/tests/components/portal-overview.test.tsx +25 -0
  524. package/tests/components/profile-form.test.tsx +139 -0
  525. package/tests/components/role-selector.test.ts +31 -0
  526. package/tests/components/status-chart.test.tsx +90 -0
  527. package/tests/e2e/auth.e2e.ts +323 -0
  528. package/tests/e2e/feedback-actions.e2e.ts +471 -0
  529. package/tests/e2e/feedback-attachment.e2e.ts +168 -0
  530. package/tests/e2e/feedback-customer.e2e.ts +226 -0
  531. package/tests/e2e/feedback-management.e2e.ts +565 -0
  532. package/tests/e2e/feedback-submit.e2e.ts +133 -0
  533. package/tests/e2e/feedback-view.e2e.ts +297 -0
  534. package/tests/e2e/fixtures/test-data.ts +235 -0
  535. package/tests/e2e/health-check.e2e.ts +230 -0
  536. package/tests/e2e/helpers/test-utils-helpers.test.ts +43 -0
  537. package/tests/e2e/helpers/test-utils.ts +298 -0
  538. package/tests/e2e/integration-placeholders.e2e.ts +199 -0
  539. package/tests/e2e/organization.e2e.ts +292 -0
  540. package/tests/e2e/permissions.e2e.ts +424 -0
  541. package/tests/e2e/project-widget.e2e.ts +63 -0
  542. package/tests/feedback/filters.test.ts +29 -0
  543. package/tests/hooks/use-permissions.test.ts +52 -0
  544. package/tests/lib/ai/classifier.test.ts +104 -0
  545. package/tests/lib/ai/duplicate-detector.test.ts +234 -0
  546. package/tests/lib/attachments-schema.test.ts +30 -0
  547. package/tests/lib/auth/session.test.ts +49 -0
  548. package/tests/lib/auth-client.test.ts +37 -0
  549. package/tests/lib/auth-config.test.ts +26 -0
  550. package/tests/lib/feedback-prefill.test.ts +52 -0
  551. package/tests/lib/feedback-processor.test.ts +41 -0
  552. package/tests/lib/feedback-schema.test.ts +33 -0
  553. package/tests/lib/file-validator.test.ts +48 -0
  554. package/tests/lib/get-feedback-by-id.test.ts +37 -0
  555. package/tests/lib/invitations.test.ts +35 -0
  556. package/tests/lib/login-schema.test.ts +36 -0
  557. package/tests/lib/org-context.test.ts +95 -0
  558. package/tests/lib/organization-access.test.ts +44 -0
  559. package/tests/lib/organization-member-role-schema.test.ts +41 -0
  560. package/tests/lib/permissions.test.ts +88 -0
  561. package/tests/lib/portal-analytics.test.ts +25 -0
  562. package/tests/lib/portal-contributors.test.ts +25 -0
  563. package/tests/lib/portal-copy.test.ts +27 -0
  564. package/tests/lib/portal-i18n.test.ts +30 -0
  565. package/tests/lib/portal-leaderboard-settings.test.ts +25 -0
  566. package/tests/lib/portal-modules.test.ts +25 -0
  567. package/tests/lib/portal-seo.test.ts +25 -0
  568. package/tests/lib/portal-sharing.test.ts +25 -0
  569. package/tests/lib/portal-sorting.test.ts +25 -0
  570. package/tests/lib/portal-theme.test.ts +25 -0
  571. package/tests/lib/rate-limit.test.ts +142 -0
  572. package/tests/lib/resolve-locale.test.ts +34 -0
  573. package/tests/lib/services/backup.test.ts +145 -0
  574. package/tests/lib/user-organizations.test.ts +42 -0
  575. package/tests/lib/user-role-schema.test.ts +33 -0
  576. package/tests/lib/user-schema.test.ts +25 -0
  577. package/tests/setup.ts +74 -0
  578. package/tsconfig.json +34 -0
  579. package/types/bun-test.d.ts +31 -0
@@ -0,0 +1,188 @@
1
+ /*
2
+ * Copyright (c) 2026 Echo Team
3
+ *
4
+ * This program is free software: you can redistribute it and/or modify
5
+ * it under the terms of the GNU Affero General Public License as published by
6
+ * the Free Software Foundation, either version 3 of the License, or
7
+ * (at your option) any later version.
8
+ *
9
+ * This program is distributed in the hope that it will be useful,
10
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ * GNU Affero General Public License for more details.
13
+ *
14
+ * You should have received a copy of the GNU Affero General Public License
15
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+ */
17
+
18
+ import { db } from "@/lib/db";
19
+ import { webhooks, webhookEvents } from "@/lib/db/schema";
20
+ import { eq, and, lte, lt } from "drizzle-orm";
21
+ import { createHmac } from "crypto";
22
+ import { logger } from "@/lib/logger";
23
+
24
+ const processing = new Set<number>();
25
+
26
+ function signPayload(payload: unknown, secret: string | null): string {
27
+ const payloadString = JSON.stringify(payload);
28
+ const hmac = createHmac("sha256", secret || "");
29
+ hmac.update(payloadString);
30
+ return `sha256=${hmac.digest("hex")}`;
31
+ }
32
+
33
+ function calculateNextRetry(retryCount: number): Date {
34
+ const delays = [60, 300, 900]; // 1min, 5min, 15min
35
+ const delay = delays[Math.min(retryCount, delays.length - 1)];
36
+ return new Date(Date.now() + delay * 1000);
37
+ }
38
+
39
+ async function retryWebhookEvent(
40
+ event: {
41
+ eventId: number;
42
+ webhookId: number;
43
+ eventType: string;
44
+ payload: unknown;
45
+ retryCount: number;
46
+ maxRetries: number;
47
+ },
48
+ webhook: { url: string; secret: string | null },
49
+ ): Promise<void> {
50
+ if (!db) return;
51
+
52
+ logger.info({ eventId: event.eventId, retryCount: event.retryCount }, "Retrying webhook");
53
+
54
+ await db
55
+ .update(webhookEvents)
56
+ .set({ status: "sending" })
57
+ .where(eq(webhookEvents.eventId, event.eventId));
58
+
59
+ try {
60
+ const signature = signPayload(event.payload, webhook.secret);
61
+ const timestamp = Date.now().toString();
62
+
63
+ const response = await fetch(webhook.url, {
64
+ method: "POST",
65
+ headers: {
66
+ "Content-Type": "application/json",
67
+ "User-Agent": "Echo-Webhooks/1.0",
68
+ "X-Echo-Webhook-ID": event.eventId.toString(),
69
+ "X-Echo-Webhook-Event": event.eventType,
70
+ "X-Echo-Webhook-Signature": signature,
71
+ "X-Echo-Webhook-Timestamp": timestamp,
72
+ },
73
+ body: JSON.stringify(event.payload),
74
+ });
75
+
76
+ const responseText = await response.text();
77
+
78
+ if (response.ok) {
79
+ await db
80
+ .update(webhookEvents)
81
+ .set({
82
+ status: "delivered",
83
+ responseStatus: response.status,
84
+ responseBody: responseText.slice(0, 10000),
85
+ deliveredAt: new Date(),
86
+ })
87
+ .where(eq(webhookEvents.eventId, event.eventId));
88
+
89
+ logger.info({ eventId: event.eventId }, "Webhook retry succeeded");
90
+ } else {
91
+ await handleRetryFailure(event, response.status, responseText);
92
+ }
93
+ } catch (error) {
94
+ await handleRetryFailure(event, null, null);
95
+ logger.error({ eventId: event.eventId, err: error }, "Webhook retry error");
96
+ }
97
+ }
98
+
99
+ async function handleRetryFailure(
100
+ event: { eventId: number; retryCount: number; maxRetries: number },
101
+ responseStatus: number | null,
102
+ responseBody: string | null,
103
+ ): Promise<void> {
104
+ if (!db) return;
105
+
106
+ const newRetryCount = event.retryCount + 1;
107
+
108
+ if (newRetryCount >= event.maxRetries) {
109
+ await db
110
+ .update(webhookEvents)
111
+ .set({
112
+ status: "failed",
113
+ retryCount: newRetryCount,
114
+ responseStatus,
115
+ responseBody: responseBody?.slice(0, 10000),
116
+ })
117
+ .where(eq(webhookEvents.eventId, event.eventId));
118
+
119
+ logger.warn({ eventId: event.eventId }, "Webhook failed after max retries");
120
+ } else {
121
+ const nextRetryAt = calculateNextRetry(newRetryCount);
122
+
123
+ await db
124
+ .update(webhookEvents)
125
+ .set({
126
+ status: "pending",
127
+ retryCount: newRetryCount,
128
+ nextRetryAt,
129
+ responseStatus,
130
+ responseBody: responseBody?.slice(0, 10000),
131
+ })
132
+ .where(eq(webhookEvents.eventId, event.eventId));
133
+ }
134
+ }
135
+
136
+ export async function processFailedWebhooks(): Promise<void> {
137
+ if (!db) {
138
+ logger.error("Database not configured");
139
+ return;
140
+ }
141
+
142
+ const failedEvents = await db
143
+ .select({
144
+ eventId: webhookEvents.eventId,
145
+ webhookId: webhookEvents.webhookId,
146
+ eventType: webhookEvents.eventType,
147
+ payload: webhookEvents.payload,
148
+ retryCount: webhookEvents.retryCount,
149
+ maxRetries: webhookEvents.maxRetries,
150
+ })
151
+ .from(webhookEvents)
152
+ .where(
153
+ and(
154
+ eq(webhookEvents.status, "pending"),
155
+ lte(webhookEvents.nextRetryAt, new Date()),
156
+ lt(webhookEvents.retryCount, webhookEvents.maxRetries),
157
+ ),
158
+ )
159
+ .limit(10);
160
+
161
+ for (const event of failedEvents) {
162
+ if (processing.has(event.eventId)) {
163
+ continue;
164
+ }
165
+
166
+ processing.add(event.eventId);
167
+
168
+ try {
169
+ const webhook = await db.query.webhooks.findFirst({
170
+ where: eq(webhooks.webhookId, event.webhookId),
171
+ });
172
+
173
+ if (!webhook || !webhook.enabled) {
174
+ await db
175
+ .update(webhookEvents)
176
+ .set({ status: "failed" })
177
+ .where(eq(webhookEvents.eventId, event.eventId));
178
+ continue;
179
+ }
180
+
181
+ await retryWebhookEvent(event, webhook);
182
+ } catch (error) {
183
+ logger.error({ eventId: event.eventId, err: error }, "Failed to retry webhook");
184
+ } finally {
185
+ processing.delete(event.eventId);
186
+ }
187
+ }
188
+ }
@@ -0,0 +1,183 @@
1
+ /*
2
+ * Copyright (c) 2026 Echo Team
3
+ *
4
+ * This program is free software: you can redistribute it and/or modify
5
+ * it under the terms of the GNU Affero General Public License as published by
6
+ * the Free Software Foundation, either version 3 of the License, or
7
+ * (at your option) any later version.
8
+ *
9
+ * This program is distributed in the hope that it will be useful,
10
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ * GNU Affero General Public License for more details.
13
+ *
14
+ * You should have received a copy of the GNU Affero General Public License
15
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+ */
17
+
18
+ import { db } from "@/lib/db";
19
+ import { webhooks, webhookEvents } from "@/lib/db/schema";
20
+ import { eq, and } from "drizzle-orm";
21
+ import { createHmac } from "crypto";
22
+ import { logger } from "@/lib/logger";
23
+ import type { WebhookPayload } from "./events";
24
+
25
+ function signPayload(payload: unknown, secret: string | null): string {
26
+ const payloadString = JSON.stringify(payload);
27
+ const hmac = createHmac("sha256", secret || "");
28
+ hmac.update(payloadString);
29
+ return `sha256=${hmac.digest("hex")}`;
30
+ }
31
+
32
+ function calculateNextRetry(retryCount: number): Date {
33
+ const delays = [60, 300, 900]; // 1min, 5min, 15min
34
+ const delay = delays[Math.min(retryCount, delays.length - 1)];
35
+ return new Date(Date.now() + delay * 1000);
36
+ }
37
+
38
+ export async function sendWebhook(
39
+ webhookId: number,
40
+ eventType: string,
41
+ payload: WebhookPayload,
42
+ ): Promise<void> {
43
+ if (!db) {
44
+ logger.error("Database not configured");
45
+ return;
46
+ }
47
+
48
+ const webhook = await db.query.webhooks.findFirst({
49
+ where: eq(webhooks.webhookId, webhookId),
50
+ });
51
+
52
+ if (!webhook || !webhook.enabled) {
53
+ logger.info({ webhookId }, "Webhook not found or disabled");
54
+ return;
55
+ }
56
+
57
+ if (!webhook.events.includes(eventType)) {
58
+ logger.info({ webhookId, eventType }, "Webhook not subscribed to event");
59
+ return;
60
+ }
61
+
62
+ const eventRecord = await db
63
+ .insert(webhookEvents)
64
+ .values({
65
+ webhookId,
66
+ eventType,
67
+ payload,
68
+ status: "sending",
69
+ retryCount: 0,
70
+ maxRetries: 3,
71
+ })
72
+ .returning();
73
+
74
+ const eventId = eventRecord[0].eventId;
75
+
76
+ try {
77
+ const signature = signPayload(payload, webhook.secret);
78
+ const timestamp = Date.now().toString();
79
+
80
+ const response = await fetch(webhook.url, {
81
+ method: "POST",
82
+ headers: {
83
+ "Content-Type": "application/json",
84
+ "User-Agent": "Echo-Webhooks/1.0",
85
+ "X-Echo-Webhook-ID": eventId.toString(),
86
+ "X-Echo-Webhook-Event": eventType,
87
+ "X-Echo-Webhook-Signature": signature,
88
+ "X-Echo-Webhook-Timestamp": timestamp,
89
+ },
90
+ body: JSON.stringify(payload),
91
+ });
92
+
93
+ const responseText = await response.text();
94
+
95
+ if (response.ok) {
96
+ await db
97
+ .update(webhookEvents)
98
+ .set({
99
+ status: "delivered",
100
+ responseStatus: response.status,
101
+ responseBody: responseText.slice(0, 10000),
102
+ deliveredAt: new Date(),
103
+ })
104
+ .where(eq(webhookEvents.eventId, eventId));
105
+
106
+ logger.info(
107
+ { webhookId, eventId, status: response.status },
108
+ "Webhook delivered",
109
+ );
110
+ } else {
111
+ const nextRetryAt = calculateNextRetry(0);
112
+
113
+ await db
114
+ .update(webhookEvents)
115
+ .set({
116
+ status: "pending",
117
+ responseStatus: response.status,
118
+ responseBody: responseText.slice(0, 10000),
119
+ retryCount: 0,
120
+ nextRetryAt,
121
+ })
122
+ .where(eq(webhookEvents.eventId, eventId));
123
+
124
+ logger.warn(
125
+ { webhookId, eventId, status: response.status },
126
+ "Webhook failed, will retry",
127
+ );
128
+ }
129
+ } catch (error) {
130
+ const nextRetryAt = calculateNextRetry(0);
131
+
132
+ await db
133
+ .update(webhookEvents)
134
+ .set({
135
+ status: "pending",
136
+ retryCount: 0,
137
+ nextRetryAt,
138
+ })
139
+ .where(eq(webhookEvents.eventId, eventId));
140
+
141
+ logger.error({ webhookId, eventId, err: error }, "Webhook error, will retry");
142
+ }
143
+ }
144
+
145
+ export async function triggerWebhooks(
146
+ organizationId: string,
147
+ eventType: string,
148
+ payload: WebhookPayload,
149
+ ): Promise<void> {
150
+ if (!db) {
151
+ logger.error("Database not configured");
152
+ return;
153
+ }
154
+
155
+ const orgWebhooks = await db.query.webhooks.findMany({
156
+ where: and(
157
+ eq(webhooks.organizationId, organizationId),
158
+ eq(webhooks.enabled, true),
159
+ ),
160
+ });
161
+
162
+ const subscribedWebhooks = orgWebhooks.filter((w) =>
163
+ w.events.includes(eventType),
164
+ );
165
+
166
+ for (const webhook of subscribedWebhooks) {
167
+ sendWebhook(webhook.webhookId, eventType, payload).catch((error) => {
168
+ logger.error(
169
+ { webhookId: webhook.webhookId, err: error },
170
+ "Failed to queue webhook",
171
+ );
172
+ });
173
+ }
174
+
175
+ logger.info(
176
+ {
177
+ organizationId,
178
+ eventType,
179
+ count: subscribedWebhooks.length,
180
+ },
181
+ "Webhooks triggered",
182
+ );
183
+ }
@@ -0,0 +1,37 @@
1
+ /*
2
+ * Copyright (c) 2026 Echo Team
3
+ *
4
+ * This program is free software: you can redistribute it and/or modify
5
+ * it under the terms of the GNU Affero General Public License as published by
6
+ * the Free Software Foundation, either version 3 of the License, or
7
+ * (at your option) any later version.
8
+ *
9
+ * This program is distributed in the hope that it will be useful,
10
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ * GNU Affero General Public License for more details.
13
+ *
14
+ * You should have received a copy of the GNU Affero General Public License
15
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+ */
17
+
18
+ import { createHmac, timingSafeEqual } from "crypto";
19
+
20
+ export function verifyWebhookSignature(
21
+ payload: string,
22
+ signature: string,
23
+ secret: string,
24
+ ): boolean {
25
+ const hmac = createHmac("sha256", secret);
26
+ hmac.update(payload);
27
+ const expectedSignature = `sha256=${hmac.digest("hex")}`;
28
+
29
+ try {
30
+ return timingSafeEqual(
31
+ Buffer.from(signature),
32
+ Buffer.from(expectedSignature),
33
+ );
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
@@ -0,0 +1,255 @@
1
+ /*
2
+ * Copyright (c) 2026 Echo Team
3
+ *
4
+ * This program is free software: you can redistribute it and/or modify
5
+ * it under the terms of the GNU Affero General Public License as published by
6
+ * the Free Software Foundation, either version 3 of the License, or
7
+ * (at your option) any later version.
8
+ *
9
+ * This program is distributed in the hope that it will be useful,
10
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ * GNU Affero General Public License for more details.
13
+ *
14
+ * You should have received a copy of the GNU Affero General Public License
15
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+ */
17
+
18
+ /**
19
+ * Background AI processing for feedback
20
+ * MVP: Uses setTimeout + in-memory queue
21
+ * Future: BullMQ + Redis for production scalability
22
+ */
23
+
24
+ import { classifyFeedback } from "@/lib/services/ai/classifier";
25
+ import { suggestTags } from "@/lib/services/ai/tag-suggester";
26
+ import {
27
+ findDuplicates,
28
+ type FeedbackForDuplicateCheck,
29
+ } from "@/lib/services/ai/duplicate-detector";
30
+ import { db } from "@/lib/db";
31
+ import {
32
+ feedback,
33
+ aiProcessingResults,
34
+ duplicateFeedback,
35
+ } from "@/lib/db/schema";
36
+ import { eq, and, isNull, ne } from "drizzle-orm";
37
+ import { logger } from "@/lib/logger";
38
+
39
+ export interface ProcessingJob {
40
+ feedbackId: number;
41
+ title: string;
42
+ description: string;
43
+ organizationId: string;
44
+ }
45
+
46
+ /**
47
+ * Process AI tasks for a single feedback item
48
+ */
49
+ export async function processFeedback(job: ProcessingJob): Promise<void> {
50
+ if (
51
+ !db ||
52
+ typeof db.update !== "function" ||
53
+ typeof db.insert !== "function" ||
54
+ typeof db.select !== "function"
55
+ ) {
56
+ logger.error("Database not available for AI processing");
57
+ return;
58
+ }
59
+
60
+ const startTime = Date.now();
61
+
62
+ try {
63
+ logger.info({ feedbackId: job.feedbackId }, "Starting AI processing");
64
+
65
+ // Update status to processing
66
+ await db
67
+ .update(feedback)
68
+ .set({ processingStatus: "processing" })
69
+ .where(eq(feedback.feedbackId, job.feedbackId));
70
+
71
+ // 1. Classification
72
+ const classification = classifyFeedback(job.title, job.description);
73
+
74
+ // 2. Tag suggestions
75
+ const tagSuggestions = suggestTags(job.title, job.description);
76
+
77
+ // 3. Duplicate detection
78
+ const existingFeedbacks: FeedbackForDuplicateCheck[] = await db
79
+ .select({
80
+ feedbackId: feedback.feedbackId,
81
+ title: feedback.title,
82
+ description: feedback.description,
83
+ })
84
+ .from(feedback)
85
+ .where(
86
+ and(
87
+ eq(feedback.organizationId, job.organizationId),
88
+ isNull(feedback.deletedAt),
89
+ ne(feedback.feedbackId, job.feedbackId),
90
+ ),
91
+ );
92
+
93
+ const duplicateCandidates = findDuplicates(
94
+ job.title,
95
+ job.description,
96
+ existingFeedbacks,
97
+ job.feedbackId,
98
+ 0.75,
99
+ );
100
+
101
+ const processingTime = Date.now() - startTime;
102
+
103
+ // Save processing results
104
+ await db.insert(aiProcessingResults).values({
105
+ feedbackId: job.feedbackId,
106
+ classification,
107
+ tagSuggestions: tagSuggestions.map((t) => ({
108
+ name: t.name,
109
+ slug: t.slug,
110
+ confidence: t.confidence,
111
+ })),
112
+ duplicateCandidates: duplicateCandidates.map((d) => ({
113
+ feedbackId: d.feedbackId,
114
+ similarity: d.similarity,
115
+ })),
116
+ processingTime,
117
+ status: "completed",
118
+ });
119
+
120
+ // Save duplicate candidates to duplicate_feedback table
121
+ for (const duplicate of duplicateCandidates) {
122
+ await db
123
+ .insert(duplicateFeedback)
124
+ .values({
125
+ originalFeedbackId: job.feedbackId,
126
+ duplicateFeedbackId: duplicate.feedbackId,
127
+ similarity: duplicate.similarity,
128
+ status: "pending",
129
+ })
130
+ .onConflictDoNothing();
131
+ }
132
+
133
+ // Update feedback status
134
+ await db
135
+ .update(feedback)
136
+ .set({
137
+ processingStatus: "completed",
138
+ processedAt: new Date(),
139
+ })
140
+ .where(eq(feedback.feedbackId, job.feedbackId));
141
+
142
+ logger.info(
143
+ {
144
+ feedbackId: job.feedbackId,
145
+ processingTime,
146
+ duplicatesFound: duplicateCandidates.length,
147
+ tagsFound: tagSuggestions.length,
148
+ },
149
+ "AI processing completed",
150
+ );
151
+ } catch (error) {
152
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
153
+
154
+ logger.error(
155
+ {
156
+ feedbackId: job.feedbackId,
157
+ error: errorMessage,
158
+ },
159
+ "AI processing failed",
160
+ );
161
+
162
+ // Update status to failed
163
+ await db
164
+ .update(feedback)
165
+ .set({ processingStatus: "failed" })
166
+ .where(eq(feedback.feedbackId, job.feedbackId));
167
+
168
+ // Save failure record
169
+ await db.insert(aiProcessingResults).values({
170
+ feedbackId: job.feedbackId,
171
+ processingTime: Date.now() - startTime,
172
+ status: "failed",
173
+ errorMessage,
174
+ });
175
+ }
176
+ }
177
+
178
+ // MVP: Simple in-memory queue
179
+ const processingQueue: ProcessingJob[] = [];
180
+ let isProcessing = false;
181
+
182
+ /**
183
+ * Add a feedback item to the processing queue
184
+ */
185
+ export function enqueueFeedbackProcessing(job: ProcessingJob): void {
186
+ processingQueue.push(job);
187
+
188
+ if (!isProcessing) {
189
+ processQueue();
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Process items from the queue
195
+ */
196
+ async function processQueue(): Promise<void> {
197
+ if (processingQueue.length === 0) {
198
+ isProcessing = false;
199
+ return;
200
+ }
201
+
202
+ isProcessing = true;
203
+ const job = processingQueue.shift();
204
+
205
+ if (job) {
206
+ // Delay to avoid blocking the response
207
+ setTimeout(async () => {
208
+ await processFeedback(job);
209
+ processQueue();
210
+ }, 100);
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Retry failed processing for a feedback item
216
+ */
217
+ export async function retryFailedProcessing(feedbackId: number): Promise<void> {
218
+ if (!db) {
219
+ throw new Error("Database not available");
220
+ }
221
+
222
+ const feedbackData = await db
223
+ .select({
224
+ feedbackId: feedback.feedbackId,
225
+ title: feedback.title,
226
+ description: feedback.description,
227
+ organizationId: feedback.organizationId,
228
+ })
229
+ .from(feedback)
230
+ .where(eq(feedback.feedbackId, feedbackId))
231
+ .limit(1);
232
+
233
+ if (feedbackData.length === 0) {
234
+ throw new Error("Feedback not found");
235
+ }
236
+
237
+ const data = feedbackData[0];
238
+
239
+ enqueueFeedbackProcessing({
240
+ feedbackId: data.feedbackId,
241
+ title: data.title,
242
+ description: data.description || "",
243
+ organizationId: data.organizationId,
244
+ });
245
+ }
246
+
247
+ /**
248
+ * Get queue status (for monitoring)
249
+ */
250
+ export function getQueueStatus(): { queueLength: number; isProcessing: boolean } {
251
+ return {
252
+ queueLength: processingQueue.length,
253
+ isProcessing,
254
+ };
255
+ }