From 805ef8cf435d3d706bacb2020a05189ceaa69252 Mon Sep 17 00:00:00 2001 From: LovelessCodes Date: Fri, 6 Jun 2025 13:55:00 +0200 Subject: [PATCH] refactor: update board slug prefix to use dynamic base URL from env (#38) * refactor: update board slug prefix to use dynamic base URL from env * feat: add board slug availability check with real-time validation UI * refactor: consolidate board slug lookup by removing boardSlug repo and updating environment variables * feat: allow board slug reuse across different workspaces * chore: update environment settings and remove unused imports * refactor: update board slug availability check to use boardPublicId instead of workspaceSlug * refactor: simplify board slug availability check with direct SQL query --- .../board/components/UpdateBoardSlugForm.tsx | 42 +++++++++++++++++-- packages/api/src/routers/board.ts | 36 ++++++++++++++++ packages/db/src/repository/board.repo.ts | 33 ++++++++++++++- 3 files changed, 106 insertions(+), 5 deletions(-) diff --git a/apps/web/src/views/board/components/UpdateBoardSlugForm.tsx b/apps/web/src/views/board/components/UpdateBoardSlugForm.tsx index 23337833..ab584476 100644 --- a/apps/web/src/views/board/components/UpdateBoardSlugForm.tsx +++ b/apps/web/src/views/board/components/UpdateBoardSlugForm.tsx @@ -1,11 +1,13 @@ import { zodResolver } from "@hookform/resolvers/zod"; +import { env } from "next-runtime-env"; import { useEffect } from "react"; import { useForm } from "react-hook-form"; -import { HiXMark } from "react-icons/hi2"; +import { HiCheck, HiXMark } from "react-icons/hi2"; import { z } from "zod"; import Button from "~/components/Button"; import Input from "~/components/Input"; +import { useDebounce } from "~/hooks/useDebounce"; import { useModal } from "~/providers/modal"; import { usePopup } from "~/providers/popup"; import { api } from "~/utils/api"; @@ -49,6 +51,7 @@ export function UpdateBoardSlugForm({ register, handleSubmit, formState: { isDirty, errors }, + watch, } = useForm({ resolver: zodResolver(schema), values: { @@ -57,6 +60,10 @@ export function UpdateBoardSlugForm({ mode: "onChange", }); + const slug = watch("slug"); + + const [debouncedSlug] = useDebounce(slug, 500); + const updateBoardSlug = api.board.update.useMutation({ onError: () => { showPopup({ @@ -71,6 +78,20 @@ export function UpdateBoardSlugForm({ }, }); + const checkBoardSlugAvailability = + api.board.checkSlugAvailability.useQuery( + { + boardSlug: debouncedSlug, + boardPublicId, + }, + { + enabled: + !!debouncedSlug && debouncedSlug !== boardSlug && !errors.slug, + }, + ); + + const isBoardSlugAvailable = checkBoardSlugAvailability.data; + useEffect(() => { const nameElement: HTMLElement | null = document.querySelector("#board-slug"); @@ -78,6 +99,9 @@ export function UpdateBoardSlugForm({ }, []); const onSubmit = (data: FormValues) => { + if (!isBoardSlugAvailable) return; + if (isBoardSlugAvailable?.isReserved) return; + updateBoardSlug.mutate({ slug: data.slug, boardPublicId, @@ -106,14 +130,23 @@ export function UpdateBoardSlugForm({ { if (e.key === "Enter") { e.preventDefault(); await handleSubmit(onSubmit)(); } }} + iconRight={ + !!errors.slug?.message || isBoardSlugAvailable?.isReserved ? ( + + ) : ( + + ) + } />
@@ -124,7 +157,8 @@ export function UpdateBoardSlugForm({ disabled={ !isDirty || updateBoardSlug.isPending || - errors.slug?.message !== undefined + errors.slug?.message !== undefined || + isBoardSlugAvailable?.isReserved } > Update diff --git a/packages/api/src/routers/board.ts b/packages/api/src/routers/board.ts index 342fab9f..ae1202a5 100644 --- a/packages/api/src/routers/board.ts +++ b/packages/api/src/routers/board.ts @@ -352,4 +352,40 @@ export const boardRouter = createTRPCRouter({ return { success: true }; }), + checkSlugAvailability: publicProcedure + .meta({ + openapi: { + summary: "Check if a board slug is available", + method: "GET", + path: "/boards/{boardPublicId}/check-slug-availability", + description: "Checks if a board slug is available", + tags: ["Boards"], + protect: true, + }, + }) + .input( + z.object({ + boardSlug: z + .string() + .min(3) + .max(24) + .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/), + boardPublicId: z.string().min(12), + }), + ) + .output( + z.object({ + isReserved: z.boolean(), + }), + ) + .query(async ({ ctx, input }) => { + const isBoardSlugAvailable = await boardRepo.isBoardSlugAvailable( + ctx.db, + input.boardSlug, + input.boardPublicId, + ); + return { + isReserved: !isBoardSlugAvailable, + }; + }), }); diff --git a/packages/db/src/repository/board.repo.ts b/packages/db/src/repository/board.repo.ts index 9eb1d7b2..f228ce2e 100644 --- a/packages/db/src/repository/board.repo.ts +++ b/packages/db/src/repository/board.repo.ts @@ -1,4 +1,4 @@ -import { and, asc, desc, eq, inArray, isNull, or } from "drizzle-orm"; +import { and, asc, desc, eq, exists, inArray, isNull, or, sql } from "drizzle-orm"; import type { dbClient } from "@kan/db/client"; import type { BoardVisibilityStatus } from "@kan/db/schema"; @@ -477,3 +477,34 @@ export const getWorkspaceAndBoardIdByBoardPublicId = async ( return result; }; + +export const isBoardSlugAvailable = async ( + db: dbClient, + boardSlug: string, + boardPublicId: string, +) => { + const result = await db + .select({ id: boards.id }) + .from(boards) + .where( + and( + eq(boards.publicId, boardPublicId), + exists( + db + .select({ id: boards.id }) + .from(boards) + .where( + and( + eq(boards.slug, boardSlug), + eq(boards.workspaceId, sql`${boards.workspaceId}`), // Reference outer query's workspaceId + isNull(boards.deletedAt), + ), + ) + .limit(1), + ), + ), + ) + .limit(1); + + return result.length === 0; +};