feat: refactor page-verification

This commit is contained in:
Philipinho
2026-04-11 23:54:36 +01:00
parent 77f0aa6483
commit 1bd63101d6
20 changed files with 504 additions and 118 deletions
@@ -761,6 +761,9 @@
"Set up verification": "Set up verification", "Set up verification": "Set up verification",
"Verify page": "Verify page", "Verify page": "Verify page",
"Page verification": "Page verification", "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.", "Choose how this page should stay accurate.": "Choose how this page should stay accurate.",
"Recurring verification": "Recurring verification", "Recurring verification": "Recurring verification",
"Verifiers re-confirm this page on a schedule.": "Verifiers re-confirm this page on a schedule.", "Verifiers re-confirm this page on a schedule.": "Verifiers re-confirm this page on a schedule.",
@@ -97,6 +97,12 @@ const groupedData: DataGroup[] = [
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" }, { label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" }, { label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" }, { label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
{
label: "Verified pages",
icon: IconShieldCheck,
path: "/settings/verifications",
feature: Feature.PAGE_VERIFICATION,
},
{ {
label: "API management", label: "API management",
icon: IconKey, icon: IconKey,
@@ -118,13 +124,6 @@ const groupedData: DataGroup[] = [
role: "owner", role: "owner",
env: "selfhosted", env: "selfhosted",
}, },
{
label: "Verified pages",
icon: IconShieldCheck,
path: "/settings/verifications",
feature: Feature.PAGE_VERIFICATION,
role: "admin",
},
], ],
}, },
{ {
@@ -521,6 +521,7 @@ function QmsManageContent({ pageId, info, onClose }: ManageContentProps) {
placeholder={t("Reason for returning this document...")} placeholder={t("Reason for returning this document...")}
minRows={2} minRows={2}
variant="filled" variant="filled"
maxLength={500}
/> />
<Group justify="flex-end" mt="sm" gap="xs"> <Group justify="flex-end" mt="sm" gap="xs">
<Button <Button
@@ -1,11 +1,4 @@
import { import { ActionIcon, Group, Menu, Modal, Text, Tooltip } from "@mantine/core";
ActionIcon,
Group,
Menu,
Modal,
Text,
Tooltip,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { import {
IconRosetteDiscountCheckFilled, IconRosetteDiscountCheckFilled,
@@ -17,6 +10,7 @@ import { extractPageSlugId } from "@/lib";
import { usePageQuery } from "@/features/page/queries/page-query"; import { usePageQuery } from "@/features/page/queries/page-query";
import { usePageVerificationInfoQuery } from "@/ee/page-verification/queries/page-verification-query"; import { usePageVerificationInfoQuery } from "@/ee/page-verification/queries/page-verification-query";
import { useHasFeature } from "@/ee/hooks/use-feature"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { Feature } from "@/ee/features"; import { Feature } from "@/ee/features";
import { SetupVerificationForm } from "./setup-verification-form"; import { SetupVerificationForm } from "./setup-verification-form";
import { ManageVerificationForm } from "./manage-verification-form"; import { ManageVerificationForm } from "./manage-verification-form";
@@ -83,17 +77,32 @@ export function PageVerificationBadge({
const { t } = useTranslation(); const { t } = useTranslation();
const { pageSlug } = useParams(); const { pageSlug } = useParams();
const pageSlugId = extractPageSlugId(pageSlug); const pageSlugId = extractPageSlugId(pageSlug);
const isCloudEE = useHasFeature(Feature.PAGE_VERIFICATION); const hasVerificationFeature = useHasFeature(Feature.PAGE_VERIFICATION);
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const { data: page } = usePageQuery({ pageId: pageSlugId }); const { data: page } = usePageQuery({ pageId: pageSlugId });
const pageId = page?.id; const pageId = page?.id;
const { data: verificationInfo, isLoading } = usePageVerificationInfoQuery( const { data: verificationInfo, isLoading } = usePageVerificationInfoQuery(
isCloudEE ? pageId : undefined, hasVerificationFeature ? pageId : undefined,
); );
const upgradeLabel = useUpgradeLabel();
if (!isCloudEE || !pageId) return null; if (!pageId) return null;
if (!hasVerificationFeature) {
if (readOnly) return null;
return (
<Tooltip
label={`${t("Page verification")}${upgradeLabel}`}
withArrow
openDelay={250}
>
<ActionIcon variant="subtle" color="gray">
<IconShieldCheck size={20} stroke={1.5} />
</ActionIcon>
</Tooltip>
);
}
if (isLoading) return null; if (isLoading) return null;
const status = verificationInfo?.status ?? "none"; const status = verificationInfo?.status ?? "none";
@@ -127,30 +136,51 @@ export function PageVerificationBadge({
</Tooltip> </Tooltip>
) : null} ) : null}
<PageVerificationModal <PageVerificationModal pageId={pageId} opened={opened} onClose={close} />
pageId={pageId}
opened={opened}
onClose={close}
/>
</> </>
); );
} }
type PageVerificationMenuItemProps = { type PageVerificationMenuItemProps = {
pageId?: string;
onClick: () => void; onClick: () => void;
}; };
export function PageVerificationMenuItem({ export function PageVerificationMenuItem({
pageId,
onClick, onClick,
}: PageVerificationMenuItemProps) { }: PageVerificationMenuItemProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const isCloudEE = useHasFeature(Feature.PAGE_VERIFICATION); const hasVerificationFeature = useHasFeature(Feature.PAGE_VERIFICATION);
const upgradeLabel = useUpgradeLabel();
if (!isCloudEE) return null; const { data: verificationInfo } = usePageVerificationInfoQuery(
hasVerificationFeature ? pageId : undefined,
);
return ( const hasVerification =
<Menu.Item leftSection={<IconShieldCheck size={16} />} onClick={onClick}> !!verificationInfo && verificationInfo.status !== "none";
{t("Page verification")} 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> </Menu.Item>
); );
if (!hasVerificationFeature) {
return (
<Tooltip label={upgradeLabel} position="left" withinPortal={false}>
{menuItem}
</Tooltip>
);
}
return menuItem;
} }
@@ -133,6 +133,7 @@ type SetupVerificationFormProps = {
export function SetupVerificationForm({ export function SetupVerificationForm({
pageId, pageId,
onClose,
}: SetupVerificationFormProps) { }: SetupVerificationFormProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const setupMutation = useSetupVerificationMutation(); const setupMutation = useSetupVerificationMutation();
@@ -182,22 +183,31 @@ export function SetupVerificationForm({
const handleSetup = () => { const handleSetup = () => {
if (selectedVerifiers.length === 0) return; if (selectedVerifiers.length === 0) return;
setupMutation.mutate({ setupMutation.mutate(
pageId, {
type, pageId,
...(!isQms && { type,
mode, ...(!isQms && {
...(mode === "period" && { mode,
periodAmount, ...(mode === "period" && {
periodUnit, periodAmount,
}), periodUnit,
...(mode === "fixed" &&
fixedDate && {
fixedExpiresAt: new Date(fixedDate).toISOString(),
}), }),
}), ...(mode === "fixed" &&
verifierIds: selectedVerifiers.map((v) => v.value), fixedDate && {
}); fixedExpiresAt: new Date(fixedDate).toISOString(),
}),
}),
verifierIds: selectedVerifiers.map((v) => v.value),
},
{
onSuccess: () => {
if (!isQms) {
onClose();
}
},
},
);
}; };
const periodValid = const periodValid =
@@ -1,4 +1,13 @@
import { Table, Text, Group, Skeleton, Anchor, Badge } from "@mantine/core"; import {
Table,
Text,
Group,
Skeleton,
Anchor,
Badge,
Avatar,
Tooltip,
} from "@mantine/core";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
@@ -10,6 +19,8 @@ import { buildPageUrl } from "@/features/page/page.utils";
import { format } from "date-fns"; import { format } from "date-fns";
import NoTableResults from "@/components/common/no-table-results"; import NoTableResults from "@/components/common/no-table-results";
const MAX_VISIBLE_VERIFIERS = 5;
type VerificationListTableProps = { type VerificationListTableProps = {
items?: IVerificationListItem[]; items?: IVerificationListItem[];
isLoading: boolean; isLoading: boolean;
@@ -20,7 +31,7 @@ function statusBadge(status: VerificationStatus | null, t: (s: string) => string
case "verified": case "verified":
return <Badge color="green" variant="light" size="sm">{t("Verified")}</Badge>; return <Badge color="green" variant="light" size="sm">{t("Verified")}</Badge>;
case "expiring": case "expiring":
return <Badge color="yellow" variant="light" size="sm">{t("Expiring")}</Badge>; return <Badge color="orange" variant="light" size="sm">{t("Expiring")}</Badge>;
case "expired": case "expired":
return <Badge color="red" variant="light" size="sm">{t("Expired")}</Badge>; return <Badge color="red" variant="light" size="sm">{t("Expired")}</Badge>;
case "approved": case "approved":
@@ -63,9 +74,10 @@ function TableSkeleton() {
</div> </div>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group gap="sm" wrap="nowrap"> <Group gap={8} wrap="nowrap">
<Skeleton circle height={28} /> <Skeleton circle height={24} />
<Skeleton height={14} width={80} /> <Skeleton circle height={24} />
<Skeleton circle height={24} />
</Group> </Group>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
@@ -92,7 +104,7 @@ export default function VerificationListTable({
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>{t("Page")}</Table.Th> <Table.Th>{t("Page")}</Table.Th>
<Table.Th>{t("Owner")}</Table.Th> <Table.Th>{t("Verifiers")}</Table.Th>
<Table.Th>{t("Verified until")}</Table.Th> <Table.Th>{t("Verified until")}</Table.Th>
<Table.Th>{t("Status")}</Table.Th> <Table.Th>{t("Status")}</Table.Th>
</Table.Tr> </Table.Tr>
@@ -103,7 +115,7 @@ export default function VerificationListTable({
<TableSkeleton /> <TableSkeleton />
) : items && items.length > 0 ? ( ) : items && items.length > 0 ? (
items.map((item) => { items.map((item) => {
const primaryVerifier = item.verifiers[0]; const verifiers = item.verifiers ?? [];
const pageUrl = buildPageUrl( const pageUrl = buildPageUrl(
item.spaceSlug, item.spaceSlug,
@@ -132,19 +144,55 @@ export default function VerificationListTable({
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
{primaryVerifier ? ( {verifiers.length === 1 ? (
<Group gap="sm" wrap="nowrap"> <Group gap={8} wrap="nowrap">
<CustomAvatar <CustomAvatar
avatarUrl={primaryVerifier.avatarUrl} size="sm"
name={primaryVerifier.name} avatarUrl={verifiers[0].avatarUrl}
size={28} name={verifiers[0].name}
/> />
<Text fz="sm" lineClamp={1}> <Text fz="sm" lineClamp={1}>
{primaryVerifier.name} {verifiers[0].name}
</Text> </Text>
</Group> </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> <Text fz="sm" c="dimmed">
</Text>
)} )}
</Table.Td> </Table.Td>
@@ -12,11 +12,9 @@ import { useVerificationListQuery } from "@/ee/page-verification/queries/page-ve
import { IVerificationListParams } from "@/ee/page-verification/types/page-verification.types"; import { IVerificationListParams } from "@/ee/page-verification/types/page-verification.types";
import VerificationListTable from "@/ee/page-verification/components/verification-list-table"; import VerificationListTable from "@/ee/page-verification/components/verification-list-table";
import { useGetSpacesQuery } from "@/features/space/queries/space-query"; import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import useUserRole from "@/hooks/use-user-role";
export default function VerifiedPages() { export default function VerifiedPages() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isAdmin } = useUserRole();
const { cursor, goNext, goPrev, resetCursor } = useCursorPaginate(); const { cursor, goNext, goPrev, resetCursor } = useCursorPaginate();
const [searchValue, setSearchValue] = useState(""); const [searchValue, setSearchValue] = useState("");
@@ -53,10 +51,6 @@ export default function VerifiedPages() {
const { data, isLoading } = useVerificationListQuery(params); const { data, isLoading } = useVerificationListQuery(params);
if (!isAdmin) {
return null;
}
const handleSpaceChange = (value: string[]) => { const handleSpaceChange = (value: string[]) => {
setSpaceFilter(value); setSpaceFilter(value);
resetCursor(); resetCursor();
@@ -84,7 +78,7 @@ export default function VerifiedPages() {
<Group mb="md" gap="sm"> <Group mb="md" gap="sm">
<TextInput <TextInput
placeholder={t("Search pages...")} placeholder={t("Search by title")}
leftSection={<IconSearch size={16} />} leftSection={<IconSearch size={16} />}
value={searchValue} value={searchValue}
onChange={handleSearchChange} onChange={handleSearchChange}
@@ -92,6 +86,7 @@ export default function VerifiedPages() {
w={220} w={220}
/> />
{/*
<MultiSelect <MultiSelect
placeholder={t("Filter by space")} placeholder={t("Filter by space")}
data={spaceOptions} data={spaceOptions}
@@ -112,6 +107,7 @@ export default function VerifiedPages() {
w={160} w={160}
size="sm" size="sm"
/> />
*/}
</Group> </Group>
<VerificationListTable items={data?.items} isLoading={isLoading} /> <VerificationListTable items={data?.items} isLoading={isLoading} />
@@ -12,7 +12,7 @@ export async function getVerificationInfo(
pageId: string, pageId: string,
): Promise<IPageVerificationInfo> { ): Promise<IPageVerificationInfo> {
const req = await api.post<IPageVerificationInfo>( const req = await api.post<IPageVerificationInfo>(
"/page-verification/info", "/pages/verification-info",
{ pageId }, { pageId },
); );
return req.data; return req.data;
@@ -21,41 +21,41 @@ export async function getVerificationInfo(
export async function setupVerification( export async function setupVerification(
data: ISetupVerification, data: ISetupVerification,
): Promise<void> { ): Promise<void> {
await api.post("/page-verification/setup", data); await api.post("/pages/create-verification", data);
} }
export async function updateVerification( export async function updateVerification(
data: IUpdateVerification, data: IUpdateVerification,
): Promise<void> { ): Promise<void> {
await api.post("/page-verification/update", data); await api.post("/pages/update-verification", data);
} }
export async function removeVerification(pageId: string): Promise<void> { export async function removeVerification(pageId: string): Promise<void> {
await api.post("/page-verification/remove", { pageId }); await api.post("/pages/delete-verification", { pageId });
} }
export async function verifyPage(pageId: string): Promise<void> { export async function verifyPage(pageId: string): Promise<void> {
await api.post("/page-verification/verify", { pageId }); await api.post("/pages/verify", { pageId });
} }
export async function submitForApproval(pageId: string): Promise<void> { export async function submitForApproval(pageId: string): Promise<void> {
await api.post("/page-verification/submit-for-approval", { pageId }); await api.post("/pages/submit-for-approval", { pageId });
} }
export async function rejectApproval(data: { export async function rejectApproval(data: {
pageId: string; pageId: string;
comment?: string; comment?: string;
}): Promise<void> { }): Promise<void> {
await api.post("/page-verification/reject", data); await api.post("/pages/reject-approval", data);
} }
export async function markObsolete(pageId: string): Promise<void> { export async function markObsolete(pageId: string): Promise<void> {
await api.post("/page-verification/obsolete", { pageId }); await api.post("/pages/mark-obsolete", { pageId });
} }
export async function getVerificationList( export async function getVerificationList(
params?: IVerificationListParams, params?: IVerificationListParams,
): Promise<IPagination<IVerificationListItem>> { ): Promise<IPagination<IVerificationListItem>> {
const req = await api.post("/page-verification/list", { ...params }); const req = await api.post("/pages/verifications", { ...params });
return req.data; return req.data;
} }
@@ -236,7 +236,10 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
</Menu.Item> </Menu.Item>
{!readOnly && ( {!readOnly && (
<PageVerificationMenuItem onClick={openVerificationModal} /> <PageVerificationMenuItem
pageId={page?.id}
onClick={openVerificationModal}
/>
)} )}
<Menu.Divider /> <Menu.Divider />
@@ -143,6 +143,18 @@ export function getPageId(documentName: string) {
return documentName.split('.')[1]; 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( function stripUnknownNodes(
json: JSONContent, json: JSONContent,
schema: Schema, schema: Schema,
@@ -18,6 +18,7 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { isDeepStrictEqual } from 'node:util'; import { isDeepStrictEqual } from 'node:util';
import { CollabHistoryService } from '../services/collab-history.service'; import { CollabHistoryService } from '../services/collab-history.service';
import { WatcherService } from '../../core/watcher/watcher.service'; import { WatcherService } from '../../core/watcher/watcher.service';
import { isEmptyParagraphDoc } from '../collaboration.util';
@Processor(QueueName.HISTORY_QUEUE) @Processor(QueueName.HISTORY_QUEUE)
export class HistoryProcessor extends WorkerHost implements OnModuleDestroy { export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
@@ -55,6 +56,14 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
{ includeContent: true }, { includeContent: true },
); );
if (!lastHistory && isEmptyParagraphDoc(page.content)) {
this.logger.debug(
`Skipping first history for page ${pageId}: empty content`,
);
await this.collabHistory.clearContributors(pageId);
return;
}
if ( if (
!lastHistory || !lastHistory ||
!isDeepStrictEqual(lastHistory.content, page.content) !isDeepStrictEqual(lastHistory.content, page.content)
@@ -1,4 +1,5 @@
import { Logger, OnModuleDestroy } from '@nestjs/common'; import { Logger, OnModuleDestroy } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq'; import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq'; import { Job } from 'bullmq';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
@@ -15,6 +16,7 @@ import {
IPermissionGrantedNotificationJob, IPermissionGrantedNotificationJob,
IVerificationExpiringNotificationJob, IVerificationExpiringNotificationJob,
IVerificationExpiredNotificationJob, IVerificationExpiredNotificationJob,
IVerificationReconcileJob,
} from '../../integrations/queue/constants/queue.interface'; } from '../../integrations/queue/constants/queue.interface';
import { CommentNotificationService } from './services/comment.notification'; import { CommentNotificationService } from './services/comment.notification';
import { PageNotificationService } from './services/page.notification'; import { PageNotificationService } from './services/page.notification';
@@ -33,6 +35,7 @@ export class NotificationProcessor
private readonly pageNotificationService: PageNotificationService, private readonly pageNotificationService: PageNotificationService,
private readonly verificationNotificationService: VerificationNotificationService, private readonly verificationNotificationService: VerificationNotificationService,
private readonly domainService: DomainService, private readonly domainService: DomainService,
private readonly moduleRef: ModuleRef,
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
) { ) {
super(); super();
@@ -47,6 +50,7 @@ export class NotificationProcessor
| IPermissionGrantedNotificationJob | IPermissionGrantedNotificationJob
| IVerificationExpiringNotificationJob | IVerificationExpiringNotificationJob
| IVerificationExpiredNotificationJob | IVerificationExpiredNotificationJob
| IVerificationReconcileJob
| IPageVerifiedNotificationJob | IPageVerifiedNotificationJob
| IApprovalRequestedNotificationJob | IApprovalRequestedNotificationJob
| IApprovalRejectedNotificationJob, | IApprovalRejectedNotificationJob,
@@ -54,7 +58,12 @@ export class NotificationProcessor
>, >,
): Promise<void> { ): Promise<void> {
try { 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); const appUrl = await this.getWorkspaceUrl(workspaceId);
switch (job.name) { switch (job.name) {
@@ -153,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> { private async getWorkspaceUrl(workspaceId: string): Promise<string> {
const workspace = await this.db const workspace = await this.db
.selectFrom('workspaces') .selectFrom('workspaces')
@@ -8,6 +8,7 @@ import { WsGateway } from '../../ws/ws.gateway';
import { MailService } from '../../integrations/mail/mail.service'; import { MailService } from '../../integrations/mail/mail.service';
import { NotificationTab, NotificationType, NotificationTypeToSettingKey } from './notification.constants'; import { NotificationTab, NotificationType, NotificationTypeToSettingKey } from './notification.constants';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'; import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
@Injectable() @Injectable()
export class NotificationService { export class NotificationService {
@@ -16,11 +17,108 @@ export class NotificationService {
constructor( constructor(
private readonly notificationRepo: NotificationRepo, private readonly notificationRepo: NotificationRepo,
private readonly pagePermissionRepo: PagePermissionRepo, private readonly pagePermissionRepo: PagePermissionRepo,
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly wsGateway: WsGateway, private readonly wsGateway: WsGateway,
private readonly mailService: MailService, private readonly mailService: MailService,
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
) {} ) {}
/**
* Returns the subset of `ids` pointing to notifications the user can
* currently see. Enforces the same dual gate as `findByUserId`:
* 1. `spaceId IS NULL` or user is a current member of `spaceId`.
* 2. `pageId IS NULL` or user has page-level access to `pageId`.
*
* Returning an empty array when `ids` is empty is a shortcut callers use to
* make the mark/count paths no-ops.
*/
private async filterAccessibleNotificationIds(
ids: string[],
userId: string,
): Promise<string[]> {
if (ids.length === 0) return [];
const rows = await this.db
.selectFrom('notifications')
.select(['id', 'pageId'])
.where('id', 'in', ids)
.where('userId', '=', userId)
.where((eb) =>
eb.or([
eb('spaceId', 'is', null),
eb(
'spaceId',
'in',
this.spaceMemberRepo.getUserSpaceIdsQuery(userId),
),
]),
)
.execute();
if (rows.length === 0) return [];
const pageIds = rows
.map((r) => r.pageId)
.filter((p): p is string => !!p);
if (pageIds.length === 0) {
return rows.map((r) => r.id);
}
const accessiblePageIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
userId,
});
const accessibleSet = new Set(accessiblePageIds);
return rows
.filter((r) => !r.pageId || accessibleSet.has(r.pageId))
.map((r) => r.id);
}
private async listUnreadAccessibleNotificationIds(
userId: string,
): Promise<string[]> {
const rows = await this.db
.selectFrom('notifications')
.select(['id', 'pageId'])
.where('userId', '=', userId)
.where('readAt', 'is', null)
.where((eb) =>
eb.or([
eb('spaceId', 'is', null),
eb(
'spaceId',
'in',
this.spaceMemberRepo.getUserSpaceIdsQuery(userId),
),
]),
)
.execute();
if (rows.length === 0) return [];
const pageIds = rows
.map((r) => r.pageId)
.filter((p): p is string => !!p);
if (pageIds.length === 0) {
return rows.map((r) => r.id);
}
const accessiblePageIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
userId,
});
const accessibleSet = new Set(accessiblePageIds);
return rows
.filter((r) => !r.pageId || accessibleSet.has(r.pageId))
.map((r) => r.id);
}
async create(data: InsertableNotification) { async create(data: InsertableNotification) {
const user = await this.db const user = await this.db
.selectFrom('users') .selectFrom('users')
@@ -73,19 +171,34 @@ export class NotificationService {
} }
async getUnreadCount(userId: string) { async getUnreadCount(userId: string) {
return this.notificationRepo.getUnreadCount(userId); const accessibleIds =
await this.listUnreadAccessibleNotificationIds(userId);
return accessibleIds.length;
} }
async markAsRead(notificationId: string, userId: string) { async markAsRead(notificationId: string, userId: string) {
return this.notificationRepo.markAsRead(notificationId, userId); const accessibleIds = await this.filterAccessibleNotificationIds(
[notificationId],
userId,
);
if (accessibleIds.length === 0) return;
return this.notificationRepo.markAsRead(accessibleIds[0], userId);
} }
async markMultipleAsRead(notificationIds: string[], userId: string) { async markMultipleAsRead(notificationIds: string[], userId: string) {
return this.notificationRepo.markMultipleAsRead(notificationIds, userId); const accessibleIds = await this.filterAccessibleNotificationIds(
notificationIds,
userId,
);
if (accessibleIds.length === 0) return;
return this.notificationRepo.markMultipleAsRead(accessibleIds, userId);
} }
async markAllAsRead(userId: string) { async markAllAsRead(userId: string) {
return this.notificationRepo.markAllAsRead(userId); const accessibleIds =
await this.listUnreadAccessibleNotificationIds(userId);
if (accessibleIds.length === 0) return;
return this.notificationRepo.markMultipleAsRead(accessibleIds, userId);
} }
async queueEmail( async queueEmail(
@@ -15,34 +15,83 @@ import { VerificationExpiredEmail } from '@docmost/transactional/emails/verifica
import { ApprovalRequestedEmail } from '@docmost/transactional/emails/approval-requested-email'; import { ApprovalRequestedEmail } from '@docmost/transactional/emails/approval-requested-email';
import { ApprovalRejectedEmail } from '@docmost/transactional/emails/approval-rejected-email'; import { ApprovalRejectedEmail } from '@docmost/transactional/emails/approval-rejected-email';
import { getPageTitle } from '../../../common/helpers'; 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() @Injectable()
export class VerificationNotificationService { export class VerificationNotificationService {
constructor( constructor(
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
private readonly notificationService: NotificationService, private readonly notificationService: NotificationService,
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
) {} ) {}
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( async processVerificationExpiring(
data: IVerificationExpiringNotificationJob, data: IVerificationExpiringNotificationJob,
appUrl: string, appUrl: string,
) { ) {
const { verifierIds, pageId, spaceId, workspaceId, expiresAt } = data; 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; if (verifierIds.length === 0) return;
const context = await this.getPageContext(pageId, spaceId, appUrl); const accessibleVerifierIds = await this.filterAccessibleRecipients(
verifierIds,
verification.pageId,
verification.spaceId,
);
if (accessibleVerifierIds.length === 0) return;
const context = await this.getPageContext(
verification.pageId,
verification.spaceId,
appUrl,
);
if (!context) return; if (!context) return;
const { pageTitle, basePageUrl } = context; const { pageTitle, basePageUrl } = context;
const expiresAtIso = new Date(verification.expiresAt).toISOString();
for (const userId of verifierIds) { for (const userId of accessibleVerifierIds) {
const notification = await this.notificationService.create({ const notification = await this.notificationService.create({
userId, userId,
workspaceId, workspaceId: verification.workspaceId,
type: NotificationType.PAGE_VERIFICATION_EXPIRING, type: NotificationType.PAGE_VERIFICATION_EXPIRING,
pageId, pageId: verification.pageId,
spaceId, spaceId: verification.spaceId,
data: { expiresAt }, data: { expiresAt: expiresAtIso },
}); });
const subject = `"${pageTitle}" needs to be re-verified soon`; const subject = `"${pageTitle}" needs to be re-verified soon`;
@@ -54,7 +103,7 @@ export class VerificationNotificationService {
VerificationExpiringEmail({ VerificationExpiringEmail({
pageTitle, pageTitle,
pageUrl: basePageUrl, pageUrl: basePageUrl,
expiresAt: new Date(expiresAt).toLocaleDateString(), expiresAt: new Date(verification.expiresAt).toLocaleDateString(),
}), }),
); );
} }
@@ -64,21 +113,44 @@ export class VerificationNotificationService {
data: IVerificationExpiredNotificationJob, data: IVerificationExpiredNotificationJob,
appUrl: string, appUrl: string,
) { ) {
const { verifierIds, pageId, spaceId, workspaceId } = data; const v = await this.db
.selectFrom('pageVerifications')
.selectAll()
.where('id', '=', data.verificationId)
.executeTakeFirst();
if (!v) return;
if (v.type !== 'expiring') return;
if (!v.expiresAt) return;
if (new Date(v.expiresAt).getTime() > Date.now()) return;
const verifierRows = await this.db
.selectFrom('pageVerifiers')
.select('userId')
.where('pageVerificationId', '=', v.id)
.execute();
const verifierIds = verifierRows.map((r) => r.userId);
if (verifierIds.length === 0) return; if (verifierIds.length === 0) return;
const context = await this.getPageContext(pageId, spaceId, appUrl); const accessibleVerifierIds = await this.filterAccessibleRecipients(
verifierIds,
v.pageId,
v.spaceId,
);
if (accessibleVerifierIds.length === 0) return;
const context = await this.getPageContext(v.pageId, v.spaceId, appUrl);
if (!context) return; if (!context) return;
const { pageTitle, basePageUrl } = context; const { pageTitle, basePageUrl } = context;
for (const userId of verifierIds) { for (const userId of accessibleVerifierIds) {
const notification = await this.notificationService.create({ const notification = await this.notificationService.create({
userId, userId,
workspaceId, workspaceId: v.workspaceId,
type: NotificationType.PAGE_VERIFICATION_EXPIRED, type: NotificationType.PAGE_VERIFICATION_EXPIRED,
pageId, pageId: v.pageId,
spaceId, spaceId: v.spaceId,
}); });
const subject = `"${pageTitle}" verification has expired`; const subject = `"${pageTitle}" verification has expired`;
@@ -99,7 +171,14 @@ export class VerificationNotificationService {
const { verifierIds, pageId, spaceId, workspaceId, actorId } = data; const { verifierIds, pageId, spaceId, workspaceId, actorId } = data;
if (verifierIds.length === 0) return; if (verifierIds.length === 0) return;
for (const userId of verifierIds) { const accessibleVerifierIds = await this.filterAccessibleRecipients(
verifierIds,
pageId,
spaceId,
);
if (accessibleVerifierIds.length === 0) return;
for (const userId of accessibleVerifierIds) {
await this.notificationService.create({ await this.notificationService.create({
userId, userId,
workspaceId, workspaceId,
@@ -118,13 +197,20 @@ export class VerificationNotificationService {
const { verifierIds, pageId, spaceId, workspaceId, actorId } = data; const { verifierIds, pageId, spaceId, workspaceId, actorId } = data;
if (verifierIds.length === 0) return; 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); const context = await this.getPageContext(pageId, spaceId, appUrl);
if (!context) return; if (!context) return;
const { pageTitle, basePageUrl } = context; const { pageTitle, basePageUrl } = context;
const actorName = await this.getUserName(actorId); const actorName = await this.getUserName(actorId);
for (const userId of verifierIds) { for (const userId of accessibleVerifierIds) {
const notification = await this.notificationService.create({ const notification = await this.notificationService.create({
userId, userId,
workspaceId, workspaceId,
@@ -156,6 +242,13 @@ export class VerificationNotificationService {
const { pageId, spaceId, workspaceId, actorId, requestedById, comment } = const { pageId, spaceId, workspaceId, actorId, requestedById, comment } =
data; data;
const recipients = await this.filterAccessibleRecipients(
[requestedById],
pageId,
spaceId,
);
if (recipients.length === 0) return;
const context = await this.getPageContext(pageId, spaceId, appUrl); const context = await this.getPageContext(pageId, spaceId, appUrl);
if (!context) return; if (!context) return;
@@ -452,6 +452,20 @@ export class PageService {
.where('pageId', 'in', pageIdsToMove) .where('pageId', 'in', pageIdsToMove)
.execute(); .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 // Update attachments
await this.attachmentRepo.updateAttachmentsByPageId( await this.attachmentRepo.updateAttachmentsByPageId(
{ spaceId }, { spaceId },
@@ -15,9 +15,7 @@ export async function up(db: Kysely<any>): Promise<void> {
.addColumn('space_id', 'uuid', (col) => .addColumn('space_id', 'uuid', (col) =>
col.notNull().references('spaces.id').onDelete('cascade'), col.notNull().references('spaces.id').onDelete('cascade'),
) )
.addColumn('type', 'varchar', (col) => .addColumn('type', 'varchar', (col) => col.notNull().defaultTo('expiring'))
col.notNull().defaultTo('expiring'),
)
.addColumn('status', 'varchar') .addColumn('status', 'varchar')
.addColumn('mode', 'varchar') .addColumn('mode', 'varchar')
.addColumn('period_amount', 'integer') .addColumn('period_amount', 'integer')
@@ -27,7 +25,6 @@ export async function up(db: Kysely<any>): Promise<void> {
col.references('users.id').onDelete('set null'), col.references('users.id').onDelete('set null'),
) )
.addColumn('expires_at', 'timestamptz') .addColumn('expires_at', 'timestamptz')
.addColumn('notified_at', 'timestamptz')
.addColumn('requested_at', 'timestamptz') .addColumn('requested_at', 'timestamptz')
.addColumn('requested_by_id', 'uuid', (col) => .addColumn('requested_by_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'), col.references('users.id').onDelete('set null'),
@@ -55,17 +52,12 @@ export async function up(db: Kysely<any>): Promise<void> {
col.primaryKey().defaultTo(sql`gen_uuid_v7()`), col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
) )
.addColumn('page_verification_id', 'uuid', (col) => .addColumn('page_verification_id', 'uuid', (col) =>
col col.notNull().references('page_verifications.id').onDelete('cascade'),
.notNull()
.references('page_verifications.id')
.onDelete('cascade'),
) )
.addColumn('user_id', 'uuid', (col) => .addColumn('user_id', 'uuid', (col) =>
col.notNull().references('users.id').onDelete('cascade'), col.notNull().references('users.id').onDelete('cascade'),
) )
.addColumn('is_primary', 'boolean', (col) => .addColumn('is_primary', 'boolean', (col) => col.notNull().defaultTo(false))
col.notNull().defaultTo(false),
)
.addColumn('added_by_id', 'uuid', (col) => .addColumn('added_by_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'), col.references('users.id').onDelete('set null'),
) )
@@ -80,12 +72,29 @@ export async function up(db: Kysely<any>): Promise<void> {
await db.schema await db.schema
.createIndex('idx_page_verifications_expires_at') .createIndex('idx_page_verifications_expires_at')
.ifNotExists()
.on('page_verifications') .on('page_verifications')
.column('expires_at') .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(); .execute();
await db.schema await db.schema
.createIndex('idx_page_verifiers_user_id') .createIndex('idx_page_verifiers_user_id')
.ifNotExists()
.on('page_verifiers') .on('page_verifiers')
.column('user_id') .column('user_id')
.execute(); .execute();
-1
View File
@@ -443,7 +443,6 @@ export interface PageVerifications {
verifiedAt: Timestamp | null; verifiedAt: Timestamp | null;
verifiedById: string | null; verifiedById: string | null;
expiresAt: Timestamp | null; expiresAt: Timestamp | null;
notifiedAt: Timestamp | null;
requestedAt: Timestamp | null; requestedAt: Timestamp | null;
requestedById: string | null; requestedById: string | null;
rejectedAt: Timestamp | null; rejectedAt: Timestamp | null;
@@ -73,6 +73,7 @@ export enum QueueJob {
PAGE_UPDATE_DIGEST = 'page-update-digest', PAGE_UPDATE_DIGEST = 'page-update-digest',
PAGE_VERIFICATION_EXPIRING = 'page-verification-expiring', PAGE_VERIFICATION_EXPIRING = 'page-verification-expiring',
PAGE_VERIFICATION_EXPIRED = 'page-verification-expired', PAGE_VERIFICATION_EXPIRED = 'page-verification-expired',
VERIFICATION_RECONCILE = 'verification-reconcile',
PAGE_VERIFIED_NOTIFICATION = 'page-verified-notification', PAGE_VERIFIED_NOTIFICATION = 'page-verified-notification',
PAGE_APPROVAL_REQUESTED_NOTIFICATION = 'page-approval-requested-notification', PAGE_APPROVAL_REQUESTED_NOTIFICATION = 'page-approval-requested-notification',
PAGE_APPROVAL_REJECTED_NOTIFICATION = 'page-approval-rejected-notification', PAGE_APPROVAL_REJECTED_NOTIFICATION = 'page-approval-rejected-notification',
@@ -79,20 +79,14 @@ export interface IPermissionGrantedNotificationJob {
export interface IVerificationExpiringNotificationJob { export interface IVerificationExpiringNotificationJob {
verificationId: string; verificationId: string;
pageId: string;
spaceId: string;
workspaceId: string;
verifierIds: string[];
expiresAt: string;
} }
export interface IVerificationExpiredNotificationJob { export interface IVerificationExpiredNotificationJob {
verificationId: string; verificationId: string;
pageId: string; }
spaceId: string;
workspaceId: string; export interface IVerificationReconcileJob {
verifierIds: string[]; // no payload
expiresAt: string;
} }
export interface IPageVerifiedNotificationJob { export interface IPageVerifiedNotificationJob {