Add workspace as root node

This commit is contained in:
Hakan Shehu
2024-11-10 20:02:25 +01:00
parent 4f77314c6b
commit 35182bdb3d
25 changed files with 315 additions and 396 deletions

View File

@@ -109,6 +109,7 @@ const createNodesTable: Migration = {
.stored()
.references('nodes.id')
.onDelete('cascade')
.notNull()
)
.addColumn('index', 'varchar(30)', (col) =>
col.generatedAlwaysAs(sql`(attributes->>'index')::VARCHAR(30)`).stored()
@@ -158,7 +159,7 @@ const createNodePathsTable: Migration = {
INSERT INTO node_paths (ancestor_id, descendant_id, workspace_id, level)
SELECT ancestor_id, NEW.id, NEW.workspace_id, level + 1
FROM node_paths
WHERE descendant_id = NEW.parent_id;
WHERE descendant_id = NEW.parent_id AND ancestor_id <> NEW.id;
RETURN NEW;
END;
@@ -179,7 +180,7 @@ const createNodePathsTable: Migration = {
INSERT INTO node_paths (ancestor_id, descendant_id, workspace_id, level)
SELECT ancestor_id, NEW.id, NEW.workspace_id, level + 1
FROM node_paths
WHERE descendant_id = NEW.parent_id;
WHERE descendant_id = NEW.parent_id AND ancestor_id <> NEW.id;
END IF;
RETURN NEW;

View File

@@ -5,24 +5,38 @@ import {
WorkspaceStatus,
WorkspaceUserStatus,
} from '@/types/workspaces';
import { generateId, IdType, NodeRoles } from '@colanode/core';
import * as Y from 'yjs';
import { fromUint8Array } from 'js-base64';
import {
ChannelAttributes,
generateId,
IdType,
NodeRoles,
PageAttributes,
SpaceAttributes,
UserAttributes,
WorkspaceAttributes,
} from '@colanode/core';
import { NodeCreatedEvent } from '@/types/events';
import { enqueueEvent } from '@/queues/events';
import {} from './constants';
import { YDoc } from '@colanode/crdt';
export const createDefaultWorkspace = async (account: SelectAccount) => {
const createdAt = new Date();
const workspaceId = generateId(IdType.Workspace);
const workspaceName = `${account.name}'s Workspace`;
const workspaceVersionId = generateId(IdType.Version);
const user = buildUserNodeCreate(workspaceId, account);
const workspace = buildWorkspaceNodeCreate(
workspaceId,
workspaceName,
workspaceVersionId,
user.id
);
const space = buildSpaceNodeCreate(workspaceId, user.id);
const page = buildPageNodeCreate(workspaceId, space.id, user.id);
const channel = buildChannelNodeCreate(workspaceId, space.id, user.id);
const nodesToCreate = [user, space, page, channel];
const nodesToCreate = [workspace, user, space, page, channel];
await database.transaction().execute(async (trx) => {
await trx
@@ -62,26 +76,54 @@ export const createDefaultWorkspace = async (account: SelectAccount) => {
}
};
const buildWorkspaceNodeCreate = (
workspaceId: string,
workspaceName: string,
workspaceVersionId: string,
userId: string
): CreateNode => {
const attributes: WorkspaceAttributes = {
type: 'workspace',
name: workspaceName,
parentId: workspaceId,
};
const ydoc = new YDoc(workspaceId);
ydoc.updateAttributes(attributes);
const state = ydoc.getState();
return {
id: workspaceId,
workspace_id: workspaceId,
created_at: new Date(),
created_by: userId,
version_id: workspaceVersionId,
server_created_at: new Date(),
attributes: JSON.stringify(attributes),
state,
};
};
const buildUserNodeCreate = (
workspaceId: string,
account: SelectAccount
): CreateNode => {
const id = generateId(IdType.User);
const versionId = generateId(IdType.Version);
const doc = new Y.Doc({ guid: id });
const attributesMap = doc.getMap('attributes');
doc.transact(() => {
attributesMap.set('type', 'user');
attributesMap.set('name', account.name);
attributesMap.set('avatar', account.avatar);
attributesMap.set('email', account.email);
attributesMap.set('role', WorkspaceRole.Owner);
attributesMap.set('accountId', account.id);
});
const attributes: UserAttributes = {
type: 'user',
name: account.name,
avatar: account.avatar,
email: account.email,
role: 'owner',
accountId: account.id,
parentId: workspaceId,
};
const attributes = JSON.stringify(attributesMap.toJSON());
const state = Y.encodeStateAsUpdate(doc);
const ydoc = new YDoc(id);
ydoc.updateAttributes(attributes);
const state = ydoc.getState();
return {
id,
@@ -90,7 +132,7 @@ const buildUserNodeCreate = (
created_by: account.id,
version_id: versionId,
server_created_at: new Date(),
attributes,
attributes: JSON.stringify(attributes),
state,
};
};
@@ -101,24 +143,20 @@ const buildSpaceNodeCreate = (
): CreateNode => {
const id = generateId(IdType.Space);
const versionId = generateId(IdType.Version);
const doc = new Y.Doc({ guid: id });
const attributesMap = doc.getMap('attributes');
doc.transact(() => {
attributesMap.set('type', 'space');
attributesMap.set('name', 'Home');
attributesMap.set('description', 'Home space');
const attributes: SpaceAttributes = {
type: 'space',
name: 'Home',
description: 'Home space',
parentId: workspaceId,
collaborators: {
[userId]: NodeRoles.Admin,
},
};
attributesMap.set('collaborators', new Y.Map());
const collaboratorsMap = attributesMap.get(
'collaborators'
) as Y.Map<string>;
collaboratorsMap.set(userId, NodeRoles.Admin);
});
const attributes = JSON.stringify(attributesMap.toJSON());
const state = Y.encodeStateAsUpdate(doc);
const ydoc = new YDoc(id);
ydoc.updateAttributes(attributes);
const state = ydoc.getState();
return {
id,
@@ -127,7 +165,7 @@ const buildSpaceNodeCreate = (
created_by: userId,
version_id: versionId,
server_created_at: new Date(),
attributes,
attributes: JSON.stringify(attributes),
state,
};
};
@@ -139,17 +177,17 @@ const buildPageNodeCreate = (
): CreateNode => {
const id = generateId(IdType.Page);
const versionId = generateId(IdType.Version);
const doc = new Y.Doc({ guid: id });
const attributesMap = doc.getMap('attributes');
doc.transact(() => {
attributesMap.set('type', 'page');
attributesMap.set('name', 'Notes');
attributesMap.set('parentId', spaceId);
});
const attributes: PageAttributes = {
type: 'page',
name: 'Notes',
parentId: spaceId,
content: {},
};
const attributes = JSON.stringify(attributesMap.toJSON());
const state = Y.encodeStateAsUpdate(doc);
const ydoc = new YDoc(id);
ydoc.updateAttributes(attributes);
const state = ydoc.getState();
return {
id,
@@ -158,7 +196,7 @@ const buildPageNodeCreate = (
created_by: userId,
version_id: versionId,
server_created_at: new Date(),
attributes,
attributes: JSON.stringify(attributes),
state,
};
};
@@ -170,17 +208,17 @@ const buildChannelNodeCreate = (
): CreateNode => {
const id = generateId(IdType.Channel);
const versionId = generateId(IdType.Version);
const doc = new Y.Doc({ guid: id });
const attributesMap = doc.getMap('attributes');
doc.transact(() => {
attributesMap.set('type', 'channel');
attributesMap.set('parentId', spaceId);
attributesMap.set('name', 'Discussions');
});
const attributes: ChannelAttributes = {
type: 'channel',
name: 'Discussions',
parentId: spaceId,
index: '0',
};
const attributes = JSON.stringify(attributesMap.toJSON());
const state = Y.encodeStateAsUpdate(doc);
const ydoc = new YDoc(id);
ydoc.updateAttributes(attributes);
const state = ydoc.getState();
return {
id,
@@ -189,7 +227,7 @@ const buildChannelNodeCreate = (
created_by: userId,
version_id: versionId,
server_created_at: new Date(),
attributes,
attributes: JSON.stringify(attributes),
state,
};
};

View File

@@ -99,7 +99,12 @@ const handleNodeDeletedEvent = async (
const createUserNodes = async (event: NodeCreatedEvent): Promise<void> => {
const userNodesToCreate: CreateUserNode[] = [];
if (event.attributes.type === NodeTypes.User) {
const isForEveryone =
event.attributes.type === NodeTypes.User ||
event.attributes.type === NodeTypes.Workspace;
if (isForEveryone) {
const userIds = await fetchWorkspaceUsers(event.workspaceId);
for (const userId of userIds) {

View File

@@ -11,21 +11,20 @@ import {
} from '@/types/accounts';
import axios from 'axios';
import { ApiError, ColanodeRequest, ColanodeResponse } from '@/types/api';
import { generateId, IdType, NodeAttributes } from '@colanode/core';
import { generateId, IdType } from '@colanode/core';
import { database } from '@/data/database';
import bcrypt from 'bcrypt';
import { WorkspaceOutput, WorkspaceRole } from '@/types/workspaces';
import { authMiddleware } from '@/middlewares/auth';
import { generateToken } from '@/lib/tokens';
import { mapServerNode } from '@/lib/nodes';
import { enqueueTask } from '@/queues/tasks';
import * as Y from 'yjs';
import { CompiledQuery } from 'kysely';
import { NodeUpdatedEvent } from '@/types/events';
import { enqueueEvent } from '@/queues/events';
import { SelectAccount } from '@/data/schema';
import { createDefaultWorkspace } from '@/lib/workspaces';
import { sha256 } from 'js-sha256';
import { YDoc } from '@colanode/crdt';
const GoogleUserInfoUrl = 'https://www.googleapis.com/oauth2/v1/userinfo';
const SaltRounds = 10;
@@ -321,21 +320,15 @@ accountsRouter.put(
continue;
}
const doc = new Y.Doc({ guid: user.id });
Y.applyUpdate(doc, user.state);
const ydoc = new YDoc(user.id, user.state);
ydoc.updateAttributes({
...user.attributes,
name: input.name,
avatar: input.avatar ?? null,
});
const attributesMap = doc.getMap('attributes');
if (name != input.name) {
attributesMap.set('name', input.name);
}
if (avatar != input.avatar) {
attributesMap.set('avatar', input.avatar);
}
const attributes = attributesMap.toJSON() as NodeAttributes;
const attributesJson = JSON.stringify(attributes);
const state = Y.encodeStateAsUpdate(doc);
const attributes = ydoc.getAttributes();
const state = ydoc.getState();
const updatedAt = new Date();
const versionId = generateId(IdType.Version);
@@ -344,7 +337,7 @@ accountsRouter.put(
database
.updateTable('nodes')
.set({
attributes: attributesJson,
attributes: JSON.stringify(attributes),
state: state,
updated_at: updatedAt,
updated_by: user.id,
@@ -418,13 +411,6 @@ const buildLoginOutput = async (
.selectAll()
.execute();
const userIds = workspaceUsers.map((wu) => wu.id);
const userNodes = await database
.selectFrom('nodes')
.selectAll()
.where('id', 'in', userIds)
.execute();
for (const workspaceUser of workspaceUsers) {
const workspace = workspaces.find(
(w) => w.id === workspaceUser.workspace_id
@@ -434,11 +420,6 @@ const buildLoginOutput = async (
continue;
}
const userNode = userNodes.find((n) => n.id === workspaceUser.id);
if (!userNode) {
continue;
}
workspaceOutputs.push({
id: workspace.id,
name: workspace.name,
@@ -449,7 +430,6 @@ const buildLoginOutput = async (
id: workspaceUser.id,
accountId: workspaceUser.account_id,
role: workspaceUser.role as WorkspaceRole,
node: mapServerNode(userNode),
},
});
}

View File

@@ -118,13 +118,7 @@ const handleCreateNodeChange = async (
const ydoc = new YDoc(changeData.id, changeData.state);
const attributes = ydoc.getAttributes();
const model = registry[attributes.type];
if (!model) {
return {
status: 'error',
};
}
const model = registry.getModel(attributes.type);
if (!model.schema.safeParse(attributes).success) {
return {
status: 'error',
@@ -212,13 +206,7 @@ const handleUpdateNodeChange = async (
const attributesJson = JSON.stringify(attributes);
const state = ydoc.getState();
const model = registry[attributes.type];
if (!model) {
return {
status: 'error',
};
}
const model = registry.getModel(attributes.type);
if (!model.schema.safeParse(attributes).success) {
return {
status: 'error',
@@ -309,13 +297,7 @@ const handleDeleteNodeChange = async (
};
}
const model = registry[existingNode.type];
if (!model) {
return {
status: 'error',
};
}
const model = registry.getModel(existingNode.type);
const ancestorRows = await fetchNodeAncestors(existingNode.id);
const ancestors = ancestorRows.map(mapNode);
const node = ancestors.find((ancestor) => ancestor.id === existingNode.id);

View File

@@ -8,11 +8,14 @@ import {
WorkspaceStatus,
} from '@/types/workspaces';
import { ApiError, ColanodeRequest, ColanodeResponse } from '@/types/api';
import { generateId, IdType } from '@colanode/core';
import {
generateId,
IdType,
UserAttributes,
WorkspaceAttributes,
} from '@colanode/core';
import { database } from '@/data/database';
import { Router } from 'express';
import * as Y from 'yjs';
import { fromUint8Array, toUint8Array } from 'js-base64';
import {
CreateAccount,
CreateNode,
@@ -26,6 +29,7 @@ import { ServerNode } from '@/types/nodes';
import { mapServerNode } from '@/lib/nodes';
import { NodeCreatedEvent } from '@/types/events';
import { enqueueEvent } from '@/queues/events';
import { YDoc } from '@colanode/crdt';
export const workspacesRouter = Router();
@@ -62,25 +66,36 @@ workspacesRouter.post(
}
const createdAt = new Date();
const workspaceId = generateId(IdType.Workspace);
const workspaceVersionId = generateId(IdType.Version);
const workspaceDoc = new YDoc(workspaceId);
const workspaceAttributes: WorkspaceAttributes = {
type: 'workspace',
name: input.name,
description: input.description,
avatar: input.avatar,
parentId: workspaceId,
};
workspaceDoc.updateAttributes(workspaceAttributes);
const userId = generateId(IdType.User);
const userVersionId = generateId(IdType.Version);
const userDoc = new Y.Doc({ guid: userId });
const userDoc = new YDoc(userId);
const userAttributesMap = userDoc.getMap('attributes');
userDoc.transact(() => {
userAttributesMap.set('type', 'user');
userAttributesMap.set('name', account.name);
userAttributesMap.set('avatar', account.avatar);
userAttributesMap.set('email', account.email);
userAttributesMap.set('role', WorkspaceRole.Owner);
userAttributesMap.set('accountId', account.id);
});
const userAttributes: UserAttributes = {
type: 'user',
name: account.name,
avatar: account.avatar,
email: account.email,
accountId: account.id,
role: WorkspaceRole.Owner,
parentId: workspaceId,
};
const userAttributes = JSON.stringify(userAttributesMap.toJSON());
const userState = Y.encodeStateAsUpdate(userDoc);
userDoc.updateAttributes(userAttributes);
await database.transaction().execute(async (trx) => {
await trx
@@ -111,13 +126,27 @@ workspacesRouter.post(
})
.execute();
await trx
.insertInto('nodes')
.values({
id: workspaceId,
workspace_id: workspaceId,
attributes: JSON.stringify(workspaceAttributes),
state: workspaceDoc.getState(),
created_at: createdAt,
created_by: account.id,
version_id: workspaceVersionId,
server_created_at: createdAt,
})
.execute();
await trx
.insertInto('nodes')
.values({
id: userId,
workspace_id: workspaceId,
attributes: userAttributes,
state: userState,
attributes: JSON.stringify(userAttributes),
state: userDoc.getState(),
created_at: createdAt,
created_by: account.id,
version_id: userVersionId,
@@ -126,11 +155,24 @@ workspacesRouter.post(
.execute();
});
const workspaceEvent: NodeCreatedEvent = {
type: 'node_created',
id: workspaceId,
workspaceId: workspaceId,
attributes: workspaceAttributes,
createdBy: account.id,
createdAt: createdAt.toISOString(),
versionId: workspaceVersionId,
serverCreatedAt: createdAt.toISOString(),
};
await enqueueEvent(workspaceEvent);
const userEvent: NodeCreatedEvent = {
type: 'node_created',
id: userId,
workspaceId: workspaceId,
attributes: JSON.parse(userAttributes),
attributes: userAttributes,
createdBy: account.id,
createdAt: createdAt.toISOString(),
versionId: userVersionId,
@@ -149,18 +191,6 @@ workspacesRouter.post(
id: userId,
accountId: account.id,
role: WorkspaceRole.Owner,
node: {
id: userId,
workspaceId: workspaceId,
type: 'user',
attributes: JSON.parse(userAttributes),
state: fromUint8Array(userState),
createdAt: new Date(),
createdBy: account.id,
versionId: userVersionId,
serverCreatedAt: new Date(),
index: null,
},
},
};
@@ -215,33 +245,6 @@ workspacesRouter.put(
});
}
if (!workspaceUser) {
return res.status(403).json({
code: ApiError.Forbidden,
message: 'Forbidden.',
});
}
if (workspaceUser.role !== WorkspaceRole.Owner) {
return res.status(403).json({
code: ApiError.Forbidden,
message: 'Forbidden.',
});
}
const userNode = await database
.selectFrom('nodes')
.selectAll()
.where('id', '=', workspaceUser.id)
.executeTakeFirst();
if (!userNode) {
return res.status(500).json({
code: ApiError.InternalServerError,
message: 'Internal server error.',
});
}
const updatedWorkspace = await database
.updateTable('workspaces')
.set({
@@ -272,7 +275,6 @@ workspacesRouter.put(
id: workspaceUser.id,
accountId: workspaceUser.account_id,
role: workspaceUser.role,
node: mapServerNode(userNode),
},
};
@@ -373,19 +375,6 @@ workspacesRouter.get(
});
}
const userNode = await database
.selectFrom('nodes')
.selectAll()
.where('id', '=', workspaceUser.id)
.executeTakeFirst();
if (!userNode) {
return res.status(500).json({
code: ApiError.InternalServerError,
message: 'Internal server error.',
});
}
const output: WorkspaceOutput = {
id: workspace.id,
name: workspace.name,
@@ -396,7 +385,6 @@ workspacesRouter.get(
id: workspaceUser.id,
accountId: workspaceUser.account_id,
role: workspaceUser.role as WorkspaceRole,
node: mapServerNode(userNode),
},
};
@@ -427,18 +415,7 @@ workspacesRouter.get(
.where('id', 'in', workspaceIds)
.execute();
const userNodes = await database
.selectFrom('nodes')
.selectAll()
.where(
'id',
'in',
workspaceUsers.map((wa) => wa.id)
)
.execute();
const outputs: WorkspaceOutput[] = [];
for (const workspace of workspaces) {
const workspaceUser = workspaceUsers.find(
(wa) => wa.workspace_id === workspace.id
@@ -448,12 +425,6 @@ workspacesRouter.get(
continue;
}
const userNode = userNodes.find((un) => un.id === workspaceUser.id);
if (!userNode) {
continue;
}
const output: WorkspaceOutput = {
id: workspace.id,
name: workspace.name,
@@ -464,7 +435,6 @@ workspacesRouter.get(
id: workspaceUser.id,
accountId: workspaceUser.account_id,
role: workspaceUser.role as WorkspaceRole,
node: mapServerNode(userNode),
},
};
@@ -616,20 +586,19 @@ workspacesRouter.post(
const userId = generateId(IdType.User);
const userVersionId = generateId(IdType.Version);
const userDoc = new Y.Doc({ guid: userId });
const userDoc = new YDoc(userId);
const userAttributesMap = userDoc.getMap('attributes');
userDoc.transact(() => {
userAttributesMap.set('type', 'user');
userAttributesMap.set('name', account!.name);
userAttributesMap.set('avatar', account!.avatar);
userAttributesMap.set('email', account!.email);
userAttributesMap.set('role', WorkspaceRole.Collaborator);
userAttributesMap.set('accountId', account!.id);
});
const userAttributes: UserAttributes = {
type: 'user',
name: account!.name,
avatar: account!.avatar,
email: account!.email,
role: WorkspaceRole.Collaborator,
accountId: account!.id,
parentId: workspace.id,
};
const userAttributes = JSON.stringify(userAttributesMap.toJSON());
const userState = Y.encodeStateAsUpdate(userDoc);
userDoc.updateAttributes(userAttributes);
workspaceUsersToCreate.push({
id: userId,
@@ -639,14 +608,14 @@ workspacesRouter.post(
created_at: new Date(),
created_by: req.account.id,
status: WorkspaceUserStatus.Active,
version_id: generateId(IdType.Version),
version_id: userVersionId,
});
const user: ServerNode = {
id: userId,
type: 'user',
attributes: JSON.parse(userAttributes),
state: fromUint8Array(userState),
attributes: userAttributes,
state: userDoc.getEncodedState(),
createdAt: new Date(),
createdBy: workspaceUser.id,
serverCreatedAt: new Date(),
@@ -657,8 +626,8 @@ workspacesRouter.post(
usersToCreate.push({
id: user.id,
attributes: userAttributes,
state: userState,
attributes: JSON.stringify(userAttributes),
state: userDoc.getState(),
created_at: user.createdAt,
created_by: user.createdBy,
server_created_at: user.serverCreatedAt,
@@ -790,20 +759,20 @@ workspacesRouter.put(
});
}
const userDoc = new Y.Doc({ guid: user.id });
Y.applyUpdate(userDoc, user.state);
const attributes = user.attributes;
if (attributes.type !== 'user') {
return res.status(400).json({
code: ApiError.BadRequest,
message: 'BadRequest.',
});
}
const userUpdates: string[] = [];
userDoc.on('update', (update) => {
userUpdates.push(fromUint8Array(update));
});
const userAttributesMap = userDoc.getMap('attributes');
userAttributesMap.set('role', input.role);
const userAttributes = JSON.stringify(userAttributesMap.toJSON());
const state = Y.encodeStateAsUpdate(userDoc);
const updatedAt = new Date();
const userDoc = new YDoc(user.id, user.state);
userDoc.updateAttributes({
...attributes,
role: input.role,
});
const userNode: ServerNode = {
id: user.id,
@@ -811,8 +780,8 @@ workspacesRouter.put(
workspaceId: user.workspace_id,
index: null,
parentId: null,
attributes: JSON.parse(userAttributes),
state: fromUint8Array(state),
attributes: userDoc.getAttributes(),
state: userDoc.getEncodedState(),
createdAt: user.created_at,
createdBy: user.created_by,
serverCreatedAt: user.server_created_at,
@@ -837,8 +806,8 @@ workspacesRouter.put(
await trx
.updateTable('nodes')
.set({
attributes: userAttributes,
state: state,
attributes: JSON.stringify(userDoc.getAttributes()),
state: userDoc.getState(),
server_updated_at: updatedAt,
updated_at: updatedAt,
updated_by: currentWorkspaceUser.id,

View File

@@ -1,5 +1,3 @@
import { ServerNode } from '@/types/nodes';
export enum WorkspaceStatus {
Active = 1,
Inactive = 2,
@@ -49,7 +47,6 @@ export type WorkspaceUserOutput = {
id: string;
accountId: string;
role: WorkspaceRole;
node: ServerNode;
};
export type WorkspaceAccountsInviteInput = {