diff --git a/apps/client/index.html b/apps/client/index.html index 29065d58..e4528b7c 100644 --- a/apps/client/index.html +++ b/apps/client/index.html @@ -4,10 +4,13 @@ - docmost + Docmost
+ + + diff --git a/apps/client/package.json b/apps/client/package.json index 5172b2ab..fb9e0058 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -1,7 +1,7 @@ { "name": "client", "private": true, - "version": "0.0.0", + "version": "0.1.0", "scripts": { "dev": "vite", "build": "tsc && vite build", @@ -31,6 +31,7 @@ "react-arborist": "^3.4.0", "react-dom": "^18.2.0", "react-error-boundary": "^4.0.13", + "react-helmet-async": "^2.0.5", "react-router-dom": "^6.22.3", "socket.io-client": "^4.7.5", "tippy.js": "^6.3.7", diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 3b6bd983..649dd739 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -65,7 +65,7 @@ export default function App() { }> } /> - } /> + } /> }> diff --git a/apps/client/src/components/layouts/components/breadcrumb.tsx b/apps/client/src/components/layouts/components/breadcrumb.tsx index 9fa79a1f..7b00a139 100644 --- a/apps/client/src/components/layouts/components/breadcrumb.tsx +++ b/apps/client/src/components/layouts/components/breadcrumb.tsx @@ -14,6 +14,8 @@ import { IconDots } from "@tabler/icons-react"; import { Link, useParams } from "react-router-dom"; import classes from "./breadcrumb.module.css"; import { SpaceTreeNode } from "@/features/page/tree/types.ts"; +import { buildPageSlug } from "@/features/page/page.utils.ts"; +import { usePageQuery } from "@/features/page/queries/page-query.ts"; function getTitle(name: string, icon: string) { if (icon) { @@ -27,23 +29,17 @@ export default function Breadcrumb() { const [breadcrumbNodes, setBreadcrumbNodes] = useState< SpaceTreeNode[] | null >(null); - const { pageId } = useParams(); + const { slugId } = useParams(); + const { data: currentPage } = usePageQuery(slugId); useEffect(() => { - if (treeData.length) { - const breadcrumb = findBreadcrumbPath(treeData, pageId); + if (treeData?.length > 0 && currentPage) { + const breadcrumb = findBreadcrumbPath(treeData, currentPage.id); if (breadcrumb) { setBreadcrumbNodes(breadcrumb); } } - }, [pageId, treeData]); - - useEffect(() => { - if (treeData.length) { - const breadcrumb = findBreadcrumbPath(treeData, pageId); - if (breadcrumb) setBreadcrumbNodes(breadcrumb); - } - }, [pageId, treeData]); + }, [currentPage?.id, treeData]); const HiddenNodesTooltipContent = () => breadcrumbNodes?.slice(1, -2).map((node) => ( @@ -51,7 +47,7 @@ export default function Breadcrumb() { + + + + ); +} diff --git a/apps/client/src/features/auth/hooks/use-auth.ts b/apps/client/src/features/auth/hooks/use-auth.ts index 11450642..a55dfb7c 100644 --- a/apps/client/src/features/auth/hooks/use-auth.ts +++ b/apps/client/src/features/auth/hooks/use-auth.ts @@ -1,10 +1,18 @@ import { useState } from "react"; -import { login, register } from "@/features/auth/services/auth-service"; +import { + login, + register, + setupWorkspace, +} from "@/features/auth/services/auth-service"; import { useNavigate } from "react-router-dom"; import { useAtom } from "jotai"; import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom"; -import { ILogin, IRegister } from "@/features/auth/types/auth.types"; +import { + ILogin, + IRegister, + ISetupWorkspace, +} from "@/features/auth/types/auth.types"; import { notifications } from "@mantine/notifications"; import { IAcceptInvite } from "@/features/workspace/types/workspace.types.ts"; import { acceptInvitation } from "@/features/workspace/services/workspace-service.ts"; @@ -76,6 +84,25 @@ export default function useAuth() { } }; + const handleSetupWorkspace = async (data: ISetupWorkspace) => { + setIsLoading(true); + + try { + const res = await setupWorkspace(data); + setIsLoading(false); + + setAuthToken(res.tokens); + + navigate("/home"); + } catch (err) { + setIsLoading(false); + notifications.show({ + message: err.response?.data.message, + color: "red", + }); + } + }; + const handleIsAuthenticated = async () => { if (!authToken) { return false; @@ -109,6 +136,7 @@ export default function useAuth() { signIn: handleSignIn, signUp: handleSignUp, invitationSignup: handleInvitationSignUp, + setupWorkspace: handleSetupWorkspace, isAuthenticated: handleIsAuthenticated, logout: handleLogout, hasTokens, diff --git a/apps/client/src/features/auth/services/auth-service.ts b/apps/client/src/features/auth/services/auth-service.ts index 57bc7b5e..a4ab9695 100644 --- a/apps/client/src/features/auth/services/auth-service.ts +++ b/apps/client/src/features/auth/services/auth-service.ts @@ -3,6 +3,7 @@ import { IChangePassword, ILogin, IRegister, + ISetupWorkspace, ITokenResponse, } from "@/features/auth/types/auth.types"; @@ -22,3 +23,10 @@ export async function changePassword( const req = await api.post("/auth/change-password", data); return req.data; } + +export async function setupWorkspace( + data: ISetupWorkspace, +): Promise { + const req = await api.post("/auth/setup", data); + return req.data; +} diff --git a/apps/client/src/features/auth/types/auth.types.ts b/apps/client/src/features/auth/types/auth.types.ts index 442677fa..0aef0050 100644 --- a/apps/client/src/features/auth/types/auth.types.ts +++ b/apps/client/src/features/auth/types/auth.types.ts @@ -9,6 +9,13 @@ export interface IRegister { password: string; } +export interface ISetupWorkspace { + workspaceName: string; + name: string; + email: string; + password: string; +} + export interface ITokens { accessToken: string; refreshToken: string; diff --git a/apps/client/src/features/comment/components/comment-list.tsx b/apps/client/src/features/comment/components/comment-list.tsx index 8a4ceda7..28593aac 100644 --- a/apps/client/src/features/comment/components/comment-list.tsx +++ b/apps/client/src/features/comment/components/comment-list.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef } from "react"; +import React, { useState, useRef, useCallback, memo } from "react"; import { useParams } from "react-router-dom"; import { Divider, Paper } from "@mantine/core"; import CommentListItem from "@/features/comment/components/comment-list-item"; @@ -11,36 +11,27 @@ import CommentEditor from "@/features/comment/components/comment-editor"; import CommentActions from "@/features/comment/components/comment-actions"; import { useFocusWithin } from "@mantine/hooks"; import { IComment } from "@/features/comment/types/comment.types.ts"; +import { usePageQuery } from "@/features/page/queries/page-query.ts"; +import { IPagination } from "@/lib/types.ts"; function CommentList() { - const { pageId } = useParams(); + const { slugId } = useParams(); + const { data: page } = usePageQuery(slugId); const { data: comments, isLoading: isCommentsLoading, isError, - } = useCommentsQuery({ pageId, limit: 100 }); - const [isLoading, setIsLoading] = useState(false); + } = useCommentsQuery({ pageId: page?.id, limit: 100 }); const createCommentMutation = useCreateCommentMutation(); + const [isLoading, setIsLoading] = useState(false); - if (isCommentsLoading) { - return <>; - } - - if (isError) { - return
Error loading comments.
; - } - - if (!comments || comments.items.length === 0) { - return <>No comments yet.; - } - - const renderComments = (comment: IComment) => { - const handleAddReply = async (commentId: string, content: string) => { + const handleAddReply = useCallback( + async (commentId: string, content: string) => { try { setIsLoading(true); const commentData = { - pageId: comment.pageId, - parentCommentId: comment.id, + pageId: page?.id, + parentCommentId: commentId, content: JSON.stringify(content), }; @@ -50,9 +41,12 @@ function CommentList() { } finally { setIsLoading(false); } - }; + }, + [createCommentMutation, page?.id], + ); - return ( + const renderComments = useCallback( + (comment: IComment) => (
- +
@@ -75,8 +69,21 @@ function CommentList() { isLoading={isLoading} />
- ); - }; + ), + [comments, handleAddReply, isLoading], + ); + + if (isCommentsLoading) { + return <>; + } + + if (isError) { + return
Error loading comments.
; + } + + if (!comments || comments.items.length === 0) { + return <>No comments yet.; + } return ( <> @@ -87,35 +94,46 @@ function CommentList() { ); } -const ChildComments = ({ comments, parentId }) => { - const getChildComments = (parentId: string) => { - return comments.items.filter( - (comment: IComment) => comment.parentCommentId === parentId, - ); - }; +interface ChildCommentsProps { + comments: IPagination; + parentId: string; +} +const ChildComments = ({ comments, parentId }: ChildCommentsProps) => { + const getChildComments = useCallback( + (parentId: string) => + comments.items.filter( + (comment: IComment) => comment.parentCommentId === parentId, + ), + [comments.items], + ); return (
{getChildComments(parentId).map((childComment) => (
- +
))}
); }; +const MemoizedChildComments = memo(ChildComments); + const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => { const [content, setContent] = useState(""); const { ref, focused } = useFocusWithin(); const commentEditorRef = useRef(null); - const handleSave = () => { + const handleSave = useCallback(() => { onSave(commentId, content); setContent(""); commentEditorRef.current?.clearContent(); - }; + }, [commentId, content, onSave]); return (
diff --git a/apps/client/src/features/editor/full-editor.tsx b/apps/client/src/features/editor/full-editor.tsx index 7f1f4c91..c7d1074c 100644 --- a/apps/client/src/features/editor/full-editor.tsx +++ b/apps/client/src/features/editor/full-editor.tsx @@ -1,20 +1,22 @@ -import classes from '@/features/editor/styles/editor.module.css'; -import React from 'react'; -import { TitleEditor } from '@/features/editor/title-editor'; -import PageEditor from '@/features/editor/page-editor'; +import classes from "@/features/editor/styles/editor.module.css"; +import React from "react"; +import { TitleEditor } from "@/features/editor/title-editor"; +import PageEditor from "@/features/editor/page-editor"; + +const MemoizedTitleEditor = React.memo(TitleEditor); +const MemoizedPageEditor = React.memo(PageEditor); export interface FullEditorProps { pageId: string; - title: any; + slugId: string; + title: string; } -export function FullEditor({ pageId, title }: FullEditorProps) { - +export function FullEditor({ pageId, title, slugId }: FullEditorProps) { return (
- - + +
- ); } diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index 969f60bd..957d525c 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -36,12 +36,9 @@ export default function PageEditor({ const [currentUser] = useAtom(currentUserAtom); const [, setEditor] = useAtom(pageEditorAtom); const [, setAsideState] = useAtom(asideStateAtom); - const [, setActiveCommentId] = useAtom(activeCommentIdAtom); const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom); - const ydoc = useMemo(() => new Y.Doc(), [pageId]); - const [isLocalSynced, setLocalSynced] = useState(false); const [isRemoteSynced, setRemoteSynced] = useState(false); const documentName = `page.${pageId}`; diff --git a/apps/client/src/features/editor/title-editor.tsx b/apps/client/src/features/editor/title-editor.tsx index 475f4ade..13780da2 100644 --- a/apps/client/src/features/editor/title-editor.tsx +++ b/apps/client/src/features/editor/title-editor.tsx @@ -10,27 +10,34 @@ import { pageEditorAtom, titleEditorAtom, } from "@/features/editor/atoms/editor-atoms"; -import { useUpdatePageMutation } from "@/features/page/queries/page-query"; +import { + usePageQuery, + useUpdatePageMutation, +} from "@/features/page/queries/page-query"; import { useDebouncedValue } from "@mantine/hooks"; import { useAtom } from "jotai"; import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom"; import { updateTreeNodeName } from "@/features/page/tree/utils"; import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; import { History } from "@tiptap/extension-history"; +import { buildPageSlug } from "@/features/page/page.utils.ts"; +import { useNavigate } from "react-router-dom"; export interface TitleEditorProps { pageId: string; + slugId: string; title: string; } -export function TitleEditor({ pageId, title }: TitleEditorProps) { - const [debouncedTitleState, setDebouncedTitleState] = useState(""); +export function TitleEditor({ pageId, slugId, title }: TitleEditorProps) { + const [debouncedTitleState, setDebouncedTitleState] = useState(null); const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 1000); const updatePageMutation = useUpdatePageMutation(); const pageEditor = useAtomValue(pageEditorAtom); const [, setTitleEditor] = useAtom(titleEditorAtom); const [treeData, setTreeData] = useAtom(treeDataAtom); const emit = useQueryEmit(); + const navigate = useNavigate(); const titleEditor = useEditor({ extensions: [ @@ -62,15 +69,23 @@ export function TitleEditor({ pageId, title }: TitleEditorProps) { }); useEffect(() => { - if (debouncedTitle !== "") { - updatePageMutation.mutate({ pageId, title: debouncedTitle }); + const pageSlug = buildPageSlug(slugId, title); + navigate(pageSlug, { replace: true }); + }, [title]); + + useEffect(() => { + if (debouncedTitle !== null) { + updatePageMutation.mutate({ + pageId: pageId, + title: debouncedTitle, + }); setTimeout(() => { emit({ operation: "updateOne", entity: ["pages"], id: pageId, - payload: { title: debouncedTitle }, + payload: { title: debouncedTitle, slugId: slugId }, }); }, 50); diff --git a/apps/client/src/features/home/components/home-tabs.tsx b/apps/client/src/features/home/components/home-tabs.tsx index 3c5a9c24..a10bec31 100644 --- a/apps/client/src/features/home/components/home-tabs.tsx +++ b/apps/client/src/features/home/components/home-tabs.tsx @@ -1,5 +1,5 @@ import { Text, Tabs, Space } from "@mantine/core"; -import { IconClockHour3, IconStar } from "@tabler/icons-react"; +import { IconClockHour3 } from "@tabler/icons-react"; import RecentChanges from "@/features/home/components/recent-changes"; export default function HomeTabs() { @@ -16,7 +16,7 @@ export default function HomeTabs() { -
Recent
+
); diff --git a/apps/client/src/features/home/components/recent-changes.tsx b/apps/client/src/features/home/components/recent-changes.tsx index 17c3ec49..7a7ff47e 100644 --- a/apps/client/src/features/home/components/recent-changes.tsx +++ b/apps/client/src/features/home/components/recent-changes.tsx @@ -1,9 +1,10 @@ -import { Text, Group, Stack, UnstyledButton, Divider } from '@mantine/core'; -import { format } from 'date-fns'; -import classes from './home.module.css'; -import { Link } from 'react-router-dom'; -import PageListSkeleton from '@/features/home/components/page-list-skeleton'; -import { useRecentChangesQuery } from '@/features/page/queries/page-query'; +import { Text, Group, Stack, UnstyledButton, Divider } from "@mantine/core"; +import { format } from "date-fns"; +import classes from "./home.module.css"; +import { Link } from "react-router-dom"; +import PageListSkeleton from "@/features/home/components/page-list-skeleton"; +import { useRecentChangesQuery } from "@/features/page/queries/page-query"; +import { buildPageSlug } from "@/features/page/page.utils.ts"; function RecentChanges() { const { data, isLoading, isError } = useRecentChangesQuery(); @@ -18,21 +19,23 @@ function RecentChanges() { return (
- {data - .map((page) => ( + {data.items.map((page) => (
- + - - {page.title || 'Untitled'} + {page.title || "Untitled"} - {format(new Date(page.updatedAt), 'PP')} + {format(new Date(page.updatedAt), "PP")} diff --git a/apps/client/src/features/page-history/components/history-list.tsx b/apps/client/src/features/page-history/components/history-list.tsx index ef2c6582..9d797914 100644 --- a/apps/client/src/features/page-history/components/history-list.tsx +++ b/apps/client/src/features/page-history/components/history-list.tsx @@ -18,9 +18,12 @@ import { import { modals } from "@mantine/modals"; import { notifications } from "@mantine/notifications"; -function HistoryList() { +interface Props { + pageId: string; +} + +function HistoryList({ pageId }: Props) { const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom); - const { pageId } = useParams(); const { data: pageHistoryList, isLoading, diff --git a/apps/client/src/features/page-history/components/history-modal-body.tsx b/apps/client/src/features/page-history/components/history-modal-body.tsx index b92ced65..0158c4e4 100644 --- a/apps/client/src/features/page-history/components/history-modal-body.tsx +++ b/apps/client/src/features/page-history/components/history-modal-body.tsx @@ -1,18 +1,22 @@ -import { ScrollArea } from '@mantine/core'; -import HistoryList from '@/features/page-history/components/history-list'; -import classes from './history.module.css'; -import { useAtom } from 'jotai'; -import { activeHistoryIdAtom } from '@/features/page-history/atoms/history-atoms'; -import HistoryView from '@/features/page-history/components/history-view'; +import { ScrollArea } from "@mantine/core"; +import HistoryList from "@/features/page-history/components/history-list"; +import classes from "./history.module.css"; +import { useAtom } from "jotai"; +import { activeHistoryIdAtom } from "@/features/page-history/atoms/history-atoms"; +import HistoryView from "@/features/page-history/components/history-view"; -export default function HistoryModalBody() { +interface Props { + pageId: string; +} + +export default function HistoryModalBody({ pageId }: Props) { const [activeHistoryId] = useAtom(activeHistoryIdAtom); return (
@@ -21,7 +25,6 @@ export default function HistoryModalBody() { {activeHistoryId && }
-
); } diff --git a/apps/client/src/features/page-history/components/history-modal.tsx b/apps/client/src/features/page-history/components/history-modal.tsx index 5ae3862f..1de8537c 100644 --- a/apps/client/src/features/page-history/components/history-modal.tsx +++ b/apps/client/src/features/page-history/components/history-modal.tsx @@ -1,24 +1,33 @@ -import { Modal, Text } from '@mantine/core'; -import { useAtom } from 'jotai'; -import { historyAtoms } from '@/features/page-history/atoms/history-atoms'; -import HistoryModalBody from '@/features/page-history/components/history-modal-body'; +import { Modal, Text } from "@mantine/core"; +import { useAtom } from "jotai"; +import { historyAtoms } from "@/features/page-history/atoms/history-atoms"; +import HistoryModalBody from "@/features/page-history/components/history-modal-body"; -export default function HistoryModal() { +interface Props { + pageId: string; +} +export default function HistoryModal({ pageId }: Props) { const [isModalOpen, setModalOpen] = useAtom(historyAtoms); return ( <> - setModalOpen(false)}> + setModalOpen(false)} + > - + - Page history + + Page history + - + diff --git a/apps/client/src/features/page/page.utils.ts b/apps/client/src/features/page/page.utils.ts new file mode 100644 index 00000000..382bf4d5 --- /dev/null +++ b/apps/client/src/features/page/page.utils.ts @@ -0,0 +1,15 @@ +import slugify from "@sindresorhus/slugify"; + +export const buildPageSlug = ( + pageShortId: string, + pageTitle?: string, +): string => { + const titleSlug = slugify(pageTitle?.substring(0, 99) || "untitled", { + customReplacements: [ + ["♥", ""], + ["🦄", ""], + ], + }); + + return `/p/${pageShortId}/${titleSlug}`; +}; diff --git a/apps/client/src/features/page/queries/page-query.ts b/apps/client/src/features/page/queries/page-query.ts index 6836ce81..fec5d9f4 100644 --- a/apps/client/src/features/page/queries/page-query.ts +++ b/apps/client/src/features/page/queries/page-query.ts @@ -27,16 +27,21 @@ import { buildTree } from "@/features/page/tree/utils"; const RECENT_CHANGES_KEY = ["recentChanges"]; -export function usePageQuery(pageId: string): UseQueryResult { +export function usePageQuery( + pageIdOrSlugId: string, +): UseQueryResult { return useQuery({ - queryKey: ["pages", pageId], - queryFn: () => getPageById(pageId), - enabled: !!pageId, + queryKey: ["pages", pageIdOrSlugId], + queryFn: () => getPageById(pageIdOrSlugId), + enabled: !!pageIdOrSlugId, staleTime: 5 * 60 * 1000, }); } -export function useRecentChangesQuery(): UseQueryResult { +export function useRecentChangesQuery(): UseQueryResult< + IPagination, + Error +> { return useQuery({ queryKey: RECENT_CHANGES_KEY, queryFn: () => getRecentChanges(), @@ -60,7 +65,7 @@ export function useUpdatePageMutation() { mutationFn: (data) => updatePage(data), onSuccess: (data) => { // update page in cache - queryClient.setQueryData(["pages", data.id], data); + queryClient.setQueryData(["pages", data.slugId], data); }, }); } diff --git a/apps/client/src/features/page/services/page-service.ts b/apps/client/src/features/page/services/page-service.ts index 3ccaab6b..121163c2 100644 --- a/apps/client/src/features/page/services/page-service.ts +++ b/apps/client/src/features/page/services/page-service.ts @@ -29,8 +29,8 @@ export async function movePage(data: IMovePage): Promise { await api.post("/pages/move", data); } -export async function getRecentChanges(): Promise { - const req = await api.post("/pages/recent"); +export async function getRecentChanges(): Promise> { + const req = await api.post("/pages/recent"); return req.data; } diff --git a/apps/client/src/features/page/tree/components/space-tree.tsx b/apps/client/src/features/page/tree/components/space-tree.tsx index 264ea8bf..616cfa96 100644 --- a/apps/client/src/features/page/tree/components/space-tree.tsx +++ b/apps/client/src/features/page/tree/components/space-tree.tsx @@ -4,12 +4,13 @@ import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts"; import { fetchAncestorChildren, useGetRootSidebarPagesQuery, + usePageQuery, useUpdatePageMutation, } from "@/features/page/queries/page-query.ts"; import React, { useEffect, useRef } from "react"; import { useNavigate, useParams } from "react-router-dom"; import classes from "@/features/page/tree/styles/tree.module.css"; -import { ActionIcon, Menu, rem } from "@mantine/core"; +import { ActionIcon, Menu, rem, Text } from "@mantine/core"; import { IconChevronDown, IconChevronRight, @@ -18,7 +19,6 @@ import { IconLink, IconPlus, IconPointFilled, - IconStar, IconTrash, } from "@tabler/icons-react"; import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts"; @@ -39,9 +39,12 @@ import { import { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts"; import { queryClient } from "@/main.tsx"; import { OpenMap } from "react-arborist/dist/main/state/open-slice"; -import { useElementSize, useMergedRef } from "@mantine/hooks"; +import { useClipboard, useElementSize, useMergedRef } from "@mantine/hooks"; import { dfs } from "react-arborist/dist/module/utils"; import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; +import { buildPageSlug } from "@/features/page/page.utils.ts"; +import { notifications } from "@mantine/notifications"; +import { modals } from "@mantine/modals"; interface SpaceTreeProps { spaceId: string; @@ -50,7 +53,7 @@ interface SpaceTreeProps { const openTreeNodesAtom = atom({}); export default function SpaceTree({ spaceId }: SpaceTreeProps) { - const { pageId } = useParams(); + const { slugId } = useParams(); const { data, setData, controllers } = useTreeMutation>(spaceId); const { @@ -68,6 +71,7 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) { const { ref: sizeRef, width, height } = useElementSize(); const mergedRef = useMergedRef(rootElement, sizeRef); const isDataLoaded = useRef(false); + const { data: currentPage } = usePageQuery(slugId); useEffect(() => { if (hasNextPage && !isFetching) { @@ -94,24 +98,24 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) { useEffect(() => { const fetchData = async () => { - if (isDataLoaded.current) { + if (isDataLoaded.current && currentPage) { // check if pageId node is present in the tree - const node = dfs(treeApiRef.current.root, pageId); + const node = dfs(treeApiRef.current.root, currentPage.id); if (node) { // if node is found, no need to traverse its ancestors return; } // if not found, fetch and build its ancestors and their children - if (!pageId) return; - const ancestors = await getPageBreadcrumbs(pageId); + if (!currentPage.id) return; + const ancestors = await getPageBreadcrumbs(currentPage.id); if (ancestors && ancestors?.length > 1) { let flatTreeItems = [...buildTree(ancestors)]; const fetchAndUpdateChildren = async (ancestor: IPage) => { // we don't want to fetch the children of the opened page - if (ancestor.id === pageId) { + if (ancestor.id === currentPage.id) { return; } const children = await fetchAncestorChildren({ @@ -148,7 +152,7 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) { setTimeout(() => { // focus on node and open all parents - treeApiRef.current.select(pageId); + treeApiRef.current.select(currentPage.id); }, 100); }); } @@ -156,13 +160,15 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) { }; fetchData(); - }, [isDataLoaded.current, pageId]); + }, [isDataLoaded.current, currentPage?.id]); useEffect(() => { - setTimeout(() => { - treeApiRef.current?.select(pageId, { align: "auto" }); - }, 200); - }, [pageId]); + if (currentPage) { + setTimeout(() => { + treeApiRef.current?.select(currentPage.id, { align: "auto" }); + }, 200); + } + }, [currentPage?.id]); useEffect(() => { if (treeApiRef.current) { @@ -241,7 +247,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps) { } const handleClick = () => { - navigate(`/p/${node.id}`); + navigate(buildPageSlug(node.data.slugId, node.data.name)); }; const handleUpdateNodeIcon = (nodeId: string, newIcon: string) => { @@ -333,6 +339,7 @@ interface CreateNodeProps { treeApi: TreeApi; onExpandTree?: () => void; } + function CreateNode({ node, treeApi, onExpandTree }: CreateNodeProps) { function handleCreate() { if (node.data.hasChildren && node.children.length === 0) { @@ -366,7 +373,32 @@ interface NodeMenuProps { node: NodeApi; treeApi: TreeApi; } + function NodeMenu({ node, treeApi }: NodeMenuProps) { + const clipboard = useClipboard({ timeout: 500 }); + + const handleCopyLink = () => { + const pageLink = + window.location.host + buildPageSlug(node.data.id, node.data.name); + clipboard.copy(pageLink); + notifications.show({ message: "Link copied" }); + }; + + const openDeleteModal = () => + modals.openConfirmModal({ + title: "Are you sure you want to delete this page?", + children: ( + + Are you sure you want to delete this page? This action is + irreversible. + + ), + centered: true, + labels: { confirm: "Delete", cancel: "Cancel" }, + confirmProps: { color: "red" }, + onConfirm: () => treeApi?.delete(node), + }); + return ( @@ -386,13 +418,12 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) { - - } onClick={(e) => { e.preventDefault(); e.stopPropagation(); + handleCopyLink(); }} > Copy link @@ -404,7 +435,7 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) { leftSection={ } - onClick={() => treeApi?.delete(node)} + onClick={openDeleteModal} > Delete @@ -417,6 +448,7 @@ interface PageArrowProps { node: NodeApi; onExpandTree?: () => void; } + function PageArrow({ node, onExpandTree }: PageArrowProps) { return ( ; label: string; initiallyOpened?: boolean; @@ -22,7 +22,7 @@ export function TreeCollapse({ label, initiallyOpened, children, -}: LinksGroupProps) { +}: TreeCollapseProps) { const [opened, setOpened] = useState(initiallyOpened || false); return ( diff --git a/apps/client/src/features/page/tree/hooks/use-tree-mutation.ts b/apps/client/src/features/page/tree/hooks/use-tree-mutation.ts index 9a69100c..25a734a2 100644 --- a/apps/client/src/features/page/tree/hooks/use-tree-mutation.ts +++ b/apps/client/src/features/page/tree/hooks/use-tree-mutation.ts @@ -19,6 +19,7 @@ import { } from "@/features/page/queries/page-query.ts"; import { generateJitteredKeyBetween } from "fractional-indexing-jittered"; import { SpaceTreeNode } from "@/features/page/tree/types.ts"; +import { buildPageSlug } from "@/features/page/page.utils.ts"; export function useTreeMutation(spaceId: string) { const [data, setData] = useAtom(treeDataAtom); @@ -46,6 +47,7 @@ export function useTreeMutation(spaceId: string) { const data = { id: createdPage.id, + slugId: createdPage.slugId, name: "", position: createdPage.position, children: [], @@ -63,7 +65,7 @@ export function useTreeMutation(spaceId: string) { tree.create({ parentId, index, data }); setData(tree.data); - navigate(`/p/${createdPage.id}`); + navigate(buildPageSlug(createdPage.slugId, createdPage.title)); return data; }; diff --git a/apps/client/src/features/page/tree/types.ts b/apps/client/src/features/page/tree/types.ts index bd74400f..b48822c1 100644 --- a/apps/client/src/features/page/tree/types.ts +++ b/apps/client/src/features/page/tree/types.ts @@ -1,9 +1,9 @@ export type SpaceTreeNode = { id: string; + slugId: string; name: string; icon?: string; position: string; - slug?: string; spaceId: string; parentPageId: string; hasChildren: boolean; diff --git a/apps/client/src/features/page/tree/utils/utils.ts b/apps/client/src/features/page/tree/utils/utils.ts index b96951de..b00be65a 100644 --- a/apps/client/src/features/page/tree/utils/utils.ts +++ b/apps/client/src/features/page/tree/utils/utils.ts @@ -17,6 +17,7 @@ export function buildTree(pages: IPage[]): SpaceTreeNode[] { pages.forEach((page) => { pageMap[page.id] = { id: page.id, + slugId: page.slugId, name: page.title, icon: page.icon, position: page.position, diff --git a/apps/client/src/features/page/types/page.types.ts b/apps/client/src/features/page/types/page.types.ts index c4ec4bcb..bf69b208 100644 --- a/apps/client/src/features/page/types/page.types.ts +++ b/apps/client/src/features/page/types/page.types.ts @@ -1,28 +1,23 @@ export interface IPage { - pageId: string; id: string; + slugId: string; title: string; content: string; - html: string; - slug: string; icon: string; coverPhoto: string; - editor: string; - shareId: string; parentPageId: string; creatorId: string; spaceId: string; workspaceId: string; - children: []; - childrenIds: []; isLocked: boolean; - status: string; - publishedAt: Date; + isPublic: boolean; + lastModifiedById: Date; createdAt: Date; updatedAt: Date; deletedAt: Date; position: string; hasChildren: boolean; + pageId: string; } export interface IMovePage { diff --git a/apps/client/src/features/space/components/space-members.tsx b/apps/client/src/features/space/components/space-members.tsx index 09c241c0..7884b9f4 100644 --- a/apps/client/src/features/space/components/space-members.tsx +++ b/apps/client/src/features/space/components/space-members.tsx @@ -57,7 +57,6 @@ export default function SpaceMembersList({ spaceId }: SpaceMembersProps) { }; const onRemove = async (memberId: string, type: MemberType) => { - console.log("remove", spaceId); const memberToRemove: IRemoveSpaceMember = { spaceId: spaceId, }; diff --git a/apps/client/src/features/user/types/user.types.ts b/apps/client/src/features/user/types/user.types.ts index 15235ba5..0a92c471 100644 --- a/apps/client/src/features/user/types/user.types.ts +++ b/apps/client/src/features/user/types/user.types.ts @@ -8,14 +8,18 @@ export interface IUser { avatarUrl: string; timezone: string; settings: any; + invitedById: string; lastLoginAt: string; + lastActiveAt: Date; createdAt: Date; updatedAt: Date; role: string; workspaceId: string; + deactivatedAt: Date; + deletedAt: Date; } export interface ICurrentUser { - user: IUser, - workspace: IWorkspace + user: IUser; + workspace: IWorkspace; } diff --git a/apps/client/src/features/websocket/use-query-subscription.ts b/apps/client/src/features/websocket/use-query-subscription.ts index 228b4848..1ce9f36e 100644 --- a/apps/client/src/features/websocket/use-query-subscription.ts +++ b/apps/client/src/features/websocket/use-query-subscription.ts @@ -12,6 +12,9 @@ export const useQuerySubscription = () => { socket?.on("message", (event) => { const data: WebSocketEvent = event; + let entity = null; + let queryKeyId = null; + switch (data.operation) { case "invalidate": queryClient.invalidateQueries({ @@ -19,8 +22,16 @@ export const useQuerySubscription = () => { }); break; case "updateOne": - queryClient.setQueryData([...data.entity, data.id], { - ...queryClient.getQueryData([...data.entity, data.id]), + entity = data.entity[0]; + if (entity === "pages") { + // we have to do this because the usePageQuery cache key is the slugId. + queryKeyId = data.payload.slugId; + } else { + queryKeyId = data.id; + } + + queryClient.setQueryData([...data.entity, queryKeyId], { + ...queryClient.getQueryData([...data.entity, queryKeyId]), ...data.payload, }); diff --git a/apps/client/src/features/workspace/queries/workspace-query.ts b/apps/client/src/features/workspace/queries/workspace-query.ts index 36c81572..9a608eb4 100644 --- a/apps/client/src/features/workspace/queries/workspace-query.ts +++ b/apps/client/src/features/workspace/queries/workspace-query.ts @@ -8,11 +8,12 @@ import { changeMemberRole, getInvitationById, getPendingInvitations, - getWorkspace, getWorkspaceMembers, createInvitation, resendInvitation, revokeInvitation, + getWorkspace, + getWorkspacePublicData, } from "@/features/workspace/services/workspace-service"; import { IPagination, QueryParams } from "@/lib/types.ts"; import { notifications } from "@mantine/notifications"; @@ -22,13 +23,23 @@ import { IWorkspace, } from "@/features/workspace/types/workspace.types.ts"; -export function useWorkspace(): UseQueryResult { +export function useWorkspaceQuery(): UseQueryResult { return useQuery({ queryKey: ["workspace"], queryFn: () => getWorkspace(), }); } +export function useWorkspacePublicDataQuery(): UseQueryResult< + IWorkspace, + Error +> { + return useQuery({ + queryKey: ["workspace-public"], + queryFn: () => getWorkspacePublicData(), + }); +} + export function useWorkspaceMembersQuery(params?: QueryParams) { return useQuery({ queryKey: ["workspaceMembers", params], @@ -69,7 +80,7 @@ export function useCreateInvitationMutation() { return useMutation({ mutationFn: (data) => createInvitation(data), onSuccess: (data, variables) => { - notifications.show({ message: "Invitation successfully" }); + notifications.show({ message: "Invitation sent" }); // TODO: mutate cache queryClient.invalidateQueries({ queryKey: ["invitations"], @@ -92,7 +103,7 @@ export function useResendInvitationMutation() { >({ mutationFn: (data) => resendInvitation(data), onSuccess: (data, variables) => { - notifications.show({ message: "Invitation mail sent" }); + notifications.show({ message: "Invitation resent" }); }, onError: (error) => { const errorMessage = error["response"]?.data?.message; diff --git a/apps/client/src/features/workspace/services/workspace-service.ts b/apps/client/src/features/workspace/services/workspace-service.ts index 0d0be6e4..7fcb1d22 100644 --- a/apps/client/src/features/workspace/services/workspace-service.ts +++ b/apps/client/src/features/workspace/services/workspace-service.ts @@ -14,6 +14,11 @@ export async function getWorkspace(): Promise { return req.data; } +export async function getWorkspacePublicData(): Promise { + const req = await api.post("/workspace/public"); + return req.data; +} + // Todo: fix all paginated types export async function getWorkspaceMembers( params?: QueryParams, diff --git a/apps/client/src/lib/api-client.ts b/apps/client/src/lib/api-client.ts index fcb3e986..eb1bb485 100644 --- a/apps/client/src/lib/api-client.ts +++ b/apps/client/src/lib/api-client.ts @@ -38,15 +38,22 @@ api.interceptors.response.use( switch (error.response.status) { case 401: // Handle unauthorized error - if (window.location.pathname != Routes.AUTH.LOGIN) { - window.location.href = Routes.AUTH.LOGIN; - } + Cookies.remove("authTokens"); + redirectToLogin(); break; case 403: // Handle forbidden error break; case 404: // Handle not found error + if ( + error.response.data.message + .toLowerCase() + .includes("workspace not found") + ) { + Cookies.remove("authTokens"); + redirectToLogin(); + } break; case 500: // Handle internal server error @@ -59,4 +66,13 @@ api.interceptors.response.use( }, ); +function redirectToLogin() { + if ( + window.location.pathname != Routes.AUTH.LOGIN && + window.location.pathname != Routes.AUTH.SIGNUP + ) { + window.location.href = Routes.AUTH.LOGIN; + } +} + export default api; diff --git a/apps/client/src/main.tsx b/apps/client/src/main.tsx index 7ba111c5..902e0000 100644 --- a/apps/client/src/main.tsx +++ b/apps/client/src/main.tsx @@ -10,6 +10,7 @@ import { BrowserRouter } from "react-router-dom"; import { ModalsProvider } from "@mantine/modals"; import { Notifications } from "@mantine/notifications"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { HelmetProvider } from "react-helmet-async"; export const queryClient = new QueryClient({ defaultOptions: { @@ -30,8 +31,10 @@ root.render( - - + + + + diff --git a/apps/client/src/pages/auth/login.tsx b/apps/client/src/pages/auth/login.tsx index 96c6c116..c3f47bee 100644 --- a/apps/client/src/pages/auth/login.tsx +++ b/apps/client/src/pages/auth/login.tsx @@ -1,5 +1,13 @@ -import { LoginForm } from '@/features/auth/components/login-form'; +import { LoginForm } from "@/features/auth/components/login-form"; +import { Helmet } from "react-helmet-async"; export default function LoginPage() { - return ; + return ( + <> + + Login + + + + ); } diff --git a/apps/client/src/pages/auth/signup.tsx b/apps/client/src/pages/auth/signup.tsx index 1e078770..3604f028 100644 --- a/apps/client/src/pages/auth/signup.tsx +++ b/apps/client/src/pages/auth/signup.tsx @@ -1,5 +1,42 @@ -import { SignUpForm } from '@/features/auth/components/sign-up-form'; +import { SignUpForm } from "@/features/auth/components/sign-up-form"; +import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts"; +import { SetupWorkspaceForm } from "@/features/auth/components/setup-workspace-form.tsx"; +import { Helmet } from "react-helmet-async"; +import React from "react"; export default function SignUpPage() { - return ; + const { + data: workspace, + isLoading, + isError, + error, + } = useWorkspacePublicDataQuery(); + + if (isLoading) { + return
; + } + + if ( + isError && + error?.["response"]?.status === 404 && + error?.["response"]?.data.message.includes("Workspace not found") + ) { + return ( + <> + + Setup workspace + + + + ); + } + + return workspace ? ( + <> + + Signup + + + + ) : null; } diff --git a/apps/client/src/pages/page/page.tsx b/apps/client/src/pages/page/page.tsx index 02c50b4b..1e50074a 100644 --- a/apps/client/src/pages/page/page.tsx +++ b/apps/client/src/pages/page/page.tsx @@ -1,27 +1,31 @@ -import { useParams } from 'react-router-dom'; -import { usePageQuery } from '@/features/page/queries/page-query'; -import { FullEditor } from '@/features/editor/full-editor'; -import HistoryModal from '@/features/page-history/components/history-modal'; +import { useParams } from "react-router-dom"; +import { usePageQuery } from "@/features/page/queries/page-query"; +import { FullEditor } from "@/features/editor/full-editor"; +import HistoryModal from "@/features/page-history/components/history-modal"; +import { Helmet } from "react-helmet-async"; export default function Page() { - const { pageId } = useParams(); - const { data, isLoading, isError } = usePageQuery(pageId); + const { slugId } = useParams(); + const { data: page, isLoading, isError } = usePageQuery(slugId); if (isLoading) { return <>; } - if (isError || !data) { // TODO: fix this + if (isError || !page) { + // TODO: fix this return
Error fetching page data.
; } return ( - data && ( + page && (
- - + + {page.title} + + +
) - ); } diff --git a/apps/server/.gitignore b/apps/server/.gitignore index 0b1c58fe..95d6c295 100644 --- a/apps/server/.gitignore +++ b/apps/server/.gitignore @@ -1,3 +1,4 @@ +/storage .env package-lock.json # compiled output diff --git a/apps/server/package.json b/apps/server/package.json index b66f7096..a09f5f13 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "server", - "version": "0.0.1", + "version": "0.1.0", "description": "", "author": "", "private": true, @@ -13,13 +13,13 @@ "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", "email:dev": "email dev -p 5019 -d ./src/integrations/transactional/emails", - "migration:create": "tsx ./src/kysely/migrate.ts create", - "migration:up": "tsx ./src/kysely/migrate.ts up", - "migration:down": "tsx ./src/kysely/migrate.ts down", - "migration:latest": "tsx ./src/kysely/migrate.ts latest", - "migration:redo": "tsx ./src/kysely/migrate.ts redo", - "migration:reset": "tsx ./src/kysely/migrate.ts down-to NO_MIGRATIONS", - "migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/kysely/types/db.d.ts", + "migration:create": "tsx src/database/migrate.ts create", + "migration:up": "tsx src/database/migrate.ts up", + "migration:down": "tsx src/database/migrate.ts down", + "migration:latest": "tsx src/database/migrate.ts latest", + "migration:redo": "tsx src/database/migrate.ts redo", + "migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS", + "migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "test": "jest", "test:watch": "jest --watch", @@ -42,7 +42,6 @@ "@nestjs/passport": "^10.0.3", "@nestjs/platform-fastify": "^10.3.8", "@nestjs/platform-socket.io": "^10.3.8", - "@nestjs/serve-static": "^4.0.2", "@nestjs/websockets": "^10.3.8", "@react-email/components": "0.0.17", "@react-email/render": "^0.0.13", @@ -68,7 +67,6 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "sanitize-filename-ts": "^1.0.2", - "slugify": "^1.6.6", "socket.io": "^4.7.5", "tsx": "^4.8.2", "uuid": "^9.0.1", diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index cd11e8ee..61386986 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -5,26 +5,11 @@ import { CoreModule } from './core/core.module'; import { EnvironmentModule } from './integrations/environment/environment.module'; import { CollaborationModule } from './collaboration/collaboration.module'; import { WsModule } from './ws/ws.module'; -import { ServeStaticModule } from '@nestjs/serve-static'; -import { join } from 'path'; import { DatabaseModule } from '@docmost/db/database.module'; -import * as fs from 'fs'; import { StorageModule } from './integrations/storage/storage.module'; import { MailModule } from './integrations/mail/mail.module'; import { QueueModule } from './integrations/queue/queue.module'; - -const clientDistPath = join(__dirname, '..', '..', 'client/dist'); - -function getServeStaticModule() { - if (fs.existsSync(clientDistPath)) { - return [ - ServeStaticModule.forRoot({ - rootPath: clientDistPath, - }), - ]; - } - return []; -} +import { StaticModule } from './integrations/static/static.module'; @Module({ imports: [ @@ -34,7 +19,7 @@ function getServeStaticModule() { CollaborationModule, WsModule, QueueModule, - ...getServeStaticModule(), + StaticModule, StorageModule.forRootAsync({ imports: [EnvironmentModule], }), diff --git a/apps/server/src/collaboration/extensions/persistence.extension.ts b/apps/server/src/collaboration/extensions/persistence.extension.ts index 760a9485..01b30ab1 100644 --- a/apps/server/src/collaboration/extensions/persistence.extension.ts +++ b/apps/server/src/collaboration/extensions/persistence.extension.ts @@ -78,6 +78,7 @@ export class PersistenceExtension implements Extension { textContent: textContent, ydoc: ydocState, lastUpdatedById: context.user.id, + updatedAt: new Date(), }, pageId, ); diff --git a/apps/server/src/core/auth/services/signup.service.ts b/apps/server/src/core/auth/services/signup.service.ts index 8b3041b9..79211232 100644 --- a/apps/server/src/core/auth/services/signup.service.ts +++ b/apps/server/src/core/auth/services/signup.service.ts @@ -9,6 +9,7 @@ import { executeTx } from '@docmost/db/utils'; import { InjectKysely } from 'nestjs-kysely'; import { User } from '@docmost/db/types/entity.types'; import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo'; +import { UserRole } from '../../../helpers/types/permission'; @Injectable() export class SignupService { @@ -75,7 +76,11 @@ export class SignupService { this.db, async (trx) => { // create user - const user = await this.userRepo.insertUser(createAdminUserDto, trx); + + const user = await this.userRepo.insertUser( + { ...createAdminUserDto, role: UserRole.OWNER }, + trx, + ); // create workspace with full setup const workspaceData: CreateWorkspaceDto = { diff --git a/apps/server/src/core/casl/abilities/space-ability.factory.ts b/apps/server/src/core/casl/abilities/space-ability.factory.ts index d75c58d9..1c49add1 100644 --- a/apps/server/src/core/casl/abilities/space-ability.factory.ts +++ b/apps/server/src/core/casl/abilities/space-ability.factory.ts @@ -1,4 +1,8 @@ -import { ForbiddenException, Injectable } from '@nestjs/common'; +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { AbilityBuilder, createMongoAbility, @@ -33,9 +37,7 @@ export default class SpaceAbilityFactory { case SpaceRole.READER: return buildSpaceReaderAbility(); default: - throw new ForbiddenException( - 'You do not have permission to access this space', - ); + throw new NotFoundException('Space permissions not found'); } } } diff --git a/apps/server/src/core/comment/comment.controller.ts b/apps/server/src/core/comment/comment.controller.ts index 3d51b4fd..101aa0e0 100644 --- a/apps/server/src/core/comment/comment.controller.ts +++ b/apps/server/src/core/comment/comment.controller.ts @@ -52,7 +52,12 @@ export class CommentController { throw new ForbiddenException(); } - return this.commentService.create(user.id, workspace.id, createCommentDto); + return this.commentService.create( + user.id, + page.id, + workspace.id, + createCommentDto, + ); } @HttpCode(HttpStatus.OK) @@ -73,7 +78,7 @@ export class CommentController { if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { throw new ForbiddenException(); } - return this.commentService.findByPageId(input.pageId, pagination); + return this.commentService.findByPageId(page.id, pagination); } @HttpCode(HttpStatus.OK) @@ -84,7 +89,6 @@ export class CommentController { throw new NotFoundException('Comment not found'); } - // TODO: add spaceId to comment entity. const page = await this.pageRepo.findById(comment.pageId); if (!page) { throw new NotFoundException('Page not found'); @@ -104,6 +108,7 @@ export class CommentController { return this.commentService.update( updateCommentDto.commentId, updateCommentDto, + user, ); } @@ -111,6 +116,6 @@ export class CommentController { @Post('delete') remove(@Body() input: CommentIdDto, @AuthUser() user: User) { // TODO: only comment creators and admins can delete their comments - return this.commentService.remove(input.commentId); + return this.commentService.remove(input.commentId, user); } } diff --git a/apps/server/src/core/comment/comment.service.ts b/apps/server/src/core/comment/comment.service.ts index 738c869d..48a5dd99 100644 --- a/apps/server/src/core/comment/comment.service.ts +++ b/apps/server/src/core/comment/comment.service.ts @@ -1,12 +1,13 @@ import { BadRequestException, + ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { CreateCommentDto } from './dto/create-comment.dto'; import { UpdateCommentDto } from './dto/update-comment.dto'; import { CommentRepo } from '@docmost/db/repos/comment/comment.repo'; -import { Comment } from '@docmost/db/types/entity.types'; +import { Comment, User } from '@docmost/db/types/entity.types'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationResult } from '@docmost/db/pagination/pagination'; import { PageRepo } from '@docmost/db/repos/page/page.repo'; @@ -30,24 +31,18 @@ export class CommentService { async create( userId: string, + pageId: string, workspaceId: string, createCommentDto: CreateCommentDto, ) { const commentContent = JSON.parse(createCommentDto.content); - const page = await this.pageRepo.findById(createCommentDto.pageId); - // const spaceId = null; // todo, get from page - - if (!page) { - throw new BadRequestException('Page not found'); - } - if (createCommentDto.parentCommentId) { const parentComment = await this.commentRepo.findById( createCommentDto.parentCommentId, ); - if (!parentComment) { + if (!parentComment || parentComment.pageId !== pageId) { throw new BadRequestException('Parent comment not found'); } @@ -57,10 +52,10 @@ export class CommentService { } const createdComment = await this.commentRepo.insertComment({ - pageId: createCommentDto.pageId, + pageId: pageId, content: commentContent, selection: createCommentDto?.selection?.substring(0, 250), - type: 'inline', // for now + type: 'inline', parentCommentId: createCommentDto?.parentCommentId, creatorId: userId, workspaceId: workspaceId, @@ -90,6 +85,7 @@ export class CommentService { async update( commentId: string, updateCommentDto: UpdateCommentDto, + authUser: User, ): Promise { const commentContent = JSON.parse(updateCommentDto.content); @@ -98,6 +94,10 @@ export class CommentService { throw new NotFoundException('Comment not found'); } + if (comment.creatorId !== authUser.id) { + throw new ForbiddenException('You can only edit your own comments'); + } + const editedAt = new Date(); await this.commentRepo.updateComment( @@ -113,12 +113,17 @@ export class CommentService { return comment; } - async remove(commentId: string): Promise { + async remove(commentId: string, authUser: User): Promise { const comment = await this.commentRepo.findById(commentId); if (!comment) { throw new NotFoundException('Comment not found'); } + + if (comment.creatorId !== authUser.id) { + throw new ForbiddenException('You can only delete your own comments'); + } + await this.commentRepo.deleteComment(commentId); } } diff --git a/apps/server/src/core/comment/dto/comments.input.ts b/apps/server/src/core/comment/dto/comments.input.ts index 4fb21e56..f3a106f8 100644 --- a/apps/server/src/core/comment/dto/comments.input.ts +++ b/apps/server/src/core/comment/dto/comments.input.ts @@ -1,7 +1,7 @@ -import { IsUUID } from 'class-validator'; +import { IsString, IsUUID } from 'class-validator'; export class PageIdDto { - @IsUUID() + @IsString() pageId: string; } diff --git a/apps/server/src/core/comment/dto/create-comment.dto.ts b/apps/server/src/core/comment/dto/create-comment.dto.ts index 87b680f1..26bdbf26 100644 --- a/apps/server/src/core/comment/dto/create-comment.dto.ts +++ b/apps/server/src/core/comment/dto/create-comment.dto.ts @@ -1,7 +1,7 @@ import { IsJSON, IsOptional, IsString, IsUUID } from 'class-validator'; export class CreateCommentDto { - @IsUUID() + @IsString() pageId: string; @IsJSON() diff --git a/apps/server/src/core/page/dto/create-page.dto.ts b/apps/server/src/core/page/dto/create-page.dto.ts index 128fec16..397e4a42 100644 --- a/apps/server/src/core/page/dto/create-page.dto.ts +++ b/apps/server/src/core/page/dto/create-page.dto.ts @@ -10,7 +10,7 @@ export class CreatePageDto { icon?: string; @IsOptional() - @IsUUID() + @IsString() parentPageId?: string; @IsUUID() diff --git a/apps/server/src/core/page/dto/move-page.dto.ts b/apps/server/src/core/page/dto/move-page.dto.ts index 72d25da2..83ff080c 100644 --- a/apps/server/src/core/page/dto/move-page.dto.ts +++ b/apps/server/src/core/page/dto/move-page.dto.ts @@ -1,13 +1,7 @@ -import { - IsString, - IsUUID, - IsOptional, - MinLength, - MaxLength, -} from 'class-validator'; +import { IsString, IsOptional, MinLength, MaxLength } from 'class-validator'; export class MovePageDto { - @IsUUID() + @IsString() pageId: string; @IsString() diff --git a/apps/server/src/core/page/dto/page-with-ordering.dto.ts b/apps/server/src/core/page/dto/page-with-ordering.dto.ts deleted file mode 100644 index f88e04d8..00000000 --- a/apps/server/src/core/page/dto/page-with-ordering.dto.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { Page } from '@docmost/db/types/entity.types'; - -export type PageWithOrderingDto = Page & { childrenIds?: string[] }; diff --git a/apps/server/src/core/page/dto/page.dto.ts b/apps/server/src/core/page/dto/page.dto.ts index be28c272..089771ec 100644 --- a/apps/server/src/core/page/dto/page.dto.ts +++ b/apps/server/src/core/page/dto/page.dto.ts @@ -1,7 +1,7 @@ -import { IsUUID } from 'class-validator'; +import { IsString, IsUUID } from 'class-validator'; export class PageIdDto { - @IsUUID() + @IsString() pageId: 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 0606d855..4ea2bb20 100644 --- a/apps/server/src/core/page/dto/sidebar-page.dto.ts +++ b/apps/server/src/core/page/dto/sidebar-page.dto.ts @@ -1,8 +1,8 @@ -import { IsOptional, IsUUID } from 'class-validator'; +import { IsOptional, IsString } from 'class-validator'; import { SpaceIdDto } from './page.dto'; export class SidebarPageDto extends SpaceIdDto { @IsOptional() - @IsUUID() + @IsString() pageId: string; } diff --git a/apps/server/src/core/page/dto/update-page.dto.ts b/apps/server/src/core/page/dto/update-page.dto.ts index e0250312..7bd2e2a6 100644 --- a/apps/server/src/core/page/dto/update-page.dto.ts +++ b/apps/server/src/core/page/dto/update-page.dto.ts @@ -1,8 +1,8 @@ import { PartialType } from '@nestjs/mapped-types'; import { CreatePageDto } from './create-page.dto'; -import { IsUUID } from 'class-validator'; +import { IsString } from 'class-validator'; export class UpdatePageDto extends PartialType(CreatePageDto) { - @IsUUID() + @IsString() pageId: string; } diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts index 80cbf775..88b8ac96 100644 --- a/apps/server/src/core/page/page.controller.ts +++ b/apps/server/src/core/page/page.controller.ts @@ -12,7 +12,7 @@ import { PageService } from './services/page.service'; import { CreatePageDto } from './dto/create-page.dto'; import { UpdatePageDto } from './dto/update-page.dto'; import { MovePageDto } from './dto/move-page.dto'; -import { PageHistoryIdDto, PageIdDto, SpaceIdDto } from './dto/page.dto'; +import { PageHistoryIdDto, PageIdDto } from './dto/page.dto'; import { PageHistoryService } from './services/page-history.service'; import { AuthUser } from '../../decorators/auth-user.decorator'; import { AuthWorkspace } from '../../decorators/auth-workspace.decorator'; @@ -118,18 +118,23 @@ export class PageController { @HttpCode(HttpStatus.OK) @Post('recent') async getRecentSpacePages( - @Body() spaceIdDto: SpaceIdDto, @Body() pagination: PaginationOptions, @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, ) { const ability = await this.spaceAbility.createForUser( user, - spaceIdDto.spaceId, + workspace.defaultSpaceId, ); + if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { throw new ForbiddenException(); } - return this.pageService.getRecentSpacePages(spaceIdDto.spaceId, pagination); + + return this.pageService.getRecentSpacePages( + workspace.defaultSpaceId, + pagination, + ); } // TODO: scope to workspaces @@ -146,7 +151,7 @@ export class PageController { throw new ForbiddenException(); } - return this.pageHistoryService.findHistoryByPageId(dto.pageId, pagination); + return this.pageHistoryService.findHistoryByPageId(page.id, pagination); } @HttpCode(HttpStatus.OK) @@ -181,7 +186,17 @@ export class PageController { if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { throw new ForbiddenException(); } - return this.pageService.getSidebarPages(dto, pagination); + + let pageId = null; + if (dto.pageId) { + const page = await this.pageRepo.findById(dto.pageId); + if (page.spaceId !== dto.spaceId) { + throw new ForbiddenException(); + } + pageId = page.id; + } + + return this.pageService.getSidebarPages(dto.spaceId, pagination, pageId); } @HttpCode(HttpStatus.OK) @@ -207,10 +222,14 @@ export class PageController { @Post('/breadcrumbs') async getPageBreadcrumbs(@Body() dto: PageIdDto, @AuthUser() user: User) { const page = await this.pageRepo.findById(dto.pageId); + if (!page) { + throw new NotFoundException('Page not found'); + } + const ability = await this.spaceAbility.createForUser(user, page.spaceId); if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { throw new ForbiddenException(); } - return this.pageService.getPageBreadCrumbs(dto.pageId); + return this.pageService.getPageBreadCrumbs(page.id); } } diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index a0d5c88c..0cf7476e 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -18,7 +18,7 @@ import { generateJitteredKeyBetween } from 'fractional-indexing-jittered'; import { MovePageDto } from '../dto/move-page.dto'; import { ExpressionBuilder } from 'kysely'; import { DB } from '@docmost/db/types/db'; -import { SidebarPageDto } from '../dto/sidebar-page.dto'; +import { genPageShortId } from '../../../helpers/nanoid.utils'; @Injectable() export class PageService { @@ -40,14 +40,19 @@ export class PageService { workspaceId: string, createPageDto: CreatePageDto, ): Promise { + let parentPageId = undefined; + // check if parent page exists if (createPageDto.parentPageId) { const parentPage = await this.pageRepo.findById( createPageDto.parentPageId, ); - if (!parentPage || parentPage.spaceId !== createPageDto.spaceId) + if (!parentPage || parentPage.spaceId !== createPageDto.spaceId) { throw new NotFoundException('Parent page not found'); + } + + parentPageId = parentPage.id; } let pagePosition: string; @@ -59,10 +64,10 @@ export class PageService { .orderBy('position', 'desc') .limit(1); - if (createPageDto.parentPageId) { + if (parentPageId) { // check for children of this page const lastPage = await lastPageQuery - .where('parentPageId', '=', createPageDto.parentPageId) + .where('parentPageId', '=', parentPageId) .executeTakeFirst(); if (!lastPage) { @@ -87,10 +92,11 @@ export class PageService { } const createdPage = await this.pageRepo.insertPage({ + slugId: genPageShortId(), title: createPageDto.title, position: pagePosition, icon: createPageDto.icon, - parentPageId: createPageDto.parentPageId, + parentPageId: parentPageId, spaceId: createPageDto.spaceId, creatorId: userId, workspaceId: workspaceId, @@ -110,6 +116,7 @@ export class PageService { title: updatePageDto.title, icon: updatePageDto.icon, lastUpdatedById: userId, + updatedAt: new Date(), }, pageId, ); @@ -135,13 +142,15 @@ export class PageService { } async getSidebarPages( - dto: SidebarPageDto, + spaceId: string, pagination: PaginationOptions, + pageId?: string, ): Promise { let query = this.db .selectFrom('pages') .select([ 'id', + 'slugId', 'title', 'icon', 'position', @@ -151,10 +160,10 @@ export class PageService { ]) .select((eb) => this.withHasChildren(eb)) .orderBy('position', 'asc') - .where('spaceId', '=', dto.spaceId); + .where('spaceId', '=', spaceId); - if (dto.pageId) { - query = query.where('parentPageId', '=', dto.pageId); + if (pageId) { + query = query.where('parentPageId', '=', pageId); } else { query = query.where('parentPageId', 'is', null); } @@ -185,8 +194,8 @@ export class PageService { if (!parentPage || parentPage.spaceId !== movedPage.spaceId) { throw new NotFoundException('Parent page not found'); } + parentPageId = parentPage.id; } - parentPageId = dto.parentPageId; } await this.pageRepo.updatePage( @@ -205,6 +214,7 @@ export class PageService { .selectFrom('pages') .select([ 'id', + 'slugId', 'title', 'icon', 'position', @@ -218,6 +228,7 @@ export class PageService { .selectFrom('pages as p') .select([ 'p.id', + 'p.slugId', 'p.title', 'p.icon', 'p.position', @@ -255,10 +266,7 @@ export class PageService { spaceId: string, pagination: PaginationOptions, ): Promise> { - const pages = await this.pageRepo.getRecentPagesInSpace( - spaceId, - pagination, - ); + const pages = await this.pageRepo.getRecentPageUpdates(spaceId, pagination); return pages; } @@ -267,6 +275,7 @@ export class PageService { await this.pageRepo.deletePage(pageId); } } + /* // TODO: page deletion and restoration async delete(pageId: string): Promise { diff --git a/apps/server/src/core/space/services/space.service.ts b/apps/server/src/core/space/services/space.service.ts index 9f3ec9db..45f9e3e1 100644 --- a/apps/server/src/core/space/services/space.service.ts +++ b/apps/server/src/core/space/services/space.service.ts @@ -5,12 +5,13 @@ import { } from '@nestjs/common'; import { CreateSpaceDto } from '../dto/create-space.dto'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; -import slugify from 'slugify'; import { SpaceRepo } from '@docmost/db/repos/space/space.repo'; import { KyselyTransaction } from '@docmost/db/types/kysely.types'; import { Space } from '@docmost/db/types/entity.types'; import { PaginationResult } from '@docmost/db/pagination/pagination'; import { UpdateSpaceDto } from '../dto/update-space.dto'; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { slugify } = require('fix-esm').require('@sindresorhus/slugify'); @Injectable() export class SpaceService { diff --git a/apps/server/src/core/workspace/controllers/workspace.controller.ts b/apps/server/src/core/workspace/controllers/workspace.controller.ts index 9ac6cbf8..61aa71d3 100644 --- a/apps/server/src/core/workspace/controllers/workspace.controller.ts +++ b/apps/server/src/core/workspace/controllers/workspace.controller.ts @@ -36,12 +36,16 @@ export class WorkspaceController { private readonly workspaceInvitationService: WorkspaceInvitationService, ) {} + @Public() + @HttpCode(HttpStatus.OK) + @Post('/public') + async getWorkspacePublicInfo(@Req() req) { + return this.workspaceService.getWorkspacePublicData(req.raw.workspaceId); + } + @HttpCode(HttpStatus.OK) @Post('/info') - async getWorkspace( - @AuthUser() user: User, - @AuthWorkspace() workspace: Workspace, - ) { + async getWorkspace(@AuthWorkspace() workspace: Workspace) { return this.workspaceService.getWorkspaceInfo(workspace.id); } diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts index bf3fc1c1..56c8f288 100644 --- a/apps/server/src/core/workspace/services/workspace.service.ts +++ b/apps/server/src/core/workspace/services/workspace.service.ts @@ -46,6 +46,19 @@ export class WorkspaceService { return workspace; } + async getWorkspacePublicData(workspaceId: string) { + const workspace = await this.db + .selectFrom('workspaces') + .select(['id']) + .where('id', '=', workspaceId) + .executeTakeFirst(); + if (!workspace) { + throw new NotFoundException('Workspace not found'); + } + + return workspace; + } + async create( user: User, createWorkspaceDto: CreateWorkspaceDto, diff --git a/apps/server/src/kysely/database.module.ts b/apps/server/src/database/database.module.ts similarity index 100% rename from apps/server/src/kysely/database.module.ts rename to apps/server/src/database/database.module.ts diff --git a/apps/server/src/kysely/migrate.ts b/apps/server/src/database/migrate.ts similarity index 100% rename from apps/server/src/kysely/migrate.ts rename to apps/server/src/database/migrate.ts diff --git a/apps/server/src/kysely/migrations/20240324T085500-workspaces.ts b/apps/server/src/database/migrations/20240324T085500-workspaces.ts similarity index 79% rename from apps/server/src/kysely/migrations/20240324T085500-workspaces.ts rename to apps/server/src/database/migrations/20240324T085500-workspaces.ts index 2fec91d1..c6422139 100644 --- a/apps/server/src/kysely/migrations/20240324T085500-workspaces.ts +++ b/apps/server/src/database/migrations/20240324T085500-workspaces.ts @@ -8,20 +8,17 @@ export async function up(db: Kysely): Promise { col.primaryKey().defaultTo(sql`gen_random_uuid()`), ) .addColumn('name', 'varchar', (col) => col) - .addColumn('description', 'text', (col) => col) + .addColumn('description', 'varchar', (col) => col) .addColumn('logo', 'varchar', (col) => col) .addColumn('hostname', 'varchar', (col) => col) .addColumn('custom_domain', 'varchar', (col) => col) - .addColumn('enable_invite', 'boolean', (col) => - col.defaultTo(true).notNull(), - ) - .addColumn('invite_code', 'varchar', (col) => - col.defaultTo(sql`gen_random_uuid()`), - ) .addColumn('settings', 'jsonb', (col) => col) .addColumn('default_role', 'varchar', (col) => col.defaultTo(UserRole.MEMBER).notNull(), ) + .addColumn('allowed_email_domains', sql`varchar[]`, (col) => + col.defaultTo('{}'), + ) .addColumn('default_space_id', 'uuid', (col) => col) .addColumn('created_at', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`), @@ -31,7 +28,7 @@ export async function up(db: Kysely): Promise { ) .addColumn('deleted_at', 'timestamptz', (col) => col) .addUniqueConstraint('workspaces_hostname_unique', ['hostname']) - .addUniqueConstraint('workspaces_invite_code_unique', ['invite_code']) + .addUniqueConstraint('workspaces_custom_domain_unique', ['custom_domain']) .execute(); } diff --git a/apps/server/src/kysely/migrations/20240324T085600-users.ts b/apps/server/src/database/migrations/20240324T085600-users.ts similarity index 79% rename from apps/server/src/kysely/migrations/20240324T085600-users.ts rename to apps/server/src/database/migrations/20240324T085600-users.ts index fe23a0fb..9ccfe6f4 100644 --- a/apps/server/src/kysely/migrations/20240324T085600-users.ts +++ b/apps/server/src/database/migrations/20240324T085600-users.ts @@ -9,10 +9,12 @@ export async function up(db: Kysely): Promise { .addColumn('name', 'varchar', (col) => col) .addColumn('email', 'varchar', (col) => col.notNull()) .addColumn('email_verified_at', 'timestamptz', (col) => col) - .addColumn('password', 'varchar', (col) => col.notNull()) + .addColumn('password', 'varchar', (col) => col) .addColumn('avatar_url', 'varchar', (col) => col) - .addColumn('role', 'varchar', (col) => col) - .addColumn('status', 'varchar', (col) => col) + .addColumn('role', 'varchar', (col) => col.notNull()) + .addColumn('invited_by_id', 'uuid', (col) => + col.references('users.id').onDelete('set null'), + ) .addColumn('workspace_id', 'uuid', (col) => col.references('workspaces.id').onDelete('cascade'), ) @@ -21,12 +23,14 @@ export async function up(db: Kysely): Promise { .addColumn('settings', 'jsonb', (col) => col) .addColumn('last_active_at', 'timestamptz', (col) => col) .addColumn('last_login_at', 'timestamptz', (col) => col) + .addColumn('deactivated_at', 'timestamptz', (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', (col) => col) .addUniqueConstraint('users_email_workspace_id_unique', [ 'email', 'workspace_id', diff --git a/apps/server/src/kysely/migrations/20240324T085700-groups.ts b/apps/server/src/database/migrations/20240324T085700-groups.ts similarity index 100% rename from apps/server/src/kysely/migrations/20240324T085700-groups.ts rename to apps/server/src/database/migrations/20240324T085700-groups.ts diff --git a/apps/server/src/kysely/migrations/20240324T085900-spaces.ts b/apps/server/src/database/migrations/20240324T085900-spaces.ts similarity index 97% rename from apps/server/src/kysely/migrations/20240324T085900-spaces.ts rename to apps/server/src/database/migrations/20240324T085900-spaces.ts index 88402359..1f7d2a7e 100644 --- a/apps/server/src/kysely/migrations/20240324T085900-spaces.ts +++ b/apps/server/src/database/migrations/20240324T085900-spaces.ts @@ -49,7 +49,7 @@ export async function up(db: Kysely): Promise { col.references('spaces.id').onDelete('cascade').notNull(), ) .addColumn('role', 'varchar', (col) => col.notNull()) - .addColumn('addedById', 'uuid', (col) => col.references('users.id')) + .addColumn('added_by_id', 'uuid', (col) => col.references('users.id')) .addColumn('created_at', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`), ) diff --git a/apps/server/src/kysely/migrations/20240324T086100-add-workspace-fk.ts b/apps/server/src/database/migrations/20240324T086100-add-workspace-fk.ts similarity index 100% rename from apps/server/src/kysely/migrations/20240324T086100-add-workspace-fk.ts rename to apps/server/src/database/migrations/20240324T086100-add-workspace-fk.ts diff --git a/apps/server/src/kysely/migrations/20240324T086200-workspace_invitations.ts b/apps/server/src/database/migrations/20240324T086200-workspace_invitations.ts similarity index 75% rename from apps/server/src/kysely/migrations/20240324T086200-workspace_invitations.ts rename to apps/server/src/database/migrations/20240324T086200-workspace_invitations.ts index ae27efdf..d2186364 100644 --- a/apps/server/src/kysely/migrations/20240324T086200-workspace_invitations.ts +++ b/apps/server/src/database/migrations/20240324T086200-workspace_invitations.ts @@ -6,19 +6,24 @@ export async function up(db: Kysely): Promise { .addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`gen_random_uuid()`), ) + .addColumn('email', 'varchar', (col) => col) + .addColumn('role', 'varchar', (col) => col.notNull()) + .addColumn('token', 'varchar', (col) => col.notNull()) + .addColumn('group_ids', sql`uuid[]`, (col) => col) + .addColumn('invited_by_id', 'uuid', (col) => col.references('users.id')) .addColumn('workspace_id', 'uuid', (col) => col.references('workspaces.id').onDelete('cascade').notNull(), ) - .addColumn('invited_by_id', 'uuid', (col) => col.references('users.id')) - .addColumn('email', 'varchar', (col) => col.notNull()) - .addColumn('role', 'varchar', (col) => col.notNull()) - .addColumn('status', 'varchar', (col) => col) .addColumn('created_at', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`), ) .addColumn('updated_at', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`), ) + .addUniqueConstraint('invitations_email_workspace_id_unique', [ + 'email', + 'workspace_id', + ]) .execute(); } diff --git a/apps/server/src/kysely/migrations/20240324T086300-pages.ts b/apps/server/src/database/migrations/20240324T086300-pages.ts similarity index 66% rename from apps/server/src/kysely/migrations/20240324T086300-pages.ts rename to apps/server/src/database/migrations/20240324T086300-pages.ts index 061a3cfc..ba41c224 100644 --- a/apps/server/src/kysely/migrations/20240324T086300-pages.ts +++ b/apps/server/src/database/migrations/20240324T086300-pages.ts @@ -6,17 +6,15 @@ export async function up(db: Kysely): Promise { .addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`gen_random_uuid()`), ) + .addColumn('slug_id', 'varchar', (col) => col.notNull()) .addColumn('title', 'varchar', (col) => col) .addColumn('icon', 'varchar', (col) => col) - .addColumn('key', 'varchar', (col) => col) + .addColumn('cover_photo', 'varchar', (col) => col) + .addColumn('position', 'varchar', (col) => col) .addColumn('content', 'jsonb', (col) => col) - .addColumn('html', 'text', (col) => col) + .addColumn('ydoc', 'bytea', (col) => col) .addColumn('text_content', 'text', (col) => col) .addColumn('tsv', sql`tsvector`, (col) => col) - .addColumn('ydoc', 'bytea', (col) => col) - .addColumn('slug', 'varchar', (col) => col) - .addColumn('cover_photo', 'varchar', (col) => col) - .addColumn('editor', 'varchar', (col) => col) .addColumn('parent_page_id', 'uuid', (col) => col.references('pages.id').onDelete('cascade'), ) @@ -32,8 +30,6 @@ export async function up(db: Kysely): Promise { col.references('workspaces.id').onDelete('cascade').notNull(), ) .addColumn('is_locked', 'boolean', (col) => col.defaultTo(false).notNull()) - .addColumn('status', 'varchar', (col) => col) - .addColumn('published_at', 'date', (col) => col) .addColumn('created_at', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`), ) @@ -41,6 +37,7 @@ export async function up(db: Kysely): Promise { col.notNull().defaultTo(sql`now()`), ) .addColumn('deleted_at', 'timestamptz', (col) => col) + .addUniqueConstraint('pages_slug_id_unique', ['slug_id']) .execute(); await db.schema @@ -49,38 +46,14 @@ export async function up(db: Kysely): Promise { .using('GIN') .column('tsv') .execute(); -} - -export async function down(db: Kysely): Promise { - await db.schema - .alterTable('pages') - .dropConstraint('pages_creator_id_fkey') - .execute(); - - await db.schema - .alterTable('pages') - .dropConstraint('pages_last_updated_by_id_fkey') - .execute(); await db.schema - .alterTable('pages') - .dropConstraint('pages_deleted_by_id_fkey') - .execute(); - - await db.schema - .alterTable('pages') - .dropConstraint('pages_space_id_fkey') - .execute(); - - await db.schema - .alterTable('pages') - .dropConstraint('pages_workspace_id_fkey') - .execute(); - - await db.schema - .alterTable('pages') - .dropConstraint('pages_parent_page_id_fkey') + .createIndex('pages_slug_id_idx') + .on('pages') + .column('slug_id') .execute(); +} +export async function down(db: Kysely): Promise { await db.schema.dropTable('pages').execute(); } diff --git a/apps/server/src/kysely/migrations/20240324T086400-page_history.ts b/apps/server/src/database/migrations/20240324T086400-page_history.ts similarity index 92% rename from apps/server/src/kysely/migrations/20240324T086400-page_history.ts rename to apps/server/src/database/migrations/20240324T086400-page_history.ts index 071b1a1b..f385645e 100644 --- a/apps/server/src/kysely/migrations/20240324T086400-page_history.ts +++ b/apps/server/src/database/migrations/20240324T086400-page_history.ts @@ -9,12 +9,13 @@ export async function up(db: Kysely): Promise { .addColumn('page_id', 'uuid', (col) => col.references('pages.id').onDelete('cascade').notNull(), ) + .addColumn('slug_id', 'varchar', (col) => col) .addColumn('title', 'varchar', (col) => col) .addColumn('content', 'jsonb', (col) => col) .addColumn('slug', 'varchar', (col) => col) .addColumn('icon', 'varchar', (col) => col) .addColumn('cover_photo', 'varchar', (col) => col) - .addColumn('version', 'int4', (col) => col.notNull()) + .addColumn('version', 'int4', (col) => col) .addColumn('last_updated_by_id', 'uuid', (col) => col.references('users.id'), ) diff --git a/apps/server/src/kysely/migrations/20240324T086600-comments.ts b/apps/server/src/database/migrations/20240324T086600-comments.ts similarity index 100% rename from apps/server/src/kysely/migrations/20240324T086600-comments.ts rename to apps/server/src/database/migrations/20240324T086600-comments.ts diff --git a/apps/server/src/kysely/migrations/20240324T086700-attachments.ts b/apps/server/src/database/migrations/20240324T086700-attachments.ts similarity index 100% rename from apps/server/src/kysely/migrations/20240324T086700-attachments.ts rename to apps/server/src/database/migrations/20240324T086700-attachments.ts diff --git a/apps/server/src/kysely/migrations/20240324T086800-pages-tsvector-trigger.ts b/apps/server/src/database/migrations/20240324T086800-pages-tsvector-trigger.ts similarity index 100% rename from apps/server/src/kysely/migrations/20240324T086800-pages-tsvector-trigger.ts rename to apps/server/src/database/migrations/20240324T086800-pages-tsvector-trigger.ts diff --git a/apps/server/src/kysely/pagination/pagination-options.ts b/apps/server/src/database/pagination/pagination-options.ts similarity index 100% rename from apps/server/src/kysely/pagination/pagination-options.ts rename to apps/server/src/database/pagination/pagination-options.ts diff --git a/apps/server/src/kysely/pagination/pagination.ts b/apps/server/src/database/pagination/pagination.ts similarity index 100% rename from apps/server/src/kysely/pagination/pagination.ts rename to apps/server/src/database/pagination/pagination.ts diff --git a/apps/server/src/kysely/repos/attachment/attachment.repo.ts b/apps/server/src/database/repos/attachment/attachment.repo.ts similarity index 100% rename from apps/server/src/kysely/repos/attachment/attachment.repo.ts rename to apps/server/src/database/repos/attachment/attachment.repo.ts diff --git a/apps/server/src/kysely/repos/comment/comment.repo.ts b/apps/server/src/database/repos/comment/comment.repo.ts similarity index 100% rename from apps/server/src/kysely/repos/comment/comment.repo.ts rename to apps/server/src/database/repos/comment/comment.repo.ts diff --git a/apps/server/src/kysely/repos/group/group-user.repo.ts b/apps/server/src/database/repos/group/group-user.repo.ts similarity index 100% rename from apps/server/src/kysely/repos/group/group-user.repo.ts rename to apps/server/src/database/repos/group/group-user.repo.ts diff --git a/apps/server/src/kysely/repos/group/group.repo.ts b/apps/server/src/database/repos/group/group.repo.ts similarity index 100% rename from apps/server/src/kysely/repos/group/group.repo.ts rename to apps/server/src/database/repos/group/group.repo.ts diff --git a/apps/server/src/kysely/repos/page/page-history.repo.ts b/apps/server/src/database/repos/page/page-history.repo.ts similarity index 85% rename from apps/server/src/kysely/repos/page/page-history.repo.ts rename to apps/server/src/database/repos/page/page-history.repo.ts index d08edf54..028662f4 100644 --- a/apps/server/src/kysely/repos/page/page-history.repo.ts +++ b/apps/server/src/database/repos/page/page-history.repo.ts @@ -6,7 +6,6 @@ import { InsertablePageHistory, Page, PageHistory, - UpdatablePageHistory, } from '@docmost/db/types/entity.types'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { executeWithPagination } from '@docmost/db/pagination/pagination'; @@ -27,19 +26,6 @@ export class PageHistoryRepo { .executeTakeFirst(); } - async updatePageHistory( - updatablePageHistory: UpdatablePageHistory, - pageHistoryId: string, - trx?: KyselyTransaction, - ) { - const db = dbOrTx(this.db, trx); - return db - .updateTable('pageHistory') - .set(updatablePageHistory) - .where('id', '=', pageHistoryId) - .execute(); - } - async insertPageHistory( insertablePageHistory: InsertablePageHistory, trx?: KyselyTransaction, @@ -55,11 +41,10 @@ export class PageHistoryRepo { async saveHistory(page: Page): Promise { await this.insertPageHistory({ pageId: page.id, + slugId: page.slugId, title: page.title, content: page.content, - slug: page.slug, icon: page.icon, - version: 1, // TODO: make incremental coverPhoto: page.coverPhoto, lastUpdatedById: page.lastUpdatedById ?? page.creatorId, spaceId: page.spaceId, diff --git a/apps/server/src/kysely/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts similarity index 65% rename from apps/server/src/kysely/repos/page/page.repo.ts rename to apps/server/src/database/repos/page/page.repo.ts index 944f8251..daf12930 100644 --- a/apps/server/src/kysely/repos/page/page.repo.ts +++ b/apps/server/src/database/repos/page/page.repo.ts @@ -7,22 +7,20 @@ import { Page, UpdatablePage, } from '@docmost/db/types/entity.types'; -import { sql } from 'kysely'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { executeWithPagination } from '@docmost/db/pagination/pagination'; +import { validate as isValidUUID } from 'uuid'; -// TODO: scope to space/workspace @Injectable() export class PageRepo { constructor(@InjectKysely() private readonly db: KyselyDB) {} private baseFields: Array = [ 'id', + 'slugId', 'title', - 'slug', 'icon', 'coverPhoto', - 'key', 'position', 'parentPageId', 'creatorId', @@ -30,8 +28,6 @@ export class PageRepo { 'spaceId', 'workspaceId', 'isLocked', - 'status', - 'publishedAt', 'createdAt', 'updatedAt', 'deletedAt', @@ -44,21 +40,19 @@ export class PageRepo { includeYdoc?: boolean; }, ): Promise { - return await this.db + let query = this.db .selectFrom('pages') .select(this.baseFields) - .where('id', '=', pageId) .$if(opts?.includeContent, (qb) => qb.select('content')) - .$if(opts?.includeYdoc, (qb) => qb.select('ydoc')) - .executeTakeFirst(); - } + .$if(opts?.includeYdoc, (qb) => qb.select('ydoc')); - async slug(slug: string): Promise { - return await this.db - .selectFrom('pages') - .selectAll() - .where(sql`LOWER(slug)`, '=', sql`LOWER(${slug})`) - .executeTakeFirst(); + if (isValidUUID(pageId)) { + query = query.where('id', '=', pageId); + } else { + query = query.where('slugId', '=', pageId); + } + + return query.executeTakeFirst(); } async updatePage( @@ -67,11 +61,15 @@ export class PageRepo { trx?: KyselyTransaction, ) { const db = dbOrTx(this.db, trx); - return db - .updateTable('pages') - .set(updatablePage) - .where('id', '=', pageId) - .executeTakeFirst(); + let query = db.updateTable('pages').set(updatablePage); + + if (isValidUUID(pageId)) { + query = query.where('id', '=', pageId); + } else { + query = query.where('slugId', '=', pageId); + } + + return query.executeTakeFirst(); } async insertPage( @@ -87,10 +85,20 @@ export class PageRepo { } async deletePage(pageId: string): Promise { - await this.db.deleteFrom('pages').where('id', '=', pageId).execute(); + let query = this.db.deleteFrom('pages'); + + if (isValidUUID(pageId)) { + query = query.where('id', '=', pageId); + } else { + query = query.where('slugId', '=', pageId); + } + + await query.execute(); } - async getRecentPagesInSpace(spaceId: string, pagination: PaginationOptions) { + async getRecentPageUpdates(spaceId: string, pagination: PaginationOptions) { + //TODO: should fetch pages from all spaces the user is member of + // for now, fetch from default space const query = this.db .selectFrom('pages') .select(this.baseFields) diff --git a/apps/server/src/kysely/repos/space/space-member.repo.ts b/apps/server/src/database/repos/space/space-member.repo.ts similarity index 100% rename from apps/server/src/kysely/repos/space/space-member.repo.ts rename to apps/server/src/database/repos/space/space-member.repo.ts diff --git a/apps/server/src/kysely/repos/space/space.repo.ts b/apps/server/src/database/repos/space/space.repo.ts similarity index 100% rename from apps/server/src/kysely/repos/space/space.repo.ts rename to apps/server/src/database/repos/space/space.repo.ts diff --git a/apps/server/src/kysely/repos/space/types.ts b/apps/server/src/database/repos/space/types.ts similarity index 100% rename from apps/server/src/kysely/repos/space/types.ts rename to apps/server/src/database/repos/space/types.ts diff --git a/apps/server/src/kysely/repos/space/utils.ts b/apps/server/src/database/repos/space/utils.ts similarity index 100% rename from apps/server/src/kysely/repos/space/utils.ts rename to apps/server/src/database/repos/space/utils.ts diff --git a/apps/server/src/kysely/repos/user/user.repo.ts b/apps/server/src/database/repos/user/user.repo.ts similarity index 98% rename from apps/server/src/kysely/repos/user/user.repo.ts rename to apps/server/src/database/repos/user/user.repo.ts index 55b0593e..20ddc7cf 100644 --- a/apps/server/src/kysely/repos/user/user.repo.ts +++ b/apps/server/src/database/repos/user/user.repo.ts @@ -28,8 +28,10 @@ export class UserRepo { 'timezone', 'settings', 'lastLoginAt', + 'deactivatedAt', 'createdAt', 'updatedAt', + 'deletedAt', ]; async findById( @@ -97,6 +99,7 @@ export class UserRepo { email: insertableUser.email.toLowerCase(), password: await hashPassword(insertableUser.password), locale: 'en', + role: insertableUser?.role, lastLoginAt: new Date(), }; diff --git a/apps/server/src/kysely/repos/workspace/workspace.repo.ts b/apps/server/src/database/repos/workspace/workspace.repo.ts similarity index 100% rename from apps/server/src/kysely/repos/workspace/workspace.repo.ts rename to apps/server/src/database/repos/workspace/workspace.repo.ts diff --git a/apps/server/src/kysely/types/db.d.ts b/apps/server/src/database/types/db.d.ts similarity index 92% rename from apps/server/src/kysely/types/db.d.ts rename to apps/server/src/database/types/db.d.ts index 569ce341..5a154829 100644 --- a/apps/server/src/kysely/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -79,10 +79,11 @@ export interface PageHistory { lastUpdatedById: string | null; pageId: string; slug: string | null; + slugId: string | null; spaceId: string; title: string | null; updatedAt: Generated; - version: number; + version: number | null; workspaceId: string; } @@ -93,19 +94,14 @@ export interface Pages { creatorId: string | null; deletedAt: Timestamp | null; deletedById: string | null; - editor: string | null; - html: string | null; icon: string | null; id: Generated; isLocked: Generated; - key: string | null; lastUpdatedById: string | null; parentPageId: string | null; position: string | null; - publishedAt: Timestamp | null; - slug: string | null; + slugId: string; spaceId: string; - status: string | null; textContent: string | null; title: string | null; tsv: string | null; @@ -115,8 +111,8 @@ export interface Pages { } export interface SpaceMembers { + addedById: string | null; createdAt: Generated; - creatorId: string | null; groupId: string | null; id: Generated; role: string; @@ -143,6 +139,8 @@ export interface Spaces { export interface Users { avatarUrl: string | null; createdAt: Generated; + deactivatedAt: Timestamp | null; + deletedAt: Timestamp | null; email: string; emailVerifiedAt: Timestamp | null; id: Generated; @@ -151,10 +149,9 @@ export interface Users { lastLoginAt: Timestamp | null; locale: string | null; name: string | null; - password: string; - role: string | null; + password: string | null; + role: string; settings: Json | null; - status: string | null; timezone: string | null; updatedAt: Generated; workspaceId: string | null; @@ -162,27 +159,26 @@ export interface Users { export interface WorkspaceInvitations { createdAt: Generated; - email: string; + email: string | null; groupIds: string[] | null; id: Generated; invitedById: string | null; role: string; - token: string | null; + token: string; updatedAt: Generated; workspaceId: string; } export interface Workspaces { + allowedEmailDomains: Generated; createdAt: Generated; customDomain: string | null; defaultRole: Generated; defaultSpaceId: string | null; deletedAt: Timestamp | null; description: string | null; - enableInvite: Generated; hostname: string | null; id: Generated; - inviteCode: Generated; logo: string | null; name: string | null; settings: Json | null; diff --git a/apps/server/src/kysely/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts similarity index 100% rename from apps/server/src/kysely/types/entity.types.ts rename to apps/server/src/database/types/entity.types.ts diff --git a/apps/server/src/kysely/types/kysely.types.ts b/apps/server/src/database/types/kysely.types.ts similarity index 100% rename from apps/server/src/kysely/types/kysely.types.ts rename to apps/server/src/database/types/kysely.types.ts diff --git a/apps/server/src/kysely/utils.ts b/apps/server/src/database/utils.ts similarity index 100% rename from apps/server/src/kysely/utils.ts rename to apps/server/src/database/utils.ts diff --git a/apps/server/src/helpers/nanoid.utils.ts b/apps/server/src/helpers/nanoid.utils.ts index c20a3817..6af99208 100644 --- a/apps/server/src/helpers/nanoid.utils.ts +++ b/apps/server/src/helpers/nanoid.utils.ts @@ -3,3 +3,7 @@ const { customAlphabet } = require('fix-esm').require('nanoid'); const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz'; export const nanoIdGen = customAlphabet(alphabet, 10); + +const slugIdAlphabet = + '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; +export const genPageShortId = customAlphabet(slugIdAlphabet, 12); diff --git a/apps/server/src/integrations/static/static.module.ts b/apps/server/src/integrations/static/static.module.ts new file mode 100644 index 00000000..3ed38d08 --- /dev/null +++ b/apps/server/src/integrations/static/static.module.ts @@ -0,0 +1,57 @@ +import { Module, OnModuleInit } from '@nestjs/common'; +import { HttpAdapterHost } from '@nestjs/core'; +import { join } from 'path'; +import * as fs from 'node:fs'; +import fastifyStatic from '@fastify/static'; +import { EnvironmentService } from '../environment/environment.service'; + +@Module({}) +export class StaticModule implements OnModuleInit { + constructor( + private readonly httpAdapterHost: HttpAdapterHost, + private readonly environmentService: EnvironmentService, + ) {} + + public async onModuleInit() { + const httpAdapter = this.httpAdapterHost.httpAdapter; + const app = httpAdapter.getInstance(); + + const clientDistPath = join( + __dirname, + '..', + '..', + '..', + '..', + 'client/dist', + ); + + if (fs.existsSync(clientDistPath)) { + const indexFilePath = join(clientDistPath, 'index.html'); + const windowVar = ''; + + const configString = { + env: this.environmentService.getEnv(), + appUrl: this.environmentService.getAppUrl(), + isCloud: this.environmentService.isCloud(), + }; + + const windowScriptContent = ``; + const html = fs.readFileSync(indexFilePath, 'utf8'); + const transformedHtml = html.replace(windowVar, windowScriptContent); + + fs.writeFileSync(indexFilePath, transformedHtml); + + const RENDER_PATH = '*'; + + await app.register(fastifyStatic, { + root: clientDistPath, + wildcard: false, + }); + + app.get(RENDER_PATH, (req: any, res: any) => { + const stream = fs.createReadStream(indexFilePath); + res.type('text/html').send(stream); + }); + } + } +} diff --git a/apps/server/src/kysely/migrations/20240413T164028-add-position-to-pages.ts b/apps/server/src/kysely/migrations/20240413T164028-add-position-to-pages.ts deleted file mode 100644 index 18adc24a..00000000 --- a/apps/server/src/kysely/migrations/20240413T164028-add-position-to-pages.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { type Kysely } from 'kysely'; - -export async function up(db: Kysely): Promise { - await db.schema - .alterTable('pages') - .addColumn('position', 'varchar', (col) => col) - .execute(); -} - -export async function down(db: Kysely): Promise { - await db.schema.alterTable('pages').dropColumn('position').execute(); -} diff --git a/apps/server/src/kysely/migrations/20240508T174817-add-columns-to-invitation.ts b/apps/server/src/kysely/migrations/20240508T174817-add-columns-to-invitation.ts deleted file mode 100644 index 98b84178..00000000 --- a/apps/server/src/kysely/migrations/20240508T174817-add-columns-to-invitation.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Kysely, sql } from 'kysely'; - -export async function up(db: Kysely): Promise { - await db.schema - .alterTable('workspace_invitations') - .addColumn('token', 'varchar', (col) => col) - .addColumn('group_ids', sql`uuid[]`, (col) => col) - .execute(); - - await db.schema - .alterTable('workspace_invitations') - .dropColumn('status') - .execute(); - - await db.schema - .alterTable('workspace_invitations') - .addUniqueConstraint('invitation_email_workspace_id_unique', [ - 'email', - 'workspace_id', - ]) - .execute(); -} - -export async function down(db: Kysely): Promise { - await db.schema - .alterTable('workspace_invitations') - .dropColumn('token') - .execute(); - await db.schema - .alterTable('workspace_invitations') - .dropColumn('group_ids') - .execute(); - - await db.schema - .alterTable('workspace_invitations') - .addColumn('status', 'varchar', (col) => col) - .execute(); - - await db.schema - .alterTable('workspace_invitations') - .dropConstraint('invitation_email_workspace_id_unique') - .execute(); -} diff --git a/apps/server/src/kysely/migrations/20240510T235414-add-invited_by_id-to-users.ts b/apps/server/src/kysely/migrations/20240510T235414-add-invited_by_id-to-users.ts deleted file mode 100644 index 7b2792d8..00000000 --- a/apps/server/src/kysely/migrations/20240510T235414-add-invited_by_id-to-users.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { type Kysely } from 'kysely'; - -export async function up(db: Kysely): Promise { - await db.schema - .alterTable('users') - .addColumn('invited_by_id', 'uuid', (col) => - col.references('users.id').onDelete('set null'), - ) - .execute(); -} - -export async function down(db: Kysely): Promise { - await db.schema.alterTable('users').dropColumn('invited_by_id').execute(); -} diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 717838b9..f81ddf51 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -4,7 +4,7 @@ import { FastifyAdapter, NestFastifyApplication, } from '@nestjs/platform-fastify'; -import { ValidationPipe } from '@nestjs/common'; +import { NotFoundException, ValidationPipe } from '@nestjs/common'; import { TransformHttpResponseInterceptor } from './interceptors/http-response.interceptor'; import fastifyMultipart from '@fastify/multipart'; @@ -14,12 +14,29 @@ async function bootstrap() { new FastifyAdapter({ ignoreTrailingSlash: true, ignoreDuplicateSlashes: true, - } as any), + }), ); app.setGlobalPrefix('api'); - await app.register(fastifyMultipart as any); + await app.register(fastifyMultipart); + + app + .getHttpAdapter() + .getInstance() + .addHook('preHandler', function (req, reply, done) { + if ( + req.originalUrl.startsWith('/api') && + !req.originalUrl.startsWith('/api/auth/setup') + ) { + if (!req.raw?.['workspaceId']) { + throw new NotFoundException('Workspace not found'); + } + done(); + } else { + done(); + } + }); app.useGlobalPipes( new ValidationPipe({ diff --git a/apps/server/src/middlewares/domain.middleware.ts b/apps/server/src/middlewares/domain.middleware.ts index 07c8321d..3ff661cf 100644 --- a/apps/server/src/middlewares/domain.middleware.ts +++ b/apps/server/src/middlewares/domain.middleware.ts @@ -17,7 +17,9 @@ export class DomainMiddleware implements NestMiddleware { if (this.environmentService.isSelfHosted()) { const workspace = await this.workspaceRepo.findFirst(); if (!workspace) { - throw new NotFoundException('Workspace not found'); + //throw new NotFoundException('Workspace not found'); + (req as any).workspaceId = null; + return next(); } (req as any).workspaceId = workspace.id; diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 40085328..5b5ae8cc 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -20,8 +20,7 @@ "strict": true, "jsx": "react", "paths": { - "@docmost/db": ["./src/kysely"], - "@docmost/db/*": ["./src/kysely/*"], + "@docmost/db/*": ["./src/database/*"], "@docmost/transactional/*": ["./src/integrations/transactional/*"] } } diff --git a/package.json b/package.json index 383a083e..abcc0281 100644 --- a/package.json +++ b/package.json @@ -11,13 +11,13 @@ "server:dev": "nx run server:start:dev", "server:start": "nx run server:start:prod", "email:dev": "nx run @docmost/transactional:dev" - }, "dependencies": { "@docmost/editor-ext": "workspace:*", "@hocuspocus/provider": "^2.12.2", "@hocuspocus/server": "^2.12.2", "@hocuspocus/transformer": "^2.12.2", + "@sindresorhus/slugify": "^2.2.1", "@tiptap/extension-code-block": "^2.3.1", "@tiptap/extension-collaboration": "^2.3.1", "@tiptap/extension-collaboration-cursor": "^2.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6bd1eab0..b087ebe5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@hocuspocus/transformer': specifier: ^2.12.2 version: 2.12.2(@tiptap/pm@2.3.1)(y-prosemirror@1.2.3(prosemirror-model@1.19.4)(prosemirror-state@1.4.3)(prosemirror-view@1.32.7)(y-protocols@1.0.6(yjs@13.6.15))(yjs@13.6.15))(yjs@13.6.15) + '@sindresorhus/slugify': + specifier: ^2.2.1 + version: 2.2.1 '@tiptap/extension-code-block': specifier: ^2.3.1 version: 2.3.1(@tiptap/core@2.3.1(@tiptap/pm@2.3.1))(@tiptap/pm@2.3.1) @@ -189,6 +192,9 @@ importers: react-error-boundary: specifier: ^4.0.13 version: 4.0.13(react@18.2.0) + react-helmet-async: + specifier: ^2.0.5 + version: 2.0.5(react@18.2.0) react-router-dom: specifier: ^6.22.3 version: 6.22.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -307,9 +313,6 @@ importers: '@nestjs/platform-socket.io': specifier: ^10.3.8 version: 10.3.8(@nestjs/common@10.3.8(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@10.3.8)(rxjs@7.8.1) - '@nestjs/serve-static': - specifier: ^4.0.2 - version: 4.0.2(@fastify/static@7.0.3)(@nestjs/common@10.3.8(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.8(@nestjs/common@10.3.8(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@10.3.8)(reflect-metadata@0.2.2)(rxjs@7.8.1))(fastify@4.26.2) '@nestjs/websockets': specifier: ^10.3.8 version: 10.3.8(@nestjs/common@10.3.8(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.8)(@nestjs/platform-socket.io@10.3.8)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -385,9 +388,6 @@ importers: sanitize-filename-ts: specifier: ^1.0.2 version: 1.0.2 - slugify: - specifier: ^1.6.6 - version: 1.6.6 socket.io: specifier: ^4.7.5 version: 4.7.5 @@ -2140,22 +2140,6 @@ packages: peerDependencies: typescript: '>=4.8.2' - '@nestjs/serve-static@4.0.2': - resolution: {integrity: sha512-cT0vdWN5ar7jDI2NKbhf4LcwJzU4vS5sVpMkVrHuyLcltbrz6JdGi1TfIMMatP2pNiq5Ie/uUdPSFDVaZX/URQ==} - peerDependencies: - '@fastify/static': ^6.5.0 || ^7.0.0 - '@nestjs/common': ^9.0.0 || ^10.0.0 - '@nestjs/core': ^9.0.0 || ^10.0.0 - express: ^4.18.1 - fastify: ^4.7.0 - peerDependenciesMeta: - '@fastify/static': - optional: true - express: - optional: true - fastify: - optional: true - '@nestjs/testing@10.3.8': resolution: {integrity: sha512-hpX9das2TdFTKQ4/2ojhjI6YgXtCfXRKui3A4Qaj54VVzc5+mtK502Jj18Vzji98o9MVS6skmYu+S/UvW3U6Fw==} peerDependencies: @@ -2939,6 +2923,14 @@ packages: '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + '@sindresorhus/slugify@2.2.1': + resolution: {integrity: sha512-MkngSCRZ8JdSOCHRaYd+D01XhvU3Hjy6MGl06zhOk614hp9EOAp5gIkBeQg7wtmxpitU6eAL4kdiRMcJa2dlrw==} + engines: {node: '>=12'} + + '@sindresorhus/transliterate@1.6.0': + resolution: {integrity: sha512-doH1gimEu3A46VX6aVxpHTeHrytJAG6HgdxntYnCFiIFHEM/ZGpG8KiZGBChchjQmG0XFIBL552kBTjVcMZXwQ==} + engines: {node: '>=12'} + '@sinonjs/commons@3.0.1': resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} @@ -6136,9 +6128,6 @@ packages: resolution: {integrity: sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==} engines: {node: '>=16 || 14 >=14.17'} - path-to-regexp@0.2.5: - resolution: {integrity: sha512-l6qtdDPIkmAmzEO6egquYDfqQGPMRNGjYtrU13HAXb3YSRrt7HSb1sJY0pKp6o2bAa86tSB6iwaW2JbthPKr7Q==} - path-to-regexp@3.2.0: resolution: {integrity: sha512-jczvQbCUS7XmS7o+y1aEO9OBVFeZBQ1MDSEqmO7xSoPgOPoowY/SxLpZ6Vh97/8qHZOteiCKb7gkG9gA2ZUxJA==} @@ -6517,6 +6506,14 @@ packages: peerDependencies: react: '>=16.13.1' + react-fast-compare@3.2.2: + resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} + + react-helmet-async@2.0.5: + resolution: {integrity: sha512-rYUYHeus+i27MvFE+Jaa4WsyBKGkL6qVgbJvSBoX8mbsWoABJXdEO0bZyi0F6i+4f0NuIb8AvqPMj3iXFHkMwg==} + peerDependencies: + react: ^16.6.0 || ^17.0.0 || ^18.0.0 + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -6805,6 +6802,9 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shallowequal@1.1.0: + resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -6840,10 +6840,6 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} - slugify@1.6.6: - resolution: {integrity: sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==} - engines: {node: '>=8.0.0'} - socket.io-adapter@2.5.4: resolution: {integrity: sha512-wDNHGXGewWAjQPt3pyeYBtpWSq9cLE5UW1ZUPL/2eGK9jtse/FpXib7epSTsz0Q0m+6sg6Y4KtcFTlah1bdOVg==} @@ -9836,15 +9832,6 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/serve-static@4.0.2(@fastify/static@7.0.3)(@nestjs/common@10.3.8(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.8(@nestjs/common@10.3.8(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@10.3.8)(reflect-metadata@0.2.2)(rxjs@7.8.1))(fastify@4.26.2)': - dependencies: - '@nestjs/common': 10.3.8(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/core': 10.3.8(@nestjs/common@10.3.8(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@10.3.8)(reflect-metadata@0.2.2)(rxjs@7.8.1) - path-to-regexp: 0.2.5 - optionalDependencies: - '@fastify/static': 7.0.3 - fastify: 4.26.2 - '@nestjs/testing@10.3.8(@nestjs/common@10.3.8(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.8(@nestjs/common@10.3.8(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@10.3.8)(reflect-metadata@0.2.2)(rxjs@7.8.1))': dependencies: '@nestjs/common': 10.3.8(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -10619,6 +10606,15 @@ snapshots: '@sinclair/typebox@0.27.8': {} + '@sindresorhus/slugify@2.2.1': + dependencies: + '@sindresorhus/transliterate': 1.6.0 + escape-string-regexp: 5.0.0 + + '@sindresorhus/transliterate@1.6.0': + dependencies: + escape-string-regexp: 5.0.0 + '@sinonjs/commons@3.0.1': dependencies: type-detect: 4.0.8 @@ -14366,8 +14362,6 @@ snapshots: lru-cache: 10.2.0 minipass: 7.0.4 - path-to-regexp@0.2.5: {} - path-to-regexp@3.2.0: {} path-to-regexp@6.2.1: {} @@ -14829,6 +14823,15 @@ snapshots: '@babel/runtime': 7.23.7 react: 18.2.0 + react-fast-compare@3.2.2: {} + + react-helmet-async@2.0.5(react@18.2.0): + dependencies: + invariant: 2.2.4 + react: 18.2.0 + react-fast-compare: 3.2.2 + shallowequal: 1.1.0 + react-is@16.13.1: {} react-is@18.2.0: {} @@ -15121,6 +15124,8 @@ snapshots: setprototypeof@1.2.0: {} + shallowequal@1.1.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -15150,8 +15155,6 @@ snapshots: slash@3.0.0: {} - slugify@1.6.6: {} - socket.io-adapter@2.5.4: dependencies: debug: 4.3.4