feat(acadedoc): add AcadeDoc branding, Brevo SMTP preset, UI customization — R4.4

- Rebranding: BRAND_NAME env var (default AcadeDoc) replaces hardcoded "DocAdenice"
  in index.html title/meta, PWA manifest, app-header logo text, email footer/body
- lib/config.ts: getAppName() reads BRAND_NAME; new getBrandLogoUrl/PrimaryColor/AccentColor helpers
- vite.config.ts: BRAND_* vars exposed via define block to client
- brand-theme.ts: getBrandTheme() generates 10-shade MantineColorsTuple from hex
  (no @mantine/colors-generator dep); merged into MantineProvider at boot
- theme/__tests__/brand-theme.test.ts: 11 vitest tests (generateColorTuple + getBrandTheme)
- Workspace branding: migration adds primary_color/accent_color to workspaces table
  WorkspaceBrandingService + WorkspaceBrandingController (POST /workspace/branding,
  POST /workspace/branding/update — admin only) + DTO hex validation
- Settings: /settings/branding page (WorkspaceBranding) + sidebar entry (admin-only)
- workspace-branding.spec.ts: 13 vitest tests (service + controller + DTO validation)
- SMTP Brevo: .env.example preset block + transactional/README.md ops guide
  (key gen, port 587 STARTTLS, 300/day free limit, swaks/curl test)
- environment.service.ts: getMailFromName() falls back to BRAND_NAME if MAIL_FROM_NAME unset
- vitest.config.ts server: include pattern extended to src/core/workspace/spec/**
- i18n: 11 branding keys added to en-US and fr-FR translations
- 0 TypeScript errors client + server, 11 client + 13 server new tests all green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Corentin JOGUET 2026-05-08 11:36:38 +02:00
parent d0b75774d8
commit b53ab5043f
27 changed files with 1012 additions and 18 deletions

View file

@ -57,6 +57,35 @@ DEBUG_DB=false
# Log http requests # Log http requests
LOG_HTTP=false LOG_HTTP=false
# ─── Branding (AcadeDoc selfhost) ────────────────────────────────────
# Override the visible product name (UI, emails, PWA manifest).
# Leave unset to keep the upstream "Docmost" default for pure selfhosters.
BRAND_NAME=AcadeDoc
# Optional: absolute URL to a custom logo asset served by your CDN or nginx.
# Default: the built-in Docmost favicon (no override needed for development).
# BRAND_LOGO_URL=/branding/acadedoc-logo.svg
# Optional CSS hex colors. Drives the Mantine theme at runtime (no rebuild).
# Default values match the Acadenice brand palette.
BRAND_PRIMARY_COLOR=#2563eb
BRAND_ACCENT_COLOR=#7c3aed
# ─── SMTP Brevo (recommande pour AcadeDoc) ─────────────────────
# Compte Brevo : https://app.brevo.com/settings/keys/smtp
# Pas le mot de passe du compte — generer une "SMTP key" dans le dashboard.
# Plan free Brevo : 300 emails/jour (suffisant pour usage interne).
#
# MAIL_DRIVER=smtp
# SMTP_HOST=smtp-relay.brevo.com
# SMTP_PORT=587
# SMTP_USERNAME=<login-email-brevo>
# SMTP_PASSWORD=<smtp-master-key-brevo>
# SMTP_SECURE=false
# SMTP_IGNORETLS=false
#
# MAIL_FROM_ADDRESS=noreply@acadenice.fr
# MAIL_FROM_NAME=AcadeDoc
# (MAIL_FROM_NAME defaults to BRAND_NAME if unset)
# ─── OIDC (Authentik) — Bloc 4b ────────────────────────────────────── # ─── OIDC (Authentik) — Bloc 4b ──────────────────────────────────────
# Disabled by default. Set OIDC_ENABLED=true and fill the block below # Disabled by default. Set OIDC_ENABLED=true and fill the block below
# to expose /api/auth/oidc/login and the SSO button on the login page. # to expose /api/auth/oidc/login and the SSO button on the login page.

View file

@ -5,13 +5,14 @@
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32x32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16x16.png" /> <link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16x16.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0 user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0 user-scalable=no" />
<title>DocAdenice</title> <title>AcadeDoc</title>
<meta name="description" content="AcadeDoc — collaborative wiki for Acadenice" />
<meta name="theme-color" content="#1f1f1f" media="(prefers-color-scheme: dark)" /> <meta name="theme-color" content="#1f1f1f" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#f6f7f9" media="(prefers-color-scheme: light)" /> <meta name="theme-color" content="#f6f7f9" media="(prefers-color-scheme: light)" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-touch-fullscreen" content="yes" /> <meta name="apple-touch-fullscreen" content="yes" />
<meta name="apple-mobile-web-app-title" content="DocAdenice" /> <meta name="apple-mobile-web-app-title" content="AcadeDoc" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" /> <meta name="apple-mobile-web-app-status-bar-style" content="default" />
<!--meta-tags--> <!--meta-tags-->

View file

@ -1,5 +1,15 @@
{ {
"Account": "Account", "Account": "Account",
"Accent color": "Accent color",
"Branding": "Branding",
"Branding settings": "Branding settings",
"Primary color": "Primary color",
"Save branding": "Save branding",
"Branding saved": "Branding saved",
"Branding update failed": "Branding update failed",
"Enter a hex color (e.g. #2563eb)": "Enter a hex color (e.g. #2563eb)",
"Workspace logo": "Workspace logo",
"Logo upload and name-based branding override is managed from the General settings tab.": "Logo upload and name-based branding override is managed from the General settings tab.",
"Active": "Active", "Active": "Active",
"Add": "Add", "Add": "Add",
"Add group members": "Add group members", "Add group members": "Add group members",

View file

@ -1,5 +1,15 @@
{ {
"Account": "Compte", "Account": "Compte",
"Accent color": "Couleur d'accentuation",
"Branding": "Personnalisation",
"Branding settings": "Paramètres de personnalisation",
"Primary color": "Couleur primaire",
"Save branding": "Enregistrer",
"Branding saved": "Personnalisation enregistrée",
"Branding update failed": "Echec de la mise à jour",
"Enter a hex color (e.g. #2563eb)": "Entrez une couleur hex (ex : #2563eb)",
"Workspace logo": "Logo de l'espace de travail",
"Logo upload and name-based branding override is managed from the General settings tab.": "Le logo et le nom se configurent depuis l'onglet Général.",
"Active": "Actif", "Active": "Actif",
"Add": "Ajouter", "Add": "Ajouter",
"Add group members": "Ajouter des membres au groupe", "Add group members": "Ajouter des membres au groupe",

View file

@ -1,6 +1,6 @@
{ {
"name": "Docmost", "name": "AcadeDoc",
"short_name": "Docmost", "short_name": "AcadeDoc",
"start_url": "/", "start_url": "/",
"display": "standalone", "display": "standalone",
"background_color": "#222", "background_color": "#222",

View file

@ -6,6 +6,7 @@ import Page from "@/pages/page/page";
import AccountSettings from "@/pages/settings/account/account-settings"; import AccountSettings from "@/pages/settings/account/account-settings";
import WorkspaceMembers from "@/pages/settings/workspace/workspace-members"; import WorkspaceMembers from "@/pages/settings/workspace/workspace-members";
import WorkspaceSettings from "@/pages/settings/workspace/workspace-settings"; import WorkspaceSettings from "@/pages/settings/workspace/workspace-settings";
import WorkspaceBranding from "@/pages/settings/workspace/workspace-branding";
import Groups from "@/pages/settings/group/groups"; import Groups from "@/pages/settings/group/groups";
import GroupInfo from "./pages/settings/group/group-info"; import GroupInfo from "./pages/settings/group/group-info";
import Spaces from "@/pages/settings/space/spaces.tsx"; import Spaces from "@/pages/settings/space/spaces.tsx";
@ -57,6 +58,8 @@ import TemplatesAdminPage from "@/features/acadenice/templates-admin/pages/templ
// Acadenice R3.7 — mention notifications // Acadenice R3.7 — mention notifications
import AcadeniceNotificationsPage from "@/features/acadenice/notifications/pages/notifications-page"; import AcadeniceNotificationsPage from "@/features/acadenice/notifications/pages/notifications-page";
import NotificationPreferencesPage from "@/features/acadenice/notifications/pages/notification-preferences-page"; import NotificationPreferencesPage from "@/features/acadenice/notifications/pages/notification-preferences-page";
// Acadenice R4.3 — Web Clipper token management
import ClipperTokensPage from "@/features/acadenice/clipper/pages/clipper-tokens-page";
export default function App() { export default function App() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -152,6 +155,10 @@ export default function App() {
<Route path={"templates"} element={<TemplatesAdminPage />} /> <Route path={"templates"} element={<TemplatesAdminPage />} />
{/* Acadenice R3.7 — notification preferences */} {/* Acadenice R3.7 — notification preferences */}
<Route path={"notifications"} element={<NotificationPreferencesPage />} /> <Route path={"notifications"} element={<NotificationPreferencesPage />} />
{/* Acadenice R4.3 — Web Clipper token management */}
<Route path={"clipper-tokens"} element={<ClipperTokensPage />} />
{/* Acadenice R4.4 — Workspace branding */}
<Route path={"branding"} element={<WorkspaceBranding />} />
{!isCloud() && <Route path={"license"} element={<License />} />} {!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />} {isCloud() && <Route path={"billing"} element={<Billing />} />}
</Route> </Route>

