diff --git a/app/components/CollectionDescription.tsx b/app/components/CollectionDescription.tsx index 81e34b02e..968e8a72c 100644 --- a/app/components/CollectionDescription.tsx +++ b/app/components/CollectionDescription.tsx @@ -25,7 +25,7 @@ function CollectionDescription({ collection }: Props) { const [isExpanded, setExpanded] = React.useState(false); const [isEditing, setEditing] = React.useState(false); const [isDirty, setDirty] = React.useState(false); - const can = usePolicy(collection.id); + const can = usePolicy(collection); const handleStartEditing = React.useCallback(() => { setEditing(true); diff --git a/app/components/DocumentListItem.tsx b/app/components/DocumentListItem.tsx index c5a9e3960..b043e1213 100644 --- a/app/components/DocumentListItem.tsx +++ b/app/components/DocumentListItem.tsx @@ -49,8 +49,8 @@ function DocumentListItem( ref: React.RefObject ) { const { t } = useTranslation(); - const currentUser = useCurrentUser(); - const currentTeam = useCurrentTeam(); + const user = useCurrentUser(); + const team = useCurrentTeam(); const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean(); const { @@ -70,7 +70,7 @@ function DocumentListItem( !!document.title.toLowerCase().includes(highlight.toLowerCase()); const canStar = !document.isDraft && !document.isArchived && !document.isTemplate; - const can = usePolicy(currentTeam.id); + const can = usePolicy(team); const canCollection = usePolicy(document.collectionId); return ( @@ -96,7 +96,7 @@ function DocumentListItem( highlight={highlight} dir={document.dir} /> - {document.isBadgedNew && document.createdBy.id !== currentUser.id && ( + {document.isBadgedNew && document.createdBy.id !== user.id && ( {t("New")} )} {canStar && ( diff --git a/app/components/EventListItem.tsx b/app/components/EventListItem.tsx index 47e43716b..0627439c7 100644 --- a/app/components/EventListItem.tsx +++ b/app/components/EventListItem.tsx @@ -33,7 +33,7 @@ type Props = { const EventListItem = ({ event, latest, document, ...rest }: Props) => { const { t } = useTranslation(); const location = useLocation(); - const can = usePolicy(document.id); + const can = usePolicy(document); const opts = { userName: event.actor.name, }; diff --git a/app/components/Sidebar/App.tsx b/app/components/Sidebar/App.tsx index b92be2181..fad3164a3 100644 --- a/app/components/Sidebar/App.tsx +++ b/app/components/Sidebar/App.tsx @@ -36,7 +36,7 @@ function AppSidebar() { const { documents } = useStores(); const team = useCurrentTeam(); const user = useCurrentUser(); - const can = usePolicy(team.id); + const can = usePolicy(team); React.useEffect(() => { if (!user.isViewer) { diff --git a/app/components/Sidebar/components/CollectionLink.tsx b/app/components/Sidebar/components/CollectionLink.tsx index aecabab02..0cdcb04eb 100644 --- a/app/components/Sidebar/components/CollectionLink.tsx +++ b/app/components/Sidebar/components/CollectionLink.tsx @@ -44,7 +44,7 @@ const CollectionLink: React.FC = ({ const { dialogs, documents, collections } = useStores(); const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean(); const [isEditing, setIsEditing] = React.useState(false); - const canUpdate = usePolicy(collection.id).update; + const canUpdate = usePolicy(collection).update; const { t } = useTranslation(); const history = useHistory(); const inStarredSection = useStarredContext(); diff --git a/app/components/Sidebar/components/CollectionLinkChildren.tsx b/app/components/Sidebar/components/CollectionLinkChildren.tsx index 9357a1d6c..8495a628a 100644 --- a/app/components/Sidebar/components/CollectionLinkChildren.tsx +++ b/app/components/Sidebar/components/CollectionLinkChildren.tsx @@ -25,7 +25,7 @@ function CollectionLinkChildren({ expanded, prefetchDocument, }: Props) { - const can = usePolicy(collection.id); + const can = usePolicy(collection); const { showToast } = useToasts(); const manualSort = collection.sort.field === "index"; const { documents } = useStores(); diff --git a/app/components/Sidebar/components/DraggableCollectionLink.tsx b/app/components/Sidebar/components/DraggableCollectionLink.tsx index 3815129e8..b2f17ab4c 100644 --- a/app/components/Sidebar/components/DraggableCollectionLink.tsx +++ b/app/components/Sidebar/components/DraggableCollectionLink.tsx @@ -39,7 +39,7 @@ function DraggableCollectionLink({ const [expanded, setExpanded] = React.useState( collection.id === ui.activeCollectionId && !locationStateStarred ); - const can = usePolicy(collection.id); + const can = usePolicy(collection); const belowCollectionIndex = belowCollection ? belowCollection.index : null; // Drop to reorder collection diff --git a/app/components/SocketProvider.tsx b/app/components/SocketProvider.tsx deleted file mode 100644 index 26f2d7bd2..000000000 --- a/app/components/SocketProvider.tsx +++ /dev/null @@ -1,391 +0,0 @@ -import invariant from "invariant"; -import { find } from "lodash"; -import { observable } from "mobx"; -import { observer } from "mobx-react"; -import * as React from "react"; -import { io, Socket } from "socket.io-client"; -import RootStore from "~/stores/RootStore"; -import withStores from "~/components/withStores"; -import { AuthorizationError, NotFoundError } from "~/utils/errors"; -import { getVisibilityListener, getPageVisible } from "~/utils/pageVisibility"; - -type SocketWithAuthentication = Socket & { - authenticated?: boolean; -}; - -export const SocketContext = React.createContext( - null -); - -type Props = RootStore; - -@observer -class SocketProvider extends React.Component { - @observable - socket: SocketWithAuthentication | null; - - componentDidMount() { - this.createConnection(); - document.addEventListener(getVisibilityListener(), this.checkConnection); - } - - componentWillUnmount() { - if (this.socket) { - this.socket.authenticated = false; - this.socket.disconnect(); - } - - document.removeEventListener(getVisibilityListener(), this.checkConnection); - } - - checkConnection = () => { - if (this.socket?.disconnected && getPageVisible()) { - // null-ifying this reference is important, do not remove. Without it - // references to old sockets are potentially held in context - this.socket.close(); - this.socket = null; - this.createConnection(); - } - }; - - createConnection = () => { - this.socket = io(window.location.origin, { - path: "/realtime", - transports: ["websocket"], - reconnectionDelay: 1000, - reconnectionDelayMax: 30000, - }); - invariant(this.socket, "Socket should be defined"); - - this.socket.authenticated = false; - const { - auth, - toasts, - documents, - collections, - groups, - pins, - stars, - memberships, - policies, - presence, - views, - fileOperations, - } = this.props; - if (!auth.token) { - return; - } - - this.socket.on("connect", () => { - // immediately send current users token to the websocket backend where it - // is verified, if all goes well an 'authenticated' message will be - // received in response - this.socket?.emit("authentication", { - token: auth.token, - }); - }); - - this.socket.on("disconnect", () => { - // when the socket is disconnected we need to clear all presence state as - // it's no longer reliable. - presence.clear(); - }); - - // on reconnection, reset the transports option, as the Websocket - // connection may have failed (caused by proxy, firewall, browser, ...) - this.socket.io.on("reconnect_attempt", () => { - if (this.socket) { - this.socket.io.opts.transports = auth?.team?.domain - ? ["websocket"] - : ["websocket", "polling"]; - } - }); - - this.socket.on("authenticated", () => { - if (this.socket) { - this.socket.authenticated = true; - } - }); - - this.socket.on("unauthorized", (err: Error) => { - if (this.socket) { - this.socket.authenticated = false; - } - toasts.showToast(err.message, { - type: "error", - }); - throw err; - }); - - this.socket.on("entities", async (event: any) => { - if (event.documentIds) { - for (const documentDescriptor of event.documentIds) { - const documentId = documentDescriptor.id; - let document = documents.get(documentId) || {}; - - if (event.event === "documents.delete") { - const document = documents.get(documentId); - - if (document) { - document.deletedAt = documentDescriptor.updatedAt; - } - - policies.remove(documentId); - continue; - } - - // if we already have the latest version (it was us that performed - // the change) then we don't need to update anything either. - // @ts-expect-error ts-migrate(2339) FIXME: Property 'title' does not exist on type '{}'. - const { title, updatedAt } = document; - - if (updatedAt === documentDescriptor.updatedAt) { - continue; - } - - // otherwise, grab the latest version of the document - try { - document = await documents.fetch(documentId, { - force: true, - }); - } catch (err) { - if ( - err instanceof AuthorizationError || - err instanceof NotFoundError - ) { - documents.remove(documentId); - return; - } - } - - // if the title changed then we need to update the collection also - // @ts-expect-error ts-migrate(2339) FIXME: Property 'title' does not exist on type '{}'. - if (title !== document.title) { - if (!event.collectionIds) { - event.collectionIds = []; - } - - const existing = find(event.collectionIds, { - // @ts-expect-error ts-migrate(2339) FIXME: Property 'collectionId' does not exist on type '{}... Remove this comment to see the full error message - id: document.collectionId, - }); - - if (!existing) { - event.collectionIds.push({ - // @ts-expect-error ts-migrate(2339) FIXME: Property 'collectionId' does not exist on type '{}... Remove this comment to see the full error message - id: document.collectionId, - }); - } - } - } - } - - if (event.collectionIds) { - for (const collectionDescriptor of event.collectionIds) { - const collectionId = collectionDescriptor.id; - const collection = collections.get(collectionId); - - if (event.event === "collections.delete") { - if (collection) { - collection.deletedAt = collectionDescriptor.updatedAt; - } - - const deletedDocuments = documents.inCollection(collectionId); - deletedDocuments.forEach((doc) => { - doc.deletedAt = collectionDescriptor.updatedAt; - policies.remove(doc.id); - }); - documents.removeCollectionDocuments(collectionId); - memberships.removeCollectionMemberships(collectionId); - collections.remove(collectionId); - policies.remove(collectionId); - continue; - } - - // if we already have the latest version (it was us that performed - // the change) then we don't need to update anything either. - - if (collection?.updatedAt === collectionDescriptor.updatedAt) { - continue; - } - - try { - await collections.fetch(collectionId, { - force: true, - }); - } catch (err) { - if ( - err instanceof AuthorizationError || - err instanceof NotFoundError - ) { - documents.removeCollectionDocuments(collectionId); - memberships.removeCollectionMemberships(collectionId); - collections.remove(collectionId); - policies.remove(collectionId); - return; - } - } - } - } - - if (event.groupIds) { - for (const groupDescriptor of event.groupIds) { - const groupId = groupDescriptor.id; - const group = groups.get(groupId) || {}; - // if we already have the latest version (it was us that performed - // the change) then we don't need to update anything either. - // @ts-expect-error ts-migrate(2339) FIXME: Property 'updatedAt' does not exist on type '{}'. - const { updatedAt } = group; - - if (updatedAt === groupDescriptor.updatedAt) { - continue; - } - - try { - await groups.fetch(groupId, { - force: true, - }); - } catch (err) { - if ( - err instanceof AuthorizationError || - err instanceof NotFoundError - ) { - groups.remove(groupId); - } - } - } - } - - if (event.teamIds) { - await auth.fetch(); - } - }); - - this.socket.on("pins.create", (event: any) => { - pins.add(event); - }); - - this.socket.on("pins.update", (event: any) => { - pins.add(event); - }); - - this.socket.on("pins.delete", (event: any) => { - pins.remove(event.modelId); - }); - - this.socket.on("stars.create", (event: any) => { - stars.add(event); - }); - - this.socket.on("stars.update", (event: any) => { - stars.add(event); - }); - - this.socket.on("stars.delete", (event: any) => { - stars.remove(event.modelId); - }); - - this.socket.on("documents.permanent_delete", (event: any) => { - documents.remove(event.documentId); - }); - - // received when a user is given access to a collection - // if the user is us then we go ahead and load the collection from API. - this.socket.on("collections.add_user", (event: any) => { - if (auth.user && event.userId === auth.user.id) { - collections.fetch(event.collectionId, { - force: true, - }); - } - - // Document policies might need updating as the permission changes - documents.inCollection(event.collectionId).forEach((document) => { - policies.remove(document.id); - }); - }); - - // received when a user is removed from having access to a collection - // to keep state in sync we must update our UI if the user is us, - // or otherwise just remove any membership state we have for that user. - this.socket.on("collections.remove_user", (event: any) => { - if (auth.user && event.userId === auth.user.id) { - collections.remove(event.collectionId); - memberships.removeCollectionMemberships(event.collectionId); - documents.removeCollectionDocuments(event.collectionId); - } else { - memberships.remove(`${event.userId}-${event.collectionId}`); - } - }); - - this.socket.on("collections.update_index", (event: any) => { - const collection = collections.get(event.collectionId); - - if (collection) { - collection.updateIndex(event.index); - } - }); - - this.socket.on("fileOperations.create", async (event: any) => { - const user = auth.user; - if (user) { - fileOperations.add({ ...event, user }); - } - }); - - this.socket.on("fileOperations.update", async (event: any) => { - const user = auth.user; - if (user) { - fileOperations.add({ ...event, user }); - } - }); - - // received a message from the API server that we should request - // to join a specific room. Forward that to the ws server. - this.socket.on("join", (event: any) => { - this.socket?.emit("join", event); - }); - - // received a message from the API server that we should request - // to leave a specific room. Forward that to the ws server. - this.socket.on("leave", (event: any) => { - this.socket?.emit("leave", event); - }); - - // received whenever we join a document room, the payload includes - // userIds that are present/viewing and those that are editing. - this.socket.on("document.presence", (event: any) => { - presence.init(event.documentId, event.userIds, event.editingIds); - }); - - // received whenever a new user joins a document room, aka they - // navigate to / start viewing a document - this.socket.on("user.join", (event: any) => { - presence.touch(event.documentId, event.userId, event.isEditing); - views.touch(event.documentId, event.userId); - }); - - // received whenever a new user leaves a document room, aka they - // navigate away / stop viewing a document - this.socket.on("user.leave", (event: any) => { - presence.leave(event.documentId, event.userId); - views.touch(event.documentId, event.userId); - }); - - // received when another client in a document room wants to change - // or update it's presence. Currently the only property is whether - // the client is in editing state or not. - this.socket.on("user.presence", (event: any) => { - presence.touch(event.documentId, event.userId, event.isEditing); - }); - }; - - render() { - return ( - - {this.props.children} - - ); - } -} - -export default withStores(SocketProvider); diff --git a/app/components/WebsocketProvider.tsx b/app/components/WebsocketProvider.tsx new file mode 100644 index 000000000..627c80284 --- /dev/null +++ b/app/components/WebsocketProvider.tsx @@ -0,0 +1,432 @@ +import invariant from "invariant"; +import { find } from "lodash"; +import { action, observable } from "mobx"; +import { observer } from "mobx-react"; +import * as React from "react"; +import { io, Socket } from "socket.io-client"; +import RootStore from "~/stores/RootStore"; +import Collection from "~/models/Collection"; +import Document from "~/models/Document"; +import FileOperation from "~/models/FileOperation"; +import Group from "~/models/Group"; +import Pin from "~/models/Pin"; +import Star from "~/models/Star"; +import Team from "~/models/Team"; +import withStores from "~/components/withStores"; +import { + PartialWithId, + WebsocketCollectionUpdateIndexEvent, + WebsocketCollectionUserEvent, + WebsocketEntitiesEvent, + WebsocketEntityDeletedEvent, +} from "~/types"; +import { AuthorizationError, NotFoundError } from "~/utils/errors"; +import { getVisibilityListener, getPageVisible } from "~/utils/pageVisibility"; + +type SocketWithAuthentication = Socket & { + authenticated?: boolean; +}; + +export const WebsocketContext = React.createContext( + null +); + +type Props = RootStore; + +@observer +class WebsocketProvider extends React.Component { + @observable + socket: SocketWithAuthentication | null; + + componentDidMount() { + this.createConnection(); + document.addEventListener(getVisibilityListener(), this.checkConnection); + } + + componentWillUnmount() { + if (this.socket) { + this.socket.authenticated = false; + this.socket.disconnect(); + } + + document.removeEventListener(getVisibilityListener(), this.checkConnection); + } + + checkConnection = () => { + if (this.socket?.disconnected && getPageVisible()) { + // null-ifying this reference is important, do not remove. Without it + // references to old sockets are potentially held in context + this.socket.close(); + this.socket = null; + this.createConnection(); + } + }; + + createConnection = () => { + this.socket = io(window.location.origin, { + path: "/realtime", + transports: ["websocket"], + reconnectionDelay: 1000, + reconnectionDelayMax: 30000, + }); + invariant(this.socket, "Socket should be defined"); + + this.socket.authenticated = false; + const { + auth, + toasts, + documents, + collections, + groups, + pins, + stars, + memberships, + policies, + presence, + views, + fileOperations, + } = this.props; + if (!auth.token) { + return; + } + + this.socket.on("connect", () => { + // immediately send current users token to the websocket backend where it + // is verified, if all goes well an 'authenticated' message will be + // received in response + this.socket?.emit("authentication", { + token: auth.token, + }); + }); + + this.socket.on("disconnect", () => { + // when the socket is disconnected we need to clear all presence state as + // it's no longer reliable. + presence.clear(); + }); + + // on reconnection, reset the transports option, as the Websocket + // connection may have failed (caused by proxy, firewall, browser, ...) + this.socket.io.on("reconnect_attempt", () => { + if (this.socket) { + this.socket.io.opts.transports = auth?.team?.domain + ? ["websocket"] + : ["websocket", "polling"]; + } + }); + + this.socket.on("authenticated", () => { + if (this.socket) { + this.socket.authenticated = true; + } + }); + + this.socket.on("unauthorized", (err: Error) => { + if (this.socket) { + this.socket.authenticated = false; + } + toasts.showToast(err.message, { + type: "error", + }); + throw err; + }); + + this.socket.on( + "entities", + action(async (event: WebsocketEntitiesEvent) => { + if (event.documentIds) { + for (const documentDescriptor of event.documentIds) { + const documentId = documentDescriptor.id; + let document = documents.get(documentId); + const previousTitle = document?.title; + + // if we already have the latest version (it was us that performed + // the change) then we don't need to update anything either. + if (document?.updatedAt === documentDescriptor.updatedAt) { + continue; + } + + // otherwise, grab the latest version of the document + try { + document = await documents.fetch(documentId, { + force: true, + }); + } catch (err) { + if ( + err instanceof AuthorizationError || + err instanceof NotFoundError + ) { + documents.remove(documentId); + return; + } + } + + // if the title changed then we need to update the collection also + if (document && previousTitle !== document.title) { + if (!event.collectionIds) { + event.collectionIds = []; + } + + const existing = find(event.collectionIds, { + id: document.collectionId, + }); + + if (!existing) { + event.collectionIds.push({ + id: document.collectionId, + }); + } + } + } + } + + if (event.collectionIds) { + for (const collectionDescriptor of event.collectionIds) { + const collectionId = collectionDescriptor.id; + const collection = collections.get(collectionId); + + // if we already have the latest version (it was us that performed + // the change) then we don't need to update anything either. + if (collection?.updatedAt === collectionDescriptor.updatedAt) { + continue; + } + + try { + await collections.fetch(collectionId, { + force: true, + }); + } catch (err) { + if ( + err instanceof AuthorizationError || + err instanceof NotFoundError + ) { + documents.removeCollectionDocuments(collectionId); + memberships.removeCollectionMemberships(collectionId); + collections.remove(collectionId); + policies.remove(collectionId); + return; + } + } + } + } + }) + ); + + this.socket.on( + "documents.update", + action( + (event: PartialWithId & { title: string; url: string }) => { + documents.add(event); + + if (event.collectionId) { + const collection = collections.get(event.collectionId); + collection?.updateDocument(event); + } + } + ) + ); + + this.socket.on( + "documents.archive", + action((event: PartialWithId) => { + documents.add(event); + policies.remove(event.id); + + if (event.collectionId) { + const collection = collections.get(event.collectionId); + collection?.removeDocument(event.id); + } + }) + ); + + this.socket.on( + "documents.delete", + action((event: PartialWithId) => { + documents.add(event); + policies.remove(event.id); + + if (event.collectionId) { + const collection = collections.get(event.collectionId); + collection?.removeDocument(event.id); + } + }) + ); + + this.socket.on( + "documents.permanent_delete", + (event: WebsocketEntityDeletedEvent) => { + documents.remove(event.modelId); + } + ); + + this.socket.on("groups.create", (event: PartialWithId) => { + groups.add(event); + }); + + this.socket.on("groups.update", (event: PartialWithId) => { + groups.add(event); + }); + + this.socket.on("groups.delete", (event: WebsocketEntityDeletedEvent) => { + groups.remove(event.modelId); + }); + + this.socket.on("collections.create", (event: PartialWithId) => { + collections.add(event); + }); + + this.socket.on( + "collections.delete", + action((event: WebsocketEntityDeletedEvent) => { + const collectionId = event.modelId; + const deletedAt = new Date().toISOString(); + + const deletedDocuments = documents.inCollection(collectionId); + deletedDocuments.forEach((doc) => { + doc.deletedAt = deletedAt; + policies.remove(doc.id); + }); + documents.removeCollectionDocuments(collectionId); + memberships.removeCollectionMemberships(collectionId); + collections.remove(collectionId); + policies.remove(collectionId); + }) + ); + + this.socket.on("teams.update", (event: PartialWithId) => { + auth.updateTeam(event); + }); + + this.socket.on("pins.create", (event: PartialWithId) => { + pins.add(event); + }); + + this.socket.on("pins.update", (event: PartialWithId) => { + pins.add(event); + }); + + this.socket.on("pins.delete", (event: WebsocketEntityDeletedEvent) => { + pins.remove(event.modelId); + }); + + this.socket.on("stars.create", (event: PartialWithId) => { + stars.add(event); + }); + + this.socket.on("stars.update", (event: PartialWithId) => { + stars.add(event); + }); + + this.socket.on("stars.delete", (event: WebsocketEntityDeletedEvent) => { + stars.remove(event.modelId); + }); + + // received when a user is given access to a collection + // if the user is us then we go ahead and load the collection from API. + this.socket.on( + "collections.add_user", + action((event: WebsocketCollectionUserEvent) => { + if (auth.user && event.userId === auth.user.id) { + collections.fetch(event.collectionId, { + force: true, + }); + } + + // Document policies might need updating as the permission changes + documents.inCollection(event.collectionId).forEach((document) => { + policies.remove(document.id); + }); + }) + ); + + // received when a user is removed from having access to a collection + // to keep state in sync we must update our UI if the user is us, + // or otherwise just remove any membership state we have for that user. + this.socket.on( + "collections.remove_user", + action((event: WebsocketCollectionUserEvent) => { + if (auth.user && event.userId === auth.user.id) { + collections.remove(event.collectionId); + memberships.removeCollectionMemberships(event.collectionId); + documents.removeCollectionDocuments(event.collectionId); + } else { + memberships.remove(`${event.userId}-${event.collectionId}`); + } + }) + ); + + this.socket.on( + "collections.update_index", + action((event: WebsocketCollectionUpdateIndexEvent) => { + const collection = collections.get(event.collectionId); + + if (collection) { + collection.updateIndex(event.index); + } + }) + ); + + this.socket.on( + "fileOperations.create", + (event: PartialWithId) => { + fileOperations.add(event); + } + ); + + this.socket.on( + "fileOperations.update", + (event: PartialWithId) => { + fileOperations.add(event); + } + ); + + // received a message from the API server that we should request + // to join a specific room. Forward that to the ws server. + this.socket.on("join", (event: any) => { + this.socket?.emit("join", event); + }); + + // received a message from the API server that we should request + // to leave a specific room. Forward that to the ws server. + this.socket.on("leave", (event: any) => { + this.socket?.emit("leave", event); + }); + + // received whenever we join a document room, the payload includes + // userIds that are present/viewing and those that are editing. + this.socket.on("document.presence", (event: any) => { + presence.init(event.documentId, event.userIds, event.editingIds); + }); + + // received whenever a new user joins a document room, aka they + // navigate to / start viewing a document + this.socket.on("user.join", (event: any) => { + presence.touch(event.documentId, event.userId, event.isEditing); + views.touch(event.documentId, event.userId); + }); + + // received whenever a new user leaves a document room, aka they + // navigate away / stop viewing a document + this.socket.on("user.leave", (event: any) => { + presence.leave(event.documentId, event.userId); + views.touch(event.documentId, event.userId); + }); + + // received when another client in a document room wants to change + // or update it's presence. Currently the only property is whether + // the client is in editing state or not. + this.socket.on("user.presence", (event: any) => { + presence.touch(event.documentId, event.userId, event.isEditing); + }); + }; + + render() { + return ( + + {this.props.children} + + ); + } +} + +export default withStores(WebsocketProvider); diff --git a/app/hooks/useAuthorizedSettingsConfig.ts b/app/hooks/useAuthorizedSettingsConfig.ts index dbb428428..aca3a250a 100644 --- a/app/hooks/useAuthorizedSettingsConfig.ts +++ b/app/hooks/useAuthorizedSettingsConfig.ts @@ -69,7 +69,7 @@ type ConfigType = { const useAuthorizedSettingsConfig = () => { const team = useCurrentTeam(); - const can = usePolicy(team.id); + const can = usePolicy(team); const { t } = useTranslation(); const config: ConfigType = React.useMemo( diff --git a/app/hooks/usePolicy.ts b/app/hooks/usePolicy.ts index 762884cc6..5e455ab57 100644 --- a/app/hooks/usePolicy.ts +++ b/app/hooks/usePolicy.ts @@ -1,12 +1,34 @@ +import * as React from "react"; +import BaseModel from "~/models/BaseModel"; import useStores from "./useStores"; /** - * Quick access to retrieve the abilities of a policy for a given entity + * Retrieve the abilities of a policy for a given entity, if the policy is not + * located in the store, it will be fetched from the server. * - * @param entityId The entity id - * @returns The available abilities + * @param entity The model or model id + * @returns The policy for the model */ -export default function usePolicy(entityId: string) { +export default function usePolicy(entity: string | BaseModel | undefined) { const { policies } = useStores(); + const triggered = React.useRef(false); + const entityId = entity + ? typeof entity === "string" + ? entity + : entity.id + : ""; + + React.useEffect(() => { + if (entity && typeof entity !== "string") { + // The policy for this model is missing and we haven't tried to fetch it + // yet, go ahead and do that now. The force flag is needed otherwise the + // network request will be skipped due to the model existing in the store + if (!policies.get(entity.id) && !triggered.current) { + triggered.current = true; + void entity.store.fetch(entity.id, { force: true }); + } + } + }, [policies, entity]); + return policies.abilities(entityId); } diff --git a/app/menus/CollectionMenu.tsx b/app/menus/CollectionMenu.tsx index 8591c033e..faca20f54 100644 --- a/app/menus/CollectionMenu.tsx +++ b/app/menus/CollectionMenu.tsx @@ -187,8 +187,8 @@ function CollectionMenu({ ); const alphabeticalSort = collection.sort.field === "title"; - const can = usePolicy(collection.id); - const canUserInTeam = usePolicy(team.id); + const can = usePolicy(collection); + const canUserInTeam = usePolicy(team); const items: MenuItem[] = React.useMemo( () => [ { diff --git a/app/menus/DocumentMenu.tsx b/app/menus/DocumentMenu.tsx index 7afb07add..9335a9a61 100644 --- a/app/menus/DocumentMenu.tsx +++ b/app/menus/DocumentMenu.tsx @@ -125,7 +125,7 @@ function DocumentMenu({ }, [menu]); const collection = collections.get(document.collectionId); - const can = usePolicy(document.id); + const can = usePolicy(document); const canViewHistory = can.read && !can.restore; const restoreItems = React.useMemo( () => [ diff --git a/app/menus/GroupMenu.tsx b/app/menus/GroupMenu.tsx index 711bd0841..86e19e0e6 100644 --- a/app/menus/GroupMenu.tsx +++ b/app/menus/GroupMenu.tsx @@ -24,7 +24,7 @@ function GroupMenu({ group, onMembers }: Props) { }); const [editModalOpen, setEditModalOpen] = React.useState(false); const [deleteModalOpen, setDeleteModalOpen] = React.useState(false); - const can = usePolicy(group.id); + const can = usePolicy(group); return ( <> diff --git a/app/menus/NewDocumentMenu.tsx b/app/menus/NewDocumentMenu.tsx index 901531280..1e9aa7acb 100644 --- a/app/menus/NewDocumentMenu.tsx +++ b/app/menus/NewDocumentMenu.tsx @@ -27,7 +27,7 @@ function NewDocumentMenu() { const { t } = useTranslation(); const team = useCurrentTeam(); const { collections, policies } = useStores(); - const can = usePolicy(team.id); + const can = usePolicy(team); const items = React.useMemo( () => collections.orderedData.reduce((filtered, collection) => { diff --git a/app/menus/NewTemplateMenu.tsx b/app/menus/NewTemplateMenu.tsx index 40ea0f916..e812bc575 100644 --- a/app/menus/NewTemplateMenu.tsx +++ b/app/menus/NewTemplateMenu.tsx @@ -22,7 +22,7 @@ function NewTemplateMenu() { const { t } = useTranslation(); const team = useCurrentTeam(); const { collections, policies } = useStores(); - const can = usePolicy(team.id); + const can = usePolicy(team); const items = React.useMemo( () => diff --git a/app/models/Collection.ts b/app/models/Collection.ts index 09b50c65d..c13d5efe9 100644 --- a/app/models/Collection.ts +++ b/app/models/Collection.ts @@ -101,8 +101,14 @@ export default class Collection extends ParanoidModel { return sortNavigationNodes(this.documents, this.sort); } + /** + * Updates the document identified by the given id in the collection in memory. + * Does not update the document in the database. + * + * @param document The document properties stored in the collection + */ @action - updateDocument(document: Document) { + updateDocument(document: Pick) { const travelNodes = (nodes: NavigationNode[]) => nodes.forEach((node) => { if (node.id === document.id) { @@ -116,6 +122,27 @@ export default class Collection extends ParanoidModel { travelNodes(this.documents); } + /** + * Removes the document identified by the given id from the collection in + * memory. Does not remove the document from the database. + * + * @param documentId The id of the document to remove. + */ + @action + removeDocument(documentId: string) { + this.documents = this.documents.filter(function f(node): boolean { + if (node.id === documentId) { + return false; + } + + if (node.children) { + node.children = node.children.filter(f); + } + + return true; + }); + } + @action updateIndex(index: string) { this.index = index; diff --git a/app/routes/authenticated.tsx b/app/routes/authenticated.tsx index 8bce9bb62..043eea54e 100644 --- a/app/routes/authenticated.tsx +++ b/app/routes/authenticated.tsx @@ -11,7 +11,7 @@ import Layout from "~/components/AuthenticatedLayout"; import CenteredContent from "~/components/CenteredContent"; import PlaceholderDocument from "~/components/PlaceholderDocument"; import Route from "~/components/ProfiledRoute"; -import SocketProvider from "~/components/SocketProvider"; +import WebsocketProvider from "~/components/WebsocketProvider"; import useCurrentTeam from "~/hooks/useCurrentTeam"; import usePolicy from "~/hooks/usePolicy"; import { matchDocumentSlug as slug } from "~/utils/routeHelpers"; @@ -64,10 +64,10 @@ const RedirectDocument = ({ function AuthenticatedRoutes() { const team = useCurrentTeam(); - const can = usePolicy(team.id); + const can = usePolicy(team); return ( - + - + ); } diff --git a/app/scenes/Collection/Actions.tsx b/app/scenes/Collection/Actions.tsx index fe22705b7..ef98abe59 100644 --- a/app/scenes/Collection/Actions.tsx +++ b/app/scenes/Collection/Actions.tsx @@ -18,7 +18,7 @@ type Props = { function Actions({ collection }: Props) { const { t } = useTranslation(); - const can = usePolicy(collection.id); + const can = usePolicy(collection); return ( <> diff --git a/app/scenes/Collection/Empty.tsx b/app/scenes/Collection/Empty.tsx index 7d7136364..883c4b5fb 100644 --- a/app/scenes/Collection/Empty.tsx +++ b/app/scenes/Collection/Empty.tsx @@ -20,7 +20,7 @@ type Props = { function EmptyCollection({ collection }: Props) { const { t } = useTranslation(); - const can = usePolicy(collection.id); + const can = usePolicy(collection); const collectionName = collection ? collection.name : ""; const [ diff --git a/app/scenes/Document/components/DataLoader.tsx b/app/scenes/Document/components/DataLoader.tsx index 83a801b85..b0db96afe 100644 --- a/app/scenes/Document/components/DataLoader.tsx +++ b/app/scenes/Document/components/DataLoader.tsx @@ -58,7 +58,7 @@ function DataLoader({ match, children }: Props) { : undefined; const isEditRoute = match.path === matchDocumentEdit; const isEditing = isEditRoute || !!auth.team?.collaborativeEditing; - const can = usePolicy(document ? document.id : ""); + const can = usePolicy(document?.id); const location = useLocation(); React.useEffect(() => { diff --git a/app/scenes/Document/components/Header.tsx b/app/scenes/Document/components/Header.tsx index d17eeb9c4..cad4b769e 100644 --- a/app/scenes/Document/components/Header.tsx +++ b/app/scenes/Document/components/Header.tsx @@ -100,7 +100,7 @@ function DocumentHeader({ }, [onSave]); const { isDeleted, isTemplate } = document; - const can = usePolicy(document.id); + const can = usePolicy(document); const canToggleEmbeds = team?.documentEmbeds; const canEdit = can.update && !isEditing; const toc = ( diff --git a/app/scenes/Document/components/SharePopover.tsx b/app/scenes/Document/components/SharePopover.tsx index 6490d0fd5..35bff4f1e 100644 --- a/app/scenes/Document/components/SharePopover.tsx +++ b/app/scenes/Document/components/SharePopover.tsx @@ -45,7 +45,7 @@ function SharePopover({ const timeout = React.useRef>(); const buttonRef = React.useRef(null); const can = usePolicy(share ? share.id : ""); - const documentAbilities = usePolicy(document.id); + const documentAbilities = usePolicy(document); const canPublish = can.update && !document.isTemplate && diff --git a/app/scenes/Document/components/SocketPresence.ts b/app/scenes/Document/components/SocketPresence.ts index 9491c9376..b67fdab3b 100644 --- a/app/scenes/Document/components/SocketPresence.ts +++ b/app/scenes/Document/components/SocketPresence.ts @@ -1,6 +1,6 @@ import * as React from "react"; import { USER_PRESENCE_INTERVAL } from "@shared/constants"; -import { SocketContext } from "~/components/SocketProvider"; +import { WebsocketContext } from "~/components/WebsocketProvider"; type Props = { documentId: string; @@ -8,9 +8,9 @@ type Props = { }; export default class SocketPresence extends React.Component { - static contextType = SocketContext; + static contextType = WebsocketContext; - previousContext: typeof SocketContext; + previousContext: typeof WebsocketContext; editingInterval: ReturnType; diff --git a/app/scenes/GroupMembers/GroupMembers.tsx b/app/scenes/GroupMembers/GroupMembers.tsx index 305a20ded..33a9b23ed 100644 --- a/app/scenes/GroupMembers/GroupMembers.tsx +++ b/app/scenes/GroupMembers/GroupMembers.tsx @@ -26,7 +26,7 @@ function GroupMembers({ group }: Props) { const { users, groupMemberships } = useStores(); const { showToast } = useToasts(); const { t } = useTranslation(); - const can = usePolicy(group.id); + const can = usePolicy(group); const handleAddModal = (state: boolean) => { setAddModalOpen(state); diff --git a/app/scenes/Home.tsx b/app/scenes/Home.tsx index 2e1841c6f..cf6445e64 100644 --- a/app/scenes/Home.tsx +++ b/app/scenes/Home.tsx @@ -30,7 +30,7 @@ function Home() { pins.fetchPage(); }, [pins]); - const canManageTeam = usePolicy(team.id).manage; + const canManageTeam = usePolicy(team).manage; return ( { diff --git a/app/scenes/Settings/Groups.tsx b/app/scenes/Settings/Groups.tsx index 4971db55d..14b83aacc 100644 --- a/app/scenes/Settings/Groups.tsx +++ b/app/scenes/Settings/Groups.tsx @@ -24,7 +24,7 @@ function Groups() { const { t } = useTranslation(); const { groups } = useStores(); const team = useCurrentTeam(); - const can = usePolicy(team.id); + const can = usePolicy(team); const [ newGroupModalOpen, handleNewGroupModalOpen, diff --git a/app/scenes/Settings/Members.tsx b/app/scenes/Settings/Members.tsx index 291d3909a..d4262b56b 100644 --- a/app/scenes/Settings/Members.tsx +++ b/app/scenes/Settings/Members.tsx @@ -40,7 +40,7 @@ function Members() { const [data, setData] = React.useState([]); const [totalPages, setTotalPages] = React.useState(0); const [userIds, setUserIds] = React.useState([]); - const can = usePolicy(team.id); + const can = usePolicy(team); const query = params.get("query") || ""; const filter = params.get("filter") || ""; const sort = params.get("sort") || "name"; diff --git a/app/scenes/Settings/Shares.tsx b/app/scenes/Settings/Shares.tsx index c9bae0811..f3be922de 100644 --- a/app/scenes/Settings/Shares.tsx +++ b/app/scenes/Settings/Shares.tsx @@ -21,7 +21,7 @@ function Shares() { const { t } = useTranslation(); const { shares, auth } = useStores(); const canShareDocuments = auth.team && auth.team.sharing; - const can = usePolicy(team.id); + const can = usePolicy(team); const [isLoading, setIsLoading] = React.useState(false); const [data, setData] = React.useState([]); const [totalPages, setTotalPages] = React.useState(0); diff --git a/app/scenes/Settings/Tokens.tsx b/app/scenes/Settings/Tokens.tsx index 5379b1fc1..d2a4878e5 100644 --- a/app/scenes/Settings/Tokens.tsx +++ b/app/scenes/Settings/Tokens.tsx @@ -23,7 +23,7 @@ function Tokens() { const { t } = useTranslation(); const { apiKeys } = useStores(); const [newModalOpen, handleNewModalOpen, handleNewModalClose] = useBoolean(); - const can = usePolicy(team.id); + const can = usePolicy(team); return ( ) { const team = useCurrentTeam(); const { fetchTemplates, templates, templatesAlphabetical } = documents; const { sort } = props.match.params; - const can = usePolicy(team.id); + const can = usePolicy(team); return ( = Partial & { id: string }; - export enum RPCAction { Info = "info", List = "list", diff --git a/app/types.ts b/app/types.ts index 5364527ed..76c5a7c35 100644 --- a/app/types.ts +++ b/app/types.ts @@ -1,7 +1,12 @@ import { Location, LocationDescriptor } from "history"; import { TFunction } from "react-i18next"; import RootStore from "~/stores/RootStore"; -import Document from "~/models/Document"; +import Document from "./models/Document"; +import FileOperation from "./models/FileOperation"; +import Pin from "./models/Pin"; +import Star from "./models/Star"; + +export type PartialWithId = Partial & { id: string }; export type MenuItemButton = { type: "button"; @@ -178,3 +183,34 @@ export type ToastOptions = { onClick: React.MouseEventHandler; }; }; + +export type WebsocketEntityDeletedEvent = { + modelId: string; +}; + +export type WebsocketEntitiesEvent = { + documentIds: { id: string; updatedAt?: string }[]; + collectionIds: { id: string; updatedAt?: string }[]; + groupIds: { id: string; updatedAt?: string }[]; + teamIds: string[]; + event: string; +}; + +export type WebsocketCollectionUserEvent = { + collectionId: string; + userId: string; +}; + +export type WebsocketCollectionUpdateIndexEvent = { + collectionId: string; + index: string; +}; + +export type WebsocketEvent = + | PartialWithId + | PartialWithId + | PartialWithId + | WebsocketCollectionUserEvent + | WebsocketCollectionUpdateIndexEvent + | WebsocketEntityDeletedEvent + | WebsocketEntitiesEvent; diff --git a/server/queues/processors/WebsocketsProcessor.ts b/server/queues/processors/WebsocketsProcessor.ts index ed7337d51..2469201ce 100644 --- a/server/queues/processors/WebsocketsProcessor.ts +++ b/server/queues/processors/WebsocketsProcessor.ts @@ -10,11 +10,16 @@ import { GroupUser, Pin, Star, + Team, } from "@server/models"; import { + presentCollection, + presentDocument, presentFileOperation, + presentGroup, presentPin, presentStar, + presentTeam, } from "@server/presenters"; import { Event } from "../../types"; @@ -23,7 +28,6 @@ export default class WebsocketsProcessor { switch (event.name) { case "documents.publish": case "documents.restore": - case "documents.archive": case "documents.unarchive": { const document = await Document.findByPk(event.documentId, { paranoid: false, @@ -51,52 +55,16 @@ export default class WebsocketsProcessor { }); } - case "documents.delete": { - const document = await Document.findByPk(event.documentId, { - paranoid: false, - }); - if (!document) { - return; - } - - if (!document.publishedAt) { - return socketio.to(`user-${document.createdById}`).emit("entities", { - event: event.name, - documentIds: [ - { - id: document.id, - updatedAt: document.updatedAt, - }, - ], - }); - } - - return socketio - .to(`collection-${document.collectionId}`) - .emit("entities", { - event: event.name, - documentIds: [ - { - id: document.id, - updatedAt: document.updatedAt, - }, - ], - collectionIds: [ - { - id: document.collectionId, - }, - ], - }); - } - case "documents.permanent_delete": { return socketio .to(`collection-${event.collectionId}`) .emit(event.name, { - documentId: event.documentId, + modelId: event.documentId, }); } + case "documents.archive": + case "documents.delete": case "documents.update": { const document = await Document.findByPk(event.documentId, { paranoid: false, @@ -107,15 +75,9 @@ export default class WebsocketsProcessor { const channel = document.publishedAt ? `collection-${document.collectionId}` : `user-${event.actorId}`; - return socketio.to(channel).emit("entities", { - event: event.name, - documentIds: [ - { - id: document.id, - updatedAt: document.updatedAt, - }, - ], - }); + + const data = await presentDocument(document); + return socketio.to(channel).emit(event.name, data); } case "documents.create": { @@ -139,13 +101,6 @@ export default class WebsocketsProcessor { }); } - case "documents.star": - case "documents.unstar": { - return socketio.to(`user-${event.actorId}`).emit(event.name, { - documentId: event.documentId, - }); - } - case "documents.move": { const documents = await Document.findAll({ where: { @@ -188,22 +143,15 @@ export default class WebsocketsProcessor { .to( collection.permission ? `team-${collection.teamId}` - : `collection-${collection.id}` + : `user-${collection.createdById}` ) - .emit("entities", { - event: event.name, - collectionIds: [ - { - id: collection.id, - updatedAt: collection.updatedAt, - }, - ], - }); + .emit(event.name, presentCollection(collection)); + return socketio .to( collection.permission ? `team-${collection.teamId}` - : `collection-${collection.id}` + : `user-${collection.createdById}` ) .emit("join", { event: event.name, @@ -211,8 +159,7 @@ export default class WebsocketsProcessor { }); } - case "collections.update": - case "collections.delete": { + case "collections.update": { const collection = await Collection.findByPk(event.collectionId, { paranoid: false, }); @@ -230,6 +177,14 @@ export default class WebsocketsProcessor { }); } + case "collections.delete": { + return socketio + .to(`collection-${event.collectionId}`) + .emit(event.name, { + modelId: event.collectionId, + }); + } + case "collections.move": { return socketio .to(`collection-${event.collectionId}`) @@ -366,8 +321,9 @@ export default class WebsocketsProcessor { if (!fileOperation) { return; } - const data = await presentFileOperation(fileOperation); - return socketio.to(`user-${event.actorId}`).emit(event.name, data); + return socketio + .to(`user-${event.actorId}`) + .emit(event.name, presentFileOperation(fileOperation)); } case "pins.create": @@ -422,15 +378,9 @@ export default class WebsocketsProcessor { if (!group) { return; } - return socketio.to(`team-${group.teamId}`).emit("entities", { - event: event.name, - groupIds: [ - { - id: group.id, - updatedAt: group.updatedAt, - }, - ], - }); + return socketio + .to(`team-${group.teamId}`) + .emit(event.name, presentGroup(group)); } case "groups.add_user": { @@ -518,25 +468,14 @@ export default class WebsocketsProcessor { } case "groups.delete": { - const group = await Group.findByPk(event.modelId, { - paranoid: false, + socketio.to(`team-${event.teamId}`).emit(event.name, { + modelId: event.modelId, }); - if (!group) { - return; - } - socketio.to(`team-${group.teamId}`).emit("entities", { - event: event.name, - groupIds: [ - { - id: group.id, - updatedAt: group.updatedAt, - }, - ], - }); - // we the users and collection relations that were just severed as a result of the group deletion - // since there are cascading deletes, we approximate this by looking for the recently deleted - // items in the GroupUser and CollectionGroup tables + // we get users and collection relations that were just severed as a + // result of the group deletion since there are cascading deletes, we + // approximate this by looking for the recently deleted items in the + // GroupUser and CollectionGroup tables const groupUsers = await GroupUser.findAll({ paranoid: false, where: { @@ -594,14 +533,13 @@ export default class WebsocketsProcessor { } case "teams.update": { - return socketio.to(`team-${event.teamId}`).emit("entities", { - event: event.name, - teamIds: [ - { - id: event.teamId, - }, - ], - }); + const team = await Team.scope("withDomains").findByPk(event.teamId); + if (!team) { + return; + } + return socketio + .to(`team-${event.teamId}`) + .emit(event.name, presentTeam(team)); } default: diff --git a/server/queues/tasks/DeliverWebhookTask.ts b/server/queues/tasks/DeliverWebhookTask.ts index cbe39d2f5..7a98f0594 100644 --- a/server/queues/tasks/DeliverWebhookTask.ts +++ b/server/queues/tasks/DeliverWebhookTask.ts @@ -122,8 +122,6 @@ export default class DeliverWebhookTask extends BaseTask { case "documents.archive": case "documents.unarchive": case "documents.restore": - case "documents.star": - case "documents.unstar": case "documents.move": case "documents.update": case "documents.title_change": diff --git a/server/routes/api/__snapshots__/documents.test.ts.snap b/server/routes/api/__snapshots__/documents.test.ts.snap index 2ac18507a..b9386a3cc 100644 --- a/server/routes/api/__snapshots__/documents.test.ts.snap +++ b/server/routes/api/__snapshots__/documents.test.ts.snap @@ -44,24 +44,6 @@ Object { } `; -exports[`#documents.star should require authentication 1`] = ` -Object { - "error": "authentication_required", - "message": "Authentication required", - "ok": false, - "status": 401, -} -`; - -exports[`#documents.unstar should require authentication 1`] = ` -Object { - "error": "authentication_required", - "message": "Authentication required", - "ok": false, - "status": 401, -} -`; - exports[`#documents.update should fail if document lastRevision does not match 1`] = ` Object { "error": "invalid_request", diff --git a/server/routes/api/documents.test.ts b/server/routes/api/documents.test.ts index 7a8e5717b..4dfe8648e 100644 --- a/server/routes/api/documents.test.ts +++ b/server/routes/api/documents.test.ts @@ -1,7 +1,6 @@ import { Document, View, - Star, Revision, Backlink, CollectionUser, @@ -1819,79 +1818,6 @@ describe("#documents.restore", () => { }); }); -describe("#documents.star", () => { - it("should star the document", async () => { - const { user, document } = await seed(); - const res = await server.post("/api/documents.star", { - body: { - token: user.getJwtToken(), - id: document.id, - }, - }); - const stars = await Star.findAll(); - expect(res.status).toEqual(200); - expect(stars.length).toEqual(1); - expect(stars[0].documentId).toEqual(document.id); - }); - - it("should require authentication", async () => { - const res = await server.post("/api/documents.star"); - const body = await res.json(); - expect(res.status).toEqual(401); - expect(body).toMatchSnapshot(); - }); - - it("should require authorization", async () => { - const { document } = await seed(); - const user = await buildUser(); - const res = await server.post("/api/documents.star", { - body: { - token: user.getJwtToken(), - id: document.id, - }, - }); - expect(res.status).toEqual(403); - }); -}); - -describe("#documents.unstar", () => { - it("should unstar the document", async () => { - const { user, document } = await seed(); - await Star.create({ - documentId: document.id, - userId: user.id, - }); - const res = await server.post("/api/documents.unstar", { - body: { - token: user.getJwtToken(), - id: document.id, - }, - }); - const stars = await Star.findAll(); - expect(res.status).toEqual(200); - expect(stars.length).toEqual(0); - }); - - it("should require authentication", async () => { - const res = await server.post("/api/documents.star"); - const body = await res.json(); - expect(res.status).toEqual(401); - expect(body).toMatchSnapshot(); - }); - - it("should require authorization", async () => { - const { document } = await seed(); - const user = await buildUser(); - const res = await server.post("/api/documents.unstar", { - body: { - token: user.getJwtToken(), - id: document.id, - }, - }); - expect(res.status).toEqual(403); - }); -}); - describe("#documents.import", () => { it("should error if no file is passed", async () => { const user = await buildUser(); diff --git a/server/routes/api/documents.ts b/server/routes/api/documents.ts index cb7abd660..8a11dadf1 100644 --- a/server/routes/api/documents.ts +++ b/server/routes/api/documents.ts @@ -23,7 +23,6 @@ import { Event, Revision, SearchQuery, - Star, User, View, } from "@server/models"; @@ -731,75 +730,6 @@ router.post( } ); -// Deprecated – use stars.create instead -router.post("documents.star", auth(), async (ctx) => { - const { id } = ctx.body; - assertPresent(id, "id is required"); - const { user } = ctx.state; - - const document = await Document.findByPk(id, { - userId: user.id, - }); - authorize(user, "read", document); - - await Star.findOrCreate({ - where: { - documentId: document.id, - userId: user.id, - }, - }); - - await Event.create({ - name: "documents.star", - documentId: document.id, - collectionId: document.collectionId, - teamId: document.teamId, - actorId: user.id, - data: { - title: document.title, - }, - ip: ctx.request.ip, - }); - - ctx.body = { - success: true, - }; -}); - -// Deprecated – use stars.delete instead -router.post("documents.unstar", auth(), async (ctx) => { - const { id } = ctx.body; - assertPresent(id, "id is required"); - const { user } = ctx.state; - - const document = await Document.findByPk(id, { - userId: user.id, - }); - authorize(user, "read", document); - - await Star.destroy({ - where: { - documentId: document.id, - userId: user.id, - }, - }); - await Event.create({ - name: "documents.unstar", - documentId: document.id, - collectionId: document.collectionId, - teamId: document.teamId, - actorId: user.id, - data: { - title: document.title, - }, - ip: ctx.request.ip, - }); - - ctx.body = { - success: true, - }; -}); - router.post("documents.templatize", auth({ member: true }), async (ctx) => { const { id } = ctx.body; assertPresent(id, "id is required"); diff --git a/server/services/websockets.ts b/server/services/websockets.ts index 901e3b2a0..e0c171fa3 100644 --- a/server/services/websockets.ts +++ b/server/services/websockets.ts @@ -6,6 +6,8 @@ import IO from "socket.io"; import { createAdapter } from "socket.io-redis"; import Logger from "@server/logging/Logger"; import Metrics from "@server/logging/metrics"; +import * as Tracing from "@server/logging/tracing"; +import { APM } from "@server/logging/tracing"; import { Document, Collection, View, User } from "@server/models"; import { can } from "@server/policies"; import { getUserForJWT } from "@server/utils/jwt"; @@ -135,14 +137,23 @@ export default function init( // Handle events from event queue that should be sent to the clients down ws const websockets = new WebsocketsProcessor(); - websocketQueue.process(async function websocketEventsProcessor(job) { - const event = job.data; - websockets.perform(event, io).catch((error) => { - Logger.error("Error processing websocket event", error, { - event, + websocketQueue.process( + APM.traceFunction({ + serviceName: "websockets", + spanName: "process", + isRoot: true, + })(async function (job) { + const event = job.data; + + Tracing.setResource(`Processor.WebsocketsProcessor`); + + websockets.perform(event, io).catch((error) => { + Logger.error("Error processing websocket event", error, { + event, + }); }); - }); - }); + }) + ); } async function authenticated(io: IO.Server, socket: SocketWithAuth) { diff --git a/server/types.ts b/server/types.ts index 3b781ab34..c8aa27db3 100644 --- a/server/types.ts +++ b/server/types.ts @@ -95,9 +95,7 @@ export type DocumentEvent = BaseEvent & | "documents.permanent_delete" | "documents.archive" | "documents.unarchive" - | "documents.restore" - | "documents.star" - | "documents.unstar"; + | "documents.restore"; documentId: string; collectionId: string; data: {