mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
feat: refactor page-verification
This commit is contained in:
@@ -761,6 +761,9 @@
|
||||
"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.",
|
||||
|
||||
@@ -97,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,
|
||||
@@ -118,13 +124,6 @@ const groupedData: DataGroup[] = [
|
||||
role: "owner",
|
||||
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...")}
|
||||
minRows={2}
|
||||
variant="filled"
|
||||
maxLength={500}
|
||||
/>
|
||||
<Group justify="flex-end" mt="sm" gap="xs">
|
||||
<Button
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Group,
|
||||
Menu,
|
||||
Modal,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { ActionIcon, Group, Menu, Modal, Text, Tooltip } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
IconRosetteDiscountCheckFilled,
|
||||
@@ -17,6 +10,7 @@ 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";
|
||||
@@ -83,17 +77,32 @@ export function PageVerificationBadge({
|
||||
const { t } = useTranslation();
|
||||
const { pageSlug } = useParams();
|
||||
const pageSlugId = extractPageSlugId(pageSlug);
|
||||
const isCloudEE = useHasFeature(Feature.PAGE_VERIFICATION);
|
||||
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(
|
||||
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;
|
||||
|
||||
const status = verificationInfo?.status ?? "none";
|
||||
@@ -127,30 +136,51 @@ export function PageVerificationBadge({
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
<PageVerificationModal
|
||||
pageId={pageId}
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
/>
|
||||
<PageVerificationModal pageId={pageId} opened={opened} onClose={close} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type PageVerificationMenuItemProps = {
|
||||
pageId?: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export function PageVerificationMenuItem({
|
||||
pageId,
|
||||
onClick,
|
||||
}: PageVerificationMenuItemProps) {
|
||||
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 (
|
||||
<Menu.Item leftSection={<IconShieldCheck size={16} />} onClick={onClick}>
|
||||
{t("Page verification")}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ type SetupVerificationFormProps = {
|
||||
|
||||
export function SetupVerificationForm({
|
||||
pageId,
|
||||
onClose,
|
||||
}: SetupVerificationFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const setupMutation = useSetupVerificationMutation();
|
||||
@@ -182,22 +183,31 @@ export function SetupVerificationForm({
|
||||
|
||||
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(),
|
||||
setupMutation.mutate(
|
||||
{
|
||||
pageId,
|
||||
type,
|
||||
...(!isQms && {
|
||||
mode,
|
||||
...(mode === "period" && {
|
||||
periodAmount,
|
||||
periodUnit,
|
||||
}),
|
||||
}),
|
||||
verifierIds: selectedVerifiers.map((v) => v.value),
|
||||
});
|
||||
...(mode === "fixed" &&
|
||||
fixedDate && {
|
||||
fixedExpiresAt: new Date(fixedDate).toISOString(),
|
||||
}),
|
||||
}),
|
||||
verifierIds: selectedVerifiers.map((v) => v.value),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
if (!isQms) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
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 { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -10,6 +19,8 @@ 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;
|
||||
@@ -20,7 +31,7 @@ function statusBadge(status: VerificationStatus | null, t: (s: string) => string
|
||||
case "verified":
|
||||
return <Badge color="green" variant="light" size="sm">{t("Verified")}</Badge>;
|
||||
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":
|
||||
return <Badge color="red" variant="light" size="sm">{t("Expired")}</Badge>;
|
||||
case "approved":
|
||||
@@ -63,9 +74,10 @@ function TableSkeleton() {
|
||||
</div>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Skeleton circle height={28} />
|
||||
<Skeleton height={14} width={80} />
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Skeleton circle height={24} />
|
||||
<Skeleton circle height={24} />
|
||||
<Skeleton circle height={24} />
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
@@ -92,7 +104,7 @@ export default function VerificationListTable({
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<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("Status")}</Table.Th>
|
||||
</Table.Tr>
|
||||
@@ -103,7 +115,7 @@ export default function VerificationListTable({
|
||||
<TableSkeleton />
|
||||
) : items && items.length > 0 ? (
|
||||
items.map((item) => {
|
||||
const primaryVerifier = item.verifiers[0];
|
||||
const verifiers = item.verifiers ?? [];
|
||||
|
||||
const pageUrl = buildPageUrl(
|
||||
item.spaceSlug,
|
||||
@@ -132,19 +144,55 @@ export default function VerificationListTable({
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
{primaryVerifier ? (
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{verifiers.length === 1 ? (
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<CustomAvatar
|
||||
avatarUrl={primaryVerifier.avatarUrl}
|
||||
name={primaryVerifier.name}
|
||||
size={28}
|
||||
size="sm"
|
||||
avatarUrl={verifiers[0].avatarUrl}
|
||||
name={verifiers[0].name}
|
||||
/>
|
||||
<Text fz="sm" lineClamp={1}>
|
||||
{primaryVerifier.name}
|
||||
{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>
|
||||
<Text fz="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
)}
|
||||
</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 VerificationListTable from "@/ee/page-verification/components/verification-list-table";
|
||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
||||
import useUserRole from "@/hooks/use-user-role";
|
||||
|
||||
export default function VerifiedPages() {
|
||||
const { t } = useTranslation();
|
||||
const { isAdmin } = useUserRole();
|
||||
const { cursor, goNext, goPrev, resetCursor } = useCursorPaginate();
|
||||
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
@@ -53,10 +51,6 @@ export default function VerifiedPages() {
|
||||
|
||||
const { data, isLoading } = useVerificationListQuery(params);
|
||||
|
||||
if (!isAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSpaceChange = (value: string[]) => {
|
||||
setSpaceFilter(value);
|
||||
resetCursor();
|
||||
@@ -84,7 +78,7 @@ export default function VerifiedPages() {
|
||||
|
||||
<Group mb="md" gap="sm">
|
||||
<TextInput
|
||||
placeholder={t("Search pages...")}
|
||||
placeholder={t("Search by title")}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={searchValue}
|
||||
onChange={handleSearchChange}
|
||||
@@ -92,6 +86,7 @@ export default function VerifiedPages() {
|
||||
w={220}
|
||||
/>
|
||||
|
||||
{/*
|
||||
<MultiSelect
|
||||
placeholder={t("Filter by space")}
|
||||
data={spaceOptions}
|
||||
@@ -112,6 +107,7 @@ export default function VerifiedPages() {
|
||||
w={160}
|
||||
size="sm"
|
||||
/>
|
||||
*/}
|
||||
</Group>
|
||||
|
||||
<VerificationListTable items={data?.items} isLoading={isLoading} />
|
||||
|
||||
@@ -12,7 +12,7 @@ export async function getVerificationInfo(
|
||||
pageId: string,
|
||||
): Promise<IPageVerificationInfo> {
|
||||
const req = await api.post<IPageVerificationInfo>(
|
||||
"/page-verification/info",
|
||||
"/pages/verification-info",
|
||||
{ pageId },
|
||||
);
|
||||
return req.data;
|
||||
@@ -21,41 +21,41 @@ export async function getVerificationInfo(
|
||||
export async function setupVerification(
|
||||
data: ISetupVerification,
|
||||
): Promise<void> {
|
||||
await api.post("/page-verification/setup", data);
|
||||
await api.post("/pages/create-verification", data);
|
||||
}
|
||||
|
||||
export async function updateVerification(
|
||||
data: IUpdateVerification,
|
||||
): Promise<void> {
|
||||
await api.post("/page-verification/update", data);
|
||||
await api.post("/pages/update-verification", data);
|
||||
}
|
||||
|
||||
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> {
|
||||
await api.post("/page-verification/verify", { pageId });
|
||||
await api.post("/pages/verify", { pageId });
|
||||
}
|
||||
|
||||
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: {
|
||||
pageId: string;
|
||||
comment?: string;
|
||||
}): Promise<void> {
|
||||
await api.post("/page-verification/reject", data);
|
||||
await api.post("/pages/reject-approval", data);
|
||||
}
|
||||
|
||||
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(
|
||||
params?: IVerificationListParams,
|
||||
): Promise<IPagination<IVerificationListItem>> {
|
||||
const req = await api.post("/page-verification/list", { ...params });
|
||||
const req = await api.post("/pages/verifications", { ...params });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
@@ -236,7 +236,10 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
</Menu.Item>
|
||||
|
||||
{!readOnly && (
|
||||
<PageVerificationMenuItem onClick={openVerificationModal} />
|
||||
<PageVerificationMenuItem
|
||||
pageId={page?.id}
|
||||
onClick={openVerificationModal}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
@@ -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)) {
|
||||
this.logger.debug(
|
||||
`Skipping first history for page ${pageId}: empty content`,
|
||||
);
|
||||
await this.collabHistory.clearContributors(pageId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!lastHistory ||
|
||||
!isDeepStrictEqual(lastHistory.content, page.content)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
IPermissionGrantedNotificationJob,
|
||||
IVerificationExpiringNotificationJob,
|
||||
IVerificationExpiredNotificationJob,
|
||||
IVerificationReconcileJob,
|
||||
} from '../../integrations/queue/constants/queue.interface';
|
||||
import { CommentNotificationService } from './services/comment.notification';
|
||||
import { PageNotificationService } from './services/page.notification';
|
||||
@@ -33,6 +35,7 @@ export class NotificationProcessor
|
||||
private readonly pageNotificationService: PageNotificationService,
|
||||
private readonly verificationNotificationService: VerificationNotificationService,
|
||||
private readonly domainService: DomainService,
|
||||
private readonly moduleRef: ModuleRef,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {
|
||||
super();
|
||||
@@ -47,6 +50,7 @@ export class NotificationProcessor
|
||||
| IPermissionGrantedNotificationJob
|
||||
| IVerificationExpiringNotificationJob
|
||||
| IVerificationExpiredNotificationJob
|
||||
| IVerificationReconcileJob
|
||||
| IPageVerifiedNotificationJob
|
||||
| IApprovalRequestedNotificationJob
|
||||
| IApprovalRejectedNotificationJob,
|
||||
@@ -54,7 +58,12 @@ export class NotificationProcessor
|
||||
>,
|
||||
): 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) {
|
||||
@@ -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> {
|
||||
const workspace = await this.db
|
||||
.selectFrom('workspaces')
|
||||
|
||||
@@ -8,6 +8,7 @@ import { WsGateway } from '../../ws/ws.gateway';
|
||||
import { MailService } from '../../integrations/mail/mail.service';
|
||||
import { NotificationTab, NotificationType, NotificationTypeToSettingKey } from './notification.constants';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationService {
|
||||
@@ -16,11 +17,108 @@ export class NotificationService {
|
||||
constructor(
|
||||
private readonly notificationRepo: NotificationRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
private readonly wsGateway: WsGateway,
|
||||
private readonly mailService: MailService,
|
||||
@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) {
|
||||
const user = await this.db
|
||||
.selectFrom('users')
|
||||
@@ -73,19 +171,34 @@ export class NotificationService {
|
||||
}
|
||||
|
||||
async getUnreadCount(userId: string) {
|
||||
return this.notificationRepo.getUnreadCount(userId);
|
||||
const accessibleIds =
|
||||
await this.listUnreadAccessibleNotificationIds(userId);
|
||||
return accessibleIds.length;
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
return this.notificationRepo.markAllAsRead(userId);
|
||||
const accessibleIds =
|
||||
await this.listUnreadAccessibleNotificationIds(userId);
|
||||
if (accessibleIds.length === 0) return;
|
||||
return this.notificationRepo.markMultipleAsRead(accessibleIds, userId);
|
||||
}
|
||||
|
||||
async queueEmail(
|
||||
|
||||
@@ -15,34 +15,83 @@ import { VerificationExpiredEmail } from '@docmost/transactional/emails/verifica
|
||||
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 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 { 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;
|
||||
|
||||
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;
|
||||
|
||||
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({
|
||||
userId,
|
||||
workspaceId,
|
||||
workspaceId: verification.workspaceId,
|
||||
type: NotificationType.PAGE_VERIFICATION_EXPIRING,
|
||||
pageId,
|
||||
spaceId,
|
||||
data: { expiresAt },
|
||||
pageId: verification.pageId,
|
||||
spaceId: verification.spaceId,
|
||||
data: { expiresAt: expiresAtIso },
|
||||
});
|
||||
|
||||
const subject = `"${pageTitle}" needs to be re-verified soon`;
|
||||
@@ -54,7 +103,7 @@ export class VerificationNotificationService {
|
||||
VerificationExpiringEmail({
|
||||
pageTitle,
|
||||
pageUrl: basePageUrl,
|
||||
expiresAt: new Date(expiresAt).toLocaleDateString(),
|
||||
expiresAt: new Date(verification.expiresAt).toLocaleDateString(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -64,21 +113,44 @@ export class VerificationNotificationService {
|
||||
data: IVerificationExpiredNotificationJob,
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
const { pageTitle, basePageUrl } = context;
|
||||
|
||||
for (const userId of verifierIds) {
|
||||
for (const userId of accessibleVerifierIds) {
|
||||
const notification = await this.notificationService.create({
|
||||
userId,
|
||||
workspaceId,
|
||||
workspaceId: v.workspaceId,
|
||||
type: NotificationType.PAGE_VERIFICATION_EXPIRED,
|
||||
pageId,
|
||||
spaceId,
|
||||
pageId: v.pageId,
|
||||
spaceId: v.spaceId,
|
||||
});
|
||||
|
||||
const subject = `"${pageTitle}" verification has expired`;
|
||||
@@ -99,7 +171,14 @@ export class VerificationNotificationService {
|
||||
const { verifierIds, pageId, spaceId, workspaceId, actorId } = data;
|
||||
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({
|
||||
userId,
|
||||
workspaceId,
|
||||
@@ -118,13 +197,20 @@ export class VerificationNotificationService {
|
||||
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, basePageUrl } = context;
|
||||
const actorName = await this.getUserName(actorId);
|
||||
|
||||
for (const userId of verifierIds) {
|
||||
for (const userId of accessibleVerifierIds) {
|
||||
const notification = await this.notificationService.create({
|
||||
userId,
|
||||
workspaceId,
|
||||
@@ -156,6 +242,13 @@ export class VerificationNotificationService {
|
||||
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;
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -15,9 +15,7 @@ export async function up(db: Kysely<any>): Promise<void> {
|
||||
.addColumn('space_id', 'uuid', (col) =>
|
||||
col.notNull().references('spaces.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('type', 'varchar', (col) =>
|
||||
col.notNull().defaultTo('expiring'),
|
||||
)
|
||||
.addColumn('type', 'varchar', (col) => col.notNull().defaultTo('expiring'))
|
||||
.addColumn('status', 'varchar')
|
||||
.addColumn('mode', 'varchar')
|
||||
.addColumn('period_amount', 'integer')
|
||||
@@ -27,7 +25,6 @@ export async function up(db: Kysely<any>): Promise<void> {
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('expires_at', 'timestamptz')
|
||||
.addColumn('notified_at', 'timestamptz')
|
||||
.addColumn('requested_at', 'timestamptz')
|
||||
.addColumn('requested_by_id', 'uuid', (col) =>
|
||||
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()`),
|
||||
)
|
||||
.addColumn('page_verification_id', 'uuid', (col) =>
|
||||
col
|
||||
.notNull()
|
||||
.references('page_verifications.id')
|
||||
.onDelete('cascade'),
|
||||
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('is_primary', 'boolean', (col) => col.notNull().defaultTo(false))
|
||||
.addColumn('added_by_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
@@ -80,12 +72,29 @@ export async function up(db: Kysely<any>): Promise<void> {
|
||||
|
||||
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();
|
||||
|
||||
-1
@@ -443,7 +443,6 @@ export interface PageVerifications {
|
||||
verifiedAt: Timestamp | null;
|
||||
verifiedById: string | null;
|
||||
expiresAt: Timestamp | null;
|
||||
notifiedAt: Timestamp | null;
|
||||
requestedAt: Timestamp | null;
|
||||
requestedById: string | null;
|
||||
rejectedAt: Timestamp | null;
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: e134a4ae98...5d08248017
@@ -73,6 +73,7 @@ export enum QueueJob {
|
||||
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',
|
||||
|
||||
@@ -79,20 +79,14 @@ export interface IPermissionGrantedNotificationJob {
|
||||
|
||||
export interface IVerificationExpiringNotificationJob {
|
||||
verificationId: string;
|
||||
pageId: string;
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
verifierIds: string[];
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface IVerificationExpiredNotificationJob {
|
||||
verificationId: string;
|
||||
pageId: string;
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
verifierIds: string[];
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface IVerificationReconcileJob {
|
||||
// no payload
|
||||
}
|
||||
|
||||
export interface IPageVerifiedNotificationJob {
|
||||
|
||||
Reference in New Issue
Block a user