View file

@ -23,7 +23,7 @@ import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-to
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx"; import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useTrial from "@/ee/hooks/use-trial.tsx"; import useTrial from "@/ee/hooks/use-trial.tsx";
import { isCloud } from "@/lib/config.ts"; import { isCloud, getAppName } from "@/lib/config.ts";
import { import {
SearchControl, SearchControl,
SearchMobileControl, SearchMobileControl,
@ -84,11 +84,11 @@ export function AppHeader() {
/> />
</Tooltip> </Tooltip>
<Link to="/home" className={classes.brand} aria-label="DocAdenice"> <Link to="/home" className={classes.brand} aria-label={getAppName()}>
<Box hiddenFrom="sm" className={classes.brandIcon}> <Box hiddenFrom="sm" className={classes.brandIcon}>
<img <img
src="/icons/favicon-32x32.png" src="/icons/favicon-32x32.png"
alt="DocAdenice" alt={getAppName()}
width={22} width={22}
height={22} height={22}
/> />
@ -99,7 +99,7 @@ export function AppHeader() {
style={{ userSelect: "none" }} style={{ userSelect: "none" }}
visibleFrom="sm" visibleFrom="sm"
> >
DocAdenice {getAppName()}
</Text> </Text>
</Link> </Link>

View file

@ -19,6 +19,7 @@ import {
IconSlash, IconSlash,
IconTemplate, IconTemplate,
IconBell, IconBell,
IconScissors,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useAcadenicePermissions } from "@/features/acadenice/rbac/hooks/use-acadenice-permissions"; import { useAcadenicePermissions } from "@/features/acadenice/rbac/hooks/use-acadenice-permissions";
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
@ -89,12 +90,25 @@ const groupedData: DataGroup[] = [
path: "/settings/account/api-keys", path: "/settings/account/api-keys",
feature: Feature.API_KEYS, feature: Feature.API_KEYS,
}, },
{
// Acadenice R4.3 — Web Clipper token management
label: "Clipper tokens",
icon: IconScissors,
path: "/settings/clipper-tokens",
},
], ],
}, },
{ {
heading: "Workspace", heading: "Workspace",
items: [ items: [
{ label: "General", icon: IconSettings, path: "/settings/workspace" }, { label: "General", icon: IconSettings, path: "/settings/workspace" },
{
// Acadenice R4.4 — workspace brand colors
label: "Branding",
icon: IconBrush,
path: "/settings/branding",
role: "admin" as const,
},
{ label: "Members", icon: IconUsers, path: "/settings/members" }, { label: "Members", icon: IconUsers, path: "/settings/members" },
{ {
label: "Billing", label: "Billing",

View file

@ -122,3 +122,26 @@ export async function getAppVersion(): Promise<IVersion> {
const req = await api.post("/version"); const req = await api.post("/version");
return req.data; return req.data;
} }
// R4.4 — branding endpoints
export interface IWorkspaceBranding {
logo: string | null;
primaryColor: string | null;
accentColor: string | null;
}
export async function getWorkspaceBranding(): Promise<IWorkspaceBranding> {
const req = await api.post<IWorkspaceBranding>("/workspace/branding");
return req.data;
}
export async function updateWorkspaceBranding(
data: Partial<Pick<IWorkspaceBranding, "primaryColor" | "accentColor">>,
): Promise<IWorkspaceBranding> {
const req = await api.post<IWorkspaceBranding>(
"/workspace/branding/update",
data,
);
return req.data;
}

View file

@ -10,7 +10,19 @@ declare global {
} }
export function getAppName(): string { export function getAppName(): string {
return "DocAdenice"; return getConfigValue("BRAND_NAME", "AcadeDoc");
}
export function getBrandLogoUrl(): string | null {
return getConfigValue("BRAND_LOGO_URL") || null;
}
export function getBrandPrimaryColor(): string {
return getConfigValue("BRAND_PRIMARY_COLOR", "#2563eb");
}
export function getBrandAccentColor(): string {
return getConfigValue("BRAND_ACCENT_COLOR", "#7c3aed");
} }
export function getAppUrl(): string { export function getAppUrl(): string {

View file

@ -7,6 +7,7 @@ import ReactDOM from "react-dom/client";
import App from "./App.tsx"; import App from "./App.tsx";
import { mantineCssResolver, theme } from "@/theme"; import { mantineCssResolver, theme } from "@/theme";
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { getBrandTheme } from "@/theme/brand-theme";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import { ModalsProvider } from "@mantine/modals"; import { ModalsProvider } from "@mantine/modals";
import { Notifications } from "@mantine/notifications"; import { Notifications } from "@mantine/notifications";
@ -42,12 +43,25 @@ if (isCloud() && isPostHogEnabled) {
}); });
} }
const brandOverride = getBrandTheme();
// Deep-merge brand color overrides into the base theme by spreading.
const brandedTheme = {
...theme,
colors: { ...theme.colors, ...brandOverride.colors },
...(brandOverride.primaryColor !== undefined
? { primaryColor: brandOverride.primaryColor }
: {}),
...(brandOverride.primaryShade !== undefined
? { primaryShade: brandOverride.primaryShade }
: {}),
};
const container = document.getElementById("root") as HTMLElement; const container = document.getElementById("root") as HTMLElement;
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container); const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
root.render( root.render(
<BrowserRouter> <BrowserRouter>
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}> <MantineProvider theme={brandedTheme} cssVariablesResolver={mantineCssResolver}>
<ModalsProvider> <ModalsProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Notifications position="bottom-center" limit={3} zIndex={10000} /> <Notifications position="bottom-center" limit={3} zIndex={10000} />

View file

@ -0,0 +1,140 @@
/**
* workspace-branding.tsx R4.4
*
* Settings page: /settings/branding
* Admin-only. Lets workspace admins set the primary and accent brand colors
* (hex). Logo upload is handled on the General tab via WorkspaceIcon component.
*/
import { useState } from "react";
import {
Button,
ColorInput,
Divider,
Group,
Stack,
Text,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Helmet } from "react-helmet-async";
import { notifications } from "@mantine/notifications";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import { getAppName } from "@/lib/config.ts";
import {
getWorkspaceBranding,
updateWorkspaceBranding,
} from "@/features/workspace/services/workspace-service.ts";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
const HEX_RE = /^#[0-9A-Fa-f]{6}$/;
function isValidHex(v: string): boolean {
return HEX_RE.test(v);
}
export default function WorkspaceBranding() {
const { t } = useTranslation();
const qc = useQueryClient();
const { data: branding } = useQuery({
queryKey: ["workspace-branding"],
queryFn: getWorkspaceBranding,
});
const [primaryColor, setPrimaryColor] = useState<string>(
branding?.primaryColor ?? "#2563eb",
);
const [accentColor, setAccentColor] = useState<string>(
branding?.accentColor ?? "#7c3aed",
);
// Sync state when data loads
const loadedPrimary = branding?.primaryColor ?? "#2563eb";
const loadedAccent = branding?.accentColor ?? "#7c3aed";
const mutation = useMutation({
mutationFn: updateWorkspaceBranding,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["workspace-branding"] });
notifications.show({
message: t("Branding saved"),
color: "green",
});
},
onError: () => {
notifications.show({
message: t("Branding update failed"),
color: "red",
});
},
});
function handleSave() {
const patch: { primaryColor?: string; accentColor?: string } = {};
if (isValidHex(primaryColor) && primaryColor !== loadedPrimary) {
patch.primaryColor = primaryColor;
}
if (isValidHex(accentColor) && accentColor !== loadedAccent) {
patch.accentColor = accentColor;
}
if (Object.keys(patch).length === 0) return;
mutation.mutate(patch);
}
return (
<>
<Helmet>
<title>
{t("Branding settings")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Branding")} />
<Text size="sm" c="dimmed" mb="md">
{t(
"Logo upload and name-based branding override is managed from the General settings tab.",
)}
</Text>
<Divider mb="md" />
<Stack gap="md" maw={400}>
<ColorInput
label={t("Primary color")}
description={t("Enter a hex color (e.g. #2563eb)")}
placeholder="#2563eb"
value={primaryColor}
onChange={setPrimaryColor}
format="hex"
withEyeDropper
aria-label={t("Primary color")}
/>
<ColorInput
label={t("Accent color")}
description={t("Enter a hex color (e.g. #2563eb)")}
placeholder="#7c3aed"
value={accentColor}
onChange={setAccentColor}
format="hex"
withEyeDropper
aria-label={t("Accent color")}
/>
<Group>
<Button
onClick={handleSave}
loading={mutation.isPending}
disabled={
(!isValidHex(primaryColor) && !isValidHex(accentColor)) ||
(primaryColor === loadedPrimary && accentColor === loadedAccent)
}
>
{t("Save branding")}
</Button>
</Group>
</Stack>
</>
);
}

