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 && (
+ }
+ onClick={handleMarkAllRead}
+ loading={markAllRead.isPending}
+ >
+ {t("acadenice.notifications.mark_all_read")}
+
+ )}
+
+
+
+
+
+ {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 {