diff --git a/src/govoplan_notifications/backend/db/models.py b/src/govoplan_notifications/backend/db/models.py index 800bc30..82ad4b9 100644 --- a/src/govoplan_notifications/backend/db/models.py +++ b/src/govoplan_notifications/backend/db/models.py @@ -19,6 +19,14 @@ class NotificationMessage(Base, TimestampMixin): __table_args__ = ( Index("ix_notification_messages_tenant_status", "tenant_id", "status"), Index("ix_notification_messages_tenant_recipient", "tenant_id", "recipient_type", "recipient_id"), + Index( + "ix_notification_messages_summary", + "tenant_id", + "recipient_id", + "deleted_at", + "status", + "read_at", + ), Index("ix_notification_messages_source", "tenant_id", "source_module", "source_resource_type", "source_resource_id"), Index("ix_notification_messages_due", "tenant_id", "status", "not_before_at"), ) diff --git a/src/govoplan_notifications/backend/manifest.py b/src/govoplan_notifications/backend/manifest.py index b9f736a..fafaf80 100644 --- a/src/govoplan_notifications/backend/manifest.py +++ b/src/govoplan_notifications/backend/manifest.py @@ -104,6 +104,13 @@ manifest = ModuleManifest( ), ), view_surfaces=( + ViewSurface( + id="notifications.widget.summary", + module_id=MODULE_ID, + kind="section", + label="Notification summary widget", + order=30, + ), ViewSurface( id="notifications.settings.preferences", module_id=MODULE_ID, diff --git a/src/govoplan_notifications/backend/migrations/versions/7a8b9c0d1e2f_v020_notification_summary_index.py b/src/govoplan_notifications/backend/migrations/versions/7a8b9c0d1e2f_v020_notification_summary_index.py new file mode 100644 index 0000000..d1a759b --- /dev/null +++ b/src/govoplan_notifications/backend/migrations/versions/7a8b9c0d1e2f_v020_notification_summary_index.py @@ -0,0 +1,29 @@ +"""Add notification summary covering index. + +Revision ID: 7a8b9c0d1e2f +Revises: 6f7a8b9c0d1e +""" + +from alembic import op + + +revision = "7a8b9c0d1e2f" +down_revision = "6f7a8b9c0d1e" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_index( + "ix_notification_messages_summary", + "notification_messages", + ["tenant_id", "recipient_id", "deleted_at", "status", "read_at"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + "ix_notification_messages_summary", + table_name="notification_messages", + ) diff --git a/src/govoplan_notifications/backend/service.py b/src/govoplan_notifications/backend/service.py index 0c5e6d7..5f73440 100644 --- a/src/govoplan_notifications/backend/service.py +++ b/src/govoplan_notifications/backend/service.py @@ -6,7 +6,7 @@ from email.message import EmailMessage from pathlib import Path from typing import Any -from sqlalchemy import event, or_ +from sqlalchemy import and_, case, event, func, or_ from sqlalchemy.orm import Session from govoplan_core.core.notifications import NotificationDispatchRequest @@ -200,21 +200,75 @@ def notification_summary( user_id: str | None = None, recipient_ids: tuple[str, ...] | None = None, ) -> dict[str, int | bool]: - base_query = session.query(NotificationMessage).filter( + filters = [ NotificationMessage.tenant_id == tenant_id, NotificationMessage.deleted_at.is_(None), - ) + ] if recipient_ids is not None: - base_query = base_query.filter(NotificationMessage.recipient_id.in_(recipient_ids)) - active_query = base_query.filter(NotificationMessage.status.notin_(["cancelled", "skipped"])) + filters.append(NotificationMessage.recipient_id.in_(recipient_ids)) + active = NotificationMessage.status.notin_(["cancelled", "skipped"]) + total, unread, pending, failed = ( + session.query( + func.count(NotificationMessage.id), + func.coalesce( + func.sum( + case( + ( + and_( + active, + NotificationMessage.read_at.is_(None), + ), + 1, + ), + else_=0, + ) + ), + 0, + ), + func.coalesce( + func.sum( + case( + ( + and_( + active, + NotificationMessage.status.in_( + sorted(PENDING_STATUSES) + ), + ), + 1, + ), + else_=0, + ) + ), + 0, + ), + func.coalesce( + func.sum( + case( + ( + and_( + active, + NotificationMessage.status == "failed", + ), + 1, + ), + else_=0, + ) + ), + 0, + ), + ) + .filter(*filters) + .one() + ) show_unread_badge = True if user_id: show_unread_badge = get_notification_preferences(session, tenant_id=tenant_id, user_id=user_id).show_unread_badge return { - "total": base_query.count(), - "unread": active_query.filter(NotificationMessage.read_at.is_(None)).count(), - "pending": active_query.filter(NotificationMessage.status.in_(sorted(PENDING_STATUSES))).count(), - "failed": active_query.filter(NotificationMessage.status == "failed").count(), + "total": int(total or 0), + "unread": int(unread or 0), + "pending": int(pending or 0), + "failed": int(failed or 0), "show_unread_badge": show_unread_badge, } diff --git a/webui/package.json b/webui/package.json index c2786dc..345ec03 100644 --- a/webui/package.json +++ b/webui/package.json @@ -22,7 +22,7 @@ "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-router-dom": "^7.1.1", + "react-router-dom": ">=7.18.2 <8", "@vitejs/plugin-react": "^4.3.4", "typescript": "^5.7.2", "vite": "^6.0.6" diff --git a/webui/src/features/notifications/NotificationSettingsPanel.tsx b/webui/src/features/notifications/NotificationSettingsPanel.tsx index 020cd3c..d0a9a79 100644 --- a/webui/src/features/notifications/NotificationSettingsPanel.tsx +++ b/webui/src/features/notifications/NotificationSettingsPanel.tsx @@ -1,20 +1,41 @@ import { useEffect, useMemo, useState } from "react"; import { Mail, Save } from "lucide-react"; -import { Button, Card, DismissibleAlert, FormField, ToggleSwitch, type ApiSettings, type AuthInfo } from "@govoplan/core-webui"; +import { + Button, + Card, + DismissibleAlert, + FormField, + ReferenceMultiSelect, + ToggleSwitch, + isViewSurfaceVisible, + moduleViewSurfaceId, + platformModuleReferenceProvider, + useEffectiveView, + usePlatformLanguage, + usePlatformModules, + useViewSurfaces, + type ApiSettings, + type AuthInfo, + type ReferenceOption +} from "@govoplan/core-webui"; import { getNotificationPreferences, updateNotificationPreferences, type NotificationPreferences } from "../../api/notifications"; type Draft = Pick & { - muted_source_modules_text: string; + muted_source_modules: string[]; }; const DEFAULT_DRAFT: Draft = { show_unread_badge: true, email_enabled: false, email_digest_enabled: false, - muted_source_modules_text: "" + muted_source_modules: [] }; export default function NotificationSettingsPanel({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) { + const modules = usePlatformModules(); + const effectiveView = useEffectiveView(); + const viewSurfaces = useViewSurfaces(); + const { translateText } = usePlatformLanguage(); const [loaded, setLoaded] = useState(null); const [draft, setDraft] = useState(DEFAULT_DRAFT); const [loading, setLoading] = useState(true); @@ -28,9 +49,40 @@ export default function NotificationSettingsPanel({ settings, auth }: { settings draft.show_unread_badge !== loaded.show_unread_badge || draft.email_enabled !== loaded.email_enabled || draft.email_digest_enabled !== loaded.email_digest_enabled || - normalizeMutedModules(draft.muted_source_modules_text).join(",") !== loaded.muted_source_modules.join(",") + draft.muted_source_modules.join(",") !== loaded.muted_source_modules.join(",") ); }, [draft, loaded]); + const moduleOptions = useMemo( + () => modules.map((module) => { + const visible = isViewSurfaceVisible( + effectiveView, + moduleViewSurfaceId(module.id), + viewSurfaces + ); + return { + value: module.id, + label: translateText(module.label), + description: [ + module.id, + `version ${module.version}`, + visible ? null : "Hidden by the active view" + ].filter(Boolean).join(" ยท "), + kind: "module", + availability: visible ? "available" : "unavailable", + disabled: !visible, + sourceModule: "core", + provenance: { + version: module.version, + visibleInActiveView: visible + } + }; + }), + [effectiveView, modules, translateText, viewSurfaces] + ); + const moduleProvider = useMemo( + () => platformModuleReferenceProvider(settings, moduleOptions), + [moduleOptions, settings] + ); useEffect(() => { void loadPreferences(); @@ -46,7 +98,7 @@ export default function NotificationSettingsPanel({ settings, auth }: { settings show_unread_badge: preferences.show_unread_badge, email_enabled: preferences.email_enabled, email_digest_enabled: preferences.email_digest_enabled, - muted_source_modules_text: preferences.muted_source_modules.join(", ") + muted_source_modules: preferences.muted_source_modules }); } catch (error) { setMessageTone("warning"); @@ -64,14 +116,14 @@ export default function NotificationSettingsPanel({ settings, auth }: { settings show_unread_badge: draft.show_unread_badge, email_enabled: draft.email_enabled, email_digest_enabled: draft.email_digest_enabled, - muted_source_modules: normalizeMutedModules(draft.muted_source_modules_text) + muted_source_modules: draft.muted_source_modules }); setLoaded(next); setDraft({ show_unread_badge: next.show_unread_badge, email_enabled: next.email_enabled, email_digest_enabled: next.email_digest_enabled, - muted_source_modules_text: next.muted_source_modules.join(", ") + muted_source_modules: next.muted_source_modules }); window.dispatchEvent(new CustomEvent("govoplan:notifications-changed")); setMessageTone("success"); @@ -95,11 +147,16 @@ export default function NotificationSettingsPanel({ settings, auth }: { settings disabled={loading} onChange={(value) => setDraft((current) => ({ ...current, show_unread_badge: value }))} /> - - setDraft((current) => ({ ...current, muted_source_modules_text: event.target.value }))} - placeholder="calendar, campaign" + + + setDraft((current) => ({ ...current, muted_source_modules })) + } + provider={moduleProvider} + aria-label="Muted source modules" + placeholder="Add a source module" + emptyText="No visible modules match." disabled={loading} /> @@ -136,15 +193,3 @@ export default function NotificationSettingsPanel({ settings, auth }: { settings ); } - -function normalizeMutedModules(value: string): string[] { - const seen = new Set(); - return value - .split(",") - .map((item) => item.trim().toLowerCase()) - .filter((item) => { - if (!item || seen.has(item)) return false; - seen.add(item); - return true; - }); -} diff --git a/webui/src/features/notifications/NotificationSummaryWidget.tsx b/webui/src/features/notifications/NotificationSummaryWidget.tsx new file mode 100644 index 0000000..259b996 --- /dev/null +++ b/webui/src/features/notifications/NotificationSummaryWidget.tsx @@ -0,0 +1,73 @@ +import { Link } from "react-router-dom"; +import { + DismissibleAlert, + LoadingFrame, + MetricCard, + adminErrorMessage, + type ApiSettings, + type DashboardWidgetConfiguration, + useSharedNotificationSummary +} from "@govoplan/core-webui"; + +export default function NotificationSummaryWidget({ + settings, + refreshKey, + configuration, + showCenterLink +}: { + settings: ApiSettings; + refreshKey: number; + configuration: DashboardWidgetConfiguration; + showCenterLink: boolean; +}) { + const showDeliveryState = configuration.showDeliveryState !== false; + const state = useSharedNotificationSummary(settings, { + enabled: true, + scopeKey: "current-session", + intervalMs: 30_000, + refreshKey + }); + const summary = state.summary; + const error = state.error ? adminErrorMessage(state.error) : ""; + + return ( + + {error && ( + + {error} + + )} +
+ + {showDeliveryState && ( + + )} + {showDeliveryState && ( + + )} +
+ {showCenterLink && ( +
+ + Open notification center + +
+ )} +
+ ); +} diff --git a/webui/src/module.ts b/webui/src/module.ts index 3514187..a5b4fdb 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -1,5 +1,10 @@ import { createElement, lazy } from "react"; -import type { PlatformWebModule, SettingsSectionsUiCapability } from "@govoplan/core-webui"; +import type { + DashboardWidgetsUiCapability, + PlatformWebModule, + SettingsSectionsUiCapability +} from "@govoplan/core-webui"; +import NotificationSummaryWidget from "./features/notifications/NotificationSummaryWidget"; import { generatedTranslations } from "./i18n/generatedTranslations"; import "./styles/notifications.css"; @@ -8,6 +13,47 @@ const NotificationSettingsPanel = lazy(() => import("./features/notifications/No const notificationRead = ["notifications:notification:read"]; +const notificationDashboardWidgets: DashboardWidgetsUiCapability = { + widgets: [ + { + id: "notifications.summary", + surfaceId: "notifications.widget.summary", + title: "Notifications", + description: "Unread notifications and delivery state.", + moduleId: "notifications", + category: "Communication", + order: 60, + defaultSize: "medium", + supportedSizes: ["medium", "wide"], + anyOf: notificationRead, + refreshIntervalMs: 30_000, + defaultConfiguration: { + showDeliveryState: true + }, + configurationFields: [ + { + id: "showDeliveryState", + label: "Show delivery state", + description: "Include pending and failed delivery counts.", + kind: "boolean" + } + ], + render: ({ settings, effectiveView, refreshKey, configuration }) => + createElement(NotificationSummaryWidget, { + settings, + refreshKey, + configuration, + showCenterLink: ( + !effectiveView?.activeViewId + || effectiveView.visibleSurfaceIds.includes( + "notifications.route.notifications" + ) + ) + }) + } + ] +}; + const notificationSettingsSections: SettingsSectionsUiCapability = { sections: [ { @@ -30,6 +76,13 @@ export const notificationsModule: PlatformWebModule = { optionalDependencies: ["mail", "tasks", "portal", "workflow", "calendar", "scheduling"], translations: generatedTranslations, viewSurfaces: [ + { + id: "notifications.widget.summary", + moduleId: "notifications", + kind: "section", + label: "Notification summary widget", + order: 30 + }, { id: "notifications.settings.preferences", moduleId: "notifications", @@ -42,7 +95,8 @@ export const notificationsModule: PlatformWebModule = { { path: "/notifications", anyOf: notificationRead, order: 59, render: ({ settings, auth }) => createElement(NotificationCenterPage, { settings, auth }) } ], uiCapabilities: { - "settings.sections": notificationSettingsSections + "settings.sections": notificationSettingsSections, + "dashboard.widgets": notificationDashboardWidgets } }; diff --git a/webui/src/styles/notifications.css b/webui/src/styles/notifications.css index 4e88589..62ccc6a 100644 --- a/webui/src/styles/notifications.css +++ b/webui/src/styles/notifications.css @@ -13,6 +13,12 @@ box-sizing: border-box; } +.notifications-widget-actions { + display: flex; + justify-content: flex-end; + margin-top: 14px; +} + .notifications-shell { height: 100%; min-height: 0;