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 { EmailPasswordResetInitMutationHandler } from '@/main/mutations/accounts/email-password-reset-init';
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 { MutationMap } from '@/shared/mutations';
@@ -141,4 +142,5 @@ export const mutationHandlerMap: MutationHandlerMap = {
email_password_reset_init: new EmailPasswordResetInitMutationHandler(),
email_password_reset_complete:
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,
___: RadarDataGetQueryOutput
): 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();
return {
hasChanges: true,

View File

@@ -47,6 +47,10 @@ export class ServerService {
return this.server.domain;
}
public get version() {
return this.server.version;
}
private async sync() {
const config = await ServerService.fetchServerConfig(this.server.domain);
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 { WorkspaceCreate } from '@/renderer/components/workspaces/workspace-create';
import { Workspace } from '@/renderer/components/workspaces/workspace';
import { ServerProvider } from '@/renderer/components/servers/server-provider';
interface AccountProps {
account: AccountType;
@@ -56,45 +57,47 @@ export const Account = ({ account }: AccountProps) => {
: undefined;
return (
<AccountContext.Provider
value={{
...account,
openSettings: () => setOpenSettings(true),
openLogout: () => setOpenLogout(true),
openWorkspaceCreate: () => setOpenCreateWorkspace(true),
openWorkspace: (id) => {
setOpenCreateWorkspace(false);
window.colanode.executeMutation({
type: 'account_metadata_save',
accountId: account.id,
key: 'workspace',
value: id,
});
},
}}
>
{!openCreateWorkspace && workspace ? (
<Workspace workspace={workspace} />
) : (
<WorkspaceCreate
onSuccess={handleWorkspaceCreateSuccess}
onCancel={handleWorkspaceCreateCancel}
/>
)}
{openSettings && (
<AccountSettingsDialog
open={true}
onOpenChange={() => setOpenSettings(false)}
/>
)}
{openLogout && (
<AccountLogout
onCancel={() => setOpenLogout(false)}
onLogout={() => {
setOpenLogout(false);
}}
/>
)}
</AccountContext.Provider>
<ServerProvider domain={account.server}>
<AccountContext.Provider
value={{
...account,
openSettings: () => setOpenSettings(true),
openLogout: () => setOpenLogout(true),
openWorkspaceCreate: () => setOpenCreateWorkspace(true),
openWorkspace: (id) => {
setOpenCreateWorkspace(false);
window.colanode.executeMutation({
type: 'account_metadata_save',
accountId: account.id,
key: 'workspace',
value: id,
});
},
}}
>
{!openCreateWorkspace && workspace ? (
<Workspace workspace={workspace} />
) : (
<WorkspaceCreate
onSuccess={handleWorkspaceCreateSuccess}
onCancel={handleWorkspaceCreateCancel}
/>
)}
{openSettings && (
<AccountSettingsDialog
open={true}
onOpenChange={() => setOpenSettings(false)}
/>
)}
{openLogout && (
<AccountLogout
onCancel={() => setOpenLogout(false)}
onLogout={() => {
setOpenLogout(false);
}}
/>
)}
</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 { WorkspaceUsers } from '@/renderer/components/workspaces/workspace-users';
import { useWorkspace } from '@/renderer/contexts/workspace';
import { WorkspaceDelete } from '@/renderer/components/workspaces/workspace-delete';
interface WorkspaceSettingsDialogProps {
open: boolean;
@@ -98,6 +99,7 @@ export const WorkspaceSettingsDialog = ({
<SidebarMenuButton
isActive={tab === 'delete'}
onClick={() => setTab('delete')}
disabled={workspace.role !== 'owner'}
>
<Trash2 className="mr-2 size-4" />
<span>Delete</span>
@@ -113,7 +115,9 @@ export const WorkspaceSettingsDialog = ({
{match(tab)
.with('info', () => <WorkspaceUpdate />)
.with('users', () => <WorkspaceUsers />)
.with('delete', () => <p>Coming soon.</p>)
.with('delete', () => (
<WorkspaceDelete onDeleted={() => onOpenChange(false)} />
))
.exhaustive()}
</div>
</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 { z } from 'zod';
import { ApiErrorCode, apiErrorOutputSchema } from '@colanode/core';
import {
ApiErrorCode,
apiErrorOutputSchema,
workspaceOutputSchema,
} from '@colanode/core';
import { database } from '@/data/database';
import { eventBus } from '@/lib/event-bus';
import { jobService } from '@/services/job-service';
export const workspaceDeleteRoute: FastifyPluginCallbackZod = (
instance,
@@ -12,13 +17,13 @@ export const workspaceDeleteRoute: FastifyPluginCallbackZod = (
) => {
instance.route({
method: 'DELETE',
url: '/:workspaceId',
url: '/',
schema: {
params: z.object({
workspaceId: z.string(),
}),
response: {
200: z.object({ id: z.string() }),
200: workspaceOutputSchema,
400: apiErrorOutputSchema,
403: apiErrorOutputSchema,
404: apiErrorOutputSchema,
@@ -35,10 +40,34 @@ export const workspaceDeleteRoute: FastifyPluginCallbackZod = (
});
}
await database
const workspace = await database
.deleteFrom('workspaces')
.returningAll()
.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({
type: 'workspace_deleted',
@@ -46,7 +75,17 @@ export const workspaceDeleteRoute: FastifyPluginCallbackZod = (
});
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({
method: 'GET',
url: '/:workspaceId',
url: '/',
schema: {
params: z.object({
workspaceId: z.string(),

View File

@@ -1,4 +1,6 @@
import { JobHandler } from '@/types/jobs';
import { database } from '@/data/database';
import { deleteFile } from '@/lib/files';
export type CleanWorkspaceDataInput = {
type: 'clean_workspace_data';
@@ -13,8 +15,137 @@ declare module '@/types/jobs' {
}
}
const BATCH_SIZE = 500;
export const cleanWorkspaceDataHandler: JobHandler<
CleanWorkspaceDataInput
> = 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();
}
};