View file

@ -0,0 +1,99 @@
/**
* Tests for brand-theme.ts R4.4
*
* Covers: tuple generation, env-var overrides, fallback on invalid input.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { generateColorTuple, getBrandTheme } from "../brand-theme";
// Mantine color tuple has exactly 10 entries.
const TUPLE_LENGTH = 10;
describe("generateColorTuple", () => {
it("returns exactly 10 shades", () => {
const tuple = generateColorTuple("#2563eb");
expect(tuple).toHaveLength(TUPLE_LENGTH);
});
it("all shades are valid hex strings", () => {
const tuple = generateColorTuple("#7c3aed");
const hexRegex = /^#[0-9a-f]{6}$/i;
tuple.forEach((shade) => {
expect(shade).toMatch(hexRegex);
});
});
it("shade[0] is lighter than shade[9] (R component higher)", () => {
const tuple = generateColorTuple("#2563eb");
const rAt0 = parseInt(tuple[0].slice(1, 3), 16);
const rAt9 = parseInt(tuple[9].slice(1, 3), 16);
expect(rAt0).toBeGreaterThan(rAt9);
});
it("returns fallback gray scale for invalid hex", () => {
const tuple = generateColorTuple("notacolor");
expect(tuple).toHaveLength(TUPLE_LENGTH);
// First shade of fallback is a light gray
expect(tuple[0]).toBe("#f8f9fa");
});
it("handles uppercase hex without crashing", () => {
const tuple = generateColorTuple("#2563EB");
expect(tuple).toHaveLength(TUPLE_LENGTH);
tuple.forEach((s) => expect(s).toMatch(/^#[0-9a-f]{6}$/i));
});
});
describe("getBrandTheme", () => {
const originalEnv = { ...process.env };
afterEach(() => {
// Restore env after each test
Object.assign(process.env, originalEnv);
vi.unstubAllEnvs();
});
it("returns brand and accent color keys in theme.colors", () => {
const theme = getBrandTheme();
expect(theme.colors).toHaveProperty("brand");
expect(theme.colors).toHaveProperty("accent");
});
it("uses default primary color (#2563eb) when env is unset", () => {
delete process.env.BRAND_PRIMARY_COLOR;
const theme = getBrandTheme();
// Shade[6] of the default primary should not be fully black or white
const shade6 = (theme.colors!.brand as unknown as string[])[6];
expect(shade6).toBeTruthy();
expect(shade6).not.toBe("#ffffff");
expect(shade6).not.toBe("#000000");
});
it("overrides primary color via BRAND_PRIMARY_COLOR env var", () => {
process.env.BRAND_PRIMARY_COLOR = "#ff0000";
const theme = getBrandTheme();
// shade[6] of #ff0000 with 0% black mix = #ff0000 itself
const shade6 = (theme.colors!.brand as unknown as string[])[6];
expect(shade6.toLowerCase()).toBe("#ff0000");
});
it("falls back to default when BRAND_PRIMARY_COLOR is invalid", () => {
process.env.BRAND_PRIMARY_COLOR = "badvalue";
const defaultTheme = getBrandTheme();
delete process.env.BRAND_PRIMARY_COLOR;
const normalTheme = getBrandTheme();
// Both should produce identical tuples (both use default)
expect(defaultTheme.colors!.brand).toEqual(normalTheme.colors!.brand);
});
it("sets primaryColor to 'brand'", () => {
const theme = getBrandTheme();
expect(theme.primaryColor).toBe("brand");
});
it("sets primaryShade to 6", () => {
const theme = getBrandTheme();
expect(theme.primaryShade).toBe(6);
});
});

View file

@ -0,0 +1,135 @@
/**
* brand-theme.ts R4.4
*
* Reads BRAND_PRIMARY_COLOR and BRAND_ACCENT_COLOR from vite define block
* (process.env.*) and produces a MantineThemeOverride with generated color
* tuples. Falls back to hardcoded defaults so the app is never misconfigured.
*
* We do not depend on @mantine/colors-generator (not installed). Instead we
* generate a 10-shade tuple by progressively mixing the hex toward white (light
* shades) and toward black (dark shades) using a simple linear interpolation.
* This is good enough for UI tokens; we are not doing a perceptual-uniform
* lightness curve here.
*/
import { MantineColorsTuple, MantineThemeOverride } from "@mantine/core";
const DEFAULT_PRIMARY = "#2563eb";
const DEFAULT_ACCENT = "#7c3aed";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function hexToRgb(hex: string): [number, number, number] | null {
const clean = hex.replace(/^#/, "");
if (clean.length !== 6) return null;
const r = parseInt(clean.slice(0, 2), 16);
const g = parseInt(clean.slice(2, 4), 16);
const b = parseInt(clean.slice(4, 6), 16);
if (isNaN(r) || isNaN(g) || isNaN(b)) return null;
return [r, g, b];
}
function rgbToHex(r: number, g: number, b: number): string {
return (
"#" +
[r, g, b]
.map((v) =>
Math.round(Math.max(0, Math.min(255, v)))
.toString(16)
.padStart(2, "0"),
)
.join("")
);
}
/**
* Mix `color` toward `target` by `ratio` (0 = color, 1 = target).
*/
function mix(
color: [number, number, number],
target: [number, number, number],
ratio: number,
): string {
return rgbToHex(
color[0] + (target[0] - color[0]) * ratio,
color[1] + (target[1] - color[1]) * ratio,
color[2] + (target[2] - color[2]) * ratio,
);
}
const WHITE: [number, number, number] = [255, 255, 255];
const BLACK: [number, number, number] = [0, 0, 0];
/**
* Generate a 10-shade MantineColorsTuple from a single hex color.
*
* Mantine uses shades[6] as the "primary" shade by convention.
* Shades 0-5 go from very light to the base color.
* Shades 6-9 go from the base color to very dark.
*/
export function generateColorTuple(hex: string): MantineColorsTuple {
const rgb = hexToRgb(hex);
if (!rgb) {
// Fallback: return a neutral gray scale — should never happen in practice.
return [
"#f8f9fa",
"#f1f3f5",
"#e9ecef",
"#dee2e6",
"#ced4da",
"#adb5bd",
"#868e96",
"#495057",
"#343a40",
"#212529",
] as unknown as MantineColorsTuple;
}
// Shades 0-5: mix toward white (lightest = 0)
const lightSteps = [0.92, 0.80, 0.65, 0.50, 0.30, 0.10];
// Shades 6-9: mix toward black (darkest = 9)
const darkSteps = [0.0, 0.15, 0.35, 0.55];
const shades: string[] = [
...lightSteps.map((ratio) => mix(rgb, WHITE, ratio)),
...darkSteps.map((ratio) => mix(rgb, BLACK, ratio)),
];
return shades as unknown as MantineColorsTuple;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Reads BRAND_PRIMARY_COLOR and BRAND_ACCENT_COLOR from the vite define block
* and returns a MantineThemeOverride that can be merged into the base theme.
*
* Called once at startup in main.tsx.
*/
export function getBrandTheme(): MantineThemeOverride {
const rawPrimary =
(typeof process !== "undefined" && process.env?.BRAND_PRIMARY_COLOR) ||
DEFAULT_PRIMARY;
const rawAccent =
(typeof process !== "undefined" && process.env?.BRAND_ACCENT_COLOR) ||
DEFAULT_ACCENT;
// Validate: if the value is not a valid hex, fall back to the default.
const primary = hexToRgb(rawPrimary) ? rawPrimary : DEFAULT_PRIMARY;
const accent = hexToRgb(rawAccent) ? rawAccent : DEFAULT_ACCENT;
return {
colors: {
brand: generateColorTuple(primary),
accent: generateColorTuple(accent),
},
// Map Mantine's default "blue" primary to our brand color so existing
// components that rely on color="blue" pick up the brand palette.
primaryColor: "brand",
primaryShade: 6,
};
}

View file

@ -16,6 +16,10 @@ export default defineConfig(({ mode }) => {
BILLING_TRIAL_DAYS, BILLING_TRIAL_DAYS,
POSTHOG_HOST, POSTHOG_HOST,
POSTHOG_KEY, POSTHOG_KEY,
BRAND_NAME,
BRAND_LOGO_URL,
BRAND_PRIMARY_COLOR,
BRAND_ACCENT_COLOR,
} = loadEnv(mode, envPath, ""); } = loadEnv(mode, envPath, "");
return { return {
@ -31,6 +35,10 @@ export default defineConfig(({ mode }) => {
BILLING_TRIAL_DAYS, BILLING_TRIAL_DAYS,
POSTHOG_HOST, POSTHOG_HOST,
POSTHOG_KEY, POSTHOG_KEY,
BRAND_NAME: BRAND_NAME || "AcadeDoc",
BRAND_LOGO_URL: BRAND_LOGO_URL || "",
BRAND_PRIMARY_COLOR: BRAND_PRIMARY_COLOR || "#2563eb",
BRAND_ACCENT_COLOR: BRAND_ACCENT_COLOR || "#7c3aed",
}, },
APP_VERSION: JSON.stringify(process.env.npm_package_version), APP_VERSION: JSON.stringify(process.env.npm_package_version),
}, },

View file

@ -0,0 +1,61 @@
import {
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
Post,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.factory';
import {
WorkspaceCaslAction,
WorkspaceCaslSubject,
} from '../../casl/interfaces/workspace-ability.type';
import { WorkspaceBrandingService } from '../services/workspace-branding.service';
import { UpdateWorkspaceBrandingDto } from '../dto/update-workspace-branding.dto';
/**
* R4.4 Workspace branding REST endpoints.
*
* GET /workspace/branding read current branding (any authenticated user)
* POST /workspace/branding/update update branding (workspace admin only)
*
* Logo upload is handled separately by the existing attachment pipeline:
* POST /attachments/workspace-icon (multipart, no change in this patch)
*/
@UseGuards(JwtAuthGuard)
@Controller('workspace/branding')
export class WorkspaceBrandingController {
constructor(
private readonly brandingService: WorkspaceBrandingService,
private readonly workspaceAbility: WorkspaceAbilityFactory,
) {}
@HttpCode(HttpStatus.OK)
@Post()
async getBranding(@AuthWorkspace() workspace: Workspace) {
return this.brandingService.getBranding(workspace.id);
}
@HttpCode(HttpStatus.OK)
@Post('update')
async updateBranding(
@Body() dto: UpdateWorkspaceBrandingDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Settings)
) {
throw new ForbiddenException();
}
return this.brandingService.updateBranding(workspace.id, dto);
}
}

View file

@ -0,0 +1,26 @@
import { IsOptional, IsString, Matches, MaxLength } from 'class-validator';
const HEX_COLOR_RE = /^#[0-9A-Fa-f]{6}$/;
const HEX_MESSAGE = 'Must be a valid 6-digit CSS hex color (e.g. #2563eb)';
/**
* R4.4 DTO for the workspace branding endpoint.
*
* All fields are optional: the caller may update only one at a time.
* Logo upload is handled by a separate multipart endpoint (existing attachment
* flow via uploadWorkspaceIcon on the client + POST /attachments/workspace-icon
* on the server unchanged).
*/
export class UpdateWorkspaceBrandingDto {
@IsOptional()
@IsString()
@MaxLength(20)
@Matches(HEX_COLOR_RE, { message: HEX_MESSAGE })
primaryColor?: string;
@IsOptional()
@IsString()
@MaxLength(20)
@Matches(HEX_COLOR_RE, { message: HEX_MESSAGE })
accentColor?: string;
}

View file

@ -0,0 +1,49 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { UpdateWorkspaceBrandingDto } from '../dto/update-workspace-branding.dto';
/**
* R4.4 WorkspaceBrandingService.
*
* Responsible for persisting and retrieving per-workspace brand overrides
* (primary_color, accent_color). Logo upload is already handled by the
* existing attachment pipeline (AttachmentController + uploadWorkspaceIcon).
*/
@Injectable()
export class WorkspaceBrandingService {
constructor(private readonly workspaceRepo: WorkspaceRepo) {}
async getBranding(workspaceId: string) {
const workspace = await this.workspaceRepo.findById(workspaceId);
if (!workspace) {
throw new NotFoundException('Workspace not found');
}
return {
logo: workspace.logo ?? null,
primaryColor: (workspace as any).primaryColor ?? null,
accentColor: (workspace as any).accentColor ?? null,
};
}
async updateBranding(
workspaceId: string,
dto: UpdateWorkspaceBrandingDto,
) {
const workspace = await this.workspaceRepo.findById(workspaceId);
if (!workspace) {
throw new NotFoundException('Workspace not found');
}
const patch: Record<string, string | null> = {};
if (dto.primaryColor !== undefined) patch.primaryColor = dto.primaryColor;
if (dto.accentColor !== undefined) patch.accentColor = dto.accentColor;
if (Object.keys(patch).length === 0) {
// Nothing to update — return current state.
return this.getBranding(workspaceId);
}
await this.workspaceRepo.updateWorkspace(patch as any, workspaceId);
return this.getBranding(workspaceId);
}
}

View file

@ -0,0 +1,200 @@
/**
* workspace-branding.spec.ts R4.4
*
* Unit tests for WorkspaceBrandingService + WorkspaceBrandingController.
* Uses vitest + vi.fn() stubs no real DB or HTTP server needed.
*
* Covered:
* 1. getBranding returns correct shape
* 2. getBranding throws NotFoundException when workspace missing
* 3. updateBranding patches primaryColor
* 4. updateBranding patches accentColor
* 5. updateBranding is a no-op when dto is empty (returns current state)
* 6. updateBranding throws NotFoundException when workspace missing
* 7. Controller: GET branding delegates to service
* 8. Controller: POST branding/update delegates to service when admin
* 9. Controller: POST branding/update returns 403 when not admin
* 10. UpdateWorkspaceBrandingDto validates hex color correctly
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NotFoundException, ForbiddenException } from '@nestjs/common';
import { WorkspaceBrandingService } from '../services/workspace-branding.service';
import { WorkspaceBrandingController } from '../controllers/workspace-branding.controller';
import { UpdateWorkspaceBrandingDto } from '../dto/update-workspace-branding.dto';
import { validate } from 'class-validator';
import { plainToInstance } from 'class-transformer';
// ---------------------------------------------------------------------------
// Fakes
// ---------------------------------------------------------------------------
const fakeWorkspace = {
id: 'ws-uuid',
name: 'Acadenice',
logo: null,
primaryColor: null,
accentColor: null,
} as any;
function makeWorkspaceRepo(overrides?: Partial<typeof fakeWorkspace>) {
const ws = { ...fakeWorkspace, ...overrides };
return {
findById: vi.fn(async (id: string) => (id === ws.id ? ws : null)),
updateWorkspace: vi.fn(async (patch: any, id: string) => {
Object.assign(ws, patch);
}),
};
}
function makeAbility(canManage = true) {
return {
createForUser: vi.fn(() => ({
cannot: vi.fn(() => !canManage),
})),
};
}
// ---------------------------------------------------------------------------
// WorkspaceBrandingService
// ---------------------------------------------------------------------------
describe('WorkspaceBrandingService', () => {
let service: WorkspaceBrandingService;
let repo: ReturnType<typeof makeWorkspaceRepo>;
beforeEach(() => {
repo = makeWorkspaceRepo();
service = new WorkspaceBrandingService(repo as any);
});
it('1. getBranding returns correct shape', async () => {
const result = await service.getBranding('ws-uuid');
expect(result).toMatchObject({
logo: null,
primaryColor: null,
accentColor: null,
});
});
it('2. getBranding throws NotFoundException when workspace missing', async () => {
await expect(service.getBranding('bad-id')).rejects.toThrow(
NotFoundException,
);
});
it('3. updateBranding patches primaryColor', async () => {
const dto = { primaryColor: '#2563eb' } as UpdateWorkspaceBrandingDto;
const result = await service.updateBranding('ws-uuid', dto);
expect(repo.updateWorkspace).toHaveBeenCalledWith(
expect.objectContaining({ primaryColor: '#2563eb' }),
'ws-uuid',
);
expect(result.primaryColor).toBe('#2563eb');
});
it('4. updateBranding patches accentColor', async () => {
const dto = { accentColor: '#7c3aed' } as UpdateWorkspaceBrandingDto;
const result = await service.updateBranding('ws-uuid', dto);
expect(repo.updateWorkspace).toHaveBeenCalledWith(
expect.objectContaining({ accentColor: '#7c3aed' }),
'ws-uuid',
);
expect(result.accentColor).toBe('#7c3aed');
});
it('5. updateBranding is a no-op when dto is empty (returns current state)', async () => {
const dto = {} as UpdateWorkspaceBrandingDto;
await service.updateBranding('ws-uuid', dto);
expect(repo.updateWorkspace).not.toHaveBeenCalled();
});
it('6. updateBranding throws NotFoundException when workspace missing', async () => {
const dto = { primaryColor: '#123456' } as UpdateWorkspaceBrandingDto;
await expect(service.updateBranding('bad-id', dto)).rejects.toThrow(
NotFoundException,
);
});
});
// ---------------------------------------------------------------------------
// WorkspaceBrandingController
// ---------------------------------------------------------------------------
describe('WorkspaceBrandingController', () => {
let controller: WorkspaceBrandingController;
let brandingService: { getBranding: ReturnType<typeof vi.fn>; updateBranding: ReturnType<typeof vi.fn> };
let ability: ReturnType<typeof makeAbility>;
const mockWorkspace = { id: 'ws-uuid' } as any;
const mockUser = { id: 'user-uuid', role: 'admin' } as any;
beforeEach(() => {
brandingService = {
getBranding: vi.fn(async () => ({ logo: null, primaryColor: null, accentColor: null })),
updateBranding: vi.fn(async () => ({ logo: null, primaryColor: '#2563eb', accentColor: null })),
};
ability = makeAbility(true);
controller = new WorkspaceBrandingController(
brandingService as any,
ability as any,
);
});
it('7. GET branding delegates to service', async () => {
const result = await controller.getBranding(mockWorkspace);
expect(brandingService.getBranding).toHaveBeenCalledWith('ws-uuid');
expect(result).toHaveProperty('logo');
});
it('8. POST branding/update delegates to service when admin', async () => {
const dto = { primaryColor: '#2563eb' } as UpdateWorkspaceBrandingDto;
const result = await controller.updateBranding(dto, mockUser, mockWorkspace);
expect(brandingService.updateBranding).toHaveBeenCalledWith('ws-uuid', dto);
expect(result.primaryColor).toBe('#2563eb');
});
it('9. POST branding/update returns ForbiddenException when not admin', async () => {
const noAdminAbility = makeAbility(false);
const restrictedController = new WorkspaceBrandingController(
brandingService as any,
noAdminAbility as any,
);
const dto = { primaryColor: '#2563eb' } as UpdateWorkspaceBrandingDto;
await expect(
restrictedController.updateBranding(dto, mockUser, mockWorkspace),
).rejects.toThrow(ForbiddenException);
});
});
// ---------------------------------------------------------------------------
// DTO validation
// ---------------------------------------------------------------------------
describe('UpdateWorkspaceBrandingDto validation', () => {
async function validate_(raw: object) {
const dto = plainToInstance(UpdateWorkspaceBrandingDto, raw);
return validate(dto);
}
it('10. accepts valid hex colors', async () => {
const errors = await validate_({ primaryColor: '#2563eb', accentColor: '#7c3aed' });
expect(errors).toHaveLength(0);
});
it('11. rejects non-hex string for primaryColor', async () => {
const errors = await validate_({ primaryColor: 'blue' });
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].property).toBe('primaryColor');
});
it('12. rejects 3-digit short hex', async () => {
const errors = await validate_({ primaryColor: '#abc' });
expect(errors.length).toBeGreaterThan(0);
});
it('13. accepts empty DTO (all fields optional)', async () => {
const errors = await validate_({});
expect(errors).toHaveLength(0);
});
});

View file

@ -4,11 +4,13 @@ import { WorkspaceController } from './controllers/workspace.controller';
import { SpaceModule } from '../space/space.module'; import { SpaceModule } from '../space/space.module';
import { WorkspaceInvitationService } from './services/workspace-invitation.service'; import { WorkspaceInvitationService } from './services/workspace-invitation.service';
import { TokenModule } from '../auth/token.module'; import { TokenModule } from '../auth/token.module';
import { WorkspaceBrandingService } from './services/workspace-branding.service';
import { WorkspaceBrandingController } from './controllers/workspace-branding.controller';
@Module({ @Module({
imports: [SpaceModule, TokenModule], imports: [SpaceModule, TokenModule],
controllers: [WorkspaceController], controllers: [WorkspaceController, WorkspaceBrandingController],
providers: [WorkspaceService, WorkspaceInvitationService], providers: [WorkspaceService, WorkspaceInvitationService, WorkspaceBrandingService],
exports: [WorkspaceService], exports: [WorkspaceService, WorkspaceBrandingService],
}) })
export class WorkspaceModule {} export class WorkspaceModule {}

View file

@ -0,0 +1,31 @@
import { Kysely, sql } from 'kysely';
/**
* R4.4 Workspace branding columns.
*
* Adds two optional columns to `workspaces`:
* - primary_color : CSS hex string (e.g. "#2563eb") overrides the default
* brand primary palette for this workspace.
* - accent_color : CSS hex string (e.g. "#7c3aed") accent/secondary palette.
*
* The existing `logo` TEXT column already covers the workspace logo URL and is
* therefore NOT duplicated here.
*
* All DDL is idempotent (IF NOT EXISTS / DO NOTHING on constraint).
* Kysely camelCase mapping: primaryColor <-> primary_color, accentColor <-> accent_color.
*/
export async function up(db: Kysely<any>): Promise<void> {
await sql`
ALTER TABLE workspaces
ADD COLUMN IF NOT EXISTS primary_color VARCHAR(20),
ADD COLUMN IF NOT EXISTS accent_color VARCHAR(20)
`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`
ALTER TABLE workspaces
DROP COLUMN IF EXISTS primary_color,
DROP COLUMN IF EXISTS accent_color
`.execute(db);
}

View file

@ -395,6 +395,10 @@ export interface Workspaces {
stripeCustomerId: string | null; stripeCustomerId: string | null;
trialEndAt: Timestamp | null; trialEndAt: Timestamp | null;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
/** R4.4 — optional CSS hex override for the workspace primary palette. */
primaryColor: string | null;
/** R4.4 — optional CSS hex override for the workspace accent palette. */
accentColor: string | null;
} }
export interface Notifications { export interface Notifications {

View file

@ -131,7 +131,11 @@ export class EnvironmentService {
} }
getMailFromName(): string { getMailFromName(): string {
return this.configService.get<string>('MAIL_FROM_NAME', 'DocAdenice'); // MAIL_FROM_NAME can be overridden explicitly; if not set, fall back to
// BRAND_NAME so that outgoing mail uses the configured brand by default.
const explicit = this.configService.get<string>('MAIL_FROM_NAME');
if (explicit) return explicit;
return this.configService.get<string>('BRAND_NAME', 'AcadeDoc');
} }
getMailBlockedRecipientDomains(): string[] { getMailBlockedRecipientDomains(): string[] {

View file

@ -0,0 +1,109 @@
# Transactional Mail — AcadeDoc
## Architecture
AcadeDoc uses NestJS + nodemailer for transactional email. Emails are rendered
with react-email and enqueued via BullMQ. The driver is selected at boot time
from `MAIL_DRIVER` (smtp | postmark | log).
All email templates live in `emails/`. Partials (header, footer, button) are
in `partials/`. CSS helpers are in `css/`.
---
## Configuring Brevo (SMTP relay)
Brevo (ex-Sendinblue) is the recommended SMTP relay for AcadeDoc selfhost.
### 1. Create a Brevo account
Go to https://app.brevo.com and register.
### 2. Generate an SMTP key
1. Log in to Brevo dashboard.
2. Navigate to **Settings > SMTP & API > SMTP**.
3. Click **Generate a new SMTP key**.
4. Copy the key — it is shown only once.
This key is your `SMTP_PASSWORD`. It is NOT your account password.
### 3. Set the environment variables
```dotenv
MAIL_DRIVER=smtp
SMTP_HOST=smtp-relay.brevo.com
SMTP_PORT=587
SMTP_USERNAME=<your-brevo-login-email>
SMTP_PASSWORD=<smtp-master-key-from-step-2>
SMTP_SECURE=false
SMTP_IGNORETLS=false
MAIL_FROM_ADDRESS=noreply@yourdomain.com
MAIL_FROM_NAME=AcadeDoc
```
`SMTP_SECURE=false` + `SMTP_IGNORETLS=false` instructs nodemailer to upgrade
the connection to TLS via STARTTLS on port 587. This is the correct mode for
Brevo (port 587 is STARTTLS, port 465 is implicit TLS / SMTP_SECURE=true).
### 4. Brevo free plan limits
| Metric | Free plan |
|--------|-----------|
| Emails/day | 300 |
| Emails/month | 9 000 |
| Contacts | Unlimited |
| SMTP relay | Yes |
For internal team usage (< 50 members), 300 emails/day is more than enough.
Upgrade if you need more capacity.
### 5. Test from the server
After deploying, send a test from the container/server shell:
```bash
# Using swaks (SMTP Swiss Army Knife)
swaks \
--to test@example.com \
--from noreply@yourdomain.com \
--server smtp-relay.brevo.com:587 \
--auth LOGIN \
--auth-user "<your-brevo-login-email>" \
--auth-password "<smtp-key>" \
--tls \
--header "Subject: AcadeDoc SMTP test"
```
Or via curl (base64-encode credentials):
```bash
curl --url "smtp://smtp-relay.brevo.com:587" \
--ssl-reqd \
--mail-from "noreply@yourdomain.com" \
--mail-rcpt "test@example.com" \
--user "<login>:<smtp-key>" \
-T - <<EOF
From: noreply@yourdomain.com
To: test@example.com
Subject: AcadeDoc SMTP test
Test email from AcadeDoc.
EOF
```
If swaks/curl are not available in the container, trigger a password-reset
from the AcadeDoc UI with a real user email and check delivery.
---
## STARTTLS vs TLS notes
| Env variable | Value | Effect |
|---|---|---|
| SMTP_SECURE | false | Port 587 / STARTTLS (recommended) |
| SMTP_SECURE | true | Port 465 / implicit TLS |
| SMTP_IGNORETLS | false | Allow STARTTLS upgrade (default) |
| SMTP_IGNORETLS | true | Disable TLS entirely (insecure, dev only) |
Brevo port 587 requires STARTTLS. Do not set SMTP_SECURE=true on port 587.

View file

@ -12,7 +12,7 @@ export const InvitationEmail = ({ inviteLink }: Props) => {
<MailBody> <MailBody>
<Section style={content}> <Section style={content}>
<Text style={paragraph}>Hi there,</Text> <Text style={paragraph}>Hi there,</Text>
<Text style={paragraph}>You have been invited to DocAdenice.</Text> <Text style={paragraph}>You have been invited to {process.env.BRAND_NAME || 'AcadeDoc'}.</Text>
<Text style={paragraph}> <Text style={paragraph}>
Please click the button below to accept this invitation. Please click the button below to accept this invitation.
</Text> </Text>

View file

@ -77,11 +77,12 @@ export function EmailButton({ href, children }: EmailButtonProps) {
} }
export function MailFooter() { export function MailFooter() {
const brandName = process.env.BRAND_NAME || 'AcadeDoc';
return ( return (
<Section style={footer}> <Section style={footer}>
<Row> <Row>
<Text style={{ textAlign: 'center', color: '#706a7b' }}> <Text style={{ textAlign: 'center', color: '#706a7b' }}>
© {new Date().getFullYear()} DocAdenice, All Rights Reserved <br /> © {new Date().getFullYear()} {brandName}, All Rights Reserved <br />
</Text> </Text>
</Row> </Row>
</Section> </Section>

View file

@ -24,8 +24,13 @@ export default defineConfig({
}, },
}, },
test: { test: {
// Only cover acadenice specs — upstream Docmost tests use Jest // Only cover acadenice specs — upstream Docmost tests use Jest.
include: ["src/core/acadenice/**/*.spec.ts", "src/database/migrations/**/*.spec.ts"], // R4.4: also include workspace branding spec.
include: [
"src/core/acadenice/**/*.spec.ts",
"src/database/migrations/**/*.spec.ts",
"src/core/workspace/spec/**/*.spec.ts",
],
globals: true, globals: true,
environment: "node", environment: "node",
// Vitest needs to transform ESM-only packages // Vitest needs to transform ESM-only packages