/** * Copyright (c) 2023-present Plane Software, Inc. and contributors * SPDX-License-Identifier: AGPL-3.0-only * See the LICENSE file for details. */ import { logger } from "@plane/logger"; import type { TDocumentPayload, TPage } from "@plane/types"; // services import { AppError } from "@/lib/errors"; import { APIService } from "../api.service"; export type TUserMention = { id: string; display_name: string; avatar_url?: string; }; export abstract class PageCoreService extends APIService { protected abstract basePath: string; constructor() { super(); } async fetchDetails(pageId: string): Promise { try { const response = await this.get(`${this.basePath}/pages/${pageId}/`, { headers: this.getHeader(), }); return response?.data as TPage; } catch (error) { const appError = new AppError(error, { context: { operation: "fetchDetails", pageId }, }); logger.error("Failed to fetch page details", appError); throw appError; } } async fetchDescriptionBinary(pageId: string): Promise { try { const response = await this.get(`${this.basePath}/pages/${pageId}/description/`, { headers: { ...this.getHeader(), "Content-Type": "application/octet-stream", }, responseType: "arraybuffer", }); const data = response?.data; if (!Buffer.isBuffer(data)) { throw new Error("Expected response to be a Buffer"); } return data; } catch (error) { const appError = new AppError(error, { context: { operation: "fetchDescriptionBinary", pageId }, }); logger.error("Failed to fetch page description binary", appError); throw appError; } } /** * Updates the title of a page */ async updatePageProperties( pageId: string, params: { data: Partial; abortSignal?: AbortSignal } ): Promise { const { data, abortSignal } = params; // Early abort check if (abortSignal?.aborted) { throw new AppError(new DOMException("Aborted", "AbortError")); } // Create an abort listener that will reject the pending promise let abortListener: (() => void) | undefined; const abortPromise = new Promise((_, reject) => { if (abortSignal) { abortListener = () => { reject(new AppError(new DOMException("Aborted", "AbortError"))); }; abortSignal.addEventListener("abort", abortListener); } }); try { return await Promise.race([ this.patch(`${this.basePath}/pages/${pageId}/`, data, { headers: this.getHeader(), signal: abortSignal, }) .then((response) => response?.data) .catch((error) => { const appError = new AppError(error, { context: { operation: "updatePageProperties", pageId }, }); if (appError.code === "ABORT_ERROR") { throw appError; } logger.error("Failed to update page properties", appError); throw appError; }), abortPromise, ]); } finally { // Clean up abort listener if (abortSignal && abortListener) { abortSignal.removeEventListener("abort", abortListener); } } } async updateDescriptionBinary(pageId: string, data: TDocumentPayload): Promise { try { const response = await this.patch(`${this.basePath}/pages/${pageId}/description/`, data, { headers: this.getHeader(), }); return response?.data as unknown; } catch (error) { const appError = new AppError(error, { context: { operation: "updateDescriptionBinary", pageId }, }); logger.error("Failed to update page description binary", appError); throw appError; } } /** * Fetches user mentions for a page * @param pageId - The page ID * @returns Array of user mentions */ async fetchUserMentions(pageId: string): Promise { try { const response = await this.get(`${this.basePath}/pages/${pageId}/mentions/`, { headers: this.getHeader(), params: { mention_type: "user_mention", }, }); return (response?.data as TUserMention[]) ?? []; } catch (error) { const appError = new AppError(error, { context: { operation: "fetchUserMentions", pageId }, }); logger.error("Failed to fetch user mentions", appError); throw appError; } } /** * Resolves an image asset ID to its actual URL by following the 302 redirect * @param workspaceSlug - The workspace slug * @param assetId - The asset UUID * @param projectId - Optional project ID for project-specific assets * @returns The resolved image URL (presigned S3 URL) */ async resolveImageAssetUrl( workspaceSlug: string, assetId: string, projectId?: string | null ): Promise { const path = projectId ? `/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${assetId}/?disposition=inline` : `/api/assets/v2/workspaces/${workspaceSlug}/${assetId}/?disposition=inline`; try { const response = await this.get(path, { headers: this.getHeader(), maxRedirects: 0, validateStatus: (status: number) => status >= 200 && status < 400, }); // If we get a 302, the Location header contains the presigned URL if (response.status === 302 || response.status === 301) { return response.headers?.location || null; } return null; } catch (error) { // Axios throws on 3xx when maxRedirects is 0, so we need to handle the redirect from the error if ((error as any).response?.status === 302 || (error as any).response?.status === 301) { return (error as any).response.headers?.location || null; } logger.error("Failed to resolve image asset URL", { assetId, workspaceSlug, error: (error as any).message, }); return null; } } /** * Resolves multiple image asset IDs to their actual URLs * @param workspaceSlug - The workspace slug * @param assetIds - Array of asset UUIDs * @param projectId - Optional project ID for project-specific assets * @returns Map of assetId to resolved URL */ async resolveImageAssetUrls( workspaceSlug: string, assetIds: string[], projectId?: string | null ): Promise> { const urlMap = new Map(); // Resolve all asset URLs in parallel const results = await Promise.allSettled( assetIds.map(async (assetId) => { const url = await this.resolveImageAssetUrl(workspaceSlug, assetId, projectId); return { assetId, url }; }) ); for (const result of results) { if (result.status === "fulfilled" && result.value.url) { urlMap.set(result.value.assetId, result.value.url); } } return urlMap; } }