mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
feat: watch space (#2096)
This commit is contained in:
@@ -677,6 +677,8 @@
|
|||||||
"<bold>{{name}}</bold> updated a page": "<bold>{{name}}</bold> updated a page",
|
"<bold>{{name}}</bold> updated a page": "<bold>{{name}}</bold> updated a page",
|
||||||
"Watch page": "Watch page",
|
"Watch page": "Watch page",
|
||||||
"Stop watching": "Stop watching",
|
"Stop watching": "Stop watching",
|
||||||
|
"Watch space": "Watch space",
|
||||||
|
"Stop watching space": "Stop watching space",
|
||||||
"Email notifications": "Email notifications",
|
"Email notifications": "Email notifications",
|
||||||
"Page updates": "Page updates",
|
"Page updates": "Page updates",
|
||||||
"Get notified when pages you watch are updated.": "Receive notifications when the pages you watch are updated.",
|
"Get notified when pages you watch are updated.": "Receive notifications when the pages you watch are updated.",
|
||||||
@@ -690,6 +692,8 @@
|
|||||||
"Get notified when your comment is resolved.": "Receive a notification when your comment is resolved.",
|
"Get notified when your comment is resolved.": "Receive a notification when your comment is resolved.",
|
||||||
"You are now watching this page": "You’re now watching this page",
|
"You are now watching this page": "You’re now watching this page",
|
||||||
"You are no longer watching this page": "You’re no longer watching this page",
|
"You are no longer watching this page": "You’re no longer watching this page",
|
||||||
|
"You are now watching this space": "You’re now watching this space",
|
||||||
|
"You are no longer watching this space": "You’re no longer watching this space",
|
||||||
"Direct": "Direct",
|
"Direct": "Direct",
|
||||||
"Updates": "Updates",
|
"Updates": "Updates",
|
||||||
"Today": "Today",
|
"Today": "Today",
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
IconArrowDown,
|
IconArrowDown,
|
||||||
IconDots,
|
IconDots,
|
||||||
|
IconEye,
|
||||||
|
IconEyeOff,
|
||||||
IconFileExport,
|
IconFileExport,
|
||||||
IconHome,
|
IconHome,
|
||||||
IconPlus,
|
IconPlus,
|
||||||
@@ -16,6 +18,11 @@ import {
|
|||||||
IconSettings,
|
IconSettings,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
import {
|
||||||
|
useSpaceWatchStatusQuery,
|
||||||
|
useWatchSpaceMutation,
|
||||||
|
useUnwatchSpaceMutation,
|
||||||
|
} from "@/features/space/queries/space-watcher-query.ts";
|
||||||
import classes from "./space-sidebar.module.css";
|
import classes from "./space-sidebar.module.css";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
@@ -160,13 +167,20 @@ export function SpaceSidebar() {
|
|||||||
{t("Pages")}
|
{t("Pages")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{spaceAbility.can(
|
<Group gap="xs">
|
||||||
SpaceCaslAction.Manage,
|
<SpaceMenu
|
||||||
SpaceCaslSubject.Page,
|
spaceId={space.id}
|
||||||
) && (
|
canManagePages={spaceAbility.can(
|
||||||
<Group gap="xs">
|
SpaceCaslAction.Manage,
|
||||||
<SpaceMenu spaceId={space.id} onSpaceSettings={openSettings} />
|
SpaceCaslSubject.Page,
|
||||||
|
)}
|
||||||
|
onSpaceSettings={openSettings}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{spaceAbility.can(
|
||||||
|
SpaceCaslAction.Manage,
|
||||||
|
SpaceCaslSubject.Page,
|
||||||
|
) && (
|
||||||
<Tooltip label={t("Create page")} withArrow position="right">
|
<Tooltip label={t("Create page")} withArrow position="right">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -177,8 +191,8 @@ export function SpaceSidebar() {
|
|||||||
<IconPlus />
|
<IconPlus />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Group>
|
)}
|
||||||
)}
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<div className={classes.pages}>
|
<div className={classes.pages}>
|
||||||
@@ -204,9 +218,14 @@ export function SpaceSidebar() {
|
|||||||
|
|
||||||
interface SpaceMenuProps {
|
interface SpaceMenuProps {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
|
canManagePages: boolean;
|
||||||
onSpaceSettings: () => void;
|
onSpaceSettings: () => void;
|
||||||
}
|
}
|
||||||
function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
|
function SpaceMenu({
|
||||||
|
spaceId,
|
||||||
|
canManagePages,
|
||||||
|
onSpaceSettings,
|
||||||
|
}: SpaceMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
const [importOpened, { open: openImportModal, close: closeImportModal }] =
|
const [importOpened, { open: openImportModal, close: closeImportModal }] =
|
||||||
@@ -214,15 +233,24 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
|
|||||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||||
useDisclosure(false);
|
useDisclosure(false);
|
||||||
|
|
||||||
|
const { data: watchStatus } = useSpaceWatchStatusQuery(spaceId);
|
||||||
|
const watchMutation = useWatchSpaceMutation();
|
||||||
|
const unwatchMutation = useUnwatchSpaceMutation();
|
||||||
|
const isWatching = watchStatus?.watching ?? false;
|
||||||
|
|
||||||
|
const handleToggleWatch = () => {
|
||||||
|
if (isWatching) {
|
||||||
|
unwatchMutation.mutate(spaceId);
|
||||||
|
} else {
|
||||||
|
watchMutation.mutate(spaceId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Menu width={200} shadow="md" withArrow>
|
<Menu width={200} shadow="md" withArrow>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<Tooltip
|
<Tooltip label={t("Space menu")} withArrow position="top">
|
||||||
label={t("Import pages & space settings")}
|
|
||||||
withArrow
|
|
||||||
position="top"
|
|
||||||
>
|
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="default"
|
||||||
size={18}
|
size={18}
|
||||||
@@ -235,50 +263,69 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
|
|||||||
|
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
onClick={openImportModal}
|
onClick={handleToggleWatch}
|
||||||
leftSection={<IconArrowDown size={16} />}
|
leftSection={
|
||||||
|
isWatching ? <IconEyeOff size={16} /> : <IconEye size={16} />
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{t("Import pages")}
|
{isWatching ? t("Stop watching space") : t("Watch space")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Item
|
{canManagePages && (
|
||||||
onClick={openExportModal}
|
<>
|
||||||
leftSection={<IconFileExport size={16} />}
|
<Menu.Divider />
|
||||||
>
|
|
||||||
{t("Export space")}
|
|
||||||
</Menu.Item>
|
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Item
|
||||||
|
onClick={openImportModal}
|
||||||
|
leftSection={<IconArrowDown size={16} />}
|
||||||
|
>
|
||||||
|
{t("Import pages")}
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
onClick={onSpaceSettings}
|
onClick={openExportModal}
|
||||||
leftSection={<IconSettings size={16} />}
|
leftSection={<IconFileExport size={16} />}
|
||||||
>
|
>
|
||||||
{t("Space settings")}
|
{t("Export space")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Item
|
<Menu.Divider />
|
||||||
component={Link}
|
|
||||||
to={`/s/${spaceSlug}/trash`}
|
<Menu.Item
|
||||||
leftSection={<IconTrash size={16} />}
|
onClick={onSpaceSettings}
|
||||||
>
|
leftSection={<IconSettings size={16} />}
|
||||||
{t("Trash")}
|
>
|
||||||
</Menu.Item>
|
{t("Space settings")}
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Item
|
||||||
|
component={Link}
|
||||||
|
to={`/s/${spaceSlug}/trash`}
|
||||||
|
leftSection={<IconTrash size={16} />}
|
||||||
|
>
|
||||||
|
{t("Trash")}
|
||||||
|
</Menu.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
<PageImportModal
|
{canManagePages && (
|
||||||
spaceId={spaceId}
|
<>
|
||||||
open={importOpened}
|
<PageImportModal
|
||||||
onClose={closeImportModal}
|
spaceId={spaceId}
|
||||||
/>
|
open={importOpened}
|
||||||
|
onClose={closeImportModal}
|
||||||
|
/>
|
||||||
|
|
||||||
<ExportModal
|
<ExportModal
|
||||||
type="space"
|
type="space"
|
||||||
id={spaceId}
|
id={spaceId}
|
||||||
open={exportOpened}
|
open={exportOpened}
|
||||||
onClose={closeExportModal}
|
onClose={closeExportModal}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
watchSpace,
|
||||||
|
unwatchSpace,
|
||||||
|
getSpaceWatchStatus,
|
||||||
|
} from "@/features/space/services/space-watcher-service";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
const SPACE_WATCHER_KEY = "space-watcher";
|
||||||
|
|
||||||
|
export function useSpaceWatchStatusQuery(spaceId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [SPACE_WATCHER_KEY, spaceId],
|
||||||
|
queryFn: () => getSpaceWatchStatus(spaceId),
|
||||||
|
enabled: !!spaceId,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWatchSpaceMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (spaceId: string) => watchSpace(spaceId),
|
||||||
|
onSuccess: (_data, spaceId) => {
|
||||||
|
queryClient.setQueryData([SPACE_WATCHER_KEY, spaceId], {
|
||||||
|
watching: true,
|
||||||
|
});
|
||||||
|
notifications.show({ message: t("You are now watching this space") });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUnwatchSpaceMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (spaceId: string) => unwatchSpace(spaceId),
|
||||||
|
onSuccess: (_data, spaceId) => {
|
||||||
|
queryClient.setQueryData([SPACE_WATCHER_KEY, spaceId], {
|
||||||
|
watching: false,
|
||||||
|
});
|
||||||
|
notifications.show({
|
||||||
|
message: t("You are no longer watching this space"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import api from "@/lib/api-client";
|
||||||
|
|
||||||
|
export async function watchSpace(
|
||||||
|
spaceId: string,
|
||||||
|
): Promise<{ watching: boolean }> {
|
||||||
|
const req = await api.post<{ watching: boolean }>("/spaces/watch", {
|
||||||
|
spaceId,
|
||||||
|
});
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unwatchSpace(
|
||||||
|
spaceId: string,
|
||||||
|
): Promise<{ watching: boolean }> {
|
||||||
|
const req = await api.post<{ watching: boolean }>("/spaces/unwatch", {
|
||||||
|
spaceId,
|
||||||
|
});
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSpaceWatchStatus(
|
||||||
|
spaceId: string,
|
||||||
|
): Promise<{ watching: boolean }> {
|
||||||
|
const req = await api.post<{ watching: boolean }>("/spaces/watch-status", {
|
||||||
|
spaceId,
|
||||||
|
});
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
@@ -179,7 +179,11 @@ export class PageNotificationService {
|
|||||||
async processPageUpdate(data: IPageUpdateNotificationJob, appUrl: string) {
|
async processPageUpdate(data: IPageUpdateNotificationJob, appUrl: string) {
|
||||||
const { pageId, spaceId, workspaceId, actorIds } = data;
|
const { pageId, spaceId, workspaceId, actorIds } = data;
|
||||||
|
|
||||||
const watcherIds = await this.watcherRepo.getPageWatcherIds(pageId);
|
const watcherIds = await this.watcherRepo.getPageUpdateRecipientIds(
|
||||||
|
pageId,
|
||||||
|
spaceId,
|
||||||
|
);
|
||||||
|
|
||||||
if (watcherIds.length === 0) return;
|
if (watcherIds.length === 0) return;
|
||||||
|
|
||||||
const actorSet = new Set(actorIds);
|
const actorSet = new Set(actorIds);
|
||||||
@@ -219,7 +223,7 @@ export class PageNotificationService {
|
|||||||
const context = await this.getPageContext(actorId, pageId, spaceId, appUrl);
|
const context = await this.getPageContext(actorId, pageId, spaceId, appUrl);
|
||||||
if (!context) return;
|
if (!context) return;
|
||||||
|
|
||||||
const { actor, pageTitle, basePageUrl } = context;
|
const { actor, pageTitle, basePageUrl, spaceName } = context;
|
||||||
|
|
||||||
for (const userId of recipientIds) {
|
for (const userId of recipientIds) {
|
||||||
const notification = await this.notificationService.create({
|
const notification = await this.notificationService.create({
|
||||||
@@ -243,6 +247,7 @@ export class PageNotificationService {
|
|||||||
actorName: actor.name,
|
actorName: actor.name,
|
||||||
pageTitle,
|
pageTitle,
|
||||||
pageUrl: basePageUrl,
|
pageUrl: basePageUrl,
|
||||||
|
spaceName,
|
||||||
}),
|
}),
|
||||||
NotificationType.PAGE_UPDATED,
|
NotificationType.PAGE_UPDATED,
|
||||||
);
|
);
|
||||||
@@ -421,7 +426,7 @@ export class PageNotificationService {
|
|||||||
.executeTakeFirst(),
|
.executeTakeFirst(),
|
||||||
this.db
|
this.db
|
||||||
.selectFrom('spaces')
|
.selectFrom('spaces')
|
||||||
.select(['id', 'slug'])
|
.select(['id', 'slug', 'name'])
|
||||||
.where('id', '=', spaceId)
|
.where('id', '=', spaceId)
|
||||||
.executeTakeFirst(),
|
.executeTakeFirst(),
|
||||||
]);
|
]);
|
||||||
@@ -432,6 +437,11 @@ export class PageNotificationService {
|
|||||||
|
|
||||||
const basePageUrl = `${appUrl}/s/${space.slug}/p/${page.slugId}`;
|
const basePageUrl = `${appUrl}/s/${space.slug}/p/${page.slugId}`;
|
||||||
|
|
||||||
return { actor, pageTitle: getPageTitle(page.title), basePageUrl };
|
return {
|
||||||
|
actor,
|
||||||
|
pageTitle: getPageTitle(page.title),
|
||||||
|
basePageUrl,
|
||||||
|
spaceName: space.name,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsString, IsNotEmpty } from 'class-validator';
|
||||||
|
|
||||||
|
export class SpaceWatcherDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
spaceId: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
NotFoundException,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { WatcherService } from './watcher.service';
|
||||||
|
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||||
|
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||||
|
import { SpaceWatcherDto } from './dto/space-watcher.dto';
|
||||||
|
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
||||||
|
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
||||||
|
import {
|
||||||
|
SpaceCaslAction,
|
||||||
|
SpaceCaslSubject,
|
||||||
|
} from '../casl/interfaces/space-ability.type';
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('spaces')
|
||||||
|
export class SpaceWatcherController {
|
||||||
|
constructor(
|
||||||
|
private readonly watcherService: WatcherService,
|
||||||
|
private readonly spaceRepo: SpaceRepo,
|
||||||
|
private readonly spaceAbility: SpaceAbilityFactory,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async loadSpaceAndAuthorize(
|
||||||
|
spaceId: string,
|
||||||
|
user: User,
|
||||||
|
workspace: Workspace,
|
||||||
|
) {
|
||||||
|
const space = await this.spaceRepo.findById(spaceId, workspace.id);
|
||||||
|
if (!space) {
|
||||||
|
throw new NotFoundException('Space not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ability = await this.spaceAbility.createForUser(user, space.id);
|
||||||
|
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Settings)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return space;
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('watch')
|
||||||
|
async watchSpace(
|
||||||
|
@Body() dto: SpaceWatcherDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
const space = await this.loadSpaceAndAuthorize(dto.spaceId, user, workspace);
|
||||||
|
|
||||||
|
await this.watcherService.watchSpace(user.id, space.id, workspace.id);
|
||||||
|
|
||||||
|
return { watching: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('unwatch')
|
||||||
|
async unwatchSpace(
|
||||||
|
@Body() dto: SpaceWatcherDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
const space = await this.loadSpaceAndAuthorize(dto.spaceId, user, workspace);
|
||||||
|
|
||||||
|
await this.watcherService.unwatchSpace(user.id, space.id);
|
||||||
|
|
||||||
|
return { watching: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('watch-status')
|
||||||
|
async getWatchStatus(
|
||||||
|
@Body() dto: SpaceWatcherDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
const space = await this.loadSpaceAndAuthorize(dto.spaceId, user, workspace);
|
||||||
|
|
||||||
|
const watching = await this.watcherService.isWatchingSpace(
|
||||||
|
user.id,
|
||||||
|
space.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { watching };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,7 +59,12 @@ export class WatcherController {
|
|||||||
|
|
||||||
await this.pageAccessService.validateCanView(page, user);
|
await this.pageAccessService.validateCanView(page, user);
|
||||||
|
|
||||||
await this.watcherService.unwatchPage(user.id, page.id);
|
await this.watcherService.unwatchPage(
|
||||||
|
user.id,
|
||||||
|
page.id,
|
||||||
|
page.spaceId,
|
||||||
|
page.workspaceId,
|
||||||
|
);
|
||||||
|
|
||||||
return { watching: false };
|
return { watching: false };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { WatcherService } from './watcher.service';
|
import { WatcherService } from './watcher.service';
|
||||||
import { WatcherController } from './watcher.controller';
|
import { WatcherController } from './watcher.controller';
|
||||||
|
import { SpaceWatcherController } from './space-watcher.controller';
|
||||||
import { PageAccessModule } from '../page/page-access/page-access.module';
|
import { PageAccessModule } from '../page/page-access/page-access.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PageAccessModule],
|
imports: [PageAccessModule],
|
||||||
controllers: [WatcherController],
|
controllers: [WatcherController, SpaceWatcherController],
|
||||||
providers: [WatcherService],
|
providers: [WatcherService],
|
||||||
exports: [WatcherService],
|
exports: [WatcherService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -50,14 +50,44 @@ export class WatcherService {
|
|||||||
return this.watcherRepo.insertMany(watchers, trx);
|
return this.watcherRepo.insertMany(watchers, trx);
|
||||||
}
|
}
|
||||||
|
|
||||||
async unwatchPage(userId: string, pageId: string) {
|
async unwatchPage(
|
||||||
return this.watcherRepo.mute(userId, pageId);
|
userId: string,
|
||||||
|
pageId: string,
|
||||||
|
spaceId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
) {
|
||||||
|
return this.watcherRepo.mute(userId, pageId, spaceId, workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async isWatchingPage(userId: string, pageId: string): Promise<boolean> {
|
async isWatchingPage(userId: string, pageId: string): Promise<boolean> {
|
||||||
return this.watcherRepo.isWatching(userId, pageId);
|
return this.watcherRepo.isWatching(userId, pageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async watchSpace(
|
||||||
|
userId: string,
|
||||||
|
spaceId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
) {
|
||||||
|
const watcher: InsertableWatcher = {
|
||||||
|
userId,
|
||||||
|
pageId: null,
|
||||||
|
spaceId,
|
||||||
|
workspaceId,
|
||||||
|
type: WatcherType.SPACE,
|
||||||
|
addedById: userId,
|
||||||
|
};
|
||||||
|
return this.watcherRepo.upsertSpace(watcher, trx);
|
||||||
|
}
|
||||||
|
|
||||||
|
async unwatchSpace(userId: string, spaceId: string) {
|
||||||
|
return this.watcherRepo.deleteSpaceWatch(userId, spaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async isWatchingSpace(userId: string, spaceId: string): Promise<boolean> {
|
||||||
|
return this.watcherRepo.isWatchingSpace(userId, spaceId);
|
||||||
|
}
|
||||||
|
|
||||||
async getPageWatchers(pageId: string, pagination: PaginationOptions) {
|
async getPageWatchers(pageId: string, pagination: PaginationOptions) {
|
||||||
return this.watcherRepo.findPageWatchers(pageId, pagination);
|
return this.watcherRepo.findPageWatchers(pageId, pagination);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,18 +20,6 @@ export type WatcherType = (typeof WatcherType)[keyof typeof WatcherType];
|
|||||||
export class WatcherRepo {
|
export class WatcherRepo {
|
||||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||||
|
|
||||||
async findByUserAndPage(
|
|
||||||
userId: string,
|
|
||||||
pageId: string,
|
|
||||||
): Promise<Watcher | undefined> {
|
|
||||||
return this.db
|
|
||||||
.selectFrom('watchers')
|
|
||||||
.selectAll()
|
|
||||||
.where('userId', '=', userId)
|
|
||||||
.where('pageId', '=', pageId)
|
|
||||||
.executeTakeFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
async findPageWatchers(pageId: string, pagination: PaginationOptions) {
|
async findPageWatchers(pageId: string, pagination: PaginationOptions) {
|
||||||
const query = this.db
|
const query = this.db
|
||||||
.selectFrom('watchers')
|
.selectFrom('watchers')
|
||||||
@@ -66,6 +54,53 @@ export class WatcherRepo {
|
|||||||
return watchers.map((w) => w.userId);
|
return watchers.map((w) => w.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recipients for a `page.updated` notification, combining:
|
||||||
|
* - Active page watchers on this page, AND
|
||||||
|
* - Active space watchers on this space, EXCLUDING any user who has a
|
||||||
|
* muted page watcher row for this page (per-page mute always wins).
|
||||||
|
*
|
||||||
|
* Deduplicated at the SQL level — a user watching both the page and the
|
||||||
|
* containing space appears once.
|
||||||
|
*/
|
||||||
|
async getPageUpdateRecipientIds(
|
||||||
|
pageId: string,
|
||||||
|
spaceId: string,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const db = dbOrTx(this.db, trx);
|
||||||
|
|
||||||
|
const pageWatchers = db
|
||||||
|
.selectFrom('watchers')
|
||||||
|
.select('userId')
|
||||||
|
.where('pageId', '=', pageId)
|
||||||
|
.where('type', '=', WatcherType.PAGE)
|
||||||
|
.where('mutedAt', 'is', null);
|
||||||
|
|
||||||
|
const spaceWatchers = db
|
||||||
|
.selectFrom('watchers as sw')
|
||||||
|
.select('sw.userId')
|
||||||
|
.where('sw.spaceId', '=', spaceId)
|
||||||
|
.where('sw.pageId', 'is', null)
|
||||||
|
.where('sw.type', '=', WatcherType.SPACE)
|
||||||
|
.where((eb) =>
|
||||||
|
eb.not(
|
||||||
|
eb.exists(
|
||||||
|
eb
|
||||||
|
.selectFrom('watchers as pw')
|
||||||
|
.select('pw.id')
|
||||||
|
.whereRef('pw.userId', '=', 'sw.userId')
|
||||||
|
.where('pw.pageId', '=', pageId)
|
||||||
|
.where('pw.type', '=', WatcherType.PAGE)
|
||||||
|
.where('pw.mutedAt', 'is not', null),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = await pageWatchers.union(spaceWatchers).execute();
|
||||||
|
return [...new Set(rows.map((r) => r.userId))];
|
||||||
|
}
|
||||||
|
|
||||||
async insert(
|
async insert(
|
||||||
watcher: InsertableWatcher,
|
watcher: InsertableWatcher,
|
||||||
trx?: KyselyTransaction,
|
trx?: KyselyTransaction,
|
||||||
@@ -110,20 +145,81 @@ export class WatcherRepo {
|
|||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async upsertSpace(
|
||||||
|
watcher: InsertableWatcher,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
): Promise<Watcher | undefined> {
|
||||||
|
const db = dbOrTx(this.db, trx);
|
||||||
|
return db
|
||||||
|
.insertInto('watchers')
|
||||||
|
.values(watcher)
|
||||||
|
.onConflict((oc) =>
|
||||||
|
oc
|
||||||
|
.columns(['userId', 'spaceId'])
|
||||||
|
.where('pageId', 'is', null)
|
||||||
|
.doNothing(),
|
||||||
|
)
|
||||||
|
.returningAll()
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
async mute(
|
async mute(
|
||||||
userId: string,
|
userId: string,
|
||||||
pageId: string,
|
pageId: string,
|
||||||
|
spaceId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
): Promise<void> {
|
||||||
|
const db = dbOrTx(this.db, trx);
|
||||||
|
const mutedAt = new Date();
|
||||||
|
await db
|
||||||
|
.insertInto('watchers')
|
||||||
|
.values({
|
||||||
|
userId,
|
||||||
|
pageId,
|
||||||
|
spaceId,
|
||||||
|
workspaceId,
|
||||||
|
type: WatcherType.PAGE,
|
||||||
|
addedById: userId,
|
||||||
|
mutedAt,
|
||||||
|
})
|
||||||
|
.onConflict((oc) =>
|
||||||
|
oc
|
||||||
|
.columns(['userId', 'pageId'])
|
||||||
|
.where('pageId', 'is not', null)
|
||||||
|
.doUpdateSet({ mutedAt }),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSpaceWatch(
|
||||||
|
userId: string,
|
||||||
|
spaceId: string,
|
||||||
trx?: KyselyTransaction,
|
trx?: KyselyTransaction,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const db = dbOrTx(this.db, trx);
|
const db = dbOrTx(this.db, trx);
|
||||||
await db
|
await db
|
||||||
.updateTable('watchers')
|
.deleteFrom('watchers')
|
||||||
.set({ mutedAt: new Date() })
|
|
||||||
.where('userId', '=', userId)
|
.where('userId', '=', userId)
|
||||||
.where('pageId', '=', pageId)
|
.where('spaceId', '=', spaceId)
|
||||||
|
.where('pageId', 'is', null)
|
||||||
|
.where('type', '=', WatcherType.SPACE)
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async isWatchingSpace(userId: string, spaceId: string): Promise<boolean> {
|
||||||
|
const watcher = await this.db
|
||||||
|
.selectFrom('watchers')
|
||||||
|
.select('id')
|
||||||
|
.where('userId', '=', userId)
|
||||||
|
.where('spaceId', '=', spaceId)
|
||||||
|
.where('pageId', 'is', null)
|
||||||
|
.where('type', '=', WatcherType.SPACE)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
return !!watcher;
|
||||||
|
}
|
||||||
|
|
||||||
async isWatching(userId: string, pageId: string): Promise<boolean> {
|
async isWatching(userId: string, pageId: string): Promise<boolean> {
|
||||||
const watcher = await this.db
|
const watcher = await this.db
|
||||||
.selectFrom('watchers')
|
.selectFrom('watchers')
|
||||||
@@ -164,14 +260,14 @@ export class WatcherRepo {
|
|||||||
.where('spaceId', '=', spaceId)
|
.where('spaceId', '=', spaceId)
|
||||||
.where('userId', 'is not', null)
|
.where('userId', 'is not', null)
|
||||||
.union(
|
.union(
|
||||||
this.db
|
db
|
||||||
.selectFrom('spaceMembers')
|
.selectFrom('spaceMembers')
|
||||||
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
|
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
|
||||||
.select('groupUsers.userId')
|
.select('groupUsers.userId')
|
||||||
.where('spaceMembers.spaceId', '=', spaceId),
|
.where('spaceMembers.spaceId', '=', spaceId),
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.db
|
await db
|
||||||
.deleteFrom('watchers')
|
.deleteFrom('watchers')
|
||||||
.where('userId', 'in', userIds)
|
.where('userId', 'in', userIds)
|
||||||
.where('spaceId', '=', spaceId)
|
.where('spaceId', '=', spaceId)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ interface Props {
|
|||||||
actorName: string;
|
actorName: string;
|
||||||
pageTitle: string;
|
pageTitle: string;
|
||||||
pageUrl: string;
|
pageUrl: string;
|
||||||
|
spaceName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PageUpdateEmail = ({
|
export const PageUpdateEmail = ({
|
||||||
@@ -15,6 +16,7 @@ export const PageUpdateEmail = ({
|
|||||||
actorName,
|
actorName,
|
||||||
pageTitle,
|
pageTitle,
|
||||||
pageUrl,
|
pageUrl,
|
||||||
|
spaceName,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
return (
|
return (
|
||||||
<MailBody>
|
<MailBody>
|
||||||
@@ -24,8 +26,8 @@ export const PageUpdateEmail = ({
|
|||||||
<strong>{actorName}</strong> updated{' '}
|
<strong>{actorName}</strong> updated{' '}
|
||||||
<Link href={pageUrl} style={link}>
|
<Link href={pageUrl} style={link}>
|
||||||
<strong>{pageTitle}</strong>
|
<strong>{pageTitle}</strong>
|
||||||
</Link>
|
</Link>{' '}
|
||||||
.
|
in <strong>{spaceName}</strong>.
|
||||||
</Text>
|
</Text>
|
||||||
</Section>
|
</Section>
|
||||||
<EmailButton href={pageUrl}>View page</EmailButton>
|
<EmailButton href={pageUrl}>View page</EmailButton>
|
||||||
|
|||||||
Reference in New Issue
Block a user