diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 048bdf9e..31f99668 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -38,7 +38,7 @@ "electron": "^33.2.1", "postcss": "^8.4.49", "tailwindcss": "^3.4.15", - "vite": "^6.0.1" + "vite": "^6.0.6" }, "dependencies": { "@colanode/core": "*", @@ -60,39 +60,39 @@ "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-tooltip": "^1.1.4", "@radix-ui/react-visually-hidden": "^1.1.0", - "@tanstack/react-query": "^5.62.0", - "@tiptap/core": "^2.10.3", - "@tiptap/extension-blockquote": "^2.10.3", - "@tiptap/extension-bold": "^2.10.3", - "@tiptap/extension-bullet-list": "^2.10.3", - "@tiptap/extension-code": "^2.10.3", - "@tiptap/extension-code-block-lowlight": "^2.10.3", - "@tiptap/extension-document": "^2.10.3", - "@tiptap/extension-dropcursor": "^2.10.3", - "@tiptap/extension-horizontal-rule": "^2.10.3", - "@tiptap/extension-italic": "^2.10.3", - "@tiptap/extension-link": "^2.10.3", - "@tiptap/extension-list-item": "^2.10.3", - "@tiptap/extension-list-keymap": "^2.10.3", - "@tiptap/extension-ordered-list": "^2.10.3", - "@tiptap/extension-paragraph": "^2.10.3", - "@tiptap/extension-placeholder": "^2.10.3", - "@tiptap/extension-strike": "^2.10.3", - "@tiptap/extension-task-item": "^2.10.3", - "@tiptap/extension-task-list": "^2.10.3", - "@tiptap/extension-text": "^2.10.3", - "@tiptap/extension-underline": "^2.10.3", - "@tiptap/react": "^2.10.3", - "@tiptap/suggestion": "^2.10.3", + "@tanstack/react-query": "^5.62.11", + "@tiptap/core": "^2.11.0", + "@tiptap/extension-blockquote": "^2.11.0", + "@tiptap/extension-bold": "^2.11.0", + "@tiptap/extension-bullet-list": "^2.11.0", + "@tiptap/extension-code": "^2.11.0", + "@tiptap/extension-code-block-lowlight": "^2.11.0", + "@tiptap/extension-document": "^2.11.0", + "@tiptap/extension-dropcursor": "^2.11.0", + "@tiptap/extension-horizontal-rule": "^2.11.0", + "@tiptap/extension-italic": "^2.11.0", + "@tiptap/extension-link": "^2.11.0", + "@tiptap/extension-list-item": "^2.11.0", + "@tiptap/extension-list-keymap": "^2.11.0", + "@tiptap/extension-ordered-list": "^2.11.0", + "@tiptap/extension-paragraph": "^2.11.0", + "@tiptap/extension-placeholder": "^2.11.0", + "@tiptap/extension-strike": "^2.11.0", + "@tiptap/extension-task-item": "^2.11.0", + "@tiptap/extension-task-list": "^2.11.0", + "@tiptap/extension-text": "^2.11.0", + "@tiptap/extension-underline": "^2.11.0", + "@tiptap/react": "^2.11.0", + "@tiptap/suggestion": "^2.11.0", "better-sqlite3": "^11.6.0", - "bufferutil": "^4.0.8", + "bufferutil": "^4.0.9", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.4", "electron-squirrel-startup": "^1.0.1", "is-hotkey": "^0.2.0", "lowlight": "^3.2.0", - "lucide-react": "^0.462.0", + "lucide-react": "^0.469.0", "mime-types": "^2.1.35", "re-resizable": "^6.10.1", "react": "^18.3.1", @@ -101,14 +101,14 @@ "react-dnd-html5-backend": "^16.0.1", "react-dom": "^18.3.1", "react-hook-form": "^7.53.2", - "react-intersection-observer": "^9.13.1", + "react-intersection-observer": "^9.14.1", "react-router-dom": "^7.0.1", "react-virtualized-auto-sizer": "^1.0.24", "react-window": "^1.8.10", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", "ts-pattern": "^5.5.0", - "utf-8-validate": "^5.0.10", + "utf-8-validate": "^6.0.5", "ws": "^8.18.0" } } diff --git a/apps/desktop/src/main/data/app/migrations.ts b/apps/desktop/src/main/data/app/migrations.ts index e3ebffd8..ff921f33 100644 --- a/apps/desktop/src/main/data/app/migrations.ts +++ b/apps/desktop/src/main/data/app/migrations.ts @@ -71,7 +71,6 @@ const createWorkspacesTable: Migration = { .addColumn('name', 'text', (col) => col.notNull()) .addColumn('description', 'text') .addColumn('avatar', 'text') - .addColumn('version_id', 'text', (col) => col.notNull()) .addColumn('role', 'text', (col) => col.notNull()) .execute(); }, diff --git a/apps/desktop/src/main/data/app/schema.ts b/apps/desktop/src/main/data/app/schema.ts index 1113b513..e8bf1ee6 100644 --- a/apps/desktop/src/main/data/app/schema.ts +++ b/apps/desktop/src/main/data/app/schema.ts @@ -37,7 +37,6 @@ interface WorkspaceTable { name: ColumnType; description: ColumnType; avatar: ColumnType; - version_id: ColumnType; role: ColumnType; } diff --git a/apps/desktop/src/main/data/workspace/migrations.ts b/apps/desktop/src/main/data/workspace/migrations.ts index a682cd9d..c895ef3d 100644 --- a/apps/desktop/src/main/data/workspace/migrations.ts +++ b/apps/desktop/src/main/data/workspace/migrations.ts @@ -57,10 +57,10 @@ const createEntriesTable: Migration = { }, }; -const createTransactionsTable: Migration = { +const createEntryTransactionsTable: Migration = { up: async (db) => { await db.schema - .createTable('transactions') + .createTable('entry_transactions') .addColumn('id', 'text', (col) => col.notNull().primaryKey()) .addColumn('entry_id', 'text', (col) => col.notNull()) .addColumn('root_id', 'text', (col) => col.notNull()) @@ -73,7 +73,7 @@ const createTransactionsTable: Migration = { .execute(); }, down: async (db) => { - await db.schema.dropTable('transactions').execute(); + await db.schema.dropTable('entry_transactions').execute(); }, }; @@ -131,6 +131,7 @@ const createMessagesTable: Migration = { .addColumn('created_by', 'text', (col) => col.notNull()) .addColumn('updated_at', 'text') .addColumn('updated_by', 'text') + .addColumn('deleted_at', 'text') .addColumn('version', 'integer') .execute(); }, @@ -148,6 +149,7 @@ const createMessageReactionsTable: Migration = { .addColumn('reaction', 'text', (col) => col.notNull()) .addColumn('root_id', 'text', (col) => col.notNull()) .addColumn('created_at', 'text', (col) => col.notNull()) + .addColumn('deleted_at', 'text') .addColumn('version', 'integer', (col) => col.notNull()) .addPrimaryKeyConstraint('message_reactions_pkey', [ 'message_id', @@ -201,6 +203,7 @@ const createFilesTable: Migration = { .addColumn('created_by', 'text', (col) => col.notNull()) .addColumn('updated_at', 'text') .addColumn('updated_by', 'text') + .addColumn('deleted_at', 'text') .addColumn('status', 'integer', (col) => col.notNull()) .addColumn('version', 'integer') .execute(); @@ -363,7 +366,7 @@ export const workspaceDatabaseMigrations: Record = { '00001_create_users_table': createUsersTable, '00002_create_entries_table': createEntriesTable, '00003_create_entry_interactions_table': createEntryInteractionsTable, - '00004_create_transactions_table': createTransactionsTable, + '00004_create_entry_transactions_table': createEntryTransactionsTable, '00005_create_collaborations_table': createCollaborationsTable, '00006_create_messages_table': createMessagesTable, '00007_create_message_reactions_table': createMessageReactionsTable, diff --git a/apps/desktop/src/main/data/workspace/schema.ts b/apps/desktop/src/main/data/workspace/schema.ts index 9089e596..53ec6cd7 100644 --- a/apps/desktop/src/main/data/workspace/schema.ts +++ b/apps/desktop/src/main/data/workspace/schema.ts @@ -53,7 +53,7 @@ interface EntryPathTable { export type SelectEntryPath = Selectable; -interface TransactionTable { +interface EntryTransactionTable { id: ColumnType; entry_id: ColumnType; root_id: ColumnType; @@ -65,9 +65,9 @@ interface TransactionTable { version: ColumnType; } -export type SelectTransaction = Selectable; -export type CreateTransaction = Insertable; -export type UpdateTransaction = Updateable; +export type SelectEntryTransaction = Selectable; +export type CreateEntryTransaction = Insertable; +export type UpdateEntryTransaction = Updateable; interface EntryInteractionTable { entry_id: ColumnType; @@ -108,6 +108,7 @@ interface MessageTable { created_by: ColumnType; updated_at: ColumnType; updated_by: ColumnType; + deleted_at: ColumnType; version: ColumnType; } @@ -121,6 +122,7 @@ interface MessageReactionTable { reaction: ColumnType; root_id: ColumnType; created_at: ColumnType; + deleted_at: ColumnType; version: ColumnType; } @@ -157,6 +159,7 @@ interface FileTable { created_by: ColumnType; updated_at: ColumnType; updated_by: ColumnType; + deleted_at: ColumnType; status: ColumnType; version: ColumnType; } @@ -230,7 +233,7 @@ export interface WorkspaceDatabaseSchema { users: UserTable; entries: EntryTable; entry_interactions: EntryInteractionTable; - transactions: TransactionTable; + entry_transactions: EntryTransactionTable; entry_paths: EntryPathTable; collaborations: CollaborationTable; messages: MessageTable; diff --git a/apps/desktop/src/main/jobs/revert-invalid-mutations.ts b/apps/desktop/src/main/jobs/revert-invalid-mutations.ts new file mode 100644 index 00000000..ccfe9f92 --- /dev/null +++ b/apps/desktop/src/main/jobs/revert-invalid-mutations.ts @@ -0,0 +1,93 @@ +import { entryService } from '@/main/services/entry-service'; +import { fileService } from '@/main/services/file-service'; +import { messageService } from '@/main/services/message-service'; +import { createDebugger } from '@/main/debugger'; +import { databaseService } from '@/main/data/database-service'; +import { JobHandler } from '@/main/jobs'; +import { mapMutation } from '@/main/utils'; + +export type RevertInvalidMutationsInput = { + type: 'revert_invalid_mutations'; + userId: string; +}; + +declare module '@/main/jobs' { + interface JobMap { + revert_invalid_mutations: { + input: RevertInvalidMutationsInput; + }; + } +} + +export class RevertInvalidMutationsJobHandler + implements JobHandler +{ + public triggerDebounce = 100; + public interval = 1000 * 60 * 5; + + private readonly debug = createDebugger('job:revert-invalid-mutations'); + + public async handleJob(input: RevertInvalidMutationsInput) { + this.debug(`Reverting invalid mutations for user ${input.userId}`); + + const workspaceDatabase = await databaseService.getWorkspaceDatabase( + input.userId + ); + + const invalidMutations = await workspaceDatabase + .selectFrom('mutations') + .selectAll() + .where('retries', '>=', 10) + .execute(); + + if (invalidMutations.length === 0) { + this.debug( + `No invalid mutations found for user ${input.userId}, skipping` + ); + return; + } + + for (const mutationRow of invalidMutations) { + const mutation = mapMutation(mutationRow); + + if (mutation.type === 'create_file') { + await fileService.revertFileCreation(input.userId, mutation.id); + } else if (mutation.type === 'delete_file') { + await fileService.revertFileDeletion(input.userId, mutation.id); + } else if (mutation.type === 'apply_create_transaction') { + await entryService.revertCreateTransaction(input.userId, mutation.data); + } else if (mutation.type === 'apply_update_transaction') { + await entryService.revertUpdateTransaction(input.userId, mutation.data); + } else if (mutation.type === 'apply_delete_transaction') { + await entryService.revertDeleteTransaction(input.userId, mutation.data); + } else if (mutation.type === 'create_message') { + await messageService.revertMessageCreation( + input.userId, + mutation.data.id + ); + } else if (mutation.type === 'delete_message') { + await messageService.revertMessageDeletion( + input.userId, + mutation.data.id + ); + } else if (mutation.type === 'create_message_reaction') { + await messageService.revertMessageReactionCreation( + input.userId, + mutation.data + ); + } else if (mutation.type === 'delete_message_reaction') { + await messageService.revertMessageReactionDeletion( + input.userId, + mutation.data + ); + } + } + + const mutationIds = invalidMutations.map((m) => m.id); + + await workspaceDatabase + .deleteFrom('mutations') + .where('id', 'in', mutationIds) + .execute(); + } +} diff --git a/apps/desktop/src/main/jobs/revert-invalid-transactions.ts b/apps/desktop/src/main/jobs/revert-invalid-transactions.ts deleted file mode 100644 index 6ee899e4..00000000 --- a/apps/desktop/src/main/jobs/revert-invalid-transactions.ts +++ /dev/null @@ -1,61 +0,0 @@ -// import { entryService } from '@/main/services/entry-service'; -// import { createDebugger } from '@/main/debugger'; -// import { databaseService } from '@/main/data/database-service'; -// import { JobHandler } from '@/main/jobs'; -// import { mapTransaction } from '@/main/utils'; - -// export type RevertInvalidTransactionsInput = { -// type: 'revert_invalid_transactions'; -// userId: string; -// }; - -// declare module '@/main/jobs' { -// interface JobMap { -// revert_invalid_transactions: { -// input: RevertInvalidTransactionsInput; -// }; -// } -// } - -// export class RevertInvalidTransactionsJobHandler -// implements JobHandler -// { -// public triggerDebounce = 100; -// public interval = 1000 * 60 * 5; - -// private readonly debug = createDebugger('job:revert-invalid-transactions'); - -// public async handleJob(input: RevertInvalidTransactionsInput) { -// this.debug(`Reverting invalid transactions for user ${input.userId}`); - -// const workspaceDatabase = await databaseService.getWorkspaceDatabase( -// input.userId -// ); - -// const invalidTransactions = await workspaceDatabase -// .selectFrom('transactions') -// .selectAll() -// .where('status', '=', 'pending') -// .where('retry_count', '>=', 10) -// .execute(); - -// if (invalidTransactions.length === 0) { -// this.debug( -// `No invalid transactions found for user ${input.userId}, skipping` -// ); -// return; -// } - -// for (const transactionRow of invalidTransactions) { -// const transaction = mapTransaction(transactionRow); - -// if (transaction.operation === 'create') { -// await entryService.revertCreateTransaction(input.userId, transaction); -// } else if (transaction.operation === 'update') { -// await entryService.revertUpdateTransaction(input.userId, transaction); -// } else if (transaction.operation === 'delete') { -// await entryService.revertDeleteTransaction(input.userId, transaction); -// } -// } -// } -// } diff --git a/apps/desktop/src/main/mutations/accounts/account-logout.ts b/apps/desktop/src/main/mutations/accounts/account-logout.ts index 8ce444ca..3479505c 100644 --- a/apps/desktop/src/main/mutations/accounts/account-logout.ts +++ b/apps/desktop/src/main/mutations/accounts/account-logout.ts @@ -1,7 +1,7 @@ import { databaseService } from '@/main/data/database-service'; import { accountService } from '@/main/services/account-service'; import { MutationHandler } from '@/main/types'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; import { AccountLogoutMutationInput, AccountLogoutMutationOutput, @@ -21,7 +21,7 @@ export class AccountLogoutMutationHandler if (!account) { throw new MutationError( - 'account_not_found', + MutationErrorCode.AccountNotFound, 'Account was not found or has been logged out already. Try closing the app and opening it again.' ); } diff --git a/apps/desktop/src/main/mutations/accounts/account-update.ts b/apps/desktop/src/main/mutations/accounts/account-update.ts index 3b08f8d5..c9740d1f 100644 --- a/apps/desktop/src/main/mutations/accounts/account-update.ts +++ b/apps/desktop/src/main/mutations/accounts/account-update.ts @@ -8,7 +8,8 @@ import { AccountUpdateMutationInput, AccountUpdateMutationOutput, } from '@/shared/mutations/accounts/account-update'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; +import { parseApiError } from '@/shared/lib/axios'; export class AccountUpdateMutationHandler implements MutationHandler @@ -24,7 +25,7 @@ export class AccountUpdateMutationHandler if (!account) { throw new MutationError( - 'account_not_found', + MutationErrorCode.AccountNotFound, 'Account not found or has been logged out already. Try closing the app and opening it again.' ); } @@ -37,56 +38,61 @@ export class AccountUpdateMutationHandler if (!server) { throw new MutationError( - 'server_not_found', + MutationErrorCode.ServerNotFound, `The server ${account.server} associated with this account was not found. Try closing the app and opening it again.` ); } - const { data } = await httpClient.put( - `/v1/accounts/${input.id}`, - { - name: input.name, - avatar: input.avatar, - }, - { - domain: server.domain, - token: account.token, - } - ); - - const updatedAccount = await databaseService.appDatabase - .updateTable('accounts') - .set({ - name: data.name, - avatar: data.avatar, - }) - .where('id', '=', input.id) - .returningAll() - .executeTakeFirst(); - - if (!updatedAccount) { - throw new MutationError( - 'account_not_found', - 'Account not found or has been logged out already. Try closing the app and opening it again.' + try { + const { data } = await httpClient.put( + `/v1/accounts/${input.id}`, + { + name: input.name, + avatar: input.avatar, + }, + { + domain: server.domain, + token: account.token, + } ); + + const updatedAccount = await databaseService.appDatabase + .updateTable('accounts') + .set({ + name: data.name, + avatar: data.avatar, + }) + .where('id', '=', input.id) + .returningAll() + .executeTakeFirst(); + + if (!updatedAccount) { + throw new MutationError( + MutationErrorCode.AccountNotFound, + 'Account not found or has been logged out already. Try closing the app and opening it again.' + ); + } + + eventBus.publish({ + type: 'account_updated', + account: { + id: updatedAccount.id, + name: updatedAccount.name, + email: updatedAccount.email, + token: updatedAccount.token, + avatar: updatedAccount.avatar, + deviceId: updatedAccount.device_id, + server: updatedAccount.server, + status: updatedAccount.status, + }, + }); + + return { + success: true, + }; + } catch (error) { + const apiError = parseApiError(error); + throw new MutationError(MutationErrorCode.ApiError, apiError.message); } - - eventBus.publish({ - type: 'account_updated', - account: { - id: updatedAccount.id, - name: updatedAccount.name, - email: updatedAccount.email, - token: updatedAccount.token, - avatar: updatedAccount.avatar, - deviceId: updatedAccount.device_id, - server: updatedAccount.server, - status: updatedAccount.status, - }, - }); - - return { - success: true, - }; } } diff --git a/apps/desktop/src/main/mutations/accounts/email-login.ts b/apps/desktop/src/main/mutations/accounts/email-login.ts index 9588b298..cd9c6496 100644 --- a/apps/desktop/src/main/mutations/accounts/email-login.ts +++ b/apps/desktop/src/main/mutations/accounts/email-login.ts @@ -8,8 +8,9 @@ import { EmailLoginMutationInput, EmailLoginMutationOutput, } from '@/shared/mutations/accounts/email-login'; -import { Account } from '@/shared/types/accounts'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; +import { parseApiError } from '@/shared/lib/axios'; +import { mapAccount, mapWorkspace } from '@/main/utils'; export class EmailLoginMutationHandler implements MutationHandler @@ -25,109 +26,99 @@ export class EmailLoginMutationHandler if (!server) { throw new MutationError( - 'server_not_found', + MutationErrorCode.ServerNotFound, `Server ${input.server} was not found! Try using a different server.` ); } - const { data } = await httpClient.post( - '/v1/accounts/login/email', - { - email: input.email, - password: input.password, - }, - { - domain: server.domain, - } - ); + try { + const { data } = await httpClient.post( + '/v1/accounts/login/email', + { + email: input.email, + password: input.password, + }, + { + domain: server.domain, + } + ); - let account: Account | undefined; - await databaseService.appDatabase.transaction().execute(async (trx) => { - const createdAccount = await trx - .insertInto('accounts') - .returningAll() - .values({ - id: data.account.id, - name: data.account.name, - avatar: data.account.avatar, - device_id: data.deviceId, - email: data.account.email, - token: data.token, - server: server.domain, - status: 'active', - }) - .executeTakeFirst(); + const { createdAccount, createdWorkspaces } = + await databaseService.appDatabase.transaction().execute(async (trx) => { + const createdAccount = await trx + .insertInto('accounts') + .returningAll() + .values({ + id: data.account.id, + name: data.account.name, + avatar: data.account.avatar, + device_id: data.deviceId, + email: data.account.email, + token: data.token, + server: server.domain, + status: 'active', + }) + .executeTakeFirst(); + + if (!createdAccount) { + throw new MutationError( + MutationErrorCode.AccountLoginFailed, + 'Failed to login with email and password! Please try again.' + ); + } + + if (data.workspaces.length === 0) { + return { createdAccount, createdWorkspaces: [] }; + } + + const createdWorkspaces = await trx + .insertInto('workspaces') + .returningAll() + .values( + data.workspaces.map((workspace) => ({ + workspace_id: workspace.id, + name: workspace.name, + account_id: data.account.id, + avatar: workspace.avatar, + role: workspace.user.role, + description: workspace.description, + user_id: workspace.user.id, + })) + ) + .execute(); + + return { createdAccount, createdWorkspaces }; + }); if (!createdAccount) { throw new MutationError( - 'account_login_failed', + MutationErrorCode.AccountLoginFailed, 'Failed to login with email and password! Please try again.' ); } - account = { - id: createdAccount.id, - name: createdAccount.name, - email: createdAccount.email, - avatar: createdAccount.avatar, - deviceId: data.deviceId, - token: data.token, - status: 'active', - server: server.domain, + const account = mapAccount(createdAccount); + eventBus.publish({ + type: 'account_created', + account, + }); + + if (createdWorkspaces.length > 0) { + for (const workspace of createdWorkspaces) { + eventBus.publish({ + type: 'workspace_created', + workspace: mapWorkspace(workspace), + }); + } + } + + return { + account, + workspaces: data.workspaces, }; - - if (data.workspaces.length === 0) { - return; - } - - await trx - .insertInto('workspaces') - .values( - data.workspaces.map((workspace) => ({ - workspace_id: workspace.id, - name: workspace.name, - account_id: data.account.id, - avatar: workspace.avatar, - role: workspace.user.role, - description: workspace.description, - user_id: workspace.user.id, - version_id: workspace.versionId, - })) - ) - .execute(); - }); - - if (!account) { - throw new MutationError( - 'account_login_failed', - 'Failed to login with email and password! Please try again.' - ); + } catch (error) { + const apiError = parseApiError(error); + throw new MutationError(MutationErrorCode.ApiError, apiError.message); } - - eventBus.publish({ - type: 'account_created', - account, - }); - - if (data.workspaces.length > 0) { - for (const workspace of data.workspaces) { - eventBus.publish({ - type: 'workspace_created', - workspace: { - id: workspace.id, - name: workspace.name, - versionId: workspace.versionId, - accountId: workspace.user.accountId, - role: workspace.user.role, - userId: workspace.user.id, - }, - }); - } - } - - return { - account, - workspaces: data.workspaces, - }; } } diff --git a/apps/desktop/src/main/mutations/accounts/email-register.ts b/apps/desktop/src/main/mutations/accounts/email-register.ts index bb4bf592..0a2e2875 100644 --- a/apps/desktop/src/main/mutations/accounts/email-register.ts +++ b/apps/desktop/src/main/mutations/accounts/email-register.ts @@ -8,8 +8,9 @@ import { EmailRegisterMutationInput, EmailRegisterMutationOutput, } from '@/shared/mutations/accounts/email-register'; -import { Account } from '@/shared/types/accounts'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; +import { parseApiError } from '@/shared/lib/axios'; +import { mapAccount, mapWorkspace } from '@/main/utils'; export class EmailRegisterMutationHandler implements MutationHandler @@ -25,109 +26,100 @@ export class EmailRegisterMutationHandler if (!server) { throw new MutationError( - 'server_not_found', + MutationErrorCode.ServerNotFound, `Server ${input.server} was not found! Try using a different server.` ); } - const { data } = await httpClient.post( - '/v1/accounts/register/email', - { - name: input.name, - email: input.email, - password: input.password, - }, - { - domain: server.domain, - } - ); + try { + const { data } = await httpClient.post( + '/v1/accounts/register/email', + { + name: input.name, + email: input.email, + password: input.password, + }, + { + domain: server.domain, + } + ); - let account: Account | undefined; - await databaseService.appDatabase.transaction().execute(async (trx) => { - const createdAccount = await trx - .insertInto('accounts') - .returningAll() - .values({ - id: data.account.id, - name: data.account.name, - avatar: data.account.avatar, - device_id: data.deviceId, - email: data.account.email, - token: data.token, - server: server.domain, - status: 'active', - }) - .executeTakeFirst(); + const { createdAccount, createdWorkspaces } = + await databaseService.appDatabase.transaction().execute(async (trx) => { + const createdAccount = await trx + .insertInto('accounts') + .returningAll() + .values({ + id: data.account.id, + name: data.account.name, + avatar: data.account.avatar, + device_id: data.deviceId, + email: data.account.email, + token: data.token, + server: server.domain, + status: 'active', + }) + .executeTakeFirst(); + + if (!createdAccount) { + throw new MutationError( + MutationErrorCode.AccountRegisterFailed, + 'Failed to register with email and password! Please try again.' + ); + } + + if (data.workspaces.length === 0) { + return { createdAccount, createdWorkspaces: [] }; + } + + const createdWorkspaces = await trx + .insertInto('workspaces') + .returningAll() + .values( + data.workspaces.map((workspace) => ({ + workspace_id: workspace.id, + name: workspace.name, + account_id: data.account.id, + avatar: workspace.avatar, + role: workspace.user.role, + description: workspace.description, + user_id: workspace.user.id, + })) + ) + .execute(); + + return { createdAccount, createdWorkspaces }; + }); if (!createdAccount) { - throw new Error( - 'Failed to create account! Please try again with a different email.' + throw new MutationError( + MutationErrorCode.AccountRegisterFailed, + 'Failed to register with email and password! Please try again.' ); } - account = { - id: createdAccount.id, - name: createdAccount.name, - email: createdAccount.email, - avatar: createdAccount.avatar, - deviceId: data.deviceId, - token: data.token, - status: 'active', - server: server.domain, + const account = mapAccount(createdAccount); + eventBus.publish({ + type: 'account_created', + account, + }); + + if (createdWorkspaces.length > 0) { + for (const workspace of createdWorkspaces) { + eventBus.publish({ + type: 'workspace_created', + workspace: mapWorkspace(workspace), + }); + } + } + + return { + account, + workspaces: data.workspaces, }; - - if (data.workspaces.length === 0) { - return; - } - - await trx - .insertInto('workspaces') - .values( - data.workspaces.map((workspace) => ({ - workspace_id: workspace.id, - name: workspace.name, - account_id: data.account.id, - avatar: workspace.avatar, - role: workspace.user.role, - description: workspace.description, - user_id: workspace.user.id, - version_id: workspace.versionId, - })) - ) - .execute(); - }); - - if (!account) { - throw new MutationError( - 'account_register_failed', - 'Failed to create account! Please try again with a different email.' - ); + } catch (error) { + const apiError = parseApiError(error); + throw new MutationError(MutationErrorCode.ApiError, apiError.message); } - - eventBus.publish({ - type: 'account_created', - account, - }); - - if (data.workspaces.length > 0) { - for (const workspace of data.workspaces) { - eventBus.publish({ - type: 'workspace_created', - workspace: { - id: workspace.id, - name: workspace.name, - versionId: workspace.versionId, - accountId: workspace.user.accountId, - role: workspace.user.role, - userId: workspace.user.id, - }, - }); - } - } - - return { - account, - workspaces: data.workspaces, - }; } } diff --git a/apps/desktop/src/main/mutations/avatars/avatar-upload.ts b/apps/desktop/src/main/mutations/avatars/avatar-upload.ts index a55727ac..356f3eef 100644 --- a/apps/desktop/src/main/mutations/avatars/avatar-upload.ts +++ b/apps/desktop/src/main/mutations/avatars/avatar-upload.ts @@ -9,7 +9,8 @@ import { AvatarUploadMutationInput, AvatarUploadMutationOutput, } from '@/shared/mutations/avatars/avatar-upload'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; +import { parseApiError } from '@/shared/lib/axios'; interface AvatarUploadResponse { id: string; @@ -30,29 +31,34 @@ export class AvatarUploadMutationHandler if (!credentials) { throw new MutationError( - 'account_not_found', + MutationErrorCode.AccountNotFound, 'Account not found or has been logged out already. Try closing the app and opening it again.' ); } - const filePath = input.filePath; - const fileStream = fs.createReadStream(filePath); + try { + const filePath = input.filePath; + const fileStream = fs.createReadStream(filePath); - const formData = new FormData(); - formData.append('avatar', fileStream); + const formData = new FormData(); + formData.append('avatar', fileStream); - const { data } = await httpClient.post( - '/v1/avatars', - formData, - { - domain: credentials.domain, - token: credentials.token, - headers: formData.getHeaders(), - } - ); + const { data } = await httpClient.post( + '/v1/avatars', + formData, + { + domain: credentials.domain, + token: credentials.token, + headers: formData.getHeaders(), + } + ); - return { - id: data.id, - }; + return { + id: data.id, + }; + } catch (error) { + const apiError = parseApiError(error); + throw new MutationError(MutationErrorCode.ApiError, apiError.message); + } } } diff --git a/apps/desktop/src/main/mutations/channels/channel-create.ts b/apps/desktop/src/main/mutations/channels/channel-create.ts index 145cf584..d460e379 100644 --- a/apps/desktop/src/main/mutations/channels/channel-create.ts +++ b/apps/desktop/src/main/mutations/channels/channel-create.ts @@ -7,7 +7,7 @@ import { ChannelCreateMutationInput, ChannelCreateMutationOutput, } from '@/shared/mutations/channels/channel-create'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; export class ChannelCreateMutationHandler implements MutationHandler @@ -27,7 +27,7 @@ export class ChannelCreateMutationHandler if (!space) { throw new MutationError( - 'space_not_found', + MutationErrorCode.SpaceNotFound, 'Space not found or has been deleted.' ); } @@ -38,7 +38,6 @@ export class ChannelCreateMutationHandler name: input.name, avatar: input.avatar, parentId: input.spaceId, - collaborators: null, }; await entryService.createEntry(input.userId, { id, attributes }); diff --git a/apps/desktop/src/main/mutations/channels/channel-update.ts b/apps/desktop/src/main/mutations/channels/channel-update.ts index 80500e74..11b51fbe 100644 --- a/apps/desktop/src/main/mutations/channels/channel-update.ts +++ b/apps/desktop/src/main/mutations/channels/channel-update.ts @@ -1,6 +1,8 @@ +import { ChannelAttributes } from '@colanode/core'; + import { entryService } from '@/main/services/entry-service'; import { MutationHandler } from '@/main/types'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; import { ChannelUpdateMutationInput, ChannelUpdateMutationOutput, @@ -12,17 +14,10 @@ export class ChannelUpdateMutationHandler async handleMutation( input: ChannelUpdateMutationInput ): Promise { - const result = await entryService.updateEntry( + const result = await entryService.updateEntry( input.channelId, input.userId, (attributes) => { - if (attributes.type !== 'channel') { - throw new MutationError( - 'invalid_attributes', - 'Something went wrong while updating the channel.' - ); - } - attributes.name = input.name; attributes.avatar = input.avatar; @@ -32,21 +27,21 @@ export class ChannelUpdateMutationHandler if (result === 'not_found') { throw new MutationError( - 'channel_not_found', + MutationErrorCode.ChannelNotFound, 'Channel not found or has been deleted.' ); } if (result === 'invalid_attributes') { throw new MutationError( - 'invalid_attributes', + MutationErrorCode.ChannelUpdateFailed, 'Something went wrong while updating the channel.' ); } if (result === 'unauthorized') { throw new MutationError( - 'unauthorized', + MutationErrorCode.ChannelUpdateForbidden, "You don't have permission to update this channel." ); } @@ -58,7 +53,7 @@ export class ChannelUpdateMutationHandler } throw new MutationError( - 'unknown', + MutationErrorCode.Unknown, 'Something went wrong while updating the channel.' ); } diff --git a/apps/desktop/src/main/mutations/chats/chat-create.ts b/apps/desktop/src/main/mutations/chats/chat-create.ts index 8a8df082..0afb2c75 100644 --- a/apps/desktop/src/main/mutations/chats/chat-create.ts +++ b/apps/desktop/src/main/mutations/chats/chat-create.ts @@ -42,10 +42,10 @@ export class ChatCreateMutationHandler const id = generateId(IdType.Chat); const attributes: ChatAttributes = { type: 'chat', - parentId: input.workspaceId, + parentId: id, collaborators: { - [input.userId]: 'collaborator', - [input.otherUserId]: 'collaborator', + [input.userId]: 'admin', + [input.otherUserId]: 'admin', }, }; diff --git a/apps/desktop/src/main/mutations/databases/database-update.ts b/apps/desktop/src/main/mutations/databases/database-update.ts index 9fd99f67..33521485 100644 --- a/apps/desktop/src/main/mutations/databases/database-update.ts +++ b/apps/desktop/src/main/mutations/databases/database-update.ts @@ -1,6 +1,8 @@ +import { DatabaseAttributes } from '@colanode/core'; + import { entryService } from '@/main/services/entry-service'; import { MutationHandler } from '@/main/types'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; import { DatabaseUpdateMutationInput, DatabaseUpdateMutationOutput, @@ -12,17 +14,10 @@ export class DatabaseUpdateMutationHandler async handleMutation( input: DatabaseUpdateMutationInput ): Promise { - const result = await entryService.updateEntry( + const result = await entryService.updateEntry( input.databaseId, input.userId, (attributes) => { - if (attributes.type !== 'database') { - throw new MutationError( - 'invalid_attributes', - 'Node is not a database' - ); - } - attributes.name = input.name; attributes.avatar = input.avatar; @@ -32,14 +27,14 @@ export class DatabaseUpdateMutationHandler if (result === 'unauthorized') { throw new MutationError( - 'unauthorized', + MutationErrorCode.DatabaseUpdateForbidden, "You don't have permission to update this database." ); } if (result !== 'success') { throw new MutationError( - 'unknown', + MutationErrorCode.DatabaseUpdateFailed, 'Something went wrong while updating the database.' ); } diff --git a/apps/desktop/src/main/mutations/databases/field-create.ts b/apps/desktop/src/main/mutations/databases/field-create.ts index 84e6aca2..e0caf8c3 100644 --- a/apps/desktop/src/main/mutations/databases/field-create.ts +++ b/apps/desktop/src/main/mutations/databases/field-create.ts @@ -13,7 +13,9 @@ import { FieldCreateMutationInput, FieldCreateMutationOutput, } from '@/shared/mutations/databases/field-create'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; +import { databaseService } from '@/main/data/database-service'; +import { fetchEntry } from '@/main/utils'; export class FieldCreateMutationHandler implements MutationHandler @@ -24,19 +26,23 @@ export class FieldCreateMutationHandler if (input.fieldType === 'relation') { if (!input.relationDatabaseId) { throw new MutationError( - 'relation_database_not_found', + MutationErrorCode.RelationDatabaseNotFound, 'Relation database not found.' ); } - const relationDatabase = await entryService.fetchEntry( - input.relationDatabaseId, + const workspaceDatabase = await databaseService.getWorkspaceDatabase( input.userId ); + const relationDatabase = await fetchEntry( + workspaceDatabase, + input.relationDatabaseId + ); + if (!relationDatabase || relationDatabase.type !== 'database') { throw new MutationError( - 'relation_database_not_found', + MutationErrorCode.RelationDatabaseNotFound, 'Relation database not found.' ); } @@ -76,14 +82,14 @@ export class FieldCreateMutationHandler if (result === 'unauthorized') { throw new MutationError( - 'unauthorized', + MutationErrorCode.FieldCreateForbidden, "You don't have permission to create a field in this database." ); } if (result !== 'success') { throw new MutationError( - 'unknown', + MutationErrorCode.FieldCreateFailed, 'Something went wrong while creating the field.' ); } diff --git a/apps/desktop/src/main/mutations/databases/field-delete.ts b/apps/desktop/src/main/mutations/databases/field-delete.ts index a397ec29..d198855a 100644 --- a/apps/desktop/src/main/mutations/databases/field-delete.ts +++ b/apps/desktop/src/main/mutations/databases/field-delete.ts @@ -1,6 +1,8 @@ +import { DatabaseAttributes } from '@colanode/core'; + import { entryService } from '@/main/services/entry-service'; import { MutationHandler } from '@/main/types'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; import { FieldDeleteMutationInput, FieldDeleteMutationOutput, @@ -12,17 +14,13 @@ export class FieldDeleteMutationHandler async handleMutation( input: FieldDeleteMutationInput ): Promise { - const result = await entryService.updateEntry( + const result = await entryService.updateEntry( input.databaseId, input.userId, (attributes) => { - if (attributes.type !== 'database') { - throw new MutationError('invalid_attributes', 'Invalid node type'); - } - if (!attributes.fields[input.fieldId]) { throw new MutationError( - 'field_not_found', + MutationErrorCode.FieldNotFound, 'The field you are trying to delete does not exist.' ); } @@ -35,14 +33,14 @@ export class FieldDeleteMutationHandler if (result === 'unauthorized') { throw new MutationError( - 'unauthorized', + MutationErrorCode.FieldDeleteForbidden, "You don't have permission to delete this field." ); } if (result !== 'success') { throw new MutationError( - 'unknown', + MutationErrorCode.FieldDeleteFailed, 'Something went wrong while deleting the field.' ); } diff --git a/apps/desktop/src/main/mutations/databases/field-name-update.ts b/apps/desktop/src/main/mutations/databases/field-name-update.ts index e20f544c..434cf67f 100644 --- a/apps/desktop/src/main/mutations/databases/field-name-update.ts +++ b/apps/desktop/src/main/mutations/databases/field-name-update.ts @@ -1,6 +1,8 @@ +import { DatabaseAttributes } from '@colanode/core'; + import { entryService } from '@/main/services/entry-service'; import { MutationHandler } from '@/main/types'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; import { FieldNameUpdateMutationInput, FieldNameUpdateMutationOutput, @@ -12,18 +14,14 @@ export class FieldNameUpdateMutationHandler async handleMutation( input: FieldNameUpdateMutationInput ): Promise { - const result = await entryService.updateEntry( + const result = await entryService.updateEntry( input.databaseId, input.userId, (attributes) => { - if (attributes.type !== 'database') { - throw new MutationError('invalid_attributes', 'Invalid node type'); - } - const field = attributes.fields[input.fieldId]; if (!field) { throw new MutationError( - 'field_not_found', + MutationErrorCode.FieldNotFound, 'The field you are trying to update does not exist.' ); } @@ -35,14 +33,14 @@ export class FieldNameUpdateMutationHandler if (result === 'unauthorized') { throw new MutationError( - 'unauthorized', + MutationErrorCode.FieldUpdateForbidden, "You don't have permission to update this field." ); } if (result !== 'success') { throw new MutationError( - 'unknown', + MutationErrorCode.FieldUpdateFailed, 'Something went wrong while updating the field.' ); } diff --git a/apps/desktop/src/main/mutations/databases/select-option-create.ts b/apps/desktop/src/main/mutations/databases/select-option-create.ts index e955983b..f829eb62 100644 --- a/apps/desktop/src/main/mutations/databases/select-option-create.ts +++ b/apps/desktop/src/main/mutations/databases/select-option-create.ts @@ -1,5 +1,6 @@ import { compareString, + DatabaseAttributes, generateId, generateNodeIndex, IdType, @@ -11,7 +12,7 @@ import { SelectOptionCreateMutationInput, SelectOptionCreateMutationOutput, } from '@/shared/mutations/databases/select-option-create'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; export class SelectOptionCreateMutationHandler implements MutationHandler @@ -20,28 +21,21 @@ export class SelectOptionCreateMutationHandler input: SelectOptionCreateMutationInput ): Promise { const id = generateId(IdType.SelectOption); - const result = await entryService.updateEntry( + const result = await entryService.updateEntry( input.databaseId, input.userId, (attributes) => { - if (attributes.type !== 'database') { - throw new MutationError( - 'invalid_attributes', - 'Node is not a database' - ); - } - const field = attributes.fields[input.fieldId]; if (!field) { throw new MutationError( - 'field_not_found', + MutationErrorCode.FieldNotFound, 'The field you are trying to create a select option in does not exist.' ); } if (field.type !== 'select' && field.type !== 'multiSelect') { throw new MutationError( - 'invalid_field_type', + MutationErrorCode.FieldTypeInvalid, 'The field you are trying to create a select option in is not a "Select" or "Multi-Select" field.' ); } @@ -69,14 +63,14 @@ export class SelectOptionCreateMutationHandler if (result === 'unauthorized') { throw new MutationError( - 'unauthorized', + MutationErrorCode.SelectOptionCreateForbidden, "You don't have permission to create a select option in this field." ); } if (result !== 'success') { throw new MutationError( - 'unknown', + MutationErrorCode.SelectOptionCreateFailed, 'Something went wrong while creating the select option.' ); } diff --git a/apps/desktop/src/main/mutations/databases/select-option-delete.ts b/apps/desktop/src/main/mutations/databases/select-option-delete.ts index ee799fdb..898523ee 100644 --- a/apps/desktop/src/main/mutations/databases/select-option-delete.ts +++ b/apps/desktop/src/main/mutations/databases/select-option-delete.ts @@ -1,6 +1,8 @@ +import { DatabaseAttributes } from '@colanode/core'; + import { entryService } from '@/main/services/entry-service'; import { MutationHandler } from '@/main/types'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; import { SelectOptionDeleteMutationInput, SelectOptionDeleteMutationOutput, @@ -12,42 +14,35 @@ export class SelectOptionDeleteMutationHandler async handleMutation( input: SelectOptionDeleteMutationInput ): Promise { - const result = await entryService.updateEntry( + const result = await entryService.updateEntry( input.databaseId, input.userId, (attributes) => { - if (attributes.type !== 'database') { - throw new MutationError( - 'invalid_attributes', - 'Node is not a database' - ); - } - const field = attributes.fields[input.fieldId]; if (!field) { throw new MutationError( - 'field_not_found', + MutationErrorCode.FieldNotFound, 'The field you are trying to delete a select option from does not exist.' ); } if (field.type !== 'select' && field.type !== 'multiSelect') { throw new MutationError( - 'invalid_field_type', + MutationErrorCode.FieldTypeInvalid, 'The field you are trying to delete a select option from is not a "Select" or "Multi-Select" field.' ); } if (!field.options) { throw new MutationError( - 'select_option_not_found', + MutationErrorCode.SelectOptionNotFound, 'The field you are trying to delete a select option from does not have any select options.' ); } if (!field.options[input.optionId]) { throw new MutationError( - 'select_option_not_found', + MutationErrorCode.SelectOptionNotFound, 'The select option you are trying to delete does not exist.' ); } @@ -60,14 +55,14 @@ export class SelectOptionDeleteMutationHandler if (result === 'unauthorized') { throw new MutationError( - 'unauthorized', + MutationErrorCode.SelectOptionDeleteForbidden, "You don't have permission to delete this select option." ); } if (result !== 'success') { throw new MutationError( - 'unknown', + MutationErrorCode.SelectOptionDeleteFailed, 'Something went wrong while deleting the select option.' ); } diff --git a/apps/desktop/src/main/mutations/databases/select-option-update.ts b/apps/desktop/src/main/mutations/databases/select-option-update.ts index 4c42fe1b..9de23725 100644 --- a/apps/desktop/src/main/mutations/databases/select-option-update.ts +++ b/apps/desktop/src/main/mutations/databases/select-option-update.ts @@ -1,6 +1,8 @@ +import { DatabaseAttributes } from '@colanode/core'; + import { entryService } from '@/main/services/entry-service'; import { MutationHandler } from '@/main/types'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; import { SelectOptionUpdateMutationInput, SelectOptionUpdateMutationOutput, @@ -12,28 +14,21 @@ export class SelectOptionUpdateMutationHandler async handleMutation( input: SelectOptionUpdateMutationInput ): Promise { - const result = await entryService.updateEntry( + const result = await entryService.updateEntry( input.databaseId, input.userId, (attributes) => { - if (attributes.type !== 'database') { - throw new MutationError( - 'invalid_attributes', - 'Node is not a database' - ); - } - const field = attributes.fields[input.fieldId]; if (!field) { throw new MutationError( - 'field_not_found', + MutationErrorCode.FieldNotFound, 'The field you are trying to update a select option in does not exist.' ); } if (field.type !== 'select' && field.type !== 'multiSelect') { throw new MutationError( - 'invalid_field_type', + MutationErrorCode.FieldTypeInvalid, 'The field you are trying to update a select option in is not a "Select" or "Multi-Select" field.' ); } @@ -45,7 +40,7 @@ export class SelectOptionUpdateMutationHandler const option = field.options[input.optionId]; if (!option) { throw new MutationError( - 'select_option_not_found', + MutationErrorCode.SelectOptionNotFound, 'The select option you are trying to update does not exist.' ); } @@ -58,14 +53,14 @@ export class SelectOptionUpdateMutationHandler if (result === 'unauthorized') { throw new MutationError( - 'unauthorized', + MutationErrorCode.SelectOptionUpdateForbidden, "You don't have permission to update this select option." ); } if (result !== 'success') { throw new MutationError( - 'unknown', + MutationErrorCode.SelectOptionUpdateFailed, 'Something went wrong while updating the select option.' ); } diff --git a/apps/desktop/src/main/mutations/databases/view-create.ts b/apps/desktop/src/main/mutations/databases/view-create.ts index 42758dc2..388a90e5 100644 --- a/apps/desktop/src/main/mutations/databases/view-create.ts +++ b/apps/desktop/src/main/mutations/databases/view-create.ts @@ -1,5 +1,6 @@ import { compareString, + DatabaseAttributes, generateId, generateNodeIndex, IdType, @@ -11,7 +12,7 @@ import { ViewCreateMutationInput, ViewCreateMutationOutput, } from '@/shared/mutations/databases/view-create'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; export class ViewCreateMutationHandler implements MutationHandler @@ -20,17 +21,10 @@ export class ViewCreateMutationHandler input: ViewCreateMutationInput ): Promise { const id = generateId(IdType.View); - const result = await entryService.updateEntry( + const result = await entryService.updateEntry( input.databaseId, input.userId, (attributes) => { - if (attributes.type !== 'database') { - throw new MutationError( - 'invalid_attributes', - 'Node is not a database' - ); - } - const maxIndex = Object.values(attributes.views) .map((view) => view.index) .sort((a, b) => -compareString(a, b))[0]; @@ -53,14 +47,14 @@ export class ViewCreateMutationHandler if (result === 'unauthorized') { throw new MutationError( - 'unauthorized', + MutationErrorCode.ViewCreateForbidden, "You don't have permission to create a view in this database." ); } if (result !== 'success') { throw new MutationError( - 'unknown', + MutationErrorCode.ViewCreateFailed, 'Something went wrong while creating the view.' ); } diff --git a/apps/desktop/src/main/mutations/databases/view-delete.ts b/apps/desktop/src/main/mutations/databases/view-delete.ts index 50699907..8de4595d 100644 --- a/apps/desktop/src/main/mutations/databases/view-delete.ts +++ b/apps/desktop/src/main/mutations/databases/view-delete.ts @@ -1,6 +1,8 @@ +import { DatabaseAttributes } from '@colanode/core'; + import { entryService } from '@/main/services/entry-service'; import { MutationHandler } from '@/main/types'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; import { ViewDeleteMutationInput, ViewDeleteMutationOutput, @@ -12,20 +14,13 @@ export class ViewDeleteMutationHandler async handleMutation( input: ViewDeleteMutationInput ): Promise { - const result = await entryService.updateEntry( + const result = await entryService.updateEntry( input.databaseId, input.userId, (attributes) => { - if (attributes.type !== 'database') { - throw new MutationError( - 'invalid_attributes', - 'Node is not a database' - ); - } - if (!attributes.views[input.viewId]) { throw new MutationError( - 'view_not_found', + MutationErrorCode.ViewNotFound, 'The view you are trying to delete does not exist.' ); } @@ -37,14 +32,14 @@ export class ViewDeleteMutationHandler if (result === 'unauthorized') { throw new MutationError( - 'unauthorized', + MutationErrorCode.ViewDeleteForbidden, "You don't have permission to delete this view." ); } if (result !== 'success') { throw new MutationError( - 'unknown', + MutationErrorCode.ViewDeleteFailed, 'Something went wrong while deleting the view.' ); } diff --git a/apps/desktop/src/main/mutations/databases/view-name-update.ts b/apps/desktop/src/main/mutations/databases/view-name-update.ts index f33f9381..e7c41bbb 100644 --- a/apps/desktop/src/main/mutations/databases/view-name-update.ts +++ b/apps/desktop/src/main/mutations/databases/view-name-update.ts @@ -1,6 +1,8 @@ +import { DatabaseAttributes } from '@colanode/core'; + import { entryService } from '@/main/services/entry-service'; import { MutationHandler } from '@/main/types'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; import { ViewNameUpdateMutationInput, ViewNameUpdateMutationOutput, @@ -12,21 +14,14 @@ export class ViewNameUpdateMutationHandler async handleMutation( input: ViewNameUpdateMutationInput ): Promise { - const result = await entryService.updateEntry( + const result = await entryService.updateEntry( input.databaseId, input.userId, (attributes) => { - if (attributes.type !== 'database') { - throw new MutationError( - 'invalid_attributes', - 'Node is not a database' - ); - } - const view = attributes.views[input.viewId]; if (!view) { throw new MutationError( - 'view_not_found', + MutationErrorCode.ViewNotFound, 'The view you are trying to update the name of does not exist.' ); } @@ -38,14 +33,14 @@ export class ViewNameUpdateMutationHandler if (result === 'unauthorized') { throw new MutationError( - 'unauthorized', + MutationErrorCode.ViewUpdateForbidden, "You don't have permission to update this view." ); } if (result !== 'success') { throw new MutationError( - 'unknown', + MutationErrorCode.ViewUpdateFailed, 'Something went wrong while updating the view.' ); } diff --git a/apps/desktop/src/main/mutations/databases/view-update.ts b/apps/desktop/src/main/mutations/databases/view-update.ts index 06457034..c56840df 100644 --- a/apps/desktop/src/main/mutations/databases/view-update.ts +++ b/apps/desktop/src/main/mutations/databases/view-update.ts @@ -1,6 +1,8 @@ +import { DatabaseAttributes } from '@colanode/core'; + import { entryService } from '@/main/services/entry-service'; import { MutationHandler } from '@/main/types'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; import { ViewUpdateMutationInput, ViewUpdateMutationOutput, @@ -12,20 +14,13 @@ export class ViewUpdateMutationHandler async handleMutation( input: ViewUpdateMutationInput ): Promise { - const result = await entryService.updateEntry( + const result = await entryService.updateEntry( input.databaseId, input.userId, (attributes) => { - if (attributes.type !== 'database') { - throw new MutationError( - 'invalid_attributes', - 'Node is not a database' - ); - } - if (!attributes.views[input.view.id]) { throw new MutationError( - 'view_not_found', + MutationErrorCode.ViewNotFound, 'The view you are trying to update does not exist.' ); } @@ -37,14 +32,14 @@ export class ViewUpdateMutationHandler if (result === 'unauthorized') { throw new MutationError( - 'unauthorized', + MutationErrorCode.ViewUpdateForbidden, "You don't have permission to update this view." ); } if (result !== 'success') { throw new MutationError( - 'unknown', + MutationErrorCode.ViewUpdateFailed, 'Something went wrong while updating the view.' ); } diff --git a/apps/desktop/src/main/mutations/entries/entry-collaborator-create.ts b/apps/desktop/src/main/mutations/entries/entry-collaborator-create.ts index 7af0ed38..d712d941 100644 --- a/apps/desktop/src/main/mutations/entries/entry-collaborator-create.ts +++ b/apps/desktop/src/main/mutations/entries/entry-collaborator-create.ts @@ -6,7 +6,7 @@ import { EntryCollaboratorCreateMutationInput, EntryCollaboratorCreateMutationOutput, } from '@/shared/mutations/entries/entry-collaborator-create'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; export class EntryCollaboratorCreateMutationHandler implements MutationHandler @@ -27,14 +27,14 @@ export class EntryCollaboratorCreateMutationHandler if (result === 'unauthorized') { throw new MutationError( - 'unauthorized', + MutationErrorCode.EntryCollaboratorCreateForbidden, "You don't have permission to add collaborators to this entry." ); } if (result !== 'success') { throw new MutationError( - 'unknown', + MutationErrorCode.EntryCollaboratorCreateFailed, 'Something went wrong while adding collaborators to the entry.' ); } diff --git a/apps/desktop/src/main/mutations/entries/entry-collaborator-delete.ts b/apps/desktop/src/main/mutations/entries/entry-collaborator-delete.ts index 1042d2df..1f4ffa35 100644 --- a/apps/desktop/src/main/mutations/entries/entry-collaborator-delete.ts +++ b/apps/desktop/src/main/mutations/entries/entry-collaborator-delete.ts @@ -6,7 +6,7 @@ import { EntryCollaboratorDeleteMutationInput, EntryCollaboratorDeleteMutationOutput, } from '@/shared/mutations/entries/entry-collaborator-delete'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; export class EntryCollaboratorDeleteMutationHandler implements MutationHandler @@ -25,14 +25,14 @@ export class EntryCollaboratorDeleteMutationHandler if (result === 'unauthorized') { throw new MutationError( - 'unauthorized', + MutationErrorCode.EntryCollaboratorDeleteForbidden, "You don't have permission to remove collaborators from this node." ); } if (result !== 'success') { throw new MutationError( - 'unknown', + MutationErrorCode.EntryCollaboratorDeleteFailed, 'Something went wrong while removing collaborators from the node.' ); } diff --git a/apps/desktop/src/main/mutations/entries/entry-collaborator-update.ts b/apps/desktop/src/main/mutations/entries/entry-collaborator-update.ts index a0e7d5dd..1e3e29b5 100644 --- a/apps/desktop/src/main/mutations/entries/entry-collaborator-update.ts +++ b/apps/desktop/src/main/mutations/entries/entry-collaborator-update.ts @@ -6,7 +6,7 @@ import { EntryCollaboratorUpdateMutationInput, EntryCollaboratorUpdateMutationOutput, } from '@/shared/mutations/entries/entry-collaborator-update'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; export class EntryCollaboratorUpdateMutationHandler implements MutationHandler @@ -25,14 +25,14 @@ export class EntryCollaboratorUpdateMutationHandler if (result === 'unauthorized') { throw new MutationError( - 'unauthorized', + MutationErrorCode.EntryCollaboratorUpdateForbidden, "You don't have permission to update collaborators for this entry." ); } if (result !== 'success') { throw new MutationError( - 'unknown', + MutationErrorCode.EntryCollaboratorUpdateFailed, 'Something went wrong while updating collaborators for the entry.' ); } diff --git a/apps/desktop/src/main/mutations/files/file-create.ts b/apps/desktop/src/main/mutations/files/file-create.ts index fc4e62ea..2d076849 100644 --- a/apps/desktop/src/main/mutations/files/file-create.ts +++ b/apps/desktop/src/main/mutations/files/file-create.ts @@ -1,4 +1,5 @@ import { + canCreateFile, CreateFileMutationData, FileStatus, generateId, @@ -11,10 +12,10 @@ import { FileCreateMutationInput, FileCreateMutationOutput, } from '@/shared/mutations/files/file-create'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; import { databaseService } from '@/main/data/database-service'; import { eventBus } from '@/shared/lib/event-bus'; -import { mapFile } from '@/main/utils'; +import { fetchEntry, fetchUser, mapEntry, mapFile } from '@/main/utils'; export class FileCreateMutationHandler implements MutationHandler @@ -25,7 +26,7 @@ export class FileCreateMutationHandler const metadata = fileService.getFileMetadata(input.filePath); if (!metadata) { throw new MutationError( - 'invalid_file', + MutationErrorCode.FileInvalid, 'File is invalid or could not be read.' ); } @@ -34,7 +35,50 @@ export class FileCreateMutationHandler input.userId ); + const user = await fetchUser(workspaceDatabase, input.userId); + if (!user) { + throw new MutationError( + MutationErrorCode.UserNotFound, + 'There was an error while fetching the user. Please make sure you are logged in.' + ); + } + + const entry = await fetchEntry(workspaceDatabase, input.entryId); + if (!entry) { + throw new MutationError( + MutationErrorCode.EntryNotFound, + 'There was an error while fetching the entry. Please make sure you have access to this entry.' + ); + } + + const root = await fetchEntry(workspaceDatabase, input.rootId); + if (!root) { + throw new MutationError( + MutationErrorCode.RootNotFound, + 'There was an error while fetching the root. Please make sure you have access to this root.' + ); + } + const fileId = generateId(IdType.File); + if ( + !canCreateFile({ + user: { + userId: input.userId, + role: user.role, + }, + root: mapEntry(root), + entry: mapEntry(entry), + file: { + id: fileId, + parentId: input.parentId, + }, + }) + ) { + throw new MutationError( + MutationErrorCode.FileCreateForbidden, + 'You are not allowed to upload a file in this entry.' + ); + } fileService.copyFileToWorkspace( input.filePath, diff --git a/apps/desktop/src/main/mutations/files/file-delete.ts b/apps/desktop/src/main/mutations/files/file-delete.ts index f8de1f5c..5fef77e5 100644 --- a/apps/desktop/src/main/mutations/files/file-delete.ts +++ b/apps/desktop/src/main/mutations/files/file-delete.ts @@ -1,4 +1,9 @@ -import { DeleteFileMutationData, generateId, IdType } from '@colanode/core'; +import { + canDeleteFile, + DeleteFileMutationData, + generateId, + IdType, +} from '@colanode/core'; import { databaseService } from '@/main/data/database-service'; import { MutationHandler } from '@/main/types'; @@ -7,7 +12,8 @@ import { FileDeleteMutationOutput, } from '@/shared/mutations/files/file-delete'; import { eventBus } from '@/shared/lib/event-bus'; -import { mapFile } from '@/main/utils'; +import { fetchEntry, fetchUser, mapEntry, mapFile } from '@/main/utils'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; export class FileDeleteMutationHandler implements MutationHandler @@ -26,9 +32,55 @@ export class FileDeleteMutationHandler .executeTakeFirst(); if (!file) { - return { - success: true, - }; + throw new MutationError( + MutationErrorCode.FileNotFound, + 'File could not be found or has been already deleted.' + ); + } + + const user = await fetchUser(workspaceDatabase, input.userId); + if (!user) { + throw new MutationError( + MutationErrorCode.UserNotFound, + 'There was an error while fetching the user. Please make sure you are logged in.' + ); + } + + const entry = await fetchEntry(workspaceDatabase, file.root_id); + if (!entry) { + throw new MutationError( + MutationErrorCode.EntryNotFound, + 'There was an error while fetching the entry. Please make sure you have access to this entry.' + ); + } + + const root = await fetchEntry(workspaceDatabase, entry.root_id); + if (!root) { + throw new MutationError( + MutationErrorCode.RootNotFound, + 'There was an error while fetching the root. Please make sure you have access to this root.' + ); + } + + if ( + !canDeleteFile({ + user: { + userId: input.userId, + role: user.role, + }, + root: mapEntry(root), + entry: mapEntry(entry), + file: { + id: input.fileId, + parentId: file.parent_id, + createdBy: file.created_by, + }, + }) + ) { + throw new MutationError( + MutationErrorCode.FileDeleteForbidden, + 'You are not allowed to delete this file.' + ); } const deletedAt = new Date().toISOString(); @@ -39,7 +91,13 @@ export class FileDeleteMutationHandler }; await workspaceDatabase.transaction().execute(async (tx) => { - await tx.deleteFrom('files').where('id', '=', input.fileId).execute(); + await tx + .updateTable('files') + .set({ + deleted_at: deletedAt, + }) + .where('id', '=', input.fileId) + .execute(); await tx .insertInto('mutations') diff --git a/apps/desktop/src/main/mutations/files/file-download.ts b/apps/desktop/src/main/mutations/files/file-download.ts index 542012a8..dba41abf 100644 --- a/apps/desktop/src/main/mutations/files/file-download.ts +++ b/apps/desktop/src/main/mutations/files/file-download.ts @@ -2,7 +2,7 @@ import { databaseService } from '@/main/data/database-service'; import { MutationHandler } from '@/main/types'; import { mapFileState } from '@/main/utils'; import { eventBus } from '@/shared/lib/event-bus'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; import { FileDownloadMutationInput, FileDownloadMutationOutput, @@ -26,7 +26,7 @@ export class FileDownloadMutationHandler if (!file) { throw new MutationError( - 'node_not_found', + MutationErrorCode.FileNotFound, 'The file you are trying to download does not exist.' ); } diff --git a/apps/desktop/src/main/mutations/folders/folder-update.ts b/apps/desktop/src/main/mutations/folders/folder-update.ts index 3d871ea3..a9f0555e 100644 --- a/apps/desktop/src/main/mutations/folders/folder-update.ts +++ b/apps/desktop/src/main/mutations/folders/folder-update.ts @@ -1,6 +1,8 @@ +import { FolderAttributes } from '@colanode/core'; + import { entryService } from '@/main/services/entry-service'; import { MutationHandler } from '@/main/types'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; import { FolderUpdateMutationInput, FolderUpdateMutationOutput, @@ -12,14 +14,10 @@ export class FolderUpdateMutationHandler async handleMutation( input: FolderUpdateMutationInput ): Promise { - const result = await entryService.updateEntry( + const result = await entryService.updateEntry( input.folderId, input.userId, (attributes) => { - if (attributes.type !== 'folder') { - throw new MutationError('invalid_attributes', 'Node is not a folder'); - } - attributes.name = input.name; attributes.avatar = input.avatar; @@ -29,15 +27,15 @@ export class FolderUpdateMutationHandler if (result === 'unauthorized') { throw new MutationError( - 'unauthorized', + MutationErrorCode.FolderUpdateForbidden, "You don't have permission to update this folder." ); } if (result !== 'success') { throw new MutationError( - 'unknown', - 'Something went wrong while updating the folder.' + MutationErrorCode.FolderUpdateFailed, + 'There was an error while updating the folder. Please try again.' ); } diff --git a/apps/desktop/src/main/mutations/messages/message-create.ts b/apps/desktop/src/main/mutations/messages/message-create.ts index 8c8a58f3..d0d2b235 100644 --- a/apps/desktop/src/main/mutations/messages/message-create.ts +++ b/apps/desktop/src/main/mutations/messages/message-create.ts @@ -1,5 +1,6 @@ import { Block, + canCreateMessage, CreateFileMutationData, CreateMessageMutationData, EditorNodeTypes, @@ -16,7 +17,7 @@ import { MessageCreateMutationInput, MessageCreateMutationOutput, } from '@/shared/mutations/messages/message-create'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; import { CreateFile, CreateFileState, @@ -24,7 +25,14 @@ import { } from '@/main/data/workspace/schema'; import { databaseService } from '@/main/data/database-service'; import { eventBus } from '@/shared/lib/event-bus'; -import { mapFile, mapFileState, mapMessage } from '@/main/utils'; +import { + fetchEntry, + fetchUser, + mapEntry, + mapFile, + mapFileState, + mapMessage, +} from '@/main/utils'; export class MessageCreateMutationHandler implements MutationHandler @@ -36,6 +44,46 @@ export class MessageCreateMutationHandler input.userId ); + const user = await fetchUser(workspaceDatabase, input.userId); + if (!user) { + throw new MutationError( + MutationErrorCode.UserNotFound, + 'There was an error while fetching the user. Please make sure you are logged in.' + ); + } + + const entry = await fetchEntry(workspaceDatabase, input.conversationId); + if (!entry) { + throw new MutationError( + MutationErrorCode.EntryNotFound, + 'There was an error while fetching the conversation. Please make sure you have access to this conversation.' + ); + } + + const root = await fetchEntry(workspaceDatabase, input.rootId); + if (!root) { + throw new MutationError( + MutationErrorCode.RootNotFound, + 'There was an error while fetching the root. Please make sure you have access to this root.' + ); + } + + if ( + !canCreateMessage({ + user: { + userId: input.userId, + role: user.role, + }, + root: mapEntry(root), + entry: mapEntry(entry), + }) + ) { + throw new MutationError( + MutationErrorCode.MessageCreateForbidden, + 'You are not allowed to create a message in this conversation.' + ); + } + const editorContent = input.content.content ?? []; const messageId = generateId(IdType.Message); const createdAt = new Date().toISOString(); @@ -52,8 +100,8 @@ export class MessageCreateMutationHandler const metadata = fileService.getFileMetadata(path); if (!metadata) { throw new MutationError( - 'invalid_file', - 'File attachment is invalid or could not be read.' + MutationErrorCode.FileInvalid, + 'The file attachment is invalid or could not be read.' ); } diff --git a/apps/desktop/src/main/mutations/messages/message-delete.ts b/apps/desktop/src/main/mutations/messages/message-delete.ts index 62362621..e01f330f 100644 --- a/apps/desktop/src/main/mutations/messages/message-delete.ts +++ b/apps/desktop/src/main/mutations/messages/message-delete.ts @@ -1,4 +1,9 @@ -import { DeleteMessageMutationData, generateId, IdType } from '@colanode/core'; +import { + canDeleteMessage, + DeleteMessageMutationData, + generateId, + IdType, +} from '@colanode/core'; import { databaseService } from '@/main/data/database-service'; import { MutationHandler } from '@/main/types'; @@ -7,7 +12,8 @@ import { MessageDeleteMutationOutput, } from '@/shared/mutations/messages/message-delete'; import { eventBus } from '@/shared/lib/event-bus'; -import { mapMessage } from '@/main/utils'; +import { fetchEntry, fetchUser, mapEntry, mapMessage } from '@/main/utils'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; export class MessageDeleteMutationHandler implements MutationHandler @@ -31,6 +37,50 @@ export class MessageDeleteMutationHandler }; } + const user = await fetchUser(workspaceDatabase, input.userId); + if (!user) { + throw new MutationError( + MutationErrorCode.UserNotFound, + 'There was an error while fetching the user. Please make sure you are logged in.' + ); + } + + const entry = await fetchEntry(workspaceDatabase, message.entry_id); + if (!entry) { + throw new MutationError( + MutationErrorCode.EntryNotFound, + 'There was an error while fetching the conversation. Please make sure you have access to this conversation.' + ); + } + + const root = await fetchEntry(workspaceDatabase, message.root_id); + if (!root) { + throw new MutationError( + MutationErrorCode.RootNotFound, + 'There was an error while fetching the root. Please make sure you have access to this root.' + ); + } + + if ( + !canDeleteMessage({ + user: { + userId: input.userId, + role: user.role, + }, + root: mapEntry(root), + entry: mapEntry(entry), + message: { + id: message.id, + createdBy: message.created_by, + }, + }) + ) { + throw new MutationError( + MutationErrorCode.MessageDeleteForbidden, + 'You are not allowed to delete this message.' + ); + } + const deletedAt = new Date().toISOString(); const deleteMessageMutationData: DeleteMessageMutationData = { id: input.messageId, @@ -40,7 +90,10 @@ export class MessageDeleteMutationHandler await workspaceDatabase.transaction().execute(async (tx) => { await tx - .deleteFrom('messages') + .updateTable('messages') + .set({ + deleted_at: deletedAt, + }) .where('id', '=', input.messageId) .execute(); diff --git a/apps/desktop/src/main/mutations/messages/message-reaction-create.ts b/apps/desktop/src/main/mutations/messages/message-reaction-create.ts index 3500e91b..41f9bfa1 100644 --- a/apps/desktop/src/main/mutations/messages/message-reaction-create.ts +++ b/apps/desktop/src/main/mutations/messages/message-reaction-create.ts @@ -1,4 +1,5 @@ import { + canCreateMessageReaction, CreateMessageReactionMutation, generateId, IdType, @@ -11,7 +12,13 @@ import { MessageReactionCreateMutationOutput, } from '@/shared/mutations/messages/message-reaction-create'; import { eventBus } from '@/shared/lib/event-bus'; -import { mapMessageReaction } from '@/main/utils'; +import { + fetchEntry, + fetchUser, + mapEntry, + mapMessageReaction, +} from '@/main/utils'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; export class MessageReactionCreateMutationHandler implements MutationHandler @@ -23,6 +30,19 @@ export class MessageReactionCreateMutationHandler input.userId ); + const message = await workspaceDatabase + .selectFrom('messages') + .selectAll() + .where('id', '=', input.messageId) + .executeTakeFirst(); + + if (!message) { + throw new MutationError( + MutationErrorCode.MessageNotFound, + 'Message not found or has been deleted.' + ); + } + const existingMessageReaction = await workspaceDatabase .selectFrom('message_reactions') .selectAll() @@ -37,6 +57,41 @@ export class MessageReactionCreateMutationHandler }; } + const user = await fetchUser(workspaceDatabase, input.userId); + if (!user) { + throw new MutationError( + MutationErrorCode.UserNotFound, + 'There was an error while fetching the user. Please make sure you are logged in.' + ); + } + + const root = await fetchEntry(workspaceDatabase, input.rootId); + if (!root) { + throw new MutationError( + MutationErrorCode.RootNotFound, + 'There was an error while fetching the root. Please make sure you have access to this root.' + ); + } + + if ( + !canCreateMessageReaction({ + user: { + userId: input.userId, + role: user.role, + }, + root: mapEntry(root), + message: { + id: message.id, + createdBy: message.created_by, + }, + }) + ) { + throw new MutationError( + MutationErrorCode.MessageReactionCreateForbidden, + "You don't have permission to react to this message." + ); + } + const { createdMessageReaction, createdMutation } = await workspaceDatabase .transaction() .execute(async (trx) => { diff --git a/apps/desktop/src/main/mutations/messages/message-reaction-delete.ts b/apps/desktop/src/main/mutations/messages/message-reaction-delete.ts index aeeea4d0..a6007332 100644 --- a/apps/desktop/src/main/mutations/messages/message-reaction-delete.ts +++ b/apps/desktop/src/main/mutations/messages/message-reaction-delete.ts @@ -41,11 +41,14 @@ export class MessageReactionDeleteMutationHandler .transaction() .execute(async (trx) => { const deletedMessageReaction = await trx - .deleteFrom('message_reactions') + .updateTable('message_reactions') + .returningAll() + .set({ + deleted_at: new Date().toISOString(), + }) .where('message_id', '=', input.messageId) .where('collaborator_id', '=', input.userId) .where('reaction', '=', input.reaction) - .returningAll() .executeTakeFirst(); if (!deletedMessageReaction) { diff --git a/apps/desktop/src/main/mutations/pages/page-content-update.ts b/apps/desktop/src/main/mutations/pages/page-content-update.ts index 29b8da35..bf86608a 100644 --- a/apps/desktop/src/main/mutations/pages/page-content-update.ts +++ b/apps/desktop/src/main/mutations/pages/page-content-update.ts @@ -1,4 +1,4 @@ -import { Block } from '@colanode/core'; +import { Block, PageAttributes } from '@colanode/core'; import { entryService } from '@/main/services/entry-service'; import { MutationHandler } from '@/main/types'; @@ -7,7 +7,7 @@ import { PageContentUpdateMutationInput, PageContentUpdateMutationOutput, } from '@/shared/mutations/pages/page-content-update'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; export class PageContentUpdateMutationHandler implements MutationHandler @@ -15,14 +15,10 @@ export class PageContentUpdateMutationHandler async handleMutation( input: PageContentUpdateMutationInput ): Promise { - const result = await entryService.updateEntry( + const result = await entryService.updateEntry( input.pageId, input.userId, (attributes) => { - if (attributes.type !== 'page') { - throw new MutationError('invalid_attributes', 'Invalid node type'); - } - const blocksMap = new Map(); if (attributes.content) { for (const [key, value] of Object.entries(attributes.content)) { @@ -50,14 +46,14 @@ export class PageContentUpdateMutationHandler if (result === 'unauthorized') { throw new MutationError( - 'unauthorized', + MutationErrorCode.PageUpdateForbidden, "You don't have permission to update this page." ); } if (result !== 'success') { throw new MutationError( - 'unknown', + MutationErrorCode.PageUpdateFailed, 'Something went wrong while updating the page content.' ); } diff --git a/apps/desktop/src/main/mutations/pages/page-update.ts b/apps/desktop/src/main/mutations/pages/page-update.ts index e2d9ff46..0bfae973 100644 --- a/apps/desktop/src/main/mutations/pages/page-update.ts +++ b/apps/desktop/src/main/mutations/pages/page-update.ts @@ -1,6 +1,8 @@ +import { PageAttributes } from '@colanode/core'; + import { entryService } from '@/main/services/entry-service'; import { MutationHandler } from '@/main/types'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; import { PageUpdateMutationInput, PageUpdateMutationOutput, @@ -12,14 +14,10 @@ export class PageUpdateMutationHandler async handleMutation( input: PageUpdateMutationInput ): Promise { - const result = await entryService.updateEntry( + const result = await entryService.updateEntry( input.pageId, input.userId, (attributes) => { - if (attributes.type !== 'page') { - throw new MutationError('invalid_attributes', 'Node is not a page'); - } - attributes.name = input.name; attributes.avatar = input.avatar; @@ -29,15 +27,15 @@ export class PageUpdateMutationHandler if (result === 'unauthorized') { throw new MutationError( - 'unauthorized', + MutationErrorCode.PageUpdateForbidden, "You don't have permission to update this page." ); } if (result !== 'success') { throw new MutationError( - 'unknown', - 'Something went wrong while updating the page.' + MutationErrorCode.PageUpdateFailed, + 'Something went wrong while updating the page. Please try again later.' ); } diff --git a/apps/desktop/src/main/mutations/records/record-avatar-update.ts b/apps/desktop/src/main/mutations/records/record-avatar-update.ts index f5fcd1cd..98ed4daa 100644 --- a/apps/desktop/src/main/mutations/records/record-avatar-update.ts +++ b/apps/desktop/src/main/mutations/records/record-avatar-update.ts @@ -1,6 +1,8 @@ +import { RecordAttributes } from '@colanode/core'; + import { entryService } from '@/main/services/entry-service'; import { MutationHandler } from '@/main/types'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; import { RecordAvatarUpdateMutationInput, RecordAvatarUpdateMutationOutput, @@ -12,14 +14,10 @@ export class RecordAvatarUpdateMutationHandler async handleMutation( input: RecordAvatarUpdateMutationInput ): Promise { - const result = await entryService.updateEntry( + const result = await entryService.updateEntry( input.recordId, input.userId, (attributes) => { - if (attributes.type !== 'record') { - throw new MutationError('invalid_attributes', 'Invalid node type'); - } - attributes.avatar = input.avatar; return attributes; } @@ -27,7 +25,7 @@ export class RecordAvatarUpdateMutationHandler if (result === 'unauthorized') { throw new MutationError( - 'unauthorized', + MutationErrorCode.RecordUpdateForbidden, "You don't have permission to update this record." ); } diff --git a/apps/desktop/src/main/mutations/records/record-content-update.ts b/apps/desktop/src/main/mutations/records/record-content-update.ts index a1fe123e..da2f61a3 100644 --- a/apps/desktop/src/main/mutations/records/record-content-update.ts +++ b/apps/desktop/src/main/mutations/records/record-content-update.ts @@ -1,4 +1,4 @@ -import { Block } from '@colanode/core'; +import { Block, RecordAttributes } from '@colanode/core'; import { entryService } from '@/main/services/entry-service'; import { MutationHandler } from '@/main/types'; @@ -7,7 +7,7 @@ import { RecordContentUpdateMutationInput, RecordContentUpdateMutationOutput, } from '@/shared/mutations/records/record-content-update'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; export class RecordContentUpdateMutationHandler implements MutationHandler @@ -15,14 +15,10 @@ export class RecordContentUpdateMutationHandler async handleMutation( input: RecordContentUpdateMutationInput ): Promise { - const result = await entryService.updateEntry( + const result = await entryService.updateEntry( input.recordId, input.userId, (attributes) => { - if (attributes.type !== 'record') { - throw new MutationError('invalid_attributes', 'Invalid node type'); - } - const blocksMap = new Map(); if (attributes.content) { for (const [key, value] of Object.entries(attributes.content)) { @@ -50,15 +46,15 @@ export class RecordContentUpdateMutationHandler if (result === 'unauthorized') { throw new MutationError( - 'unauthorized', + MutationErrorCode.RecordUpdateForbidden, "You don't have permission to update this record." ); } if (result !== 'success') { throw new MutationError( - 'unknown', - 'Something went wrong while updating the record content.' + MutationErrorCode.RecordUpdateFailed, + 'Something went wrong while updating the record content. Please try again later.' ); } diff --git a/apps/desktop/src/main/mutations/records/record-field-value-delete.ts b/apps/desktop/src/main/mutations/records/record-field-value-delete.ts index e3f1a734..2f6eaf51 100644 --- a/apps/desktop/src/main/mutations/records/record-field-value-delete.ts +++ b/apps/desktop/src/main/mutations/records/record-field-value-delete.ts @@ -1,6 +1,8 @@ +import { RecordAttributes } from '@colanode/core'; + import { entryService } from '@/main/services/entry-service'; import { MutationHandler } from '@/main/types'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; import { RecordFieldValueDeleteMutationInput, RecordFieldValueDeleteMutationOutput, @@ -12,14 +14,10 @@ export class RecordFieldValueDeleteMutationHandler async handleMutation( input: RecordFieldValueDeleteMutationInput ): Promise { - const result = await entryService.updateEntry( + const result = await entryService.updateEntry( input.recordId, input.userId, (attributes) => { - if (attributes.type !== 'record') { - throw new MutationError('invalid_attributes', 'Invalid node type'); - } - delete attributes.fields[input.fieldId]; return attributes; } @@ -27,15 +25,15 @@ export class RecordFieldValueDeleteMutationHandler if (result === 'unauthorized') { throw new MutationError( - 'unauthorized', + MutationErrorCode.RecordUpdateForbidden, "You don't have permission to delete this field value." ); } if (result !== 'success') { throw new MutationError( - 'unknown', - 'Something went wrong while deleting the field value.' + MutationErrorCode.RecordUpdateFailed, + 'Something went wrong while deleting the field value. Please try again later.' ); } diff --git a/apps/desktop/src/main/mutations/records/record-field-value-set.ts b/apps/desktop/src/main/mutations/records/record-field-value-set.ts index 21764ad4..563a3cc0 100644 --- a/apps/desktop/src/main/mutations/records/record-field-value-set.ts +++ b/apps/desktop/src/main/mutations/records/record-field-value-set.ts @@ -1,6 +1,8 @@ +import { RecordAttributes } from '@colanode/core'; + import { entryService } from '@/main/services/entry-service'; import { MutationHandler } from '@/main/types'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; import { RecordFieldValueSetMutationInput, RecordFieldValueSetMutationOutput, @@ -12,14 +14,10 @@ export class RecordFieldValueSetMutationHandler async handleMutation( input: RecordFieldValueSetMutationInput ): Promise { - const result = await entryService.updateEntry( + const result = await entryService.updateEntry( input.recordId, input.userId, (attributes) => { - if (attributes.type !== 'record') { - throw new MutationError('invalid_attributes', 'Invalid node type'); - } - attributes.fields[input.fieldId] = input.value; return attributes; } @@ -27,14 +25,14 @@ export class RecordFieldValueSetMutationHandler if (result === 'unauthorized') { throw new MutationError( - 'unauthorized', + MutationErrorCode.RecordUpdateForbidden, "You don't have permission to set this field value." ); } if (result !== 'success') { throw new MutationError( - 'unknown', + MutationErrorCode.RecordUpdateFailed, 'Something went wrong while setting the field value.' ); } diff --git a/apps/desktop/src/main/mutations/records/record-name-update.ts b/apps/desktop/src/main/mutations/records/record-name-update.ts index 8f33499a..1cc0c428 100644 --- a/apps/desktop/src/main/mutations/records/record-name-update.ts +++ b/apps/desktop/src/main/mutations/records/record-name-update.ts @@ -1,6 +1,8 @@ +import { RecordAttributes } from '@colanode/core'; + import { entryService } from '@/main/services/entry-service'; import { MutationHandler } from '@/main/types'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; import { RecordNameUpdateMutationInput, RecordNameUpdateMutationOutput, @@ -12,14 +14,10 @@ export class RecordNameUpdateMutationHandler async handleMutation( input: RecordNameUpdateMutationInput ): Promise { - const result = await entryService.updateEntry( + const result = await entryService.updateEntry( input.recordId, input.userId, (attributes) => { - if (attributes.type !== 'record') { - throw new MutationError('invalid_attributes', 'Invalid node type'); - } - attributes.name = input.name; return attributes; } @@ -27,15 +25,15 @@ export class RecordNameUpdateMutationHandler if (result === 'unauthorized') { throw new MutationError( - 'unauthorized', + MutationErrorCode.RecordUpdateForbidden, "You don't have permission to update this record." ); } if (result !== 'success') { throw new MutationError( - 'unknown', - 'Something went wrong while updating the record name.' + MutationErrorCode.RecordUpdateFailed, + 'Something went wrong while updating the record name. Please try again later.' ); } diff --git a/apps/desktop/src/main/mutations/servers/server-create.ts b/apps/desktop/src/main/mutations/servers/server-create.ts index 350a31e7..1018c6a1 100644 --- a/apps/desktop/src/main/mutations/servers/server-create.ts +++ b/apps/desktop/src/main/mutations/servers/server-create.ts @@ -1,7 +1,7 @@ import { databaseService } from '@/main/data/database-service'; import { serverService } from '@/main/services/server-service'; import { MutationHandler } from '@/main/types'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; import { ServerCreateMutationInput, ServerCreateMutationOutput, @@ -13,23 +13,24 @@ export class ServerCreateMutationHandler async handleMutation( input: ServerCreateMutationInput ): Promise { + const domain = parseDomain(input.domain); const existingServer = await databaseService.appDatabase .selectFrom('servers') .selectAll() - .where('domain', '=', input.domain) + .where('domain', '=', domain) .executeTakeFirst(); if (existingServer) { throw new MutationError( - 'server_already_exists', + MutationErrorCode.ServerAlreadyExists, 'A server with this domain already exists.' ); } - const server = await serverService.createServer(input.domain); + const server = await serverService.createServer(domain); if (server === null) { throw new MutationError( - 'invalid_server_domain', + MutationErrorCode.ServerInitFailed, 'Could not fetch server configuration. Please make sure the domain is correct.' ); } @@ -39,3 +40,17 @@ export class ServerCreateMutationHandler }; } } + +const parseDomain = (domain: string): string => { + try { + // Try to parse as URL first + const url = new URL(domain.toLowerCase()); + return url.host; // host includes domain + port if present + } catch { + // If not a valid URL, treat as domain directly + throw new MutationError( + MutationErrorCode.ServerDomainInvalid, + 'The provided domain is not valid. Please make sure it is a valid server domain.' + ); + } +}; diff --git a/apps/desktop/src/main/mutations/spaces/space-create.ts b/apps/desktop/src/main/mutations/spaces/space-create.ts index dd5631b8..51868ddf 100644 --- a/apps/desktop/src/main/mutations/spaces/space-create.ts +++ b/apps/desktop/src/main/mutations/spaces/space-create.ts @@ -13,7 +13,7 @@ import { SpaceCreateMutationInput, SpaceCreateMutationOutput, } from '@/shared/mutations/spaces/space-create'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; export class SpaceCreateMutationHandler implements MutationHandler @@ -28,12 +28,15 @@ export class SpaceCreateMutationHandler .executeTakeFirst(); if (!workspace) { - throw new MutationError('workspace_not_found', 'Workspace not found'); + throw new MutationError( + MutationErrorCode.WorkspaceNotFound, + 'Workspace was not found or has been deleted.' + ); } if (workspace.role === 'guest' || workspace.role === 'none') { throw new MutationError( - 'unauthorized', + MutationErrorCode.SpaceCreateForbidden, "You don't have permission to create spaces in this workspace." ); } @@ -45,11 +48,16 @@ export class SpaceCreateMutationHandler collaborators: { [input.userId]: 'admin', }, - parentId: input.workspaceId, + parentId: spaceId, description: input.description, avatar: null, }; + await entryService.createEntry(input.userId, { + id: spaceId, + attributes: spaceAttributes, + }); + const pageId = generateId(IdType.Page); const pageAttributes: PageAttributes = { type: 'page', @@ -59,6 +67,11 @@ export class SpaceCreateMutationHandler collaborators: {}, }; + await entryService.createEntry(input.userId, { + id: pageId, + attributes: pageAttributes, + }); + const channelId = generateId(IdType.Channel); const channelAttributes: ChannelAttributes = { type: 'channel', @@ -66,20 +79,10 @@ export class SpaceCreateMutationHandler parentId: spaceId, }; - await entryService.createEntry(input.userId, [ - { - id: spaceId, - attributes: spaceAttributes, - }, - { - id: pageId, - attributes: pageAttributes, - }, - { - id: channelId, - attributes: channelAttributes, - }, - ]); + await entryService.createEntry(input.userId, { + id: channelId, + attributes: channelAttributes, + }); return { id: spaceId, diff --git a/apps/desktop/src/main/mutations/spaces/space-update.ts b/apps/desktop/src/main/mutations/spaces/space-update.ts index 2d622f22..ed62bf07 100644 --- a/apps/desktop/src/main/mutations/spaces/space-update.ts +++ b/apps/desktop/src/main/mutations/spaces/space-update.ts @@ -1,6 +1,8 @@ +import { SpaceAttributes } from '@colanode/core'; + import { entryService } from '@/main/services/entry-service'; import { MutationHandler } from '@/main/types'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; import { SpaceUpdateMutationInput, SpaceUpdateMutationOutput, @@ -12,14 +14,10 @@ export class SpaceUpdateMutationHandler async handleMutation( input: SpaceUpdateMutationInput ): Promise { - const result = await entryService.updateEntry( + const result = await entryService.updateEntry( input.id, input.userId, (attributes) => { - if (attributes.type !== 'space') { - throw new MutationError('invalid_attributes', 'Entry is not a space'); - } - attributes.name = input.name; attributes.description = input.description; attributes.avatar = input.avatar; @@ -30,15 +28,15 @@ export class SpaceUpdateMutationHandler if (result === 'unauthorized') { throw new MutationError( - 'unauthorized', + MutationErrorCode.SpaceUpdateForbidden, "You don't have permission to update this space." ); } if (result !== 'success') { throw new MutationError( - 'unknown', - 'Something went wrong while updating the space.' + MutationErrorCode.SpaceUpdateFailed, + 'Something went wrong while updating the space. Please try again later.' ); } diff --git a/apps/desktop/src/main/mutations/users/user-role-update.ts b/apps/desktop/src/main/mutations/users/user-role-update.ts index 923ddd83..5d2febd0 100644 --- a/apps/desktop/src/main/mutations/users/user-role-update.ts +++ b/apps/desktop/src/main/mutations/users/user-role-update.ts @@ -7,7 +7,8 @@ import { UserRoleUpdateMutationInput, UserRoleUpdateMutationOutput, } from '@/shared/mutations/workspaces/workspace-user-role-update'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; +import { parseApiError } from '@/shared/lib/axios'; export class UserRoleUpdateMutationHandler implements MutationHandler @@ -22,7 +23,10 @@ export class UserRoleUpdateMutationHandler .executeTakeFirst(); if (!workspace) { - throw new MutationError('workspace_not_found', 'Workspace not found'); + throw new MutationError( + MutationErrorCode.WorkspaceNotFound, + 'Workspace was not found or has been deleted.' + ); } const account = await databaseService.appDatabase @@ -33,8 +37,8 @@ export class UserRoleUpdateMutationHandler if (!account) { throw new MutationError( - 'account_not_found', - 'The account associated with this workspace was not found.' + MutationErrorCode.AccountNotFound, + 'The account associated with this workspace was not found or has been logged out.' ); } @@ -46,24 +50,29 @@ export class UserRoleUpdateMutationHandler if (!server) { throw new MutationError( - 'server_not_found', + MutationErrorCode.ServerNotFound, 'The server associated with this account was not found.' ); } - await httpClient.put( - `/v1/workspaces/${workspace.workspace_id}/users/${input.userToUpdateId}`, - { - role: input.role, - }, - { - domain: server.domain, - token: account.token, - } - ); + try { + await httpClient.put( + `/v1/workspaces/${workspace.workspace_id}/users/${input.userToUpdateId}`, + { + role: input.role, + }, + { + domain: server.domain, + token: account.token, + } + ); - return { - success: true, - }; + return { + success: true, + }; + } catch (error) { + const apiError = parseApiError(error); + throw new MutationError(MutationErrorCode.ApiError, apiError.message); + } } } diff --git a/apps/desktop/src/main/mutations/users/users-invite.ts b/apps/desktop/src/main/mutations/users/users-invite.ts index f95092e0..11e2c004 100644 --- a/apps/desktop/src/main/mutations/users/users-invite.ts +++ b/apps/desktop/src/main/mutations/users/users-invite.ts @@ -7,7 +7,8 @@ import { UsersInviteMutationInput, UsersInviteMutationOutput, } from '@/shared/mutations/workspaces/workspace-users-invite'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; +import { parseApiError } from '@/shared/lib/axios'; export class UsersInviteMutationHandler implements MutationHandler @@ -22,7 +23,10 @@ export class UsersInviteMutationHandler .executeTakeFirst(); if (!workspace) { - throw new MutationError('workspace_not_found', 'Workspace not found'); + throw new MutationError( + MutationErrorCode.WorkspaceNotFound, + 'Workspace was not found or has been deleted.' + ); } const account = await databaseService.appDatabase @@ -33,8 +37,8 @@ export class UsersInviteMutationHandler if (!account) { throw new MutationError( - 'account_not_found', - 'The account associated with this workspace was not found.' + MutationErrorCode.AccountNotFound, + 'The account associated with this workspace was not found or has been logged out.' ); } @@ -46,25 +50,30 @@ export class UsersInviteMutationHandler if (!server) { throw new MutationError( - 'server_not_found', + MutationErrorCode.ServerNotFound, 'The server associated with this account was not found.' ); } - await httpClient.post( - `/v1/workspaces/${workspace.workspace_id}/users`, - { - emails: input.emails, - role: input.role, - }, - { - domain: server.domain, - token: account.token, - } - ); + try { + await httpClient.post( + `/v1/workspaces/${workspace.workspace_id}/users`, + { + emails: input.emails, + role: input.role, + }, + { + domain: server.domain, + token: account.token, + } + ); - return { - success: true, - }; + return { + success: true, + }; + } catch (error) { + const apiError = parseApiError(error); + throw new MutationError(MutationErrorCode.ApiError, apiError.message); + } } } diff --git a/apps/desktop/src/main/mutations/workspaces/workspace-create.ts b/apps/desktop/src/main/mutations/workspaces/workspace-create.ts index dc130af6..3d867be1 100644 --- a/apps/desktop/src/main/mutations/workspaces/workspace-create.ts +++ b/apps/desktop/src/main/mutations/workspaces/workspace-create.ts @@ -8,7 +8,8 @@ import { WorkspaceCreateMutationInput, WorkspaceCreateMutationOutput, } from '@/shared/mutations/workspaces/workspace-create'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; +import { parseApiError } from '@/shared/lib/axios'; export class WorkspaceCreateMutationHandler implements MutationHandler @@ -23,7 +24,10 @@ export class WorkspaceCreateMutationHandler .executeTakeFirst(); if (!account) { - throw new MutationError('account_not_found', 'Account not found!'); + throw new MutationError( + MutationErrorCode.AccountNotFound, + 'Account not found or has been logged out.' + ); } const server = await databaseService.appDatabase @@ -34,61 +38,67 @@ export class WorkspaceCreateMutationHandler if (!server) { throw new MutationError( - 'server_not_found', + MutationErrorCode.ServerNotFound, 'The server associated with this account was not found.' ); } - const { data } = await httpClient.post( - `/v1/workspaces`, - { - name: input.name, - description: input.description, - avatar: input.avatar, - }, - { - domain: server.domain, - token: account.token, + try { + const { data } = await httpClient.post( + `/v1/workspaces`, + { + name: input.name, + description: input.description, + avatar: input.avatar, + }, + { + domain: server.domain, + token: account.token, + } + ); + + const createdWorkspace = await databaseService.appDatabase + .insertInto('workspaces') + .returningAll() + .values({ + workspace_id: data.id ?? data.id, + account_id: data.user.accountId, + name: data.name, + description: data.description, + avatar: data.avatar, + role: data.user.role, + user_id: data.user.id, + }) + .onConflict((cb) => cb.doNothing()) + .executeTakeFirst(); + + if (!createdWorkspace) { + throw new MutationError( + MutationErrorCode.WorkspaceNotCreated, + 'Something went wrong updating the workspace. Please try again later.' + ); } - ); - const createdWorkspace = await databaseService.appDatabase - .insertInto('workspaces') - .returningAll() - .values({ - workspace_id: data.id ?? data.id, - account_id: data.user.accountId, - name: data.name, - description: data.description, - avatar: data.avatar, - role: data.user.role, - user_id: data.user.id, - version_id: data.versionId, - }) - .onConflict((cb) => cb.doNothing()) - .executeTakeFirst(); + eventBus.publish({ + type: 'workspace_created', + workspace: { + id: createdWorkspace.workspace_id, + userId: createdWorkspace.user_id, + name: createdWorkspace.name, + accountId: createdWorkspace.account_id, + role: createdWorkspace.role, + avatar: createdWorkspace.avatar, + description: createdWorkspace.description, + }, + }); - if (!createdWorkspace) { - throw new MutationError('unknown', 'Failed to create workspace!'); - } - - eventBus.publish({ - type: 'workspace_created', - workspace: { + return { id: createdWorkspace.workspace_id, userId: createdWorkspace.user_id, - name: createdWorkspace.name, - versionId: createdWorkspace.version_id, - accountId: createdWorkspace.account_id, - role: createdWorkspace.role, - avatar: createdWorkspace.avatar, - description: createdWorkspace.description, - }, - }); - - return { - id: createdWorkspace.workspace_id, - userId: createdWorkspace.user_id, - }; + }; + } catch (error) { + const apiError = parseApiError(error); + throw new MutationError(MutationErrorCode.ApiError, apiError.message); + } } } diff --git a/apps/desktop/src/main/mutations/workspaces/workspace-update.ts b/apps/desktop/src/main/mutations/workspaces/workspace-update.ts index c908af5b..7a028544 100644 --- a/apps/desktop/src/main/mutations/workspaces/workspace-update.ts +++ b/apps/desktop/src/main/mutations/workspaces/workspace-update.ts @@ -1,8 +1,9 @@ import { databaseService } from '@/main/data/database-service'; import { MutationHandler } from '@/main/types'; +import { parseApiError } from '@/shared/lib/axios'; import { eventBus } from '@/shared/lib/event-bus'; import { httpClient } from '@/shared/lib/http-client'; -import { MutationError } from '@/shared/mutations'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; import { WorkspaceUpdateMutationInput, WorkspaceUpdateMutationOutput, @@ -22,7 +23,10 @@ export class WorkspaceUpdateMutationHandler .executeTakeFirst(); if (!account) { - throw new MutationError('account_not_found', 'Account not found!'); + throw new MutationError( + MutationErrorCode.AccountNotFound, + 'Account not found or has been logged out.' + ); } const server = await databaseService.appDatabase @@ -33,60 +37,66 @@ export class WorkspaceUpdateMutationHandler if (!server) { throw new MutationError( - 'server_not_found', + MutationErrorCode.ServerNotFound, 'The server associated with this account was not found.' ); } - const { data } = await httpClient.put( - `/v1/workspaces/${input.id}`, - { - name: input.name, - description: input.description, - avatar: input.avatar, - }, - { - domain: server.domain, - token: account.token, + try { + const { data } = await httpClient.put( + `/v1/workspaces/${input.id}`, + { + name: input.name, + description: input.description, + avatar: input.avatar, + }, + { + domain: server.domain, + token: account.token, + } + ); + + const updatedWorkspace = await databaseService.appDatabase + .updateTable('workspaces') + .returningAll() + .set({ + name: data.name, + description: data.description, + avatar: data.avatar, + role: data.role, + }) + .where((eb) => + eb.and([ + eb('account_id', '=', input.accountId), + eb('workspace_id', '=', input.id), + ]) + ) + .executeTakeFirst(); + + if (!updatedWorkspace) { + throw new MutationError( + MutationErrorCode.WorkspaceNotUpdated, + 'Something went wrong updating the workspace. Please try again later.' + ); } - ); - const updatedWorkspace = await databaseService.appDatabase - .updateTable('workspaces') - .returningAll() - .set({ - name: data.name, - description: data.description, - avatar: data.avatar, - role: data.role, - version_id: data.versionId, - }) - .where((eb) => - eb.and([ - eb('account_id', '=', input.accountId), - eb('workspace_id', '=', input.id), - ]) - ) - .executeTakeFirst(); + eventBus.publish({ + type: 'workspace_updated', + workspace: { + id: updatedWorkspace.workspace_id, + userId: updatedWorkspace.user_id, + name: updatedWorkspace.name, + accountId: updatedWorkspace.account_id, + role: updatedWorkspace.role, + }, + }); - if (!updatedWorkspace) { - throw new MutationError('unknown', 'Failed to update workspace!'); + return { + success: true, + }; + } catch (error) { + const apiError = parseApiError(error); + throw new MutationError(MutationErrorCode.ApiError, apiError.message); } - - eventBus.publish({ - type: 'workspace_updated', - workspace: { - id: updatedWorkspace.workspace_id, - userId: updatedWorkspace.user_id, - name: updatedWorkspace.name, - versionId: updatedWorkspace.version_id, - accountId: updatedWorkspace.account_id, - role: updatedWorkspace.role, - }, - }); - - return { - success: true, - }; } } diff --git a/apps/desktop/src/main/queries/chats/chat-list.ts b/apps/desktop/src/main/queries/chats/chat-list.ts new file mode 100644 index 00000000..139fd847 --- /dev/null +++ b/apps/desktop/src/main/queries/chats/chat-list.ts @@ -0,0 +1,98 @@ +import { ChatEntry } from '@colanode/core'; + +import { databaseService } from '@/main/data/database-service'; +import { SelectEntry } from '@/main/data/workspace/schema'; +import { ChangeCheckResult, QueryHandler } from '@/main/types'; +import { mapEntry } from '@/main/utils'; +import { ChatListQueryInput } from '@/shared/queries/chats/chat-list'; +import { Event } from '@/shared/types/events'; + +export class ChatListQueryHandler implements QueryHandler { + public async handleQuery(input: ChatListQueryInput): Promise { + const rows = await this.fetchChildren(input); + return rows.map(mapEntry) as ChatEntry[]; + } + + public async checkForChanges( + event: Event, + input: ChatListQueryInput, + output: ChatEntry[] + ): Promise> { + if ( + event.type === 'workspace_deleted' && + event.workspace.userId === input.userId + ) { + return { + hasChanges: true, + result: [], + }; + } + + if ( + event.type === 'entry_created' && + event.userId === input.userId && + event.entry.type === 'chat' + ) { + const newChildren = [...output, event.entry]; + return { + hasChanges: true, + result: newChildren, + }; + } + + if ( + event.type === 'entry_updated' && + event.userId === input.userId && + event.entry.type === 'chat' + ) { + const entry = output.find((entry) => entry.id === event.entry.id); + if (entry) { + const newChildren = output.map((entry) => + entry.id === event.entry.id ? (event.entry as ChatEntry) : entry + ); + + return { + hasChanges: true, + result: newChildren, + }; + } + } + + if ( + event.type === 'entry_deleted' && + event.userId === input.userId && + event.entry.type === 'chat' + ) { + const entry = output.find((entry) => entry.id === event.entry.id); + if (entry) { + const newChildren = output.filter( + (entry) => entry.id !== event.entry.id + ); + return { + hasChanges: true, + result: newChildren, + }; + } + } + + return { + hasChanges: false, + }; + } + + private async fetchChildren( + input: ChatListQueryInput + ): Promise { + const workspaceDatabase = await databaseService.getWorkspaceDatabase( + input.userId + ); + + const rows = await workspaceDatabase + .selectFrom('entries') + .selectAll() + .where('type', '=', 'chat') + .execute(); + + return rows; + } +} diff --git a/apps/desktop/src/main/queries/entries/entry-tree-get.ts b/apps/desktop/src/main/queries/entries/entry-tree-get.ts index 76631f24..7104dc7a 100644 --- a/apps/desktop/src/main/queries/entries/entry-tree-get.ts +++ b/apps/desktop/src/main/queries/entries/entry-tree-get.ts @@ -96,9 +96,11 @@ export class EntryTreeGetQueryHandler while (entry) { result.unshift(entry); - entry = rows.find((row) => row.id === entry?.parent_id); + entry = rows.find( + (row) => row.id !== entry?.id && row.id === entry?.parent_id + ); - if (!entry || entry.id === entry.parent_id) { + if (!entry) { break; } } diff --git a/apps/desktop/src/main/queries/files/file-get.ts b/apps/desktop/src/main/queries/files/file-get.ts index 3d9ff0fb..56940d4c 100644 --- a/apps/desktop/src/main/queries/files/file-get.ts +++ b/apps/desktop/src/main/queries/files/file-get.ts @@ -97,6 +97,7 @@ export class FileGetQueryHandler implements QueryHandler { 'f.created_by', 'f.updated_at', 'f.updated_by', + 'f.deleted_at', 'f.status', 'f.version', 'fs.download_status', @@ -105,9 +106,10 @@ export class FileGetQueryHandler implements QueryHandler { 'fs.upload_progress', ]) .where('f.id', '=', input.id) + .where('f.deleted_at', 'is', null) .executeTakeFirst(); - if (!file) { + if (!file || file.deleted_at) { return null; } diff --git a/apps/desktop/src/main/queries/files/file-list.ts b/apps/desktop/src/main/queries/files/file-list.ts index f0d04106..34c6eb96 100644 --- a/apps/desktop/src/main/queries/files/file-list.ts +++ b/apps/desktop/src/main/queries/files/file-list.ts @@ -113,6 +113,7 @@ export class FileListQueryHandler implements QueryHandler { 'f.created_by', 'f.updated_at', 'f.updated_by', + 'f.deleted_at', 'f.status', 'f.version', 'fs.download_status', @@ -121,6 +122,7 @@ export class FileListQueryHandler implements QueryHandler { 'fs.upload_progress', ]) .where('f.parent_id', '=', input.parentId) + .where('f.deleted_at', 'is', null) .orderBy('f.id', 'asc') .limit(input.count) .offset(offset) diff --git a/apps/desktop/src/main/queries/index.ts b/apps/desktop/src/main/queries/index.ts index 6cea71c9..687363e7 100644 --- a/apps/desktop/src/main/queries/index.ts +++ b/apps/desktop/src/main/queries/index.ts @@ -20,6 +20,8 @@ import { UserListQueryHandler } from '@/main/queries/users/user-list'; import { DatabaseListQueryHandler } from '@/main/queries/databases/database-list'; import { RecordSearchQueryHandler } from '@/main/queries/records/record-search'; import { UserGetQueryHandler } from '@/main/queries/users/user-get'; +import { SpaceListQueryHandler } from '@/main/queries/spaces/space-list'; +import { ChatListQueryHandler } from '@/main/queries/chats/chat-list'; import { QueryHandler } from '@/main/types'; import { QueryMap } from '@/shared/queries'; @@ -50,4 +52,6 @@ export const queryHandlerMap: QueryHandlerMap = { record_search: new RecordSearchQueryHandler(), user_get: new UserGetQueryHandler(), file_get: new FileGetQueryHandler(), + chat_list: new ChatListQueryHandler(), + space_list: new SpaceListQueryHandler(), }; diff --git a/apps/desktop/src/main/queries/messages/message-list.ts b/apps/desktop/src/main/queries/messages/message-list.ts index 4430f313..716ee08a 100644 --- a/apps/desktop/src/main/queries/messages/message-list.ts +++ b/apps/desktop/src/main/queries/messages/message-list.ts @@ -100,6 +100,7 @@ export class MessageListQueryHandler .selectFrom('messages') .selectAll() .where('parent_id', '=', input.conversationId) + .where('deleted_at', 'is', null) .orderBy('id', 'desc') .limit(input.count) .offset(offset) diff --git a/apps/desktop/src/main/queries/messages/message-reactions-get.ts b/apps/desktop/src/main/queries/messages/message-reactions-get.ts index 8ba8c302..03b18495 100644 --- a/apps/desktop/src/main/queries/messages/message-reactions-get.ts +++ b/apps/desktop/src/main/queries/messages/message-reactions-get.ts @@ -86,7 +86,7 @@ export class MessageReactionsGetQueryHandler ELSE 0 END) as reacted FROM message_reactions - WHERE message_id = ${input.messageId} + WHERE message_id = ${input.messageId} AND deleted_at IS NULL GROUP BY reaction `.execute(workspaceDatabase); diff --git a/apps/desktop/src/main/queries/spaces/space-list.ts b/apps/desktop/src/main/queries/spaces/space-list.ts new file mode 100644 index 00000000..f4a5b41d --- /dev/null +++ b/apps/desktop/src/main/queries/spaces/space-list.ts @@ -0,0 +1,100 @@ +import { SpaceEntry } from '@colanode/core'; + +import { databaseService } from '@/main/data/database-service'; +import { SelectEntry } from '@/main/data/workspace/schema'; +import { ChangeCheckResult, QueryHandler } from '@/main/types'; +import { mapEntry } from '@/main/utils'; +import { SpaceListQueryInput } from '@/shared/queries/spaces/space-list'; +import { Event } from '@/shared/types/events'; + +export class SpaceListQueryHandler + implements QueryHandler +{ + public async handleQuery(input: SpaceListQueryInput): Promise { + const rows = await this.fetchChildren(input); + return rows.map(mapEntry) as SpaceEntry[]; + } + + public async checkForChanges( + event: Event, + input: SpaceListQueryInput, + output: SpaceEntry[] + ): Promise> { + if ( + event.type === 'workspace_deleted' && + event.workspace.userId === input.userId + ) { + return { + hasChanges: true, + result: [], + }; + } + + if ( + event.type === 'entry_created' && + event.userId === input.userId && + event.entry.type === 'space' + ) { + const newChildren = [...output, event.entry]; + return { + hasChanges: true, + result: newChildren, + }; + } + + if ( + event.type === 'entry_updated' && + event.userId === input.userId && + event.entry.type === 'space' + ) { + const entry = output.find((entry) => entry.id === event.entry.id); + if (entry) { + const newChildren = output.map((entry) => + entry.id === event.entry.id ? (event.entry as SpaceEntry) : entry + ); + + return { + hasChanges: true, + result: newChildren, + }; + } + } + + if ( + event.type === 'entry_deleted' && + event.userId === input.userId && + event.entry.type === 'space' + ) { + const entry = output.find((entry) => entry.id === event.entry.id); + if (entry) { + const newChildren = output.filter( + (entry) => entry.id !== event.entry.id + ); + return { + hasChanges: true, + result: newChildren, + }; + } + } + + return { + hasChanges: false, + }; + } + + private async fetchChildren( + input: SpaceListQueryInput + ): Promise { + const workspaceDatabase = await databaseService.getWorkspaceDatabase( + input.userId + ); + + const rows = await workspaceDatabase + .selectFrom('entries') + .selectAll() + .where('type', '=', 'space') + .execute(); + + return rows; + } +} diff --git a/apps/desktop/src/main/scheduler.ts b/apps/desktop/src/main/scheduler.ts index 548b2ee6..a7fc7373 100644 --- a/apps/desktop/src/main/scheduler.ts +++ b/apps/desktop/src/main/scheduler.ts @@ -8,7 +8,7 @@ import { JobHandler, JobInput, JobMap } from '@/main/jobs'; import { SyncServersJobHandler } from '@/main/jobs/sync-servers'; import { SyncAccountJobHandler } from '@/main/jobs/sync-account'; import { InitSynchronizersJobHandler } from '@/main/jobs/init-synchronizers'; -// import { RevertInvalidTransactionsJobHandler } from '@/main/jobs/revert-invalid-transactions'; +import { RevertInvalidMutationsJobHandler } from '@/main/jobs/revert-invalid-mutations'; import { SyncPendingMutationsJobHandler } from '@/main/jobs/sync-pending-mutations'; import { SyncDeletedTokensJobHandler } from '@/main/jobs/sync-deleted-tokens'; import { ConnectSocketJobHandler } from '@/main/jobs/connect-socket'; @@ -26,7 +26,7 @@ export const jobHandlerMap: JobHandlerMap = { sync_servers: new SyncServersJobHandler(), sync_account: new SyncAccountJobHandler(), init_synchronizers: new InitSynchronizersJobHandler(), - // revert_invalid_transactions: new RevertInvalidTransactionsJobHandler(), + revert_invalid_mutations: new RevertInvalidMutationsJobHandler(), sync_pending_mutations: new SyncPendingMutationsJobHandler(), sync_deleted_tokens: new SyncDeletedTokensJobHandler(), connect_socket: new ConnectSocketJobHandler(), @@ -239,10 +239,10 @@ class Scheduler { userId, }); - // this.schedule({ - // type: 'revert_invalid_transactions', - // userId, - // }); + this.schedule({ + type: 'revert_invalid_mutations', + userId, + }); this.schedule({ type: 'init_synchronizers', @@ -294,12 +294,12 @@ class Scheduler { return true; } - // if ( - // state.input.type === 'revert_invalid_transactions' && - // state.input.userId === userId - // ) { - // return true; - // } + if ( + state.input.type === 'revert_invalid_mutations' && + state.input.userId === userId + ) { + return true; + } if (state.input.type === 'upload_files' && state.input.userId === userId) { return true; diff --git a/apps/desktop/src/main/services/account-service.ts b/apps/desktop/src/main/services/account-service.ts index aebe4682..d375ee24 100644 --- a/apps/desktop/src/main/services/account-service.ts +++ b/apps/desktop/src/main/services/account-service.ts @@ -123,7 +123,6 @@ class AccountService { avatar: workspace.avatar, description: workspace.description, role: workspace.user.role, - version_id: workspace.versionId, }) .returningAll() .executeTakeFirst(); @@ -153,7 +152,6 @@ class AccountService { avatar: workspace.avatar, description: workspace.description, role: workspace.user.role, - version_id: workspace.versionId, }) .where('user_id', '=', currentWorkspace.user_id) .executeTakeFirst(); diff --git a/apps/desktop/src/main/services/collaboration-service.ts b/apps/desktop/src/main/services/collaboration-service.ts index bfe7fd51..5e98e574 100644 --- a/apps/desktop/src/main/services/collaboration-service.ts +++ b/apps/desktop/src/main/services/collaboration-service.ts @@ -49,7 +49,7 @@ class CollaborationService { .execute(); await tx - .deleteFrom('transactions') + .deleteFrom('entry_transactions') .where('entry_id', '=', collaboration.entryId) .execute(); }); diff --git a/apps/desktop/src/main/services/entry-service.ts b/apps/desktop/src/main/services/entry-service.ts index f9026ea5..cba2dca9 100644 --- a/apps/desktop/src/main/services/entry-service.ts +++ b/apps/desktop/src/main/services/entry-service.ts @@ -4,31 +4,30 @@ import { LocalCreateTransaction, LocalDeleteTransaction, LocalUpdateTransaction, - Entry, EntryAttributes, - EntryMutationContext, - registry, - SyncCreateTransactionData, - SyncDeleteTransactionData, - SyncTransactionData, - SyncUpdateTransactionData, + SyncCreateEntryTransactionData, + SyncDeleteEntryTransactionData, SyncEntryInteractionData, + SyncEntryTransactionData, + SyncUpdateEntryTransactionData, + extractEntryText, + canDeleteEntry, + canUpdateEntry, + entryAttributesSchema, + Entry, + canCreateEntry, } from '@colanode/core'; import { decodeState, YDoc } from '@colanode/crdt'; import { createDebugger } from '@/main/debugger'; -import { SelectWorkspace } from '@/main/data/app/schema'; import { databaseService } from '@/main/data/database-service'; +import { SelectEntryTransaction } from '@/main/data/workspace/schema'; import { - SelectMutation, - SelectEntry, - SelectTransaction, -} from '@/main/data/workspace/schema'; -import { - fetchEntryAncestors, + fetchEntry, + fetchUser, mapEntry, mapEntryInteraction, - mapTransaction, + mapEntryTransaction, } from '@/main/utils'; import { eventBus } from '@/shared/lib/event-bus'; @@ -49,88 +48,71 @@ export type UpdateEntryResult = class EntryService { private readonly debug = createDebugger('service:entry'); - public async fetchEntry( - entryId: string, - userId: string - ): Promise { + public async createEntry(userId: string, input: CreateEntryInput) { + this.debug(`Creating ${Array.isArray(input) ? 'entries' : 'entry'}`); + const workspaceDatabase = await databaseService.getWorkspaceDatabase(userId); - const entryRow = await workspaceDatabase - .selectFrom('entries') - .where('id', '=', entryId) - .selectAll() - .executeTakeFirst(); - - if (!entryRow) { - return null; + const user = await fetchUser(workspaceDatabase, userId); + if (!user) { + throw new Error('User not found'); } - return mapEntry(entryRow); - } + let root: Entry | null = null; + const parent = await fetchEntry( + workspaceDatabase, + input.attributes.parentId + ); - public async createEntry( - userId: string, - input: CreateEntryInput | CreateEntryInput[] - ) { - this.debug(`Creating ${Array.isArray(input) ? 'entries' : 'entry'}`); - const workspace = await this.fetchWorkspace(userId); - - const inputs = Array.isArray(input) ? input : [input]; - const createdEntries: SelectEntry[] = []; - const createdMutations: SelectMutation[] = []; - - const workspaceDatabase = - await databaseService.getWorkspaceDatabase(userId); - - await workspaceDatabase.transaction().execute(async (transaction) => { - for (const inputItem of inputs) { - const model = registry.getModel(inputItem.attributes.type); - if (!model.schema.safeParse(inputItem.attributes).success) { - throw new Error('Invalid attributes'); + if (parent) { + if (parent.id === parent.root_id) { + root = mapEntry(parent); + } else { + const rootRow = await fetchEntry(workspaceDatabase, parent.root_id); + if (rootRow) { + root = mapEntry(rootRow); } + } + } - let ancestors: Entry[] = []; - if (inputItem.attributes.parentId) { - const ancestorRows = await fetchEntryAncestors( - transaction, - inputItem.attributes.parentId - ); - ancestors = ancestorRows.map(mapEntry); - } + if ( + !canCreateEntry( + { + user: { + userId, + role: user.role, + }, + root: root, + }, + input.attributes + ) + ) { + throw new Error('Insufficient permissions'); + } - const rootId = ancestors[0]?.id ?? inputItem.id; - const context = new EntryMutationContext( - workspace.account_id, - workspace.workspace_id, - userId, - workspace.role, - ancestors - ); + const ydoc = new YDoc(); + const update = ydoc.updateAttributes( + entryAttributesSchema, + input.attributes + ); - if (!model.canCreate(context, inputItem.attributes)) { - throw new Error('Insufficient permissions'); - } + const createdAt = new Date().toISOString(); + const transactionId = generateId(IdType.Transaction); + const text = extractEntryText(input.id, input.attributes); - const ydoc = new YDoc(); - const update = ydoc.updateAttributes( - model.schema, - inputItem.attributes - ); - - const createdAt = new Date().toISOString(); - const transactionId = generateId(IdType.Transaction); - const text = model.getText(inputItem.id, inputItem.attributes); - - const createdEntry = await transaction + const { createdEntry, createdMutation } = await workspaceDatabase + .transaction() + .execute(async (trx) => { + const createdEntry = await trx .insertInto('entries') .returningAll() .values({ - id: inputItem.id, - root_id: rootId, - attributes: JSON.stringify(inputItem.attributes), + id: input.id, + root_id: root?.id ?? input.id, + attributes: JSON.stringify(input.attributes), created_at: createdAt, - created_by: context.userId, + created_by: userId, transaction_id: transactionId, }) .executeTakeFirst(); @@ -139,19 +121,17 @@ class EntryService { throw new Error('Failed to create entry'); } - createdEntries.push(createdEntry); - - const createdTransaction = await transaction - .insertInto('transactions') + const createdTransaction = await trx + .insertInto('entry_transactions') .returningAll() .values({ id: transactionId, - entry_id: inputItem.id, - root_id: rootId, + entry_id: input.id, + root_id: root?.id ?? input.id, operation: 'create', data: update, created_at: createdAt, - created_by: context.userId, + created_by: userId, version: 0n, }) .executeTakeFirst(); @@ -160,60 +140,66 @@ class EntryService { throw new Error('Failed to create transaction'); } - const mutation = await transaction + const createdMutation = await trx .insertInto('mutations') .returningAll() .values({ id: generateId(IdType.Mutation), - node_id: inputItem.id, + node_id: input.id, type: 'apply_create_transaction', - data: JSON.stringify(mapTransaction(createdTransaction)), + data: JSON.stringify(mapEntryTransaction(createdTransaction)), created_at: createdAt, retries: 0, }) .executeTakeFirst(); - if (!mutation) { + if (!createdMutation) { throw new Error('Failed to create mutation'); } - createdMutations.push(mutation); - if (text) { - await transaction + await trx .insertInto('texts') - .values({ id: inputItem.id, name: text.name, text: text.text }) + .values({ id: input.id, name: text.name, text: text.text }) .execute(); } - } + + return { + createdEntry, + createdMutation, + }; + }); + + if (!createdEntry) { + throw new Error('Failed to create entry'); + } + + this.debug( + `Created entry ${createdEntry.id} with type ${createdEntry.type}` + ); + + eventBus.publish({ + type: 'entry_created', + userId, + entry: mapEntry(createdEntry), }); - for (const createdEntry of createdEntries) { - this.debug( - `Created entry ${createdEntry.id} with type ${createdEntry.type}` - ); - - eventBus.publish({ - type: 'entry_created', - userId, - entry: mapEntry(createdEntry), - }); + if (!createdMutation) { + throw new Error('Failed to create mutation'); } - for (const createdMutation of createdMutations) { - this.debug(`Created mutation ${createdMutation.id}`); + this.debug(`Created mutation ${createdMutation.id}`); - eventBus.publish({ - type: 'mutation_created', - userId, - }); - } + eventBus.publish({ + type: 'mutation_created', + userId, + }); } - public async updateEntry( + public async updateEntry( entryId: string, userId: string, - updater: (attributes: EntryAttributes) => EntryAttributes + updater: (attributes: T) => T ): Promise { for (let count = 0; count < UPDATE_RETRIES_LIMIT; count++) { const result = await this.tryUpdateEntry(entryId, userId, updater); @@ -225,55 +211,39 @@ class EntryService { return 'failed'; } - private async tryUpdateEntry( + private async tryUpdateEntry( entryId: string, userId: string, - updater: (attributes: EntryAttributes) => EntryAttributes + updater: (attributes: T) => T ): Promise { this.debug(`Updating entry ${entryId}`); - const workspace = await this.fetchWorkspace(userId); - const workspaceDatabase = await databaseService.getWorkspaceDatabase(userId); - const ancestorRows = await fetchEntryAncestors(workspaceDatabase, entryId); - const entryRow = ancestorRows.find((ancestor) => ancestor.id === entryId); + const entryRow = await fetchEntry(workspaceDatabase, entryId); if (!entryRow) { return 'not_found'; } - const ancestors = ancestorRows.map(mapEntry); - const entry = mapEntry(entryRow); - - if (!entry) { + const user = await fetchUser(workspaceDatabase, userId); + if (!user) { return 'not_found'; } - const context = new EntryMutationContext( - workspace.account_id, - workspace.workspace_id, - userId, - workspace.role, - ancestors - ); + const root = await fetchEntry(workspaceDatabase, entryRow.root_id); + if (!root) { + return 'not_found'; + } + const entry = mapEntry(entryRow); const transactionId = generateId(IdType.Transaction); const updatedAt = new Date().toISOString(); - const updatedAttributes = updater(entry.attributes); - - const model = registry.getModel(entry.type); - if (!model.schema.safeParse(updatedAttributes).success) { - return 'invalid_attributes'; - } - - if (!model.canUpdate(context, entry, updatedAttributes)) { - return 'unauthorized'; - } + const updatedAttributes = updater(entry.attributes as T); const ydoc = new YDoc(); const previousTransactions = await workspaceDatabase - .selectFrom('transactions') + .selectFrom('entry_transactions') .where('entry_id', '=', entryId) .selectAll() .execute(); @@ -286,8 +256,28 @@ class EntryService { ydoc.applyUpdate(previousTransaction.data); } - const update = ydoc.updateAttributes(model.schema, updatedAttributes); - const text = model.getText(entryId, updatedAttributes); + const update = ydoc.updateAttributes( + entryAttributesSchema, + updatedAttributes + ); + + if ( + !canUpdateEntry( + { + user: { + userId, + role: user.role, + }, + root: mapEntry(root), + entry: entry, + }, + updatedAttributes + ) + ) { + return 'unauthorized'; + } + + const text = extractEntryText(entryId, updatedAttributes); const { updatedEntry, createdMutation } = await workspaceDatabase .transaction() @@ -298,7 +288,7 @@ class EntryService { .set({ attributes: JSON.stringify(ydoc.getAttributes()), updated_at: updatedAt, - updated_by: context.userId, + updated_by: userId, transaction_id: transactionId, }) .where('id', '=', entryId) @@ -308,8 +298,9 @@ class EntryService { if (!updatedEntry) { return { updatedEntry: undefined }; } + const createdTransaction = await trx - .insertInto('transactions') + .insertInto('entry_transactions') .returningAll() .values({ id: transactionId, @@ -318,7 +309,7 @@ class EntryService { operation: 'update', data: update, created_at: updatedAt, - created_by: context.userId, + created_by: userId, version: 0n, }) .executeTakeFirst(); @@ -334,7 +325,7 @@ class EntryService { id: generateId(IdType.Mutation), node_id: entryId, type: 'apply_update_transaction', - data: JSON.stringify(mapTransaction(createdTransaction)), + data: JSON.stringify(mapEntryTransaction(createdTransaction)), created_at: updatedAt, retries: 0, }) @@ -394,28 +385,34 @@ class EntryService { } public async deleteEntry(entryId: string, userId: string) { - const workspace = await this.fetchWorkspace(userId); const workspaceDatabase = await databaseService.getWorkspaceDatabase(userId); - const ancestorRows = await fetchEntryAncestors(workspaceDatabase, entryId); - const ancestors = ancestorRows.map(mapEntry); + const user = await fetchUser(workspaceDatabase, userId); + if (!user) { + throw new Error('User not found'); + } - const entry = ancestors.find((ancestor) => ancestor.id === entryId); + const root = await fetchEntry(workspaceDatabase, entryId); + if (!root) { + throw new Error('Entry not found'); + } + + const entry = await fetchEntry(workspaceDatabase, entryId); if (!entry) { throw new Error('Entry not found'); } - const model = registry.getModel(entry.type); - const context = new EntryMutationContext( - workspace.account_id, - workspace.workspace_id, - userId, - workspace.role, - ancestors - ); - - if (!model.canDelete(context, entry)) { + if ( + !canDeleteEntry({ + user: { + userId, + role: user.role, + }, + root: mapEntry(root), + entry: mapEntry(entry), + }) + ) { throw new Error('Insufficient permissions'); } @@ -432,29 +429,19 @@ class EntryService { return { deletedEntry: undefined }; } - await trx - .deleteFrom('transactions') - .where('entry_id', '=', entryId) - .execute(); - - await trx - .deleteFrom('collaborations') - .where('entry_id', '=', entryId) - .execute(); - await trx.deleteFrom('texts').where('id', '=', entryId).execute(); const createdTransaction = await trx - .insertInto('transactions') + .insertInto('entry_transactions') .returningAll() .values({ id: generateId(IdType.Transaction), entry_id: entryId, - root_id: entry.rootId, + root_id: root.root_id, operation: 'delete', data: null, created_at: new Date().toISOString(), - created_by: context.userId, + created_by: userId, version: 0n, }) .executeTakeFirst(); @@ -470,7 +457,7 @@ class EntryService { id: generateId(IdType.Mutation), node_id: entryId, type: 'apply_delete_transaction', - data: JSON.stringify(mapTransaction(createdTransaction)), + data: JSON.stringify(mapEntryTransaction(createdTransaction)), created_at: new Date().toISOString(), retries: 0, }) @@ -507,7 +494,7 @@ class EntryService { public async applyServerTransaction( userId: string, - transaction: SyncTransactionData + transaction: SyncEntryTransactionData ) { if (transaction.operation === 'create') { await this.applyServerCreateTransaction(userId, transaction); @@ -518,237 +505,9 @@ class EntryService { } } - public async replaceTransactions( - userId: string, - entryId: string, - transactions: SyncTransactionData[], - transactionCursor: bigint - ): Promise { - for (let count = 0; count < UPDATE_RETRIES_LIMIT; count++) { - const result = await this.tryReplaceTransactions( - userId, - entryId, - transactions, - transactionCursor - ); - - if (result !== null) { - return result; - } - } - - return false; - } - - public async tryReplaceTransactions( - userId: string, - entryId: string, - transactions: SyncTransactionData[], - transactionCursor: bigint - ): Promise { - const workspaceDatabase = - await databaseService.getWorkspaceDatabase(userId); - - const firstTransaction = transactions[0]; - if (!firstTransaction || firstTransaction.operation !== 'create') { - return false; - } - - if (transactionCursor < BigInt(firstTransaction.version)) { - return false; - } - - const lastTransaction = transactions[transactions.length - 1]; - if (!lastTransaction) { - return false; - } - - const ydoc = new YDoc(); - for (const transaction of transactions) { - if (transaction.operation === 'delete') { - await this.applyServerDeleteTransaction(userId, transaction); - return true; - } - - ydoc.applyUpdate(transaction.data); - } - - const attributes = ydoc.getAttributes(); - const model = registry.getModel(attributes.type); - if (!model) { - return false; - } - - const attributesJson = JSON.stringify(attributes); - const text = model.getText(entryId, attributes); - - const existingEntry = await workspaceDatabase - .selectFrom('entries') - .selectAll() - .where('id', '=', entryId) - .executeTakeFirst(); - - if (existingEntry) { - const updatedEntry = await workspaceDatabase - .transaction() - .execute(async (trx) => { - const updatedEntry = await trx - .updateTable('entries') - .returningAll() - .set({ - attributes: attributesJson, - updated_at: - firstTransaction.id !== lastTransaction.id - ? lastTransaction.createdAt - : null, - updated_by: - firstTransaction.id !== lastTransaction.id - ? lastTransaction.createdBy - : null, - transaction_id: lastTransaction.id, - }) - .where('id', '=', entryId) - .where('transaction_id', '=', existingEntry.transaction_id) - .executeTakeFirst(); - - if (!updatedEntry) { - return undefined; - } - - await trx - .deleteFrom('transactions') - .where('entry_id', '=', entryId) - .execute(); - - await trx - .insertInto('transactions') - .values( - transactions.map((t) => ({ - id: t.id, - entry_id: t.entryId, - root_id: t.rootId, - operation: t.operation, - data: - t.operation !== 'delete' && t.data - ? decodeState(t.data) - : null, - created_at: t.createdAt, - created_by: t.createdBy, - retry_count: 0, - status: 'synced', - version: BigInt(t.version), - server_created_at: t.serverCreatedAt, - })) - ) - .execute(); - - if (text !== undefined) { - await trx.deleteFrom('texts').where('id', '=', entryId).execute(); - } - - if (text) { - await trx - .insertInto('texts') - .values({ id: entryId, name: text.name, text: text.text }) - .execute(); - } - }); - - if (updatedEntry) { - eventBus.publish({ - type: 'entry_updated', - userId, - entry: mapEntry(updatedEntry), - }); - - return true; - } - - return null; - } - - const createdEntry = await workspaceDatabase - .transaction() - .execute(async (trx) => { - const createdEntry = await trx - .insertInto('entries') - .returningAll() - .values({ - id: entryId, - root_id: firstTransaction.rootId, - attributes: attributesJson, - created_at: firstTransaction.createdAt, - created_by: firstTransaction.createdBy, - updated_at: - firstTransaction.id !== lastTransaction.id - ? lastTransaction.createdAt - : null, - updated_by: - firstTransaction.id !== lastTransaction.id - ? lastTransaction.createdBy - : null, - transaction_id: lastTransaction.id, - }) - .onConflict((b) => b.doNothing()) - .executeTakeFirst(); - - if (!createdEntry) { - return undefined; - } - - await trx - .deleteFrom('transactions') - .where('entry_id', '=', entryId) - .execute(); - - await trx - .insertInto('transactions') - .values( - transactions.map((t) => ({ - id: t.id, - entry_id: t.entryId, - root_id: t.rootId, - operation: t.operation, - data: - t.operation !== 'delete' && t.data ? decodeState(t.data) : null, - created_at: t.createdAt, - created_by: t.createdBy, - retry_count: 0, - status: 'synced', - version: BigInt(t.version), - server_created_at: t.serverCreatedAt, - })) - ) - .execute(); - - if (text !== undefined) { - await trx.deleteFrom('texts').where('id', '=', entryId).execute(); - } - - if (text) { - await trx - .insertInto('texts') - .values({ id: entryId, name: text.name, text: text.text }) - .execute(); - } - }); - - if (createdEntry) { - eventBus.publish({ - type: 'entry_created', - userId, - entry: mapEntry(createdEntry), - }); - - return true; - } - - return null; - } - private async applyServerCreateTransaction( userId: string, - transaction: SyncCreateTransactionData + transaction: SyncCreateEntryTransactionData ) { this.debug( `Applying server create transaction ${transaction.id} for entry ${transaction.entryId}` @@ -759,7 +518,7 @@ class EntryService { const version = BigInt(transaction.version); const existingTransaction = await workspaceDatabase - .selectFrom('transactions') + .selectFrom('entry_transactions') .select(['id', 'version', 'server_created_at']) .where('id', '=', transaction.id) .executeTakeFirst(); @@ -776,7 +535,7 @@ class EntryService { } await workspaceDatabase - .updateTable('transactions') + .updateTable('entry_transactions') .set({ version, server_created_at: transaction.serverCreatedAt, @@ -793,13 +552,7 @@ class EntryService { const ydoc = new YDoc(); ydoc.applyUpdate(transaction.data); const attributes = ydoc.getAttributes(); - - const model = registry.getModel(attributes.type); - if (!model) { - return; - } - - const text = model.getText(transaction.entryId, attributes); + const text = extractEntryText(transaction.entryId, attributes); const { createdEntry } = await workspaceDatabase .transaction() @@ -818,7 +571,7 @@ class EntryService { .executeTakeFirst(); await trx - .insertInto('transactions') + .insertInto('entry_transactions') .values({ id: transaction.id, entry_id: transaction.entryId, @@ -866,14 +619,14 @@ class EntryService { private async applyServerUpdateTransaction( userId: string, - transaction: SyncUpdateTransactionData + transaction: SyncUpdateEntryTransactionData ) { const workspaceDatabase = await databaseService.getWorkspaceDatabase(userId); const version = BigInt(transaction.version); const existingTransaction = await workspaceDatabase - .selectFrom('transactions') + .selectFrom('entry_transactions') .select(['id', 'version', 'server_created_at']) .where('id', '=', transaction.id) .executeTakeFirst(); @@ -890,7 +643,7 @@ class EntryService { } await workspaceDatabase - .updateTable('transactions') + .updateTable('entry_transactions') .set({ version, server_created_at: transaction.serverCreatedAt, @@ -904,13 +657,8 @@ class EntryService { return; } - const model = registry.getModel(transaction.entryId); - if (!model) { - return; - } - const previousTransactions = await workspaceDatabase - .selectFrom('transactions') + .selectFrom('entry_transactions') .selectAll() .where('entry_id', '=', transaction.entryId) .orderBy('id', 'asc') @@ -926,7 +674,7 @@ class EntryService { ydoc.applyUpdate(transaction.data); const attributes = ydoc.getAttributes(); - const text = model.getText(transaction.entryId, attributes); + const text = extractEntryText(transaction.entryId, attributes); const { updatedEntry } = await workspaceDatabase .transaction() @@ -944,7 +692,7 @@ class EntryService { .executeTakeFirst(); await trx - .insertInto('transactions') + .insertInto('entry_transactions') .values({ id: transaction.id, entry_id: transaction.entryId, @@ -999,7 +747,7 @@ class EntryService { private async applyServerDeleteTransaction( userId: string, - transaction: SyncDeleteTransactionData + transaction: SyncDeleteEntryTransactionData ) { this.debug( `Applying server delete transaction ${transaction.id} for entry ${transaction.entryId}` @@ -1008,45 +756,45 @@ class EntryService { const workspaceDatabase = await databaseService.getWorkspaceDatabase(userId); - const entry = await workspaceDatabase - .selectFrom('entries') - .selectAll() - .where('id', '=', transaction.entryId) - .executeTakeFirst(); + const { deletedEntry } = await workspaceDatabase + .transaction() + .execute(async (trx) => { + const deletedEntry = await trx + .deleteFrom('entries') + .returningAll() + .where('id', '=', transaction.entryId) + .executeTakeFirst(); - if (!entry) { - return; - } + await trx + .deleteFrom('entry_transactions') + .where('entry_id', '=', transaction.entryId) + .execute(); - await workspaceDatabase.transaction().execute(async (trx) => { - await trx - .deleteFrom('entries') - .where('id', '=', transaction.entryId) - .execute(); - await trx - .deleteFrom('transactions') - .where('entry_id', '=', transaction.entryId) - .execute(); + await trx + .deleteFrom('entry_interactions') + .where('entry_id', '=', transaction.entryId) + .execute(); - await trx - .deleteFrom('collaborations') - .where('entry_id', '=', transaction.entryId) - .execute(); + await trx + .deleteFrom('texts') + .where('id', '=', transaction.entryId) + .execute(); - await trx - .deleteFrom('texts') - .where('id', '=', transaction.entryId) - .execute(); - }); + return { deletedEntry }; + }); this.debug( - `Deleted entry ${entry.id} with type ${entry.type} with transaction ${transaction.id}` + `Deleted entry ${transaction.entryId} with transaction ${transaction.id}` ); + if (!deletedEntry) { + return; + } + eventBus.publish({ type: 'entry_deleted', userId, - entry: mapEntry(entry), + entry: mapEntry(deletedEntry), }); } @@ -1068,7 +816,7 @@ class EntryService { } const transactionRow = await workspaceDatabase - .selectFrom('transactions') + .selectFrom('entry_transactions') .selectAll() .where('id', '=', transaction.id) .executeTakeFirst(); @@ -1084,14 +832,19 @@ class EntryService { .execute(); await tx - .deleteFrom('transactions') + .deleteFrom('entry_transactions') .where('id', '=', transaction.id) .execute(); await tx - .deleteFrom('collaborations') + .deleteFrom('entry_interactions') .where('entry_id', '=', transaction.entryId) .execute(); + + await tx + .deleteFrom('texts') + .where('id', '=', transaction.entryId) + .execute(); }); eventBus.publish({ @@ -1128,11 +881,27 @@ class EntryService { .executeTakeFirst(); if (!entry) { + // Make sure we don't have any data left behind + await workspaceDatabase + .deleteFrom('entry_transactions') + .where('id', '=', transaction.id) + .execute(); + + await workspaceDatabase + .deleteFrom('entry_interactions') + .where('entry_id', '=', transaction.entryId) + .execute(); + + await workspaceDatabase + .deleteFrom('texts') + .where('id', '=', transaction.entryId) + .execute(); + return true; } const transactionRow = await workspaceDatabase - .selectFrom('transactions') + .selectFrom('entry_transactions') .selectAll() .where('id', '=', transaction.id) .executeTakeFirst(); @@ -1142,20 +911,14 @@ class EntryService { } const previousTransactions = await workspaceDatabase - .selectFrom('transactions') + .selectFrom('entry_transactions') .selectAll() .where('entry_id', '=', transaction.entryId) .orderBy('id', 'asc') .execute(); - const model = registry.getModel(entry.type); - if (!model) { - return true; - } - const ydoc = new YDoc(); - - let lastTransaction: SelectTransaction | undefined; + let lastTransaction: SelectEntryTransaction | undefined; for (const previousTransaction of previousTransactions) { if (previousTransaction.id === transaction.id) { continue; @@ -1172,6 +935,9 @@ class EntryService { return true; } + const attributes = ydoc.getAttributes(); + const text = extractEntryText(transaction.entryId, attributes); + const updatedEntry = await workspaceDatabase .transaction() .execute(async (trx) => { @@ -1192,8 +958,26 @@ class EntryService { return undefined; } + if (text !== undefined) { + await trx + .deleteFrom('texts') + .where('id', '=', transaction.entryId) + .execute(); + } + + if (text) { + await trx + .insertInto('texts') + .values({ + id: transaction.entryId, + name: text.name, + text: text.text, + }) + .execute(); + } + await trx - .deleteFrom('transactions') + .deleteFrom('entry_transactions') .where('id', '=', transaction.id) .execute(); }); @@ -1219,7 +1003,7 @@ class EntryService { await databaseService.getWorkspaceDatabase(userId); const transactionRow = await workspaceDatabase - .selectFrom('transactions') + .selectFrom('entry_transactions') .selectAll() .where('id', '=', transaction.id) .executeTakeFirst(); @@ -1228,10 +1012,103 @@ class EntryService { return; } - await workspaceDatabase - .deleteFrom('transactions') - .where('id', '=', transaction.id) + const previousTransactions = await workspaceDatabase + .selectFrom('entry_transactions') + .selectAll() + .where('entry_id', '=', transaction.entryId) + .orderBy('id', 'asc') .execute(); + + const ydoc = new YDoc(); + + let lastTransaction: SelectEntryTransaction | undefined; + for (const previousTransaction of previousTransactions) { + if (previousTransaction.id === transaction.id) { + continue; + } + + if (previousTransaction.data) { + ydoc.applyUpdate(previousTransaction.data); + } + + lastTransaction = previousTransaction; + } + + if (!lastTransaction) { + return true; + } + + const attributes = ydoc.getAttributes(); + const text = extractEntryText(transaction.entryId, attributes); + + const createdEntry = await workspaceDatabase + .transaction() + .execute(async (trx) => { + const createdEntry = await trx + .insertInto('entries') + .returningAll() + .values({ + id: transaction.entryId, + root_id: transaction.rootId, + created_at: lastTransaction.created_at, + created_by: lastTransaction.created_by, + attributes: JSON.stringify(attributes), + updated_at: lastTransaction.created_at, + updated_by: lastTransaction.created_by, + transaction_id: lastTransaction.id, + }) + .onConflict((b) => + b + .columns(['id']) + .doUpdateSet({ + attributes: JSON.stringify(attributes), + updated_at: lastTransaction.created_at, + updated_by: lastTransaction.created_by, + transaction_id: lastTransaction.id, + }) + .where('transaction_id', '=', transaction.id) + ) + .executeTakeFirst(); + + if (!createdEntry) { + return undefined; + } + + await trx + .deleteFrom('entry_transactions') + .where('id', '=', transaction.id) + .execute(); + + if (text !== undefined) { + await trx + .deleteFrom('texts') + .where('id', '=', transaction.entryId) + .execute(); + } + + if (text) { + await trx + .insertInto('texts') + .values({ + id: transaction.entryId, + name: text.name, + text: text.text, + }) + .execute(); + } + }); + + if (createdEntry) { + eventBus.publish({ + type: 'entry_created', + userId, + entry: mapEntry(createdEntry), + }); + + return true; + } + + return false; } public async syncServerEntryInteraction( @@ -1295,20 +1172,6 @@ class EntryService { `Server entry interaction for entry ${entryInteraction.entryId} has been synced` ); } - - private async fetchWorkspace(userId: string): Promise { - const workspace = await databaseService.appDatabase - .selectFrom('workspaces') - .selectAll() - .where('user_id', '=', userId) - .executeTakeFirst(); - - if (!workspace) { - throw new Error('Workspace not found'); - } - - return workspace; - } } export const entryService = new EntryService(); diff --git a/apps/desktop/src/main/services/file-service.ts b/apps/desktop/src/main/services/file-service.ts index d0a44381..17cd4641 100644 --- a/apps/desktop/src/main/services/file-service.ts +++ b/apps/desktop/src/main/services/file-service.ts @@ -5,6 +5,7 @@ import { extractFileType, SyncFileData, SyncFileInteractionData, + SyncFileTombstoneData, } from '@colanode/core'; import axios from 'axios'; import mime from 'mime-types'; @@ -442,46 +443,6 @@ class FileService { const workspaceDatabase = await databaseService.getWorkspaceDatabase(userId); - if (file.deletedAt) { - const deletedFile = await workspaceDatabase - .deleteFrom('files') - .returningAll() - .where('id', '=', file.id) - .executeTakeFirst(); - - if (!deletedFile) { - return; - } - - await workspaceDatabase - .deleteFrom('file_interactions') - .where('file_id', '=', file.id) - .execute(); - - await workspaceDatabase - .deleteFrom('file_states') - .where('file_id', '=', file.id) - .execute(); - - // if the file exists in the workspace, we need to delete it - const filePath = path.join( - getWorkspaceFilesDirectoryPath(userId), - `${file.id}${file.extension}` - ); - - if (fs.existsSync(filePath)) { - fs.rmSync(filePath, { force: true }); - } - - eventBus.publish({ - type: 'file_deleted', - userId, - file: mapFile(deletedFile), - }); - - return; - } - const existingFile = await workspaceDatabase .selectFrom('files') .selectAll() @@ -565,6 +526,50 @@ class FileService { this.debug(`Server file ${file.id} has been synced`); } + public async syncServerFileTombstone( + userId: string, + fileTombstone: SyncFileTombstoneData + ) { + const workspaceDatabase = + await databaseService.getWorkspaceDatabase(userId); + + const deletedFile = await workspaceDatabase + .deleteFrom('files') + .returningAll() + .where('id', '=', fileTombstone.id) + .executeTakeFirst(); + + await workspaceDatabase + .deleteFrom('file_interactions') + .where('file_id', '=', fileTombstone.id) + .execute(); + + await workspaceDatabase + .deleteFrom('file_states') + .where('file_id', '=', fileTombstone.id) + .execute(); + + if (deletedFile) { + // if the file exists in the workspace, we need to delete it + const filePath = path.join( + getWorkspaceFilesDirectoryPath(userId), + `${fileTombstone.id}${deletedFile.extension}` + ); + + if (fs.existsSync(filePath)) { + fs.rmSync(filePath, { force: true }); + } + + eventBus.publish({ + type: 'file_deleted', + userId, + file: mapFile(deletedFile), + }); + } + + this.debug(`Server file tombstone ${fileTombstone.id} has been synced`); + } + public async syncServerFileInteraction( userId: string, fileInteraction: SyncFileInteractionData @@ -626,6 +631,71 @@ class FileService { `Server file interaction for file ${fileInteraction.fileId} has been synced` ); } + + public async revertFileCreation(userId: string, fileId: string) { + const workspaceDatabase = + await databaseService.getWorkspaceDatabase(userId); + + const deletedFile = await workspaceDatabase + .deleteFrom('files') + .returningAll() + .where('id', '=', fileId) + .executeTakeFirst(); + + if (!deletedFile) { + return; + } + + await workspaceDatabase + .deleteFrom('file_states') + .returningAll() + .where('file_id', '=', fileId) + .executeTakeFirst(); + + await workspaceDatabase + .deleteFrom('file_interactions') + .where('file_id', '=', fileId) + .execute(); + + const filePath = path.join( + getWorkspaceFilesDirectoryPath(userId), + `${fileId}${deletedFile.extension}` + ); + + if (fs.existsSync(filePath)) { + fs.rmSync(filePath, { force: true }); + } + + eventBus.publish({ + type: 'file_deleted', + userId, + file: mapFile(deletedFile), + }); + } + + public async revertFileDeletion(userId: string, fileId: string) { + const workspaceDatabase = + await databaseService.getWorkspaceDatabase(userId); + + const deletedFile = await workspaceDatabase + .updateTable('files') + .returningAll() + .set({ + deleted_at: null, + }) + .where('id', '=', fileId) + .executeTakeFirst(); + + if (!deletedFile) { + return; + } + + eventBus.publish({ + type: 'file_created', + userId, + file: mapFile(deletedFile), + }); + } } export const fileService = new FileService(); diff --git a/apps/desktop/src/main/services/message-service.ts b/apps/desktop/src/main/services/message-service.ts index f43a6b98..86ba4ddb 100644 --- a/apps/desktop/src/main/services/message-service.ts +++ b/apps/desktop/src/main/services/message-service.ts @@ -1,10 +1,14 @@ import { - extractText, + CreateMessageReactionMutationData, + DeleteMessageReactionMutationData, + extractMessageText, SyncMessageData, SyncMessageInteractionData, SyncMessageReactionData, + SyncMessageTombstoneData, } from '@colanode/core'; +import { fileService } from '@/main/services/file-service'; import { mapMessage, mapMessageInteraction, @@ -21,41 +25,6 @@ class MessageService { const workspaceDatabase = await databaseService.getWorkspaceDatabase(userId); - if (message.deletedAt) { - const deletedMessage = await workspaceDatabase - .deleteFrom('messages') - .returningAll() - .where('id', '=', message.id) - .executeTakeFirst(); - - if (!deletedMessage) { - return; - } - - await workspaceDatabase - .deleteFrom('message_reactions') - .where('message_id', '=', message.id) - .execute(); - - await workspaceDatabase - .deleteFrom('message_interactions') - .where('message_id', '=', message.id) - .execute(); - - await workspaceDatabase - .deleteFrom('texts') - .where('id', '=', message.id) - .execute(); - - eventBus.publish({ - type: 'message_deleted', - userId, - message: mapMessage(deletedMessage), - }); - - return; - } - const existingMessage = await workspaceDatabase .selectFrom('messages') .selectAll() @@ -92,14 +61,17 @@ class MessageService { .where('id', '=', message.id) .execute(); - await workspaceDatabase - .insertInto('texts') - .values({ - id: message.id, - name: null, - text: extractText(message.id, message.content.blocks), - }) - .execute(); + const text = extractMessageText(message.id, message.content); + if (text) { + await workspaceDatabase + .insertInto('texts') + .values({ + id: message.id, + name: null, + text: text.text, + }) + .execute(); + } eventBus.publish({ type: 'message_updated', @@ -133,14 +105,17 @@ class MessageService { return; } - await workspaceDatabase - .insertInto('texts') - .values({ - id: message.id, - name: null, - text: extractText(message.id, message.content.blocks), - }) - .execute(); + const text = extractMessageText(message.id, message.content); + if (text) { + await workspaceDatabase + .insertInto('texts') + .values({ + id: message.id, + name: null, + text: text.text, + }) + .execute(); + } eventBus.publish({ type: 'message_created', @@ -151,6 +126,47 @@ class MessageService { this.debug(`Server message ${message.id} has been synced`); } + public async syncServerMessageTombstone( + userId: string, + messageTombstone: SyncMessageTombstoneData + ) { + const workspaceDatabase = + await databaseService.getWorkspaceDatabase(userId); + + const deletedMessage = await workspaceDatabase + .deleteFrom('messages') + .returningAll() + .where('id', '=', messageTombstone.id) + .executeTakeFirst(); + + await workspaceDatabase + .deleteFrom('message_reactions') + .where('message_id', '=', messageTombstone.id) + .execute(); + + await workspaceDatabase + .deleteFrom('message_interactions') + .where('message_id', '=', messageTombstone.id) + .execute(); + + await workspaceDatabase + .deleteFrom('texts') + .where('id', '=', messageTombstone.id) + .execute(); + + if (deletedMessage) { + eventBus.publish({ + type: 'message_deleted', + userId, + message: mapMessage(deletedMessage), + }); + } + + this.debug( + `Server message tombstone ${messageTombstone.id} has been synced` + ); + } + public async syncServerMessageReaction( userId: string, messageReaction: SyncMessageReactionData @@ -283,6 +299,126 @@ class MessageService { `Server message interaction for message ${messageInteraction.messageId} has been synced` ); } + + public async revertMessageCreation(userId: string, messageId: string) { + const workspaceDatabase = + await databaseService.getWorkspaceDatabase(userId); + + const deletedMessage = await workspaceDatabase + .deleteFrom('messages') + .returningAll() + .where('id', '=', messageId) + .executeTakeFirst(); + + if (!deletedMessage) { + return; + } + + await workspaceDatabase + .deleteFrom('message_reactions') + .where('message_id', '=', messageId) + .execute(); + + await workspaceDatabase + .deleteFrom('message_interactions') + .where('message_id', '=', messageId) + .execute(); + + eventBus.publish({ + type: 'message_deleted', + userId, + message: mapMessage(deletedMessage), + }); + + const files = await workspaceDatabase + .selectFrom('files') + .selectAll() + .where('parent_id', '=', messageId) + .execute(); + + for (const file of files) { + await fileService.revertFileCreation(userId, file.id); + } + } + + public async revertMessageDeletion(userId: string, messageId: string) { + const workspaceDatabase = + await databaseService.getWorkspaceDatabase(userId); + + const deletedMessage = await workspaceDatabase + .updateTable('messages') + .returningAll() + .set({ + deleted_at: null, + }) + .where('id', '=', messageId) + .executeTakeFirst(); + + if (!deletedMessage) { + return; + } + + eventBus.publish({ + type: 'message_created', + userId, + message: mapMessage(deletedMessage), + }); + } + + public async revertMessageReactionCreation( + userId: string, + messageReaction: CreateMessageReactionMutationData + ) { + const workspaceDatabase = + await databaseService.getWorkspaceDatabase(userId); + + const deletedMessageReaction = await workspaceDatabase + .deleteFrom('message_reactions') + .returningAll() + .where('message_id', '=', messageReaction.messageId) + .where('collaborator_id', '=', userId) + .where('reaction', '=', messageReaction.reaction) + .executeTakeFirst(); + + if (!deletedMessageReaction) { + return; + } + + eventBus.publish({ + type: 'message_reaction_deleted', + userId, + messageReaction: mapMessageReaction(deletedMessageReaction), + }); + } + + public async revertMessageReactionDeletion( + userId: string, + messageReaction: DeleteMessageReactionMutationData + ) { + const workspaceDatabase = + await databaseService.getWorkspaceDatabase(userId); + + const createdMessageReaction = await workspaceDatabase + .updateTable('message_reactions') + .returningAll() + .set({ + deleted_at: null, + }) + .where('message_id', '=', messageReaction.messageId) + .where('collaborator_id', '=', userId) + .where('reaction', '=', messageReaction.reaction) + .executeTakeFirst(); + + if (!createdMessageReaction) { + return; + } + + eventBus.publish({ + type: 'message_reaction_created', + userId, + messageReaction: mapMessageReaction(createdMessageReaction), + }); + } } export const messageService = new MessageService(); diff --git a/apps/desktop/src/main/services/mutation-service.ts b/apps/desktop/src/main/services/mutation-service.ts index dc98af1c..86bac2b7 100644 --- a/apps/desktop/src/main/services/mutation-service.ts +++ b/apps/desktop/src/main/services/mutation-service.ts @@ -3,6 +3,7 @@ import { mutationHandlerMap } from '@/main/mutations'; import { MutationHandler } from '@/main/types'; import { MutationError, + MutationErrorCode, MutationInput, MutationResult, } from '@/shared/mutations'; @@ -41,7 +42,7 @@ class MutationService { return { success: false, error: { - code: 'unknown', + code: MutationErrorCode.Unknown, message: 'Something went wrong trying to execute the mutation.', }, }; diff --git a/apps/desktop/src/main/services/sync-service.ts b/apps/desktop/src/main/services/sync-service.ts index af641e55..b17b2716 100644 --- a/apps/desktop/src/main/services/sync-service.ts +++ b/apps/desktop/src/main/services/sync-service.ts @@ -7,13 +7,15 @@ import { createDebugger } from '@/main/debugger'; import { UserSynchronizer } from '@/main/synchronizers/users'; import { WorkspaceDatabaseSchema } from '@/main/data/workspace/schema'; import { CollaborationSynchronizer } from '@/main/synchronizers/collaborations'; -import { TransactionSynchronizer } from '@/main/synchronizers/transactions'; +import { EntryTransactionSynchronizer } from '@/main/synchronizers/entry-transactions'; import { MessageSynchronizer } from '@/main/synchronizers/messages'; import { MessageReactionSynchronizer } from '@/main/synchronizers/message-reactions'; import { FileSynchronizer } from '@/main/synchronizers/files'; import { EntryInteractionSynchronizer } from '@/main/synchronizers/entry-interactions'; import { FileInteractionSynchronizer } from '@/main/synchronizers/file-interactions'; import { MessageInteractionSynchronizer } from '@/main/synchronizers/message-interactions'; +import { FileTombstoneSynchronizer } from '@/main/synchronizers/file-tombstones'; +import { MessageTombstoneSynchronizer } from '@/main/synchronizers/message-tombstones'; import { eventBus } from '@/shared/lib/event-bus'; class SyncService { @@ -171,8 +173,8 @@ class SyncService { ); } - if (input.type === 'transactions') { - return new TransactionSynchronizer( + if (input.type === 'entry_transactions') { + return new EntryTransactionSynchronizer( userId, accountId, input, @@ -207,6 +209,24 @@ class SyncService { ); } + if (input.type === 'file_tombstones') { + return new FileTombstoneSynchronizer( + userId, + accountId, + input, + workspaceDatabase + ); + } + + if (input.type === 'message_tombstones') { + return new MessageTombstoneSynchronizer( + userId, + accountId, + input, + workspaceDatabase + ); + } + return null; } @@ -217,7 +237,7 @@ class SyncService { workspaceDatabase: Kysely ) { await this.initSynchronizer(userId, accountId, workspaceDatabase, { - type: 'transactions', + type: 'entry_transactions', rootId, }); @@ -250,6 +270,16 @@ class SyncService { type: 'message_interactions', rootId, }); + + await this.initSynchronizer(userId, accountId, workspaceDatabase, { + type: 'file_tombstones', + rootId, + }); + + await this.initSynchronizer(userId, accountId, workspaceDatabase, { + type: 'message_tombstones', + rootId, + }); } private removeRootNodeSynchronizers(userId: string, rootId: string) { @@ -262,52 +292,50 @@ class SyncService { } if ( - synchronizer.input.type === 'transactions' && + synchronizer.input.type === 'entry_transactions' && synchronizer.input.rootId === rootId ) { this.synchronizers.delete(key); - } - - if ( + } else if ( synchronizer.input.type === 'messages' && synchronizer.input.rootId === rootId ) { this.synchronizers.delete(key); - } - - if ( + } else if ( synchronizer.input.type === 'message_reactions' && synchronizer.input.rootId === rootId ) { this.synchronizers.delete(key); - } - - if ( + } else if ( synchronizer.input.type === 'files' && synchronizer.input.rootId === rootId ) { this.synchronizers.delete(key); - } - - if ( + } else if ( synchronizer.input.type === 'entry_interactions' && synchronizer.input.rootId === rootId ) { this.synchronizers.delete(key); - } - - if ( + } else if ( synchronizer.input.type === 'file_interactions' && synchronizer.input.rootId === rootId ) { this.synchronizers.delete(key); - } - - if ( + } else if ( synchronizer.input.type === 'message_interactions' && synchronizer.input.rootId === rootId ) { this.synchronizers.delete(key); + } else if ( + synchronizer.input.type === 'file_tombstones' && + synchronizer.input.rootId === rootId + ) { + this.synchronizers.delete(key); + } else if ( + synchronizer.input.type === 'message_tombstones' && + synchronizer.input.rootId === rootId + ) { + this.synchronizers.delete(key); } } } diff --git a/apps/desktop/src/main/services/user-service.ts b/apps/desktop/src/main/services/user-service.ts index c149a19b..72dd8d58 100644 --- a/apps/desktop/src/main/services/user-service.ts +++ b/apps/desktop/src/main/services/user-service.ts @@ -1,14 +1,17 @@ import { SyncUserData } from '@colanode/core'; +import { mapUser } from '@/main/utils'; import { databaseService } from '@/main/data/database-service'; +import { eventBus } from '@/shared/lib/event-bus'; class UserService { public async syncServerUser(userId: string, user: SyncUserData) { const workspaceDatabase = await databaseService.getWorkspaceDatabase(userId); - await workspaceDatabase + const createdUser = await workspaceDatabase .insertInto('users') + .returningAll() .values({ id: user.id, email: user.email, @@ -36,7 +39,7 @@ class UserService { }) .where('version', '<', BigInt(user.version)) ) - .execute(); + .executeTakeFirst(); await workspaceDatabase .deleteFrom('texts') @@ -47,6 +50,14 @@ class UserService { .insertInto('texts') .values({ id: user.id, name: user.name, text: null }) .execute(); + + if (createdUser) { + eventBus.publish({ + type: 'user_created', + userId: userId, + user: mapUser(createdUser), + }); + } } } diff --git a/apps/desktop/src/main/synchronizers/entry-transactions.ts b/apps/desktop/src/main/synchronizers/entry-transactions.ts new file mode 100644 index 00000000..449b5faf --- /dev/null +++ b/apps/desktop/src/main/synchronizers/entry-transactions.ts @@ -0,0 +1,17 @@ +import { + SyncEntryTransactionData, + SyncEntryTransactionsInput, +} from '@colanode/core'; + +import { BaseSynchronizer } from '@/main/synchronizers/base'; +import { entryService } from '@/main/services/entry-service'; + +export class EntryTransactionSynchronizer extends BaseSynchronizer { + protected async process(data: SyncEntryTransactionData): Promise { + await entryService.applyServerTransaction(this.userId, data); + } + + protected get cursorKey(): string { + return `entry_transactions:${this.input.rootId}`; + } +} diff --git a/apps/desktop/src/main/synchronizers/file-tombstones.ts b/apps/desktop/src/main/synchronizers/file-tombstones.ts new file mode 100644 index 00000000..2ee841cc --- /dev/null +++ b/apps/desktop/src/main/synchronizers/file-tombstones.ts @@ -0,0 +1,14 @@ +import { SyncFileTombstonesInput, SyncFileTombstoneData } from '@colanode/core'; + +import { BaseSynchronizer } from '@/main/synchronizers/base'; +import { fileService } from '@/main/services/file-service'; + +export class FileTombstoneSynchronizer extends BaseSynchronizer { + protected async process(data: SyncFileTombstoneData): Promise { + await fileService.syncServerFileTombstone(this.userId, data); + } + + protected get cursorKey(): string { + return `file_tombstones:${this.input.rootId}`; + } +} diff --git a/apps/desktop/src/main/synchronizers/message-tombstones.ts b/apps/desktop/src/main/synchronizers/message-tombstones.ts new file mode 100644 index 00000000..4ede6cce --- /dev/null +++ b/apps/desktop/src/main/synchronizers/message-tombstones.ts @@ -0,0 +1,17 @@ +import { + SyncMessageTombstonesInput, + SyncMessageTombstoneData, +} from '@colanode/core'; + +import { BaseSynchronizer } from '@/main/synchronizers/base'; +import { messageService } from '@/main/services/message-service'; + +export class MessageTombstoneSynchronizer extends BaseSynchronizer { + protected async process(data: SyncMessageTombstoneData): Promise { + await messageService.syncServerMessageTombstone(this.userId, data); + } + + protected get cursorKey(): string { + return `message_tombstones:${this.input.rootId}`; + } +} diff --git a/apps/desktop/src/main/synchronizers/transactions.ts b/apps/desktop/src/main/synchronizers/transactions.ts deleted file mode 100644 index a87e1af1..00000000 --- a/apps/desktop/src/main/synchronizers/transactions.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { SyncTransactionData, SyncTransactionsInput } from '@colanode/core'; - -import { BaseSynchronizer } from '@/main/synchronizers/base'; -import { entryService } from '@/main/services/entry-service'; - -export class TransactionSynchronizer extends BaseSynchronizer { - protected async process(data: SyncTransactionData): Promise { - await entryService.applyServerTransaction(this.userId, data); - } - - protected get cursorKey(): string { - return `transactions:${this.input.rootId}`; - } -} diff --git a/apps/desktop/src/main/utils.ts b/apps/desktop/src/main/utils.ts index 049d5d26..fcdc970b 100644 --- a/apps/desktop/src/main/utils.ts +++ b/apps/desktop/src/main/utils.ts @@ -25,7 +25,7 @@ import { SelectMessageReaction, SelectMutation, SelectEntry, - SelectTransaction, + SelectEntryTransaction, SelectUser, WorkspaceDatabaseSchema, SelectMessageInteraction, @@ -114,6 +114,32 @@ export const fetchEntryAncestors = ( .execute(); }; +export const fetchEntry = ( + database: + | Kysely + | Transaction, + entryId: string +): Promise => { + return database + .selectFrom('entries') + .selectAll() + .where('id', '=', entryId) + .executeTakeFirst(); +}; + +export const fetchUser = ( + database: + | Kysely + | Transaction, + userId: string +): Promise => { + return database + .selectFrom('users') + .selectAll() + .where('id', '=', userId) + .executeTakeFirst(); +}; + export const fetchWorkspaceCredentials = async ( userId: string ): Promise => { @@ -192,7 +218,6 @@ export const mapWorkspace = (row: SelectWorkspace): Workspace => { return { id: row.workspace_id, name: row.name, - versionId: row.version_id, accountId: row.account_id, role: row.role, userId: row.user_id, @@ -201,7 +226,9 @@ export const mapWorkspace = (row: SelectWorkspace): Workspace => { }; }; -export const mapTransaction = (row: SelectTransaction): LocalTransaction => { +export const mapEntryTransaction = ( + row: SelectEntryTransaction +): LocalTransaction => { if (row.operation === 'create' && row.data) { return { id: row.id, @@ -306,6 +333,7 @@ export const mapFile = (row: SelectFile): File => { id: row.id, type: row.type, parentId: row.parent_id, + entryId: row.entry_id, rootId: row.root_id, name: row.name, originalName: row.original_name, diff --git a/apps/desktop/src/renderer/components/channels/channel-container.tsx b/apps/desktop/src/renderer/components/channels/channel-container.tsx index d050d09d..410ebf07 100644 --- a/apps/desktop/src/renderer/components/channels/channel-container.tsx +++ b/apps/desktop/src/renderer/components/channels/channel-container.tsx @@ -1,4 +1,4 @@ -import { extractEntryRole } from '@colanode/core'; +import { ChannelEntry, extractEntryRole } from '@colanode/core'; import { ChannelBody } from '@/renderer/components/channels/channel-body'; import { ChannelHeader } from '@/renderer/components/channels/channel-header'; @@ -11,27 +11,41 @@ interface ChannelContainerProps { export const ChannelContainer = ({ channelId }: ChannelContainerProps) => { const workspace = useWorkspace(); - const { data, isPending } = useQuery({ - type: 'entry_tree_get', + + const { data: entry, isPending: isPendingEntry } = useQuery({ + type: 'entry_get', entryId: channelId, userId: workspace.userId, }); - if (isPending) { + const channel = entry as ChannelEntry; + const { data: root, isPending: isPendingRoot } = useQuery( + { + type: 'entry_get', + entryId: channel?.rootId ?? '', + userId: workspace.userId, + }, + { + enabled: !!channel?.rootId, + } + ); + + if (isPendingEntry || isPendingRoot) { return null; } - const entries = data ?? []; - const channel = entries.find((entry) => entry.id === channelId); - const role = extractEntryRole(entries, workspace.userId); + if (!channel || !root) { + return null; + } - if (!channel || channel.type !== 'channel' || !role) { + const role = extractEntryRole(root, workspace.userId); + if (!role) { return null; } return (
- +
); diff --git a/apps/desktop/src/renderer/components/channels/channel-header.tsx b/apps/desktop/src/renderer/components/channels/channel-header.tsx index 10b747e6..c44f7b57 100644 --- a/apps/desktop/src/renderer/components/channels/channel-header.tsx +++ b/apps/desktop/src/renderer/components/channels/channel-header.tsx @@ -1,4 +1,4 @@ -import { ChannelEntry, Entry, EntryRole } from '@colanode/core'; +import { ChannelEntry, EntryRole } from '@colanode/core'; import { ChannelSettings } from '@/renderer/components/channels/channel-settings'; import { EntryBreadcrumb } from '@/renderer/components/layouts/entry-breadcrumb'; @@ -7,23 +7,18 @@ import { Header } from '@/renderer/components/ui/header'; import { useContainer } from '@/renderer/contexts/container'; interface ChannelHeaderProps { - entries: Entry[]; channel: ChannelEntry; role: EntryRole; } -export const ChannelHeader = ({ - entries, - channel, - role, -}: ChannelHeaderProps) => { +export const ChannelHeader = ({ channel, role }: ChannelHeaderProps) => { const container = useContainer(); return (
- {container.mode === 'main' && } + {container.mode === 'main' && } {container.mode === 'modal' && ( )} diff --git a/apps/desktop/src/renderer/components/channels/channel-settings.tsx b/apps/desktop/src/renderer/components/channels/channel-settings.tsx index 46afac05..7f502df4 100644 --- a/apps/desktop/src/renderer/components/channels/channel-settings.tsx +++ b/apps/desktop/src/renderer/components/channels/channel-settings.tsx @@ -1,4 +1,4 @@ -import { ChannelEntry, hasEditorAccess, EntryRole } from '@colanode/core'; +import { ChannelEntry, EntryRole, hasEntryRole } from '@colanode/core'; import { Copy, Image, LetterText, Settings, Trash2 } from 'lucide-react'; import React from 'react'; @@ -23,8 +23,8 @@ export const ChannelSettings = ({ channel, role }: ChannelSettingsProps) => { const [showUpdateDialog, setShowUpdateDialog] = React.useState(false); const [showDeleteDialog, setShowDeleteModal] = React.useState(false); - const canEdit = hasEditorAccess(role); - const canDelete = hasEditorAccess(role); + const canEdit = hasEntryRole(role, 'editor'); + const canDelete = hasEntryRole(role, 'editor'); return ( diff --git a/apps/desktop/src/renderer/components/channels/channel-update-dialog.tsx b/apps/desktop/src/renderer/components/channels/channel-update-dialog.tsx index edf64ef5..7c0b9321 100644 --- a/apps/desktop/src/renderer/components/channels/channel-update-dialog.tsx +++ b/apps/desktop/src/renderer/components/channels/channel-update-dialog.tsx @@ -1,4 +1,4 @@ -import { ChannelEntry, hasEditorAccess, EntryRole } from '@colanode/core'; +import { ChannelEntry, EntryRole, hasEntryRole } from '@colanode/core'; import { ChannelForm } from '@/renderer/components/channels/channel-form'; import { @@ -27,7 +27,7 @@ export const ChannelUpdateDialog = ({ }: ChannelUpdateDialogProps) => { const workspace = useWorkspace(); const { mutate, isPending } = useMutation(); - const canEdit = hasEditorAccess(role); + const canEdit = hasEntryRole(role, 'editor'); return ( diff --git a/apps/desktop/src/renderer/components/chats/chat-header.tsx b/apps/desktop/src/renderer/components/chats/chat-header.tsx index 4f94818a..8b7a5355 100644 --- a/apps/desktop/src/renderer/components/chats/chat-header.tsx +++ b/apps/desktop/src/renderer/components/chats/chat-header.tsx @@ -18,7 +18,7 @@ export const ChatHeader = ({ chat, role }: ChatHeaderProps) => {
- + {container.mode === 'modal' && ( )} diff --git a/apps/desktop/src/renderer/components/collaborators/entry-collaborator-create.tsx b/apps/desktop/src/renderer/components/collaborators/entry-collaborator-create.tsx index cd1306a4..fd5b4e6d 100644 --- a/apps/desktop/src/renderer/components/collaborators/entry-collaborator-create.tsx +++ b/apps/desktop/src/renderer/components/collaborators/entry-collaborator-create.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { EntryRole } from '@colanode/core'; import { User } from '@/shared/types/users'; import { EntryCollaboratorRoleDropdown } from '@/renderer/components/collaborators/entry-collaborator-role-dropdown'; @@ -22,7 +23,7 @@ export const EntryCollaboratorCreate = ({ const { mutate, isPending } = useMutation(); const [users, setUsers] = React.useState([]); - const [role, setRole] = React.useState('collaborator'); + const [role, setRole] = React.useState('editor'); return (
diff --git a/apps/desktop/src/renderer/components/collaborators/entry-collaborator-role-dropdown.tsx b/apps/desktop/src/renderer/components/collaborators/entry-collaborator-role-dropdown.tsx index 39e7c222..939cefe7 100644 --- a/apps/desktop/src/renderer/components/collaborators/entry-collaborator-role-dropdown.tsx +++ b/apps/desktop/src/renderer/components/collaborators/entry-collaborator-role-dropdown.tsx @@ -1,4 +1,5 @@ import { Check, ChevronDown } from 'lucide-react'; +import { EntryRole } from '@colanode/core'; import { DropdownMenu, @@ -9,7 +10,7 @@ import { interface EntryCollaboratorRole { name: string; - value: string; + value: EntryRole; description: string; enabled: boolean; } @@ -28,9 +29,9 @@ const roles: EntryCollaboratorRole[] = [ enabled: true, }, { - name: 'Collaborator', - value: 'collaborator', - description: 'Can contribute in content', + name: 'Commenter', + value: 'commenter', + description: 'Can message or comment on content', enabled: true, }, { @@ -42,8 +43,8 @@ const roles: EntryCollaboratorRole[] = [ ]; interface EntryCollaboratorRoleDropdownProps { - value: string; - onChange: (value: string) => void; + value: EntryRole; + onChange: (value: EntryRole) => void; canEdit: boolean; } diff --git a/apps/desktop/src/renderer/components/collaborators/entry-collaborators.tsx b/apps/desktop/src/renderer/components/collaborators/entry-collaborators.tsx index d878d416..92e600d4 100644 --- a/apps/desktop/src/renderer/components/collaborators/entry-collaborators.tsx +++ b/apps/desktop/src/renderer/components/collaborators/entry-collaborators.tsx @@ -1,8 +1,8 @@ import { - hasAdminAccess, Entry, EntryRole, extractEntryName, + hasEntryRole, } from '@colanode/core'; import React from 'react'; @@ -30,7 +30,7 @@ export const EntryCollaborators = ({ (collaborator) => collaborator.collaboratorId ); - const isAdmin = hasAdminAccess(role); + const isAdmin = hasEntryRole(role, 'admin'); const ancestors = entries.filter((entry) => entry.id !== entryId); return ( diff --git a/apps/desktop/src/renderer/components/databases/database-container.tsx b/apps/desktop/src/renderer/components/databases/database-container.tsx index dc5ec234..d0640dbc 100644 --- a/apps/desktop/src/renderer/components/databases/database-container.tsx +++ b/apps/desktop/src/renderer/components/databases/database-container.tsx @@ -1,4 +1,4 @@ -import { extractEntryRole } from '@colanode/core'; +import { DatabaseEntry, extractEntryRole } from '@colanode/core'; import { DatabaseBody } from '@/renderer/components/databases/database-body'; import { DatabaseHeader } from '@/renderer/components/databases/database-header'; @@ -11,27 +11,42 @@ interface DatabaseContainerProps { export const DatabaseContainer = ({ databaseId }: DatabaseContainerProps) => { const workspace = useWorkspace(); - const { data, isPending } = useQuery({ - type: 'entry_tree_get', + + const { data: entry, isPending: isPendingEntry } = useQuery({ + type: 'entry_get', entryId: databaseId, userId: workspace.userId, }); - if (isPending) { + const database = entry as DatabaseEntry; + + const { data: root, isPending: isPendingRoot } = useQuery( + { + type: 'entry_get', + entryId: database?.rootId ?? '', + userId: workspace.userId, + }, + { + enabled: !!database?.rootId, + } + ); + + if (isPendingEntry || isPendingRoot) { return null; } - const entries = data ?? []; - const database = entries.find((entry) => entry.id === databaseId); - const role = extractEntryRole(entries, workspace.userId); + if (!database || !root) { + return null; + } - if (!database || database.type !== 'database' || !role) { + const role = extractEntryRole(root, workspace.userId); + if (!role) { return null; } return (
- +
); diff --git a/apps/desktop/src/renderer/components/databases/database-header.tsx b/apps/desktop/src/renderer/components/databases/database-header.tsx index 75ca8d95..c1362cce 100644 --- a/apps/desktop/src/renderer/components/databases/database-header.tsx +++ b/apps/desktop/src/renderer/components/databases/database-header.tsx @@ -1,4 +1,4 @@ -import { DatabaseEntry, Entry, EntryRole } from '@colanode/core'; +import { DatabaseEntry, EntryRole } from '@colanode/core'; import { DatabaseSettings } from '@/renderer/components/databases/database-settings'; import { EntryBreadcrumb } from '@/renderer/components/layouts/entry-breadcrumb'; @@ -7,23 +7,18 @@ import { Header } from '@/renderer/components/ui/header'; import { useContainer } from '@/renderer/contexts/container'; interface DatabaseHeaderProps { - entries: Entry[]; database: DatabaseEntry; role: EntryRole; } -export const DatabaseHeader = ({ - entries, - database, - role, -}: DatabaseHeaderProps) => { +export const DatabaseHeader = ({ database, role }: DatabaseHeaderProps) => { const container = useContainer(); return (
- {container.mode === 'main' && } + {container.mode === 'main' && } {container.mode === 'modal' && ( )} diff --git a/apps/desktop/src/renderer/components/databases/database-settings.tsx b/apps/desktop/src/renderer/components/databases/database-settings.tsx index 1f9066d7..1b30a743 100644 --- a/apps/desktop/src/renderer/components/databases/database-settings.tsx +++ b/apps/desktop/src/renderer/components/databases/database-settings.tsx @@ -1,4 +1,4 @@ -import { DatabaseEntry, hasEditorAccess, EntryRole } from '@colanode/core'; +import { DatabaseEntry, EntryRole, hasEntryRole } from '@colanode/core'; import { Copy, Image, LetterText, Settings, Trash2 } from 'lucide-react'; import React from 'react'; @@ -23,8 +23,8 @@ export const DatabaseSettings = ({ database, role }: DatabaseSettingsProps) => { const [showUpdateDialog, setShowUpdateDialog] = React.useState(false); const [showDeleteDialog, setShowDeleteModal] = React.useState(false); - const canEdit = hasEditorAccess(role); - const canDelete = hasEditorAccess(role); + const canEdit = hasEntryRole(role, 'editor'); + const canDelete = hasEntryRole(role, 'admin'); return ( diff --git a/apps/desktop/src/renderer/components/databases/database-update-dialog.tsx b/apps/desktop/src/renderer/components/databases/database-update-dialog.tsx index de8ead46..a4eb6968 100644 --- a/apps/desktop/src/renderer/components/databases/database-update-dialog.tsx +++ b/apps/desktop/src/renderer/components/databases/database-update-dialog.tsx @@ -1,4 +1,4 @@ -import { DatabaseEntry, hasEditorAccess, EntryRole } from '@colanode/core'; +import { DatabaseEntry, EntryRole, hasEntryRole } from '@colanode/core'; import { DatabaseForm } from '@/renderer/components/databases/database-form'; import { @@ -27,7 +27,7 @@ export const DatabaseUpdateDialog = ({ }: DatabaseUpdateDialogProps) => { const workspace = useWorkspace(); const { mutate, isPending } = useMutation(); - const canEdit = hasEditorAccess(role); + const canEdit = hasEntryRole(role, 'editor'); return ( diff --git a/apps/desktop/src/renderer/components/databases/database.tsx b/apps/desktop/src/renderer/components/databases/database.tsx index 423edf54..e7bb6ba1 100644 --- a/apps/desktop/src/renderer/components/databases/database.tsx +++ b/apps/desktop/src/renderer/components/databases/database.tsx @@ -1,9 +1,4 @@ -import { - DatabaseEntry, - hasCollaboratorAccess, - hasEditorAccess, - EntryRole, -} from '@colanode/core'; +import { DatabaseEntry, EntryRole, hasEntryRole } from '@colanode/core'; import React from 'react'; import { DatabaseContext } from '@/renderer/contexts/database'; @@ -21,8 +16,8 @@ export const Database = ({ database, role, children }: DatabaseProps) => { const workspace = useWorkspace(); const { mutate } = useMutation(); - const canEdit = hasEditorAccess(role); - const canCreateRecord = hasCollaboratorAccess(role); + const canEdit = hasEntryRole(role, 'editor'); + const canCreateRecord = hasEntryRole(role, 'editor'); return ( { const workspace = useWorkspace(); - const { data: file, isPending: isFilePending } = useQuery({ + const { data: file, isPending: isPendingFile } = useQuery({ type: 'file_get', id: fileId, userId: workspace.userId, }); - const { data: entries, isPending: isEntriesPending } = useQuery( + const { data: entry, isPending: isPendingEntry } = useQuery( { - type: 'entry_tree_get', - entryId: file?.parentId ?? '', + type: 'entry_get', + entryId: file?.entryId ?? '', userId: workspace.userId, }, { @@ -29,18 +29,33 @@ export const FileContainer = ({ fileId }: FileContainerProps) => { } ); - if (isFilePending || isEntriesPending) { + const { data: root, isPending: isPendingRoot } = useQuery( + { + type: 'entry_get', + entryId: file?.rootId ?? '', + userId: workspace.userId, + }, + { + enabled: !!file, + } + ); + + if (isPendingFile || isPendingEntry || isPendingRoot) { return null; } - const role = extractEntryRole(entries ?? [], workspace.userId); - if (!file || !role) { + if (!file || !entry || !root) { + return null; + } + + const role = extractEntryRole(root, workspace.userId); + if (!role) { return null; } return (
- +
); diff --git a/apps/desktop/src/renderer/components/files/file-header.tsx b/apps/desktop/src/renderer/components/files/file-header.tsx index 548f5474..d54af8f8 100644 --- a/apps/desktop/src/renderer/components/files/file-header.tsx +++ b/apps/desktop/src/renderer/components/files/file-header.tsx @@ -8,25 +8,25 @@ import { useContainer } from '@/renderer/contexts/container'; import { FileWithState } from '@/shared/types/files'; interface FileHeaderProps { - entries: Entry[]; file: FileWithState; + entry: Entry; role: EntryRole; } -export const FileHeader = ({ entries, file }: FileHeaderProps) => { +export const FileHeader = ({ file, entry, role }: FileHeaderProps) => { const container = useContainer(); return (
- {container.mode === 'main' && } + {container.mode === 'main' && } {container.mode === 'modal' && ( )}
- +
diff --git a/apps/desktop/src/renderer/components/files/file-settings.tsx b/apps/desktop/src/renderer/components/files/file-settings.tsx index 13f8621f..3f2ce08b 100644 --- a/apps/desktop/src/renderer/components/files/file-settings.tsx +++ b/apps/desktop/src/renderer/components/files/file-settings.tsx @@ -1,5 +1,6 @@ import { Copy, Settings, Trash2 } from 'lucide-react'; import React from 'react'; +import { Entry, EntryRole, hasEntryRole } from '@colanode/core'; import { FileDeleteDialog } from '@/renderer/components/files/file-delete-dialog'; import { @@ -8,13 +9,22 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/renderer/components/ui/dropdown-menu'; +import { FileWithState } from '@/shared/types/files'; +import { useWorkspace } from '@/renderer/contexts/workspace'; interface FileSettingsProps { - fileId: string; + file: FileWithState; + role: EntryRole; + entry: Entry; } -export const FileSettings = ({ fileId }: FileSettingsProps) => { +export const FileSettings = ({ file, role, entry }: FileSettingsProps) => { + const workspace = useWorkspace(); const [showDeleteModal, setShowDeleteModal] = React.useState(false); + const canDelete = + file.parentId === entry.id && + (file.createdBy === workspace.userId || hasEntryRole(role, 'editor')); + return ( @@ -29,19 +39,26 @@ export const FileSettings = ({ fileId }: FileSettingsProps) => { { + if (!canDelete) { + return; + } + setShowDeleteModal(true); }} + disabled={!canDelete} > Delete - + {canDelete && ( + + )} ); }; diff --git a/apps/desktop/src/renderer/components/folders/folder-body.tsx b/apps/desktop/src/renderer/components/folders/folder-body.tsx index 7a8e73ff..ed2536ca 100644 --- a/apps/desktop/src/renderer/components/folders/folder-body.tsx +++ b/apps/desktop/src/renderer/components/folders/folder-body.tsx @@ -1,4 +1,4 @@ -import { FolderEntry } from '@colanode/core'; +import { EntryRole, FolderEntry } from '@colanode/core'; import { Check, Filter, @@ -57,6 +57,7 @@ export const folderLayouts: FolderLayout[] = [ interface FolderBodyProps { folder: FolderEntry; + role: EntryRole; } export const FolderBody = ({ folder }: FolderBodyProps) => { diff --git a/apps/desktop/src/renderer/components/folders/folder-container.tsx b/apps/desktop/src/renderer/components/folders/folder-container.tsx index 859eb69d..28098b57 100644 --- a/apps/desktop/src/renderer/components/folders/folder-container.tsx +++ b/apps/desktop/src/renderer/components/folders/folder-container.tsx @@ -1,4 +1,4 @@ -import { extractEntryRole } from '@colanode/core'; +import { extractEntryRole, FolderEntry } from '@colanode/core'; import { FolderBody } from '@/renderer/components/folders/folder-body'; import { FolderHeader } from '@/renderer/components/folders/folder-header'; @@ -12,28 +12,42 @@ interface FolderContainerProps { export const FolderContainer = ({ folderId }: FolderContainerProps) => { const workspace = useWorkspace(); - const { data, isPending } = useQuery({ - type: 'entry_tree_get', + const { data: entry, isPending: isPendingEntry } = useQuery({ + type: 'entry_get', entryId: folderId, userId: workspace.userId, }); - if (isPending) { + const folder = entry as FolderEntry; + + const { data: root, isPending: isPendingRoot } = useQuery( + { + type: 'entry_get', + entryId: folder?.rootId ?? '', + userId: workspace.userId, + }, + { + enabled: !!folder?.rootId, + } + ); + + if (isPendingEntry || isPendingRoot) { return null; } - const entries = data ?? []; - const folder = entries.find((entry) => entry.id === folderId); - const role = extractEntryRole(entries, workspace.userId); + if (!folder || !root) { + return null; + } - if (!folder || folder.type !== 'folder' || !role) { + const role = extractEntryRole(root, workspace.userId); + if (!role) { return null; } return (
- - + +
); }; diff --git a/apps/desktop/src/renderer/components/folders/folder-header.tsx b/apps/desktop/src/renderer/components/folders/folder-header.tsx index bc4cc018..cd91eef6 100644 --- a/apps/desktop/src/renderer/components/folders/folder-header.tsx +++ b/apps/desktop/src/renderer/components/folders/folder-header.tsx @@ -1,4 +1,4 @@ -import { FolderEntry, Entry, EntryRole } from '@colanode/core'; +import { FolderEntry, EntryRole } from '@colanode/core'; import { FolderSettings } from '@/renderer/components/folders/folder-settings'; import { EntryBreadcrumb } from '@/renderer/components/layouts/entry-breadcrumb'; @@ -7,19 +7,18 @@ import { Header } from '@/renderer/components/ui/header'; import { useContainer } from '@/renderer/contexts/container'; interface FolderHeaderProps { - entries: Entry[]; folder: FolderEntry; role: EntryRole; } -export const FolderHeader = ({ entries, folder, role }: FolderHeaderProps) => { +export const FolderHeader = ({ folder, role }: FolderHeaderProps) => { const container = useContainer(); return (
- + {container.mode === 'modal' && ( )} diff --git a/apps/desktop/src/renderer/components/folders/folder-settings.tsx b/apps/desktop/src/renderer/components/folders/folder-settings.tsx index 56a6cf51..ebf06781 100644 --- a/apps/desktop/src/renderer/components/folders/folder-settings.tsx +++ b/apps/desktop/src/renderer/components/folders/folder-settings.tsx @@ -1,4 +1,4 @@ -import { FolderEntry, hasEditorAccess, EntryRole } from '@colanode/core'; +import { EntryRole, FolderEntry, hasEntryRole } from '@colanode/core'; import { Copy, Image, LetterText, Settings, Trash2 } from 'lucide-react'; import React from 'react'; @@ -23,8 +23,8 @@ export const FolderSettings = ({ folder, role }: FolderSettingsProps) => { const [showUpdateDialog, setShowUpdateDialog] = React.useState(false); const [showDeleteDialog, setShowDeleteModal] = React.useState(false); - const canEdit = hasEditorAccess(role); - const canDelete = hasEditorAccess(role); + const canEdit = hasEntryRole(role, 'editor'); + const canDelete = hasEntryRole(role, 'editor'); return ( diff --git a/apps/desktop/src/renderer/components/folders/folder-update-dialog.tsx b/apps/desktop/src/renderer/components/folders/folder-update-dialog.tsx index cfdae575..3cd41538 100644 --- a/apps/desktop/src/renderer/components/folders/folder-update-dialog.tsx +++ b/apps/desktop/src/renderer/components/folders/folder-update-dialog.tsx @@ -1,4 +1,4 @@ -import { FolderEntry, hasEditorAccess, EntryRole } from '@colanode/core'; +import { EntryRole, FolderEntry, hasEntryRole } from '@colanode/core'; import { FolderForm } from '@/renderer/components/folders/folder-form'; import { @@ -27,7 +27,7 @@ export const FolderUpdateDialog = ({ }: FolderUpdateDialogProps) => { const workspace = useWorkspace(); const { mutate, isPending } = useMutation(); - const canEdit = hasEditorAccess(role); + const canEdit = hasEntryRole(role, 'editor'); return ( diff --git a/apps/desktop/src/renderer/components/layouts/entry-breadcrumb.tsx b/apps/desktop/src/renderer/components/layouts/entry-breadcrumb.tsx index b4a284ad..610af1f7 100644 --- a/apps/desktop/src/renderer/components/layouts/entry-breadcrumb.tsx +++ b/apps/desktop/src/renderer/components/layouts/entry-breadcrumb.tsx @@ -16,15 +16,28 @@ import { DropdownMenuTrigger, } from '@/renderer/components/ui/dropdown-menu'; import { useWorkspace } from '@/renderer/contexts/workspace'; +import { useQuery } from '@/renderer/hooks/use-query'; interface EntryBreadcrumbProps { - entries: Entry[]; + entry: Entry; } const isClickable = (type: EntryType) => type !== 'space'; -export const EntryBreadcrumb = ({ entries }: EntryBreadcrumbProps) => { +export const EntryBreadcrumb = ({ entry }: EntryBreadcrumbProps) => { const workspace = useWorkspace(); + const { data } = useQuery( + { + type: 'entry_tree_get', + entryId: entry.id, + userId: workspace.userId, + }, + { + enabled: entry.type !== 'chat', + } + ); + + const entries = data?.length ? data : [entry]; // Show ellipsis if we have more than 3 nodes (first + last two) const showEllipsis = entries.length > 3; diff --git a/apps/desktop/src/renderer/components/layouts/layout-sidebar-chats.tsx b/apps/desktop/src/renderer/components/layouts/layout-sidebar-chats.tsx index acbb6f04..982d4b2b 100644 --- a/apps/desktop/src/renderer/components/layouts/layout-sidebar-chats.tsx +++ b/apps/desktop/src/renderer/components/layouts/layout-sidebar-chats.tsx @@ -1,5 +1,3 @@ -import { ChatEntry } from '@colanode/core'; - import { ChatCreatePopover } from '@/renderer/components/chats/chat-create-popover'; import { ChatSidebarItem } from '@/renderer/components/chats/chat-sidebar-item'; import { useWorkspace } from '@/renderer/contexts/workspace'; @@ -10,13 +8,14 @@ export const LayoutSidebarChats = () => { const workspace = useWorkspace(); const { data } = useQuery({ - type: 'entry_children_get', + type: 'chat_list', userId: workspace.userId, - entryId: workspace.id, - types: ['chat'], + parentId: workspace.id, + page: 0, + count: 100, }); - const chats = data?.map((entry) => entry as ChatEntry) ?? []; + const chats = data ?? []; return (
@@ -24,7 +23,7 @@ export const LayoutSidebarChats = () => {
Chats
-
+
diff --git a/apps/desktop/src/renderer/components/layouts/layout-sidebar-spaces.tsx b/apps/desktop/src/renderer/components/layouts/layout-sidebar-spaces.tsx index 4846ca6a..a986fca8 100644 --- a/apps/desktop/src/renderer/components/layouts/layout-sidebar-spaces.tsx +++ b/apps/desktop/src/renderer/components/layouts/layout-sidebar-spaces.tsx @@ -1,5 +1,3 @@ -import { SpaceEntry } from '@colanode/core'; - import { SpaceCreateButton } from '@/renderer/components/spaces/space-create-button'; import { SpaceSidebarItem } from '@/renderer/components/spaces/space-sidebar-item'; import { useWorkspace } from '@/renderer/contexts/workspace'; @@ -11,13 +9,14 @@ export const LayoutSidebarSpaces = () => { workspace.role !== 'guest' && workspace.role !== 'none'; const { data } = useQuery({ - type: 'entry_children_get', + type: 'space_list', userId: workspace.userId, - entryId: workspace.id, - types: ['space'], + parentId: workspace.id, + page: 0, + count: 100, }); - const spaces = data?.map((entry) => entry as SpaceEntry) ?? []; + const spaces = data ?? []; return (
diff --git a/apps/desktop/src/renderer/components/messages/conversation.tsx b/apps/desktop/src/renderer/components/messages/conversation.tsx index fa78cbe1..d543eefe 100644 --- a/apps/desktop/src/renderer/components/messages/conversation.tsx +++ b/apps/desktop/src/renderer/components/messages/conversation.tsx @@ -1,8 +1,4 @@ -import { - hasAdminAccess, - hasCollaboratorAccess, - EntryRole, -} from '@colanode/core'; +import { EntryRole, hasEntryRole } from '@colanode/core'; import React from 'react'; import { InView } from 'react-intersection-observer'; @@ -76,8 +72,8 @@ export const Conversation = ({ } }; - const isAdmin = hasAdminAccess(role); - const isCollaborator = hasCollaboratorAccess(role); + const isAdmin = hasEntryRole(role, 'admin'); + const canCreateMessage = hasEntryRole(role, 'commenter'); return ( { if (messageCreateRef.current) { messageCreateRef.current.setReplyTo(message); diff --git a/apps/desktop/src/renderer/components/pages/page-body.tsx b/apps/desktop/src/renderer/components/pages/page-body.tsx index f0084a01..390761c6 100644 --- a/apps/desktop/src/renderer/components/pages/page-body.tsx +++ b/apps/desktop/src/renderer/components/pages/page-body.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect } from 'react'; -import { hasEditorAccess, EntryRole, PageEntry } from '@colanode/core'; +import { EntryRole, PageEntry, hasEntryRole } from '@colanode/core'; import { JSONContent } from '@tiptap/core'; import { Document } from '@/renderer/components/documents/document'; @@ -18,7 +18,7 @@ export const PageBody = ({ page, role }: PageBodyProps) => { const workspace = useWorkspace(); const radar = useRadar(); const { mutate } = useMutation(); - const canEdit = hasEditorAccess(role); + const canEdit = hasEntryRole(role, 'editor'); const handleUpdate = useCallback( (content: JSONContent) => { diff --git a/apps/desktop/src/renderer/components/pages/page-container.tsx b/apps/desktop/src/renderer/components/pages/page-container.tsx index 49954544..b8fd01c7 100644 --- a/apps/desktop/src/renderer/components/pages/page-container.tsx +++ b/apps/desktop/src/renderer/components/pages/page-container.tsx @@ -1,4 +1,4 @@ -import { extractEntryRole } from '@colanode/core'; +import { extractEntryRole, PageEntry } from '@colanode/core'; import { PageBody } from '@/renderer/components/pages/page-body'; import { PageHeader } from '@/renderer/components/pages/page-header'; @@ -11,27 +11,41 @@ interface PageContainerProps { export const PageContainer = ({ pageId }: PageContainerProps) => { const workspace = useWorkspace(); - const { data, isPending } = useQuery({ - type: 'entry_tree_get', + + const { data: entry, isPending: isPendingEntry } = useQuery({ + type: 'entry_get', entryId: pageId, userId: workspace.userId, }); - if (isPending) { + const page = entry as PageEntry; + + const { data: root, isPending: isPendingRoot } = useQuery( + { + type: 'entry_get', + entryId: page?.rootId ?? '', + userId: workspace.userId, + }, + { + enabled: !!page?.rootId, + } + ); + + if (isPendingEntry || isPendingRoot) { return null; } - const entries = data ?? []; - const page = entries.find((entry) => entry.id === pageId); - const role = extractEntryRole(entries, workspace.userId); - - if (!page || page.type !== 'page' || !role) { + if (!page || !root) { return null; } + const role = extractEntryRole(root, workspace.userId); + if (!role) { + return null; + } return (
- +
); diff --git a/apps/desktop/src/renderer/components/pages/page-header.tsx b/apps/desktop/src/renderer/components/pages/page-header.tsx index f2eb8c16..decdb517 100644 --- a/apps/desktop/src/renderer/components/pages/page-header.tsx +++ b/apps/desktop/src/renderer/components/pages/page-header.tsx @@ -1,4 +1,4 @@ -import { Entry, EntryRole, PageEntry } from '@colanode/core'; +import { EntryRole, PageEntry } from '@colanode/core'; import { EntryBreadcrumb } from '@/renderer/components/layouts/entry-breadcrumb'; import { EntryFullscreenButton } from '@/renderer/components/layouts/entry-fullscreen-button'; @@ -7,19 +7,18 @@ import { Header } from '@/renderer/components/ui/header'; import { useContainer } from '@/renderer/contexts/container'; interface PageHeaderProps { - entries: Entry[]; page: PageEntry; role: EntryRole; } -export const PageHeader = ({ entries, page, role }: PageHeaderProps) => { +export const PageHeader = ({ page, role }: PageHeaderProps) => { const container = useContainer(); return (
- + {container.mode === 'modal' && ( )} diff --git a/apps/desktop/src/renderer/components/pages/page-settings.tsx b/apps/desktop/src/renderer/components/pages/page-settings.tsx index 8a94a42b..1f6810fc 100644 --- a/apps/desktop/src/renderer/components/pages/page-settings.tsx +++ b/apps/desktop/src/renderer/components/pages/page-settings.tsx @@ -1,4 +1,4 @@ -import { hasEditorAccess, EntryRole, PageEntry } from '@colanode/core'; +import { EntryRole, PageEntry, hasEntryRole } from '@colanode/core'; import { Copy, Image, LetterText, Settings, Trash2 } from 'lucide-react'; import React from 'react'; @@ -23,8 +23,8 @@ export const PageSettings = ({ page, role }: PageSettingsProps) => { const [showUpdateDialog, setShowUpdateDialog] = React.useState(false); const [showDeleteDialog, setShowDeleteModal] = React.useState(false); - const canEdit = hasEditorAccess(role); - const canDelete = hasEditorAccess(role); + const canEdit = hasEntryRole(role, 'editor'); + const canDelete = hasEntryRole(role, 'editor'); return ( diff --git a/apps/desktop/src/renderer/components/pages/page-update-dialog.tsx b/apps/desktop/src/renderer/components/pages/page-update-dialog.tsx index 10b3b45e..ae93284a 100644 --- a/apps/desktop/src/renderer/components/pages/page-update-dialog.tsx +++ b/apps/desktop/src/renderer/components/pages/page-update-dialog.tsx @@ -1,4 +1,4 @@ -import { hasEditorAccess, EntryRole, PageEntry } from '@colanode/core'; +import { EntryRole, PageEntry, hasEntryRole } from '@colanode/core'; import { PageForm } from '@/renderer/components/pages/page-form'; import { @@ -27,7 +27,7 @@ export const PageUpdateDialog = ({ }: PageUpdateDialogProps) => { const workspace = useWorkspace(); const { mutate, isPending } = useMutation(); - const canEdit = hasEditorAccess(role); + const canEdit = hasEntryRole(role, 'editor'); return ( diff --git a/apps/desktop/src/renderer/components/records/record-body.tsx b/apps/desktop/src/renderer/components/records/record-body.tsx index 5b71320c..0379b353 100644 --- a/apps/desktop/src/renderer/components/records/record-body.tsx +++ b/apps/desktop/src/renderer/components/records/record-body.tsx @@ -1,7 +1,7 @@ import { DatabaseEntry, - hasEditorAccess, EntryRole, + hasEntryRole, RecordEntry, } from '@colanode/core'; import { JSONContent } from '@tiptap/core'; @@ -20,23 +20,17 @@ import { useRadar } from '@/renderer/contexts/radar'; interface RecordBodyProps { record: RecordEntry; - recordRole: EntryRole; database: DatabaseEntry; - databaseRole: EntryRole; + role: EntryRole; } -export const RecordBody = ({ - record, - recordRole, - database, - databaseRole, -}: RecordBodyProps) => { +export const RecordBody = ({ record, database, role }: RecordBodyProps) => { const workspace = useWorkspace(); const radar = useRadar(); const { mutate } = useMutation(); const canEdit = - record.createdBy === workspace.userId || hasEditorAccess(recordRole); + record.createdBy === workspace.userId || hasEntryRole(role, 'editor'); const handleUpdate = useCallback( (content: JSONContent) => { @@ -70,9 +64,9 @@ export const RecordBody = ({ }, [record.id, record.type, record.transactionId]); return ( - + - + diff --git a/apps/desktop/src/renderer/components/records/record-container.tsx b/apps/desktop/src/renderer/components/records/record-container.tsx index 0e3d28eb..25aac038 100644 --- a/apps/desktop/src/renderer/components/records/record-container.tsx +++ b/apps/desktop/src/renderer/components/records/record-container.tsx @@ -1,4 +1,4 @@ -import { extractEntryRole } from '@colanode/core'; +import { DatabaseEntry, extractEntryRole, RecordEntry } from '@colanode/core'; import { RecordBody } from '@/renderer/components/records/record-body'; import { RecordHeader } from '@/renderer/components/records/record-header'; @@ -11,55 +11,56 @@ interface RecordContainerProps { export const RecordContainer = ({ recordId }: RecordContainerProps) => { const workspace = useWorkspace(); - const { data, isPending } = useQuery({ - type: 'entry_tree_get', + + const { data: entry, isPending: isPendingEntry } = useQuery({ + type: 'entry_get', entryId: recordId, userId: workspace.userId, }); - if (isPending) { - return null; - } + const record = entry as RecordEntry; - const entries = data ?? []; - const record = entries.find((entry) => entry.id === recordId); - if (!record || record.type !== 'record') { - return null; - } - - const databaseIndex = entries.findIndex( - (entry) => entry.id === record.attributes.databaseId - ); - if (databaseIndex === -1) { - return null; - } - - const database = entries[databaseIndex]; - if (!database || database.type !== 'database') { - return null; - } - - const databaseAncestors = entries.slice(0, databaseIndex); - - const recordRole = extractEntryRole(entries, workspace.userId); - const databaseRole = extractEntryRole( - [...databaseAncestors, database], - workspace.userId + const { data: root, isPending: isPendingRoot } = useQuery( + { + type: 'entry_get', + entryId: record?.rootId ?? '', + userId: workspace.userId, + }, + { + enabled: !!record?.rootId, + } ); - if (!recordRole || !databaseRole) { + const { data: databaseEntry, isPending: isPendingDatabase } = useQuery( + { + type: 'entry_get', + entryId: record?.attributes.databaseId ?? '', + userId: workspace.userId, + }, + { + enabled: !!record?.attributes.databaseId, + } + ); + + const database = databaseEntry as DatabaseEntry; + + if (isPendingEntry || isPendingRoot || isPendingDatabase) { + return null; + } + + if (!record || !root || !database) { + return null; + } + + const role = extractEntryRole(root, workspace.userId); + if (!role) { return null; } return (
- - + +
); }; diff --git a/apps/desktop/src/renderer/components/records/record-header.tsx b/apps/desktop/src/renderer/components/records/record-header.tsx index 66acf95c..b7a701d2 100644 --- a/apps/desktop/src/renderer/components/records/record-header.tsx +++ b/apps/desktop/src/renderer/components/records/record-header.tsx @@ -1,4 +1,4 @@ -import { Entry, EntryRole, RecordEntry } from '@colanode/core'; +import { EntryRole, RecordEntry } from '@colanode/core'; import { EntryBreadcrumb } from '@/renderer/components/layouts/entry-breadcrumb'; import { EntryFullscreenButton } from '@/renderer/components/layouts/entry-fullscreen-button'; @@ -7,19 +7,18 @@ import { Header } from '@/renderer/components/ui/header'; import { useContainer } from '@/renderer/contexts/container'; interface RecordHeaderProps { - entries: Entry[]; record: RecordEntry; role: EntryRole; } -export const RecordHeader = ({ entries, record, role }: RecordHeaderProps) => { +export const RecordHeader = ({ record, role }: RecordHeaderProps) => { const container = useContainer(); return (
- {container.mode === 'main' && } + {container.mode === 'main' && } {container.mode === 'modal' && ( )} diff --git a/apps/desktop/src/renderer/components/records/record-provider.tsx b/apps/desktop/src/renderer/components/records/record-provider.tsx index 5f0ace4e..ce11e2f9 100644 --- a/apps/desktop/src/renderer/components/records/record-provider.tsx +++ b/apps/desktop/src/renderer/components/records/record-provider.tsx @@ -1,4 +1,4 @@ -import { hasEditorAccess, EntryRole, RecordEntry } from '@colanode/core'; +import { EntryRole, RecordEntry, hasEntryRole } from '@colanode/core'; import React from 'react'; import { RecordContext } from '@/renderer/contexts/record'; @@ -19,7 +19,7 @@ export const RecordProvider = ({ const { mutate } = useMutation(); const canEdit = - record.createdBy === workspace.userId || hasEditorAccess(role); + record.createdBy === workspace.userId || hasEntryRole(role, 'editor'); return ( { const workspace = useWorkspace(); const [showDeleteDialog, setShowDeleteModal] = React.useState(false); const canDelete = - record.createdBy === workspace.userId || hasEditorAccess(role); + record.createdBy === workspace.userId || hasEntryRole(role, 'editor'); return ( diff --git a/apps/desktop/src/renderer/components/spaces/space-settings-dialog.tsx b/apps/desktop/src/renderer/components/spaces/space-settings-dialog.tsx index 1a6b889f..e3e95f9b 100644 --- a/apps/desktop/src/renderer/components/spaces/space-settings-dialog.tsx +++ b/apps/desktop/src/renderer/components/spaces/space-settings-dialog.tsx @@ -1,4 +1,4 @@ -import { extractEntryRole, hasAdminAccess, SpaceEntry } from '@colanode/core'; +import { extractEntryRole, SpaceEntry, hasEntryRole } from '@colanode/core'; import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; import { Info, Trash2, Users } from 'lucide-react'; @@ -38,7 +38,7 @@ export const SpaceSettingsDialog = ({ return null; } - const canDelete = hasAdminAccess(role); + const canDelete = hasEntryRole(role, 'editor'); return ( diff --git a/apps/desktop/src/renderer/hooks/use-mutation.tsx b/apps/desktop/src/renderer/hooks/use-mutation.tsx index ed59faf5..c2c38f57 100644 --- a/apps/desktop/src/renderer/hooks/use-mutation.tsx +++ b/apps/desktop/src/renderer/hooks/use-mutation.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { MutationError, + MutationErrorCode, MutationErrorData, MutationInput, MutationMap, @@ -32,7 +33,7 @@ export const useMutation = () => { options.onError?.(error); } else { options.onError?.({ - code: 'unknown', + code: MutationErrorCode.Unknown, message: 'Something went wrong trying to execute the mutation.', }); } diff --git a/apps/desktop/src/shared/lib/axios.ts b/apps/desktop/src/shared/lib/axios.ts index aee76a06..94e978c5 100644 --- a/apps/desktop/src/shared/lib/axios.ts +++ b/apps/desktop/src/shared/lib/axios.ts @@ -1,6 +1,5 @@ import { isAxiosError } from 'axios'; - -import { ApiErrorOutput } from '@/shared/types/errors'; +import { ApiErrorCode, ApiErrorOutput } from '@colanode/core'; export const parseApiError = (error: unknown): ApiErrorOutput => { if (isAxiosError(error) && error.response) { @@ -14,35 +13,35 @@ export const parseApiError = (error: unknown): ApiErrorOutput => { if (error.response.status === 401) { return { - code: 'UNAUTHORIZED', + code: ApiErrorCode.Unauthorized, message: 'You are not authorized to perform this action', }; } if (error.response.status === 403) { return { - code: 'FORBIDDEN', + code: ApiErrorCode.Forbidden, message: 'You are forbidden from performing this action', }; } if (error.response.status === 404) { return { - code: 'NOT_FOUND', + code: ApiErrorCode.NotFound, message: 'Resource not found', }; } if (error.response.status === 400) { return { - code: 'BAD_REQUEST', + code: ApiErrorCode.BadRequest, message: 'Bad request', }; } } return { - code: 'UNKNOWN', + code: ApiErrorCode.Unknown, message: 'An unknown error occurred', }; }; diff --git a/apps/desktop/src/shared/mutations/index.ts b/apps/desktop/src/shared/mutations/index.ts index 17afaa42..bcf61e04 100644 --- a/apps/desktop/src/shared/mutations/index.ts +++ b/apps/desktop/src/shared/mutations/index.ts @@ -31,24 +31,78 @@ export class MutationError extends Error { } } -export type MutationErrorCode = - | 'unknown' - | 'account_not_found' - | 'account_login_failed' - | 'account_register_failed' - | 'server_not_found' - | 'invalid_attributes' - | 'space_not_found' - | 'channel_not_found' - | 'unauthorized' - | 'invalid_file' - | 'node_not_found' - | 'view_not_found' - | 'field_not_found' - | 'invalid_field_type' - | 'select_option_not_found' - | 'message_not_found' - | 'invalid_server_domain' - | 'server_already_exists' - | 'workspace_not_found' - | 'relation_database_not_found'; +export enum MutationErrorCode { + Unknown = 'unknown', + ApiError = 'api_error', + AccountNotFound = 'account_not_found', + AccountLoginFailed = 'account_login_failed', + AccountRegisterFailed = 'account_register_failed', + ServerNotFound = 'server_not_found', + WorkspaceNotFound = 'workspace_not_found', + WorkspaceNotCreated = 'workspace_not_created', + WorkspaceNotUpdated = 'workspace_not_updated', + SpaceNotFound = 'space_not_found', + SpaceUpdateForbidden = 'space_update_forbidden', + SpaceUpdateFailed = 'space_update_failed', + SpaceCreateForbidden = 'space_create_forbidden', + SpaceCreateFailed = 'space_create_failed', + ServerAlreadyExists = 'server_already_exists', + ServerDomainInvalid = 'server_domain_invalid', + ServerInitFailed = 'server_init_failed', + ChannelNotFound = 'channel_not_found', + ChannelUpdateForbidden = 'channel_update_forbidden', + ChannelUpdateFailed = 'channel_update_failed', + DatabaseNotFound = 'database_not_found', + DatabaseUpdateForbidden = 'database_update_forbidden', + DatabaseUpdateFailed = 'database_update_failed', + RelationDatabaseNotFound = 'relation_database_not_found', + FieldNotFound = 'field_not_found', + FileInvalid = 'file_invalid', + FieldCreateForbidden = 'field_create_forbidden', + FieldCreateFailed = 'field_create_failed', + FieldUpdateForbidden = 'field_update_forbidden', + FieldUpdateFailed = 'field_update_failed', + FieldDeleteForbidden = 'field_delete_forbidden', + FieldDeleteFailed = 'field_delete_failed', + FieldTypeInvalid = 'field_type_invalid', + SelectOptionCreateForbidden = 'select_option_create_forbidden', + SelectOptionCreateFailed = 'select_option_create_failed', + SelectOptionNotFound = 'select_option_not_found', + SelectOptionUpdateForbidden = 'select_option_update_forbidden', + SelectOptionUpdateFailed = 'select_option_update_failed', + SelectOptionDeleteForbidden = 'select_option_delete_forbidden', + SelectOptionDeleteFailed = 'select_option_delete_failed', + ViewNotFound = 'view_not_found', + ViewCreateForbidden = 'view_create_forbidden', + ViewCreateFailed = 'view_create_failed', + ViewUpdateForbidden = 'view_update_forbidden', + ViewUpdateFailed = 'view_update_failed', + ViewDeleteForbidden = 'view_delete_forbidden', + ViewDeleteFailed = 'view_delete_failed', + RecordUpdateForbidden = 'record_update_forbidden', + RecordUpdateFailed = 'record_update_failed', + PageUpdateForbidden = 'page_update_forbidden', + PageUpdateFailed = 'page_update_failed', + EntryCollaboratorCreateForbidden = 'entry_collaborator_create_forbidden', + EntryCollaboratorCreateFailed = 'entry_collaborator_create_failed', + EntryCollaboratorDeleteForbidden = 'entry_collaborator_delete_forbidden', + EntryCollaboratorDeleteFailed = 'entry_collaborator_delete_failed', + EntryCollaboratorUpdateForbidden = 'entry_collaborator_update_forbidden', + EntryCollaboratorUpdateFailed = 'entry_collaborator_update_failed', + UserNotFound = 'user_not_found', + EntryNotFound = 'entry_not_found', + RootNotFound = 'root_not_found', + FileCreateForbidden = 'file_create_forbidden', + FileCreateFailed = 'file_create_failed', + FileNotFound = 'file_not_found', + FileDeleteForbidden = 'file_delete_forbidden', + FileDeleteFailed = 'file_delete_failed', + FolderUpdateForbidden = 'folder_update_forbidden', + FolderUpdateFailed = 'folder_update_failed', + MessageCreateForbidden = 'message_create_forbidden', + MessageCreateFailed = 'message_create_failed', + MessageDeleteForbidden = 'message_delete_forbidden', + MessageDeleteFailed = 'message_delete_failed', + MessageNotFound = 'message_not_found', + MessageReactionCreateForbidden = 'message_reaction_create_forbidden', +} diff --git a/apps/desktop/src/shared/queries/chats/chat-list.ts b/apps/desktop/src/shared/queries/chats/chat-list.ts new file mode 100644 index 00000000..56ff2712 --- /dev/null +++ b/apps/desktop/src/shared/queries/chats/chat-list.ts @@ -0,0 +1,17 @@ +import { ChatEntry } from '@colanode/core'; + +export type ChatListQueryInput = { + type: 'chat_list'; + page: number; + count: number; + userId: string; +}; + +declare module '@/shared/queries' { + interface QueryMap { + chat_list: { + input: ChatListQueryInput; + output: ChatEntry[]; + }; + } +} diff --git a/apps/desktop/src/shared/queries/spaces/space-list.ts b/apps/desktop/src/shared/queries/spaces/space-list.ts new file mode 100644 index 00000000..4825c04f --- /dev/null +++ b/apps/desktop/src/shared/queries/spaces/space-list.ts @@ -0,0 +1,17 @@ +import { SpaceEntry } from '@colanode/core'; + +export type SpaceListQueryInput = { + type: 'space_list'; + page: number; + count: number; + userId: string; +}; + +declare module '@/shared/queries' { + interface QueryMap { + space_list: { + input: SpaceListQueryInput; + output: SpaceEntry[]; + }; + } +} diff --git a/apps/desktop/src/shared/types/errors.ts b/apps/desktop/src/shared/types/errors.ts deleted file mode 100644 index 69108cb3..00000000 --- a/apps/desktop/src/shared/types/errors.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type ApiErrorOutput = { - message: string; - code: string; -}; diff --git a/apps/desktop/src/shared/types/files.ts b/apps/desktop/src/shared/types/files.ts index 0aa0ae70..d830a992 100644 --- a/apps/desktop/src/shared/types/files.ts +++ b/apps/desktop/src/shared/types/files.ts @@ -13,6 +13,7 @@ export type File = { id: string; type: FileType; parentId: string; + entryId: string; rootId: string; name: string; originalName: string; diff --git a/apps/desktop/src/shared/types/workspaces.ts b/apps/desktop/src/shared/types/workspaces.ts index c8e8bb1b..444d10ab 100644 --- a/apps/desktop/src/shared/types/workspaces.ts +++ b/apps/desktop/src/shared/types/workspaces.ts @@ -5,7 +5,6 @@ export type Workspace = { name: string; description?: string | null; avatar?: string | null; - versionId: string; accountId: string; role: WorkspaceRole; userId: string; diff --git a/apps/server/src/controllers/client/accounts/account-sync.ts b/apps/server/src/controllers/client/accounts/account-sync.ts index 3c1607b7..c5bfe485 100644 --- a/apps/server/src/controllers/client/accounts/account-sync.ts +++ b/apps/server/src/controllers/client/accounts/account-sync.ts @@ -3,10 +3,11 @@ import { AccountSyncOutput, WorkspaceOutput, WorkspaceRole, + ApiErrorCode, } from '@colanode/core'; -import { ApiError } from '@/types/api'; import { database } from '@/data/database'; +import { ResponseBuilder } from '@/lib/response-builder'; export const accountSyncHandler = async ( _: Request, @@ -19,11 +20,10 @@ export const accountSyncHandler = async ( .executeTakeFirst(); if (!account) { - res.status(404).json({ - code: ApiError.ResourceNotFound, - message: 'Account not found.', + return ResponseBuilder.notFound(res, { + code: ApiErrorCode.AccountNotFound, + message: 'Account not found. Check your token.', }); - return; } const workspaceOutputs: WorkspaceOutput[] = []; @@ -51,7 +51,6 @@ export const accountSyncHandler = async ( workspaceOutputs.push({ id: workspace.id, name: workspace.name, - versionId: workspace.version_id, avatar: workspace.avatar, description: workspace.description, user: { @@ -73,5 +72,5 @@ export const accountSyncHandler = async ( workspaces: workspaceOutputs, }; - res.status(200).json(output); + return ResponseBuilder.success(res, output); }; diff --git a/apps/server/src/controllers/client/accounts/account-update.ts b/apps/server/src/controllers/client/accounts/account-update.ts index c842e7ca..fa2b11bf 100644 --- a/apps/server/src/controllers/client/accounts/account-update.ts +++ b/apps/server/src/controllers/client/accounts/account-update.ts @@ -1,9 +1,13 @@ import { Request, Response } from 'express'; -import { AccountUpdateInput, AccountUpdateOutput } from '@colanode/core'; +import { + AccountUpdateInput, + AccountUpdateOutput, + ApiErrorCode, +} from '@colanode/core'; import { database } from '@/data/database'; -import { ApiError } from '@/types/api'; import { eventBus } from '@/lib/event-bus'; +import { ResponseBuilder } from '@/lib/response-builder'; export const accountUpdateHandler = async ( req: Request, @@ -13,11 +17,11 @@ export const accountUpdateHandler = async ( const input: AccountUpdateInput = req.body; if (accountId !== res.locals.account.id) { - res.status(400).json({ - code: ApiError.BadRequest, - message: 'Invalid account id.', + return ResponseBuilder.badRequest(res, { + code: ApiErrorCode.AccountMismatch, + message: + 'The provided account id does not match the account id in the token. Make sure you are using the correct account token.', }); - return; } const account = await database @@ -27,55 +31,72 @@ export const accountUpdateHandler = async ( .executeTakeFirst(); if (!account) { - res.status(404).json({ - code: ApiError.ResourceNotFound, - message: 'Account not found.', + return ResponseBuilder.notFound(res, { + code: ApiErrorCode.AccountNotFound, + message: 'Account not found or has been deleted.', }); - return; } const nameChanged = account.name !== input.name; const avatarChanged = account.avatar !== input.avatar; if (!nameChanged && !avatarChanged) { - res.status(400).json({ - code: ApiError.BadRequest, - message: 'Nothing to update.', - }); - return; - } - - await database - .updateTable('accounts') - .set({ + const output: AccountUpdateOutput = { + id: account.id, name: input.name, avatar: input.avatar, - updated_at: new Date(), - }) - .where('id', '=', account.id) - .execute(); + }; - const users = await database - .selectFrom('users') - .select(['id', 'workspace_id']) - .where('account_id', '=', account.id) - .execute(); + return ResponseBuilder.success(res, output); + } - if (users.length > 0) { - const userIds = users.map((u) => u.id); + const { updatedAccount, updatedUsers } = await database + .transaction() + .execute(async (tx) => { + const updatedAccount = await tx + .updateTable('accounts') + .returningAll() + .set({ + name: input.name, + avatar: input.avatar, + updated_at: new Date(), + }) + .where('id', '=', account.id) + .executeTakeFirst(); - await database - .updateTable('users') - .set({ - name: input.name, - avatar: input.avatar, - updated_at: new Date(), - updated_by: account.id, - }) - .where('id', 'in', userIds) - .execute(); + if (!updatedAccount) { + throw new Error('Account not found or has been deleted.'); + } - for (const user of users) { + const updatedUsers = await tx + .updateTable('users') + .returningAll() + .set({ + name: input.name, + avatar: input.avatar, + updated_at: new Date(), + updated_by: account.id, + }) + .where('account_id', '=', account.id) + .execute(); + + return { updatedAccount, updatedUsers }; + }); + + if (!updatedAccount) { + return ResponseBuilder.notFound(res, { + code: ApiErrorCode.AccountNotFound, + message: 'Account not found or has been deleted.', + }); + } + + eventBus.publish({ + type: 'account_updated', + accountId: account.id, + }); + + if (updatedUsers.length > 0) { + for (const user of updatedUsers) { eventBus.publish({ type: 'user_updated', userId: user.id, @@ -85,16 +106,11 @@ export const accountUpdateHandler = async ( } } - eventBus.publish({ - type: 'account_updated', - accountId: account.id, - }); - const output: AccountUpdateOutput = { id: account.id, name: input.name, avatar: input.avatar, }; - res.status(200).json(output); + return ResponseBuilder.success(res, output); }; diff --git a/apps/server/src/controllers/client/accounts/login-email.ts b/apps/server/src/controllers/client/accounts/login-email.ts index 97113519..95bac9a0 100644 --- a/apps/server/src/controllers/client/accounts/login-email.ts +++ b/apps/server/src/controllers/client/accounts/login-email.ts @@ -1,11 +1,11 @@ import { Request, Response } from 'express'; -import { AccountStatus, EmailLoginInput } from '@colanode/core'; +import { AccountStatus, EmailLoginInput, ApiErrorCode } from '@colanode/core'; import bcrypt from 'bcrypt'; import { sha256 } from 'js-sha256'; import { database } from '@/data/database'; -import { ApiError } from '@/types/api'; import { accountService } from '@/services/account-service'; +import { ResponseBuilder } from '@/lib/response-builder'; export const loginWithEmailHandler = async ( req: Request, @@ -20,28 +20,18 @@ export const loginWithEmailHandler = async ( .selectAll() .executeTakeFirst(); - if (!account) { - res.status(400).json({ - code: ApiError.EmailOrPasswordIncorrect, - message: 'Invalid credentials.', + if (!account || !account.password) { + return ResponseBuilder.badRequest(res, { + code: ApiErrorCode.EmailOrPasswordIncorrect, + message: 'Invalid email or password.', }); - return; } if (account.status === AccountStatus.Pending) { - res.status(400).json({ - code: ApiError.UserPendingActivation, - message: 'User is pending activation.', + return ResponseBuilder.badRequest(res, { + code: ApiErrorCode.AccountPendingActivation, + message: 'Account is not activated yet. Register or use another email.', }); - return; - } - - if (!account.password) { - res.status(400).json({ - code: ApiError.EmailOrPasswordIncorrect, - message: 'Invalid credentials.', - }); - return; } const preHashedPassword = sha256(input.password); @@ -51,13 +41,12 @@ export const loginWithEmailHandler = async ( ); if (!passwordMatch) { - res.status(400).json({ - code: ApiError.EmailOrPasswordIncorrect, - message: 'Invalid credentials.', + return ResponseBuilder.badRequest(res, { + code: ApiErrorCode.EmailOrPasswordIncorrect, + message: 'Invalid email or password.', }); - return; } const output = await accountService.buildLoginOutput(account); - res.status(200).json(output); + return ResponseBuilder.success(res, output); }; diff --git a/apps/server/src/controllers/client/accounts/login-google.ts b/apps/server/src/controllers/client/accounts/login-google.ts index 57b55caf..dd9616c4 100644 --- a/apps/server/src/controllers/client/accounts/login-google.ts +++ b/apps/server/src/controllers/client/accounts/login-google.ts @@ -5,13 +5,13 @@ import { GoogleLoginInput, GoogleUserInfo, IdType, + ApiErrorCode, } from '@colanode/core'; import axios from 'axios'; import { database } from '@/data/database'; -import { ApiError } from '@/types/api'; import { accountService } from '@/services/account-service'; - +import { ResponseBuilder } from '@/lib/response-builder'; const GoogleUserInfoUrl = 'https://www.googleapis.com/oauth2/v1/userinfo'; export const loginWithGoogleHandler = async ( @@ -23,21 +23,19 @@ export const loginWithGoogleHandler = async ( const userInfoResponse = await axios.get(url); if (userInfoResponse.status !== 200) { - res.status(400).json({ - code: ApiError.GoogleAuthFailed, + return ResponseBuilder.badRequest(res, { + code: ApiErrorCode.GoogleAuthFailed, message: 'Failed to authenticate with Google.', }); - return; } const googleUser: GoogleUserInfo = userInfoResponse.data; if (!googleUser) { - res.status(400).json({ - code: ApiError.GoogleAuthFailed, + return ResponseBuilder.badRequest(res, { + code: ApiErrorCode.GoogleAuthFailed, message: 'Failed to authenticate with Google.', }); - return; } const existingAccount = await database @@ -64,7 +62,7 @@ export const loginWithGoogleHandler = async ( } const output = await accountService.buildLoginOutput(existingAccount); - res.status(200).json(output); + return ResponseBuilder.success(res, output); } const newAccount = await database @@ -81,13 +79,12 @@ export const loginWithGoogleHandler = async ( .executeTakeFirst(); if (!newAccount) { - res.status(500).json({ - code: ApiError.InternalServerError, + return ResponseBuilder.badRequest(res, { + code: ApiErrorCode.AccountCreationFailed, message: 'Failed to create account.', }); - return; } const output = await accountService.buildLoginOutput(newAccount); - res.status(200).json(output); + return ResponseBuilder.success(res, output); }; diff --git a/apps/server/src/controllers/client/accounts/logout.ts b/apps/server/src/controllers/client/accounts/logout.ts index 75533777..3ecbf225 100644 --- a/apps/server/src/controllers/client/accounts/logout.ts +++ b/apps/server/src/controllers/client/accounts/logout.ts @@ -2,6 +2,7 @@ import { Request, Response } from 'express'; import { database } from '@/data/database'; import { eventBus } from '@/lib/event-bus'; +import { ResponseBuilder } from '@/lib/response-builder'; export const logoutHandler = async ( req: Request, @@ -20,5 +21,5 @@ export const logoutHandler = async ( deviceId: account.deviceId, }); - res.status(200).end(); + return ResponseBuilder.success(res, {}); }; diff --git a/apps/server/src/controllers/client/accounts/register-email.ts b/apps/server/src/controllers/client/accounts/register-email.ts index 41111404..f681ad00 100644 --- a/apps/server/src/controllers/client/accounts/register-email.ts +++ b/apps/server/src/controllers/client/accounts/register-email.ts @@ -4,14 +4,15 @@ import { EmailRegisterInput, generateId, IdType, + ApiErrorCode, } from '@colanode/core'; import bcrypt from 'bcrypt'; import { sha256 } from 'js-sha256'; import { database } from '@/data/database'; -import { ApiError } from '@/types/api'; import { SelectAccount } from '@/data/schema'; import { accountService } from '@/services/account-service'; +import { ResponseBuilder } from '@/lib/response-builder'; const SaltRounds = 10; @@ -35,11 +36,10 @@ export const registerWithEmailHandler = async ( let account: SelectAccount | null | undefined = null; if (existingAccount) { if (existingAccount.status !== AccountStatus.Pending) { - res.status(400).json({ - code: ApiError.EmailAlreadyExists, - message: 'Email already exists.', + return ResponseBuilder.badRequest(res, { + code: ApiErrorCode.EmailAlreadyExists, + message: 'Email already exists. Login or use another email.', }); - return; } account = await database @@ -69,13 +69,12 @@ export const registerWithEmailHandler = async ( } if (!account) { - res.status(500).json({ - code: ApiError.InternalServerError, + return ResponseBuilder.badRequest(res, { + code: ApiErrorCode.AccountCreationFailed, message: 'Failed to create account.', }); - return; } const output = await accountService.buildLoginOutput(account); - res.status(200).json(output); + return ResponseBuilder.success(res, output); }; diff --git a/apps/server/src/controllers/client/avatars/avatar-download.ts b/apps/server/src/controllers/client/avatars/avatar-download.ts index 73d01d1a..f2fb0184 100644 --- a/apps/server/src/controllers/client/avatars/avatar-download.ts +++ b/apps/server/src/controllers/client/avatars/avatar-download.ts @@ -1,9 +1,11 @@ import { Request, Response } from 'express'; import { GetObjectCommand } from '@aws-sdk/client-s3'; +import { ApiErrorCode } from '@colanode/core'; import { Readable } from 'stream'; import { avatarStorage, BUCKET_NAMES } from '@/data/storage'; +import { ResponseBuilder } from '@/lib/response-builder'; export const avatarDownloadHandler = async ( req: Request, @@ -18,17 +20,24 @@ export const avatarDownloadHandler = async ( const avatarResponse = await avatarStorage.send(command); if (!avatarResponse.Body) { - res.status(404).json({ error: 'Avatar not found' }); - return; + return ResponseBuilder.badRequest(res, { + code: ApiErrorCode.AvatarNotFound, + message: 'Avatar not found', + }); } if (avatarResponse.Body instanceof Readable) { avatarResponse.Body.pipe(res); } else { - res.status(404).json({ error: 'Avatar not found' }); + return ResponseBuilder.badRequest(res, { + code: ApiErrorCode.AvatarNotFound, + message: 'Avatar not found', + }); } - } catch (error) { - console.error('Error downloading avatar:', error); - res.status(500).json({ error: 'Failed to download avatar' }); + } catch { + return ResponseBuilder.internalError(res, { + code: ApiErrorCode.AvatarDownloadFailed, + message: 'Failed to download avatar', + }); } }; diff --git a/apps/server/src/controllers/client/avatars/avatar-upload.ts b/apps/server/src/controllers/client/avatars/avatar-upload.ts index 012553cd..5c09940d 100644 --- a/apps/server/src/controllers/client/avatars/avatar-upload.ts +++ b/apps/server/src/controllers/client/avatars/avatar-upload.ts @@ -1,12 +1,13 @@ import { Request, Response } from 'express'; import sharp from 'sharp'; import { PutObjectCommand } from '@aws-sdk/client-s3'; -import { generateId, IdType } from '@colanode/core'; +import { ApiErrorCode, generateId, IdType } from '@colanode/core'; import multer from 'multer'; import path from 'path'; import { avatarStorage, BUCKET_NAMES } from '@/data/storage'; +import { ResponseBuilder } from '@/lib/response-builder'; const storage = multer.memoryStorage(); const uploadMulter = multer({ @@ -35,8 +36,10 @@ export const avatarUploadHandler = async ( ): Promise => { try { if (!req.file) { - res.status(400).json({ error: 'No file uploaded' }); - return; + return ResponseBuilder.badRequest(res, { + code: ApiErrorCode.AvatarFileNotUploaded, + message: 'Avatar file not uploaded as part of request', + }); } // Resize image to a maximum of 500x500 pixels while keeping aspect ratio, and convert to JPEG using Sharp @@ -58,9 +61,11 @@ export const avatarUploadHandler = async ( }); await avatarStorage.send(command); - res.json({ success: true, id: avatarId }); - } catch (error) { - console.error('Error uploading file:', error); - res.status(500).json({ error: 'Failed to upload avatar' }); + return ResponseBuilder.success(res, { success: true, id: avatarId }); + } catch { + return ResponseBuilder.internalError(res, { + code: ApiErrorCode.AvatarUploadFailed, + message: 'Failed to upload avatar', + }); } }; diff --git a/apps/server/src/controllers/client/workspaces/files/file-download-get.ts b/apps/server/src/controllers/client/workspaces/files/file-download-get.ts index 7538f53e..1db97e9b 100644 --- a/apps/server/src/controllers/client/workspaces/files/file-download-get.ts +++ b/apps/server/src/controllers/client/workspaces/files/file-download-get.ts @@ -1,12 +1,17 @@ import { Request, Response } from 'express'; -import { CreateDownloadOutput, hasViewerAccess } from '@colanode/core'; +import { + CreateDownloadOutput, + hasEntryRole, + ApiErrorCode, + extractEntryRole, +} from '@colanode/core'; import { GetObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; -import { ApiError } from '@/types/api'; import { database } from '@/data/database'; -import { fetchEntryRole } from '@/lib/entries'; +import { fetchEntry, mapEntry } from '@/lib/entries'; import { BUCKET_NAMES, filesStorage } from '@/data/storage'; +import { ResponseBuilder } from '@/lib/response-builder'; export const fileDownloadGetHandler = async ( req: Request, @@ -21,20 +26,26 @@ export const fileDownloadGetHandler = async ( .executeTakeFirst(); if (!file) { - res.status(404).json({ - code: ApiError.ResourceNotFound, + return ResponseBuilder.badRequest(res, { + code: ApiErrorCode.FileNotFound, message: 'File not found.', }); - return; } - const role = await fetchEntryRole(file.root_id, res.locals.user.id); - if (role === null || !hasViewerAccess(role)) { - res.status(403).json({ - code: ApiError.Forbidden, - message: 'Forbidden.', + const root = await fetchEntry(file.root_id); + if (!root) { + return ResponseBuilder.badRequest(res, { + code: ApiErrorCode.RootNotFound, + message: 'Root not found.', + }); + } + + const role = extractEntryRole(mapEntry(root), res.locals.user.id); + if (role === null || !hasEntryRole(role, 'viewer')) { + return ResponseBuilder.forbidden(res, { + code: ApiErrorCode.FileNoAccess, + message: 'You do not have access to this file.', }); - return; } //generate presigned url for download @@ -52,5 +63,5 @@ export const fileDownloadGetHandler = async ( url: presignedUrl, }; - res.status(200).json(output); + return ResponseBuilder.success(res, output); }; diff --git a/apps/server/src/controllers/client/workspaces/files/file-upload-complete.ts b/apps/server/src/controllers/client/workspaces/files/file-upload-complete.ts index f7a0fcb3..89afb8d8 100644 --- a/apps/server/src/controllers/client/workspaces/files/file-upload-complete.ts +++ b/apps/server/src/controllers/client/workspaces/files/file-upload-complete.ts @@ -1,11 +1,11 @@ import { Request, Response } from 'express'; import { HeadObjectCommand } from '@aws-sdk/client-s3'; -import { FileStatus } from '@colanode/core'; +import { FileStatus, ApiErrorCode } from '@colanode/core'; -import { ApiError } from '@/types/api'; import { database } from '@/data/database'; import { BUCKET_NAMES, filesStorage } from '@/data/storage'; import { eventBus } from '@/lib/event-bus'; +import { ResponseBuilder } from '@/lib/response-builder'; export const fileUploadCompleteHandler = async ( req: Request, @@ -21,35 +21,37 @@ export const fileUploadCompleteHandler = async ( .executeTakeFirst(); if (!file) { - res.status(404).json({ - code: ApiError.ResourceNotFound, + return ResponseBuilder.badRequest(res, { + code: ApiErrorCode.FileNotFound, message: 'File not found.', }); - return; } if (file.created_by !== res.locals.user.id) { - res.status(403).json({ - code: ApiError.Forbidden, - message: 'Forbidden.', + return ResponseBuilder.forbidden(res, { + code: ApiErrorCode.FileOwnerMismatch, + message: 'You cannot complete this file upload.', }); - return; } if (file.workspace_id !== workspaceId) { - res.status(400).json({ - code: ApiError.BadRequest, + return ResponseBuilder.badRequest(res, { + code: ApiErrorCode.WorkspaceMismatch, message: 'File does not belong to this workspace.', }); - return; } - if (file.status !== FileStatus.Pending) { - res.status(400).json({ - code: ApiError.BadRequest, - message: 'File is not pending.', + if (file.status === FileStatus.Ready) { + return ResponseBuilder.success(res, { + success: true, + }); + } + + if (file.status === FileStatus.Error) { + return ResponseBuilder.badRequest(res, { + code: ApiErrorCode.FileError, + message: 'File has failed to upload.', }); - return; } const path = `files/${file.workspace_id}/${file.id}${file.extension}`; @@ -64,27 +66,24 @@ export const fileUploadCompleteHandler = async ( // Verify file size matches expected size if (headObject.ContentLength !== file.size) { - res.status(400).json({ - code: ApiError.BadRequest, + return ResponseBuilder.badRequest(res, { + code: ApiErrorCode.FileSizeMismatch, message: 'Uploaded file size does not match expected size', }); - return; } // Verify mime type matches expected type if (headObject.ContentType !== file.mime_type) { - res.status(400).json({ - code: ApiError.BadRequest, + return ResponseBuilder.badRequest(res, { + code: ApiErrorCode.FileMimeTypeMismatch, message: 'Uploaded file type does not match expected type', }); - return; } } catch { - res.status(400).json({ - code: ApiError.BadRequest, + return ResponseBuilder.badRequest(res, { + code: ApiErrorCode.FileError, message: 'File upload verification failed', }); - return; } const updatedFile = await database @@ -99,11 +98,10 @@ export const fileUploadCompleteHandler = async ( .executeTakeFirst(); if (!updatedFile) { - res.status(500).json({ - code: ApiError.InternalServerError, - message: 'Failed to update file status.', + return ResponseBuilder.internalError(res, { + code: ApiErrorCode.FileUploadCompleteFailed, + message: 'Failed to complete file upload.', }); - return; } eventBus.publish({ @@ -113,5 +111,7 @@ export const fileUploadCompleteHandler = async ( workspaceId: updatedFile.workspace_id, }); - res.status(200).json({ success: true }); + return ResponseBuilder.success(res, { + success: true, + }); }; diff --git a/apps/server/src/controllers/client/workspaces/files/file-upload-init.ts b/apps/server/src/controllers/client/workspaces/files/file-upload-init.ts index aa553693..ecbf15f4 100644 --- a/apps/server/src/controllers/client/workspaces/files/file-upload-init.ts +++ b/apps/server/src/controllers/client/workspaces/files/file-upload-init.ts @@ -1,11 +1,15 @@ import { Request, Response } from 'express'; -import { CreateUploadInput, CreateUploadOutput } from '@colanode/core'; +import { + CreateUploadInput, + CreateUploadOutput, + ApiErrorCode, +} from '@colanode/core'; import { PutObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; -import { ApiError } from '@/types/api'; import { database } from '@/data/database'; import { BUCKET_NAMES, filesStorage } from '@/data/storage'; +import { ResponseBuilder } from '@/lib/response-builder'; export const fileUploadInitHandler = async ( req: Request, @@ -21,19 +25,17 @@ export const fileUploadInitHandler = async ( .executeTakeFirst(); if (!file) { - res.status(404).json({ - code: ApiError.ResourceNotFound, + return ResponseBuilder.notFound(res, { + code: ApiErrorCode.FileNotFound, message: 'File not found.', }); - return; } if (file.created_by !== res.locals.user.id) { - res.status(403).json({ - code: ApiError.Forbidden, - message: 'Forbidden.', + return ResponseBuilder.forbidden(res, { + code: ApiErrorCode.FileOwnerMismatch, + message: 'You do not have access to this file.', }); - return; } //generate presigned url for upload @@ -54,5 +56,5 @@ export const fileUploadInitHandler = async ( url: presignedUrl, }; - res.status(200).json(output); + return ResponseBuilder.success(res, output); }; diff --git a/apps/server/src/controllers/client/workspaces/mutations/mutations-sync.ts b/apps/server/src/controllers/client/workspaces/mutations/mutations-sync.ts index 911e6dcc..12397491 100644 --- a/apps/server/src/controllers/client/workspaces/mutations/mutations-sync.ts +++ b/apps/server/src/controllers/client/workspaces/mutations/mutations-sync.ts @@ -24,6 +24,7 @@ import { SelectUser } from '@/data/schema'; import { entryService } from '@/services/entry-service'; import { fileService } from '@/services/file-service'; import { messageService } from '@/services/message-service'; +import { ResponseBuilder } from '@/lib/response-builder'; export const mutationsSyncHandler = async ( req: Request, @@ -48,8 +49,7 @@ export const mutationsSyncHandler = async ( } } - console.log('executed mutations', results); - res.status(200).json({ results }); + return ResponseBuilder.success(res, { results }); }; const handleMutation = async ( diff --git a/apps/server/src/controllers/client/workspaces/users/user-create.ts b/apps/server/src/controllers/client/workspaces/users/user-create.ts index 6924f2c2..3100f298 100644 --- a/apps/server/src/controllers/client/workspaces/users/user-create.ts +++ b/apps/server/src/controllers/client/workspaces/users/user-create.ts @@ -1,6 +1,7 @@ import { Request, Response } from 'express'; import { AccountStatus, + ApiErrorCode, generateId, IdType, UserInviteResult, @@ -9,10 +10,10 @@ import { } from '@colanode/core'; import { database } from '@/data/database'; -import { ApiError } from '@/types/api'; import { getNameFromEmail } from '@/lib/utils'; -import { SelectUser } from '@/data/schema'; +import { SelectAccount, SelectUser } from '@/data/schema'; import { eventBus } from '@/lib/event-bus'; +import { ResponseBuilder } from '@/lib/response-builder'; export const userCreateHandler = async ( req: Request, @@ -23,55 +24,22 @@ export const userCreateHandler = async ( const user: SelectUser = res.locals.user; if (!input.emails || input.emails.length === 0) { - res.status(400).json({ - code: ApiError.BadRequest, - message: 'BadRequest.', + return ResponseBuilder.badRequest(res, { + code: ApiErrorCode.UserEmailRequired, + message: 'User email is required.', }); - return; - } - - if (!res.locals.account) { - res.status(401).json({ - code: ApiError.Unauthorized, - message: 'Unauthorized.', - }); - return; } if (user.role !== 'owner' && user.role !== 'admin') { - res.status(403).json({ - code: ApiError.Forbidden, - message: 'Forbidden.', + return ResponseBuilder.forbidden(res, { + code: ApiErrorCode.UserInviteNoAccess, + message: 'You do not have access to invite users to this workspace.', }); - return; } const results: UserInviteResult[] = []; for (const email of input.emails) { - let account = await database - .selectFrom('accounts') - .select(['id', 'name', 'email', 'avatar']) - .where('email', '=', email) - .executeTakeFirst(); - - if (!account) { - account = await database - .insertInto('accounts') - .returning(['id', 'name', 'email', 'avatar']) - .values({ - id: generateId(IdType.Account), - name: getNameFromEmail(email), - email: email, - avatar: null, - attrs: null, - password: null, - status: AccountStatus.Pending, - created_at: new Date(), - updated_at: null, - }) - .executeTakeFirst(); - } - + const account = await getOrCreateAccount(email); if (!account) { results.push({ email: email, @@ -133,7 +101,39 @@ export const userCreateHandler = async ( }); } - res.status(200).json({ + return ResponseBuilder.success(res, { results: results, }); }; + +const getOrCreateAccount = async ( + email: string +): Promise => { + const account = await database + .selectFrom('accounts') + .selectAll() + .where('email', '=', email) + .executeTakeFirst(); + + if (account) { + return account; + } + + const createdAccount = await database + .insertInto('accounts') + .returningAll() + .values({ + id: generateId(IdType.Account), + name: getNameFromEmail(email), + email: email, + avatar: null, + attrs: null, + password: null, + status: AccountStatus.Pending, + created_at: new Date(), + updated_at: null, + }) + .executeTakeFirst(); + + return createdAccount; +}; diff --git a/apps/server/src/controllers/client/workspaces/users/user-role-update.ts b/apps/server/src/controllers/client/workspaces/users/user-role-update.ts index a08f997d..a78f99cb 100644 --- a/apps/server/src/controllers/client/workspaces/users/user-role-update.ts +++ b/apps/server/src/controllers/client/workspaces/users/user-role-update.ts @@ -1,10 +1,10 @@ import { Request, Response } from 'express'; -import { UserRoleUpdateInput } from '@colanode/core'; +import { UserRoleUpdateInput, ApiErrorCode } from '@colanode/core'; import { database } from '@/data/database'; -import { ApiError } from '@/types/api'; import { SelectUser } from '@/data/schema'; import { eventBus } from '@/lib/event-bus'; +import { ResponseBuilder } from '@/lib/response-builder'; export const userRoleUpdateHandler = async ( req: Request, @@ -15,11 +15,10 @@ export const userRoleUpdateHandler = async ( const user: SelectUser = res.locals.user; if (user.role !== 'owner' && user.role !== 'admin') { - res.status(403).json({ - code: ApiError.Forbidden, - message: 'Forbidden.', + return ResponseBuilder.forbidden(res, { + code: ApiErrorCode.UserUpdateNoAccess, + message: 'You do not have access to update users to this workspace.', }); - return; } const userToUpdate = await database @@ -29,11 +28,10 @@ export const userRoleUpdateHandler = async ( .executeTakeFirst(); if (!userToUpdate) { - res.status(404).json({ - code: ApiError.ResourceNotFound, - message: 'NotFound.', + return ResponseBuilder.notFound(res, { + code: ApiErrorCode.UserNotFound, + message: 'User not found.', }); - return; } await database @@ -53,7 +51,7 @@ export const userRoleUpdateHandler = async ( workspaceId: userToUpdate.workspace_id, }); - res.status(200).json({ + return ResponseBuilder.success(res, { success: true, }); }; diff --git a/apps/server/src/controllers/client/workspaces/workspace-create.ts b/apps/server/src/controllers/client/workspaces/workspace-create.ts index 8618dd7a..0aea5d52 100644 --- a/apps/server/src/controllers/client/workspaces/workspace-create.ts +++ b/apps/server/src/controllers/client/workspaces/workspace-create.ts @@ -1,9 +1,9 @@ -import { WorkspaceCreateInput } from '@colanode/core'; +import { WorkspaceCreateInput, ApiErrorCode } from '@colanode/core'; import { Request, Response } from 'express'; import { workspaceService } from '@/services/workspace-service'; -import { ApiError } from '@/types/api'; import { database } from '@/data/database'; +import { ResponseBuilder } from '@/lib/response-builder'; export const workspaceCreateHandler = async ( req: Request, @@ -12,11 +12,10 @@ export const workspaceCreateHandler = async ( const input: WorkspaceCreateInput = req.body; if (!input.name) { - res.status(400).json({ - code: ApiError.MissingRequiredFields, - message: 'Missing required fields.', + return ResponseBuilder.badRequest(res, { + code: ApiErrorCode.WorkspaceNameRequired, + message: 'Workspace name is required.', }); - return; } const account = await database @@ -26,13 +25,12 @@ export const workspaceCreateHandler = async ( .executeTakeFirst(); if (!account) { - res.status(404).json({ - code: ApiError.ResourceNotFound, + return ResponseBuilder.badRequest(res, { + code: ApiErrorCode.AccountNotFound, message: 'Account not found.', }); - return; } const output = await workspaceService.createWorkspace(account, input); - res.status(200).json(output); + return ResponseBuilder.success(res, output); }; diff --git a/apps/server/src/controllers/client/workspaces/workspace-delete.ts b/apps/server/src/controllers/client/workspaces/workspace-delete.ts index d790a2d0..5c87f6a3 100644 --- a/apps/server/src/controllers/client/workspaces/workspace-delete.ts +++ b/apps/server/src/controllers/client/workspaces/workspace-delete.ts @@ -1,8 +1,9 @@ import { Request, Response } from 'express'; +import { ApiErrorCode } from '@colanode/core'; import { database } from '@/data/database'; -import { ApiError } from '@/types/api'; import { eventBus } from '@/lib/event-bus'; +import { ResponseBuilder } from '@/lib/response-builder'; export const workspaceDeleteHandler = async ( req: Request, @@ -11,11 +12,11 @@ export const workspaceDeleteHandler = async ( const workspaceId = req.params.workspaceId as string; if (res.locals.user.role !== 'owner') { - res.status(403).json({ - code: ApiError.Forbidden, - message: 'Forbidden.', + return ResponseBuilder.forbidden(res, { + code: ApiErrorCode.WorkspaceDeleteNotAllowed, + message: + 'You are not allowed to delete this workspace. Only owners can delete workspaces.', }); - return; } await database @@ -28,7 +29,7 @@ export const workspaceDeleteHandler = async ( workspaceId: workspaceId, }); - res.status(200).json({ + return ResponseBuilder.success(res, { id: res.locals.workspace.id, }); }; diff --git a/apps/server/src/controllers/client/workspaces/workspace-get.ts b/apps/server/src/controllers/client/workspaces/workspace-get.ts index 9246c4b1..e871988b 100644 --- a/apps/server/src/controllers/client/workspaces/workspace-get.ts +++ b/apps/server/src/controllers/client/workspaces/workspace-get.ts @@ -1,8 +1,8 @@ -import { WorkspaceRole, WorkspaceOutput } from '@colanode/core'; +import { WorkspaceRole, WorkspaceOutput, ApiErrorCode } from '@colanode/core'; import { Request, Response } from 'express'; import { database } from '@/data/database'; -import { ApiError } from '@/types/api'; +import { ResponseBuilder } from '@/lib/response-builder'; export const workspaceGetHandler = async ( req: Request, @@ -17,11 +17,10 @@ export const workspaceGetHandler = async ( .executeTakeFirst(); if (!workspace) { - res.status(404).json({ - code: ApiError.ResourceNotFound, + return ResponseBuilder.badRequest(res, { + code: ApiErrorCode.WorkspaceNotFound, message: 'Workspace not found.', }); - return; } const user = await database @@ -32,11 +31,10 @@ export const workspaceGetHandler = async ( .executeTakeFirst(); if (!user) { - res.status(403).json({ - code: ApiError.Forbidden, - message: 'Forbidden.', + return ResponseBuilder.forbidden(res, { + code: ApiErrorCode.WorkspaceNoAccess, + message: 'You do not have access to this workspace.', }); - return; } const output: WorkspaceOutput = { @@ -44,7 +42,6 @@ export const workspaceGetHandler = async ( name: workspace.name, description: workspace.description, avatar: workspace.avatar, - versionId: workspace.version_id, user: { id: user.id, accountId: user.account_id, @@ -52,5 +49,5 @@ export const workspaceGetHandler = async ( }, }; - res.status(200).json(output); + return ResponseBuilder.success(res, output); }; diff --git a/apps/server/src/controllers/client/workspaces/workspace-update.ts b/apps/server/src/controllers/client/workspaces/workspace-update.ts index 60359f0c..3b67cf38 100644 --- a/apps/server/src/controllers/client/workspaces/workspace-update.ts +++ b/apps/server/src/controllers/client/workspaces/workspace-update.ts @@ -1,9 +1,13 @@ import { Request, Response } from 'express'; -import { WorkspaceOutput, WorkspaceUpdateInput } from '@colanode/core'; +import { + WorkspaceOutput, + WorkspaceUpdateInput, + ApiErrorCode, +} from '@colanode/core'; import { database } from '@/data/database'; -import { ApiError } from '@/types/api'; import { eventBus } from '@/lib/event-bus'; +import { ResponseBuilder } from '@/lib/response-builder'; export const workspaceUpdateHandler = async ( req: Request, @@ -13,11 +17,11 @@ export const workspaceUpdateHandler = async ( const input: WorkspaceUpdateInput = req.body; if (res.locals.user.role !== 'owner') { - res.status(403).json({ - code: ApiError.Forbidden, - message: 'Forbidden.', + return ResponseBuilder.forbidden(res, { + code: ApiErrorCode.WorkspaceUpdateNotAllowed, + message: + 'You are not allowed to update this workspace. Only owners can update workspaces.', }); - return; } const updatedWorkspace = await database @@ -34,11 +38,10 @@ export const workspaceUpdateHandler = async ( .executeTakeFirst(); if (!updatedWorkspace) { - res.status(500).json({ - code: ApiError.InternalServerError, - message: 'Internal server error.', + return ResponseBuilder.internalError(res, { + code: ApiErrorCode.WorkspaceUpdateFailed, + message: 'Failed to update workspace.', }); - return; } eventBus.publish({ @@ -51,7 +54,6 @@ export const workspaceUpdateHandler = async ( name: updatedWorkspace.name, description: updatedWorkspace.description, avatar: updatedWorkspace.avatar, - versionId: updatedWorkspace.version_id, user: { id: res.locals.user.id, accountId: res.locals.user.account_id, @@ -59,5 +61,5 @@ export const workspaceUpdateHandler = async ( }, }; - res.status(200).json(output); + return ResponseBuilder.success(res, output); }; diff --git a/apps/server/src/data/migrations.ts b/apps/server/src/data/migrations.ts index 974a5831..b017d949 100644 --- a/apps/server/src/data/migrations.ts +++ b/apps/server/src/data/migrations.ts @@ -40,8 +40,15 @@ const createDevicesTable: Migration = { .addColumn('last_online_at', 'timestamptz') .addColumn('last_active_at', 'timestamptz') .execute(); + + await db.schema + .createIndex('devices_account_id_idx') + .on('devices') + .column('account_id') + .execute(); }, down: async (db) => { + await db.schema.dropIndex('devices_account_id_idx').execute(); await db.schema.dropTable('devices').execute(); }, }; @@ -60,7 +67,6 @@ const createWorkspacesTable: Migration = { .addColumn('updated_at', 'timestamptz') .addColumn('updated_by', 'varchar(30)') .addColumn('status', 'integer', (col) => col.notNull()) - .addColumn('version_id', 'varchar(30)', (col) => col.notNull()) .execute(); }, down: async (db) => { @@ -104,6 +110,12 @@ const createUsersTable: Migration = { ]) .execute(); + await db.schema + .createIndex('users_workspace_id_version_idx') + .on('users') + .columns(['workspace_id', 'version']) + .execute(); + await sql` CREATE OR REPLACE FUNCTION update_user_version() RETURNS TRIGGER AS $$ BEGIN @@ -124,6 +136,7 @@ const createUsersTable: Migration = { DROP FUNCTION IF EXISTS update_user_version(); `.execute(db); + await db.schema.dropIndex('users_workspace_id_version_idx').execute(); await db.schema.dropTable('users').execute(); await sql`DROP SEQUENCE IF EXISTS users_version_sequence`.execute(db); }, @@ -158,10 +171,10 @@ const createEntriesTable: Migration = { }, }; -const createTransactionsTable: Migration = { +const createEntryTransactionsTable: Migration = { up: async (db) => { await sql` - CREATE SEQUENCE IF NOT EXISTS transactions_version_sequence + CREATE SEQUENCE IF NOT EXISTS entry_transactions_version_sequence START WITH 1000000000 INCREMENT BY 1 NO MINVALUE @@ -170,7 +183,7 @@ const createTransactionsTable: Migration = { `.execute(db); await db.schema - .createTable('transactions') + .createTable('entry_transactions') .addColumn('id', 'varchar(30)', (col) => col.notNull().primaryKey()) .addColumn('entry_id', 'varchar(30)', (col) => col.notNull()) .addColumn('root_id', 'varchar(30)', (col) => col.notNull()) @@ -181,13 +194,32 @@ const createTransactionsTable: Migration = { .addColumn('created_by', 'varchar(30)', (col) => col.notNull()) .addColumn('server_created_at', 'timestamptz', (col) => col.notNull()) .addColumn('version', 'bigint', (col) => - col.notNull().defaultTo(sql`nextval('transactions_version_sequence')`) + col + .notNull() + .defaultTo(sql`nextval('entry_transactions_version_sequence')`) ) .execute(); + + await db.schema + .createIndex('entry_transactions_entry_id_idx') + .on('entry_transactions') + .column('entry_id') + .execute(); + + await db.schema + .createIndex('entry_transactions_root_id_version_idx') + .on('entry_transactions') + .columns(['root_id', 'version']) + .execute(); }, down: async (db) => { - await db.schema.dropTable('transactions').execute(); - await sql`DROP SEQUENCE IF EXISTS transactions_version_sequence`.execute( + await db.schema.dropIndex('entry_transactions_entry_id_idx').execute(); + await db.schema + .dropIndex('entry_transactions_root_id_version_idx') + .execute(); + + await db.schema.dropTable('entry_transactions').execute(); + await sql`DROP SEQUENCE IF EXISTS entry_transactions_version_sequence`.execute( db ); }, @@ -225,6 +257,12 @@ const createCollaborationsTable: Migration = { ]) .execute(); + await db.schema + .createIndex('collaborations_collaborator_version_idx') + .on('collaborations') + .columns(['collaborator_id', 'version']) + .execute(); + await sql` CREATE OR REPLACE FUNCTION update_collaboration_version() RETURNS TRIGGER AS $$ BEGIN @@ -245,6 +283,10 @@ const createCollaborationsTable: Migration = { DROP FUNCTION IF EXISTS update_collaboration_version(); `.execute(db); + await db.schema + .dropIndex('collaborations_collaborator_version_idx') + .execute(); + await db.schema.dropTable('collaborations').execute(); await sql`DROP SEQUENCE IF EXISTS collaborations_version_sequence`.execute( db @@ -297,6 +339,12 @@ const createEntryInteractionsTable: Migration = { FOR EACH ROW EXECUTE FUNCTION update_entry_interaction_version(); `.execute(db); + + await db.schema + .createIndex('entry_interactions_root_id_version_idx') + .on('entry_interactions') + .columns(['root_id', 'version']) + .execute(); }, down: async (db) => { await sql` @@ -304,6 +352,10 @@ const createEntryInteractionsTable: Migration = { DROP FUNCTION IF EXISTS update_entry_interaction_version(); `.execute(db); + await db.schema + .dropIndex('entry_interactions_root_id_version_idx') + .execute(); + await db.schema.dropTable('entry_interactions').execute(); await sql`DROP SEQUENCE IF EXISTS entry_interactions_version_sequence`.execute( db @@ -335,13 +387,17 @@ const createMessagesTable: Migration = { .addColumn('created_by', 'varchar(30)', (col) => col.notNull()) .addColumn('updated_at', 'timestamptz') .addColumn('updated_by', 'varchar(30)') - .addColumn('deleted_at', 'timestamptz') - .addColumn('deleted_by', 'varchar(30)') .addColumn('version', 'bigint', (col) => col.notNull().defaultTo(sql`nextval('messages_version_sequence')`) ) .execute(); + await db.schema + .createIndex('messages_root_id_version_idx') + .on('messages') + .columns(['root_id', 'version']) + .execute(); + await sql` CREATE OR REPLACE FUNCTION update_message_version() RETURNS TRIGGER AS $$ BEGIN @@ -362,6 +418,7 @@ const createMessagesTable: Migration = { DROP FUNCTION IF EXISTS update_message_version(); `.execute(db); + await db.schema.dropIndex('messages_root_id_version_idx').execute(); await db.schema.dropTable('messages').execute(); await sql`DROP SEQUENCE IF EXISTS messages_version_sequence`.execute(db); }, @@ -399,6 +456,12 @@ const createMessageReactionsTable: Migration = { ]) .execute(); + await db.schema + .createIndex('message_reactions_root_id_version_idx') + .on('message_reactions') + .columns(['root_id', 'version']) + .execute(); + await sql` CREATE OR REPLACE FUNCTION update_message_reaction_version() RETURNS TRIGGER AS $$ BEGIN @@ -414,6 +477,10 @@ const createMessageReactionsTable: Migration = { DROP FUNCTION IF EXISTS update_message_reaction_version(); `.execute(db); + await db.schema + .dropIndex('message_reactions_root_id_version_idx') + .execute(); + await db.schema.dropTable('message_reactions').execute(); await sql`DROP SEQUENCE IF EXISTS message_reactions_version_sequence`.execute( db @@ -452,6 +519,12 @@ const createMessageInteractionsTable: Migration = { ]) .execute(); + await db.schema + .createIndex('message_interactions_root_id_version_idx') + .on('message_interactions') + .columns(['root_id', 'version']) + .execute(); + await sql` CREATE OR REPLACE FUNCTION update_message_interaction_version() RETURNS TRIGGER AS $$ BEGIN @@ -472,6 +545,10 @@ const createMessageInteractionsTable: Migration = { DROP FUNCTION IF EXISTS update_message_interaction_version(); `.execute(db); + await db.schema + .dropIndex('message_interactions_root_id_version_idx') + .execute(); + await db.schema.dropTable('message_interactions').execute(); await sql`DROP SEQUENCE IF EXISTS message_interactions_version_sequence`.execute( db @@ -479,6 +556,49 @@ const createMessageInteractionsTable: Migration = { }, }; +const createMessageTombstonesTable: Migration = { + up: async (db) => { + await sql` + CREATE SEQUENCE IF NOT EXISTS message_tombstones_version_sequence + START WITH 1000000000 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + `.execute(db); + + await db.schema + .createTable('message_tombstones') + .addColumn('id', 'varchar(30)', (col) => col.notNull().primaryKey()) + .addColumn('root_id', 'varchar(30)', (col) => col.notNull()) + .addColumn('workspace_id', 'varchar(30)', (col) => col.notNull()) + .addColumn('deleted_at', 'timestamptz', (col) => col.notNull()) + .addColumn('deleted_by', 'varchar(30)', (col) => col.notNull()) + .addColumn('version', 'bigint', (col) => + col + .notNull() + .defaultTo(sql`nextval('message_tombstones_version_sequence')`) + ) + .execute(); + + await db.schema + .createIndex('message_tombstones_root_id_version_idx') + .on('message_tombstones') + .columns(['root_id', 'version']) + .execute(); + }, + down: async (db) => { + await db.schema + .dropIndex('message_tombstones_root_id_version_idx') + .execute(); + + await db.schema.dropTable('message_tombstones').execute(); + await sql`DROP SEQUENCE IF EXISTS message_tombstones_version_sequence`.execute( + db + ); + }, +}; + const createFilesTable: Migration = { up: async (db) => { await sql` @@ -507,14 +627,18 @@ const createFilesTable: Migration = { .addColumn('created_by', 'varchar(30)', (col) => col.notNull()) .addColumn('updated_at', 'timestamptz') .addColumn('updated_by', 'varchar(30)') - .addColumn('deleted_at', 'timestamptz') - .addColumn('deleted_by', 'varchar(30)') .addColumn('status', 'integer', (col) => col.notNull()) .addColumn('version', 'bigint', (col) => col.notNull().defaultTo(sql`nextval('files_version_sequence')`) ) .execute(); + await db.schema + .createIndex('files_root_id_version_idx') + .on('files') + .columns(['root_id', 'version']) + .execute(); + await sql` CREATE OR REPLACE FUNCTION update_file_version() RETURNS TRIGGER AS $$ BEGIN @@ -535,6 +659,7 @@ const createFilesTable: Migration = { DROP FUNCTION IF EXISTS update_file_version(); `.execute(db); + await db.schema.dropIndex('files_root_id_version_idx').execute(); await db.schema.dropTable('files').execute(); await sql`DROP SEQUENCE IF EXISTS files_version_sequence`.execute(db); }, @@ -572,6 +697,12 @@ const createFileInteractionsTable: Migration = { ]) .execute(); + await db.schema + .createIndex('file_interactions_root_id_version_idx') + .on('file_interactions') + .columns(['root_id', 'version']) + .execute(); + await sql` CREATE OR REPLACE FUNCTION update_file_interaction_version() RETURNS TRIGGER AS $$ BEGIN @@ -592,6 +723,10 @@ const createFileInteractionsTable: Migration = { DROP FUNCTION IF EXISTS update_file_interaction_version(); `.execute(db); + await db.schema + .dropIndex('file_interactions_root_id_version_idx') + .execute(); + await db.schema.dropTable('file_interactions').execute(); await sql`DROP SEQUENCE IF EXISTS file_interactions_version_sequence`.execute( db @@ -599,6 +734,46 @@ const createFileInteractionsTable: Migration = { }, }; +const createFileTombstonesTable: Migration = { + up: async (db) => { + await sql` + CREATE SEQUENCE IF NOT EXISTS file_tombstones_version_sequence + START WITH 1000000000 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + `.execute(db); + + await db.schema + .createTable('file_tombstones') + .addColumn('id', 'varchar(30)', (col) => col.notNull().primaryKey()) + .addColumn('root_id', 'varchar(30)', (col) => col.notNull()) + .addColumn('workspace_id', 'varchar(30)', (col) => col.notNull()) + .addColumn('deleted_at', 'timestamptz', (col) => col.notNull()) + .addColumn('deleted_by', 'varchar(30)', (col) => col.notNull()) + .addColumn('version', 'bigint', (col) => + col + .notNull() + .defaultTo(sql`nextval('file_tombstones_version_sequence')`) + ) + .execute(); + + await db.schema + .createIndex('file_tombstones_root_id_version_idx') + .on('file_tombstones') + .columns(['root_id', 'version']) + .execute(); + }, + down: async (db) => { + await db.schema.dropIndex('file_tombstones_root_id_version_idx').execute(); + await db.schema.dropTable('file_tombstones').execute(); + await sql`DROP SEQUENCE IF EXISTS file_tombstones_version_sequence`.execute( + db + ); + }, +}; + const createEntryPathsTable: Migration = { up: async (db) => { await db.schema @@ -715,14 +890,16 @@ export const databaseMigrations: Record = { '00003_create_workspaces_table': createWorkspacesTable, '00004_create_users_table': createUsersTable, '00005_create_entries_table': createEntriesTable, - '00006_create_transactions_table': createTransactionsTable, + '00006_create_entry_transactions_table': createEntryTransactionsTable, '00007_create_entry_interactions_table': createEntryInteractionsTable, - '00008_create_messages_table': createMessagesTable, - '00009_create_message_reactions_table': createMessageReactionsTable, - '00010_create_message_interactions_table': createMessageInteractionsTable, - '00011_create_files_table': createFilesTable, - '00012_create_file_interactions_table': createFileInteractionsTable, - '00013_create_collaborations_table': createCollaborationsTable, - '00014_create_entry_paths_table': createEntryPathsTable, - '00015_create_ai_embeddings_table': createAIEmbeddingsTable, + '00008_create_entry_paths_table': createEntryPathsTable, + '00009_create_messages_table': createMessagesTable, + '00010_create_message_reactions_table': createMessageReactionsTable, + '00011_create_message_interactions_table': createMessageInteractionsTable, + '00012_create_message_tombstones_table': createMessageTombstonesTable, + '00013_create_files_table': createFilesTable, + '00014_create_file_interactions_table': createFileInteractionsTable, + '00015_create_file_tombstones_table': createFileTombstonesTable, + '00016_create_collaborations_table': createCollaborationsTable, + '00017_create_ai_embeddings_table': createAIEmbeddingsTable, }; diff --git a/apps/server/src/data/schema.ts b/apps/server/src/data/schema.ts index 791ebb5b..83fcb717 100644 --- a/apps/server/src/data/schema.ts +++ b/apps/server/src/data/schema.ts @@ -64,7 +64,6 @@ interface WorkspaceTable { created_by: ColumnType; updated_by: ColumnType; status: ColumnType; - version_id: ColumnType; } export type SelectWorkspace = Selectable; @@ -129,7 +128,7 @@ export type SelectCollaboration = Selectable; export type CreateCollaboration = Insertable; export type UpdateCollaboration = Updateable; -interface TransactionTable { +interface EntryTransactionTable { id: ColumnType; entry_id: ColumnType; root_id: ColumnType; @@ -142,9 +141,9 @@ interface TransactionTable { version: ColumnType; } -export type SelectTransaction = Selectable; -export type CreateTransaction = Insertable; -export type UpdateTransaction = Updateable; +export type SelectEntryTransaction = Selectable; +export type CreateEntryTransaction = Insertable; +export type UpdateEntryTransaction = Updateable; interface EntryInteractionTable { entry_id: ColumnType; @@ -162,6 +161,17 @@ export type SelectEntryInteraction = Selectable; export type CreateEntryInteraction = Insertable; export type UpdateEntryInteraction = Updateable; +interface EntryPathTable { + ancestor_id: ColumnType; + descendant_id: ColumnType; + workspace_id: ColumnType; + level: ColumnType; +} + +export type SelectEntryPath = Selectable; +export type CreateEntryPath = Insertable; +export type UpdateEntryPath = Updateable; + interface MessageTable { id: ColumnType; type: ColumnType; @@ -174,8 +184,6 @@ interface MessageTable { created_by: ColumnType; updated_at: ColumnType; updated_by: ColumnType; - deleted_at: ColumnType; - deleted_by: ColumnType; version: ColumnType; } @@ -213,6 +221,19 @@ export type SelectMessageInteraction = Selectable; export type CreateMessageInteraction = Insertable; export type UpdateMessageInteraction = Updateable; +interface MessageTombstoneTable { + id: ColumnType; + root_id: ColumnType; + workspace_id: ColumnType; + deleted_at: ColumnType; + deleted_by: ColumnType; + version: ColumnType; +} + +export type SelectMessageTombstone = Selectable; +export type CreateMessageTombstone = Insertable; +export type UpdateMessageTombstone = Updateable; + interface FileTable { id: ColumnType; type: ColumnType; @@ -229,8 +250,6 @@ interface FileTable { created_by: ColumnType; updated_at: ColumnType; updated_by: ColumnType; - deleted_at: ColumnType; - deleted_by: ColumnType; status: ColumnType; version: ColumnType; } @@ -255,16 +274,18 @@ export type SelectFileInteraction = Selectable; export type CreateFileInteraction = Insertable; export type UpdateFileInteraction = Updateable; -interface EntryPathTable { - ancestor_id: ColumnType; - descendant_id: ColumnType; +interface FileTombstoneTable { + id: ColumnType; + root_id: ColumnType; workspace_id: ColumnType; - level: ColumnType; + deleted_at: ColumnType; + deleted_by: ColumnType; + version: ColumnType; } -export type SelectEntryPath = Selectable; -export type CreateEntryPath = Insertable; -export type UpdateEntryPath = Updateable; +export type SelectFileTombstone = Selectable; +export type CreateFileTombstone = Insertable; +export type UpdateFileTombstone = Updateable; interface AIEmbeddingsTable { id: ColumnType; @@ -278,7 +299,6 @@ interface AIEmbeddingsTable { updated_at: ColumnType; } - export type SelectAIEmbedding = Selectable; export type CreateAIEmbedding = Insertable; export type UpdateAIEmbedding = Updateable; @@ -290,14 +310,14 @@ export interface DatabaseSchema { workspaces: WorkspaceTable; users: UserTable; entries: EntryTable; - transactions: TransactionTable; + entry_transactions: EntryTransactionTable; entry_interactions: EntryInteractionTable; - collaborations: CollaborationTable; + entry_paths: EntryPathTable; messages: MessageTable; message_reactions: MessageReactionTable; message_interactions: MessageInteractionTable; + message_tombstones: MessageTombstoneTable; files: FileTable; file_interactions: FileInteractionTable; entry_paths: EntryPathTable; - ai_embeddings: AIEmbeddingsTable } diff --git a/apps/server/src/jobs/clean-entry-data.ts b/apps/server/src/jobs/clean-entry-data.ts index ef91dde2..deb9f318 100644 --- a/apps/server/src/jobs/clean-entry-data.ts +++ b/apps/server/src/jobs/clean-entry-data.ts @@ -1,12 +1,16 @@ +import { DeleteObjectCommand } from '@aws-sdk/client-s3'; import { generateId, IdType } from '@colanode/core'; -// import { DeleteObjectCommand } from '@aws-sdk/client-s3'; import { database } from '@/data/database'; -import { CreateTransaction } from '@/data/schema'; +import { + CreateEntryTransaction, + CreateFileTombstone, + CreateMessageTombstone, +} from '@/data/schema'; import { JobHandler } from '@/types/jobs'; -// import { filesStorage, BUCKET_NAMES } from '@/data/storage'; import { eventBus } from '@/lib/event-bus'; import { createLogger } from '@/lib/logger'; +import { BUCKET_NAMES, filesStorage } from '@/data/storage'; const BATCH_SIZE = 100; @@ -32,7 +36,7 @@ export const cleanEntryDataHandler: JobHandler = async ( logger.trace(`Cleaning entry data for ${input.entryId}`); const deleteTransactions = await database - .selectFrom('transactions') + .selectFrom('entry_transactions') .selectAll() .where('entry_id', '=', input.entryId) .execute(); @@ -51,24 +55,21 @@ export const cleanEntryDataHandler: JobHandler = async ( return; } + const userId = deleteTransaction.created_by; + const parentIds = [input.entryId]; while (parentIds.length > 0) { const tempParentIds = parentIds.splice(0, BATCH_SIZE); - const deletedEntryIds = await deleteChildren( - tempParentIds, - input.workspaceId, - deleteTransaction.created_by - ); + const deletedEntryIds = await deleteChildren(tempParentIds, userId); + + await deleteMessages(tempParentIds, userId); + await deleteFiles(tempParentIds, userId); parentIds.push(...deletedEntryIds); } }; -const deleteChildren = async ( - parentIds: string[], - workspaceId: string, - userId: string -) => { +const deleteChildren = async (parentIds: string[], userId: string) => { const deletedEntryIds: string[] = []; let hasMore = true; while (hasMore) { @@ -87,42 +88,28 @@ const deleteChildren = async ( break; } - // const fileIds: string[] = descendants - // .filter((d) => d.type === 'file') - // .map((d) => d.id); - - // const uploads: SelectUpload[] = - // fileIds.length > 0 - // ? await database - // .selectFrom('uploads') - // .selectAll() - // .where('node_id', 'in', fileIds) - // .execute() - // : []; - const entryIds: string[] = descendants.map((d) => d.id); - const transactionsToCreate: CreateTransaction[] = descendants.map( + const transactionsToCreate: CreateEntryTransaction[] = descendants.map( (descendant) => ({ id: generateId(IdType.Transaction), entry_id: descendant.id, root_id: descendant.root_id, - workspace_id: workspaceId, + workspace_id: descendant.workspace_id, operation: 'delete', created_at: new Date(), created_by: userId, server_created_at: new Date(), }) ); - // const uploadsToDelete: string[] = uploads.map((u) => u.node_id); await database.transaction().execute(async (trx) => { await trx - .deleteFrom('transactions') + .deleteFrom('entry_transactions') .where('entry_id', 'in', entryIds) .execute(); const createdTransactions = await trx - .insertInto('transactions') + .insertInto('entry_transactions') .returningAll() .values(transactionsToCreate) .execute(); @@ -131,13 +118,6 @@ const deleteChildren = async ( throw new Error('Failed to create transactions'); } - // if (uploadsToDelete.length > 0) { - // await trx - // .deleteFrom('uploads') - // .where('node_id', 'in', uploadsToDelete) - // .execute(); - // } - await trx.deleteFrom('entries').where('id', 'in', entryIds).execute(); await trx .updateTable('collaborations') @@ -149,26 +129,13 @@ const deleteChildren = async ( .execute(); }); - // for (const upload of uploads) { - // const command = new DeleteObjectCommand({ - // Bucket: BUCKET_NAMES.FILES, - // Key: upload.path, - // }); - - // logger.trace( - // `Deleting file as a descendant of ${parentIds}: ${upload.path}` - // ); - - // await filesStorage.send(command); - // } - for (const entry of descendants) { eventBus.publish({ type: 'entry_deleted', entryId: entry.id, entryType: entry.type, rootId: entry.root_id, - workspaceId: workspaceId, + workspaceId: entry.workspace_id, }); deletedEntryIds.push(entry.id); @@ -183,3 +150,137 @@ const deleteChildren = async ( return deletedEntryIds; }; + +const deleteMessages = async (entryIds: string[], userId: string) => { + let hasMore = true; + while (hasMore) { + try { + const messages = await database + .selectFrom('messages') + .selectAll() + .where('entry_id', 'in', entryIds) + .orderBy('id', 'asc') + .limit(BATCH_SIZE) + .execute(); + + if (messages.length === 0) { + hasMore = false; + break; + } + + const messageIds: string[] = messages.map((m) => m.id); + const messageTombstonesToCreate: CreateMessageTombstone[] = messages.map( + (message) => ({ + id: message.id, + root_id: message.root_id, + workspace_id: message.workspace_id, + deleted_at: new Date(), + deleted_by: userId, + }) + ); + + await database.transaction().execute(async (trx) => { + await trx + .deleteFrom('messages') + .where('id', 'in', messageIds) + .execute(); + + await trx + .deleteFrom('message_reactions') + .where('message_id', 'in', messageIds) + .execute(); + + await trx + .deleteFrom('message_interactions') + .where('message_id', 'in', messageIds) + .execute(); + + await trx + .insertInto('message_tombstones') + .values(messageTombstonesToCreate) + .execute(); + }); + + for (const message of messages) { + eventBus.publish({ + type: 'message_deleted', + messageId: message.id, + rootId: message.root_id, + workspaceId: message.workspace_id, + }); + } + + hasMore = messages.length === BATCH_SIZE; + } catch (error) { + logger.error(`Error deleting messages for ${entryIds}: ${error}`); + hasMore = false; + } + } +}; + +const deleteFiles = async (entryIds: string[], userId: string) => { + let hasMore = true; + while (hasMore) { + try { + const files = await database + .selectFrom('files') + .selectAll() + .where('entry_id', 'in', entryIds) + .orderBy('id', 'asc') + .limit(BATCH_SIZE) + .execute(); + + if (files.length === 0) { + hasMore = false; + break; + } + + const fileIds: string[] = files.map((m) => m.id); + const fileTombstonesToCreate: CreateFileTombstone[] = files.map( + (file) => ({ + id: file.id, + root_id: file.root_id, + workspace_id: file.workspace_id, + deleted_at: new Date(), + deleted_by: userId, + }) + ); + + await database.transaction().execute(async (trx) => { + await trx.deleteFrom('files').where('id', 'in', fileIds).execute(); + + await trx + .deleteFrom('file_interactions') + .where('file_id', 'in', fileIds) + .execute(); + + await trx + .insertInto('file_tombstones') + .values(fileTombstonesToCreate) + .execute(); + }); + + for (const file of files) { + eventBus.publish({ + type: 'file_deleted', + fileId: file.id, + rootId: file.root_id, + workspaceId: file.workspace_id, + }); + + const path = `files/${file.workspace_id}/${file.id}${file.extension}`; + const command = new DeleteObjectCommand({ + Bucket: BUCKET_NAMES.FILES, + Key: path, + }); + + await filesStorage.send(command); + } + + hasMore = files.length === BATCH_SIZE; + } catch (error) { + logger.error(`Error deleting files for ${entryIds}: ${error}`); + hasMore = false; + } + } +}; diff --git a/apps/server/src/lib/entries.ts b/apps/server/src/lib/entries.ts index 156fdede..cbaa5954 100644 --- a/apps/server/src/lib/entries.ts +++ b/apps/server/src/lib/entries.ts @@ -1,15 +1,7 @@ -import { - extractEntryCollaborators, - extractEntryRole, - Entry, - EntryOutput, - EntryRole, - EntryType, -} from '@colanode/core'; +import { Entry, EntryOutput, EntryType } from '@colanode/core'; import { database } from '@/data/database'; import { SelectEntry } from '@/data/schema'; -import { EntryCollaborator } from '@/types/entries'; export const mapEntryOutput = (entry: SelectEntry): EntryOutput => { return { @@ -80,37 +72,3 @@ export const fetchEntryDescendants = async ( return result.map((row) => row.descendant_id); }; - -export const fetchEntryCollaborators = async ( - entryId: string -): Promise => { - const ancestors = await fetchEntryAncestors(entryId); - const collaboratorsMap = new Map(); - - for (const ancestor of ancestors) { - const collaborators = extractEntryCollaborators(ancestor.attributes); - for (const [collaboratorId, role] of Object.entries(collaborators)) { - collaboratorsMap.set(collaboratorId, role); - } - } - - return Array.from(collaboratorsMap.entries()).map( - ([collaboratorId, role]) => ({ - entryId: entryId, - collaboratorId: collaboratorId, - role: role, - }) - ); -}; - -export const fetchEntryRole = async ( - entryId: string, - collaboratorId: string -): Promise => { - const ancestors = await fetchEntryAncestors(entryId); - if (ancestors.length === 0) { - return null; - } - - return extractEntryRole(ancestors.map(mapEntry), collaboratorId); -}; diff --git a/apps/server/src/lib/response-builder.ts b/apps/server/src/lib/response-builder.ts new file mode 100644 index 00000000..1a881d05 --- /dev/null +++ b/apps/server/src/lib/response-builder.ts @@ -0,0 +1,32 @@ +import { Response } from 'express'; +import { ApiErrorOutput } from '@colanode/core'; + +export class ResponseBuilder { + static error(res: Response, status: number, error: ApiErrorOutput): void { + res.status(status).json(error); + } + + static notFound(res: Response, error: ApiErrorOutput): void { + ResponseBuilder.error(res, 404, error); + } + + static badRequest(res: Response, error: ApiErrorOutput): void { + ResponseBuilder.error(res, 400, error); + } + + static unauthorized(res: Response, error: ApiErrorOutput): void { + ResponseBuilder.error(res, 401, error); + } + + static forbidden(res: Response, error: ApiErrorOutput): void { + ResponseBuilder.error(res, 403, error); + } + + static internalError(res: Response, error: ApiErrorOutput): void { + ResponseBuilder.error(res, 500, error); + } + + static success(res: Response, data: T): void { + res.status(200).json(data); + } +} diff --git a/apps/server/src/middlewares/auth.ts b/apps/server/src/middlewares/auth.ts index ceca4395..afda7897 100644 --- a/apps/server/src/middlewares/auth.ts +++ b/apps/server/src/middlewares/auth.ts @@ -1,7 +1,8 @@ import { NextFunction, Request, Response, RequestHandler } from 'express'; +import { ApiErrorCode } from '@colanode/core'; import { verifyToken } from '@/lib/tokens'; -import { ApiError } from '@/types/api'; +import { ResponseBuilder } from '@/lib/response-builder'; export const authMiddleware: RequestHandler = async ( req: Request, @@ -11,20 +12,18 @@ export const authMiddleware: RequestHandler = async ( const token = req.headers.authorization?.split(' ')[1]; if (!token) { - res.status(401).json({ - code: ApiError.Unauthorized, - message: 'Access Denied: No Token Provided!', + return ResponseBuilder.unauthorized(res, { + code: ApiErrorCode.TokenMissing, + message: 'No token provided', }); - return; } const result = await verifyToken(token); if (!result.authenticated) { - res.status(400).json({ - code: ApiError.Unauthorized, - message: 'Invalid Token', + return ResponseBuilder.unauthorized(res, { + code: ApiErrorCode.TokenInvalid, + message: 'Token is invalid or expired', }); - return; } res.locals.account = result.account; diff --git a/apps/server/src/middlewares/workspace.ts b/apps/server/src/middlewares/workspace.ts index a44e5e11..b31bde50 100644 --- a/apps/server/src/middlewares/workspace.ts +++ b/apps/server/src/middlewares/workspace.ts @@ -1,7 +1,8 @@ import { NextFunction, Request, RequestHandler, Response } from 'express'; +import { ApiErrorCode } from '@colanode/core'; import { database } from '@/data/database'; -import { ApiError } from '@/types/api'; +import { ResponseBuilder } from '@/lib/response-builder'; export const workspaceMiddleware: RequestHandler = async ( req: Request, @@ -18,11 +19,10 @@ export const workspaceMiddleware: RequestHandler = async ( .executeTakeFirst(); if (!user) { - res.status(403).json({ - code: ApiError.Forbidden, - message: 'Forbidden.', + return ResponseBuilder.forbidden(res, { + code: ApiErrorCode.WorkspaceNoAccess, + message: 'You do not have access to this workspace.', }); - return; } res.locals.user = user; diff --git a/apps/server/src/services/account-service.ts b/apps/server/src/services/account-service.ts index 684a3991..dbc0dccd 100644 --- a/apps/server/src/services/account-service.ts +++ b/apps/server/src/services/account-service.ts @@ -38,7 +38,6 @@ class AccountService { workspaceOutputs.push({ id: workspace.id, name: workspace.name, - versionId: workspace.version_id, avatar: workspace.avatar, description: workspace.description, user: { diff --git a/apps/server/src/services/entry-service.ts b/apps/server/src/services/entry-service.ts index 91aa3c70..924961a0 100644 --- a/apps/server/src/services/entry-service.ts +++ b/apps/server/src/services/entry-service.ts @@ -3,13 +3,15 @@ import { generateId, IdType, EntryAttributes, - EntryMutationContext, EntryRole, - registry, MarkEntrySeenMutation, MarkEntryOpenedMutation, extractEntryRole, - hasViewerAccess, + hasEntryRole, + entryAttributesSchema, + canCreateEntry, + canUpdateEntry, + canDeleteEntry, } from '@colanode/core'; import { decodeState, YDoc } from '@colanode/crdt'; import { Transaction } from 'kysely'; @@ -20,13 +22,13 @@ import { database } from '@/data/database'; import { CreateEntry, CreateCollaboration, - CreateTransaction, + CreateEntryTransaction, DatabaseSchema, SelectUser, SelectCollaboration, } from '@/data/schema'; import { eventBus } from '@/lib/event-bus'; -import { fetchEntryAncestors, mapEntry } from '@/lib/entries'; +import { fetchEntry, mapEntry } from '@/lib/entries'; import { createLogger } from '@/lib/logger'; import { ApplyCreateTransactionInput, @@ -60,9 +62,11 @@ class EntryService { public async createEntry( input: CreateEntryInput ): Promise { - const model = registry.getModel(input.attributes.type); const ydoc = new YDoc(); - const update = ydoc.updateAttributes(model.schema, input.attributes); + const update = ydoc.updateAttributes( + entryAttributesSchema, + input.attributes + ); const attributes = ydoc.getAttributes(); const attributesJson = JSON.stringify(attributes); @@ -79,7 +83,7 @@ class EntryService { transaction_id: transactionId, }; - const createTransaction: CreateTransaction = { + const createTransaction: CreateEntryTransaction = { id: transactionId, root_id: input.rootId, entry_id: input.entryId, @@ -152,16 +156,14 @@ class EntryService { private async tryUpdateEntry( input: UpdateEntryInput ): Promise> { - const ancestorRows = await fetchEntryAncestors(input.entryId); - const ancestors = ancestorRows.map(mapEntry); - - const entry = ancestors.find((ancestor) => ancestor.id === input.entryId); - if (!entry) { + const entryRow = await fetchEntry(input.entryId); + if (!entryRow) { return { type: 'error', output: null }; } + const entry = mapEntry(entryRow); const previousTransactions = await database - .selectFrom('transactions') + .selectFrom('entry_transactions') .selectAll() .where('entry_id', '=', input.entryId) .orderBy('id', 'asc') @@ -182,8 +184,10 @@ class EntryService { return { type: 'error', output: null }; } - const model = registry.getModel(updatedAttributes.type); - const update = ydoc.updateAttributes(model.schema, updatedAttributes); + const update = ydoc.updateAttributes( + entryAttributesSchema, + updatedAttributes + ); const attributes = ydoc.getAttributes(); const attributesJson = JSON.stringify(attributes); @@ -221,7 +225,7 @@ class EntryService { } const createdTransaction = await trx - .insertInto('transactions') + .insertInto('entry_transactions') .returningAll() .values({ id: transactionId, @@ -244,7 +248,6 @@ class EntryService { await this.applyCollaboratorUpdates( trx, input.entryId, - entry.rootId, input.userId, input.workspaceId, collaboratorChanges @@ -305,29 +308,23 @@ class EntryService { user: SelectUser, input: ApplyCreateTransactionInput ): Promise { + const root = await fetchEntry(input.rootId); const ydoc = new YDoc(); ydoc.applyUpdate(input.data); const attributes = ydoc.getAttributes(); - const ancestorRows = attributes.parentId - ? await fetchEntryAncestors(attributes.parentId) - : []; - - const ancestors = ancestorRows.map(mapEntry); - const context = new EntryMutationContext( - user.account_id, - user.workspace_id, - user.id, - user.role, - ancestors - ); - - const model = registry.getModel(attributes.type); - if (!model.schema.safeParse(attributes).success) { - return null; - } - - if (!model.canCreate(context, attributes)) { + if ( + !canCreateEntry( + { + user: { + userId: user.id, + role: user.role, + }, + root: root ? mapEntry(root) : null, + }, + attributes + ) + ) { return null; } @@ -335,22 +332,22 @@ class EntryService { id: input.entryId, root_id: input.rootId, attributes: JSON.stringify(attributes), - workspace_id: context.workspaceId, + workspace_id: user.workspace_id, created_at: input.createdAt, - created_by: context.userId, + created_by: user.id, transaction_id: input.id, }; - const createTransaction: CreateTransaction = { + const createTransaction: CreateEntryTransaction = { id: input.id, entry_id: input.entryId, root_id: input.rootId, - workspace_id: context.workspaceId, + workspace_id: user.workspace_id, operation: 'create', data: typeof input.data === 'string' ? decodeState(input.data) : input.data, created_at: input.createdAt, - created_by: context.userId, + created_by: user.id, server_created_at: new Date(), }; @@ -367,7 +364,7 @@ class EntryService { entryId: input.entryId, entryType: attributes.type, rootId: input.rootId, - workspaceId: context.workspaceId, + workspaceId: user.workspace_id, }); for (const createdCollaboration of createdCollaborations) { @@ -375,7 +372,7 @@ class EntryService { type: 'collaboration_created', collaboratorId: createdCollaboration.collaborator_id, entryId: input.entryId, - workspaceId: context.workspaceId, + workspaceId: user.workspace_id, }); } @@ -412,16 +409,18 @@ class EntryService { user: SelectUser, input: ApplyUpdateTransactionInput ): Promise> { - const ancestorRows = await fetchEntryAncestors(input.entryId); - const ancestors = ancestorRows.map(mapEntry); + const root = await fetchEntry(input.rootId); + if (!root) { + return { type: 'error', output: null }; + } - const entry = ancestors.find((ancestor) => ancestor.id === input.entryId); + const entry = await fetchEntry(input.entryId); if (!entry) { return { type: 'error', output: null }; } const previousTransactions = await database - .selectFrom('transactions') + .selectFrom('entry_transactions') .selectAll() .where('entry_id', '=', input.entryId) .orderBy('version', 'asc') @@ -440,20 +439,20 @@ class EntryService { const attributes = ydoc.getAttributes(); const attributesJson = JSON.stringify(attributes); - const model = registry.getModel(attributes.type); - if (!model.schema.safeParse(attributes).success) { - return { type: 'error', output: null }; - } - const context = new EntryMutationContext( - user.account_id, - user.workspace_id, - user.id, - user.role, - ancestors - ); - - if (!model.canUpdate(context, entry, attributes)) { + if ( + !canUpdateEntry( + { + user: { + userId: user.id, + role: user.role, + }, + root: mapEntry(root), + entry: mapEntry(entry), + }, + attributes + ) + ) { return { type: 'error', output: null }; } @@ -479,7 +478,7 @@ class EntryService { transaction_id: input.id, }) .where('id', '=', input.entryId) - .where('transaction_id', '=', entry.transactionId) + .where('transaction_id', '=', entry.transaction_id) .executeTakeFirst(); if (!updatedEntry) { @@ -487,13 +486,13 @@ class EntryService { } const createdTransaction = await trx - .insertInto('transactions') + .insertInto('entry_transactions') .returningAll() .values({ id: input.id, entry_id: input.entryId, root_id: input.rootId, - workspace_id: context.workspaceId, + workspace_id: user.workspace_id, operation: 'update', data: typeof input.data === 'string' @@ -513,9 +512,8 @@ class EntryService { await this.applyCollaboratorUpdates( trx, input.entryId, - input.rootId, input.userId, - context.workspaceId, + user.workspace_id, collaboratorChanges ); @@ -531,8 +529,8 @@ class EntryService { type: 'entry_updated', entryId: input.entryId, entryType: entry.type, - rootId: entry.rootId, - workspaceId: context.workspaceId, + rootId: entry.root_id, + workspaceId: user.workspace_id, }); for (const createdCollaboration of createdCollaborations) { @@ -540,7 +538,7 @@ class EntryService { type: 'collaboration_created', collaboratorId: createdCollaboration.collaborator_id, entryId: input.entryId, - workspaceId: context.workspaceId, + workspaceId: user.workspace_id, }); } @@ -549,7 +547,7 @@ class EntryService { type: 'collaboration_updated', collaboratorId: updatedCollaboration.collaborator_id, entryId: input.entryId, - workspaceId: context.workspaceId, + workspaceId: user.workspace_id, }); } @@ -569,23 +567,26 @@ class EntryService { user: SelectUser, input: ApplyDeleteTransactionInput ): Promise { - const ancestorRows = await fetchEntryAncestors(input.entryId); - const ancestors = ancestorRows.map(mapEntry); - const entry = ancestors.find((ancestor) => ancestor.id === input.entryId); + const entry = await fetchEntry(input.entryId); if (!entry) { return null; } - const model = registry.getModel(entry.attributes.type); - const context = new EntryMutationContext( - user.account_id, - user.workspace_id, - user.id, - user.role, - ancestors - ); + const root = await fetchEntry(entry.root_id); + if (!root) { + return null; + } - if (!model.canDelete(context, entry)) { + if ( + !canDeleteEntry({ + user: { + userId: user.id, + role: user.role, + }, + root: mapEntry(root), + entry: mapEntry(entry), + }) + ) { return null; } @@ -602,12 +603,12 @@ class EntryService { } await trx - .deleteFrom('transactions') + .deleteFrom('entry_transactions') .where('entry_id', '=', input.entryId) .execute(); const createdTransaction = await trx - .insertInto('transactions') + .insertInto('entry_transactions') .returningAll() .values({ id: input.id, @@ -646,7 +647,7 @@ class EntryService { type: 'entry_deleted', entryId: input.entryId, entryType: entry.type, - rootId: entry.rootId, + rootId: entry.root_id, workspaceId: user.workspace_id, }); @@ -697,7 +698,7 @@ class EntryService { const rootEntry = mapEntry(root); const role = extractEntryRole(rootEntry, user.id); - if (!hasViewerAccess(role)) { + if (!role || !hasEntryRole(role, 'viewer')) { return false; } @@ -778,7 +779,7 @@ class EntryService { const rootEntry = mapEntry(root); const role = extractEntryRole(rootEntry, user.id); - if (!hasViewerAccess(role)) { + if (!role || !hasEntryRole(role, 'viewer')) { return false; } @@ -836,7 +837,7 @@ class EntryService { private async applyDatabaseCreateTransaction( attributes: EntryAttributes, entry: CreateEntry, - transaction: CreateTransaction + transaction: CreateEntryTransaction ) { const collaborationsToCreate: CreateCollaboration[] = Object.entries( extractEntryCollaborators(attributes) @@ -861,7 +862,7 @@ class EntryService { } const createdTransaction = await trx - .insertInto('transactions') + .insertInto('entry_transactions') .returningAll() .values(transaction) .executeTakeFirst(); @@ -891,7 +892,6 @@ class EntryService { private async applyCollaboratorUpdates( transaction: Transaction, entryId: string, - rootId: string, userId: string, workspaceId: string, updateResult: CollaboratorChangeResult diff --git a/apps/server/src/services/file-service.ts b/apps/server/src/services/file-service.ts index 2f51cb12..aedc8b16 100644 --- a/apps/server/src/services/file-service.ts +++ b/apps/server/src/services/file-service.ts @@ -1,18 +1,21 @@ import { + canCreateFile, + canDeleteFile, CreateFileMutation, DeleteFileMutation, extractEntryRole, FileStatus, - hasCollaboratorAccess, - hasViewerAccess, + hasEntryRole, MarkFileOpenedMutation, MarkFileSeenMutation, } from '@colanode/core'; +import { DeleteObjectCommand } from '@aws-sdk/client-s3'; import { database } from '@/data/database'; import { SelectUser } from '@/data/schema'; -import { mapEntry } from '@/lib/entries'; +import { fetchEntry, mapEntry } from '@/lib/entries'; import { eventBus } from '@/lib/event-bus'; +import { filesStorage, BUCKET_NAMES } from '@/data/storage'; class FileService { public async createFile( @@ -28,19 +31,30 @@ class FileService { return true; } - const root = await database - .selectFrom('entries') - .selectAll() - .where('id', '=', mutation.data.rootId) - .executeTakeFirst(); + const entry = await fetchEntry(mutation.data.entryId); + if (!entry) { + return false; + } + const root = await fetchEntry(mutation.data.rootId); if (!root) { return false; } - const rootEntry = mapEntry(root); - const role = extractEntryRole(rootEntry, user.id); - if (!hasCollaboratorAccess(role)) { + if ( + !canCreateFile({ + user: { + userId: user.id, + role: user.role, + }, + root: mapEntry(root), + entry: mapEntry(entry), + file: { + id: mutation.data.id, + parentId: mutation.data.parentId, + }, + }) + ) { return false; } @@ -93,36 +107,76 @@ class FileService { return true; } - const root = await database - .selectFrom('entries') - .selectAll() - .where('id', '=', mutation.data.rootId) - .executeTakeFirst(); + const entry = await fetchEntry(file.entry_id); + if (!entry) { + return false; + } + const root = await fetchEntry(file.root_id); if (!root) { return false; } - const rootEntry = mapEntry(root); - const role = extractEntryRole(rootEntry, user.id); - if (!hasCollaboratorAccess(role)) { + if ( + !canDeleteFile({ + user: { + userId: user.id, + role: user.role, + }, + root: mapEntry(root), + entry: mapEntry(entry), + file: { + id: mutation.data.id, + parentId: file.parent_id, + createdBy: file.created_by, + }, + }) + ) { return false; } - const deletedFile = await database - .updateTable('files') - .returningAll() - .set({ - deleted_at: new Date(mutation.data.deletedAt), - deleted_by: user.id, - }) - .where('id', '=', mutation.data.id) - .executeTakeFirst(); + const deletedFile = await database.transaction().execute(async (tx) => { + const deletedFile = await tx + .deleteFrom('files') + .returningAll() + .where('id', '=', mutation.data.id) + .executeTakeFirst(); + + if (!deletedFile) { + return null; + } + + await tx + .deleteFrom('file_interactions') + .where('file_id', '=', deletedFile.id) + .execute(); + + await tx + .insertInto('file_tombstones') + .values({ + id: deletedFile.id, + root_id: deletedFile.root_id, + workspace_id: deletedFile.workspace_id, + deleted_at: new Date(mutation.data.deletedAt), + deleted_by: user.id, + }) + .executeTakeFirst(); + + return deletedFile; + }); if (!deletedFile) { return false; } + const path = `files/${deletedFile.workspace_id}/${deletedFile.id}${deletedFile.extension}`; + const command = new DeleteObjectCommand({ + Bucket: BUCKET_NAMES.FILES, + Key: path, + }); + + await filesStorage.send(command); + eventBus.publish({ type: 'file_deleted', fileId: deletedFile.id, @@ -147,19 +201,14 @@ class FileService { return false; } - const root = await database - .selectFrom('entries') - .selectAll() - .where('id', '=', file.root_id) - .executeTakeFirst(); - + const root = await fetchEntry(file.root_id); if (!root) { return false; } const rootEntry = mapEntry(root); const role = extractEntryRole(rootEntry, user.id); - if (!hasViewerAccess(role)) { + if (!role || !hasEntryRole(role, 'viewer')) { return false; } @@ -220,7 +269,7 @@ class FileService { ): Promise { const file = await database .selectFrom('files') - .select(['id', 'root_id', 'workspace_id']) + .select(['id', 'entry_id', 'root_id', 'workspace_id']) .where('id', '=', mutation.data.fileId) .executeTakeFirst(); @@ -228,19 +277,14 @@ class FileService { return false; } - const root = await database - .selectFrom('entries') - .selectAll() - .where('id', '=', file.root_id) - .executeTakeFirst(); - + const root = await fetchEntry(file.root_id); if (!root) { return false; } const rootEntry = mapEntry(root); const role = extractEntryRole(rootEntry, user.id); - if (!hasViewerAccess(role)) { + if (!role || !hasEntryRole(role, 'viewer')) { return false; } diff --git a/apps/server/src/services/message-service.ts b/apps/server/src/services/message-service.ts index a21de5bf..8b85199b 100644 --- a/apps/server/src/services/message-service.ts +++ b/apps/server/src/services/message-service.ts @@ -1,17 +1,19 @@ import { + canCreateMessage, + canCreateMessageReaction, + canDeleteMessage, CreateMessageMutation, CreateMessageReactionMutation, DeleteMessageMutation, DeleteMessageReactionMutation, extractEntryRole, - hasCollaboratorAccess, - hasViewerAccess, + hasEntryRole, MarkMessageSeenMutation, } from '@colanode/core'; import { database } from '@/data/database'; import { SelectUser } from '@/data/schema'; -import { mapEntry } from '@/lib/entries'; +import { fetchEntry, mapEntry } from '@/lib/entries'; import { eventBus } from '@/lib/event-bus'; import { jobService } from '@/services/job-service'; @@ -29,19 +31,23 @@ class MessageService { return true; } - const root = await database - .selectFrom('entries') - .selectAll() - .where('id', '=', mutation.data.rootId) - .executeTakeFirst(); + const root = await fetchEntry(mutation.data.rootId); + const entry = await fetchEntry(mutation.data.entryId); - if (!root) { + if (!root || !entry) { return false; } - const rootEntry = mapEntry(root); - const role = extractEntryRole(rootEntry, user.id); - if (!hasCollaboratorAccess(role)) { + if ( + !canCreateMessage({ + user: { + userId: user.id, + role: user.role, + }, + root: mapEntry(root), + entry: mapEntry(entry), + }) + ) { return false; } @@ -88,7 +94,7 @@ class MessageService { ): Promise { const message = await database .selectFrom('messages') - .select(['id', 'root_id', 'workspace_id']) + .select(['id', 'entry_id', 'root_id', 'workspace_id', 'created_by']) .where('id', '=', mutation.data.id) .executeTakeFirst(); @@ -96,31 +102,59 @@ class MessageService { return true; } - const root = await database - .selectFrom('entries') - .selectAll() - .where('id', '=', message.root_id) - .executeTakeFirst(); + const root = await fetchEntry(message.root_id); + const entry = await fetchEntry(message.entry_id); - if (!root) { + if (!root || !entry) { return false; } - const rootEntry = mapEntry(root); - const role = extractEntryRole(rootEntry, user.id); - if (!hasCollaboratorAccess(role)) { - return false; - } - - const deletedMessage = await database - .updateTable('messages') - .returningAll() - .set({ - deleted_at: new Date(mutation.data.deletedAt), - deleted_by: user.id, + if ( + !canDeleteMessage({ + user: { + userId: user.id, + role: user.role, + }, + root: mapEntry(root), + entry: mapEntry(entry), + message: { + id: message.id, + createdBy: message.created_by, + }, }) - .where('id', '=', mutation.data.id) - .executeTakeFirst(); + ) { + return false; + } + + const deletedMessage = await database.transaction().execute(async (tx) => { + const deletedMessage = await tx + .deleteFrom('messages') + .returningAll() + .where('id', '=', mutation.data.id) + .executeTakeFirst(); + + if (!deletedMessage) { + return null; + } + + await tx + .deleteFrom('message_interactions') + .where('message_id', '=', deletedMessage.id) + .execute(); + + await tx + .insertInto('message_tombstones') + .values({ + id: deletedMessage.id, + root_id: deletedMessage.root_id, + workspace_id: deletedMessage.workspace_id, + deleted_at: new Date(mutation.data.deletedAt), + deleted_by: user.id, + }) + .executeTakeFirst(); + + return deletedMessage; + }); if (!deletedMessage) { return false; @@ -142,7 +176,7 @@ class MessageService { ): Promise { const message = await database .selectFrom('messages') - .select(['id', 'root_id', 'workspace_id']) + .select(['id', 'root_id', 'workspace_id', 'created_by']) .where('id', '=', mutation.data.messageId) .executeTakeFirst(); @@ -160,9 +194,19 @@ class MessageService { return false; } - const rootEntry = mapEntry(root); - const role = extractEntryRole(rootEntry, user.id); - if (!hasCollaboratorAccess(role)) { + if ( + !canCreateMessageReaction({ + user: { + userId: user.id, + role: user.role, + }, + root: mapEntry(root), + message: { + id: message.id, + createdBy: message.created_by, + }, + }) + ) { return false; } @@ -226,7 +270,7 @@ class MessageService { const rootEntry = mapEntry(root); const role = extractEntryRole(rootEntry, user.id); - if (!hasCollaboratorAccess(role)) { + if (!role || !hasEntryRole(role, 'commenter')) { return false; } @@ -281,7 +325,7 @@ class MessageService { const rootEntry = mapEntry(root); const role = extractEntryRole(rootEntry, user.id); - if (!hasViewerAccess(role)) { + if (!role || !hasEntryRole(role, 'viewer')) { return false; } diff --git a/apps/server/src/services/socket-connection.ts b/apps/server/src/services/socket-connection.ts index 8cb12d72..6dc14597 100644 --- a/apps/server/src/services/socket-connection.ts +++ b/apps/server/src/services/socket-connection.ts @@ -24,10 +24,12 @@ import { CollaborationSynchronizer } from '@/synchronizers/collaborations'; import { FileSynchronizer } from '@/synchronizers/files'; import { MessageSynchronizer } from '@/synchronizers/messages'; import { MessageReactionSynchronizer } from '@/synchronizers/message-reactions'; -import { TransactionSynchronizer } from '@/synchronizers/transactions'; +import { EntryTransactionSynchronizer } from '@/synchronizers/entry-transactions'; import { MessageInteractionSynchronizer } from '@/synchronizers/message-interactions'; import { EntryInteractionSynchronizer } from '@/synchronizers/entry-interactions'; import { FileInteractionSynchronizer } from '@/synchronizers/file-interactions'; +import { FileTombstoneSynchronizer } from '@/synchronizers/file-tombstones'; +import { MessageTombstoneSynchronizer } from '@/synchronizers/message-tombstones'; type SocketUser = { user: ConnectedUser; @@ -164,8 +166,8 @@ export class SocketConnection { message.input, cursor ); - } else if (message.input.type === 'transactions') { - return new TransactionSynchronizer( + } else if (message.input.type === 'entry_transactions') { + return new EntryTransactionSynchronizer( message.id, user.user, message.input, @@ -204,6 +206,20 @@ export class SocketConnection { message.input, cursor ); + } else if (message.input.type === 'file_tombstones') { + return new FileTombstoneSynchronizer( + message.id, + user.user, + message.input, + cursor + ); + } else if (message.input.type === 'message_tombstones') { + return new MessageTombstoneSynchronizer( + message.id, + user.user, + message.input, + cursor + ); } return null; diff --git a/apps/server/src/services/workspace-service.ts b/apps/server/src/services/workspace-service.ts index 23d40fe2..9bd441cf 100644 --- a/apps/server/src/services/workspace-service.ts +++ b/apps/server/src/services/workspace-service.ts @@ -33,7 +33,6 @@ class WorkspaceService { created_at: date, created_by: account.id, status: WorkspaceStatus.Active, - version_id: generateId(IdType.Version), }) .returningAll() .executeTakeFirst(); @@ -70,7 +69,7 @@ class WorkspaceService { type: 'space', name: 'Home', description: 'This is your home space.', - parentId: workspaceId, + parentId: spaceId, collaborators: { [userId]: 'admin', }, @@ -127,7 +126,6 @@ class WorkspaceService { name: workspace.name, description: workspace.description, avatar: workspace.avatar, - versionId: workspace.version_id, user: { id: user.id, accountId: user.account_id, diff --git a/apps/server/src/synchronizers/collaborations.ts b/apps/server/src/synchronizers/collaborations.ts index 73b73ed1..ce187db9 100644 --- a/apps/server/src/synchronizers/collaborations.ts +++ b/apps/server/src/synchronizers/collaborations.ts @@ -43,7 +43,6 @@ export class CollaborationSynchronizer extends BaseSynchronizer', this.cursor) .orderBy('version', 'asc') diff --git a/apps/server/src/synchronizers/transactions.ts b/apps/server/src/synchronizers/entry-transactions.ts similarity index 80% rename from apps/server/src/synchronizers/transactions.ts rename to apps/server/src/synchronizers/entry-transactions.ts index 6d080b2e..fa87c4fc 100644 --- a/apps/server/src/synchronizers/transactions.ts +++ b/apps/server/src/synchronizers/entry-transactions.ts @@ -1,17 +1,17 @@ import { SynchronizerOutputMessage, - SyncTransactionsInput, - SyncTransactionData, + SyncEntryTransactionsInput, + SyncEntryTransactionData, } from '@colanode/core'; import { encodeState } from '@colanode/crdt'; import { BaseSynchronizer } from '@/synchronizers/base'; import { Event } from '@/types/events'; import { database } from '@/data/database'; -import { SelectTransaction } from '@/data/schema'; +import { SelectEntryTransaction } from '@/data/schema'; -export class TransactionSynchronizer extends BaseSynchronizer { - public async fetchData(): Promise | null> { +export class EntryTransactionSynchronizer extends BaseSynchronizer { + public async fetchData(): Promise | null> { const transactions = await this.fetchTransactions(); if (transactions.length === 0) { return null; @@ -22,7 +22,7 @@ export class TransactionSynchronizer extends BaseSynchronizer | null> { + ): Promise | null> { if (!this.shouldFetch(event)) { return null; } @@ -42,7 +42,7 @@ export class TransactionSynchronizer extends BaseSynchronizer', this.cursor) @@ -55,9 +55,9 @@ export class TransactionSynchronizer extends BaseSynchronizer { - const items: SyncTransactionData[] = unsyncedTransactions.map( + unsyncedTransactions: SelectEntryTransaction[] + ): SynchronizerOutputMessage { + const items: SyncEntryTransactionData[] = unsyncedTransactions.map( (transaction) => { if (transaction.operation === 'create' && transaction.data) { return { @@ -119,24 +119,15 @@ export class TransactionSynchronizer extends BaseSynchronizer { + public async fetchData(): Promise | null> { + const fileTombstones = await this.fetchFileTombstones(); + if (fileTombstones.length === 0) { + return null; + } + + return this.buildMessage(fileTombstones); + } + + public async fetchDataFromEvent( + event: Event + ): Promise | null> { + if (!this.shouldFetch(event)) { + return null; + } + + const fileTombstones = await this.fetchFileTombstones(); + if (fileTombstones.length === 0) { + return null; + } + + return this.buildMessage(fileTombstones); + } + + private async fetchFileTombstones() { + if (this.status === 'fetching') { + return []; + } + + this.status = 'fetching'; + const fileTombstones = await database + .selectFrom('file_tombstones') + .selectAll() + .where('root_id', '=', this.input.rootId) + .where('version', '>', this.cursor) + .orderBy('version', 'asc') + .limit(20) + .execute(); + + this.status = 'pending'; + return fileTombstones; + } + + private buildMessage( + unsyncedFileTombstones: SelectFileTombstone[] + ): SynchronizerOutputMessage { + const items: SyncFileTombstoneData[] = unsyncedFileTombstones.map( + (fileTombstone) => ({ + id: fileTombstone.id, + rootId: fileTombstone.root_id, + workspaceId: fileTombstone.workspace_id, + deletedAt: fileTombstone.deleted_at.toISOString(), + deletedBy: fileTombstone.deleted_by, + version: fileTombstone.version.toString(), + }) + ); + + return { + type: 'synchronizer_output', + userId: this.user.userId, + id: this.id, + items: items.map((item) => ({ + cursor: item.version, + data: item, + })), + }; + } + + private shouldFetch(event: Event) { + if (event.type === 'file_deleted' && event.rootId === this.input.rootId) { + return true; + } + + return false; + } +} diff --git a/apps/server/src/synchronizers/files.ts b/apps/server/src/synchronizers/files.ts index 9a76ec71..b48158db 100644 --- a/apps/server/src/synchronizers/files.ts +++ b/apps/server/src/synchronizers/files.ts @@ -72,8 +72,6 @@ export class FileSynchronizer extends BaseSynchronizer { createdBy: file.created_by, updatedAt: file.updated_at?.toISOString() ?? null, updatedBy: file.updated_by ?? null, - deletedAt: file.deleted_at?.toISOString() ?? null, - deletedBy: file.deleted_by ?? null, version: file.version.toString(), status: file.status, })); diff --git a/apps/server/src/synchronizers/message-tombstones.ts b/apps/server/src/synchronizers/message-tombstones.ts new file mode 100644 index 00000000..7a0938ea --- /dev/null +++ b/apps/server/src/synchronizers/message-tombstones.ts @@ -0,0 +1,91 @@ +import { + SynchronizerOutputMessage, + SyncMessageTombstonesInput, + SyncMessageTombstoneData, +} from '@colanode/core'; + +import { BaseSynchronizer } from '@/synchronizers/base'; +import { Event } from '@/types/events'; +import { database } from '@/data/database'; +import { SelectMessageTombstone } from '@/data/schema'; + +export class MessageTombstoneSynchronizer extends BaseSynchronizer { + public async fetchData(): Promise | null> { + const messageTombstones = await this.fetchMessageTombstones(); + if (messageTombstones.length === 0) { + return null; + } + + return this.buildMessage(messageTombstones); + } + + public async fetchDataFromEvent( + event: Event + ): Promise | null> { + if (!this.shouldFetch(event)) { + return null; + } + + const messageTombstones = await this.fetchMessageTombstones(); + if (messageTombstones.length === 0) { + return null; + } + + return this.buildMessage(messageTombstones); + } + + private async fetchMessageTombstones() { + if (this.status === 'fetching') { + return []; + } + + this.status = 'fetching'; + const messageTombstones = await database + .selectFrom('message_tombstones') + .selectAll() + .where('root_id', '=', this.input.rootId) + .where('version', '>', this.cursor) + .orderBy('version', 'asc') + .limit(20) + .execute(); + + this.status = 'pending'; + return messageTombstones; + } + + private buildMessage( + unsyncedMessageTombstones: SelectMessageTombstone[] + ): SynchronizerOutputMessage { + const items: SyncMessageTombstoneData[] = unsyncedMessageTombstones.map( + (messageTombstone) => ({ + id: messageTombstone.id, + rootId: messageTombstone.root_id, + workspaceId: messageTombstone.workspace_id, + deletedAt: messageTombstone.deleted_at.toISOString(), + deletedBy: messageTombstone.deleted_by, + version: messageTombstone.version.toString(), + }) + ); + + return { + type: 'synchronizer_output', + userId: this.user.userId, + id: this.id, + items: items.map((item) => ({ + cursor: item.version, + data: item, + })), + }; + } + + private shouldFetch(event: Event) { + if ( + event.type === 'message_deleted' && + event.rootId === this.input.rootId + ) { + return true; + } + + return false; + } +} diff --git a/apps/server/src/synchronizers/messages.ts b/apps/server/src/synchronizers/messages.ts index 4d0d579c..ededbc7c 100644 --- a/apps/server/src/synchronizers/messages.ts +++ b/apps/server/src/synchronizers/messages.ts @@ -68,8 +68,6 @@ export class MessageSynchronizer extends BaseSynchronizer { createdBy: message.created_by, updatedAt: message.updated_at?.toISOString() ?? null, updatedBy: message.updated_by ?? null, - deletedAt: message.deleted_at?.toISOString() ?? null, - deletedBy: message.deleted_by ?? null, version: message.version.toString(), })); diff --git a/apps/server/src/types/api.ts b/apps/server/src/types/api.ts index f9aa12b6..688cf25b 100644 --- a/apps/server/src/types/api.ts +++ b/apps/server/src/types/api.ts @@ -1,16 +1,3 @@ -export enum ApiError { - GoogleAuthFailed = 'GoogleAuthFailed', - UserPendingActivation = 'UserPendingActivation', - InternalServerError = 'InternalServerError', - EmailAlreadyExists = 'EmailAlreadyExists', - EmailOrPasswordIncorrect = 'EmailOrPasswordIncorrect', - MissingRequiredFields = 'MissingRequiredFields', - ResourceNotFound = 'ResourceNotFound', - Unauthorized = 'Unauthorized', - Forbidden = 'Forbidden', - BadRequest = 'BadRequest', -} - export type RequestAccount = { id: string; deviceId: string; diff --git a/apps/server/src/types/entries.ts b/apps/server/src/types/entries.ts index 5dae443e..db132d17 100644 --- a/apps/server/src/types/entries.ts +++ b/apps/server/src/types/entries.ts @@ -1,6 +1,6 @@ import { Entry, EntryAttributes } from '@colanode/core'; -import { SelectEntry, SelectTransaction } from '@/data/schema'; +import { SelectEntry, SelectEntryTransaction } from '@/data/schema'; export type EntryCollaborator = { entryId: string; @@ -19,7 +19,7 @@ export type CreateEntryInput = { export type CreateEntryOutput = { entry: SelectEntry; - transaction: SelectTransaction; + transaction: SelectEntryTransaction; }; export type UpdateEntryInput = { @@ -31,7 +31,7 @@ export type UpdateEntryInput = { export type UpdateEntryOutput = { entry: SelectEntry; - transaction: SelectTransaction; + transaction: SelectEntryTransaction; }; export type ApplyCreateTransactionInput = { @@ -44,7 +44,7 @@ export type ApplyCreateTransactionInput = { export type ApplyCreateTransactionOutput = { entry: SelectEntry; - transaction: SelectTransaction; + transaction: SelectEntryTransaction; }; export type ApplyUpdateTransactionInput = { @@ -58,7 +58,7 @@ export type ApplyUpdateTransactionInput = { export type ApplyUpdateTransactionOutput = { entry: SelectEntry; - transaction: SelectTransaction; + transaction: SelectEntryTransaction; }; export type ApplyDeleteTransactionInput = { @@ -70,5 +70,5 @@ export type ApplyDeleteTransactionInput = { export type ApplyDeleteTransactionOutput = { entry: SelectEntry; - transaction: SelectTransaction; + transaction: SelectEntryTransaction; }; diff --git a/package-lock.json b/package-lock.json index d2f8597a..ed46db0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "devDependencies": { "@types/debug": "^4.1.12", "@types/lodash-es": "^4.17.12", - "@typescript-eslint/eslint-plugin": "^8.16.0", + "@typescript-eslint/eslint-plugin": "^8.19.0", "eslint": "^8.57.1", "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.31.0", @@ -56,39 +56,39 @@ "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-tooltip": "^1.1.4", "@radix-ui/react-visually-hidden": "^1.1.0", - "@tanstack/react-query": "^5.62.0", - "@tiptap/core": "^2.10.3", - "@tiptap/extension-blockquote": "^2.10.3", - "@tiptap/extension-bold": "^2.10.3", - "@tiptap/extension-bullet-list": "^2.10.3", - "@tiptap/extension-code": "^2.10.3", - "@tiptap/extension-code-block-lowlight": "^2.10.3", - "@tiptap/extension-document": "^2.10.3", - "@tiptap/extension-dropcursor": "^2.10.3", - "@tiptap/extension-horizontal-rule": "^2.10.3", - "@tiptap/extension-italic": "^2.10.3", - "@tiptap/extension-link": "^2.10.3", - "@tiptap/extension-list-item": "^2.10.3", - "@tiptap/extension-list-keymap": "^2.10.3", - "@tiptap/extension-ordered-list": "^2.10.3", - "@tiptap/extension-paragraph": "^2.10.3", - "@tiptap/extension-placeholder": "^2.10.3", - "@tiptap/extension-strike": "^2.10.3", - "@tiptap/extension-task-item": "^2.10.3", - "@tiptap/extension-task-list": "^2.10.3", - "@tiptap/extension-text": "^2.10.3", - "@tiptap/extension-underline": "^2.10.3", - "@tiptap/react": "^2.10.3", - "@tiptap/suggestion": "^2.10.3", + "@tanstack/react-query": "^5.62.11", + "@tiptap/core": "^2.11.0", + "@tiptap/extension-blockquote": "^2.11.0", + "@tiptap/extension-bold": "^2.11.0", + "@tiptap/extension-bullet-list": "^2.11.0", + "@tiptap/extension-code": "^2.11.0", + "@tiptap/extension-code-block-lowlight": "^2.11.0", + "@tiptap/extension-document": "^2.11.0", + "@tiptap/extension-dropcursor": "^2.11.0", + "@tiptap/extension-horizontal-rule": "^2.11.0", + "@tiptap/extension-italic": "^2.11.0", + "@tiptap/extension-link": "^2.11.0", + "@tiptap/extension-list-item": "^2.11.0", + "@tiptap/extension-list-keymap": "^2.11.0", + "@tiptap/extension-ordered-list": "^2.11.0", + "@tiptap/extension-paragraph": "^2.11.0", + "@tiptap/extension-placeholder": "^2.11.0", + "@tiptap/extension-strike": "^2.11.0", + "@tiptap/extension-task-item": "^2.11.0", + "@tiptap/extension-task-list": "^2.11.0", + "@tiptap/extension-text": "^2.11.0", + "@tiptap/extension-underline": "^2.11.0", + "@tiptap/react": "^2.11.0", + "@tiptap/suggestion": "^2.11.0", "better-sqlite3": "^11.6.0", - "bufferutil": "^4.0.8", + "bufferutil": "^4.0.9", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.4", "electron-squirrel-startup": "^1.0.1", "is-hotkey": "^0.2.0", "lowlight": "^3.2.0", - "lucide-react": "^0.462.0", + "lucide-react": "^0.469.0", "mime-types": "^2.1.35", "re-resizable": "^6.10.1", "react": "^18.3.1", @@ -97,14 +97,14 @@ "react-dnd-html5-backend": "^16.0.1", "react-dom": "^18.3.1", "react-hook-form": "^7.53.2", - "react-intersection-observer": "^9.13.1", + "react-intersection-observer": "^9.14.1", "react-router-dom": "^7.0.1", "react-virtualized-auto-sizer": "^1.0.24", "react-window": "^1.8.10", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", "ts-pattern": "^5.5.0", - "utf-8-validate": "^5.0.10", + "utf-8-validate": "^6.0.5", "ws": "^8.18.0" }, "devDependencies": { @@ -131,17 +131,30 @@ "electron": "^33.2.1", "postcss": "^8.4.49", "tailwindcss": "^3.4.15", - "vite": "^6.0.1" + "vite": "^6.0.6" + } + }, + "apps/desktop/node_modules/utf-8-validate": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-6.0.5.tgz", + "integrity": "sha512-EYZR+OpIXp9Y1eG1iueg8KRsY8TuT8VNgnanZ0uA3STqhHQTLwbl+WX76/9X5OY12yQubymBpaBSmMPkSTQcKA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" } }, "apps/desktop/node_modules/vite": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.5.tgz", - "integrity": "sha512-akD5IAH/ID5imgue2DYhzsEwCi0/4VKY31uhMLEYJwPP4TiUp8pL5PIK+Wo7H8qT8JY9i+pVfPydcFPYD1EL7g==", + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.6.tgz", + "integrity": "sha512-NSjmUuckPmDU18bHz7QZ+bTYhRR0iA72cs2QAxCqDpafJ0S6qetco0LB3WW2OxlMHS0JmAv+yZ/R3uPmMyGTjQ==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "0.24.0", + "esbuild": "^0.24.2", "postcss": "^8.4.49", "rollup": "^4.23.0" }, @@ -2532,9 +2545,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.0.tgz", - "integrity": "sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", "cpu": [ "ppc64" ], @@ -2549,9 +2562,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.0.tgz", - "integrity": "sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", "cpu": [ "arm" ], @@ -2566,9 +2579,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.0.tgz", - "integrity": "sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", "cpu": [ "arm64" ], @@ -2583,9 +2596,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.0.tgz", - "integrity": "sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", "cpu": [ "x64" ], @@ -2600,9 +2613,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.0.tgz", - "integrity": "sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", "cpu": [ "arm64" ], @@ -2617,9 +2630,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.0.tgz", - "integrity": "sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", "cpu": [ "x64" ], @@ -2634,9 +2647,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.0.tgz", - "integrity": "sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", "cpu": [ "arm64" ], @@ -2651,9 +2664,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.0.tgz", - "integrity": "sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", "cpu": [ "x64" ], @@ -2668,9 +2681,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.0.tgz", - "integrity": "sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", "cpu": [ "arm" ], @@ -2685,9 +2698,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.0.tgz", - "integrity": "sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", "cpu": [ "arm64" ], @@ -2702,9 +2715,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.0.tgz", - "integrity": "sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", "cpu": [ "ia32" ], @@ -2719,9 +2732,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.0.tgz", - "integrity": "sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", "cpu": [ "loong64" ], @@ -2736,9 +2749,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.0.tgz", - "integrity": "sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", "cpu": [ "mips64el" ], @@ -2753,9 +2766,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.0.tgz", - "integrity": "sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", "cpu": [ "ppc64" ], @@ -2770,9 +2783,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.0.tgz", - "integrity": "sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", "cpu": [ "riscv64" ], @@ -2787,9 +2800,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.0.tgz", - "integrity": "sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", "cpu": [ "s390x" ], @@ -2804,9 +2817,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.0.tgz", - "integrity": "sha512-vbutsFqQ+foy3wSSbmjBXXIJ6PL3scghJoM8zCL142cGaZKAdCZHyf+Bpu/MmX9zT9Q0zFBVKb36Ma5Fzfa8xA==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", "cpu": [ "x64" ], @@ -2820,10 +2833,27 @@ "node": ">=18" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.0.tgz", - "integrity": "sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", "cpu": [ "x64" ], @@ -2838,9 +2868,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.0.tgz", - "integrity": "sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", "cpu": [ "arm64" ], @@ -2855,9 +2885,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.0.tgz", - "integrity": "sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", "cpu": [ "x64" ], @@ -2872,9 +2902,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.0.tgz", - "integrity": "sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", "cpu": [ "x64" ], @@ -2889,9 +2919,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.0.tgz", - "integrity": "sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", "cpu": [ "arm64" ], @@ -2906,9 +2936,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.0.tgz", - "integrity": "sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", "cpu": [ "ia32" ], @@ -2923,9 +2953,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.0.tgz", - "integrity": "sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", "cpu": [ "x64" ], @@ -6131,9 +6161,9 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.62.10", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.62.10.tgz", - "integrity": "sha512-1e1WpHM5oGf27nWM/NWLY62/X9pbMBWa6ErWYmeuK0OqB9/g9UzA59ogiWbxCmS2wtAFQRhOdHhfSofrkhPl2g==", + "version": "5.62.11", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.62.11.tgz", + "integrity": "sha512-Xb1nw0cYMdtFmwkvH9+y5yYFhXvLRCnXoqlzSw7UkqtCVFq3cG8q+rHZ2Yz1XrC+/ysUaTqbLKJqk95mCgC1oQ==", "license": "MIT", "dependencies": { "@tanstack/query-core": "5.62.9" @@ -6147,9 +6177,9 @@ } }, "node_modules/@tiptap/core": { - "version": "2.10.4", - "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.10.4.tgz", - "integrity": "sha512-fExFRTRgb6MSpg2VvR5qO2dPTQAZWuUoU4UsBCurIVcPWcyVv4FG1YzgMyoLDKy44rebFtwUGJbfU9NzX7Q/bA==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.11.0.tgz", + "integrity": "sha512-0S3AWx6E2QqwdQqb6z0/q6zq2u9lA9oL3BLyAaITGSC9zt8OwjloS2k1zN6wLa9hp2rO0c0vDnWsTPeFaEaMdw==", "license": "MIT", "funding": { "type": "github", @@ -6160,9 +6190,9 @@ } }, "node_modules/@tiptap/extension-blockquote": { - "version": "2.10.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.10.4.tgz", - "integrity": "sha512-4JSwAM3B92YWvGzu/Vd5rovPrCGwLSaSLD5rxcLyfxLSrTDQd3n7lp78pzVgGhunVECzaGF5A0ByWWpEyS0a3w==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.11.0.tgz", + "integrity": "sha512-DBjWbgmbAAR879WAsk0+5xxgqpOTweWNnY7kEqWv3EJtLUvECXN63smiv3o4fREwwbEJqgihBu5/YugRC5z1dg==", "license": "MIT", "funding": { "type": "github", @@ -6173,9 +6203,9 @@ } }, "node_modules/@tiptap/extension-bold": { - "version": "2.10.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.10.4.tgz", - "integrity": "sha512-SdO4oFQKaERCGfwOc1CLYQRtThENam2KWfWmvpsymknokt5qYzU57ft0SE1HQV9vVYEzZ9HrWIgv2xrgu0g9kg==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.11.0.tgz", + "integrity": "sha512-3x9BQZHYD5xFA0pCEneEMHZyIoxYo4NKcbhR4CLxGad1Xd+5g109nr1+eZ1JgvnChkeVf1eD6SaQE2A28lxR5g==", "license": "MIT", "funding": { "type": "github", @@ -6186,9 +6216,9 @@ } }, "node_modules/@tiptap/extension-bubble-menu": { - "version": "2.10.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.10.4.tgz", - "integrity": "sha512-GVtZwJaQyLBptMsmDtYl5GEobd1Uu7C9sc9Z+PdXwMuxmFfg+j07bCKCj5JJj/tjgXCSLVxWdTlDHxNrgzQHjw==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.11.0.tgz", + "integrity": "sha512-21KyB7+QSQjw72Oxzs3Duw9WErAUrigFZCyoCZNjp24wP7mFVsy1jAcnRiAi8pBVwlwHBZ29IW1PeavqCSFFVA==", "license": "MIT", "dependencies": { "tippy.js": "^6.3.7" @@ -6203,9 +6233,9 @@ } }, "node_modules/@tiptap/extension-bullet-list": { - "version": "2.10.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.10.4.tgz", - "integrity": "sha512-JVwDPgOBYRU2ivaadOh4IaQYXQEiSw6sB36KT/bwqJF2GnEvLiMwptdRMn9Uuh6xYR3imjIZtV6uZAoneZdd6g==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.11.0.tgz", + "integrity": "sha512-UALypJvO+cPSk/nC1HhkX/ImS9FxbKe2Pr0iDofakvZU1U1msumLVn2M/iq+ax1Mm9thodpvJv0hGDtFRwm7lQ==", "license": "MIT", "funding": { "type": "github", @@ -6216,9 +6246,9 @@ } }, "node_modules/@tiptap/extension-code": { - "version": "2.10.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.10.4.tgz", - "integrity": "sha512-Vj/N0nbSQiV1o7X7pRySK9Fu72Dd266gm27TSlsts6IwJu5MklFvz7ezJUWoLjt2wmCV8/U/USmk/39ic9qjvg==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.11.0.tgz", + "integrity": "sha512-2roNZxcny1bGjyZ8x6VmGTuKbwfJyTZ1hiqPc/CRTQ1u42yOhbjF4ziA5kfyUoQlzygZrWH9LR5IMYGzPQ1N3w==", "license": "MIT", "funding": { "type": "github", @@ -6244,9 +6274,9 @@ } }, "node_modules/@tiptap/extension-code-block-lowlight": { - "version": "2.10.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-2.10.4.tgz", - "integrity": "sha512-wB1Muo1IOqOrjo/2SxaD8uNxrC862B/9FM9ulM9AtGteWdL4PqWi6OJ5aRQMnK0H6feFqsY9AHEPWLnwF8nj+Q==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-2.11.0.tgz", + "integrity": "sha512-B9UeQhcy5lQCOQWRFMruLXd1ghwUwTXCcDkYAX3yTPjC7blVHPJaocFSkq5LFsKQLV0Y0VXSpX9oNztp5lI7+A==", "license": "MIT", "funding": { "type": "github", @@ -6261,9 +6291,9 @@ } }, "node_modules/@tiptap/extension-document": { - "version": "2.10.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.10.4.tgz", - "integrity": "sha512-1Pqrl6Rr9bVEHJ3zO2dM7UUA0Qn/r70JQ9YLlestjW1sbMaMuY3Ifvu2uSyUE7SAGV3gvxwNVQCrv8f0VlVEaA==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.11.0.tgz", + "integrity": "sha512-9YI0AT3mxyUZD7NHECHyV1uAjQ8KwxOS5ACwvrK1MU8TqY084LmodYNTXPKwpqbr51yvt3qZq1R7UIVu4/22Cg==", "license": "MIT", "funding": { "type": "github", @@ -6274,9 +6304,9 @@ } }, "node_modules/@tiptap/extension-dropcursor": { - "version": "2.10.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.10.4.tgz", - "integrity": "sha512-0XEM/yNLaMc/sZlYOau7XpHyYiHT9LwXUe7kmze/L8eowIa/iLvmRbcnUd3rtlZ7x7wooE6UO9c7OtlREg4ZBw==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.11.0.tgz", + "integrity": "sha512-p7tUtlz7KzBa+06+7W2LJ8AEiHG5chdnUIapojZ7SqQCrFRVw70R+orpkzkoictxNNHsun0A9FCUy4rz8L0+nQ==", "license": "MIT", "funding": { "type": "github", @@ -6288,9 +6318,9 @@ } }, "node_modules/@tiptap/extension-floating-menu": { - "version": "2.10.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.10.4.tgz", - "integrity": "sha512-K2MDiu6CwQ7+Jr6g1Lh3Tuxm1L6SefSHMpQO0UW3aRGwgEV5pjlrztnBFX4K9b7MNuQ4dJGCUK9u8Cv7Xss0qg==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.11.0.tgz", + "integrity": "sha512-dexhhUJm0x9OolbeVCa7RpxuALU3bJZC7dFpu/rPG3ZetXKhVw8hTrqUQD5w1DjXpczBzScnLgLrvnjxbG66pw==", "license": "MIT", "dependencies": { "tippy.js": "^6.3.7" @@ -6305,9 +6335,9 @@ } }, "node_modules/@tiptap/extension-horizontal-rule": { - "version": "2.10.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.10.4.tgz", - "integrity": "sha512-s9ycm/BOGoW3L0Epnj541vdngHbFbMM488HoODd1CmVSw1C+wBWFgsukgqKjlyE3VGfZXuSb1ur9zinW0RiLJQ==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.11.0.tgz", + "integrity": "sha512-ZbkILwmcccmwQB2VTA/dzHRMB+xoJQ8UJdafcUiaAUlQfvDgl898+AYMa2GRTZkLPvzCKjXMC9hybSyy54Lz3Q==", "license": "MIT", "funding": { "type": "github", @@ -6319,9 +6349,9 @@ } }, "node_modules/@tiptap/extension-italic": { - "version": "2.10.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.10.4.tgz", - "integrity": "sha512-8MIQ+wsbyxNCZDCFTVTOXrS2AvFyOhtlBNgVU2+6r6xnJV4AcfEA3qclysqrjOlL117ped/nzDeoB0AeX0CI+Q==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.11.0.tgz", + "integrity": "sha512-T+jjS0gOsvNzQXVTSArmUp/kt2R9OikPQaV1DI60bfjO0rknOgtG0tbwZmfbugzwc07RbpxOYFy3vBxMLDsksA==", "license": "MIT", "funding": { "type": "github", @@ -6332,12 +6362,12 @@ } }, "node_modules/@tiptap/extension-link": { - "version": "2.10.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.10.4.tgz", - "integrity": "sha512-9lbtMUPc9IYCRMKV/B4k/no9J5OQQl/jJn9W2ce3NjJZSrOjuZs0CjJZgCESIaj6911s7nEJUvxKKmsbD3UC3Q==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.11.0.tgz", + "integrity": "sha512-hvJSj0Ul4h8uxivtFtqaSy08s9G3smaW0He0ybYJ7rcJIsZ1zSrxQLGvIr/J8/yUq8VoVNspNR5cGUoyQaaw4A==", "license": "MIT", "dependencies": { - "linkifyjs": "^4.1.0" + "linkifyjs": "^4.2.0" }, "funding": { "type": "github", @@ -6349,9 +6379,9 @@ } }, "node_modules/@tiptap/extension-list-item": { - "version": "2.10.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.10.4.tgz", - "integrity": "sha512-8K3WUD5fPyw2poQKnJGGm7zlfeIbpld92+SRF4M9wkp95EzvgexTlodvxlrL3i8zKXcQQVyExWA8kCcGPFb9bA==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.11.0.tgz", + "integrity": "sha512-Jikcg0fccpM13a3hAFLtguMcpVg4eMWI8NnC0aUULD9rFhvWZQYQYQuoK3fO6vQrAQpNhsV4oa0dfSq1btu9kg==", "license": "MIT", "funding": { "type": "github", @@ -6362,9 +6392,9 @@ } }, "node_modules/@tiptap/extension-list-keymap": { - "version": "2.10.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-2.10.4.tgz", - "integrity": "sha512-uFlgq7fdTTAtYzGJLPz+HHOwWVAFgKnbfWI8o7y46HWLDQrmfIvj452sAcYaLolnX0PKJ+vr6jbxP7jnqLqy/Q==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-2.11.0.tgz", + "integrity": "sha512-m1WI8lZuBF2VmUZ7+ijjVGeIwNB0WLAYnL8pGvOKYQRc/Rjxr3v/obCYt/nr9oZJNdW0inR3HbHOq5iteUeXoA==", "license": "MIT", "funding": { "type": "github", @@ -6375,9 +6405,9 @@ } }, "node_modules/@tiptap/extension-ordered-list": { - "version": "2.10.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.10.4.tgz", - "integrity": "sha512-NaeEu+qFG2O0emc8WlwOM7DKNKOaqHWuNkuKrrmQzslgL+UQSEGlGMo6NEJ5sLLckPBDpIa0MuRm30407JE+cg==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.11.0.tgz", + "integrity": "sha512-i6pNsDHA2QvBAebwjAuvhHKwz+bZVJ929PCIJaN8mxg0ldiAmFbAsf+rwIIFHWogMp+5xEX2RBzux20usNVZ9w==", "license": "MIT", "funding": { "type": "github", @@ -6388,9 +6418,9 @@ } }, "node_modules/@tiptap/extension-paragraph": { - "version": "2.10.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.10.4.tgz", - "integrity": "sha512-SRNVhT8OXqjpZtcyuOtofbtOpXXFrQrjqqCc/yXebda//2SfUTOvB16Lss77vQOWi6xr7TF1mZuowJgSTkcczw==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.11.0.tgz", + "integrity": "sha512-xLNC05An3SQq0bVHJtOTLa8As5r6NxDZFpK0NZqO2hTq/fAIRL/9VPeZ8E0tziXULwIvIPp+L0Taw3TvaUkRUg==", "license": "MIT", "funding": { "type": "github", @@ -6401,9 +6431,9 @@ } }, "node_modules/@tiptap/extension-placeholder": { - "version": "2.10.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.10.4.tgz", - "integrity": "sha512-leWG4xP7cvddR6alGZS7yojOh9941bxehgAeQDLlEisaJcNa2Od5Vbap2zipjc5sXMxZakQVChL27oH1wWhHkQ==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.11.0.tgz", + "integrity": "sha512-ee8vz51pW6H+1rEDMFg2FnBs2Tj5rUHlJ1JgD7Dcp3+89SVHGB3UILGfbNpAnHZvhmsTY3NcfPAcZZ80QfQFMQ==", "license": "MIT", "funding": { "type": "github", @@ -6415,9 +6445,9 @@ } }, "node_modules/@tiptap/extension-strike": { - "version": "2.10.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.10.4.tgz", - "integrity": "sha512-OibipsomFpOJWTPVX/z4Z53HgwDA93lE/loHGa+ONJfML1dO6Zd6UTwzaVO1/g8WOwRgwkYu/6JnhxLKRlP8Lg==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.11.0.tgz", + "integrity": "sha512-71i2IZT58kY2ohlhyO+ucyAioNNCkNkuPkrVERc9lXhmcCKOff5y6ekDHQHO2jNjnejkVE5ibyDO3Z7RUXjh1A==", "license": "MIT", "funding": { "type": "github", @@ -6428,9 +6458,9 @@ } }, "node_modules/@tiptap/extension-task-item": { - "version": "2.10.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-task-item/-/extension-task-item-2.10.4.tgz", - "integrity": "sha512-ucKGXdHdHCBanIJTB/nhmQ3iIL6BcSVKr7mN5BGEu6sSLYROflX7lmnMPVIRcTKJz+FGJeR6AqPFVagZAXVkGQ==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-task-item/-/extension-task-item-2.11.0.tgz", + "integrity": "sha512-qu6VuRc8qF80Bwr82CItFcrKtC67LJkwpxESLEIi42zWZ5sXF/3DJEPPS/4Kk+nAc9UCBoEMFAULibPq7rRl/w==", "license": "MIT", "funding": { "type": "github", @@ -6442,9 +6472,9 @@ } }, "node_modules/@tiptap/extension-task-list": { - "version": "2.10.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-task-list/-/extension-task-list-2.10.4.tgz", - "integrity": "sha512-21bFlHlvGr5hsXUEug9p+BWPLqdziFS/4mGG6nUnrSDI1e4eEC86WZczsG+If6FEpjcCS9Eb2RHgqaA4VoJEqg==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-task-list/-/extension-task-list-2.11.0.tgz", + "integrity": "sha512-+dZRjeXLXxyliFt3J7uQADxfOwi6ntyepVM+ri1rnmIaqVZUHJbUFodOc0LivI+Z5iZZ10u3TId8gehqWJHD+w==", "license": "MIT", "funding": { "type": "github", @@ -6455,9 +6485,9 @@ } }, "node_modules/@tiptap/extension-text": { - "version": "2.10.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.10.4.tgz", - "integrity": "sha512-wPdVxCHrIS9S+8n08lgyyqRZPj9FBbyLlFt74/lV5yBC3LOorq1VKdjrTskmaj4jud7ImXoKDyBddAYTHdJ1xw==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.11.0.tgz", + "integrity": "sha512-LcyrP+7ZEVx3YaKzjMAeujq+4xRt4mZ3ITGph2CQ4vOKFaMI8bzSR909q18t7Qyyvek0a9VydEU1NHSaq4G5jw==", "license": "MIT", "funding": { "type": "github", @@ -6468,9 +6498,9 @@ } }, "node_modules/@tiptap/extension-underline": { - "version": "2.10.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-2.10.4.tgz", - "integrity": "sha512-KhlCndQFMe/Gsz+3qkVn9z1utDy8y1igvdePijMjA5B8PTu0hPs2Q1d6szfLTBdtoFNkCokknxzXhSY0OFJEyQ==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-2.11.0.tgz", + "integrity": "sha512-DE1piq441y1+9Aj1pvvuq1dcc5B2HZ2d1SPtO4DTMjCxrhok12biTkMxxq0q1dzA5/BouLlUW6WTPpinhmrUWA==", "license": "MIT", "funding": { "type": "github", @@ -6512,13 +6542,13 @@ } }, "node_modules/@tiptap/react": { - "version": "2.10.4", - "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.10.4.tgz", - "integrity": "sha512-JTeqDB+xgjo46QC9ILRXe2TcSfxKVRwhZ3vDvYoemN7giRk5a/WsCF1VQIT1fax+tCl6kfv3U1f4Mkx0DkbPkA==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.11.0.tgz", + "integrity": "sha512-AALzHbqNq/gerJpkbXmN2OXFmHAs2bQENH7rXbnH70bpxVdIfQVtvjK4dIb+cQQvAuTWZvhsISnTrFY2BesT3Q==", "license": "MIT", "dependencies": { - "@tiptap/extension-bubble-menu": "^2.10.4", - "@tiptap/extension-floating-menu": "^2.10.4", + "@tiptap/extension-bubble-menu": "^2.11.0", + "@tiptap/extension-floating-menu": "^2.11.0", "@types/use-sync-external-store": "^0.0.6", "fast-deep-equal": "^3", "use-sync-external-store": "^1" @@ -6535,9 +6565,9 @@ } }, "node_modules/@tiptap/suggestion": { - "version": "2.10.4", - "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-2.10.4.tgz", - "integrity": "sha512-7Bzcn1REA7OmVRxiMF2kVK9EhosXotdLAGaEvSbn4zQtHCJG0tREuYvPy53LGzVuPkBDR6Pf6sp1QbGvSne/8g==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-2.11.0.tgz", + "integrity": "sha512-f+KcczhzEEy2f7/0N/RSID+Z6NjxCX6ab26NLfWZxdaEm/J+vQ2Pqh/e5Z59vMfKiC0DJXVcO0rdv2LBh23qDw==", "license": "MIT", "funding": { "type": "github", @@ -7067,17 +7097,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.18.2.tgz", - "integrity": "sha512-adig4SzPLjeQ0Tm+jvsozSGiCliI2ajeURDGHjZ2llnA+A67HihCQ+a3amtPhUakd1GlwHxSRvzOZktbEvhPPg==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.19.0.tgz", + "integrity": "sha512-NggSaEZCdSrFddbctrVjkVZvFC6KGfKfNK0CU7mNK/iKHGKbzT4Wmgm08dKpcZECBu9f5FypndoMyRHkdqfT1Q==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.18.2", - "@typescript-eslint/type-utils": "8.18.2", - "@typescript-eslint/utils": "8.18.2", - "@typescript-eslint/visitor-keys": "8.18.2", + "@typescript-eslint/scope-manager": "8.19.0", + "@typescript-eslint/type-utils": "8.19.0", + "@typescript-eslint/utils": "8.19.0", + "@typescript-eslint/visitor-keys": "8.19.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -7096,6 +7126,69 @@ "typescript": ">=4.8.4 <5.8.0" } }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.19.0.tgz", + "integrity": "sha512-hkoJiKQS3GQ13TSMEiuNmSCvhz7ujyqD1x3ShbaETATHrck+9RaDdUbt+osXaUuns9OFwrDTTrjtwsU8gJyyRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.19.0", + "@typescript-eslint/visitor-keys": "8.19.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.19.0.tgz", + "integrity": "sha512-8XQ4Ss7G9WX8oaYvD4OOLCjIQYgRQxO+qCiR2V2s2GxI9AUpo7riNwo6jDhKtTcaJjT8PY54j2Yb33kWtSJsmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.19.0.tgz", + "integrity": "sha512-mCFtBbFBJDCNCWUl5y6sZSCHXw1DEFEk3c/M3nRK2a4XUB8StGFtmcEMizdjKuBzB6e/smJAAWYug3VrdLMr1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.19.0", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@typescript-eslint/parser": { "version": "8.18.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.18.2.tgz", @@ -7128,6 +7221,7 @@ "integrity": "sha512-YJFSfbd0CJjy14r/EvWapYgV4R5CHzptssoag2M7y3Ra7XNta6GPAJPPP5KGB9j14viYXyrzRO5GkX7CRfo8/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/types": "8.18.2", "@typescript-eslint/visitor-keys": "8.18.2" @@ -7141,14 +7235,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.18.2.tgz", - "integrity": "sha512-AB/Wr1Lz31bzHfGm/jgbFR0VB0SML/hd2P1yxzKDM48YmP7vbyJNHRExUE/wZsQj2wUCvbWH8poNHFuxLqCTnA==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.19.0.tgz", + "integrity": "sha512-TZs0I0OSbd5Aza4qAMpp1cdCYVnER94IziudE3JU328YUHgWu9gwiwhag+fuLeJ2LkWLXI+F/182TbG+JaBdTg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.18.2", - "@typescript-eslint/utils": "8.18.2", + "@typescript-eslint/typescript-estree": "8.19.0", + "@typescript-eslint/utils": "8.19.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -7164,12 +7258,85 @@ "typescript": ">=4.8.4 <5.8.0" } }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.19.0.tgz", + "integrity": "sha512-8XQ4Ss7G9WX8oaYvD4OOLCjIQYgRQxO+qCiR2V2s2GxI9AUpo7riNwo6jDhKtTcaJjT8PY54j2Yb33kWtSJsmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.19.0.tgz", + "integrity": "sha512-WW9PpDaLIFW9LCbucMSdYUuGeFUz1OkWYS/5fwZwTA+l2RwlWFdJvReQqMUMBw4yJWJOfqd7An9uwut2Oj8sLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.19.0", + "@typescript-eslint/visitor-keys": "8.19.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.19.0.tgz", + "integrity": "sha512-mCFtBbFBJDCNCWUl5y6sZSCHXw1DEFEk3c/M3nRK2a4XUB8StGFtmcEMizdjKuBzB6e/smJAAWYug3VrdLMr1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.19.0", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@typescript-eslint/types": { "version": "8.18.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.18.2.tgz", "integrity": "sha512-Z/zblEPp8cIvmEn6+tPDIHUbRu/0z5lqZ+NvolL5SvXWT5rQy7+Nch83M0++XzO0XrWRFWECgOAyE8bsJTl1GQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -7184,6 +7351,7 @@ "integrity": "sha512-WXAVt595HjpmlfH4crSdM/1bcsqh+1weFRWIa9XMTx/XHZ9TCKMcr725tLYqWOgzKdeDrqVHxFotrvWcEsk2Tg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/types": "8.18.2", "@typescript-eslint/visitor-keys": "8.18.2", @@ -7206,16 +7374,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.18.2.tgz", - "integrity": "sha512-Cr4A0H7DtVIPkauj4sTSXVl+VBWewE9/o40KcF3TV9aqDEOWoXF3/+oRXNby3DYzZeCATvbdksYsGZzplwnK/Q==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.19.0.tgz", + "integrity": "sha512-PTBG+0oEMPH9jCZlfg07LCB2nYI0I317yyvXGfxnvGvw4SHIOuRnQ3kadyyXY6tGdChusIHIbM5zfIbp4M6tCg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.18.2", - "@typescript-eslint/types": "8.18.2", - "@typescript-eslint/typescript-estree": "8.18.2" + "@typescript-eslint/scope-manager": "8.19.0", + "@typescript-eslint/types": "8.19.0", + "@typescript-eslint/typescript-estree": "8.19.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7229,12 +7397,103 @@ "typescript": ">=4.8.4 <5.8.0" } }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.19.0.tgz", + "integrity": "sha512-hkoJiKQS3GQ13TSMEiuNmSCvhz7ujyqD1x3ShbaETATHrck+9RaDdUbt+osXaUuns9OFwrDTTrjtwsU8gJyyRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.19.0", + "@typescript-eslint/visitor-keys": "8.19.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.19.0.tgz", + "integrity": "sha512-8XQ4Ss7G9WX8oaYvD4OOLCjIQYgRQxO+qCiR2V2s2GxI9AUpo7riNwo6jDhKtTcaJjT8PY54j2Yb33kWtSJsmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.19.0.tgz", + "integrity": "sha512-WW9PpDaLIFW9LCbucMSdYUuGeFUz1OkWYS/5fwZwTA+l2RwlWFdJvReQqMUMBw4yJWJOfqd7An9uwut2Oj8sLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.19.0", + "@typescript-eslint/visitor-keys": "8.19.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.19.0.tgz", + "integrity": "sha512-mCFtBbFBJDCNCWUl5y6sZSCHXw1DEFEk3c/M3nRK2a4XUB8StGFtmcEMizdjKuBzB6e/smJAAWYug3VrdLMr1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.19.0", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@typescript-eslint/visitor-keys": { "version": "8.18.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.18.2.tgz", "integrity": "sha512-zORcwn4C3trOWiCqFQP1x6G3xTRyZ1LYydnj51cRnJ6hxBlr/cKPckk+PKPUw/fXmvfKTcw7bwY3w9izgx5jZw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/types": "8.18.2", "eslint-visitor-keys": "^4.2.0" @@ -7253,6 +7512,7 @@ "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -8296,9 +8556,9 @@ "license": "MIT" }, "node_modules/bufferutil": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.8.tgz", - "integrity": "sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.9.tgz", + "integrity": "sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -10513,9 +10773,9 @@ "optional": true }, "node_modules/esbuild": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.0.tgz", - "integrity": "sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -10526,30 +10786,31 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.24.0", - "@esbuild/android-arm": "0.24.0", - "@esbuild/android-arm64": "0.24.0", - "@esbuild/android-x64": "0.24.0", - "@esbuild/darwin-arm64": "0.24.0", - "@esbuild/darwin-x64": "0.24.0", - "@esbuild/freebsd-arm64": "0.24.0", - "@esbuild/freebsd-x64": "0.24.0", - "@esbuild/linux-arm": "0.24.0", - "@esbuild/linux-arm64": "0.24.0", - "@esbuild/linux-ia32": "0.24.0", - "@esbuild/linux-loong64": "0.24.0", - "@esbuild/linux-mips64el": "0.24.0", - "@esbuild/linux-ppc64": "0.24.0", - "@esbuild/linux-riscv64": "0.24.0", - "@esbuild/linux-s390x": "0.24.0", - "@esbuild/linux-x64": "0.24.0", - "@esbuild/netbsd-x64": "0.24.0", - "@esbuild/openbsd-arm64": "0.24.0", - "@esbuild/openbsd-x64": "0.24.0", - "@esbuild/sunos-x64": "0.24.0", - "@esbuild/win32-arm64": "0.24.0", - "@esbuild/win32-ia32": "0.24.0", - "@esbuild/win32-x64": "0.24.0" + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" } }, "node_modules/escalade": { @@ -13823,12 +14084,12 @@ } }, "node_modules/lucide-react": { - "version": "0.462.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.462.0.tgz", - "integrity": "sha512-NTL7EbAao9IFtuSivSZgrAh4fZd09Lr+6MTkqIxuHaH2nnYiYIzXPo06cOxHg9wKLdj6LL8TByG4qpePqwgx/g==", + "version": "0.469.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.469.0.tgz", + "integrity": "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw==", "license": "ISC", "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/luxon": { @@ -16551,9 +16812,9 @@ } }, "node_modules/react-intersection-observer": { - "version": "9.14.0", - "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.14.0.tgz", - "integrity": "sha512-AYqlmDZn85VUmlODwYym9y5OlqY2cFyIu41dkN0GJWvhdbd19Mh16mz5IH6fO1gp5V4FfQOO4m0zGc04Tj13rQ==", + "version": "9.14.1", + "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.14.1.tgz", + "integrity": "sha512-k1xIUn3sCQi3ugNeF64FJb3zwve5mcetvAUR9JazXeOmtap4IP2evN8rs+yf6SQ7F1QydsOGiqTmt+lySKZ9uA==", "license": "MIT", "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0", @@ -19989,6 +20250,8 @@ "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", "hasInstallScript": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "node-gyp-build": "^4.3.0" }, diff --git a/package.json b/package.json index 28e80ab2..4cc552df 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "devDependencies": { "@types/debug": "^4.1.12", "@types/lodash-es": "^4.17.12", - "@typescript-eslint/eslint-plugin": "^8.16.0", + "@typescript-eslint/eslint-plugin": "^8.19.0", "eslint": "^8.57.1", "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.31.0", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 59644dde..0f257390 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -23,8 +23,10 @@ export * from './types/entries'; export * from './types/servers'; export * from './types/sync'; export * from './types/workspaces'; -export * from './lib/blocks'; export * from './types/mutations'; export * from './types/messages'; export * from './synchronizers'; export * from './lib/entries'; +export * from './lib/texts'; +export * from './lib/permissions'; +export * from './types/api'; diff --git a/packages/core/src/lib/blocks.ts b/packages/core/src/lib/blocks.ts deleted file mode 100644 index 9ba1d7ae..00000000 --- a/packages/core/src/lib/blocks.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Block } from '../registry/block'; - -const collectText = ( - blockId: string, - blocks: Record -): string => { - const texts: string[] = []; - - // Extract text from the current block's leaf nodes - const block = blocks[blockId]; - if (block) { - let text = ''; - if (block.content) { - for (const leaf of block.content) { - if (leaf.text) { - text += leaf.text; - } - } - } - texts.push(text); - } - - // Find children and sort them by their index to maintain a stable order - const children = Object.values(blocks) - .filter((child) => child.parentId === blockId) - .sort((a, b) => a.index.localeCompare(b.index)); - - // Recursively collect text from children - for (const child of children) { - texts.push(collectText(child.id, blocks)); - } - - return texts.join('\n'); -}; - -export const extractText = ( - entryId: string, - blocks: Record | undefined | null -): string | null => { - if (!blocks) { - return null; - } - - const result = collectText(entryId, blocks); - return result.length > 0 ? result : null; -}; diff --git a/packages/core/src/lib/entries.ts b/packages/core/src/lib/entries.ts index 04dd0114..22125faa 100644 --- a/packages/core/src/lib/entries.ts +++ b/packages/core/src/lib/entries.ts @@ -46,27 +46,6 @@ export const extractEntryRole = ( return role; }; -export const hasAdminAccess = (role: EntryRole | null): boolean => { - return role === 'admin'; -}; - -export const hasEditorAccess = (role: EntryRole | null): boolean => { - return role === 'admin' || role === 'editor'; -}; - -export const hasCollaboratorAccess = (role: EntryRole | null): boolean => { - return role === 'admin' || role === 'editor' || role === 'collaborator'; -}; - -export const hasViewerAccess = (role: EntryRole | null): boolean => { - return ( - role === 'admin' || - role === 'editor' || - role === 'collaborator' || - role === 'viewer' - ); -}; - export const generateNodeIndex = ( previous?: string | null, next?: string | null diff --git a/packages/core/src/lib/permissions.ts b/packages/core/src/lib/permissions.ts new file mode 100644 index 00000000..38c0f117 --- /dev/null +++ b/packages/core/src/lib/permissions.ts @@ -0,0 +1,284 @@ +import { isEqual } from 'lodash-es'; + +import { extractEntryRole } from './entries'; + +import { Entry, EntryAttributes } from '../registry'; +import { WorkspaceRole } from '../types/workspaces'; + +import { EntryRole } from '~/registry/core'; + +export type UserInput = { + userId: string; + role: WorkspaceRole; +}; + +export type CanCreateEntryInput = { + user: UserInput; + root: Entry | null; +}; + +export const canCreateEntry = ( + input: CanCreateEntryInput, + attributes: EntryAttributes +): boolean => { + if (input.user.role === 'none') { + return false; + } + + if (attributes.type === 'chat') { + return true; + } + + if (attributes.type === 'space') { + return hasWorkspaceRole(input.user.role, 'collaborator'); + } + + const root = input.root; + if (!root) { + return false; + } + + const rootRole = extractEntryRole(root, input.user.userId); + if (!rootRole) { + return false; + } + + return hasEntryRole(rootRole, 'editor'); +}; + +export type CanUpdateEntryInput = { + user: UserInput; + root: Entry; + entry: Entry; +}; + +export const canUpdateEntry = ( + input: CanUpdateEntryInput, + attributes: EntryAttributes +): boolean => { + if (input.user.role === 'none') { + return false; + } + + if (attributes.type === 'chat') { + return false; + } + + const rootRole = extractEntryRole(input.root, input.user.userId); + if (!rootRole) { + return false; + } + + if (attributes.type === 'space') { + if (input.entry.type !== 'space') { + return false; + } + + const afterCollaborators = attributes.collaborators; + const beforeCollaborators = input.entry.attributes.collaborators; + + if (!isEqual(afterCollaborators, beforeCollaborators)) { + return hasEntryRole(rootRole, 'admin'); + } + } + + return hasEntryRole(rootRole, 'editor'); +}; + +export type CanDeleteEntryInput = { + user: UserInput; + root: Entry; + entry: Entry; +}; + +export const canDeleteEntry = (input: CanDeleteEntryInput): boolean => { + if (input.user.role === 'none') { + return false; + } + + const entry = input.entry; + if (entry.attributes.type === 'chat') { + return false; + } + + const rootRole = extractEntryRole(input.root, input.user.userId); + if (!rootRole) { + return false; + } + + if (entry.attributes.type === 'record') { + return input.entry.createdBy === input.user.userId; + } + + if (entry.attributes.type === 'space') { + return hasEntryRole(rootRole, 'admin'); + } + + return hasEntryRole(rootRole, 'editor'); +}; + +export const hasWorkspaceRole = ( + currentRole: WorkspaceRole, + targetRole: WorkspaceRole +) => { + if (targetRole === 'owner') { + return currentRole === 'owner'; + } + + if (targetRole === 'admin') { + return currentRole === 'admin' || currentRole === 'owner'; + } + + if (targetRole === 'collaborator') { + return ( + currentRole === 'admin' || + currentRole === 'collaborator' || + currentRole === 'owner' + ); + } + + if (targetRole === 'guest') { + return ( + currentRole === 'admin' || + currentRole === 'owner' || + currentRole === 'collaborator' || + currentRole === 'guest' + ); + } + + return false; +}; + +export const hasEntryRole = (currentRole: EntryRole, targetRole: EntryRole) => { + if (targetRole === 'admin') { + return currentRole === 'admin'; + } + + if (targetRole === 'editor') { + return currentRole === 'admin' || currentRole === 'editor'; + } + + if (targetRole === 'commenter') { + return ( + currentRole === 'admin' || + currentRole === 'editor' || + currentRole === 'commenter' + ); + } + + if (targetRole === 'viewer') { + return ( + currentRole === 'admin' || + currentRole === 'editor' || + currentRole === 'commenter' || + currentRole === 'viewer' + ); + } + + return false; +}; + +export type CreateMessageInput = { + user: UserInput; + root: Entry; + entry: Entry; +}; + +export const canCreateMessage = (input: CreateMessageInput): boolean => { + const rootRole = extractEntryRole(input.root, input.user.userId); + if (!rootRole) { + return false; + } + + return hasEntryRole(rootRole, 'commenter'); +}; + +export type DeleteMessageInput = { + user: UserInput; + root: Entry; + entry: Entry; + message: { + id: string; + createdBy: string; + }; +}; + +export const canDeleteMessage = (input: DeleteMessageInput): boolean => { + const rootRole = extractEntryRole(input.root, input.user.userId); + if (!rootRole) { + return false; + } + + if (input.message.createdBy === input.user.userId) { + return true; + } + + return hasEntryRole(rootRole, 'admin'); +}; + +export type CanCreateMessageReactionInput = { + user: UserInput; + root: Entry; + message: { + id: string; + createdBy: string; + }; +}; + +export const canCreateMessageReaction = ( + input: CanCreateMessageReactionInput +): boolean => { + const rootRole = extractEntryRole(input.root, input.user.userId); + if (!rootRole) { + return false; + } + + return hasEntryRole(rootRole, 'viewer'); +}; + +export type CreateFileInput = { + user: UserInput; + root: Entry; + entry: Entry; + file: { + id: string; + parentId: string; + }; +}; + +export const canCreateFile = (input: CreateFileInput): boolean => { + const rootRole = extractEntryRole(input.root, input.user.userId); + if (!rootRole) { + return false; + } + + if (input.file.parentId === input.entry.id) { + return hasEntryRole(rootRole, 'editor'); + } + + return hasEntryRole(rootRole, 'commenter'); +}; + +export type DeleteFileInput = { + user: UserInput; + root: Entry; + entry: Entry; + file: { + id: string; + parentId: string; + createdBy: string; + }; +}; + +export const canDeleteFile = (input: DeleteFileInput): boolean => { + const rootRole = extractEntryRole(input.root, input.user.userId); + if (!rootRole) { + return false; + } + + if (input.file.createdBy === input.user.userId) { + return true; + } + + return hasEntryRole(rootRole, 'admin'); +}; diff --git a/packages/core/src/lib/texts.ts b/packages/core/src/lib/texts.ts new file mode 100644 index 00000000..4872d9b9 --- /dev/null +++ b/packages/core/src/lib/texts.ts @@ -0,0 +1,88 @@ +import { Block } from '../registry/block'; +import { EntryAttributes } from '../registry'; +import { MessageContent } from '../types/messages'; + +export type TextResult = { + id: string; + name: string | null; + text: string | null; +}; + +export const extractEntryText = ( + id: string, + attributes: EntryAttributes +): TextResult | undefined => { + if (attributes.type === 'page') { + return { + id, + name: attributes.name, + text: extractBlockTexts(id, attributes.content), + }; + } + + if (attributes.type === 'record') { + return { + id, + name: attributes.name, + text: extractBlockTexts(id, attributes.content), + }; + } + + return undefined; +}; + +export const extractMessageText = ( + id: string, + content: MessageContent +): TextResult | undefined => { + return { + id, + name: null, + text: extractBlockTexts(id, content.blocks), + }; +}; + +const extractBlockTexts = ( + entryId: string, + blocks: Record | undefined | null +): string | null => { + if (!blocks) { + return null; + } + + const result = collectBlockText(entryId, blocks); + return result.length > 0 ? result : null; +}; + +const collectBlockText = ( + blockId: string, + blocks: Record +): string => { + const texts: string[] = []; + + // Extract text from the current block's leaf nodes + const block = blocks[blockId]; + if (block) { + let text = ''; + if (block.content) { + for (const leaf of block.content) { + if (leaf.text) { + text += leaf.text; + } + } + } + texts.push(text); + } + + // Find children and sort them by their index to maintain a stable order + const children = Object.values(blocks) + .filter((child) => child.parentId === blockId) + .sort((a, b) => a.index.localeCompare(b.index)); + + // Recursively collect text from children + for (const child of children) { + texts.push(collectBlockText(child.id, blocks)); + } + + return texts.join('\n'); +}; diff --git a/packages/core/src/registry/channel.ts b/packages/core/src/registry/channel.ts index 4a6f7fef..c5651292 100644 --- a/packages/core/src/registry/channel.ts +++ b/packages/core/src/registry/channel.ts @@ -1,65 +1,10 @@ -import { isEqual } from 'lodash-es'; import { z } from 'zod'; -import { EntryModel, entryRoleEnum } from './core'; - export const channelAttributesSchema = z.object({ type: z.literal('channel'), name: z.string(), avatar: z.string().nullable().optional(), parentId: z.string(), - collaborators: z.record(z.string(), entryRoleEnum).nullable().optional(), }); export type ChannelAttributes = z.infer; - -export const channelModel: EntryModel = { - type: 'channel', - schema: channelAttributesSchema, - getText: (id, attributes) => { - if (attributes.type !== 'channel') { - return undefined; - } - - return { - id, - name: attributes.name, - text: null, - }; - }, - canCreate: async (context, attributes) => { - if (attributes.type !== 'channel') { - return false; - } - - if (context.ancestors.length !== 1) { - return false; - } - - const parent = context.ancestors[0]; - if (!parent || parent.type !== 'space') { - return false; - } - - const collaboratorIds = Object.keys(attributes.collaborators ?? {}); - if (collaboratorIds.length > 0 && !context.hasAdminAccess()) { - return false; - } - - return context.hasEditorAccess(); - }, - canUpdate: async (context, node, attributes) => { - if (attributes.type !== 'channel' || node.type !== 'channel') { - return false; - } - - if (!isEqual(node.attributes.collaborators, attributes.collaborators)) { - return context.hasAdminAccess(); - } - - return context.hasEditorAccess(); - }, - canDelete: async (context, _) => { - return context.hasEditorAccess(); - }, -}; diff --git a/packages/core/src/registry/chat.ts b/packages/core/src/registry/chat.ts index 8929781e..89d5dee5 100644 --- a/packages/core/src/registry/chat.ts +++ b/packages/core/src/registry/chat.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import { EntryModel, entryRoleEnum } from './core'; +import { entryRoleEnum } from './core'; export const chatAttributesSchema = z.object({ type: z.literal('chat'), @@ -9,33 +9,3 @@ export const chatAttributesSchema = z.object({ }); export type ChatAttributes = z.infer; - -export const chatModel: EntryModel = { - type: 'chat', - schema: chatAttributesSchema, - getText: () => { - return undefined; - }, - canCreate: async (context, attributes) => { - if (attributes.type !== 'chat') { - return false; - } - - const collaboratorIds = Object.keys(attributes.collaborators ?? {}); - if (collaboratorIds.length !== 2) { - return false; - } - - if (!collaboratorIds.includes(context.userId)) { - return false; - } - - return true; - }, - canUpdate: async () => { - return false; - }, - canDelete: async () => { - return false; - }, -}; diff --git a/packages/core/src/registry/core.ts b/packages/core/src/registry/core.ts index a8ddb158..275bfd8f 100644 --- a/packages/core/src/registry/core.ts +++ b/packages/core/src/registry/core.ts @@ -1,85 +1,4 @@ -import { z, ZodSchema } from 'zod'; +import { z } from 'zod'; -import { - extractEntryRole, - hasAdminAccess, - hasCollaboratorAccess, - hasEditorAccess, - hasViewerAccess, -} from '../lib/entries'; -import { WorkspaceRole } from '../types/workspaces'; - -import { Entry, EntryAttributes } from './'; - -export type EntryRole = 'admin' | 'editor' | 'collaborator' | 'viewer'; -export const entryRoleEnum = z.enum([ - 'admin', - 'editor', - 'collaborator', - 'viewer', -]); - -export type EntryText = { - id: string; - name: string | null; - text: string | null; -}; - -export class EntryMutationContext { - public accountId: string; - public workspaceId: string; - public userId: string; - public ancestors: Entry[]; - public entryRole: EntryRole | null; - public workspaceRole: WorkspaceRole | null; - - constructor( - accountId: string, - workspaceId: string, - userId: string, - workspaceRole: WorkspaceRole | null, - ancestors: Entry[] - ) { - this.accountId = accountId; - this.workspaceId = workspaceId; - this.userId = userId; - this.workspaceRole = workspaceRole; - this.ancestors = ancestors; - this.entryRole = extractEntryRole(ancestors, userId); - } - - public hasAdminAccess = () => { - return hasAdminAccess(this.entryRole); - }; - - public hasEditorAccess = () => { - return hasEditorAccess(this.entryRole); - }; - - public hasCollaboratorAccess = () => { - return hasCollaboratorAccess(this.entryRole); - }; - - public hasViewerAccess = () => { - return hasViewerAccess(this.entryRole); - }; -} - -export interface EntryModel { - type: string; - schema: ZodSchema; - canCreate: ( - context: EntryMutationContext, - attributes: EntryAttributes - ) => Promise; - canUpdate: ( - context: EntryMutationContext, - entry: Entry, - attributes: EntryAttributes - ) => Promise; - canDelete: (context: EntryMutationContext, entry: Entry) => Promise; - getText: ( - id: string, - attributes: EntryAttributes - ) => EntryText | undefined | null; -} +export type EntryRole = 'admin' | 'editor' | 'commenter' | 'viewer'; +export const entryRoleEnum = z.enum(['admin', 'editor', 'commenter', 'viewer']); diff --git a/packages/core/src/registry/database.ts b/packages/core/src/registry/database.ts index 50815fdd..0b0b7779 100644 --- a/packages/core/src/registry/database.ts +++ b/packages/core/src/registry/database.ts @@ -1,7 +1,5 @@ -import { isEqual } from 'lodash-es'; import { z } from 'zod'; -import { EntryModel, entryRoleEnum } from './core'; import { fieldAttributesSchema } from './fields'; export const viewFieldAttributesSchema = z.object({ @@ -73,52 +71,9 @@ export const databaseAttributesSchema = z.object({ name: z.string(), avatar: z.string().nullable().optional(), parentId: z.string(), - collaborators: z.record(z.string(), entryRoleEnum).nullable().optional(), fields: z.record(z.string(), fieldAttributesSchema), views: z.record(z.string(), viewAttributesSchema), }); export type DatabaseAttributes = z.infer; export type ViewType = 'table' | 'board' | 'calendar'; - -export const databaseModel: EntryModel = { - type: 'database', - schema: databaseAttributesSchema, - getText: (id, attributes) => { - if (attributes.type !== 'database') { - return undefined; - } - - return { - id, - name: attributes.name, - text: null, - }; - }, - canCreate: async (context, attributes) => { - if (attributes.type !== 'database') { - return false; - } - - const collaboratorIds = Object.keys(attributes.collaborators ?? {}); - if (collaboratorIds.length > 0 && !context.hasAdminAccess()) { - return false; - } - - return context.hasEditorAccess(); - }, - canUpdate: async (context, node, attributes) => { - if (attributes.type !== 'database' || node.type !== 'database') { - return false; - } - - if (!isEqual(node.attributes.collaborators, attributes.collaborators)) { - return context.hasAdminAccess(); - } - - return context.hasEditorAccess(); - }, - canDelete: async (context, _) => { - return context.hasEditorAccess(); - }, -}; diff --git a/packages/core/src/registry/folder.ts b/packages/core/src/registry/folder.ts index 95486600..7d49462a 100644 --- a/packages/core/src/registry/folder.ts +++ b/packages/core/src/registry/folder.ts @@ -1,7 +1,6 @@ -import { isEqual } from 'lodash-es'; import { z } from 'zod'; -import { EntryModel, entryRoleEnum } from './core'; +import { entryRoleEnum } from './core'; export const folderAttributesSchema = z.object({ type: z.literal('folder'), @@ -12,45 +11,3 @@ export const folderAttributesSchema = z.object({ }); export type FolderAttributes = z.infer; - -export const folderModel: EntryModel = { - type: 'folder', - schema: folderAttributesSchema, - getText: (id, attributes) => { - if (attributes.type !== 'folder') { - return undefined; - } - - return { - id, - name: attributes.name, - text: null, - }; - }, - canCreate: async (context, attributes) => { - if (attributes.type !== 'folder') { - return false; - } - - const collaboratorIds = Object.keys(attributes.collaborators ?? {}); - if (collaboratorIds.length > 0 && !context.hasAdminAccess()) { - return false; - } - - return context.hasEditorAccess(); - }, - canUpdate: async (context, node, attributes) => { - if (attributes.type !== 'folder' || node.type !== 'folder') { - return false; - } - - if (!isEqual(node.attributes.collaborators, attributes.collaborators)) { - return context.hasAdminAccess(); - } - - return context.hasEditorAccess(); - }, - canDelete: async (context, _) => { - return context.hasEditorAccess(); - }, -}; diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index d4741c84..1d860c04 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -1,11 +1,12 @@ -import { ChannelAttributes, channelModel } from './channel'; -import { ChatAttributes, chatModel } from './chat'; -import { EntryModel } from './core'; -import { DatabaseAttributes, databaseModel } from './database'; -import { FolderAttributes, folderModel } from './folder'; -import { PageAttributes, pageModel } from './page'; -import { RecordAttributes, recordModel } from './record'; -import { SpaceAttributes, spaceModel } from './space'; +import { z } from 'zod'; + +import { ChannelAttributes, channelAttributesSchema } from './channel'; +import { ChatAttributes, chatAttributesSchema } from './chat'; +import { DatabaseAttributes, databaseAttributesSchema } from './database'; +import { FolderAttributes, folderAttributesSchema } from './folder'; +import { PageAttributes, pageAttributesSchema } from './page'; +import { RecordAttributes, recordAttributesSchema } from './record'; +import { SpaceAttributes, spaceAttributesSchema } from './space'; type EntryBase = { id: string; @@ -73,27 +74,12 @@ export type Entry = | RecordEntry | SpaceEntry; -class Registry { - private models: Map = new Map(); - - constructor() { - this.models.set('channel', channelModel); - this.models.set('chat', chatModel); - this.models.set('database', databaseModel); - this.models.set('folder', folderModel); - this.models.set('page', pageModel); - this.models.set('record', recordModel); - this.models.set('space', spaceModel); - } - - getModel(type: string): EntryModel { - const model = this.models.get(type); - if (!model) { - throw new Error(`Model for type ${type} not found`); - } - - return model; - } -} - -export const registry = new Registry(); +export const entryAttributesSchema = z.discriminatedUnion('type', [ + channelAttributesSchema, + chatAttributesSchema, + databaseAttributesSchema, + folderAttributesSchema, + pageAttributesSchema, + recordAttributesSchema, + spaceAttributesSchema, +]); diff --git a/packages/core/src/registry/page.ts b/packages/core/src/registry/page.ts index c4ccc948..3f69f6ed 100644 --- a/packages/core/src/registry/page.ts +++ b/packages/core/src/registry/page.ts @@ -1,10 +1,7 @@ -import { isEqual } from 'lodash-es'; import { z } from 'zod'; import { blockSchema } from './block'; -import { EntryModel, entryRoleEnum } from './core'; - -import { extractText } from '../lib/blocks'; +import { entryRoleEnum } from './core'; export const pageAttributesSchema = z.object({ type: z.literal('page'), @@ -16,45 +13,3 @@ export const pageAttributesSchema = z.object({ }); export type PageAttributes = z.infer; - -export const pageModel: EntryModel = { - type: 'page', - schema: pageAttributesSchema, - getText: (id, attributes) => { - if (attributes.type !== 'page') { - return undefined; - } - - return { - id, - name: attributes.name, - text: extractText(id, attributes.content), - }; - }, - canCreate: async (context, attributes) => { - if (attributes.type !== 'page') { - return false; - } - - const collaboratorIds = Object.keys(attributes.collaborators ?? {}); - if (collaboratorIds.length > 0 && !context.hasAdminAccess()) { - return false; - } - - return context.hasEditorAccess(); - }, - canUpdate: async (context, node, attributes) => { - if (attributes.type !== 'page' || node.type !== 'page') { - return false; - } - - if (!isEqual(attributes.collaborators, node.attributes.collaborators)) { - return context.hasAdminAccess(); - } - - return context.hasEditorAccess(); - }, - canDelete: async (context, _) => { - return context.hasEditorAccess(); - }, -}; diff --git a/packages/core/src/registry/record.ts b/packages/core/src/registry/record.ts index db789cb8..6f9295f2 100644 --- a/packages/core/src/registry/record.ts +++ b/packages/core/src/registry/record.ts @@ -1,11 +1,8 @@ import { z } from 'zod'; -import { EntryModel } from './core'; import { blockSchema } from './block'; import { fieldValueSchema } from './fields'; -import { extractText } from '../lib/blocks'; - export const recordAttributesSchema = z.object({ type: z.literal('record'), parentId: z.string(), @@ -17,48 +14,3 @@ export const recordAttributesSchema = z.object({ }); export type RecordAttributes = z.infer; - -export const recordModel: EntryModel = { - type: 'record', - schema: recordAttributesSchema, - getText: (id, attributes) => { - if (attributes.type !== 'record') { - return undefined; - } - - return { - id, - name: attributes.name, - text: extractText(id, attributes.content), - }; - }, - canCreate: async (context, attributes) => { - if (attributes.type !== 'record') { - return false; - } - - return context.hasCollaboratorAccess(); - }, - canUpdate: async (context, node, attributes) => { - if (attributes.type !== 'record' || node.type !== 'record') { - return false; - } - - if (node.createdBy === context.userId) { - return true; - } - - return context.hasEditorAccess(); - }, - canDelete: async (context, node) => { - if (node.type !== 'record') { - return false; - } - - if (node.createdBy === context.userId) { - return true; - } - - return context.hasEditorAccess(); - }, -}; diff --git a/packages/core/src/registry/space.ts b/packages/core/src/registry/space.ts index 8e8613fa..767ba596 100644 --- a/packages/core/src/registry/space.ts +++ b/packages/core/src/registry/space.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import { EntryModel, entryRoleEnum } from './core'; +import { entryRoleEnum } from './core'; export const spaceAttributesSchema = z.object({ type: z.literal('space'), @@ -12,32 +12,3 @@ export const spaceAttributesSchema = z.object({ }); export type SpaceAttributes = z.infer; - -export const spaceModel: EntryModel = { - type: 'space', - schema: spaceAttributesSchema, - getText: (id, attributes) => { - if (attributes.type !== 'space') { - return undefined; - } - - return { - id, - name: attributes.name, - text: null, - }; - }, - canCreate: async (context, __) => { - if (context.workspaceRole === 'guest' || context.workspaceRole === 'none') { - return false; - } - - return true; - }, - canUpdate: async (context, _, __) => { - return context.hasAdminAccess(); - }, - canDelete: async (context, _) => { - return context.hasAdminAccess(); - }, -}; diff --git a/packages/core/src/synchronizers/transactions.ts b/packages/core/src/synchronizers/entry-transactions.ts similarity index 60% rename from packages/core/src/synchronizers/transactions.ts rename to packages/core/src/synchronizers/entry-transactions.ts index 28ac7c67..14fda5fb 100644 --- a/packages/core/src/synchronizers/transactions.ts +++ b/packages/core/src/synchronizers/entry-transactions.ts @@ -1,9 +1,9 @@ -export type SyncTransactionsInput = { - type: 'transactions'; +export type SyncEntryTransactionsInput = { + type: 'entry_transactions'; rootId: string; }; -export type SyncCreateTransactionData = { +export type SyncCreateEntryTransactionData = { id: string; operation: 'create'; entryId: string; @@ -16,7 +16,7 @@ export type SyncCreateTransactionData = { version: string; }; -export type SyncUpdateTransactionData = { +export type SyncUpdateEntryTransactionData = { id: string; operation: 'update'; entryId: string; @@ -29,7 +29,7 @@ export type SyncUpdateTransactionData = { version: string; }; -export type SyncDeleteTransactionData = { +export type SyncDeleteEntryTransactionData = { id: string; operation: 'delete'; entryId: string; @@ -41,16 +41,16 @@ export type SyncDeleteTransactionData = { version: string; }; -export type SyncTransactionData = - | SyncCreateTransactionData - | SyncUpdateTransactionData - | SyncDeleteTransactionData; +export type SyncEntryTransactionData = + | SyncCreateEntryTransactionData + | SyncUpdateEntryTransactionData + | SyncDeleteEntryTransactionData; declare module '@colanode/core' { interface SynchronizerMap { - transactions: { - input: SyncTransactionsInput; - data: SyncTransactionData; + entry_transactions: { + input: SyncEntryTransactionsInput; + data: SyncEntryTransactionData; }; } } diff --git a/packages/core/src/synchronizers/file-tombstones.ts b/packages/core/src/synchronizers/file-tombstones.ts new file mode 100644 index 00000000..c9d4c453 --- /dev/null +++ b/packages/core/src/synchronizers/file-tombstones.ts @@ -0,0 +1,22 @@ +export type SyncFileTombstonesInput = { + type: 'file_tombstones'; + rootId: string; +}; + +export type SyncFileTombstoneData = { + id: string; + rootId: string; + workspaceId: string; + deletedBy: string; + deletedAt: string; + version: string; +}; + +declare module '@colanode/core' { + interface SynchronizerMap { + file_tombstones: { + input: SyncFileTombstonesInput; + data: SyncFileTombstoneData; + }; + } +} diff --git a/packages/core/src/synchronizers/files.ts b/packages/core/src/synchronizers/files.ts index 352cd844..4518ea44 100644 --- a/packages/core/src/synchronizers/files.ts +++ b/packages/core/src/synchronizers/files.ts @@ -22,8 +22,6 @@ export type SyncFileData = { createdBy: string; updatedAt: string | null; updatedBy: string | null; - deletedAt: string | null; - deletedBy: string | null; version: string; }; diff --git a/packages/core/src/synchronizers/index.ts b/packages/core/src/synchronizers/index.ts index ef13ec1c..f162bedc 100644 --- a/packages/core/src/synchronizers/index.ts +++ b/packages/core/src/synchronizers/index.ts @@ -1,5 +1,5 @@ export * from './messages'; -export * from './transactions'; +export * from './entry-transactions'; export * from './users'; export * from './files'; export * from './message-reactions'; @@ -7,6 +7,8 @@ export * from './message-interactions'; export * from './file-interactions'; export * from './entry-interactions'; export * from './collaborations'; +export * from './message-tombstones'; +export * from './file-tombstones'; // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface SynchronizerMap {} diff --git a/packages/core/src/synchronizers/message-tombstones.ts b/packages/core/src/synchronizers/message-tombstones.ts new file mode 100644 index 00000000..c4e074f5 --- /dev/null +++ b/packages/core/src/synchronizers/message-tombstones.ts @@ -0,0 +1,22 @@ +export type SyncMessageTombstonesInput = { + type: 'message_tombstones'; + rootId: string; +}; + +export type SyncMessageTombstoneData = { + id: string; + rootId: string; + workspaceId: string; + deletedAt: string; + deletedBy: string; + version: string; +}; + +declare module '@colanode/core' { + interface SynchronizerMap { + message_tombstones: { + input: SyncMessageTombstonesInput; + data: SyncMessageTombstoneData; + }; + } +} diff --git a/packages/core/src/synchronizers/messages.ts b/packages/core/src/synchronizers/messages.ts index 4371d961..0850da2c 100644 --- a/packages/core/src/synchronizers/messages.ts +++ b/packages/core/src/synchronizers/messages.ts @@ -17,8 +17,6 @@ export type SyncMessageData = { createdBy: string; updatedAt: string | null; updatedBy: string | null; - deletedAt: string | null; - deletedBy: string | null; version: string; }; diff --git a/packages/core/src/types/api.ts b/packages/core/src/types/api.ts new file mode 100644 index 00000000..5c6d791b --- /dev/null +++ b/packages/core/src/types/api.ts @@ -0,0 +1,45 @@ +export type ApiErrorOutput = { + message: string; + code: ApiErrorCode; +}; + +export enum ApiErrorCode { + AccountNotFound = 'account_not_found', + AccountMismatch = 'account_mismatch', + AccountPendingActivation = 'account_pending_activation', + EmailOrPasswordIncorrect = 'email_or_password_incorrect', + GoogleAuthFailed = 'google_auth_failed', + AccountCreationFailed = 'account_creation_failed', + EmailAlreadyExists = 'email_already_exists', + AvatarNotFound = 'avatar_not_found', + AvatarDownloadFailed = 'avatar_download_failed', + AvatarFileNotUploaded = 'avatar_file_not_uploaded', + AvatarUploadFailed = 'avatar_upload_failed', + WorkspaceNameRequired = 'workspace_name_required', + WorkspaceDeleteNotAllowed = 'workspace_delete_not_allowed', + WorkspaceNotFound = 'workspace_not_found', + WorkspaceNoAccess = 'workspace_no_access', + WorkspaceUpdateNotAllowed = 'workspace_update_not_allowed', + WorkspaceUpdateFailed = 'workspace_update_failed', + FileNotFound = 'file_not_found', + FileNoAccess = 'file_no_access', + FileOwnerMismatch = 'file_owner_mismatch', + WorkspaceMismatch = 'workspace_mismatch', + FileError = 'file_error', + FileSizeMismatch = 'file_size_mismatch', + FileMimeTypeMismatch = 'file_mime_type_mismatch', + FileUploadCompleteFailed = 'file_upload_complete_failed', + UserEmailRequired = 'user_email_required', + UserInviteNoAccess = 'user_invite_no_access', + UserUpdateNoAccess = 'user_update_no_access', + UserNotFound = 'user_not_found', + TokenMissing = 'token_missing', + TokenInvalid = 'token_invalid', + RootNotFound = 'root_not_found', + + Unauthorized = 'unauthorized', + Forbidden = 'forbidden', + NotFound = 'not_found', + BadRequest = 'bad_request', + Unknown = 'unknown', +} diff --git a/packages/core/src/types/workspaces.ts b/packages/core/src/types/workspaces.ts index c5c01101..c70d8195 100644 --- a/packages/core/src/types/workspaces.ts +++ b/packages/core/src/types/workspaces.ts @@ -28,7 +28,6 @@ export type WorkspaceOutput = { name: string; description?: string | null; avatar?: string | null; - versionId: string; user: WorkspaceUserOutput; };