diff --git a/backend/package-lock.json b/backend/package-lock.json index 1101ac4e..9f1e0fbc 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -22,7 +22,8 @@ "passport-github": "^1.1.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.1.13", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "slugify": "^1.6.6" }, "devDependencies": { "@nestjs/cli": "^10.0.0", @@ -1652,9 +1653,9 @@ "dev": true }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.21", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.21.tgz", - "integrity": "sha512-SRfKmRe1KvYnxjEMtxEr+J4HIeMX5YBg/qhRHpxEIGjhX1rshcHlnFUE9K0GazhVKWM7B+nARSkV8LuvJdJ5/g==", + "version": "0.3.22", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.22.tgz", + "integrity": "sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw==", "dev": true, "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -2086,9 +2087,9 @@ "dev": true }, "node_modules/@sinonjs/commons": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", - "integrity": "sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", "dev": true, "dependencies": { "type-detect": "4.0.8" @@ -3361,9 +3362,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001578", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001578.tgz", - "integrity": "sha512-J/jkFgsQ3NEl4w2lCoM9ZPxrD+FoBNJ7uJUpGVjIg/j0OwJosWM36EPDv+Yyi0V4twBk9pPmlFS+PLykgEvUmg==", + "version": "1.0.30001579", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001579.tgz", + "integrity": "sha512-u5AUVkixruKHJjw/pj9wISlcMpgFWzSrczLZbrqBSxukQixmg0SJ5sZTpvaFvxU0HoQKd4yoyAogyrAz9pzJnA==", "dev": true, "funding": [ { @@ -3818,12 +3819,6 @@ } } }, - "node_modules/debug/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, "node_modules/dedent": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", @@ -4004,9 +3999,9 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "node_modules/electron-to-chromium": { - "version": "1.4.637", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.637.tgz", - "integrity": "sha512-G7j3UCOukFtxVO1vWrPQUoDk3kL70mtvjc/DC/k2o7lE0wAdq+Vwp1ipagOow+BH0uVztFysLWbkM/RTIrbK3w==", + "version": "1.4.640", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.640.tgz", + "integrity": "sha512-z/6oZ/Muqk4BaE7P69bXhUhpJbUM9ZJeka43ZwxsDshKtePns4mhBlh8bU5+yrnOnz3fhG82XLzGUXazOmsWnA==", "dev": true }, "node_modules/emittery": { @@ -6684,9 +6679,9 @@ } }, "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/multer": { "version": "1.4.4-lts.1", @@ -7838,6 +7833,11 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", @@ -8001,6 +8001,14 @@ "node": ">=8" } }, + "node_modules/slugify": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.6.tgz", + "integrity": "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/source-map": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", diff --git a/backend/package.json b/backend/package.json index 506a2a41..11756834 100644 --- a/backend/package.json +++ b/backend/package.json @@ -34,7 +34,8 @@ "passport-github": "^1.1.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.1.13", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "slugify": "^1.6.6" }, "devDependencies": { "@nestjs/cli": "^10.0.0", diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index f00adbd7..216be253 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -38,6 +38,7 @@ model UserWorkspace { model Workspace { id String @id @default(auto()) @map("_id") @db.ObjectId title String + slug String createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") documentList Document[] @@ -50,6 +51,7 @@ model Document { id String @id @default(auto()) @map("_id") @db.ObjectId yorkieDocumentId String @map("yorkie_document_id") title String + slug String content String? createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/backend/src/documents/types/document-domain.type.ts b/backend/src/documents/types/document-domain.type.ts index 12157ff6..9758695b 100644 --- a/backend/src/documents/types/document-domain.type.ts +++ b/backend/src/documents/types/document-domain.type.ts @@ -7,6 +7,8 @@ export class DocumentDomain { yorkieDocumentId: string; @ApiProperty({ type: String, description: "Title of the document" }) title: string; + @ApiProperty({ type: String, description: "Slug of the document" }) + slug: string; @ApiProperty({ type: String, description: "Content of the document", required: false }) content?: string; @ApiProperty({ type: Date, description: "Created date of the document" }) diff --git a/backend/src/users/types/user-domain.type.ts b/backend/src/users/types/user-domain.type.ts index 31d3e3eb..57f4be2f 100644 --- a/backend/src/users/types/user-domain.type.ts +++ b/backend/src/users/types/user-domain.type.ts @@ -5,8 +5,8 @@ export class UserDomain { id: string; @ApiProperty({ type: String, description: "Nickname of user" }) nickname: string; - @ApiProperty({ type: String, description: "Last worksace ID of user" }) - lastWorkspaceId: string; + @ApiProperty({ type: String, description: "Last worksace slug of user" }) + lastWorkspaceSlug: string; @ApiProperty({ type: Date, description: "Created date of user" }) createdAt: Date; @ApiProperty({ type: Date, description: "Updated date of user" }) diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 70964b34..763e4b1f 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -3,6 +3,7 @@ import { User } from "@prisma/client"; import { PrismaService } from "src/db/prisma.service"; import { FindUserResponse } from "./types/find-user-response.type"; import { WorkspaceRoleConstants } from "src/utils/constants/auth-role"; +import slugify from "slugify"; @Injectable() export class UsersService { @@ -11,7 +12,11 @@ export class UsersService { async findOne(userId: string): Promise { const foundUserWorkspace = await this.prismaService.userWorkspace.findFirst({ select: { - workspaceId: true, + workspace: { + select: { + slug: true, + }, + }, }, where: { userId, @@ -35,7 +40,7 @@ export class UsersService { return { ...foundUser, - lastWorkspaceId: foundUserWorkspace.workspaceId, + lastWorkspaceSlug: foundUserWorkspace.workspace.slug, }; } @@ -63,9 +68,23 @@ export class UsersService { }, }); + const title = `${user.nickname}'s Workspace`; + let slug = slugify(title); + + const duplicatedWorkspaceList = await this.prismaService.workspace.findMany({ + where: { + slug, + }, + }); + + if (duplicatedWorkspaceList.length) { + slug += `-${duplicatedWorkspaceList.length + 1}`; + } + const workspace = await this.prismaService.workspace.create({ data: { - title: `${user.nickname}'s Workspace`, + title, + slug, }, }); diff --git a/backend/src/workspace-documents/workspace-documents.controller.ts b/backend/src/workspace-documents/workspace-documents.controller.ts index 0ea2f825..afcd8fea 100644 --- a/backend/src/workspace-documents/workspace-documents.controller.ts +++ b/backend/src/workspace-documents/workspace-documents.controller.ts @@ -36,7 +36,7 @@ import { CreateWorkspaceDocumentShareTokenDto } from "./dto/create-workspace-doc export class WorkspaceDocumentsController { constructor(private workspaceDocumentsService: WorkspaceDocumentsService) {} - @Get(":document_id") + @Get(":document_slug") @ApiOperation({ summary: "Retrieve a Document in the Workspace", description: "If the user has the access permissions, return a document.", @@ -50,9 +50,9 @@ export class WorkspaceDocumentsController { async findOne( @Req() req: AuthroizedRequest, @Param("workspace_id") workspaceId: string, - @Param("document_id") documentId: string + @Param("document_slug") documentSlug: string ): Promise { - return this.workspaceDocumentsService.findOne(req.user.id, workspaceId, documentId); + return this.workspaceDocumentsService.findOneBySlug(req.user.id, workspaceId, documentSlug); } @Get("") diff --git a/backend/src/workspace-documents/workspace-documents.service.ts b/backend/src/workspace-documents/workspace-documents.service.ts index 966297bc..ca813074 100644 --- a/backend/src/workspace-documents/workspace-documents.service.ts +++ b/backend/src/workspace-documents/workspace-documents.service.ts @@ -5,6 +5,7 @@ import { FindWorkspaceDocumentsResponse } from "./types/find-workspace-documents import { JwtService } from "@nestjs/jwt"; import { CreateWorkspaceDocumentShareTokenResponse } from "./types/create-workspace-document-share-token-response.type"; import { ShareRole } from "src/utils/types/share-role.type"; +import slugify from "slugify"; @Injectable() export class WorkspaceDocumentsService { @@ -25,16 +26,29 @@ export class WorkspaceDocumentsService { throw new NotFoundException(); } + let slug = slugify(title); + + const duplicatedDocumentList = await this.prismaService.document.findMany({ + where: { + slug, + }, + }); + + if (duplicatedDocumentList.length) { + slug += `-${duplicatedDocumentList.length + 1}`; + } + return this.prismaService.document.create({ data: { title, + slug, workspaceId, yorkieDocumentId: Math.random().toString(36).substring(7), }, }); } - async findOne(userId: string, workspaceId: string, documentId: string) { + async findOneBySlug(userId: string, workspaceId: string, documentSlug: string) { try { await this.prismaService.userWorkspace.findFirstOrThrow({ where: { @@ -43,9 +57,9 @@ export class WorkspaceDocumentsService { }, }); - return this.prismaService.document.findUniqueOrThrow({ + return this.prismaService.document.findFirstOrThrow({ where: { - id: documentId, + slug: documentSlug, }, }); } catch (e) { diff --git a/backend/src/workspaces/types/workspace-domain.type.ts b/backend/src/workspaces/types/workspace-domain.type.ts index 953d310b..66e6dd19 100644 --- a/backend/src/workspaces/types/workspace-domain.type.ts +++ b/backend/src/workspaces/types/workspace-domain.type.ts @@ -5,6 +5,8 @@ export class WorkspaceDomain { id: string; @ApiProperty({ type: String, description: "Title of the workspace" }) title: string; + @ApiProperty({ type: String, description: "Slug of the workspace" }) + slug: string; @ApiProperty({ type: Date, description: "Created date of the workspace" }) createdAt: Date; @ApiProperty({ type: Date, description: "Updated date of the workspace" }) diff --git a/backend/src/workspaces/workspaces.controller.ts b/backend/src/workspaces/workspaces.controller.ts index 04784e6f..2899efdc 100644 --- a/backend/src/workspaces/workspaces.controller.ts +++ b/backend/src/workspaces/workspaces.controller.ts @@ -53,7 +53,7 @@ export class WorkspacesController { return this.workspacesService.create(req.user.id, createWorkspaceDto.title); } - @Get(":id") + @Get(":workspace_slug") @ApiOperation({ summary: "Retrieve a Workspace", description: "If the user has the access permissions, return a workspace.", @@ -65,9 +65,9 @@ export class WorkspacesController { }) async findOne( @Req() req: AuthroizedRequest, - @Param("id") workspaceId: string + @Param("workspace_slug") workspaceSlug: string ): Promise { - return this.workspacesService.findOne(req.user.id, workspaceId); + return this.workspacesService.findOneBySlug(req.user.id, workspaceSlug); } @Get("") diff --git a/backend/src/workspaces/workspaces.service.ts b/backend/src/workspaces/workspaces.service.ts index 33cec48e..0a8b957a 100644 --- a/backend/src/workspaces/workspaces.service.ts +++ b/backend/src/workspaces/workspaces.service.ts @@ -6,6 +6,7 @@ import { JwtService } from "@nestjs/jwt"; import { CreateInvitationTokenResponse } from "./types/create-inviation-token-response.type"; import { InvitationTokenPayload } from "./types/inviation-token-payload.type"; import { WorkspaceRoleConstants } from "src/utils/constants/auth-role"; +import slugify from "slugify"; @Injectable() export class WorkspacesService { @@ -15,9 +16,22 @@ export class WorkspacesService { ) {} async create(userId: string, title: string): Promise { + let slug = slugify(title); + + const duplicatedWorkspaceList = await this.prismaService.workspace.findMany({ + where: { + slug, + }, + }); + + if (duplicatedWorkspaceList.length) { + slug += `-${duplicatedWorkspaceList.length + 1}`; + } + const workspace = await this.prismaService.workspace.create({ data: { title, + slug, }, }); @@ -32,20 +46,22 @@ export class WorkspacesService { return workspace; } - async findOne(userId: string, workspaceId: string) { + async findOneBySlug(userId: string, workspaceSlug: string) { try { - await this.prismaService.userWorkspace.findFirstOrThrow({ + const foundWorkspace = await this.prismaService.workspace.findFirstOrThrow({ where: { - userId, - workspaceId, + slug: workspaceSlug, }, }); - return this.prismaService.workspace.findUniqueOrThrow({ + await this.prismaService.userWorkspace.findFirstOrThrow({ where: { - id: workspaceId, + userId, + workspaceId: foundWorkspace.id, }, }); + + return foundWorkspace; } catch (e) { throw new NotFoundException(); } diff --git a/frontend/src/components/common/GuestRoute.tsx b/frontend/src/components/common/GuestRoute.tsx index 43a2645f..c83d70f6 100644 --- a/frontend/src/components/common/GuestRoute.tsx +++ b/frontend/src/components/common/GuestRoute.tsx @@ -17,7 +17,7 @@ const GuestRoute = (props: RejectLoggedInRouteProps) => { if (isLoggedIn) { return ( diff --git a/frontend/src/components/drawers/WorkspaceDrawer.tsx b/frontend/src/components/drawers/WorkspaceDrawer.tsx index 1ded594b..160e0ecc 100644 --- a/frontend/src/components/drawers/WorkspaceDrawer.tsx +++ b/frontend/src/components/drawers/WorkspaceDrawer.tsx @@ -26,7 +26,7 @@ const DRAWER_WIDTH = 240; function WorkspaceDrawer() { const params = useParams(); const userStore = useSelector(selectUser); - const { data: workspace } = useGetWorkspaceQuery(params.workspaceId); + const { data: workspace } = useGetWorkspaceQuery(params.workspaceSlug); const [profileAnchorEl, setProfileAnchorEl] = useState<(EventTarget & Element) | null>(null); const [workspaceListAnchorEl, setWorkspaceListAnchorEl] = useState< (EventTarget & Element) | null diff --git a/frontend/src/hooks/api/types/user.d.ts b/frontend/src/hooks/api/types/user.d.ts index bf4223c2..17fe7cb6 100644 --- a/frontend/src/hooks/api/types/user.d.ts +++ b/frontend/src/hooks/api/types/user.d.ts @@ -1,6 +1,7 @@ export interface User { id: string; nickname: string; + lastWorkspaceSlug: string; createdAt: Date; updatedAt: Date; } diff --git a/frontend/src/hooks/api/types/workspace.d.ts b/frontend/src/hooks/api/types/workspace.d.ts index d879a029..bdb3577e 100644 --- a/frontend/src/hooks/api/types/workspace.d.ts +++ b/frontend/src/hooks/api/types/workspace.d.ts @@ -1,6 +1,7 @@ export interface Workspace { id: string; title: string; + slug: string; createdAt: Date; updatedAt: Date; } diff --git a/frontend/src/hooks/api/workspace.ts b/frontend/src/hooks/api/workspace.ts index cb98612e..f5264303 100644 --- a/frontend/src/hooks/api/workspace.ts +++ b/frontend/src/hooks/api/workspace.ts @@ -10,12 +10,12 @@ export const generateGetWorkspaceListQueryKey = () => { return ["workspaces"]; }; -export const useGetWorkspaceQuery = (workspaceId?: string) => { +export const useGetWorkspaceQuery = (workspaceSlug?: string) => { const query = useQuery({ - queryKey: generateGetWorkspaceQueryKey(workspaceId || ""), - enabled: Boolean(workspaceId), + queryKey: generateGetWorkspaceQueryKey(workspaceSlug || ""), + enabled: Boolean(workspaceSlug), queryFn: async () => { - const res = await axios.get(`/workspaces/${workspaceId}`); + const res = await axios.get(`/workspaces/${workspaceSlug}`); return res.data; }, meta: { diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index ff06a4bb..db634f7a 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -32,7 +32,7 @@ const codePairRoutes = [ element: , children: [ { - path: ":workspaceId", + path: ":workspaceSlug", element: , }, ], diff --git a/frontend/src/store/userSlice.ts b/frontend/src/store/userSlice.ts index 0734fb31..41ee044d 100644 --- a/frontend/src/store/userSlice.ts +++ b/frontend/src/store/userSlice.ts @@ -5,7 +5,7 @@ import { RootState } from "./store"; export interface User { id: string; nickname: string; - lastWorkspaceId: string; + lastWorkspaceSlug: string; updatedAt: Date; createdAt: Date; }