From ca621aaea1fba3bc1e3664d2079838b1e78f6ff5 Mon Sep 17 00:00:00 2001 From: Jacob Owens Date: Fri, 29 May 2026 08:14:15 -0700 Subject: [PATCH 1/5] feat: remove announcement and forum channel types --- apps/api/scripts/seed-channels.ts | 1 - apps/api/src/routes/v1/channels/schema.ts | 6 ++++- apps/api/src/routes/v1/guilds/handlers.ts | 5 +---- apps/api/src/routes/v1/uploads/handlers.ts | 21 ------------------ apps/realtime/src/services/messages.ts | 22 ------------------- .../sidebar/channel-panel/channel-list.tsx | 4 ---- .../channel-panel/create-channel-dialog.tsx | 17 +++++++------- apps/web/src/lib/permissions.ts | 10 --------- .../_authenticated/$guildSlug/$channelId.tsx | 11 +--------- packages/auth/src/lib/auth.ts | 8 +------ packages/db/src/schemas/channels.ts | 2 -- .../db/src/schemas/notification-events.ts | 1 - 12 files changed, 16 insertions(+), 92 deletions(-) diff --git a/apps/api/scripts/seed-channels.ts b/apps/api/scripts/seed-channels.ts index 357653c..4f069f4 100644 --- a/apps/api/scripts/seed-channels.ts +++ b/apps/api/scripts/seed-channels.ts @@ -38,7 +38,6 @@ const categories = [ { name: "Community", channels: [ - { name: "announcements", type: "announcement" as const }, { name: "feedback", type: "text" as const }, { name: "showcase", type: "text" as const }, ], diff --git a/apps/api/src/routes/v1/channels/schema.ts b/apps/api/src/routes/v1/channels/schema.ts index 1ce437a..6fb331a 100644 --- a/apps/api/src/routes/v1/channels/schema.ts +++ b/apps/api/src/routes/v1/channels/schema.ts @@ -44,7 +44,11 @@ export const listChannelsResponseSchema = z.object({ categories: z.array(categoryWithChannelsSchema), }) -export const createChannelRequestSchema = insertChannelSchema +// Narrow the create endpoint to guild-creatable types only. +// DMs / group DMs are created via the /dms route, not generic create-channel. +export const createChannelRequestSchema = insertChannelSchema.extend({ + type: z.enum(["text", "voice", "category"]).default("text"), +}) export const createChannelResponseSchema = selectChannelSchema diff --git a/apps/api/src/routes/v1/guilds/handlers.ts b/apps/api/src/routes/v1/guilds/handlers.ts index d828b5d..91bfafe 100644 --- a/apps/api/src/routes/v1/guilds/handlers.ts +++ b/apps/api/src/routes/v1/guilds/handlers.ts @@ -325,10 +325,7 @@ export const searchMessages: AppRouteHandler = async ( }) .from(schema.channel) .where( - and( - eq(schema.channel.guildId, guild.id), - inArray(schema.channel.type, ["text", "announcement", "forum"]) - ) + and(eq(schema.channel.guildId, guild.id), eq(schema.channel.type, "text")) ) const emptyResult = { diff --git a/apps/api/src/routes/v1/uploads/handlers.ts b/apps/api/src/routes/v1/uploads/handlers.ts index c0ab63a..1e49b88 100644 --- a/apps/api/src/routes/v1/uploads/handlers.ts +++ b/apps/api/src/routes/v1/uploads/handlers.ts @@ -71,27 +71,6 @@ export const presign: AppRouteHandler = async (c) => { HttpStatusCodes.FORBIDDEN ) } - - // Block uploads in announcement channels for non-admins/owners - if (ch.type === "announcement") { - const guildRecord = await db - .select({ ownerId: guild.ownerId }) - .from(guild) - .where(eq(guild.id, ch.guildId)) - .limit(1) - .then((rows) => rows[0]) - - if (!guildRecord) { - return c.json( - { success: false, message: "Forbidden" }, - HttpStatusCodes.FORBIDDEN - ) - } - - assertGuildPermission(member, guildRecord, { - channel: ["update"], - }) - } } else if ( DM_CHANNEL_TYPES.includes(ch.type as (typeof DM_CHANNEL_TYPES)[number]) ) { diff --git a/apps/realtime/src/services/messages.ts b/apps/realtime/src/services/messages.ts index 2eb4e6e..5381d02 100644 --- a/apps/realtime/src/services/messages.ts +++ b/apps/realtime/src/services/messages.ts @@ -1,8 +1,3 @@ -import { - type GuildRole, - guildAuthorityHasPermissions, - isGuildRole, -} from "@repo/auth/permissions" import { and, count, db, eq, schema } from "@repo/db" import type { DeleteMessagePayload, @@ -62,23 +57,6 @@ export async function createMessage(input: CreateMessageInput) { const channelRecord = input.accessibleChannel - // Block sending in announcement channels for users without permission - if (channelRecord.type === "announcement" && channelRecord.guildId) { - const role = channelRecord.memberRole - if ( - !role || - !isGuildRole(role) || - !guildAuthorityHasPermissions( - { role: role as GuildRole, isOwner: channelRecord.memberIsOwner }, - { channel: ["create"] } - ) - ) { - throw new Error( - "Only admins and owners can post in announcement channels" - ) - } - } - let hasReply = !!input.payload.referencedMessageId const messageWithAuthor = await db.transaction(async (tx) => { diff --git a/apps/web/src/components/sidebar/channel-panel/channel-list.tsx b/apps/web/src/components/sidebar/channel-panel/channel-list.tsx index ad7d810..925671b 100644 --- a/apps/web/src/components/sidebar/channel-panel/channel-list.tsx +++ b/apps/web/src/components/sidebar/channel-panel/channel-list.tsx @@ -31,8 +31,6 @@ import { useNavigate, useParams } from "@tanstack/react-router" import { ChevronDown, FolderPlus, - Megaphone, - MessageSquare, MoreHorizontal, Plus, Scroll, @@ -56,8 +54,6 @@ import { EditChannelDialog } from "./edit-channel-dialog" const channelIcons = { text: Scroll, voice: Volume2, - announcement: Megaphone, - forum: MessageSquare, } as const type ChannelData = ListChannelsResponse diff --git a/apps/web/src/components/sidebar/channel-panel/create-channel-dialog.tsx b/apps/web/src/components/sidebar/channel-panel/create-channel-dialog.tsx index 3111a3e..d0e2f19 100644 --- a/apps/web/src/components/sidebar/channel-panel/create-channel-dialog.tsx +++ b/apps/web/src/components/sidebar/channel-panel/create-channel-dialog.tsx @@ -17,23 +17,22 @@ import { } from "@repo/ui/components/select" import { useQueryClient } from "@tanstack/react-query" import { useNavigate, useParams } from "@tanstack/react-router" -import { Loader2, Megaphone, Scroll } from "lucide-react" +import { Loader2, Scroll, Volume2 } from "lucide-react" import { useState } from "react" import { apiClient } from "@/lib/api-client" const channelTypes = [ { value: "text", - label: "Scroll", + label: "Text", icon: Scroll, description: "A text channel for general conversation and discussion", }, { - value: "announcement", - label: "Decree", - icon: Megaphone, - description: - "A read-only channel for important announcements. Only admins and wardens can post", + value: "voice", + label: "Voice", + icon: Volume2, + description: "A voice channel for huddles and meetings", }, ] as const @@ -52,7 +51,7 @@ export function CreateChannelDialog({ const navigate = useNavigate() const queryClient = useQueryClient() const [name, setName] = useState("") - const [type, setType] = useState<"text" | "announcement">("text") + const [type, setType] = useState<"text" | "voice">("text") const [error, setError] = useState(null) const [loading, setLoading] = useState(false) @@ -143,7 +142,7 @@ export function CreateChannelDialog({ Invite Link or Code setInviteLink(e.target.value)} disabled={loading} diff --git a/apps/web/src/components/sidebar/guild-bar/create-guild-dialog.tsx b/apps/web/src/components/sidebar/guild-bar/create-guild-dialog.tsx index f43dee9..9654d5f 100644 --- a/apps/web/src/components/sidebar/guild-bar/create-guild-dialog.tsx +++ b/apps/web/src/components/sidebar/guild-bar/create-guild-dialog.tsx @@ -232,7 +232,7 @@ export function CreateGuildDialog({
- townhall.chat/ + lor.chat/ Invite Link or Code setInviteLink(e.target.value)} disabled={loading} diff --git a/apps/web/src/components/sidebar/index.tsx b/apps/web/src/components/sidebar/index.tsx index 86e0496..04835ae 100644 --- a/apps/web/src/components/sidebar/index.tsx +++ b/apps/web/src/components/sidebar/index.tsx @@ -58,7 +58,7 @@ function DesktopSidebarLayout({ children }: { children: React.ReactNode }) { const widthRef = useRef(panelWidth) const { defaultLayout, onLayoutChange } = useDefaultLayout({ - groupId: "townhall-sidebar", + groupId: "lor-sidebar", storage: localStorage, }) diff --git a/apps/web/src/components/sidebar/right-panel/right-sidebar-context.tsx b/apps/web/src/components/sidebar/right-panel/right-sidebar-context.tsx index c4d5647..069fb86 100644 --- a/apps/web/src/components/sidebar/right-panel/right-sidebar-context.tsx +++ b/apps/web/src/components/sidebar/right-panel/right-sidebar-context.tsx @@ -20,8 +20,8 @@ interface RightSidebarContextValue { isHydrated: boolean } -const PANEL_COLLAPSED_KEY = "townhall-right-panel-collapsed" -const PANEL_WIDTH_KEY = "townhall-right-panel-width" +const PANEL_COLLAPSED_KEY = "lor-right-panel-collapsed" +const PANEL_WIDTH_KEY = "lor-right-panel-width" const DEFAULT_WIDTH = 280 function getStoredCollapsed(): boolean { diff --git a/apps/web/src/routes/_authenticated.tsx b/apps/web/src/routes/_authenticated.tsx index 2102f78..593218f 100644 --- a/apps/web/src/routes/_authenticated.tsx +++ b/apps/web/src/routes/_authenticated.tsx @@ -48,7 +48,7 @@ function UpdateBanner() { ) } -const LAST_PATH_KEY = "townhall:last-path" +const LAST_PATH_KEY = "lor:last-path" export const Route = createFileRoute("/_authenticated")({ component: AuthenticatedLayout, diff --git a/apps/web/src/routes/login.tsx b/apps/web/src/routes/login.tsx index da0db92..60541b5 100644 --- a/apps/web/src/routes/login.tsx +++ b/apps/web/src/routes/login.tsx @@ -66,11 +66,7 @@ function LoginPage() { to="/login" className="flex items-center gap-2 self-center font-medium" > - Lor + Lor Lor
diff --git a/apps/web/src/routes/signup.tsx b/apps/web/src/routes/signup.tsx index 5a11ce5..8f47321 100644 --- a/apps/web/src/routes/signup.tsx +++ b/apps/web/src/routes/signup.tsx @@ -60,11 +60,7 @@ function SignUpPage() { to="/login" className="flex items-center gap-2 self-center font-medium" > - Lor + Lor Lor
diff --git a/docker-compose.yml b/docker-compose.yml index c9f550f..d5af560 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,7 +50,7 @@ services: S3_REGION: ${S3_REGION:-auto} S3_PUBLIC_URL: ${S3_PUBLIC_URL} RESEND_API_KEY: ${RESEND_API_KEY} - EMAIL_FROM: ${EMAIL_FROM:-Townhall } + EMAIL_FROM: ${EMAIL_FROM:-Lor } depends_on: postgres: condition: service_healthy @@ -79,7 +79,7 @@ services: S3_REGION: ${S3_REGION:-auto} S3_PUBLIC_URL: ${S3_PUBLIC_URL} RESEND_API_KEY: ${RESEND_API_KEY} - EMAIL_FROM: ${EMAIL_FROM:-Townhall } + EMAIL_FROM: ${EMAIL_FROM:-Lor } depends_on: postgres: condition: service_healthy @@ -104,7 +104,7 @@ services: S3_PUBLIC_URL: ${S3_PUBLIC_URL} BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} RESEND_API_KEY: ${RESEND_API_KEY} - EMAIL_FROM: ${EMAIL_FROM:-Townhall } + EMAIL_FROM: ${EMAIL_FROM:-Lor } depends_on: postgres: condition: service_healthy diff --git a/package.json b/package.json index 2900ee2..f75dcd0 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "townhall", + "name": "lor", "private": true, "scripts": { "build": "turbo run build", diff --git a/packages/auth/src/lib/auth.ts b/packages/auth/src/lib/auth.ts index 486e57a..cd9f745 100644 --- a/packages/auth/src/lib/auth.ts +++ b/packages/auth/src/lib/auth.ts @@ -85,7 +85,7 @@ export const auth = betterAuth({ baseURL: env.NEXT_PUBLIC_API_URL, database: drizzleAdapter(db, { provider: "pg", schema }), secret: env.BETTER_AUTH_SECRET, - secondaryStorage: redisStorage({ client: redis, keyPrefix: "townhall:" }), + secondaryStorage: redisStorage({ client: redis, keyPrefix: "lor:" }), rateLimit: { enabled: true, window: 60, @@ -184,7 +184,7 @@ export const auth = betterAuth({ }, }, advanced: { - cookiePrefix: "townhall", + cookiePrefix: "lor", database: { generateId: false, }, diff --git a/packages/env/src/server.ts b/packages/env/src/server.ts index 47dc02b..fce3e03 100644 --- a/packages/env/src/server.ts +++ b/packages/env/src/server.ts @@ -45,7 +45,7 @@ const serverSchema = z.object({ S3_REGION: z.string().default("auto"), S3_PUBLIC_URL: z.string().url(), RESEND_API_KEY: z.string().min(1), - EMAIL_FROM: z.string().default("Townhall "), + EMAIL_FROM: z.string().default("Lor "), TRUSTED_ORIGINS: z.string().default(""), COOKIE_DOMAIN: z.string().default(""), }) From a79d24348e87e4014dda48674454ceb2ab0c0dea Mon Sep 17 00:00:00 2001 From: Jacob Owens Date: Fri, 29 May 2026 13:10:04 -0700 Subject: [PATCH 4/5] chore: renaming townhall -> lor --- apps/desktop/src-tauri/tauri.conf.json | 2 +- docker-compose.yml | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index d7f47a5..2ad2686 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -45,7 +45,7 @@ "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQxNjNCOUYwOEIwQ0RCRTAKUldUZzJ3eUw4TGxqUWJkKzl4emczdUNLMlNJVWZLc24rWFpOaWNVaTEzV0doSUdPMzVHTzBYcXYK", "endpoints": [ - "https://github.com/BuckyMcYolo/townhall/releases/latest/download/latest.json" + "https://github.com/BuckyMcYolo/lor/releases/latest/download/latest.json" ] } } diff --git a/docker-compose.yml b/docker-compose.yml index d5af560..551efef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,15 +3,15 @@ services: image: postgres:16-alpine restart: unless-stopped environment: - POSTGRES_USER: townhall - POSTGRES_PASSWORD: townhall - POSTGRES_DB: townhall + POSTGRES_USER: lor + POSTGRES_PASSWORD: lor + POSTGRES_DB: lor ports: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U townhall"] + test: ["CMD-SHELL", "pg_isready -U lor"] interval: 5s timeout: 3s retries: 5 @@ -39,7 +39,7 @@ services: environment: NODE_ENV: production PORT: 8080 - DATABASE_URL: postgresql://townhall:townhall@postgres:5432/townhall + DATABASE_URL: postgresql://lor:lor@postgres:5432/lor REDIS_URL: redis://redis:6379 BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8080} @@ -67,7 +67,7 @@ services: environment: NODE_ENV: production REALTIME_PORT: 8000 - DATABASE_URL: postgresql://townhall:townhall@postgres:5432/townhall + DATABASE_URL: postgresql://lor:lor@postgres:5432/lor REDIS_URL: redis://redis:6379 BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8080} @@ -93,7 +93,7 @@ services: restart: unless-stopped environment: NODE_ENV: production - DATABASE_URL: postgresql://townhall:townhall@postgres:5432/townhall + DATABASE_URL: postgresql://lor:lor@postgres:5432/lor REDIS_URL: redis://redis:6379 NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8080} S3_ENDPOINT: ${S3_ENDPOINT} From 9709b59f7b833865ff8202112198a7ed72d215a7 Mon Sep 17 00:00:00 2001 From: Jacob Owens Date: Fri, 29 May 2026 15:41:24 -0700 Subject: [PATCH 5/5] =?UTF-8?q?chore(lor-pivot):=20rename=20guilds=20?= =?UTF-8?q?=E2=86=92=20workspaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PIVOT.md | 2 +- apps/api/scripts/seed-channels.ts | 22 +- apps/api/scripts/seed-dms.ts | 4 +- apps/api/src/app.ts | 4 +- apps/api/src/lib/permissions.ts | 50 ++--- apps/api/src/lib/types/app-types.ts | 10 +- apps/api/src/middleware/session-auth.ts | 4 +- .../{guild-auth.ts => workspace-auth.ts} | 32 +-- apps/api/src/routes/v1/channels/handlers.ts | 78 +++++--- apps/api/src/routes/v1/channels/routes.ts | 54 ++--- apps/api/src/routes/v1/channels/schema.ts | 12 +- apps/api/src/routes/v1/dms/handlers.ts | 30 +-- apps/api/src/routes/v1/guilds/index.ts | 12 -- apps/api/src/routes/v1/guilds/routes.ts | 155 -------------- apps/api/src/routes/v1/invites/handlers.ts | 189 +++++++++--------- apps/api/src/routes/v1/invites/index.ts | 2 +- apps/api/src/routes/v1/invites/routes.ts | 44 ++-- apps/api/src/routes/v1/invites/schema.ts | 46 +++-- apps/api/src/routes/v1/uploads/handlers.ts | 74 ++++--- apps/api/src/routes/v1/uploads/index.ts | 2 +- apps/api/src/routes/v1/uploads/routes.ts | 22 +- apps/api/src/routes/v1/uploads/schema.ts | 23 ++- .../v1/{guilds => workspaces}/handlers.ts | 176 ++++++++-------- apps/api/src/routes/v1/workspaces/index.ts | 12 ++ apps/api/src/routes/v1/workspaces/routes.ts | 156 +++++++++++++++ .../v1/{guilds => workspaces}/schema.ts | 38 ++-- apps/realtime/src/index.ts | 108 +++++----- apps/realtime/src/services/channel-access.ts | 21 +- apps/realtime/src/services/notifications.ts | 20 +- apps/realtime/src/services/rate-limit.ts | 19 +- apps/realtime/src/services/read-states.ts | 29 +-- .../web/src/components/chat/header-search.tsx | 12 +- apps/web/src/components/chat/header.tsx | 8 +- .../invite/create-invite-dialog.tsx | 22 +- .../invite/manage-invites-dialog.tsx | 22 +- .../onboarding/onboarding-dialog.tsx | 77 ++++--- .../sidebar/channel-panel/channel-list.tsx | 71 ++++--- .../sidebar/channel-panel/channel-panel.tsx | 4 +- .../channel-panel/create-channel-dialog.tsx | 18 +- .../channel-panel/delete-channel-dialog.tsx | 17 +- .../channel-panel/edit-channel-dialog.tsx | 20 +- .../sidebar/channel-panel/search-bar.tsx | 24 ++- ...{guild-header.tsx => workspace-header.tsx} | 50 ++--- apps/web/src/components/sidebar/index.tsx | 24 +-- .../right-panel/pinned-messages-panel.tsx | 8 +- .../right-panel/right-sidebar-panel.tsx | 6 +- .../right-panel/right-sidebar-types.ts | 14 +- ...-panel.tsx => workspace-members-panel.tsx} | 140 +++++++------ .../create-workspace-dialog.tsx} | 66 +++--- .../workspace-bar.tsx} | 40 ++-- .../workspace-settings-dialog.tsx} | 58 +++--- .../src/hooks/use-browser-notifications.ts | 10 +- apps/web/src/hooks/use-message-pinning.ts | 10 +- apps/web/src/lib/api-types.ts | 29 +-- apps/web/src/lib/permissions.ts | 68 +++---- apps/web/src/routes/_authenticated.tsx | 10 +- .../{$guildSlug.tsx => $workspaceSlug.tsx} | 75 +++---- .../$channelId.tsx | 58 +++--- .../{$guildSlug => $workspaceSlug}/index.tsx | 16 +- .../routes/_authenticated/invite/$code.tsx | 37 ++-- packages/auth/src/lib/auth.ts | 38 ++-- packages/auth/src/lib/permissions.ts | 80 ++++---- packages/db/src/generated-schema.ts | 87 ++++---- packages/db/src/schemas/channels.ts | 24 +-- packages/db/src/schemas/guild-members.ts | 47 ----- packages/db/src/schemas/guild-roles.ts | 30 --- packages/db/src/schemas/guilds.ts | 56 ------ packages/db/src/schemas/index.ts | 8 +- packages/db/src/schemas/invitations.ts | 14 +- packages/db/src/schemas/message-mentions.ts | 4 +- .../db/src/schemas/notification-events.ts | 16 +- packages/db/src/schemas/sessions.ts | 2 +- packages/db/src/schemas/users.ts | 8 +- ...{guild-invites.ts => workspace-invites.ts} | 45 +++-- packages/db/src/schemas/workspace-members.ts | 50 +++++ packages/db/src/schemas/workspace-roles.ts | 30 +++ packages/db/src/schemas/workspaces.ts | 56 ++++++ packages/realtime-types/src/events.ts | 34 ++-- packages/realtime-types/src/rooms.ts | 4 +- 79 files changed, 1620 insertions(+), 1477 deletions(-) rename apps/api/src/middleware/{guild-auth.ts => workspace-auth.ts} (62%) delete mode 100644 apps/api/src/routes/v1/guilds/index.ts delete mode 100644 apps/api/src/routes/v1/guilds/routes.ts rename apps/api/src/routes/v1/{guilds => workspaces}/handlers.ts (64%) create mode 100644 apps/api/src/routes/v1/workspaces/index.ts create mode 100644 apps/api/src/routes/v1/workspaces/routes.ts rename apps/api/src/routes/v1/{guilds => workspaces}/schema.ts (65%) rename apps/web/src/components/sidebar/channel-panel/{guild-header.tsx => workspace-header.tsx} (73%) rename apps/web/src/components/sidebar/right-panel/{guild-members-panel.tsx => workspace-members-panel.tsx} (81%) rename apps/web/src/components/sidebar/{guild-bar/create-guild-dialog.tsx => workspace-bar/create-workspace-dialog.tsx} (84%) rename apps/web/src/components/sidebar/{guild-bar/guild-bar.tsx => workspace-bar/workspace-bar.tsx} (82%) rename apps/web/src/components/{guild/guild-settings-dialog.tsx => workspace/workspace-settings-dialog.tsx} (84%) rename apps/web/src/routes/_authenticated/{$guildSlug.tsx => $workspaceSlug.tsx} (57%) rename apps/web/src/routes/_authenticated/{$guildSlug => $workspaceSlug}/$channelId.tsx (84%) rename apps/web/src/routes/_authenticated/{$guildSlug => $workspaceSlug}/index.tsx (62%) delete mode 100644 packages/db/src/schemas/guild-members.ts delete mode 100644 packages/db/src/schemas/guild-roles.ts delete mode 100644 packages/db/src/schemas/guilds.ts rename packages/db/src/schemas/{guild-invites.ts => workspace-invites.ts} (50%) create mode 100644 packages/db/src/schemas/workspace-members.ts create mode 100644 packages/db/src/schemas/workspace-roles.ts create mode 100644 packages/db/src/schemas/workspaces.ts diff --git a/PIVOT.md b/PIVOT.md index 5154f94..aae820e 100644 --- a/PIVOT.md +++ b/PIVOT.md @@ -318,7 +318,7 @@ Recommended v1 scope: **one connector, done exceptionally well** (GitHub is the **Foundation first — rework the existing chat app before any Merlin work begins.** Per maintainer call (2026-05-28): the order below front-loads the delete pass + workspace rename so we have a clean base, then layers Merlin on top. 1. **Branch the delete pass.** Strip the old surfaces listed in [Delete aggressively](#delete-aggressively-do-not-rename-do-not-migrate-just-rm) — social/friendship layer, per-guild roles/bans/timeouts, `announcement`/`forum`/`category` channel types, Townhall lexicon, Ravn references. Voice channels stay. -2. **Resolve the workspace primitive name** (see Open decisions) and execute the `guilds → workspaces|organizations|keeps` rename across schema, API routes, web routes, and components. +2. ~~**Resolve the workspace primitive name** (see Open decisions) and execute the `guilds → workspaces|organizations|keeps` rename across schema, API routes, web routes, and components.~~ **Done.** `guild*` → `workspace*` rename executed across schemas (`packages/db/src/schemas/workspace*.ts`), API (`apps/api/src/routes/v1/workspaces/`), web routes (`apps/web/src/routes/_authenticated/$workspaceSlug/`), components, realtime room/event names, and permission helpers. Better-auth's `organization` plugin surface is unchanged at the API boundary. 3. **Collapse private-Hall scaffolding** if any landed — channels are public-to-workspace by design. 4. **Rebuild the sidebar IA** — tabbed sidebar (Channels / DMs / Merlin) + minimal top bar per [Navigation & sidebar IA](#navigation--sidebar-ia). Includes Discord-style collapsible categories. **The workspace switcher itself is deferred** — top-left can stay as a static workspace badge for now (the multi-workspace switcher dropdown is post-foundation work). 5. Rebuild marketing site copy on `apps/www` against the new Lor / institutional-memory positioning. diff --git a/apps/api/scripts/seed-channels.ts b/apps/api/scripts/seed-channels.ts index 4f069f4..874b321 100644 --- a/apps/api/scripts/seed-channels.ts +++ b/apps/api/scripts/seed-channels.ts @@ -1,18 +1,18 @@ /** - * Seed a guild with sample channels. + * Seed a workspace with sample channels. * * Usage: - * pnpm --filter @repo/api exec tsx scripts/seed-channels.ts + * pnpm --filter @repo/api exec tsx scripts/seed-channels.ts */ import { db } from "@repo/db" import { channel } from "@repo/db/schema" import { eq } from "drizzle-orm" -const guildId = process.argv[2] -if (!guildId) { +const workspaceId = process.argv[2] +if (!workspaceId) { console.error( - "Usage: pnpm --filter @repo/api exec tsx scripts/seed-channels.ts " + "Usage: pnpm --filter @repo/api exec tsx scripts/seed-channels.ts " ) process.exit(1) } @@ -53,9 +53,9 @@ const categories = [ ] async function seed() { - // Clear existing channels for this guild - await db.delete(channel).where(eq(channel.guildId, guildId)) - console.log(`Cleared existing channels for guild ${guildId}`) + // Clear existing channels for this workspace + await db.delete(channel).where(eq(channel.workspaceId, workspaceId)) + console.log(`Cleared existing channels for workspace ${workspaceId}`) // Uncategorized channels at the top const uncategorized = [ @@ -67,7 +67,7 @@ async function seed() { await db.insert(channel).values({ name: uncategorized[i].name, type: uncategorized[i].type, - guildId, + workspaceId, position: i, }) console.log(` # ${uncategorized[i].name}`) @@ -81,7 +81,7 @@ async function seed() { .values({ name: cat.name, type: "category", - guildId, + workspaceId, position: catIdx, }) .returning() @@ -93,7 +93,7 @@ async function seed() { await db.insert(channel).values({ name: ch.name, type: ch.type, - guildId, + workspaceId, parentId: categoryRow.id, position: chIdx, }) diff --git a/apps/api/scripts/seed-dms.ts b/apps/api/scripts/seed-dms.ts index 53c4f1e..aacaf8f 100644 --- a/apps/api/scripts/seed-dms.ts +++ b/apps/api/scripts/seed-dms.ts @@ -141,7 +141,7 @@ async function seed() { .insert(channel) .values({ type: "dm", - guildId: null, + workspaceId: null, position: 0, }) .returning() @@ -198,7 +198,7 @@ async function seed() { .values({ type: "group_dm", name: group.name, - guildId: null, + workspaceId: null, ownerId: userId, position: 0, }) diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 515345a..03475e3 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -6,11 +6,11 @@ import { globalRateLimit } from "@/middleware/rate-limit" import index from "@/routes/index.route" import channelsRouter from "@/routes/v1/channels/index" import dmsRouter from "@/routes/v1/dms/index" -import guildsRouter from "@/routes/v1/guilds/index" import invitesRouter from "@/routes/v1/invites/index" import notificationSettingsRouter from "@/routes/v1/notification-settings/index" import uploadsRouter from "@/routes/v1/uploads/index" import usersRouter from "@/routes/v1/users/index" +import workspacesRouter from "@/routes/v1/workspaces/index" import waitlistRouter from "@/routes/waitlist/index" const app = createApp() @@ -38,7 +38,7 @@ app.route("/", index) const routes = app .route("/", waitlistRouter) .route("/v1", channelsRouter) - .route("/v1", guildsRouter) + .route("/v1", workspacesRouter) .route("/v1", invitesRouter) .route("/v1", notificationSettingsRouter) .route("/v1", dmsRouter) diff --git a/apps/api/src/lib/permissions.ts b/apps/api/src/lib/permissions.ts index 32fefdd..676370f 100644 --- a/apps/api/src/lib/permissions.ts +++ b/apps/api/src/lib/permissions.ts @@ -1,15 +1,15 @@ import { auth } from "@repo/auth" import { - canManageGuildAuthority, - type GuildAuthority, - guildAuthorityHasPermissions, - isGuildRole, + canManageWorkspaceAuthority, + isWorkspaceRole, type PermissionRequest, type StatementKey, + type WorkspaceAuthority, + workspaceAuthorityHasPermissions, } from "@repo/auth/permissions" import { HTTPException } from "hono/http-exception" import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" -import type { Guild, GuildMember } from "@/lib/types/app-types" +import type { Workspace, WorkspaceMember } from "@/lib/types/app-types" // ── Type-Safe Permission Types ────────────────────────────────────── @@ -19,26 +19,26 @@ export type PermissionForStatement = NonNullable< PermissionRequest[T] >[number] -function toGuildAuthority( - member: Pick, - guild: Pick -): GuildAuthority { - if (!isGuildRole(member.role)) { +function toWorkspaceAuthority( + member: Pick, + workspace: Pick +): WorkspaceAuthority { + if (!isWorkspaceRole(member.role)) { throw new HTTPException(HttpStatusCodes.FORBIDDEN, { - message: `Unknown guild role: ${member.role}`, + message: `Unknown workspace role: ${member.role}`, }) } return { role: member.role, - isOwner: guild.ownerId === member.userId, + isOwner: workspace.ownerId === member.userId, } } // ── Permission Check ────────────────────────────────────── /** - * Checks if the current user has the specified permissions in their active guild. + * Checks if the current user has the specified permissions in their active workspace. * Uses better-auth's hasPermission API and throws HTTPException(403) when the * requested permission is missing. * @@ -70,14 +70,14 @@ export async function checkPermission< return true } -export function assertGuildPermission( - member: Pick, - guild: Pick, +export function assertWorkspacePermission( + member: Pick, + workspace: Pick, requestedPermissions: PermissionRequest ) { - const authority = toGuildAuthority(member, guild) + const authority = toWorkspaceAuthority(member, workspace) - if (!guildAuthorityHasPermissions(authority, requestedPermissions)) { + if (!workspaceAuthorityHasPermissions(authority, requestedPermissions)) { throw new HTTPException(HttpStatusCodes.FORBIDDEN, { message: "You do not have permission to perform this action", }) @@ -86,10 +86,10 @@ export function assertGuildPermission( return authority } -export function assertCanManageGuildMember( - actor: Pick, - target: Pick, - guild: Pick +export function assertCanManageWorkspaceMember( + actor: Pick, + target: Pick, + workspace: Pick ) { if (actor.userId === target.userId) { throw new HTTPException(HttpStatusCodes.FORBIDDEN, { @@ -97,10 +97,10 @@ export function assertCanManageGuildMember( }) } - const actorAuthority = toGuildAuthority(actor, guild) - const targetAuthority = toGuildAuthority(target, guild) + const actorAuthority = toWorkspaceAuthority(actor, workspace) + const targetAuthority = toWorkspaceAuthority(target, workspace) - if (!canManageGuildAuthority(actorAuthority, targetAuthority)) { + if (!canManageWorkspaceAuthority(actorAuthority, targetAuthority)) { throw new HTTPException(HttpStatusCodes.FORBIDDEN, { message: "You cannot moderate this member", }) diff --git a/apps/api/src/lib/types/app-types.ts b/apps/api/src/lib/types/app-types.ts index b9326ef..757ca4c 100644 --- a/apps/api/src/lib/types/app-types.ts +++ b/apps/api/src/lib/types/app-types.ts @@ -1,19 +1,19 @@ import type { OpenAPIHono, RouteConfig, RouteHandler } from "@hono/zod-openapi" import type { Session } from "@repo/auth" -import type { guild, guildMember } from "@repo/db/schema" +import type { workspace, workspaceMember } from "@repo/db/schema" import type { Schema } from "hono" import type { PinoLogger } from "hono-pino" -export type Guild = typeof guild.$inferSelect -export type GuildMember = typeof guildMember.$inferSelect +export type Workspace = typeof workspace.$inferSelect +export type WorkspaceMember = typeof workspaceMember.$inferSelect export interface AppBindings { Variables: { logger: PinoLogger user: Session["user"] session: Session["session"] - guild: Guild - member: GuildMember + workspace: Workspace + member: WorkspaceMember } } diff --git a/apps/api/src/middleware/session-auth.ts b/apps/api/src/middleware/session-auth.ts index 3d5144c..b252170 100644 --- a/apps/api/src/middleware/session-auth.ts +++ b/apps/api/src/middleware/session-auth.ts @@ -5,9 +5,9 @@ import type { AppBindings } from "@/lib/types/app-types" /** * Lightweight auth middleware that only validates the session. - * Does NOT require or resolve an active guild. + * Does NOT require or resolve an active workspace. * - * Use this for guild-independent routes like DMs. + * Use this for workspace-independent routes like DMs. * * Sets in context: * - user: The authenticated user diff --git a/apps/api/src/middleware/guild-auth.ts b/apps/api/src/middleware/workspace-auth.ts similarity index 62% rename from apps/api/src/middleware/guild-auth.ts rename to apps/api/src/middleware/workspace-auth.ts index 8cd6335..d772e18 100644 --- a/apps/api/src/middleware/guild-auth.ts +++ b/apps/api/src/middleware/workspace-auth.ts @@ -1,6 +1,6 @@ import { auth } from "@repo/auth" import { db } from "@repo/db" -import { guild, guildMember } from "@repo/db/schema" +import { workspace, workspaceMember } from "@repo/db/schema" import { and, eq } from "drizzle-orm" import type { Context, Next } from "hono" import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" @@ -8,16 +8,16 @@ import type { AppBindings } from "@/lib/types/app-types" /** * Authenticates the request via better-auth session and resolves the - * guild from the :guildSlug path parameter. Verifies the user is a - * member of the guild. + * workspace from the :workspaceSlug path parameter. Verifies the user is a + * member of the workspace. * * Sets in context: * - user: The authenticated user * - session: The session object - * - guild: The resolved guild - * - member: The user's membership in the guild + * - workspace: The resolved workspace + * - member: The user's membership in the workspace */ -export const guildAuthMiddleware = async ( +export const workspaceAuthMiddleware = async ( c: Context, next: Next ) => { @@ -30,29 +30,29 @@ export const guildAuthMiddleware = async ( ) } - const guildSlug = c.req.param("guildSlug") + const workspaceSlug = c.req.param("workspaceSlug") - const guildRecord = await db + const workspaceRecord = await db .select() - .from(guild) - .where(eq(guild.slug, guildSlug)) + .from(workspace) + .where(eq(workspace.slug, workspaceSlug)) .limit(1) .then((rows) => rows[0]) - if (!guildRecord) { + if (!workspaceRecord) { return c.json( - { success: false, message: "Guild not found" }, + { success: false, message: "Workspace not found" }, HttpStatusCodes.NOT_FOUND ) } const memberRecord = await db .select() - .from(guildMember) + .from(workspaceMember) .where( and( - eq(guildMember.userId, session.user.id), - eq(guildMember.guildId, guildRecord.id) + eq(workspaceMember.userId, session.user.id), + eq(workspaceMember.workspaceId, workspaceRecord.id) ) ) .limit(1) @@ -67,7 +67,7 @@ export const guildAuthMiddleware = async ( c.set("user", session.user) c.set("session", session.session) - c.set("guild", guildRecord) + c.set("workspace", workspaceRecord) c.set("member", memberRecord) await next() diff --git a/apps/api/src/routes/v1/channels/handlers.ts b/apps/api/src/routes/v1/channels/handlers.ts index 5490d8d..4881b6a 100644 --- a/apps/api/src/routes/v1/channels/handlers.ts +++ b/apps/api/src/routes/v1/channels/handlers.ts @@ -8,7 +8,7 @@ import { } from "@repo/db/schema" import { and, asc, desc, eq, inArray } from "drizzle-orm" import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" -import { assertGuildPermission } from "@/lib/permissions" +import { assertWorkspacePermission } from "@/lib/permissions" import { fetchMessagePage } from "@/lib/queries/messages" import type { AppRouteHandler } from "@/lib/types/app-types" import type { @@ -24,12 +24,12 @@ import type { } from "./routes" export const listChannels: AppRouteHandler = async (c) => { - const guild = c.var.guild + const workspace = c.var.workspace const channels = await db .select() .from(channel) - .where(eq(channel.guildId, guild.id)) + .where(eq(channel.workspaceId, workspace.id)) .orderBy(asc(channel.position)) const categoryMap = new Map() @@ -66,11 +66,11 @@ export const listChannels: AppRouteHandler = async (c) => { } export const createChannel: AppRouteHandler = async (c) => { - const guild = c.var.guild + const workspace = c.var.workspace const member = c.var.member const body = c.req.valid("json") - assertGuildPermission(member, guild, { + assertWorkspacePermission(member, workspace, { channel: ["create"], }) @@ -78,7 +78,7 @@ export const createChannel: AppRouteHandler = async (c) => { .insert(channel) .values({ ...body, - guildId: guild.id, + workspaceId: workspace.id, }) .returning() .then((rows) => rows[0]) @@ -96,28 +96,34 @@ export const createChannel: AppRouteHandler = async (c) => { export const reorderChannels: AppRouteHandler = async ( c ) => { - const guild = c.var.guild + const workspace = c.var.workspace const member = c.var.member const { channels: updates } = c.req.valid("json") - assertGuildPermission(member, guild, { + assertWorkspacePermission(member, workspace, { channel: ["update"], }) const channelIds = updates.map((u) => u.id) const uniqueChannelIds = [...new Set(channelIds)] - // Verify all channels belong to this guild + // Verify all channels belong to this workspace const existing = await db .select({ id: channel.id }) .from(channel) .where( - and(eq(channel.guildId, guild.id), inArray(channel.id, uniqueChannelIds)) + and( + eq(channel.workspaceId, workspace.id), + inArray(channel.id, uniqueChannelIds) + ) ) if (existing.length !== uniqueChannelIds.length) { return c.json( - { success: false, message: "One or more channels not found in guild" }, + { + success: false, + message: "One or more channels not found in workspace", + }, HttpStatusCodes.FORBIDDEN ) } @@ -127,7 +133,9 @@ export const reorderChannels: AppRouteHandler = async ( await tx .update(channel) .set({ position: update.position, parentId: update.parentId }) - .where(and(eq(channel.id, update.id), eq(channel.guildId, guild.id))) + .where( + and(eq(channel.id, update.id), eq(channel.workspaceId, workspace.id)) + ) } }) @@ -135,13 +143,15 @@ export const reorderChannels: AppRouteHandler = async ( } export const getChannel: AppRouteHandler = async (c) => { - const guild = c.var.guild + const workspace = c.var.workspace const { channelId } = c.req.valid("param") const ch = await db .select() .from(channel) - .where(and(eq(channel.id, channelId), eq(channel.guildId, guild.id))) + .where( + and(eq(channel.id, channelId), eq(channel.workspaceId, workspace.id)) + ) .limit(1) .then((rows) => rows[0]) @@ -156,19 +166,21 @@ export const getChannel: AppRouteHandler = async (c) => { } export const updateChannel: AppRouteHandler = async (c) => { - const guild = c.var.guild + const workspace = c.var.workspace const member = c.var.member const { channelId } = c.req.valid("param") const body = c.req.valid("json") - assertGuildPermission(member, guild, { + assertWorkspacePermission(member, workspace, { channel: ["update"], }) const updated = await db .update(channel) .set(body) - .where(and(eq(channel.id, channelId), eq(channel.guildId, guild.id))) + .where( + and(eq(channel.id, channelId), eq(channel.workspaceId, workspace.id)) + ) .returning() .then((rows) => rows[0]) @@ -183,17 +195,19 @@ export const updateChannel: AppRouteHandler = async (c) => { } export const deleteChannel: AppRouteHandler = async (c) => { - const guild = c.var.guild + const workspace = c.var.workspace const member = c.var.member const { channelId } = c.req.valid("param") - assertGuildPermission(member, guild, { + assertWorkspacePermission(member, workspace, { channel: ["delete"], }) const deleted = await db .delete(channel) - .where(and(eq(channel.id, channelId), eq(channel.guildId, guild.id))) + .where( + and(eq(channel.id, channelId), eq(channel.workspaceId, workspace.id)) + ) .returning({ id: channel.id }) .then((rows) => rows[0]) @@ -210,16 +224,18 @@ export const deleteChannel: AppRouteHandler = async (c) => { export const listChannelMessages: AppRouteHandler< ListChannelMessagesRoute > = async (c) => { - const guild = c.var.guild + const workspace = c.var.workspace const currentUser = c.var.user const { channelId } = c.req.valid("param") const { page, perPage } = c.req.valid("query") - // Verify channel belongs to this guild + // Verify channel belongs to this workspace const ch = await db .select({ id: channel.id }) .from(channel) - .where(and(eq(channel.id, channelId), eq(channel.guildId, guild.id))) + .where( + and(eq(channel.id, channelId), eq(channel.workspaceId, workspace.id)) + ) .limit(1) .then((rows) => rows[0]) @@ -239,15 +255,15 @@ export const listChannelMessages: AppRouteHandler< export const toggleMessagePin: AppRouteHandler = async ( c ) => { - const guild = c.var.guild + const workspace = c.var.workspace const member = c.var.member const { channelId, messageId } = c.req.valid("param") - assertGuildPermission(member, guild, { + assertWorkspacePermission(member, workspace, { message: ["pin"], }) - // Verify message exists in this channel and guild + // Verify message exists in this channel and workspace const msg = await db .select({ id: message.id, @@ -260,7 +276,7 @@ export const toggleMessagePin: AppRouteHandler = async ( and( eq(message.id, messageId), eq(message.channelId, channelId), - eq(channel.guildId, guild.id) + eq(channel.workspaceId, workspace.id) ) ) .limit(1) @@ -289,15 +305,17 @@ export const toggleMessagePin: AppRouteHandler = async ( export const listPinnedMessages: AppRouteHandler< ListPinnedMessagesRoute > = async (c) => { - const guild = c.var.guild + const workspace = c.var.workspace const currentUser = c.var.user const { channelId } = c.req.valid("param") - // Verify channel belongs to guild + // Verify channel belongs to workspace const ch = await db .select({ id: channel.id }) .from(channel) - .where(and(eq(channel.id, channelId), eq(channel.guildId, guild.id))) + .where( + and(eq(channel.id, channelId), eq(channel.workspaceId, workspace.id)) + ) .limit(1) .then((rows) => rows[0]) diff --git a/apps/api/src/routes/v1/channels/routes.ts b/apps/api/src/routes/v1/channels/routes.ts index 4ecf91f..640cdd7 100644 --- a/apps/api/src/routes/v1/channels/routes.ts +++ b/apps/api/src/routes/v1/channels/routes.ts @@ -7,14 +7,13 @@ import { notFoundSchema, unauthorizedSchema, } from "@/lib/helpers/openapi/schemas" -import { guildAuthMiddleware } from "@/middleware/guild-auth" +import { workspaceAuthMiddleware } from "@/middleware/workspace-auth" import { channelParamsSchema, channelResponseSchema, createChannelRequestSchema, createChannelResponseSchema, deleteChannelResponseSchema, - guildSlugParamsSchema, listChannelsResponseSchema, listMessagesQuerySchema, listMessagesResponseSchema, @@ -25,17 +24,18 @@ import { togglePinResponseSchema, updateChannelRequestSchema, updateChannelResponseSchema, + workspaceSlugParamsSchema, } from "./schema" export const listChannels = createRoute({ - path: "/guilds/{guildSlug}/channels", + path: "/workspaces/{workspaceSlug}/channels", method: "get", summary: "List channels", - description: "Lists all channels in the specified guild.", + description: "Lists all channels in the specified workspace.", tags: ["Channels"], - middleware: [guildAuthMiddleware] as const, + middleware: [workspaceAuthMiddleware] as const, request: { - params: guildSlugParamsSchema, + params: workspaceSlugParamsSchema, }, responses: { [HttpStatusCodes.OK]: jsonContent({ @@ -49,14 +49,14 @@ export const listChannels = createRoute({ }) export const createChannel = createRoute({ - path: "/guilds/{guildSlug}/channels", + path: "/workspaces/{workspaceSlug}/channels", method: "post", summary: "Create a channel", - description: "Creates a new channel in the specified guild.", + description: "Creates a new channel in the specified workspace.", tags: ["Channels"], - middleware: [guildAuthMiddleware] as const, + middleware: [workspaceAuthMiddleware] as const, request: { - params: guildSlugParamsSchema, + params: workspaceSlugParamsSchema, body: jsonContent({ schema: createChannelRequestSchema, description: "Channel details", @@ -74,15 +74,15 @@ export const createChannel = createRoute({ }) export const reorderChannels = createRoute({ - path: "/guilds/{guildSlug}/channels/reorder", + path: "/workspaces/{workspaceSlug}/channels/reorder", method: "patch", summary: "Reorder channels", description: - "Batch-update channel positions and parent categories within the specified guild.", + "Batch-update channel positions and parent categories within the specified workspace.", tags: ["Channels"], - middleware: [guildAuthMiddleware] as const, + middleware: [workspaceAuthMiddleware] as const, request: { - params: guildSlugParamsSchema, + params: workspaceSlugParamsSchema, body: jsonContent({ schema: reorderChannelsRequestSchema, description: "Channel positions to update", @@ -100,12 +100,12 @@ export const reorderChannels = createRoute({ }) export const getChannel = createRoute({ - path: "/guilds/{guildSlug}/channels/{channelId}", + path: "/workspaces/{workspaceSlug}/channels/{channelId}", method: "get", summary: "Get a channel", - description: "Gets a single channel by ID within the specified guild.", + description: "Gets a single channel by ID within the specified workspace.", tags: ["Channels"], - middleware: [guildAuthMiddleware] as const, + middleware: [workspaceAuthMiddleware] as const, request: { params: channelParamsSchema, }, @@ -122,12 +122,12 @@ export const getChannel = createRoute({ }) export const listChannelMessages = createRoute({ - path: "/guilds/{guildSlug}/channels/{channelId}/messages", + path: "/workspaces/{workspaceSlug}/channels/{channelId}/messages", method: "get", summary: "List channel messages", description: "Returns paginated messages for a channel.", tags: ["Channels"], - middleware: [guildAuthMiddleware] as const, + middleware: [workspaceAuthMiddleware] as const, request: { params: channelParamsSchema, query: listMessagesQuerySchema, @@ -145,13 +145,13 @@ export const listChannelMessages = createRoute({ }) export const updateChannel = createRoute({ - path: "/guilds/{guildSlug}/channels/{channelId}", + path: "/workspaces/{workspaceSlug}/channels/{channelId}", method: "patch", summary: "Update a channel", description: "Updates a channel's name, topic, or other properties. Requires channel:update permission.", tags: ["Channels"], - middleware: [guildAuthMiddleware] as const, + middleware: [workspaceAuthMiddleware] as const, request: { params: channelParamsSchema, body: jsonContent({ @@ -172,13 +172,13 @@ export const updateChannel = createRoute({ }) export const deleteChannel = createRoute({ - path: "/guilds/{guildSlug}/channels/{channelId}", + path: "/workspaces/{workspaceSlug}/channels/{channelId}", method: "delete", summary: "Delete a channel", description: "Permanently deletes a channel and all its messages. Requires channel:delete permission.", tags: ["Channels"], - middleware: [guildAuthMiddleware] as const, + middleware: [workspaceAuthMiddleware] as const, request: { params: channelParamsSchema, }, @@ -195,13 +195,13 @@ export const deleteChannel = createRoute({ }) export const toggleMessagePin = createRoute({ - path: "/guilds/{guildSlug}/channels/{channelId}/messages/{messageId}/pin", + path: "/workspaces/{workspaceSlug}/channels/{channelId}/messages/{messageId}/pin", method: "patch", summary: "Toggle message pin", description: "Pins or unpins a message in the channel. Requires message:pin permission.", tags: ["Channels"], - middleware: [guildAuthMiddleware] as const, + middleware: [workspaceAuthMiddleware] as const, request: { params: messageIdParamsSchema, }, @@ -218,12 +218,12 @@ export const toggleMessagePin = createRoute({ }) export const listPinnedMessages = createRoute({ - path: "/guilds/{guildSlug}/channels/{channelId}/pins", + path: "/workspaces/{workspaceSlug}/channels/{channelId}/pins", method: "get", summary: "List pinned messages", description: "Returns all pinned messages in a channel.", tags: ["Channels"], - middleware: [guildAuthMiddleware] as const, + middleware: [workspaceAuthMiddleware] as const, request: { params: channelParamsSchema, }, diff --git a/apps/api/src/routes/v1/channels/schema.ts b/apps/api/src/routes/v1/channels/schema.ts index 6fb331a..71873d8 100644 --- a/apps/api/src/routes/v1/channels/schema.ts +++ b/apps/api/src/routes/v1/channels/schema.ts @@ -12,18 +12,18 @@ import { // ── Path Params ────────────────────────────────────────── -export const guildSlugParamsSchema = z.object({ - guildSlug: z.string().openapi({ +export const workspaceSlugParamsSchema = z.object({ + workspaceSlug: z.string().openapi({ param: { - name: "guildSlug", + name: "workspaceSlug", in: "path", required: true, }, - example: "my-guild", + example: "my-workspace", }), }) -export const channelParamsSchema = guildSlugParamsSchema.extend({ +export const channelParamsSchema = workspaceSlugParamsSchema.extend({ channelId: z .string() .uuid() @@ -44,7 +44,7 @@ export const listChannelsResponseSchema = z.object({ categories: z.array(categoryWithChannelsSchema), }) -// Narrow the create endpoint to guild-creatable types only. +// Narrow the create endpoint to workspace-creatable types only. // DMs / group DMs are created via the /dms route, not generic create-channel. export const createChannelRequestSchema = insertChannelSchema.extend({ type: z.enum(["text", "voice", "category"]).default("text"), diff --git a/apps/api/src/routes/v1/dms/handlers.ts b/apps/api/src/routes/v1/dms/handlers.ts index 6db1716..b266400 100644 --- a/apps/api/src/routes/v1/dms/handlers.ts +++ b/apps/api/src/routes/v1/dms/handlers.ts @@ -2,9 +2,9 @@ import { db } from "@repo/db" import { channel, channelMember, - guildMember, message, user, + workspaceMember, } from "@repo/db/schema" import { and, count, desc, eq, ilike, inArray, ne, sql } from "drizzle-orm" import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" @@ -37,7 +37,7 @@ async function fetchDMMembershipChannel(dmId: string, userId: string) { name: channel.name, topic: channel.topic, type: channel.type, - guildId: channel.guildId, + workspaceId: channel.workspaceId, parentId: channel.parentId, position: channel.position, ownerId: channel.ownerId, @@ -73,16 +73,16 @@ export const createDM: AppRouteHandler = async (c) => { } // Workspace-scope check: every target must share at least one workspace - // (guild) with the requester. DMs are scoped to workspace membership in + // with the requester. DMs are scoped to workspace membership in // Lor — you can't DM someone you don't share a workspace with. - const myGuildRows = await db - .select({ guildId: guildMember.guildId }) - .from(guildMember) - .where(eq(guildMember.userId, currentUser.id)) + const myWorkspaceRows = await db + .select({ workspaceId: workspaceMember.workspaceId }) + .from(workspaceMember) + .where(eq(workspaceMember.userId, currentUser.id)) - const myGuildIds = myGuildRows.map((row) => row.guildId) + const myWorkspaceIds = myWorkspaceRows.map((row) => row.workspaceId) - if (myGuildIds.length === 0) { + if (myWorkspaceIds.length === 0) { return c.json( { success: false, @@ -93,12 +93,12 @@ export const createDM: AppRouteHandler = async (c) => { } const sharedRows = await db - .selectDistinct({ userId: guildMember.userId }) - .from(guildMember) + .selectDistinct({ userId: workspaceMember.userId }) + .from(workspaceMember) .where( and( - inArray(guildMember.guildId, myGuildIds), - inArray(guildMember.userId, targetUserIds) + inArray(workspaceMember.workspaceId, myWorkspaceIds), + inArray(workspaceMember.userId, targetUserIds) ) ) @@ -181,7 +181,7 @@ export const createDM: AppRouteHandler = async (c) => { .insert(channel) .values({ type: isDirect ? "dm" : "group_dm", - guildId: null, + workspaceId: null, ownerId: isDirect ? null : currentUser.id, position: 0, createdAt: now, @@ -279,7 +279,7 @@ export const listDMs: AppRouteHandler = async (c) => { name: channel.name, topic: channel.topic, type: channel.type, - guildId: channel.guildId, + workspaceId: channel.workspaceId, parentId: channel.parentId, position: channel.position, ownerId: channel.ownerId, diff --git a/apps/api/src/routes/v1/guilds/index.ts b/apps/api/src/routes/v1/guilds/index.ts deleted file mode 100644 index adc52de..0000000 --- a/apps/api/src/routes/v1/guilds/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { createRouter } from "@/lib/helpers/app/create-app" -import * as handlers from "@/routes/v1/guilds/handlers" -import * as routes from "@/routes/v1/guilds/routes" - -const guildsRouter = createRouter() - .openapi(routes.listGuildMembers, handlers.listGuildMembers) - .openapi(routes.searchMessages, handlers.searchMessages) - .openapi(routes.updateGuild, handlers.updateGuild) - .openapi(routes.updateGuildMemberRole, handlers.updateGuildMemberRole) - .openapi(routes.kickGuildMember, handlers.kickGuildMember) - -export default guildsRouter diff --git a/apps/api/src/routes/v1/guilds/routes.ts b/apps/api/src/routes/v1/guilds/routes.ts deleted file mode 100644 index caa2320..0000000 --- a/apps/api/src/routes/v1/guilds/routes.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { createRoute } from "@hono/zod-openapi" -import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" -import jsonContent from "@/lib/helpers/openapi/json-content" -import { - forbiddenSchema, - internalServerErrorSchema, - notFoundSchema, - unauthorizedSchema, -} from "@/lib/helpers/openapi/schemas" -import { guildAuthMiddleware } from "@/middleware/guild-auth" -import { - guildMemberParamsSchema, - guildSlugParamsSchema, - listGuildMembersResponseSchema, - moderateGuildMemberResponseSchema, - searchMessagesQuerySchema, - searchMessagesResponseSchema, - updateGuildMemberRoleRequestSchema, - updateGuildMemberRoleResponseSchema, - updateGuildRequestSchema, - updateGuildResponseSchema, -} from "./schema" - -export const listGuildMembers = createRoute({ - path: "/guilds/{guildSlug}/members", - method: "get", - summary: "List guild members with presence", - description: - "Returns all guild members and their current online/offline status.", - tags: ["Guilds"], - middleware: [guildAuthMiddleware] as const, - request: { - params: guildSlugParamsSchema, - }, - responses: { - [HttpStatusCodes.OK]: jsonContent({ - schema: listGuildMembersResponseSchema, - description: "Guild members with presence status", - }), - [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, - [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, - [HttpStatusCodes.NOT_FOUND]: notFoundSchema, - [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, - }, -}) - -export type ListGuildMembersRoute = typeof listGuildMembers - -export const updateGuildMemberRole = createRoute({ - path: "/guilds/{guildSlug}/members/{userId}/role", - method: "patch", - summary: "Update a guild member role", - description: - "Updates a guild member's built-in role. Requires member role update permission and sufficient hierarchy.", - tags: ["Guilds"], - middleware: [guildAuthMiddleware] as const, - request: { - params: guildMemberParamsSchema, - body: jsonContent({ - schema: updateGuildMemberRoleRequestSchema, - description: "Updated built-in guild role", - }), - }, - responses: { - [HttpStatusCodes.OK]: jsonContent({ - schema: updateGuildMemberRoleResponseSchema, - description: "Updated guild member", - }), - [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, - [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, - [HttpStatusCodes.NOT_FOUND]: notFoundSchema, - [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, - }, -}) - -export type UpdateGuildMemberRoleRoute = typeof updateGuildMemberRole - -export const kickGuildMember = createRoute({ - path: "/guilds/{guildSlug}/members/{userId}/kick", - method: "post", - summary: "Kick a guild member", - description: - "Removes a member from the guild. Requires admin or owner role; the owner cannot be kicked, and admins cannot kick other admins.", - tags: ["Guilds"], - middleware: [guildAuthMiddleware] as const, - request: { - params: guildMemberParamsSchema, - }, - responses: { - [HttpStatusCodes.OK]: jsonContent({ - schema: moderateGuildMemberResponseSchema, - description: "Member kicked", - }), - [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, - [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, - [HttpStatusCodes.NOT_FOUND]: notFoundSchema, - [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, - }, -}) - -export type KickGuildMemberRoute = typeof kickGuildMember - -export const searchMessages = createRoute({ - path: "/guilds/{guildSlug}/search", - method: "get", - summary: "Search messages in a guild", - description: - "Searches messages across all channels in a guild. Optionally filter by channel.", - tags: ["Guilds"], - middleware: [guildAuthMiddleware] as const, - request: { - params: guildSlugParamsSchema, - query: searchMessagesQuerySchema, - }, - responses: { - [HttpStatusCodes.OK]: jsonContent({ - schema: searchMessagesResponseSchema, - description: "Search results", - }), - [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, - [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, - [HttpStatusCodes.NOT_FOUND]: notFoundSchema, - [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, - }, -}) - -export type SearchMessagesRoute = typeof searchMessages - -export const updateGuild = createRoute({ - path: "/guilds/{guildSlug}", - method: "patch", - summary: "Update guild settings", - description: "Updates guild name and/or logo. Requires admin or owner role.", - tags: ["Guilds"], - middleware: [guildAuthMiddleware] as const, - request: { - params: guildSlugParamsSchema, - body: jsonContent({ - schema: updateGuildRequestSchema, - description: "Guild fields to update", - }), - }, - responses: { - [HttpStatusCodes.OK]: jsonContent({ - schema: updateGuildResponseSchema, - description: "Updated guild", - }), - [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, - [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, - [HttpStatusCodes.NOT_FOUND]: notFoundSchema, - [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, - }, -}) - -export type UpdateGuildRoute = typeof updateGuild diff --git a/apps/api/src/routes/v1/invites/handlers.ts b/apps/api/src/routes/v1/invites/handlers.ts index d2e20f7..361252e 100644 --- a/apps/api/src/routes/v1/invites/handlers.ts +++ b/apps/api/src/routes/v1/invites/handlers.ts @@ -1,7 +1,7 @@ import { randomBytes } from "node:crypto" import { and, db, eq, schema, sql } from "@repo/db" import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" -import { assertGuildPermission } from "@/lib/permissions" +import { assertWorkspacePermission } from "@/lib/permissions" import type { AppRouteHandler } from "@/lib/types/app-types" import type { AcceptInviteRoute, @@ -40,7 +40,7 @@ function toInviteResponse( invite: { id: string code: string - guildId: string + workspaceId: string inviterId: string channelId: string | null maxUses: number | null @@ -57,7 +57,7 @@ function toInviteResponse( return { id: invite.id, code: invite.code, - guildId: invite.guildId, + workspaceId: invite.workspaceId, inviterId: invite.inviterId, channelId: invite.channelId, maxUses: invite.maxUses, @@ -72,10 +72,10 @@ function toInviteResponse( } } -// ── Guild-scoped Handlers ──────────────────────────────── +// ── Workspace-scoped Handlers ──────────────────────────────── export const createInvite: AppRouteHandler = async (c) => { - const guild = c.var.guild + const workspace = c.var.workspace const user = c.var.user const { channelId, maxUses, expiresInMinutes } = c.req.valid("json") @@ -88,9 +88,9 @@ export const createInvite: AppRouteHandler = async (c) => { const code = generateInviteCode() try { const rows = await db - .insert(schema.guildInvite) + .insert(schema.workspaceInvite) .values({ - guildId: guild.id, + workspaceId: workspace.id, code, inviterId: user.id, channelId: channelId ?? null, @@ -132,35 +132,38 @@ export const createInvite: AppRouteHandler = async (c) => { } export const listInvites: AppRouteHandler = async (c) => { - const guild = c.var.guild + const workspace = c.var.workspace const actor = c.var.member - assertGuildPermission(actor, guild, { - guildMember: ["kick"], // admins+ can view invites (same permission tier as kick) + assertWorkspacePermission(actor, workspace, { + workspaceMember: ["kick"], // admins+ can view invites (same permission tier as kick) }) const rows = await db .select({ - id: schema.guildInvite.id, - code: schema.guildInvite.code, - guildId: schema.guildInvite.guildId, - inviterId: schema.guildInvite.inviterId, - channelId: schema.guildInvite.channelId, - maxUses: schema.guildInvite.maxUses, - uses: schema.guildInvite.uses, - expiresAt: schema.guildInvite.expiresAt, - createdAt: schema.guildInvite.createdAt, + id: schema.workspaceInvite.id, + code: schema.workspaceInvite.code, + workspaceId: schema.workspaceInvite.workspaceId, + inviterId: schema.workspaceInvite.inviterId, + channelId: schema.workspaceInvite.channelId, + maxUses: schema.workspaceInvite.maxUses, + uses: schema.workspaceInvite.uses, + expiresAt: schema.workspaceInvite.expiresAt, + createdAt: schema.workspaceInvite.createdAt, inviterName: schema.user.name, inviterUsername: schema.user.username, inviterImage: schema.user.image, }) - .from(schema.guildInvite) - .innerJoin(schema.user, eq(schema.guildInvite.inviterId, schema.user.id)) + .from(schema.workspaceInvite) + .innerJoin( + schema.user, + eq(schema.workspaceInvite.inviterId, schema.user.id) + ) .where( and( - eq(schema.guildInvite.guildId, guild.id), - sql`(${schema.guildInvite.expiresAt} IS NULL OR ${schema.guildInvite.expiresAt} > NOW())`, - sql`(${schema.guildInvite.maxUses} IS NULL OR ${schema.guildInvite.uses} < ${schema.guildInvite.maxUses})` + eq(schema.workspaceInvite.workspaceId, workspace.id), + sql`(${schema.workspaceInvite.expiresAt} IS NULL OR ${schema.workspaceInvite.expiresAt} > NOW())`, + sql`(${schema.workspaceInvite.maxUses} IS NULL OR ${schema.workspaceInvite.uses} < ${schema.workspaceInvite.maxUses})` ) ) @@ -176,17 +179,17 @@ export const listInvites: AppRouteHandler = async (c) => { } export const deleteInvite: AppRouteHandler = async (c) => { - const guild = c.var.guild + const workspace = c.var.workspace const actor = c.var.member const { code } = c.req.valid("param") const invite = await db .select() - .from(schema.guildInvite) + .from(schema.workspaceInvite) .where( and( - eq(schema.guildInvite.guildId, guild.id), - eq(schema.guildInvite.code, code) + eq(schema.workspaceInvite.workspaceId, workspace.id), + eq(schema.workspaceInvite.code, code) ) ) .limit(1) @@ -201,14 +204,14 @@ export const deleteInvite: AppRouteHandler = async (c) => { // Non-admin members can only delete their own invites if (invite.inviterId !== actor.userId) { - assertGuildPermission(actor, guild, { - guildMember: ["kick"], + assertWorkspacePermission(actor, workspace, { + workspaceMember: ["kick"], }) } await db - .delete(schema.guildInvite) - .where(eq(schema.guildInvite.id, invite.id)) + .delete(schema.workspaceInvite) + .where(eq(schema.workspaceInvite.id, invite.id)) return c.json({ success: true as const }, HttpStatusCodes.OK) } @@ -221,23 +224,29 @@ export const previewInvite: AppRouteHandler = async (c) => { const invite = await db .select({ - code: schema.guildInvite.code, - guildId: schema.guildInvite.guildId, - channelId: schema.guildInvite.channelId, - maxUses: schema.guildInvite.maxUses, - uses: schema.guildInvite.uses, - expiresAt: schema.guildInvite.expiresAt, - guildName: schema.guild.name, - guildSlug: schema.guild.slug, - guildLogo: schema.guild.logo, + code: schema.workspaceInvite.code, + workspaceId: schema.workspaceInvite.workspaceId, + channelId: schema.workspaceInvite.channelId, + maxUses: schema.workspaceInvite.maxUses, + uses: schema.workspaceInvite.uses, + expiresAt: schema.workspaceInvite.expiresAt, + workspaceName: schema.workspace.name, + workspaceSlug: schema.workspace.slug, + workspaceLogo: schema.workspace.logo, inviterName: schema.user.name, inviterUsername: schema.user.username, inviterImage: schema.user.image, }) - .from(schema.guildInvite) - .innerJoin(schema.guild, eq(schema.guildInvite.guildId, schema.guild.id)) - .innerJoin(schema.user, eq(schema.guildInvite.inviterId, schema.user.id)) - .where(eq(schema.guildInvite.code, code)) + .from(schema.workspaceInvite) + .innerJoin( + schema.workspace, + eq(schema.workspaceInvite.workspaceId, schema.workspace.id) + ) + .innerJoin( + schema.user, + eq(schema.workspaceInvite.inviterId, schema.user.id) + ) + .where(eq(schema.workspaceInvite.code, code)) .limit(1) .then((rows) => rows[0]) @@ -251,18 +260,18 @@ export const previewInvite: AppRouteHandler = async (c) => { // Get member count const memberCountResult = await db .select({ count: sql`count(*)::int` }) - .from(schema.guildMember) - .where(eq(schema.guildMember.guildId, invite.guildId)) + .from(schema.workspaceMember) + .where(eq(schema.workspaceMember.workspaceId, invite.workspaceId)) .then((rows) => rows[0]) // Check if user is already a member const existingMember = await db - .select({ id: schema.guildMember.id }) - .from(schema.guildMember) + .select({ id: schema.workspaceMember.id }) + .from(schema.workspaceMember) .where( and( - eq(schema.guildMember.guildId, invite.guildId), - eq(schema.guildMember.userId, user.id) + eq(schema.workspaceMember.workspaceId, invite.workspaceId), + eq(schema.workspaceMember.userId, user.id) ) ) .limit(1) @@ -288,10 +297,10 @@ export const previewInvite: AppRouteHandler = async (c) => { success: true as const, invite: { code: invite.code, - guild: { - name: invite.guildName, - slug: invite.guildSlug, - logo: invite.guildLogo, + workspace: { + name: invite.workspaceName, + slug: invite.workspaceSlug, + logo: invite.workspaceLogo, memberCount: memberCountResult?.count ?? 0, }, channel: channelInfo, @@ -314,8 +323,8 @@ export const acceptInvite: AppRouteHandler = async (c) => { const invite = await db .select() - .from(schema.guildInvite) - .where(eq(schema.guildInvite.code, code)) + .from(schema.workspaceInvite) + .where(eq(schema.workspaceInvite.code, code)) .limit(1) .then((rows) => rows[0]) @@ -341,71 +350,71 @@ export const acceptInvite: AppRouteHandler = async (c) => { ) } - // Join the guild in a transaction with race-condition protection + // Join the workspace in a transaction with race-condition protection const result = await db.transaction(async (tx) => { // Check if already a member (inside transaction) const existingMember = await tx - .select({ id: schema.guildMember.id }) - .from(schema.guildMember) + .select({ id: schema.workspaceMember.id }) + .from(schema.workspaceMember) .where( and( - eq(schema.guildMember.guildId, invite.guildId), - eq(schema.guildMember.userId, user.id) + eq(schema.workspaceMember.workspaceId, invite.workspaceId), + eq(schema.workspaceMember.userId, user.id) ) ) .limit(1) .then((rows) => rows[0]) if (existingMember) { - const guild = await tx + const workspace = await tx .select({ - id: schema.guild.id, - name: schema.guild.name, - slug: schema.guild.slug, + id: schema.workspace.id, + name: schema.workspace.name, + slug: schema.workspace.slug, }) - .from(schema.guild) - .where(eq(schema.guild.id, invite.guildId)) + .from(schema.workspace) + .where(eq(schema.workspace.id, invite.workspaceId)) .limit(1) .then((rows) => rows[0]) - return { alreadyMember: true as const, guild } + return { alreadyMember: true as const, workspace } } // Atomically increment uses only if under the limit const updated = await tx - .update(schema.guildInvite) - .set({ uses: sql`${schema.guildInvite.uses} + 1` }) + .update(schema.workspaceInvite) + .set({ uses: sql`${schema.workspaceInvite.uses} + 1` }) .where( and( - eq(schema.guildInvite.id, invite.id), - sql`(${schema.guildInvite.maxUses} IS NULL OR ${schema.guildInvite.uses} < ${schema.guildInvite.maxUses})` + eq(schema.workspaceInvite.id, invite.id), + sql`(${schema.workspaceInvite.maxUses} IS NULL OR ${schema.workspaceInvite.uses} < ${schema.workspaceInvite.maxUses})` ) ) - .returning({ id: schema.guildInvite.id }) + .returning({ id: schema.workspaceInvite.id }) if (updated.length === 0) { return { maxedOut: true as const } } // Insert membership - await tx.insert(schema.guildMember).values({ - guildId: invite.guildId, + await tx.insert(schema.workspaceMember).values({ + workspaceId: invite.workspaceId, userId: user.id, role: "member", createdAt: new Date(), }) - const guild = await tx + const workspace = await tx .select({ - id: schema.guild.id, - name: schema.guild.name, - slug: schema.guild.slug, + id: schema.workspace.id, + name: schema.workspace.name, + slug: schema.workspace.slug, }) - .from(schema.guild) - .where(eq(schema.guild.id, invite.guildId)) + .from(schema.workspace) + .where(eq(schema.workspace.id, invite.workspaceId)) .limit(1) .then((rows) => rows[0]) - return { joined: true as const, guild } + return { joined: true as const, workspace } }) if ("maxedOut" in result) { @@ -415,11 +424,11 @@ export const acceptInvite: AppRouteHandler = async (c) => { ) } - const guildRecord = result.guild + const workspaceRecord = result.workspace - if (!guildRecord) { + if (!workspaceRecord) { return c.json( - { success: false, message: "Guild not found" }, + { success: false, message: "Workspace not found" }, HttpStatusCodes.INTERNAL_SERVER_ERROR ) } @@ -427,10 +436,10 @@ export const acceptInvite: AppRouteHandler = async (c) => { return c.json( { success: true as const, - guild: { - id: guildRecord.id, - name: guildRecord.name, - slug: guildRecord.slug, + workspace: { + id: workspaceRecord.id, + name: workspaceRecord.name, + slug: workspaceRecord.slug, }, }, HttpStatusCodes.OK diff --git a/apps/api/src/routes/v1/invites/index.ts b/apps/api/src/routes/v1/invites/index.ts index e2deb58..520b1ff 100644 --- a/apps/api/src/routes/v1/invites/index.ts +++ b/apps/api/src/routes/v1/invites/index.ts @@ -3,7 +3,7 @@ import * as handlers from "@/routes/v1/invites/handlers" import * as routes from "@/routes/v1/invites/routes" const invitesRouter = createRouter() - // Guild-scoped routes + // Workspace-scoped routes .openapi(routes.createInvite, handlers.createInvite) .openapi(routes.listInvites, handlers.listInvites) .openapi(routes.deleteInvite, handlers.deleteInvite) diff --git a/apps/api/src/routes/v1/invites/routes.ts b/apps/api/src/routes/v1/invites/routes.ts index 77ee508..1d7a5a0 100644 --- a/apps/api/src/routes/v1/invites/routes.ts +++ b/apps/api/src/routes/v1/invites/routes.ts @@ -7,32 +7,32 @@ import { notFoundSchema, unauthorizedSchema, } from "@/lib/helpers/openapi/schemas" -import { guildAuthMiddleware } from "@/middleware/guild-auth" import { sessionAuthMiddleware } from "@/middleware/session-auth" +import { workspaceAuthMiddleware } from "@/middleware/workspace-auth" import { acceptInviteResponseSchema, createInviteRequestSchema, createInviteResponseSchema, deleteInviteResponseSchema, - guildInviteCodeParamsSchema, - guildSlugParamsSchema, inviteCodeParamsSchema, invitePreviewResponseSchema, listInvitesResponseSchema, + workspaceInviteCodeParamsSchema, + workspaceSlugParamsSchema, } from "./schema" -// ── Guild-scoped routes (require guild membership) ────── +// ── Workspace-scoped routes (require workspace membership) ────── export const createInvite = createRoute({ - path: "/guilds/{guildSlug}/invites", + path: "/workspaces/{workspaceSlug}/invites", method: "post", - summary: "Create a guild invite link", + summary: "Create a workspace invite link", description: - "Generates a shareable invite code for the guild. Any guild member can create invite links.", + "Generates a shareable invite code for the workspace. Any workspace member can create invite links.", tags: ["Invites"], - middleware: [guildAuthMiddleware] as const, + middleware: [workspaceAuthMiddleware] as const, request: { - params: guildSlugParamsSchema, + params: workspaceSlugParamsSchema, body: jsonContent({ schema: createInviteRequestSchema, description: "Invite options", @@ -52,20 +52,20 @@ export const createInvite = createRoute({ export type CreateInviteRoute = typeof createInvite export const listInvites = createRoute({ - path: "/guilds/{guildSlug}/invites", + path: "/workspaces/{workspaceSlug}/invites", method: "get", - summary: "List guild invite links", + summary: "List workspace invite links", description: - "Returns all active invite links for the guild. Requires admin or higher role.", + "Returns all active invite links for the workspace. Requires admin or higher role.", tags: ["Invites"], - middleware: [guildAuthMiddleware] as const, + middleware: [workspaceAuthMiddleware] as const, request: { - params: guildSlugParamsSchema, + params: workspaceSlugParamsSchema, }, responses: { [HttpStatusCodes.OK]: jsonContent({ schema: listInvitesResponseSchema, - description: "Active guild invites", + description: "Active workspace invites", }), [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, @@ -76,15 +76,15 @@ export const listInvites = createRoute({ export type ListInvitesRoute = typeof listInvites export const deleteInvite = createRoute({ - path: "/guilds/{guildSlug}/invites/{code}", + path: "/workspaces/{workspaceSlug}/invites/{code}", method: "delete", - summary: "Revoke a guild invite link", + summary: "Revoke a workspace invite link", description: "Deletes an invite link. Admins+ can delete any invite; members can only delete their own.", tags: ["Invites"], - middleware: [guildAuthMiddleware] as const, + middleware: [workspaceAuthMiddleware] as const, request: { - params: guildInviteCodeParamsSchema, + params: workspaceInviteCodeParamsSchema, }, responses: { [HttpStatusCodes.OK]: jsonContent({ @@ -107,7 +107,7 @@ export const previewInvite = createRoute({ method: "get", summary: "Preview an invite link", description: - "Returns guild info for an invite code. Used to show a preview before joining.", + "Returns workspace info for an invite code. Used to show a preview before joining.", tags: ["Invites"], middleware: [sessionAuthMiddleware] as const, request: { @@ -131,7 +131,7 @@ export const acceptInvite = createRoute({ method: "post", summary: "Accept an invite link", description: - "Joins the guild associated with the invite code. Checks for bans, expiry, and max uses.", + "Joins the workspace associated with the invite code. Checks for bans, expiry, and max uses.", tags: ["Invites"], middleware: [sessionAuthMiddleware] as const, request: { @@ -140,7 +140,7 @@ export const acceptInvite = createRoute({ responses: { [HttpStatusCodes.OK]: jsonContent({ schema: acceptInviteResponseSchema, - description: "Successfully joined guild", + description: "Successfully joined workspace", }), [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, diff --git a/apps/api/src/routes/v1/invites/schema.ts b/apps/api/src/routes/v1/invites/schema.ts index 2c7add9..614f8de 100644 --- a/apps/api/src/routes/v1/invites/schema.ts +++ b/apps/api/src/routes/v1/invites/schema.ts @@ -1,7 +1,7 @@ import { z } from "@hono/zod-openapi" -import { guildSlugParamsSchema } from "@/routes/v1/channels/schema" +import { workspaceSlugParamsSchema } from "@/routes/v1/channels/schema" -export { guildSlugParamsSchema } +export { workspaceSlugParamsSchema } // ── Path Params ────────────────────────────────────────── @@ -20,20 +20,22 @@ export const inviteCodeParamsSchema = z.object({ }), }) -export const guildInviteCodeParamsSchema = guildSlugParamsSchema.extend({ - code: z - .string() - .min(1) - .max(12) - .openapi({ - param: { - name: "code", - in: "path", - required: true, - }, - example: "aBc4xZ7q", - }), -}) +export const workspaceInviteCodeParamsSchema = workspaceSlugParamsSchema.extend( + { + code: z + .string() + .min(1) + .max(12) + .openapi({ + param: { + name: "code", + in: "path", + required: true, + }, + example: "aBc4xZ7q", + }), + } +) // ── Request Schemas ────────────────────────────────────── @@ -51,10 +53,10 @@ export const createInviteRequestSchema = z.object({ // ── Response Schemas ────────────────────────────────────── -export const guildInviteSchema = z.object({ +export const workspaceInviteSchema = z.object({ id: z.string().uuid(), code: z.string(), - guildId: z.string().uuid(), + workspaceId: z.string().uuid(), inviterId: z.string().uuid(), channelId: z.string().uuid().nullable(), maxUses: z.number().nullable(), @@ -70,12 +72,12 @@ export const guildInviteSchema = z.object({ export const createInviteResponseSchema = z.object({ success: z.literal(true), - invite: guildInviteSchema, + invite: workspaceInviteSchema, }) export const listInvitesResponseSchema = z.object({ success: z.literal(true), - invites: z.array(guildInviteSchema), + invites: z.array(workspaceInviteSchema), }) export const deleteInviteResponseSchema = z.object({ @@ -84,7 +86,7 @@ export const deleteInviteResponseSchema = z.object({ export const invitePreviewSchema = z.object({ code: z.string(), - guild: z.object({ + workspace: z.object({ name: z.string(), slug: z.string(), logo: z.string().nullable(), @@ -112,7 +114,7 @@ export const invitePreviewResponseSchema = z.object({ export const acceptInviteResponseSchema = z.object({ success: z.literal(true), - guild: z.object({ + workspace: z.object({ id: z.string().uuid(), name: z.string(), slug: z.string(), diff --git a/apps/api/src/routes/v1/uploads/handlers.ts b/apps/api/src/routes/v1/uploads/handlers.ts index 1e49b88..7ae05eb 100644 --- a/apps/api/src/routes/v1/uploads/handlers.ts +++ b/apps/api/src/routes/v1/uploads/handlers.ts @@ -1,21 +1,26 @@ import { PutObjectCommand } from "@aws-sdk/client-s3" import { getSignedUrl } from "@aws-sdk/s3-request-presigner" import { db } from "@repo/db" -import { channel, channelMember, guild, guildMember } from "@repo/db/schema" +import { + channel, + channelMember, + workspace, + workspaceMember, +} from "@repo/db/schema" import { env } from "@repo/env/server" import { and, eq } from "drizzle-orm" import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" -import { assertGuildPermission } from "@/lib/permissions" +import { assertWorkspacePermission } from "@/lib/permissions" import { s3Client } from "@/lib/s3" import type { AppRouteHandler } from "@/lib/types/app-types" import type { AvatarPresignRoute, - GuildIconPresignRoute, PresignRoute, + WorkspaceIconPresignRoute, } from "./routes" import { MAX_AVATAR_SIZE, - MAX_GUILD_ICON_SIZE, + MAX_WORKSPACE_ICON_SIZE, PRESIGNED_URL_EXPIRY_SECONDS, } from "./schema" @@ -34,7 +39,11 @@ export const presign: AppRouteHandler = async (c) => { // Fetch the channel to determine access check strategy const ch = await db - .select({ id: channel.id, guildId: channel.guildId, type: channel.type }) + .select({ + id: channel.id, + workspaceId: channel.workspaceId, + type: channel.type, + }) .from(channel) .where(eq(channel.id, channelId)) .limit(1) @@ -47,19 +56,19 @@ export const presign: AppRouteHandler = async (c) => { ) } - // Guild channel — verify guild membership - if (ch.guildId) { + // Workspace channel — verify workspace membership + if (ch.workspaceId) { const member = await db .select({ - id: guildMember.id, - role: guildMember.role, - userId: guildMember.userId, + id: workspaceMember.id, + role: workspaceMember.role, + userId: workspaceMember.userId, }) - .from(guildMember) + .from(workspaceMember) .where( and( - eq(guildMember.guildId, ch.guildId), - eq(guildMember.userId, user.id) + eq(workspaceMember.workspaceId, ch.workspaceId), + eq(workspaceMember.userId, user.id) ) ) .limit(1) @@ -94,7 +103,7 @@ export const presign: AppRouteHandler = async (c) => { ) } } else { - // Unknown channel type with no guild — reject + // Unknown channel type with no workspace — reject return c.json( { success: false, message: "Forbidden" }, HttpStatusCodes.FORBIDDEN @@ -151,28 +160,28 @@ export const avatarPresign: AppRouteHandler = async (c) => { return c.json({ uploadUrl, fileUrl }, HttpStatusCodes.OK) } -export const guildIconPresign: AppRouteHandler = async ( - c -) => { +export const workspaceIconPresign: AppRouteHandler< + WorkspaceIconPresignRoute +> = async (c) => { const user = c.var.user - const { guildId, filename, contentType, size } = c.req.valid("json") + const { workspaceId, filename, contentType, size } = c.req.valid("json") - if (size > MAX_GUILD_ICON_SIZE) { + if (size > MAX_WORKSPACE_ICON_SIZE) { return c.json( { success: false, message: "File too large" }, HttpStatusCodes.REQUEST_TOO_LONG ) } - // Verify guild exists and user has update permission - const guildRecord = await db - .select({ ownerId: guild.ownerId }) - .from(guild) - .where(eq(guild.id, guildId)) + // Verify workspace exists and user has update permission + const workspaceRecord = await db + .select({ ownerId: workspace.ownerId }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) .limit(1) .then((rows) => rows[0]) - if (!guildRecord) { + if (!workspaceRecord) { return c.json( { success: false, message: "Forbidden" }, HttpStatusCodes.FORBIDDEN @@ -180,10 +189,13 @@ export const guildIconPresign: AppRouteHandler = async ( } const member = await db - .select({ role: guildMember.role, userId: guildMember.userId }) - .from(guildMember) + .select({ role: workspaceMember.role, userId: workspaceMember.userId }) + .from(workspaceMember) .where( - and(eq(guildMember.guildId, guildId), eq(guildMember.userId, user.id)) + and( + eq(workspaceMember.workspaceId, workspaceId), + eq(workspaceMember.userId, user.id) + ) ) .limit(1) .then((rows) => rows[0]) @@ -195,10 +207,12 @@ export const guildIconPresign: AppRouteHandler = async ( ) } - assertGuildPermission(member, guildRecord, { organization: ["update"] }) + assertWorkspacePermission(member, workspaceRecord, { + organization: ["update"], + }) const sanitizedFilename = filename.replace(/[^a-zA-Z0-9._-]/g, "_") - const key = `guild-icons/${guildId}/${crypto.randomUUID()}/${sanitizedFilename}` + const key = `workspace-icons/${workspaceId}/${crypto.randomUUID()}/${sanitizedFilename}` const command = new PutObjectCommand({ Bucket: env.S3_BUCKET_NAME, diff --git a/apps/api/src/routes/v1/uploads/index.ts b/apps/api/src/routes/v1/uploads/index.ts index c3b1237..eb31143 100644 --- a/apps/api/src/routes/v1/uploads/index.ts +++ b/apps/api/src/routes/v1/uploads/index.ts @@ -5,6 +5,6 @@ import * as routes from "./routes" const uploadsRouter = createRouter() .openapi(routes.presign, handlers.presign) .openapi(routes.avatarPresign, handlers.avatarPresign) - .openapi(routes.guildIconPresign, handlers.guildIconPresign) + .openapi(routes.workspaceIconPresign, handlers.workspaceIconPresign) export default uploadsRouter diff --git a/apps/api/src/routes/v1/uploads/routes.ts b/apps/api/src/routes/v1/uploads/routes.ts index 700dbbb..6882c54 100644 --- a/apps/api/src/routes/v1/uploads/routes.ts +++ b/apps/api/src/routes/v1/uploads/routes.ts @@ -11,10 +11,10 @@ import { sessionAuthMiddleware } from "@/middleware/session-auth" import { avatarPresignRequestSchema, avatarPresignResponseSchema, - guildIconPresignRequestSchema, - guildIconPresignResponseSchema, presignRequestSchema, presignResponseSchema, + workspaceIconPresignRequestSchema, + workspaceIconPresignResponseSchema, } from "./schema" export const presign = createRoute({ @@ -72,24 +72,24 @@ export const avatarPresign = createRoute({ export type AvatarPresignRoute = typeof avatarPresign -export const guildIconPresign = createRoute({ - path: "/uploads/guild-icon/presign", +export const workspaceIconPresign = createRoute({ + path: "/uploads/workspace-icon/presign", method: "post", - summary: "Request a presigned URL for guild icon upload", + summary: "Request a presigned URL for workspace icon upload", description: - "Returns a presigned URL for uploading a guild icon to S3-compatible storage.", + "Returns a presigned URL for uploading a workspace icon to S3-compatible storage.", tags: ["Uploads"], middleware: [sessionAuthMiddleware] as const, request: { body: jsonContent({ - schema: guildIconPresignRequestSchema, - description: "Guild icon file metadata", + schema: workspaceIconPresignRequestSchema, + description: "Workspace icon file metadata", }), }, responses: { [HttpStatusCodes.OK]: jsonContent({ - schema: guildIconPresignResponseSchema, - description: "Presigned URL for guild icon upload", + schema: workspaceIconPresignResponseSchema, + description: "Presigned URL for workspace icon upload", }), [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, @@ -98,4 +98,4 @@ export const guildIconPresign = createRoute({ }, }) -export type GuildIconPresignRoute = typeof guildIconPresign +export type WorkspaceIconPresignRoute = typeof workspaceIconPresign diff --git a/apps/api/src/routes/v1/uploads/schema.ts b/apps/api/src/routes/v1/uploads/schema.ts index 10bdfcd..3f25bdf 100644 --- a/apps/api/src/routes/v1/uploads/schema.ts +++ b/apps/api/src/routes/v1/uploads/schema.ts @@ -48,29 +48,32 @@ export const avatarPresignResponseSchema = z.object({ fileUrl: z.string().url(), }) -// ── Guild Icon ───────────────────────────────────────── +// ── Workspace Icon ───────────────────────────────────────── -const GUILD_ICON_MIME_TYPES = [ +const WORKSPACE_ICON_MIME_TYPES = [ "image/jpeg", "image/png", "image/webp", "image/svg+xml", ] as const -export const MAX_GUILD_ICON_SIZE = 2 * 1024 * 1024 // 2 MB +export const MAX_WORKSPACE_ICON_SIZE = 2 * 1024 * 1024 // 2 MB -export const guildIconPresignRequestSchema = z.object({ - guildId: z.string().uuid(), +export const workspaceIconPresignRequestSchema = z.object({ + workspaceId: z.string().uuid(), filename: z.string().min(1).max(256), contentType: z .string() - .refine((ct) => (GUILD_ICON_MIME_TYPES as readonly string[]).includes(ct), { - message: "Unsupported image type", - }), - size: z.number().int().min(1).max(MAX_GUILD_ICON_SIZE), + .refine( + (ct) => (WORKSPACE_ICON_MIME_TYPES as readonly string[]).includes(ct), + { + message: "Unsupported image type", + } + ), + size: z.number().int().min(1).max(MAX_WORKSPACE_ICON_SIZE), }) -export const guildIconPresignResponseSchema = z.object({ +export const workspaceIconPresignResponseSchema = z.object({ uploadUrl: z.string().url(), fileUrl: z.string().url(), }) diff --git a/apps/api/src/routes/v1/guilds/handlers.ts b/apps/api/src/routes/v1/workspaces/handlers.ts similarity index 64% rename from apps/api/src/routes/v1/guilds/handlers.ts rename to apps/api/src/routes/v1/workspaces/handlers.ts index 91bfafe..fa08ca2 100644 --- a/apps/api/src/routes/v1/guilds/handlers.ts +++ b/apps/api/src/routes/v1/workspaces/handlers.ts @@ -1,6 +1,6 @@ import { - getGuildAuthorityPosition, - getGuildRolePosition, + getWorkspaceAuthorityPosition, + getWorkspaceRolePosition, } from "@repo/auth/permissions" import { and, count, db, desc, eq, ilike, inArray, schema } from "@repo/db" import { env } from "@repo/env/server" @@ -10,18 +10,18 @@ import { HTTPException } from "hono/http-exception" import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" import { logger } from "@/lib/logger" import { - assertCanManageGuildMember, - assertGuildPermission, + assertCanManageWorkspaceMember, + assertWorkspacePermission, } from "@/lib/permissions" import { getRedisClient } from "@/lib/redis" import type { AppRouteHandler } from "@/lib/types/app-types" import type { - KickGuildMemberRoute, - ListGuildMembersRoute, + KickWorkspaceMemberRoute, + ListWorkspaceMembersRoute, SearchMessagesRoute, - UpdateGuildMemberRoleRoute, - UpdateGuildRoute, -} from "@/routes/v1/guilds/routes" + UpdateWorkspaceMemberRoleRoute, + UpdateWorkspaceRoute, +} from "@/routes/v1/workspaces/routes" const PRESENCE_MEMBERSHIP_CHUNK_SIZE = 250 @@ -54,7 +54,7 @@ async function listOnlineUserIds(userIds: string[]) { } } -function toGuildMemberPresence( +function toWorkspaceMemberPresence( member: { userId: string name: string @@ -80,45 +80,45 @@ function toGuildMemberPresence( } } -async function getGuildMemberRow(guildId: string, userId: string) { +async function getWorkspaceMemberRow(workspaceId: string, userId: string) { return db .select({ - userId: schema.guildMember.userId, - role: schema.guildMember.role, + userId: schema.workspaceMember.userId, + role: schema.workspaceMember.role, name: schema.user.name, username: schema.user.username, displayUsername: schema.user.displayUsername, image: schema.user.image, }) - .from(schema.guildMember) - .innerJoin(schema.user, eq(schema.guildMember.userId, schema.user.id)) + .from(schema.workspaceMember) + .innerJoin(schema.user, eq(schema.workspaceMember.userId, schema.user.id)) .where( and( - eq(schema.guildMember.guildId, guildId), - eq(schema.guildMember.userId, userId) + eq(schema.workspaceMember.workspaceId, workspaceId), + eq(schema.workspaceMember.userId, userId) ) ) .limit(1) .then((rows) => rows[0] ?? null) } -export const listGuildMembers: AppRouteHandler = async ( - c -) => { - const guild = c.var.guild +export const listWorkspaceMembers: AppRouteHandler< + ListWorkspaceMembersRoute +> = async (c) => { + const workspace = c.var.workspace const memberRows = await db .select({ - userId: schema.guildMember.userId, - role: schema.guildMember.role, + userId: schema.workspaceMember.userId, + role: schema.workspaceMember.role, name: schema.user.name, username: schema.user.username, displayUsername: schema.user.displayUsername, image: schema.user.image, }) - .from(schema.guildMember) - .innerJoin(schema.user, eq(schema.guildMember.userId, schema.user.id)) - .where(eq(schema.guildMember.guildId, guild.id)) + .from(schema.workspaceMember) + .innerJoin(schema.user, eq(schema.workspaceMember.userId, schema.user.id)) + .where(eq(schema.workspaceMember.workspaceId, workspace.id)) .orderBy(asc(schema.user.name)) const userIds = memberRows.map((row) => row.userId) @@ -126,44 +126,45 @@ export const listGuildMembers: AppRouteHandler = async ( return c.json( { - guildId: guild.id, - guildSlug: guild.slug, - guildName: guild.name, - ownerId: guild.ownerId, + workspaceId: workspace.id, + workspaceSlug: workspace.slug, + workspaceName: workspace.name, + ownerId: workspace.ownerId, members: memberRows.map((member) => - toGuildMemberPresence(member, guild.ownerId, onlineUserIds) + toWorkspaceMemberPresence(member, workspace.ownerId, onlineUserIds) ), }, HttpStatusCodes.OK ) } -export const updateGuildMemberRole: AppRouteHandler< - UpdateGuildMemberRoleRoute +export const updateWorkspaceMemberRole: AppRouteHandler< + UpdateWorkspaceMemberRoleRoute > = async (c) => { - const guild = c.var.guild + const workspace = c.var.workspace const actor = c.var.member const { userId } = c.req.valid("param") const { role } = c.req.valid("json") - const actorAuthority = assertGuildPermission(actor, guild, { - guildMember: ["role:update"], + const actorAuthority = assertWorkspacePermission(actor, workspace, { + workspaceMember: ["role:update"], }) - const target = await getGuildMemberRow(guild.id, userId) + const target = await getWorkspaceMemberRow(workspace.id, userId) if (!target) { return c.json( - { success: false, message: "Guild member not found" }, + { success: false, message: "Workspace member not found" }, HttpStatusCodes.NOT_FOUND ) } - assertCanManageGuildMember(actor, target, guild) + assertCanManageWorkspaceMember(actor, target, workspace) if ( !actorAuthority.isOwner && - getGuildRolePosition(role) <= getGuildAuthorityPosition(actorAuthority) + getWorkspaceRolePosition(role) <= + getWorkspaceAuthorityPosition(actorAuthority) ) { return c.json( { success: false, message: "You cannot assign that role" }, @@ -172,20 +173,20 @@ export const updateGuildMemberRole: AppRouteHandler< } await db - .update(schema.guildMember) + .update(schema.workspaceMember) .set({ role }) .where( and( - eq(schema.guildMember.guildId, guild.id), - eq(schema.guildMember.userId, userId) + eq(schema.workspaceMember.workspaceId, workspace.id), + eq(schema.workspaceMember.userId, userId) ) ) - const updatedMember = await getGuildMemberRow(guild.id, userId) + const updatedMember = await getWorkspaceMemberRow(workspace.id, userId) if (!updatedMember) { return c.json( - { success: false, message: "Guild member not found" }, + { success: false, message: "Workspace member not found" }, HttpStatusCodes.NOT_FOUND ) } @@ -195,9 +196,9 @@ export const updateGuildMemberRole: AppRouteHandler< return c.json( { success: true as const, - member: toGuildMemberPresence( + member: toWorkspaceMemberPresence( updatedMember, - guild.ownerId, + workspace.ownerId, onlineUserIds ), }, @@ -205,54 +206,56 @@ export const updateGuildMemberRole: AppRouteHandler< ) } -export const kickGuildMember: AppRouteHandler = async ( - c -) => { - const guild = c.var.guild +export const kickWorkspaceMember: AppRouteHandler< + KickWorkspaceMemberRoute +> = async (c) => { + const workspace = c.var.workspace const actor = c.var.member const { userId } = c.req.valid("param") - assertGuildPermission(actor, guild, { - guildMember: ["kick"], + assertWorkspacePermission(actor, workspace, { + workspaceMember: ["kick"], }) - const target = await getGuildMemberRow(guild.id, userId) + const target = await getWorkspaceMemberRow(workspace.id, userId) if (!target) { return c.json( - { success: false, message: "Guild member not found" }, + { success: false, message: "Workspace member not found" }, HttpStatusCodes.NOT_FOUND ) } - assertCanManageGuildMember(actor, target, guild) + assertCanManageWorkspaceMember(actor, target, workspace) await db - .delete(schema.guildMember) + .delete(schema.workspaceMember) .where( and( - eq(schema.guildMember.guildId, guild.id), - eq(schema.guildMember.userId, userId) + eq(schema.workspaceMember.workspaceId, workspace.id), + eq(schema.workspaceMember.userId, userId) ) ) return c.json({ success: true as const }, HttpStatusCodes.OK) } -// ── Guild Settings ───────────────────────────────────── +// ── Workspace Settings ───────────────────────────────────── -export const updateGuild: AppRouteHandler = async (c) => { - const guild = c.var.guild +export const updateWorkspace: AppRouteHandler = async ( + c +) => { + const workspace = c.var.workspace const actor = c.var.member - assertGuildPermission(actor, guild, { + assertWorkspacePermission(actor, workspace, { organization: ["update"], }) const body = c.req.valid("json") - const guildIconPrefix = `${env.S3_PUBLIC_URL.replace(/\/$/, "")}/guild-icons/${guild.id}/` - if (body.logo && !body.logo.startsWith(guildIconPrefix)) { + const workspaceIconPrefix = `${env.S3_PUBLIC_URL.replace(/\/$/, "")}/workspace-icons/${workspace.id}/` + if (body.logo && !body.logo.startsWith(workspaceIconPrefix)) { throw new HTTPException(HttpStatusCodes.BAD_REQUEST, { message: "Invalid logo URL", }) @@ -266,11 +269,11 @@ export const updateGuild: AppRouteHandler = async (c) => { return c.json( { success: true as const, - guild: { - id: guild.id, - name: guild.name, - slug: guild.slug, - logo: guild.logo, + workspace: { + id: workspace.id, + name: workspace.name, + slug: workspace.slug, + logo: workspace.logo, }, }, HttpStatusCodes.OK @@ -278,19 +281,19 @@ export const updateGuild: AppRouteHandler = async (c) => { } const [updated] = await db - .update(schema.guild) + .update(schema.workspace) .set(updates) - .where(eq(schema.guild.id, guild.id)) + .where(eq(schema.workspace.id, workspace.id)) .returning({ - id: schema.guild.id, - name: schema.guild.name, - slug: schema.guild.slug, - logo: schema.guild.logo, + id: schema.workspace.id, + name: schema.workspace.name, + slug: schema.workspace.slug, + logo: schema.workspace.logo, }) if (!updated) { return c.json( - { success: false, message: "Guild not found" }, + { success: false, message: "Workspace not found" }, HttpStatusCodes.NOT_FOUND ) } @@ -298,7 +301,7 @@ export const updateGuild: AppRouteHandler = async (c) => { return c.json( { success: true as const, - guild: { + workspace: { id: updated.id, name: updated.name, slug: updated.slug, @@ -314,18 +317,21 @@ export const updateGuild: AppRouteHandler = async (c) => { export const searchMessages: AppRouteHandler = async ( c ) => { - const guild = c.var.guild + const workspace = c.var.workspace const { query, channelId, page, perPage } = c.req.valid("query") const offset = (page - 1) * perPage - const guildChannels = await db + const workspaceChannels = await db .select({ id: schema.channel.id, name: schema.channel.name, }) .from(schema.channel) .where( - and(eq(schema.channel.guildId, guild.id), eq(schema.channel.type, "text")) + and( + eq(schema.channel.workspaceId, workspace.id), + eq(schema.channel.type, "text") + ) ) const emptyResult = { @@ -336,14 +342,14 @@ export const searchMessages: AppRouteHandler = async ( data: [], } - if (guildChannels.length === 0) { + if (workspaceChannels.length === 0) { return c.json(emptyResult, HttpStatusCodes.OK) } - const channelMap = new Map(guildChannels.map((ch) => [ch.id, ch.name])) + const channelMap = new Map(workspaceChannels.map((ch) => [ch.id, ch.name])) const searchChannelIds = channelId - ? guildChannels.filter((ch) => ch.id === channelId).map((ch) => ch.id) - : guildChannels.map((ch) => ch.id) + ? workspaceChannels.filter((ch) => ch.id === channelId).map((ch) => ch.id) + : workspaceChannels.map((ch) => ch.id) if (searchChannelIds.length === 0) { return c.json(emptyResult, HttpStatusCodes.OK) diff --git a/apps/api/src/routes/v1/workspaces/index.ts b/apps/api/src/routes/v1/workspaces/index.ts new file mode 100644 index 0000000..f9b5e5e --- /dev/null +++ b/apps/api/src/routes/v1/workspaces/index.ts @@ -0,0 +1,12 @@ +import { createRouter } from "@/lib/helpers/app/create-app" +import * as handlers from "@/routes/v1/workspaces/handlers" +import * as routes from "@/routes/v1/workspaces/routes" + +const workspacesRouter = createRouter() + .openapi(routes.listWorkspaceMembers, handlers.listWorkspaceMembers) + .openapi(routes.searchMessages, handlers.searchMessages) + .openapi(routes.updateWorkspace, handlers.updateWorkspace) + .openapi(routes.updateWorkspaceMemberRole, handlers.updateWorkspaceMemberRole) + .openapi(routes.kickWorkspaceMember, handlers.kickWorkspaceMember) + +export default workspacesRouter diff --git a/apps/api/src/routes/v1/workspaces/routes.ts b/apps/api/src/routes/v1/workspaces/routes.ts new file mode 100644 index 0000000..83368a3 --- /dev/null +++ b/apps/api/src/routes/v1/workspaces/routes.ts @@ -0,0 +1,156 @@ +import { createRoute } from "@hono/zod-openapi" +import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" +import jsonContent from "@/lib/helpers/openapi/json-content" +import { + forbiddenSchema, + internalServerErrorSchema, + notFoundSchema, + unauthorizedSchema, +} from "@/lib/helpers/openapi/schemas" +import { workspaceAuthMiddleware } from "@/middleware/workspace-auth" +import { + listWorkspaceMembersResponseSchema, + moderateWorkspaceMemberResponseSchema, + searchMessagesQuerySchema, + searchMessagesResponseSchema, + updateWorkspaceMemberRoleRequestSchema, + updateWorkspaceMemberRoleResponseSchema, + updateWorkspaceRequestSchema, + updateWorkspaceResponseSchema, + workspaceMemberParamsSchema, + workspaceSlugParamsSchema, +} from "./schema" + +export const listWorkspaceMembers = createRoute({ + path: "/workspaces/{workspaceSlug}/members", + method: "get", + summary: "List workspace members with presence", + description: + "Returns all workspace members and their current online/offline status.", + tags: ["Workspaces"], + middleware: [workspaceAuthMiddleware] as const, + request: { + params: workspaceSlugParamsSchema, + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: listWorkspaceMembersResponseSchema, + description: "Workspace members with presence status", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, + [HttpStatusCodes.NOT_FOUND]: notFoundSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type ListWorkspaceMembersRoute = typeof listWorkspaceMembers + +export const updateWorkspaceMemberRole = createRoute({ + path: "/workspaces/{workspaceSlug}/members/{userId}/role", + method: "patch", + summary: "Update a workspace member role", + description: + "Updates a workspace member's built-in role. Requires member role update permission and sufficient hierarchy.", + tags: ["Workspaces"], + middleware: [workspaceAuthMiddleware] as const, + request: { + params: workspaceMemberParamsSchema, + body: jsonContent({ + schema: updateWorkspaceMemberRoleRequestSchema, + description: "Updated built-in workspace role", + }), + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: updateWorkspaceMemberRoleResponseSchema, + description: "Updated workspace member", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, + [HttpStatusCodes.NOT_FOUND]: notFoundSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type UpdateWorkspaceMemberRoleRoute = typeof updateWorkspaceMemberRole + +export const kickWorkspaceMember = createRoute({ + path: "/workspaces/{workspaceSlug}/members/{userId}/kick", + method: "post", + summary: "Kick a workspace member", + description: + "Removes a member from the workspace. Requires admin or owner role; the owner cannot be kicked, and admins cannot kick other admins.", + tags: ["Workspaces"], + middleware: [workspaceAuthMiddleware] as const, + request: { + params: workspaceMemberParamsSchema, + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: moderateWorkspaceMemberResponseSchema, + description: "Member kicked", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, + [HttpStatusCodes.NOT_FOUND]: notFoundSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type KickWorkspaceMemberRoute = typeof kickWorkspaceMember + +export const searchMessages = createRoute({ + path: "/workspaces/{workspaceSlug}/search", + method: "get", + summary: "Search messages in a workspace", + description: + "Searches messages across all channels in a workspace. Optionally filter by channel.", + tags: ["Workspaces"], + middleware: [workspaceAuthMiddleware] as const, + request: { + params: workspaceSlugParamsSchema, + query: searchMessagesQuerySchema, + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: searchMessagesResponseSchema, + description: "Search results", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, + [HttpStatusCodes.NOT_FOUND]: notFoundSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type SearchMessagesRoute = typeof searchMessages + +export const updateWorkspace = createRoute({ + path: "/workspaces/{workspaceSlug}", + method: "patch", + summary: "Update workspace settings", + description: + "Updates workspace name and/or logo. Requires admin or owner role.", + tags: ["Workspaces"], + middleware: [workspaceAuthMiddleware] as const, + request: { + params: workspaceSlugParamsSchema, + body: jsonContent({ + schema: updateWorkspaceRequestSchema, + description: "Workspace fields to update", + }), + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: updateWorkspaceResponseSchema, + description: "Updated workspace", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, + [HttpStatusCodes.NOT_FOUND]: notFoundSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type UpdateWorkspaceRoute = typeof updateWorkspace diff --git a/apps/api/src/routes/v1/guilds/schema.ts b/apps/api/src/routes/v1/workspaces/schema.ts similarity index 65% rename from apps/api/src/routes/v1/guilds/schema.ts rename to apps/api/src/routes/v1/workspaces/schema.ts index ac64940..fd9fb73 100644 --- a/apps/api/src/routes/v1/guilds/schema.ts +++ b/apps/api/src/routes/v1/workspaces/schema.ts @@ -1,15 +1,15 @@ import { z } from "@hono/zod-openapi" -import { assignableGuildRoles } from "@repo/auth/permissions" +import { assignableWorkspaceRoles } from "@repo/auth/permissions" import { messageAuthorSchema } from "@/lib/helpers/openapi/message-schemas" import { paginatedResponseSchema, paginationQuerySchema, } from "@/lib/helpers/openapi/schemas" -import { guildSlugParamsSchema } from "@/routes/v1/channels/schema" +import { workspaceSlugParamsSchema } from "@/routes/v1/channels/schema" -export { guildSlugParamsSchema } +export { workspaceSlugParamsSchema } -export const guildMemberPresenceSchema = z.object({ +export const workspaceMemberPresenceSchema = z.object({ userId: z.string().uuid(), name: z.string(), username: z.string().nullable(), @@ -20,15 +20,15 @@ export const guildMemberPresenceSchema = z.object({ status: z.enum(["online", "offline"]), }) -export const listGuildMembersResponseSchema = z.object({ - guildId: z.string().uuid(), - guildSlug: z.string(), - guildName: z.string(), +export const listWorkspaceMembersResponseSchema = z.object({ + workspaceId: z.string().uuid(), + workspaceSlug: z.string(), + workspaceName: z.string(), ownerId: z.string().uuid(), - members: z.array(guildMemberPresenceSchema), + members: z.array(workspaceMemberPresenceSchema), }) -export const guildMemberParamsSchema = guildSlugParamsSchema.extend({ +export const workspaceMemberParamsSchema = workspaceSlugParamsSchema.extend({ userId: z .string() .uuid() @@ -42,22 +42,22 @@ export const guildMemberParamsSchema = guildSlugParamsSchema.extend({ }), }) -export const moderateGuildMemberResponseSchema = z.object({ +export const moderateWorkspaceMemberResponseSchema = z.object({ success: z.literal(true), }) -export const updateGuildMemberRoleRequestSchema = z.object({ - role: z.enum(assignableGuildRoles), +export const updateWorkspaceMemberRoleRequestSchema = z.object({ + role: z.enum(assignableWorkspaceRoles), }) -export const updateGuildMemberRoleResponseSchema = z.object({ +export const updateWorkspaceMemberRoleResponseSchema = z.object({ success: z.literal(true), - member: guildMemberPresenceSchema, + member: workspaceMemberPresenceSchema, }) -// ── Guild Settings ───────────────────────────────────── +// ── Workspace Settings ───────────────────────────────────── -export const updateGuildRequestSchema = z +export const updateWorkspaceRequestSchema = z .object({ name: z.string().trim().min(1).max(100).optional(), logo: z.string().url().nullable().optional(), @@ -66,9 +66,9 @@ export const updateGuildRequestSchema = z message: "At least one field (name or logo) must be provided", }) -export const updateGuildResponseSchema = z.object({ +export const updateWorkspaceResponseSchema = z.object({ success: z.literal(true), - guild: z.object({ + workspace: z.object({ id: z.string().uuid(), name: z.string(), slug: z.string(), diff --git a/apps/realtime/src/index.ts b/apps/realtime/src/index.ts index 1fca827..fbce8e6 100644 --- a/apps/realtime/src/index.ts +++ b/apps/realtime/src/index.ts @@ -12,14 +12,14 @@ import { channelRoomPayloadSchema, deleteMessagePayloadSchema, editMessagePayloadSchema, - guildMemberJoinedPayloadSchema, - guildRoom, markChannelReadPayloadSchema, presenceSubscribePayloadSchema, sendMessagePayloadSchema, toggleMessageReactionPayloadSchema, typingStartPayloadSchema, userRoom, + workspaceMemberJoinedPayloadSchema, + workspaceRoom, } from "@repo/realtime-types" import type { LinkUnfurlJobData } from "@repo/realtime-types/queues" import { LINK_UNFURL_QUEUE } from "@repo/realtime-types/queues" @@ -46,14 +46,14 @@ import { } from "@/services/presence" import { enforceDmMessageRateLimit, - enforceGuildMessageRateLimit, + enforceWorkspaceMessageRateLimit, } from "@/services/rate-limit" import { getUnreadStatesForUser, markChannelRead } from "@/services/read-states" type SocketData = { user: Session["user"] session: Session["session"] - guildIds?: string[] + workspaceIds?: string[] initialized?: boolean initPromise?: Promise isAlive?: boolean @@ -198,19 +198,21 @@ async function initializeConnection(socket: RealtimeSocket) { const initSocketId = socket.id const userPresenceRoom = userRoom(socket.data.user.id) - const guildMembershipRows = await db + const workspaceMembershipRows = await db .select({ - guildId: schema.guildMember.guildId, + workspaceId: schema.workspaceMember.workspaceId, }) - .from(schema.guildMember) - .where(eq(schema.guildMember.userId, socket.data.user.id)) + .from(schema.workspaceMember) + .where(eq(schema.workspaceMember.userId, socket.data.user.id)) - const guildIds = guildMembershipRows.map((row) => row.guildId) - socket.data.guildIds = guildIds + const workspaceIds = workspaceMembershipRows.map((row) => row.workspaceId) + socket.data.workspaceIds = workspaceIds - const guildPresenceRooms = guildIds.map((guildId) => guildRoom(guildId)) - if (guildPresenceRooms.length > 0) { - await socket.join(guildPresenceRooms) + const workspacePresenceRooms = workspaceIds.map((workspaceId) => + workspaceRoom(workspaceId) + ) + if (workspacePresenceRooms.length > 0) { + await socket.join(workspacePresenceRooms) } const { becameOnline } = await markUserConnected( @@ -236,9 +238,9 @@ async function initializeConnection(socket: RealtimeSocket) { } if (becameOnline && isCurrentSocketAlive) { - for (const guildId of guildIds) { - io.to(guildRoom(guildId)).emit("presence:user:update", { - guildId, + for (const workspaceId of workspaceIds) { + io.to(workspaceRoom(workspaceId)).emit("presence:user:update", { + workspaceId, userId: socket.data.user.id, status: "online", }) @@ -264,7 +266,7 @@ async function initializeConnection(socket: RealtimeSocket) { userId: socket.data.user.id, rooms: { user: userPresenceRoom, - guilds: guildPresenceRooms, + workspaces: workspacePresenceRooms, }, }) @@ -313,21 +315,21 @@ io.on("connection", (socket) => { } const parsed = presenceSubscribePayloadSchema.parse(payload) - const guildIds = socket.data.guildIds ?? [] + const workspaceIds = socket.data.workspaceIds ?? [] - if (!guildIds.includes(parsed.guildId)) { + if (!workspaceIds.includes(parsed.workspaceId)) { ack?.({ ok: false, error: "Forbidden" }) return } - const guildMemberRows = await db + const workspaceMemberRows = await db .select({ - userId: schema.guildMember.userId, + userId: schema.workspaceMember.userId, }) - .from(schema.guildMember) - .where(eq(schema.guildMember.guildId, parsed.guildId)) + .from(schema.workspaceMember) + .where(eq(schema.workspaceMember.workspaceId, parsed.workspaceId)) - const userIds = [...new Set(guildMemberRows.map((row) => row.userId))] + const userIds = [...new Set(workspaceMemberRows.map((row) => row.userId))] const onlineUserIds = await listOnlineUserIds( redisPresenceClient, userIds @@ -336,7 +338,7 @@ io.on("connection", (socket) => { ack?.({ ok: true, snapshot: { - guildId: parsed.guildId, + workspaceId: parsed.workspaceId, onlineUserIds, }, }) @@ -374,9 +376,9 @@ io.on("connection", (socket) => { parsed.channelId ) - if (accessibleChannel.guildId && accessibleChannel.memberRole) { - await enforceGuildMessageRateLimit(redisPresenceClient, { - guildId: accessibleChannel.guildId, + if (accessibleChannel.workspaceId && accessibleChannel.memberRole) { + await enforceWorkspaceMessageRateLimit(redisPresenceClient, { + workspaceId: accessibleChannel.workspaceId, userId: socket.data.user.id, role: accessibleChannel.memberRole, }) @@ -546,18 +548,18 @@ io.on("connection", (socket) => { } }) - socket.on("guild:member:joined", async (payload, ack) => { + socket.on("workspace:member:joined", async (payload, ack) => { try { - const parsed = guildMemberJoinedPayloadSchema.parse(payload) + const parsed = workspaceMemberJoinedPayloadSchema.parse(payload) - // Verify the user is actually a member of this guild + // Verify the user is actually a member of this workspace const membership = await db - .select({ guildId: schema.guildMember.guildId }) - .from(schema.guildMember) + .select({ workspaceId: schema.workspaceMember.workspaceId }) + .from(schema.workspaceMember) .where( and( - eq(schema.guildMember.guildId, parsed.guildId), - eq(schema.guildMember.userId, socket.data.user.id) + eq(schema.workspaceMember.workspaceId, parsed.workspaceId), + eq(schema.workspaceMember.userId, socket.data.user.id) ) ) .limit(1) @@ -568,23 +570,25 @@ io.on("connection", (socket) => { return } - // Join the guild room so the new member receives future events - await socket.join(guildRoom(parsed.guildId)) + // Join the workspace room so the new member receives future events + await socket.join(workspaceRoom(parsed.workspaceId)) - // Deduplicate guildIds - const currentGuildIds = socket.data.guildIds ?? [] - if (!currentGuildIds.includes(parsed.guildId)) { - socket.data.guildIds = [...currentGuildIds, parsed.guildId] + // Deduplicate workspaceIds + const currentWorkspaceIds = socket.data.workspaceIds ?? [] + if (!currentWorkspaceIds.includes(parsed.workspaceId)) { + socket.data.workspaceIds = [...currentWorkspaceIds, parsed.workspaceId] } - // Broadcast to other guild members - socket.to(guildRoom(parsed.guildId)).emit("guild:member:joined", { - guildId: parsed.guildId, - userId: socket.data.user.id, - name: socket.data.user.name, - username: socket.data.user.username ?? null, - image: socket.data.user.image ?? null, - }) + // Broadcast to other workspace members + socket + .to(workspaceRoom(parsed.workspaceId)) + .emit("workspace:member:joined", { + workspaceId: parsed.workspaceId, + userId: socket.data.user.id, + name: socket.data.user.name, + username: socket.data.user.username ?? null, + image: socket.data.user.image ?? null, + }) ack?.({ ok: true }) } catch (error) { @@ -605,9 +609,9 @@ io.on("connection", (socket) => { if (!becameOffline) return - for (const guildId of socket.data.guildIds ?? []) { - io.to(guildRoom(guildId)).emit("presence:user:update", { - guildId, + for (const workspaceId of socket.data.workspaceIds ?? []) { + io.to(workspaceRoom(workspaceId)).emit("presence:user:update", { + workspaceId, userId: socket.data.user.id, status: "offline", }) diff --git a/apps/realtime/src/services/channel-access.ts b/apps/realtime/src/services/channel-access.ts index f81d60a..1c19a5e 100644 --- a/apps/realtime/src/services/channel-access.ts +++ b/apps/realtime/src/services/channel-access.ts @@ -4,7 +4,7 @@ export type AccessibleChannel = { id: string name: string | null type: (typeof schema.channel.$inferSelect)["type"] - guildId: string | null + workspaceId: string | null memberRole: string | null memberIsOwner: boolean } @@ -18,7 +18,7 @@ export async function assertUserCanAccessChannel( id: schema.channel.id, name: schema.channel.name, type: schema.channel.type, - guildId: schema.channel.guildId, + workspaceId: schema.channel.workspaceId, }) .from(schema.channel) .where(eq(schema.channel.id, channelId)) @@ -33,18 +33,21 @@ export async function assertUserCanAccessChannel( throw new Error("Cannot join a category channel") } - if (channelRecord.guildId) { + if (channelRecord.workspaceId) { const memberRecord = await db .select({ - role: schema.guildMember.role, - ownerId: schema.guild.ownerId, + role: schema.workspaceMember.role, + ownerId: schema.workspace.ownerId, }) - .from(schema.guildMember) - .innerJoin(schema.guild, eq(schema.guild.id, schema.guildMember.guildId)) + .from(schema.workspaceMember) + .innerJoin( + schema.workspace, + eq(schema.workspace.id, schema.workspaceMember.workspaceId) + ) .where( and( - eq(schema.guildMember.guildId, channelRecord.guildId), - eq(schema.guildMember.userId, userId) + eq(schema.workspaceMember.workspaceId, channelRecord.workspaceId), + eq(schema.workspaceMember.userId, userId) ) ) .limit(1) diff --git a/apps/realtime/src/services/notifications.ts b/apps/realtime/src/services/notifications.ts index 2966b9a..2439d2e 100644 --- a/apps/realtime/src/services/notifications.ts +++ b/apps/realtime/src/services/notifications.ts @@ -53,13 +53,13 @@ function extractDirectMentionUserIds(content: string) { } async function listRecipientUserIds(channel: AccessibleChannel) { - if (channel.guildId) { + if (channel.workspaceId) { return db .select({ - userId: schema.guildMember.userId, + userId: schema.workspaceMember.userId, }) - .from(schema.guildMember) - .where(eq(schema.guildMember.guildId, channel.guildId)) + .from(schema.workspaceMember) + .where(eq(schema.workspaceMember.workspaceId, channel.workspaceId)) .then((rows) => rows.map((row) => row.userId)) } @@ -81,7 +81,7 @@ function buildMentionTargets(args: { args.messageContent ) const includeEveryoneMention = - Boolean(args.channel.guildId) && + Boolean(args.channel.workspaceId) && EVERYONE_MENTION_REGEX.test(args.messageContent) const mentionTypeByUserId = new Map() @@ -135,7 +135,7 @@ export async function buildMessageFanout(input: MessageFanoutInput) { userId, payload: { channelId: input.channel.id, - guildId: input.channel.guildId, + workspaceId: input.channel.workspaceId, messageId: input.message.id, unreadCountDelta: 1, authorName: input.message.author.name, @@ -212,7 +212,7 @@ export async function buildMessageFanout(input: MessageFanoutInput) { const notificationRows = Array.from(mentionTypeByUserId.entries()).map( ([userId, mentionType]) => ({ userId, - guildId: input.channel.guildId, + workspaceId: input.channel.workspaceId, channelId: input.channel.id, messageId: input.message.id, type: toNotificationType(mentionType), @@ -229,7 +229,7 @@ export async function buildMessageFanout(input: MessageFanoutInput) { type: schema.notificationEvent.type, messageId: schema.notificationEvent.messageId, channelId: schema.notificationEvent.channelId, - guildId: schema.notificationEvent.guildId, + workspaceId: schema.notificationEvent.workspaceId, createdAt: schema.notificationEvent.createdAt, }) @@ -253,7 +253,7 @@ export async function buildMessageFanout(input: MessageFanoutInput) { type: schema.notificationEvent.type, messageId: schema.notificationEvent.messageId, channelId: schema.notificationEvent.channelId, - guildId: schema.notificationEvent.guildId, + workspaceId: schema.notificationEvent.workspaceId, createdAt: schema.notificationEvent.createdAt, }) .from(schema.notificationEvent) @@ -303,7 +303,7 @@ export async function buildMessageFanout(input: MessageFanoutInput) { type: notification.type, messageId: notification.messageId ?? input.message.id, channelId: notification.channelId ?? input.channel.id, - guildId: notification.guildId ?? input.channel.guildId, + workspaceId: notification.workspaceId ?? input.channel.workspaceId, createdAt: notification.createdAt.toISOString(), }, }, diff --git a/apps/realtime/src/services/rate-limit.ts b/apps/realtime/src/services/rate-limit.ts index 032c47f..1ed8b3b 100644 --- a/apps/realtime/src/services/rate-limit.ts +++ b/apps/realtime/src/services/rate-limit.ts @@ -1,4 +1,7 @@ -import { getGuildMessageRateLimit, isGuildRole } from "@repo/auth/permissions" +import { + getWorkspaceMessageRateLimit, + isWorkspaceRole, +} from "@repo/auth/permissions" import type { createClient } from "redis" type RedisClient = ReturnType @@ -9,17 +12,17 @@ const KEY_TTL_SECONDS = 90 // The DB role column is plain text — unknown values fall back to the // `member` tier (the safest/strictest rate limit). function getRoleRateLimit(role: string): number { - if (isGuildRole(role)) return getGuildMessageRateLimit(role) - return getGuildMessageRateLimit("member") + if (isWorkspaceRole(role)) return getWorkspaceMessageRateLimit(role) + return getWorkspaceMessageRateLimit("member") } function getMessageRateLimitKey( - guildId: string, + workspaceId: string, userId: string, timestamp: number ) { const currentWindow = Math.floor(timestamp / (WINDOW_SECONDS * 1000)) - return `ratelimit:guild:${guildId}:user:${userId}:message:${currentWindow}` + return `ratelimit:workspace:${workspaceId}:user:${userId}:message:${currentWindow}` } function getRetryAfterSeconds(timestamp: number) { @@ -27,16 +30,16 @@ function getRetryAfterSeconds(timestamp: number) { return Math.max(1, WINDOW_SECONDS - elapsedSeconds) } -export async function enforceGuildMessageRateLimit( +export async function enforceWorkspaceMessageRateLimit( redis: RedisClient, input: { - guildId: string + workspaceId: string userId: string role: string } ) { const now = Date.now() - const key = getMessageRateLimitKey(input.guildId, input.userId, now) + const key = getMessageRateLimitKey(input.workspaceId, input.userId, now) const nextCount = await redis.incr(key) if (nextCount === 1) { diff --git a/apps/realtime/src/services/read-states.ts b/apps/realtime/src/services/read-states.ts index 28392b2..ed1a0cb 100644 --- a/apps/realtime/src/services/read-states.ts +++ b/apps/realtime/src/services/read-states.ts @@ -162,24 +162,27 @@ export async function getUnreadStatesForUser( .from(schema.channelMember) .where(eq(schema.channelMember.userId, userId)) - // Get guild channel IDs via guild_member -> channels - const guildMemberships = await db - .select({ guildId: schema.guildMember.guildId }) - .from(schema.guildMember) - .where(eq(schema.guildMember.userId, userId)) - - let guildChannelIds: string[] = [] - if (guildMemberships.length > 0) { - const guildIds = guildMemberships.map((m) => m.guildId) - const guildChannels = await db + // Get workspace channel IDs via workspace_member -> channels + const workspaceMemberships = await db + .select({ workspaceId: schema.workspaceMember.workspaceId }) + .from(schema.workspaceMember) + .where(eq(schema.workspaceMember.userId, userId)) + + let workspaceChannelIds: string[] = [] + if (workspaceMemberships.length > 0) { + const workspaceIds = workspaceMemberships.map((m) => m.workspaceId) + const workspaceChannels = await db .select({ id: schema.channel.id }) .from(schema.channel) - .where(inArray(schema.channel.guildId, guildIds)) - guildChannelIds = guildChannels.map((c) => c.id) + .where(inArray(schema.channel.workspaceId, workspaceIds)) + workspaceChannelIds = workspaceChannels.map((c) => c.id) } const channelIds = [ - ...new Set([...dmMemberships.map((m) => m.channelId), ...guildChannelIds]), + ...new Set([ + ...dmMemberships.map((m) => m.channelId), + ...workspaceChannelIds, + ]), ] if (channelIds.length === 0) { diff --git a/apps/web/src/components/chat/header-search.tsx b/apps/web/src/components/chat/header-search.tsx index a9806b4..147c3c4 100644 --- a/apps/web/src/components/chat/header-search.tsx +++ b/apps/web/src/components/chat/header-search.tsx @@ -31,10 +31,10 @@ export function HeaderSearch({ mode, channelId, }: { - mode: "guild" | "dm" + mode: "workspace" | "dm" channelId: string }) { - const { guildSlug } = useParams({ strict: false }) + const { workspaceSlug } = useParams({ strict: false }) const navigate = useNavigate() const [isOpen, setIsOpen] = useState(false) const [query, setQuery] = useState("") @@ -87,8 +87,8 @@ export function HeaderSearch({ if (!res.ok) throw new Error("Search failed") return res.json() } - const res = await apiClient.v1.guilds[":guildSlug"].search.$get({ - param: { guildSlug: guildSlug as string }, + const res = await apiClient.v1.workspaces[":workspaceSlug"].search.$get({ + param: { workspaceSlug: workspaceSlug as string }, query: { query: debouncedQuery, channelId }, }) if (!res.ok) throw new Error("Search failed") @@ -114,8 +114,8 @@ export function HeaderSearch({ }) } else { void navigate({ - to: "/$guildSlug/$channelId", - params: { guildSlug: guildSlug as string, channelId }, + to: "/$workspaceSlug/$channelId", + params: { workspaceSlug: workspaceSlug as string, channelId }, search: { msgId }, }) } diff --git a/apps/web/src/components/chat/header.tsx b/apps/web/src/components/chat/header.tsx index 0b4aa0a..056ca71 100644 --- a/apps/web/src/components/chat/header.tsx +++ b/apps/web/src/components/chat/header.tsx @@ -34,7 +34,7 @@ export function ChatHeader({ useRightSidebar() const isMobile = useIsMobile() const { setOpen: openMobileSidebar } = useMobileSidebar() - const { guildSlug } = useParams({ strict: false }) + const { workspaceSlug } = useParams({ strict: false }) return (
@@ -76,7 +76,7 @@ export function ChatHeader({ )}
{context.type === "channel" && onTogglePinnedMessages && ( @@ -104,8 +104,8 @@ export function ChatHeader({ clearView() } else { setView({ - type: "guild-members", - guildSlug: guildSlug ?? "", + type: "workspace-members", + workspaceSlug: workspaceSlug ?? "", channelId, }) } diff --git a/apps/web/src/components/invite/create-invite-dialog.tsx b/apps/web/src/components/invite/create-invite-dialog.tsx index db6d528..199d96c 100644 --- a/apps/web/src/components/invite/create-invite-dialog.tsx +++ b/apps/web/src/components/invite/create-invite-dialog.tsx @@ -50,7 +50,7 @@ export function CreateInviteDialog({ open: boolean onOpenChange: (open: boolean) => void }) { - const { guildSlug } = useParams({ strict: false }) + const { workspaceSlug } = useParams({ strict: false }) const [expiresIn, setExpiresIn] = useState("1440") const [maxUses, setMaxUses] = useState("none") const [inviteCode, setInviteCode] = useState(null) @@ -58,15 +58,17 @@ export function CreateInviteDialog({ const createMutation = useMutation({ mutationFn: async () => { - if (!guildSlug) throw new Error("Missing guild slug") + if (!workspaceSlug) throw new Error("Missing workspace slug") - const res = await apiClient.v1.guilds[":guildSlug"].invites.$post({ - param: { guildSlug }, - json: { - expiresInMinutes: expiresIn === "never" ? null : Number(expiresIn), - maxUses: maxUses === "none" ? null : Number(maxUses), - }, - }) + const res = await apiClient.v1.workspaces[":workspaceSlug"].invites.$post( + { + param: { workspaceSlug }, + json: { + expiresInMinutes: expiresIn === "never" ? null : Number(expiresIn), + maxUses: maxUses === "none" ? null : Number(maxUses), + }, + } + ) if (!res.ok) { const body = await res.text() @@ -125,7 +127,7 @@ export function CreateInviteDialog({ Create Invite Link - Generate a shareable link to invite people to this guild. + Generate a shareable link to invite people to this workspace. diff --git a/apps/web/src/components/invite/manage-invites-dialog.tsx b/apps/web/src/components/invite/manage-invites-dialog.tsx index a890ede..1841ab4 100644 --- a/apps/web/src/components/invite/manage-invites-dialog.tsx +++ b/apps/web/src/components/invite/manage-invites-dialog.tsx @@ -30,36 +30,36 @@ export function ManageInvitesDialog({ open: boolean onOpenChange: (open: boolean) => void }) { - const { guildSlug } = useParams({ strict: false }) + const { workspaceSlug } = useParams({ strict: false }) const queryClient = useQueryClient() const [revokeCode, setRevokeCode] = useState(null) const { data, isPending, isError } = useQuery({ - queryKey: ["guild-invites", guildSlug], + queryKey: ["workspace-invites", workspaceSlug], queryFn: async () => { - if (!guildSlug) throw new Error("Missing guild slug") - const res = await apiClient.v1.guilds[":guildSlug"].invites.$get({ - param: { guildSlug }, + if (!workspaceSlug) throw new Error("Missing workspace slug") + const res = await apiClient.v1.workspaces[":workspaceSlug"].invites.$get({ + param: { workspaceSlug }, }) if (!res.ok) throw new Error("Failed to fetch invites") return res.json() }, - enabled: open && !!guildSlug, + enabled: open && !!workspaceSlug, }) const revokeMutation = useMutation({ mutationFn: async (code: string) => { - if (!guildSlug) throw new Error("Missing guild slug") - const res = await apiClient.v1.guilds[":guildSlug"].invites[ + if (!workspaceSlug) throw new Error("Missing workspace slug") + const res = await apiClient.v1.workspaces[":workspaceSlug"].invites[ ":code" ].$delete({ - param: { guildSlug, code }, + param: { workspaceSlug, code }, }) if (!res.ok) throw new Error("Failed to revoke invite") }, onSuccess: () => { queryClient.invalidateQueries({ - queryKey: ["guild-invites", guildSlug], + queryKey: ["workspace-invites", workspaceSlug], }) toast.success("Invite revoked") setRevokeCode(null) @@ -100,7 +100,7 @@ export function ManageInvitesDialog({ - Guild Invites + Workspace Invites View and manage active invite links. diff --git a/apps/web/src/components/onboarding/onboarding-dialog.tsx b/apps/web/src/components/onboarding/onboarding-dialog.tsx index 2ca1f76..ad7cb88 100644 --- a/apps/web/src/components/onboarding/onboarding-dialog.tsx +++ b/apps/web/src/components/onboarding/onboarding-dialog.tsx @@ -26,7 +26,7 @@ type Step = "username" | "welcome" | "create" | "join" const MIN_USERNAME_LENGTH = 3 const MAX_USERNAME_LENGTH = 30 const USERNAME_REGEX = /^[a-zA-Z0-9_.]+$/ -// TODO: Remove hardcoded invite code once we have a proper discovery/featured guilds system +// TODO: Remove hardcoded invite code once we have a proper discovery/featured workspaces system const LOR_INVITE_CODE = "k9yDieWZ" const showLorJoin = !env.NEXT_PUBLIC_SELF_HOSTED @@ -79,8 +79,8 @@ export function OnboardingDialog({ open }: { open: boolean }) { if (!joinLor) return apiClient.v1.invites[":code"].accept .$post({ param: { code: LOR_INVITE_CODE } }) - .then(() => queryClient.invalidateQueries({ queryKey: ["guilds"] })) - .catch((err) => console.error("Failed to join guild:", err)) + .then(() => queryClient.invalidateQueries({ queryKey: ["workspaces"] })) + .catch((err) => console.error("Failed to join workspace:", err)) }, [joinLor, queryClient]) // Username step state @@ -158,9 +158,11 @@ export function OnboardingDialog({ open }: { open: boolean }) { } }, [name, slugEdited]) - const getFirstChannelId = async (guildSlug: string) => { - const channelsRes = await apiClient.v1.guilds[":guildSlug"].channels.$get({ - param: { guildSlug }, + const getFirstChannelId = async (workspaceSlug: string) => { + const channelsRes = await apiClient.v1.workspaces[ + ":workspaceSlug" + ].channels.$get({ + param: { workspaceSlug }, }) if (!channelsRes.ok) return null @@ -187,38 +189,43 @@ export function OnboardingDialog({ open }: { open: boolean }) { }) if (res.error) { - const message = (res.error.message ?? "Failed to create guild").replace( - /organization/gi, - "Guild" - ) + const message = ( + res.error.message ?? "Failed to create workspace" + ).replace(/organization/gi, "Workspace") setError(message) return } - const createdGuildSlug = res.data?.slug ?? normalizedSlug - await queryClient.invalidateQueries({ queryKey: ["guilds"] }) + const createdWorkspaceSlug = res.data?.slug ?? normalizedSlug + await queryClient.invalidateQueries({ queryKey: ["workspaces"] }) acceptLorInvite() let firstChannelId: string | null = null try { - firstChannelId = await getFirstChannelId(createdGuildSlug) + firstChannelId = await getFirstChannelId(createdWorkspaceSlug) } catch (error) { console.error( - `Failed to fetch first channel for guild ${createdGuildSlug}:`, + `Failed to fetch first channel for workspace ${createdWorkspaceSlug}:`, error ) } if (firstChannelId) { navigate({ - to: "/$guildSlug/$channelId", - params: { guildSlug: createdGuildSlug, channelId: firstChannelId }, + to: "/$workspaceSlug/$channelId", + params: { + workspaceSlug: createdWorkspaceSlug, + channelId: firstChannelId, + }, }) return } - navigate({ to: "/$guildSlug", params: { guildSlug: createdGuildSlug } }) + navigate({ + to: "/$workspaceSlug", + params: { workspaceSlug: createdWorkspaceSlug }, + }) } catch { setError("Something went wrong. Please try again.") } finally { @@ -355,8 +362,8 @@ export function OnboardingDialog({ open }: { open: boolean }) { Welcome to Lor - Get started by creating a new guild or joining one you've - been invited to. + Get started by creating a new workspace or joining one + you've been invited to. @@ -370,7 +377,7 @@ export function OnboardingDialog({ open }: { open: boolean }) {
-

Create a Guild

+

Create a Workspace

Start your own community from scratch

@@ -386,9 +393,9 @@ export function OnboardingDialog({ open }: { open: boolean }) {
-

Join an Existing Guild

+

Join an Existing Workspace

- Enter an invite link to join a guild + Enter an invite link to join a workspace

@@ -439,7 +446,9 @@ export function OnboardingDialog({ open }: { open: boolean }) { - Create a Guild + + Create a Workspace + Give your community a name and a unique URL. @@ -447,10 +456,10 @@ export function OnboardingDialog({ open }: { open: boolean }) {
- + setName(e.target.value)} disabled={loading} @@ -459,15 +468,15 @@ export function OnboardingDialog({ open }: { open: boolean }) {
- +
lor.chat/ { setSlugEdited(true) @@ -491,7 +500,7 @@ export function OnboardingDialog({ open }: { open: boolean }) { {loading && ( )} - Create Guild + Create Workspace @@ -512,9 +521,11 @@ export function OnboardingDialog({ open }: { open: boolean }) { - Join a Guild + + Join a Workspace + - Paste an invite link or code to join an existing guild. + Paste an invite link or code to join an existing workspace. @@ -541,7 +552,7 @@ export function OnboardingDialog({ open }: { open: boolean }) { {loading && ( )} - Join Guild + Join Workspace diff --git a/apps/web/src/components/sidebar/channel-panel/channel-list.tsx b/apps/web/src/components/sidebar/channel-panel/channel-list.tsx index 925671b..014020e 100644 --- a/apps/web/src/components/sidebar/channel-panel/channel-list.tsx +++ b/apps/web/src/components/sidebar/channel-panel/channel-list.tsx @@ -110,33 +110,37 @@ function buildReorderPayload(data: ChannelData) { } export function ChannelList() { - const { guildSlug, channelId: activeChannelId } = useParams({ strict: false }) + const { workspaceSlug, channelId: activeChannelId } = useParams({ + strict: false, + }) const navigate = useNavigate() const queryClient = useQueryClient() const { setOpen: closeMobileSidebar } = useMobileSidebar() const { data, isPending } = useQuery({ - queryKey: ["channels", guildSlug], + queryKey: ["channels", workspaceSlug], queryFn: async () => { - const res = await apiClient.v1.guilds[":guildSlug"].channels.$get({ - param: { guildSlug: guildSlug as string }, - }) + const res = await apiClient.v1.workspaces[":workspaceSlug"].channels.$get( + { + param: { workspaceSlug: workspaceSlug as string }, + } + ) if (!res.ok) { throw new Error("Failed to fetch channels") } return res.json() }, - enabled: !!guildSlug, + enabled: !!workspaceSlug, }) const reorderMutation = useMutation({ mutationFn: async ( channels: { id: string; position: number; parentId: string | null }[] ) => { - const res = await apiClient.v1.guilds[ - ":guildSlug" + const res = await apiClient.v1.workspaces[ + ":workspaceSlug" ].channels.reorder.$patch({ - param: { guildSlug: guildSlug as string }, + param: { workspaceSlug: workspaceSlug as string }, json: { channels }, }) if (!res.ok) { @@ -144,12 +148,12 @@ export function ChannelList() { } }, onError: () => { - queryClient.invalidateQueries({ queryKey: ["channels", guildSlug] }) + queryClient.invalidateQueries({ queryKey: ["channels", workspaceSlug] }) }, }) const { data: activeMember } = useQuery({ - queryKey: ["active-guild-member", guildSlug], + queryKey: ["active-workspace-member", workspaceSlug], queryFn: async (): Promise<{ userId: string; role: string } | null> => { const res = await authClient.organization.getActiveMember() return res.data @@ -159,37 +163,37 @@ export function ChannelList() { } : null }, - enabled: !!guildSlug, + enabled: !!workspaceSlug, }) - const { data: guildMembersData } = useQuery({ - queryKey: ["guild-members", guildSlug], + const { data: workspaceMembersData } = useQuery({ + queryKey: ["workspace-members", workspaceSlug], queryFn: async () => { - const res = await apiClient.v1.guilds[":guildSlug"].members.$get({ - param: { guildSlug: guildSlug as string }, + const res = await apiClient.v1.workspaces[":workspaceSlug"].members.$get({ + param: { workspaceSlug: workspaceSlug as string }, }) - if (!res.ok) throw new Error("Failed to fetch guild members") + if (!res.ok) throw new Error("Failed to fetch workspace members") return res.json() }, - enabled: !!guildSlug, + enabled: !!workspaceSlug, }) const permissionCtx = - activeMember && guildMembersData?.ownerId + activeMember && workspaceMembersData?.ownerId ? { actor: activeMember, - guild: { ownerId: guildMembersData.ownerId }, + workspace: { ownerId: workspaceMembersData.ownerId }, } : null const canCreate = permissionCtx - ? canCreateChannels(permissionCtx.actor, permissionCtx.guild) + ? canCreateChannels(permissionCtx.actor, permissionCtx.workspace) : false const canManage = permissionCtx - ? canManageChannels(permissionCtx.actor, permissionCtx.guild) + ? canManageChannels(permissionCtx.actor, permissionCtx.workspace) : false const canDelete = permissionCtx - ? canDeleteChannels(permissionCtx.actor, permissionCtx.guild) + ? canDeleteChannels(permissionCtx.actor, permissionCtx.workspace) : false const [createDialogOpen, setCreateDialogOpen] = useState(false) @@ -247,7 +251,7 @@ export function ChannelList() { // Category-to-category: reorder optimistically if (activeItem.isCategory && overItem.isCategory) { queryClient.setQueryData( - ["channels", guildSlug], + ["channels", workspaceSlug], (old: ChannelData | undefined) => { if (!old) return old const newData = structuredClone(old) @@ -278,7 +282,7 @@ export function ChannelList() { // If moving between containers, update optimistically if (activeContainer !== overContainer) { queryClient.setQueryData( - ["channels", guildSlug], + ["channels", workspaceSlug], (old: ChannelData | undefined) => { if (!old) return old const newData = structuredClone(old) @@ -334,7 +338,7 @@ export function ChannelList() { ) } }, - [data, findChannel, guildSlug, queryClient] + [data, findChannel, workspaceSlug, queryClient] ) const handleDragEnd = useCallback( @@ -349,7 +353,7 @@ export function ChannelList() { let newData: ChannelData | undefined queryClient.setQueryData( - ["channels", guildSlug], + ["channels", workspaceSlug], (old: ChannelData | undefined) => { if (!old) return old const updated = structuredClone(old) @@ -390,7 +394,7 @@ export function ChannelList() { reorderMutation.mutate(buildReorderPayload(newData)) } }, - [data, findChannel, guildSlug, queryClient, reorderMutation] + [data, findChannel, workspaceSlug, queryClient, reorderMutation] ) if (isPending) { @@ -473,9 +477,9 @@ export function ChannelList() { canDelete={canDelete} onClick={() => { navigate({ - to: "/$guildSlug/$channelId", + to: "/$workspaceSlug/$channelId", params: { - guildSlug: guildSlug as string, + workspaceSlug: workspaceSlug as string, channelId: ch.id, }, }) @@ -506,8 +510,11 @@ export function ChannelList() { canDelete={canDelete} onChannelClick={(channelId) => { navigate({ - to: "/$guildSlug/$channelId", - params: { guildSlug: guildSlug as string, channelId }, + to: "/$workspaceSlug/$channelId", + params: { + workspaceSlug: workspaceSlug as string, + channelId, + }, }) closeMobileSidebar(false) }} diff --git a/apps/web/src/components/sidebar/channel-panel/channel-panel.tsx b/apps/web/src/components/sidebar/channel-panel/channel-panel.tsx index a1ad746..5adb9ee 100644 --- a/apps/web/src/components/sidebar/channel-panel/channel-panel.tsx +++ b/apps/web/src/components/sidebar/channel-panel/channel-panel.tsx @@ -1,13 +1,13 @@ import { ScrollArea } from "@repo/ui/components/scroll-area" import { ChannelList } from "./channel-list" -import { GuildHeader } from "./guild-header" import { SearchBar } from "./search-bar" import { UserBar } from "./user-bar" +import { WorkspaceHeader } from "./workspace-header" export function ChannelPanel() { return (
- +
diff --git a/apps/web/src/components/sidebar/channel-panel/create-channel-dialog.tsx b/apps/web/src/components/sidebar/channel-panel/create-channel-dialog.tsx index d0e2f19..c05c7f4 100644 --- a/apps/web/src/components/sidebar/channel-panel/create-channel-dialog.tsx +++ b/apps/web/src/components/sidebar/channel-panel/create-channel-dialog.tsx @@ -47,7 +47,7 @@ export function CreateChannelDialog({ parentId?: string | null forceType?: "category" }) { - const { guildSlug } = useParams({ strict: false }) + const { workspaceSlug } = useParams({ strict: false }) const navigate = useNavigate() const queryClient = useQueryClient() const [name, setName] = useState("") @@ -58,7 +58,7 @@ export function CreateChannelDialog({ const handleCreate = async (e: React.FormEvent) => { e.preventDefault() const trimmed = name.trim() - if (!trimmed || !guildSlug) return + if (!trimmed || !workspaceSlug) return if (forceType !== "category" && !normalizedName) return setError(null) setLoading(true) @@ -66,8 +66,10 @@ export function CreateChannelDialog({ const isCategory = forceType === "category" try { - const res = await apiClient.v1.guilds[":guildSlug"].channels.$post({ - param: { guildSlug }, + const res = await apiClient.v1.workspaces[ + ":workspaceSlug" + ].channels.$post({ + param: { workspaceSlug }, json: { name: isCategory ? trimmed : normalizedName, type: isCategory ? "category" : type, @@ -86,7 +88,7 @@ export function CreateChannelDialog({ const channel = await res.json() await queryClient.invalidateQueries({ - queryKey: ["channels", guildSlug], + queryKey: ["channels", workspaceSlug], }) onOpenChange(false) setName("") @@ -95,8 +97,8 @@ export function CreateChannelDialog({ if (!isCategory) { navigate({ - to: "/$guildSlug/$channelId", - params: { guildSlug, channelId: channel.id }, + to: "/$workspaceSlug/$channelId", + params: { workspaceSlug, channelId: channel.id }, }) } } catch { @@ -133,7 +135,7 @@ export function CreateChannelDialog({ {isCategory ? "Add a new category to organize your channels." - : "Add a new channel to your guild."} + : "Add a new channel to your workspace."}
diff --git a/apps/web/src/components/sidebar/channel-panel/delete-channel-dialog.tsx b/apps/web/src/components/sidebar/channel-panel/delete-channel-dialog.tsx index e572ebc..b1eecc4 100644 --- a/apps/web/src/components/sidebar/channel-panel/delete-channel-dialog.tsx +++ b/apps/web/src/components/sidebar/channel-panel/delete-channel-dialog.tsx @@ -22,7 +22,7 @@ export function DeleteChannelDialog({ open: boolean onOpenChange: (open: boolean) => void }) { - const { guildSlug, channelId: activeChannelId } = useParams({ + const { workspaceSlug, channelId: activeChannelId } = useParams({ strict: false, }) const queryClient = useQueryClient() @@ -30,10 +30,13 @@ export function DeleteChannelDialog({ const deleteMutation = useMutation({ mutationFn: async () => { - const res = await apiClient.v1.guilds[":guildSlug"].channels[ + const res = await apiClient.v1.workspaces[":workspaceSlug"].channels[ ":channelId" ].$delete({ - param: { guildSlug: guildSlug as string, channelId: channel.id }, + param: { + workspaceSlug: workspaceSlug as string, + channelId: channel.id, + }, }) if (!res.ok) { throw new Error("Failed to delete channel") @@ -41,14 +44,14 @@ export function DeleteChannelDialog({ return res.json() }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["channels", guildSlug] }) + queryClient.invalidateQueries({ queryKey: ["channels", workspaceSlug] }) onOpenChange(false) - // If we deleted the active channel, navigate to guild root + // If we deleted the active channel, navigate to workspace root if (activeChannelId === channel.id) { navigate({ - to: "/$guildSlug", - params: { guildSlug: guildSlug as string }, + to: "/$workspaceSlug", + params: { workspaceSlug: workspaceSlug as string }, }) } }, diff --git a/apps/web/src/components/sidebar/channel-panel/edit-channel-dialog.tsx b/apps/web/src/components/sidebar/channel-panel/edit-channel-dialog.tsx index 6be4523..d8f3cfe 100644 --- a/apps/web/src/components/sidebar/channel-panel/edit-channel-dialog.tsx +++ b/apps/web/src/components/sidebar/channel-panel/edit-channel-dialog.tsx @@ -26,7 +26,7 @@ export function EditChannelDialog({ open: boolean onOpenChange: (open: boolean) => void }) { - const { guildSlug } = useParams({ strict: false }) + const { workspaceSlug } = useParams({ strict: false }) const queryClient = useQueryClient() const [name, setName] = useState(channel.name ?? "") @@ -41,15 +41,15 @@ export function EditChannelDialog({ const updateMutation = useMutation({ mutationFn: async () => { - if (!guildSlug) { - throw new Error("Missing guild slug") + if (!workspaceSlug) { + throw new Error("Missing workspace slug") } - const validatedGuildSlug = guildSlug - const res = await apiClient.v1.guilds[":guildSlug"].channels[ + const validatedWorkspaceSlug = workspaceSlug + const res = await apiClient.v1.workspaces[":workspaceSlug"].channels[ ":channelId" ].$patch({ - param: { guildSlug: validatedGuildSlug, channelId: channel.id }, + param: { workspaceSlug: validatedWorkspaceSlug, channelId: channel.id }, json: { name, ...(channel.type !== "category" ? { topic: topic || undefined } : {}), @@ -73,16 +73,16 @@ export function EditChannelDialog({ throw new Error(message) } return { - guildSlug: validatedGuildSlug, + workspaceSlug: validatedWorkspaceSlug, channel: await res.json(), } }, - onSuccess: ({ guildSlug: validatedGuildSlug }) => { + onSuccess: ({ workspaceSlug: validatedWorkspaceSlug }) => { queryClient.invalidateQueries({ - queryKey: ["channels", validatedGuildSlug], + queryKey: ["channels", validatedWorkspaceSlug], }) queryClient.invalidateQueries({ - queryKey: ["channel", validatedGuildSlug, channel.id], + queryKey: ["channel", validatedWorkspaceSlug, channel.id], }) onOpenChange(false) }, diff --git a/apps/web/src/components/sidebar/channel-panel/search-bar.tsx b/apps/web/src/components/sidebar/channel-panel/search-bar.tsx index 95b148b..c82f893 100644 --- a/apps/web/src/components/sidebar/channel-panel/search-bar.tsx +++ b/apps/web/src/components/sidebar/channel-panel/search-bar.tsx @@ -27,8 +27,12 @@ type SearchResponse = { data: SearchResult[] } -export function SearchBar({ mode = "guild" }: { mode?: "guild" | "dm" }) { - const { guildSlug } = useParams({ strict: false }) +export function SearchBar({ + mode = "workspace", +}: { + mode?: "workspace" | "dm" +}) { + const { workspaceSlug } = useParams({ strict: false }) const navigate = useNavigate() const [isOpen, setIsOpen] = useState(false) const [query, setQuery] = useState("") @@ -73,8 +77,8 @@ export function SearchBar({ mode = "guild" }: { mode?: "guild" | "dm" }) { const { data, isPending } = useQuery({ queryKey: [ - mode === "guild" ? "guild-search" : "dm-search", - guildSlug, + mode === "workspace" ? "workspace-search" : "dm-search", + workspaceSlug, debouncedQuery, ], queryFn: async (): Promise => { @@ -85,14 +89,14 @@ export function SearchBar({ mode = "guild" }: { mode?: "guild" | "dm" }) { if (!res.ok) throw new Error("Search failed") return res.json() } - const res = await apiClient.v1.guilds[":guildSlug"].search.$get({ - param: { guildSlug: guildSlug as string }, + const res = await apiClient.v1.workspaces[":workspaceSlug"].search.$get({ + param: { workspaceSlug: workspaceSlug as string }, query: { query: debouncedQuery }, }) if (!res.ok) throw new Error("Search failed") return res.json() }, - enabled: debouncedQuery.length > 0 && (mode === "dm" || !!guildSlug), + enabled: debouncedQuery.length > 0 && (mode === "dm" || !!workspaceSlug), }) const handleResultClick = (channelId: string, messageId: string) => { @@ -111,8 +115,8 @@ export function SearchBar({ mode = "guild" }: { mode?: "guild" | "dm" }) { }) } else { void navigate({ - to: "/$guildSlug/$channelId", - params: { guildSlug: guildSlug as string, channelId }, + to: "/$workspaceSlug/$channelId", + params: { workspaceSlug: workspaceSlug as string, channelId }, search: { msgId: messageId }, }) } @@ -186,7 +190,7 @@ export function SearchBar({ mode = "guild" }: { mode?: "guild" | "dm" }) { {msg.author.displayUsername ?? msg.author.name} - in {mode === "guild" ? "#" : ""} + in {mode === "workspace" ? "#" : ""} {msg.channelName} diff --git a/apps/web/src/components/sidebar/channel-panel/guild-header.tsx b/apps/web/src/components/sidebar/channel-panel/workspace-header.tsx similarity index 73% rename from apps/web/src/components/sidebar/channel-panel/guild-header.tsx rename to apps/web/src/components/sidebar/channel-panel/workspace-header.tsx index ce887ec..4a7576d 100644 --- a/apps/web/src/components/sidebar/channel-panel/guild-header.tsx +++ b/apps/web/src/components/sidebar/channel-panel/workspace-header.tsx @@ -10,19 +10,19 @@ import { useQuery } from "@tanstack/react-query" import { useParams } from "@tanstack/react-router" import { ChevronDown, Link, Settings, UserPlus } from "lucide-react" import { useMemo, useState } from "react" -import { GuildSettingsDialog } from "@/components/guild/guild-settings-dialog" import { CreateInviteDialog } from "@/components/invite/create-invite-dialog" import { ManageInvitesDialog } from "@/components/invite/manage-invites-dialog" -import { canKickGuildMembers, isAdminOrOwner } from "@/lib/permissions" +import { WorkspaceSettingsDialog } from "@/components/workspace/workspace-settings-dialog" +import { canKickWorkspaceMembers, isAdminOrOwner } from "@/lib/permissions" -export function GuildHeader() { - const { guildSlug } = useParams({ strict: false }) +export function WorkspaceHeader() { + const { workspaceSlug } = useParams({ strict: false }) const [inviteDialogOpen, setInviteDialogOpen] = useState(false) const [manageInvitesOpen, setManageInvitesOpen] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false) - const { data: guilds, isPending } = useQuery({ - queryKey: ["guilds"], + const { data: workspaces, isPending } = useQuery({ + queryKey: ["workspaces"], queryFn: async () => { const res = await authClient.organization.list() if (res.error) throw res.error @@ -31,7 +31,7 @@ export function GuildHeader() { }) const { data: activeMember } = useQuery({ - queryKey: ["active-guild-member", guildSlug], + queryKey: ["active-workspace-member", workspaceSlug], queryFn: async (): Promise<{ userId: string; role: string } | null> => { const res = await authClient.organization.getActiveMember() if (res.error) { @@ -45,34 +45,36 @@ export function GuildHeader() { } : null }, - enabled: !!guildSlug, + enabled: !!workspaceSlug, }) - const activeGuild = useMemo( - () => guilds?.find((g) => g.slug === guildSlug) ?? null, - [guilds, guildSlug] + const activeWorkspace = useMemo( + () => workspaces?.find((g) => g.slug === workspaceSlug) ?? null, + [workspaces, workspaceSlug] ) const permissionCtx = - activeMember && activeGuild?.ownerId + activeMember && activeWorkspace?.ownerId ? { actor: activeMember, - guild: { ownerId: activeGuild.ownerId }, + workspace: { ownerId: activeWorkspace.ownerId }, } : null const canManageInvites = permissionCtx - ? canKickGuildMembers(permissionCtx.actor, permissionCtx.guild) + ? canKickWorkspaceMembers(permissionCtx.actor, permissionCtx.workspace) : false - const canEditGuild = permissionCtx - ? isAdminOrOwner(permissionCtx.actor, permissionCtx.guild) + const canEditWorkspace = permissionCtx + ? isAdminOrOwner(permissionCtx.actor, permissionCtx.workspace) : false - const guildName = activeGuild?.name + const workspaceName = activeWorkspace?.name - const title = isPending ? "Loading..." : (guildName ?? "Guild not found") + const title = isPending + ? "Loading..." + : (workspaceName ?? "Workspace not found") - const showDropdown = canManageInvites || canEditGuild + const showDropdown = canManageInvites || canEditWorkspace if (!showDropdown) { return ( @@ -111,12 +113,12 @@ export function GuildHeader() { )} - {canEditGuild && ( + {canEditWorkspace && ( <> {canManageInvites && } setSettingsOpen(true)}> - Guild Settings + Workspace Settings )} @@ -131,11 +133,11 @@ export function GuildHeader() { open={manageInvitesOpen} onOpenChange={setManageInvitesOpen} /> - {canEditGuild && activeGuild && ( - )} diff --git a/apps/web/src/components/sidebar/index.tsx b/apps/web/src/components/sidebar/index.tsx index 04835ae..ed43e1d 100644 --- a/apps/web/src/components/sidebar/index.tsx +++ b/apps/web/src/components/sidebar/index.tsx @@ -13,21 +13,21 @@ import { useCallback, useEffect, useRef, useState } from "react" import { useMobileSidebar } from "@/context/mobile-sidebar-context" import { ChannelPanel } from "./channel-panel/channel-panel" import { DMPanel } from "./dm-panel/dm-panel" -import { GuildBar } from "./guild-bar/guild-bar" import { RightSidebarProvider, useRightSidebar, } from "./right-panel/right-sidebar-context" import { RightSidebarPanel } from "./right-panel/right-sidebar-panel" +import { WorkspaceBar } from "./workspace-bar/workspace-bar" function LeftSidebarContent() { - const { guildSlug } = useParams({ strict: false }) + const { workspaceSlug } = useParams({ strict: false }) return (
- +
- {guildSlug ? : } + {workspaceSlug ? : }
) @@ -50,7 +50,7 @@ function MobileSidebar() { } function DesktopSidebarLayout({ children }: { children: React.ReactNode }) { - const { guildSlug } = useParams({ strict: false }) + const { workspaceSlug } = useParams({ strict: false }) const { view, isCollapsed, panelWidth, setPanelWidth, isHydrated } = useRightSidebar() const [isResizing, setIsResizing] = useState(false) @@ -92,11 +92,11 @@ function DesktopSidebarLayout({ children }: { children: React.ReactNode }) { [panelWidth, setPanelWidth] ) - const showRightPanel = !!view && !!guildSlug + const showRightPanel = !!view && !!workspaceSlug return (
- + - {guildSlug ? : } + {workspaceSlug ? : } @@ -154,12 +154,12 @@ function DesktopSidebarLayout({ children }: { children: React.ReactNode }) { function MobileRightPanel() { const { view, clearView } = useRightSidebar() - const { guildSlug } = useParams({ strict: false }) - const open = !!view && !!guildSlug + const { workspaceSlug } = useParams({ strict: false }) + const open = !!view && !!workspaceSlug useEffect(() => { - if (!guildSlug && view) clearView() - }, [guildSlug, view, clearView]) + if (!workspaceSlug && view) clearView() + }, [workspaceSlug, view, clearView]) return ( { setView({ - type: "guild-members", - guildSlug: view.guildSlug, + type: "workspace-members", + workspaceSlug: view.workspaceSlug, channelId: view.channelId, }) } @@ -32,10 +32,10 @@ export function PinnedMessagesPanel({ const { data, isPending } = useQuery({ queryKey: ["pinned-messages", view.channelId], queryFn: async () => { - const res = await apiClient.v1.guilds[":guildSlug"].channels[ + const res = await apiClient.v1.workspaces[":workspaceSlug"].channels[ ":channelId" ].pins.$get({ - param: { guildSlug: view.guildSlug, channelId: view.channelId }, + param: { workspaceSlug: view.workspaceSlug, channelId: view.channelId }, }) if (!res.ok) throw new Error("Failed to fetch pinned messages") return res.json() diff --git a/apps/web/src/components/sidebar/right-panel/right-sidebar-panel.tsx b/apps/web/src/components/sidebar/right-panel/right-sidebar-panel.tsx index ddc461b..625ef53 100644 --- a/apps/web/src/components/sidebar/right-panel/right-sidebar-panel.tsx +++ b/apps/web/src/components/sidebar/right-panel/right-sidebar-panel.tsx @@ -1,8 +1,8 @@ import { Image, MessageSquareQuote } from "lucide-react" import type { ReactNode } from "react" -import { GuildMembersPanel } from "./guild-members-panel" import { PinnedMessagesPanel } from "./pinned-messages-panel" import type { RightSidebarView } from "./right-sidebar-types" +import { WorkspaceMembersPanel } from "./workspace-members-panel" function PlaceholderSidebar({ title, @@ -27,7 +27,9 @@ function PlaceholderSidebar({ export function RightSidebarPanel({ view }: { view: RightSidebarView }) { return (
- {view.type === "guild-members" && } + {view.type === "workspace-members" && ( + + )} {view.type === "pinned-messages" && } {view.type === "thread" && ( = { +const statusStyles: Record = { online: "bg-emerald-500", offline: "bg-muted-foreground/40", } -const statusLabel: Record = { +const statusLabel: Record = { online: "Online", offline: "Offline", } -function formatRole(role: GuildMemberPresence["role"]) { - if (!role || !isGuildRole(role)) return "Member" - return formatGuildRole(role) +function formatRole(role: WorkspaceMemberPresence["role"]) { + if (!role || !isWorkspaceRole(role)) return "Member" + return formatWorkspaceRole(role) } type ModerationDialogState = { type: "kick" - member: GuildMemberPresence + member: WorkspaceMemberPresence } | null function MembersSkeleton() { @@ -100,30 +103,33 @@ function MemberRow({ onKick, isBusy, }: { - member: GuildMemberPresence + member: WorkspaceMemberPresence currentUserId: string | null currentMember: { userId: string; role: string } | null ownerId: string | null - onRoleChange: (member: GuildMemberPresence, role: AssignableGuildRole) => void - onKick: (member: GuildMemberPresence) => void + onRoleChange: ( + member: WorkspaceMemberPresence, + role: AssignableWorkspaceRole + ) => void + onKick: (member: WorkspaceMemberPresence) => void isBusy: boolean }) { - const targetRole = isGuildRole(member.role) ? member.role : null - const guildCtx = ownerId ? { ownerId } : null + const targetRole = isWorkspaceRole(member.role) ? member.role : null + const workspaceCtx = ownerId ? { ownerId } : null const canManageTarget = - currentMember && guildCtx && currentUserId !== member.userId - ? canManageGuildMember( + currentMember && workspaceCtx && currentUserId !== member.userId + ? canManageWorkspaceMember( currentMember, { userId: member.userId, role: member.role }, - guildCtx + workspaceCtx ) : false const canUpdateRole = canManageTarget && !!targetRole const canKick = - currentMember && guildCtx - ? canKickGuildMembers(currentMember, guildCtx) && canManageTarget + currentMember && workspaceCtx + ? canKickWorkspaceMembers(currentMember, workspaceCtx) && canManageTarget : false const showActions = canUpdateRole || canKick @@ -178,27 +184,27 @@ function MemberRow({ { if ( - !assignableGuildRoles.includes( - value as AssignableGuildRole + !assignableWorkspaceRoles.includes( + value as AssignableWorkspaceRole ) ) { return } - onRoleChange(member, value as AssignableGuildRole) + onRoleChange(member, value as AssignableWorkspaceRole) }} > - {assignableGuildRoles.map((role) => ( + {assignableWorkspaceRoles.map((role) => ( - {formatGuildRole(role)} + {formatWorkspaceRole(role)} ))} @@ -218,7 +224,11 @@ function MemberRow({ ) } -export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { +export function WorkspaceMembersPanel({ + view, +}: { + view: WorkspaceMembersSidebarView +}) { const socket = useSocket() const queryClient = useQueryClient() const { data: session } = authClient.useSession() @@ -227,17 +237,17 @@ export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { const [moderationDialog, setModerationDialog] = useState(null) const queryKey = useMemo( - () => ["guild-members", view.guildSlug] as const, - [view.guildSlug] + () => ["workspace-members", view.workspaceSlug] as const, + [view.workspaceSlug] ) const { data, isPending, isError } = useQuery({ queryKey, queryFn: async () => { - const res = await apiClient.v1.guilds[":guildSlug"].members.$get({ - param: { guildSlug: view.guildSlug }, + const res = await apiClient.v1.workspaces[":workspaceSlug"].members.$get({ + param: { workspaceSlug: view.workspaceSlug }, }) - if (!res.ok) throw new Error("Failed to fetch guild members") + if (!res.ok) throw new Error("Failed to fetch workspace members") return res.json() }, }) @@ -246,7 +256,7 @@ export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { error: activeMemberError, isError: hasActiveMemberError, } = useQuery({ - queryKey: ["active-guild-member", view.guildSlug], + queryKey: ["active-workspace-member", view.workspaceSlug], queryFn: async () => { const res = await authClient.organization.getActiveMember() if (res.error) { @@ -261,9 +271,9 @@ export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { return res.data }, - enabled: !!view.guildSlug, + enabled: !!view.workspaceSlug, }) - const guildId = data?.guildId + const workspaceId = data?.workspaceId const currentUserId = session?.user?.id ?? null const activeMemberRole = typeof activeMember?.role === "string" ? activeMember.role : null @@ -281,7 +291,7 @@ export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { ? activeMemberError.message : "Failed to verify moderation permissions" ) - }, [hasActiveMemberError, activeMemberError, view.guildSlug]) + }, [hasActiveMemberError, activeMemberError, view.workspaceSlug]) const invalidateMembers = async () => { await queryClient.invalidateQueries({ queryKey }) @@ -290,17 +300,17 @@ export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { const updateRoleMutation = useMutation({ mutationFn: async (input: { userId: string - role: AssignableGuildRole + role: AssignableWorkspaceRole }) => { - const res = await apiClient.v1.guilds[":guildSlug"].members[ + const res = await apiClient.v1.workspaces[":workspaceSlug"].members[ ":userId" ].role.$patch({ - param: { guildSlug: view.guildSlug, userId: input.userId }, + param: { workspaceSlug: view.workspaceSlug, userId: input.userId }, json: { role: input.role }, }) if (!res.ok) { - throw new Error("Failed to update guild member role") + throw new Error("Failed to update workspace member role") } return res.json() @@ -316,10 +326,10 @@ export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { const kickMutation = useMutation({ mutationFn: async (userId: string) => { - const res = await apiClient.v1.guilds[":guildSlug"].members[ + const res = await apiClient.v1.workspaces[":workspaceSlug"].members[ ":userId" ].kick.$post({ - param: { guildSlug: view.guildSlug, userId }, + param: { workspaceSlug: view.workspaceSlug, userId }, }) if (!res.ok) { @@ -339,14 +349,14 @@ export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { const isMutating = updateRoleMutation.isPending || kickMutation.isPending const handleRoleChange = ( - member: GuildMemberPresence, - role: AssignableGuildRole + member: WorkspaceMemberPresence, + role: AssignableWorkspaceRole ) => { if (member.role === role) return updateRoleMutation.mutate({ userId: member.userId, role }) } - const handleKick = (member: GuildMemberPresence) => { + const handleKick = (member: WorkspaceMemberPresence) => { setModerationDialog({ type: "kick", member }) } @@ -359,11 +369,11 @@ export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { } useEffect(() => { - if (!socket || !guildId) return + if (!socket || !workspaceId) return const applySnapshot = (onlineUserIds: string[]) => { const onlineSet = new Set(onlineUserIds) - queryClient.setQueryData( + queryClient.setQueryData( queryKey, (current) => { if (!current) return current @@ -379,9 +389,9 @@ export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { } const requestSnapshot = () => { - if (!guildId) return + if (!workspaceId) return - socket.emit("presence:subscribe", { guildId }, (result) => { + socket.emit("presence:subscribe", { workspaceId }, (result) => { if (!result.ok) return applySnapshot(result.snapshot.onlineUserIds) }) @@ -396,11 +406,11 @@ export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { } const onPresenceUpdate = (payload: PresenceUserUpdate) => { - if (!guildId || payload.guildId !== guildId) return - const nextStatus: GuildMemberPresence["status"] = + if (!workspaceId || payload.workspaceId !== workspaceId) return + const nextStatus: WorkspaceMemberPresence["status"] = payload.status === "offline" ? "offline" : "online" - queryClient.setQueryData( + queryClient.setQueryData( queryKey, (current) => { if (!current) return current @@ -416,8 +426,8 @@ export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { ) } - const onMemberJoined = (payload: GuildMemberJoinedEvent) => { - if (!guildId || payload.guildId !== guildId) return + const onMemberJoined = (payload: WorkspaceMemberJoinedEvent) => { + if (!workspaceId || payload.workspaceId !== workspaceId) return // Refetch the full member list to get the new member with all fields queryClient.invalidateQueries({ queryKey }) } @@ -425,7 +435,7 @@ export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { socket.on("presence:ready", onPresenceReady) socket.on("connect", onConnect) socket.on("presence:user:update", onPresenceUpdate) - socket.on("guild:member:joined", onMemberJoined) + socket.on("workspace:member:joined", onMemberJoined) if (socket.connected) { requestSnapshot() @@ -435,9 +445,9 @@ export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { socket.off("presence:ready", onPresenceReady) socket.off("connect", onConnect) socket.off("presence:user:update", onPresenceUpdate) - socket.off("guild:member:joined", onMemberJoined) + socket.off("workspace:member:joined", onMemberJoined) } - }, [socket, guildId, queryClient, queryKey]) + }, [socket, workspaceId, queryClient, queryKey]) const members = data?.members ?? [] const onlineMembers = members.filter((member) => member.status !== "offline") @@ -445,7 +455,7 @@ export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { const isModerationDialogOpen = moderationDialog !== null const moderationDialogTitle = "Kick member" const moderationDialogDescription = moderationDialog - ? `Are you sure you want to kick ${moderationDialog.member.name} from this guild? They can rejoin if invited again.` + ? `Are you sure you want to kick ${moderationDialog.member.name} from this workspace? They can rejoin if invited again.` : "" const isModerationSubmitting = moderationDialog?.type === "kick" && kickMutation.isPending @@ -484,7 +494,7 @@ export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { {members.length === 0 ? (
- No members found for this guild. + No members found for this workspace.
) : (
diff --git a/apps/web/src/components/sidebar/guild-bar/create-guild-dialog.tsx b/apps/web/src/components/sidebar/workspace-bar/create-workspace-dialog.tsx similarity index 84% rename from apps/web/src/components/sidebar/guild-bar/create-guild-dialog.tsx rename to apps/web/src/components/sidebar/workspace-bar/create-workspace-dialog.tsx index 9654d5f..508ed3b 100644 --- a/apps/web/src/components/sidebar/guild-bar/create-guild-dialog.tsx +++ b/apps/web/src/components/sidebar/workspace-bar/create-workspace-dialog.tsx @@ -25,7 +25,7 @@ function normalizeSlugInput(value: string) { .replace(/[^a-z0-9-]/g, "") } -export function CreateGuildDialog({ +export function CreateWorkspaceDialog({ open, onOpenChange, }: { @@ -61,9 +61,11 @@ export function CreateGuildDialog({ } }, [name, slugEdited]) - const getFirstChannelId = async (guildSlug: string) => { - const channelsRes = await apiClient.v1.guilds[":guildSlug"].channels.$get({ - param: { guildSlug }, + const getFirstChannelId = async (workspaceSlug: string) => { + const channelsRes = await apiClient.v1.workspaces[ + ":workspaceSlug" + ].channels.$get({ + param: { workspaceSlug }, }) if (!channelsRes.ok) return null const channels = await channelsRes.json() @@ -88,33 +90,35 @@ export function CreateGuildDialog({ }) if (res.error) { - const message = (res.error.message ?? "Failed to create guild").replace( - /organization/gi, - "Guild" - ) + const message = ( + res.error.message ?? "Failed to create workspace" + ).replace(/organization/gi, "Workspace") setError(message) return } - const createdGuildSlug = res.data?.slug ?? normalizedSlug - await queryClient.invalidateQueries({ queryKey: ["guilds"] }) + const createdWorkspaceSlug = res.data?.slug ?? normalizedSlug + await queryClient.invalidateQueries({ queryKey: ["workspaces"] }) let firstChannelId: string | null = null try { - firstChannelId = await getFirstChannelId(createdGuildSlug) + firstChannelId = await getFirstChannelId(createdWorkspaceSlug) } catch {} onOpenChange(false) if (firstChannelId) { navigate({ - to: "/$guildSlug/$channelId", - params: { guildSlug: createdGuildSlug, channelId: firstChannelId }, + to: "/$workspaceSlug/$channelId", + params: { + workspaceSlug: createdWorkspaceSlug, + channelId: firstChannelId, + }, }) } else { navigate({ - to: "/$guildSlug", - params: { guildSlug: createdGuildSlug }, + to: "/$workspaceSlug", + params: { workspaceSlug: createdWorkspaceSlug }, }) } } catch { @@ -157,9 +161,9 @@ export function CreateGuildDialog({ {step === "choose" && ( <> - Add a Guild + Add a Workspace - Create a new guild or join an existing one. + Create a new workspace or join an existing one.
@@ -172,7 +176,7 @@ export function CreateGuildDialog({
-

Create a Guild

+

Create a Workspace

Start your own community from scratch

@@ -187,9 +191,9 @@ export function CreateGuildDialog({
-

Join an Existing Guild

+

Join an Existing Workspace

- Enter an invite link to join a guild + Enter an invite link to join a workspace

@@ -211,17 +215,17 @@ export function CreateGuildDialog({ Back - Create a Guild + Create a Workspace Give your community a name and a unique URL.
- + setName(e.target.value)} disabled={loading} @@ -229,15 +233,15 @@ export function CreateGuildDialog({ />
- +
lor.chat/ { setSlugEdited(true) @@ -257,7 +261,7 @@ export function CreateGuildDialog({ disabled={loading || !name.trim() || !sluggify(slug)} > {loading && } - Create Guild + Create Workspace @@ -277,9 +281,9 @@ export function CreateGuildDialog({ Back - Join a Guild + Join a Workspace - Paste an invite link or code to join an existing guild. + Paste an invite link or code to join an existing workspace.
@@ -301,7 +305,7 @@ export function CreateGuildDialog({ disabled={loading || !inviteLink.trim()} > {loading && } - Join Guild + Join Workspace diff --git a/apps/web/src/components/sidebar/guild-bar/guild-bar.tsx b/apps/web/src/components/sidebar/workspace-bar/workspace-bar.tsx similarity index 82% rename from apps/web/src/components/sidebar/guild-bar/guild-bar.tsx rename to apps/web/src/components/sidebar/workspace-bar/workspace-bar.tsx index 7f3295f..bcd31e1 100644 --- a/apps/web/src/components/sidebar/guild-bar/guild-bar.tsx +++ b/apps/web/src/components/sidebar/workspace-bar/workspace-bar.tsx @@ -4,9 +4,9 @@ import { useQuery } from "@tanstack/react-query" import { useNavigate, useParams } from "@tanstack/react-router" import { MessageCircle, Plus } from "lucide-react" import { useState } from "react" -import { CreateGuildDialog } from "./create-guild-dialog" +import { CreateWorkspaceDialog } from "./create-workspace-dialog" -function GuildIcon({ +function WorkspaceIcon({ name, logo, active, @@ -59,14 +59,14 @@ function GuildIcon({ ) } -export function GuildBar() { +export function WorkspaceBar() { const navigate = useNavigate() const [createDialogOpen, setCreateDialogOpen] = useState(false) - const { guildSlug } = useParams({ strict: false }) + const { workspaceSlug } = useParams({ strict: false }) - const { data: guilds } = useQuery({ - queryKey: ["guilds"], + const { data: workspaces } = useQuery({ + queryKey: ["workspaces"], queryFn: async () => { const res = await authClient.organization.list() return res.data @@ -83,13 +83,13 @@ export function GuildBar() {
- {/* Guild icons */} + {/* Workspace icons */}
- {guilds && guilds.length > 0 ? ( - guilds.map((guild) => ( - 0 ? ( + workspaces.map((workspace) => ( + navigate({ - to: "/$guildSlug", - params: { guildSlug: guild.slug }, + to: "/$workspaceSlug", + params: { workspaceSlug: workspace.slug }, }) } /> @@ -128,7 +128,7 @@ export function GuildBar() { {/* Separator */}
- {/* Add guild button */} + {/* Add workspace button */}
- diff --git a/apps/web/src/components/guild/guild-settings-dialog.tsx b/apps/web/src/components/workspace/workspace-settings-dialog.tsx similarity index 84% rename from apps/web/src/components/guild/guild-settings-dialog.tsx rename to apps/web/src/components/workspace/workspace-settings-dialog.tsx index b3c720a..a002995 100644 --- a/apps/web/src/components/guild/guild-settings-dialog.tsx +++ b/apps/web/src/components/workspace/workspace-settings-dialog.tsx @@ -15,7 +15,7 @@ import { useCallback, useEffect, useRef, useState } from "react" import { toast } from "sonner" import { apiClient } from "@/lib/api-client" -const MAX_GUILD_ICON_BYTES = 2 * 1024 * 1024 +const MAX_WORKSPACE_ICON_BYTES = 2 * 1024 * 1024 const ACCEPTED_IMAGE_TYPES = [ "image/jpeg", "image/png", @@ -27,30 +27,30 @@ function validateIconFile(file: File): string | null { if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { return "Only JPEG, PNG, WebP, and SVG images are allowed" } - if (file.size > MAX_GUILD_ICON_BYTES) { + if (file.size > MAX_WORKSPACE_ICON_BYTES) { return "Icon must be under 2 MB" } return null } -type Guild = { +type Workspace = { id: string name: string slug: string logo?: string | null } -export function GuildSettingsDialog({ +export function WorkspaceSettingsDialog({ open, onOpenChange, - guild, + workspace, }: { open: boolean onOpenChange: (open: boolean) => void - guild: Guild + workspace: Workspace }) { const queryClient = useQueryClient() - const [name, setName] = useState(guild.name) + const [name, setName] = useState(workspace.name) const [iconPreview, setIconPreview] = useState(null) const [iconFile, setIconFile] = useState(null) const [isSaving, setIsSaving] = useState(false) @@ -60,14 +60,14 @@ export function GuildSettingsDialog({ const dragCountRef = useRef(0) useEffect(() => { - setName(guild.name) + setName(workspace.name) setIconFile(null) if (iconPreviewRef.current) { URL.revokeObjectURL(iconPreviewRef.current) iconPreviewRef.current = null } setIconPreview(null) - }, [guild, open]) + }, [workspace, open]) useEffect(() => { return () => { @@ -129,9 +129,9 @@ export function GuildSettingsDialog({ const uploadIcon = useCallback( async (file: File): Promise => { - const res = await apiClient.v1.uploads["guild-icon"].presign.$post({ + const res = await apiClient.v1.uploads["workspace-icon"].presign.$post({ json: { - guildId: guild.id, + workspaceId: workspace.id, filename: file.name, contentType: file.type, size: file.size, @@ -152,7 +152,7 @@ export function GuildSettingsDialog({ return fileUrl }, - [guild.id] + [workspace.id] ) const handleSave = useCallback(async () => { @@ -163,15 +163,15 @@ export function GuildSettingsDialog({ logoUrl = await uploadIcon(iconFile) } - const res = await apiClient.v1.guilds[":guildSlug"].$patch({ - param: { guildSlug: guild.slug }, + const res = await apiClient.v1.workspaces[":workspaceSlug"].$patch({ + param: { workspaceSlug: workspace.slug }, json: { - ...(name.trim() !== guild.name ? { name: name.trim() } : {}), + ...(name.trim() !== workspace.name ? { name: name.trim() } : {}), ...(logoUrl !== undefined ? { logo: logoUrl } : {}), }, }) - if (!res.ok) throw new Error("Failed to update guild") + if (!res.ok) throw new Error("Failed to update workspace") setIconFile(null) if (iconPreview) { @@ -181,19 +181,19 @@ export function GuildSettingsDialog({ } await Promise.all([ - queryClient.invalidateQueries({ queryKey: ["guilds"] }), - queryClient.invalidateQueries({ queryKey: ["active-guild"] }), + queryClient.invalidateQueries({ queryKey: ["workspaces"] }), + queryClient.invalidateQueries({ queryKey: ["active-workspace"] }), ]) - toast.success("Guild updated") + toast.success("Workspace updated") onOpenChange(false) } catch { - toast.error("Failed to update guild") + toast.error("Failed to update workspace") } finally { setIsSaving(false) } }, [ - guild, + workspace, name, iconFile, iconPreview, @@ -202,11 +202,11 @@ export function GuildSettingsDialog({ onOpenChange, ]) - const hasChanges = name.trim() !== guild.name || iconFile !== null + const hasChanges = name.trim() !== workspace.name || iconFile !== null const isValid = name.trim().length > 0 - const displayIcon = iconPreview ?? guild.logo - const initials = guild.name + const displayIcon = iconPreview ?? workspace.logo + const initials = workspace.name .split(" ") .map((w) => w[0]) .join("") @@ -216,7 +216,7 @@ export function GuildSettingsDialog({ - Guild Settings + Workspace Settings
@@ -238,7 +238,7 @@ export function GuildSettingsDialog({ > {displayIcon && ( - + )} {initials} @@ -272,13 +272,13 @@ export function GuildSettingsDialog({
- + setName(e.target.value)} maxLength={100} - placeholder="My Awesome Guild" + placeholder="My Awesome Workspace" />
diff --git a/apps/web/src/hooks/use-browser-notifications.ts b/apps/web/src/hooks/use-browser-notifications.ts index 71ce37b..04d8e38 100644 --- a/apps/web/src/hooks/use-browser-notifications.ts +++ b/apps/web/src/hooks/use-browser-notifications.ts @@ -38,7 +38,10 @@ export function useBrowserNotifications() { if (settings.desktopNotifications === "nothing") return // For DM mentions, check dmNotifications setting - if (payload.guildId === null && settings.dmNotifications === "nothing") { + if ( + payload.workspaceId === null && + settings.dmNotifications === "nothing" + ) { return } @@ -56,7 +59,10 @@ export function useBrowserNotifications() { if (settings.desktopNotifications !== "all_messages") return // For DMs, check dmNotifications setting - if (payload.guildId === null && settings.dmNotifications === "nothing") { + if ( + payload.workspaceId === null && + settings.dmNotifications === "nothing" + ) { return } diff --git a/apps/web/src/hooks/use-message-pinning.ts b/apps/web/src/hooks/use-message-pinning.ts index e21acde..68ab9ea 100644 --- a/apps/web/src/hooks/use-message-pinning.ts +++ b/apps/web/src/hooks/use-message-pinning.ts @@ -13,14 +13,14 @@ interface UseMessagePinningOptions { socket: AppSocket | null queryClient: QueryClient channelId: string - guildSlug: string + workspaceSlug: string } export function useMessagePinning({ socket, queryClient, channelId, - guildSlug, + workspaceSlug, }: UseMessagePinningOptions) { const updatePinInCache = useCallback( (messageId: string, pinned: boolean) => { @@ -59,10 +59,10 @@ export function useMessagePinning({ updatePinInCache(messageId, !currentlyPinned) try { - const res = await apiClient.v1.guilds[":guildSlug"].channels[ + const res = await apiClient.v1.workspaces[":workspaceSlug"].channels[ ":channelId" ].messages[":messageId"].pin.$patch({ - param: { guildSlug, channelId, messageId }, + param: { workspaceSlug, channelId, messageId }, }) if (!res.ok) { @@ -72,7 +72,7 @@ export function useMessagePinning({ updatePinInCache(messageId, currentlyPinned) } }, - [guildSlug, channelId, updatePinInCache] + [workspaceSlug, channelId, updatePinInCache] ) return { handleTogglePin } diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index 53ed969..8615ff0 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -2,7 +2,7 @@ import type { Client, InferResponseType } from "@repo/api-client" // ── Channels ────────────────────────────────────────── -type ChannelsClient = Client["v1"]["guilds"][":guildSlug"]["channels"] +type ChannelsClient = Client["v1"]["workspaces"][":workspaceSlug"]["channels"] export type ListChannelsResponse = InferResponseType< ChannelsClient["$get"], @@ -12,14 +12,14 @@ export type Channel = ListChannelsResponse["uncategorized"][number] export type CategoryWithChannels = ListChannelsResponse["categories"][number] type ChannelClient = - Client["v1"]["guilds"][":guildSlug"]["channels"][":channelId"] + Client["v1"]["workspaces"][":workspaceSlug"]["channels"][":channelId"] export type GetChannelResponse = InferResponseType // ── Messages ────────────────────────────────────────── type MessagesClient = - Client["v1"]["guilds"][":guildSlug"]["channels"][":channelId"]["messages"] + Client["v1"]["workspaces"][":workspaceSlug"]["channels"][":channelId"]["messages"] export type ListMessagesResponse = InferResponseType< MessagesClient["$get"], @@ -47,15 +47,16 @@ export type ListDMMessagesResponse = InferResponseType< 200 > -// ── Guild Invites ────────────────────────────────────────── +// ── Workspace Invites ────────────────────────────────────────── -type GuildInvitesClient = Client["v1"]["guilds"][":guildSlug"]["invites"] +type WorkspaceInvitesClient = + Client["v1"]["workspaces"][":workspaceSlug"]["invites"] -export type ListGuildInvitesResponse = InferResponseType< - GuildInvitesClient["$get"], +export type ListWorkspaceInvitesResponse = InferResponseType< + WorkspaceInvitesClient["$get"], 200 > -export type GuildInvite = ListGuildInvitesResponse["invites"][number] +export type WorkspaceInvite = ListWorkspaceInvitesResponse["invites"][number] type InvitePreviewClient = Client["v1"]["invites"][":code"] @@ -74,12 +75,14 @@ export type GetUserProfileResponse = InferResponseType< > export type UserProfile = GetUserProfileResponse["user"] -// ── Guild Members ────────────────────────────────────────── +// ── Workspace Members ────────────────────────────────────────── -type GuildMembersClient = Client["v1"]["guilds"][":guildSlug"]["members"] +type WorkspaceMembersClient = + Client["v1"]["workspaces"][":workspaceSlug"]["members"] -export type ListGuildMembersResponse = InferResponseType< - GuildMembersClient["$get"], +export type ListWorkspaceMembersResponse = InferResponseType< + WorkspaceMembersClient["$get"], 200 > -export type GuildMemberPresence = ListGuildMembersResponse["members"][number] +export type WorkspaceMemberPresence = + ListWorkspaceMembersResponse["members"][number] diff --git a/apps/web/src/lib/permissions.ts b/apps/web/src/lib/permissions.ts index 2c08852..b671b78 100644 --- a/apps/web/src/lib/permissions.ts +++ b/apps/web/src/lib/permissions.ts @@ -1,28 +1,28 @@ import { - canManageGuildAuthority, - formatGuildRole as formatGuildRoleHelper, - type GuildAuthority, - guildAuthorityHasPermissions, - isGuildRole, + canManageWorkspaceAuthority, + formatWorkspaceRole as formatWorkspaceRoleHelper, + isWorkspaceRole, type PermissionRequest, + type WorkspaceAuthority, + workspaceAuthorityHasPermissions, } from "@repo/auth/permissions" function toAuthority( member: { userId: string; role: string }, - guild: { ownerId: string } -): GuildAuthority | null { - if (!isGuildRole(member.role)) return null + workspace: { ownerId: string } +): WorkspaceAuthority | null { + if (!isWorkspaceRole(member.role)) return null return { role: member.role, - isOwner: guild.ownerId === member.userId, + isOwner: workspace.ownerId === member.userId, } } export function isOwner( member: { userId: string }, - guild: { ownerId: string } + workspace: { ownerId: string } ): boolean { - return member.userId === guild.ownerId + return member.userId === workspace.ownerId } export function isAdmin(member: { role: string }): boolean { @@ -31,69 +31,69 @@ export function isAdmin(member: { role: string }): boolean { export function isAdminOrOwner( member: { userId: string; role: string }, - guild: { ownerId: string } + workspace: { ownerId: string } ): boolean { - return isOwner(member, guild) || isAdmin(member) + return isOwner(member, workspace) || isAdmin(member) } -export function formatGuildRole(role: string): string { - if (isGuildRole(role)) return formatGuildRoleHelper(role) +export function formatWorkspaceRole(role: string): string { + if (isWorkspaceRole(role)) return formatWorkspaceRoleHelper(role) return "Member" } function hasPermissions( member: { userId: string; role: string }, - guild: { ownerId: string }, + workspace: { ownerId: string }, requestedPermissions: PermissionRequest ): boolean { - const authority = toAuthority(member, guild) + const authority = toAuthority(member, workspace) if (!authority) return false - return guildAuthorityHasPermissions(authority, requestedPermissions) + return workspaceAuthorityHasPermissions(authority, requestedPermissions) } export function canManageChannels( member: { userId: string; role: string }, - guild: { ownerId: string } + workspace: { ownerId: string } ): boolean { - return hasPermissions(member, guild, { channel: ["update"] }) + return hasPermissions(member, workspace, { channel: ["update"] }) } export function canCreateChannels( member: { userId: string; role: string }, - guild: { ownerId: string } + workspace: { ownerId: string } ): boolean { - return hasPermissions(member, guild, { channel: ["create"] }) + return hasPermissions(member, workspace, { channel: ["create"] }) } export function canDeleteChannels( member: { userId: string; role: string }, - guild: { ownerId: string } + workspace: { ownerId: string } ): boolean { - return hasPermissions(member, guild, { channel: ["delete"] }) + return hasPermissions(member, workspace, { channel: ["delete"] }) } export function canPinMessages( member: { userId: string; role: string }, - guild: { ownerId: string } + workspace: { ownerId: string } ): boolean { - return hasPermissions(member, guild, { message: ["pin"] }) + return hasPermissions(member, workspace, { message: ["pin"] }) } -export function canKickGuildMembers( +export function canKickWorkspaceMembers( member: { userId: string; role: string }, - guild: { ownerId: string } + workspace: { ownerId: string } ): boolean { - return hasPermissions(member, guild, { guildMember: ["kick"] }) + return hasPermissions(member, workspace, { workspaceMember: ["kick"] }) } -export function canManageGuildMember( +export function canManageWorkspaceMember( actor: { userId: string; role: string }, target: { userId: string; role: string }, - guild: { ownerId: string } + workspace: { ownerId: string } ): boolean { if (actor.userId === target.userId) return false - const actorAuthority = toAuthority(actor, guild) - const targetAuthority = toAuthority(target, guild) + const actorAuthority = toAuthority(actor, workspace) + const targetAuthority = toAuthority(target, workspace) if (!actorAuthority || !targetAuthority) return false - return canManageGuildAuthority(actorAuthority, targetAuthority) + return canManageWorkspaceAuthority(actorAuthority, targetAuthority) } diff --git a/apps/web/src/routes/_authenticated.tsx b/apps/web/src/routes/_authenticated.tsx index 593218f..d514fd3 100644 --- a/apps/web/src/routes/_authenticated.tsx +++ b/apps/web/src/routes/_authenticated.tsx @@ -71,8 +71,8 @@ function AuthenticatedLayout() { } }, [location.pathname]) - const { data: guilds } = useQuery({ - queryKey: ["guilds"], + const { data: workspaces } = useQuery({ + queryKey: ["workspaces"], queryFn: async () => { const res = await authClient.organization.list() return res.data @@ -92,14 +92,14 @@ function AuthenticatedLayout() { return null } - // Only show onboarding if explicitly not completed AND no existing guilds + // Only show onboarding if explicitly not completed AND no existing workspaces // (guards against existing users whose flag defaulted to false) const isInviteRoute = location.pathname.startsWith("/invite/") const showOnboarding = !isInviteRoute && session.user.onboardingCompleted === false && - guilds !== undefined && - guilds?.length === 0 + workspaces !== undefined && + workspaces?.length === 0 return ( diff --git a/apps/web/src/routes/_authenticated/$guildSlug.tsx b/apps/web/src/routes/_authenticated/$workspaceSlug.tsx similarity index 57% rename from apps/web/src/routes/_authenticated/$guildSlug.tsx rename to apps/web/src/routes/_authenticated/$workspaceSlug.tsx index 47ff1f8..01a488f 100644 --- a/apps/web/src/routes/_authenticated/$guildSlug.tsx +++ b/apps/web/src/routes/_authenticated/$workspaceSlug.tsx @@ -4,35 +4,35 @@ import { createFileRoute, Outlet } from "@tanstack/react-router" import { useEffect, useMemo, useRef, useState } from "react" import { toast } from "sonner" -export const Route = createFileRoute("/_authenticated/$guildSlug")({ - component: GuildLayout, +export const Route = createFileRoute("/_authenticated/$workspaceSlug")({ + component: WorkspaceLayout, }) -function GuildLayout() { - const { guildSlug } = Route.useParams() - const [isSwitchingGuild, setIsSwitchingGuild] = useState(false) +function WorkspaceLayout() { + const { workspaceSlug } = Route.useParams() + const [isSwitchingWorkspace, setIsSwitchingWorkspace] = useState(false) const [switchError, setSwitchError] = useState(null) - const latestDesiredGuildRef = useRef(null) + const latestDesiredWorkspaceRef = useRef(null) const switchRequestRef = useRef(0) - const { data: guilds, isPending: guildsLoading } = useQuery({ - queryKey: ["guilds"], + const { data: workspaces, isPending: workspacesLoading } = useQuery({ + queryKey: ["workspaces"], queryFn: async () => { const res = await authClient.organization.list() return res.data }, }) const { data: activeOrg } = useQuery({ - queryKey: ["active-guild"], + queryKey: ["active-workspace"], queryFn: async () => { const res = await authClient.organization.getFullOrganization() return res.data }, }) - const guild = useMemo( - () => guilds?.find((g) => g.slug === guildSlug), - [guilds, guildSlug] + const workspace = useMemo( + () => workspaces?.find((g) => g.slug === workspaceSlug), + [workspaces, workspaceSlug] ) const queryClient = useQueryClient() @@ -40,35 +40,35 @@ function GuildLayout() { useEffect(() => { let cancelled = false - if (!guild) { - latestDesiredGuildRef.current = null + if (!workspace) { + latestDesiredWorkspaceRef.current = null setSwitchError(null) - setIsSwitchingGuild(false) + setIsSwitchingWorkspace(false) return } - const desiredGuildId = guild.id - latestDesiredGuildRef.current = desiredGuildId + const desiredWorkspaceId = workspace.id + latestDesiredWorkspaceRef.current = desiredWorkspaceId - if (activeOrg?.id === desiredGuildId) { + if (activeOrg?.id === desiredWorkspaceId) { setSwitchError(null) - setIsSwitchingGuild(false) + setIsSwitchingWorkspace(false) return } const requestId = ++switchRequestRef.current setSwitchError(null) - setIsSwitchingGuild(true) + setIsSwitchingWorkspace(true) void (async () => { try { await authClient.organization.setActive({ - organizationId: desiredGuildId, + organizationId: desiredWorkspaceId, }) if ( cancelled || - latestDesiredGuildRef.current !== desiredGuildId || + latestDesiredWorkspaceRef.current !== desiredWorkspaceId || switchRequestRef.current !== requestId ) { return @@ -76,32 +76,35 @@ function GuildLayout() { await Promise.all([ queryClient.invalidateQueries({ - queryKey: ["active-guild"], + queryKey: ["active-workspace"], }), queryClient.invalidateQueries({ - queryKey: ["active-guild-member", guildSlug], + queryKey: ["active-workspace-member", workspaceSlug], }), ]) } catch (error) { if ( cancelled || - latestDesiredGuildRef.current !== desiredGuildId || + latestDesiredWorkspaceRef.current !== desiredWorkspaceId || switchRequestRef.current !== requestId ) { return } - console.error("[guild-layout] Failed to switch active guild", error) - const message = "Failed to switch guild. Please try again." + console.error( + "[workspace-layout] Failed to switch active workspace", + error + ) + const message = "Failed to switch workspace. Please try again." setSwitchError(message) toast.error(message) } finally { if ( !cancelled && - latestDesiredGuildRef.current === desiredGuildId && + latestDesiredWorkspaceRef.current === desiredWorkspaceId && switchRequestRef.current === requestId ) { - setIsSwitchingGuild(false) + setIsSwitchingWorkspace(false) } } })() @@ -109,9 +112,9 @@ function GuildLayout() { return () => { cancelled = true } - }, [guild, activeOrg?.id, guildSlug, queryClient]) + }, [workspace, activeOrg?.id, workspaceSlug, queryClient]) - if (guildsLoading) { + if (workspacesLoading) { return (
Loading... @@ -119,15 +122,17 @@ function GuildLayout() { ) } - if (!guild) { + if (!workspace) { return (
- Guild not found + + Workspace not found +
) } - if (isSwitchingGuild) { + if (isSwitchingWorkspace) { return (
Loading... @@ -135,7 +140,7 @@ function GuildLayout() { ) } - if (activeOrg?.id !== guild.id) { + if (activeOrg?.id !== workspace.id) { return (
diff --git a/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx b/apps/web/src/routes/_authenticated/$workspaceSlug/$channelId.tsx similarity index 84% rename from apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx rename to apps/web/src/routes/_authenticated/$workspaceSlug/$channelId.tsx index 28f6214..cec5900 100644 --- a/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx +++ b/apps/web/src/routes/_authenticated/$workspaceSlug/$channelId.tsx @@ -32,7 +32,9 @@ type ChannelSearchParams = { msgId?: string } -export const Route = createFileRoute("/_authenticated/$guildSlug/$channelId")({ +export const Route = createFileRoute( + "/_authenticated/$workspaceSlug/$channelId" +)({ component: ChannelView, validateSearch: (search: Record): ChannelSearchParams => ({ msgId: typeof search.msgId === "string" ? search.msgId : undefined, @@ -40,7 +42,7 @@ export const Route = createFileRoute("/_authenticated/$guildSlug/$channelId")({ }) function ChannelView() { - const { guildSlug, channelId } = Route.useParams() + const { workspaceSlug, channelId } = Route.useParams() const { msgId } = Route.useSearch() const navigate = Route.useNavigate() const socket = useSocket() @@ -53,36 +55,36 @@ function ChannelView() { const currentUserId = session?.user.id useEffect(() => { - if (!guildSlug || !channelId) return + if (!workspaceSlug || !channelId) return try { if (typeof window !== "undefined") { - localStorage.setItem(`last-channel:${guildSlug}`, channelId) + localStorage.setItem(`last-channel:${workspaceSlug}`, channelId) } } catch { // localStorage may be unavailable in restricted environments } - }, [guildSlug, channelId]) + }, [workspaceSlug, channelId]) useEffect(() => { if (isMobile === false) { setView({ - type: "guild-members", - guildSlug, + type: "workspace-members", + workspaceSlug, channelId, }) } return () => { clearView() } - }, [setView, clearView, guildSlug, channelId, isMobile]) + }, [setView, clearView, workspaceSlug, channelId, isMobile]) const { data, isPending, isError, error } = useQuery({ - queryKey: ["channel", guildSlug, channelId], + queryKey: ["channel", workspaceSlug, channelId], queryFn: async () => { - const res = await apiClient.v1.guilds[":guildSlug"].channels[ + const res = await apiClient.v1.workspaces[":workspaceSlug"].channels[ ":channelId" ].$get({ - param: { guildSlug, channelId }, + param: { workspaceSlug, channelId }, }) if (!res.ok) throw new Error("Failed to fetch channel") return res.json() @@ -98,10 +100,10 @@ function ChannelView() { } = useInfiniteQuery({ queryKey: ["messages", channelId], queryFn: async ({ pageParam }) => { - const res = await apiClient.v1.guilds[":guildSlug"].channels[ + const res = await apiClient.v1.workspaces[":workspaceSlug"].channels[ ":channelId" ].messages.$get({ - param: { guildSlug, channelId }, + param: { workspaceSlug, channelId }, query: { page: String(pageParam), perPage: "50" }, }) if (!res.ok) throw new Error("Failed to fetch messages") @@ -129,13 +131,13 @@ function ChannelView() { return () => clearTimeout(timer) }, [msgId, messagesLoading, messages, navigate]) - const { data: guildMembersData } = useQuery({ - queryKey: ["guild-members", guildSlug], + const { data: workspaceMembersData } = useQuery({ + queryKey: ["workspace-members", workspaceSlug], queryFn: async () => { - const res = await apiClient.v1.guilds[":guildSlug"].members.$get({ - param: { guildSlug }, + const res = await apiClient.v1.workspaces[":workspaceSlug"].members.$get({ + param: { workspaceSlug }, }) - if (!res.ok) throw new Error("Failed to fetch guild members") + if (!res.ok) throw new Error("Failed to fetch workspace members") return res.json() }, }) @@ -179,7 +181,7 @@ function ChannelView() { }) const { data: activeMember } = useQuery({ - queryKey: ["active-guild-member", guildSlug], + queryKey: ["active-workspace-member", workspaceSlug], queryFn: async () => { const res = await authClient.organization.getActiveMember() if (res.error) return null @@ -190,34 +192,34 @@ function ChannelView() { const activeMemberCtx = typeof activeMember?.role === "string" && typeof activeMember.userId === "string" && - guildMembersData?.ownerId + workspaceMembersData?.ownerId ? { actor: { userId: activeMember.userId, role: activeMember.role }, - guild: { ownerId: guildMembersData.ownerId }, + workspace: { ownerId: workspaceMembersData.ownerId }, } : null const canPin = activeMemberCtx - ? canPinMessages(activeMemberCtx.actor, activeMemberCtx.guild) + ? canPinMessages(activeMemberCtx.actor, activeMemberCtx.workspace) : false const { handleTogglePin } = useMessagePinning({ socket, queryClient, channelId, - guildSlug, + workspaceSlug, }) const togglePinnedMessages = useCallback(() => { if (view?.type === "pinned-messages" && !isCollapsed) { - setView({ type: "guild-members", guildSlug, channelId }) + setView({ type: "workspace-members", workspaceSlug, channelId }) } else { - setView({ type: "pinned-messages", guildSlug, channelId }) + setView({ type: "pinned-messages", workspaceSlug, channelId }) if (isCollapsed) { toggleCollapsed() } } - }, [view, setView, guildSlug, channelId, isCollapsed, toggleCollapsed]) + }, [view, setView, workspaceSlug, channelId, isCollapsed, toggleCollapsed]) const { replyingTo, setReplyingTo, clearReply } = useReplyState() @@ -257,7 +259,7 @@ function ChannelView() { name: "everyone", search: "everyone all members", }, - ...(guildMembersData?.members.map((member) => ({ + ...(workspaceMembersData?.members.map((member) => ({ id: member.userId, label: member.displayUsername ?? member.username ?? member.name, name: member.name, @@ -266,7 +268,7 @@ function ChannelView() { image: member.image, })) ?? []), ], - [guildMembersData?.members] + [workspaceMembersData?.members] ) if (isPending) { diff --git a/apps/web/src/routes/_authenticated/$guildSlug/index.tsx b/apps/web/src/routes/_authenticated/$workspaceSlug/index.tsx similarity index 62% rename from apps/web/src/routes/_authenticated/$guildSlug/index.tsx rename to apps/web/src/routes/_authenticated/$workspaceSlug/index.tsx index c3d1c1e..d53db4e 100644 --- a/apps/web/src/routes/_authenticated/$guildSlug/index.tsx +++ b/apps/web/src/routes/_authenticated/$workspaceSlug/index.tsx @@ -1,31 +1,31 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router" import { useEffect } from "react" -export const Route = createFileRoute("/_authenticated/$guildSlug/")({ - component: GuildHome, +export const Route = createFileRoute("/_authenticated/$workspaceSlug/")({ + component: WorkspaceHome, }) -function GuildHome() { - const { guildSlug } = Route.useParams() +function WorkspaceHome() { + const { workspaceSlug } = Route.useParams() const navigate = useNavigate() useEffect(() => { let lastChannelId: string | null = null try { if (typeof window !== "undefined") { - lastChannelId = localStorage.getItem(`last-channel:${guildSlug}`) + lastChannelId = localStorage.getItem(`last-channel:${workspaceSlug}`) } } catch { // localStorage may be unavailable in restricted environments } if (lastChannelId) { void navigate({ - to: "/$guildSlug/$channelId", - params: { guildSlug, channelId: lastChannelId }, + to: "/$workspaceSlug/$channelId", + params: { workspaceSlug, channelId: lastChannelId }, replace: true, }) } - }, [guildSlug, navigate]) + }, [workspaceSlug, navigate]) return (
diff --git a/apps/web/src/routes/_authenticated/invite/$code.tsx b/apps/web/src/routes/_authenticated/invite/$code.tsx index eb88d45..5beede1 100644 --- a/apps/web/src/routes/_authenticated/invite/$code.tsx +++ b/apps/web/src/routes/_authenticated/invite/$code.tsx @@ -40,7 +40,7 @@ function InvitePage() { }) if (!res.ok) { const body = await res.text() - let message = "Failed to join guild" + let message = "Failed to join workspace" try { const parsed = JSON.parse(body) as { message?: string } if (typeof parsed.message === "string") message = parsed.message @@ -53,15 +53,20 @@ function InvitePage() { }, onSuccess: (data) => { if (socket?.connected) { - socket.emit("guild:member:joined", { guildId: data.guild.id }) + socket.emit("workspace:member:joined", { + workspaceId: data.workspace.id, + }) } - queryClient.invalidateQueries({ queryKey: ["guilds"] }) - toast.success(`Joined ${data.guild.name}!`) - navigate({ to: "/$guildSlug", params: { guildSlug: data.guild.slug } }) + queryClient.invalidateQueries({ queryKey: ["workspaces"] }) + toast.success(`Joined ${data.workspace.name}!`) + navigate({ + to: "/$workspaceSlug", + params: { workspaceSlug: data.workspace.slug }, + }) }, onError: (error) => { toast.error( - error instanceof Error ? error.message : "Failed to join guild" + error instanceof Error ? error.message : "Failed to join workspace" ) }, }) @@ -137,24 +142,24 @@ function InvitePage() {
) : invite ? (
- {invite.guild.logo ? ( + {invite.workspace.logo ? ( {invite.guild.name} ) : (
- {invite.guild.name.charAt(0).toUpperCase()} + {invite.workspace.name.charAt(0).toUpperCase()}
)}
-

{invite.guild.name}

+

{invite.workspace.name}

- {invite.guild.memberCount}{" "} - {invite.guild.memberCount === 1 ? "member" : "members"} + {invite.workspace.memberCount}{" "} + {invite.workspace.memberCount === 1 ? "member" : "members"}

@@ -170,12 +175,12 @@ function InvitePage() { className="w-full" onClick={() => navigate({ - to: "/$guildSlug", - params: { guildSlug: invite.guild.slug }, + to: "/$workspaceSlug", + params: { workspaceSlug: invite.workspace.slug }, }) } > - Already a Member — Go to Guild + Already a Member — Go to Workspace ) : (