From 63506e8610da6e2d5498ea6c5f16ddc8f8b6bada Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Mon, 4 May 2026 02:26:40 +0300 Subject: [PATCH] Messaging and notification feature --- .vscode/settings.json | 8 +- core/api_urls.py | 6 + core/tests/test_entrypoints.py | 7 +- .../newsletter-maker-staging-application.yaml | 2 +- .../newsletter-maker/templates/configmap.yaml | 2 + .../templates/service-monitor.yaml | 2 +- .../helm/newsletter-maker/values-staging.yaml | 3 +- deploy/helm/newsletter-maker/values.yaml | 2 + .../_components/ContentFeed/index.stories.tsx | 2 +- .../_components/ContentFeed/index.test.tsx | 2 +- .../(home)/_components/ContentFeed/index.tsx | 2 +- .../DashboardFilterToolbar/index.stories.tsx | 2 +- .../DashboardFilterToolbar/index.test.tsx | 2 +- .../DashboardFilterToolbar/index.tsx | 2 +- .../DashboardOverview/index.stories.tsx | 2 +- .../DashboardOverview/index.test.tsx | 2 +- .../_components/DashboardOverview/index.tsx | 2 +- .../DashboardSidebar/index.stories.tsx | 2 +- .../DashboardSidebar/index.test.tsx | 2 +- .../_components/DashboardSidebar/index.tsx | 2 +- .../HomePageContent/index.stories.tsx | 2 +- .../HomePageContent/index.test.tsx | 2 +- .../_components/HomePageContent/index.tsx | 2 +- .../ReviewQueueTable/index.stories.tsx | 2 +- .../ReviewQueueTable/index.test.tsx | 2 +- .../_components/ReviewQueueTable/index.tsx | 2 +- .../src/app/(home)/_components/shared.test.ts | 2 +- frontend/src/app/(home)/_components/shared.ts | 2 +- frontend/src/app/(home)/page.stories.tsx | 2 +- .../SourceDiversityPanel/index.test.tsx | 2 +- .../SourceHealthPanel/index.stories.tsx | 2 +- .../SourceHealthPanel/index.test.tsx | 2 +- .../_components/SourceHealthPanel/index.tsx | 2 +- .../TopicCentroidPanel/index.stories.tsx | 2 +- .../TopicCentroidPanel/index.test.tsx | 2 +- .../_components/TopicCentroidPanel/index.tsx | 2 +- .../TrendTaskRunsPanel/index.stories.tsx | 2 +- .../TrendTaskRunsPanel/index.test.tsx | 2 +- .../_components/TrendTaskRunsPanel/index.tsx | 2 +- .../NewProjectFormCard/index.stories.tsx | 2 +- .../NewProjectFormCard/index.test.tsx | 2 +- .../_components/NewProjectFormCard/index.tsx | 2 +- .../ProjectFlashNotice/index.stories.tsx | 2 +- .../ProjectFlashNotice/index.test.tsx | 2 +- .../_components/ProjectFlashNotice/index.tsx | 2 +- .../app/admin/projects/new/page.stories.tsx | 2 +- .../src/app/admin/projects/new/page.test.tsx | 2 +- .../NewsletterIntakePanel/index.stories.tsx | 2 +- .../NewsletterIntakePanel/index.test.tsx | 2 +- .../NewsletterIntakePanel/index.tsx | 2 +- .../ProviderSetupPanel/index.stories.tsx | 2 +- .../ProviderSetupPanel/index.test.tsx | 2 +- .../_components/ProviderSetupPanel/index.tsx | 2 +- .../SourceConfigList/index.stories.tsx | 2 +- .../SourceConfigList/index.test.tsx | 2 +- .../_components/SourceConfigList/index.tsx | 2 +- .../app/admin/sources/_components/helpers.ts | 2 +- .../src/app/admin/sources/page.stories.tsx | 2 +- frontend/src/app/admin/sources/page.tsx | 2 +- .../threads/[threadId]/messages/route.test.ts | 67 +++ .../threads/[threadId]/messages/route.ts | 62 +++ .../threads/[threadId]/read/route.test.ts | 32 ++ .../messages/threads/[threadId]/read/route.ts | 34 ++ .../app/api/messages/threads/route.test.ts | 78 +++ .../src/app/api/messages/threads/route.ts | 48 ++ .../src/app/api/notifications/route.test.ts | 88 +++ frontend/src/app/api/notifications/route.ts | 67 +++ .../ContentDetailMainColumn/index.stories.tsx | 2 +- .../ContentDetailMainColumn/index.test.tsx | 2 +- .../ContentDetailMainColumn/index.tsx | 2 +- .../ContentDetailSidebar/index.stories.tsx | 2 +- .../ContentDetailSidebar/index.test.tsx | 2 +- .../ContentDetailSidebar/index.tsx | 2 +- .../SkillActionBar/index.stories.tsx | 2 +- .../src/app/content/[id]/page.stories.tsx | 2 +- frontend/src/app/content/[id]/page.tsx | 2 +- .../_components/DraftEditor/index.stories.tsx | 2 +- .../DraftOverviewCards/index.stories.tsx | 2 +- .../DraftOverviewCards/index.test.tsx | 2 +- .../_components/DraftOverviewCards/index.tsx | 2 +- .../DraftRenderedOutput/index.stories.tsx | 2 +- .../DraftRenderedOutput/index.test.tsx | 2 +- .../_components/DraftRenderedOutput/index.tsx | 2 +- .../DraftViewSwitcher/index.stories.tsx | 2 +- .../DraftViewSwitcher/index.test.tsx | 2 +- .../_components/DraftViewSwitcher/index.tsx | 2 +- .../src/app/drafts/[draftId]/page.stories.tsx | 2 +- .../_components/DraftsList/index.stories.tsx | 2 +- .../_components/DraftsList/index.test.tsx | 2 +- .../drafts/_components/DraftsList/index.tsx | 2 +- .../DraftsOverviewCards/index.stories.tsx | 2 +- .../DraftsOverviewCards/index.test.tsx | 2 +- .../_components/DraftsOverviewCards/index.tsx | 2 +- .../DraftsToolbar/index.stories.tsx | 2 +- .../_components/DraftsToolbar/index.test.tsx | 2 +- .../_components/DraftsToolbar/index.tsx | 2 +- frontend/src/app/drafts/page.stories.tsx | 2 +- .../AuthorityHistoryPanel/index.stories.tsx | 2 +- .../AuthorityHistoryPanel/index.test.tsx | 2 +- .../AuthorityHistoryPanel/index.tsx | 2 +- .../AuthorityWeightControls/index.stories.tsx | 2 +- .../EntityDetailPageContent/index.test.tsx | 2 +- .../EntityDetailPageContent/index.tsx | 2 +- .../EntityMentionsPanel/index.stories.tsx | 2 +- .../EntityMentionsPanel/index.test.tsx | 2 +- .../_components/EntityMentionsPanel/index.tsx | 2 +- .../EntityOverviewCard/index.stories.tsx | 2 +- .../EntityOverviewCard/index.test.tsx | 2 +- .../_components/EntityOverviewCard/index.tsx | 2 +- .../EntitySidebar/index.stories.tsx | 2 +- .../_components/EntitySidebar/index.test.tsx | 2 +- .../[id]/_components/EntitySidebar/index.tsx | 2 +- .../src/app/entities/[id]/page.stories.tsx | 2 +- .../CreateEntityCard/index.stories.tsx | 2 +- .../CreateEntityCard/index.test.tsx | 2 +- .../_components/CreateEntityCard/index.tsx | 2 +- .../EntitiesPageContent/index.test.tsx | 2 +- .../_components/EntitiesPageContent/index.tsx | 2 +- .../EntityCandidatesCard/index.stories.tsx | 2 +- .../EntityCandidatesCard/index.test.tsx | 2 +- .../EntityCandidatesCard/index.tsx | 2 +- .../_components/EntityCard/index.stories.tsx | 2 +- .../_components/EntityCard/index.test.tsx | 2 +- .../entities/_components/EntityCard/index.tsx | 2 +- .../src/app/entities/_components/shared.tsx | 2 +- .../CandidateClusterCard/index.stories.tsx | 2 +- .../CandidateClusterCard/index.test.tsx | 2 +- .../CandidateClusterCard/index.tsx | 2 +- .../CandidateQueueOverview/index.stories.tsx | 2 +- .../CandidateQueueOverview/index.test.tsx | 2 +- .../CandidateQueueOverview/index.tsx | 2 +- .../ResolvedCandidateList/index.stories.tsx | 2 +- .../ResolvedCandidateList/index.test.tsx | 2 +- .../ResolvedCandidateList/index.tsx | 2 +- .../entities/candidates/_components/shared.ts | 2 +- .../app/entities/candidates/page.stories.tsx | 2 +- frontend/src/app/entities/page.stories.tsx | 2 +- .../IdeasQueueOverview/index.stories.tsx | 2 +- .../IdeasQueueOverview/index.test.tsx | 2 +- .../_components/IdeasQueueOverview/index.tsx | 2 +- .../IdeasToolbarCard/index.stories.tsx | 2 +- .../IdeasToolbarCard/index.test.tsx | 2 +- .../_components/IdeasToolbarCard/index.tsx | 2 +- .../OriginalContentIdeaCard/index.test.tsx | 2 +- frontend/src/app/ideas/_components/shared.ts | 2 +- .../InvitationDetailsCard/index.stories.tsx | 2 +- .../InvitationDetailsCard/index.test.tsx | 2 +- .../InvitationDetailsCard/index.tsx | 2 +- .../InvitePageContent/index.stories.tsx | 2 +- .../InvitePageContent/index.test.tsx | 2 +- .../_components/InvitePageContent/index.tsx | 2 +- .../src/app/invite/[token]/page.stories.tsx | 2 +- frontend/src/app/invite/[token]/page.test.tsx | 2 +- .../_components/LoginForm/index.stories.tsx | 2 +- .../_components/LoginForm/index.test.tsx | 2 +- .../app/login/_components/LoginForm/index.tsx | 2 +- .../LoginPageContent/index.stories.tsx | 2 +- .../LoginPageContent/index.test.tsx | 2 +- .../_components/LoginPageContent/index.tsx | 2 +- .../SocialAuthButtons/index.stories.tsx | 2 +- .../SocialAuthButtons/index.test.tsx | 2 +- .../_components/SocialAuthButtons/index.tsx | 2 +- frontend/src/app/login/page.stories.tsx | 2 +- .../_components/MessagesPageContent/index.tsx | 54 ++ .../MessagesWorkspace/index.test.tsx | 257 +++++++++ .../_components/MessagesWorkspace/index.tsx | 510 ++++++++++++++++++ frontend/src/app/messages/page.test.tsx | 226 ++++++++ frontend/src/app/messages/page.tsx | 80 +++ .../AvatarDropzone/index.stories.tsx | 2 +- .../_components/AvatarDropzone/index.test.tsx | 2 +- .../_components/AvatarDropzone/index.tsx | 2 +- .../AvatarPreview/index.stories.tsx | 2 +- .../_components/AvatarPreview/index.test.tsx | 2 +- .../_components/AvatarPreview/index.tsx | 2 +- .../_components/ProfileForm/index.stories.tsx | 2 +- .../_components/ProfileForm/index.test.tsx | 2 +- .../profile/_components/ProfileForm/index.tsx | 2 +- .../ProfileSettingsPanel/index.test.tsx | 2 +- .../ProfileSettingsPanel/index.tsx | 2 +- .../index.stories.tsx | 2 +- .../ProfileSettingsWorkspace/index.test.tsx | 2 +- .../ProfileSettingsWorkspace/index.tsx | 2 +- frontend/src/app/profile/page.stories.tsx | 2 +- frontend/src/app/profile/page.test.tsx | 2 +- .../InvitationsCard/index.stories.tsx | 2 +- .../InvitationsCard/index.test.tsx | 2 +- .../_components/InvitationsCard/index.tsx | 2 +- .../MembersPageContent/index.stories.tsx | 3 +- .../MembersPageContent/index.test.tsx | 3 +- .../_components/MembersPageContent/index.tsx | 5 +- .../MembershipsCard/index.stories.tsx | 3 +- .../MembershipsCard/index.test.tsx | 20 +- .../_components/MembershipsCard/index.tsx | 30 +- .../[id]/members/_components/shared.tsx | 2 +- .../InviteMemberFormCard/index.stories.tsx | 2 +- .../InviteMemberFormCard/index.test.tsx | 2 +- .../InviteMemberFormCard/index.tsx | 2 +- .../InviteMemberPageContent/index.stories.tsx | 2 +- .../InviteMemberPageContent/index.test.tsx | 2 +- .../InviteMemberPageContent/index.tsx | 2 +- .../members/invite/_components/shared.tsx | 2 +- .../[id]/members/invite/page.stories.tsx | 2 +- .../[id]/members/invite/page.test.tsx | 2 +- .../projects/[id]/members/page.stories.tsx | 3 +- .../app/projects/[id]/members/page.test.tsx | 8 +- .../src/app/projects/[id]/members/page.tsx | 5 +- .../ThemeSuggestionCard/index.test.tsx | 2 +- .../ThemesFilterToolbar/index.stories.tsx | 2 +- .../ThemesFilterToolbar/index.test.tsx | 2 +- .../_components/ThemesFilterToolbar/index.tsx | 2 +- .../ThemesPageContent/index.stories.tsx | 2 +- .../ThemesPageContent/index.test.tsx | 2 +- .../_components/ThemesPageContent/index.tsx | 2 +- .../ThemesQueueOverview/index.stories.tsx | 2 +- .../ThemesQueueOverview/index.test.tsx | 2 +- .../_components/ThemesQueueOverview/index.tsx | 2 +- frontend/src/app/themes/_components/shared.ts | 2 +- .../TopicClusterCard/index.test.tsx | 2 +- .../TrendClusterDetailPanel/index.stories.tsx | 2 +- .../TrendClusterDetailPanel/index.test.tsx | 2 +- .../TrendClusterDetailPanel/index.tsx | 2 +- .../TrendsFilterToolbar/index.stories.tsx | 2 +- .../TrendsFilterToolbar/index.test.tsx | 2 +- .../_components/TrendsFilterToolbar/index.tsx | 2 +- .../TrendsPageContent/index.stories.tsx | 2 +- .../TrendsPageContent/index.test.tsx | 2 +- .../_components/TrendsPageContent/index.tsx | 2 +- .../TrendsQueueOverview/index.stories.tsx | 2 +- .../TrendsQueueOverview/index.test.tsx | 2 +- .../_components/TrendsQueueOverview/index.tsx | 2 +- .../src/app/trends/_components/shared.test.ts | 2 +- frontend/src/app/trends/_components/shared.ts | 2 +- .../elements/ThemeToggle/index.test.tsx | 2 +- .../elements/UserAvatar/index.stories.tsx | 2 +- .../elements/UserAvatar/index.test.tsx | 2 +- .../components/elements/UserAvatar/index.tsx | 2 +- .../AppShellHeader/index.stories.tsx | 3 +- .../_components/AppShellHeader/index.test.tsx | 12 +- .../_components/AppShellHeader/index.tsx | 31 +- .../AppShellSidebar/index.stories.tsx | 25 +- .../AppShellSidebar/index.test.tsx | 185 ++++++- .../_components/AppShellSidebar/index.tsx | 149 ++++- .../components/layout/AppShell/index.test.tsx | 95 +++- .../src/components/layout/AppShell/index.tsx | 15 +- .../layout/NotificationMenu/index.test.tsx | 165 ++++++ .../layout/NotificationMenu/index.tsx | 199 +++++++ .../UserMenuContent/index.stories.tsx | 2 +- .../UserMenuContent/index.test.tsx | 2 +- .../_components/UserMenuContent/index.tsx | 2 +- .../UserMenuTrigger/index.stories.tsx | 2 +- .../UserMenuTrigger/index.test.tsx | 2 +- .../_components/UserMenuTrigger/index.tsx | 2 +- frontend/src/lib/api.ts | 118 ++++ frontend/src/lib/messages.ts | 134 +++++ frontend/src/lib/notifications.ts | 121 +++++ frontend/src/lib/types.ts | 52 ++ frontend/tsconfig.tsbuildinfo | 2 +- ingestion/tasks.py | 13 + ingestion/tests/test_tasks.py | 40 +- messaging/__init__.py | 1 + messaging/admin.py | 31 ++ messaging/api.py | 151 ++++++ messaging/api_urls.py | 11 + messaging/apps.py | 14 + messaging/consumers.py | 60 +++ messaging/migrations/0001_initial.py | 125 +++++ messaging/migrations/__init__.py | 1 + messaging/models.py | 117 ++++ messaging/realtime.py | 31 ++ messaging/routing.py | 9 + messaging/serializers.py | 212 ++++++++ messaging/signals.py | 32 ++ messaging/tests/__init__.py | 1 + messaging/tests/test_api.py | 172 ++++++ messaging/tests/test_signals.py | 70 +++ newsletter_maker/asgi.py | 22 +- newsletter_maker/settings/__init__.py | 10 + newsletter_maker/settings/base.py | 29 + newsletter_maker/telemetry.py | 13 +- newsletters/tasks.py | 78 ++- newsletters/tests/test_tasks.py | 170 +++++- notifications/__init__.py | 1 + notifications/admin.py | 25 + notifications/api.py | 100 ++++ notifications/api_urls.py | 11 + notifications/apps.py | 13 + notifications/consumers.py | 46 ++ notifications/emit.py | 62 +++ notifications/migrations/0001_initial.py | 81 +++ notifications/models.py | 57 ++ notifications/realtime.py | 16 + notifications/routing.py | 9 + notifications/serializers.py | 31 ++ notifications/signals.py | 32 ++ notifications/tests/test_api.py | 126 +++++ notifications/tests/test_consumer.py | 46 ++ notifications/tests/test_emit.py | 94 ++++ notifications/tests/test_signals.py | 56 ++ requirements.txt | 30 +- skills/newsletter_extraction/SKILL.md | 2 +- 300 files changed, 5381 insertions(+), 315 deletions(-) create mode 100644 frontend/src/app/api/messages/threads/[threadId]/messages/route.test.ts create mode 100644 frontend/src/app/api/messages/threads/[threadId]/messages/route.ts create mode 100644 frontend/src/app/api/messages/threads/[threadId]/read/route.test.ts create mode 100644 frontend/src/app/api/messages/threads/[threadId]/read/route.ts create mode 100644 frontend/src/app/api/messages/threads/route.test.ts create mode 100644 frontend/src/app/api/messages/threads/route.ts create mode 100644 frontend/src/app/api/notifications/route.test.ts create mode 100644 frontend/src/app/api/notifications/route.ts create mode 100644 frontend/src/app/messages/_components/MessagesPageContent/index.tsx create mode 100644 frontend/src/app/messages/_components/MessagesWorkspace/index.test.tsx create mode 100644 frontend/src/app/messages/_components/MessagesWorkspace/index.tsx create mode 100644 frontend/src/app/messages/page.test.tsx create mode 100644 frontend/src/app/messages/page.tsx create mode 100644 frontend/src/components/layout/NotificationMenu/index.test.tsx create mode 100644 frontend/src/components/layout/NotificationMenu/index.tsx create mode 100644 frontend/src/lib/messages.ts create mode 100644 frontend/src/lib/notifications.ts create mode 100644 messaging/__init__.py create mode 100644 messaging/admin.py create mode 100644 messaging/api.py create mode 100644 messaging/api_urls.py create mode 100644 messaging/apps.py create mode 100644 messaging/consumers.py create mode 100644 messaging/migrations/0001_initial.py create mode 100644 messaging/migrations/__init__.py create mode 100644 messaging/models.py create mode 100644 messaging/realtime.py create mode 100644 messaging/routing.py create mode 100644 messaging/serializers.py create mode 100644 messaging/signals.py create mode 100644 messaging/tests/__init__.py create mode 100644 messaging/tests/test_api.py create mode 100644 messaging/tests/test_signals.py create mode 100644 notifications/__init__.py create mode 100644 notifications/admin.py create mode 100644 notifications/api.py create mode 100644 notifications/api_urls.py create mode 100644 notifications/apps.py create mode 100644 notifications/consumers.py create mode 100644 notifications/emit.py create mode 100644 notifications/migrations/0001_initial.py create mode 100644 notifications/models.py create mode 100644 notifications/realtime.py create mode 100644 notifications/routing.py create mode 100644 notifications/serializers.py create mode 100644 notifications/signals.py create mode 100644 notifications/tests/test_api.py create mode 100644 notifications/tests/test_consumer.py create mode 100644 notifications/tests/test_emit.py create mode 100644 notifications/tests/test_signals.py diff --git a/.vscode/settings.json b/.vscode/settings.json index bd6472fc..2232536f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,6 +5,7 @@ "botocore", "bsky", "buildx", + "cbor", "cbranch", "cfgv", "cstat", @@ -48,7 +49,9 @@ "ormsgpack", "PRAW", "psycopg", + "pyasn", "pylint", + "pyopenssl", "PYTHONDONTWRITEBYTECODE", "PYTHONUNBUFFERED", "pytokens", @@ -66,6 +69,8 @@ "solomonstre", "svix", "topicv", + "txaio", + "ujson", "Unparseable", "unstub", "upserted", @@ -76,6 +81,7 @@ "vfarcic", "Viktor", "xrpc", - "xxhash" + "xxhash", + "zope" ] } diff --git a/core/api_urls.py b/core/api_urls.py index c30b5356..9d8197ba 100644 --- a/core/api_urls.py +++ b/core/api_urls.py @@ -11,9 +11,13 @@ from ingestion.api_urls import ( register_project_routes as register_ingestion_project_routes, ) +from messaging.api_urls import register_root_routes as register_messaging_root_routes from newsletters.api_urls import ( register_project_routes as register_newsletters_project_routes, ) +from notifications.api_urls import ( + register_root_routes as register_notifications_root_routes, +) from pipeline.api_urls import ( register_project_routes as register_pipeline_project_routes, ) @@ -30,6 +34,8 @@ router = DefaultRouter() register_projects_root_routes(router) +register_notifications_root_routes(router) +register_messaging_root_routes(router) project_router = NestedSimpleRouter(router, r"projects", lookup="project") register_projects_project_routes(project_router) diff --git a/core/tests/test_entrypoints.py b/core/tests/test_entrypoints.py index 52560ed5..20d40bcc 100644 --- a/core/tests/test_entrypoints.py +++ b/core/tests/test_entrypoints.py @@ -2,6 +2,8 @@ import os import sys +from channels.routing import ProtocolTypeRouter + from newsletter_maker.celery import app @@ -22,7 +24,10 @@ def test_asgi_module_sets_default_settings_and_builds_application(mocker): "DJANGO_SETTINGS_MODULE", "newsletter_maker.settings" ) get_app_mock.assert_called_once_with() - assert module.application == "asgi-app" + assert module.django_asgi_application == "asgi-app" + assert isinstance(module.application, ProtocolTypeRouter) + assert module.application.application_mapping["http"] == "asgi-app" + assert "websocket" in module.application.application_mapping def test_wsgi_module_sets_default_settings_and_builds_application(mocker): diff --git a/deploy/argocd/newsletter-maker-staging-application.yaml b/deploy/argocd/newsletter-maker-staging-application.yaml index 71b79a64..88c5e941 100644 --- a/deploy/argocd/newsletter-maker-staging-application.yaml +++ b/deploy/argocd/newsletter-maker-staging-application.yaml @@ -20,4 +20,4 @@ spec: prune: true selfHeal: true syncOptions: - - CreateNamespace=true \ No newline at end of file + - CreateNamespace=true diff --git a/deploy/helm/newsletter-maker/templates/configmap.yaml b/deploy/helm/newsletter-maker/templates/configmap.yaml index d48bf95d..8530dae0 100644 --- a/deploy/helm/newsletter-maker/templates/configmap.yaml +++ b/deploy/helm/newsletter-maker/templates/configmap.yaml @@ -10,7 +10,9 @@ data: CSRF_TRUSTED_ORIGINS: {{ .Values.env.csrfTrustedOrigins | quote }} SITE_ID: {{ .Values.env.siteId | quote }} REDIS_URL: {{ include "newsletter-maker.redisUrl" . | quote }} + CHANNEL_LAYER_URL: {{ default (include "newsletter-maker.redisUrl" .) .Values.env.channelLayerUrl | quote }} QDRANT_URL: {{ include "newsletter-maker.qdrantUrl" . | quote }} + MESSAGING_ENABLED: {{ .Values.env.messagingEnabled | quote }} NEWSLETTER_API_BASE_URL: {{ .Values.env.newsletterApiBaseUrl | quote }} EMAIL_BACKEND: {{ .Values.env.emailBackend | quote }} DEFAULT_FROM_EMAIL: {{ .Values.env.defaultFromEmail | quote }} diff --git a/deploy/helm/newsletter-maker/templates/service-monitor.yaml b/deploy/helm/newsletter-maker/templates/service-monitor.yaml index 3dadc35e..87b60b0f 100644 --- a/deploy/helm/newsletter-maker/templates/service-monitor.yaml +++ b/deploy/helm/newsletter-maker/templates/service-monitor.yaml @@ -20,4 +20,4 @@ spec: bearerTokenSecret: name: {{ include "newsletter-maker.secretName" . }} key: METRICS_TOKEN -{{- end }} \ No newline at end of file +{{- end }} diff --git a/deploy/helm/newsletter-maker/values-staging.yaml b/deploy/helm/newsletter-maker/values-staging.yaml index 595f4548..243ec651 100644 --- a/deploy/helm/newsletter-maker/values-staging.yaml +++ b/deploy/helm/newsletter-maker/values-staging.yaml @@ -7,6 +7,7 @@ env: debug: "false" allowedHosts: "staging.newsletter-maker.example.com" csrfTrustedOrigins: "https://staging.newsletter-maker.example.com" + messagingEnabled: "true" newsletterApiBaseUrl: "https://staging.newsletter-maker.example.com" logLevel: INFO @@ -23,4 +24,4 @@ networkPolicy: serviceMonitor: enabled: true labels: - release: kube-prometheus-stack \ No newline at end of file + release: kube-prometheus-stack diff --git a/deploy/helm/newsletter-maker/values.yaml b/deploy/helm/newsletter-maker/values.yaml index 2efac430..af66ee95 100644 --- a/deploy/helm/newsletter-maker/values.yaml +++ b/deploy/helm/newsletter-maker/values.yaml @@ -11,6 +11,8 @@ env: allowedHosts: "localhost,127.0.0.1" csrfTrustedOrigins: "http://localhost,http://127.0.0.1" siteId: "1" + channelLayerUrl: "" + messagingEnabled: "true" newsletterApiBaseUrl: "http://newsletter-maker.local" emailBackend: anymail.backends.resend.EmailBackend defaultFromEmail: onboarding@resend.dev diff --git a/frontend/src/app/(home)/_components/ContentFeed/index.stories.tsx b/frontend/src/app/(home)/_components/ContentFeed/index.stories.tsx index 7c882f8a..a580493a 100644 --- a/frontend/src/app/(home)/_components/ContentFeed/index.stories.tsx +++ b/frontend/src/app/(home)/_components/ContentFeed/index.stories.tsx @@ -38,4 +38,4 @@ export const Empty: Story = { filteredContents: [], contentClusterLookup: new Map(), }, -} \ No newline at end of file +} diff --git a/frontend/src/app/(home)/_components/ContentFeed/index.test.tsx b/frontend/src/app/(home)/_components/ContentFeed/index.test.tsx index df5c7d19..9a8099e2 100644 --- a/frontend/src/app/(home)/_components/ContentFeed/index.test.tsx +++ b/frontend/src/app/(home)/_components/ContentFeed/index.test.tsx @@ -43,4 +43,4 @@ describe("ContentFeed", () => { expect(screen.getByText("reference")).toBeInTheDocument() expect(screen.getByRole("button", { name: "Upvote" })).toBeInTheDocument() }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/(home)/_components/ContentFeed/index.tsx b/frontend/src/app/(home)/_components/ContentFeed/index.tsx index 926fd6ef..558a9227 100644 --- a/frontend/src/app/(home)/_components/ContentFeed/index.tsx +++ b/frontend/src/app/(home)/_components/ContentFeed/index.tsx @@ -136,4 +136,4 @@ export function ContentFeed({ })} ) -} \ No newline at end of file +} diff --git a/frontend/src/app/(home)/_components/DashboardFilterToolbar/index.stories.tsx b/frontend/src/app/(home)/_components/DashboardFilterToolbar/index.stories.tsx index 707f8705..15654b19 100644 --- a/frontend/src/app/(home)/_components/DashboardFilterToolbar/index.stories.tsx +++ b/frontend/src/app/(home)/_components/DashboardFilterToolbar/index.stories.tsx @@ -35,4 +35,4 @@ export const Filtered: Story = { sourceFilter: "rss", duplicateStateFilter: "duplicate_related", }, -} \ No newline at end of file +} diff --git a/frontend/src/app/(home)/_components/DashboardFilterToolbar/index.test.tsx b/frontend/src/app/(home)/_components/DashboardFilterToolbar/index.test.tsx index a524055f..744e6efe 100644 --- a/frontend/src/app/(home)/_components/DashboardFilterToolbar/index.test.tsx +++ b/frontend/src/app/(home)/_components/DashboardFilterToolbar/index.test.tsx @@ -25,4 +25,4 @@ describe("DashboardFilterToolbar", () => { expect(container.querySelector('input[name="source"]')).toHaveValue("rss") expect(container.querySelector('input[name="days"]')).toHaveValue("30") }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/(home)/_components/DashboardFilterToolbar/index.tsx b/frontend/src/app/(home)/_components/DashboardFilterToolbar/index.tsx index 45f740ed..49a4bfe7 100644 --- a/frontend/src/app/(home)/_components/DashboardFilterToolbar/index.tsx +++ b/frontend/src/app/(home)/_components/DashboardFilterToolbar/index.tsx @@ -156,4 +156,4 @@ export function DashboardFilterToolbar({ ) -} \ No newline at end of file +} diff --git a/frontend/src/app/(home)/_components/DashboardOverview/index.stories.tsx b/frontend/src/app/(home)/_components/DashboardOverview/index.stories.tsx index d7e120c7..7c6a1e9b 100644 --- a/frontend/src/app/(home)/_components/DashboardOverview/index.stories.tsx +++ b/frontend/src/app/(home)/_components/DashboardOverview/index.stories.tsx @@ -24,4 +24,4 @@ export default meta type Story = StoryObj -export const Default: Story = {} \ No newline at end of file +export const Default: Story = {} diff --git a/frontend/src/app/(home)/_components/DashboardOverview/index.test.tsx b/frontend/src/app/(home)/_components/DashboardOverview/index.test.tsx index 19b24996..0aaa8b76 100644 --- a/frontend/src/app/(home)/_components/DashboardOverview/index.test.tsx +++ b/frontend/src/app/(home)/_components/DashboardOverview/index.test.tsx @@ -20,4 +20,4 @@ describe("DashboardOverview", () => { expect(screen.getByText("Tracked entities")).toBeInTheDocument() expect(screen.getByText("5/2")).toBeInTheDocument() }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/(home)/_components/DashboardOverview/index.tsx b/frontend/src/app/(home)/_components/DashboardOverview/index.tsx index 9e38b314..e1310045 100644 --- a/frontend/src/app/(home)/_components/DashboardOverview/index.tsx +++ b/frontend/src/app/(home)/_components/DashboardOverview/index.tsx @@ -55,4 +55,4 @@ export function DashboardOverview({ ))} ) -} \ No newline at end of file +} diff --git a/frontend/src/app/(home)/_components/DashboardSidebar/index.stories.tsx b/frontend/src/app/(home)/_components/DashboardSidebar/index.stories.tsx index ee31bed2..ab0bc3a8 100644 --- a/frontend/src/app/(home)/_components/DashboardSidebar/index.stories.tsx +++ b/frontend/src/app/(home)/_components/DashboardSidebar/index.stories.tsx @@ -23,4 +23,4 @@ export default meta type Story = StoryObj -export const Default: Story = {} \ No newline at end of file +export const Default: Story = {} diff --git a/frontend/src/app/(home)/_components/DashboardSidebar/index.test.tsx b/frontend/src/app/(home)/_components/DashboardSidebar/index.test.tsx index 89121731..89645693 100644 --- a/frontend/src/app/(home)/_components/DashboardSidebar/index.test.tsx +++ b/frontend/src/app/(home)/_components/DashboardSidebar/index.test.tsx @@ -20,4 +20,4 @@ describe("DashboardSidebar", () => { expect(screen.getByText("Active sources")).toBeInTheDocument() expect(screen.getByText("Editorial queue")).toBeInTheDocument() }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/(home)/_components/DashboardSidebar/index.tsx b/frontend/src/app/(home)/_components/DashboardSidebar/index.tsx index d79dd0e1..41dba9a0 100644 --- a/frontend/src/app/(home)/_components/DashboardSidebar/index.tsx +++ b/frontend/src/app/(home)/_components/DashboardSidebar/index.tsx @@ -46,4 +46,4 @@ export function DashboardSidebar({ ) -} \ No newline at end of file +} diff --git a/frontend/src/app/(home)/_components/HomePageContent/index.stories.tsx b/frontend/src/app/(home)/_components/HomePageContent/index.stories.tsx index 44570e58..19e92fec 100644 --- a/frontend/src/app/(home)/_components/HomePageContent/index.stories.tsx +++ b/frontend/src/app/(home)/_components/HomePageContent/index.stories.tsx @@ -65,4 +65,4 @@ export const ReviewView: Story = { args: { view: "review", }, -} \ No newline at end of file +} diff --git a/frontend/src/app/(home)/_components/HomePageContent/index.test.tsx b/frontend/src/app/(home)/_components/HomePageContent/index.test.tsx index 604fb151..39a667ee 100644 --- a/frontend/src/app/(home)/_components/HomePageContent/index.test.tsx +++ b/frontend/src/app/(home)/_components/HomePageContent/index.test.tsx @@ -95,4 +95,4 @@ describe("HomePageContent", () => { expect(screen.getByText("borderline_relevance")).toBeInTheDocument() expect(screen.getByRole("button", { name: "Approve" })).toBeInTheDocument() }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/(home)/_components/HomePageContent/index.tsx b/frontend/src/app/(home)/_components/HomePageContent/index.tsx index 13b7e016..86e2baad 100644 --- a/frontend/src/app/(home)/_components/HomePageContent/index.tsx +++ b/frontend/src/app/(home)/_components/HomePageContent/index.tsx @@ -119,4 +119,4 @@ export function HomePageContent({ )} ) -} \ No newline at end of file +} diff --git a/frontend/src/app/(home)/_components/ReviewQueueTable/index.stories.tsx b/frontend/src/app/(home)/_components/ReviewQueueTable/index.stories.tsx index b414a4e8..2dd1d56c 100644 --- a/frontend/src/app/(home)/_components/ReviewQueueTable/index.stories.tsx +++ b/frontend/src/app/(home)/_components/ReviewQueueTable/index.stories.tsx @@ -45,4 +45,4 @@ export const Empty: Story = { pendingReviewItems: [], contentMap: new Map(), }, -} \ No newline at end of file +} diff --git a/frontend/src/app/(home)/_components/ReviewQueueTable/index.test.tsx b/frontend/src/app/(home)/_components/ReviewQueueTable/index.test.tsx index a254b67f..8cd630dc 100644 --- a/frontend/src/app/(home)/_components/ReviewQueueTable/index.test.tsx +++ b/frontend/src/app/(home)/_components/ReviewQueueTable/index.test.tsx @@ -47,4 +47,4 @@ describe("ReviewQueueTable", () => { expect(screen.getByRole("button", { name: "Approve" })).toBeInTheDocument() expect(screen.getByRole("button", { name: "Reject" })).toBeInTheDocument() }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/(home)/_components/ReviewQueueTable/index.tsx b/frontend/src/app/(home)/_components/ReviewQueueTable/index.tsx index f900b285..ed5d03be 100644 --- a/frontend/src/app/(home)/_components/ReviewQueueTable/index.tsx +++ b/frontend/src/app/(home)/_components/ReviewQueueTable/index.tsx @@ -106,4 +106,4 @@ export function ReviewQueueTable({ ) -} \ No newline at end of file +} diff --git a/frontend/src/app/(home)/_components/shared.test.ts b/frontend/src/app/(home)/_components/shared.test.ts index 1cd10197..78a06834 100644 --- a/frontend/src/app/(home)/_components/shared.test.ts +++ b/frontend/src/app/(home)/_components/shared.test.ts @@ -26,4 +26,4 @@ describe("buildContentClusterLookup", () => { velocityScore: 0.8, }) }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/(home)/_components/shared.ts b/frontend/src/app/(home)/_components/shared.ts index fb01172e..2a6c073f 100644 --- a/frontend/src/app/(home)/_components/shared.ts +++ b/frontend/src/app/(home)/_components/shared.ts @@ -44,4 +44,4 @@ export function buildContentClusterLookup(clusterDetails: TopicClusterDetail[]) } return lookup -} \ No newline at end of file +} diff --git a/frontend/src/app/(home)/page.stories.tsx b/frontend/src/app/(home)/page.stories.tsx index f2cb613d..4871d81f 100644 --- a/frontend/src/app/(home)/page.stories.tsx +++ b/frontend/src/app/(home)/page.stories.tsx @@ -71,4 +71,4 @@ export const WithFlashMessages: Story = { errorMessage: "Filter failed", successMessage: "Filters applied", }, -} \ No newline at end of file +} diff --git a/frontend/src/app/admin/health/_components/SourceDiversityPanel/index.test.tsx b/frontend/src/app/admin/health/_components/SourceDiversityPanel/index.test.tsx index 0cc4fe00..e70e04da 100644 --- a/frontend/src/app/admin/health/_components/SourceDiversityPanel/index.test.tsx +++ b/frontend/src/app/admin/health/_components/SourceDiversityPanel/index.test.tsx @@ -95,4 +95,4 @@ describe("SourceDiversityPanel", () => { screen.getByText("No source-diversity snapshots exist for this project yet."), ).toBeInTheDocument() }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/admin/health/_components/SourceHealthPanel/index.stories.tsx b/frontend/src/app/admin/health/_components/SourceHealthPanel/index.stories.tsx index 784dbe2b..4bcf1155 100644 --- a/frontend/src/app/admin/health/_components/SourceHealthPanel/index.stories.tsx +++ b/frontend/src/app/admin/health/_components/SourceHealthPanel/index.stories.tsx @@ -54,4 +54,4 @@ export const Empty: Story = { statusLabel: "idle", statusTone: "neutral", }, -} \ No newline at end of file +} diff --git a/frontend/src/app/admin/health/_components/SourceHealthPanel/index.test.tsx b/frontend/src/app/admin/health/_components/SourceHealthPanel/index.test.tsx index f0839761..ead81518 100644 --- a/frontend/src/app/admin/health/_components/SourceHealthPanel/index.test.tsx +++ b/frontend/src/app/admin/health/_components/SourceHealthPanel/index.test.tsx @@ -60,4 +60,4 @@ describe("SourceHealthPanel", () => { screen.getByText("No source configurations exist for this project yet."), ).toBeInTheDocument() }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/admin/health/_components/SourceHealthPanel/index.tsx b/frontend/src/app/admin/health/_components/SourceHealthPanel/index.tsx index b2428380..23d74294 100644 --- a/frontend/src/app/admin/health/_components/SourceHealthPanel/index.tsx +++ b/frontend/src/app/admin/health/_components/SourceHealthPanel/index.tsx @@ -116,4 +116,4 @@ export function SourceHealthPanel({ ) -} \ No newline at end of file +} diff --git a/frontend/src/app/admin/health/_components/TopicCentroidPanel/index.stories.tsx b/frontend/src/app/admin/health/_components/TopicCentroidPanel/index.stories.tsx index f95170e7..5c315112 100644 --- a/frontend/src/app/admin/health/_components/TopicCentroidPanel/index.stories.tsx +++ b/frontend/src/app/admin/health/_components/TopicCentroidPanel/index.stories.tsx @@ -57,4 +57,4 @@ export const NoSnapshots: Story = { statusTone: "neutral", statusLabel: "idle", }, -} \ No newline at end of file +} diff --git a/frontend/src/app/admin/health/_components/TopicCentroidPanel/index.test.tsx b/frontend/src/app/admin/health/_components/TopicCentroidPanel/index.test.tsx index 0c354500..419b5f8c 100644 --- a/frontend/src/app/admin/health/_components/TopicCentroidPanel/index.test.tsx +++ b/frontend/src/app/admin/health/_components/TopicCentroidPanel/index.test.tsx @@ -93,4 +93,4 @@ describe("TopicCentroidPanel", () => { screen.getByText("No centroid snapshot history exists for this project yet."), ).toBeInTheDocument() }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/admin/health/_components/TopicCentroidPanel/index.tsx b/frontend/src/app/admin/health/_components/TopicCentroidPanel/index.tsx index 4e1af5f9..64e3126d 100644 --- a/frontend/src/app/admin/health/_components/TopicCentroidPanel/index.tsx +++ b/frontend/src/app/admin/health/_components/TopicCentroidPanel/index.tsx @@ -228,4 +228,4 @@ export function TopicCentroidPanel({ ) -} \ No newline at end of file +} diff --git a/frontend/src/app/admin/health/_components/TrendTaskRunsPanel/index.stories.tsx b/frontend/src/app/admin/health/_components/TrendTaskRunsPanel/index.stories.tsx index c64c46aa..7352b328 100644 --- a/frontend/src/app/admin/health/_components/TrendTaskRunsPanel/index.stories.tsx +++ b/frontend/src/app/admin/health/_components/TrendTaskRunsPanel/index.stories.tsx @@ -89,4 +89,4 @@ export const Empty: Story = { summary: createSummary({ run_count: 0, failed_run_count: 0, latest_runs: [] }), visibleRuns: [], }, -} \ No newline at end of file +} diff --git a/frontend/src/app/admin/health/_components/TrendTaskRunsPanel/index.test.tsx b/frontend/src/app/admin/health/_components/TrendTaskRunsPanel/index.test.tsx index 8ad59640..c692dbcf 100644 --- a/frontend/src/app/admin/health/_components/TrendTaskRunsPanel/index.test.tsx +++ b/frontend/src/app/admin/health/_components/TrendTaskRunsPanel/index.test.tsx @@ -87,4 +87,4 @@ describe("TrendTaskRunsPanel", () => { screen.getByText("No trend task run history exists for this project yet."), ).toBeInTheDocument() }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/admin/health/_components/TrendTaskRunsPanel/index.tsx b/frontend/src/app/admin/health/_components/TrendTaskRunsPanel/index.tsx index b1448e05..5bda6da4 100644 --- a/frontend/src/app/admin/health/_components/TrendTaskRunsPanel/index.tsx +++ b/frontend/src/app/admin/health/_components/TrendTaskRunsPanel/index.tsx @@ -293,4 +293,4 @@ export function TrendTaskRunsPanel({ ) -} \ No newline at end of file +} diff --git a/frontend/src/app/admin/projects/new/_components/NewProjectFormCard/index.stories.tsx b/frontend/src/app/admin/projects/new/_components/NewProjectFormCard/index.stories.tsx index b9f15efe..1a902237 100644 --- a/frontend/src/app/admin/projects/new/_components/NewProjectFormCard/index.stories.tsx +++ b/frontend/src/app/admin/projects/new/_components/NewProjectFormCard/index.stories.tsx @@ -17,4 +17,4 @@ export default meta type Story = StoryObj -export const Default: Story = {} \ No newline at end of file +export const Default: Story = {} diff --git a/frontend/src/app/admin/projects/new/_components/NewProjectFormCard/index.test.tsx b/frontend/src/app/admin/projects/new/_components/NewProjectFormCard/index.test.tsx index df1a909b..0ea08d17 100644 --- a/frontend/src/app/admin/projects/new/_components/NewProjectFormCard/index.test.tsx +++ b/frontend/src/app/admin/projects/new/_components/NewProjectFormCard/index.test.tsx @@ -25,4 +25,4 @@ describe("NewProjectFormCard", () => { expect(redirectInput).toHaveAttribute("name", "redirectTo") expect(redirectInput).toHaveAttribute("type", "hidden") }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/admin/projects/new/_components/NewProjectFormCard/index.tsx b/frontend/src/app/admin/projects/new/_components/NewProjectFormCard/index.tsx index 9602e7af..959ec6ba 100644 --- a/frontend/src/app/admin/projects/new/_components/NewProjectFormCard/index.tsx +++ b/frontend/src/app/admin/projects/new/_components/NewProjectFormCard/index.tsx @@ -68,4 +68,4 @@ export function NewProjectFormCard() { ) -} \ No newline at end of file +} diff --git a/frontend/src/app/admin/projects/new/_components/ProjectFlashNotice/index.stories.tsx b/frontend/src/app/admin/projects/new/_components/ProjectFlashNotice/index.stories.tsx index 66b96cc7..1901fc32 100644 --- a/frontend/src/app/admin/projects/new/_components/ProjectFlashNotice/index.stories.tsx +++ b/frontend/src/app/admin/projects/new/_components/ProjectFlashNotice/index.stories.tsx @@ -28,4 +28,4 @@ export const Error: Story = { tone: "error", children: "A project with that name already exists.", }, -} \ No newline at end of file +} diff --git a/frontend/src/app/admin/projects/new/_components/ProjectFlashNotice/index.test.tsx b/frontend/src/app/admin/projects/new/_components/ProjectFlashNotice/index.test.tsx index 9ab51956..3806ceb5 100644 --- a/frontend/src/app/admin/projects/new/_components/ProjectFlashNotice/index.test.tsx +++ b/frontend/src/app/admin/projects/new/_components/ProjectFlashNotice/index.test.tsx @@ -30,4 +30,4 @@ describe("ProjectFlashNotice", () => { screen.getByText("Project created. You are now the first project admin."), ).toBeInTheDocument() }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/admin/projects/new/_components/ProjectFlashNotice/index.tsx b/frontend/src/app/admin/projects/new/_components/ProjectFlashNotice/index.tsx index 156ca4f5..38a4c2af 100644 --- a/frontend/src/app/admin/projects/new/_components/ProjectFlashNotice/index.tsx +++ b/frontend/src/app/admin/projects/new/_components/ProjectFlashNotice/index.tsx @@ -24,4 +24,4 @@ export function ProjectFlashNotice({ tone, children }: ProjectFlashNoticeProps) {children} ) -} \ No newline at end of file +} diff --git a/frontend/src/app/admin/projects/new/page.stories.tsx b/frontend/src/app/admin/projects/new/page.stories.tsx index fbb0a3ce..befccb48 100644 --- a/frontend/src/app/admin/projects/new/page.stories.tsx +++ b/frontend/src/app/admin/projects/new/page.stories.tsx @@ -65,4 +65,4 @@ function NewProjectPagePreview({ ) -} \ No newline at end of file +} diff --git a/frontend/src/app/admin/projects/new/page.test.tsx b/frontend/src/app/admin/projects/new/page.test.tsx index a1c8ecbf..9a44e786 100644 --- a/frontend/src/app/admin/projects/new/page.test.tsx +++ b/frontend/src/app/admin/projects/new/page.test.tsx @@ -123,4 +123,4 @@ describe("NewProjectPage", () => { screen.getByText("Project created. You are now the first project admin."), ).toBeInTheDocument() }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/admin/sources/_components/NewsletterIntakePanel/index.stories.tsx b/frontend/src/app/admin/sources/_components/NewsletterIntakePanel/index.stories.tsx index b9183078..99c466bf 100644 --- a/frontend/src/app/admin/sources/_components/NewsletterIntakePanel/index.stories.tsx +++ b/frontend/src/app/admin/sources/_components/NewsletterIntakePanel/index.stories.tsx @@ -101,4 +101,4 @@ export const Empty: Story = { selectedIntake: null, selectedProject: createProject({ intake_enabled: false }), }, -} \ No newline at end of file +} diff --git a/frontend/src/app/admin/sources/_components/NewsletterIntakePanel/index.test.tsx b/frontend/src/app/admin/sources/_components/NewsletterIntakePanel/index.test.tsx index 991550d9..8e20a8cd 100644 --- a/frontend/src/app/admin/sources/_components/NewsletterIntakePanel/index.test.tsx +++ b/frontend/src/app/admin/sources/_components/NewsletterIntakePanel/index.test.tsx @@ -113,4 +113,4 @@ describe("NewsletterIntakePanel", () => { screen.getByText("No inbound newsletters have been captured for this project yet."), ).toBeInTheDocument() }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/admin/sources/_components/NewsletterIntakePanel/index.tsx b/frontend/src/app/admin/sources/_components/NewsletterIntakePanel/index.tsx index 88315381..dd495d30 100644 --- a/frontend/src/app/admin/sources/_components/NewsletterIntakePanel/index.tsx +++ b/frontend/src/app/admin/sources/_components/NewsletterIntakePanel/index.tsx @@ -371,4 +371,4 @@ export function NewsletterIntakePanel({ ) -} \ No newline at end of file +} diff --git a/frontend/src/app/admin/sources/_components/ProviderSetupPanel/index.stories.tsx b/frontend/src/app/admin/sources/_components/ProviderSetupPanel/index.stories.tsx index 9bd46a22..38ea7efb 100644 --- a/frontend/src/app/admin/sources/_components/ProviderSetupPanel/index.stories.tsx +++ b/frontend/src/app/admin/sources/_components/ProviderSetupPanel/index.stories.tsx @@ -98,4 +98,4 @@ export const Empty: Story = { linkedinVerificationState: { label: "not configured", tone: "neutral" }, mastodonVerificationState: { label: "not configured", tone: "neutral" }, }, -} \ No newline at end of file +} diff --git a/frontend/src/app/admin/sources/_components/ProviderSetupPanel/index.test.tsx b/frontend/src/app/admin/sources/_components/ProviderSetupPanel/index.test.tsx index 17a579c9..8743c2e9 100644 --- a/frontend/src/app/admin/sources/_components/ProviderSetupPanel/index.test.tsx +++ b/frontend/src/app/admin/sources/_components/ProviderSetupPanel/index.test.tsx @@ -105,4 +105,4 @@ describe("ProviderSetupPanel", () => { expect(screen.getByRole("button", { name: "Verify LinkedIn credentials" })).toBeDisabled() expect(screen.getByRole("button", { name: "Verify Mastodon credentials" })).toBeDisabled() }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/admin/sources/_components/ProviderSetupPanel/index.tsx b/frontend/src/app/admin/sources/_components/ProviderSetupPanel/index.tsx index 2d7ed63d..cfb52adc 100644 --- a/frontend/src/app/admin/sources/_components/ProviderSetupPanel/index.tsx +++ b/frontend/src/app/admin/sources/_components/ProviderSetupPanel/index.tsx @@ -478,4 +478,4 @@ export function ProviderSetupPanel({ ) -} \ No newline at end of file +} diff --git a/frontend/src/app/admin/sources/_components/SourceConfigList/index.stories.tsx b/frontend/src/app/admin/sources/_components/SourceConfigList/index.stories.tsx index e57e9e4f..84fc70fa 100644 --- a/frontend/src/app/admin/sources/_components/SourceConfigList/index.stories.tsx +++ b/frontend/src/app/admin/sources/_components/SourceConfigList/index.stories.tsx @@ -46,4 +46,4 @@ export const Empty: Story = { args: { rows: [], }, -} \ No newline at end of file +} diff --git a/frontend/src/app/admin/sources/_components/SourceConfigList/index.test.tsx b/frontend/src/app/admin/sources/_components/SourceConfigList/index.test.tsx index c74399aa..8e348070 100644 --- a/frontend/src/app/admin/sources/_components/SourceConfigList/index.test.tsx +++ b/frontend/src/app/admin/sources/_components/SourceConfigList/index.test.tsx @@ -68,4 +68,4 @@ describe("SourceConfigList", () => { screen.getByText("No source configurations exist for this project yet."), ).toBeInTheDocument() }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/admin/sources/_components/SourceConfigList/index.tsx b/frontend/src/app/admin/sources/_components/SourceConfigList/index.tsx index 60a597c8..12d77458 100644 --- a/frontend/src/app/admin/sources/_components/SourceConfigList/index.tsx +++ b/frontend/src/app/admin/sources/_components/SourceConfigList/index.tsx @@ -105,4 +105,4 @@ export function SourceConfigList({ ))} ) -} \ No newline at end of file +} diff --git a/frontend/src/app/admin/sources/_components/helpers.ts b/frontend/src/app/admin/sources/_components/helpers.ts index 0900c82d..97c2d06f 100644 --- a/frontend/src/app/admin/sources/_components/helpers.ts +++ b/frontend/src/app/admin/sources/_components/helpers.ts @@ -133,4 +133,4 @@ export function filterNewsletterIntakes( } return true }) -} \ No newline at end of file +} diff --git a/frontend/src/app/admin/sources/page.stories.tsx b/frontend/src/app/admin/sources/page.stories.tsx index b5b37f17..848aa871 100644 --- a/frontend/src/app/admin/sources/page.stories.tsx +++ b/frontend/src/app/admin/sources/page.stories.tsx @@ -220,4 +220,4 @@ function SourcesPagePreview({ showError = false, showMessage = false }: SourcesP ) -} \ No newline at end of file +} diff --git a/frontend/src/app/admin/sources/page.tsx b/frontend/src/app/admin/sources/page.tsx index 261676a6..21f15f94 100644 --- a/frontend/src/app/admin/sources/page.tsx +++ b/frontend/src/app/admin/sources/page.tsx @@ -169,4 +169,4 @@ export default async function SourcesPage({ searchParams }: SourcesPageProps) { ) -} \ No newline at end of file +} diff --git a/frontend/src/app/api/messages/threads/[threadId]/messages/route.test.ts b/frontend/src/app/api/messages/threads/[threadId]/messages/route.test.ts new file mode 100644 index 00000000..82640840 --- /dev/null +++ b/frontend/src/app/api/messages/threads/[threadId]/messages/route.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { createThreadMessage, getThreadMessages } from "@/lib/api" + +import { GET, POST } from "./route" + +vi.mock("@/lib/api", () => ({ + createThreadMessage: vi.fn(), + getThreadMessages: vi.fn(), +})) + +describe("/api/messages/threads/[threadId]/messages route", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("returns thread messages from the backend helper", async () => { + vi.mocked(getThreadMessages).mockResolvedValue([ + { + id: 14, + thread: 3, + sender: 8, + sender_username: "maya", + sender_display_name: "Maya", + body: "Can you review this draft?", + created_at: "2026-05-03T10:00:00Z", + edited_at: null, + }, + ]) + + const response = await GET(new Request("http://localhost/api/messages"), { + params: Promise.resolve({ threadId: "3" }), + }) + + expect(getThreadMessages).toHaveBeenCalledWith(3) + await expect(response.json()).resolves.toEqual([ + expect.objectContaining({ id: 14, thread: 3 }), + ]) + }) + + it("routes message creation to the backend helper", async () => { + vi.mocked(createThreadMessage).mockResolvedValue({ + id: 15, + thread: 3, + sender: 4, + sender_username: "editor", + sender_display_name: "Editor", + body: "On it.", + created_at: "2026-05-03T10:01:00Z", + edited_at: null, + }) + + const response = await POST( + new Request("http://localhost/api/messages", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ body: "On it." }), + }), + { + params: Promise.resolve({ threadId: "3" }), + }, + ) + + expect(createThreadMessage).toHaveBeenCalledWith(3, { body: "On it." }) + expect(response.status).toBe(201) + }) +}) \ No newline at end of file diff --git a/frontend/src/app/api/messages/threads/[threadId]/messages/route.ts b/frontend/src/app/api/messages/threads/[threadId]/messages/route.ts new file mode 100644 index 00000000..2c86cf20 --- /dev/null +++ b/frontend/src/app/api/messages/threads/[threadId]/messages/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from "next/server" + +import { createThreadMessage, getThreadMessages } from "@/lib/api" + +function buildErrorResponse(error: unknown, fallbackMessage: string) { + const message = error instanceof Error ? error.message : fallbackMessage + return NextResponse.json({ error: message }, { status: 400 }) +} + +async function parseThreadId(context: { params: Promise<{ threadId: string }> }) { + const { threadId } = await context.params + const parsedThreadId = Number.parseInt(threadId, 10) + + if (Number.isNaN(parsedThreadId)) { + throw new Error("threadId must be a number.") + } + + return parsedThreadId +} + +/** + * Return one thread's message history through the Next.js route boundary. + */ +export async function GET( + request: Request, + context: { params: Promise<{ threadId: string }> }, +) { + void request + + try { + return NextResponse.json(await getThreadMessages(await parseThreadId(context))) + } catch (error) { + return buildErrorResponse(error, "Unable to load message history.") + } +} + +/** + * Send one direct message through the Next.js route boundary. + */ +export async function POST( + request: Request, + context: { params: Promise<{ threadId: string }> }, +) { + try { + const payload = (await request.json()) as { body?: string } + if (typeof payload.body !== "string" || payload.body.trim().length === 0) { + return NextResponse.json( + { error: "body is required." }, + { status: 400 }, + ) + } + + return NextResponse.json( + await createThreadMessage(await parseThreadId(context), { + body: payload.body, + }), + { status: 201 }, + ) + } catch (error) { + return buildErrorResponse(error, "Unable to send message.") + } +} \ No newline at end of file diff --git a/frontend/src/app/api/messages/threads/[threadId]/read/route.test.ts b/frontend/src/app/api/messages/threads/[threadId]/read/route.test.ts new file mode 100644 index 00000000..dba37e9b --- /dev/null +++ b/frontend/src/app/api/messages/threads/[threadId]/read/route.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { markThreadRead } from "@/lib/api" + +import { POST } from "./route" + +vi.mock("@/lib/api", () => ({ + markThreadRead: vi.fn(), +})) + +describe("/api/messages/threads/[threadId]/read route", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("routes read markers to the backend helper", async () => { + vi.mocked(markThreadRead).mockResolvedValue({ + thread_id: 3, + last_read_at: "2026-05-03T10:02:00Z", + }) + + const response = await POST(new Request("http://localhost/api/messages/read"), { + params: Promise.resolve({ threadId: "3" }), + }) + + expect(markThreadRead).toHaveBeenCalledWith(3) + await expect(response.json()).resolves.toEqual({ + thread_id: 3, + last_read_at: "2026-05-03T10:02:00Z", + }) + }) +}) \ No newline at end of file diff --git a/frontend/src/app/api/messages/threads/[threadId]/read/route.ts b/frontend/src/app/api/messages/threads/[threadId]/read/route.ts new file mode 100644 index 00000000..eeeab465 --- /dev/null +++ b/frontend/src/app/api/messages/threads/[threadId]/read/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server" + +import { markThreadRead } from "@/lib/api" + +function buildErrorResponse(error: unknown, fallbackMessage: string) { + const message = error instanceof Error ? error.message : fallbackMessage + return NextResponse.json({ error: message }, { status: 400 }) +} + +/** + * Mark one direct-message thread as read through the Next.js route boundary. + */ +export async function POST( + request: Request, + context: { params: Promise<{ threadId: string }> }, +) { + void request + + try { + const { threadId } = await context.params + const parsedThreadId = Number.parseInt(threadId, 10) + + if (Number.isNaN(parsedThreadId)) { + return NextResponse.json( + { error: "threadId must be a number." }, + { status: 400 }, + ) + } + + return NextResponse.json(await markThreadRead(parsedThreadId)) + } catch (error) { + return buildErrorResponse(error, "Unable to update thread read status.") + } +} \ No newline at end of file diff --git a/frontend/src/app/api/messages/threads/route.test.ts b/frontend/src/app/api/messages/threads/route.test.ts new file mode 100644 index 00000000..8f146a7a --- /dev/null +++ b/frontend/src/app/api/messages/threads/route.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { createMessageThread, getMessageThreads } from "@/lib/api" + +import { GET, POST } from "./route" + +vi.mock("@/lib/api", () => ({ + createMessageThread: vi.fn(), + getMessageThreads: vi.fn(), +})) + +describe("/api/messages/threads route", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("returns message threads from the backend helper", async () => { + vi.mocked(getMessageThreads).mockResolvedValue([ + { + id: 3, + counterpart: { + id: 8, + username: "maya", + display_name: "Maya", + avatar_url: null, + avatar_thumbnail_url: null, + }, + has_unread: true, + last_message_preview: "Can you review this draft?", + last_message_at: "2026-05-03T10:00:00Z", + last_read_at: null, + created_at: "2026-05-01T10:00:00Z", + }, + ]) + + const response = await GET() + + expect(getMessageThreads).toHaveBeenCalled() + await expect(response.json()).resolves.toEqual([ + expect.objectContaining({ id: 3, has_unread: true }), + ]) + }) + + it("routes thread creation to the backend helper", async () => { + vi.mocked(createMessageThread).mockResolvedValue({ + id: 9, + counterpart: { + id: 5, + username: "liam", + display_name: "Liam", + avatar_url: null, + avatar_thumbnail_url: null, + }, + has_unread: false, + last_message_preview: "Hello", + last_message_at: "2026-05-03T10:00:00Z", + last_read_at: "2026-05-03T10:00:00Z", + created_at: "2026-05-03T10:00:00Z", + }) + + const response = await POST( + new Request("http://localhost/api/messages/threads", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + recipient_user_id: 5, + opening_message: "Hello", + }), + }), + ) + + expect(createMessageThread).toHaveBeenCalledWith({ + recipient_user_id: 5, + opening_message: "Hello", + }) + expect(response.status).toBe(201) + }) +}) \ No newline at end of file diff --git a/frontend/src/app/api/messages/threads/route.ts b/frontend/src/app/api/messages/threads/route.ts new file mode 100644 index 00000000..8c53dd29 --- /dev/null +++ b/frontend/src/app/api/messages/threads/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server" + +import { createMessageThread, getMessageThreads } from "@/lib/api" + +function buildErrorResponse(error: unknown, fallbackMessage: string) { + const message = error instanceof Error ? error.message : fallbackMessage + return NextResponse.json({ error: message }, { status: 400 }) +} + +/** + * Return the current user's direct-message threads through the Next.js route boundary. + */ +export async function GET() { + try { + return NextResponse.json(await getMessageThreads()) + } catch (error) { + return buildErrorResponse(error, "Unable to load message threads.") + } +} + +/** + * Open or find a direct-message thread through the Next.js route boundary. + */ +export async function POST(request: Request) { + try { + const payload = (await request.json()) as { + recipient_user_id?: number + opening_message?: string + } + + if (typeof payload.recipient_user_id !== "number") { + return NextResponse.json( + { error: "recipient_user_id is required." }, + { status: 400 }, + ) + } + + return NextResponse.json( + await createMessageThread({ + recipient_user_id: payload.recipient_user_id, + opening_message: payload.opening_message, + }), + { status: 201 }, + ) + } catch (error) { + return buildErrorResponse(error, "Unable to open message thread.") + } +} \ No newline at end of file diff --git a/frontend/src/app/api/notifications/route.test.ts b/frontend/src/app/api/notifications/route.test.ts new file mode 100644 index 00000000..f538a1dc --- /dev/null +++ b/frontend/src/app/api/notifications/route.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { + getNotifications, + readAllNotifications, + readNotification, +} from "@/lib/api" + +import { GET, PATCH } from "./route" + +vi.mock("@/lib/api", () => ({ + getNotifications: vi.fn(), + readAllNotifications: vi.fn(), + readNotification: vi.fn(), +})) + +describe("/api/notifications route", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("returns notifications from the backend helper", async () => { + vi.mocked(getNotifications).mockResolvedValue([ + { + id: 1, + project: 3, + level: "info", + body: "Draft ready", + link_path: "/drafts", + metadata: {}, + created_at: "2026-05-03T10:00:00Z", + read_at: null, + is_read: false, + }, + ]) + + const response = await GET( + new Request("http://localhost/api/notifications?unread=true"), + ) + + expect(getNotifications).toHaveBeenCalledWith({ unread: true }) + await expect(response.json()).resolves.toEqual([ + expect.objectContaining({ id: 1, body: "Draft ready" }), + ]) + }) + + it("routes individual read actions to the backend helper", async () => { + vi.mocked(readNotification).mockResolvedValue({ + id: 7, + project: null, + level: "success", + body: "Read now", + link_path: "", + metadata: {}, + created_at: "2026-05-03T10:00:00Z", + read_at: "2026-05-03T10:01:00Z", + is_read: true, + }) + + const response = await PATCH( + new Request("http://localhost/api/notifications", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "read", notification_id: 7 }), + }), + ) + + expect(readNotification).toHaveBeenCalledWith(7) + await expect(response.json()).resolves.toEqual( + expect.objectContaining({ id: 7, is_read: true }), + ) + }) + + it("routes mark-all actions to the backend helper", async () => { + vi.mocked(readAllNotifications).mockResolvedValue({ updated_count: 4 }) + + const response = await PATCH( + new Request("http://localhost/api/notifications", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "read_all" }), + }), + ) + + expect(readAllNotifications).toHaveBeenCalled() + await expect(response.json()).resolves.toEqual({ updated_count: 4 }) + }) +}) \ No newline at end of file diff --git a/frontend/src/app/api/notifications/route.ts b/frontend/src/app/api/notifications/route.ts new file mode 100644 index 00000000..ad293d26 --- /dev/null +++ b/frontend/src/app/api/notifications/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from "next/server" + +import { + getNotifications, + readAllNotifications, + readNotification, +} from "@/lib/api" + +function buildErrorResponse(error: unknown, fallbackMessage: string) { + const message = error instanceof Error ? error.message : fallbackMessage + return NextResponse.json({ error: message }, { status: 400 }) +} + +/** + * Return the current user's notifications through the Next.js route boundary. + * + * @param request - Incoming request containing optional unread filter params. + * @returns JSON notifications payload from the backend API. + */ +export async function GET(request: Request) { + const { searchParams } = new URL(request.url) + + try { + return NextResponse.json( + await getNotifications({ + unread: searchParams.get("unread") === "true", + }), + ) + } catch (error) { + return buildErrorResponse(error, "Unable to load notifications.") + } +} + +/** + * Proxy notification read mutations through the Next.js route boundary. + * + * @param request - Incoming JSON request describing the desired mutation. + * @returns JSON mutation payload from the backend API. + */ +export async function PATCH(request: Request) { + try { + const payload = (await request.json()) as + | { action?: "read"; notification_id?: number } + | { action?: "read_all" } + + if (payload.action === "read") { + if (typeof payload.notification_id !== "number") { + return NextResponse.json( + { error: "notification_id is required when action is read." }, + { status: 400 }, + ) + } + return NextResponse.json(await readNotification(payload.notification_id)) + } + + if (payload.action === "read_all") { + return NextResponse.json(await readAllNotifications()) + } + + return NextResponse.json( + { error: "Unsupported notification action." }, + { status: 400 }, + ) + } catch (error) { + return buildErrorResponse(error, "Unable to update notifications.") + } +} \ No newline at end of file diff --git a/frontend/src/app/content/[id]/_components/ContentDetailMainColumn/index.stories.tsx b/frontend/src/app/content/[id]/_components/ContentDetailMainColumn/index.stories.tsx index b0675396..80a38f8f 100644 --- a/frontend/src/app/content/[id]/_components/ContentDetailMainColumn/index.stories.tsx +++ b/frontend/src/app/content/[id]/_components/ContentDetailMainColumn/index.stories.tsx @@ -69,4 +69,4 @@ export const Minimal: Story = { effectiveRelevanceScore: null, initialPendingSkills: [], }, -} \ No newline at end of file +} diff --git a/frontend/src/app/content/[id]/_components/ContentDetailMainColumn/index.test.tsx b/frontend/src/app/content/[id]/_components/ContentDetailMainColumn/index.test.tsx index 70f8d0cb..c94a2743 100644 --- a/frontend/src/app/content/[id]/_components/ContentDetailMainColumn/index.test.tsx +++ b/frontend/src/app/content/[id]/_components/ContentDetailMainColumn/index.test.tsx @@ -151,4 +151,4 @@ describe("ContentDetailMainColumn", () => { expect(screen.getByText("unclassified")).toBeInTheDocument() expect(screen.getByTestId("status-badge")).toHaveTextContent("Adjusted n/a") }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/content/[id]/_components/ContentDetailMainColumn/index.tsx b/frontend/src/app/content/[id]/_components/ContentDetailMainColumn/index.tsx index 4c8e92ab..124e0c20 100644 --- a/frontend/src/app/content/[id]/_components/ContentDetailMainColumn/index.tsx +++ b/frontend/src/app/content/[id]/_components/ContentDetailMainColumn/index.tsx @@ -212,4 +212,4 @@ export function ContentDetailMainColumn({ ))} ) -} \ No newline at end of file +} diff --git a/frontend/src/app/content/[id]/_components/ContentDetailSidebar/index.stories.tsx b/frontend/src/app/content/[id]/_components/ContentDetailSidebar/index.stories.tsx index a8dd4f5b..362689e5 100644 --- a/frontend/src/app/content/[id]/_components/ContentDetailSidebar/index.stories.tsx +++ b/frontend/src/app/content/[id]/_components/ContentDetailSidebar/index.stories.tsx @@ -63,4 +63,4 @@ export const Empty: Story = { reviewItems: [], upvotes: 0, }, -} \ No newline at end of file +} diff --git a/frontend/src/app/content/[id]/_components/ContentDetailSidebar/index.test.tsx b/frontend/src/app/content/[id]/_components/ContentDetailSidebar/index.test.tsx index 9b0315c9..bf973b79 100644 --- a/frontend/src/app/content/[id]/_components/ContentDetailSidebar/index.test.tsx +++ b/frontend/src/app/content/[id]/_components/ContentDetailSidebar/index.test.tsx @@ -123,4 +123,4 @@ describe("ContentDetailSidebar", () => { screen.getByText("This content has not been promoted by a theme suggestion yet."), ).toBeInTheDocument() }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/content/[id]/_components/ContentDetailSidebar/index.tsx b/frontend/src/app/content/[id]/_components/ContentDetailSidebar/index.tsx index a1325dcf..ea9f8923 100644 --- a/frontend/src/app/content/[id]/_components/ContentDetailSidebar/index.tsx +++ b/frontend/src/app/content/[id]/_components/ContentDetailSidebar/index.tsx @@ -108,4 +108,4 @@ export function ContentDetailSidebar({ ) -} \ No newline at end of file +} diff --git a/frontend/src/app/content/[id]/_components/SkillActionBar/index.stories.tsx b/frontend/src/app/content/[id]/_components/SkillActionBar/index.stories.tsx index b6e1daba..d1d75742 100644 --- a/frontend/src/app/content/[id]/_components/SkillActionBar/index.stories.tsx +++ b/frontend/src/app/content/[id]/_components/SkillActionBar/index.stories.tsx @@ -36,4 +36,4 @@ export const SummarizationDisabled: Story = { args: { canSummarize: false, }, -} \ No newline at end of file +} diff --git a/frontend/src/app/content/[id]/page.stories.tsx b/frontend/src/app/content/[id]/page.stories.tsx index c380a7ad..3eb6d486 100644 --- a/frontend/src/app/content/[id]/page.stories.tsx +++ b/frontend/src/app/content/[id]/page.stories.tsx @@ -141,4 +141,4 @@ function ContentDetailPagePreview({ ) -} \ No newline at end of file +} diff --git a/frontend/src/app/content/[id]/page.tsx b/frontend/src/app/content/[id]/page.tsx index 3a724920..69441d34 100644 --- a/frontend/src/app/content/[id]/page.tsx +++ b/frontend/src/app/content/[id]/page.tsx @@ -136,4 +136,4 @@ export default async function ContentDetailPage({ ) -} \ No newline at end of file +} diff --git a/frontend/src/app/drafts/[draftId]/_components/DraftEditor/index.stories.tsx b/frontend/src/app/drafts/[draftId]/_components/DraftEditor/index.stories.tsx index 70d80d59..a053154d 100644 --- a/frontend/src/app/drafts/[draftId]/_components/DraftEditor/index.stories.tsx +++ b/frontend/src/app/drafts/[draftId]/_components/DraftEditor/index.stories.tsx @@ -150,4 +150,4 @@ export const WithGenerationError: Story = { }, }), }, -} \ No newline at end of file +} diff --git a/frontend/src/app/drafts/[draftId]/_components/DraftOverviewCards/index.stories.tsx b/frontend/src/app/drafts/[draftId]/_components/DraftOverviewCards/index.stories.tsx index ddeffd04..3360ac4a 100644 --- a/frontend/src/app/drafts/[draftId]/_components/DraftOverviewCards/index.stories.tsx +++ b/frontend/src/app/drafts/[draftId]/_components/DraftOverviewCards/index.stories.tsx @@ -45,4 +45,4 @@ export default meta type Story = StoryObj -export const Default: Story = {} \ No newline at end of file +export const Default: Story = {} diff --git a/frontend/src/app/drafts/[draftId]/_components/DraftOverviewCards/index.test.tsx b/frontend/src/app/drafts/[draftId]/_components/DraftOverviewCards/index.test.tsx index 30cf50da..bf2b02fa 100644 --- a/frontend/src/app/drafts/[draftId]/_components/DraftOverviewCards/index.test.tsx +++ b/frontend/src/app/drafts/[draftId]/_components/DraftOverviewCards/index.test.tsx @@ -59,4 +59,4 @@ describe("DraftOverviewCards", () => { expect(screen.getByText("2026-05-08")).toBeInTheDocument() expect(screen.getByText("No manual edits yet.")).toBeInTheDocument() }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/drafts/[draftId]/_components/DraftOverviewCards/index.tsx b/frontend/src/app/drafts/[draftId]/_components/DraftOverviewCards/index.tsx index daad6f99..8b2fa21e 100644 --- a/frontend/src/app/drafts/[draftId]/_components/DraftOverviewCards/index.tsx +++ b/frontend/src/app/drafts/[draftId]/_components/DraftOverviewCards/index.tsx @@ -61,4 +61,4 @@ export function DraftOverviewCards({ draft }: DraftOverviewCardsProps) { ) -} \ No newline at end of file +} diff --git a/frontend/src/app/drafts/[draftId]/_components/DraftRenderedOutput/index.stories.tsx b/frontend/src/app/drafts/[draftId]/_components/DraftRenderedOutput/index.stories.tsx index dff6c37c..452a884e 100644 --- a/frontend/src/app/drafts/[draftId]/_components/DraftRenderedOutput/index.stories.tsx +++ b/frontend/src/app/drafts/[draftId]/_components/DraftRenderedOutput/index.stories.tsx @@ -52,4 +52,4 @@ export const Html: Story = { args: { view: "html", }, -} \ No newline at end of file +} diff --git a/frontend/src/app/drafts/[draftId]/_components/DraftRenderedOutput/index.test.tsx b/frontend/src/app/drafts/[draftId]/_components/DraftRenderedOutput/index.test.tsx index 7a3a2189..cd782436 100644 --- a/frontend/src/app/drafts/[draftId]/_components/DraftRenderedOutput/index.test.tsx +++ b/frontend/src/app/drafts/[draftId]/_components/DraftRenderedOutput/index.test.tsx @@ -45,4 +45,4 @@ describe("DraftRenderedOutput", () => { expect(screen.getByText("Rendered HTML")).toBeInTheDocument() expect(container.querySelector("h1")).toHaveTextContent("Draft") }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/drafts/[draftId]/_components/DraftRenderedOutput/index.tsx b/frontend/src/app/drafts/[draftId]/_components/DraftRenderedOutput/index.tsx index 83f0f7b4..be4cd137 100644 --- a/frontend/src/app/drafts/[draftId]/_components/DraftRenderedOutput/index.tsx +++ b/frontend/src/app/drafts/[draftId]/_components/DraftRenderedOutput/index.tsx @@ -38,4 +38,4 @@ export function DraftRenderedOutput({ draft, view }: DraftRenderedOutputProps) { } return null -} \ No newline at end of file +} diff --git a/frontend/src/app/drafts/[draftId]/_components/DraftViewSwitcher/index.stories.tsx b/frontend/src/app/drafts/[draftId]/_components/DraftViewSwitcher/index.stories.tsx index 669e16dd..71a2203a 100644 --- a/frontend/src/app/drafts/[draftId]/_components/DraftViewSwitcher/index.stories.tsx +++ b/frontend/src/app/drafts/[draftId]/_components/DraftViewSwitcher/index.stories.tsx @@ -26,4 +26,4 @@ export const Markdown: Story = { args: { currentView: "markdown", }, -} \ No newline at end of file +} diff --git a/frontend/src/app/drafts/[draftId]/_components/DraftViewSwitcher/index.test.tsx b/frontend/src/app/drafts/[draftId]/_components/DraftViewSwitcher/index.test.tsx index 7396f14b..6fb2820a 100644 --- a/frontend/src/app/drafts/[draftId]/_components/DraftViewSwitcher/index.test.tsx +++ b/frontend/src/app/drafts/[draftId]/_components/DraftViewSwitcher/index.test.tsx @@ -33,4 +33,4 @@ describe("DraftViewSwitcher", () => { "/drafts?project=1", ) }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/drafts/[draftId]/_components/DraftViewSwitcher/index.tsx b/frontend/src/app/drafts/[draftId]/_components/DraftViewSwitcher/index.tsx index 0bb9f24d..a0208451 100644 --- a/frontend/src/app/drafts/[draftId]/_components/DraftViewSwitcher/index.tsx +++ b/frontend/src/app/drafts/[draftId]/_components/DraftViewSwitcher/index.tsx @@ -78,4 +78,4 @@ export function DraftViewSwitcher({ ) -} \ No newline at end of file +} diff --git a/frontend/src/app/drafts/[draftId]/page.stories.tsx b/frontend/src/app/drafts/[draftId]/page.stories.tsx index d7284eae..08d4c459 100644 --- a/frontend/src/app/drafts/[draftId]/page.stories.tsx +++ b/frontend/src/app/drafts/[draftId]/page.stories.tsx @@ -149,4 +149,4 @@ function DraftDetailPagePreview({ ) : null} ) -} \ No newline at end of file +} diff --git a/frontend/src/app/drafts/_components/DraftsList/index.stories.tsx b/frontend/src/app/drafts/_components/DraftsList/index.stories.tsx index 4b00250e..0cc8270b 100644 --- a/frontend/src/app/drafts/_components/DraftsList/index.stories.tsx +++ b/frontend/src/app/drafts/_components/DraftsList/index.stories.tsx @@ -59,4 +59,4 @@ export const Empty: Story = { args: { drafts: [], }, -} \ No newline at end of file +} diff --git a/frontend/src/app/drafts/_components/DraftsList/index.test.tsx b/frontend/src/app/drafts/_components/DraftsList/index.test.tsx index cbef668e..7a2828c6 100644 --- a/frontend/src/app/drafts/_components/DraftsList/index.test.tsx +++ b/frontend/src/app/drafts/_components/DraftsList/index.test.tsx @@ -57,4 +57,4 @@ describe("DraftsList", () => { ) expect(screen.getByText("Composer heuristic")).toBeInTheDocument() }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/drafts/_components/DraftsList/index.tsx b/frontend/src/app/drafts/_components/DraftsList/index.tsx index deb2e9f5..7f140b8c 100644 --- a/frontend/src/app/drafts/_components/DraftsList/index.tsx +++ b/frontend/src/app/drafts/_components/DraftsList/index.tsx @@ -83,4 +83,4 @@ export function DraftsList({ drafts, selectedProjectId }: DraftsListProps) { ))} ) -} \ No newline at end of file +} diff --git a/frontend/src/app/drafts/_components/DraftsOverviewCards/index.stories.tsx b/frontend/src/app/drafts/_components/DraftsOverviewCards/index.stories.tsx index 83ba5641..63322ef2 100644 --- a/frontend/src/app/drafts/_components/DraftsOverviewCards/index.stories.tsx +++ b/frontend/src/app/drafts/_components/DraftsOverviewCards/index.stories.tsx @@ -50,4 +50,4 @@ export default meta type Story = StoryObj -export const Default: Story = {} \ No newline at end of file +export const Default: Story = {} diff --git a/frontend/src/app/drafts/_components/DraftsOverviewCards/index.test.tsx b/frontend/src/app/drafts/_components/DraftsOverviewCards/index.test.tsx index f9d7c330..2b1cc7d5 100644 --- a/frontend/src/app/drafts/_components/DraftsOverviewCards/index.test.tsx +++ b/frontend/src/app/drafts/_components/DraftsOverviewCards/index.test.tsx @@ -51,4 +51,4 @@ describe("DraftsOverviewCards", () => { expect(screen.getByText("Drafts that ended in an error state.")).toBeInTheDocument() expect(screen.getAllByText("1")).toHaveLength(5) }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/drafts/_components/DraftsOverviewCards/index.tsx b/frontend/src/app/drafts/_components/DraftsOverviewCards/index.tsx index 8bedcd7d..64835b20 100644 --- a/frontend/src/app/drafts/_components/DraftsOverviewCards/index.tsx +++ b/frontend/src/app/drafts/_components/DraftsOverviewCards/index.tsx @@ -51,4 +51,4 @@ export function DraftsOverviewCards({ drafts }: DraftsOverviewCardsProps) { ) -} \ No newline at end of file +} diff --git a/frontend/src/app/drafts/_components/DraftsToolbar/index.stories.tsx b/frontend/src/app/drafts/_components/DraftsToolbar/index.stories.tsx index e50d2866..44a69ee3 100644 --- a/frontend/src/app/drafts/_components/DraftsToolbar/index.stories.tsx +++ b/frontend/src/app/drafts/_components/DraftsToolbar/index.stories.tsx @@ -27,4 +27,4 @@ export const AllDrafts: Story = { currentPageHref: "/drafts?project=1", statusFilter: "all", }, -} \ No newline at end of file +} diff --git a/frontend/src/app/drafts/_components/DraftsToolbar/index.test.tsx b/frontend/src/app/drafts/_components/DraftsToolbar/index.test.tsx index c2954b7e..571bf4ca 100644 --- a/frontend/src/app/drafts/_components/DraftsToolbar/index.test.tsx +++ b/frontend/src/app/drafts/_components/DraftsToolbar/index.test.tsx @@ -22,4 +22,4 @@ describe("DraftsToolbar", () => { expect(container.querySelector('form[action="/api/projects/1/drafts/generate"]')).not.toBeNull() expect(screen.getByRole("button", { name: "Generate now" })).toBeInTheDocument() }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/drafts/_components/DraftsToolbar/index.tsx b/frontend/src/app/drafts/_components/DraftsToolbar/index.tsx index da05ce10..7fa6cb2a 100644 --- a/frontend/src/app/drafts/_components/DraftsToolbar/index.tsx +++ b/frontend/src/app/drafts/_components/DraftsToolbar/index.tsx @@ -76,4 +76,4 @@ export function DraftsToolbar({ ) -} \ No newline at end of file +} diff --git a/frontend/src/app/drafts/page.stories.tsx b/frontend/src/app/drafts/page.stories.tsx index 50bff05d..d1889ce7 100644 --- a/frontend/src/app/drafts/page.stories.tsx +++ b/frontend/src/app/drafts/page.stories.tsx @@ -128,4 +128,4 @@ function DraftsPagePreview({ ) -} \ No newline at end of file +} diff --git a/frontend/src/app/entities/[id]/_components/AuthorityHistoryPanel/index.stories.tsx b/frontend/src/app/entities/[id]/_components/AuthorityHistoryPanel/index.stories.tsx index 1bc2d5a9..57c18801 100644 --- a/frontend/src/app/entities/[id]/_components/AuthorityHistoryPanel/index.stories.tsx +++ b/frontend/src/app/entities/[id]/_components/AuthorityHistoryPanel/index.stories.tsx @@ -51,4 +51,4 @@ export const Empty: Story = { projectConfig: null, userRole: "member", }, -} \ No newline at end of file +} diff --git a/frontend/src/app/entities/[id]/_components/AuthorityHistoryPanel/index.test.tsx b/frontend/src/app/entities/[id]/_components/AuthorityHistoryPanel/index.test.tsx index 1d6014c9..627260bb 100644 --- a/frontend/src/app/entities/[id]/_components/AuthorityHistoryPanel/index.test.tsx +++ b/frontend/src/app/entities/[id]/_components/AuthorityHistoryPanel/index.test.tsx @@ -69,4 +69,4 @@ describe("AuthorityHistoryPanel", () => { expect(screen.getByText("More recomputations will draw the trend line here.")).toBeInTheDocument() expect(screen.queryByTestId("authority-weight-controls")).not.toBeInTheDocument() }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/entities/[id]/_components/AuthorityHistoryPanel/index.tsx b/frontend/src/app/entities/[id]/_components/AuthorityHistoryPanel/index.tsx index 95bfa929..7b558638 100644 --- a/frontend/src/app/entities/[id]/_components/AuthorityHistoryPanel/index.tsx +++ b/frontend/src/app/entities/[id]/_components/AuthorityHistoryPanel/index.tsx @@ -297,4 +297,4 @@ function AuthorityComponentCard({ label, value }: { label: string; value: number

) -} \ No newline at end of file +} diff --git a/frontend/src/app/entities/[id]/_components/AuthorityWeightControls/index.stories.tsx b/frontend/src/app/entities/[id]/_components/AuthorityWeightControls/index.stories.tsx index f5393b75..3711fd71 100644 --- a/frontend/src/app/entities/[id]/_components/AuthorityWeightControls/index.stories.tsx +++ b/frontend/src/app/entities/[id]/_components/AuthorityWeightControls/index.stories.tsx @@ -29,4 +29,4 @@ export const NoSavedConfig: Story = { args: { projectConfig: null, }, -} \ No newline at end of file +} diff --git a/frontend/src/app/entities/[id]/_components/EntityDetailPageContent/index.test.tsx b/frontend/src/app/entities/[id]/_components/EntityDetailPageContent/index.test.tsx index 00570519..ae37b39e 100644 --- a/frontend/src/app/entities/[id]/_components/EntityDetailPageContent/index.test.tsx +++ b/frontend/src/app/entities/[id]/_components/EntityDetailPageContent/index.test.tsx @@ -66,4 +66,4 @@ describe("EntityDetailPageContent", () => { "Authority weight controls for project 3" ) }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/entities/[id]/_components/EntityDetailPageContent/index.tsx b/frontend/src/app/entities/[id]/_components/EntityDetailPageContent/index.tsx index 76efe503..e02753e2 100644 --- a/frontend/src/app/entities/[id]/_components/EntityDetailPageContent/index.tsx +++ b/frontend/src/app/entities/[id]/_components/EntityDetailPageContent/index.tsx @@ -78,4 +78,4 @@ export function EntityDetailPageContent({ ) -} \ No newline at end of file +} diff --git a/frontend/src/app/entities/[id]/_components/EntityMentionsPanel/index.stories.tsx b/frontend/src/app/entities/[id]/_components/EntityMentionsPanel/index.stories.tsx index 1d961c15..32742003 100644 --- a/frontend/src/app/entities/[id]/_components/EntityMentionsPanel/index.stories.tsx +++ b/frontend/src/app/entities/[id]/_components/EntityMentionsPanel/index.stories.tsx @@ -38,4 +38,4 @@ export const Empty: Story = { args: { mentions: [], }, -} \ No newline at end of file +} diff --git a/frontend/src/app/entities/[id]/_components/EntityMentionsPanel/index.test.tsx b/frontend/src/app/entities/[id]/_components/EntityMentionsPanel/index.test.tsx index aff5d8f1..dd13c7bc 100644 --- a/frontend/src/app/entities/[id]/_components/EntityMentionsPanel/index.test.tsx +++ b/frontend/src/app/entities/[id]/_components/EntityMentionsPanel/index.test.tsx @@ -43,4 +43,4 @@ describe("EntityMentionsPanel", () => { expect(screen.getByText("No extracted mentions exist for this entity yet.")).toBeInTheDocument() }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/entities/[id]/_components/EntityMentionsPanel/index.tsx b/frontend/src/app/entities/[id]/_components/EntityMentionsPanel/index.tsx index 61fbc702..35ee8e60 100644 --- a/frontend/src/app/entities/[id]/_components/EntityMentionsPanel/index.tsx +++ b/frontend/src/app/entities/[id]/_components/EntityMentionsPanel/index.tsx @@ -62,4 +62,4 @@ export function EntityMentionsPanel({ mentions, projectId }: EntityMentionsPanel ) -} \ No newline at end of file +} diff --git a/frontend/src/app/entities/[id]/_components/EntityOverviewCard/index.stories.tsx b/frontend/src/app/entities/[id]/_components/EntityOverviewCard/index.stories.tsx index 12daaddd..81e4f1e9 100644 --- a/frontend/src/app/entities/[id]/_components/EntityOverviewCard/index.stories.tsx +++ b/frontend/src/app/entities/[id]/_components/EntityOverviewCard/index.stories.tsx @@ -43,4 +43,4 @@ export const EmptyIdentity: Story = { twitter_handle: "", }), }, -} \ No newline at end of file +} diff --git a/frontend/src/app/entities/[id]/_components/EntityOverviewCard/index.test.tsx b/frontend/src/app/entities/[id]/_components/EntityOverviewCard/index.test.tsx index ebc0546c..0ebe78f7 100644 --- a/frontend/src/app/entities/[id]/_components/EntityOverviewCard/index.test.tsx +++ b/frontend/src/app/entities/[id]/_components/EntityOverviewCard/index.test.tsx @@ -50,4 +50,4 @@ describe("EntityOverviewCard", () => { expect(screen.getByText("No description is set for this entity yet.")).toBeInTheDocument() expect(screen.getByText("No external identity links are set yet.")).toBeInTheDocument() }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/entities/[id]/_components/EntityOverviewCard/index.tsx b/frontend/src/app/entities/[id]/_components/EntityOverviewCard/index.tsx index 379e9774..76e15176 100644 --- a/frontend/src/app/entities/[id]/_components/EntityOverviewCard/index.tsx +++ b/frontend/src/app/entities/[id]/_components/EntityOverviewCard/index.tsx @@ -79,4 +79,4 @@ export function EntityOverviewCard({ entity }: EntityOverviewCardProps) { ) -} \ No newline at end of file +} diff --git a/frontend/src/app/entities/[id]/_components/EntitySidebar/index.stories.tsx b/frontend/src/app/entities/[id]/_components/EntitySidebar/index.stories.tsx index 3ae0e0bd..9f3b884c 100644 --- a/frontend/src/app/entities/[id]/_components/EntitySidebar/index.stories.tsx +++ b/frontend/src/app/entities/[id]/_components/EntitySidebar/index.stories.tsx @@ -31,4 +31,4 @@ export const Empty: Story = { args: { siblingEntities: [], }, -} \ No newline at end of file +} diff --git a/frontend/src/app/entities/[id]/_components/EntitySidebar/index.test.tsx b/frontend/src/app/entities/[id]/_components/EntitySidebar/index.test.tsx index c85f1c11..5f6e044c 100644 --- a/frontend/src/app/entities/[id]/_components/EntitySidebar/index.test.tsx +++ b/frontend/src/app/entities/[id]/_components/EntitySidebar/index.test.tsx @@ -32,4 +32,4 @@ describe("EntitySidebar", () => { expect(screen.getByText("No other entities exist in this project yet.")).toBeInTheDocument() }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/entities/[id]/_components/EntitySidebar/index.tsx b/frontend/src/app/entities/[id]/_components/EntitySidebar/index.tsx index c13a65f1..e6c8d52f 100644 --- a/frontend/src/app/entities/[id]/_components/EntitySidebar/index.tsx +++ b/frontend/src/app/entities/[id]/_components/EntitySidebar/index.tsx @@ -71,4 +71,4 @@ export function EntitySidebar({ ) -} \ No newline at end of file +} diff --git a/frontend/src/app/entities/[id]/page.stories.tsx b/frontend/src/app/entities/[id]/page.stories.tsx index 4467fdb1..fb28a5c7 100644 --- a/frontend/src/app/entities/[id]/page.stories.tsx +++ b/frontend/src/app/entities/[id]/page.stories.tsx @@ -92,4 +92,4 @@ function EntityDetailPagePreview({ successMessage={showMessage ? "Entity updated" : undefined} /> ) -} \ No newline at end of file +} diff --git a/frontend/src/app/entities/_components/CreateEntityCard/index.stories.tsx b/frontend/src/app/entities/_components/CreateEntityCard/index.stories.tsx index 2474a808..345727d3 100644 --- a/frontend/src/app/entities/_components/CreateEntityCard/index.stories.tsx +++ b/frontend/src/app/entities/_components/CreateEntityCard/index.stories.tsx @@ -20,4 +20,4 @@ export default meta type Story = StoryObj -export const Default: Story = {} \ No newline at end of file +export const Default: Story = {} diff --git a/frontend/src/app/entities/_components/CreateEntityCard/index.test.tsx b/frontend/src/app/entities/_components/CreateEntityCard/index.test.tsx index 8d6c3c78..22cce71c 100644 --- a/frontend/src/app/entities/_components/CreateEntityCard/index.test.tsx +++ b/frontend/src/app/entities/_components/CreateEntityCard/index.test.tsx @@ -29,4 +29,4 @@ describe("CreateEntityCard", () => { expect(redirectInput).toHaveAttribute("name", "redirectTo") expect(redirectInput).toHaveAttribute("type", "hidden") }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/entities/_components/CreateEntityCard/index.tsx b/frontend/src/app/entities/_components/CreateEntityCard/index.tsx index 38492a25..5c9e844e 100644 --- a/frontend/src/app/entities/_components/CreateEntityCard/index.tsx +++ b/frontend/src/app/entities/_components/CreateEntityCard/index.tsx @@ -75,4 +75,4 @@ export function CreateEntityCard({ projectId }: CreateEntityCardProps) { ) -} \ No newline at end of file +} diff --git a/frontend/src/app/entities/_components/EntitiesPageContent/index.test.tsx b/frontend/src/app/entities/_components/EntitiesPageContent/index.test.tsx index 08fe8b32..217e8852 100644 --- a/frontend/src/app/entities/_components/EntitiesPageContent/index.test.tsx +++ b/frontend/src/app/entities/_components/EntitiesPageContent/index.test.tsx @@ -62,4 +62,4 @@ describe("EntitiesPageContent", () => { expect(screen.getByText("River Labs")).toBeInTheDocument() expect(screen.getByRole("link", { name: "Anthropic" })).toBeInTheDocument() }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/entities/_components/EntitiesPageContent/index.tsx b/frontend/src/app/entities/_components/EntitiesPageContent/index.tsx index b755af41..07a148c4 100644 --- a/frontend/src/app/entities/_components/EntitiesPageContent/index.tsx +++ b/frontend/src/app/entities/_components/EntitiesPageContent/index.tsx @@ -67,4 +67,4 @@ export function EntitiesPageContent({ ) -} \ No newline at end of file +} diff --git a/frontend/src/app/entities/_components/EntityCandidatesCard/index.stories.tsx b/frontend/src/app/entities/_components/EntityCandidatesCard/index.stories.tsx index a42ed7bb..a7f7d1da 100644 --- a/frontend/src/app/entities/_components/EntityCandidatesCard/index.stories.tsx +++ b/frontend/src/app/entities/_components/EntityCandidatesCard/index.stories.tsx @@ -32,4 +32,4 @@ export const Empty: Story = { args: { entityCandidates: [], }, -} \ No newline at end of file +} diff --git a/frontend/src/app/entities/_components/EntityCandidatesCard/index.test.tsx b/frontend/src/app/entities/_components/EntityCandidatesCard/index.test.tsx index eb689454..496f8cb1 100644 --- a/frontend/src/app/entities/_components/EntityCandidatesCard/index.test.tsx +++ b/frontend/src/app/entities/_components/EntityCandidatesCard/index.test.tsx @@ -34,4 +34,4 @@ describe("EntityCandidatesCard", () => { expect(screen.getByLabelText("Merge into existing entity")).toBeInTheDocument() expect(screen.getByText("pending")).toBeInTheDocument() }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/entities/_components/EntityCandidatesCard/index.tsx b/frontend/src/app/entities/_components/EntityCandidatesCard/index.tsx index 6bdd35c1..f558b029 100644 --- a/frontend/src/app/entities/_components/EntityCandidatesCard/index.tsx +++ b/frontend/src/app/entities/_components/EntityCandidatesCard/index.tsx @@ -143,4 +143,4 @@ export function EntityCandidatesCard({ ) -} \ No newline at end of file +} diff --git a/frontend/src/app/entities/_components/EntityCard/index.stories.tsx b/frontend/src/app/entities/_components/EntityCard/index.stories.tsx index c5a16c20..4061ec6a 100644 --- a/frontend/src/app/entities/_components/EntityCard/index.stories.tsx +++ b/frontend/src/app/entities/_components/EntityCard/index.stories.tsx @@ -31,4 +31,4 @@ export const NoMentions: Story = { mention_count: 0, }), }, -} \ No newline at end of file +} diff --git a/frontend/src/app/entities/_components/EntityCard/index.test.tsx b/frontend/src/app/entities/_components/EntityCard/index.test.tsx index d3ba9791..44c9c87c 100644 --- a/frontend/src/app/entities/_components/EntityCard/index.test.tsx +++ b/frontend/src/app/entities/_components/EntityCard/index.test.tsx @@ -47,4 +47,4 @@ describe("EntityCard", () => { expect(screen.getByText("No extracted mentions for this entity yet.")).toBeInTheDocument() }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/entities/_components/EntityCard/index.tsx b/frontend/src/app/entities/_components/EntityCard/index.tsx index 8b6e41cf..8e957dfe 100644 --- a/frontend/src/app/entities/_components/EntityCard/index.tsx +++ b/frontend/src/app/entities/_components/EntityCard/index.tsx @@ -180,4 +180,4 @@ export function EntityCard({ entity, projectId }: EntityCardProps) { ) -} \ No newline at end of file +} diff --git a/frontend/src/app/entities/_components/shared.tsx b/frontend/src/app/entities/_components/shared.tsx index 077e538e..e6685570 100644 --- a/frontend/src/app/entities/_components/shared.tsx +++ b/frontend/src/app/entities/_components/shared.tsx @@ -47,4 +47,4 @@ export function EntityTypeSelect({ ) -} \ No newline at end of file +} diff --git a/frontend/src/app/entities/candidates/_components/CandidateClusterCard/index.stories.tsx b/frontend/src/app/entities/candidates/_components/CandidateClusterCard/index.stories.tsx index 7cdee1b9..184b6c16 100644 --- a/frontend/src/app/entities/candidates/_components/CandidateClusterCard/index.stories.tsx +++ b/frontend/src/app/entities/candidates/_components/CandidateClusterCard/index.stories.tsx @@ -37,4 +37,4 @@ export default meta type Story = StoryObj -export const Default: Story = {} \ No newline at end of file +export const Default: Story = {} diff --git a/frontend/src/app/entities/candidates/_components/CandidateClusterCard/index.test.tsx b/frontend/src/app/entities/candidates/_components/CandidateClusterCard/index.test.tsx index 77220f64..c1692fdb 100644 --- a/frontend/src/app/entities/candidates/_components/CandidateClusterCard/index.test.tsx +++ b/frontend/src/app/entities/candidates/_components/CandidateClusterCard/index.test.tsx @@ -45,4 +45,4 @@ describe("CandidateClusterCard", () => { expect(screen.getByLabelText("Merge cluster into entity")).toBeInTheDocument() expect(screen.getAllByTestId("status-badge")).toHaveLength(2) }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/entities/candidates/_components/CandidateClusterCard/index.tsx b/frontend/src/app/entities/candidates/_components/CandidateClusterCard/index.tsx index e0397428..2a8e5c31 100644 --- a/frontend/src/app/entities/candidates/_components/CandidateClusterCard/index.tsx +++ b/frontend/src/app/entities/candidates/_components/CandidateClusterCard/index.tsx @@ -150,4 +150,4 @@ export function CandidateClusterCard({ ) -} \ No newline at end of file +} diff --git a/frontend/src/app/entities/candidates/_components/CandidateQueueOverview/index.stories.tsx b/frontend/src/app/entities/candidates/_components/CandidateQueueOverview/index.stories.tsx index 51a25e02..2dd7b2c5 100644 --- a/frontend/src/app/entities/candidates/_components/CandidateQueueOverview/index.stories.tsx +++ b/frontend/src/app/entities/candidates/_components/CandidateQueueOverview/index.stories.tsx @@ -30,4 +30,4 @@ export const AutoLog: Story = { args: { activeTab: "auto-log", }, -} \ No newline at end of file +} diff --git a/frontend/src/app/entities/candidates/_components/CandidateQueueOverview/index.test.tsx b/frontend/src/app/entities/candidates/_components/CandidateQueueOverview/index.test.tsx index 253884a7..898dd9cf 100644 --- a/frontend/src/app/entities/candidates/_components/CandidateQueueOverview/index.test.tsx +++ b/frontend/src/app/entities/candidates/_components/CandidateQueueOverview/index.test.tsx @@ -32,4 +32,4 @@ describe("CandidateQueueOverview", () => { "/entities?project=7" ) }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/entities/candidates/_components/CandidateQueueOverview/index.tsx b/frontend/src/app/entities/candidates/_components/CandidateQueueOverview/index.tsx index 53dacf1c..d4518d36 100644 --- a/frontend/src/app/entities/candidates/_components/CandidateQueueOverview/index.tsx +++ b/frontend/src/app/entities/candidates/_components/CandidateQueueOverview/index.tsx @@ -85,4 +85,4 @@ export function CandidateQueueOverview({ ) -} \ No newline at end of file +} diff --git a/frontend/src/app/entities/candidates/_components/ResolvedCandidateList/index.stories.tsx b/frontend/src/app/entities/candidates/_components/ResolvedCandidateList/index.stories.tsx index 6358d192..78002caf 100644 --- a/frontend/src/app/entities/candidates/_components/ResolvedCandidateList/index.stories.tsx +++ b/frontend/src/app/entities/candidates/_components/ResolvedCandidateList/index.stories.tsx @@ -41,4 +41,4 @@ export const Empty: Story = { args: { resolvedCandidates: [], }, -} \ No newline at end of file +} diff --git a/frontend/src/app/entities/candidates/_components/ResolvedCandidateList/index.test.tsx b/frontend/src/app/entities/candidates/_components/ResolvedCandidateList/index.test.tsx index 7e347d3a..96b9ec03 100644 --- a/frontend/src/app/entities/candidates/_components/ResolvedCandidateList/index.test.tsx +++ b/frontend/src/app/entities/candidates/_components/ResolvedCandidateList/index.test.tsx @@ -40,4 +40,4 @@ describe("ResolvedCandidateList", () => { expect(screen.getByText("2 sources")).toBeInTheDocument() expect(screen.getByTestId("status-badge")).toHaveTextContent("accepted") }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/entities/candidates/_components/ResolvedCandidateList/index.tsx b/frontend/src/app/entities/candidates/_components/ResolvedCandidateList/index.tsx index 83932314..16e13ef7 100644 --- a/frontend/src/app/entities/candidates/_components/ResolvedCandidateList/index.tsx +++ b/frontend/src/app/entities/candidates/_components/ResolvedCandidateList/index.tsx @@ -63,4 +63,4 @@ export function ResolvedCandidateList({ ))} ) -} \ No newline at end of file +} diff --git a/frontend/src/app/entities/candidates/_components/shared.ts b/frontend/src/app/entities/candidates/_components/shared.ts index d1bb295b..e4fe2389 100644 --- a/frontend/src/app/entities/candidates/_components/shared.ts +++ b/frontend/src/app/entities/candidates/_components/shared.ts @@ -45,4 +45,4 @@ export function groupCandidateClusters( export function formatBlockedReason(reason: string) { return reason.replaceAll("_", " ") -} \ No newline at end of file +} diff --git a/frontend/src/app/entities/candidates/page.stories.tsx b/frontend/src/app/entities/candidates/page.stories.tsx index 4c959ef0..6cfc547e 100644 --- a/frontend/src/app/entities/candidates/page.stories.tsx +++ b/frontend/src/app/entities/candidates/page.stories.tsx @@ -120,4 +120,4 @@ function CandidateQueuePagePreview({ ) -} \ No newline at end of file +} diff --git a/frontend/src/app/entities/page.stories.tsx b/frontend/src/app/entities/page.stories.tsx index 149683e6..79654530 100644 --- a/frontend/src/app/entities/page.stories.tsx +++ b/frontend/src/app/entities/page.stories.tsx @@ -83,4 +83,4 @@ function EntitiesPagePreview({ successMessage={successMessage} /> ) -} \ No newline at end of file +} diff --git a/frontend/src/app/ideas/_components/IdeasQueueOverview/index.stories.tsx b/frontend/src/app/ideas/_components/IdeasQueueOverview/index.stories.tsx index fab4865e..64acc43f 100644 --- a/frontend/src/app/ideas/_components/IdeasQueueOverview/index.stories.tsx +++ b/frontend/src/app/ideas/_components/IdeasQueueOverview/index.stories.tsx @@ -23,4 +23,4 @@ export default meta type Story = StoryObj -export const Default: Story = {} \ No newline at end of file +export const Default: Story = {} diff --git a/frontend/src/app/ideas/_components/IdeasQueueOverview/index.test.tsx b/frontend/src/app/ideas/_components/IdeasQueueOverview/index.test.tsx index 0ae1eb61..6ab3d41e 100644 --- a/frontend/src/app/ideas/_components/IdeasQueueOverview/index.test.tsx +++ b/frontend/src/app/ideas/_components/IdeasQueueOverview/index.test.tsx @@ -23,4 +23,4 @@ describe("IdeasQueueOverview", () => { expect(screen.getByText("3")).toBeInTheDocument() expect(screen.getByText("1")).toBeInTheDocument() }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/ideas/_components/IdeasQueueOverview/index.tsx b/frontend/src/app/ideas/_components/IdeasQueueOverview/index.tsx index 8a162ed2..81b82d4a 100644 --- a/frontend/src/app/ideas/_components/IdeasQueueOverview/index.tsx +++ b/frontend/src/app/ideas/_components/IdeasQueueOverview/index.tsx @@ -53,4 +53,4 @@ export function IdeasQueueOverview({ ))} ) -} \ No newline at end of file +} diff --git a/frontend/src/app/ideas/_components/IdeasToolbarCard/index.stories.tsx b/frontend/src/app/ideas/_components/IdeasToolbarCard/index.stories.tsx index 026f804a..70c3ddc2 100644 --- a/frontend/src/app/ideas/_components/IdeasToolbarCard/index.stories.tsx +++ b/frontend/src/app/ideas/_components/IdeasToolbarCard/index.stories.tsx @@ -29,4 +29,4 @@ export const Filtered: Story = { statusFilter: "accepted", currentPageHref: "/ideas?project=1&status=accepted", }, -} \ No newline at end of file +} diff --git a/frontend/src/app/ideas/_components/IdeasToolbarCard/index.test.tsx b/frontend/src/app/ideas/_components/IdeasToolbarCard/index.test.tsx index b680a66c..e6e46c90 100644 --- a/frontend/src/app/ideas/_components/IdeasToolbarCard/index.test.tsx +++ b/frontend/src/app/ideas/_components/IdeasToolbarCard/index.test.tsx @@ -26,4 +26,4 @@ describe("IdeasToolbarCard", () => { "/ideas?project=7&status=accepted", ) }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/ideas/_components/IdeasToolbarCard/index.tsx b/frontend/src/app/ideas/_components/IdeasToolbarCard/index.tsx index eaa15496..6a07b23a 100644 --- a/frontend/src/app/ideas/_components/IdeasToolbarCard/index.tsx +++ b/frontend/src/app/ideas/_components/IdeasToolbarCard/index.tsx @@ -69,4 +69,4 @@ export function IdeasToolbarCard({ ) -} \ No newline at end of file +} diff --git a/frontend/src/app/ideas/_components/OriginalContentIdeaCard/index.test.tsx b/frontend/src/app/ideas/_components/OriginalContentIdeaCard/index.test.tsx index 691ebd20..08408414 100644 --- a/frontend/src/app/ideas/_components/OriginalContentIdeaCard/index.test.tsx +++ b/frontend/src/app/ideas/_components/OriginalContentIdeaCard/index.test.tsx @@ -48,4 +48,4 @@ describe("OriginalContentIdeaCard", () => { expect(screen.getByText(/Decided by editor-2/)).toBeInTheDocument() expect(screen.getByRole("button", { name: "Mark written" })).toBeInTheDocument() }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/ideas/_components/shared.ts b/frontend/src/app/ideas/_components/shared.ts index c4bd7fbb..0d321a60 100644 --- a/frontend/src/app/ideas/_components/shared.ts +++ b/frontend/src/app/ideas/_components/shared.ts @@ -12,4 +12,4 @@ export const ideaStatusOptions = [ { value: "dismissed", label: "Dismissed" }, ] as const -export const selectTriggerClassName = "min-h-11 w-full rounded-2xl border-border/12 bg-muted/70 px-4 py-3 text-sm text-foreground" \ No newline at end of file +export const selectTriggerClassName = "min-h-11 w-full rounded-2xl border-border/12 bg-muted/70 px-4 py-3 text-sm text-foreground" diff --git a/frontend/src/app/invite/[token]/_components/InvitationDetailsCard/index.stories.tsx b/frontend/src/app/invite/[token]/_components/InvitationDetailsCard/index.stories.tsx index d16dddbd..0ba4661e 100644 --- a/frontend/src/app/invite/[token]/_components/InvitationDetailsCard/index.stories.tsx +++ b/frontend/src/app/invite/[token]/_components/InvitationDetailsCard/index.stories.tsx @@ -42,4 +42,4 @@ export const Revoked: Story = { args: { invitation: createPublicMembershipInvitation({ status: "revoked" }), }, -} \ No newline at end of file +} diff --git a/frontend/src/app/invite/[token]/_components/InvitationDetailsCard/index.test.tsx b/frontend/src/app/invite/[token]/_components/InvitationDetailsCard/index.test.tsx index fd0345ce..f37db021 100644 --- a/frontend/src/app/invite/[token]/_components/InvitationDetailsCard/index.test.tsx +++ b/frontend/src/app/invite/[token]/_components/InvitationDetailsCard/index.test.tsx @@ -58,4 +58,4 @@ describe("InvitationDetailsCard", () => { expect(screen.getByText("This invitation has been revoked.")).toBeInTheDocument() }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/invite/[token]/_components/InvitationDetailsCard/index.tsx b/frontend/src/app/invite/[token]/_components/InvitationDetailsCard/index.tsx index 7677aff0..52f2309d 100644 --- a/frontend/src/app/invite/[token]/_components/InvitationDetailsCard/index.tsx +++ b/frontend/src/app/invite/[token]/_components/InvitationDetailsCard/index.tsx @@ -76,4 +76,4 @@ export function InvitationDetailsCard({ ) -} \ No newline at end of file +} diff --git a/frontend/src/app/invite/[token]/_components/InvitePageContent/index.stories.tsx b/frontend/src/app/invite/[token]/_components/InvitePageContent/index.stories.tsx index fef0bd24..e655fc3e 100644 --- a/frontend/src/app/invite/[token]/_components/InvitePageContent/index.stories.tsx +++ b/frontend/src/app/invite/[token]/_components/InvitePageContent/index.stories.tsx @@ -37,4 +37,4 @@ export const FetchError: Story = { invitation: null, invitationError: "Unable to load invitation.", }, -} \ No newline at end of file +} diff --git a/frontend/src/app/invite/[token]/_components/InvitePageContent/index.test.tsx b/frontend/src/app/invite/[token]/_components/InvitePageContent/index.test.tsx index 61ea6d2d..451d0b64 100644 --- a/frontend/src/app/invite/[token]/_components/InvitePageContent/index.test.tsx +++ b/frontend/src/app/invite/[token]/_components/InvitePageContent/index.test.tsx @@ -36,4 +36,4 @@ describe("InvitePageContent", () => { expect(screen.getByText("Unable to load invitation.")).toBeInTheDocument() expect(screen.queryByText("Invited Project")).not.toBeInTheDocument() }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/invite/[token]/_components/InvitePageContent/index.tsx b/frontend/src/app/invite/[token]/_components/InvitePageContent/index.tsx index 8e8f6efc..5b9e421d 100644 --- a/frontend/src/app/invite/[token]/_components/InvitePageContent/index.tsx +++ b/frontend/src/app/invite/[token]/_components/InvitePageContent/index.tsx @@ -62,4 +62,4 @@ export function InvitePageContent({ ) -} \ No newline at end of file +} diff --git a/frontend/src/app/invite/[token]/page.stories.tsx b/frontend/src/app/invite/[token]/page.stories.tsx index aa26ddcf..56e3f440 100644 --- a/frontend/src/app/invite/[token]/page.stories.tsx +++ b/frontend/src/app/invite/[token]/page.stories.tsx @@ -61,4 +61,4 @@ function InvitePagePreview({ token="invite-token" /> ) -} \ No newline at end of file +} diff --git a/frontend/src/app/invite/[token]/page.test.tsx b/frontend/src/app/invite/[token]/page.test.tsx index 24dd06a4..077f23aa 100644 --- a/frontend/src/app/invite/[token]/page.test.tsx +++ b/frontend/src/app/invite/[token]/page.test.tsx @@ -73,4 +73,4 @@ describe("InvitePage", () => { expect(screen.getByText("Invitation lookup failed")).toBeInTheDocument() expect(screen.queryByText("Invited Project")).not.toBeInTheDocument() }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/login/_components/LoginForm/index.stories.tsx b/frontend/src/app/login/_components/LoginForm/index.stories.tsx index 73dcb20e..40b25534 100644 --- a/frontend/src/app/login/_components/LoginForm/index.stories.tsx +++ b/frontend/src/app/login/_components/LoginForm/index.stories.tsx @@ -20,4 +20,4 @@ export default meta type Story = StoryObj -export const Default: Story = {} \ No newline at end of file +export const Default: Story = {} diff --git a/frontend/src/app/login/_components/LoginForm/index.test.tsx b/frontend/src/app/login/_components/LoginForm/index.test.tsx index fc9b731a..c4f20d92 100644 --- a/frontend/src/app/login/_components/LoginForm/index.test.tsx +++ b/frontend/src/app/login/_components/LoginForm/index.test.tsx @@ -108,4 +108,4 @@ describe("LoginForm", () => { expect(await screen.findByText("Unable to sign in right now.")).toBeInTheDocument() expect(pushMock).not.toHaveBeenCalled() }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/login/_components/LoginForm/index.tsx b/frontend/src/app/login/_components/LoginForm/index.tsx index d1678b8f..0987be66 100644 --- a/frontend/src/app/login/_components/LoginForm/index.tsx +++ b/frontend/src/app/login/_components/LoginForm/index.tsx @@ -109,4 +109,4 @@ export default function LoginForm({ callbackUrl }: LoginFormProps) { ) -} \ No newline at end of file +} diff --git a/frontend/src/app/login/_components/LoginPageContent/index.stories.tsx b/frontend/src/app/login/_components/LoginPageContent/index.stories.tsx index 08d2b0e1..9a60ed0e 100644 --- a/frontend/src/app/login/_components/LoginPageContent/index.stories.tsx +++ b/frontend/src/app/login/_components/LoginPageContent/index.stories.tsx @@ -20,4 +20,4 @@ export default meta type Story = StoryObj -export const Default: Story = {} \ No newline at end of file +export const Default: Story = {} diff --git a/frontend/src/app/login/_components/LoginPageContent/index.test.tsx b/frontend/src/app/login/_components/LoginPageContent/index.test.tsx index 9b317e12..2f6fbc5d 100644 --- a/frontend/src/app/login/_components/LoginPageContent/index.test.tsx +++ b/frontend/src/app/login/_components/LoginPageContent/index.test.tsx @@ -38,4 +38,4 @@ describe("LoginPageContent", () => { "/admin/", ) }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/login/_components/LoginPageContent/index.tsx b/frontend/src/app/login/_components/LoginPageContent/index.tsx index 878c05e5..589571ad 100644 --- a/frontend/src/app/login/_components/LoginPageContent/index.tsx +++ b/frontend/src/app/login/_components/LoginPageContent/index.tsx @@ -48,4 +48,4 @@ export default function LoginPageContent({ callbackUrl }: LoginPageContentProps) ) -} \ No newline at end of file +} diff --git a/frontend/src/app/login/_components/SocialAuthButtons/index.stories.tsx b/frontend/src/app/login/_components/SocialAuthButtons/index.stories.tsx index 13cd1521..a069ac25 100644 --- a/frontend/src/app/login/_components/SocialAuthButtons/index.stories.tsx +++ b/frontend/src/app/login/_components/SocialAuthButtons/index.stories.tsx @@ -20,4 +20,4 @@ export default meta type Story = StoryObj -export const Default: Story = {} \ No newline at end of file +export const Default: Story = {} diff --git a/frontend/src/app/login/_components/SocialAuthButtons/index.test.tsx b/frontend/src/app/login/_components/SocialAuthButtons/index.test.tsx index ffd9fe0a..88932099 100644 --- a/frontend/src/app/login/_components/SocialAuthButtons/index.test.tsx +++ b/frontend/src/app/login/_components/SocialAuthButtons/index.test.tsx @@ -29,4 +29,4 @@ describe("SocialAuthButtons", () => { callbackUrl: "/content/4?project=2", }) }) -}) \ No newline at end of file +}) diff --git a/frontend/src/app/login/_components/SocialAuthButtons/index.tsx b/frontend/src/app/login/_components/SocialAuthButtons/index.tsx index 9f1bb182..6daa3ee8 100644 --- a/frontend/src/app/login/_components/SocialAuthButtons/index.tsx +++ b/frontend/src/app/login/_components/SocialAuthButtons/index.tsx @@ -59,4 +59,4 @@ export default function SocialAuthButtons({ callbackUrl }: SocialAuthButtonsProp ) -} \ No newline at end of file +} diff --git a/frontend/src/app/login/page.stories.tsx b/frontend/src/app/login/page.stories.tsx index 59cac9c6..eb76eb1c 100644 --- a/frontend/src/app/login/page.stories.tsx +++ b/frontend/src/app/login/page.stories.tsx @@ -33,4 +33,4 @@ export const InviteCallback: Story = { function LoginPagePreview({ callbackUrl = "/" }: LoginPagePreviewProps) { return -} \ No newline at end of file +} diff --git a/frontend/src/app/messages/_components/MessagesPageContent/index.tsx b/frontend/src/app/messages/_components/MessagesPageContent/index.tsx new file mode 100644 index 00000000..61c193eb --- /dev/null +++ b/frontend/src/app/messages/_components/MessagesPageContent/index.tsx @@ -0,0 +1,54 @@ +import { MessagesWorkspace } from "@/app/messages/_components/MessagesWorkspace" +import { AppShell } from "@/components/layout/AppShell" +import type { + DirectMessage, + MessageThread, + Project, + ProjectMembership, +} from "@/lib/types" + +type MessagesPageContentProps = { + availableRecipients: ProjectMembership[] + projects: Project[] + selectedProject: Project + currentUserId: number + initialThreads: MessageThread[] + initialRecipientUserId: number | null + initialSelectedThreadId: number | null + initialMessages: DirectMessage[] +} + +function getApiBaseUrl() { + return process.env.NEWSLETTER_API_BASE_URL ?? "http://127.0.0.1:8080" +} + +/** Render the editor-facing direct-message workspace for one selected project shell. */ +export function MessagesPageContent({ + availableRecipients, + projects, + selectedProject, + currentUserId, + initialThreads, + initialRecipientUserId, + initialSelectedThreadId, + initialMessages, +}: MessagesPageContentProps) { + return ( + + + + ) +} \ No newline at end of file diff --git a/frontend/src/app/messages/_components/MessagesWorkspace/index.test.tsx b/frontend/src/app/messages/_components/MessagesWorkspace/index.test.tsx new file mode 100644 index 00000000..362d29c8 --- /dev/null +++ b/frontend/src/app/messages/_components/MessagesWorkspace/index.test.tsx @@ -0,0 +1,257 @@ +import { act, render, screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { MessagesWorkspace } from "@/app/messages/_components/MessagesWorkspace" +import { + fetchMessageThreads, + fetchThreadMessages, + markMessageThreadRead, + openMessageThread, + sendThreadMessage, +} from "@/lib/messages" +import { QueryProvider } from "@/providers/QueryProvider" + +const sockets: FakeWebSocket[] = [] + +class FakeWebSocket { + onmessage: ((event: { data: string }) => void) | null = null + close = vi.fn() + + constructor(public readonly url: string) { + sockets.push(this) + } + + emit(payload: unknown) { + this.onmessage?.({ data: JSON.stringify(payload) }) + } +} + +vi.mock("@/lib/messages", () => ({ + MESSAGE_THREADS_QUERY_KEY: ["message-threads"], + fetchMessageThreads: vi.fn(), + fetchThreadMessages: vi.fn(), + markMessageThreadRead: vi.fn(), + openMessageThread: vi.fn(), + sendThreadMessage: vi.fn(), + threadMessagesQueryKey: (threadId: number) => ["thread-messages", threadId], +})) + +function renderWorkspace(overrides: Partial> = {}) { + return render( + + + , + ) +} + +describe("MessagesWorkspace", () => { + beforeEach(() => { + vi.clearAllMocks() + sockets.length = 0 + vi.stubGlobal("WebSocket", FakeWebSocket) + vi.mocked(fetchMessageThreads).mockResolvedValue([ + { + id: 7, + counterpart: { + id: 8, + username: "maya", + display_name: "Maya", + avatar_url: null, + avatar_thumbnail_url: null, + }, + has_unread: false, + last_message_preview: "Can you review this draft?", + last_message_at: "2026-05-03T10:00:00Z", + last_read_at: "2026-05-03T10:00:00Z", + created_at: "2026-05-01T10:00:00Z", + }, + ]) + vi.mocked(fetchThreadMessages).mockResolvedValue([ + { + id: 11, + thread: 7, + sender: 8, + sender_username: "maya", + sender_display_name: "Maya", + body: "Can you review this draft?", + created_at: "2026-05-03T10:00:00Z", + edited_at: null, + }, + ]) + vi.mocked(markMessageThreadRead).mockResolvedValue({ + thread_id: 7, + last_read_at: "2026-05-03T10:00:00Z", + }) + vi.mocked(openMessageThread).mockResolvedValue({ + id: 9, + counterpart: { + id: 8, + username: "maya", + display_name: "Maya", + avatar_url: null, + avatar_thumbnail_url: null, + }, + has_unread: false, + last_message_preview: "Starting thread", + last_message_at: "2026-05-03T10:05:00Z", + last_read_at: "2026-05-03T10:05:00Z", + created_at: "2026-05-03T10:05:00Z", + }) + vi.mocked(sendThreadMessage).mockImplementation(async (_threadId, body) => ({ + id: 12, + thread: 7, + sender: 4, + sender_username: "editor", + sender_display_name: "Editor", + body, + created_at: "2026-05-03T10:05:00Z", + edited_at: null, + })) + }) + + it("applies incoming websocket messages to the selected thread", async () => { + renderWorkspace() + + await waitFor(() => { + expect(sockets).toHaveLength(1) + }) + + act(() => { + sockets[0].emit({ + type: "message.created", + message: { + id: 13, + thread: 7, + sender: 8, + sender_username: "maya", + sender_display_name: "Maya", + body: "I also added notes inline.", + created_at: "2026-05-03T10:06:00Z", + edited_at: null, + }, + }) + }) + + await waitFor(() => { + expect(screen.getAllByText("I also added notes inline.")).toHaveLength(2) + }) + }) + + it("sends a new reply through the internal route helper", async () => { + const user = userEvent.setup() + + renderWorkspace() + + await user.type(screen.getByRole("textbox", { name: "Message body" }), "On it.") + await user.click(screen.getByRole("button", { name: "Send message" })) + + await waitFor(() => { + expect(sendThreadMessage).toHaveBeenCalledWith(7, "On it.") + }) + expect(screen.getAllByText("On it.")).toHaveLength(2) + }) + + it("starts a new conversation with a selected collaborator", async () => { + const user = userEvent.setup() + + vi.mocked(fetchThreadMessages).mockImplementation(async (threadId) => { + if (threadId === 9) { + return [ + { + id: 20, + thread: 9, + sender: 4, + sender_username: "editor", + sender_display_name: "Editor", + body: "Starting thread", + created_at: "2026-05-03T10:05:00Z", + edited_at: null, + }, + ] + } + + return [ + { + id: 11, + thread: 7, + sender: 8, + sender_username: "maya", + sender_display_name: "Maya", + body: "Can you review this draft?", + created_at: "2026-05-03T10:00:00Z", + edited_at: null, + }, + ] + }) + + renderWorkspace({ + initialMessages: [], + initialRecipientUserId: 8, + initialSelectedThreadId: null, + initialThreads: [], + }) + + await user.type( + screen.getByRole("textbox", { name: "Opening message" }), + "Starting thread", + ) + await user.click(screen.getByRole("button", { name: "Start conversation" })) + + await waitFor(() => { + expect(openMessageThread).toHaveBeenCalledWith({ + recipient_user_id: 8, + opening_message: "Starting thread", + }) + }) + expect(screen.getAllByText("Starting thread")).toHaveLength(2) + }) +}) \ No newline at end of file diff --git a/frontend/src/app/messages/_components/MessagesWorkspace/index.tsx b/frontend/src/app/messages/_components/MessagesWorkspace/index.tsx new file mode 100644 index 00000000..e2b59d5f --- /dev/null +++ b/frontend/src/app/messages/_components/MessagesWorkspace/index.tsx @@ -0,0 +1,510 @@ +"use client" + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { useEffect, useMemo, useState } from "react" + +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Textarea } from "@/components/ui/textarea" +import { + fetchMessageThreads, + fetchThreadMessages, + markMessageThreadRead, + MESSAGE_THREADS_QUERY_KEY, + openMessageThread, + sendThreadMessage, + threadMessagesQueryKey, +} from "@/lib/messages" +import type { DirectMessage, MessageThread, ProjectMembership } from "@/lib/types" + +type MessagesWorkspaceProps = { + apiBaseUrl: string + availableRecipients: ProjectMembership[] + currentUserId: number + initialThreads: MessageThread[] + initialRecipientUserId: number | null + initialSelectedThreadId: number | null + initialMessages: DirectMessage[] +} + +function buildMessagesWebsocketUrl(apiBaseUrl: string, threadId: number) { + const websocketUrl = new URL(`/ws/messages/${threadId}/`, apiBaseUrl) + websocketUrl.protocol = websocketUrl.protocol === "https:" ? "wss:" : "ws:" + return websocketUrl.toString() +} + +function formatTimestamp(value: string | null) { + if (!value) { + return "Waiting for the first message" + } + + return new Intl.DateTimeFormat("en", { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(value)) +} + +function upsertMessage( + currentMessages: DirectMessage[], + incomingMessage: DirectMessage, +) { + const remainingMessages = currentMessages.filter( + (message) => message.id !== incomingMessage.id, + ) + + return [...remainingMessages, incomingMessage].sort((left, right) => { + const leftTime = new Date(left.created_at).getTime() + const rightTime = new Date(right.created_at).getTime() + + if (leftTime === rightTime) { + return left.id - right.id + } + + return leftTime - rightTime + }) +} + +function updateThreadSummary( + currentThreads: MessageThread[], + incomingMessage: DirectMessage, +) { + const matchingThread = currentThreads.find( + (thread) => thread.id === incomingMessage.thread, + ) + + if (!matchingThread) { + return currentThreads + } + + const updatedThread: MessageThread = { + ...matchingThread, + has_unread: false, + last_message_at: incomingMessage.created_at, + last_message_preview: incomingMessage.body.slice(0, 140), + last_read_at: incomingMessage.created_at, + } + + return [ + updatedThread, + ...currentThreads.filter((thread) => thread.id !== incomingMessage.thread), + ] +} + +function upsertThread( + currentThreads: MessageThread[], + incomingThread: MessageThread, +) { + return [ + incomingThread, + ...currentThreads.filter((thread) => thread.id !== incomingThread.id), + ] +} + +/** Render the interactive thread list, live message history, and composer. */ +export function MessagesWorkspace({ + apiBaseUrl, + availableRecipients, + currentUserId, + initialThreads, + initialRecipientUserId, + initialSelectedThreadId, + initialMessages, +}: MessagesWorkspaceProps) { + const queryClient = useQueryClient() + const [selectedThreadId, setSelectedThreadId] = useState( + initialSelectedThreadId, + ) + const [selectedRecipientId, setSelectedRecipientId] = useState( + initialRecipientUserId ?? availableRecipients[0]?.user ?? null, + ) + const [openingMessage, setOpeningMessage] = useState("") + const [draftBody, setDraftBody] = useState("") + + const threadsQuery = useQuery({ + queryKey: MESSAGE_THREADS_QUERY_KEY, + queryFn: fetchMessageThreads, + initialData: initialThreads, + }) + const threads = useMemo(() => threadsQuery.data ?? [], [threadsQuery.data]) + const activeRecipientId = useMemo(() => { + if (availableRecipients.length === 0) { + return null + } + + if ( + selectedRecipientId !== null && + availableRecipients.some((recipient) => recipient.user === selectedRecipientId) + ) { + return selectedRecipientId + } + + if ( + initialRecipientUserId !== null && + availableRecipients.some((recipient) => recipient.user === initialRecipientUserId) + ) { + return initialRecipientUserId + } + + return availableRecipients[0].user + }, [availableRecipients, initialRecipientUserId, selectedRecipientId]) + const activeThreadId = useMemo(() => { + if (threads.length === 0) { + return null + } + + if (selectedThreadId !== null && threads.some((thread) => thread.id === selectedThreadId)) { + return selectedThreadId + } + + return threads[0].id + }, [selectedThreadId, threads]) + const selectedThread = threads.find((thread) => thread.id === activeThreadId) ?? null + + const messagesQuery = useQuery({ + queryKey: threadMessagesQueryKey(activeThreadId ?? 0), + queryFn: () => fetchThreadMessages(activeThreadId ?? 0), + enabled: activeThreadId !== null, + initialData: + activeThreadId === initialSelectedThreadId ? initialMessages : undefined, + }) + + const markReadMutation = useMutation({ + mutationFn: markMessageThreadRead, + onSuccess: (payload) => { + queryClient.setQueryData( + MESSAGE_THREADS_QUERY_KEY, + (currentThreads = []) => + currentThreads.map((thread) => + thread.id === payload.thread_id + ? { + ...thread, + has_unread: false, + last_read_at: payload.last_read_at, + } + : thread, + ), + ) + }, + }) + + const sendMessageMutation = useMutation({ + mutationFn: ({ body, threadId }: { body: string; threadId: number }) => + sendThreadMessage(threadId, body), + onSuccess: (message) => { + setDraftBody("") + queryClient.setQueryData( + threadMessagesQueryKey(message.thread), + (currentMessages = []) => upsertMessage(currentMessages, message), + ) + queryClient.setQueryData( + MESSAGE_THREADS_QUERY_KEY, + (currentThreads = []) => updateThreadSummary(currentThreads, message), + ) + }, + }) + + const openThreadMutation = useMutation({ + mutationFn: ({ + openingMessage, + recipientUserId, + }: { + openingMessage: string + recipientUserId: number + }) => + openMessageThread({ + recipient_user_id: recipientUserId, + opening_message: openingMessage || undefined, + }), + onSuccess: (thread) => { + setOpeningMessage("") + queryClient.setQueryData( + MESSAGE_THREADS_QUERY_KEY, + (currentThreads = []) => upsertThread(currentThreads, thread), + ) + }, + }) + + useEffect(() => { + if ( + activeThreadId === null || + !selectedThread?.has_unread || + !messagesQuery.isSuccess + ) { + return + } + + void markReadMutation.mutateAsync(activeThreadId) + }, [activeThreadId, messagesQuery.isSuccess, markReadMutation, selectedThread?.has_unread]) + + useEffect(() => { + if (activeThreadId === null) { + return undefined + } + + const socket = new WebSocket(buildMessagesWebsocketUrl(apiBaseUrl, activeThreadId)) + socket.onmessage = (event) => { + try { + const payload = JSON.parse(event.data) as { + type?: string + message?: DirectMessage + } + + if (payload.type !== "message.created" || payload.message === undefined) { + return + } + + queryClient.setQueryData( + threadMessagesQueryKey(activeThreadId), + (currentMessages = []) => + upsertMessage(currentMessages, payload.message as DirectMessage), + ) + queryClient.setQueryData( + MESSAGE_THREADS_QUERY_KEY, + (currentThreads = []) => + updateThreadSummary(currentThreads, payload.message as DirectMessage), + ) + + if ((payload.message as DirectMessage).sender !== currentUserId) { + void markReadMutation.mutateAsync(activeThreadId) + } + } catch { + // Ignore malformed websocket frames and keep the thread usable. + } + } + + return () => { + socket.close() + } + }, [activeThreadId, apiBaseUrl, currentUserId, markReadMutation, queryClient]) + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + if (activeThreadId === null || draftBody.trim().length === 0) { + return + } + + await sendMessageMutation.mutateAsync({ + body: draftBody.trim(), + threadId: activeThreadId, + }) + } + + async function handleStartConversation(event: React.FormEvent) { + event.preventDefault() + + if (activeRecipientId === null) { + return + } + + const thread = await openThreadMutation.mutateAsync({ + openingMessage: openingMessage.trim(), + recipientUserId: activeRecipientId, + }) + + await queryClient.fetchQuery({ + queryKey: threadMessagesQueryKey(thread.id), + queryFn: () => fetchThreadMessages(thread.id), + }) + setSelectedThreadId(thread.id) + } + + const canStartConversation = activeRecipientId !== null + + return ( +
+ + + Threads + + +
void handleStartConversation(event)}> +
+

Start a conversation

+

+ Pick a collaborator from this project and optionally send the opening message immediately. +

+
+ + + +