Implement workspace delete (#21)

This commit is contained in:
Hakan Shehu
2025-05-01 19:48:47 +02:00
committed by GitHub
parent b86ae4e5e4
commit 00b91c3e3c
15 changed files with 521 additions and 50 deletions

View File

@@ -65,6 +65,7 @@ import { AccountMetadataSaveMutationHandler } from '@/main/mutations/accounts/ac
import { AccountMetadataDeleteMutationHandler } from '@/main/mutations/accounts/account-metadata-delete'; import { AccountMetadataDeleteMutationHandler } from '@/main/mutations/accounts/account-metadata-delete';
import { EmailPasswordResetInitMutationHandler } from '@/main/mutations/accounts/email-password-reset-init'; import { EmailPasswordResetInitMutationHandler } from '@/main/mutations/accounts/email-password-reset-init';
import { EmailPasswordResetCompleteMutationHandler } from '@/main/mutations/accounts/email-password-reset-complete'; import { EmailPasswordResetCompleteMutationHandler } from '@/main/mutations/accounts/email-password-reset-complete';
import { WorkspaceDeleteMutationHandler } from '@/main/mutations/workspaces/workspace-delete';
import { MutationHandler } from '@/main/lib/types'; import { MutationHandler } from '@/main/lib/types';
import { MutationMap } from '@/shared/mutations'; import { MutationMap } from '@/shared/mutations';
@@ -141,4 +142,5 @@ export const mutationHandlerMap: MutationHandlerMap = {
email_password_reset_init: new EmailPasswordResetInitMutationHandler(), email_password_reset_init: new EmailPasswordResetInitMutationHandler(),
email_password_reset_complete: email_password_reset_complete:
new EmailPasswordResetCompleteMutationHandler(), new EmailPasswordResetCompleteMutationHandler(),
workspace_delete: new WorkspaceDeleteMutationHandler(),
}; };

View File

@@ -0,0 +1,50 @@
import { WorkspaceOutput } from '@colanode/core';
import { appService } from '@/main/services/app-service';
import { MutationHandler } from '@/main/lib/types';
import { parseApiError } from '@/shared/lib/axios';
import { MutationError, MutationErrorCode } from '@/shared/mutations';
import {
WorkspaceDeleteMutationInput,
WorkspaceDeleteMutationOutput,
} from '@/shared/mutations/workspaces/workspace-delete';
export class WorkspaceDeleteMutationHandler
implements MutationHandler<WorkspaceDeleteMutationInput>
{
async handleMutation(
input: WorkspaceDeleteMutationInput
): Promise<WorkspaceDeleteMutationOutput> {
const accountService = appService.getAccount(input.accountId);
if (!accountService) {
throw new MutationError(
MutationErrorCode.AccountNotFound,
'Account not found or has been logged out.'
);
}
const workspaceService = accountService.getWorkspace(input.workspaceId);
if (!workspaceService) {
throw new MutationError(
MutationErrorCode.WorkspaceNotFound,
'Workspace not found.'
);
}
try {
const { data } = await accountService.client.delete<WorkspaceOutput>(
`/v1/workspaces/${input.workspaceId}`
);
await accountService.deleteWorkspace(data.id);
return {
id: data.id,
};
} catch (error) {
const apiError = parseApiError(error);
throw new MutationError(MutationErrorCode.ApiError, apiError.message);
}
}
}

View File

