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",
"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
View File
@@ -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;
@@ -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 {