mirror of
https://github.com/colanode/colanode.git
synced 2025-12-16 19:57:46 +01:00
Implement workspace delete (#21)
This commit is contained in:
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,6 +57,7 @@ export const Account = ({ account }: AccountProps) => {
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ServerProvider domain={account.server}>
|
||||
<AccountContext.Provider
|
||||
value={{
|
||||
...account,
|
||||
@@ -96,5 +98,6 @@ export const Account = ({ account }: AccountProps) => {
|
||||
/>
|
||||
)}
|
||||
</AccountContext.Provider>
|
||||
</ServerProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
12
apps/desktop/src/renderer/contexts/server.ts
Normal file
12
apps/desktop/src/renderer/contexts/server.ts
Normal 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);
|
||||
28
apps/desktop/src/shared/lib/features.ts
Normal file
28
apps/desktop/src/shared/lib/features.ts
Normal 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);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ export const workspaceGetRoute: FastifyPluginCallbackZod = (
|
||||
) => {
|
||||
instance.route({
|
||||
method: 'GET',
|
||||
url: '/:workspaceId',
|
||||
url: '/',
|
||||
schema: {
|
||||
params: z.object({
|
||||
workspaceId: z.string(),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user