diff --git a/apps/client/src/features/space/components/space-grid.module.css b/apps/client/src/features/space/components/space-grid.module.css
index e714f81a..17ff9ba2 100644
--- a/apps/client/src/features/space/components/space-grid.module.css
+++ b/apps/client/src/features/space/components/space-grid.module.css
@@ -7,9 +7,26 @@
}
.cardSection {
+ position: relative;
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
}
+.starButton {
+ position: absolute;
+ top: 4px;
+ right: 4px;
+ opacity: 0;
+ transition: opacity 150ms ease;
+}
+
+.starButton[data-favorited="true"] {
+ opacity: 1;
+}
+
+.card:hover .starButton {
+ opacity: 1;
+}
+
.title {
font-family:
Greycliff CF,
diff --git a/apps/client/src/features/space/components/space-grid.tsx b/apps/client/src/features/space/components/space-grid.tsx
index 76a01ec3..d1e9b439 100644
--- a/apps/client/src/features/space/components/space-grid.tsx
+++ b/apps/client/src/features/space/components/space-grid.tsx
@@ -12,12 +12,15 @@ import { useTranslation } from "react-i18next";
import { IconArrowRight } from "@tabler/icons-react";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
+import StarButton from "@/features/favorite/components/star-button";
+import { useFavoriteIds } from "@/features/favorite/queries/favorite-query";
export default function SpaceGrid() {
const { t } = useTranslation();
const { data, isLoading } = useGetSpacesQuery({ limit: 10 });
+ const spaceFavoriteIds = useFavoriteIds("space");
- const cards = data?.items.slice(0, 9).map((space, index) => (
+ const cards = data?.items.slice(0, 6).map((space, index) => (
-
+
+
+
+
+
{cards}
- {data?.items && data.items.length > 9 && (
+ {data?.items && data.items.length > 6 && (
+
+ )}
+
+ );
+}
diff --git a/apps/client/src/features/space/queries/space-query.ts b/apps/client/src/features/space/queries/space-query.ts
index 2ab01154..dd259326 100644
--- a/apps/client/src/features/space/queries/space-query.ts
+++ b/apps/client/src/features/space/queries/space-query.ts
@@ -69,9 +69,10 @@ export const prefetchSpace = (spaceSlug: string, spaceId?: string) => {
if (spaceId) {
// this endpoint only accepts uuid for now
- queryClient.prefetchQuery({
+ queryClient.prefetchInfiniteQuery({
queryKey: ["recent-changes", spaceId],
- queryFn: () => getRecentChanges(spaceId),
+ queryFn: () => getRecentChanges({ spaceId }),
+ initialPageParam: undefined,
});
}
};
diff --git a/apps/client/src/features/workspace/types/workspace.types.ts b/apps/client/src/features/workspace/types/workspace.types.ts
index 96df25b7..f733f73a 100644
--- a/apps/client/src/features/workspace/types/workspace.types.ts
+++ b/apps/client/src/features/workspace/types/workspace.types.ts
@@ -27,12 +27,14 @@ export interface IWorkspace {
mcpEnabled?: boolean;
trashRetentionDays?: number;
restrictApiToAdmins?: boolean;
+ allowMemberTemplates?: boolean;
}
export interface IWorkspaceSettings {
ai?: IWorkspaceAiSettings;
sharing?: IWorkspaceSharingSettings;
api?: IWorkspaceApiSettings;
+ templates?: IWorkspaceTemplateSettings;
}
export interface IWorkspaceApiSettings {
@@ -50,6 +52,10 @@ export interface IWorkspaceSharingSettings {
disabled?: boolean;
}
+export interface IWorkspaceTemplateSettings {
+ allowMemberTemplates?: boolean;
+}
+
export interface ICreateInvite {
role: string;
emails: string[];
diff --git a/apps/client/src/hooks/use-time-ago.tsx b/apps/client/src/hooks/use-time-ago.tsx
index 6f2148ca..9a95cfa8 100644
--- a/apps/client/src/hooks/use-time-ago.tsx
+++ b/apps/client/src/hooks/use-time-ago.tsx
@@ -26,7 +26,7 @@ function getSnapshot() {
return tick;
}
-export function useTimeAgo(date: Date | string) {
+export function useTimeAgo(date: Date | string | undefined) {
const currentTick = useSyncExternalStore(subscribe, getSnapshot);
- return useMemo(() => timeAgo(new Date(date)), [date, currentTick]);
+ return useMemo(() => (date ? timeAgo(new Date(date)) : ""), [date, currentTick]);
}
diff --git a/apps/client/src/lib/app-route.ts b/apps/client/src/lib/app-route.ts
index 630dd048..e817c072 100644
--- a/apps/client/src/lib/app-route.ts
+++ b/apps/client/src/lib/app-route.ts
@@ -1,6 +1,7 @@
const APP_ROUTE = {
HOME: "/home",
SPACES: "/spaces",
+ FAVORITES: "/favorites",
SEARCH: "/search",
AUTH: {
LOGIN: "/login",
diff --git a/apps/client/src/pages/dashboard/home.tsx b/apps/client/src/pages/dashboard/home.tsx
index cefff053..83d55303 100644
--- a/apps/client/src/pages/dashboard/home.tsx
+++ b/apps/client/src/pages/dashboard/home.tsx
@@ -16,7 +16,7 @@ export default function Home() {
{t("Home")} - {getAppName()}
-
+
diff --git a/apps/client/src/pages/favorites/favorites-page.tsx b/apps/client/src/pages/favorites/favorites-page.tsx
new file mode 100644
index 00000000..7e9aa626
--- /dev/null
+++ b/apps/client/src/pages/favorites/favorites-page.tsx
@@ -0,0 +1,139 @@
+import {
+ Text,
+ Group,
+ UnstyledButton,
+ Badge,
+ Table,
+ Container,
+ Title,
+ ActionIcon,
+ Button,
+} from "@mantine/core";
+import { Link } from "react-router-dom";
+import { buildPageUrl } from "@/features/page/page.utils";
+import { formattedDate } from "@/lib/time";
+import { useFavoritesQuery } from "@/features/favorite/queries/favorite-query";
+import { IconFileDescription, IconStar } from "@tabler/icons-react";
+import { EmptyState } from "@/components/ui/empty-state";
+import { getSpaceUrl } from "@/lib/config";
+import { useTranslation } from "react-i18next";
+import { getInitialsColor } from "@/lib/get-initials-color";
+import PageListSkeleton from "@/components/ui/page-list-skeleton";
+
+export default function FavoritesPage() {
+ const { t } = useTranslation();
+ const { data, isLoading, isError, hasNextPage, fetchNextPage, isFetchingNextPage } = useFavoritesQuery("page");
+ const favorites = data?.pages.flatMap((p) => p.items) ?? [];
+
+ if (isLoading) {
+ return (
+
+
+ {t("Favorites")}
+
+
+
+ );
+ }
+
+ if (isError) {
+ return (
+
+
+ {t("Favorites")}
+
+ {t("Failed to fetch favorite pages")}
+
+ );
+ }
+
+ return (
+
+
+ {t("Favorites")}
+
+ {favorites.length > 0 ? (
+ <>
+
+
+
+ {favorites.map((fav) =>
+ fav.page ? (
+
+
+
+
+ {fav.page.icon || (
+
+
+
+ )}
+
+ {fav.page.title || t("Untitled")}
+
+
+
+
+
+ {fav.space && (
+
+ {fav.space.name}
+
+ )}
+
+
+
+ {formattedDate(new Date(fav.createdAt))}
+
+
+
+ ) : null,
+ )}
+
+
+
+ {hasNextPage && (
+
+ )}
+ >
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/apps/client/src/pages/space/space-home.tsx b/apps/client/src/pages/space/space-home.tsx
index 0b5622fe..b8d72a2d 100644
--- a/apps/client/src/pages/space/space-home.tsx
+++ b/apps/client/src/pages/space/space-home.tsx
@@ -14,7 +14,7 @@ export default function SpaceHome() {
{space?.name || 'Overview'} - {getAppName()}
-
+
{space && }
>
diff --git a/apps/client/src/pages/spaces/spaces.tsx b/apps/client/src/pages/spaces/spaces.tsx
index 5fc63929..81e350f7 100644
--- a/apps/client/src/pages/spaces/spaces.tsx
+++ b/apps/client/src/pages/spaces/spaces.tsx
@@ -5,6 +5,7 @@ import { getAppName } from "@/lib/config";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import CreateSpaceModal from "@/features/space/components/create-space-modal";
import { AllSpacesList } from "@/features/space/components/spaces-page";
+import FavoriteSpacesGrid from "@/features/space/components/spaces-page/favorite-spaces-grid";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
import useUserRole from "@/hooks/use-user-role";
@@ -33,9 +34,11 @@ export default function Spaces() {
{isAdmin && }
+
+
- {t("Spaces you belong to")}
+ {t("All spaces")}
{
+ if (dto.type === 'page') {
+ if (!dto.pageId) throw new BadRequestException('pageId is required');
+ const page = await this.pageRepo.findById(dto.pageId);
+ if (!page) throw new NotFoundException('Page not found');
+ await this.pageAccessService.validateCanView(page, user);
+ return { spaceId: page.spaceId, page };
+ }
+
+ if (dto.type === 'space') {
+ if (!dto.spaceId) throw new BadRequestException('spaceId is required');
+ const space = await this.spaceRepo.findById(dto.spaceId, workspaceId);
+ if (!space) throw new NotFoundException('Space not found');
+ await this.validateSpaceAccess(user.id, space.id);
+ return { spaceId: space.id };
+ }
+
+ if (dto.type === 'template') {
+ if (!dto.templateId)
+ throw new BadRequestException('templateId is required');
+ const template = await this.templateRepo.findById(
+ dto.templateId,
+ workspaceId,
+ );
+ if (!template) throw new NotFoundException('Template not found');
+ if (template.spaceId) {
+ await this.validateSpaceAccess(user.id, template.spaceId);
+ }
+ return { spaceId: template.spaceId };
+ }
+
+ throw new BadRequestException('Invalid favorite type');
+ }
+
+ private async validateSpaceAccess(
+ userId: string,
+ spaceId: string,
+ ): Promise {
+ const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
+ if (!userSpaceIds.includes(spaceId)) {
+ throw new ForbiddenException();
+ }
+ }
+}
diff --git a/apps/server/src/core/favorite/favorite.module.ts b/apps/server/src/core/favorite/favorite.module.ts
new file mode 100644
index 00000000..fc63e001
--- /dev/null
+++ b/apps/server/src/core/favorite/favorite.module.ts
@@ -0,0 +1,10 @@
+import { Module } from '@nestjs/common';
+import { FavoriteService } from './services/favorite.service';
+import { FavoriteController } from './favorite.controller';
+
+@Module({
+ controllers: [FavoriteController],
+ providers: [FavoriteService],
+ exports: [FavoriteService],
+})
+export class FavoriteModule {}
diff --git a/apps/server/src/core/favorite/services/favorite.service.ts b/apps/server/src/core/favorite/services/favorite.service.ts
new file mode 100644
index 00000000..a4295fd2
--- /dev/null
+++ b/apps/server/src/core/favorite/services/favorite.service.ts
@@ -0,0 +1,110 @@
+import { Injectable } from '@nestjs/common';
+import {
+ FavoriteRepo,
+ FavoriteType,
+} from '@docmost/db/repos/favorite/favorite.repo';
+import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
+import { InsertableFavorite } from '@docmost/db/types/entity.types';
+import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
+import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
+
+@Injectable()
+export class FavoriteService {
+ constructor(
+ private readonly favoriteRepo: FavoriteRepo,
+ private readonly pagePermissionRepo: PagePermissionRepo,
+ private readonly spaceMemberRepo: SpaceMemberRepo,
+ ) {}
+
+ async addFavorite(
+ userId: string,
+ workspaceId: string,
+ opts: {
+ type: FavoriteType;
+ pageId?: string;
+ spaceId?: string;
+ templateId?: string;
+ },
+ ): Promise {
+ const favorite: InsertableFavorite = {
+ userId,
+ pageId: opts.pageId ?? null,
+ spaceId: opts.spaceId ?? null,
+ templateId: opts.templateId ?? null,
+ type: opts.type,
+ workspaceId,
+ };
+
+ await this.favoriteRepo.insert(favorite);
+ }
+
+ async removeFavorite(
+ userId: string,
+ opts: {
+ type: FavoriteType;
+ pageId?: string;
+ spaceId?: string;
+ templateId?: string;
+ },
+ ): Promise {
+ if (opts.type === FavoriteType.PAGE && opts.pageId) {
+ await this.favoriteRepo.deleteByUserAndPage(userId, opts.pageId);
+ } else if (opts.type === FavoriteType.SPACE && opts.spaceId) {
+ await this.favoriteRepo.deleteByUserAndSpace(userId, opts.spaceId);
+ } else if (opts.type === FavoriteType.TEMPLATE && opts.templateId) {
+ await this.favoriteRepo.deleteByUserAndTemplate(userId, opts.templateId);
+ }
+ }
+
+ async getUserFavorites(
+ userId: string,
+ workspaceId: string,
+ pagination: PaginationOptions,
+ type?: FavoriteType,
+ ) {
+ const result = await this.favoriteRepo.findUserFavorites(
+ userId,
+ workspaceId,
+ pagination,
+ type,
+ );
+
+ if (result.items.length === 0) {
+ return result;
+ }
+
+ const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
+ const spaceSet = new Set(userSpaceIds);
+
+ const pageFavorites = result.items.filter(
+ (f) => f.type === FavoriteType.PAGE && f.pageId,
+ );
+
+ let accessiblePageSet: Set | undefined;
+ if (pageFavorites.length > 0) {
+ const pageIds = pageFavorites.map((f) => f.pageId as string);
+ const accessibleIds =
+ await this.pagePermissionRepo.filterAccessiblePageIds({
+ pageIds,
+ userId,
+ });
+ accessiblePageSet = new Set(accessibleIds);
+ }
+
+ result.items = result.items.filter((f) => {
+ if (f.type === FavoriteType.PAGE) {
+ return f.pageId && accessiblePageSet?.has(f.pageId);
+ }
+ if (f.type === FavoriteType.SPACE) {
+ return f.spaceId && spaceSet.has(f.spaceId);
+ }
+ if (f.type === FavoriteType.TEMPLATE) {
+ const templateSpaceId = (f as any).template?.spaceId;
+ return !templateSpaceId || spaceSet.has(templateSpaceId);
+ }
+ return true;
+ });
+
+ return result;
+ }
+}
diff --git a/apps/server/src/core/group/services/group-user.service.ts b/apps/server/src/core/group/services/group-user.service.ts
index e0bdc23a..58ca8a89 100644
--- a/apps/server/src/core/group/services/group-user.service.ts
+++ b/apps/server/src/core/group/services/group-user.service.ts
@@ -14,6 +14,7 @@ import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { executeTx } from '@docmost/db/utils';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
+import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo';
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
import {
AUDIT_SERVICE,
@@ -29,6 +30,7 @@ export class GroupUserService {
@Inject(forwardRef(() => GroupService))
private groupService: GroupService,
private readonly watcherRepo: WatcherRepo,
+ private readonly favoriteRepo: FavoriteRepo,
@InjectKysely() private readonly db: KyselyDB,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@@ -137,6 +139,12 @@ export class GroupUserService {
spaceId,
{ trx },
);
+
+ await this.favoriteRepo.deleteByUsersWithoutSpaceAccess(
+ [userId],
+ spaceId,
+ { trx },
+ );
}
});
diff --git a/apps/server/src/core/group/services/group.service.ts b/apps/server/src/core/group/services/group.service.ts
index 7d6005f0..83837638 100644
--- a/apps/server/src/core/group/services/group.service.ts
+++ b/apps/server/src/core/group/services/group.service.ts
@@ -16,6 +16,7 @@ import { Group, InsertableGroup, User } from '@docmost/db/types/entity.types';
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
import { GroupUserService } from './group-user.service';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
+import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo';
import { executeTx } from '@docmost/db/utils';
import { InjectKysely } from 'nestjs-kysely';
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
@@ -34,6 +35,7 @@ export class GroupService {
@Inject(forwardRef(() => GroupUserService))
private groupUserService: GroupUserService,
private readonly watcherRepo: WatcherRepo,
+ private readonly favoriteRepo: FavoriteRepo,
@InjectKysely() private readonly db: KyselyDB,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@@ -189,6 +191,12 @@ export class GroupService {
spaceId,
{ trx },
);
+
+ await this.favoriteRepo.deleteByUsersWithoutSpaceAccess(
+ userIds,
+ spaceId,
+ { trx },
+ );
}
});
diff --git a/apps/server/src/core/page/dto/created-by-user.dto.ts b/apps/server/src/core/page/dto/created-by-user.dto.ts
new file mode 100644
index 00000000..1c5c1292
--- /dev/null
+++ b/apps/server/src/core/page/dto/created-by-user.dto.ts
@@ -0,0 +1,11 @@
+import { IsOptional, IsUUID } from 'class-validator';
+
+export class CreatedByUserDto {
+ @IsOptional()
+ @IsUUID()
+ userId?: string;
+
+ @IsOptional()
+ @IsUUID()
+ spaceId?: string;
+}
diff --git a/apps/server/src/core/page/dto/sidebar-page.dto.ts b/apps/server/src/core/page/dto/sidebar-page.dto.ts
index 012f64b0..7f4568f9 100644
--- a/apps/server/src/core/page/dto/sidebar-page.dto.ts
+++ b/apps/server/src/core/page/dto/sidebar-page.dto.ts
@@ -1,5 +1,4 @@
import { IsOptional, IsString, IsUUID } from 'class-validator';
-import { SpaceIdDto } from './page.dto';
export class SidebarPageDto {
@IsOptional()
diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts
index eff24936..fa279d85 100644
--- a/apps/server/src/core/page/page.controller.ts
+++ b/apps/server/src/core/page/page.controller.ts
@@ -35,6 +35,7 @@ import {
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { RecentPageDto } from './dto/recent-page.dto';
+import { CreatedByUserDto } from './dto/created-by-user.dto';
import { DuplicatePageDto } from './dto/duplicate-page.dto';
import { DeletedPageDto } from './dto/deleted-page.dto';
import {
@@ -336,6 +337,29 @@ export class PageController {
return this.pageService.getRecentPages(user.id, pagination);
}
+ @HttpCode(HttpStatus.OK)
+ @Post('created-by-user')
+ async getCreatedByPages(
+ @Body() dto: CreatedByUserDto,
+ @Body() pagination: PaginationOptions,
+ @AuthUser() user: User,
+ ) {
+ const targetUserId = dto.userId ?? user.id;
+
+ if (dto.spaceId) {
+ const ability = await this.spaceAbility.createForUser(
+ user,
+ dto.spaceId,
+ );
+
+ if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
+ throw new ForbiddenException();
+ }
+ }
+
+ return this.pageService.getCreatedByPages(targetUserId, user.id, pagination, dto.spaceId);
+ }
+
@HttpCode(HttpStatus.OK)
@Post('trash')
async getDeletedPages(
diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts
index c162782c..b62d9864 100644
--- a/apps/server/src/core/page/services/page.service.ts
+++ b/apps/server/src/core/page/services/page.service.ts
@@ -300,7 +300,7 @@ export class PageService {
}
const result = await executeWithCursorPagination(query, {
- perPage: 200,
+ perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
@@ -856,6 +856,33 @@ export class PageService {
return result;
}
+ async getCreatedByPages(
+ creatorId: string,
+ requestingUserId: string,
+ pagination: PaginationOptions,
+ spaceId?: string,
+ ): Promise> {
+ const result = await this.pageRepo.getCreatedByPages(
+ creatorId,
+ requestingUserId,
+ pagination,
+ spaceId,
+ );
+
+ if (result.items.length > 0) {
+ const pageIds = result.items.map((p) => p.id);
+ const accessibleIds =
+ await this.pagePermissionRepo.filterAccessiblePageIds({
+ pageIds,
+ userId: requestingUserId,
+ });
+ const accessibleSet = new Set(accessibleIds);
+ result.items = result.items.filter((p) => accessibleSet.has(p.id));
+ }
+
+ return result;
+ }
+
async getDeletedSpacePages(
spaceId: string,
userId: string,
diff --git a/apps/server/src/core/space/services/space-member.service.ts b/apps/server/src/core/space/services/space-member.service.ts
index 0fbab02e..bc280f25 100644
--- a/apps/server/src/core/space/services/space-member.service.ts
+++ b/apps/server/src/core/space/services/space-member.service.ts
@@ -17,6 +17,7 @@ import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto';
import { SpaceRole } from '../../../common/helpers/types/permission';
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
+import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo';
import { executeTx } from '@docmost/db/utils';
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
import {
@@ -31,6 +32,7 @@ export class SpaceMemberService {
private groupUserRepo: GroupUserRepo,
private spaceRepo: SpaceRepo,
private watcherRepo: WatcherRepo,
+ private favoriteRepo: FavoriteRepo,
@InjectKysely() private readonly db: KyselyDB,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@@ -272,6 +274,12 @@ export class SpaceMemberService {
dto.spaceId,
{ trx },
);
+
+ await this.favoriteRepo.deleteByUsersWithoutSpaceAccess(
+ affectedUserIds,
+ dto.spaceId,
+ { trx },
+ );
});
this.auditService.log({
diff --git a/apps/server/src/core/space/space.controller.ts b/apps/server/src/core/space/space.controller.ts
index 67cc2e03..74dfebf3 100644
--- a/apps/server/src/core/space/space.controller.ts
+++ b/apps/server/src/core/space/space.controller.ts
@@ -53,7 +53,41 @@ export class SpaceController {
pagination: PaginationOptions,
@AuthUser() user: User,
) {
- return this.spaceMemberService.getUserSpaces(user.id, pagination);
+ const result = await this.spaceMemberService.getUserSpaces(
+ user.id,
+ pagination,
+ );
+
+ if (result.items.length > 0) {
+ const spaceIds = result.items.map((s) => s.id);
+ const roles = await this.spaceMemberRepo.getUserRolesForSpaces(
+ user.id,
+ spaceIds,
+ );
+
+ const roleMap = new Map();
+ for (const row of roles) {
+ const existing = roleMap.get(row.spaceId) || [];
+ existing.push(row.role);
+ roleMap.set(row.spaceId, existing);
+ }
+
+ result.items = result.items.map((space) => {
+ const spaceRoles = roleMap.get(space.id);
+ const role = spaceRoles
+ ? findHighestUserSpaceRole(
+ spaceRoles.map((r) => ({ userId: user.id, role: r })),
+ )
+ : undefined;
+
+ return {
+ ...space,
+ membership: { userId: user.id, role },
+ };
+ });
+ }
+
+ return result;
}
@HttpCode(HttpStatus.OK)
diff --git a/apps/server/src/core/workspace/dto/update-workspace.dto.ts b/apps/server/src/core/workspace/dto/update-workspace.dto.ts
index d3f029d4..4b148208 100644
--- a/apps/server/src/core/workspace/dto/update-workspace.dto.ts
+++ b/apps/server/src/core/workspace/dto/update-workspace.dto.ts
@@ -54,4 +54,8 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsInt()
@Min(1)
trashRetentionDays: number;
+
+ @IsOptional()
+ @IsBoolean()
+ allowMemberTemplates: boolean;
}
diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts
index c6d822ff..72608951 100644
--- a/apps/server/src/core/workspace/services/workspace.service.ts
+++ b/apps/server/src/core/workspace/services/workspace.service.ts
@@ -42,6 +42,7 @@ import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers';
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
+import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo';
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
import {
AUDIT_SERVICE,
@@ -64,6 +65,7 @@ export class WorkspaceService {
private licenseCheckService: LicenseCheckService,
private shareRepo: ShareRepo,
private watcherRepo: WatcherRepo,
+ private favoriteRepo: FavoriteRepo,
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
@@ -328,7 +330,8 @@ export class WorkspaceService {
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
typeof updateWorkspaceDto.mcpEnabled !== 'undefined' ||
- typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined'
+ typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' ||
+ typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined'
) {
const ws = await this.db
.selectFrom('workspaces')
@@ -351,7 +354,8 @@ export class WorkspaceService {
if (
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
- typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined'
+ typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' ||
+ typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined'
) {
if (!this.licenseCheckService.hasFeature(ws.licenseKey, Feature.SECURITY_SETTINGS, ws.plan)) {
throw new ForbiddenException(
@@ -458,6 +462,20 @@ export class WorkspaceService {
);
}
+ if (typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined') {
+ const prev = settingsBefore?.templates?.allowMemberTemplates ?? false;
+ if (prev !== updateWorkspaceDto.allowMemberTemplates) {
+ before.allowMemberTemplates = prev;
+ after.allowMemberTemplates = updateWorkspaceDto.allowMemberTemplates;
+ }
+ await this.workspaceRepo.updateTemplateSettings(
+ workspaceId,
+ 'allowMemberTemplates',
+ updateWorkspaceDto.allowMemberTemplates,
+ trx,
+ );
+ }
+
if (typeof updateWorkspaceDto.aiChat !== 'undefined') {
const prev = settingsBefore?.ai?.chat ?? false;
if (prev !== updateWorkspaceDto.aiChat) {
@@ -477,6 +495,7 @@ export class WorkspaceService {
delete updateWorkspaceDto.generativeAi;
delete updateWorkspaceDto.disablePublicSharing;
delete updateWorkspaceDto.mcpEnabled;
+ delete updateWorkspaceDto.allowMemberTemplates;
delete updateWorkspaceDto.aiChat;
await this.workspaceRepo.updateWorkspace(
@@ -808,6 +827,10 @@ export class WorkspaceService {
trx,
});
+ await this.favoriteRepo.deleteByUserAndWorkspace(userId, workspaceId, {
+ trx,
+ });
+
await this.userSessionRepo.revokeByUserId(userId, workspaceId, trx);
});
diff --git a/apps/server/src/database/database.module.ts b/apps/server/src/database/database.module.ts
index 6c9a7e56..748cf697 100644
--- a/apps/server/src/database/database.module.ts
+++ b/apps/server/src/database/database.module.ts
@@ -22,6 +22,8 @@ import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
+import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo';
+import { TemplateRepo } from '@docmost/db/repos/template/template.repo';
import { PageListener } from '@docmost/db/listeners/page.listener';
import { PostgresJSDialect } from 'kysely-postgres-js';
import * as postgres from 'postgres';
@@ -75,6 +77,7 @@ import { normalizePostgresUrl } from '../common/helpers';
PagePermissionRepo,
PageHistoryRepo,
CommentRepo,
+ FavoriteRepo,
AttachmentRepo,
UserTokenRepo,
UserSessionRepo,
@@ -82,6 +85,7 @@ import { normalizePostgresUrl } from '../common/helpers';
ShareRepo,
NotificationRepo,
WatcherRepo,
+ TemplateRepo,
PageListener,
],
exports: [
@@ -95,6 +99,7 @@ import { normalizePostgresUrl } from '../common/helpers';
PagePermissionRepo,
PageHistoryRepo,
CommentRepo,
+ FavoriteRepo,
AttachmentRepo,
UserTokenRepo,
UserSessionRepo,
@@ -102,6 +107,7 @@ import { normalizePostgresUrl } from '../common/helpers';
ShareRepo,
NotificationRepo,
WatcherRepo,
+ TemplateRepo,
],
})
export class DatabaseModule implements OnApplicationBootstrap {
diff --git a/apps/server/src/database/migrations/20260412T135891-templates.ts b/apps/server/src/database/migrations/20260412T135891-templates.ts
new file mode 100644
index 00000000..7684be6d
--- /dev/null
+++ b/apps/server/src/database/migrations/20260412T135891-templates.ts
@@ -0,0 +1,76 @@
+import { Kysely, sql } from 'kysely';
+
+export async function up(db: Kysely): Promise {
+ await db.schema
+ .createTable('templates')
+ .addColumn('id', 'uuid', (col) =>
+ col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
+ )
+ .addColumn('title', 'varchar')
+ .addColumn('description', 'text')
+ .addColumn('content', 'jsonb')
+ .addColumn('ydoc', 'bytea')
+ .addColumn('icon', 'varchar')
+ .addColumn('space_id', 'uuid', (col) =>
+ col.references('spaces.id').onDelete('cascade'),
+ )
+ .addColumn('workspace_id', 'uuid', (col) =>
+ col.notNull().references('workspaces.id').onDelete('cascade'),
+ )
+ .addColumn('creator_id', 'uuid', (col) =>
+ col.references('users.id').onDelete('set null'),
+ )
+ .addColumn('last_updated_by_id', 'uuid', (col) =>
+ col.references('users.id').onDelete('set null'),
+ )
+ .addColumn('collaborator_ids', sql`uuid[]`)
+ .addColumn('text_content', 'text', (col) => col)
+ .addColumn('tsv', sql`tsvector`, (col) => col)
+ .addColumn('created_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .addColumn('updated_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .addColumn('deleted_at', 'timestamptz')
+ .execute();
+
+ await db.schema
+ .createIndex('idx_templates_workspace_id')
+ .on('templates')
+ .columns(['workspace_id'])
+ .execute();
+
+ await db.schema
+ .createIndex('idx_templates_space_id')
+ .on('templates')
+ .columns(['space_id'])
+ .execute();
+
+ await db.schema
+ .createIndex('templates_tsv_idx')
+ .on('templates')
+ .using('GIN')
+ .column('tsv')
+ .execute();
+
+ await sql`
+ CREATE OR REPLACE FUNCTION templates_tsvector_trigger() RETURNS trigger AS $$
+ begin
+ new.tsv :=
+ setweight(to_tsvector('english', f_unaccent(coalesce(new.title, ''))), 'A') ||
+ setweight(to_tsvector('english', f_unaccent(substring(coalesce(new.text_content, ''), 1, 1000000))), 'B');
+ return new;
+ end;
+ $$ LANGUAGE plpgsql;
+ `.execute(db);
+
+ await sql`CREATE OR REPLACE TRIGGER templates_tsvector_update BEFORE INSERT OR UPDATE
+ ON templates FOR EACH ROW EXECUTE FUNCTION templates_tsvector_trigger();`.execute(db);
+}
+
+export async function down(db: Kysely): Promise {
+ await sql`DROP TRIGGER IF EXISTS templates_tsvector_update ON templates`.execute(db);
+ await sql`DROP FUNCTION IF EXISTS templates_tsvector_trigger`.execute(db);
+ await db.schema.dropTable('templates').execute();
+}
diff --git a/apps/server/src/database/migrations/20260412T162318-favorites.ts b/apps/server/src/database/migrations/20260412T162318-favorites.ts
new file mode 100644
index 00000000..0c5cfdaf
--- /dev/null
+++ b/apps/server/src/database/migrations/20260412T162318-favorites.ts
@@ -0,0 +1,63 @@
+import { type Kysely, sql } from 'kysely';
+
+export async function up(db: Kysely): Promise {
+ await db.schema
+ .createTable('favorites')
+ .addColumn('id', 'uuid', (col) =>
+ col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
+ )
+ .addColumn('user_id', 'uuid', (col) =>
+ col.references('users.id').onDelete('cascade').notNull(),
+ )
+ .addColumn('page_id', 'uuid', (col) =>
+ col.references('pages.id').onDelete('cascade'),
+ )
+ .addColumn('space_id', 'uuid', (col) =>
+ col.references('spaces.id').onDelete('cascade'),
+ )
+ .addColumn('template_id', 'uuid', (col) =>
+ col.references('templates.id').onDelete('cascade'),
+ )
+ .addColumn('type', 'varchar', (col) => col.notNull())
+ .addColumn('workspace_id', 'uuid', (col) =>
+ col.references('workspaces.id').onDelete('cascade').notNull(),
+ )
+ .addColumn('created_at', 'timestamptz', (col) =>
+ col.defaultTo(sql`now()`).notNull(),
+ )
+ .execute();
+
+ await db.schema
+ .createIndex('idx_favorites_user_page')
+ .on('favorites')
+ .columns(['user_id', 'page_id'])
+ .unique()
+ .where('page_id', 'is not', null)
+ .execute();
+
+ await db.schema
+ .createIndex('idx_favorites_user_space')
+ .on('favorites')
+ .columns(['user_id', 'space_id'])
+ .unique()
+ .where('space_id', 'is not', null)
+ .execute();
+
+ await db.schema
+ .createIndex('idx_favorites_user_template')
+ .on('favorites')
+ .columns(['user_id', 'template_id'])
+ .unique()
+ .where('template_id', 'is not', null)
+ .execute();
+
+ await db.schema
+ .createIndex('idx_favorites_user_workspace_type')
+ .on('favorites')
+ .columns(['user_id', 'workspace_id', 'type'])
+ .execute();
+}
+
+export async function down(db: Kysely): Promise {
+ await db.schema.dropTable('favorites').execute();
+}
diff --git a/apps/server/src/database/repos/favorite/favorite.repo.ts b/apps/server/src/database/repos/favorite/favorite.repo.ts
new file mode 100644
index 00000000..24c80343
--- /dev/null
+++ b/apps/server/src/database/repos/favorite/favorite.repo.ts
@@ -0,0 +1,216 @@
+import { Injectable } from '@nestjs/common';
+import { InjectKysely } from 'nestjs-kysely';
+import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
+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 { DB } from '@docmost/db/types/db';
+import { dbOrTx } from '@docmost/db/utils';
+
+export const FavoriteType = {
+ PAGE: 'page',
+ SPACE: 'space',
+ TEMPLATE: 'template',
+} as const;
+
+export type FavoriteType = (typeof FavoriteType)[keyof typeof FavoriteType];
+
+@Injectable()
+export class FavoriteRepo {
+ constructor(@InjectKysely() private readonly db: KyselyDB) {}
+
+ async insert(favorite: InsertableFavorite): Promise {
+ try {
+ return await this.db
+ .insertInto('favorites')
+ .values(favorite)
+ .returningAll()
+ .executeTakeFirst();
+ } catch (err: any) {
+ if (err?.code === '23505') return undefined;
+ throw err;
+ }
+ }
+
+ async deleteByUserAndPage(userId: string, pageId: string): Promise {
+ await this.db
+ .deleteFrom('favorites')
+ .where('userId', '=', userId)
+ .where('pageId', '=', pageId)
+ .execute();
+ }
+
+ async deleteByUserAndSpace(userId: string, spaceId: string): Promise {
+ await this.db
+ .deleteFrom('favorites')
+ .where('userId', '=', userId)
+ .where('spaceId', '=', spaceId)
+ .where('type', '=', FavoriteType.SPACE)
+ .execute();
+ }
+
+ async deleteByUserAndTemplate(
+ userId: string,
+ templateId: string,
+ ): Promise {
+ await this.db
+ .deleteFrom('favorites')
+ .where('userId', '=', userId)
+ .where('templateId', '=', templateId)
+ .execute();
+ }
+
+ async findUserFavorites(
+ userId: string,
+ workspaceId: string,
+ pagination: PaginationOptions,
+ type?: FavoriteType,
+ ) {
+ let query = this.db
+ .selectFrom('favorites')
+ .selectAll('favorites')
+ .where('favorites.userId', '=', userId)
+ .where('favorites.workspaceId', '=', workspaceId);
+
+ if (type) {
+ query = query.where('favorites.type', '=', type);
+ }
+
+ if (type === FavoriteType.PAGE || !type) {
+ query = query.select((eb) => this.withPage(eb));
+ }
+
+ if (type === FavoriteType.PAGE) {
+ query = query.select((eb) => this.withPageSpace(eb));
+ } else if (type === FavoriteType.SPACE) {
+ query = query.select((eb) => this.withSpace(eb));
+ } else {
+ query = query.select((eb) => this.withSpaceResolved(eb));
+ }
+
+ if (type === FavoriteType.TEMPLATE || !type) {
+ query = query.select((eb) => this.withTemplate(eb));
+ }
+
+ return executeWithCursorPagination(query, {
+ perPage: pagination.limit,
+ cursor: pagination.cursor,
+ beforeCursor: pagination.beforeCursor,
+ fields: [{ expression: 'favorites.id', direction: 'desc' }],
+ parseCursor: (cursor) => ({
+ id: cursor.id,
+ }),
+ });
+ }
+
+ async deleteByUsersWithoutSpaceAccess(
+ userIds: string[],
+ spaceId: string,
+ opts?: { trx?: KyselyTransaction },
+ ): Promise {
+ if (userIds.length === 0) return;
+
+ const { trx } = opts;
+ const db = dbOrTx(this.db, trx);
+
+ const usersWithAccess = db
+ .selectFrom('spaceMembers')
+ .select('userId')
+ .where('spaceId', '=', spaceId)
+ .where('userId', 'is not', null)
+ .union(
+ db
+ .selectFrom('spaceMembers')
+ .innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
+ .select('groupUsers.userId')
+ .where('spaceMembers.spaceId', '=', spaceId),
+ );
+
+ await db
+ .deleteFrom('favorites')
+ .where('userId', 'in', userIds)
+ .where('spaceId', '=', spaceId)
+ .where('userId', 'not in', usersWithAccess)
+ .execute();
+ }
+
+ async deleteByUserAndWorkspace(
+ userId: string,
+ workspaceId: string,
+ opts?: { trx?: KyselyTransaction },
+ ): Promise {
+ const { trx } = opts;
+ const db = dbOrTx(this.db, trx);
+
+ await db
+ .deleteFrom('favorites')
+ .where('userId', '=', userId)
+ .where('workspaceId', '=', workspaceId)
+ .execute();
+ }
+
+ private withPage(eb: ExpressionBuilder) {
+ return jsonObjectFrom(
+ eb
+ .selectFrom('pages')
+ .select([
+ 'pages.id',
+ 'pages.slugId',
+ 'pages.title',
+ 'pages.icon',
+ 'pages.spaceId',
+ ])
+ .whereRef('pages.id', '=', 'favorites.pageId'),
+ ).as('page');
+ }
+
+ private withSpace(eb: ExpressionBuilder) {
+ return jsonObjectFrom(
+ eb
+ .selectFrom('spaces')
+ .select(['spaces.id', 'spaces.name', 'spaces.slug', 'spaces.logo'])
+ .whereRef('spaces.id', '=', 'favorites.spaceId'),
+ ).as('space');
+ }
+
+ private withPageSpace(eb: ExpressionBuilder) {
+ return jsonObjectFrom(
+ eb
+ .selectFrom('spaces')
+ .innerJoin('pages', 'pages.spaceId', 'spaces.id')
+ .select(['spaces.id', 'spaces.name', 'spaces.slug', 'spaces.logo'])
+ .whereRef('pages.id', '=', 'favorites.pageId'),
+ ).as('space');
+ }
+
+ private withSpaceResolved(eb: ExpressionBuilder) {
+ return jsonObjectFrom(
+ eb
+ .selectFrom('spaces')
+ .select(['spaces.id', 'spaces.name', 'spaces.slug', 'spaces.logo'])
+ .where(({ or, ref }) =>
+ or([
+ sql`${ref('spaces.id')} = ${ref('favorites.spaceId')}`,
+ sql`${ref('spaces.id')} = (SELECT pages.space_id FROM pages WHERE pages.id = ${ref('favorites.pageId')})`,
+ ]),
+ ),
+ ).as('space');
+ }
+
+ private withTemplate(eb: ExpressionBuilder) {
+ return jsonObjectFrom(
+ eb
+ .selectFrom('templates')
+ .select([
+ 'templates.id',
+ 'templates.title',
+ 'templates.description',
+ 'templates.icon',
+ 'templates.spaceId',
+ ])
+ .whereRef('templates.id', '=', 'favorites.templateId'),
+ ).as('template');
+ }
+}
diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts
index 14490312..a35234f6 100644
--- a/apps/server/src/database/repos/page/page.repo.ts
+++ b/apps/server/src/database/repos/page/page.repo.ts
@@ -324,6 +324,35 @@ export class PageRepo {
});
}
+ async getCreatedByPages(creatorId: string, requestingUserId: string, pagination: PaginationOptions, spaceId?: string) {
+ let query = this.db
+ .selectFrom('pages')
+ .select(this.baseFields)
+ .select((eb) => this.withSpace(eb))
+ .where('creatorId', '=', creatorId)
+ .where('deletedAt', 'is', null);
+
+ if (spaceId) {
+ query = query.where('spaceId', '=', spaceId);
+ } else {
+ query = query.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(requestingUserId));
+ }
+
+ return executeWithCursorPagination(query, {
+ perPage: pagination.limit,
+ cursor: pagination.cursor,
+ beforeCursor: pagination.beforeCursor,
+ fields: [
+ { expression: 'updatedAt', direction: 'desc' },
+ { expression: 'id', direction: 'desc' },
+ ],
+ parseCursor: (cursor) => ({
+ updatedAt: new Date(cursor.updatedAt),
+ id: cursor.id,
+ }),
+ });
+ }
+
async getDeletedPagesInSpace(spaceId: string, pagination: PaginationOptions) {
const query = this.db
.selectFrom('pages')
diff --git a/apps/server/src/database/repos/space/space-member.repo.ts b/apps/server/src/database/repos/space/space-member.repo.ts
index 908c8497..50961802 100644
--- a/apps/server/src/database/repos/space/space-member.repo.ts
+++ b/apps/server/src/database/repos/space/space-member.repo.ts
@@ -290,6 +290,32 @@ export class SpaceMemberRepo {
return membership.map((space) => space.id);
}
+ async getUserRolesForSpaces(
+ userId: string,
+ spaceIds: string[],
+ ): Promise<{ spaceId: string; role: string }[]> {
+ if (spaceIds.length === 0) return [];
+
+ return this.db
+ .selectFrom('spaceMembers')
+ .select(['spaceId', 'role'])
+ .where('userId', '=', userId)
+ .where('spaceId', 'in', spaceIds)
+ .unionAll(
+ this.db
+ .selectFrom('spaceMembers')
+ .innerJoin(
+ 'groupUsers',
+ 'groupUsers.groupId',
+ 'spaceMembers.groupId',
+ )
+ .select(['spaceMembers.spaceId', 'spaceMembers.role'])
+ .where('groupUsers.userId', '=', userId)
+ .where('spaceMembers.spaceId', 'in', spaceIds),
+ )
+ .execute();
+ }
+
async getUserSpaces(userId: string, pagination: PaginationOptions) {
let query = this.db
.selectFrom('spaces')
diff --git a/apps/server/src/database/repos/template/template.repo.ts b/apps/server/src/database/repos/template/template.repo.ts
new file mode 100644
index 00000000..583cc9ed
--- /dev/null
+++ b/apps/server/src/database/repos/template/template.repo.ts
@@ -0,0 +1,160 @@
+import { Injectable } from '@nestjs/common';
+import { InjectKysely } from 'nestjs-kysely';
+import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
+import { dbOrTx } from '@docmost/db/utils';
+import {
+ InsertableTemplate,
+ Page,
+ Template,
+ UpdatableTemplate,
+} from '@docmost/db/types/entity.types';
+import { PaginationOptions } from '../../pagination/pagination-options';
+import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
+import { ExpressionBuilder, sql } from 'kysely';
+import { DB } from '@docmost/db/types/db';
+import { jsonObjectFrom } from 'kysely/helpers/postgres';
+
+@Injectable()
+export class TemplateRepo {
+ private baseFields: Array = [
+ 'id',
+ 'title',
+ 'description',
+ 'icon',
+ 'spaceId',
+ 'workspaceId',
+ 'creatorId',
+ 'lastUpdatedById',
+ 'createdAt',
+ 'updatedAt',
+ ];
+
+ constructor(@InjectKysely() private readonly db: KyselyDB) {}
+
+ async findById(
+ templateId: string,
+ workspaceId: string,
+ opts?: { includeContent?: boolean; trx?: KyselyTransaction },
+ ): Promise {
+ const db = dbOrTx(this.db, opts?.trx);
+
+ const query = db
+ .selectFrom('templates')
+ .select(this.baseFields)
+ .$if(opts?.includeContent ?? false, (qb) => qb.select('content'))
+ .select((eb) => [this.withCreator(eb)])
+ .where('id', '=', templateId)
+ .where('workspaceId', '=', workspaceId);
+
+ return query.executeTakeFirst() as Promise;
+ }
+
+ async findTemplates(
+ workspaceId: string,
+ accessibleSpaceIds: string[],
+ pagination: PaginationOptions,
+ opts?: { spaceId?: string },
+ ) {
+ let query = this.db
+ .selectFrom('templates')
+ .select(this.baseFields)
+ .select((eb) => [this.withCreator(eb)])
+ .where('workspaceId', '=', workspaceId);
+
+ if (opts?.spaceId) {
+ if (!accessibleSpaceIds.includes(opts.spaceId)) {
+ query = query.where('spaceId', 'is', null);
+ } else {
+ query = query.where((eb) =>
+ eb.or([eb('spaceId', '=', opts.spaceId), eb('spaceId', 'is', null)]),
+ );
+ }
+ } else {
+ query = query.where((eb) =>
+ eb.or([
+ eb('spaceId', 'is', null),
+ ...(accessibleSpaceIds.length > 0
+ ? [eb('spaceId', 'in', accessibleSpaceIds)]
+ : []),
+ ]),
+ );
+ }
+
+ if (pagination.query) {
+ const searchTerm = `%${pagination.query}%`;
+ query = query.where((eb) =>
+ eb.or([
+ eb(sql`f_unaccent(title)`, 'ilike', sql`f_unaccent(${searchTerm})`),
+ eb(
+ sql`f_unaccent(description)`,
+ 'ilike',
+ sql`f_unaccent(${searchTerm})`,
+ ),
+ ]),
+ );
+ }
+
+ return executeWithCursorPagination(query, {
+ perPage: pagination.limit,
+ cursor: pagination.cursor,
+ beforeCursor: pagination.beforeCursor,
+ fields: [
+ { expression: 'title', direction: 'asc' },
+ { expression: 'id', direction: 'asc' },
+ ],
+ parseCursor: (cursor) => ({
+ title: cursor.title,
+ id: cursor.id,
+ }),
+ });
+ }
+
+ async insertTemplate(
+ insertableTemplate: InsertableTemplate,
+ trx?: KyselyTransaction,
+ ): Promise<{ id: string }> {
+ const db = dbOrTx(this.db, trx);
+ return db
+ .insertInto('templates')
+ .values(insertableTemplate)
+ .returning('id')
+ .executeTakeFirst();
+ }
+
+ async updateTemplate(
+ updatableTemplate: UpdatableTemplate,
+ templateId: string,
+ workspaceId: string,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ await db
+ .updateTable('templates')
+ .set({ ...updatableTemplate, updatedAt: new Date() })
+ .where('id', '=', templateId)
+ .where('workspaceId', '=', workspaceId)
+ .execute();
+ }
+
+ async deleteTemplate(
+ templateId: string,
+ workspaceId: string,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ await db
+ .deleteFrom('templates')
+ .where('id', '=', templateId)
+ .where('workspaceId', '=', workspaceId)
+ .execute();
+ }
+
+ withCreator(eb: ExpressionBuilder) {
+ return jsonObjectFrom(
+ eb
+ .selectFrom('users')
+ .select(['users.id', 'users.name', 'users.avatarUrl'])
+ .whereRef('users.id', '=', 'templates.creatorId'),
+ ).as('creator');
+ }
+}
diff --git a/apps/server/src/database/repos/workspace/workspace.repo.ts b/apps/server/src/database/repos/workspace/workspace.repo.ts
index deb52a93..4e399011 100644
--- a/apps/server/src/database/repos/workspace/workspace.repo.ts
+++ b/apps/server/src/database/repos/workspace/workspace.repo.ts
@@ -230,4 +230,24 @@ export class WorkspaceRepo {
.executeTakeFirst();
}
+ async updateTemplateSettings(
+ workspaceId: string,
+ prefKey: string,
+ prefValue: string | boolean,
+ trx?: KyselyTransaction,
+ ) {
+ const db = dbOrTx(this.db, trx);
+ return db
+ .updateTable('workspaces')
+ .set({
+ settings: sql`COALESCE(settings, '{}'::jsonb)
+ || jsonb_build_object('templates', COALESCE(settings->'templates', '{}'::jsonb)
+ || jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
+ updatedAt: new Date(),
+ })
+ .where('id', '=', workspaceId)
+ .returning(this.baseFields)
+ .executeTakeFirst();
+ }
+
}
diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts
index 3f4081e1..6ec3790c 100644
--- a/apps/server/src/database/types/db.d.ts
+++ b/apps/server/src/database/types/db.d.ts
@@ -175,6 +175,17 @@ export interface Comments {
workspaceId: string;
}
+export interface Favorites {
+ id: Generated;
+ userId: string;
+ pageId: string | null;
+ spaceId: string | null;
+ templateId: string | null;
+ type: string;
+ workspaceId: string;
+ createdAt: Generated;
+}
+
export interface FileTasks {
createdAt: Generated;
creatorId: string | null;
@@ -430,6 +441,25 @@ export interface PagePermissions {
updatedAt: Generated;
}
+export interface Templates {
+ id: Generated;
+ title: string | null;
+ description: string | null;
+ content: Json | null;
+ ydoc: Buffer | null;
+ icon: string | null;
+ spaceId: string | null;
+ workspaceId: string;
+ creatorId: string | null;
+ lastUpdatedById: string | null;
+ collaboratorIds: string[] | null;
+ textContent: string | null;
+ tsv: string | null;
+ createdAt: Generated;
+ updatedAt: Generated;
+ deletedAt: Timestamp | null;
+}
+
export interface AiChats {
id: Generated;
workspaceId: string;
@@ -481,6 +511,7 @@ export interface DB {
backlinks: Backlinks;
billing: Billing;
comments: Comments;
+ favorites: Favorites;
fileTasks: FileTasks;
groups: Groups;
groupUsers: GroupUsers;
@@ -492,6 +523,7 @@ export interface DB {
shares: Shares;
spaceMembers: SpaceMembers;
spaces: Spaces;
+ templates: Templates;
userMfa: UserMfa;
users: Users;
userSessions: UserSessions;
diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts
index 8d4f482a..a15b99f4 100644
--- a/apps/server/src/database/types/entity.types.ts
+++ b/apps/server/src/database/types/entity.types.ts
@@ -22,12 +22,14 @@ import {
AuthProviders,
AuthAccounts,
Shares,
+ Favorites,
FileTasks,
UserMfa as _UserMFA,
UserSessions,
ApiKeys,
Watchers,
Audit as _Audit,
+ Templates,
} from './db';
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
@@ -135,6 +137,11 @@ export type Share = Selectable;
export type InsertableShare = Insertable;
export type UpdatableShare = Updateable>;
+// Favorite
+export type Favorite = Selectable;
+export type InsertableFavorite = Insertable;
+export type UpdatableFavorite = Updateable>;
+
// File Task
export type FileTask = Selectable;
export type InsertableFileTask = Insertable;
@@ -184,3 +191,8 @@ export type UpdatableUserSession = Updateable>;
export type Audit = Selectable<_Audit>;
export type InsertableAudit = Insertable<_Audit>;
export type UpdatableAudit = Updateable>;
+
+// Template
+export type Template = Selectable;
+export type InsertableTemplate = Insertable;
+export type UpdatableTemplate = Updateable>;
diff --git a/apps/server/src/ee b/apps/server/src/ee
index d3bc4c51..d80f660b 160000
--- a/apps/server/src/ee
+++ b/apps/server/src/ee
@@ -1 +1 @@
-Subproject commit d3bc4c5160fec9c4fabf769180f0ff00ef12042b
+Subproject commit d80f660b20e71f07b0dce1e8bae012597ebe1ec5