@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.
- package/.changeset/README.md +21 -0
- package/.changeset/config.json +11 -0
- package/.changeset/cozy-ghosts-care.md +5 -0
- package/.changeset/sharp-lines-stand.md +5 -0
- package/.changeset/sour-doodles-eat.md +5 -0
- package/.changeset/tender-moose-shop.md +5 -0
- package/.github/pull_request_template.md +13 -0
- package/.github/workflows/ci.yml +41 -0
- package/.github/workflows/publish.yml +44 -0
- package/.github/workflows/release.yml +73 -0
- package/AGENTS.md +92 -0
- package/CHANGELOG.md +13 -0
- package/Dockerfile +57 -0
- package/LICENSE +661 -0
- package/Makefile +77 -0
- package/README.md +198 -0
- package/app/(auth)/login/page.tsx +53 -0
- package/app/(auth)/register/page.tsx +48 -0
- package/app/(auth)/sign-in/page.tsx +22 -0
- package/app/(dashboard)/admin/feedback/[id]/edit/page.tsx +103 -0
- package/app/(dashboard)/admin/feedback/[id]/page.tsx +154 -0
- package/app/(dashboard)/admin/feedback/new/page.tsx +91 -0
- package/app/(dashboard)/admin/feedback/page.tsx +81 -0
- package/app/(dashboard)/admin/layout.tsx +48 -0
- package/app/(dashboard)/analytics/portal/page.tsx +30 -0
- package/app/(dashboard)/dashboard/page.tsx +133 -0
- package/app/(dashboard)/layout.tsx +69 -0
- package/app/(dashboard)/no-access/page.tsx +45 -0
- package/app/(dashboard)/settings/access/page.tsx +56 -0
- package/app/(dashboard)/settings/api-keys/page.tsx +55 -0
- package/app/(dashboard)/settings/appearance/page.tsx +40 -0
- package/app/(dashboard)/settings/branding/page.tsx +62 -0
- package/app/(dashboard)/settings/changelog/page.tsx +51 -0
- package/app/(dashboard)/settings/danger-zone/page.tsx +92 -0
- package/app/(dashboard)/settings/feedback/page.tsx +63 -0
- package/app/(dashboard)/settings/integrations/page.tsx +94 -0
- package/app/(dashboard)/settings/layout.tsx +43 -0
- package/app/(dashboard)/settings/modules/page.tsx +54 -0
- package/app/(dashboard)/settings/notifications/page.tsx +48 -0
- package/app/(dashboard)/settings/organization/page.tsx +104 -0
- package/app/(dashboard)/settings/organization/portal/access/page.tsx +22 -0
- package/app/(dashboard)/settings/organization/portal/experience/page.tsx +22 -0
- package/app/(dashboard)/settings/organization/portal/growth/page.tsx +22 -0
- package/app/(dashboard)/settings/organization/portal/layout.tsx +24 -0
- package/app/(dashboard)/settings/organization/portal/page.tsx +22 -0
- package/app/(dashboard)/settings/organizations/[orgId]/members/page.tsx +69 -0
- package/app/(dashboard)/settings/organizations/new/page.tsx +36 -0
- package/app/(dashboard)/settings/page.tsx +22 -0
- package/app/(dashboard)/settings/portal-access/page.tsx +53 -0
- package/app/(dashboard)/settings/portal-branding/page.tsx +59 -0
- package/app/(dashboard)/settings/portal-growth/page.tsx +57 -0
- package/app/(dashboard)/settings/portal-modules/page.tsx +49 -0
- package/app/(dashboard)/settings/portal-resources/page.tsx +66 -0
- package/app/(dashboard)/settings/profile/page.tsx +48 -0
- package/app/(dashboard)/settings/widgets/page.tsx +63 -0
- package/app/(public)/[organizationSlug]/changelog/page.tsx +109 -0
- package/app/(public)/[organizationSlug]/feedback/[id]/page.tsx +146 -0
- package/app/(public)/[organizationSlug]/page.tsx +160 -0
- package/app/(public)/[organizationSlug]/roadmap/page.tsx +142 -0
- package/app/(public)/docs/page.tsx +48 -0
- package/app/(public)/feedback/[id]/not-found.tsx +33 -0
- package/app/(public)/feedback/[id]/page.tsx +102 -0
- package/app/(public)/invite/[token]/page.tsx +121 -0
- package/app/(public)/page.tsx +22 -0
- package/app/(public)/widget/[organizationId]/page.tsx +122 -0
- package/app/api/_utils.ts +29 -0
- package/app/api/admin/backup/route.ts +72 -0
- package/app/api/api-keys/[keyId]/route.ts +92 -0
- package/app/api/api-keys/route.ts +116 -0
- package/app/api/auth/[...all]/route.ts +21 -0
- package/app/api/auth/clear-session/route.ts +43 -0
- package/app/api/auth/register/handler.ts +176 -0
- package/app/api/auth/register/route.ts +26 -0
- package/app/api/docs/route.ts +28 -0
- package/app/api/feedback/[id]/comments/[commentId]/route.ts +105 -0
- package/app/api/feedback/[id]/comments/route.ts +421 -0
- package/app/api/feedback/[id]/duplicates/route.ts +285 -0
- package/app/api/feedback/[id]/handler.ts +91 -0
- package/app/api/feedback/[id]/processing-status/route.ts +199 -0
- package/app/api/feedback/[id]/reclassify/route.ts +145 -0
- package/app/api/feedback/[id]/route.ts +511 -0
- package/app/api/feedback/[id]/suggest-tags/route.ts +227 -0
- package/app/api/feedback/[id]/sync-github/route.ts +52 -0
- package/app/api/feedback/[id]/vote/route.ts +431 -0
- package/app/api/feedback/bulk/route.ts +212 -0
- package/app/api/feedback/handler.ts +138 -0
- package/app/api/feedback/route.ts +298 -0
- package/app/api/feedback/similar/route.ts +100 -0
- package/app/api/health/route.test.ts +64 -0
- package/app/api/health/route.ts +92 -0
- package/app/api/identify/jwt/route.ts +29 -0
- package/app/api/integrations/github/route.ts +196 -0
- package/app/api/internal/domain-lookup/route.ts +67 -0
- package/app/api/invitations/accept/handler.ts +101 -0
- package/app/api/invitations/accept/route.ts +29 -0
- package/app/api/notifications/preferences/route.ts +109 -0
- package/app/api/organizations/[orgId]/handler.ts +123 -0
- package/app/api/organizations/[orgId]/invitations/handler.ts +121 -0
- package/app/api/organizations/[orgId]/invitations/route.ts +29 -0
- package/app/api/organizations/[orgId]/members/[memberId]/handler.ts +208 -0
- package/app/api/organizations/[orgId]/members/[memberId]/route.ts +30 -0
- package/app/api/organizations/[orgId]/members/handler.ts +77 -0
- package/app/api/organizations/[orgId]/members/route.ts +29 -0
- package/app/api/organizations/[orgId]/route.ts +30 -0
- package/app/api/organizations/handler.ts +97 -0
- package/app/api/organizations/route.ts +29 -0
- package/app/api/tags/sync/route.ts +88 -0
- package/app/api/upload/handler.ts +79 -0
- package/app/api/upload/route.ts +37 -0
- package/app/api/v1/feedback/[id]/route.ts +276 -0
- package/app/api/v1/feedback/route.ts +250 -0
- package/app/api/v1/spec/route.ts +356 -0
- package/app/api/webhooks/[webhookId]/route.ts +213 -0
- package/app/api/webhooks/github/route.ts +158 -0
- package/app/api/webhooks/route.ts +143 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +139 -0
- package/app/health/route.ts +108 -0
- package/app/layout.tsx +60 -0
- package/bun.lock +2503 -0
- package/components/api/rate-limit-info.tsx +86 -0
- package/components/api-keys/api-key-manager.tsx +262 -0
- package/components/auth/login-form.tsx +207 -0
- package/components/auth/register-form.tsx +230 -0
- package/components/comment/comment-form.tsx +111 -0
- package/components/comment/internal-notes.tsx +219 -0
- package/components/comment/public-comments.tsx +387 -0
- package/components/component-example-client-only.tsx +29 -0
- package/components/component-example.tsx +519 -0
- package/components/dashboard/index.ts +22 -0
- package/components/dashboard/organization-switcher.tsx +96 -0
- package/components/dashboard/quick-actions.tsx +57 -0
- package/components/dashboard/recent-feedback-list.tsx +152 -0
- package/components/dashboard/stats-cards.tsx +88 -0
- package/components/dashboard/status-chart.tsx +106 -0
- package/components/example.tsx +70 -0
- package/components/feedback/attachment-list.tsx +103 -0
- package/components/feedback/auto-classification-badge.tsx +92 -0
- package/components/feedback/classification-override.tsx +64 -0
- package/components/feedback/duplicate-suggestions-inline.tsx +158 -0
- package/components/feedback/duplicate-suggestions.tsx +188 -0
- package/components/feedback/embedded-feedback-form.tsx +439 -0
- package/components/feedback/feedback-actions.tsx +160 -0
- package/components/feedback/feedback-bulk-actions.tsx +184 -0
- package/components/feedback/feedback-detail-view.tsx +321 -0
- package/components/feedback/feedback-detail.tsx +305 -0
- package/components/feedback/feedback-edit-form.tsx +131 -0
- package/components/feedback/feedback-filters.tsx +222 -0
- package/components/feedback/feedback-list-controls.tsx +433 -0
- package/components/feedback/feedback-list-item.tsx +298 -0
- package/components/feedback/feedback-list-skeleton.tsx +49 -0
- package/components/feedback/feedback-list.tsx +523 -0
- package/components/feedback/feedback-sorter.tsx +117 -0
- package/components/feedback/feedback-stats.tsx +124 -0
- package/components/feedback/file-upload.tsx +289 -0
- package/components/feedback/processing-status.tsx +161 -0
- package/components/feedback/status-history.tsx +134 -0
- package/components/feedback/status-selector.tsx +153 -0
- package/components/feedback/submit-on-behalf-form.tsx +403 -0
- package/components/feedback/tag-suggestions.tsx +212 -0
- package/components/feedback/vote-button.tsx +113 -0
- package/components/feedback/vote-list.tsx +108 -0
- package/components/integrations/github-config.tsx +200 -0
- package/components/landing/hero.tsx +150 -0
- package/components/layout/dashboard-layout.tsx +59 -0
- package/components/layout/index.ts +20 -0
- package/components/layout/language-switcher.tsx +129 -0
- package/components/layout/mobile-sidebar.tsx +66 -0
- package/components/layout/sidebar.tsx +279 -0
- package/components/portal/changelog-entry.tsx +132 -0
- package/components/portal/changelog-list.tsx +85 -0
- package/components/portal/contributor-badge.tsx +29 -0
- package/components/portal/contributors-sidebar.tsx +98 -0
- package/components/portal/create-post-dialog.tsx +247 -0
- package/components/portal/feedback-board.tsx +205 -0
- package/components/portal/feedback-post-card.tsx +198 -0
- package/components/portal/help-center.tsx +169 -0
- package/components/portal/leaderboard.tsx +29 -0
- package/components/portal/portal-header.tsx +153 -0
- package/components/portal/portal-layout.tsx +62 -0
- package/components/portal/portal-modules-panel.tsx +118 -0
- package/components/portal/portal-nav.tsx +59 -0
- package/components/portal/portal-overview.tsx +174 -0
- package/components/portal/portal-settings-nav.tsx +62 -0
- package/components/portal/portal-settings-shell.tsx +71 -0
- package/components/portal/portal-shell.tsx +62 -0
- package/components/portal/portal-tab-nav.tsx +77 -0
- package/components/portal/project-switcher.tsx +20 -0
- package/components/portal/roadmap-board.tsx +82 -0
- package/components/portal/roadmap-card.tsx +76 -0
- package/components/portal/roadmap-column.tsx +78 -0
- package/components/portal/settings-forms/access-form.tsx +194 -0
- package/components/portal/settings-forms/copy-form.tsx +95 -0
- package/components/portal/settings-forms/index.ts +23 -0
- package/components/portal/settings-forms/languages-form.tsx +223 -0
- package/components/portal/settings-forms/seo-form.tsx +156 -0
- package/components/portal/settings-forms/sharing-form.tsx +155 -0
- package/components/portal/settings-forms/theme-form.tsx +104 -0
- package/components/settings/api-keys-list.tsx +167 -0
- package/components/settings/appearance-form.tsx +71 -0
- package/components/settings/index.ts +25 -0
- package/components/settings/invite-member-form.tsx +119 -0
- package/components/settings/notification-preferences.tsx +174 -0
- package/components/settings/organization-form.tsx +165 -0
- package/components/settings/organization-members-list.tsx +197 -0
- package/components/settings/profile-form.tsx +124 -0
- package/components/settings/role-selector.tsx +57 -0
- package/components/settings/settings-sidebar.tsx +115 -0
- package/components/shared/pagination.tsx +215 -0
- package/components/ui/alert-dialog.tsx +201 -0
- package/components/ui/alert.tsx +75 -0
- package/components/ui/avatar.tsx +126 -0
- package/components/ui/badge.tsx +62 -0
- package/components/ui/button.tsx +77 -0
- package/components/ui/card.tsx +111 -0
- package/components/ui/combobox.tsx +311 -0
- package/components/ui/dialog.tsx +158 -0
- package/components/ui/dropdown-menu.tsx +272 -0
- package/components/ui/field.tsx +256 -0
- package/components/ui/input-group.tsx +164 -0
- package/components/ui/input.tsx +36 -0
- package/components/ui/label.tsx +41 -0
- package/components/ui/pagination.tsx +142 -0
- package/components/ui/select.tsx +202 -0
- package/components/ui/separator.tsx +45 -0
- package/components/ui/sheet.tsx +151 -0
- package/components/ui/skeleton.tsx +32 -0
- package/components/ui/switch.tsx +49 -0
- package/components/ui/table.tsx +118 -0
- package/components/ui/tabs.tsx +107 -0
- package/components/ui/textarea.tsx +35 -0
- package/components/ui/tooltip.tsx +78 -0
- package/components/widget/widget-form.tsx +439 -0
- package/components.json +24 -0
- package/db/init/01-init.sql +13 -0
- package/docker-compose.dev.yml +26 -0
- package/docker-compose.yml +98 -0
- package/docs/architecture.md +259 -0
- package/docs/component-inventory.md +261 -0
- package/docs/database-migrations.md +76 -0
- package/docs/development-guide.md +209 -0
- package/docs/e2e-user-flows.csv +31 -0
- package/docs/er-diagram-feedback.mmd +138 -0
- package/docs/er-diagram.mmd +281 -0
- package/docs/i18n-check-report.md +296 -0
- package/docs/index.md +214 -0
- package/docs/logic-chain.md +94 -0
- package/docs/plans/2026-01-02-database-migration-scripts.md +496 -0
- package/docs/plans/2026-01-02-user-login-design.md +37 -0
- package/docs/plans/2026-01-02-user-login.md +437 -0
- package/docs/plans/2026-01-02-user-registration-design.md +47 -0
- package/docs/plans/2026-01-02-user-registration.md +628 -0
- package/docs/plans/2026-01-03-roles-permissions-design.md +20 -0
- package/docs/plans/2026-01-03-roles-permissions.md +266 -0
- package/docs/plans/2026-01-05-authentication-middleware.md +207 -0
- package/docs/plans/2026-01-05-member-removal.md +186 -0
- package/docs/plans/2026-01-05-organization-creation.md +374 -0
- package/docs/plans/2026-01-05-rbac-middleware.md +112 -0
- package/docs/plans/2026-01-05-role-configuration.md +441 -0
- package/docs/plans/2026-01-06-file-upload-support.md +804 -0
- package/docs/plans/2026-01-06-permission-check-hook.md +155 -0
- package/docs/plans/2026-01-06-resource-ownership-check.md +231 -0
- package/docs/plans/2026-01-07-feedback-tracking-link.md +459 -0
- package/docs/plans/2026-01-09-logout-redirect-design.md +52 -0
- package/docs/plans/2026-01-09-phase2-3-plan.md +654 -0
- package/docs/plans/2026-01-09-portal-execution-plan.md +408 -0
- package/docs/plans/2026-01-09-project-delete-feature-design.md +163 -0
- package/docs/plans/2026-01-09-project-delete-implementation.md +451 -0
- package/docs/plans/2026-01-09-project-edit-delete-design.md +52 -0
- package/docs/plans/2026-01-09-settings-center-design.md +114 -0
- package/docs/plans/2026-01-09-settings-center.md +948 -0
- package/docs/plans/2026-01-10-organization-only-design.md +66 -0
- package/docs/plans/2026-01-10-organization-only-implementation.md +433 -0
- package/docs/plans/2026-01-10-portal-settings-restructure-plan.md +18 -0
- package/docs/plans/2026-01-10-project-settings-tabs-design-implementation.md +296 -0
- package/docs/plans/2026-01-14-e2e-playwright-feedback.md +173 -0
- package/docs/plans/2026-01-15-feedback-management-org-context-design.md +82 -0
- package/docs/plans/2026-01-15-feedback-management-org-context-implementation-plan.md +521 -0
- package/docs/plans/2026-01-16-admin-feedback-filters-design.md +75 -0
- package/docs/plans/2026-01-16-admin-feedback-filters-implementation.md +293 -0
- package/docs/plans/2026-01-16-admin-feedback-route-consolidation.md +180 -0
- package/docs/plans/2026-01-16-e2e-test-fixes.md +158 -0
- package/docs/plans/2026-01-17-admin-feedback-filters.md +214 -0
- package/docs/plans/2026-01-17-admin-feedback-improvements.md +453 -0
- package/docs/plans/2026-01-18-changesets-design.md +40 -0
- package/docs/product_changes.md +37 -0
- package/docs/project-overview.md +159 -0
- package/docs/project-scan-report.json +104 -0
- package/docs/route-role-visibility.md +51 -0
- package/docs/source-tree-analysis.md +150 -0
- package/docs/testing/delete-project-manual-tests.md +18 -0
- package/docs/user-story-tracking.md +191 -0
- package/drizzle.config.ts +32 -0
- package/eslint.config.mjs +19 -0
- package/hooks/use-permissions.ts +56 -0
- package/i18n/config.ts +45 -0
- package/i18n/request.ts +28 -0
- package/i18n/resolve-locale.ts +38 -0
- package/lib/api/errors.ts +62 -0
- package/lib/auth/cli-config.ts +35 -0
- package/lib/auth/client.ts +20 -0
- package/lib/auth/config.ts +55 -0
- package/lib/auth/jwt-identity.ts +21 -0
- package/lib/auth/org-context.ts +71 -0
- package/lib/auth/organization.ts +107 -0
- package/lib/auth/permissions.ts +87 -0
- package/lib/auth/session.ts +23 -0
- package/lib/config/rate-limits.ts +64 -0
- package/lib/dashboard/get-dashboard-stats.ts +136 -0
- package/lib/db/index.ts +41 -0
- package/lib/db/migrate.test.ts +49 -0
- package/lib/db/migrate.ts +62 -0
- package/lib/db/migrations/.gitkeep +0 -0
- package/lib/db/migrations/0000_cynical_gladiator.sql +53 -0
- package/lib/db/migrations/0001_wandering_sunfire.sql +27 -0
- package/lib/db/migrations/0002_shallow_speedball.sql +1 -0
- package/lib/db/migrations/0003_add_org_description.sql +1 -0
- package/lib/db/migrations/0003_boring_wild_pack.sql +13 -0
- package/lib/db/migrations/0004_windy_tyrannus.sql +27 -0
- package/lib/db/migrations/0005_perpetual_doorman.sql +5 -0
- package/lib/db/migrations/0006_aberrant_captain_midlands.sql +13 -0
- package/lib/db/migrations/0007_clever_captain_cross.sql +14 -0
- package/lib/db/migrations/0008_sparkling_pandemic.sql +2 -0
- package/lib/db/migrations/0009_happy_black_tom.sql +29 -0
- package/lib/db/migrations/0010_kind_junta.sql +8 -0
- package/lib/db/migrations/0011_mute_squadron_supreme.sql +25 -0
- package/lib/db/migrations/0012_giant_power_man.sql +24 -0
- package/lib/db/migrations/0013_damp_titanium_man.sql +17 -0
- package/lib/db/migrations/0014_blue_alice.sql +18 -0
- package/lib/db/migrations/0015_webhook_tables.sql +41 -0
- package/lib/db/migrations/0016_github_integration.sql +30 -0
- package/lib/db/migrations/0016_overjoyed_ghost_rider.sql +22 -0
- package/lib/db/migrations/0017_slimy_inhumans.sql +6 -0
- package/lib/db/migrations/0018_same_spitfire.sql +1 -0
- package/lib/db/migrations/0019_jittery_loners.sql +16 -0
- package/lib/db/migrations/0019_remove_projects_add_org_settings.sql +14 -0
- package/lib/db/migrations/meta/0000_snapshot.json +374 -0
- package/lib/db/migrations/meta/0001_snapshot.json +553 -0
- package/lib/db/migrations/meta/0002_snapshot.json +560 -0
- package/lib/db/migrations/meta/0003_snapshot.json +650 -0
- package/lib/db/migrations/meta/0004_snapshot.json +852 -0
- package/lib/db/migrations/meta/0005_snapshot.json +900 -0
- package/lib/db/migrations/meta/0006_snapshot.json +1011 -0
- package/lib/db/migrations/meta/0007_snapshot.json +1125 -0
- package/lib/db/migrations/meta/0008_snapshot.json +1146 -0
- package/lib/db/migrations/meta/0009_snapshot.json +1386 -0
- package/lib/db/migrations/meta/0010_snapshot.json +1419 -0
- package/lib/db/migrations/meta/0011_snapshot.json +1615 -0
- package/lib/db/migrations/meta/0012_snapshot.json +1805 -0
- package/lib/db/migrations/meta/0013_snapshot.json +1948 -0
- package/lib/db/migrations/meta/0014_snapshot.json +2082 -0
- package/lib/db/migrations/meta/0015_snapshot.json +2476 -0
- package/lib/db/migrations/meta/0016_snapshot.json +2633 -0
- package/lib/db/migrations/meta/0017_snapshot.json +2680 -0
- package/lib/db/migrations/meta/0018_snapshot.json +2686 -0
- package/lib/db/migrations/meta/0019_snapshot.json +2741 -0
- package/lib/db/migrations/meta/_journal.json +146 -0
- package/lib/db/schema/ai-processing.ts +90 -0
- package/lib/db/schema/api-keys.ts +61 -0
- package/lib/db/schema/attachments.ts +48 -0
- package/lib/db/schema/auth.ts +111 -0
- package/lib/db/schema/comments.ts +74 -0
- package/lib/db/schema/duplicates.ts +80 -0
- package/lib/db/schema/feedback.ts +88 -0
- package/lib/db/schema/github-integrations.ts +66 -0
- package/lib/db/schema/index.ts +35 -0
- package/lib/db/schema/invitations.ts +32 -0
- package/lib/db/schema/notifications.ts +85 -0
- package/lib/db/schema/organization-members.ts +37 -0
- package/lib/db/schema/organization-settings.ts +134 -0
- package/lib/db/schema/organizations.ts +30 -0
- package/lib/db/schema/projects.ts +145 -0
- package/lib/db/schema/status-history.ts +63 -0
- package/lib/db/schema/tags.ts +194 -0
- package/lib/db/schema/user-profiles.ts +31 -0
- package/lib/db/schema/votes.ts +60 -0
- package/lib/db/schema/webhooks.ts +106 -0
- package/lib/feedback/filters.ts +28 -0
- package/lib/feedback/find-similar.ts +49 -0
- package/lib/feedback/get-feedback-by-id.ts +159 -0
- package/lib/feedback/prefill.ts +51 -0
- package/lib/http/get-request-url.ts +28 -0
- package/lib/integrations/github.ts +159 -0
- package/lib/invitations.ts +22 -0
- package/lib/logger.test.ts +31 -0
- package/lib/logger.ts +58 -0
- package/lib/middleware/api-key.ts +126 -0
- package/lib/middleware/rate-limit-keys.ts +47 -0
- package/lib/middleware/rate-limit.ts +148 -0
- package/lib/middleware/rbac.ts +39 -0
- package/lib/middleware/request-id.test.ts +28 -0
- package/lib/middleware/request-id.ts +30 -0
- package/lib/middleware/request-logger.test.ts +36 -0
- package/lib/middleware/request-logger.ts +41 -0
- package/lib/middleware/with-rate-limit.ts +33 -0
- package/lib/portal/analytics.ts +20 -0
- package/lib/portal/contributors.ts +27 -0
- package/lib/portal/i18n.ts +20 -0
- package/lib/portal/leaderboard-settings.ts +20 -0
- package/lib/portal/modules.ts +20 -0
- package/lib/portal/portal-copy.ts +20 -0
- package/lib/portal/public-context.tsx +110 -0
- package/lib/portal/seo.ts +20 -0
- package/lib/portal/settings-context.ts +56 -0
- package/lib/portal/sharing.ts +20 -0
- package/lib/portal/sorting.ts +20 -0
- package/lib/portal/theme.ts +20 -0
- package/lib/services/ai/classifier.ts +296 -0
- package/lib/services/ai/duplicate-detector.ts +255 -0
- package/lib/services/ai/tag-suggester.ts +108 -0
- package/lib/services/api-keys.ts +164 -0
- package/lib/services/backup.ts +173 -0
- package/lib/services/email/templates.ts +158 -0
- package/lib/services/email.ts +68 -0
- package/lib/services/github-sync.ts +205 -0
- package/lib/services/notifications/index.ts +224 -0
- package/lib/services/portal-settings.ts +157 -0
- package/lib/swagger/config.ts +296 -0
- package/lib/swagger/generate.ts +400 -0
- package/lib/upload/file-validator.ts +52 -0
- package/lib/upload/storage.ts +59 -0
- package/lib/utils/format.ts +26 -0
- package/lib/utils/slug.ts +28 -0
- package/lib/utils.ts +23 -0
- package/lib/validations/auth.ts +56 -0
- package/lib/validations/comment.ts +44 -0
- package/lib/validations/feedback.ts +51 -0
- package/lib/validations/invitations.ts +23 -0
- package/lib/validations/organizations.ts +34 -0
- package/lib/validations/projects.ts +49 -0
- package/lib/validators/feedback.ts +57 -0
- package/lib/validators/index.ts +18 -0
- package/lib/webhooks/events.ts +73 -0
- package/lib/webhooks/index.ts +21 -0
- package/lib/webhooks/retry.ts +188 -0
- package/lib/webhooks/sender.ts +183 -0
- package/lib/webhooks/verify.ts +37 -0
- package/lib/workers/feedback-processor.ts +255 -0
- package/messages/en.json +965 -0
- package/messages/jp.json +862 -0
- package/messages/zh-CN.json +855 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +66 -0
- package/package.json +84 -0
- package/playwright.config.ts +44 -0
- package/postcss.config.mjs +7 -0
- package/proxy.test.ts +131 -0
- package/proxy.ts +190 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/logo-64.svg +5 -0
- package/public/logo.svg +5 -0
- package/public/next.svg +1 -0
- package/public/openapi.json +673 -0
- package/public/uploads/.gitkeep +0 -0
- package/public/uploads/02695701-ded0-4c81-8a21-9326c1d65448.pdf +1 -0
- package/public/uploads/178843ea-2780-48ef-8988-f4cba442e4cb.pdf +1 -0
- package/public/uploads/24b0a9ef-da93-49da-934f-637f89c7871d.pdf +1 -0
- package/public/uploads/7a11626d-a8e4-4b91-a8eb-20b6213b0a5a.pdf +1 -0
- package/public/uploads/b0703f4d-6e7b-4aab-8191-1a7b15f1b8ee.pdf +1 -0
- package/public/uploads/c8de0aed-4d3a-44aa-83bb-6594b7a2ddb3.pdf +1 -0
- package/public/uploads/e4cce295-0d85-4525-a1b0-a61c45722e26.pdf +1 -0
- package/public/uploads/eb4df45e-563c-48b8-9c68-c18212312426.pdf +1 -0
- package/public/vercel.svg +1 -0
- package/public/widget/embed.js +249 -0
- package/public/window.svg +1 -0
- package/scripts/backup-db.sh +57 -0
- package/scripts/backup-db.ts +24 -0
- package/scripts/generate-openapi.ts +22 -0
- package/scripts/migration-helper.ts +39 -0
- package/scripts/pre-deploy.ts +75 -0
- package/scripts/restore-db.sh +60 -0
- package/scripts/rollback.ts +72 -0
- package/scripts/seed-tags.ts +48 -0
- package/tests/api/feedback-bulk.test.ts +47 -0
- package/tests/api/feedback-by-id.test.ts +67 -0
- package/tests/api/feedback-comments-route-import.test.ts +26 -0
- package/tests/api/feedback-create.test.ts +71 -0
- package/tests/api/feedback-delete.test.ts +160 -0
- package/tests/api/feedback-filter.test.ts +250 -0
- package/tests/api/feedback-list.test.ts +234 -0
- package/tests/api/feedback-route-assignee-condition.test.ts +32 -0
- package/tests/api/feedback-similar.test.ts +46 -0
- package/tests/api/feedback-sort.test.ts +261 -0
- package/tests/api/feedback-status-enum.test.ts +49 -0
- package/tests/api/feedback-status-filter.test.ts +117 -0
- package/tests/api/feedback-submit-on-behalf.test.ts +269 -0
- package/tests/api/feedback.test.ts +175 -0
- package/tests/api/identify-jwt.test.ts +25 -0
- package/tests/api/invitation-accept.test.ts +213 -0
- package/tests/api/organization-invitations.test.ts +186 -0
- package/tests/api/organization-members-list.test.ts +79 -0
- package/tests/api/organization-members.test.ts +340 -0
- package/tests/api/organizations.test.ts +149 -0
- package/tests/api/register.test.ts +112 -0
- package/tests/api/upload.test.ts +103 -0
- package/tests/api/vote.test.ts +82 -0
- package/tests/app/admin-feedback-detail-page.test.tsx +25 -0
- package/tests/app/admin-feedback-list-page.test.tsx +25 -0
- package/tests/app/admin-feedback-new-page.test.tsx +25 -0
- package/tests/app/health-route-helpers.test.ts +27 -0
- package/tests/app/login-page.test.ts +26 -0
- package/tests/app/portal-page.test.ts +29 -0
- package/tests/app/project-portal-overview.test.tsx +25 -0
- package/tests/app/widget-page-import.test.ts +25 -0
- package/tests/components/create-post-dialog-defaults.test.ts +43 -0
- package/tests/components/feedback/duplicate-suggestions-inline.test.tsx +27 -0
- package/tests/components/feedback/embedded-feedback-form.test.tsx +96 -0
- package/tests/components/feedback/feedback-detail.test.tsx +25 -0
- package/tests/components/feedback/feedback-stats.test.tsx +49 -0
- package/tests/components/feedback-bulk-actions.test.tsx +39 -0
- package/tests/components/feedback-i18n-keys.test.ts +70 -0
- package/tests/components/feedback-list-controls-compile.test.ts +25 -0
- package/tests/components/feedback-list-controls.test.tsx +204 -0
- package/tests/components/feedback-list-item.test.tsx +67 -0
- package/tests/components/landing/hero.test.tsx +46 -0
- package/tests/components/layout/language-switcher.test.tsx +25 -0
- package/tests/components/layout/sidebar.test.tsx +157 -0
- package/tests/components/login-form.test.ts +25 -0
- package/tests/components/organization-form.test.ts +32 -0
- package/tests/components/organization-switcher.test.ts +25 -0
- package/tests/components/pagination.test.tsx +43 -0
- package/tests/components/portal-overview.test.tsx +25 -0
- package/tests/components/profile-form.test.tsx +139 -0
- package/tests/components/role-selector.test.ts +31 -0
- package/tests/components/status-chart.test.tsx +90 -0
- package/tests/e2e/auth.e2e.ts +323 -0
- package/tests/e2e/feedback-actions.e2e.ts +471 -0
- package/tests/e2e/feedback-attachment.e2e.ts +168 -0
- package/tests/e2e/feedback-customer.e2e.ts +226 -0
- package/tests/e2e/feedback-management.e2e.ts +565 -0
- package/tests/e2e/feedback-submit.e2e.ts +133 -0
- package/tests/e2e/feedback-view.e2e.ts +297 -0
- package/tests/e2e/fixtures/test-data.ts +235 -0
- package/tests/e2e/health-check.e2e.ts +230 -0
- package/tests/e2e/helpers/test-utils-helpers.test.ts +43 -0
- package/tests/e2e/helpers/test-utils.ts +298 -0
- package/tests/e2e/integration-placeholders.e2e.ts +199 -0
- package/tests/e2e/organization.e2e.ts +292 -0
- package/tests/e2e/permissions.e2e.ts +424 -0
- package/tests/e2e/project-widget.e2e.ts +63 -0
- package/tests/feedback/filters.test.ts +29 -0
- package/tests/hooks/use-permissions.test.ts +52 -0
- package/tests/lib/ai/classifier.test.ts +104 -0
- package/tests/lib/ai/duplicate-detector.test.ts +234 -0
- package/tests/lib/attachments-schema.test.ts +30 -0
- package/tests/lib/auth/session.test.ts +49 -0
- package/tests/lib/auth-client.test.ts +37 -0
- package/tests/lib/auth-config.test.ts +26 -0
- package/tests/lib/feedback-prefill.test.ts +52 -0
- package/tests/lib/feedback-processor.test.ts +41 -0
- package/tests/lib/feedback-schema.test.ts +33 -0
- package/tests/lib/file-validator.test.ts +48 -0
- package/tests/lib/get-feedback-by-id.test.ts +37 -0
- package/tests/lib/invitations.test.ts +35 -0
- package/tests/lib/login-schema.test.ts +36 -0
- package/tests/lib/org-context.test.ts +95 -0
- package/tests/lib/organization-access.test.ts +44 -0
- package/tests/lib/organization-member-role-schema.test.ts +41 -0
- package/tests/lib/permissions.test.ts +88 -0
- package/tests/lib/portal-analytics.test.ts +25 -0
- package/tests/lib/portal-contributors.test.ts +25 -0
- package/tests/lib/portal-copy.test.ts +27 -0
- package/tests/lib/portal-i18n.test.ts +30 -0
- package/tests/lib/portal-leaderboard-settings.test.ts +25 -0
- package/tests/lib/portal-modules.test.ts +25 -0
- package/tests/lib/portal-seo.test.ts +25 -0
- package/tests/lib/portal-sharing.test.ts +25 -0
- package/tests/lib/portal-sorting.test.ts +25 -0
- package/tests/lib/portal-theme.test.ts +25 -0
- package/tests/lib/rate-limit.test.ts +142 -0
- package/tests/lib/resolve-locale.test.ts +34 -0
- package/tests/lib/services/backup.test.ts +145 -0
- package/tests/lib/user-organizations.test.ts +42 -0
- package/tests/lib/user-role-schema.test.ts +33 -0
- package/tests/lib/user-schema.test.ts +25 -0
- package/tests/setup.ts +74 -0
- package/tsconfig.json +34 -0
- 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
|
+
}
|