diff --git a/apps/client/src/features/base/components/cells/cell-page.tsx b/apps/client/src/features/base/components/cells/cell-page.tsx new file mode 100644 index 00000000..536b2d2a --- /dev/null +++ b/apps/client/src/features/base/components/cells/cell-page.tsx @@ -0,0 +1,268 @@ +import { useState, useRef, useEffect, useCallback, useMemo } from "react"; +import { Popover, ActionIcon, Text } from "@mantine/core"; +import { useDebouncedValue } from "@mantine/hooks"; +import { useQuery } from "@tanstack/react-query"; +import { IconX, IconFileDescription } from "@tabler/icons-react"; +import { Link } from "react-router-dom"; +import clsx from "clsx"; +import { IBaseProperty } from "@/features/base/types/base.types"; +import { useResolvedPages } from "@/features/base/queries/base-page-resolver-query"; +import { useBaseQuery } from "@/features/base/queries/base-query"; +import { searchSuggestions } from "@/features/search/services/search-service"; +import { buildPageUrl } from "@/features/page/page.utils"; +import { useListKeyboardNav } from "@/features/base/hooks/use-list-keyboard-nav"; +import cellClasses from "@/features/base/styles/cells.module.css"; + +type CellPageProps = { + value: unknown; + property: IBaseProperty; + rowId: string; + isEditing: boolean; + onCommit: (value: unknown) => void; + onCancel: () => void; +}; + +type PageSuggestion = { + id: string; + slugId: string; + title: string | null; + icon: string | null; + spaceId: string; + space?: { id: string; slug: string; name: string } | null; +}; + +function parsePageId(value: unknown): string | null { + if (typeof value === "string" && value.length > 0) return value; + return null; +} + +export function CellPage({ + value, + property, + isEditing, + onCommit, + onCancel, +}: CellPageProps) { + const pageId = parsePageId(value); + const { data: base } = useBaseQuery(property.baseId); + + const ids = useMemo(() => (pageId ? [pageId] : []), [pageId]); + const { pages } = useResolvedPages(ids); + const resolvedPage = pageId ? pages.get(pageId) : undefined; + + if (isEditing) { + return ( + + ); + } + + if (!pageId) { + return ; + } + + if (resolvedPage === undefined) { + // Still resolving — render an empty pill-shaped placeholder to avoid + // the "Page not found" flicker on initial load. + return ; + } + + if (resolvedPage === null) { + return ( + + + Page not found + + ); + } + + return ; +} + +type PillPage = { + slugId: string; + title: string | null; + icon: string | null; + space: { slug: string } | null; +}; + +function PagePill({ page }: { page: PillPage }) { + const title = page.title || "Untitled"; + const spaceSlug = page.space?.slug ?? ""; + const url = buildPageUrl(spaceSlug, page.slugId, title); + + return ( + e.stopPropagation()} + onDoubleClick={(e) => e.stopPropagation()} + > + {page.icon ? ( + {page.icon} + ) : ( + + )} + {title} + + ); +} + +type PagePickerProps = { + pageId: string | null; + resolvedPage: { id: string; slugId: string; title: string | null; icon: string | null; space: { id: string; slug: string; name: string } | null } | null; + spaceId?: string; + onCommit: (value: unknown) => void; + onCancel: () => void; +}; + +function PagePicker({ + pageId, + resolvedPage, + spaceId, + onCommit, + onCancel, +}: PagePickerProps) { + const [search, setSearch] = useState(""); + const [debouncedSearch] = useDebouncedValue(search, 250); + const searchRef = useRef(null); + + useEffect(() => { + requestAnimationFrame(() => searchRef.current?.focus()); + }, []); + + const trimmed = debouncedSearch.trim(); + const { data: suggestions = [] } = useQuery({ + queryKey: ["bases", "pages", "search", trimmed, spaceId ?? ""], + queryFn: async () => { + const res = await searchSuggestions({ + query: trimmed, + includePages: true, + spaceId, + limit: trimmed ? 25 : 5, + }); + return (res.pages ?? []) as PageSuggestion[]; + }, + staleTime: 15_000, + }); + + const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } = + useListKeyboardNav(suggestions.length, [debouncedSearch]); + + const handleSelect = useCallback( + (id: string) => { + onCommit(id === pageId ? null : id); + }, + [pageId, onCommit], + ); + + const handleRemove = useCallback(() => { + onCommit(null); + }, [onCommit]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + return; + } + if (handleNavKey(e)) return; + if (e.key === "Enter") { + if (activeIndex < 0 || activeIndex >= suggestions.length) return; + e.preventDefault(); + handleSelect(suggestions[activeIndex].id); + } + }, + [onCancel, handleNavKey, activeIndex, suggestions, handleSelect], + ); + + return ( + + +
+ {resolvedPage ? : } +
+
+ +
+ {pageId && resolvedPage && ( + + {resolvedPage.icon ? ( + {resolvedPage.icon} + ) : ( + + )} + + {resolvedPage.title || "Untitled"} + + + + )} + setSearch(e.currentTarget.value)} + onKeyDown={handleKeyDown} + /> +
+ +
+
+ {suggestions.length === 0 && ( +
+ {trimmed ? "No pages found" : "No pages yet"} +
+ )} + {suggestions.map((page, idx) => { + const isSelected = page.id === pageId; + return ( +
setActiveIndex(idx)} + onMouseDown={(e) => e.preventDefault()} + onClick={() => handleSelect(page.id)} + > + {page.icon ? ( + {page.icon} + ) : ( + + )} + + {page.title || "Untitled"} + + {page.space?.name && ( + + {page.space.name} + + )} +
+ ); + })} +
+ + + ); +} diff --git a/apps/client/src/features/base/components/grid/grid-cell.tsx b/apps/client/src/features/base/components/grid/grid-cell.tsx index b043e4f3..8079441e 100644 --- a/apps/client/src/features/base/components/grid/grid-cell.tsx +++ b/apps/client/src/features/base/components/grid/grid-cell.tsx @@ -15,6 +15,7 @@ import { CellUrl } from "@/features/base/components/cells/cell-url"; import { CellEmail } from "@/features/base/components/cells/cell-email"; import { CellPerson } from "@/features/base/components/cells/cell-person"; import { CellFile } from "@/features/base/components/cells/cell-file"; +import { CellPage } from "@/features/base/components/cells/cell-page"; import { CellCreatedAt } from "@/features/base/components/cells/cell-created-at"; import { CellLastEditedAt } from "@/features/base/components/cells/cell-last-edited-at"; import { CellLastEditedBy } from "@/features/base/components/cells/cell-last-edited-by"; @@ -45,6 +46,7 @@ const cellComponents: Record< email: CellEmail, person: CellPerson, file: CellFile, + page: CellPage, createdAt: CellCreatedAt, lastEditedAt: CellLastEditedAt, lastEditedBy: CellLastEditedBy, diff --git a/apps/client/src/features/base/components/property/property-type-picker.tsx b/apps/client/src/features/base/components/property/property-type-picker.tsx index cb9e40c7..6c871658 100644 --- a/apps/client/src/features/base/components/property/property-type-picker.tsx +++ b/apps/client/src/features/base/components/property/property-type-picker.tsx @@ -8,6 +8,7 @@ import { IconCalendar, IconUser, IconPaperclip, + IconFileDescription, IconCheckbox, IconLink, IconMail, @@ -35,6 +36,7 @@ const propertyTypes: { { type: "date", icon: IconCalendar, labelKey: "Date" }, { type: "person", icon: IconUser, labelKey: "Person" }, { type: "file", icon: IconPaperclip, labelKey: "File" }, + { type: "page", icon: IconFileDescription, labelKey: "Page" }, { type: "checkbox", icon: IconCheckbox, labelKey: "Checkbox" }, { type: "url", icon: IconLink, labelKey: "URL" }, { type: "email", icon: IconMail, labelKey: "Email" }, diff --git a/apps/client/src/features/base/components/views/view-filter-config.tsx b/apps/client/src/features/base/components/views/view-filter-config.tsx index aae01f74..ad5b49bc 100644 --- a/apps/client/src/features/base/components/views/view-filter-config.tsx +++ b/apps/client/src/features/base/components/views/view-filter-config.tsx @@ -65,6 +65,8 @@ function getOperatorsForType(type: string): FilterOperator[] { return ["eq", "neq", "any", "none", "isEmpty", "isNotEmpty"]; case "file": return ["isEmpty", "isNotEmpty"]; + case "page": + return ["isEmpty", "isNotEmpty"]; default: return ["eq", "neq", "isEmpty", "isNotEmpty"]; } diff --git a/apps/client/src/features/base/components/views/view-sort-config.tsx b/apps/client/src/features/base/components/views/view-sort-config.tsx index 0a2665df..3e7847fd 100644 --- a/apps/client/src/features/base/components/views/view-sort-config.tsx +++ b/apps/client/src/features/base/components/views/view-sort-config.tsx @@ -41,7 +41,11 @@ export function ViewSortConfigPopover({ if (!opened) setDraft(null); }, [opened]); - const propertyOptions = properties.map((p) => ({ + // Page properties store a UUID; sorting by raw UUID is unhelpful and + // title-based sort would require a join. Hide until we support it properly. + const sortableProperties = properties.filter((p) => p.type !== "page"); + + const propertyOptions = sortableProperties.map((p) => ({ value: p.id, label: p.name, })); @@ -53,10 +57,10 @@ export function ViewSortConfigPopover({ const handleStartDraft = useCallback(() => { const usedIds = new Set(sorts.map((s) => s.propertyId)); - const available = properties.find((p) => !usedIds.has(p.id)); + const available = sortableProperties.find((p) => !usedIds.has(p.id)); if (!available) return; setDraft({ propertyId: available.id, direction: "asc" }); - }, [sorts, properties]); + }, [sorts, sortableProperties]); const handleSaveDraft = useCallback(() => { if (!draft) return; @@ -99,7 +103,8 @@ export function ViewSortConfigPopover({ [sorts, onChange], ); - const canAddMore = properties.length > sorts.length + (draft ? 1 : 0); + const canAddMore = + sortableProperties.length > sorts.length + (draft ? 1 : 0); return ( { + if (pageIds.length === 0) return []; + const res = await api.post<{ items: ResolvedPage[] }>( + "/bases/pages/resolve", + { pageIds }, + ); + return res.data.items; +} + +// Stable, sorted, deduped list so the query key is consistent across renders +// no matter what order the caller hands us the ids in. +function normalize(ids: (string | null | undefined)[]): string[] { + const set = new Set(); + for (const id of ids) { + if (typeof id === "string" && id.length > 0) set.add(id); + } + return Array.from(set).sort(); +} + +export type PageResolution = { + // Map distinguishes three states via lookup: + // - key absent → id not requested + // - value undefined → still resolving (query pending, or stale fetch in flight) + // - value null → resolved and not accessible (deleted, restricted, cross-workspace) + // - value ResolvedPage → resolved and accessible + pages: Map; + isLoading: boolean; +}; + +export function useResolvedPages( + pageIds: (string | null | undefined)[], +): PageResolution { + const normalized = useMemo(() => normalize(pageIds), [pageIds]); + + const { data, isSuccess, isLoading } = useQuery({ + queryKey: ["bases", "pages", "resolve", normalized], + queryFn: () => resolvePages(normalized), + enabled: normalized.length > 0, + staleTime: 30_000, + gcTime: 5 * 60_000, + }); + + const pages = useMemo(() => { + const map = new Map(); + // Seed with undefined (= "still resolving") until the fetch succeeds. + for (const id of normalized) map.set(id, isSuccess ? null : undefined); + for (const item of data ?? []) map.set(item.id, item); + return map; + }, [normalized, data, isSuccess]); + + return { pages, isLoading }; +} diff --git a/apps/client/src/features/base/styles/cells.module.css b/apps/client/src/features/base/styles/cells.module.css index 24803a7f..fba6a207 100644 --- a/apps/client/src/features/base/styles/cells.module.css +++ b/apps/client/src/features/base/styles/cells.module.css @@ -295,3 +295,48 @@ white-space: nowrap; flex-shrink: 0; } + +.pagePill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 1px 6px; + border-radius: var(--mantine-radius-sm); + font-size: var(--mantine-font-size-xs); + color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-4)); + background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5)); + text-decoration: none; + max-width: 100%; + overflow: hidden; +} + +.pagePill:hover { + background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4)); +} + +.pagePillIcon { + display: inline-flex; + font-size: 14px; + line-height: 1; + flex-shrink: 0; +} + +.pagePillIconFallback { + color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2)); + flex-shrink: 0; +} + +.pagePillText { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pageMissing { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: var(--mantine-font-size-xs); + color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3)); + font-style: italic; +} diff --git a/apps/client/src/features/base/types/base.types.ts b/apps/client/src/features/base/types/base.types.ts index b5cdb948..563883bf 100644 --- a/apps/client/src/features/base/types/base.types.ts +++ b/apps/client/src/features/base/types/base.types.ts @@ -7,6 +7,7 @@ export type BasePropertyType = | 'date' | 'person' | 'file' + | 'page' | 'checkbox' | 'url' | 'email' @@ -63,6 +64,8 @@ export type PersonTypeOptions = { allowMultiple?: boolean; }; +export type PageTypeOptions = Record; + export type TypeOptions = | SelectTypeOptions | NumberTypeOptions @@ -72,6 +75,7 @@ export type TypeOptions = | UrlTypeOptions | EmailTypeOptions | PersonTypeOptions + | PageTypeOptions | Record; export type IBaseProperty = { diff --git a/apps/server/src/core/base/base.module.ts b/apps/server/src/core/base/base.module.ts index 12d37271..f1b9a1bc 100644 --- a/apps/server/src/core/base/base.module.ts +++ b/apps/server/src/core/base/base.module.ts @@ -9,6 +9,7 @@ import { BasePropertyService } from './services/base-property.service'; import { BaseRowService } from './services/base-row.service'; import { BaseViewService } from './services/base-view.service'; import { BaseCsvExportService } from './services/base-csv-export.service'; +import { BasePageResolverService } from './services/base-page-resolver.service'; import { BaseQueueProcessor } from './processors/base-queue.processor'; import { BaseWsService } from './realtime/base-ws.service'; import { BaseWsConsumers } from './realtime/base-ws-consumers'; @@ -29,6 +30,7 @@ import { QueueName } from '../../integrations/queue/constants'; BaseRowService, BaseViewService, BaseCsvExportService, + BasePageResolverService, BaseQueueProcessor, BasePresenceService, BaseWsService, diff --git a/apps/server/src/core/base/base.schemas.ts b/apps/server/src/core/base/base.schemas.ts index ab063121..b05b7fe6 100644 --- a/apps/server/src/core/base/base.schemas.ts +++ b/apps/server/src/core/base/base.schemas.ts @@ -9,6 +9,7 @@ export const BasePropertyType = { DATE: 'date', PERSON: 'person', FILE: 'file', + PAGE: 'page', CHECKBOX: 'checkbox', URL: 'url', EMAIL: 'email', @@ -114,6 +115,7 @@ const typeOptionsSchemaMap: Record = { [BasePropertyType.DATE]: dateTypeOptionsSchema, [BasePropertyType.PERSON]: personTypeOptionsSchema, [BasePropertyType.FILE]: emptyTypeOptionsSchema, + [BasePropertyType.PAGE]: emptyTypeOptionsSchema, [BasePropertyType.CHECKBOX]: checkboxTypeOptionsSchema, [BasePropertyType.URL]: urlTypeOptionsSchema, [BasePropertyType.EMAIL]: emailTypeOptionsSchema, @@ -159,6 +161,7 @@ const cellValueSchemaMap: Partial> = { fileSize: z.number().optional(), filePath: z.string().optional(), })), + [BasePropertyType.PAGE]: z.uuid(), [BasePropertyType.CHECKBOX]: z.boolean(), [BasePropertyType.URL]: z.url(), [BasePropertyType.EMAIL]: z.email(), @@ -192,6 +195,7 @@ export type CellConversionContext = { fromTypeOptions?: unknown; userNames?: Map; attachmentNames?: Map; + pageTitles?: Map; }; function resolveChoiceName( @@ -256,6 +260,16 @@ export function attemptCellConversion( .filter((v): v is string => typeof v === 'string' && v.length > 0); return { converted: true, value: parts.join(', ') }; } + if (fromType === BasePropertyType.PAGE && typeof value === 'string') { + const title = ctx.pageTitles?.get(value); + return { converted: true, value: title ?? '' }; + } + } + + // Page cells only accept a page UUID. Free text / other IDs can't be + // coerced into a valid page reference — drop to null. + if (toType === BasePropertyType.PAGE && fromType !== BasePropertyType.PAGE) { + return { converted: true, value: null }; } const targetSchema = cellValueSchemaMap[toType]; diff --git a/apps/server/src/core/base/controllers/base.controller.ts b/apps/server/src/core/base/controllers/base.controller.ts index ff31d7ac..a646a7c2 100644 --- a/apps/server/src/core/base/controllers/base.controller.ts +++ b/apps/server/src/core/base/controllers/base.controller.ts @@ -12,11 +12,13 @@ import { import { FastifyReply } from 'fastify'; import { BaseService } from '../services/base.service'; import { BaseCsvExportService } from '../services/base-csv-export.service'; +import { BasePageResolverService } from '../services/base-page-resolver.service'; import { BaseRepo } from '@docmost/db/repos/base/base.repo'; import { CreateBaseDto } from '../dto/create-base.dto'; import { UpdateBaseDto } from '../dto/update-base.dto'; import { BaseIdDto } from '../dto/base.dto'; import { ExportBaseCsvDto } from '../dto/export-base.dto'; +import { ResolvePagesDto } from '../dto/resolve-pages.dto'; import { AuthUser } from '../../../common/decorators/auth-user.decorator'; import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator'; import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard'; @@ -35,6 +37,7 @@ export class BaseController { constructor( private readonly baseService: BaseService, private readonly baseCsvExportService: BaseCsvExportService, + private readonly basePageResolverService: BasePageResolverService, private readonly baseRepo: BaseRepo, private readonly spaceAbility: SpaceAbilityFactory, ) {} @@ -138,4 +141,19 @@ export class BaseController { res, ); } + + @HttpCode(HttpStatus.OK) + @Post('pages/resolve') + async resolvePages( + @Body() dto: ResolvePagesDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + const items = await this.basePageResolverService.resolvePages( + dto.pageIds, + workspace.id, + user.id, + ); + return { items }; + } } diff --git a/apps/server/src/core/base/dto/resolve-pages.dto.ts b/apps/server/src/core/base/dto/resolve-pages.dto.ts new file mode 100644 index 00000000..43e08ca9 --- /dev/null +++ b/apps/server/src/core/base/dto/resolve-pages.dto.ts @@ -0,0 +1,9 @@ +import { ArrayMaxSize, ArrayMinSize, IsArray, IsUUID } from 'class-validator'; + +export class ResolvePagesDto { + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(100) + @IsUUID('all', { each: true }) + pageIds: string[]; +} diff --git a/apps/server/src/core/base/engine/kinds.ts b/apps/server/src/core/base/engine/kinds.ts index 4148c769..873e61f3 100644 --- a/apps/server/src/core/base/engine/kinds.ts +++ b/apps/server/src/core/base/engine/kinds.ts @@ -9,6 +9,7 @@ export const PropertyKind = { MULTI: 'multi', PERSON: 'person', FILE: 'file', + PAGE: 'page', SYS_USER: 'sys_user', } as const; @@ -37,6 +38,8 @@ export function propertyKind(type: string): PropertyKindValue | null { return PropertyKind.PERSON; case BasePropertyType.FILE: return PropertyKind.FILE; + case BasePropertyType.PAGE: + return PropertyKind.PAGE; case BasePropertyType.LAST_EDITED_BY: return PropertyKind.SYS_USER; default: diff --git a/apps/server/src/core/base/engine/predicate.ts b/apps/server/src/core/base/engine/predicate.ts index ff60db2c..1b7eb6a9 100644 --- a/apps/server/src/core/base/engine/predicate.ts +++ b/apps/server/src/core/base/engine/predicate.ts @@ -66,6 +66,8 @@ function buildCondition( return personCondition(eb, cond, prop); case PropertyKind.FILE: return arrayOfIdsCondition(eb, cond); + case PropertyKind.PAGE: + return pageCondition(eb, cond); default: return FALSE; } @@ -292,6 +294,48 @@ function personCondition( } } +function pageCondition(eb: Eb, cond: Condition): Expression { + // Page cells store a single page uuid as text. Shape matches selectCondition. + const expr = textCell(cond.propertyId); + const val = cond.value; + switch (cond.op) { + case 'isEmpty': + return eb.or([ + eb(expr as any, 'is', null), + eb(expr as any, '=', ''), + ]); + case 'isNotEmpty': + return eb.and([ + eb(expr as any, 'is not', null), + eb(expr as any, '!=', ''), + ]); + case 'eq': + return val == null ? FALSE : eb(expr as any, '=', String(val)); + case 'neq': + return val == null + ? FALSE + : eb.or([ + eb(expr as any, 'is', null), + eb(expr as any, '!=', String(val)), + ]); + case 'any': { + const arr = asStringArray(val); + if (arr.length === 0) return FALSE; + return eb(expr as any, 'in', arr); + } + case 'none': { + const arr = asStringArray(val); + if (arr.length === 0) return TRUE; + return eb.or([ + eb(expr as any, 'is', null), + eb(expr as any, 'not in', arr), + ]); + } + default: + return FALSE; + } +} + function arrayOfIdsCondition(eb: Eb, cond: Condition): Expression { const expr = arrayCell(cond.propertyId); const val = cond.value; diff --git a/apps/server/src/core/base/export/cell-csv-serializer.spec.ts b/apps/server/src/core/base/export/cell-csv-serializer.spec.ts index fb563fc5..ffd613ac 100644 --- a/apps/server/src/core/base/export/cell-csv-serializer.spec.ts +++ b/apps/server/src/core/base/export/cell-csv-serializer.spec.ts @@ -87,4 +87,16 @@ describe('serializeCellForCsv', () => { expect(serializeCellForCsv(prop, 'u2', { userNames })).toBe('Bob'); expect(serializeCellForCsv(prop, 'missing', { userNames })).toBe(''); }); + + it('page resolves via pageTitles', () => { + const pageTitles = new Map([ + ['p1', 'Launch plan'], + ['p2', 'Retro notes'], + ]); + const prop = p(BasePropertyType.PAGE); + expect(serializeCellForCsv(prop, 'p1', { pageTitles })).toBe('Launch plan'); + expect(serializeCellForCsv(prop, 'missing', { pageTitles })).toBe(''); + expect(serializeCellForCsv(prop, 'p1', {})).toBe(''); + expect(serializeCellForCsv(prop, 123, { pageTitles })).toBe(''); + }); }); diff --git a/apps/server/src/core/base/export/cell-csv-serializer.ts b/apps/server/src/core/base/export/cell-csv-serializer.ts index 3882419c..a873d878 100644 --- a/apps/server/src/core/base/export/cell-csv-serializer.ts +++ b/apps/server/src/core/base/export/cell-csv-serializer.ts @@ -2,6 +2,7 @@ import { BasePropertyType, BasePropertyTypeValue } from '../base.schemas'; export type CellCsvContext = { userNames?: Map; + pageTitles?: Map; }; type PropertyLike = { @@ -81,6 +82,10 @@ export function serializeCellForCsv( case BasePropertyType.LAST_EDITED_BY: return resolveUser(value, ctx); + case BasePropertyType.PAGE: + if (typeof value !== 'string') return ''; + return ctx.pageTitles?.get(value) ?? ''; + default: return typeof value === 'object' ? JSON.stringify(value) : String(value); } diff --git a/apps/server/src/core/base/services/base-csv-export.service.ts b/apps/server/src/core/base/services/base-csv-export.service.ts index 94ad9592..4b1a9d5b 100644 --- a/apps/server/src/core/base/services/base-csv-export.service.ts +++ b/apps/server/src/core/base/services/base-csv-export.service.ts @@ -138,40 +138,68 @@ export class BaseCsvExportService { chunk: Array<{ cells: unknown; lastUpdatedById: string | null }>, properties: Array<{ id: string; type: string }>, ): Promise { + const ctx: CellCsvContext = {}; + const needsUsers = properties.some( (p) => p.type === BasePropertyType.PERSON || p.type === BasePropertyType.LAST_EDITED_BY, ); - if (!needsUsers) return {}; - const userIds = new Set(); - const personPropIds = properties - .filter((p) => p.type === BasePropertyType.PERSON) - .map((p) => p.id); + if (needsUsers) { + const userIds = new Set(); + const personPropIds = properties + .filter((p) => p.type === BasePropertyType.PERSON) + .map((p) => p.id); - for (const row of chunk) { - if (row.lastUpdatedById) userIds.add(row.lastUpdatedById); - const cells = (row.cells ?? {}) as Record; - for (const pid of personPropIds) { - const v = cells[pid]; - if (typeof v === 'string') userIds.add(v); - else if (Array.isArray(v)) { - for (const id of v) if (typeof id === 'string') userIds.add(id); + for (const row of chunk) { + if (row.lastUpdatedById) userIds.add(row.lastUpdatedById); + const cells = (row.cells ?? {}) as Record; + for (const pid of personPropIds) { + const v = cells[pid]; + if (typeof v === 'string') userIds.add(v); + else if (Array.isArray(v)) { + for (const id of v) if (typeof id === 'string') userIds.add(id); + } } } + + if (userIds.size > 0) { + const rows = await this.db + .selectFrom('users') + .select(['id', 'name', 'email']) + .where('id', 'in', Array.from(userIds)) + .execute(); + ctx.userNames = new Map( + rows.map((u) => [u.id, u.name || u.email || '']), + ); + } } - if (userIds.size === 0) return {}; + const pagePropIds = properties + .filter((p) => p.type === BasePropertyType.PAGE) + .map((p) => p.id); - const rows = await this.db - .selectFrom('users') - .select(['id', 'name', 'email']) - .where('id', 'in', Array.from(userIds)) - .execute(); + if (pagePropIds.length > 0) { + const pageIds = new Set(); + for (const row of chunk) { + const cells = (row.cells ?? {}) as Record; + for (const pid of pagePropIds) { + const v = cells[pid]; + if (typeof v === 'string' && v.length > 0) pageIds.add(v); + } + } - return { - userNames: new Map(rows.map((u) => [u.id, u.name || u.email || ''])), - }; + if (pageIds.size > 0) { + const rows = await this.db + .selectFrom('pages') + .select(['id', 'title']) + .where('id', 'in', Array.from(pageIds)) + .execute(); + ctx.pageTitles = new Map(rows.map((p) => [p.id, p.title ?? ''])); + } + } + + return ctx; } } diff --git a/apps/server/src/core/base/services/base-page-resolver.service.ts b/apps/server/src/core/base/services/base-page-resolver.service.ts new file mode 100644 index 00000000..7e5eb55b --- /dev/null +++ b/apps/server/src/core/base/services/base-page-resolver.service.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { jsonObjectFrom } from 'kysely/helpers/postgres'; +import { KyselyDB } from '@docmost/db/types/kysely.types'; +import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'; + +export type ResolvedPage = { + id: string; + slugId: string; + title: string | null; + icon: string | null; + spaceId: string; + space: { id: string; slug: string; name: string } | null; +}; + +@Injectable() +export class BasePageResolverService { + constructor( + @InjectKysely() private readonly db: KyselyDB, + private readonly pagePermissionRepo: PagePermissionRepo, + ) {} + + async resolvePages( + pageIds: string[], + workspaceId: string, + userId: string, + ): Promise { + const unique = Array.from(new Set(pageIds)); + if (unique.length === 0) return []; + + const rows = await this.db + .selectFrom('pages') + .select([ + 'pages.id', + 'pages.slugId', + 'pages.title', + 'pages.icon', + 'pages.spaceId', + ]) + .select((eb) => + jsonObjectFrom( + eb + .selectFrom('spaces') + .select(['spaces.id', 'spaces.name', 'spaces.slug']) + .whereRef('spaces.id', '=', 'pages.spaceId'), + ).as('space'), + ) + .where('pages.id', 'in', unique) + .where('pages.workspaceId', '=', workspaceId) + .where('pages.deletedAt', 'is', null) + .execute(); + + if (rows.length === 0) return []; + + const accessible = await this.pagePermissionRepo.filterAccessiblePageIds({ + pageIds: rows.map((r) => r.id), + userId, + }); + const accessibleSet = new Set(accessible); + + return rows.filter((r) => accessibleSet.has(r.id)); + } +} diff --git a/apps/server/src/core/base/tasks/base-type-conversion.task.ts b/apps/server/src/core/base/tasks/base-type-conversion.task.ts index 529202a9..c5fe8c7c 100644 --- a/apps/server/src/core/base/tasks/base-type-conversion.task.ts +++ b/apps/server/src/core/base/tasks/base-type-conversion.task.ts @@ -159,6 +159,16 @@ async function buildCtx( .execute(); ctx.attachmentNames = new Map(rows.map((a) => [a.id, a.fileName])); } + } else if (fromType === BasePropertyType.PAGE) { + const ids = collectIds(chunk, propertyId); + if (ids.size > 0) { + const rows = await db + .selectFrom('pages') + .select(['id', 'title']) + .where('id', 'in', Array.from(ids)) + .execute(); + ctx.pageTitles = new Map(rows.map((p) => [p.id, p.title ?? ''])); + } } return ctx;