@@ -22,7 +22,14 @@ export class RadarDataGetQueryHandler
_: RadarDataGetQueryInput, _: RadarDataGetQueryInput,
___: RadarDataGetQueryOutput ___: RadarDataGetQueryOutput
): Promise<ChangeCheckResult<RadarDataGetQueryInput>> { ): Promise<ChangeCheckResult<RadarDataGetQueryInput>> {
if (event.type === 'radar_data_updated') { const shouldUpdate =
event.type === 'radar_data_updated' ||
event.type === 'workspace_created' ||
event.type === 'workspace_deleted' ||
event.type === 'account_created' ||
event.type === 'account_deleted';
if (shouldUpdate) {
const data = this.getRadarData(); const data = this.getRadarData();
return { return {
hasChanges: true, hasChanges: true,

View File

@@ -47,6 +47,10 @@ export class ServerService {
return this.server.domain; return this.server.domain;
} }
public get version() {
return this.server.version;
}
private async sync() { private async sync() {
const config = await ServerService.fetchServerConfig(this.server.domain); const config = await ServerService.fetchServerConfig(this.server.domain);
const existingState = this.state; const existingState = this.state;

View File

@@ -7,6 +7,7 @@ import { Account as AccountType } from '@/shared/types/accounts';
import { useQuery } from '@/renderer/hooks/use-query'; import { useQuery } from '@/renderer/hooks/use-query';
import { WorkspaceCreate } from '@/renderer/components/workspaces/workspace-create'; import { WorkspaceCreate } from '@/renderer/components/workspaces/workspace-create';
import { Workspace } from '@/renderer/components/workspaces/workspace'; import { Workspace } from '@/renderer/components/workspaces/workspace';
import { ServerProvider } from '@/renderer/components/servers/server-provider';
interface AccountProps { interface AccountProps {
account: AccountType; account: AccountType;
@@ -56,45 +57,47 @@ export const Account = ({ account }: AccountProps) => {
: undefined; : undefined;
return ( return (
<AccountContext.Provider <ServerProvider domain={account.server}>
value={{ <AccountContext.Provider
...account, value={{
openSettings: () => setOpenSettings(true), ...account,
openLogout: () => setOpenLogout(true), openSettings: () => setOpenSettings(true),
openWorkspaceCreate: () => setOpenCreateWorkspace(true), openLogout: () => setOpenLogout(true),
openWorkspace: (id) => { openWorkspaceCreate: () => setOpenCreateWorkspace(true),
setOpenCreateWorkspace(false); openWorkspace: (id) => {
window.colanode.executeMutation({ setOpenCreateWorkspace(false);
type: 'account_metadata_save', window.colanode.executeMutation({
accountId: account.id, type: 'account_metadata_save',
key: 'workspace', accountId: account.id,
value: id, key: 'workspace',
}); value: id,
}, });
}} },
> }}
{!openCreateWorkspace && workspace ? ( >
<Workspace workspace={workspace} /> {!openCreateWorkspace && workspace ? (
) : ( <Workspace workspace={workspace} />
<WorkspaceCreate ) : (
onSuccess={handleWorkspaceCreateSuccess} <WorkspaceCreate
onCancel={handleWorkspaceCreateCancel} onSuccess={handleWorkspaceCreateSuccess}
/> onCancel={handleWorkspaceCreateCancel}
)} />
{openSettings && ( )}
<AccountSettingsDialog {openSettings && (
open={true} <AccountSettingsDialog
onOpenChange={() => setOpenSettings(false)} open={true}
/> onOpenChange={() => setOpenSettings(false)}
)} />
{openLogout && ( )}
<AccountLogout {openLogout && (
onCancel={() => setOpenLogout(false)} <AccountLogout
onLogout={() => { onCancel={() => setOpenLogout(false)}
setOpenLogout(false); onLogout={() => {
}} setOpenLogout(false);
/> }}
)} />
</AccountContext.Provider> )}
</AccountContext.Provider>
</ServerProvider>
); );
}; };

View File

@@ -0,0 +1,20 @@
import { BadgeAlert } from 'lucide-react';
interface ServerNotFoundProps {
domain: string;
}
export const ServerNotFound = ({ domain }: ServerNotFoundProps) => {
return (
<div className="flex flex-col items-center justify-center h-full p-6 text-center">
<BadgeAlert className="size-12 mb-4" />
<h1 className="text-2xl font-semibold tracking-tight">
Server not found
</h1>
<p className="mt-2 text-sm font-medium text-muted-foreground">
The server {domain} does not exist. It may have been deleted from your
app or the data has been lost.
</p>
</div>
);
};

View File

@@ -0,0 +1,38 @@
import { ServerContext } from '@/renderer/contexts/server';
import { useQuery } from '@/renderer/hooks/use-query';
import { ServerNotFound } from '@/renderer/components/servers/server-not-found';
import { isFeatureSupported } from '@/shared/lib/features';
interface ServerProviderProps {
domain: string;
children: React.ReactNode;
}
export const ServerProvider = ({ domain, children }: ServerProviderProps) => {
const { data, isPending } = useQuery({
type: 'server_list',
});
const server = data?.find((server) => server.domain === domain);
if (isPending) {
return null;
}
if (!server) {
return <ServerNotFound domain={domain} />;
}
return (
<ServerContext.Provider
value={{
...server,
supports: (feature) => {
return isFeatureSupported(feature, server.version);
},
}}
>
{children}
</ServerContext.Provider>
);
};

View File

@@ -0,0 +1,115 @@
import React from 'react';
import { Button } from '@/renderer/components/ui/button';
import { useWorkspace } from '@/renderer/contexts/workspace';
import { useMutation } from '@/renderer/hooks/use-mutation';
import { toast } from '@/renderer/hooks/use-toast';
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/renderer/components/ui/alert-dialog';
import { Spinner } from '@/renderer/components/ui/spinner';
import { useServer } from '@/renderer/contexts/server';
interface WorkspaceDeleteProps {
onDeleted: () => void;
}
export const WorkspaceDelete = ({ onDeleted }: WorkspaceDeleteProps) => {
const server = useServer();
const workspace = useWorkspace();
const { mutate, isPending } = useMutation();
const [showDeleteModal, setShowDeleteModal] = React.useState(false);
const isDeleteSupported = server.supports('workspace-delete');
if (!isDeleteSupported) {
return (
<div className="flex flex-col gap-4">
<h3 className="font-heading mb-px text-2xl font-semibold tracking-tight">
Delete workspace
</h3>
<p>
This feature is not supported on the server this workspace is hosted
on. Please contact your administrator to upgrade the server.
</p>
</div>
);
}
return (
<div className="flex flex-col gap-4">
<h3 className="font-heading mb-px text-2xl font-semibold tracking-tight">
Delete workspace
</h3>
<p>Deleting a workspace is permanent and cannot be undone.</p>
<p>
All data associated with the workspace will be deleted, including users,
chats, messages, pages, channels, databases, records, files and more.
</p>
<div>
<Button
variant="destructive"
onClick={() => {
setShowDeleteModal(true);
}}
>
Delete
</Button>
</div>
<AlertDialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure you want delete this workspace?
</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This workspace will no longer be
accessible by you or other users that are part of it.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button
variant="destructive"
disabled={isPending}
onClick={() => {
mutate({
input: {
type: 'workspace_delete',
accountId: workspace.accountId,
workspaceId: workspace.id,
},
onSuccess() {
setShowDeleteModal(false);
onDeleted();
toast({
title: 'Workspace deleted',
description: 'Workspace was deleted successfully',
variant: 'default',
});
},
onError(error) {
toast({
title: 'Failed to delete workspace',
description: error.message,
variant: 'destructive',
});
},
});
}}
>
{isPending && <Spinner className="mr-1" />}
Delete
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};

View File

@@ -23,6 +23,7 @@ import {
import { WorkspaceUpdate } from '@/renderer/components/workspaces/workspace-update'; import { WorkspaceUpdate } from '@/renderer/components/workspaces/workspace-update';
import { WorkspaceUsers } from '@/renderer/components/workspaces/workspace-users'; import { WorkspaceUsers } from '@/renderer/components/workspaces/workspace-users';
import { useWorkspace } from '@/renderer/contexts/workspace'; import { useWorkspace } from '@/renderer/contexts/workspace';
import { WorkspaceDelete } from '@/renderer/components/workspaces/workspace-delete';
interface WorkspaceSettingsDialogProps { interface WorkspaceSettingsDialogProps {
open: boolean; open: boolean;
@@ -98,6 +99,7 @@ export const WorkspaceSettingsDialog = ({
<SidebarMenuButton <SidebarMenuButton
isActive={tab === 'delete'} isActive={tab === 'delete'}
onClick={() => setTab('delete')} onClick={() => setTab('delete')}
disabled={workspace.role !== 'owner'}
> >
<Trash2 className="mr-2 size-4" /> <Trash2 className="mr-2 size-4" />
<span>Delete</span> <span>Delete</span>
@@ -113,7 +115,9 @@ export const WorkspaceSettingsDialog = ({
{match(tab) {match(tab)
.with('info', () => <WorkspaceUpdate />) .with('info', () => <WorkspaceUpdate />)
.with('users', () => <WorkspaceUsers />) .with('users', () => <WorkspaceUsers />)
.with('delete', () => <p>Coming soon.</p>) .with('delete', () => (
<WorkspaceDelete onDeleted={() => onOpenChange(false)} />
))
.exhaustive()} .exhaustive()}
</div> </div>
</SidebarProvider> </SidebarProvider>

View File

@@ -0,0 +1,12 @@
import { createContext, useContext } from 'react';
import { FeatureKey } from '@/shared/lib/features';
import { Server } from '@/shared/types/servers';
interface ServerContext extends Server {
supports(feature: FeatureKey): boolean;
}
export const ServerContext = createContext<ServerContext>({} as ServerContext);
export const useServer = () => useContext(ServerContext);

View File

@@ -0,0 +1,28 @@
import semver from 'semver';
export const FeatureVersions = {
'workspace-delete': semver.parse('0.1.5'),
} as const;
export type FeatureKey = keyof typeof FeatureVersions;
export const isFeatureSupported = (
feature: FeatureKey,
version: string
): boolean => {
if (version === 'dev') {
return true;
}
const parsedVersion = semver.parse(version);
if (!parsedVersion) {
return false;
}
const featureVersion = FeatureVersions[feature];
if (!featureVersion) {
return false;
}
return semver.gte(featureVersion, parsedVersion);
};

View File

@@ -0,0 +1,18 @@
export type WorkspaceDeleteMutationInput = {
type: 'workspace_delete';
accountId: string;
workspaceId: string;
};
export type WorkspaceDeleteMutationOutput = {
id: string;
};
declare module '@/shared/mutations' {
interface MutationMap {
workspace_delete: {
input: WorkspaceDeleteMutationInput;
output: WorkspaceDeleteMutationOutput;
};
}
}

View File

@@ -1,9 +1,14 @@
import { FastifyPluginCallbackZod } from 'fastify-type-provider-zod'; import { FastifyPluginCallbackZod } from 'fastify-type-provider-zod';
import { z } from 'zod'; import { z } from 'zod';
import { ApiErrorCode, apiErrorOutputSchema } from '@colanode/core'; import {
ApiErrorCode,
apiErrorOutputSchema,
workspaceOutputSchema,
} from '@colanode/core';
import { database } from '@/data/database'; import { database } from '@/data/database';
import { eventBus } from '@/lib/event-bus'; import { eventBus } from '@/lib/event-bus';
import { jobService } from '@/services/job-service';
export const workspaceDeleteRoute: FastifyPluginCallbackZod = ( export const workspaceDeleteRoute: FastifyPluginCallbackZod = (
instance, instance,
@@ -12,13 +17,13 @@ export const workspaceDeleteRoute: FastifyPluginCallbackZod = (
) => { ) => {
instance.route({ instance.route({
method: 'DELETE', method: 'DELETE',
url: '/:workspaceId', url: '/',
schema: { schema: {
params: z.object({ params: z.object({
workspaceId: z.string(), workspaceId: z.string(),
}), }),
response: { response: {
200: z.object({ id: z.string() }), 200: workspaceOutputSchema,
400: apiErrorOutputSchema, 400: apiErrorOutputSchema,
403: apiErrorOutputSchema, 403: apiErrorOutputSchema,
404: apiErrorOutputSchema, 404: apiErrorOutputSchema,
@@ -35,10 +40,34 @@ export const workspaceDeleteRoute: FastifyPluginCallbackZod = (
}); });
} }
await database const workspace = await database
.deleteFrom('workspaces') .deleteFrom('workspaces')
.returningAll()
.where('id', '=', workspaceId) .where('id', '=', workspaceId)
.execute(); .executeTakeFirst();
await jobService.addJob(
{
type: 'clean_workspace_data',
workspaceId: workspaceId,
},
{
jobId: `clean_workspace_data_${workspaceId}`,
attempts: 5,
backoff: {
type: 'exponential',
delay: 1000,
},
delay: 1000,
}
);
if (!workspace) {
return reply.code(404).send({
code: ApiErrorCode.WorkspaceNotFound,
message: 'Workspace not found.',
});
}
eventBus.publish({ eventBus.publish({
type: 'workspace_deleted', type: 'workspace_deleted',
@@ -46,7 +75,17 @@ export const workspaceDeleteRoute: FastifyPluginCallbackZod = (
}); });
return { return {
id: workspaceId, id: workspace.id,
name: workspace.name,
description: workspace.description,
avatar: workspace.avatar,
user: {
id: request.user.id,
accountId: request.user.account_id,
role: request.user.role,
storageLimit: request.user.storage_limit,
maxFileSize: request.user.max_file_size,
},
}; };
}, },
}); });

View File

@@ -18,7 +18,7 @@ export const workspaceGetRoute: FastifyPluginCallbackZod = (
) => { ) => {
instance.route({ instance.route({
method: 'GET', method: 'GET',
url: '/:workspaceId', url: '/',
schema: { schema: {
params: z.object({ params: z.object({
workspaceId: z.string(), workspaceId: z.string(),

View File

@@ -1,4 +1,6 @@
import { JobHandler } from '@/types/jobs'; import { JobHandler } from '@/types/jobs';
import { database } from '@/data/database';
import { deleteFile } from '@/lib/files';
export type CleanWorkspaceDataInput = { export type CleanWorkspaceDataInput = {
type: 'clean_workspace_data'; type: 'clean_workspace_data';
@@ -13,8 +15,137 @@ declare module '@/types/jobs' {
} }
} }
const BATCH_SIZE = 500;
export const cleanWorkspaceDataHandler: JobHandler< export const cleanWorkspaceDataHandler: JobHandler<
CleanWorkspaceDataInput CleanWorkspaceDataInput
> = async (input) => { > = async (input) => {
console.log(input); try {
await deleteWorkspaceUsers(input.workspaceId);
await deleteWorkspaceNodes(input.workspaceId);
await deleteWorkspaceUploads(input.workspaceId);
} catch (error) {
console.error('Error cleaning workspace data:', error);
throw error;
}
};
const deleteWorkspaceUsers = async (workspaceId: string) => {
let hasMore = true;
while (hasMore) {
const result = await database
.deleteFrom('users')
.returning(['id'])
.where(
'id',
'in',
database
.selectFrom('users')
.select('id')
.where('workspace_id', '=', workspaceId)
.limit(BATCH_SIZE)
)
.execute();
if (result.length === 0) {
hasMore = false;
break;
}
}
};
const deleteWorkspaceNodes = async (workspaceId: string) => {
let hasMore = true;
while (hasMore) {
const nodes = await database
.selectFrom('nodes')
.select('id')
.where('workspace_id', '=', workspaceId)
.limit(BATCH_SIZE)
.execute();
const nodeIds = nodes.map((node) => node.id);
if (nodeIds.length === 0) {
hasMore = false;
break;
}
// delete node updates
await database
.deleteFrom('node_updates')
.where('node_id', 'in', nodeIds)
.execute();
await database
.deleteFrom('node_reactions')
.where('node_id', 'in', nodeIds)
.execute();
await database
.deleteFrom('node_interactions')
.where('node_id', 'in', nodeIds)
.execute();
await database
.deleteFrom('node_tombstones')
.where('id', 'in', nodeIds)
.execute();
await database
.deleteFrom('node_embeddings')
.where('node_id', 'in', nodeIds)
.execute();
await database
.deleteFrom('collaborations')
.where('node_id', 'in', nodeIds)
.execute();
await database
.deleteFrom('document_embeddings')
.where('document_id', 'in', nodeIds)
.execute();
await database
.deleteFrom('document_updates')
.where('document_id', 'in', nodeIds)
.execute();
await database
.deleteFrom('document_embeddings')
.where('document_id', 'in', nodeIds)
.execute();
await database.deleteFrom('nodes').where('id', 'in', nodeIds).execute();
}
};
const deleteWorkspaceUploads = async (workspaceId: string) => {
let hasMore = true;
while (hasMore) {
const uploads = await database
.selectFrom('uploads')
.select(['file_id', 'path'])
.where('workspace_id', '=', workspaceId)
.limit(BATCH_SIZE)
.execute();
if (uploads.length === 0) {
hasMore = false;
break;
}
for (const upload of uploads) {
await deleteFile(upload.path);
}
const fileIds = uploads.map((upload) => upload.file_id);
await database
.deleteFrom('uploads')
.where('file_id', 'in', fileIds)
.execute();
}
}; };