@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,227 @@
|
|
|
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 { NextRequest, NextResponse } from "next/server";
|
|
19
|
+
import { headers } from "next/headers";
|
|
20
|
+
import { eq, and } from "drizzle-orm";
|
|
21
|
+
import { db } from "@/lib/db";
|
|
22
|
+
import { feedback, feedbackTags, tags } from "@/lib/db/schema";
|
|
23
|
+
import { auth } from "@/lib/auth/config";
|
|
24
|
+
import { apiError } from "@/lib/api/errors";
|
|
25
|
+
import { suggestTags } from "@/lib/services/ai/tag-suggester";
|
|
26
|
+
import { getOrgContext } from "@/lib/auth/org-context";
|
|
27
|
+
|
|
28
|
+
export const dynamic = "force-dynamic";
|
|
29
|
+
export const runtime = "nodejs";
|
|
30
|
+
|
|
31
|
+
export async function GET(
|
|
32
|
+
req: NextRequest,
|
|
33
|
+
ctx: { params: Promise<{ id: string }> },
|
|
34
|
+
) {
|
|
35
|
+
if (!db) {
|
|
36
|
+
return NextResponse.json(
|
|
37
|
+
{ error: "Database not configured", code: "DATABASE_NOT_CONFIGURED" },
|
|
38
|
+
{ status: 500 },
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const { id } = await ctx.params;
|
|
44
|
+
const feedbackId = parseInt(id);
|
|
45
|
+
|
|
46
|
+
if (isNaN(feedbackId)) {
|
|
47
|
+
return NextResponse.json(
|
|
48
|
+
{ error: "Invalid feedback ID", code: "INVALID_ID" },
|
|
49
|
+
{ status: 400 },
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const session = await auth.api.getSession({ headers: await headers() });
|
|
54
|
+
|
|
55
|
+
if (!session?.user) {
|
|
56
|
+
return NextResponse.json(
|
|
57
|
+
{ error: "Authentication required", code: "UNAUTHORIZED" },
|
|
58
|
+
{ status: 401 },
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let context;
|
|
63
|
+
try {
|
|
64
|
+
context = await getOrgContext({
|
|
65
|
+
request: req,
|
|
66
|
+
db,
|
|
67
|
+
userId: session.user.id,
|
|
68
|
+
requireMembership: true,
|
|
69
|
+
});
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (error instanceof Error && error.message === "Missing organization") {
|
|
72
|
+
return NextResponse.json(
|
|
73
|
+
{ error: "Organization ID is required", code: "MISSING_ORG_ID" },
|
|
74
|
+
{ status: 400 },
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return NextResponse.json(
|
|
78
|
+
{ error: "Insufficient permissions", code: "FORBIDDEN" },
|
|
79
|
+
{ status: 403 },
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const [feedbackData] = await db
|
|
84
|
+
.select({
|
|
85
|
+
feedbackId: feedback.feedbackId,
|
|
86
|
+
title: feedback.title,
|
|
87
|
+
description: feedback.description,
|
|
88
|
+
})
|
|
89
|
+
.from(feedback)
|
|
90
|
+
.where(
|
|
91
|
+
and(
|
|
92
|
+
eq(feedback.feedbackId, feedbackId),
|
|
93
|
+
eq(feedback.organizationId, context.organizationId),
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
.limit(1);
|
|
97
|
+
|
|
98
|
+
if (!feedbackData) {
|
|
99
|
+
return NextResponse.json(
|
|
100
|
+
{ error: "Feedback not found", code: "NOT_FOUND" },
|
|
101
|
+
{ status: 404 },
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const appliedTags = await db
|
|
106
|
+
.select({
|
|
107
|
+
tagId: tags.tagId,
|
|
108
|
+
name: tags.name,
|
|
109
|
+
slug: tags.slug,
|
|
110
|
+
color: tags.color,
|
|
111
|
+
})
|
|
112
|
+
.from(feedbackTags)
|
|
113
|
+
.innerJoin(tags, eq(feedbackTags.tagId, tags.tagId))
|
|
114
|
+
.where(eq(feedbackTags.feedbackId, feedbackId));
|
|
115
|
+
|
|
116
|
+
const suggestions = suggestTags(feedbackData.title, feedbackData.description);
|
|
117
|
+
|
|
118
|
+
const appliedTagSlugs = appliedTags.map((t) => t.slug);
|
|
119
|
+
const filteredSuggestions = suggestions.filter(
|
|
120
|
+
(s) => !appliedTagSlugs.includes(s.slug),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
return NextResponse.json({
|
|
124
|
+
suggestions: filteredSuggestions,
|
|
125
|
+
applied: appliedTags,
|
|
126
|
+
});
|
|
127
|
+
} catch (error) {
|
|
128
|
+
return apiError(error);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function POST(
|
|
133
|
+
req: NextRequest,
|
|
134
|
+
ctx: { params: Promise<{ id: string }> },
|
|
135
|
+
) {
|
|
136
|
+
if (!db) {
|
|
137
|
+
return NextResponse.json(
|
|
138
|
+
{ error: "Database not configured", code: "DATABASE_NOT_CONFIGURED" },
|
|
139
|
+
{ status: 500 },
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const { id } = await ctx.params;
|
|
145
|
+
const feedbackId = parseInt(id);
|
|
146
|
+
|
|
147
|
+
if (isNaN(feedbackId)) {
|
|
148
|
+
return NextResponse.json(
|
|
149
|
+
{ error: "Invalid feedback ID", code: "INVALID_ID" },
|
|
150
|
+
{ status: 400 },
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const session = await auth.api.getSession({ headers: await headers() });
|
|
155
|
+
|
|
156
|
+
if (!session?.user) {
|
|
157
|
+
return NextResponse.json(
|
|
158
|
+
{ error: "Authentication required", code: "UNAUTHORIZED" },
|
|
159
|
+
{ status: 401 },
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let context;
|
|
164
|
+
try {
|
|
165
|
+
context = await getOrgContext({
|
|
166
|
+
request: req,
|
|
167
|
+
db,
|
|
168
|
+
userId: session.user.id,
|
|
169
|
+
requireMembership: true,
|
|
170
|
+
});
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (error instanceof Error && error.message === "Missing organization") {
|
|
173
|
+
return NextResponse.json(
|
|
174
|
+
{ error: "Organization ID is required", code: "MISSING_ORG_ID" },
|
|
175
|
+
{ status: 400 },
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return NextResponse.json(
|
|
179
|
+
{ error: "Insufficient permissions", code: "FORBIDDEN" },
|
|
180
|
+
{ status: 403 },
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const body = await req.json();
|
|
185
|
+
const { tagIds } = body;
|
|
186
|
+
|
|
187
|
+
if (!Array.isArray(tagIds)) {
|
|
188
|
+
return NextResponse.json(
|
|
189
|
+
{ error: "Invalid tagIds", code: "VALIDATION_ERROR" },
|
|
190
|
+
{ status: 400 },
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const [existingFeedback] = await db
|
|
195
|
+
.select({ feedbackId: feedback.feedbackId })
|
|
196
|
+
.from(feedback)
|
|
197
|
+
.where(
|
|
198
|
+
and(
|
|
199
|
+
eq(feedback.feedbackId, feedbackId),
|
|
200
|
+
eq(feedback.organizationId, context.organizationId),
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
.limit(1);
|
|
204
|
+
|
|
205
|
+
if (!existingFeedback) {
|
|
206
|
+
return NextResponse.json(
|
|
207
|
+
{ error: "Feedback not found", code: "NOT_FOUND" },
|
|
208
|
+
{ status: 404 },
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
await db.delete(feedbackTags).where(eq(feedbackTags.feedbackId, feedbackId));
|
|
213
|
+
|
|
214
|
+
if (tagIds.length > 0) {
|
|
215
|
+
await db.insert(feedbackTags).values(
|
|
216
|
+
tagIds.map((tagId: number) => ({
|
|
217
|
+
feedbackId,
|
|
218
|
+
tagId,
|
|
219
|
+
})),
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return NextResponse.json({ success: true });
|
|
224
|
+
} catch (error) {
|
|
225
|
+
return apiError(error);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
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 { NextRequest, NextResponse } from "next/server";
|
|
19
|
+
import { auth } from "@/lib/auth/config";
|
|
20
|
+
import { syncToGitHub } from "@/lib/services/github-sync";
|
|
21
|
+
import { apiError } from "@/lib/api/errors";
|
|
22
|
+
|
|
23
|
+
export const dynamic = "force-dynamic";
|
|
24
|
+
export const runtime = "nodejs";
|
|
25
|
+
|
|
26
|
+
export async function POST(
|
|
27
|
+
req: NextRequest,
|
|
28
|
+
{ params }: { params: Promise<{ id: string }> },
|
|
29
|
+
) {
|
|
30
|
+
const session = await auth.api.getSession({ headers: req.headers });
|
|
31
|
+
if (!session?.user?.id) {
|
|
32
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const { id } = await params;
|
|
37
|
+
const feedbackId = parseInt(id, 10);
|
|
38
|
+
|
|
39
|
+
if (isNaN(feedbackId)) {
|
|
40
|
+
return NextResponse.json(
|
|
41
|
+
{ error: "Invalid feedback ID" },
|
|
42
|
+
{ status: 400 },
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await syncToGitHub(feedbackId);
|
|
47
|
+
|
|
48
|
+
return NextResponse.json({ success: true });
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return apiError(error);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,431 @@
|
|
|
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 { NextRequest, NextResponse } from "next/server";
|
|
19
|
+
import { headers } from "next/headers";
|
|
20
|
+
import { eq, and } from "drizzle-orm";
|
|
21
|
+
import { db } from "@/lib/db";
|
|
22
|
+
import { votes, feedback, organizationSettings } from "@/lib/db/schema";
|
|
23
|
+
import { apiError } from "@/lib/api/errors";
|
|
24
|
+
import { auth } from "@/lib/auth/config";
|
|
25
|
+
import { getOrgContext } from "@/lib/auth/org-context";
|
|
26
|
+
|
|
27
|
+
export const dynamic = "force-dynamic";
|
|
28
|
+
export const runtime = "nodejs";
|
|
29
|
+
|
|
30
|
+
interface RouteParams {
|
|
31
|
+
params: Promise<{ id: string }>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getClientIp(req: NextRequest): string {
|
|
35
|
+
const forwardedFor = req.headers.get("x-forwarded-for");
|
|
36
|
+
if (forwardedFor) {
|
|
37
|
+
return forwardedFor.split(",")[0].trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const realIp = req.headers.get("x-real-ip");
|
|
41
|
+
if (realIp) {
|
|
42
|
+
return realIp;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return req.headers.get("x-client-ip") || "unknown";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function generateVisitorId(ip: string, userAgent: string | null): string {
|
|
49
|
+
const raw = `${ip}-${userAgent || "unknown"}`;
|
|
50
|
+
let hash = 0;
|
|
51
|
+
for (let i = 0; i < raw.length; i++) {
|
|
52
|
+
const char = raw.charCodeAt(i);
|
|
53
|
+
hash = (hash << 5) - hash + char;
|
|
54
|
+
hash = hash & hash;
|
|
55
|
+
}
|
|
56
|
+
return Math.abs(hash).toString(36);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function GET(req: NextRequest, { params }: RouteParams) {
|
|
60
|
+
if (!db) {
|
|
61
|
+
return NextResponse.json(
|
|
62
|
+
{ error: "Database not configured", code: "DATABASE_NOT_CONFIGURED" },
|
|
63
|
+
{ status: 500 },
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const { id } = await params;
|
|
69
|
+
const feedbackId = parseInt(id);
|
|
70
|
+
|
|
71
|
+
if (isNaN(feedbackId)) {
|
|
72
|
+
return NextResponse.json(
|
|
73
|
+
{ error: "Invalid feedback ID", code: "INVALID_ID" },
|
|
74
|
+
{ status: 400 },
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const session = await auth.api.getSession({ headers: await headers() });
|
|
79
|
+
const userId = session?.user?.id ?? null;
|
|
80
|
+
const clientIp = getClientIp(req);
|
|
81
|
+
const userAgent = req.headers.get("user-agent");
|
|
82
|
+
const visitorId = generateVisitorId(clientIp, userAgent);
|
|
83
|
+
let context;
|
|
84
|
+
try {
|
|
85
|
+
context = await getOrgContext({
|
|
86
|
+
request: req,
|
|
87
|
+
db,
|
|
88
|
+
userId,
|
|
89
|
+
requireMembership: !!userId,
|
|
90
|
+
});
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (error instanceof Error) {
|
|
93
|
+
if (error.message === "Missing organization") {
|
|
94
|
+
return NextResponse.json(
|
|
95
|
+
{ error: "Organization ID is required", code: "MISSING_ORG_ID" },
|
|
96
|
+
{ status: 400 },
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
if (error.message === "Access denied") {
|
|
100
|
+
return NextResponse.json(
|
|
101
|
+
{ error: "Insufficient permissions", code: "FORBIDDEN" },
|
|
102
|
+
{ status: 403 },
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return apiError(error);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const [existingFeedback] = await db
|
|
110
|
+
.select({ feedbackId: feedback.feedbackId, organizationId: feedback.organizationId })
|
|
111
|
+
.from(feedback)
|
|
112
|
+
.where(eq(feedback.feedbackId, feedbackId))
|
|
113
|
+
.limit(1);
|
|
114
|
+
|
|
115
|
+
if (!existingFeedback) {
|
|
116
|
+
return NextResponse.json(
|
|
117
|
+
{ error: "Feedback not found", code: "NOT_FOUND" },
|
|
118
|
+
{ status: 404 },
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
if (existingFeedback.organizationId !== context.organizationId) {
|
|
122
|
+
return NextResponse.json(
|
|
123
|
+
{ error: "Feedback not found", code: "NOT_FOUND" },
|
|
124
|
+
{ status: 404 },
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let hasVoted = false;
|
|
129
|
+
let voteId: number | null = null;
|
|
130
|
+
|
|
131
|
+
if (userId) {
|
|
132
|
+
const [userVote] = await db
|
|
133
|
+
.select({ voteId: votes.voteId })
|
|
134
|
+
.from(votes)
|
|
135
|
+
.where(and(eq(votes.feedbackId, feedbackId), eq(votes.userId, userId)))
|
|
136
|
+
.limit(1);
|
|
137
|
+
|
|
138
|
+
hasVoted = !!userVote;
|
|
139
|
+
voteId = userVote?.voteId ?? null;
|
|
140
|
+
} else {
|
|
141
|
+
const [ipVote] = await db
|
|
142
|
+
.select({ voteId: votes.voteId })
|
|
143
|
+
.from(votes)
|
|
144
|
+
.where(
|
|
145
|
+
and(eq(votes.feedbackId, feedbackId), eq(votes.visitorId, visitorId)),
|
|
146
|
+
)
|
|
147
|
+
.limit(1);
|
|
148
|
+
|
|
149
|
+
hasVoted = !!ipVote;
|
|
150
|
+
voteId = ipVote?.voteId ?? null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return NextResponse.json({
|
|
154
|
+
data: {
|
|
155
|
+
hasVoted,
|
|
156
|
+
voteId,
|
|
157
|
+
feedbackId,
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
} catch (error) {
|
|
161
|
+
return apiError(error);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function POST(req: NextRequest, { params }: RouteParams) {
|
|
166
|
+
if (!db) {
|
|
167
|
+
return NextResponse.json(
|
|
168
|
+
{ error: "Database not configured", code: "DATABASE_NOT_CONFIGURED" },
|
|
169
|
+
{ status: 500 },
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
const { id } = await params;
|
|
175
|
+
const feedbackId = parseInt(id);
|
|
176
|
+
|
|
177
|
+
if (isNaN(feedbackId)) {
|
|
178
|
+
return NextResponse.json(
|
|
179
|
+
{ error: "Invalid feedback ID", code: "INVALID_ID" },
|
|
180
|
+
{ status: 400 },
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const session = await auth.api.getSession({ headers: await headers() });
|
|
185
|
+
const userId = session?.user?.id ?? null;
|
|
186
|
+
const clientIp = getClientIp(req);
|
|
187
|
+
const userAgent = req.headers.get("user-agent");
|
|
188
|
+
const visitorId = generateVisitorId(clientIp, userAgent);
|
|
189
|
+
let context;
|
|
190
|
+
try {
|
|
191
|
+
context = await getOrgContext({
|
|
192
|
+
request: req,
|
|
193
|
+
db,
|
|
194
|
+
userId,
|
|
195
|
+
requireMembership: !!userId,
|
|
196
|
+
});
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (error instanceof Error) {
|
|
199
|
+
if (error.message === "Missing organization") {
|
|
200
|
+
return NextResponse.json(
|
|
201
|
+
{ error: "Organization ID is required", code: "MISSING_ORG_ID" },
|
|
202
|
+
{ status: 400 },
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
if (error.message === "Access denied") {
|
|
206
|
+
return NextResponse.json(
|
|
207
|
+
{ error: "Insufficient permissions", code: "FORBIDDEN" },
|
|
208
|
+
{ status: 403 },
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return apiError(error);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const [existingFeedback] = await db
|
|
216
|
+
.select({
|
|
217
|
+
feedbackId: feedback.feedbackId,
|
|
218
|
+
deletedAt: feedback.deletedAt,
|
|
219
|
+
organizationId: feedback.organizationId,
|
|
220
|
+
})
|
|
221
|
+
.from(feedback)
|
|
222
|
+
.where(eq(feedback.feedbackId, feedbackId))
|
|
223
|
+
.limit(1);
|
|
224
|
+
|
|
225
|
+
if (!existingFeedback) {
|
|
226
|
+
return NextResponse.json(
|
|
227
|
+
{ error: "Feedback not found", code: "NOT_FOUND" },
|
|
228
|
+
{ status: 404 },
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
if (existingFeedback.organizationId !== context.organizationId) {
|
|
232
|
+
return NextResponse.json(
|
|
233
|
+
{ error: "Feedback not found", code: "NOT_FOUND" },
|
|
234
|
+
{ status: 404 },
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (existingFeedback.deletedAt !== null) {
|
|
239
|
+
return NextResponse.json(
|
|
240
|
+
{ error: "Cannot vote on deleted feedback", code: "DELETED" },
|
|
241
|
+
{ status: 400 },
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Check if public voting is allowed for anonymous users
|
|
246
|
+
if (!userId) {
|
|
247
|
+
const [settings] = await db
|
|
248
|
+
.select({ portalConfig: organizationSettings.portalConfig })
|
|
249
|
+
.from(organizationSettings)
|
|
250
|
+
.where(eq(organizationSettings.organizationId, context.organizationId))
|
|
251
|
+
.limit(1);
|
|
252
|
+
|
|
253
|
+
const allowPublicVoting = settings?.portalConfig?.sharing?.allowPublicVoting ?? true;
|
|
254
|
+
|
|
255
|
+
if (!allowPublicVoting) {
|
|
256
|
+
return NextResponse.json(
|
|
257
|
+
{ error: "Public voting is disabled", code: "PUBLIC_VOTING_DISABLED" },
|
|
258
|
+
{ status: 403 },
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (userId) {
|
|
264
|
+
const [existingVote] = await db
|
|
265
|
+
.select({ voteId: votes.voteId })
|
|
266
|
+
.from(votes)
|
|
267
|
+
.where(and(eq(votes.feedbackId, feedbackId), eq(votes.userId, userId)))
|
|
268
|
+
.limit(1);
|
|
269
|
+
|
|
270
|
+
if (existingVote) {
|
|
271
|
+
return NextResponse.json(
|
|
272
|
+
{
|
|
273
|
+
error: "Already voted",
|
|
274
|
+
code: "ALREADY_VOTED",
|
|
275
|
+
voteId: existingVote.voteId,
|
|
276
|
+
},
|
|
277
|
+
{ status: 400 },
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
} else {
|
|
281
|
+
const [existingVote] = await db
|
|
282
|
+
.select({ voteId: votes.voteId })
|
|
283
|
+
.from(votes)
|
|
284
|
+
.where(
|
|
285
|
+
and(eq(votes.feedbackId, feedbackId), eq(votes.visitorId, visitorId)),
|
|
286
|
+
)
|
|
287
|
+
.limit(1);
|
|
288
|
+
|
|
289
|
+
if (existingVote) {
|
|
290
|
+
return NextResponse.json(
|
|
291
|
+
{
|
|
292
|
+
error: "Already voted from this device",
|
|
293
|
+
code: "ALREADY_VOTED",
|
|
294
|
+
voteId: existingVote.voteId,
|
|
295
|
+
},
|
|
296
|
+
{ status: 400 },
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const [newVote] = await db
|
|
302
|
+
.insert(votes)
|
|
303
|
+
.values({
|
|
304
|
+
feedbackId,
|
|
305
|
+
userId,
|
|
306
|
+
visitorId: userId ? null : visitorId,
|
|
307
|
+
})
|
|
308
|
+
.returning();
|
|
309
|
+
|
|
310
|
+
return NextResponse.json(
|
|
311
|
+
{
|
|
312
|
+
data: newVote,
|
|
313
|
+
message: "Vote added successfully",
|
|
314
|
+
},
|
|
315
|
+
{ status: 201 },
|
|
316
|
+
);
|
|
317
|
+
} catch (error) {
|
|
318
|
+
if (error instanceof Error && error.message.includes("unique constraint")) {
|
|
319
|
+
return NextResponse.json(
|
|
320
|
+
{ error: "Already voted", code: "ALREADY_VOTED" },
|
|
321
|
+
{ status: 400 },
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
return apiError(error);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export async function DELETE(req: NextRequest, { params }: RouteParams) {
|
|
329
|
+
if (!db) {
|
|
330
|
+
return NextResponse.json(
|
|
331
|
+
{ error: "Database not configured", code: "DATABASE_NOT_CONFIGURED" },
|
|
332
|
+
{ status: 500 },
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
const { id } = await params;
|
|
338
|
+
const feedbackId = parseInt(id);
|
|
339
|
+
|
|
340
|
+
if (isNaN(feedbackId)) {
|
|
341
|
+
return NextResponse.json(
|
|
342
|
+
{ error: "Invalid feedback ID", code: "INVALID_ID" },
|
|
343
|
+
{ status: 400 },
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const session = await auth.api.getSession({ headers: await headers() });
|
|
348
|
+
const userId = session?.user?.id ?? null;
|
|
349
|
+
const clientIp = getClientIp(req);
|
|
350
|
+
const userAgent = req.headers.get("user-agent");
|
|
351
|
+
const visitorId = generateVisitorId(clientIp, userAgent);
|
|
352
|
+
let context;
|
|
353
|
+
try {
|
|
354
|
+
context = await getOrgContext({
|
|
355
|
+
request: req,
|
|
356
|
+
db,
|
|
357
|
+
userId,
|
|
358
|
+
requireMembership: !!userId,
|
|
359
|
+
});
|
|
360
|
+
} catch (error) {
|
|
361
|
+
if (error instanceof Error) {
|
|
362
|
+
if (error.message === "Missing organization") {
|
|
363
|
+
return NextResponse.json(
|
|
364
|
+
{ error: "Organization ID is required", code: "MISSING_ORG_ID" },
|
|
365
|
+
{ status: 400 },
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
if (error.message === "Access denied") {
|
|
369
|
+
return NextResponse.json(
|
|
370
|
+
{ error: "Insufficient permissions", code: "FORBIDDEN" },
|
|
371
|
+
{ status: 403 },
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return apiError(error);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const [existingFeedback] = await db
|
|
379
|
+
.select({ feedbackId: feedback.feedbackId, organizationId: feedback.organizationId })
|
|
380
|
+
.from(feedback)
|
|
381
|
+
.where(eq(feedback.feedbackId, feedbackId))
|
|
382
|
+
.limit(1);
|
|
383
|
+
|
|
384
|
+
if (!existingFeedback) {
|
|
385
|
+
return NextResponse.json(
|
|
386
|
+
{ error: "Feedback not found", code: "NOT_FOUND" },
|
|
387
|
+
{ status: 404 },
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
if (existingFeedback.organizationId !== context.organizationId) {
|
|
391
|
+
return NextResponse.json(
|
|
392
|
+
{ error: "Feedback not found", code: "NOT_FOUND" },
|
|
393
|
+
{ status: 404 },
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
let deletedVote = null;
|
|
398
|
+
|
|
399
|
+
if (userId) {
|
|
400
|
+
const [deleted] = await db
|
|
401
|
+
.delete(votes)
|
|
402
|
+
.where(and(eq(votes.feedbackId, feedbackId), eq(votes.userId, userId)))
|
|
403
|
+
.returning();
|
|
404
|
+
|
|
405
|
+
deletedVote = deleted;
|
|
406
|
+
} else {
|
|
407
|
+
const [deleted] = await db
|
|
408
|
+
.delete(votes)
|
|
409
|
+
.where(
|
|
410
|
+
and(eq(votes.feedbackId, feedbackId), eq(votes.visitorId, visitorId)),
|
|
411
|
+
)
|
|
412
|
+
.returning();
|
|
413
|
+
|
|
414
|
+
deletedVote = deleted;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (!deletedVote) {
|
|
418
|
+
return NextResponse.json(
|
|
419
|
+
{ error: "Vote not found", code: "NOT_FOUND" },
|
|
420
|
+
{ status: 404 },
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return NextResponse.json({
|
|
425
|
+
data: deletedVote,
|
|
426
|
+
message: "Vote removed successfully",
|
|
427
|
+
});
|
|
428
|
+
} catch (error) {
|
|
429
|
+
return apiError(error);
|
|
430
|
+
}
|
|
431
|
+
}
|