feat(ee): page verification workflow (#2102)

* feat: page verification workflow

* feat: refactor page-verification

* sync

* fix type

* fix

* fix

* notification icon

* use full word

* accept .license file

* - update templates
- update migration and notification

* fix copy

* update audit labels

* sync

* add space name
This commit is contained in:
Philip Okugbe
2026-04-13 20:20:34 +01:00
committed by GitHub
parent d6068310b4
commit bd68e47e03
50 changed files with 3828 additions and 58 deletions
@@ -739,6 +739,93 @@
"Removed page restriction": "Removed page restriction",
"Added page permission": "Added page permission",
"Removed page permission": "Removed page permission",
"day": "day",
"days": "days",
"week": "week",
"weeks": "weeks",
"month": "month",
"months": "months",
"year": "year",
"years": "years",
"Period": "Period",
"Fixed date": "Fixed date",
"Indefinitely": "Indefinitely",
"Days": "Days",
"Weeks": "Weeks",
"Months": "Months",
"Years": "Years",
"Pick a date": "Pick a date",
"Maximum is {{max}} {{unit}} for this unit": "Maximum is {{max}} {{unit}} for this unit",
"Never expires. Verifiers can re-verify at any time.": "Never expires. Verifiers can re-verify at any time.",
"Verified": "Verified",
"Review needed": "Review needed",
"Verification expired": "Verification expired",
"Draft": "Draft",
"In Approval": "In Approval",
"In approval": "In approval",
"Approved": "Approved",
"Obsolete": "Obsolete",
"Expiring": "Expiring",
"Set up verification": "Set up verification",
"Verify page": "Verify page",
"Page verification": "Page verification",
"Add verification": "Add verification",
"Edit verification": "Edit verification",
"Search by title": "Search by title",
"Choose how this page should stay accurate.": "Choose how this page should stay accurate.",
"Recurring verification": "Recurring verification",
"Verifiers re-confirm this page on a schedule.": "Verifiers re-confirm this page on a schedule.",
"Re-verify on a schedule (e.g every 30 days )": "Re-verify on a schedule (e.g every 30 days )",
"Page stays editable at all times": "Page stays editable at all times",
"Best for runbooks, FAQs, living documentation": "Best for runbooks, FAQs, living documentation",
"Approval workflow": "Approval workflow",
"Formal document lifecycle with named approvers.": "Formal document lifecycle with named approvers.",
"Draft → In approval → Approved → Obsolete": "Draft → In approval → Approved → Obsolete",
"Locked once approved, with full history": "Locked once approved, with full history",
"Designed for ISO 9001, ISO 13485, and FDA": "Designed for ISO 9001, ISO 13485, and FDA",
"Best for SOPs and controlled documents": "Best for SOPs and controlled documents",
"Back": "Back",
"Quality management": "Quality management",
"Recurring": "Recurring",
"Pages move through draft, approval, and approved stages.": "Pages move through draft, approval, and approved stages.",
"Verifiers": "Verifiers",
"Add verifier": "Add verifier",
"I've reviewed this page for accuracy": "I've reviewed this page for accuracy",
"Set up": "Set up",
"Remove verification": "Remove verification",
"Are you sure you want to remove verification from this page?": "Are you sure you want to remove verification from this page?",
"Assigned verifiers must periodically re-verify this page.": "Assigned verifiers must periodically re-verify this page.",
"Last verified by {{name}} {{time}} (expired)": "Last verified by {{name}} {{time}} (expired)",
"The fixed expiration date has passed.": "The fixed expiration date has passed.",
"Verified by {{name}} {{time}}": "Verified by {{name}} {{time}}",
"Expires {{date}}": "Expires {{date}}",
"Expired {{date}}": "Expired {{date}}",
"Mark as obsolete": "Mark as obsolete",
"Mark obsolete": "Mark obsolete",
"Returned by {{name}} {{time}}": "Returned by {{name}} {{time}}",
"No approval has been requested yet.": "No approval has been requested yet.",
"Submitted by {{name}} {{time}}": "Submitted by {{name}} {{time}}",
"Someone": "Someone",
"Approved by {{name}} {{time}}": "Approved by {{name}} {{time}}",
"This document has been marked as obsolete.": "This document has been marked as obsolete.",
"Rejection comment": "Rejection comment",
"Reason for returning this document...": "Reason for returning this document...",
"Confirm rejection": "Confirm rejection",
"Submit for approval": "Submit for approval",
"Reject": "Reject",
"Approve": "Approve",
"Re-submit for approval": "Re-submit for approval",
"Verified until": "Verified until",
"QMS": "QMS",
"Verified pages": "Verified pages",
"Search pages...": "Search pages...",
"Filter by space": "Filter by space",
"Filter by type": "Filter by type",
"<bold>{{name}}</bold> verified a page": "<bold>{{name}}</bold> verified a page",
"<bold>{{name}}</bold> submitted a page for your approval": "<bold>{{name}}</bold> submitted a page for your approval",
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> returned a page for revision",
"Page verification expires soon": "Page verification expires soon",
"Page verification has expired": "Page verification has expired",
"Verifying your email": "Verifying your email",
"Please wait...": "Please wait...",
"Verification failed. The link may have expired.": "Verification failed. The link may have expired.",
+2
View File
@@ -38,6 +38,7 @@ import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx";
import TemplateList from "@/ee/template/pages/template-list";
import TemplateEditor from "@/ee/template/pages/template-editor";
import FavoritesPage from "@/pages/favorites/favorites-page";
@@ -119,6 +120,7 @@ export default function App() {
<Route path={"ai"} element={<AiSettings />} />
<Route path={"ai/mcp"} element={<AiSettings />} />
<Route path={"audit"} element={<AuditLogs />} />
<Route path={"verifications"} element={<VerifiedPages />} />
{!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />}
</Route>
@@ -12,6 +12,7 @@ import { getSsoProviders } from "@/ee/security/services/security-service.ts";
import { getShares } from "@/features/share/services/share-service.ts";
import { getApiKeys } from "@/ee/api-key";
import { getAuditLogs } from "@/ee/audit/services/audit-service";
import { getVerificationList } from "@/ee/page-verification/services/page-verification-service";
export const prefetchWorkspaceMembers = () => {
const params: QueryParams = { limit: 100, query: "" };
@@ -89,3 +90,11 @@ export const prefetchAuditLogs = () => {
queryFn: () => getAuditLogs(params),
});
};
export const prefetchVerifiedPages = () => {
const params = { limit: 50 };
queryClient.prefetchQuery({
queryKey: ["verification-list", params],
queryFn: () => getVerificationList(params),
});
};
@@ -14,6 +14,7 @@ import {
IconWorld,
IconSparkles,
IconHistory,
IconShieldCheck,
} from "@tabler/icons-react";
import { Link, useLocation } from "react-router-dom";
import classes from "./settings.module.css";
@@ -35,6 +36,7 @@ import {
prefetchSsoProviders,
prefetchWorkspaceMembers,
prefetchAuditLogs,
prefetchVerifiedPages,
} from "@/components/settings/settings-queries.tsx";
import AppVersion from "@/components/settings/app-version.tsx";
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
@@ -95,6 +97,12 @@ const groupedData: DataGroup[] = [
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
{
label: "Verified pages",
icon: IconShieldCheck,
path: "/settings/verifications",
feature: Feature.PAGE_VERIFICATION,
},
{
label: "API management",
icon: IconKey,
@@ -210,6 +218,9 @@ export default function SettingsSidebar() {
case "Audit log":
prefetchHandler = prefetchAuditLogs;
break;
case "Verified pages":
prefetchHandler = prefetchVerifiedPages;
break;
default:
break;
}
@@ -58,6 +58,13 @@ export const auditEventLabels: Record<string, string> = {
"page.restriction_removed": "Removed page restriction",
"page.permission_added": "Added page permission",
"page.permission_removed": "Removed page permission",
"page.verification_created": "Created page verification",
"page.verification_updated": "Updated page verification",
"page.verification_removed": "Removed page verification",
"page.verified": "Verified page",
"page.approval_requested": "Requested page approval",
"page.approval_rejected": "Rejected page approval",
"page.marked_obsolete": "Marked page as obsolete",
"share.created": "Created share link",
"share.deleted": "Deleted share link",
@@ -136,6 +143,13 @@ export const eventFilterOptions: EventGroup[] = [
{ value: "page.restriction_removed", label: "Removed page restriction" },
{ value: "page.permission_added", label: "Added page permission" },
{ value: "page.permission_removed", label: "Removed page permission" },
{ value: "page.verification_created", label: "Created page verification" },
{ value: "page.verification_updated", label: "Updated page verification" },
{ value: "page.verification_removed", label: "Removed page verification" },
{ value: "page.verified", label: "Verified page" },
{ value: "page.approval_requested", label: "Requested page approval" },
{ value: "page.approval_rejected", label: "Rejected page approval" },
{ value: "page.marked_obsolete", label: "Marked page as obsolete" },
],
},
{
@@ -87,7 +87,7 @@ export function ActivateLicenseForm({ onClose }: ActivateLicenseFormProps) {
<form onSubmit={form.onSubmit(handleSubmit)}>
<input
type="file"
accept=".txt"
accept=".txt,.license"
ref={fileInputRef}
onChange={handleFileUpload}
hidden
@@ -2,14 +2,15 @@ import { Group, List, Stack, Table, Text, ThemeIcon } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
const enterpriseFeatures = [
"SSO (SAML, OIDC, LDAP)",
"AI Integration (Search & Assistant)",
"Page-level Permissions",
"Audit Logs",
"API Keys",
"AI Integration (Chat, Search & Assistant)",
"MCP Support",
"SSO (SAML, OIDC, LDAP)",
"Multi-factor Authentication (2FA)",
"Page-level Permissions",
"Page verification & approval workflow",
"Audit Logs",
"Enterprise Controls",
"API Keys",
"Advanced Search Engine Support",
"Full-text Search in Attachments (PDF, DOCX)",
"Resolve Comments",
@@ -68,11 +69,31 @@ export default function OssDetails() {
</List>
<Text size="sm" c="dimmed">
Get an enterprise trial key at <a href="https://customers.docmost.com/" target="_blank" rel="noopener noreferrer">customers.docmost.com</a>.
Get an enterprise trial key at{" "}
<a
href="https://customers.docmost.com/"
target="_blank"
rel="noopener noreferrer"
>
customers.docmost.com
</a>
.
</Text>
<Text size="sm" c="dimmed">
Visit <a href="https://docmost.com/pricing" target="_blank" rel="noopener noreferrer">docmost.com/pricing</a> to purchase an enterprise license.
Visit{" "}
<a
href="https://docmost.com/pricing"
target="_blank"
rel="noopener noreferrer"
>
docmost.com/pricing
</a>{" "}
to purchase an enterprise license.
</Text>
<Text size="sm" c="dimmed">
For inquiries, contact{" "}
<a href="mailto:sales@docmost.com">sales@docmost.com</a>
</Text>
</Stack>
</Stack>
@@ -0,0 +1,232 @@
import { Group, NumberInput, Select, Text } from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { useTranslation } from "react-i18next";
import {
ExpirationMode,
PeriodUnit,
} from "@/ee/page-verification/types/page-verification.types";
export const PERIOD_UNIT_DAYS: Record<PeriodUnit, number> = {
day: 1,
week: 7,
month: 30,
year: 365,
};
export const PERIOD_UNIT_MAX_AMOUNT: Record<PeriodUnit, number> = {
day: 3650,
week: 520,
month: 120,
year: 20,
};
export const PERIOD_AMOUNT_MIN = 1;
export function addDays(days: number, from?: Date): Date {
const date = from ? new Date(from) : new Date();
date.setDate(date.getDate() + days);
return date;
}
function formatShortDate(date: Date): string {
const crossesYear = date.getFullYear() !== new Date().getFullYear();
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
...(crossesYear && { year: "numeric" }),
});
}
function formatLongDate(date: Date): string {
return date.toLocaleDateString(undefined, {
month: "long",
day: "numeric",
year: "numeric",
});
}
export function toLocalDateString(input: Date | string): string {
const d = typeof input === "string" ? new Date(input) : input;
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function pluralizeUnit(
unit: PeriodUnit,
amount: number,
t: (key: string) => string,
): string {
switch (unit) {
case "day":
return amount === 1 ? t("day") : t("days");
case "week":
return amount === 1 ? t("week") : t("weeks");
case "month":
return amount === 1 ? t("month") : t("months");
case "year":
return amount === 1 ? t("year") : t("years");
}
}
function buildModeOptions(
t: (key: string) => string,
): { value: ExpirationMode; label: string }[] {
return [
{ value: "period", label: t("Period") },
{ value: "fixed", label: t("Fixed date") },
{ value: "indefinite", label: t("Indefinitely") },
];
}
function buildUnitOptions(
t: (key: string) => string,
): { value: PeriodUnit; label: string }[] {
return [
{ value: "day", label: t("Days") },
{ value: "week", label: t("Weeks") },
{ value: "month", label: t("Months") },
{ value: "year", label: t("Years") },
];
}
type ExpirationFieldsProps = {
mode: ExpirationMode;
periodAmount: number;
periodUnit: PeriodUnit;
fixedDate: string;
onModeChange: (mode: ExpirationMode) => void;
onPeriodAmountChange: (amount: number) => void;
onPeriodUnitChange: (unit: PeriodUnit) => void;
onFixedDateChange: (iso: string) => void;
baseDate?: Date;
};
export function ExpirationFields({
mode,
periodAmount,
periodUnit,
fixedDate,
onModeChange,
onPeriodAmountChange,
onPeriodUnitChange,
onFixedDateChange,
baseDate,
}: ExpirationFieldsProps) {
const { t } = useTranslation();
const modeOptions = buildModeOptions(t);
const unitOptions = buildUnitOptions(t);
const unitMax = PERIOD_UNIT_MAX_AMOUNT[periodUnit];
const handleUnitChange = (nextUnit: PeriodUnit) => {
const nextMax = PERIOD_UNIT_MAX_AMOUNT[nextUnit];
if (periodAmount > nextMax) {
onPeriodAmountChange(nextMax);
}
onPeriodUnitChange(nextUnit);
};
const amountValid =
Number.isInteger(periodAmount) &&
periodAmount >= PERIOD_AMOUNT_MIN &&
periodAmount <= unitMax;
const nextDueDate =
mode === "period" && amountValid
? addDays(periodAmount * PERIOD_UNIT_DAYS[periodUnit], baseDate)
: null;
const fixedDateObj = fixedDate ? new Date(fixedDate) : null;
let helperText: string | null = null;
let helperError = false;
if (mode === "period" && !amountValid) {
helperText = t("Maximum is {{max}} {{unit}} for this unit", {
max: unitMax,
unit: pluralizeUnit(periodUnit, unitMax, t),
});
helperError = true;
} else if (mode === "period" && nextDueDate && amountValid) {
helperText = t(
"Re-verifies every {{amount}} {{unit}} · Next due {{date}}",
{
amount: periodAmount,
unit: pluralizeUnit(periodUnit, periodAmount, t),
date: formatShortDate(nextDueDate),
},
);
} else if (mode === "fixed" && fixedDateObj) {
helperText = t(
"Expires on {{date}}. Re-verifying won't change the deadline.",
{ date: formatLongDate(fixedDateObj) },
);
} else if (mode === "indefinite") {
helperText = t("Never expires. Verifiers can re-verify at any time.");
}
return (
<div>
<Group align="flex-start" gap="xs" wrap="wrap">
<Select
data={modeOptions}
value={mode}
onChange={(val) => val && onModeChange(val as ExpirationMode)}
variant="filled"
allowDeselect={false}
style={{ flex: "1 1 140px", minWidth: 140 }}
/>
{mode === "period" && (
<Group
gap="xs"
wrap="nowrap"
style={{ flex: "1 1 220px", minWidth: 220 }}
>
<NumberInput
value={periodAmount}
onChange={(val) => {
const n =
typeof val === "number" ? val : parseInt(String(val), 10);
if (!Number.isNaN(n)) onPeriodAmountChange(n);
}}
min={PERIOD_AMOUNT_MIN}
max={unitMax}
clampBehavior="blur"
variant="filled"
style={{ flex: "0 0 80px" }}
hideControls
/>
<Select
data={unitOptions}
value={periodUnit}
onChange={(val) => val && handleUnitChange(val as PeriodUnit)}
variant="filled"
allowDeselect={false}
style={{ flex: 1, minWidth: 120 }}
/>
</Group>
)}
{mode === "fixed" && (
<DateInput
value={fixedDate || undefined}
onChange={(val) => onFixedDateChange(val ?? "")}
placeholder={t("Pick a date")}
variant="filled"
minDate={addDays(1)}
clearable
style={{ flex: "1 1 200px", minWidth: 180 }}
/>
)}
</Group>
{helperText && (
<Text size="xs" c={helperError ? "red" : "dimmed"} mt={6}>
{helperText}
</Text>
)}
</div>
);
}
@@ -0,0 +1,633 @@
import { useState } from "react";
import {
Button,
Center,
Checkbox,
Divider,
Group,
Loader,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { modals } from "@mantine/modals";
import { useTranslation } from "react-i18next";
import {
useMarkObsoleteMutation,
usePageVerificationInfoQuery,
useRejectApprovalMutation,
useRemoveVerificationMutation,
useSubmitForApprovalMutation,
useUpdateVerificationMutation,
useVerifyPageMutation,
} from "@/ee/page-verification/queries/page-verification-query";
import {
ExpirationMode,
IPageVerificationInfo,
PeriodUnit,
} from "@/ee/page-verification/types/page-verification.types";
import { useTimeAgo } from "@/hooks/use-time-ago";
import { VerifierList } from "./verifier-list";
import {
ExpirationFields,
PERIOD_AMOUNT_MIN,
PERIOD_UNIT_MAX_AMOUNT,
toLocalDateString,
} from "./expiration-fields";
import { VerifierPicker } from "./verifier-picker";
import { MAX_VERIFIERS } from "./user-option";
type ManageVerificationFormProps = {
pageId: string;
onClose: () => void;
};
export function ManageVerificationForm({
pageId,
onClose,
}: ManageVerificationFormProps) {
const { data: info, isLoading } = usePageVerificationInfoQuery(pageId);
if (isLoading || !info) {
return (
<Center py="xl">
<Loader size="sm" />
</Center>
);
}
if (info.type === "qms") {
return <QmsManageContent pageId={pageId} info={info} onClose={onClose} />;
}
return (
<ExpiringManageContent pageId={pageId} info={info} onClose={onClose} />
);
}
type ManageContentProps = {
pageId: string;
info: IPageVerificationInfo;
onClose: () => void;
};
function ExpiringManageContent({ pageId, info, onClose }: ManageContentProps) {
const { t } = useTranslation();
const verifyMutation = useVerifyPageMutation();
const removeMutation = useRemoveVerificationMutation();
const updateMutation = useUpdateVerificationMutation();
const [confirmed, setConfirmed] = useState(false);
const initialMode: ExpirationMode = (info.mode as ExpirationMode) ?? "period";
const initialPeriodAmount = info.periodAmount ?? 1;
const initialPeriodUnit: PeriodUnit =
(info.periodUnit as PeriodUnit) ?? "month";
const initialFixedDate =
initialMode === "fixed" && info.expiresAt
? toLocalDateString(info.expiresAt)
: "";
const [mode, setMode] = useState<ExpirationMode>(initialMode);
const [periodAmount, setPeriodAmount] = useState<number>(initialPeriodAmount);
const [periodUnit, setPeriodUnit] = useState<PeriodUnit>(initialPeriodUnit);
const [fixedDate, setFixedDate] = useState<string>(initialFixedDate);
const verifiedAtAgo = useTimeAgo(info.verifiedAt ?? new Date().toISOString());
const hasExpirationChange =
mode !== initialMode ||
(mode === "period" &&
(periodAmount !== initialPeriodAmount ||
periodUnit !== initialPeriodUnit)) ||
(mode === "fixed" && fixedDate !== initialFixedDate);
const periodValid =
mode !== "period" ||
(Number.isInteger(periodAmount) &&
periodAmount >= PERIOD_AMOUNT_MIN &&
periodAmount <= PERIOD_UNIT_MAX_AMOUNT[periodUnit]);
const fixedDateValid =
mode !== "fixed" ||
(!!fixedDate && new Date(fixedDate).getTime() > Date.now());
const canSaveExpiration = hasExpirationChange && periodValid && fixedDateValid;
const storedFixedExpired =
info.mode === "fixed" &&
!!info.expiresAt &&
new Date(info.expiresAt).getTime() <= Date.now();
const existingVerifierIds = info.verifiers?.map((v) => v.id) ?? [];
const handleVerify = () => {
verifyMutation.mutate(pageId, {
onSuccess: () => {
setConfirmed(false);
onClose();
},
});
};
const handleRemove = () => {
modals.openConfirmModal({
title: t("Remove verification"),
children: (
<Text size="sm">
{t("Are you sure you want to remove verification from this page?")}
</Text>
),
labels: { confirm: t("Remove"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => removeMutation.mutate(pageId, { onSuccess: onClose }),
});
};
const handleSaveExpiration = () => {
if (!canSaveExpiration) return;
updateMutation.mutate({
pageId,
mode,
...(mode === "period" && {
periodAmount,
periodUnit,
}),
...(mode === "fixed" &&
fixedDate && {
fixedExpiresAt: new Date(fixedDate).toISOString(),
}),
});
};
const handleRemoveVerifier = (userId: string) => {
if (!info.verifiers) return;
const remaining = info.verifiers
.filter((v) => v.id !== userId)
.map((v) => v.id);
updateMutation.mutate({ pageId, verifierIds: remaining });
};
const handleAddVerifier = (userId: string) => {
if (!info.verifiers) return;
if (info.verifiers.some((v) => v.id === userId)) return;
const verifierIds = [...info.verifiers.map((v) => v.id), userId];
updateMutation.mutate({ pageId, verifierIds });
};
const status = info.status;
return (
<Stack>
<Text size="sm" c="dimmed">
{t("Assigned verifiers must periodically re-verify this page.")}
</Text>
{info.verifiedBy && (
<Group gap="sm">
<div>
<Text size="sm">
{status === "expired"
? t("Last verified by {{name}} {{time}} (expired)", {
name: info.verifiedBy.name,
time: verifiedAtAgo,
})
: t("Verified by {{name}} {{time}}", {
name: info.verifiedBy.name,
time: verifiedAtAgo,
})}
</Text>
{info.expiresAt && (
<Text size="xs" c="dimmed">
{t(status === "expired" ? "Expired {{date}}" : "Expires {{date}}", {
date: new Date(info.expiresAt).toLocaleDateString(undefined, {
month: "long",
day: "numeric",
year: "numeric",
}),
})}
</Text>
)}
</div>
</Group>
)}
<Divider />
{info.verifiers && info.verifiers.length > 0 && (
<>
<div>
<Text size="sm" fw={600} tt="uppercase" c="dimmed" mb={4}>
{t("Verifiers")}
</Text>
<VerifierList
verifiers={info.verifiers}
canManage={info.permissions?.canManage}
onRemove={
info.permissions?.canManage ? handleRemoveVerifier : undefined
}
/>
{info.permissions?.canManage &&
info.verifiers.length < MAX_VERIFIERS && (
<div style={{ marginTop: "var(--mantine-spacing-xs)" }}>
<VerifierPicker
excludeIds={existingVerifierIds}
onSelect={(user) => handleAddVerifier(user.value)}
/>
</div>
)}
</div>
<Divider />
</>
)}
{info.permissions?.canManage && (
<>
<div>
<Text size="sm" fw={600} mb={6}>
{t("Expiration")}
</Text>
<ExpirationFields
mode={mode}
periodAmount={periodAmount}
periodUnit={periodUnit}
fixedDate={fixedDate}
onModeChange={setMode}
onPeriodAmountChange={setPeriodAmount}
onPeriodUnitChange={setPeriodUnit}
onFixedDateChange={setFixedDate}
baseDate={
info.verifiedAt ? new Date(info.verifiedAt) : undefined
}
/>
{hasExpirationChange && (
<Button
size="compact-sm"
mt="xs"
color="dark"
onClick={handleSaveExpiration}
loading={updateMutation.isPending}
disabled={!canSaveExpiration}
>
{t("Save")}
</Button>
)}
</div>
<Divider />
</>
)}
{info.permissions?.canVerify && (
<div>
<Text size="sm" fw={600} mb={4}>
{t("Confirm")}
</Text>
<Checkbox
label={t("I've reviewed this page for accuracy")}
checked={confirmed}
onChange={(event) => setConfirmed(event.currentTarget.checked)}
color="dark"
/>
{storedFixedExpired && (
<Text size="xs" c="red" mt={6}>
{t("The fixed expiration date has passed.")}
</Text>
)}
</div>
)}
<Group justify="space-between">
{info.permissions?.canManage && (
<Button
variant="subtle"
color="red"
size="compact-sm"
onClick={handleRemove}
loading={removeMutation.isPending}
>
{t("Remove verification")}
</Button>
)}
{info.permissions?.canVerify && (
<Button
onClick={handleVerify}
disabled={!confirmed || storedFixedExpired}
loading={verifyMutation.isPending}
color={status === "expired" ? "red" : "dark"}
ml="auto"
>
{t("Verify")}
</Button>
)}
</Group>
</Stack>
);
}
function QmsManageContent({ pageId, info, onClose }: ManageContentProps) {
const { t } = useTranslation();
const verifyMutation = useVerifyPageMutation();
const submitMutation = useSubmitForApprovalMutation();
const rejectMutation = useRejectApprovalMutation();
const obsoleteMutation = useMarkObsoleteMutation();
const removeMutation = useRemoveVerificationMutation();
const updateMutation = useUpdateVerificationMutation();
const [confirmed, setConfirmed] = useState(false);
const [showRejectForm, setShowRejectForm] = useState(false);
const [rejectComment, setRejectComment] = useState("");
const verifiedAtAgo = useTimeAgo(info.verifiedAt ?? new Date().toISOString());
const requestedAtAgo = useTimeAgo(
info.requestedAt ?? new Date().toISOString(),
);
const rejectedAtAgo = useTimeAgo(info.rejectedAt ?? new Date().toISOString());
const status = info.status;
const existingVerifierIds = info.verifiers?.map((v) => v.id) ?? [];
const handleSubmitForApproval = () => {
submitMutation.mutate(pageId, { onSuccess: onClose });
};
const handleVerify = () => {
verifyMutation.mutate(pageId, {
onSuccess: () => {
setConfirmed(false);
onClose();
},
});
};
const handleReject = () => {
rejectMutation.mutate(
{ pageId, comment: rejectComment || undefined },
{
onSuccess: () => {
setShowRejectForm(false);
setRejectComment("");
onClose();
},
},
);
};
const handleMarkObsolete = () => {
modals.openConfirmModal({
title: t("Mark as obsolete"),
children: (
<Stack gap="xs">
<Text size="sm">
{t(
"Are you sure you want to mark this page as obsolete? This action cannot be undone.",
)}
</Text>
<Text size="sm" c="dimmed">
{t(
"To restore this page, you will need to remove verification and set it up again.",
)}
</Text>
</Stack>
),
labels: { confirm: t("Mark obsolete"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () =>
obsoleteMutation.mutate(pageId, { onSuccess: onClose }),
});
};
const handleRemove = () => {
modals.openConfirmModal({
title: t("Remove verification"),
children: (
<Text size="sm">
{t("Are you sure you want to remove verification from this page?")}
</Text>
),
labels: { confirm: t("Remove"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => removeMutation.mutate(pageId, { onSuccess: onClose }),
});
};
const handleRemoveVerifier = (userId: string) => {
if (!info.verifiers) return;
const remaining = info.verifiers
.filter((v) => v.id !== userId)
.map((v) => v.id);
updateMutation.mutate({ pageId, verifierIds: remaining });
};
const handleAddVerifier = (userId: string) => {
if (!info.verifiers) return;
if (info.verifiers.some((v) => v.id === userId)) return;
const verifierIds = [...info.verifiers.map((v) => v.id), userId];
updateMutation.mutate({ pageId, verifierIds });
};
const canManageVerifiers =
info.permissions?.canManage && status !== "obsolete";
return (
<Stack>
<Text size="sm" c="dimmed">
{t("Pages move through draft, approval, and approved stages.")}
</Text>
{status === "draft" && (
<>
{info.rejectedBy && info.rejectedAt && (
<div>
<Text size="sm" c="red">
{t("Returned by {{name}} {{time}}", {
name: info.rejectedBy.name,
time: rejectedAtAgo,
})}
</Text>
{info.rejectionComment && (
<Text size="sm" c="dimmed" mt={4} fs="italic">
&ldquo;{info.rejectionComment}&rdquo;
</Text>
)}
</div>
)}
{!info.rejectedBy && (
<Text size="sm">{t("No approval has been requested yet.")}</Text>
)}
</>
)}
{status === "in_approval" && (
<div>
<Text size="sm">
{t("Submitted by {{name}} {{time}}", {
name: info.requestedBy?.name ?? t("Someone"),
time: requestedAtAgo,
})}
</Text>
</div>
)}
{status === "approved" && info.verifiedBy && (
<div>
<Text size="sm">
{t("Approved by {{name}} {{time}}", {
name: info.verifiedBy.name,
time: verifiedAtAgo,
})}
</Text>
</div>
)}
{status === "obsolete" && (
<Text size="sm" c="dimmed">
{t("This document has been marked as obsolete.")}
</Text>
)}
<Divider />
{info.verifiers && info.verifiers.length > 0 && (
<>
<div>
<Text size="sm" fw={600} tt="uppercase" c="dimmed" mb={4}>
{t("Verifiers")}
</Text>
<VerifierList
verifiers={info.verifiers}
canManage={canManageVerifiers}
onRemove={canManageVerifiers ? handleRemoveVerifier : undefined}
/>
{canManageVerifiers && info.verifiers.length < MAX_VERIFIERS && (
<div style={{ marginTop: "var(--mantine-spacing-xs)" }}>
<VerifierPicker
excludeIds={existingVerifierIds}
onSelect={(user) => handleAddVerifier(user.value)}
/>
</div>
)}
</div>
<Divider />
</>
)}
{status === "in_approval" && info.permissions?.canVerify && (
<>
{showRejectForm ? (
<div>
<Text size="sm" fw={600} mb={4}>
{t("Rejection comment")}
</Text>
<Textarea
value={rejectComment}
onChange={(e) => setRejectComment(e.currentTarget.value)}
placeholder={t("Reason for returning this document...")}
minRows={2}
variant="filled"
maxLength={500}
/>
<Group justify="flex-end" mt="sm" gap="xs">
<Button
variant="subtle"
color="gray"
size="compact-sm"
onClick={() => {
setShowRejectForm(false);
setRejectComment("");
}}
>
{t("Cancel")}
</Button>
<Button
color="red"
onClick={handleReject}
loading={rejectMutation.isPending}
>
{t("Confirm rejection")}
</Button>
</Group>
</div>
) : (
<div>
<Checkbox
label={t("I've reviewed this page for accuracy")}
checked={confirmed}
onChange={(event) => setConfirmed(event.currentTarget.checked)}
color="dark"
/>
</div>
)}
</>
)}
<Group justify="space-between">
{info.permissions?.canManage && (
<Button
variant="subtle"
color="red"
size="compact-sm"
onClick={handleRemove}
loading={removeMutation.isPending}
>
{t("Remove verification")}
</Button>
)}
<Group gap="xs" ml="auto">
{status === "draft" && info.permissions?.canSubmitForApproval && (
<Button
onClick={handleSubmitForApproval}
loading={submitMutation.isPending}
color="dark"
>
{t("Submit for approval")}
</Button>
)}
{status === "in_approval" &&
info.permissions?.canVerify &&
!showRejectForm && (
<>
<Button
variant="light"
color="red"
onClick={() => setShowRejectForm(true)}
>
{t("Reject")}
</Button>
<Button
onClick={handleVerify}
disabled={!confirmed}
loading={verifyMutation.isPending}
color="dark"
>
{t("Approve")}
</Button>
</>
)}
{status === "approved" && (
<>
{info.permissions?.canSubmitForApproval && (
<Button
variant="light"
onClick={handleSubmitForApproval}
loading={submitMutation.isPending}
>
{t("Re-submit for approval")}
</Button>
)}
{info.permissions?.canMarkObsolete && (
<Button
variant="light"
color="gray"
onClick={handleMarkObsolete}
loading={obsoleteMutation.isPending}
>
{t("Mark obsolete")}
</Button>
)}
</>
)}
</Group>
</Group>
</Stack>
);
}
@@ -0,0 +1,278 @@
.chooser {
display: flex;
flex-direction: column;
gap: 8px;
}
.subhead {
font-size: 12px;
line-height: 1.5;
color: light-dark(
var(--mantine-color-gray-6),
var(--mantine-color-dark-2)
);
margin-bottom: 2px;
max-width: 52ch;
}
.card {
position: relative;
display: block;
width: 100%;
padding: 14px 16px 12px;
border: 1px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
border-radius: 10px;
background: light-dark(
var(--mantine-color-white),
var(--mantine-color-dark-7)
);
cursor: pointer;
text-align: left;
overflow: hidden;
transition:
border-color 220ms cubic-bezier(0.16, 1, 0.3, 1),
transform 220ms cubic-bezier(0.16, 1, 0.3, 1),
box-shadow 220ms cubic-bezier(0.16, 1, 0.3, 1),
background-color 220ms cubic-bezier(0.16, 1, 0.3, 1);
}
.card::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(
120% 90% at 100% 0%,
light-dark(rgba(15, 15, 20, 0.035), rgba(255, 255, 255, 0.04)),
transparent 55%
);
opacity: 0;
transition: opacity 260ms cubic-bezier(0.16, 1, 0.3, 1);
pointer-events: none;
}
.card:hover {
border-color: light-dark(
var(--mantine-color-dark-9),
var(--mantine-color-gray-3)
);
transform: translateY(-2px);
box-shadow:
0 1px 0 0
light-dark(
rgba(15, 15, 20, 0.04),
rgba(255, 255, 255, 0.04)
),
0 18px 36px -22px
light-dark(rgba(15, 15, 20, 0.22), rgba(0, 0, 0, 0.6));
}
.card:hover::before {
opacity: 1;
}
.card:focus-visible {
outline: none;
border-color: light-dark(
var(--mantine-color-dark-9),
var(--mantine-color-gray-3)
);
box-shadow: 0 0 0 3px
light-dark(
rgba(15, 15, 20, 0.08),
rgba(255, 255, 255, 0.12)
);
}
.titleRow {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 2px;
}
.iconStamp {
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 7px;
background: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-6)
);
color: light-dark(
var(--mantine-color-dark-7),
var(--mantine-color-gray-2)
);
transition:
background-color 220ms cubic-bezier(0.16, 1, 0.3, 1),
color 220ms cubic-bezier(0.16, 1, 0.3, 1),
transform 320ms cubic-bezier(0.16, 1, 0.3, 1);
flex-shrink: 0;
}
.card:hover .iconStamp {
background: light-dark(
var(--mantine-color-dark-9),
var(--mantine-color-gray-1)
);
color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-9)
);
transform: rotate(-4deg);
}
.title {
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
color: light-dark(
var(--mantine-color-dark-9),
var(--mantine-color-gray-0)
);
line-height: 1.25;
margin: 0;
}
.description {
font-size: 12px;
color: light-dark(
var(--mantine-color-gray-7),
var(--mantine-color-dark-1)
);
margin: 0;
line-height: 1.45;
max-width: 52ch;
}
.rule {
height: 1px;
background: light-dark(
var(--mantine-color-gray-2),
var(--mantine-color-dark-5)
);
margin: 10px 0 8px;
}
.meta {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
.metaItem {
display: flex;
align-items: center;
gap: 8px;
font-size: 11.5px;
color: light-dark(
var(--mantine-color-gray-7),
var(--mantine-color-dark-1)
);
line-height: 1.35;
}
.metaIcon {
flex-shrink: 0;
color: light-dark(
var(--mantine-color-gray-5),
var(--mantine-color-dark-2)
);
}
.cardFooter {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 8px;
border-top: 1px dashed
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
gap: 12px;
}
.bestFor {
font-size: 10.5px;
color: light-dark(
var(--mantine-color-gray-6),
var(--mantine-color-dark-2)
);
font-style: italic;
letter-spacing: 0.005em;
}
.arrow {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
color: light-dark(
var(--mantine-color-gray-5),
var(--mantine-color-dark-2)
);
transition:
transform 260ms cubic-bezier(0.16, 1, 0.3, 1),
color 220ms cubic-bezier(0.16, 1, 0.3, 1);
}
.card:hover .arrow {
transform: translateX(4px);
color: light-dark(
var(--mantine-color-dark-9),
var(--mantine-color-gray-0)
);
}
.backButton {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 500;
color: light-dark(
var(--mantine-color-gray-6),
var(--mantine-color-dark-2)
);
background: none;
border: none;
cursor: pointer;
padding: 4px 8px;
margin-left: -8px;
border-radius: 6px;
transition:
color 150ms ease,
background-color 150ms ease;
}
.backButton:hover {
color: light-dark(
var(--mantine-color-dark-9),
var(--mantine-color-gray-0)
);
background: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-6)
);
}
.configureHeader {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 4px;
}
.configureEyebrow {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.14em;
color: light-dark(
var(--mantine-color-gray-6),
var(--mantine-color-dark-2)
);
}
@@ -0,0 +1,186 @@
import { ActionIcon, Group, Menu, Modal, Text, Tooltip } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import {
IconRosetteDiscountCheckFilled,
IconShieldCheck,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { extractPageSlugId } from "@/lib";
import { usePageQuery } from "@/features/page/queries/page-query";
import { usePageVerificationInfoQuery } from "@/ee/page-verification/queries/page-verification-query";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { Feature } from "@/ee/features";
import { SetupVerificationForm } from "./setup-verification-form";
import { ManageVerificationForm } from "./manage-verification-form";
import { getStatusColor, getStatusLabel } from "./verification-status";
type PageVerificationModalProps = {
pageId: string;
opened: boolean;
onClose: () => void;
};
export function PageVerificationModal({
pageId,
opened,
onClose,
}: PageVerificationModalProps) {
const { t } = useTranslation();
const { data: verificationInfo } = usePageVerificationInfoQuery(
opened ? pageId : undefined,
);
const status = verificationInfo?.status ?? "none";
return (
<Modal
opened={opened}
onClose={onClose}
title={
<Group gap="xs">
<IconShieldCheck
size={20}
stroke={1.5}
color={
status === "verified" || status === "approved"
? "var(--mantine-color-blue-6)"
: status === "expired"
? "var(--mantine-color-red-6)"
: undefined
}
/>
<Text fw={600}>
{status === "none" ? t("Set up verification") : t("Verify page")}
</Text>
</Group>
}
size={520}
>
{status === "none" ? (
<SetupVerificationForm pageId={pageId} onClose={onClose} />
) : (
<ManageVerificationForm pageId={pageId} onClose={onClose} />
)}
</Modal>
);
}
type PageVerificationBadgeProps = {
readOnly?: boolean;
};
export function PageVerificationBadge({
readOnly,
}: PageVerificationBadgeProps) {
const { t } = useTranslation();
const { pageSlug } = useParams();
const pageSlugId = extractPageSlugId(pageSlug);
const hasVerificationFeature = useHasFeature(Feature.PAGE_VERIFICATION);
const [opened, { open, close }] = useDisclosure(false);
const { data: page } = usePageQuery({ pageId: pageSlugId });
const pageId = page?.id;
const { data: verificationInfo, isLoading } = usePageVerificationInfoQuery(
hasVerificationFeature ? pageId : undefined,
);
const upgradeLabel = useUpgradeLabel();
if (!pageId) return null;
if (!hasVerificationFeature) {
if (readOnly) return null;
return (
<Tooltip
label={`${t("Add verification")}${upgradeLabel}`}
withArrow
openDelay={250}
>
<ActionIcon variant="subtle" color="gray">
<IconShieldCheck size={20} stroke={1.5} />
</ActionIcon>
</Tooltip>
);
}
if (isLoading) return null;
const status = verificationInfo?.status ?? "none";
if (status === "none" && readOnly) return null;
return (
<>
{status !== "none" ? (
<Tooltip label={getStatusLabel(status, t)} withArrow openDelay={250}>
<Group
gap={4}
onClick={open}
style={{ cursor: "pointer" }}
wrap="nowrap"
>
<IconRosetteDiscountCheckFilled
size={18}
color={`var(--mantine-color-${getStatusColor(status).replace(".", "-")})`}
/>
<Text size="sm" c={getStatusColor(status)}>
{getStatusLabel(status, t)}
</Text>
</Group>
</Tooltip>
) : !readOnly ? (
<Tooltip label={t("Set up verification")} withArrow openDelay={250}>
<ActionIcon variant="subtle" color="gray" onClick={open}>
<IconShieldCheck size={20} stroke={1.5} />
</ActionIcon>
</Tooltip>
) : null}
<PageVerificationModal pageId={pageId} opened={opened} onClose={close} />
</>
);
}
type PageVerificationMenuItemProps = {
pageId?: string;
onClick: () => void;
};
export function PageVerificationMenuItem({
pageId,
onClick,
}: PageVerificationMenuItemProps) {
const { t } = useTranslation();
const hasVerificationFeature = useHasFeature(Feature.PAGE_VERIFICATION);
const upgradeLabel = useUpgradeLabel();
const { data: verificationInfo } = usePageVerificationInfoQuery(
hasVerificationFeature ? pageId : undefined,
);
const hasVerification =
!!verificationInfo && verificationInfo.status !== "none";
const label = hasVerification
? t("Edit verification")
: t("Add verification");
const menuItem = (
<Menu.Item
disabled={!hasVerificationFeature}
leftSection={<IconShieldCheck size={16} />}
onClick={hasVerificationFeature ? onClick : undefined}
>
{label}
</Menu.Item>
);
if (!hasVerificationFeature) {
return (
<Tooltip label={upgradeLabel} position="left" withinPortal={false}>
{menuItem}
</Tooltip>
);
}
return menuItem;
}
@@ -0,0 +1,335 @@
import { useEffect, useRef, useState } from "react";
import {
Button,
Checkbox,
Divider,
Group,
Stack,
Text,
UnstyledButton,
} from "@mantine/core";
import {
IconArrowLeft,
IconArrowRight,
IconCertificate2,
IconCheck,
IconRefresh,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useAtom } from "jotai";
import classes from "./page-verification-modal.module.css";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { useSetupVerificationMutation } from "@/ee/page-verification/queries/page-verification-query";
import {
ExpirationMode,
PeriodUnit,
VerificationType,
} from "@/ee/page-verification/types/page-verification.types";
import {
ExpirationFields,
PERIOD_AMOUNT_MIN,
PERIOD_UNIT_MAX_AMOUNT,
} from "./expiration-fields";
import { VerifierPicker } from "./verifier-picker";
import { VerifierList } from "./verifier-list";
import { MAX_VERIFIERS, UserOptionItem } from "./user-option";
type WorkflowChooserProps = {
onSelect: (type: VerificationType) => void;
};
function WorkflowChooser({ onSelect }: WorkflowChooserProps) {
const { t } = useTranslation();
return (
<Stack gap="md">
<Text className={classes.subhead}>
{t("Choose how this page should stay accurate.")}
</Text>
<div className={classes.chooser}>
<UnstyledButton
component="button"
type="button"
className={classes.card}
onClick={() => onSelect("expiring" as VerificationType)}
>
<div className={classes.titleRow}>
<span className={classes.iconStamp}>
<IconRefresh size={15} stroke={1.7} />
</span>
<h3 className={classes.title}>{t("Recurring verification")}</h3>
</div>
<p className={classes.description}>
{t("Verifiers re-confirm this page on a schedule.")}
</p>
<div className={classes.rule} />
<div className={classes.meta}>
<div className={classes.metaItem}>
<IconCheck size={13} stroke={2.4} className={classes.metaIcon} />
{t("Re-verify on a schedule (e.g every 30 days )")}
</div>
</div>
<div className={classes.cardFooter}>
<span className={classes.bestFor}>
{t("Best for runbooks, FAQs, living documentation")}
</span>
<span className={classes.arrow}>
<IconArrowRight size={16} stroke={1.8} />
</span>
</div>
</UnstyledButton>
<UnstyledButton
component="button"
type="button"
className={classes.card}
onClick={() => onSelect("qms" as VerificationType)}
>
<div className={classes.titleRow}>
<span className={classes.iconStamp}>
<IconCertificate2 size={15} stroke={1.7} />
</span>
<h3 className={classes.title}>{t("Approval workflow")}</h3>
</div>
<p className={classes.description}>
{t("Formal document lifecycle with named approvers.")}
</p>
<div className={classes.rule} />
<div className={classes.meta}>
<div className={classes.metaItem}>
<IconCheck size={13} stroke={2.4} className={classes.metaIcon} />
{t("Draft → In approval → Approved → Obsolete")}
</div>
<div className={classes.metaItem}>
<IconCheck size={13} stroke={2.4} className={classes.metaIcon} />
{t("Designed for ISO 9001, ISO 13485, and FDA")}
</div>
</div>
<div className={classes.cardFooter}>
<span className={classes.bestFor}>
{t("Best for SOPs and controlled documents")}
</span>
<span className={classes.arrow}>
<IconArrowRight size={16} stroke={1.8} />
</span>
</div>
</UnstyledButton>
</div>
</Stack>
);
}
type SetupVerificationFormProps = {
pageId: string;
onClose: () => void;
};
export function SetupVerificationForm({
pageId,
onClose,
}: SetupVerificationFormProps) {
const { t } = useTranslation();
const setupMutation = useSetupVerificationMutation();
const [currentUser] = useAtom(currentUserAtom);
const [type, setType] = useState<VerificationType | null>(null);
const [mode, setMode] = useState<ExpirationMode>("period");
const [periodAmount, setPeriodAmount] = useState<number>(1);
const [periodUnit, setPeriodUnit] = useState<PeriodUnit>("month");
const [fixedDate, setFixedDate] = useState<string>("");
const [confirmed, setConfirmed] = useState(false);
const [selectedVerifiers, setSelectedVerifiers] = useState<UserOptionItem[]>(
[],
);
const didInitCurrentUser = useRef(false);
useEffect(() => {
if (!didInitCurrentUser.current && currentUser?.user) {
didInitCurrentUser.current = true;
const u = currentUser.user;
setSelectedVerifiers([
{
value: u.id,
label: u.name,
email: u.email,
avatarUrl: u.avatarUrl,
},
]);
}
}, [currentUser]);
const isQms = type === "qms";
const canAddMore = selectedVerifiers.length < MAX_VERIFIERS;
if (type === null) {
return <WorkflowChooser onSelect={setType} />;
}
const handleAddVerifier = (user: UserOptionItem) => {
setSelectedVerifiers((prev) =>
prev.some((v) => v.value === user.value) ? prev : [...prev, user],
);
};
const handleRemoveVerifier = (userId: string) => {
setSelectedVerifiers((prev) => prev.filter((v) => v.value !== userId));
};
const handleSetup = () => {
if (selectedVerifiers.length === 0) return;
setupMutation.mutate(
{
pageId,
type,
...(!isQms && {
mode,
...(mode === "period" && {
periodAmount,
periodUnit,
}),
...(mode === "fixed" &&
fixedDate && {
fixedExpiresAt: new Date(fixedDate).toISOString(),
}),
}),
verifierIds: selectedVerifiers.map((v) => v.value),
},
{
onSuccess: () => {
if (!isQms) {
onClose();
}
},
},
);
};
const periodValid =
mode !== "period" ||
(Number.isInteger(periodAmount) &&
periodAmount >= PERIOD_AMOUNT_MIN &&
periodAmount <= PERIOD_UNIT_MAX_AMOUNT[periodUnit]);
const fixedDateValid =
mode !== "fixed" ||
(!!fixedDate && new Date(fixedDate).getTime() > Date.now());
const hasVerifiers = selectedVerifiers.length > 0;
const canSubmit = isQms
? hasVerifiers
: hasVerifiers && confirmed && periodValid && fixedDateValid;
return (
<Stack>
<div>
<button
type="button"
className={classes.backButton}
onClick={() => setType(null)}
>
<IconArrowLeft size={12} stroke={2.2} />
{t("Back")}
</button>
<div className={classes.configureHeader}>
<span className={classes.iconStamp}>
{isQms ? (
<IconCertificate2 size={16} stroke={1.6} />
) : (
<IconRefresh size={16} stroke={1.6} />
)}
</span>
<div>
<span className={classes.configureEyebrow}>
{isQms ? t("Quality management") : t("Recurring")}
</span>
<Text size="sm" c="dimmed" mt={2}>
{isQms
? t("Pages move through draft, approval, and approved stages.")
: t(
"Assigned verifiers must periodically re-verify this page.",
)}
</Text>
</div>
</div>
</div>
<div>
<Text size="sm" fw={600} tt="uppercase" c="dimmed" mb={4}>
{t("Verifiers")}
</Text>
{selectedVerifiers.length > 0 && (
<div style={{ marginBottom: "var(--mantine-spacing-xs)" }}>
<VerifierList
verifiers={selectedVerifiers.map((v) => ({
id: v.value,
name: v.label,
email: v.email,
avatarUrl: v.avatarUrl,
}))}
canManage
onRemove={handleRemoveVerifier}
/>
</div>
)}
{canAddMore && (
<VerifierPicker
excludeIds={selectedVerifiers.map((v) => v.value)}
onSelect={handleAddVerifier}
/>
)}
</div>
{!isQms && (
<>
<Divider />
<div>
<Text size="sm" fw={600} mb={6}>
{t("Expiration")}
</Text>
<ExpirationFields
mode={mode}
periodAmount={periodAmount}
periodUnit={periodUnit}
fixedDate={fixedDate}
onModeChange={setMode}
onPeriodAmountChange={setPeriodAmount}
onPeriodUnitChange={setPeriodUnit}
onFixedDateChange={setFixedDate}
/>
</div>
<Divider />
<div>
<Text size="sm" fw={600} mb={4}>
{t("Confirm")}
</Text>
<Checkbox
label={t("I've reviewed this page for accuracy")}
checked={confirmed}
onChange={(event) => setConfirmed(event.currentTarget.checked)}
color="dark"
/>
</div>
</>
)}
<Group justify="flex-end">
<Button
onClick={handleSetup}
disabled={!canSubmit}
loading={setupMutation.isPending}
color="dark"
>
{isQms ? t("Set up") : t("Verify")}
</Button>
</Group>
</Stack>
);
}
@@ -0,0 +1,43 @@
import { Group, SelectProps, Text } from "@mantine/core";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { IUser } from "@/features/user/types/user.types";
export const MAX_VERIFIERS = 5;
export type UserOptionItem = {
value: string;
label: string;
email: string;
avatarUrl: string;
};
export function toUserOptions(users: IUser[] | undefined): UserOptionItem[] {
return (users ?? []).map((user) => ({
value: user.id,
label: user.name,
email: user.email,
avatarUrl: user.avatarUrl,
}));
}
export const renderUserSelectOption: SelectProps["renderOption"] = ({
option,
}) => (
<Group gap="sm" wrap="nowrap">
<CustomAvatar
avatarUrl={option["avatarUrl"]}
size={20}
name={option.label}
/>
<div>
<Text size="sm" lineClamp={1}>
{option.label}
</Text>
{option["email"] && (
<Text size="xs" c="dimmed" lineClamp={1}>
{option["email"]}
</Text>
)}
</div>
</Group>
);
@@ -0,0 +1,218 @@
import {
Table,
Text,
Group,
Skeleton,
Anchor,
Badge,
Avatar,
Tooltip,
} from "@mantine/core";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
IVerificationListItem,
VerificationStatus,
} from "@/ee/page-verification/types/page-verification.types";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { buildPageUrl } from "@/features/page/page.utils";
import { format } from "date-fns";
import NoTableResults from "@/components/common/no-table-results";
const MAX_VISIBLE_VERIFIERS = 5;
type VerificationListTableProps = {
items?: IVerificationListItem[];
isLoading: boolean;
};
function statusBadge(status: VerificationStatus | null, t: (s: string) => string) {
switch (status) {
case "verified":
return <Badge color="green" variant="light" size="sm">{t("Verified")}</Badge>;
case "expiring":
return <Badge color="orange" variant="light" size="sm">{t("Expiring")}</Badge>;
case "expired":
return <Badge color="red" variant="light" size="sm">{t("Expired")}</Badge>;
case "approved":
return <Badge color="green" variant="light" size="sm">{t("Approved")}</Badge>;
case "draft":
return <Badge color="gray" variant="light" size="sm">{t("Draft")}</Badge>;
case "in_approval":
return <Badge color="blue" variant="light" size="sm">{t("In approval")}</Badge>;
case "obsolete":
return <Badge color="red" variant="light" size="sm">{t("Obsolete")}</Badge>;
default:
return null;
}
}
function verifiedUntilText(item: IVerificationListItem, t: (s: string) => string): string {
if (item.type === "qms") {
if (item.status === "approved") return t("Indefinitely");
return "—";
}
if (!item.expiresAt) return t("Indefinitely");
const expires = new Date(item.expiresAt);
const now = new Date();
if (expires <= now) return t("Expired");
return format(expires, "MMM d, yyyy");
}
function TableSkeleton() {
return (
<>
{Array.from({ length: 8 }).map((_, i) => (
<Table.Tr key={i}>
<Table.Td>
<div>
<Skeleton height={14} width={160} mb={4} />
<Skeleton height={10} width={100} />
</div>
</Table.Td>
<Table.Td>
<Group gap={8} wrap="nowrap">
<Skeleton circle height={24} />
<Skeleton circle height={24} />
<Skeleton circle height={24} />
</Group>
</Table.Td>
<Table.Td>
<Skeleton height={14} width={100} />
</Table.Td>
<Table.Td>
<Skeleton height={20} width={60} />
</Table.Td>
</Table.Tr>
))}
</>
);
}
export default function VerificationListTable({
items,
isLoading,
}: VerificationListTableProps) {
const { t } = useTranslation();
return (
<Table.ScrollContainer minWidth={600}>
<Table highlightOnHover verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Page")}</Table.Th>
<Table.Th>{t("Verifiers")}</Table.Th>
<Table.Th>{t("Verified until")}</Table.Th>
<Table.Th>{t("Status")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isLoading ? (
<TableSkeleton />
) : items && items.length > 0 ? (
items.map((item) => {
const verifiers = item.verifiers ?? [];
const pageUrl = buildPageUrl(
item.spaceSlug,
item.pageSlugId,
item.pageTitle ?? undefined,
);
return (
<Table.Tr key={item.id}>
<Table.Td>
<Anchor
size="sm"
underline="never"
style={{ color: "var(--mantine-color-text)" }}
component={Link}
to={pageUrl}
>
<Text fz="sm" fw={500} lineClamp={1}>
{item.pageIcon ? `${item.pageIcon} ` : ""}
{item.pageTitle || t("Untitled")}
</Text>
</Anchor>
<Text fz="xs" c="dimmed" lineClamp={1}>
{item.spaceName}
</Text>
</Table.Td>
<Table.Td>
{verifiers.length === 1 ? (
<Group gap={8} wrap="nowrap">
<CustomAvatar
size="sm"
avatarUrl={verifiers[0].avatarUrl}
name={verifiers[0].name}
/>
<Text fz="sm" lineClamp={1}>
{verifiers[0].name}
</Text>
</Group>
) : verifiers.length > 1 ? (
<Tooltip.Group openDelay={300} closeDelay={100}>
<Avatar.Group spacing={8}>
{verifiers
.slice(0, MAX_VISIBLE_VERIFIERS)
.map((verifier) => (
<Tooltip
key={verifier.id}
label={verifier.name}
withArrow
>
<CustomAvatar
size="sm"
avatarUrl={verifier.avatarUrl}
name={verifier.name}
/>
</Tooltip>
))}
{verifiers.length > MAX_VISIBLE_VERIFIERS && (
<Tooltip
withArrow
label={verifiers
.slice(MAX_VISIBLE_VERIFIERS)
.map((v) => (
<div key={v.id}>{v.name}</div>
))}
>
<Avatar size="sm" color="gray">
+{verifiers.length - MAX_VISIBLE_VERIFIERS}
</Avatar>
</Tooltip>
)}
</Avatar.Group>
</Tooltip.Group>
) : (
<Text fz="sm" c="dimmed">
</Text>
)}
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{verifiedUntilText(item, t)}
</Text>
</Table.Td>
<Table.Td>
{statusBadge(item.status as VerificationStatus, t)}
</Table.Td>
</Table.Tr>
);
})
) : (
<NoTableResults colSpan={4} />
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
@@ -0,0 +1,43 @@
import { VerificationStatus } from "@/ee/page-verification/types/page-verification.types";
export function getStatusColor(status: VerificationStatus): string {
switch (status) {
case "verified":
case "approved":
return "blue.7";
case "expiring":
case "in_approval":
return "orange.8";
case "expired":
return "red.7";
case "draft":
case "obsolete":
return "gray.6";
default:
return "gray.6";
}
}
export function getStatusLabel(
status: VerificationStatus,
t: (key: string) => string,
): string {
switch (status) {
case "verified":
return t("Verified");
case "expiring":
return t("Review needed");
case "expired":
return t("Verification expired");
case "draft":
return t("Draft");
case "in_approval":
return t("In Approval");
case "approved":
return t("Approved");
case "obsolete":
return t("Obsolete");
default:
return "";
}
}
@@ -0,0 +1,70 @@
import { ActionIcon, Group, Text, Tooltip } from "@mantine/core";
import { IconX } from "@tabler/icons-react";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { IVerifier } from "@/ee/page-verification/types/page-verification.types";
import { useTranslation } from "react-i18next";
type VerifierListProps = {
verifiers: IVerifier[];
canManage?: boolean;
onRemove?: (userId: string) => void;
};
export function VerifierList({
verifiers,
canManage,
onRemove,
}: VerifierListProps) {
const { t } = useTranslation();
if (verifiers.length === 0) return null;
return (
<>
{verifiers.map((verifier, index) => (
<Group
key={verifier.id}
justify="space-between"
wrap="nowrap"
py={6}
style={{
borderBottom:
index < verifiers.length - 1
? "1px solid var(--mantine-color-gray-1)"
: undefined,
}}
>
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<CustomAvatar
avatarUrl={verifier.avatarUrl}
name={verifier.name}
size={28}
/>
<div style={{ minWidth: 0 }}>
<Text size="sm" truncate="end">
{verifier.name}
</Text>
{verifier.email && (
<Text size="xs" c="dimmed" truncate="end">
{verifier.email}
</Text>
)}
</div>
</Group>
{canManage && onRemove && (
<Tooltip label={t("Remove")} withArrow>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => onRemove(verifier.id)}
>
<IconX size={14} />
</ActionIcon>
</Tooltip>
)}
</Group>
))}
</>
);
}
@@ -0,0 +1,65 @@
import { useState } from "react";
import { Select } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query";
import {
renderUserSelectOption,
toUserOptions,
UserOptionItem,
} from "./user-option";
type VerifierPickerProps = {
excludeIds: string[];
disabled?: boolean;
onSelect: (user: UserOptionItem) => void;
placeholder?: string;
};
export function VerifierPicker({
excludeIds,
disabled,
onSelect,
placeholder,
}: VerifierPickerProps) {
const { t } = useTranslation();
const [searchValue, setSearchValue] = useState("");
const [debouncedQuery] = useDebouncedValue(searchValue, 300);
const { data: suggestion } = useSearchSuggestionsQuery({
query: debouncedQuery,
includeUsers: true,
includeGroups: false,
preload: true,
});
const excludeSet = new Set(excludeIds);
const options = toUserOptions(suggestion?.users).filter(
(u) => !excludeSet.has(u.value),
);
const handleChange = (userId: string | null) => {
if (!userId) return;
const picked = options.find((u) => u.value === userId);
if (!picked) return;
onSelect(picked);
setSearchValue("");
};
return (
<Select
data={options}
value={null}
onChange={handleChange}
renderOption={renderUserSelectOption}
placeholder={placeholder ?? t("Add verifier")}
searchable
searchValue={searchValue}
onSearchChange={setSearchValue}
filter={({ options }) => options}
variant="filled"
disabled={disabled}
nothingFoundMessage={t("No user found")}
/>
);
}
@@ -0,0 +1,5 @@
export * from "./components/page-verification-modal";
export * from "./components/verifier-list";
export * from "./queries/page-verification-query";
export * from "./services/page-verification-service";
export * from "./types/page-verification.types";
@@ -0,0 +1,127 @@
import { useState, useMemo } from "react";
import { Group, MultiSelect, Select, Space, TextInput } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { IconSearch } from "@tabler/icons-react";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import Paginate from "@/components/common/paginate";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { useVerificationListQuery } from "@/ee/page-verification/queries/page-verification-query";
import { IVerificationListParams } from "@/ee/page-verification/types/page-verification.types";
import VerificationListTable from "@/ee/page-verification/components/verification-list-table";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
export default function VerifiedPages() {
const { t } = useTranslation();
const { cursor, goNext, goPrev, resetCursor } = useCursorPaginate();
const [searchValue, setSearchValue] = useState("");
const [debouncedSearch] = useDebouncedValue(searchValue, 300);
const [spaceFilter, setSpaceFilter] = useState<string[]>([]);
const [typeFilter, setTypeFilter] = useState<string | null>(null);
const { data: spacesData } = useGetSpacesQuery({ limit: 100 });
const spaceOptions = useMemo(
() =>
spacesData?.items?.map((space) => ({
value: space.id,
label: space.name,
})) ?? [],
[spacesData],
);
const typeOptions = [
{ value: "expiring", label: t("Expiring") },
{ value: "qms", label: t("QMS") },
];
const params: IVerificationListParams = useMemo(
() => ({
cursor,
limit: 50,
spaceIds: spaceFilter.length > 0 ? spaceFilter : undefined,
type: typeFilter as IVerificationListParams["type"],
query: debouncedSearch || undefined,
}),
[cursor, spaceFilter, typeFilter, debouncedSearch],
);
const { data, isLoading } = useVerificationListQuery(params);
const handleSpaceChange = (value: string[]) => {
setSpaceFilter(value);
resetCursor();
};
const handleTypeChange = (value: string | null) => {
setTypeFilter(value);
resetCursor();
};
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchValue(e.currentTarget.value);
resetCursor();
};
return (
<>
<Helmet>
<title>
{t("Verified pages")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Verified pages")} />
<Group mb="md" gap="sm">
<TextInput
placeholder={t("Search by title")}
leftSection={<IconSearch size={16} />}
value={searchValue}
onChange={handleSearchChange}
size="sm"
w={220}
/>
{/*
<MultiSelect
placeholder={t("Filter by space")}
data={spaceOptions}
value={spaceFilter}
onChange={handleSpaceChange}
clearable
searchable
w={220}
size="sm"
/>
<Select
placeholder={t("Filter by type")}
data={typeOptions}
value={typeFilter}
onChange={handleTypeChange}
clearable
w={160}
size="sm"
/>
*/}
</Group>
<VerificationListTable items={data?.items} isLoading={isLoading} />
<Space h="md" />
{data?.items && data.items.length > 0 && (
<Paginate
hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage}
onNext={() => goNext(data?.meta?.nextCursor)}
onPrev={goPrev}
/>
)}
</>
);
}
@@ -0,0 +1,202 @@
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
IPageVerificationInfo,
ISetupVerification,
IUpdateVerification,
IVerificationListItem,
IVerificationListParams,
} from "@/ee/page-verification/types/page-verification.types";
import {
getVerificationInfo,
getVerificationList,
markObsolete,
rejectApproval,
removeVerification,
setupVerification,
submitForApproval,
updateVerification,
verifyPage,
} from "@/ee/page-verification/services/page-verification-service";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { IPagination } from "@/lib/types";
export function usePageVerificationInfoQuery(
pageId: string | undefined,
): UseQueryResult<IPageVerificationInfo, Error> {
return useQuery({
queryKey: ["page-verification-info", pageId],
queryFn: () => getVerificationInfo(pageId!),
enabled: !!pageId,
});
}
export function useSetupVerificationMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, ISetupVerification>({
mutationFn: (data) => setupVerification(data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["page-verification-info", variables.pageId],
});
notifications.show({ message: t("Verification enabled") });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to enable verification"),
color: "red",
});
},
});
}
export function useUpdateVerificationMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, IUpdateVerification>({
mutationFn: (data) => updateVerification(data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["page-verification-info", variables.pageId],
});
notifications.show({ message: t("Verification updated") });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to update verification"),
color: "red",
});
},
});
}
export function useRemoveVerificationMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, string>({
mutationFn: (pageId) => removeVerification(pageId),
onSuccess: (_, pageId) => {
queryClient.invalidateQueries({
queryKey: ["page-verification-info", pageId],
});
notifications.show({ message: t("Verification removed") });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to remove verification"),
color: "red",
});
},
});
}
export function useVerifyPageMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, string>({
mutationFn: (pageId) => verifyPage(pageId),
onSuccess: (_, pageId) => {
queryClient.invalidateQueries({
queryKey: ["page-verification-info", pageId],
});
notifications.show({ message: t("Page verified") });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to verify page"),
color: "red",
});
},
});
}
export function useSubmitForApprovalMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, string>({
mutationFn: (pageId) => submitForApproval(pageId),
onSuccess: (_, pageId) => {
queryClient.invalidateQueries({
queryKey: ["page-verification-info", pageId],
});
notifications.show({ message: t("Submitted for approval") });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to submit for approval"),
color: "red",
});
},
});
}
export function useRejectApprovalMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, { pageId: string; comment?: string }>({
mutationFn: (data) => rejectApproval(data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["page-verification-info", variables.pageId],
});
notifications.show({ message: t("Approval rejected") });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to reject approval"),
color: "red",
});
},
});
}
export function useMarkObsoleteMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, string>({
mutationFn: (pageId) => markObsolete(pageId),
onSuccess: (_, pageId) => {
queryClient.invalidateQueries({
queryKey: ["page-verification-info", pageId],
});
notifications.show({ message: t("Page marked as obsolete") });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to mark as obsolete"),
color: "red",
});
},
});
}
export function useVerificationListQuery(
params?: IVerificationListParams,
): UseQueryResult<IPagination<IVerificationListItem>, Error> {
return useQuery({
queryKey: ["verification-list", params],
queryFn: () => getVerificationList(params),
placeholderData: keepPreviousData,
});
}
@@ -0,0 +1,61 @@
import api from "@/lib/api-client";
import {
IPageVerificationInfo,
ISetupVerification,
IUpdateVerification,
IVerificationListItem,
IVerificationListParams,
} from "@/ee/page-verification/types/page-verification.types";
import { IPagination } from "@/lib/types";
export async function getVerificationInfo(
pageId: string,
): Promise<IPageVerificationInfo> {
const req = await api.post<IPageVerificationInfo>(
"/pages/verification-info",
{ pageId },
);
return req.data;
}
export async function setupVerification(
data: ISetupVerification,
): Promise<void> {
await api.post("/pages/create-verification", data);
}
export async function updateVerification(
data: IUpdateVerification,
): Promise<void> {
await api.post("/pages/update-verification", data);
}
export async function removeVerification(pageId: string): Promise<void> {
await api.post("/pages/delete-verification", { pageId });
}
export async function verifyPage(pageId: string): Promise<void> {
await api.post("/pages/verify", { pageId });
}
export async function submitForApproval(pageId: string): Promise<void> {
await api.post("/pages/submit-for-approval", { pageId });
}
export async function rejectApproval(data: {
pageId: string;
comment?: string;
}): Promise<void> {
await api.post("/pages/reject-approval", data);
}
export async function markObsolete(pageId: string): Promise<void> {
await api.post("/pages/mark-obsolete", { pageId });
}
export async function getVerificationList(
params?: IVerificationListParams,
): Promise<IPagination<IVerificationListItem>> {
const req = await api.post("/pages/verifications", { ...params });
return req.data;
}
@@ -0,0 +1,104 @@
export type VerificationType = "expiring" | "qms";
export type ExpirationMode = "period" | "fixed" | "indefinite";
export type PeriodUnit = "day" | "week" | "month" | "year";
export type VerificationStatus =
| "verified"
| "expiring"
| "expired"
| "draft"
| "in_approval"
| "approved"
| "obsolete"
| "none";
export type IUserRef = {
id: string;
name: string;
avatarUrl: string | null;
};
export type IVerifier = {
id: string;
name: string;
avatarUrl: string | null;
email: string;
};
export type IPageVerificationInfo = {
id?: string;
pageId?: string;
type?: VerificationType;
mode?: ExpirationMode | null;
periodAmount?: number | null;
periodUnit?: PeriodUnit | null;
status: VerificationStatus;
verifiedAt?: string | null;
verifiedBy?: IUserRef | null;
expiresAt?: string | null;
requestedAt?: string | null;
requestedBy?: IUserRef | null;
rejectedAt?: string | null;
rejectedBy?: IUserRef | null;
rejectionComment?: string | null;
verifiers?: IVerifier[];
permissions?: IPageVerificationPermissions;
};
export type IPageVerificationPermissions = {
canVerify: boolean;
canManage: boolean;
canSubmitForApproval: boolean;
canMarkObsolete: boolean;
};
export type ISetupVerification = {
pageId: string;
type?: VerificationType;
mode?: ExpirationMode;
periodAmount?: number;
periodUnit?: PeriodUnit;
fixedExpiresAt?: string;
verifierIds: string[];
};
export type IUpdateVerification = {
pageId: string;
mode?: ExpirationMode;
periodAmount?: number;
periodUnit?: PeriodUnit;
fixedExpiresAt?: string;
verifierIds?: string[];
};
export type IVerificationListItem = {
id: string;
pageId: string;
spaceId: string;
type: VerificationType;
status: VerificationStatus | null;
mode: ExpirationMode | null;
periodAmount: number | null;
periodUnit: PeriodUnit | null;
verifiedAt: string | null;
expiresAt: string | null;
createdAt: string;
pageTitle: string | null;
pageSlugId: string;
pageIcon: string | null;
spaceName: string;
spaceSlug: string;
verifiers: IUserRef[];
};
export type IVerificationListParams = {
spaceIds?: string[];
verifierId?: string;
type?: VerificationType;
cursor?: string;
beforeCursor?: string;
limit?: number;
query?: string;
};
+116 -1
View File
@@ -2,13 +2,31 @@ import classes from "@/features/editor/styles/editor.module.css";
import React from "react";
import { TitleEditor } from "@/features/editor/title-editor";
import PageEditor from "@/features/editor/page-editor";
import { Container } from "@mantine/core";
import {
Container,
Divider,
Group,
Popover,
Stack,
Text,
UnstyledButton,
} from "@mantine/core";
import { useAtom } from "jotai";
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { PageVerificationBadge } from "@/ee/page-verification";
import { useTranslation } from "react-i18next";
import { IContributor } from "@/features/page/types/page.types.ts";
const MemoizedTitleEditor = React.memo(TitleEditor);
const MemoizedPageEditor = React.memo(PageEditor);
type PageCreator = {
id: string;
name: string;
avatarUrl: string;
};
export interface FullEditorProps {
pageId: string;
slugId: string;
@@ -16,6 +34,8 @@ export interface FullEditorProps {
content: string;
spaceSlug: string;
editable: boolean;
creator?: PageCreator;
contributors?: IContributor[];
canComment?: boolean;
}
@@ -26,6 +46,8 @@ export function FullEditor({
content,
spaceSlug,
editable,
creator,
contributors,
canComment,
}: FullEditorProps) {
const [user] = useAtom(userAtom);
@@ -44,6 +66,11 @@ export function FullEditor({
spaceSlug={spaceSlug}
editable={editable}
/>
<PageByline
creator={creator}
contributors={contributors}
readOnly={!editable}
/>
<MemoizedPageEditor
pageId={pageId}
editable={editable}
@@ -53,3 +80,91 @@ export function FullEditor({
</Container>
);
}
type PageBylineProps = {
creator?: PageCreator;
contributors?: IContributor[];
readOnly?: boolean;
};
function PageByline({
creator,
contributors,
readOnly,
}: PageBylineProps) {
const { t } = useTranslation();
const otherContributors = (contributors ?? []).filter(
(c) => c.id !== creator?.id,
);
return (
<Group
gap="sm"
mb="md"
style={{ marginTop: "-0.5em", paddingLeft: "3rem" }}
>
{creator && (
<Popover position="bottom-start" shadow="md" width={280} withArrow>
<Popover.Target>
<UnstyledButton>
<Group gap={6}>
<CustomAvatar
avatarUrl={creator.avatarUrl}
name={creator.name}
size={22}
/>
<Text size="sm" c="dimmed">
{t("By {{name}}", { name: creator.name })}
</Text>
</Group>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs">
<Group gap="sm">
<CustomAvatar
avatarUrl={creator.avatarUrl}
name={creator.name}
size={36}
/>
<div>
<Text size="sm" fw={500}>
{creator.name}
</Text>
<Text size="xs" c="dimmed">
{otherContributors.length === 0
? t("Owner, no contributors")
: t("Owner")}
</Text>
</div>
</Group>
{otherContributors.length > 0 && (
<>
<Divider />
<Text size="xs" fw={500} c="dimmed" tt="uppercase">
{t("Contributors")}
</Text>
<Stack gap={6}>
{otherContributors.map((contributor) => (
<Group gap="sm" key={contributor.id}>
<CustomAvatar
avatarUrl={contributor.avatarUrl}
name={contributor.name}
size={28}
/>
<Text size="sm">{contributor.name}</Text>
</Group>
))}
</Stack>
</>
)}
</Stack>
</Popover.Dropdown>
</Popover>
)}
<PageVerificationBadge readOnly={readOnly} />
</Group>
);
}
@@ -6,10 +6,12 @@ import {
UnstyledButton,
} from "@mantine/core";
import {
IconBell,
IconCheck,
IconFileDescription,
IconPointFilled,
} from "@tabler/icons-react";
import { Avatar } from "@mantine/core";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { INotification } from "../types/notification.types";
import { Trans, useTranslation } from "react-i18next";
@@ -51,6 +53,16 @@ export function NotificationItem({
: "<bold>{{name}}</bold> gave you view access to a page";
case "page.updated":
return "<bold>{{name}}</bold> updated a page";
case "page.verified":
return "<bold>{{name}}</bold> verified a page";
case "page.approval_requested":
return "<bold>{{name}}</bold> submitted a page for your approval";
case "page.approval_rejected":
return "<bold>{{name}}</bold> returned a page for revision";
case "page.verification_expiring":
return "Page verification expires soon";
case "page.verification_expired":
return "Page verification has expired";
default:
return "";
}
@@ -96,11 +108,17 @@ export function NotificationItem({
className={classes.notificationItem}
>
<Group wrap="nowrap" align="flex-start" gap="sm">
{notification.actor ? (
<CustomAvatar
avatarUrl={notification.actor?.avatarUrl}
name={notification.actor?.name || "?"}
avatarUrl={notification.actor.avatarUrl}
name={notification.actor.name}
size="sm"
/>
) : (
<Avatar size="sm" color="gray" radius="xl">
<IconBell size={14} />
</Avatar>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" lineClamp={2}>
@@ -4,7 +4,12 @@ export type NotificationType =
| "comment.resolved"
| "page.user_mention"
| "page.permission_granted"
| "page.updated";
| "page.updated"
| "page.verification_expiring"
| "page.verification_expired"
| "page.verified"
| "page.approval_requested"
| "page.approval_rejected";
export type INotification = {
id: string;
@@ -44,6 +44,10 @@ import { PageStateSegmentedControl } from "@/features/user/components/page-state
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import { PageShareModal } from "@/ee/page-permission";
import {
PageVerificationMenuItem,
PageVerificationModal,
} from "@/ee/page-verification";
import {
useFavoriteIds,
useAddFavoriteMutation,
@@ -135,6 +139,10 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
movePageModalOpened,
{ open: openMovePageModal, close: closeMoveSpaceModal },
] = useDisclosure(false);
const [
verificationOpened,
{ open: openVerificationModal, close: closeVerificationModal },
] = useDisclosure(false);
const [pageEditor] = useAtom(pageEditorAtom);
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
const favoriteIds = useFavoriteIds("page");
@@ -261,6 +269,13 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
{t("Page history")}
</Menu.Item>
{!readOnly && (
<PageVerificationMenuItem
pageId={page?.id}
onClick={openVerificationModal}
/>
)}
<Menu.Divider />
{!readOnly && (
@@ -350,6 +365,12 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
onClose={closeMoveSpaceModal}
open={movePageModalOpened}
/>
<PageVerificationModal
pageId={page.id}
opened={verificationOpened}
onClose={closeVerificationModal}
/>
</>
);
}
@@ -22,6 +22,7 @@ export interface IPage {
creator: ICreator;
lastUpdatedBy: ILastUpdatedBy;
deletedBy: IDeletedBy;
contributors?: IContributor[];
space: Partial<ISpace>;
permissions?: {
canEdit: boolean;
@@ -29,6 +30,12 @@ export interface IPage {
};
}
export interface IContributor {
id: string;
name: string;
avatarUrl: string;
}
interface ICreator {
id: string;
name: string;
@@ -85,6 +85,11 @@ export type RefetchRootTreeNodeEvent = {
spaceId: string;
};
export type VerificationUpdatedEvent = {
operation: "verificationUpdated";
pageId: string;
};
export type WebSocketEvent =
| InvalidateEvent
| CommentCreatedEvent
@@ -96,4 +101,5 @@ export type WebSocketEvent =
| AddTreeNodeEvent
| MoveTreeNodeEvent
| DeleteTreeNodeEvent
| RefetchRootTreeNodeEvent;
| RefetchRootTreeNodeEvent
| VerificationUpdatedEvent;
@@ -157,6 +157,11 @@ export const useQuerySubscription = () => {
});
break;
}
case "verificationUpdated":
queryClient.invalidateQueries({
queryKey: ["page-verification-info", data.pageId],
});
break;
}
});
}, [queryClient, socket]);
+2
View File
@@ -107,6 +107,8 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
slugId={page.slugId}
spaceSlug={page?.space?.slug}
editable={canEdit}
creator={page.creator}
contributors={page.contributors}
canComment={canComment}
/>
<MemoizedHistoryModal pageId={page.id} />
@@ -143,6 +143,18 @@ export function getPageId(documentName: string) {
return documentName.split('.')[1];
}
export function isEmptyParagraphDoc(tiptapJson: JSONContent): boolean {
if (!tiptapJson || tiptapJson.type !== 'doc') return false;
const content = tiptapJson.content;
if (!Array.isArray(content) || content.length !== 1) return false;
const child = content[0];
if (!child || child.type !== 'paragraph') return false;
return (
!child.content ||
(Array.isArray(child.content) && child.content.length === 0)
);
}
function stripUnknownNodes(
json: JSONContent,
schema: Schema,
@@ -18,6 +18,7 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { isDeepStrictEqual } from 'node:util';
import { CollabHistoryService } from '../services/collab-history.service';
import { WatcherService } from '../../core/watcher/watcher.service';
import { isEmptyParagraphDoc } from '../collaboration.util';
@Processor(QueueName.HISTORY_QUEUE)
export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
@@ -55,6 +56,14 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
{ includeContent: true },
);
if (!lastHistory && isEmptyParagraphDoc(page.content as any)) {
this.logger.debug(
`Skipping first history for page ${pageId}: empty content`,
);
await this.collabHistory.clearContributors(pageId);
return;
}
if (
!lastHistory ||
!isDeepStrictEqual(lastHistory.content, page.content)
@@ -59,6 +59,14 @@ export const AuditEvent = {
PAGE_RESTRICTION_REMOVED: 'page.restriction_removed',
PAGE_PERMISSION_ADDED: 'page.permission_added',
PAGE_PERMISSION_REMOVED: 'page.permission_removed',
// Page verification
PAGE_VERIFICATION_CREATED: 'page.verification_created',
PAGE_VERIFICATION_UPDATED: 'page.verification_updated',
PAGE_VERIFICATION_REMOVED: 'page.verification_removed',
PAGE_VERIFIED: 'page.verified',
PAGE_APPROVAL_REQUESTED: 'page.approval_requested',
PAGE_APPROVAL_REJECTED: 'page.approval_rejected',
PAGE_MARKED_OBSOLETE: 'page.marked_obsolete',
// Share
SHARE_CREATED: 'share.created',
@@ -5,6 +5,11 @@ export const NotificationType = {
PAGE_USER_MENTION: 'page.user_mention',
PAGE_PERMISSION_GRANTED: 'page.permission_granted',
PAGE_UPDATED: 'page.updated',
PAGE_VERIFICATION_EXPIRING: 'page.verification_expiring',
PAGE_VERIFICATION_EXPIRED: 'page.verification_expired',
PAGE_VERIFIED: 'page.verified',
PAGE_APPROVAL_REQUESTED: 'page.approval_requested',
PAGE_APPROVAL_REJECTED: 'page.approval_rejected',
} as const;
export type NotificationType =
@@ -4,6 +4,7 @@ import { NotificationController } from './notification.controller';
import { NotificationProcessor } from './notification.processor';
import { CommentNotificationService } from './services/comment.notification';
import { PageNotificationService } from './services/page.notification';
import { VerificationNotificationService } from './services/verification.notification';
import { PageUpdateEmailRateLimiter } from './services/page-update-email-rate-limiter';
@Module({
@@ -14,6 +15,7 @@ import { PageUpdateEmailRateLimiter } from './services/page-update-email-rate-li
NotificationProcessor,
CommentNotificationService,
PageNotificationService,
VerificationNotificationService,
PageUpdateEmailRateLimiter,
],
exports: [NotificationService],
@@ -1,18 +1,26 @@
import { Logger, OnModuleDestroy } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { QueueJob, QueueName } from '../../integrations/queue/constants';
import {
IApprovalRejectedNotificationJob,
IApprovalRequestedNotificationJob,
ICommentNotificationJob,
ICommentResolvedNotificationJob,
IPageMentionNotificationJob,
IPageUpdateNotificationJob,
IPageVerifiedNotificationJob,
IPermissionGrantedNotificationJob,
IVerificationExpiringNotificationJob,
IVerificationExpiredNotificationJob,
IVerificationReconcileJob,
} from '../../integrations/queue/constants/queue.interface';
import { CommentNotificationService } from './services/comment.notification';
import { PageNotificationService } from './services/page.notification';
import { VerificationNotificationService } from './services/verification.notification';
import { DomainService } from '../../integrations/environment/domain.service';
@Processor(QueueName.NOTIFICATION_QUEUE)
@@ -25,7 +33,9 @@ export class NotificationProcessor
constructor(
private readonly commentNotificationService: CommentNotificationService,
private readonly pageNotificationService: PageNotificationService,
private readonly verificationNotificationService: VerificationNotificationService,
private readonly domainService: DomainService,
private readonly moduleRef: ModuleRef,
@InjectKysely() private readonly db: KyselyDB,
) {
super();
@@ -37,12 +47,23 @@ export class NotificationProcessor
| ICommentResolvedNotificationJob
| IPageMentionNotificationJob
| IPageUpdateNotificationJob
| IPermissionGrantedNotificationJob,
| IPermissionGrantedNotificationJob
| IVerificationExpiringNotificationJob
| IVerificationExpiredNotificationJob
| IVerificationReconcileJob
| IPageVerifiedNotificationJob
| IApprovalRequestedNotificationJob
| IApprovalRejectedNotificationJob,
void
>,
): Promise<void> {
try {
const workspaceId = (job.data as { workspaceId: string }).workspaceId;
if (job.name === QueueJob.VERIFICATION_RECONCILE) {
await this.runVerificationReconcile();
return;
}
const workspaceId = await this.resolveWorkspaceId(job);
const appUrl = await this.getWorkspaceUrl(workspaceId);
switch (job.name) {
@@ -92,6 +113,45 @@ export class NotificationProcessor
break;
}
case QueueJob.PAGE_VERIFICATION_EXPIRING: {
await this.verificationNotificationService.processVerificationExpiring(
job.data as IVerificationExpiringNotificationJob,
appUrl,
);
break;
}
case QueueJob.PAGE_VERIFICATION_EXPIRED: {
await this.verificationNotificationService.processVerificationExpired(
job.data as IVerificationExpiredNotificationJob,
appUrl,
);
break;
}
case QueueJob.PAGE_VERIFIED_NOTIFICATION: {
await this.verificationNotificationService.processPageVerified(
job.data as IPageVerifiedNotificationJob,
);
break;
}
case QueueJob.PAGE_APPROVAL_REQUESTED_NOTIFICATION: {
await this.verificationNotificationService.processApprovalRequested(
job.data as IApprovalRequestedNotificationJob,
appUrl,
);
break;
}
case QueueJob.PAGE_APPROVAL_REJECTED_NOTIFICATION: {
await this.verificationNotificationService.processApprovalRejected(
job.data as IApprovalRejectedNotificationJob,
appUrl,
);
break;
}
default:
this.logger.warn(`Unknown notification job: ${job.name}`);
}
@@ -102,6 +162,49 @@ export class NotificationProcessor
}
}
private async resolveWorkspaceId(job: Job): Promise<string> {
if (
job.name === QueueJob.PAGE_VERIFICATION_EXPIRING ||
job.name === QueueJob.PAGE_VERIFICATION_EXPIRED
) {
const { verificationId } = job.data as { verificationId: string };
const row = await this.db
.selectFrom('pageVerifications')
.select('workspaceId')
.where('id', '=', verificationId)
.executeTakeFirst();
return row?.workspaceId ?? '';
}
return (job.data as { workspaceId: string }).workspaceId;
}
private async runVerificationReconcile(): Promise<void> {
let eeModule: { PageVerificationSchedulerService?: unknown };
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
eeModule = require('../../ee/page-verification/page-verification-scheduler.service');
} catch {
this.logger.debug(
'VERIFICATION_RECONCILE fired but EE scheduler not bundled in this build',
);
return;
}
const schedulerClass = eeModule.PageVerificationSchedulerService as
| (new (...args: unknown[]) => { reconcile(): Promise<void> })
| undefined;
if (!schedulerClass) return;
const scheduler = this.moduleRef.get(schedulerClass, { strict: false });
if (!scheduler) {
this.logger.warn(
'VERIFICATION_RECONCILE fired but scheduler service not resolvable',
);
return;
}
await scheduler.reconcile();
}
private async getWorkspaceUrl(workspaceId: string): Promise<string> {
const workspace = await this.db
.selectFrom('workspaces')
@@ -0,0 +1,355 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import {
IApprovalRejectedNotificationJob,
IApprovalRequestedNotificationJob,
IPageVerifiedNotificationJob,
IVerificationExpiringNotificationJob,
IVerificationExpiredNotificationJob,
} from '../../../integrations/queue/constants/queue.interface';
import { NotificationService } from '../notification.service';
import { NotificationType } from '../notification.constants';
import { VerificationExpiringEmail } from '@docmost/transactional/emails/verification-expiring-email';
import { VerificationExpiredEmail } from '@docmost/transactional/emails/verification-expired-email';
import { ApprovalRequestedEmail } from '@docmost/transactional/emails/approval-requested-email';
import { ApprovalRejectedEmail } from '@docmost/transactional/emails/approval-rejected-email';
import { getPageTitle } from '../../../common/helpers';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
@Injectable()
export class VerificationNotificationService {
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly notificationService: NotificationService,
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
) {}
private async getAlreadyNotifiedUserIds(
pageVerificationId: string,
type: string,
): Promise<Set<string>> {
const rows = await this.db
.selectFrom('notifications')
.select('userId')
.where('pageVerificationId', '=', pageVerificationId)
.where('type', '=', type)
.execute();
return new Set(rows.map((r) => r.userId));
}
private async filterAccessibleRecipients(
userIds: string[],
pageId: string,
spaceId: string,
): Promise<string[]> {
if (userIds.length === 0) return [];
const inSpace = await this.spaceMemberRepo.getUserIdsWithSpaceAccess(
userIds,
spaceId,
);
if (inSpace.size === 0) return [];
return this.pagePermissionRepo.getUserIdsWithPageAccess(pageId, [
...inSpace,
]);
}
async processVerificationExpiring(
data: IVerificationExpiringNotificationJob,
appUrl: string,
) {
const verification = await this.db
.selectFrom('pageVerifications')
.selectAll()
.where('id', '=', data.verificationId)
.executeTakeFirst();
if (!verification) return;
if (verification.type !== 'expiring') return;
if (!verification.expiresAt) return;
const expiresAtMs = new Date(verification.expiresAt).getTime();
if (expiresAtMs <= Date.now()) return;
const verifierRows = await this.db
.selectFrom('pageVerifiers')
.select('userId')
.where('pageVerificationId', '=', verification.id)
.execute();
const verifierIds = verifierRows.map((r) => r.userId);
if (verifierIds.length === 0) return;
const accessibleVerifierIds = await this.filterAccessibleRecipients(
verifierIds,
verification.pageId,
verification.spaceId,
);
if (accessibleVerifierIds.length === 0) return;
const alreadyNotified = await this.getAlreadyNotifiedUserIds(
verification.id,
NotificationType.PAGE_VERIFICATION_EXPIRING,
);
const recipients = accessibleVerifierIds.filter(
(id) => !alreadyNotified.has(id),
);
if (recipients.length === 0) return;
const context = await this.getPageContext(
verification.pageId,
verification.spaceId,
appUrl,
);
if (!context) return;
const { pageTitle, spaceName, basePageUrl } = context;
const expiresAtIso = new Date(verification.expiresAt).toISOString();
for (const userId of recipients) {
const notification = await this.notificationService.create({
userId,
workspaceId: verification.workspaceId,
type: NotificationType.PAGE_VERIFICATION_EXPIRING,
pageId: verification.pageId,
spaceId: verification.spaceId,
pageVerificationId: verification.id,
data: { expiresAt: expiresAtIso },
});
const subject = `"${pageTitle}" needs to be re-verified soon`;
await this.notificationService.queueEmail(
userId,
notification.id,
subject,
VerificationExpiringEmail({
pageTitle,
spaceName,
pageUrl: basePageUrl,
expiresAt: new Date(verification.expiresAt).toLocaleDateString(),
}),
);
}
}
async processVerificationExpired(
data: IVerificationExpiredNotificationJob,
appUrl: string,
) {
const verification = await this.db
.selectFrom('pageVerifications')
.selectAll()
.where('id', '=', data.verificationId)
.executeTakeFirst();
if (!verification) return;
if (verification.type !== 'expiring') return;
if (!verification.expiresAt) return;
if (new Date(verification.expiresAt).getTime() > Date.now()) return;
const verifierRows = await this.db
.selectFrom('pageVerifiers')
.select('userId')
.where('pageVerificationId', '=', verification.id)
.execute();
const verifierIds = verifierRows.map((r) => r.userId);
if (verifierIds.length === 0) return;
const accessibleVerifierIds = await this.filterAccessibleRecipients(
verifierIds,
verification.pageId,
verification.spaceId,
);
if (accessibleVerifierIds.length === 0) return;
const alreadyNotified = await this.getAlreadyNotifiedUserIds(
verification.id,
NotificationType.PAGE_VERIFICATION_EXPIRED,
);
const recipients = accessibleVerifierIds.filter(
(id) => !alreadyNotified.has(id),
);
if (recipients.length === 0) return;
const context = await this.getPageContext(
verification.pageId,
verification.spaceId,
appUrl,
);
if (!context) return;
const { pageTitle, spaceName, basePageUrl } = context;
for (const userId of recipients) {
const notification = await this.notificationService.create({
userId,
workspaceId: verification.workspaceId,
type: NotificationType.PAGE_VERIFICATION_EXPIRED,
pageId: verification.pageId,
spaceId: verification.spaceId,
pageVerificationId: verification.id,
});
const subject = `"${pageTitle}" verification has expired`;
await this.notificationService.queueEmail(
userId,
notification.id,
subject,
VerificationExpiredEmail({
pageTitle,
spaceName,
pageUrl: basePageUrl,
}),
);
}
}
async processPageVerified(data: IPageVerifiedNotificationJob) {
const { verifierIds, pageId, spaceId, workspaceId, actorId } = data;
if (verifierIds.length === 0) return;
const accessibleVerifierIds = await this.filterAccessibleRecipients(
verifierIds,
pageId,
spaceId,
);
if (accessibleVerifierIds.length === 0) return;
for (const userId of accessibleVerifierIds) {
await this.notificationService.create({
userId,
workspaceId,
type: NotificationType.PAGE_VERIFIED,
actorId,
pageId,
spaceId,
});
}
}
async processApprovalRequested(
data: IApprovalRequestedNotificationJob,
appUrl: string,
) {
const { verifierIds, pageId, spaceId, workspaceId, actorId } = data;
if (verifierIds.length === 0) return;
const accessibleVerifierIds = await this.filterAccessibleRecipients(
verifierIds,
pageId,
spaceId,
);
if (accessibleVerifierIds.length === 0) return;
const context = await this.getPageContext(pageId, spaceId, appUrl);
if (!context) return;
const { pageTitle, spaceName, basePageUrl } = context;
const actorName = await this.getUserName(actorId);
for (const userId of accessibleVerifierIds) {
const notification = await this.notificationService.create({
userId,
workspaceId,
type: NotificationType.PAGE_APPROVAL_REQUESTED,
actorId,
pageId,
spaceId,
});
const subject = `"${pageTitle}" needs your approval`;
await this.notificationService.queueEmail(
userId,
notification.id,
subject,
ApprovalRequestedEmail({
actorName,
pageTitle,
spaceName,
pageUrl: basePageUrl,
}),
);
}
}
async processApprovalRejected(
data: IApprovalRejectedNotificationJob,
appUrl: string,
) {
const { pageId, spaceId, workspaceId, actorId, requestedById, comment } =
data;
const recipients = await this.filterAccessibleRecipients(
[requestedById],
pageId,
spaceId,
);
if (recipients.length === 0) return;
const context = await this.getPageContext(pageId, spaceId, appUrl);
if (!context) return;
const { pageTitle, spaceName, basePageUrl } = context;
const actorName = await this.getUserName(actorId);
const notification = await this.notificationService.create({
userId: requestedById,
workspaceId,
type: NotificationType.PAGE_APPROVAL_REJECTED,
actorId,
pageId,
spaceId,
});
const subject = `"${pageTitle}" was returned for revision`;
await this.notificationService.queueEmail(
requestedById,
notification.id,
subject,
ApprovalRejectedEmail({
actorName,
pageTitle,
spaceName,
pageUrl: basePageUrl,
comment,
}),
);
}
private async getUserName(userId: string): Promise<string> {
const user = await this.db
.selectFrom('users')
.select('name')
.where('id', '=', userId)
.executeTakeFirst();
return user?.name ?? 'Someone';
}
private async getPageContext(
pageId: string,
spaceId: string,
appUrl: string,
) {
const [page, space] = await Promise.all([
this.db
.selectFrom('pages')
.select(['id', 'title', 'slugId'])
.where('id', '=', pageId)
.executeTakeFirst(),
this.db
.selectFrom('spaces')
.select(['id', 'slug', 'name'])
.where('id', '=', spaceId)
.executeTakeFirst(),
]);
if (!page || !space) return null;
const basePageUrl = `${appUrl}/s/${space.slug}/p/${page.slugId}`;
return { pageTitle: getPageTitle(page.title), spaceName: space.name ?? space.slug, basePageUrl };
}
}
@@ -452,6 +452,20 @@ export class PageService {
.where('pageId', 'in', pageIdsToMove)
.execute();
// Update page verifications
await trx
.updateTable('pageVerifications')
.set({ spaceId: spaceId })
.where('pageId', 'in', pageIdsToMove)
.execute();
// Update notifications — access follows the page after a move
await trx
.updateTable('notifications')
.set({ spaceId: spaceId })
.where('pageId', 'in', pageIdsToMove)
.execute();
// Update attachments
await this.attachmentRepo.updateAttachmentsByPageId(
{ spaceId },
@@ -0,0 +1,117 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('page_verifications')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('page_id', 'uuid', (col) =>
col.notNull().unique().references('pages.id').onDelete('cascade'),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.notNull().references('workspaces.id').onDelete('cascade'),
)
.addColumn('space_id', 'uuid', (col) =>
col.notNull().references('spaces.id').onDelete('cascade'),
)
.addColumn('type', 'varchar', (col) => col.notNull().defaultTo('expiring'))
.addColumn('status', 'varchar')
.addColumn('mode', 'varchar')
.addColumn('period_amount', 'integer')
.addColumn('period_unit', 'varchar')
.addColumn('verified_at', 'timestamptz')
.addColumn('verified_by_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('expires_at', 'timestamptz')
.addColumn('requested_at', 'timestamptz')
.addColumn('requested_by_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('rejected_at', 'timestamptz')
.addColumn('rejected_by_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('rejection_comment', 'text')
.addColumn('data', 'jsonb')
.addColumn('creator_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
await db.schema
.createTable('page_verifiers')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('page_verification_id', 'uuid', (col) =>
col.notNull().references('page_verifications.id').onDelete('cascade'),
)
.addColumn('user_id', 'uuid', (col) =>
col.notNull().references('users.id').onDelete('cascade'),
)
.addColumn('is_primary', 'boolean', (col) => col.notNull().defaultTo(false))
.addColumn('added_by_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addUniqueConstraint('page_verifiers_verification_user_unique', [
'page_verification_id',
'user_id',
])
.execute();
await db.schema
.createIndex('idx_page_verifications_expires_at')
.ifNotExists()
.on('page_verifications')
.column('expires_at')
.where('expires_at', 'is not', null)
.execute();
await db.schema
.createIndex('idx_page_verifications_workspace_id_id')
.ifNotExists()
.on('page_verifications')
.columns(['workspace_id', 'id desc'])
.execute();
await db.schema
.createIndex('idx_page_verifications_space_id')
.ifNotExists()
.on('page_verifications')
.column('space_id')
.execute();
await db.schema
.createIndex('idx_page_verifiers_user_id')
.ifNotExists()
.on('page_verifiers')
.column('user_id')
.execute();
await db.schema
.alterTable('notifications')
.addColumn('page_verification_id', 'uuid', (col) =>
col.references('page_verifications.id').onDelete('cascade'),
)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('notifications')
.dropColumn('page_verification_id')
.execute();
await db.schema.dropTable('page_verifiers').ifExists().execute();
await db.schema.dropTable('page_verifications').ifExists().execute();
}
@@ -11,7 +11,10 @@ import { ExpressionBuilder } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { NotificationTab, NotificationType } from '../../../core/notification/notification.constants';
import {
NotificationTab,
NotificationType,
} from '../../../core/notification/notification.constants';
@Injectable()
export class NotificationRepo {
@@ -43,7 +46,11 @@ export class NotificationRepo {
.where((eb) =>
eb.or([
eb('spaceId', 'is', null),
eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)),
eb(
'spaceId',
'in',
this.spaceMemberRepo.getUserSpaceIdsQuery(userId),
),
]),
);
@@ -62,6 +69,14 @@ export class NotificationRepo {
});
}
async insert(notification: InsertableNotification): Promise<Notification> {
return this.db
.insertInto('notifications')
.values(notification)
.returningAll()
.executeTakeFirst();
}
async getUnreadCount(userId: string): Promise<number> {
const result = await this.db
.selectFrom('notifications')
@@ -71,7 +86,11 @@ export class NotificationRepo {
.where((eb) =>
eb.or([
eb('spaceId', 'is', null),
eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)),
eb(
'spaceId',
'in',
this.spaceMemberRepo.getUserSpaceIdsQuery(userId),
),
]),
)
.executeTakeFirst();
@@ -79,14 +98,6 @@ export class NotificationRepo {
return Number(result?.count ?? 0);
}
async insert(notification: InsertableNotification): Promise<Notification> {
return this.db
.insertInto('notifications')
.values(notification)
.returningAll()
.executeTakeFirst();
}
async markAsRead(notificationId: string, userId: string): Promise<void> {
await this.db
.updateTable('notifications')
@@ -94,12 +105,6 @@ export class NotificationRepo {
.where('id', '=', notificationId)
.where('userId', '=', userId)
.where('readAt', 'is', null)
.where((eb) =>
eb.or([
eb('spaceId', 'is', null),
eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)),
]),
)
.execute();
}
@@ -116,21 +121,6 @@ export class NotificationRepo {
.where('id', 'in', notificationIds)
.where('userId', '=', userId)
.where('readAt', 'is', null)
.where((eb) =>
eb.or([
eb('spaceId', 'is', null),
eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)),
]),
)
.execute();
}
async markAsEmailed(notificationId: string): Promise<void> {
await this.db
.updateTable('notifications')
.set({ emailedAt: new Date() })
.where('id', '=', notificationId)
.where('emailedAt', 'is', null)
.execute();
}
@@ -140,12 +130,15 @@ export class NotificationRepo {
.set({ readAt: new Date() })
.where('userId', '=', userId)
.where('readAt', 'is', null)
.where((eb) =>
eb.or([
eb('spaceId', 'is', null),
eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)),
]),
)
.execute();
}
async markAsEmailed(notificationId: string): Promise<void> {
await this.db
.updateTable('notifications')
.set({ emailedAt: new Date() })
.where('id', '=', notificationId)
.where('emailedAt', 'is', null)
.execute();
}
+36
View File
@@ -400,6 +400,7 @@ export interface Notifications {
pageId: string | null;
spaceId: string | null;
commentId: string | null;
pageVerificationId: string | null;
data: Json | null;
readAt: Timestamp | null;
emailedAt: Timestamp | null;
@@ -441,6 +442,39 @@ export interface PagePermissions {
updatedAt: Generated<Timestamp>;
}
export interface PageVerifications {
id: Generated<string>;
pageId: string;
workspaceId: string;
spaceId: string;
type: Generated<string>;
status: string | null;
mode: string | null;
periodAmount: number | null;
periodUnit: string | null;
verifiedAt: Timestamp | null;
verifiedById: string | null;
expiresAt: Timestamp | null;
requestedAt: Timestamp | null;
requestedById: string | null;
rejectedAt: Timestamp | null;
rejectedById: string | null;
rejectionComment: string | null;
data: Json | null;
creatorId: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
}
export interface PageVerifiers {
id: Generated<string>;
pageVerificationId: string;
userId: string;
isPrimary: Generated<boolean>;
addedById: string | null;
createdAt: Generated<Timestamp>;
}
export interface Templates {
id: Generated<string>;
title: string | null;
@@ -519,6 +553,8 @@ export interface DB {
pageAccess: PageAccess;
pagePermissions: PagePermissions;
pageHistory: PageHistory;
pageVerifications: PageVerifications;
pageVerifiers: PageVerifiers;
pages: Pages;
shares: Shares;
spaceMembers: SpaceMembers;
@@ -8,6 +8,8 @@ import {
Notifications,
PageAccess as _PageAccess,
PagePermissions as _PagePermissions,
PageVerifications as _PageVerifications,
PageVerifiers as _PageVerifiers,
Pages,
Spaces,
Users,
@@ -182,6 +184,15 @@ export type PagePermission = Selectable<_PagePermissions>;
export type InsertablePagePermission = Insertable<_PagePermissions>;
export type UpdatablePagePermission = Updateable<Omit<_PagePermissions, 'id'>>;
// Page Verification
export type PageVerification = Selectable<_PageVerifications>;
export type InsertablePageVerification = Insertable<_PageVerifications>;
export type UpdatablePageVerification = Updateable<Omit<_PageVerifications, 'id'>>;
// Page Verifier
export type PageVerifier = Selectable<_PageVerifiers>;
export type InsertablePageVerifier = Insertable<_PageVerifiers>;
// User Session
export type UserSession = Selectable<UserSessions>;
export type InsertableUserSession = Insertable<UserSessions>;
@@ -71,6 +71,12 @@ export enum QueueJob {
PAGE_MENTION_NOTIFICATION = 'page-mention-notification',
PAGE_PERMISSION_GRANTED = 'page-permission-granted',
PAGE_UPDATE_DIGEST = 'page-update-digest',
PAGE_VERIFICATION_EXPIRING = 'page-verification-expiring',
PAGE_VERIFICATION_EXPIRED = 'page-verification-expired',
VERIFICATION_RECONCILE = 'verification-reconcile',
PAGE_VERIFIED_NOTIFICATION = 'page-verified-notification',
PAGE_APPROVAL_REQUESTED_NOTIFICATION = 'page-approval-requested-notification',
PAGE_APPROVAL_REJECTED_NOTIFICATION = 'page-approval-rejected-notification',
AUDIT_LOG = 'audit-log',
AUDIT_CLEANUP = 'audit-cleanup',
@@ -76,3 +76,40 @@ export interface IPermissionGrantedNotificationJob {
actorId: string;
role: string;
}
export interface IVerificationExpiringNotificationJob {
verificationId: string;
}
export interface IVerificationExpiredNotificationJob {
verificationId: string;
}
export interface IVerificationReconcileJob {
// no payload
}
export interface IPageVerifiedNotificationJob {
pageId: string;
spaceId: string;
workspaceId: string;
actorId: string;
verifierIds: string[];
}
export interface IApprovalRequestedNotificationJob {
pageId: string;
spaceId: string;
workspaceId: string;
actorId: string;
verifierIds: string[];
}
export interface IApprovalRejectedNotificationJob {
pageId: string;
spaceId: string;
workspaceId: string;
actorId: string;
requestedById: string;
comment?: string;
}
@@ -0,0 +1,41 @@
import { Section, Text } from '@react-email/components';
import * as React from 'react';
import { content, paragraph } from '../css/styles';
import { EmailButton, MailBody } from '../partials/partials';
interface Props {
actorName: string;
pageTitle: string;
spaceName: string;
pageUrl: string;
comment?: string;
}
export const ApprovalRejectedEmail = ({
actorName,
pageTitle,
spaceName,
pageUrl,
comment,
}: Props) => {
return (
<MailBody>
<Section style={content}>
<Text style={paragraph}>Hi there,</Text>
<Text style={paragraph}>
<strong>{actorName}</strong> returned{' '}
<strong>{pageTitle}</strong> in the{' '}
<strong>{spaceName}</strong> space for revision.
</Text>
{comment && (
<Text style={{ ...paragraph, fontStyle: 'italic' }}>
&ldquo;{comment}&rdquo;
</Text>
)}
</Section>
<EmailButton href={pageUrl}>View page</EmailButton>
</MailBody>
);
};
export default ApprovalRejectedEmail;
@@ -0,0 +1,34 @@
import { Section, Text } from '@react-email/components';
import * as React from 'react';
import { content, paragraph } from '../css/styles';
import { EmailButton, MailBody } from '../partials/partials';
interface Props {
actorName: string;
pageTitle: string;
spaceName: string;
pageUrl: string;
}
export const ApprovalRequestedEmail = ({
actorName,
pageTitle,
spaceName,
pageUrl,
}: Props) => {
return (
<MailBody>
<Section style={content}>
<Text style={paragraph}>Hi there,</Text>
<Text style={paragraph}>
<strong>{actorName}</strong> submitted{' '}
<strong>{pageTitle}</strong> in the{' '}
<strong>{spaceName}</strong> space for your approval.
</Text>
</Section>
<EmailButton href={pageUrl}>Review page</EmailButton>
</MailBody>
);
};
export default ApprovalRequestedEmail;
@@ -27,7 +27,7 @@ export const PageUpdateEmail = ({
<Link href={pageUrl} style={link}>
<strong>{pageTitle}</strong>
</Link>{' '}
in <strong>{spaceName}</strong>.
in the <strong>{spaceName}</strong> space.
</Text>
</Section>
<EmailButton href={pageUrl}>View page</EmailButton>
@@ -0,0 +1,28 @@
import { Section, Text } from '@react-email/components';
import * as React from 'react';
import { content, paragraph } from '../css/styles';
import { EmailButton, MailBody } from '../partials/partials';
interface Props {
pageTitle: string;
spaceName: string;
pageUrl: string;
}
export const VerificationExpiredEmail = ({ pageTitle, spaceName, pageUrl }: Props) => {
return (
<MailBody>
<Section style={content}>
<Text style={paragraph}>Hi there,</Text>
<Text style={paragraph}>
The verification for <strong>{pageTitle}</strong> in the{' '}
<strong>{spaceName}</strong> space has expired. Please re-verify the
page to confirm it is still accurate.
</Text>
</Section>
<EmailButton href={pageUrl}>Re-verify page</EmailButton>
</MailBody>
);
};
export default VerificationExpiredEmail;
@@ -0,0 +1,34 @@
import { Section, Text } from '@react-email/components';
import * as React from 'react';
import { content, paragraph } from '../css/styles';
import { EmailButton, MailBody } from '../partials/partials';
interface Props {
pageTitle: string;
spaceName: string;
pageUrl: string;
expiresAt: string;
}
export const VerificationExpiringEmail = ({
pageTitle,
spaceName,
pageUrl,
expiresAt,
}: Props) => {
return (
<MailBody>
<Section style={content}>
<Text style={paragraph}>Hi there,</Text>
<Text style={paragraph}>
The page <strong>{pageTitle}</strong> in the{' '}
<strong>{spaceName}</strong> space needs to be re-verified. The
verification expires on <strong>{expiresAt}</strong>.
</Text>
</Section>
<EmailButton href={pageUrl}>Review page</EmailButton>
</MailBody>
);
};
export default VerificationExpiringEmail;