From 2c2f564ecf461d83a5e3e80192cdeb28fe803f51 Mon Sep 17 00:00:00 2001 From: aster <137767097+aster-void@users.noreply.github.com> Date: Thu, 15 Jan 2026 12:02:50 +0900 Subject: [PATCH 1/8] wip --- REVIEW.md | 138 +++++++++++++++ .../components/channels/ChannelGroup.svelte | 111 ++++++++++++ .../components/channels/ChannelList.svelte | 71 +++++++- .../channels/CreateChannelButton.svelte | 107 ++++++++---- .../channels/CreateGroupButton.svelte | 100 +++++++++++ .../channels/channelGroups.svelte.ts | 90 ++++++++++ apps/server/src/db/channelGroups.ts | 53 ++++++ apps/server/src/db/channels.ts | 13 +- apps/server/src/db/schema.ts | 1 + .../src/domains/channelGroups/queries.ts | 56 ++++++ .../src/domains/channelGroups/routes.ts | 163 ++++++++++++++++++ apps/server/src/domains/channels/routes.ts | 44 +++++ apps/server/src/index.ts | 2 + packages/api-client/src/types.ts | 11 ++ 14 files changed, 924 insertions(+), 36 deletions(-) create mode 100644 REVIEW.md create mode 100644 apps/desktop/src/components/channels/ChannelGroup.svelte create mode 100644 apps/desktop/src/components/channels/CreateGroupButton.svelte create mode 100644 apps/desktop/src/components/channels/channelGroups.svelte.ts create mode 100644 apps/server/src/db/channelGroups.ts create mode 100644 apps/server/src/domains/channelGroups/queries.ts create mode 100644 apps/server/src/domains/channelGroups/routes.ts diff --git a/REVIEW.md b/REVIEW.md new file mode 100644 index 0000000..a9677ed --- /dev/null +++ b/REVIEW.md @@ -0,0 +1,138 @@ +# Code Review: Channel Groups Feature + +## 概要 + +チャンネルをグループ化する機能の追加。サーバー側でDB スキーマ・API を追加し、フロントエンドでグループ表示・作成UIを実装。 + +## 良い点 + +- **構造が整理されている**: サーバー側は `db/`, `domains/` の分離、フロントエンドは `.svelte.ts` への状態管理の切り出しが適切 +- **再帰的な階層構造**: ネストしたグループをサポートする設計 +- **UXへの配慮**: 折りたたみ状態を localStorage で永続化 +- **権限チェック**: 既存の `canCreateChannels` 権限を再利用 + +--- + +## 問題点 + +### Critical + +#### 1. 子グループのフィルタリングロジックが間違っている + +`ChannelGroup.svelte:96` + +```svelte +childGroups={childGroups.filter((g) => g.parentGroupId === child.id)} +``` + +`childGroups` は既に「現在のグループの直接の子」のみを含んでいるため、その中から `child.id` を親に持つグループを探しても見つからない。 + +**修正案**: 全グループリストを渡してフィルタリングするか、`organized.groups` を使う + +```svelte +childGroups={organized.groups.filter((g) => g.parentGroupId === child.id)} +``` + +ただし、これだと `organized` を prop で渡す必要があるため、`allGroups` プロパティを追加する設計が望ましい。 + +--- + +### High + +#### 2. `parentGroupId` のクロス組織バリデーションがない + +`routes.ts` (POST, PATCH) で `parentGroupId` が指定された場合、その親グループが同じ organization に属するかの検証がない。 + +```ts +// routes.ts:68 +return createChannelGroup(db, body); +// parentGroupId が別組織のグループでもエラーにならない +``` + +**修正案**: 作成/更新前に `parentGroupId` の organization をチェック + +```ts +if (body.parentGroupId) { + const parent = await getChannelGroupById(db, body.parentGroupId); + if (!parent || parent.organizationId !== body.organizationId) { + throw new BadRequestError("Invalid parent group", "INVALID_PARENT_GROUP"); + } +} +``` + +#### 3. グループ削除時の子グループ・チャンネルの扱いが不明確 + +`channelGroups.ts` のスキーマで `parentGroupId` に `onDelete` が設定されていない。 + +- 子グループはどうなる?(孤児になる?cascade?) +- 所属チャンネルは `onDelete: "set null"` で ungrouped になる + +**確認必要**: 意図した動作かどうか。子グループが孤児になるのは問題。 + +--- + +### Medium + +#### 4. 型キャストによる型安全性の低下 + +`channelGroups.svelte.ts:84-85` + +```ts +const groupId = + (channel as Channel & { groupId?: string | null }).groupId ?? null; +``` + +`Channel` 型に `groupId` が含まれていないため型キャストしている。api-client の型定義を更新すべき。 + +#### 5. リアクティビティのための Set 再作成にコメントがない + +`channelGroups.svelte.ts:57` + +```ts +collapsedGroups = new Set(collapsedGroups); +``` + +Svelte 5 のリアクティビティのための再代入だが、初見では意図が分かりにくい。コメント追加を推奨。 + +#### 6. `ChannelList.svelte` のファイル長 + +変更後 196 行になっており、CLAUDE.md の「30-50行推奨、100行超で警告」に抵触。グループ関連のロジックを分離することを検討。 + +--- + +### Low + +#### 7. `query` パラメータの型定義 + +`routes.ts:37` の GET エンドポイントで `query` のスキーマ定義がない(他のルートにはある)。Elysia の validation を追加すべき。 + +```ts +{ + query: t.Object({ + organizationId: t.String(), + }), +} +``` + +#### 8. インデント計算の CSS + +`ChannelGroup.svelte:71` + +```svelte +style:padding-left={`calc(${indent} + 0.5rem)`} +``` + +JS で計算してから渡す方がシンプル。あるいは Tailwind のクラスで対応。 + +--- + +## 総評 + +基本的な実装は整っているが、**子グループのフィルタリングバグ**と**クロス組織のセキュリティ問題**は修正必須。型安全性とファイル長は改善推奨。 + +| 項目 | 評価 | +| ------------ | ------------------ | +| 機能性 | ⚠️ バグあり | +| セキュリティ | ⚠️ 要修正 | +| コード品質 | ○ 概ね良好 | +| 保守性 | △ ファイル長に注意 | diff --git a/apps/desktop/src/components/channels/ChannelGroup.svelte b/apps/desktop/src/components/channels/ChannelGroup.svelte new file mode 100644 index 0000000..a8adb10 --- /dev/null +++ b/apps/desktop/src/components/channels/ChannelGroup.svelte @@ -0,0 +1,111 @@ + + +
+ + + {#if !collapsed} + + {/if} +
diff --git a/apps/desktop/src/components/channels/ChannelList.svelte b/apps/desktop/src/components/channels/ChannelList.svelte index 39c017e..c98e4ea 100644 --- a/apps/desktop/src/components/channels/ChannelList.svelte +++ b/apps/desktop/src/components/channels/ChannelList.svelte @@ -10,7 +10,14 @@ import type { Selection } from "$components/chat/types"; import DMList from "$components/dms/DMList.svelte"; import UserSearch from "$components/dms/UserSearch.svelte"; + import ChannelGroup from "./ChannelGroup.svelte"; import CreateChannelButton from "./CreateChannelButton.svelte"; + import CreateGroupButton from "./CreateGroupButton.svelte"; + import { + type ChannelGroup as ChannelGroupType, + organizeChannelsIntoGroups, + useChannelGroupState, + } from "./channelGroups.svelte.ts"; const api = getApiClient(); @@ -26,9 +33,27 @@ return unwrapResponse(response); }); + const channelGroups = useQuery(async () => { + const response = await api["channel-groups"].get({ + query: { organizationId }, + }); + return unwrapResponse(response); + }); + const unreadManager = new UnreadManager(api, () => organizationId); + const groupState = useChannelGroupState(() => organizationId); let showUserSearch = $state(false); + const organized = $derived( + organizeChannelsIntoGroups(channels.data ?? [], channelGroups.data ?? []), + ); + + const rootGroups = $derived( + organized.groups.filter((g) => g.parentGroupId === null), + ); + + const ungroupedChannels = $derived(organized.channelsByGroup.get(null) ?? []); + // WebSocket: refresh unread counts on new messages useWebSocket("message:created", () => { unreadManager.fetchUnreadCounts(); @@ -37,6 +62,15 @@ onMount(() => { unreadManager.fetchUnreadCounts(); }); + + async function handleCreateGroup(name: string, parentGroupId: string | null) { + await api["channel-groups"].post({ + name, + organizationId, + parentGroupId: parentGroupId ?? undefined, + }); + channelGroups.refetch(); + }
@@ -46,12 +80,41 @@ Channels - +
+ + channels.refetch()} + /> +
diff --git a/apps/desktop/src/components/channels/ChannelGroupContextMenu.svelte b/apps/desktop/src/components/channels/ChannelGroupContextMenu.svelte new file mode 100644 index 0000000..44d9f18 --- /dev/null +++ b/apps/desktop/src/components/channels/ChannelGroupContextMenu.svelte @@ -0,0 +1,80 @@ + + + e.key === "Escape" && onClose()} +/> + + diff --git a/apps/desktop/src/components/channels/ChannelItem.svelte b/apps/desktop/src/components/channels/ChannelItem.svelte index 0223051..fda285f 100644 --- a/apps/desktop/src/components/channels/ChannelItem.svelte +++ b/apps/desktop/src/components/channels/ChannelItem.svelte @@ -1,6 +1,8 @@ +{#if contextMenu} + onEdit?.(channel)} + onMoveToGroup={(groupId) => onMoveToGroup?.(channel.id, groupId)} + onClose={() => (contextMenu = null)} + /> +{/if} + { - unreadManager.fetchUnreadCounts(); - }); - - onMount(() => { - unreadManager.fetchUnreadCounts(); - }); + useWebSocket("message:created", () => unreadManager.fetchUnreadCounts()); + onMount(() => unreadManager.fetchUnreadCounts()); + // Group CRUD handlers async function handleCreateGroup(name: string, parentGroupId: string | null) { await api["channel-groups"].post({ name, @@ -68,10 +65,67 @@ }); channelGroups.refetch(); } + + async function handleRenameGroup(groupId: string, newName: string) { + await api["channel-groups"]({ id: groupId }).patch({ name: newName }); + channelGroups.refetch(); + } + + async function handleDeleteGroup(groupId: string) { + if (!confirm("Are you sure you want to delete this group?")) return; + await api["channel-groups"]({ id: groupId }).delete(); + channelGroups.refetch(); + } + + // Channel handlers + async function handleEditChannel( + channelId: string, + name: string, + description: string, + ) { + await api.channels({ id: channelId }).patch({ + name, + description: description || null, + }); + channels.refetch(); + } + + async function handleMoveChannelToGroup( + channelId: string, + groupId: string | null, + ) { + await api.channels({ id: channelId }).group.patch({ groupId }); + channels.refetch(); + } + + // Context menu state + let selectedGroupId = $state(null); + let openChannelModal: (() => void) | null = $state(null); + let openGroupModal: (() => void) | null = $state(null); + let openRenameModal: ((id: string, name: string) => void) | null = + $state(null); + let openEditChannelModal: ((channel: Channel) => void) | null = $state(null); + + function handleCreateChannelFromContext(groupId: string) { + selectedGroupId = groupId; + setTimeout(() => openChannelModal?.(), 0); + } + + function handleCreateGroupFromContext(parentGroupId: string) { + selectedGroupId = parentGroupId; + setTimeout(() => openGroupModal?.(), 0); + } + + function handleRenameFromContext(groupId: string, currentName: string) { + openRenameModal?.(groupId, currentName); + } + + function handleEditChannelFromContext(channel: Channel) { + openEditChannelModal?.(channel); + }
-
@@ -92,7 +146,6 @@
- + + channels.refetch()} + showButton={false} + registerOpen={(fn) => (openChannelModal = fn)} + /> + (openGroupModal = fn)} + /> + (openRenameModal = fn)} + /> + (openEditChannelModal = fn)} + /> + -
diff --git a/apps/desktop/src/components/channels/ChannelGroup.svelte b/apps/desktop/src/components/channels/ChannelGroup.svelte index de7bb28..28e3bf4 100644 --- a/apps/desktop/src/components/channels/ChannelGroup.svelte +++ b/apps/desktop/src/components/channels/ChannelGroup.svelte @@ -26,6 +26,7 @@ onRename?: (groupId: string, currentName: string) => void; onDelete?: (groupId: string) => void; onEditChannel?: (channel: Channel) => void; + onDeleteChannel?: (channelId: string) => void; onMoveChannelToGroup?: (channelId: string, groupId: string | null) => void; } @@ -46,6 +47,7 @@ onRename, onDelete, onEditChannel, + onDeleteChannel, onMoveChannelToGroup, }: Props = $props(); @@ -100,6 +102,7 @@ indent={`calc(${indent} + 0.5rem)`} groups={allGroups} onEdit={onEditChannel} + onDelete={onDeleteChannel} onMoveToGroup={onMoveChannelToGroup} /> {/each} @@ -122,6 +125,7 @@ {onRename} {onDelete} {onEditChannel} + {onDeleteChannel} {onMoveChannelToGroup} /> {/each} diff --git a/apps/desktop/src/components/channels/ChannelItem.svelte b/apps/desktop/src/components/channels/ChannelItem.svelte index fda285f..13c0942 100644 --- a/apps/desktop/src/components/channels/ChannelItem.svelte +++ b/apps/desktop/src/components/channels/ChannelItem.svelte @@ -12,6 +12,7 @@ indent?: string; groups?: ChannelGroup[]; onEdit?: (channel: Channel) => void; + onDelete?: (channelId: string) => void; onMoveToGroup?: (channelId: string, groupId: string | null) => void; } @@ -23,6 +24,7 @@ indent, groups = [], onEdit, + onDelete, onMoveToGroup, }: Props = $props(); @@ -41,6 +43,7 @@ currentGroupId={channel.groupId ?? null} {groups} onEdit={() => onEdit?.(channel)} + onDelete={() => onDelete?.(channel.id)} onMoveToGroup={(groupId) => onMoveToGroup?.(channel.id, groupId)} onClose={() => (contextMenu = null)} /> diff --git a/apps/desktop/src/components/channels/ChannelList.svelte b/apps/desktop/src/components/channels/ChannelList.svelte index a44062d..942ab28 100644 --- a/apps/desktop/src/components/channels/ChannelList.svelte +++ b/apps/desktop/src/components/channels/ChannelList.svelte @@ -98,6 +98,12 @@ channels.refetch(); } + async function handleDeleteChannel(channelId: string) { + if (!confirm("Are you sure you want to delete this channel?")) return; + await api.channels({ id: channelId }).delete(); + channels.refetch(); + } + // Context menu state let selectedGroupId = $state(null); let openChannelModal: (() => void) | null = $state(null); @@ -165,6 +171,7 @@ onRename={handleRenameFromContext} onDelete={handleDeleteGroup} onEditChannel={handleEditChannelFromContext} + onDeleteChannel={handleDeleteChannel} onMoveChannelToGroup={handleMoveChannelToGroup} /> {/each} @@ -182,6 +189,7 @@ unreadCount={unreadManager.getUnreadCount(channel.id)} groups={organized.groups} onEdit={handleEditChannelFromContext} + onDelete={handleDeleteChannel} onMoveToGroup={handleMoveChannelToGroup} /> {/each} diff --git a/apps/server/src/domains/channels/routes.ts b/apps/server/src/domains/channels/routes.ts index be006dc..299705a 100644 --- a/apps/server/src/domains/channels/routes.ts +++ b/apps/server/src/domains/channels/routes.ts @@ -1,4 +1,4 @@ -import { desc, eq } from "drizzle-orm"; +import { asc, eq } from "drizzle-orm"; import { Elysia, t } from "elysia"; import { db } from "../../db/index.ts"; import { channels } from "../../db/schema.ts"; @@ -30,7 +30,7 @@ export const channelRoutes = new Elysia({ prefix: "/channels" }) .select() .from(channels) .where(eq(channels.organizationId, query.organizationId)) - .orderBy(desc(channels.createdAt)); + .orderBy(asc(channels.name)); return channelList; } catch (error) { @@ -191,4 +191,34 @@ export const channelRoutes = new Elysia({ prefix: "/channels" }) groupId: t.Nullable(t.String()), }), }, - ); + ) + .delete("/:id", async ({ user, params, set }) => { + try { + if (!user) throw new UnauthorizedError(); + + const [channel] = await db + .select() + .from(channels) + .where(eq(channels.id, params.id)) + .limit(1); + + if (!channel) throw new NotFoundError("Channel", "CHANNEL_NOT_FOUND"); + + const perms = await getOrganizationPermissions( + user.id, + channel.organizationId, + ); + if (!perms.canCreateChannels) { + throw new ForbiddenError( + "Insufficient permissions", + "CANNOT_DELETE_CHANNEL", + ); + } + + await db.delete(channels).where(eq(channels.id, params.id)); + + return { success: true }; + } catch (error) { + return handleError(error, set); + } + }); From 674c02d0f43ca84e52f0c4d636ae31802107442a Mon Sep 17 00:00:00 2001 From: aster <137767097+aster-void@users.noreply.github.com> Date: Sun, 18 Jan 2026 15:22:48 +0900 Subject: [PATCH 8/8] refactor: extract ChannelList logic to controller and improve UX - Extract ChannelList business logic to ChannelList.controller.svelte.ts - Replace native confirm() with DaisyUI modal (ConfirmModal) - Add toast notifications for success/error feedback - Simplify modal state management (remove registerOpen pattern) - Use ChannelGroup type from @packages/api-client directly - Add DB migration for channel_groups table --- .../channels/ChannelContextMenu.svelte | 2 +- .../components/channels/ChannelGroup.svelte | 6 +- .../components/channels/ChannelItem.svelte | 3 +- .../channels/ChannelList.controller.svelte.ts | 163 ++ .../components/channels/ChannelList.svelte | 247 +-- .../channels/CreateChannelButton.svelte | 130 -- .../channels/CreateChannelModal.svelte | 116 ++ .../channels/CreateGroupButton.svelte | 120 -- .../channels/CreateGroupModal.svelte | 98 + .../channels/EditChannelModal.svelte | 128 +- .../channels/RenameGroupModal.svelte | 99 +- .../channels/channelGroups.svelte.ts | 2 - .../src/components/channels/modals.svelte.ts | 86 + .../src/lib/confirm/ConfirmModal.svelte | 48 + .../desktop/src/lib/confirm/confirm.svelte.ts | 64 + .../src/lib/toast/ToastContainer.svelte | 30 + apps/desktop/src/lib/toast/toast.svelte.ts | 28 + apps/desktop/src/routes/+layout.svelte | 4 + apps/server/drizzle/0001_faulty_dust.sql | 27 + apps/server/drizzle/meta/0001_snapshot.json | 1746 +++++++++++++++++ apps/server/drizzle/meta/_journal.json | 7 + 21 files changed, 2606 insertions(+), 548 deletions(-) create mode 100644 apps/desktop/src/components/channels/ChannelList.controller.svelte.ts delete mode 100644 apps/desktop/src/components/channels/CreateChannelButton.svelte create mode 100644 apps/desktop/src/components/channels/CreateChannelModal.svelte delete mode 100644 apps/desktop/src/components/channels/CreateGroupButton.svelte create mode 100644 apps/desktop/src/components/channels/CreateGroupModal.svelte create mode 100644 apps/desktop/src/components/channels/modals.svelte.ts create mode 100644 apps/desktop/src/lib/confirm/ConfirmModal.svelte create mode 100644 apps/desktop/src/lib/confirm/confirm.svelte.ts create mode 100644 apps/desktop/src/lib/toast/ToastContainer.svelte create mode 100644 apps/desktop/src/lib/toast/toast.svelte.ts create mode 100644 apps/server/drizzle/0001_faulty_dust.sql create mode 100644 apps/server/drizzle/meta/0001_snapshot.json diff --git a/apps/desktop/src/components/channels/ChannelContextMenu.svelte b/apps/desktop/src/components/channels/ChannelContextMenu.svelte index a0d0543..9c90608 100644 --- a/apps/desktop/src/components/channels/ChannelContextMenu.svelte +++ b/apps/desktop/src/components/channels/ChannelContextMenu.svelte @@ -4,7 +4,7 @@ import FolderOutput from "@lucide/svelte/icons/folder-output"; import Pencil from "@lucide/svelte/icons/pencil"; import Trash2 from "@lucide/svelte/icons/trash-2"; - import type { ChannelGroup } from "./channelGroups.svelte.ts"; + import type { ChannelGroup } from "@packages/api-client"; interface Props { x: number; diff --git a/apps/desktop/src/components/channels/ChannelGroup.svelte b/apps/desktop/src/components/channels/ChannelGroup.svelte index 28e3bf4..c72e9fb 100644 --- a/apps/desktop/src/components/channels/ChannelGroup.svelte +++ b/apps/desktop/src/components/channels/ChannelGroup.svelte @@ -1,13 +1,15 @@
@@ -138,98 +31,112 @@ Channels
- - channels.refetch()} - /> + +
- - channels.refetch()} - showButton={false} - registerOpen={(fn) => (openChannelModal = fn)} + groups={controller.channelGroups.data ?? []} + isOpen={modals.createChannel.isOpen} + defaultGroupId={modals.createChannel.defaultGroupId} + onClose={() => modals.closeCreateChannel()} + onCreated={() => controller.refetchChannels()} /> - (openGroupModal = fn)} + + modals.closeCreateGroup()} + onCreate={(name, parentId) => controller.createGroup(name, parentId)} /> + (openRenameModal = fn)} + isOpen={modals.renameGroup.isOpen} + groupId={modals.renameGroup.groupId} + currentName={modals.renameGroup.currentName} + onClose={() => modals.closeRenameGroup()} + onRename={(id, name) => controller.renameGroup(id, name)} /> + (openEditChannelModal = fn)} + isOpen={modals.editChannel.isOpen} + channel={modals.editChannel.channel} + onClose={() => modals.closeEditChannel()} + onSave={(id, name, desc) => controller.editChannel(id, name, desc)} /> - import Plus from "@lucide/svelte/icons/plus"; - import { getApiClient, unwrapResponse } from "@/lib/api.svelte"; - import Modal, { ModalManager } from "@/lib/modal/modal.svelte"; - import type { ChannelGroup } from "./channelGroups.svelte.ts"; - - const api = getApiClient(); - - interface Props { - organizationId: string; - groups?: ChannelGroup[]; - defaultGroupId?: string | null; - onCreated?: () => void; - showButton?: boolean; - registerOpen?: (fn: () => void) => void; - } - const { - organizationId, - groups = [], - defaultGroupId, - onCreated, - showButton = true, - registerOpen, - }: Props = $props(); - - let name = $state(""); - let groupId = $state(null); - let form: HTMLFormElement | null = $state(null); - let disabled = $state(false); - - const modalManager = new ModalManager(); - - function openModal() { - groupId = defaultGroupId ?? null; - modalManager.dispatch(createChannelModalContent); - } - - $effect(() => { - registerOpen?.(openModal); - }); - - async function createChannel(event: Event) { - event.preventDefault(); - - if (disabled || !name.trim()) return; - disabled = true; - try { - const response = await api.channels.post({ - name: name.trim(), - organizationId, - groupId: groupId ?? undefined, - }); - unwrapResponse(response); - onCreated?.(); - } catch (error) { - console.error(error); - } finally { - disabled = false; - form?.reset(); - name = ""; - groupId = defaultGroupId ?? null; - modalManager.close(); - } - } - - - - -{#if showButton} - -{/if} - -{#snippet createChannelModalContent()} -
-

Create Channel

- -
- - -
- - {#if groups.length > 0} -
- - -
- {/if} - -
- - {#if disabled} - - {:else} - - {/if} -
-
-{/snippet} diff --git a/apps/desktop/src/components/channels/CreateChannelModal.svelte b/apps/desktop/src/components/channels/CreateChannelModal.svelte new file mode 100644 index 0000000..f1c8324 --- /dev/null +++ b/apps/desktop/src/components/channels/CreateChannelModal.svelte @@ -0,0 +1,116 @@ + + + + + + diff --git a/apps/desktop/src/components/channels/CreateGroupButton.svelte b/apps/desktop/src/components/channels/CreateGroupButton.svelte deleted file mode 100644 index 65fa22c..0000000 --- a/apps/desktop/src/components/channels/CreateGroupButton.svelte +++ /dev/null @@ -1,120 +0,0 @@ - - - - -{#if showButton} - -{/if} - -{#snippet createGroupModalContent()} -
-

Create Channel Group

- -
- - -
- - {#if groups.length > 0} -
- - -
- {/if} - -
- - {#if disabled} - - {:else} - - {/if} -
-
-{/snippet} diff --git a/apps/desktop/src/components/channels/CreateGroupModal.svelte b/apps/desktop/src/components/channels/CreateGroupModal.svelte new file mode 100644 index 0000000..5adcb24 --- /dev/null +++ b/apps/desktop/src/components/channels/CreateGroupModal.svelte @@ -0,0 +1,98 @@ + + + + + + diff --git a/apps/desktop/src/components/channels/EditChannelModal.svelte b/apps/desktop/src/components/channels/EditChannelModal.svelte index a63f5f5..d87145c 100644 --- a/apps/desktop/src/components/channels/EditChannelModal.svelte +++ b/apps/desktop/src/components/channels/EditChannelModal.svelte @@ -1,103 +1,95 @@ - - -{#snippet editChannelModalContent()} -
-

Edit Channel

+ + + +
+ -{/snippet} + diff --git a/apps/desktop/src/components/channels/RenameGroupModal.svelte b/apps/desktop/src/components/channels/RenameGroupModal.svelte index b8143b1..2750d55 100644 --- a/apps/desktop/src/components/channels/RenameGroupModal.svelte +++ b/apps/desktop/src/components/channels/RenameGroupModal.svelte @@ -1,28 +1,25 @@ - - -{#snippet renameModalContent()} -
-

Rename Group

+ + + +
+ -{/snippet} + diff --git a/apps/desktop/src/components/channels/channelGroups.svelte.ts b/apps/desktop/src/components/channels/channelGroups.svelte.ts index a4218ea..a25496d 100644 --- a/apps/desktop/src/components/channels/channelGroups.svelte.ts +++ b/apps/desktop/src/components/channels/channelGroups.svelte.ts @@ -3,8 +3,6 @@ import { browser } from "$app/environment"; const STORAGE_KEY = "channel-groups-collapsed"; -export type { ChannelGroup }; - export interface GroupedChannels { groups: ChannelGroup[]; channelsByGroup: Map; diff --git a/apps/desktop/src/components/channels/modals.svelte.ts b/apps/desktop/src/components/channels/modals.svelte.ts new file mode 100644 index 0000000..bc43c12 --- /dev/null +++ b/apps/desktop/src/components/channels/modals.svelte.ts @@ -0,0 +1,86 @@ +import type { Channel } from "@packages/api-client"; + +/** + * Modal state management for channel list. + * Centralizes modal open/close logic instead of using registerOpen callbacks. + */ + +interface CreateChannelModalState { + isOpen: boolean; + defaultGroupId: string | null; +} + +interface CreateGroupModalState { + isOpen: boolean; + defaultParentGroupId: string | null; +} + +interface RenameGroupModalState { + isOpen: boolean; + groupId: string; + currentName: string; +} + +interface EditChannelModalState { + isOpen: boolean; + channel: Channel | null; +} + +class ChannelModals { + createChannel = $state({ + isOpen: false, + defaultGroupId: null, + }); + + createGroup = $state({ + isOpen: false, + defaultParentGroupId: null, + }); + + renameGroup = $state({ + isOpen: false, + groupId: "", + currentName: "", + }); + + editChannel = $state({ + isOpen: false, + channel: null, + }); + + openCreateChannel(groupId: string | null = null) { + this.createChannel = { isOpen: true, defaultGroupId: groupId }; + } + + closeCreateChannel() { + this.createChannel = { isOpen: false, defaultGroupId: null }; + } + + openCreateGroup(parentGroupId: string | null = null) { + this.createGroup = { isOpen: true, defaultParentGroupId: parentGroupId }; + } + + closeCreateGroup() { + this.createGroup = { isOpen: false, defaultParentGroupId: null }; + } + + openRenameGroup(groupId: string, currentName: string) { + this.renameGroup = { isOpen: true, groupId, currentName }; + } + + closeRenameGroup() { + this.renameGroup = { isOpen: false, groupId: "", currentName: "" }; + } + + openEditChannel(channel: Channel) { + this.editChannel = { isOpen: true, channel }; + } + + closeEditChannel() { + this.editChannel = { isOpen: false, channel: null }; + } +} + +export function useChannelModals() { + return new ChannelModals(); +} diff --git a/apps/desktop/src/lib/confirm/ConfirmModal.svelte b/apps/desktop/src/lib/confirm/ConfirmModal.svelte new file mode 100644 index 0000000..8571d78 --- /dev/null +++ b/apps/desktop/src/lib/confirm/ConfirmModal.svelte @@ -0,0 +1,48 @@ + + + confirmManager.handleCancel()} +> + + + diff --git a/apps/desktop/src/lib/confirm/confirm.svelte.ts b/apps/desktop/src/lib/confirm/confirm.svelte.ts new file mode 100644 index 0000000..43c881b --- /dev/null +++ b/apps/desktop/src/lib/confirm/confirm.svelte.ts @@ -0,0 +1,64 @@ +/** + * Confirmation dialog state management. + * Use with ConfirmModal component. + */ + +interface ConfirmState { + isOpen: boolean; + title: string; + message: string; + confirmText: string; + cancelText: string; + variant: "danger" | "default"; + resolve: ((value: boolean) => void) | null; +} + +class ConfirmManager { + state = $state({ + isOpen: false, + title: "", + message: "", + confirmText: "Confirm", + cancelText: "Cancel", + variant: "default", + resolve: null, + }); + + confirm(options: ConfirmOptions): Promise { + return new Promise((resolve) => { + this.state = { + isOpen: true, + title: options.title, + message: options.message, + confirmText: options.confirmText ?? "Confirm", + cancelText: options.cancelText ?? "Cancel", + variant: options.variant ?? "default", + resolve, + }; + }); + } + + handleConfirm() { + this.state.resolve?.(true); + this.state = { ...this.state, isOpen: false, resolve: null }; + } + + handleCancel() { + this.state.resolve?.(false); + this.state = { ...this.state, isOpen: false, resolve: null }; + } +} + +export interface ConfirmOptions { + title: string; + message: string; + confirmText?: string; + cancelText?: string; + variant?: "danger" | "default"; +} + +export const confirmManager = new ConfirmManager(); + +export function confirm(options: ConfirmOptions): Promise { + return confirmManager.confirm(options); +} diff --git a/apps/desktop/src/lib/toast/ToastContainer.svelte b/apps/desktop/src/lib/toast/ToastContainer.svelte new file mode 100644 index 0000000..16469a8 --- /dev/null +++ b/apps/desktop/src/lib/toast/ToastContainer.svelte @@ -0,0 +1,30 @@ + + +
+ {#each toasts as toast (toast.id)} +
+ {#if toast.type === "success"} + + {:else if toast.type === "error"} + + {:else} + + {/if} + {toast.message} +
+ {/each} +
diff --git a/apps/desktop/src/lib/toast/toast.svelte.ts b/apps/desktop/src/lib/toast/toast.svelte.ts new file mode 100644 index 0000000..a3157d5 --- /dev/null +++ b/apps/desktop/src/lib/toast/toast.svelte.ts @@ -0,0 +1,28 @@ +/** + * Simple toast notification system. + */ + +type ToastType = "success" | "error" | "info"; + +interface Toast { + id: number; + message: string; + type: ToastType; +} + +let toasts = $state([]); +let nextId = 0; + +export function showToast(message: string, type: ToastType = "info") { + const id = nextId++; + toasts.push({ id, message, type }); + setTimeout(() => removeToast(id), 3000); +} + +function removeToast(id: number) { + toasts = toasts.filter((t) => t.id !== id); +} + +export function getToasts() { + return toasts; +} diff --git a/apps/desktop/src/routes/+layout.svelte b/apps/desktop/src/routes/+layout.svelte index 1098f00..25bca0e 100644 --- a/apps/desktop/src/routes/+layout.svelte +++ b/apps/desktop/src/routes/+layout.svelte @@ -1,7 +1,9 @@ {@render children()} + + diff --git a/apps/server/drizzle/0001_faulty_dust.sql b/apps/server/drizzle/0001_faulty_dust.sql new file mode 100644 index 0000000..d544b84 --- /dev/null +++ b/apps/server/drizzle/0001_faulty_dust.sql @@ -0,0 +1,27 @@ +CREATE TABLE "channel_groups" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "organization_id" uuid NOT NULL, + "parent_group_id" uuid, + "order" integer DEFAULT 0 NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "channels" ADD COLUMN "group_id" uuid;--> statement-breakpoint +ALTER TABLE "channel_groups" ADD CONSTRAINT "channel_groups_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "channel_groups" ADD CONSTRAINT "channel_groups_parent_group_id_channel_groups_id_fk" FOREIGN KEY ("parent_group_id") REFERENCES "public"."channel_groups"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "channel_groups_org_idx" ON "channel_groups" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "channel_groups_parent_idx" ON "channel_groups" USING btree ("parent_group_id");--> statement-breakpoint +ALTER TABLE "channels" ADD CONSTRAINT "channels_group_id_channel_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."channel_groups"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "channels_group_idx" ON "channels" USING btree ("group_id");--> statement-breakpoint +CREATE INDEX "message_attachments_message_idx" ON "message_attachments" USING btree ("message_id");--> statement-breakpoint +CREATE INDEX "message_attachments_file_idx" ON "message_attachments" USING btree ("file_id");--> statement-breakpoint +CREATE INDEX "messages_user_idx" ON "messages" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "messages_parent_idx" ON "messages" USING btree ("parent_id");--> statement-breakpoint +CREATE INDEX "messages_created_at_idx" ON "messages" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "messages_pinned_at_idx" ON "messages" USING btree ("pinned_at");--> statement-breakpoint +CREATE INDEX "organizations_owner_idx" ON "organizations" USING btree ("owner_id");--> statement-breakpoint +CREATE INDEX "personalizations_user_idx" ON "personalizations" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "personalizations_org_idx" ON "personalizations" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "personalizations_user_org_idx" ON "personalizations" USING btree ("user_id","organization_id"); \ No newline at end of file diff --git a/apps/server/drizzle/meta/0001_snapshot.json b/apps/server/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..8f943da --- /dev/null +++ b/apps/server/drizzle/meta/0001_snapshot.json @@ -0,0 +1,1746 @@ +{ + "id": "def26810-e4a2-4ce3-85e4-a7a7114f6172", + "prevId": "b280a52c-2189-47e1-8f7f-ec98982c8f9c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_groups": { + "name": "channel_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "parent_group_id": { + "name": "parent_group_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channel_groups_org_idx": { + "name": "channel_groups_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channel_groups_parent_idx": { + "name": "channel_groups_parent_idx", + "columns": [ + { + "expression": "parent_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channel_groups_organization_id_organizations_id_fk": { + "name": "channel_groups_organization_id_organizations_id_fk", + "tableFrom": "channel_groups", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_groups_parent_group_id_channel_groups_id_fk": { + "name": "channel_groups_parent_group_id_channel_groups_id_fk", + "tableFrom": "channel_groups", + "tableTo": "channel_groups", + "columnsFrom": ["parent_group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_read_status": { + "name": "channel_read_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_read_message_id": { + "name": "last_read_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channel_read_status_user_idx": { + "name": "channel_read_status_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channel_read_status_channel_idx": { + "name": "channel_read_status_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channel_read_status_user_channel_idx": { + "name": "channel_read_status_user_channel_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channel_read_status_user_id_users_id_fk": { + "name": "channel_read_status_user_id_users_id_fk", + "tableFrom": "channel_read_status", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_read_status_channel_id_channels_id_fk": { + "name": "channel_read_status_channel_id_channels_id_fk", + "tableFrom": "channel_read_status", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_read_status_last_read_message_id_messages_id_fk": { + "name": "channel_read_status_last_read_message_id_messages_id_fk", + "tableFrom": "channel_read_status", + "tableTo": "messages", + "columnsFrom": ["last_read_message_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_members": { + "name": "channel_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channel_members_channel_idx": { + "name": "channel_members_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channel_members_user_idx": { + "name": "channel_members_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channel_members_channel_id_channels_id_fk": { + "name": "channel_members_channel_id_channels_id_fk", + "tableFrom": "channel_members", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_members_user_id_users_id_fk": { + "name": "channel_members_user_id_users_id_fk", + "tableFrom": "channel_members", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_org_idx": { + "name": "channels_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channels_group_idx": { + "name": "channels_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_organization_id_organizations_id_fk": { + "name": "channels_organization_id_organizations_id_fk", + "tableFrom": "channels", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channels_group_id_channel_groups_id_fk": { + "name": "channels_group_id_channel_groups_id_fk", + "tableFrom": "channels", + "tableTo": "channel_groups", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.files": { + "name": "files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "storage_id": { + "name": "storage_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "files_org_idx": { + "name": "files_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "files_uploader_idx": { + "name": "files_uploader_idx", + "columns": [ + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "files_uploaded_by_users_id_fk": { + "name": "files_uploaded_by_users_id_fk", + "tableFrom": "files", + "tableTo": "users", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "files_organization_id_organizations_id_fk": { + "name": "files_organization_id_organizations_id_fk", + "tableFrom": "files", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_attachments": { + "name": "message_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "message_id": { + "name": "message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "file_id": { + "name": "file_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "message_attachments_message_idx": { + "name": "message_attachments_message_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_attachments_file_idx": { + "name": "message_attachments_file_idx", + "columns": [ + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "message_attachments_message_id_messages_id_fk": { + "name": "message_attachments_message_id_messages_id_fk", + "tableFrom": "message_attachments", + "tableTo": "messages", + "columnsFrom": ["message_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_attachments_file_id_files_id_fk": { + "name": "message_attachments_file_id_files_id_fk", + "tableFrom": "message_attachments", + "tableTo": "files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "vote_id": { + "name": "vote_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned_by": { + "name": "pinned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "edited_at": { + "name": "edited_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "messages_channel_idx": { + "name": "messages_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "messages_user_idx": { + "name": "messages_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "messages_parent_idx": { + "name": "messages_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "messages_created_at_idx": { + "name": "messages_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "messages_pinned_at_idx": { + "name": "messages_pinned_at_idx", + "columns": [ + { + "expression": "pinned_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_channel_id_channels_id_fk": { + "name": "messages_channel_id_channels_id_fk", + "tableFrom": "messages", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_user_id_users_id_fk": { + "name": "messages_user_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_parent_id_messages_id_fk": { + "name": "messages_parent_id_messages_id_fk", + "tableFrom": "messages", + "tableTo": "messages", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "messages_vote_id_votes_id_fk": { + "name": "messages_vote_id_votes_id_fk", + "tableFrom": "messages", + "tableTo": "votes", + "columnsFrom": ["vote_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "messages_pinned_by_users_id_fk": { + "name": "messages_pinned_by_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": ["pinned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reactions": { + "name": "reactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "message_id": { + "name": "message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reactions_message_idx": { + "name": "reactions_message_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reactions_user_idx": { + "name": "reactions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reactions_message_id_messages_id_fk": { + "name": "reactions_message_id_messages_id_fk", + "tableFrom": "reactions", + "tableTo": "messages", + "columnsFrom": ["message_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reactions_user_id_users_id_fk": { + "name": "reactions_user_id_users_id_fk", + "tableFrom": "reactions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_members": { + "name": "organization_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_members_org_idx": { + "name": "org_members_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_members_user_idx": { + "name": "org_members_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_members_organization_id_organizations_id_fk": { + "name": "organization_members_organization_id_organizations_id_fk", + "tableFrom": "organization_members", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_members_user_id_users_id_fk": { + "name": "organization_members_user_id_users_id_fk", + "tableFrom": "organization_members", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_owner_idx": { + "name": "organizations_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organizations_owner_id_users_id_fk": { + "name": "organizations_owner_id_users_id_fk", + "tableFrom": "organizations", + "tableTo": "users", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.personalizations": { + "name": "personalizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "nickname": { + "name": "nickname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "personalizations_user_idx": { + "name": "personalizations_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personalizations_org_idx": { + "name": "personalizations_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personalizations_user_org_idx": { + "name": "personalizations_user_org_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "personalizations_user_id_users_id_fk": { + "name": "personalizations_user_id_users_id_fk", + "tableFrom": "personalizations", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "personalizations_organization_id_organizations_id_fk": { + "name": "personalizations_organization_id_organizations_id_fk", + "tableFrom": "personalizations", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_completed": { + "name": "is_completed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "assigner": { + "name": "assigner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.votes": { + "name": "votes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "max_votes": { + "name": "max_votes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "vote_options": { + "name": "vote_options", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "voters": { + "name": "voters", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index 79e4a83..4bd097d 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1765968590903, "tag": "0000_faithful_jack_power", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1768717215360, + "tag": "0001_faulty_dust", + "breakpoints": true } ] }