mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 362341cc6c | |||
| 46a3ca4112 | |||
| 4ec2e458f8 | |||
| c802d29b85 | |||
| cc00e77dfb |
@@ -43,6 +43,9 @@ POSTMARK_TOKEN=
|
||||
# for custom drawio server
|
||||
DRAWIO_URL=
|
||||
|
||||
# Gotenberg URL for server-side PDF export
|
||||
GOTENBERG_URL=
|
||||
|
||||
DISABLE_TELEMETRY=false
|
||||
|
||||
# Enable debug logging in production (default: false)
|
||||
|
||||
@@ -26,6 +26,7 @@ import Security from "@/ee/security/pages/security.tsx";
|
||||
import License from "@/ee/licence/pages/license.tsx";
|
||||
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
|
||||
import SharedPage from "@/pages/share/shared-page.tsx";
|
||||
import PdfRenderPage from "@/ee/pdf-export/pdf-render-page.tsx";
|
||||
import Shares from "@/pages/settings/shares/shares.tsx";
|
||||
import ShareLayout from "@/features/share/components/share-layout.tsx";
|
||||
import ShareRedirect from "@/pages/share/share-redirect.tsx";
|
||||
@@ -81,6 +82,7 @@ export default function App() {
|
||||
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path={"/pdf-render/:pageId"} element={<PdfRenderPage />} />
|
||||
<Route path={"/share/:shareId"} element={<ShareRedirect />} />
|
||||
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import "@/features/editor/styles/index.css";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import ReadonlyPageEditor from "@/features/editor/readonly-page-editor";
|
||||
import { Container } from "@mantine/core";
|
||||
|
||||
type PdfRenderData = {
|
||||
pageId: string;
|
||||
title: string;
|
||||
content: any;
|
||||
};
|
||||
|
||||
export default function PdfRenderPage() {
|
||||
const { pageId } = useParams<{ pageId: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const token = searchParams.get("token");
|
||||
|
||||
const [data, setData] = useState<PdfRenderData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pageId || !token) {
|
||||
setError("Missing page ID or token");
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/api/pdf/render', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pageId, token }),
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then((result) => setData(result.data))
|
||||
.catch((err) => setError(err.message));
|
||||
}, [pageId, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.title) {
|
||||
document.title = data.title;
|
||||
}
|
||||
}, [data?.title]);
|
||||
|
||||
if (error) {
|
||||
return <div>{error}</div>;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size={900} p={0}>
|
||||
<ReadonlyPageEditor
|
||||
key={data.pageId}
|
||||
title={data.title}
|
||||
content={data.content}
|
||||
pageId={data.pageId}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -14,11 +14,11 @@ import {
|
||||
} from "../services/favorite-service";
|
||||
import { FavoriteType } from "../types/favorite.types";
|
||||
|
||||
export function useFavoritesQuery(type?: FavoriteType) {
|
||||
export function useFavoritesQuery(type?: FavoriteType, spaceId?: string) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["favorites", type],
|
||||
queryKey: ["favorites", type, spaceId],
|
||||
queryFn: ({ pageParam }) =>
|
||||
getFavorites({ type, cursor: pageParam, limit: 15 }),
|
||||
getFavorites({ type, spaceId, cursor: pageParam, limit: 15 }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
|
||||
@@ -26,10 +26,10 @@ export function useFavoritesQuery(type?: FavoriteType) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useFavoriteIds(type: FavoriteType): Set<string> {
|
||||
export function useFavoriteIds(type: FavoriteType, spaceId?: string): Set<string> {
|
||||
const { data } = useQuery({
|
||||
queryKey: ["favorite-ids", type],
|
||||
queryFn: () => getFavoriteIds(type),
|
||||
queryKey: ["favorite-ids", type, spaceId],
|
||||
queryFn: () => getFavoriteIds(type, spaceId),
|
||||
refetchOnMount: true,
|
||||
});
|
||||
|
||||
@@ -52,9 +52,9 @@ export function useAddFavoriteMutation() {
|
||||
onSuccess: (_result, variables) => {
|
||||
const entityId = getEntityId(variables);
|
||||
if (entityId) {
|
||||
queryClient.setQueryData(
|
||||
["favorite-ids", variables.type],
|
||||
(old: { items: string[]; meta: any } | undefined) => {
|
||||
queryClient.setQueriesData<{ items: string[]; meta: any }>(
|
||||
{ queryKey: ["favorite-ids", variables.type] },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
if (old.items.includes(entityId)) return old;
|
||||
return { ...old, items: [...old.items, entityId] };
|
||||
@@ -76,9 +76,9 @@ export function useRemoveFavoriteMutation() {
|
||||
onSuccess: (_result, variables) => {
|
||||
const entityId = getEntityId(variables);
|
||||
if (entityId) {
|
||||
queryClient.setQueryData(
|
||||
["favorite-ids", variables.type],
|
||||
(old: { items: string[]; meta: any } | undefined) => {
|
||||
queryClient.setQueriesData<{ items: string[]; meta: any }>(
|
||||
{ queryKey: ["favorite-ids", variables.type] },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return { ...old, items: old.items.filter((id) => id !== entityId) };
|
||||
},
|
||||
|
||||
@@ -21,13 +21,14 @@ export async function removeFavorite(
|
||||
await api.post("/favorites/remove", params);
|
||||
}
|
||||
|
||||
export async function getFavoriteIds(type: FavoriteType): Promise<IPagination<string>> {
|
||||
const req = await api.post<IPagination<string>>("/favorites/ids", { type });
|
||||
export async function getFavoriteIds(type: FavoriteType, spaceId?: string): Promise<IPagination<string>> {
|
||||
const req = await api.post<IPagination<string>>("/favorites/ids", { type, spaceId });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getFavorites(params?: {
|
||||
type?: FavoriteType;
|
||||
spaceId?: string;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}): Promise<IPagination<IFavorite>> {
|
||||
|
||||
@@ -18,7 +18,11 @@ import { getSpaceUrl } from "@/lib/config";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getInitialsColor } from "@/lib/get-initials-color";
|
||||
|
||||
export default function FavoritesPages() {
|
||||
interface Props {
|
||||
spaceId?: string;
|
||||
}
|
||||
|
||||
export default function FavoritesPages({ spaceId }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
data,
|
||||
@@ -27,7 +31,7 @@ export default function FavoritesPages() {
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useFavoritesQuery("page");
|
||||
} = useFavoritesQuery("page", spaceId);
|
||||
|
||||
const favorites = data?.pages.flatMap((p) => p.items) ?? [];
|
||||
|
||||
@@ -72,19 +76,21 @@ export default function FavoritesPages() {
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{fav.space && (
|
||||
<Badge
|
||||
color={getInitialsColor(fav.space.name)}
|
||||
variant="light"
|
||||
component={Link}
|
||||
to={getSpaceUrl(fav.space.slug)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{fav.space.name}
|
||||
</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
{!spaceId && (
|
||||
<Table.Td>
|
||||
{fav.space && (
|
||||
<Badge
|
||||
color={getInitialsColor(fav.space.name)}
|
||||
variant="light"
|
||||
component={Link}
|
||||
to={getSpaceUrl(fav.space.slug)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{fav.space.name}
|
||||
</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
)}
|
||||
<Table.Td>
|
||||
<Text
|
||||
c="dimmed"
|
||||
|
||||
@@ -145,7 +145,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
] = useDisclosure(false);
|
||||
const [pageEditor] = useAtom(pageEditorAtom);
|
||||
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
|
||||
const favoriteIds = useFavoriteIds("page");
|
||||
const favoriteIds = useFavoriteIds("page", page?.spaceId);
|
||||
const addFavoriteMutation = useAddFavoriteMutation();
|
||||
const removeFavoriteMutation = useRemoveFavoriteMutation();
|
||||
const isFavorited = page?.id ? favoriteIds.has(page.id) : false;
|
||||
|
||||
@@ -509,7 +509,7 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
|
||||
copyPageModalOpened,
|
||||
{ open: openCopyPageModal, close: closeCopySpaceModal },
|
||||
] = useDisclosure(false);
|
||||
const favoriteIds = useFavoriteIds("page");
|
||||
const favoriteIds = useFavoriteIds("page", spaceId);
|
||||
const addFavorite = useAddFavoriteMutation();
|
||||
const removeFavorite = useRemoveFavoriteMutation();
|
||||
const isFavorited = favoriteIds.has(node.data.id);
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function SpaceHomeTabs() {
|
||||
{space?.id && <RecentChanges spaceId={space.id} />}
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="favorites">
|
||||
<FavoritesPages />
|
||||
{space?.id && <FavoritesPages spaceId={space.id} />}
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="created">
|
||||
{space?.id && <CreatedByMe spaceId={space.id} />}
|
||||
|
||||
@@ -18,6 +18,7 @@ export const Feature = {
|
||||
SHARING_CONTROLS: 'sharing:controls',
|
||||
VIEWER_COMMENTS: 'comment:viewer',
|
||||
TEMPLATES: 'templates',
|
||||
PDF_EXPORT: 'export:pdf',
|
||||
} as const;
|
||||
|
||||
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
|
||||
|
||||
@@ -5,6 +5,8 @@ export enum JwtType {
|
||||
ATTACHMENT = 'attachment',
|
||||
MFA_TOKEN = 'mfa_token',
|
||||
API_KEY = 'api_key',
|
||||
PDF_RENDER = 'pdf_render',
|
||||
PDF_EXPORT_DOWNLOAD = 'pdf_export_download',
|
||||
}
|
||||
export type JwtPayload = {
|
||||
sub: string;
|
||||
@@ -45,3 +47,15 @@ export type JwtApiKeyPayload = {
|
||||
apiKeyId: string;
|
||||
type: 'api_key';
|
||||
};
|
||||
|
||||
export type JwtPdfRenderPayload = {
|
||||
pageId: string;
|
||||
workspaceId: string;
|
||||
type: 'pdf_render';
|
||||
};
|
||||
|
||||
export type JwtPdfExportDownloadPayload = {
|
||||
fileTaskId: string;
|
||||
workspaceId: string;
|
||||
type: 'pdf_export_download';
|
||||
};
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
JwtExchangePayload,
|
||||
JwtMfaTokenPayload,
|
||||
JwtPayload,
|
||||
JwtPdfExportDownloadPayload,
|
||||
JwtPdfRenderPayload,
|
||||
JwtType,
|
||||
} from '../dto/jwt-payload';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
@@ -115,6 +117,30 @@ export class TokenService {
|
||||
return this.jwtService.sign(payload, expiresIn ? { expiresIn } : {});
|
||||
}
|
||||
|
||||
async generatePdfRenderToken(
|
||||
pageId: string,
|
||||
workspaceId: string,
|
||||
): Promise<string> {
|
||||
const payload: JwtPdfRenderPayload = {
|
||||
pageId,
|
||||
workspaceId,
|
||||
type: JwtType.PDF_RENDER,
|
||||
};
|
||||
return this.jwtService.sign(payload, { expiresIn: '60s' });
|
||||
}
|
||||
|
||||
async generatePdfExportDownloadToken(
|
||||
fileTaskId: string,
|
||||
workspaceId: string,
|
||||
): Promise<string> {
|
||||
const payload: JwtPdfExportDownloadPayload = {
|
||||
fileTaskId,
|
||||
workspaceId,
|
||||
type: JwtType.PDF_EXPORT_DOWNLOAD,
|
||||
};
|
||||
return this.jwtService.sign(payload, { expiresIn: '1h' });
|
||||
}
|
||||
|
||||
async verifyJwt(token: string, tokenType: string) {
|
||||
const payload = await this.jwtService.verifyAsync(token, {
|
||||
secret: this.environmentService.getAppSecret(),
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { IsIn, IsNotEmpty, IsString } from 'class-validator';
|
||||
import { IsIn, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class FavoriteIdsDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsIn(['page', 'space', 'template'])
|
||||
type: 'page' | 'space' | 'template';
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
spaceId?: string;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { IsIn, IsOptional, IsString } from 'class-validator';
|
||||
import { IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class ListFavoritesDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['page', 'space', 'template'])
|
||||
type?: 'page' | 'space' | 'template';
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
spaceId?: string;
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ export class FavoriteController {
|
||||
user.id,
|
||||
workspace.id,
|
||||
dto.type as FavoriteType,
|
||||
dto.spaceId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,6 +99,7 @@ export class FavoriteController {
|
||||
workspace.id,
|
||||
pagination,
|
||||
dto.type as FavoriteType | undefined,
|
||||
dto.spaceId,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,11 +20,13 @@ export class FavoriteService {
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
type: FavoriteType,
|
||||
spaceId?: string,
|
||||
) {
|
||||
const result = await this.favoriteRepo.getFavoriteIds(
|
||||
userId,
|
||||
workspaceId,
|
||||
type,
|
||||
spaceId,
|
||||
);
|
||||
|
||||
if (result.items.length === 0) {
|
||||
@@ -95,12 +97,14 @@ export class FavoriteService {
|
||||
workspaceId: string,
|
||||
pagination: PaginationOptions,
|
||||
type?: FavoriteType,
|
||||
spaceId?: string,
|
||||
) {
|
||||
const result = await this.favoriteRepo.findUserFavorites(
|
||||
userId,
|
||||
workspaceId,
|
||||
pagination,
|
||||
type,
|
||||
spaceId,
|
||||
);
|
||||
|
||||
if (result.items.length === 0) {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.alterTable('file_tasks')
|
||||
.addColumn('page_id', 'uuid', (col) =>
|
||||
col.references('pages.id').onDelete('set null').ifNotExists(),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.alterTable('file_tasks')
|
||||
.addColumn('metadata', 'jsonb', (col) => col.ifNotExists())
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_file_tasks_page_export')
|
||||
.ifNotExists()
|
||||
.on('file_tasks')
|
||||
.columns(['page_id', 'workspace_id'])
|
||||
.where(sql.ref('type'), '=', 'export')
|
||||
.where(sql.ref('deleted_at'), 'is', null)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropIndex('idx_file_tasks_page_export').execute();
|
||||
|
||||
await db.schema.alterTable('file_tasks').dropColumn('page_id').execute();
|
||||
|
||||
await db.schema.alterTable('file_tasks').dropColumn('metadata').execute();
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { InsertableFavorite, Favorite } from '@docmost/db/types/entity.types';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { ExpressionBuilder, sql } from 'kysely';
|
||||
import { ExpressionBuilder, SelectQueryBuilder, sql } from 'kysely';
|
||||
import { DB } from '@docmost/db/types/db';
|
||||
import { dbOrTx } from '@docmost/db/utils';
|
||||
|
||||
@@ -66,6 +66,7 @@ export class FavoriteRepo {
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
type: FavoriteType,
|
||||
spaceId?: string,
|
||||
): Promise<{ items: string[]; meta: any }> {
|
||||
const idColumn =
|
||||
type === FavoriteType.PAGE
|
||||
@@ -74,12 +75,16 @@ export class FavoriteRepo {
|
||||
? 'spaceId'
|
||||
: 'templateId';
|
||||
|
||||
const query = this.db
|
||||
let query = this.db
|
||||
.selectFrom('favorites')
|
||||
.select(['favorites.id', `favorites.${idColumn} as entityId`])
|
||||
.where('userId', '=', userId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('type', '=', type);
|
||||
.where('favorites.userId', '=', userId)
|
||||
.where('favorites.workspaceId', '=', workspaceId)
|
||||
.where('favorites.type', '=', type);
|
||||
|
||||
if (spaceId) {
|
||||
query = this.applySpaceFilter(query, type, spaceId);
|
||||
}
|
||||
|
||||
const result = await executeWithCursorPagination(query, {
|
||||
perPage: 250,
|
||||
@@ -100,6 +105,7 @@ export class FavoriteRepo {
|
||||
workspaceId: string,
|
||||
pagination: PaginationOptions,
|
||||
type?: FavoriteType,
|
||||
spaceId?: string,
|
||||
) {
|
||||
let query = this.db
|
||||
.selectFrom('favorites')
|
||||
@@ -111,6 +117,10 @@ export class FavoriteRepo {
|
||||
query = query.where('favorites.type', '=', type);
|
||||
}
|
||||
|
||||
if (spaceId) {
|
||||
query = this.applySpaceFilter(query, type, spaceId);
|
||||
}
|
||||
|
||||
if (type === FavoriteType.PAGE || !type) {
|
||||
query = query.select((eb) => this.withPage(eb));
|
||||
}
|
||||
@@ -184,6 +194,39 @@ export class FavoriteRepo {
|
||||
.execute();
|
||||
}
|
||||
|
||||
private applySpaceFilter<Q extends SelectQueryBuilder<any, any, any>>(
|
||||
query: Q,
|
||||
type: FavoriteType | undefined,
|
||||
spaceId: string,
|
||||
): Q {
|
||||
if (type === FavoriteType.PAGE) {
|
||||
return query.where((eb: any) =>
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('pages')
|
||||
.select(sql`1`.as('one'))
|
||||
.whereRef('pages.id', '=', 'favorites.pageId')
|
||||
.where('pages.spaceId', '=', spaceId),
|
||||
),
|
||||
) as Q;
|
||||
}
|
||||
if (type === FavoriteType.SPACE) {
|
||||
return query.where('favorites.spaceId' as any, '=', spaceId) as Q;
|
||||
}
|
||||
if (type === FavoriteType.TEMPLATE) {
|
||||
return query.where((eb: any) =>
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('templates')
|
||||
.select(sql`1`.as('one'))
|
||||
.whereRef('templates.id', '=', 'favorites.templateId')
|
||||
.where('templates.spaceId', '=', spaceId),
|
||||
),
|
||||
) as Q;
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
private withPage(eb: ExpressionBuilder<DB, 'favorites'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
|
||||
+2
@@ -196,6 +196,8 @@ export interface FileTasks {
|
||||
filePath: string;
|
||||
fileSize: Int8 | null;
|
||||
id: Generated<string>;
|
||||
metadata: Json | null;
|
||||
pageId: string | null;
|
||||
source: string | null;
|
||||
spaceId: string | null;
|
||||
status: string | null;
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: a5b5e10eec...03f67751cc
@@ -75,6 +75,10 @@ export class EnvironmentService {
|
||||
return new Date(Date.now() + msUntilExpiry);
|
||||
}
|
||||
|
||||
getGotenbergUrl(): string | undefined {
|
||||
return this.configService.get<string>('GOTENBERG_URL');
|
||||
}
|
||||
|
||||
getStorageDriver(): string {
|
||||
return this.configService.get<string>('STORAGE_DRIVER', 'local');
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import { QueueJob, QueueName } from 'src/integrations/queue/constants';
|
||||
import { FileImportTaskService } from '../services/file-import-task.service';
|
||||
import { FileTaskStatus } from '../utils/file.utils';
|
||||
import { StorageService } from '../../storage/storage.service';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
|
||||
@Processor(QueueName.FILE_TASK_QUEUE)
|
||||
export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
@@ -13,6 +16,8 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
constructor(
|
||||
private readonly fileTaskService: FileImportTaskService,
|
||||
private readonly storageService: StorageService,
|
||||
private readonly moduleRef: ModuleRef,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -23,8 +28,11 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
case QueueJob.IMPORT_TASK:
|
||||
await this.fileTaskService.processZIpImport(job.data.fileTaskId);
|
||||
break;
|
||||
case QueueJob.EXPORT_TASK:
|
||||
// TODO: export task
|
||||
case QueueJob.PDF_EXPORT_TASK:
|
||||
await this.processExportTask(job.data.fileTaskId);
|
||||
break;
|
||||
case QueueJob.PDF_EXPORT_CLEANUP:
|
||||
await this.processExportCleanup();
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -33,6 +41,24 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private getPdfExportService() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const PdfExportModule = require('./../../../ee/pdf-export/pdf-export.service');
|
||||
return this.moduleRef.get(PdfExportModule.PdfExportService, {
|
||||
strict: false,
|
||||
});
|
||||
}
|
||||
|
||||
private async processExportTask(fileTaskId: string): Promise<void> {
|
||||
const pdfExportService = this.getPdfExportService();
|
||||
await pdfExportService.generateAndStorePdf(fileTaskId);
|
||||
}
|
||||
|
||||
private async processExportCleanup(): Promise<void> {
|
||||
const pdfExportService = this.getPdfExportService();
|
||||
await pdfExportService.cleanupExpiredExports();
|
||||
}
|
||||
|
||||
@OnWorkerEvent('active')
|
||||
onActive(job: Job) {
|
||||
this.logger.debug(`Processing ${job.name} job`);
|
||||
@@ -41,32 +67,39 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
@OnWorkerEvent('failed')
|
||||
async onFailed(job: Job) {
|
||||
this.logger.error(
|
||||
`Error processing ${job.name} job. Import Task ID: ${job.data.fileTaskId}. Reason: ${job.failedReason}`,
|
||||
`Error processing ${job.name} job. File Task ID: ${job.data?.fileTaskId}. Reason: ${job.failedReason}`,
|
||||
);
|
||||
|
||||
await this.handleFailedJob(job);
|
||||
if (job.name === QueueJob.IMPORT_TASK) {
|
||||
await this.handleFailedImportJob(job);
|
||||
} else if (job.name === QueueJob.PDF_EXPORT_TASK) {
|
||||
await this.handleFailedExportJob(job);
|
||||
}
|
||||
}
|
||||
|
||||
@OnWorkerEvent('completed')
|
||||
async onCompleted(job: Job) {
|
||||
this.logger.log(
|
||||
`Completed ${job.name} job for File task ID ${job.data.fileTaskId}`,
|
||||
`Completed ${job.name} job for File task ID ${job.data?.fileTaskId}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const fileTask = await this.fileTaskService.getFileTask(
|
||||
job.data.fileTaskId,
|
||||
);
|
||||
if (fileTask) {
|
||||
await this.storageService.delete(fileTask.filePath);
|
||||
this.logger.debug(`Deleted imported zip file: ${fileTask.filePath}`);
|
||||
if (job.name === QueueJob.IMPORT_TASK) {
|
||||
try {
|
||||
const fileTask = await this.fileTaskService.getFileTask(
|
||||
job.data.fileTaskId,
|
||||
);
|
||||
if (fileTask) {
|
||||
await this.storageService.delete(fileTask.filePath);
|
||||
this.logger.debug(`Deleted imported zip file: ${fileTask.filePath}`);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to delete imported zip file:`, err);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to delete imported zip file:`, err);
|
||||
}
|
||||
// Export tasks: do NOT delete the file on completion (kept for 24h cache)
|
||||
}
|
||||
|
||||
private async handleFailedJob(job: Job) {
|
||||
private async handleFailedImportJob(job: Job) {
|
||||
try {
|
||||
const fileTaskId = job.data.fileTaskId;
|
||||
const reason = job.failedReason || 'Unknown error';
|
||||
@@ -86,6 +119,25 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleFailedExportJob(job: Job) {
|
||||
try {
|
||||
const fileTaskId = job.data.fileTaskId;
|
||||
const reason = job.failedReason || 'Unknown error';
|
||||
|
||||
await this.db
|
||||
.updateTable('fileTasks')
|
||||
.set({
|
||||
status: FileTaskStatus.Failed,
|
||||
errorMessage: reason,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where('id', '=', fileTaskId)
|
||||
.execute();
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (this.worker) {
|
||||
await this.worker.close();
|
||||
|
||||
@@ -80,4 +80,7 @@ export enum QueueJob {
|
||||
|
||||
AUDIT_LOG = 'audit-log',
|
||||
AUDIT_CLEANUP = 'audit-cleanup',
|
||||
|
||||
PDF_EXPORT_TASK = 'pdf-export-task',
|
||||
PDF_EXPORT_CLEANUP = 'pdf-export-cleanup',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user