diff --git a/Cargo.lock b/Cargo.lock index 033f642..8bead4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "OxideChat" -version = "0.1.4" +version = "0.1.6" dependencies = [ "aes-gcm", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 8f26704..c9fb113 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "OxideChat" -version = "0.1.5" +version = "0.1.6" edition = "2024" description = "A modern, performance and on customization focused AI chat application" license = "MIT" diff --git a/frontend/components/AppSidebar.vue b/frontend/components/AppSidebar.vue index 98c8c83..74ab08e 100644 --- a/frontend/components/AppSidebar.vue +++ b/frontend/components/AppSidebar.vue @@ -53,11 +53,17 @@ class="text-popover-foreground focus:bg-accent" @click="chatStore.setActiveWorkspace(workspace.id)" > + {{ workspace.name }} {{ workspace.chat_count }} + + + + {{ store.getTranslation('sidebar.manage_workspaces') }} + @@ -74,10 +80,12 @@ + + diff --git a/frontend/components/workspace/WorkspaceManagerDialog.vue b/frontend/components/workspace/WorkspaceManagerDialog.vue new file mode 100644 index 0000000..ea74d61 --- /dev/null +++ b/frontend/components/workspace/WorkspaceManagerDialog.vue @@ -0,0 +1,163 @@ + + + diff --git a/frontend/lib/apply-theme.ts b/frontend/lib/apply-theme.ts index 67de6fd..159d2c0 100644 --- a/frontend/lib/apply-theme.ts +++ b/frontend/lib/apply-theme.ts @@ -53,3 +53,32 @@ export function getSystemTheme(): ThemeMode { if (typeof window === 'undefined') return 'light'; return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } + +const ACCENT_VARS = ['--primary', '--primary-foreground', '--ring', '--sidebar-primary', '--sidebar-primary-foreground']; + +function readableForeground(hex: string): string { + const normalized = hex.replace('#', ''); + const full = normalized.length === 3 ? normalized.split('').map(c => c + c).join('') : normalized; + if (full.length !== 6) return 'rgb(255, 255, 255)'; + const r = parseInt(full.slice(0, 2), 16) / 255; + const g = parseInt(full.slice(2, 4), 16) / 255; + const b = parseInt(full.slice(4, 6), 16) / 255; + const channel = (c: number) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)); + const luminance = 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); + return luminance > 0.45 ? 'rgb(23, 23, 23)' : 'rgb(255, 255, 255)'; +} + +export function clearAccentFromElement(element: HTMLElement) { + if (!element) return; + ACCENT_VARS.forEach(prop => element.style.removeProperty(prop)); +} + +export function applyAccentToElement(color: string | null, element: HTMLElement) { + if (!element || !color) return; + const foreground = readableForeground(color); + element.style.setProperty('--primary', color); + element.style.setProperty('--primary-foreground', foreground); + element.style.setProperty('--ring', color); + element.style.setProperty('--sidebar-primary', color); + element.style.setProperty('--sidebar-primary-foreground', foreground); +} diff --git a/frontend/stores/chatStore.ts b/frontend/stores/chatStore.ts index e8375e4..6f24e31 100644 --- a/frontend/stores/chatStore.ts +++ b/frontend/stores/chatStore.ts @@ -1,5 +1,6 @@ import {defineStore} from 'pinia'; import {useMainStore} from './index'; +import {useThemeStore} from './theme'; import type { Workspace, Chat, @@ -9,6 +10,7 @@ import type { ModelList, CreateWorkspaceRequest, UpdateWorkspaceRequest, + DeleteWorkspaceOptions, CreateChatRequest, UpdateChatRequest, UpdatePreferencesRequest, @@ -17,6 +19,25 @@ import type { PaginatedResponse, } from '~/types/chat'; +const ACTIVE_WORKSPACE_KEY = 'oxide-active-workspace'; + +function loadActiveWorkspaceId(): string | null { + if (typeof window === 'undefined') return null; + try { + return localStorage.getItem(ACTIVE_WORKSPACE_KEY); + } catch { + return null; + } +} + +function persistActiveWorkspaceId(id: string | null) { + if (typeof window === 'undefined') return; + try { + if (id) localStorage.setItem(ACTIVE_WORKSPACE_KEY, id); + else localStorage.removeItem(ACTIVE_WORKSPACE_KEY); + } catch {} +} + interface ChatState { workspaces: Workspace[]; activeWorkspaceId: string | null; @@ -194,10 +215,17 @@ export const useChatStore = defineStore('chat', { return workspace as Workspace; } catch (e) { console.error('Failed to create workspace:', e); + this.notifyWorkspaceError(e, 'workspace.create_failed'); return null; } }, + notifyWorkspaceError(e: any, fallbackKey: string) { + const mainStore = useMainStore(); + const message = e?.data?.errors?.[0]?.message || mainStore.getTranslation(fallbackKey); + mainStore.toast(message, {type: 'error'}); + }, + async updateWorkspace(id: string, data: UpdateWorkspaceRequest): Promise { try { const {$customFetch} = useNuxtApp(); @@ -207,31 +235,49 @@ export const useChatStore = defineStore('chat', { }); const index = this.workspaces.findIndex(w => w.id === id); if (index !== -1) this.workspaces[index] = updated as Workspace; + if (this.activeWorkspaceId === id) this.applyWorkspaceAccent(); return true; } catch (e) { console.error('Failed to update workspace:', e); + this.notifyWorkspaceError(e, 'workspace.update_failed'); return false; } }, - async deleteWorkspace(id: string): Promise { + async deleteWorkspace(id: string, opts: DeleteWorkspaceOptions): Promise { try { const {$customFetch} = useNuxtApp(); - await $customFetch(`/api/v1/workspaces/${id}`, {method: 'DELETE'}); + const query = new URLSearchParams({action: opts.action}); + if (opts.action === 'move' && opts.target_workspace_id) query.set('target_workspace_id', opts.target_workspace_id); + await $customFetch(`/api/v1/workspaces/${id}?${query.toString()}`, {method: 'DELETE'}); this.workspaces = this.workspaces.filter(w => w.id !== id); - if (this.activeWorkspaceId === id) this.activeWorkspaceId = null; + if (this.activeWorkspaceId === id) { + this.activeWorkspaceId = null; + persistActiveWorkspaceId(null); + this.applyWorkspaceAccent(); + } + await this.fetchWorkspaces(); + await this.fetchChats({workspace_id: this.activeWorkspaceId || undefined}); return true; } catch (e) { console.error('Failed to delete workspace:', e); + this.notifyWorkspaceError(e, 'workspace.delete_failed'); return false; } }, setActiveWorkspace(id: string | null) { this.activeWorkspaceId = id; + persistActiveWorkspaceId(id); + this.applyWorkspaceAccent(); this.fetchChats({workspace_id: id || undefined}); }, + applyWorkspaceAccent() { + const active = this.activeWorkspaceId ? this.workspaces.find(w => w.id === this.activeWorkspaceId) : undefined; + useThemeStore().setWorkspaceAccent(active?.color ?? null); + }, + async fetchChats(params?: ChatListParams) { this.chatsLoading = true; try { @@ -824,7 +870,14 @@ export const useChatStore = defineStore('chat', { async init() { this.initialized = false; - await Promise.all([this.fetchWorkspaces(), this.fetchChats(), this.fetchPreferences()]); + this.activeWorkspaceId = loadActiveWorkspaceId(); + await Promise.all([this.fetchWorkspaces(), this.fetchPreferences()]); + if (this.activeWorkspaceId && !this.workspaces.some(w => w.id === this.activeWorkspaceId)) { + this.activeWorkspaceId = null; + persistActiveWorkspaceId(null); + } + await this.fetchChats({workspace_id: this.activeWorkspaceId || undefined}); + this.applyWorkspaceAccent(); await this.fetchModels(); this.initialized = true; }, diff --git a/frontend/stores/theme.ts b/frontend/stores/theme.ts index 13658e8..9213f33 100644 --- a/frontend/stores/theme.ts +++ b/frontend/stores/theme.ts @@ -1,6 +1,6 @@ import {defineStore} from 'pinia'; import type {ThemeCssVars, FetchedTheme, GlobalConfig} from '~/types/chat'; -import {applyThemeToElement, clearThemeFromElement, getSystemTheme, type ThemeMode, type ThemeState} from '~/lib/apply-theme'; +import {applyThemeToElement, applyAccentToElement, clearAccentFromElement, clearThemeFromElement, getSystemTheme, type ThemeMode, type ThemeState} from '~/lib/apply-theme'; import {fetchThemeFromUrl, THEME_URLS} from '~/lib/theme-utils'; const THEME_STORE_KEY = 'oxide-theme-store'; @@ -49,6 +49,7 @@ export const useThemeStore = defineStore('theme', { fetchedThemes: [] as FetchedTheme[], customThemeUrls: [] as string[], isLoadingThemes: false, + workspaceAccent: null as string | null, }; }, @@ -118,9 +119,16 @@ export const useThemeStore = defineStore('theme', { this.apply(); }, + setWorkspaceAccent(color: string | null) { + this.workspaceAccent = color; + this.apply(); + }, + apply() { if (typeof document === 'undefined') return; + clearAccentFromElement(document.body); applyThemeToElement(this.themeState, document.body); + applyAccentToElement(this.workspaceAccent, document.body); }, persist() { diff --git a/frontend/types/chat.ts b/frontend/types/chat.ts index a9a3c78..26a3974 100644 --- a/frontend/types/chat.ts +++ b/frontend/types/chat.ts @@ -130,11 +130,18 @@ export interface CreateWorkspaceRequest { export interface UpdateWorkspaceRequest { name?: string; icon?: string; - color?: string; + color?: string | null; sort_order?: number; is_default?: boolean; } +export type WorkspaceDeleteAction = 'move' | 'archive' | 'delete'; + +export interface DeleteWorkspaceOptions { + action: WorkspaceDeleteAction; + target_workspace_id?: string; +} + export interface CreateChatRequest { workspace_id?: string; title?: string; @@ -143,7 +150,7 @@ export interface CreateChatRequest { export interface UpdateChatRequest { title?: string; - workspace_id?: string; + workspace_id?: string | null; is_pinned?: boolean; is_archived?: boolean; } diff --git a/migrations/20260701000000_workspace_management_i18n.sql b/migrations/20260701000000_workspace_management_i18n.sql new file mode 100644 index 0000000..cb75678 --- /dev/null +++ b/migrations/20260701000000_workspace_management_i18n.sql @@ -0,0 +1,59 @@ +-- UI translations for workspace management (create / rename / delete dialogs). +INSERT INTO i18n_translations (language, key_path, value) VALUES + ('en', 'sidebar.manage_workspaces', 'Manage workspaces'), + ('en', 'workspace.manage_title', 'Workspaces'), + ('en', 'workspace.manage_description', 'Create, rename, recolor, and delete your workspaces.'), + ('en', 'workspace.create', 'Create workspace'), + ('en', 'workspace.rename', 'Rename'), + ('en', 'workspace.delete', 'Delete'), + ('en', 'workspace.save', 'Save'), + ('en', 'workspace.name', 'Name'), + ('en', 'workspace.name_placeholder', 'Workspace name'), + ('en', 'workspace.color', 'Accent color'), + ('en', 'workspace.color_none', 'No color'), + ('en', 'workspace.set_default', 'Set as default'), + ('en', 'workspace.default_badge', 'Default'), + ('en', 'workspace.chat_count', '{count} chats'), + ('en', 'workspace.empty', 'No workspaces yet.'), + ('en', 'workspace.cannot_delete_default', 'The default workspace cannot be deleted.'), + ('en', 'workspace.delete_title', 'Delete workspace'), + ('en', 'workspace.delete_description', 'Choose what happens to the chats in "{name}".'), + ('en', 'workspace.delete_action_move', 'Move chats to another workspace'), + ('en', 'workspace.delete_action_archive', 'Archive the chats'), + ('en', 'workspace.delete_action_delete', 'Delete the chats permanently'), + ('en', 'workspace.delete_move_target', 'Move to'), + ('en', 'workspace.confirm', 'Confirm'), + ('en', 'workspace.cancel', 'Cancel'), + ('en', 'workspace.create_failed', 'Could not create the workspace.'), + ('en', 'workspace.update_failed', 'Could not update the workspace.'), + ('en', 'workspace.delete_failed', 'Could not delete the workspace.'), + ('en', 'chat.context_menu.no_workspace', 'No workspace'), + ('de', 'sidebar.manage_workspaces', 'Arbeitsbereiche verwalten'), + ('de', 'workspace.manage_title', 'Arbeitsbereiche'), + ('de', 'workspace.manage_description', 'Arbeitsbereiche erstellen, umbenennen, einfärben und löschen.'), + ('de', 'workspace.create', 'Arbeitsbereich erstellen'), + ('de', 'workspace.rename', 'Umbenennen'), + ('de', 'workspace.delete', 'Löschen'), + ('de', 'workspace.save', 'Speichern'), + ('de', 'workspace.name', 'Name'), + ('de', 'workspace.name_placeholder', 'Name des Arbeitsbereichs'), + ('de', 'workspace.color', 'Akzentfarbe'), + ('de', 'workspace.color_none', 'Keine Farbe'), + ('de', 'workspace.set_default', 'Als Standard festlegen'), + ('de', 'workspace.default_badge', 'Standard'), + ('de', 'workspace.chat_count', '{count} Chats'), + ('de', 'workspace.empty', 'Noch keine Arbeitsbereiche.'), + ('de', 'workspace.cannot_delete_default', 'Der Standard-Arbeitsbereich kann nicht gelöscht werden.'), + ('de', 'workspace.delete_title', 'Arbeitsbereich löschen'), + ('de', 'workspace.delete_description', 'Wähle, was mit den Chats in "{name}" passiert.'), + ('de', 'workspace.delete_action_move', 'Chats in einen anderen Arbeitsbereich verschieben'), + ('de', 'workspace.delete_action_archive', 'Chats archivieren'), + ('de', 'workspace.delete_action_delete', 'Chats endgültig löschen'), + ('de', 'workspace.delete_move_target', 'Verschieben nach'), + ('de', 'workspace.confirm', 'Bestätigen'), + ('de', 'workspace.cancel', 'Abbrechen'), + ('de', 'workspace.create_failed', 'Arbeitsbereich konnte nicht erstellt werden.'), + ('de', 'workspace.update_failed', 'Arbeitsbereich konnte nicht aktualisiert werden.'), + ('de', 'workspace.delete_failed', 'Arbeitsbereich konnte nicht gelöscht werden.'), + ('de', 'chat.context_menu.no_workspace', 'Kein Arbeitsbereich') +ON CONFLICT (language, key_path) DO NOTHING; diff --git a/src/routes/public/chats.rs b/src/routes/public/chats.rs index 2118361..4c65d34 100644 --- a/src/routes/public/chats.rs +++ b/src/routes/public/chats.rs @@ -180,8 +180,8 @@ pub async fn update_chat(State(state): State>, cookies: Cookies, P return ErrorBuilder::new(ErrorCode::NotAuthenticated).build(); }; - if let Some(workspace_id) = req.workspace_id { - match Chat::verify_workspace_belongs_to_user(&state.db, &workspace_id, &user.id).await { + if let Some(Some(workspace_id)) = &req.workspace_id { + match Chat::verify_workspace_belongs_to_user(&state.db, workspace_id, &user.id).await { Ok(false) => return ErrorBuilder::new(ErrorCode::NotFound).build(), Err(e) => { eprintln!("[CHATS] Failed to validate workspace: {e}"); @@ -196,7 +196,7 @@ pub async fn update_chat(State(state): State>, cookies: Cookies, P &id, &user.id, req.title.as_deref(), - req.workspace_id.as_ref(), + req.workspace_id.as_ref().map(|w| w.as_ref()), req.is_pinned, req.is_archived, ) diff --git a/src/routes/public/workspaces.rs b/src/routes/public/workspaces.rs index 7006ec9..a0b034e 100644 --- a/src/routes/public/workspaces.rs +++ b/src/routes/public/workspaces.rs @@ -1,10 +1,10 @@ use crate::routes::public::auth::get_current_user; use crate::types::JobState; -use crate::types::{CreateWorkspaceRequest, UpdateWorkspaceRequest, Workspace, WorkspaceResponse}; +use crate::types::{Chat, CreateWorkspaceRequest, DeleteWorkspaceParams, UpdateWorkspaceRequest, Workspace, WorkspaceDeleteAction, WorkspaceResponse}; use crate::utils::response::{ErrorBuilder, ErrorCode, ResponseBody, ResponseBuilder}; use axum::{ Json, - extract::{Path, State}, + extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, }; @@ -33,14 +33,7 @@ pub async fn create_workspace(State(state): State>, cookies: Cooki return ErrorBuilder::new(ErrorCode::NotAuthenticated).build(); }; - if req.is_default { - if let Err(e) = Workspace::clear_default_for_user(&state.db, &user.id, None).await { - eprintln!("[WORKSPACES] Failed to unset default: {e}"); - return ErrorBuilder::new(ErrorCode::InternalError).build(); - } - } - - match Workspace::create(&state.db, &user.id, &req.name, req.icon.as_deref(), req.color.as_deref(), req.is_default).await { + match Workspace::create_from_request(&state.db, &user.id, &req).await { Ok(ws) => { let response = WorkspaceResponse::from_workspace(ws, 0); ResponseBuilder::new(ResponseBody::Json(response)).status(StatusCode::CREATED).build() @@ -78,24 +71,7 @@ pub async fn update_workspace(State(state): State>, cookies: Cooki return ErrorBuilder::new(ErrorCode::NotAuthenticated).build(); }; - if req.is_default == Some(true) { - if let Err(e) = Workspace::clear_default_for_user(&state.db, &user.id, Some(&id)).await { - eprintln!("[WORKSPACES] Failed to unset default: {e}"); - return ErrorBuilder::new(ErrorCode::InternalError).build(); - } - } - - let updated = Workspace::update( - &state.db, - &id, - &user.id, - req.name.as_deref(), - req.icon.as_deref(), - req.color.as_deref(), - req.sort_order, - req.is_default, - ) - .await; + let updated = Workspace::update_from_request(&state.db, &id, &user.id, &req).await; match updated { Ok(None) => ErrorBuilder::new(ErrorCode::NotFound).build(), @@ -119,12 +95,47 @@ pub async fn update_workspace(State(state): State>, cookies: Cooki } /// DELETE /api/v1/workspaces/:id -pub async fn delete_workspace(State(state): State>, cookies: Cookies, Path(id): Path) -> impl IntoResponse { +pub async fn delete_workspace( + State(state): State>, + cookies: Cookies, + Path(id): Path, + Query(params): Query, +) -> impl IntoResponse { let Some(user) = get_current_user(&state.db, &cookies).await else { return ErrorBuilder::new(ErrorCode::NotAuthenticated).build(); }; - match Workspace::delete(&state.db, &id, &user.id).await { + let workspace = match Workspace::find_by_id_and_user(&state.db, &id, &user.id).await { + Ok(Some(ws)) => ws, + Ok(None) => return ErrorBuilder::new(ErrorCode::NotFound).build(), + Err(e) => { + eprintln!("[WORKSPACES] Failed to load workspace: {e}"); + return ErrorBuilder::new(ErrorCode::InternalError).build(); + } + }; + + if workspace.is_default { + return ErrorBuilder::new(ErrorCode::DefaultWorkspaceDelete).build(); + } + + if let WorkspaceDeleteAction::Move = params.action { + let Some(target) = params.target_workspace_id else { + return ErrorBuilder::new(ErrorCode::ValidationFailed).build(); + }; + if target == id { + return ErrorBuilder::new(ErrorCode::ValidationFailed).build(); + } + match Chat::verify_workspace_belongs_to_user(&state.db, &target, &user.id).await { + Ok(true) => {} + Ok(false) => return ErrorBuilder::new(ErrorCode::ValidationFailed).build(), + Err(e) => { + eprintln!("[WORKSPACES] Failed to validate target workspace: {e}"); + return ErrorBuilder::new(ErrorCode::InternalError).build(); + } + } + } + + match Workspace::delete_with_chat_disposition(&state.db, &id, &user.id, params.action, params.target_workspace_id.as_ref()).await { Ok(true) => (StatusCode::NO_CONTENT, "").into_response(), Ok(false) => ErrorBuilder::new(ErrorCode::NotFound).build(), Err(e) => { diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 254080f..bdaf4aa 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -6,3 +6,4 @@ pub mod i18n; pub mod logging; pub mod models; pub mod response; +pub mod workspaces; diff --git a/src/tests/workspaces.rs b/src/tests/workspaces.rs new file mode 100644 index 0000000..b63e990 --- /dev/null +++ b/src/tests/workspaces.rs @@ -0,0 +1,93 @@ +#[cfg(test)] +mod tests { + use crate::types::{Chat, Workspace}; + use sqlx::PgPool; + use uuid::Uuid; + + async fn create_user(pool: &PgPool, suffix: &str) -> Uuid { + sqlx::query_scalar::<_, Uuid>( + r#" + INSERT INTO users (email, username, password_hash) + VALUES ($1, $2, 'hash') + RETURNING id + "#, + ) + .bind(format!("{suffix}@example.com")) + .bind(format!("user_{suffix}")) + .fetch_one(pool) + .await + .unwrap() + } + + async fn insert_message(pool: &PgPool, chat_id: &Uuid) { + sqlx::query("INSERT INTO messages (chat_id, role, content) VALUES ($1, 'user', 'hi')") + .bind(chat_id) + .execute(pool) + .await + .unwrap(); + } + + #[sqlx::test(migrations = "./migrations")] + async fn move_disposition_reassigns_chats(pool: PgPool) { + let user = create_user(&pool, "move").await; + let from = Workspace::create(&pool, &user, "From", None, None, false).await.unwrap(); + let to = Workspace::create(&pool, &user, "To", None, None, false).await.unwrap(); + let chat = Chat::create(&pool, &user, Some(&from.id), Some("c")).await.unwrap(); + + let moved = Chat::move_all_to_workspace(&pool, &user, &from.id, &to.id).await.unwrap(); + assert_eq!(moved, 1); + + let reloaded = Chat::find_by_id_and_user(&pool, &chat.id, &user).await.unwrap().unwrap(); + assert_eq!(reloaded.workspace_id, Some(to.id)); + + assert!(Workspace::delete(&pool, &from.id, &user).await.unwrap()); + } + + #[sqlx::test(migrations = "./migrations")] + async fn archive_disposition_archives_and_unassigns(pool: PgPool) { + let user = create_user(&pool, "archive").await; + let ws = Workspace::create(&pool, &user, "Ws", None, None, false).await.unwrap(); + let chat = Chat::create(&pool, &user, Some(&ws.id), Some("c")).await.unwrap(); + + let archived = Chat::archive_all_in_workspace(&pool, &user, &ws.id).await.unwrap(); + assert_eq!(archived, 1); + + let reloaded = Chat::find_by_id_and_user(&pool, &chat.id, &user).await.unwrap().unwrap(); + assert!(reloaded.is_archived); + assert_eq!(reloaded.workspace_id, None); + } + + #[sqlx::test(migrations = "./migrations")] + async fn update_color_clears_only_on_explicit_null(pool: PgPool) { + let user = create_user(&pool, "color").await; + let ws = Workspace::create(&pool, &user, "Ws", None, Some("#3b82f6"), false).await.unwrap(); + + let kept = Workspace::update(&pool, &ws.id, &user, Some("Renamed"), None, None, None, None).await.unwrap().unwrap(); + assert_eq!(kept.color.as_deref(), Some("#3b82f6")); + + let recolored = Workspace::update(&pool, &ws.id, &user, None, None, Some(Some("#ef4444")), None, None).await.unwrap().unwrap(); + assert_eq!(recolored.color.as_deref(), Some("#ef4444")); + + let cleared = Workspace::update(&pool, &ws.id, &user, None, None, Some(None), None, None).await.unwrap().unwrap(); + assert_eq!(cleared.color, None); + } + + #[sqlx::test(migrations = "./migrations")] + async fn delete_disposition_removes_chats_and_messages(pool: PgPool) { + let user = create_user(&pool, "delete").await; + let ws = Workspace::create(&pool, &user, "Ws", None, None, false).await.unwrap(); + let chat = Chat::create(&pool, &user, Some(&ws.id), Some("c")).await.unwrap(); + insert_message(&pool, &chat.id).await; + + let deleted = Chat::delete_all_in_workspace(&pool, &user, &ws.id).await.unwrap(); + assert_eq!(deleted, 1); + + assert!(Chat::find_by_id_and_user(&pool, &chat.id, &user).await.unwrap().is_none()); + let remaining: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM messages WHERE chat_id = $1") + .bind(chat.id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(remaining, 0); + } +} diff --git a/src/types/chat/patch.rs b/src/types/chat/patch.rs index e5d1f0e..0a833f3 100644 --- a/src/types/chat/patch.rs +++ b/src/types/chat/patch.rs @@ -1,20 +1,49 @@ -use sqlx::PgPool; +use sqlx::{PgPool, Postgres, Transaction}; use uuid::Uuid; -use super::{Chat, Message, Workspace}; +use super::{Chat, CreateWorkspaceRequest, Message, UpdateWorkspaceRequest, Workspace, WorkspaceDeleteAction}; impl Workspace { - pub async fn clear_default_for_user(pool: &PgPool, user_id: &Uuid, exclude_id: Option<&Uuid>) -> Result<(), sqlx::Error> { + pub async fn create_from_request(pool: &PgPool, user_id: &Uuid, req: &CreateWorkspaceRequest) -> Result { + if !req.is_default { + return Self::create(pool, user_id, &req.name, req.icon.as_deref(), req.color.as_deref(), req.is_default).await; + } + + let mut tx = pool.begin().await?; + Self::clear_default_for_user_tx(&mut tx, user_id, None).await?; + let workspace = Self::create_tx(&mut tx, user_id, req).await?; + tx.commit().await?; + Ok(workspace) + } + + async fn create_tx(tx: &mut Transaction<'_, Postgres>, user_id: &Uuid, req: &CreateWorkspaceRequest) -> Result { + sqlx::query_as::<_, Workspace>( + r#" + INSERT INTO workspaces (user_id, name, icon, color, is_default) + VALUES ($1, $2, $3, $4, $5) + RETURNING * + "#, + ) + .bind(user_id) + .bind(&req.name) + .bind(req.icon.as_deref()) + .bind(req.color.as_deref()) + .bind(req.is_default) + .fetch_one(&mut **tx) + .await + } + + async fn clear_default_for_user_tx(tx: &mut Transaction<'_, Postgres>, user_id: &Uuid, exclude_id: Option<&Uuid>) -> Result<(), sqlx::Error> { if let Some(exclude) = exclude_id { sqlx::query("UPDATE workspaces SET is_default = false WHERE user_id = $1 AND id != $2") .bind(user_id) .bind(exclude) - .execute(pool) + .execute(&mut **tx) .await?; } else { sqlx::query("UPDATE workspaces SET is_default = false WHERE user_id = $1") .bind(user_id) - .execute(pool) + .execute(&mut **tx) .await?; } Ok(()) @@ -26,16 +55,19 @@ impl Workspace { user_id: &Uuid, name: Option<&str>, icon: Option<&str>, - color: Option<&str>, + color: Option>, sort_order: Option, is_default: Option, ) -> Result, sqlx::Error> { + let update_color = color.is_some(); + let color_value = color.flatten(); + sqlx::query_as::<_, Workspace>( r#" UPDATE workspaces SET name = COALESCE($3, name), icon = COALESCE($4, icon), - color = COALESCE($5, color), + color = CASE WHEN $8 THEN $5 ELSE color END, sort_order = COALESCE($6, sort_order), is_default = COALESCE($7, is_default), updated_at = NOW() @@ -47,21 +79,103 @@ impl Workspace { .bind(user_id) .bind(name) .bind(icon) - .bind(color) + .bind(color_value) .bind(sort_order) .bind(is_default) + .bind(update_color) .fetch_optional(pool) .await } - pub async fn delete(pool: &PgPool, id: &Uuid, user_id: &Uuid) -> Result { + async fn update_tx(tx: &mut Transaction<'_, Postgres>, id: &Uuid, user_id: &Uuid, req: &UpdateWorkspaceRequest) -> Result, sqlx::Error> { + let update_color = req.color.is_some(); + let color_value = req.color.as_ref().and_then(Option::as_deref); + + sqlx::query_as::<_, Workspace>( + r#" + UPDATE workspaces + SET name = COALESCE($3, name), + icon = COALESCE($4, icon), + color = CASE WHEN $8 THEN $5 ELSE color END, + sort_order = COALESCE($6, sort_order), + is_default = COALESCE($7, is_default), + updated_at = NOW() + WHERE id = $1 AND user_id = $2 + RETURNING * + "#, + ) + .bind(id) + .bind(user_id) + .bind(req.name.as_deref()) + .bind(req.icon.as_deref()) + .bind(color_value) + .bind(req.sort_order) + .bind(req.is_default) + .bind(update_color) + .fetch_optional(&mut **tx) + .await + } + + pub async fn update_from_request(pool: &PgPool, id: &Uuid, user_id: &Uuid, req: &UpdateWorkspaceRequest) -> Result, sqlx::Error> { + if req.is_default != Some(true) { + return Self::update( + pool, + id, + user_id, + req.name.as_deref(), + req.icon.as_deref(), + req.color.as_ref().map(Option::as_deref), + req.sort_order, + req.is_default, + ) + .await; + } + + let mut tx = pool.begin().await?; + Self::clear_default_for_user_tx(&mut tx, user_id, Some(id)).await?; + let updated = Self::update_tx(&mut tx, id, user_id, req).await?; + if updated.is_some() { + tx.commit().await?; + } + Ok(updated) + } + + async fn delete_tx(tx: &mut Transaction<'_, Postgres>, id: &Uuid, user_id: &Uuid) -> Result { let result = sqlx::query("DELETE FROM workspaces WHERE id = $1 AND user_id = $2") .bind(id) .bind(user_id) - .execute(pool) + .execute(&mut **tx) .await?; Ok(result.rows_affected() > 0) } + + pub async fn delete_with_chat_disposition( + pool: &PgPool, + id: &Uuid, + user_id: &Uuid, + action: WorkspaceDeleteAction, + target_workspace_id: Option<&Uuid>, + ) -> Result { + let mut tx = pool.begin().await?; + match action { + WorkspaceDeleteAction::Move => { + if let Some(target) = target_workspace_id { + Chat::move_all_to_workspace_tx(&mut tx, user_id, id, target).await?; + } + } + WorkspaceDeleteAction::Archive => { + Chat::archive_all_in_workspace_tx(&mut tx, user_id, id).await?; + } + WorkspaceDeleteAction::Delete => { + Chat::delete_all_in_workspace_tx(&mut tx, user_id, id).await?; + } + } + let deleted = Self::delete_tx(&mut tx, id, user_id).await?; + if deleted { + tx.commit().await?; + } + Ok(deleted) + } } impl Chat { @@ -70,15 +184,18 @@ impl Chat { id: &Uuid, user_id: &Uuid, title: Option<&str>, - workspace_id: Option<&Uuid>, + workspace_id: Option>, is_pinned: Option, is_archived: Option, ) -> Result, sqlx::Error> { + let update_workspace = workspace_id.is_some(); + let workspace_value = workspace_id.flatten(); + sqlx::query_as::<_, Chat>( r#" UPDATE chats SET title = COALESCE($3, title), - workspace_id = COALESCE($4, workspace_id), + workspace_id = CASE WHEN $7 THEN $4 ELSE workspace_id END, is_pinned = COALESCE($5, is_pinned), is_archived = COALESCE($6, is_archived), updated_at = NOW() @@ -89,21 +206,47 @@ impl Chat { .bind(id) .bind(user_id) .bind(title) - .bind(workspace_id) + .bind(workspace_value) .bind(is_pinned) .bind(is_archived) + .bind(update_workspace) .fetch_optional(pool) .await } pub async fn touch(pool: &PgPool, id: &Uuid) -> Result<(), sqlx::Error> { - sqlx::query("UPDATE chats SET updated_at = NOW() WHERE id = $1") - .bind(id) - .execute(pool) - .await?; + sqlx::query("UPDATE chats SET updated_at = NOW() WHERE id = $1").bind(id).execute(pool).await?; Ok(()) } + async fn move_all_to_workspace_tx(tx: &mut Transaction<'_, Postgres>, user_id: &Uuid, from: &Uuid, to: &Uuid) -> Result { + let result = sqlx::query("UPDATE chats SET workspace_id = $3, updated_at = NOW() WHERE user_id = $1 AND workspace_id = $2") + .bind(user_id) + .bind(from) + .bind(to) + .execute(&mut **tx) + .await?; + Ok(result.rows_affected()) + } + + async fn archive_all_in_workspace_tx(tx: &mut Transaction<'_, Postgres>, user_id: &Uuid, workspace_id: &Uuid) -> Result { + let result = sqlx::query("UPDATE chats SET is_archived = true, workspace_id = NULL, updated_at = NOW() WHERE user_id = $1 AND workspace_id = $2") + .bind(user_id) + .bind(workspace_id) + .execute(&mut **tx) + .await?; + Ok(result.rows_affected()) + } + + async fn delete_all_in_workspace_tx(tx: &mut Transaction<'_, Postgres>, user_id: &Uuid, workspace_id: &Uuid) -> Result { + let result = sqlx::query("DELETE FROM chats WHERE user_id = $1 AND workspace_id = $2") + .bind(user_id) + .bind(workspace_id) + .execute(&mut **tx) + .await?; + Ok(result.rows_affected()) + } + pub async fn delete(pool: &PgPool, id: &Uuid, user_id: &Uuid) -> Result { let result = sqlx::query("DELETE FROM chats WHERE id = $1 AND user_id = $2") .bind(id) @@ -115,40 +258,6 @@ impl Chat { } impl Message { - pub async fn deactivate_subtree(tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, message_id: &Uuid) -> Result<(), sqlx::Error> { - sqlx::query( - r#" - WITH RECURSIVE descendants AS ( - SELECT id FROM messages WHERE id = $1 - UNION - SELECT m.id FROM messages m INNER JOIN descendants d ON m.parent_id = d.id - ) - UPDATE messages SET is_active_fork = false WHERE id IN (SELECT id FROM descendants) - "#, - ) - .bind(message_id) - .execute(&mut **tx) - .await?; - Ok(()) - } - - pub async fn activate_subtree(tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, message_id: &Uuid) -> Result<(), sqlx::Error> { - sqlx::query( - r#" - WITH RECURSIVE descendants AS ( - SELECT id FROM messages WHERE id = $1 - UNION - SELECT m.id FROM messages m INNER JOIN descendants d ON m.parent_id = d.id - ) - UPDATE messages SET is_active_fork = true WHERE id IN (SELECT id FROM descendants) - "#, - ) - .bind(message_id) - .execute(&mut **tx) - .await?; - Ok(()) - } - /// Deactivate all siblings at the given parent_id level and their entire subtrees. pub async fn deactivate_fork_level(pool: &PgPool, chat_id: &Uuid, parent_id: Option<&Uuid>) -> Result<(), sqlx::Error> { sqlx::query( @@ -176,13 +285,11 @@ impl Message { /// Deactivates all current siblings and their subtrees, then inserts the new fork. pub async fn create_fork(pool: &PgPool, chat_id: &Uuid, original: &Message, new_content: &str) -> Result { let next_fork_index: i32 = { - let row: (Option,) = sqlx::query_as( - "SELECT COALESCE(MAX(fork_index), 0) + 1 FROM messages WHERE chat_id = $1 AND parent_id IS NOT DISTINCT FROM $2", - ) - .bind(chat_id) - .bind(original.parent_id) - .fetch_one(pool) - .await?; + let row: (Option,) = sqlx::query_as("SELECT COALESCE(MAX(fork_index), 0) + 1 FROM messages WHERE chat_id = $1 AND parent_id IS NOT DISTINCT FROM $2") + .bind(chat_id) + .bind(original.parent_id) + .fetch_one(pool) + .await?; row.0.unwrap_or(1) }; @@ -230,14 +337,12 @@ impl Message { .execute(pool) .await?; - let target = sqlx::query_as::<_, Message>( - "SELECT * FROM messages WHERE chat_id = $1 AND parent_id IS NOT DISTINCT FROM $2 AND fork_index = $3", - ) - .bind(chat_id) - .bind(parent_id) - .bind(fork_index) - .fetch_optional(pool) - .await?; + let target = sqlx::query_as::<_, Message>("SELECT * FROM messages WHERE chat_id = $1 AND parent_id IS NOT DISTINCT FROM $2 AND fork_index = $3") + .bind(chat_id) + .bind(parent_id) + .bind(fork_index) + .fetch_optional(pool) + .await?; if let Some(ref msg) = target { sqlx::query( diff --git a/src/types/chat/requests.rs b/src/types/chat/requests.rs index 3a76e15..dc8d2e1 100644 --- a/src/types/chat/requests.rs +++ b/src/types/chat/requests.rs @@ -1,8 +1,18 @@ -use serde::Deserialize; +use serde::{Deserialize, Deserializer}; use uuid::Uuid; use super::ThemeCssVars; +/// Distinguishes an absent field (`None`) from an explicit JSON `null` (`Some(None)`), +/// so a PATCH can clear a nullable column instead of leaving it unchanged. +fn deserialize_nullable_field<'de, T, D>(deserializer: D) -> Result>, D::Error> +where + T: Deserialize<'de>, + D: Deserializer<'de>, +{ + Ok(Some(Option::deserialize(deserializer)?)) +} + #[derive(Debug, Deserialize)] pub struct CreateWorkspaceRequest { pub name: String, @@ -16,11 +26,28 @@ pub struct CreateWorkspaceRequest { pub struct UpdateWorkspaceRequest { pub name: Option, pub icon: Option, - pub color: Option, + #[serde(default, deserialize_with = "deserialize_nullable_field")] + pub color: Option>, pub sort_order: Option, pub is_default: Option, } +#[derive(Debug, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceDeleteAction { + #[default] + Archive, + Move, + Delete, +} + +#[derive(Debug, Deserialize)] +pub struct DeleteWorkspaceParams { + #[serde(default)] + pub action: WorkspaceDeleteAction, + pub target_workspace_id: Option, +} + #[derive(Debug, Deserialize)] pub struct CreateChatRequest { pub workspace_id: Option, @@ -30,7 +57,8 @@ pub struct CreateChatRequest { #[derive(Debug, Deserialize)] pub struct UpdateChatRequest { pub title: Option, - pub workspace_id: Option, + #[serde(default, deserialize_with = "deserialize_nullable_field")] + pub workspace_id: Option>, pub model_id: Option, pub is_pinned: Option, pub is_archived: Option, diff --git a/src/utils/response.rs b/src/utils/response.rs index e2c14ae..08546c7 100644 --- a/src/utils/response.rs +++ b/src/utils/response.rs @@ -205,6 +205,7 @@ pub enum ErrorCode { MalformedRequest, InvalidProvider, ProviderNotConfigured, + DefaultWorkspaceDelete, // 401 Unauthorized Unauthorized, @@ -271,7 +272,8 @@ impl ErrorCode { | Self::SetupCompleted | Self::MalformedRequest | Self::InvalidProvider - | Self::ProviderNotConfigured => StatusCode::BAD_REQUEST, + | Self::ProviderNotConfigured + | Self::DefaultWorkspaceDelete => StatusCode::BAD_REQUEST, Self::Unauthorized | Self::NotAuthenticated @@ -314,6 +316,7 @@ impl ErrorCode { Self::MalformedRequest => "errors.malformed_request", Self::InvalidProvider => "auth.errors.oauth_provider_invalid", Self::ProviderNotConfigured => "auth.errors.oauth_provider_disabled", + Self::DefaultWorkspaceDelete => "workspace.cannot_delete_default", Self::Unauthorized => "auth.errors.unauthorized", Self::NotAuthenticated => "auth.errors.not_authenticated", @@ -372,6 +375,7 @@ impl ErrorCode { Self::MalformedRequest => "malformed_request", Self::InvalidProvider => "invalid_provider", Self::ProviderNotConfigured => "provider_not_configured", + Self::DefaultWorkspaceDelete => "default_workspace_delete", Self::Unauthorized => "unauthorized", Self::NotAuthenticated => "not_authenticated",