diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 7d9e018c..4aee66ac 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -24,6 +24,7 @@ "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-toast": "^1.2.1", "@radix-ui/react-tooltip": "^1.1.2", + "@radix-ui/react-visually-hidden": "^1.1.0", "@react-oauth/google": "^0.12.1", "@tanstack/react-query": "^5.56.2", "@tiptap/core": "^2.7.4", diff --git a/desktop/package.json b/desktop/package.json index 12117e5d..b1b7ab0b 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -65,6 +65,7 @@ "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-toast": "^1.2.1", "@radix-ui/react-tooltip": "^1.1.2", + "@radix-ui/react-visually-hidden": "^1.1.0", "@react-oauth/google": "^0.12.1", "@tanstack/react-query": "^5.56.2", "@tiptap/core": "^2.7.4", diff --git a/desktop/src/components/workspaces/workspace-settings-dialog.tsx b/desktop/src/components/workspaces/workspace-settings-dialog.tsx index d6bbdb0b..3c08a438 100644 --- a/desktop/src/components/workspaces/workspace-settings-dialog.tsx +++ b/desktop/src/components/workspaces/workspace-settings-dialog.tsx @@ -6,6 +6,8 @@ import { Avatar } from '@/components/ui/avatar'; import { useWorkspace } from '@/contexts/workspace'; import { WorkspaceUpdate } from '@/components/workspaces/workspace-update'; import { WorkspaceUsers } from '@/components/workspaces/workspace-users'; +import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; +import { DialogTitle } from '@radix-ui/react-dialog'; interface WorkspaceSettingsDialogProps { open: boolean; @@ -20,7 +22,13 @@ export const WorkspaceSettingsDialog = ({ return ( - + + + Workspace Settings + { const { data, isPending, hasNextPage, fetchNextPage, isFetchingNextPage } = @@ -30,7 +31,7 @@ export const WorkspaceUsers = () => { const name: string = user.attributes.name ?? 'Unknown'; const email: string = user.attributes.email ?? ' '; const avatar: string | null | undefined = user.attributes.avatar; - const role: number = user.attributes.role; + const role: WorkspaceRole = user.attributes.role; const accountId: string = user.attributes.accountId; if (!accountId || !role) { diff --git a/desktop/src/electron/app-manager.ts b/desktop/src/electron/app-manager.ts index 21fc4a08..83add467 100644 --- a/desktop/src/electron/app-manager.ts +++ b/desktop/src/electron/app-manager.ts @@ -16,7 +16,7 @@ import { extractTablesFromSql, resultHasChanged, } from '@/electron/utils'; -import { Workspace } from '@/types/workspaces'; +import { Workspace, WorkspaceRole } from '@/types/workspaces'; import { SubscribedQueryContext, SubscribedQueryResult } from '@/types/queries'; import { eventBus } from '@/lib/event-bus'; import { isEqual } from 'lodash'; @@ -372,7 +372,7 @@ class AppManager { description: workspace.description, avatar: workspace.avatar, versionId: workspace.version_id, - role: workspace.role, + role: workspace.role as WorkspaceRole, userId: workspace.user_id, synced: workspace.synced === 1, }), diff --git a/desktop/src/electron/migrations/app.ts b/desktop/src/electron/migrations/app.ts index 52e88fff..b5a55ac1 100644 --- a/desktop/src/electron/migrations/app.ts +++ b/desktop/src/electron/migrations/app.ts @@ -89,7 +89,7 @@ const createWorkspacesTable: Migration = { .addColumn('description', 'text') .addColumn('avatar', 'text') .addColumn('version_id', 'text', (col) => col.notNull()) - .addColumn('role', 'integer', (col) => col.notNull()) + .addColumn('role', 'text', (col) => col.notNull()) .addColumn('user_id', 'text', (col) => col.notNull()) .addColumn('synced', 'integer') .execute(); diff --git a/desktop/src/electron/schemas/app.ts b/desktop/src/electron/schemas/app.ts index 4daf4cfd..672f0bfb 100644 --- a/desktop/src/electron/schemas/app.ts +++ b/desktop/src/electron/schemas/app.ts @@ -36,7 +36,7 @@ interface WorkspaceTable { description: ColumnType; avatar: ColumnType; version_id: ColumnType; - role: ColumnType; + role: ColumnType; user_id: ColumnType; synced: ColumnType; } diff --git a/desktop/src/electron/workspace-manager.ts b/desktop/src/electron/workspace-manager.ts index f93cbbff..f9fa9431 100644 --- a/desktop/src/electron/workspace-manager.ts +++ b/desktop/src/electron/workspace-manager.ts @@ -218,15 +218,18 @@ export class WorkspaceManager { return; } - const executedMutations = data.executedMutations; + const executedMutations = data.results + .filter((result) => result.status === 'success') + .map((result) => result.id); + await this.database .deleteFrom('mutations') .where('id', 'in', executedMutations) .execute(); - const failedMutationIds = mutations - .filter((mutation) => !executedMutations.includes(mutation.id)) - .map((mutation) => mutation.id); + const failedMutationIds = data.results + .filter((result) => result.status === 'error') + .map((result) => result.id); if (failedMutationIds.length > 0) { await this.database diff --git a/desktop/src/mutations/use-workspace-account-role-update-mutation.tsx b/desktop/src/mutations/use-workspace-account-role-update-mutation.tsx index e23d0bf8..ec908bc4 100644 --- a/desktop/src/mutations/use-workspace-account-role-update-mutation.tsx +++ b/desktop/src/mutations/use-workspace-account-role-update-mutation.tsx @@ -5,7 +5,7 @@ import { useMutation } from '@tanstack/react-query'; interface UpdateWorkspaceAccountRoleInput { accountId: string; - role: number; + role: string; } export const useWorkspaceAccountRoleUpdateMutation = () => { diff --git a/desktop/src/queries/use-workspaces-query.tsx b/desktop/src/queries/use-workspaces-query.tsx index 2dc4dc9b..5055c141 100644 --- a/desktop/src/queries/use-workspaces-query.tsx +++ b/desktop/src/queries/use-workspaces-query.tsx @@ -1,6 +1,6 @@ import { useAppDatabase } from '@/contexts/app-database'; import { SelectWorkspace } from '@/electron/schemas/app'; -import { Workspace } from '@/types/workspaces'; +import { Workspace, WorkspaceRole } from '@/types/workspaces'; import { useQuery } from '@tanstack/react-query'; import { QueryResult } from 'kysely'; @@ -43,7 +43,7 @@ const mapWorkspaces = (row: SelectWorkspace): Workspace => { avatar: row.avatar, versionId: row.version_id, accountId: row.account_id, - role: row.role, + role: row.role as WorkspaceRole, userId: row.user_id, synced: row.synced === 1, }; diff --git a/desktop/src/types/mutations.ts b/desktop/src/types/mutations.ts index 582d5284..91b7772a 100644 --- a/desktop/src/types/mutations.ts +++ b/desktop/src/types/mutations.ts @@ -8,6 +8,12 @@ export type ServerMutation = { }; export type ServerExecuteMutationsResponse = { - success: boolean; - executedMutations: number[]; + results: ServerExecuteMutationResult[]; }; + +export type ServerExecuteMutationResult = { + id: number; + status: ServerExecuteMutationStatus; +}; + +export type ServerExecuteMutationStatus = 'success' | 'error'; diff --git a/desktop/src/types/workspaces.ts b/desktop/src/types/workspaces.ts index 3c1183e3..d9cc2632 100644 --- a/desktop/src/types/workspaces.ts +++ b/desktop/src/types/workspaces.ts @@ -1,10 +1,10 @@ import { ServerNode, ServerNodeReaction } from '@/types/nodes'; export enum WorkspaceRole { - Owner = 1, - Admin = 2, - Collaborator = 3, - Viewer = 4, + Owner = 'owner', + Admin = 'admin', + Collaborator = 'collaborator', + Viewer = 'viewer', } export type Workspace = { diff --git a/server/src/lib/nodes.ts b/server/src/lib/nodes.ts index dc7d71fc..47600421 100644 --- a/server/src/lib/nodes.ts +++ b/server/src/lib/nodes.ts @@ -1,5 +1,7 @@ +import { database } from '@/data/database'; import { SelectNode } from '@/data/schema'; import { ServerNode } from '@/types/nodes'; +import { sql } from 'kysely'; export const mapNode = (node: SelectNode): ServerNode => { return { @@ -19,3 +21,47 @@ export const mapNode = (node: SelectNode): ServerNode => { serverUpdatedAt: node.server_updated_at, }; }; + +type NodeCollaboratorRow = { + node_id: string; + node_level: number; + role: string; +}; + +export const getCollaboratorRole = async ( + nodeId: string, + collaboratorId: string, +): Promise => { + const query = sql` + WITH RECURSIVE ancestors(id, parent_id, level) AS ( + SELECT id, parent_id, 0 AS level + FROM nodes + WHERE id = ${nodeId} + UNION ALL + SELECT n.id, n.parent_id, a.level + 1 + FROM nodes n + INNER JOIN ancestors a ON n.id = a.parent_id + ) + SELECT + nc.node_id, + a.level AS node_level, + nc.role + FROM node_collaborators nc + JOIN ancestors a ON nc.node_id = a.id + WHERE nc.collaborator_id = ${collaboratorId}; + `.compile(database); + + const result = await database.executeQuery(query); + if (result.rows.length === 0) { + return null; + } + + const roleHierarchy = ['owner', 'admin', 'collaborator']; + const highestRole = result.rows.reduce((highest, row) => { + const currentRoleIndex = roleHierarchy.indexOf(row.role); + const highestRoleIndex = roleHierarchy.indexOf(highest); + return currentRoleIndex < highestRoleIndex ? row.role : highest; + }, 'collaborator'); + + return highestRole; +}; diff --git a/server/src/mutations/node-collaborators.ts b/server/src/mutations/node-collaborators.ts new file mode 100644 index 00000000..f3464513 --- /dev/null +++ b/server/src/mutations/node-collaborators.ts @@ -0,0 +1,351 @@ +import { database } from '@/data/database'; +import { SelectWorkspaceAccount } from '@/data/schema'; +import { getCollaboratorRole } from '@/lib/nodes'; +import { + ExecuteLocalMutationResult, + LocalMutation, + LocalNodeCollaboratorMutationData, +} from '@/types/mutations'; + +export const handleNodeCollaboratorMutation = async ( + workspaceAccount: SelectWorkspaceAccount, + mutation: LocalMutation, +): Promise => { + switch (mutation.action) { + case 'insert': { + return handleCreateNodeCollaboratorMutation(workspaceAccount, mutation); + } + case 'update': { + return handleUpdateNodeCollaboratorMutation(workspaceAccount, mutation); + } + case 'delete': { + return handleDeleteNodeCollaboratorMutation(workspaceAccount, mutation); + } + } +}; + +const handleCreateNodeCollaboratorMutation = async ( + workspaceAccount: SelectWorkspaceAccount, + mutation: LocalMutation, +): Promise => { + if (!mutation.after) { + return { + status: 'error', + }; + } + + const nodeCollaboratorData = JSON.parse( + mutation.after, + ) as LocalNodeCollaboratorMutationData; + + const canCreate = await canCreateNodeCollaborator( + workspaceAccount, + nodeCollaboratorData, + ); + if (!canCreate) { + return { + status: 'error', + }; + } + + await database + .insertInto('node_collaborators') + .values({ + node_id: nodeCollaboratorData.node_id, + collaborator_id: nodeCollaboratorData.collaborator_id, + role: nodeCollaboratorData.role, + workspace_id: workspaceAccount.workspace_id, + created_at: new Date(nodeCollaboratorData.created_at), + created_by: nodeCollaboratorData.created_by, + server_created_at: new Date(), + version_id: nodeCollaboratorData.version_id, + }) + .onConflict((ob) => ob.doNothing()) + .execute(); + + return { + status: 'success', + }; +}; + +const canCreateNodeCollaborator = async ( + workspaceAccount: SelectWorkspaceAccount, + data: LocalNodeCollaboratorMutationData, +): Promise => { + const node = await database + .selectFrom('nodes') + .selectAll() + .where('id', '=', data.node_id) + .executeTakeFirst(); + + if (node === null || node === undefined) { + return false; + } + + // If the node is a root node and created by the current user + if ( + node.parent_id === null && + node.created_by === workspaceAccount.user_id && + data.collaborator_id === workspaceAccount.user_id + ) { + return true; + } + + // Get the current user's role for the node or its ancestors + const currentUserRole = await getCollaboratorRole( + data.node_id, + workspaceAccount.user_id, + ); + + if (currentUserRole === null) { + return false; // User has no access to the node + } + + if (currentUserRole === 'owner') { + // Owners can add any role + return true; + } + + if (currentUserRole === 'admin') { + // Admins can add admins and collaborators, but not owners + if (data.role === 'owner') { + return false; + } + + return true; + } + + // Collaborators cannot add other collaborators + return false; +}; + +const handleUpdateNodeCollaboratorMutation = async ( + workspaceAccount: SelectWorkspaceAccount, + mutation: LocalMutation, +): Promise => { + if (!mutation.after) { + return { + status: 'error', + }; + } + + const nodeCollaboratorData = JSON.parse( + mutation.after, + ) as LocalNodeCollaboratorMutationData; + + const existingNodeCollaborator = await database + .selectFrom('node_collaborators') + .selectAll() + .where((eb) => + eb.and([ + eb('node_id', '=', nodeCollaboratorData.node_id), + eb('collaborator_id', '=', nodeCollaboratorData.collaborator_id), + ]), + ) + .executeTakeFirst(); + + if ( + !existingNodeCollaborator || + existingNodeCollaborator.workspace_id != workspaceAccount.workspace_id || + existingNodeCollaborator.updated_at === null || + existingNodeCollaborator.updated_by === null + ) { + return { + status: 'error', + }; + } + + const canUpdate = await canUpdateNodeCollaborator( + workspaceAccount, + nodeCollaboratorData, + ); + + if (!canUpdate) { + return { + status: 'error', + }; + } + + if (existingNodeCollaborator.role === nodeCollaboratorData.role) { + return { + status: 'success', + }; + } + + const updatedAt = new Date(existingNodeCollaborator.updated_at); + if (existingNodeCollaborator.server_updated_at !== null) { + const serverUpdatedAt = new Date( + existingNodeCollaborator.server_updated_at, + ); + if (serverUpdatedAt > updatedAt) { + return { + status: 'success', + }; + } + } + + await database + .updateTable('node_collaborators') + .set({ + role: nodeCollaboratorData.role, + updated_at: updatedAt, + updated_by: + nodeCollaboratorData.updated_by ?? existingNodeCollaborator.created_by, + version_id: nodeCollaboratorData.version_id, + server_updated_at: new Date(), + }) + .where((eb) => + eb.and([ + eb('node_id', '=', nodeCollaboratorData.node_id), + eb('collaborator_id', '=', nodeCollaboratorData.collaborator_id), + ]), + ) + .execute(); + + return { + status: 'success', + }; +}; + +const canUpdateNodeCollaborator = async ( + workspaceAccount: SelectWorkspaceAccount, + data: LocalNodeCollaboratorMutationData, +): Promise => { + const node = await database + .selectFrom('nodes') + .selectAll() + .where('id', '=', data.node_id) + .executeTakeFirst(); + + if (node === null || node === undefined) { + return false; + } + + // Get the current user's role for the node or its ancestors + const currentUserRole = await getCollaboratorRole( + data.node_id, + workspaceAccount.user_id, + ); + + if (currentUserRole === null) { + return false; // User has no access to the node + } + + if (currentUserRole === 'owner') { + // Owners can add any role + return true; + } + + if (currentUserRole === 'admin') { + // Admins can add admins and collaborators, but not owners + if (data.role === 'owner') { + return false; + } + + return true; + } + + // Collaborators cannot add other collaborators + return false; +}; + +const handleDeleteNodeCollaboratorMutation = async ( + workspaceAccount: SelectWorkspaceAccount, + mutation: LocalMutation, +): Promise => { + if (!mutation.before) { + return { + status: 'error', + }; + } + + const nodeCollaboratorData = JSON.parse( + mutation.before, + ) as LocalNodeCollaboratorMutationData; + + const existingNodeCollaborator = await database + .selectFrom('node_collaborators') + .selectAll() + .where((eb) => + eb.and([ + eb('node_id', '=', nodeCollaboratorData.node_id), + eb('collaborator_id', '=', nodeCollaboratorData.collaborator_id), + ]), + ) + .executeTakeFirst(); + + if ( + !existingNodeCollaborator || + existingNodeCollaborator.workspace_id != workspaceAccount.workspace_id + ) { + return { + status: 'error', + }; + } + + const canDelete = await canDeleteNodeCollaborator( + workspaceAccount, + nodeCollaboratorData, + ); + if (!canDelete) { + return { + status: 'error', + }; + } + + await database + .deleteFrom('node_collaborators') + .where((eb) => + eb.and([ + eb('node_id', '=', nodeCollaboratorData.node_id), + eb('collaborator_id', '=', nodeCollaboratorData.collaborator_id), + ]), + ) + .execute(); + + return { + status: 'success', + }; +}; + +const canDeleteNodeCollaborator = async ( + workspaceAccount: SelectWorkspaceAccount, + data: LocalNodeCollaboratorMutationData, +): Promise => { + const node = await database + .selectFrom('nodes') + .selectAll() + .where('id', '=', data.node_id) + .executeTakeFirst(); + + if (node === null || node === undefined) { + return false; + } + + // Get the current user's role for the node or its ancestors + const currentUserRole = await getCollaboratorRole( + data.node_id, + workspaceAccount.user_id, + ); + + if (currentUserRole === null) { + return false; // User has no access to the node + } + + if (currentUserRole === 'owner') { + // Owners can add any role + return true; + } + + if (currentUserRole === 'admin') { + // Admins can add admins and collaborators, but not owners + if (data.role === 'owner') { + return false; + } + + return true; + } + + // Collaborators cannot add other collaborators + return false; +}; diff --git a/server/src/mutations/node-reactions.ts b/server/src/mutations/node-reactions.ts new file mode 100644 index 00000000..02495c2a --- /dev/null +++ b/server/src/mutations/node-reactions.ts @@ -0,0 +1,111 @@ +import { database } from '@/data/database'; +import { SelectWorkspaceAccount } from '@/data/schema'; +import { getCollaboratorRole } from '@/lib/nodes'; +import { + ExecuteLocalMutationResult, + LocalMutation, + LocalNodeReactionMutationData, +} from '@/types/mutations'; + +export const handleNodeReactionMutation = async ( + workspaceAccount: SelectWorkspaceAccount, + mutation: LocalMutation, +): Promise => { + switch (mutation.action) { + case 'insert': { + return handleCreateNodeReactionMutation(workspaceAccount, mutation); + } + case 'delete': { + return handleDeleteNodeReactionMutation(workspaceAccount, mutation); + } + } + + return { + status: 'error', + }; +}; + +const handleCreateNodeReactionMutation = async ( + workspaceAccount: SelectWorkspaceAccount, + mutation: LocalMutation, +): Promise => { + if (!mutation.after) { + return { + status: 'error', + }; + } + + const nodeReactionData = JSON.parse( + mutation.after, + ) as LocalNodeReactionMutationData; + + if (nodeReactionData.reactor_id !== workspaceAccount.user_id) { + return { + status: 'error', + }; + } + + const nodeRole = await getCollaboratorRole( + nodeReactionData.node_id, + workspaceAccount.user_id, + ); + + if (nodeRole === null) { + return { + status: 'error', + }; + } + + await database + .insertInto('node_reactions') + .values({ + node_id: nodeReactionData.node_id, + reactor_id: nodeReactionData.reactor_id, + reaction: nodeReactionData.reaction, + created_at: new Date(nodeReactionData.created_at), + workspace_id: workspaceAccount.workspace_id, + server_created_at: new Date(), + }) + .onConflict((ob) => ob.doNothing()) + .execute(); + + return { + status: 'success', + }; +}; + +const handleDeleteNodeReactionMutation = async ( + workspaceAccount: SelectWorkspaceAccount, + mutation: LocalMutation, +): Promise => { + if (!mutation.before) { + return { + status: 'error', + }; + } + + const nodeReactionData = JSON.parse( + mutation.before, + ) as LocalNodeReactionMutationData; + + if (nodeReactionData.reactor_id !== workspaceAccount.user_id) { + return { + status: 'error', + }; + } + + await database + .deleteFrom('node_reactions') + .where((eb) => + eb.and([ + eb('node_id', '=', nodeReactionData.node_id), + eb('reactor_id', '=', nodeReactionData.reactor_id), + eb('reaction', '=', nodeReactionData.reaction), + ]), + ) + .execute(); + + return { + status: 'success', + }; +}; diff --git a/server/src/mutations/nodes.ts b/server/src/mutations/nodes.ts new file mode 100644 index 00000000..85ba84db --- /dev/null +++ b/server/src/mutations/nodes.ts @@ -0,0 +1,192 @@ +import { database } from '@/data/database'; +import { SelectWorkspaceAccount } from '@/data/schema'; +import { getCollaboratorRole } from '@/lib/nodes'; +import { + ExecuteLocalMutationResult, + LocalMutation, + LocalNodeMutationData, +} from '@/types/mutations'; +import { ServerNodeAttributes } from '@/types/nodes'; +import { fromUint8Array, toUint8Array } from 'js-base64'; +import * as Y from 'yjs'; + +export const handleNodeMutation = async ( + workspaceAccount: SelectWorkspaceAccount, + mutation: LocalMutation, +): Promise => { + switch (mutation.action) { + case 'insert': { + return handleCreateNodeMutation(workspaceAccount, mutation); + } + case 'update': { + return handleUpdateNodeMutation(workspaceAccount, mutation); + } + case 'delete': { + return handleDeleteNodeMutation(workspaceAccount, mutation); + } + } +}; + +const handleCreateNodeMutation = async ( + workspaceAccount: SelectWorkspaceAccount, + mutation: LocalMutation, +): Promise => { + if (!mutation.after) { + return { + status: 'error', + }; + } + + const nodeData = JSON.parse(mutation.after) as LocalNodeMutationData; + const existingNode = await database + .selectFrom('nodes') + .where('id', '=', nodeData.id) + .executeTakeFirst(); + + if (existingNode) { + return { + status: 'success', + }; + } + + const attributes: ServerNodeAttributes = JSON.parse(nodeData.attributes); + if (attributes.parentId) { + const parentRole = await getCollaboratorRole( + attributes.parentId, + workspaceAccount.user_id, + ); + + if ( + parentRole === null || + (parentRole !== 'owner' && parentRole !== 'admin') + ) { + return { + status: 'error', + }; + } + } + + await database + .insertInto('nodes') + .values({ + id: nodeData.id, + attributes: nodeData.attributes, + workspace_id: workspaceAccount.workspace_id, + state: nodeData.state, + created_at: new Date(nodeData.created_at), + created_by: nodeData.created_by, + version_id: nodeData.version_id, + server_created_at: new Date(), + }) + .execute(); + + return { + status: 'success', + }; +}; + +const handleUpdateNodeMutation = async ( + workspaceAccount: SelectWorkspaceAccount, + mutation: LocalMutation, +): Promise => { + if (!mutation.after) { + return { + status: 'error', + }; + } + + const nodeData = JSON.parse(mutation.after) as LocalNodeMutationData; + const existingNode = await database + .selectFrom('nodes') + .select(['id', 'workspace_id', 'state']) + .where('id', '=', nodeData.id) + .executeTakeFirst(); + + if ( + !existingNode || + existingNode.workspace_id != workspaceAccount.workspace_id + ) { + return { + status: 'error', + }; + } + + const role = await getCollaboratorRole(nodeData.id, workspaceAccount.user_id); + if (role === null) { + return { + status: 'error', + }; + } + + const updatedAt = nodeData.updated_at + ? new Date(nodeData.updated_at) + : new Date(); + const updatedBy = nodeData.updated_by ?? workspaceAccount.user_id; + + const doc = new Y.Doc({ + guid: nodeData.id, + }); + + Y.applyUpdate(doc, toUint8Array(existingNode.state)); + Y.applyUpdate(doc, toUint8Array(nodeData.state)); + + const attributesMap = doc.getMap('attributes'); + const attributes = JSON.stringify(attributesMap.toJSON()); + const encodedState = fromUint8Array(Y.encodeStateAsUpdate(doc)); + + await database + .updateTable('nodes') + .set({ + attributes: attributes, + state: encodedState, + updated_at: updatedAt, + updated_by: updatedBy, + version_id: nodeData.version_id, + server_updated_at: new Date(), + }) + .where('id', '=', nodeData.id) + .execute(); + + return { + status: 'success', + }; +}; + +const handleDeleteNodeMutation = async ( + workspaceAccount: SelectWorkspaceAccount, + mutation: LocalMutation, +): Promise => { + if (!mutation.before) { + return { + status: 'error', + }; + } + + const nodeData = JSON.parse(mutation.before) as LocalNodeMutationData; + const existingNode = await database + .selectFrom('nodes') + .where('id', '=', nodeData.id) + .select(['id', 'workspace_id']) + .executeTakeFirst(); + + if ( + !existingNode || + existingNode.workspace_id !== workspaceAccount.workspace_id + ) { + return { + status: 'error', + }; + } + + const role = await getCollaboratorRole(nodeData.id, workspaceAccount.user_id); + if (role === null) { + return { + status: 'error', + }; + } + + await database.deleteFrom('nodes').where('id', '=', nodeData.id).execute(); + return { + status: 'success', + }; +}; diff --git a/server/src/routes/mutations.ts b/server/src/routes/mutations.ts index 4e48b860..8e7af107 100644 --- a/server/src/routes/mutations.ts +++ b/server/src/routes/mutations.ts @@ -1,375 +1,91 @@ -import { NeuronRequest, NeuronResponse } from '@/types/api'; +import { ApiError, NeuronRequest, NeuronResponse } from '@/types/api'; import { Router } from 'express'; import { + ExecuteLocalMutationResult, ExecuteLocalMutationsInput, LocalMutation, - LocalNodeMutationData, - LocalNodeCollaboratorMutationData, - LocalNodeReactionMutationData, + ServerExecuteMutationResult, } from '@/types/mutations'; +import { handleNodeMutation } from '@/mutations/nodes'; +import { handleNodeCollaboratorMutation } from '@/mutations/node-collaborators'; +import { handleNodeReactionMutation } from '@/mutations/node-reactions'; import { database } from '@/data/database'; -import * as Y from 'yjs'; -import { fromUint8Array, toUint8Array } from 'js-base64'; +import { SelectWorkspaceAccount } from '@/data/schema'; export const mutationsRouter = Router(); mutationsRouter.post('/', async (req: NeuronRequest, res: NeuronResponse) => { const input = req.body as ExecuteLocalMutationsInput; - const executedMutations: number[] = []; + if (!req.accountId) { + return res.status(401).json({ + code: ApiError.Unauthorized, + message: 'Unauthorized.', + }); + } + + const workspace = await database + .selectFrom('workspaces') + .selectAll() + .where('id', '=', input.workspaceId) + .executeTakeFirst(); + + if (!workspace) { + return res.status(404).json({ + code: ApiError.ResourceNotFound, + message: 'Workspace not found.', + }); + } + + const workspaceAccount = await database + .selectFrom('workspace_accounts') + .selectAll() + .where('workspace_id', '=', workspace.id) + .where('account_id', '=', req.accountId) + .executeTakeFirst(); + + if (!workspaceAccount) { + return res.status(403).json({ + code: ApiError.Forbidden, + message: 'Forbidden.', + }); + } + + const results: ServerExecuteMutationResult[] = []; for (const mutation of input.mutations) { try { - switch (mutation.table) { - case 'nodes': { - switch (mutation.action) { - case 'insert': { - await handleCreateNodeMutation(input.workspaceId, mutation); - break; - } - case 'update': { - await handleUpdateNodeMutation(input.workspaceId, mutation); - break; - } - case 'delete': { - await handleDeleteNodeMutation(input.workspaceId, mutation); - break; - } - } - break; - } - case 'node_collaborators': { - switch (mutation.action) { - case 'insert': { - await handleCreateNodeCollaboratorMutation( - input.workspaceId, - mutation, - ); - break; - } - case 'update': { - await handleUpdateNodeCollaboratorMutation( - input.workspaceId, - mutation, - ); - break; - } - case 'delete': { - await handleDeleteNodeCollaboratorMutation( - input.workspaceId, - mutation, - ); - break; - } - } - break; - } - case 'node_reactions': { - switch (mutation.action) { - case 'insert': { - await handleCreateNodeReactionMutation( - input.workspaceId, - mutation, - ); - break; - } - case 'delete': { - await handleDeleteNodeReactionMutation(mutation); - break; - } - } - break; - } - } - - executedMutations.push(mutation.id); + const result = await handleLocalMutation(workspaceAccount, mutation); + results.push({ + id: mutation.id, + status: result.status, + }); } catch (error) { - console.error(error); + results.push({ + id: mutation.id, + status: 'error', + }); } } - res.status(200).json({ success: true, executedMutations }); + res.status(200).json({ results }); }); -const handleCreateNodeMutation = async ( - workspaceId: string, +const handleLocalMutation = async ( + workspaceAccount: SelectWorkspaceAccount, mutation: LocalMutation, -): Promise => { - if (!mutation.after) { - return; - } - - const nodeData = JSON.parse(mutation.after) as LocalNodeMutationData; - const existingNode = await database - .selectFrom('nodes') - .where('id', '=', nodeData.id) - .executeTakeFirst(); - - if (existingNode) { - return; - } - - await database - .insertInto('nodes') - .values({ - id: nodeData.id, - attributes: nodeData.attributes, - workspace_id: workspaceId, - state: nodeData.state, - created_at: new Date(nodeData.created_at), - created_by: nodeData.created_by, - version_id: nodeData.version_id, - server_created_at: new Date(), - }) - .execute(); -}; - -const handleUpdateNodeMutation = async ( - workspaceId: string, - mutation: LocalMutation, -): Promise => { - if (!mutation.after) { - return; - } - - const nodeData = JSON.parse(mutation.after) as LocalNodeMutationData; - const existingNode = await database - .selectFrom('nodes') - .select(['id', 'workspace_id', 'state']) - .where('id', '=', nodeData.id) - .executeTakeFirst(); - - if (!existingNode || existingNode.workspace_id != workspaceId) { - return; - } - - const updatedAt = nodeData.updated_at - ? new Date(nodeData.updated_at) - : new Date(); - const updatedBy = nodeData.updated_by ?? nodeData.created_by; - - const doc = new Y.Doc({ - guid: nodeData.id, - }); - - Y.applyUpdate(doc, toUint8Array(existingNode.state)); - Y.applyUpdate(doc, toUint8Array(nodeData.state)); - - const attributesMap = doc.getMap('attributes'); - const attributes = JSON.stringify(attributesMap.toJSON()); - const encodedState = fromUint8Array(Y.encodeStateAsUpdate(doc)); - - await database - .updateTable('nodes') - .set({ - attributes: attributes, - state: encodedState, - updated_at: updatedAt, - updated_by: updatedBy, - version_id: nodeData.version_id, - server_updated_at: new Date(), - }) - .where('id', '=', nodeData.id) - .execute(); -}; - -const handleDeleteNodeMutation = async ( - workspaceId: string, - mutation: LocalMutation, -): Promise => { - if (!mutation.before) { - return; - } - - const nodeData = JSON.parse(mutation.before) as LocalNodeMutationData; - const existingNode = await database - .selectFrom('nodes') - .where('id', '=', nodeData.id) - .select(['id', 'workspace_id']) - .executeTakeFirst(); - - if (!existingNode || existingNode.workspace_id !== workspaceId) { - return; - } - - await database.deleteFrom('nodes').where('id', '=', nodeData.id).execute(); -}; - -const handleCreateNodeCollaboratorMutation = async ( - workspaceId: string, - mutation: LocalMutation, -): Promise => { - if (!mutation.after) { - return; - } - - const nodeCollaboratorData = JSON.parse( - mutation.after, - ) as LocalNodeCollaboratorMutationData; - await database - .insertInto('node_collaborators') - .values({ - node_id: nodeCollaboratorData.node_id, - collaborator_id: nodeCollaboratorData.collaborator_id, - role: nodeCollaboratorData.role, - workspace_id: workspaceId, - created_at: new Date(nodeCollaboratorData.created_at), - created_by: nodeCollaboratorData.created_by, - server_created_at: new Date(), - version_id: nodeCollaboratorData.version_id, - }) - .onConflict((ob) => ob.doNothing()) - .execute(); -}; - -const handleUpdateNodeCollaboratorMutation = async ( - workspaceId: string, - mutation: LocalMutation, -): Promise => { - if (!mutation.after) { - return; - } - - const nodeCollaboratorData = JSON.parse( - mutation.after, - ) as LocalNodeCollaboratorMutationData; - - const existingNodeCollaborator = await database - .selectFrom('node_collaborators') - .selectAll() - .where((eb) => - eb.and([ - eb('node_id', '=', nodeCollaboratorData.node_id), - eb('collaborator_id', '=', nodeCollaboratorData.collaborator_id), - ]), - ) - .executeTakeFirst(); - - if ( - !existingNodeCollaborator || - existingNodeCollaborator.workspace_id != workspaceId || - existingNodeCollaborator.updated_at === null || - existingNodeCollaborator.updated_by === null - ) { - return; - } - - if (existingNodeCollaborator.role === nodeCollaboratorData.role) { - return; - } - - const updatedAt = new Date(existingNodeCollaborator.updated_at); - if (existingNodeCollaborator.server_updated_at !== null) { - const serverUpdatedAt = new Date( - existingNodeCollaborator.server_updated_at, - ); - if (serverUpdatedAt > updatedAt) { - return; +): Promise => { + switch (mutation.table) { + case 'nodes': { + return handleNodeMutation(workspaceAccount, mutation); + } + case 'node_collaborators': { + return handleNodeCollaboratorMutation(workspaceAccount, mutation); + } + case 'node_reactions': { + return handleNodeReactionMutation(workspaceAccount, mutation); } } - await database - .updateTable('node_collaborators') - .set({ - role: nodeCollaboratorData.role, - updated_at: updatedAt, - updated_by: - nodeCollaboratorData.updated_by ?? existingNodeCollaborator.created_by, - version_id: nodeCollaboratorData.version_id, - server_updated_at: new Date(), - }) - .where((eb) => - eb.and([ - eb('node_id', '=', nodeCollaboratorData.node_id), - eb('collaborator_id', '=', nodeCollaboratorData.collaborator_id), - ]), - ) - .execute(); -}; - -const handleDeleteNodeCollaboratorMutation = async ( - workspaceId: string, - mutation: LocalMutation, -): Promise => { - if (!mutation.before) { - return; - } - - const nodeCollaboratorData = JSON.parse( - mutation.before, - ) as LocalNodeCollaboratorMutationData; - - const existingNodeCollaborator = await database - .selectFrom('node_collaborators') - .selectAll() - .where((eb) => - eb.and([ - eb('node_id', '=', nodeCollaboratorData.node_id), - eb('collaborator_id', '=', nodeCollaboratorData.collaborator_id), - ]), - ) - .executeTakeFirst(); - - if ( - !existingNodeCollaborator || - existingNodeCollaborator.workspace_id != workspaceId - ) { - return; - } - - await database - .deleteFrom('node_collaborators') - .where((eb) => - eb.and([ - eb('node_id', '=', nodeCollaboratorData.node_id), - eb('collaborator_id', '=', nodeCollaboratorData.collaborator_id), - ]), - ) - .execute(); -}; - -const handleCreateNodeReactionMutation = async ( - workspaceId: string, - mutation: LocalMutation, -): Promise => { - if (!mutation.after) { - return; - } - - const nodeReactionData = JSON.parse( - mutation.after, - ) as LocalNodeReactionMutationData; - await database - .insertInto('node_reactions') - .values({ - node_id: nodeReactionData.node_id, - reactor_id: nodeReactionData.reactor_id, - reaction: nodeReactionData.reaction, - created_at: new Date(nodeReactionData.created_at), - workspace_id: workspaceId, - server_created_at: new Date(), - }) - .onConflict((ob) => ob.doNothing()) - .execute(); -}; - -const handleDeleteNodeReactionMutation = async ( - mutation: LocalMutation, -): Promise => { - if (!mutation.before) { - return; - } - - const nodeReactionData = JSON.parse( - mutation.before, - ) as LocalNodeReactionMutationData; - - await database - .deleteFrom('node_reactions') - .where((eb) => - eb.and([ - eb('node_id', '=', nodeReactionData.node_id), - eb('reactor_id', '=', nodeReactionData.reactor_id), - eb('reaction', '=', nodeReactionData.reaction), - ]), - ) - .execute(); + return { + status: 'error', + }; }; diff --git a/server/src/types/mutations.ts b/server/src/types/mutations.ts index 503ea7a3..347b2124 100644 --- a/server/src/types/mutations.ts +++ b/server/src/types/mutations.ts @@ -3,6 +3,17 @@ export type ExecuteLocalMutationsInput = { mutations: LocalMutation[]; }; +export type ExecuteLocalMutationResult = { + status: ExecuteLocalMutationStatus; +}; + +export type ExecuteLocalMutationStatus = 'success' | 'error'; + +export type ServerExecuteMutationResult = { + id: number; + status: ExecuteLocalMutationStatus; +}; + export type LocalMutation = { id: number; table: string;