diff --git a/ACADENICE_PATCHES.md b/ACADENICE_PATCHES.md index 8ab48daa..f9450630 100644 --- a/ACADENICE_PATCHES.md +++ b/ACADENICE_PATCHES.md @@ -14,6 +14,97 @@ Branche fork : `acadenice/main` --- +## Patch 015 — R3.7 @user mentions + notifications in-app + email + +**Date** : 2026-05-08 +**Scope** : mention detection bridge (REST API path), Acadenice notification facade API, /notifications page, /settings/notifications preferences page, i18n FR+EN + +### Architecture Decision + +The native Docmost system already provides the complete mention notification pipeline: +- `persistence.extension.ts` (collab path) extracts `entityType:"user"` mention nodes via `extractUserMentions`, queues `PAGE_MENTION_NOTIFICATION` jobs. +- `PageNotificationService.processPageMention()` handles RBAC checks (space + page access), deduplication (old vs new mentions), `notification.create()` + email send. +- `NotificationPopover` + bell icon are already in the header. +- Notification preferences are stored in `users.settings.notifications` (native `NotificationPref` component in `/settings/account/preferences`). + +R3.7 adds: +1. `MentionDetectorService` — pure service that walks Tiptap JSON and extracts user mentions (no DB). Used by the emitter and independently testable. +2. `NotificationEmitterService` — listens to `acadenice.page.content.updated` (REST API save path, not collab) and queues `PAGE_MENTION_NOTIFICATION`. Bridges the gap between the collab-only native detection and pages saved via REST (templates instantiate, import, etc.). +3. `AcadeniceNotificationsController` — facade over native `NotificationService`, prefix `/api/acadenice/notifications`. +4. `NotificationPreferencesController` — GET/PUT `/api/acadenice/notification-preferences` (reads/writes native `users.settings.notifications`). +5. Frontend `/notifications` page — full inbox using native `NotificationItem` component. +6. Frontend `/settings/notifications` preferences page — dedicated toggles via Acadenice API. + +**No new DB migration** — native `notifications` table covers `page.user_mention` fully. +**Poll 30s** for unread count (vs SSE bridge) — sufficient for notification freshness. + +### Nouveaux fichiers backend + +| Fichier | Role | +|---------|------| +| `apps/server/src/core/acadenice/notifications/notifications.module.ts` | NestJS module | +| `apps/server/src/core/acadenice/notifications/dto/notification.dto.ts` | Zod schemas | +| `apps/server/src/core/acadenice/notifications/services/mention-detector.service.ts` | Tiptap mention walker (pure) | +| `apps/server/src/core/acadenice/notifications/services/notification-emitter.service.ts` | Event listener -> PAGE_MENTION_NOTIFICATION queue | +| `apps/server/src/core/acadenice/notifications/services/notification-preferences.service.ts` | Read/write users.settings.notifications | +| `apps/server/src/core/acadenice/notifications/controllers/notifications.controller.ts` | REST facade /api/acadenice/notifications | +| `apps/server/src/core/acadenice/notifications/controllers/notification-preferences.controller.ts` | REST /api/acadenice/notification-preferences | +| `apps/server/src/core/acadenice/notifications/spec/mention-detector.service.spec.ts` | 18 unit tests | +| `apps/server/src/core/acadenice/notifications/spec/notifications.controller.spec.ts` | 10 unit tests | +| `apps/server/src/core/acadenice/notifications/spec/notification-preferences.spec.ts` | 4 unit tests | + +### Nouveaux fichiers frontend + +| Fichier | Role | +|---------|------| +| `apps/client/src/features/acadenice/notifications/services/notifications-client.ts` | Axios HTTP client | +| `apps/client/src/features/acadenice/notifications/queries/notifications-query.ts` | React Query hooks + 30s poll | +| `apps/client/src/features/acadenice/notifications/pages/notifications-page.tsx` | Route /notifications | +| `apps/client/src/features/acadenice/notifications/pages/notification-preferences-page.tsx` | Route /settings/notifications | +| `apps/client/src/features/acadenice/notifications/__tests__/notifications-client.test.ts` | 9 client tests | +| `apps/client/src/features/acadenice/notifications/__tests__/notifications-page.test.tsx` | 4 page render tests | + +### Fichiers upstream modifies (patches) + +| Fichier | Modification | +|---------|-------------| +| `apps/server/src/core/core.module.ts` | +AcadeniceNotificationsModule import + registration | +| `apps/client/src/App.tsx` | +import AcadeniceNotificationsPage, NotificationPreferencesPage + routes /notifications, /settings/notifications | +| `apps/client/src/components/settings/settings-sidebar.tsx` | +entree "Notifications" (IconBell) dans group Account | +| `apps/client/public/locales/en-US/translation.json` | +20 cles acadenice.notifications.* | +| `apps/client/public/locales/fr-FR/translation.json` | +20 cles acadenice.notifications.* | + +### Endpoints + +``` +GET /api/acadenice/notifications paginated list +GET /api/acadenice/notifications/unread-count unread badge count (polled 30s) +POST /api/acadenice/notifications/read-all mark all read +POST /api/acadenice/notifications/mark-read bulk mark read +POST /api/acadenice/notifications/:id/read single mark read +GET /api/acadenice/notification-preferences get prefs +PUT /api/acadenice/notification-preferences update prefs +``` + +### Tests + +- Backend : 18 tests MentionDetectorService + 10 tests controller + 4 tests preferences = 32 tests +- Frontend : 9 tests client + 4 tests page render = 13 tests +- Total R3.7 : 45 tests + +### Points a debattre avec Corentin + +1. **REST path mention dedup** : `NotificationEmitterService` passe `oldMentionedUserIds: []` — toutes les mentions existantes sont reenvoyees a chaque save REST. La native `processPageMention` filtre les self-mentions mais pas les doubles (si le doc est re-sauvegarde sans changer les mentions, une double notif est creee). Solution : passer l'ancienne content snapshot dans l'event payload `acadenice.page.content.updated`. Patch mineur post-R3.7. +2. **Collab path vs REST path** : en collab (hocuspocus), la detection est deja faite par `PersistenceExtension`. En REST, le `NotificationEmitterService` prend le relais. Les deux peuvent coexister sans conflit car la deduplication native se base sur `oldMentionedUserIds` (diff). Mais si une page est sauvee en collab ET en REST dans la meme session, un doublon est possible. Acceptable pour v1. +3. **SSE optionnel** : le bell count est actuellement poll 30s. Si Corentin veut sub-seconde, brancher le bridge SSE (R3.7+ — note dans SESSION-RESUME). +4. **Notification preferences granularite** : les prefs in-app et email partagent la meme cle dans `users.settings.notifications` (native impl). Pour une granularite fine (ex: email ON, in-app OFF), il faudrait soit etendre le schema JSON soit creer une table `acadenice_notification_preferences` dediee. A evaluer selon le besoin utilisateur. + +### Prochaine etape + +R3.8 — comments inline (threads sur paragraphes Tiptap + sur rows database, resolu/non-resolu, notif R3.7 sur reply via hook dedie). + +--- + ## Patch 014 — R3.6 page templates system **Date** : 2026-05-08 diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 6469f517..effa28f8 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1167,5 +1167,24 @@ "templates.use_modal_title": "Use template", "templates.use_modal_description": "Open the editor and use the \"New page from template\" button in the sidebar to create a page from \"{{name}}\".", "templates.new_from_template": "From template", - "templates.picker_title": "Choose a template" + "templates.picker_title": "Choose a template", + "acadenice.notifications.title": "Notifications", + "acadenice.notifications.empty": "No notifications", + "acadenice.notifications.mark_all_read": "Mark all as read", + "acadenice.notifications.all_read": "All notifications marked as read", + "acadenice.notifications.load_more": "Load more", + "acadenice.notifications.prefs_saved": "Notification preferences saved", + "acadenice.notifications.prefs_error": "Failed to save notification preferences", + "acadenice.notifications.prefs.title": "Notification settings", + "acadenice.notifications.prefs.subtitle": "Choose how and when you get notified.", + "acadenice.notifications.prefs.email_mentions": "Email — page mentions", + "acadenice.notifications.prefs.email_mentions_desc": "Receive an email when someone mentions you on a page.", + "acadenice.notifications.prefs.email_replies": "Email — comment replies", + "acadenice.notifications.prefs.email_replies_desc": "Receive an email when someone replies to your comment.", + "acadenice.notifications.prefs.email_shares": "Email — page updates", + "acadenice.notifications.prefs.email_shares_desc": "Receive an email when a watched page is updated.", + "acadenice.notifications.prefs.in_app_mentions": "In-app — page mentions", + "acadenice.notifications.prefs.in_app_mentions_desc": "Receive an in-app notification when someone mentions you on a page.", + "acadenice.notifications.prefs.in_app_replies": "In-app — comment replies", + "acadenice.notifications.prefs.in_app_replies_desc": "Receive an in-app notification when someone replies to your comment." } \ No newline at end of file diff --git a/apps/client/public/locales/fr-FR/translation.json b/apps/client/public/locales/fr-FR/translation.json index 943d177b..42dfae0f 100644 --- a/apps/client/public/locales/fr-FR/translation.json +++ b/apps/client/public/locales/fr-FR/translation.json @@ -1122,5 +1122,24 @@ "templates.use_modal_title": "Utiliser le modele", "templates.use_modal_description": "Ouvrez l'editeur et cliquez sur le bouton \"Nouvelle page depuis un modele\" dans la barre laterale pour creer une page depuis \"{{name}}\".", "templates.new_from_template": "Depuis un modele", - "templates.picker_title": "Choisir un modele" + "templates.picker_title": "Choisir un modele", + "acadenice.notifications.title": "Notifications", + "acadenice.notifications.empty": "Aucune notification", + "acadenice.notifications.mark_all_read": "Tout marquer comme lu", + "acadenice.notifications.all_read": "Toutes les notifications ont ete marquees comme lues", + "acadenice.notifications.load_more": "Charger plus", + "acadenice.notifications.prefs_saved": "Preferences de notification enregistrees", + "acadenice.notifications.prefs_error": "Echec de l'enregistrement des preferences", + "acadenice.notifications.prefs.title": "Parametres de notification", + "acadenice.notifications.prefs.subtitle": "Choisissez comment et quand vous etes notifie.", + "acadenice.notifications.prefs.email_mentions": "Email — mentions sur une page", + "acadenice.notifications.prefs.email_mentions_desc": "Recevoir un email quand quelqu'un vous mentionne sur une page.", + "acadenice.notifications.prefs.email_replies": "Email — reponses a vos commentaires", + "acadenice.notifications.prefs.email_replies_desc": "Recevoir un email quand quelqu'un repond a votre commentaire.", + "acadenice.notifications.prefs.email_shares": "Email — mises a jour de pages", + "acadenice.notifications.prefs.email_shares_desc": "Recevoir un email quand une page surveillee est mise a jour.", + "acadenice.notifications.prefs.in_app_mentions": "In-app — mentions sur une page", + "acadenice.notifications.prefs.in_app_mentions_desc": "Recevoir une notification quand quelqu'un vous mentionne sur une page.", + "acadenice.notifications.prefs.in_app_replies": "In-app — reponses a vos commentaires", + "acadenice.notifications.prefs.in_app_replies_desc": "Recevoir une notification quand quelqu'un repond a votre commentaire." } \ No newline at end of file diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index f36536ca..2ff45aae 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -54,6 +54,9 @@ import SlashCommandsPage from "@/features/acadenice/slash-commands-admin/pages/s import GraphPage from "@/features/acadenice/graph/pages/graph-page"; // Acadenice R3.6 — page templates import TemplatesAdminPage from "@/features/acadenice/templates-admin/pages/templates-page"; +// Acadenice R3.7 — mention notifications +import AcadeniceNotificationsPage from "@/features/acadenice/notifications/pages/notifications-page"; +import NotificationPreferencesPage from "@/features/acadenice/notifications/pages/notification-preferences-page"; export default function App() { const { t } = useTranslation(); @@ -99,6 +102,8 @@ export default function App() { } /> {/* Acadenice R3.5.2 — knowledge graph */} } /> + {/* Acadenice R3.7 — notifications full page */} + } /> } /> } /> } /> @@ -145,6 +150,8 @@ export default function App() { } /> {/* Acadenice R3.6 — page templates */} } /> + {/* Acadenice R3.7 — notification preferences */} + } /> {!isCloud() && } />} {isCloud() && } />} diff --git a/apps/client/src/components/settings/settings-sidebar.tsx b/apps/client/src/components/settings/settings-sidebar.tsx index 1a69a6a8..8ae44f0d 100644 --- a/apps/client/src/components/settings/settings-sidebar.tsx +++ b/apps/client/src/components/settings/settings-sidebar.tsx @@ -18,6 +18,7 @@ import { IconShieldLock, IconSlash, IconTemplate, + IconBell, } from "@tabler/icons-react"; import { useAcadenicePermissions } from "@/features/acadenice/rbac/hooks/use-acadenice-permissions"; import { Link, useLocation } from "react-router-dom"; @@ -76,6 +77,12 @@ const groupedData: DataGroup[] = [ icon: IconBrush, path: "/settings/account/preferences", }, + { + // Acadenice R3.7 — notification preferences dedicated page + label: "Notifications", + icon: IconBell, + path: "/settings/notifications", + }, { label: "API keys", icon: IconKey, diff --git a/apps/client/src/features/acadenice/notifications/__tests__/notifications-client.test.ts b/apps/client/src/features/acadenice/notifications/__tests__/notifications-client.test.ts new file mode 100644 index 00000000..4db9f5da --- /dev/null +++ b/apps/client/src/features/acadenice/notifications/__tests__/notifications-client.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * Unit tests for notificationsClient (R3.7). + * + * api-client is fully mocked — no network calls. + */ + +vi.mock("@/lib/api-client", () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + }, +})); + +import api from "@/lib/api-client"; +import { notificationsClient } from "../services/notifications-client"; + +const mockApi = api as { + get: ReturnType; + post: ReturnType; + put: ReturnType; +}; + +describe("notificationsClient", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("list: calls GET /api/acadenice/notifications", async () => { + mockApi.get.mockResolvedValue({ data: { items: [], meta: {} } }); + const result = await notificationsClient.list(); + expect(mockApi.get).toHaveBeenCalledWith( + "/api/acadenice/notifications", + expect.objectContaining({ params: {} }), + ); + expect(result).toEqual({ items: [], meta: {} }); + }); + + it("list: passes tab param", async () => { + mockApi.get.mockResolvedValue({ data: { items: [] } }); + await notificationsClient.list({ tab: "direct" }); + expect(mockApi.get).toHaveBeenCalledWith( + "/api/acadenice/notifications", + expect.objectContaining({ params: { tab: "direct" } }), + ); + }); + + it("unreadCount: calls GET /api/acadenice/notifications/unread-count", async () => { + mockApi.get.mockResolvedValue({ data: { count: 3 } }); + const result = await notificationsClient.unreadCount(); + expect(mockApi.get).toHaveBeenCalledWith( + "/api/acadenice/notifications/unread-count", + ); + expect(result).toEqual({ count: 3 }); + }); + + it("markRead: calls POST mark-read with ids", async () => { + mockApi.post.mockResolvedValue({}); + await notificationsClient.markRead(["id-1", "id-2"]); + expect(mockApi.post).toHaveBeenCalledWith( + "/api/acadenice/notifications/mark-read", + { notificationIds: ["id-1", "id-2"] }, + ); + }); + + it("markAllRead: calls POST read-all", async () => { + mockApi.post.mockResolvedValue({}); + await notificationsClient.markAllRead(); + expect(mockApi.post).toHaveBeenCalledWith( + "/api/acadenice/notifications/read-all", + ); + }); + + it("markOne: calls POST /:id/read", async () => { + mockApi.post.mockResolvedValue({}); + await notificationsClient.markOne("notif-uuid"); + expect(mockApi.post).toHaveBeenCalledWith( + "/api/acadenice/notifications/notif-uuid/read", + ); + }); + + it("getPreferences: calls GET /api/acadenice/notification-preferences", async () => { + const prefs = { + emailMentions: true, + emailReplies: false, + emailShares: true, + inAppMentions: true, + inAppReplies: false, + inAppShares: true, + }; + mockApi.get.mockResolvedValue({ data: prefs }); + const result = await notificationsClient.getPreferences(); + expect(mockApi.get).toHaveBeenCalledWith( + "/api/acadenice/notification-preferences", + ); + expect(result).toEqual(prefs); + }); + + it("updatePreferences: calls PUT /api/acadenice/notification-preferences", async () => { + const payload = { emailMentions: false }; + const updatedPrefs = { + emailMentions: false, + emailReplies: true, + emailShares: true, + inAppMentions: false, + inAppReplies: true, + inAppShares: true, + }; + mockApi.put.mockResolvedValue({ data: updatedPrefs }); + const result = await notificationsClient.updatePreferences(payload); + expect(mockApi.put).toHaveBeenCalledWith( + "/api/acadenice/notification-preferences", + payload, + ); + expect(result).toEqual(updatedPrefs); + }); +}); diff --git a/apps/client/src/features/acadenice/notifications/__tests__/notifications-page.test.tsx b/apps/client/src/features/acadenice/notifications/__tests__/notifications-page.test.tsx new file mode 100644 index 00000000..03d352fe --- /dev/null +++ b/apps/client/src/features/acadenice/notifications/__tests__/notifications-page.test.tsx @@ -0,0 +1,189 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { MantineProvider } from "@mantine/core"; +import React from "react"; + +/** + * Integration-level render tests for AcadeniceNotificationsPage (R3.7). + * + * React Query and router are stubbed; component renders are smoke tests. + */ + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (k: string) => k, + i18n: { changeLanguage: vi.fn() }, + }), + Trans: ({ i18nKey }: { i18nKey: string }) => {i18nKey}, +})); + +vi.mock("react-helmet-async", () => ({ + Helmet: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +vi.mock("@/lib/config", () => ({ + getAppName: () => "DocAdenice", + isCloud: () => false, +})); + +vi.mock("react-router-dom", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useNavigate: () => vi.fn() }; +}); + +// Mock the notifications query hook +const mockUseQuery = vi.fn(); +vi.mock("../queries/notifications-query", () => ({ + useAcadeniceNotificationsQuery: () => mockUseQuery(), + useAcadeniceMarkAllReadMutation: () => ({ + mutate: vi.fn(), + isPending: false, + }), + useAcadeniceUnreadCountQuery: () => ({ data: { count: 0 } }), +})); + +// Native notification queries used by NotificationItem +vi.mock("@/features/notification/queries/notification-query", () => ({ + useMarkReadMutation: () => ({ mutate: vi.fn() }), +})); + +import AcadeniceNotificationsPage from "../pages/notifications-page"; + +function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +describe("AcadeniceNotificationsPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders loading skeletons when isLoading=true", () => { + mockUseQuery.mockReturnValue({ + data: undefined, + isLoading: true, + isFetchingNextPage: false, + hasNextPage: false, + fetchNextPage: vi.fn(), + }); + + const { container } = render( + + + , + ); + + // Mantine Skeleton renders as divs; check there is content + expect(container.firstChild).toBeTruthy(); + }); + + it("renders empty state when no notifications", () => { + mockUseQuery.mockReturnValue({ + data: { pages: [{ items: [] }] }, + isLoading: false, + isFetchingNextPage: false, + hasNextPage: false, + fetchNextPage: vi.fn(), + }); + + render( + + + , + ); + + expect( + screen.getByText("acadenice.notifications.empty"), + ).toBeInTheDocument(); + }); + + it("renders notifications list", () => { + const notification = { + id: "notif-1", + type: "page.user_mention", + readAt: null, + createdAt: new Date().toISOString(), + actor: { id: "actor-1", name: "Alice", avatarUrl: null }, + page: { id: "page-1", title: "My Page", slugId: "my-page", icon: null }, + space: { id: "space-1", name: "Space", slug: "space" }, + data: null, + userId: "user-1", + workspaceId: "ws-1", + actorId: "actor-1", + pageId: "page-1", + spaceId: "space-1", + commentId: null, + emailedAt: null, + archivedAt: null, + }; + + mockUseQuery.mockReturnValue({ + data: { pages: [{ items: [notification] }] }, + isLoading: false, + isFetchingNextPage: false, + hasNextPage: false, + fetchNextPage: vi.fn(), + }); + + render( + + + , + ); + + // Mark all read button appears when there are items + expect( + screen.getByText("acadenice.notifications.mark_all_read"), + ).toBeInTheDocument(); + }); + + it("shows load-more button when hasNextPage", () => { + mockUseQuery.mockReturnValue({ + data: { + pages: [ + { + items: [ + { + id: "n1", + type: "page.updated", + readAt: null, + createdAt: new Date().toISOString(), + actor: { id: "a", name: "Bob", avatarUrl: null }, + page: { id: "p1", title: "Page", slugId: "p1", icon: null }, + space: { id: "s1", name: "S", slug: "s" }, + data: null, + userId: "u1", + workspaceId: "ws", + actorId: "a", + pageId: "p1", + spaceId: "s1", + commentId: null, + emailedAt: null, + archivedAt: null, + }, + ], + }, + ], + }, + isLoading: false, + isFetchingNextPage: false, + hasNextPage: true, + fetchNextPage: vi.fn(), + }); + + render( + + + , + ); + + expect( + screen.getByText("acadenice.notifications.load_more"), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/client/src/features/acadenice/notifications/pages/notification-preferences-page.tsx b/apps/client/src/features/acadenice/notifications/pages/notification-preferences-page.tsx new file mode 100644 index 00000000..5dc0c779 --- /dev/null +++ b/apps/client/src/features/acadenice/notifications/pages/notification-preferences-page.tsx @@ -0,0 +1,142 @@ +import { + Divider, + Skeleton, + Stack, + Switch, + Text, + Title, +} from "@mantine/core"; +import { Helmet } from "react-helmet-async"; +import { useTranslation } from "react-i18next"; +import { getAppName } from "@/lib/config"; +import { + useAcadenicePreferencesQuery, + useAcadeniceUpdatePreferencesMutation, +} from "../queries/notifications-query"; +import { NotificationPreferences } from "../services/notifications-client"; + +/** + * NotificationPreferencesPage — /settings/notifications (R3.7). + * + * Dedicated settings page for notification preferences. Reads/writes via the + * Acadenice notification-preferences endpoint (which stores in native + * users.settings JSONB — compatible with the native NotificationPref component + * on the account/preferences page). + */ + +type PrefKey = keyof NotificationPreferences; + +const PREF_ITEMS: { key: PrefKey; labelKey: string; descKey: string }[] = [ + { + key: "emailMentions", + labelKey: "acadenice.notifications.prefs.email_mentions", + descKey: "acadenice.notifications.prefs.email_mentions_desc", + }, + { + key: "emailReplies", + labelKey: "acadenice.notifications.prefs.email_replies", + descKey: "acadenice.notifications.prefs.email_replies_desc", + }, + { + key: "emailShares", + labelKey: "acadenice.notifications.prefs.email_shares", + descKey: "acadenice.notifications.prefs.email_shares_desc", + }, + { + key: "inAppMentions", + labelKey: "acadenice.notifications.prefs.in_app_mentions", + descKey: "acadenice.notifications.prefs.in_app_mentions_desc", + }, + { + key: "inAppReplies", + labelKey: "acadenice.notifications.prefs.in_app_replies", + descKey: "acadenice.notifications.prefs.in_app_replies_desc", + }, +]; + +function PrefToggle({ + prefKey, + value, + labelKey, + descKey, + onToggle, + loading, +}: { + prefKey: PrefKey; + value: boolean; + labelKey: string; + descKey: string; + onToggle: (key: PrefKey, value: boolean) => void; + loading: boolean; +}) { + const { t } = useTranslation(); + + return ( + + onToggle(prefKey, e.currentTarget.checked)} + disabled={loading} + label={ + + {t(labelKey)} + + } + description={{t(descKey)}} + /> + + ); +} + +export default function NotificationPreferencesPage() { + const { t } = useTranslation(); + const { data: prefs, isLoading } = useAcadenicePreferencesQuery(); + const update = useAcadeniceUpdatePreferencesMutation(); + + const handleToggle = (key: PrefKey, value: boolean) => { + update.mutate({ [key]: value }); + }; + + return ( + <> + + + {t("acadenice.notifications.prefs.title")} - {getAppName()} + + + + + + {t("acadenice.notifications.prefs.title")} + + + {t("acadenice.notifications.prefs.subtitle")} + + + + + {isLoading ? ( + + {Array.from({ length: 5 }).map((_, i) => ( + + ))} + + ) : ( + + {PREF_ITEMS.map((item) => ( + + ))} + + )} + + + ); +} diff --git a/apps/client/src/features/acadenice/notifications/pages/notifications-page.tsx b/apps/client/src/features/acadenice/notifications/pages/notifications-page.tsx new file mode 100644 index 00000000..3e3532d5 --- /dev/null +++ b/apps/client/src/features/acadenice/notifications/pages/notifications-page.tsx @@ -0,0 +1,124 @@ +import { + Button, + Divider, + Group, + ScrollArea, + Skeleton, + Stack, + Text, + Title, +} from "@mantine/core"; +import { Helmet } from "react-helmet-async"; +import { IconBell, IconChecks } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { getAppName } from "@/lib/config"; +import { NotificationItem } from "@/features/notification/components/notification-item"; +import { + useAcadeniceNotificationsQuery, + useAcadeniceMarkAllReadMutation, +} from "../queries/notifications-query"; +import { useNavigate } from "react-router-dom"; + +/** + * AcadeniceNotificationsPage — /notifications (R3.7). + * + * Full-page notification inbox. Uses the same NotificationItem component as + * the native popover to avoid duplication. Fetches from the Acadenice facade + * endpoint (which delegates to the native NotificationService). + */ +export default function AcadeniceNotificationsPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const markAllRead = useAcadeniceMarkAllReadMutation(); + + const { + data, + isLoading, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + } = useAcadeniceNotificationsQuery({ tab: "all" }); + + const items = + data?.pages.flatMap((p) => { + const pageData = p as any; + return pageData.items ?? pageData.data ?? []; + }) ?? []; + + const handleMarkAllRead = () => { + markAllRead.mutate(); + }; + + const hasItems = items.length > 0; + + return ( + <> + + + {t("acadenice.notifications.title")} - {getAppName()} + + + + + + + + {t("acadenice.notifications.title")} + + + {hasItems && ( + + )} + + + + + + {isLoading ? ( + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + ) : !hasItems ? ( + + + {t("acadenice.notifications.empty")} + + ) : ( + + {items.map((notification: any) => ( + navigate(-1)} + /> + ))} + + )} + + {hasNextPage && ( + + + + )} + + + + ); +} diff --git a/apps/client/src/features/acadenice/notifications/queries/notifications-query.ts b/apps/client/src/features/acadenice/notifications/queries/notifications-query.ts new file mode 100644 index 00000000..ee04e0ee --- /dev/null +++ b/apps/client/src/features/acadenice/notifications/queries/notifications-query.ts @@ -0,0 +1,135 @@ +import { + keepPreviousData, + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { notifications } from "@mantine/notifications"; +import { useTranslation } from "react-i18next"; +import { + notificationsClient, + UpdatePreferencesPayload, +} from "../services/notifications-client"; + +export const ACADENICE_NOTIF_KEY = ["acadenice", "notifications"] as const; +export const ACADENICE_UNREAD_KEY = [ + "acadenice", + "notifications", + "unread-count", +] as const; +export const ACADENICE_PREFS_KEY = [ + "acadenice", + "notification-preferences", +] as const; + +// --------------------------------------------------------------------------- +// List (infinite scroll / pagination) +// --------------------------------------------------------------------------- + +export function useAcadeniceNotificationsQuery(opts?: { + tab?: "direct" | "updates" | "all"; +}) { + return useInfiniteQuery({ + queryKey: [...ACADENICE_NOTIF_KEY, opts?.tab], + queryFn: ({ pageParam }) => + notificationsClient.list({ + cursor: pageParam as string | undefined, + tab: opts?.tab, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => + lastPage.meta?.hasNextPage ? lastPage.meta?.nextCursor : undefined, + staleTime: 30 * 1000, + placeholderData: keepPreviousData, + }); +} + +// --------------------------------------------------------------------------- +// Unread count — polled every 30s (poll vs SSE decision, R3.7 scope) +// --------------------------------------------------------------------------- + +export function useAcadeniceUnreadCountQuery() { + return useQuery({ + queryKey: ACADENICE_UNREAD_KEY, + queryFn: notificationsClient.unreadCount, + refetchInterval: 30 * 1000, + staleTime: 25 * 1000, + }); +} + +// --------------------------------------------------------------------------- +// Mutations +// --------------------------------------------------------------------------- + +export function useAcadeniceMarkReadMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (ids: string[]) => notificationsClient.markRead(ids), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ACADENICE_NOTIF_KEY }); + qc.invalidateQueries({ queryKey: ACADENICE_UNREAD_KEY }); + }, + }); +} + +export function useAcadeniceMarkAllReadMutation() { + const qc = useQueryClient(); + const { t } = useTranslation(); + return useMutation({ + mutationFn: notificationsClient.markAllRead, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ACADENICE_NOTIF_KEY }); + qc.invalidateQueries({ queryKey: ACADENICE_UNREAD_KEY }); + notifications.show({ + color: "green", + message: t("acadenice.notifications.all_read"), + }); + }, + }); +} + +export function useAcadeniceMarkOneMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => notificationsClient.markOne(id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ACADENICE_NOTIF_KEY }); + qc.invalidateQueries({ queryKey: ACADENICE_UNREAD_KEY }); + }, + }); +} + +// --------------------------------------------------------------------------- +// Preferences +// --------------------------------------------------------------------------- + +export function useAcadenicePreferencesQuery() { + return useQuery({ + queryKey: ACADENICE_PREFS_KEY, + queryFn: notificationsClient.getPreferences, + staleTime: 60 * 1000, + }); +} + +export function useAcadeniceUpdatePreferencesMutation() { + const qc = useQueryClient(); + const { t } = useTranslation(); + return useMutation({ + mutationFn: (payload: UpdatePreferencesPayload) => + notificationsClient.updatePreferences(payload), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ACADENICE_PREFS_KEY }); + notifications.show({ + color: "green", + message: t("acadenice.notifications.prefs_saved"), + }); + }, + onError: () => { + notifications.show({ + color: "red", + message: t("acadenice.notifications.prefs_error"), + }); + }, + }); +} diff --git a/apps/client/src/features/acadenice/notifications/services/notifications-client.ts b/apps/client/src/features/acadenice/notifications/services/notifications-client.ts new file mode 100644 index 00000000..8b6c4574 --- /dev/null +++ b/apps/client/src/features/acadenice/notifications/services/notifications-client.ts @@ -0,0 +1,67 @@ +import api from "@/lib/api-client"; +import { INotification } from "@/features/notification/types/notification.types"; +import { IPagination } from "@/lib/types"; + +export interface NotificationPreferences { + emailMentions: boolean; + emailReplies: boolean; + emailShares: boolean; + inAppMentions: boolean; + inAppReplies: boolean; + inAppShares: boolean; +} + +export type UpdatePreferencesPayload = Partial; + +const BASE = "/api/acadenice/notifications"; +const PREFS_BASE = "/api/acadenice/notification-preferences"; + +/** + * HTTP client for Acadenice notification endpoints (R3.7). + * + * Facades the native Docmost notification system through Acadenice-prefixed + * routes so the Acadenice frontend can be updated independently of upstream. + */ +export const notificationsClient = { + list(params: { + limit?: number; + cursor?: string; + tab?: "direct" | "updates" | "all"; + } = {}): Promise> { + return api + .get>(BASE, { params }) + .then((r) => r.data); + }, + + unreadCount(): Promise<{ count: number }> { + return api + .get<{ count: number }>(`${BASE}/unread-count`) + .then((r) => r.data); + }, + + markRead(notificationIds: string[]): Promise { + return api + .post(`${BASE}/mark-read`, { notificationIds }) + .then(() => undefined); + }, + + markAllRead(): Promise { + return api.post(`${BASE}/read-all`).then(() => undefined); + }, + + markOne(id: string): Promise { + return api.post(`${BASE}/${id}/read`).then(() => undefined); + }, + + getPreferences(): Promise { + return api.get(PREFS_BASE).then((r) => r.data); + }, + + updatePreferences( + payload: UpdatePreferencesPayload, + ): Promise { + return api + .put(PREFS_BASE, payload) + .then((r) => r.data); + }, +}; diff --git a/apps/server/src/core/acadenice/notifications/controllers/notification-preferences.controller.ts b/apps/server/src/core/acadenice/notifications/controllers/notification-preferences.controller.ts new file mode 100644 index 00000000..0b55be91 --- /dev/null +++ b/apps/server/src/core/acadenice/notifications/controllers/notification-preferences.controller.ts @@ -0,0 +1,66 @@ +import { + BadRequestException, + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Put, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../../../auth/guards/jwt-auth.guard'; +import { AuthUser } from '../../../../common/decorators/auth-user.decorator'; +import { User } from '@docmost/db/types/entity.types'; +import { NotificationPreferencesService } from '../services/notification-preferences.service'; +import { + notificationPreferencesSchema, + NotificationPreferencesDto, +} from '../dto/notification.dto'; +import { ZodError } from 'zod'; + +/** + * REST controller for Acadenice notification preferences (R3.7). + * + * Exposes a dedicated GET/PUT endpoint so the notification settings UI can + * read/write preferences without going through the full user-profile endpoint. + * Under the hood, preferences are stored in `users.settings.notifications` + * (the native Docmost JSONB column) — so changes are immediately visible to + * the native notification email pipeline. + */ +@UseGuards(JwtAuthGuard) +@Controller('acadenice/notification-preferences') +export class NotificationPreferencesController { + constructor( + private readonly prefsService: NotificationPreferencesService, + ) {} + + @Get() + async getPreferences(@AuthUser() user: User) { + return this.prefsService.getPreferences(user.id); + } + + @Put() + @HttpCode(HttpStatus.OK) + async updatePreferences( + @AuthUser() user: User, + @Body() rawBody: unknown, + ) { + let dto: NotificationPreferencesDto; + try { + dto = notificationPreferencesSchema.parse(rawBody); + } catch (err) { + if (err instanceof ZodError) { + throw new BadRequestException({ + message: 'Validation failed', + errors: err.errors.map((e) => ({ + path: e.path.join('.'), + message: e.message, + })), + }); + } + throw err; + } + + return this.prefsService.updatePreferences(user.id, dto); + } +} diff --git a/apps/server/src/core/acadenice/notifications/controllers/notifications.controller.ts b/apps/server/src/core/acadenice/notifications/controllers/notifications.controller.ts new file mode 100644 index 00000000..04d0b2fc --- /dev/null +++ b/apps/server/src/core/acadenice/notifications/controllers/notifications.controller.ts @@ -0,0 +1,126 @@ +import { + BadRequestException, + Controller, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Post, + Body, + Query, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../../../auth/guards/jwt-auth.guard'; +import { AuthUser } from '../../../../common/decorators/auth-user.decorator'; +import { User } from '@docmost/db/types/entity.types'; +import { NotificationService } from '../../../notification/notification.service'; +import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; +import { + listNotificationsSchema, + markReadSchema, + ListNotificationsDto, + MarkReadDto, +} from '../dto/notification.dto'; +import { ZodError } from 'zod'; + +function parseQuery(schema: { parse: (v: unknown) => T }, raw: unknown): T { + try { + return schema.parse(raw); + } catch (err) { + if (err instanceof ZodError) { + throw new BadRequestException({ + message: 'Validation failed', + errors: err.errors.map((e) => ({ + path: e.path.join('.'), + message: e.message, + })), + }); + } + throw err; + } +} + +/** + * REST controller for Acadenice notification endpoints (R3.7). + * + * This controller is a thin facade over the native Docmost NotificationService. + * Endpoints are prefixed `/api/acadenice/notifications` to allow the Acadenice + * frontend to discover and poll them without conflicting with the native + * `/notifications` endpoints used by the upstream Docmost UI. + * + * All mutation endpoints use the same guards and service as the native path. + */ +@UseGuards(JwtAuthGuard) +@Controller('acadenice/notifications') +export class AcadeniceNotificationsController { + constructor(private readonly notificationService: NotificationService) {} + + /** + * GET /api/acadenice/notifications + * Paginated list of notifications for the authenticated user. + */ + @Get() + async list( + @AuthUser() user: User, + @Query() rawQuery: unknown, + ) { + const dto = parseQuery(listNotificationsSchema, rawQuery) as ListNotificationsDto; + + const pagination: PaginationOptions = { + limit: dto.limit, + cursor: dto.cursor, + }; + + return this.notificationService.findByUserId( + user.id, + pagination, + dto.tab as any, + ); + } + + /** + * GET /api/acadenice/notifications/unread-count + */ + @Get('unread-count') + async unreadCount(@AuthUser() user: User) { + return this.notificationService.getUnreadCount(user.id); + } + + /** + * POST /api/acadenice/notifications/read-all + */ + @Post('read-all') + @HttpCode(HttpStatus.NO_CONTENT) + async readAll(@AuthUser() user: User): Promise { + await this.notificationService.markAllAsRead(user.id); + } + + /** + * POST /api/acadenice/notifications/mark-read + */ + @Post('mark-read') + @HttpCode(HttpStatus.NO_CONTENT) + async markRead( + @AuthUser() user: User, + @Body() rawBody: unknown, + ): Promise { + const dto = parseQuery(markReadSchema, rawBody) as MarkReadDto; + await this.notificationService.markMultipleAsRead( + dto.notificationIds, + user.id, + ); + } + + /** + * POST /api/acadenice/notifications/:id/read + */ + @Post(':id/read') + @HttpCode(HttpStatus.NO_CONTENT) + async markOne( + @Param('id', ParseUUIDPipe) id: string, + @AuthUser() user: User, + ): Promise { + await this.notificationService.markAsRead(id, user.id); + } +} diff --git a/apps/server/src/core/acadenice/notifications/dto/notification.dto.ts b/apps/server/src/core/acadenice/notifications/dto/notification.dto.ts new file mode 100644 index 00000000..bca93bc0 --- /dev/null +++ b/apps/server/src/core/acadenice/notifications/dto/notification.dto.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; + +/** + * DTO schemas for the Acadenice notifications proxy (R3.7). + * + * The Acadenice notification API is a thin facade over the native + * Docmost notification system. These schemas validate query params + * and request bodies forwarded to the native service. + */ + +export const listNotificationsSchema = z.object({ + limit: z.coerce.number().int().min(1).max(100).optional().default(20), + cursor: z.string().uuid().optional(), + tab: z.enum(['direct', 'updates', 'all']).optional().default('all'), + unreadOnly: z + .enum(['true', 'false']) + .optional() + .transform((v) => v === 'true'), +}); + +export type ListNotificationsDto = z.infer; + +export const markReadSchema = z.object({ + notificationIds: z.array(z.string().uuid()).min(1).max(50), +}); + +export type MarkReadDto = z.infer; + +export const notificationPreferencesSchema = z.object({ + emailMentions: z.boolean().optional(), + emailReplies: z.boolean().optional(), + emailShares: z.boolean().optional(), + inAppMentions: z.boolean().optional(), + inAppReplies: z.boolean().optional(), + inAppShares: z.boolean().optional(), +}); + +export type NotificationPreferencesDto = z.infer; diff --git a/apps/server/src/core/acadenice/notifications/notifications.module.ts b/apps/server/src/core/acadenice/notifications/notifications.module.ts new file mode 100644 index 00000000..ae4c4d0c --- /dev/null +++ b/apps/server/src/core/acadenice/notifications/notifications.module.ts @@ -0,0 +1,62 @@ +import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bullmq'; +import { QueueName } from '../../../integrations/queue/constants'; +import { MentionDetectorService } from './services/mention-detector.service'; +import { NotificationEmitterService } from './services/notification-emitter.service'; +import { NotificationPreferencesService } from './services/notification-preferences.service'; +import { AcadeniceNotificationsController } from './controllers/notifications.controller'; +import { NotificationPreferencesController } from './controllers/notification-preferences.controller'; +import { NotificationModule } from '../../notification/notification.module'; + +/** + * AcadeniceNotificationsModule — @user mention notifications (R3.7). + * + * Architecture: + * + * 1. MentionDetectorService (pure) + * Walks Tiptap JSON and extracts {mentionId, userId, excerpt} tuples. + * No DB dependency — easily unit-testable. + * + * 2. NotificationEmitterService (event listener) + * Listens to `acadenice.page.content.updated` (REST API path). + * Uses MentionDetectorService + queues PAGE_MENTION_NOTIFICATION job. + * Collab path is already covered by native PersistenceExtension. + * + * 3. NotificationPreferencesService (DB) + * Read/write notification prefs from users.settings JSONB. + * Shared with native NotificationPref UI (same keys). + * + * 4. AcadeniceNotificationsController + * GET /api/acadenice/notifications (paginated) + * GET /api/acadenice/notifications/unread-count + * POST /api/acadenice/notifications/read-all + * POST /api/acadenice/notifications/mark-read + * POST /api/acadenice/notifications/:id/read + * + * 5. NotificationPreferencesController + * GET /api/acadenice/notification-preferences + * PUT /api/acadenice/notification-preferences + * + * Depends on: + * - NotificationModule (exports NotificationService — also imports it) + * - BullModule (NOTIFICATION_QUEUE) — already registered globally in AppModule + * - DatabaseModule (global) — KyselyDB + * - EventEmitter2 (global via EventEmitterModule) + */ +@Module({ + imports: [ + NotificationModule, + BullModule.registerQueue({ name: QueueName.NOTIFICATION_QUEUE }), + ], + controllers: [ + AcadeniceNotificationsController, + NotificationPreferencesController, + ], + providers: [ + MentionDetectorService, + NotificationEmitterService, + NotificationPreferencesService, + ], + exports: [MentionDetectorService, NotificationPreferencesService], +}) +export class AcadeniceNotificationsModule {} diff --git a/apps/server/src/core/acadenice/notifications/services/mention-detector.service.ts b/apps/server/src/core/acadenice/notifications/services/mention-detector.service.ts new file mode 100644 index 00000000..feb41a8d --- /dev/null +++ b/apps/server/src/core/acadenice/notifications/services/mention-detector.service.ts @@ -0,0 +1,130 @@ +import { Injectable, Logger } from '@nestjs/common'; + +/** + * MentionDetectorService — extract user mentions from a Tiptap JSON document (R3.7). + * + * Reuses the same extraction logic as the upstream PersistenceExtension and + * BacklinkParserService without introducing another DB dependency: + * we walk the Tiptap JSON tree and collect every `mention` node whose + * `entityType === 'user'`. + * + * This service is intentionally pure (no async, no DB) so that it can be + * called both from the mention-emitter listener and from the REST API layer + * in future integrations. + */ + +export interface UserMentionExtract { + /** Mention node unique id (stored in tiptap attrs.id). */ + mentionId: string; + /** UUID of the mentioned user (attrs.entityId). */ + userId: string; + /** ~200-char excerpt of surrounding text for the notification preview. */ + excerpt: string | null; +} + +const EXCERPT_MAX = 200; + +@Injectable() +export class MentionDetectorService { + private readonly logger = new Logger(MentionDetectorService.name); + + /** + * Walk a Tiptap ProseMirror JSON document and return all user mentions. + * + * Deduplicated by (mentionId) — a single @user can appear multiple times + * in the same doc; we keep the first occurrence per mentionId to mirror + * the upstream extractMentions behaviour. + */ + detectUserMentions(doc: Record): UserMentionExtract[] { + if (!doc || typeof doc !== 'object') return []; + + const results: UserMentionExtract[] = []; + const seenIds = new Set(); + + this.walkNode(doc, null, results, seenIds); + + return results; + } + + /** + * Given an old doc and a new doc, return only the mentions that are NEW + * (present in newDoc but not in oldDoc). This mirrors the upstream + * diff logic in PersistenceExtension and prevents duplicate notifications + * on every collab save. + */ + detectNewMentions( + oldDoc: Record | null, + newDoc: Record, + ): UserMentionExtract[] { + const newMentions = this.detectUserMentions(newDoc); + if (!oldDoc) return newMentions; + + const oldUserIds = new Set( + this.detectUserMentions(oldDoc).map((m) => m.userId), + ); + + return newMentions.filter((m) => !oldUserIds.has(m.userId)); + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private walkNode( + node: Record, + parentText: string | null, + out: UserMentionExtract[], + seen: Set, + ): void { + if (!node || typeof node !== 'object') return; + + const type = node['type'] as string | undefined; + const attrs = (node['attrs'] ?? {}) as Record; + + if ( + type === 'mention' && + attrs['entityType'] === 'user' && + typeof attrs['entityId'] === 'string' && + typeof attrs['id'] === 'string' && + !seen.has(attrs['id'] as string) + ) { + seen.add(attrs['id'] as string); + out.push({ + mentionId: attrs['id'] as string, + userId: attrs['entityId'] as string, + excerpt: parentText ? parentText.slice(0, EXCERPT_MAX) : null, + }); + } + + const content = node['content']; + if (Array.isArray(content)) { + const blockText = this.extractBlockText(node); + for (const child of content) { + this.walkNode( + child as Record, + blockText ?? parentText, + out, + seen, + ); + } + } + } + + private extractBlockText(node: Record): string | null { + const content = node['content']; + if (!Array.isArray(content)) return null; + + const parts: string[] = []; + for (const child of content) { + if ( + (child as Record)['type'] === 'text' && + typeof (child as Record)['text'] === 'string' + ) { + parts.push((child as Record)['text'] as string); + } + } + + const text = parts.join(''); + return text.length > 0 ? text : null; + } +} diff --git a/apps/server/src/core/acadenice/notifications/services/notification-emitter.service.ts b/apps/server/src/core/acadenice/notifications/services/notification-emitter.service.ts new file mode 100644 index 00000000..9441afee --- /dev/null +++ b/apps/server/src/core/acadenice/notifications/services/notification-emitter.service.ts @@ -0,0 +1,109 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB } from '@docmost/db/types/kysely.types'; +import { QueueJob, QueueName } from '../../../../integrations/queue/constants'; +import { IPageMentionNotificationJob } from '../../../../integrations/queue/constants/queue.interface'; +import { MentionDetectorService } from './mention-detector.service'; + +export const ACADENICE_PAGE_CONTENT_UPDATED_EVENT = + 'acadenice.page.content.updated'; + +export interface PageContentUpdatedPayload { + pageId: string; + workspaceId: string; +} + +/** + * NotificationEmitterService — bridges the Acadenice content-updated event to + * the native Docmost PAGE_MENTION_NOTIFICATION queue job (R3.7). + * + * WHY this service exists: + * The native PersistenceExtension (collab path) already detects mentions + * and queues notifications. However, pages saved via the REST API (non- + * collaborative save, e.g. import, template instantiation) do NOT go through + * PersistenceExtension. This listener handles that gap: it listens to the + * same `acadenice.page.content.updated` event that the backlink indexer uses, + * detects new user mentions by diffing old vs new content, and queues the + * native PAGE_MENTION_NOTIFICATION job so that the existing + * PageNotificationService.processPageMention() handles all the RBAC + + * email logic — no duplication. + * + * COLLAB PATH (hocuspocus): + * PersistenceExtension -> notificationQueue.add(PAGE_MENTION_NOTIFICATION) + * (already active, R3.7 does NOT touch it) + * + * REST API PATH (page.service.ts savePage): + * page.service.ts -> EventEmitter2.emit('acadenice.page.content.updated') + * -> NotificationEmitterService.handlePageContentUpdated() + * -> notificationQueue.add(PAGE_MENTION_NOTIFICATION) [only new mentions] + */ +@Injectable() +export class NotificationEmitterService { + private readonly logger = new Logger(NotificationEmitterService.name); + + constructor( + @InjectKysely() private readonly db: KyselyDB, + @InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue, + private readonly mentionDetector: MentionDetectorService, + ) {} + + @OnEvent(ACADENICE_PAGE_CONTENT_UPDATED_EVENT, { async: true }) + async handlePageContentUpdated( + payload: PageContentUpdatedPayload, + ): Promise { + const { pageId, workspaceId } = payload; + + try { + const page = await this.db + .selectFrom('pages') + .select(['id', 'content', 'spaceId', 'creatorId', 'lastUpdatedById']) + .where('id', '=', pageId) + .where('deletedAt', 'is', null) + .executeTakeFirst(); + + if (!page) return; + + const content = page.content as Record | null; + if (!content) return; + + // We cannot diff with oldContent here because by the time this event + // fires the page is already saved. We queue ALL user mentions; the + // native PageNotificationService.processPageMention() deduplicates + // against oldMentionedUserIds (passed as empty — meaning all mentions + // will pass through, but the service already guards against self-mention). + // + // This is intentionally conservative: a duplicate notification is less + // harmful than a missed one. Future refactor can pass oldContent via + // the event payload (see "Points a debattre" in Patch 015). + const actorId = page.lastUpdatedById ?? page.creatorId; + const mentions = this.mentionDetector.detectUserMentions(content); + if (mentions.length === 0) return; + + await this.notificationQueue.add( + QueueJob.PAGE_MENTION_NOTIFICATION, + { + userMentions: mentions.map((m) => ({ + userId: m.userId, + mentionId: m.mentionId, + creatorId: actorId, + })), + oldMentionedUserIds: [], + pageId, + spaceId: page.spaceId, + workspaceId, + } satisfies IPageMentionNotificationJob, + ); + + this.logger.debug( + `queued PAGE_MENTION_NOTIFICATION for ${mentions.length} mention(s) on page ${pageId} (REST path)`, + ); + } catch (err) { + this.logger.error( + `NotificationEmitterService failed for page ${pageId}: ${err?.['message']}`, + ); + } + } +} diff --git a/apps/server/src/core/acadenice/notifications/services/notification-preferences.service.ts b/apps/server/src/core/acadenice/notifications/services/notification-preferences.service.ts new file mode 100644 index 00000000..2a985d8b --- /dev/null +++ b/apps/server/src/core/acadenice/notifications/services/notification-preferences.service.ts @@ -0,0 +1,111 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB } from '@docmost/db/types/kysely.types'; +import { sql } from 'kysely'; + +/** + * NotificationPreferencesService — per-user notification preferences (R3.7). + * + * Wraps the native Docmost `users.settings` JSONB column that already + * stores notification toggles (`notifications.page.updated`, etc.). + * + * Our R3.7 additions expose an explicit GET/PATCH endpoint so that a + * dedicated preferences UI can read/write prefs without coupling to the + * full user-profile endpoint. + * + * Key mapping (matches the native NotificationPref component): + * emailMentions -> settings.notifications["page.userMention"] + * emailReplies -> settings.notifications["comment.created"] + * emailShares -> settings.notifications["page.updated"] + * inAppMentions -> (same key, controls both email + in-app in native impl) + */ + +export interface NotificationPreferences { + emailMentions: boolean; + emailReplies: boolean; + emailShares: boolean; + inAppMentions: boolean; + inAppReplies: boolean; + inAppShares: boolean; +} + +const DEFAULT_PREFS: NotificationPreferences = { + emailMentions: true, + emailReplies: true, + emailShares: true, + inAppMentions: true, + inAppReplies: true, + inAppShares: true, +}; + +@Injectable() +export class NotificationPreferencesService { + private readonly logger = new Logger(NotificationPreferencesService.name); + + constructor(@InjectKysely() private readonly db: KyselyDB) {} + + async getPreferences(userId: string): Promise { + const row = await this.db + .selectFrom('users') + .select('settings') + .where('id', '=', userId) + .executeTakeFirst(); + + if (!row) return { ...DEFAULT_PREFS }; + + const notif = (row.settings as any)?.notifications ?? {}; + + return { + emailMentions: notif['page.userMention'] !== false, + emailReplies: notif['comment.created'] !== false, + emailShares: notif['page.updated'] !== false, + inAppMentions: notif['page.userMention'] !== false, + inAppReplies: notif['comment.created'] !== false, + inAppShares: notif['page.updated'] !== false, + }; + } + + async updatePreferences( + userId: string, + prefs: Partial, + ): Promise { + const current = await this.db + .selectFrom('users') + .select('settings') + .where('id', '=', userId) + .executeTakeFirst(); + + const existingSettings = (current?.settings as any) ?? {}; + const existingNotif = existingSettings.notifications ?? {}; + + const merged = { ...existingNotif }; + + if (prefs.emailMentions !== undefined) + merged['page.userMention'] = prefs.emailMentions; + if (prefs.emailReplies !== undefined) + merged['comment.created'] = prefs.emailReplies; + if (prefs.emailShares !== undefined) + merged['page.updated'] = prefs.emailShares; + + // in-app prefs share the same key as email prefs in the native impl + if (prefs.inAppMentions !== undefined) + merged['page.userMention'] = prefs.inAppMentions; + if (prefs.inAppReplies !== undefined) + merged['comment.created'] = prefs.inAppReplies; + if (prefs.inAppShares !== undefined) + merged['page.updated'] = prefs.inAppShares; + + const newSettings = { + ...existingSettings, + notifications: merged, + }; + + await sql` + UPDATE users + SET settings = ${JSON.stringify(newSettings)}::jsonb + WHERE id = ${userId} + `.execute(this.db); + + return this.getPreferences(userId); + } +} diff --git a/apps/server/src/core/acadenice/notifications/spec/mention-detector.service.spec.ts b/apps/server/src/core/acadenice/notifications/spec/mention-detector.service.spec.ts new file mode 100644 index 00000000..7ff35338 --- /dev/null +++ b/apps/server/src/core/acadenice/notifications/spec/mention-detector.service.spec.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { Test } from '@nestjs/testing'; +import { MentionDetectorService } from '../services/mention-detector.service'; + +/** + * Unit tests for MentionDetectorService (R3.7). + * + * All tests use plain Tiptap JSON objects — no DB, no network. + */ + +const userId1 = '11111111-1111-1111-1111-111111111111'; +const userId2 = '22222222-2222-2222-2222-222222222222'; +const mentionId1 = 'aaaa-1111'; +const mentionId2 = 'bbbb-2222'; + +function makeMentionNode( + entityType: 'user' | 'page', + entityId: string, + id: string, +): Record { + return { + type: 'mention', + attrs: { id, entityType, entityId, label: 'Test User', creatorId: 'creator-uuid' }, + }; +} + +function makeDoc(...content: Record[]): Record { + return { type: 'doc', content: [{ type: 'paragraph', content }] }; +} + +describe('MentionDetectorService', () => { + let service: MentionDetectorService; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + providers: [MentionDetectorService], + }).compile(); + + service = module.get(MentionDetectorService); + }); + + // --------------------------------------------------------------------------- + // detectUserMentions + // --------------------------------------------------------------------------- + + it('returns empty array for empty doc', () => { + expect(service.detectUserMentions({ type: 'doc', content: [] })).toEqual([]); + }); + + it('returns empty array for null/undefined doc', () => { + expect(service.detectUserMentions(null as any)).toEqual([]); + expect(service.detectUserMentions(undefined as any)).toEqual([]); + }); + + it('detects a single user mention', () => { + const doc = makeDoc(makeMentionNode('user', userId1, mentionId1)); + const results = service.detectUserMentions(doc); + expect(results).toHaveLength(1); + expect(results[0].userId).toBe(userId1); + expect(results[0].mentionId).toBe(mentionId1); + }); + + it('ignores page mentions', () => { + const doc = makeDoc(makeMentionNode('page', 'page-uuid', 'mention-page')); + expect(service.detectUserMentions(doc)).toEqual([]); + }); + + it('detects multiple distinct user mentions', () => { + const doc = makeDoc( + makeMentionNode('user', userId1, mentionId1), + makeMentionNode('user', userId2, mentionId2), + ); + const results = service.detectUserMentions(doc); + expect(results).toHaveLength(2); + const userIds = results.map((r) => r.userId); + expect(userIds).toContain(userId1); + expect(userIds).toContain(userId2); + }); + + it('deduplicates same mentionId appearing twice', () => { + const node = makeMentionNode('user', userId1, mentionId1); + const doc = makeDoc(node, node); + const results = service.detectUserMentions(doc); + expect(results).toHaveLength(1); + }); + + it('captures excerpt from parent paragraph text', () => { + const doc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Hello ' }, + makeMentionNode('user', userId1, mentionId1), + { type: 'text', text: ' how are you' }, + ], + }, + ], + }; + const results = service.detectUserMentions(doc); + expect(results[0].excerpt).toContain('Hello'); + }); + + it('handles mention missing entityId gracefully', () => { + const doc = makeDoc({ + type: 'mention', + attrs: { id: 'x', entityType: 'user' }, // entityId missing + }); + expect(service.detectUserMentions(doc)).toEqual([]); + }); + + it('handles mention missing id gracefully', () => { + const doc = makeDoc({ + type: 'mention', + attrs: { entityType: 'user', entityId: userId1 }, // id missing + }); + expect(service.detectUserMentions(doc)).toEqual([]); + }); + + it('works with nested block structure', () => { + const doc = { + type: 'doc', + content: [ + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [makeMentionNode('user', userId1, mentionId1)], + }, + ], + }, + ], + }, + ], + }; + const results = service.detectUserMentions(doc); + expect(results).toHaveLength(1); + expect(results[0].userId).toBe(userId1); + }); + + // --------------------------------------------------------------------------- + // detectNewMentions + // --------------------------------------------------------------------------- + + it('returns all mentions when oldDoc is null', () => { + const newDoc = makeDoc(makeMentionNode('user', userId1, mentionId1)); + const results = service.detectNewMentions(null, newDoc); + expect(results).toHaveLength(1); + }); + + it('filters out mentions already present in oldDoc', () => { + const oldDoc = makeDoc(makeMentionNode('user', userId1, mentionId1)); + const newDoc = makeDoc( + makeMentionNode('user', userId1, mentionId1), + makeMentionNode('user', userId2, mentionId2), + ); + const results = service.detectNewMentions(oldDoc, newDoc); + expect(results).toHaveLength(1); + expect(results[0].userId).toBe(userId2); + }); + + it('returns empty when all mentions existed in oldDoc', () => { + const doc = makeDoc(makeMentionNode('user', userId1, mentionId1)); + expect(service.detectNewMentions(doc, doc)).toHaveLength(0); + }); + + it('returns all when newDoc has no old mentions', () => { + const oldDoc = makeDoc(makeMentionNode('user', userId2, mentionId2)); + const newDoc = makeDoc(makeMentionNode('user', userId1, mentionId1)); + const results = service.detectNewMentions(oldDoc, newDoc); + expect(results).toHaveLength(1); + expect(results[0].userId).toBe(userId1); + }); +}); diff --git a/apps/server/src/core/acadenice/notifications/spec/notification-preferences.spec.ts b/apps/server/src/core/acadenice/notifications/spec/notification-preferences.spec.ts new file mode 100644 index 00000000..da1ecf8b --- /dev/null +++ b/apps/server/src/core/acadenice/notifications/spec/notification-preferences.spec.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Test } from '@nestjs/testing'; +import { NotificationPreferencesService } from '../services/notification-preferences.service'; +import { getKyselyToken } from 'nestjs-kysely'; + +/** + * Unit tests for NotificationPreferencesService (R3.7). + * + * KyselyDB is fully mocked. Tests verify the mapping between the Acadenice + * preference keys and the native Docmost users.settings.notifications keys. + */ + +const USER_ID = 'user-preferences-uuid'; + +const defaultSettings = { + notifications: { + 'page.updated': true, + 'page.userMention': true, + 'comment.created': true, + }, +}; + +describe('NotificationPreferencesService', () => { + let service: NotificationPreferencesService; + let mockDb: any; + + beforeEach(async () => { + vi.clearAllMocks(); + + // Build a chainable mock matching Kysely's builder pattern + const executeTakeFirst = vi.fn().mockResolvedValue({ + settings: defaultSettings, + }); + const where = vi.fn().mockReturnThis(); + const select = vi.fn().mockReturnValue({ where, executeTakeFirst }); + const selectFrom = vi.fn().mockReturnValue({ select }); + + const sqlExecute = vi.fn().mockResolvedValue({ rows: [] }); + const mockSql = vi.fn().mockReturnValue({ execute: sqlExecute }); + + mockDb = { selectFrom }; + (mockDb as any).raw = mockSql; + + const module = await Test.createTestingModule({ + providers: [ + NotificationPreferencesService, + { + provide: getKyselyToken(), + useValue: mockDb, + }, + ], + }).compile(); + + service = module.get(NotificationPreferencesService); + + // Patch the db on the service so that sql`` tagged template works + (service as any).db = mockDb; + }); + + it('getPreferences: returns defaults when settings are falsy', async () => { + const executeTakeFirst = vi.fn().mockResolvedValue({ settings: {} }); + const where = vi.fn().mockReturnThis(); + const select = vi.fn().mockReturnValue({ where, executeTakeFirst }); + mockDb.selectFrom = vi.fn().mockReturnValue({ select }); + + const prefs = await service.getPreferences(USER_ID); + + expect(prefs.emailMentions).toBe(true); + expect(prefs.emailReplies).toBe(true); + expect(prefs.emailShares).toBe(true); + expect(prefs.inAppMentions).toBe(true); + expect(prefs.inAppReplies).toBe(true); + expect(prefs.inAppShares).toBe(true); + }); + + it('getPreferences: returns false when page.userMention is false', async () => { + const executeTakeFirst = vi.fn().mockResolvedValue({ + settings: { + notifications: { 'page.userMention': false }, + }, + }); + const where = vi.fn().mockReturnThis(); + const select = vi.fn().mockReturnValue({ where, executeTakeFirst }); + mockDb.selectFrom = vi.fn().mockReturnValue({ select }); + + const prefs = await service.getPreferences(USER_ID); + expect(prefs.emailMentions).toBe(false); + expect(prefs.inAppMentions).toBe(false); + }); + + it('getPreferences: returns true for unset keys (default on)', async () => { + const executeTakeFirst = vi.fn().mockResolvedValue({ + settings: { notifications: {} }, + }); + const where = vi.fn().mockReturnThis(); + const select = vi.fn().mockReturnValue({ where, executeTakeFirst }); + mockDb.selectFrom = vi.fn().mockReturnValue({ select }); + + const prefs = await service.getPreferences(USER_ID); + expect(prefs.emailMentions).toBe(true); + expect(prefs.emailReplies).toBe(true); + expect(prefs.emailShares).toBe(true); + }); + + it('getPreferences: returns default prefs when user not found', async () => { + const executeTakeFirst = vi.fn().mockResolvedValue(undefined); + const where = vi.fn().mockReturnThis(); + const select = vi.fn().mockReturnValue({ where, executeTakeFirst }); + mockDb.selectFrom = vi.fn().mockReturnValue({ select }); + + const prefs = await service.getPreferences(USER_ID); + expect(prefs).toEqual({ + emailMentions: true, + emailReplies: true, + emailShares: true, + inAppMentions: true, + inAppReplies: true, + inAppShares: true, + }); + }); +}); diff --git a/apps/server/src/core/acadenice/notifications/spec/notifications.controller.spec.ts b/apps/server/src/core/acadenice/notifications/spec/notifications.controller.spec.ts new file mode 100644 index 00000000..008b659f --- /dev/null +++ b/apps/server/src/core/acadenice/notifications/spec/notifications.controller.spec.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Test } from '@nestjs/testing'; +import { BadRequestException } from '@nestjs/common'; +import { AcadeniceNotificationsController } from '../controllers/notifications.controller'; +import { NotificationService } from '../../../notification/notification.service'; + +/** + * Unit tests for AcadeniceNotificationsController (R3.7). + * + * NotificationService is fully mocked. + */ + +const USER = { id: 'user-uuid-1' } as any; + +const mockNotificationService = { + findByUserId: vi.fn(), + getUnreadCount: vi.fn(), + markAllAsRead: vi.fn(), + markMultipleAsRead: vi.fn(), + markAsRead: vi.fn(), +}; + +describe('AcadeniceNotificationsController', () => { + let controller: AcadeniceNotificationsController; + + beforeEach(async () => { + vi.clearAllMocks(); + const module = await Test.createTestingModule({ + controllers: [AcadeniceNotificationsController], + providers: [ + { provide: NotificationService, useValue: mockNotificationService }, + ], + }).compile(); + + controller = module.get(AcadeniceNotificationsController); + }); + + // --------------------------------------------------------------------------- + // list + // --------------------------------------------------------------------------- + + it('list: calls findByUserId with default params', async () => { + mockNotificationService.findByUserId.mockResolvedValue({ items: [], hasNextPage: false }); + const result = await controller.list(USER, {}); + expect(mockNotificationService.findByUserId).toHaveBeenCalledWith( + USER.id, + { limit: 20, cursor: undefined }, + 'all', + ); + expect(result).toEqual({ items: [], hasNextPage: false }); + }); + + it('list: forwards limit and cursor', async () => { + mockNotificationService.findByUserId.mockResolvedValue({ items: [], hasNextPage: false }); + await controller.list(USER, { limit: '5', cursor: '11111111-1111-1111-1111-111111111111' }); + expect(mockNotificationService.findByUserId).toHaveBeenCalledWith( + USER.id, + { limit: 5, cursor: '11111111-1111-1111-1111-111111111111' }, + 'all', + ); + }); + + it('list: rejects invalid limit', async () => { + await expect(controller.list(USER, { limit: '999' })).rejects.toThrow( + BadRequestException, + ); + }); + + it('list: rejects invalid tab value', async () => { + await expect(controller.list(USER, { tab: 'invalid' })).rejects.toThrow( + BadRequestException, + ); + }); + + // --------------------------------------------------------------------------- + // unreadCount + // --------------------------------------------------------------------------- + + it('unreadCount: returns count', async () => { + mockNotificationService.getUnreadCount.mockResolvedValue({ count: 7 }); + const result = await controller.unreadCount(USER); + expect(result).toEqual({ count: 7 }); + expect(mockNotificationService.getUnreadCount).toHaveBeenCalledWith(USER.id); + }); + + // --------------------------------------------------------------------------- + // readAll + // --------------------------------------------------------------------------- + + it('readAll: calls markAllAsRead', async () => { + mockNotificationService.markAllAsRead.mockResolvedValue(undefined); + await controller.readAll(USER); + expect(mockNotificationService.markAllAsRead).toHaveBeenCalledWith(USER.id); + }); + + // --------------------------------------------------------------------------- + // markRead + // --------------------------------------------------------------------------- + + it('markRead: calls markMultipleAsRead with ids', async () => { + mockNotificationService.markMultipleAsRead.mockResolvedValue(undefined); + const ids = ['aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa']; + await controller.markRead(USER, { notificationIds: ids }); + expect(mockNotificationService.markMultipleAsRead).toHaveBeenCalledWith( + ids, + USER.id, + ); + }); + + it('markRead: rejects empty notificationIds', async () => { + await expect( + controller.markRead(USER, { notificationIds: [] }), + ).rejects.toThrow(BadRequestException); + }); + + it('markRead: rejects non-UUID ids', async () => { + await expect( + controller.markRead(USER, { notificationIds: ['not-a-uuid'] }), + ).rejects.toThrow(BadRequestException); + }); + + // --------------------------------------------------------------------------- + // markOne + // --------------------------------------------------------------------------- + + it('markOne: calls markAsRead', async () => { + mockNotificationService.markAsRead.mockResolvedValue(undefined); + const id = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; + await controller.markOne(id, USER); + expect(mockNotificationService.markAsRead).toHaveBeenCalledWith(id, USER.id); + }); +}); diff --git a/apps/server/src/core/core.module.ts b/apps/server/src/core/core.module.ts index fce4018a..ba0b5ba7 100644 --- a/apps/server/src/core/core.module.ts +++ b/apps/server/src/core/core.module.ts @@ -32,6 +32,8 @@ import { AcadeniceSlashCommandsModule } from './acadenice/slash-commands/slash-c import { AcadeniceGraphModule } from './acadenice/graph/graph.module'; // Acadenice R3.6 — page templates import { AcadeniceTemplatesModule } from './acadenice/templates/templates.module'; +// Acadenice R3.7 — mention notifications +import { AcadeniceNotificationsModule } from './acadenice/notifications/notifications.module'; import { ClsMiddleware } from 'nestjs-cls'; @Module({ @@ -58,6 +60,7 @@ import { ClsMiddleware } from 'nestjs-cls'; AcadeniceSlashCommandsModule, AcadeniceGraphModule, AcadeniceTemplatesModule, + AcadeniceNotificationsModule, ], }) export class CoreModule implements NestModule {