这是indexloc提供的服务,不要输入任何密码
Skip to content

Conversation

@LeeWxx
Copy link
Contributor

@LeeWxx LeeWxx commented Aug 25, 2025

What this PR does / why we need it:

Implements automatic redirect to user's last accessed workspace on login. Users are now taken directly to their most recently used workspace, reducing navigation steps and improving UX.

Which issue(s) this PR fixes:

Fixes #188

Special notes for your reviewer:

  • Added lastWorkspaceSlug field to User model (requires DB migration)
  • Fallback to first available workspace if no last workspace found
  • Aligns DB schema with existing UserDomain type that already declared lastWorkspaceSlug?: string

Does this PR introduce a user-facing change?:

 Users are now automatically redirected to their last accessed workspace

Additional documentation:


Checklist:

  • Added relevant tests or not required
  • Didn't break anything

Summary by CodeRabbit

  • New Features
    • Added a dedicated /workspace route that automatically redirects to your last visited (or first available) workspace, showing a full-screen loading indicator during redirect.
    • Persisting last visited workspace per user for consistent navigation.
  • Bug Fixes
    • Clear stored last-workspace on logout or session refresh to avoid stale redirects.
  • Refactor
    • Streamlined guest and header navigation to always route via /workspace.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 25, 2025

Walkthrough

Adds client-side per-user last-workspace persistence (localStorage), a /workspace redirect handler route that forwards users to last or first workspace, removes lastWorkspaceSlug from public API/types, and adds a Prisma field lastWorkspaceSlug on User (stored but not exposed). Several components persist/clear the slug.

Changes

Cohort / File(s) Summary
Prisma model
backend/prisma/schema.prisma
Add optional lastWorkspaceSlug (String?) mapped to last_workspace_slug on User.
Backend types & API spec
backend/src/users/types/user-domain.type.ts, api/backend-openapi-spec.json
Remove lastWorkspaceSlug from exposed UserDomain and from FindUserResponse in OpenAPI.
Backend service
backend/src/users/users.service.ts
Stop deriving/merging lastWorkspaceSlug via userWorkspace; findOne returns the raw user record.
Frontend route & redirect component
frontend/src/routes.tsx, frontend/src/components/common/WorkspaceRedirectHandler.tsx, frontend/src/components/common/GuestRoute.tsx, frontend/src/components/headers/SettingHeader.tsx
Add private /workspace route rendering WorkspaceRedirectHandler; GuestRoute and settings navigation now point to static /workspace. Redirect handler navigates to lastWorkspaceSlug (if present) or first workspace slug; shows backdrop while loading.
Frontend local persistence util
frontend/src/utils/lastWorkspace.ts
New utility: get/set/clear last workspace slug per user in localStorage with safe try/catch and dev-only warnings.
Frontend layouts persist slug
frontend/src/components/layouts/WorkspaceLayout.tsx, frontend/src/components/layouts/DocumentLayout.tsx
On user.id or route slug changes, call setLastWorkspaceSlug(user.id, workspaceSlug) to persist last visited workspace.
Frontend auth/hooks/logout changes
frontend/src/hooks/api/user.ts, frontend/src/components/popovers/ProfilePopover.tsx
On auth refresh failures or logout, call clearLastWorkspaceSlug(userId) before clearing user state; include user id in interceptor deps.
Frontend types & store
frontend/src/hooks/api/types/user.d.ts, frontend/src/store/userSlice.ts
Remove lastWorkspaceSlug property from frontend User types and store User interface.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor User
  participant Router as Router (/workspace)
  participant WRH as WorkspaceRedirectHandler
  participant Store as Redux Store
  participant API as Workspace API
  participant Nav as Navigator

  User->>Router: Navigate to /workspace (private)
  Router->>WRH: Render
  WRH->>Store: selectUser()
  WRH->>API: useGetWorkspaceListQuery()
  alt user has lastWorkspaceSlug
    WRH->>Nav: replace to "/{lastWorkspaceSlug}"
  else no lastWorkspaceSlug
    alt workspace list has first page
      API-->>WRH: workspaces[0].slug
      WRH->>Nav: replace to "/{firstSlug}"
    else still loading / no workspaces
      WRH-->>User: show Backdrop (loading)
    end
  end
Loading
sequenceDiagram
  autonumber
  participant Layout as Workspace/Document Layout
  participant Store as Redux Store
  participant Util as lastWorkspace.ts (localStorage)

  Layout->>Store: selectUser()
  Layout->>Layout: read workspaceSlug from route
  alt user.id and workspaceSlug present
    Layout->>Util: setLastWorkspaceSlug(user.id, workspaceSlug)
  else
    Layout-->>Layout: No-op
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • Fix workspace disappearing issue #547 — Related frontend changes to workspace list fetching and components that consume workspace list (useGetWorkspaceListQuery), overlapping with redirect handler and workspace resolution.

Suggested reviewers

  • devleejb
  • blurfx

Poem

A hop to where I last did dwell,
I stash the slug and guard its spell.
When next I call, no path to scan,
I bounce right back and code—hop, span! 🐇✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title clearly and concisely summarizes the primary change of automatically redirecting users to their last accessed workspace and matches the main focus of the pull request.
Linked Issues Check ✅ Passed The changes fully realize issue #188 by persisting the last accessed workspace slug client-side, introducing a redirect handler at the /workspace route that falls back to the first workspace when needed, and integrating persistence and clearing logic in relevant UI flows to automatically start users in their last workspace as requested.
Out of Scope Changes Check ✅ Passed All modifications are directly related to implementing the automatic redirect feature and supporting its persistence logic, and no unrelated or extraneous changes have been introduced in this pull request.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 86ad3f8 and b00d4e3.

📒 Files selected for processing (15)
  • api/backend-openapi-spec.json (0 hunks)
  • backend/prisma/schema.prisma (1 hunks)
  • backend/src/users/types/user-domain.type.ts (0 hunks)
  • backend/src/users/users.service.ts (1 hunks)
  • frontend/src/components/common/GuestRoute.tsx (1 hunks)
  • frontend/src/components/common/WorkspaceRedirectHandler.tsx (1 hunks)
  • frontend/src/components/headers/SettingHeader.tsx (1 hunks)
  • frontend/src/components/layouts/DocumentLayout.tsx (1 hunks)
  • frontend/src/components/layouts/WorkspaceLayout.tsx (2 hunks)
  • frontend/src/components/popovers/ProfilePopover.tsx (1 hunks)
  • frontend/src/hooks/api/types/user.d.ts (0 hunks)
  • frontend/src/hooks/api/user.ts (5 hunks)
  • frontend/src/routes.tsx (2 hunks)
  • frontend/src/store/userSlice.ts (0 hunks)
  • frontend/src/utils/lastWorkspace.ts (1 hunks)
💤 Files with no reviewable changes (4)
  • frontend/src/store/userSlice.ts
  • api/backend-openapi-spec.json
  • frontend/src/hooks/api/types/user.d.ts
  • backend/src/users/types/user-domain.type.ts
🚧 Files skipped from review as they are similar to previous changes (6)
  • backend/prisma/schema.prisma
  • frontend/src/routes.tsx
  • frontend/src/utils/lastWorkspace.ts
  • frontend/src/components/layouts/WorkspaceLayout.tsx
  • frontend/src/components/layouts/DocumentLayout.tsx
  • frontend/src/components/common/GuestRoute.tsx
🧰 Additional context used
🧬 Code graph analysis (3)
frontend/src/components/popovers/ProfilePopover.tsx (2)
frontend/src/store/userSlice.ts (1)
  • selectUser (32-32)
frontend/src/utils/lastWorkspace.ts (1)
  • clearLastWorkspaceSlug (24-33)
frontend/src/components/common/WorkspaceRedirectHandler.tsx (3)
frontend/src/store/userSlice.ts (1)
  • selectUser (32-32)
frontend/src/hooks/api/workspace.ts (1)
  • useGetWorkspaceListQuery (53-63)
frontend/src/utils/lastWorkspace.ts (1)
  • getLastWorkspaceSlug (3-11)
frontend/src/hooks/api/user.ts (2)
frontend/src/store/userSlice.ts (1)
  • selectUser (32-32)
frontend/src/utils/lastWorkspace.ts (1)
  • clearLastWorkspaceSlug (24-33)
🔇 Additional comments (10)
frontend/src/components/headers/SettingHeader.tsx (1)

22-24: LGTM! Clean separation of concerns.

The change from dynamic workspace path construction to a fixed /workspace route improves the architecture by centralizing workspace selection logic. This aligns well with the PR's objective of automatic workspace redirect, where the WorkspaceRedirectHandler (mentioned in the PR summary) now handles the logic for determining the destination workspace based on the user's last accessed workspace.

backend/src/users/users.service.ts (1)

15-29: LGTM! Correctly removes lastWorkspaceSlug from the public API response.

The change aligns with the PR's architecture where lastWorkspaceSlug is stored in the database but not exposed through public APIs. Frontend components handle workspace persistence via localStorage instead.

frontend/src/hooks/api/user.ts (4)

6-7: LGTM: Imports are correct.

The new imports for selectUser and clearLastWorkspaceSlug are correctly added and used consistently in the file.


41-41: LGTM: userStore declaration is correct.

The userStore declaration correctly uses useSelector(selectUser) to access user state for the cleanup logic.


45-72: LGTM: Interceptor effect and dependencies are correct.

The effect correctly sets up the axios interceptor to clear the last workspace slug on 401 refresh errors. The dependency array includes userStore.data?.id, ensuring the interceptor closure is updated when the user changes. The clearLastWorkspaceSlug utility safely handles null/undefined user IDs.


84-93: LGTM: Query effect and dependencies are correct.

The effect correctly clears the last workspace slug on query errors before logging out. The dependency array includes userStore.data?.id, ensuring the effect closure is updated when the user changes. The clearLastWorkspaceSlug utility safely handles null/undefined user IDs.

frontend/src/components/popovers/ProfilePopover.tsx (3)

13-13: LGTM: Imports are correct.

The new imports for useSelector, selectUser, and clearLastWorkspaceSlug are correctly added and used consistently in the component.

Also applies to: 18-19


23-23: LGTM: userStore declaration is correct.

The userStore declaration correctly uses useSelector(selectUser) to access user state for the cleanup logic in the logout handler.


27-31: LGTM: Logout handler is correct.

The logout handler correctly clears the last workspace slug before dispatching logout and clearing user data. The order of operations ensures the slug is cleared while the user ID is still available. The clearLastWorkspaceSlug utility safely handles null/undefined user IDs.

frontend/src/components/common/WorkspaceRedirectHandler.tsx (1)

1-8: LGTM!

Imports are appropriate and correctly structured for the component's redirect logic.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (6)
frontend/src/components/common/GuestRoute.tsx (1)

18-22: Optional: centralize all “where to go” logic behind WorkspaceRedirectHandler

If lastWorkspaceSlug becomes stale (user removed from that workspace), navigating directly to /${slug} could yield a 404 before any fallback. Consider always sending guests to /workspace and letting the centralized redirect handler decide the target. This trades one extra redirect for more robust behavior in edge cases.

Example change:

-    const redirectPath = userStore.data?.lastWorkspaceSlug
-      ? `/${userStore.data.lastWorkspaceSlug}`
-      : "/workspace";
-
-    return <Navigate to={redirectPath} state={{ from: location }} replace />;
+    return <Navigate to="/workspace" state={{ from: location }} replace />;
backend/src/users/dto/update-last-workspace-slug.dto.ts (1)

1-8: ValidationPipe Transform Confirmed

Global ValidationPipe is configured with { transform: true } in backend/src/main.ts:22, so the @Transform decorator will run as expected.

You can safely apply the following enhancements to UpdateLastWorkspaceSlugDto to tighten validation and normalize the workspace slug:

--- a/backend/src/users/dto/update-last-workspace-slug.dto.ts
+++ b/backend/src/users/dto/update-last-workspace-slug.dto.ts
@@
-import { ApiProperty } from "@nestjs/swagger";
-import { IsString } from "class-validator";
+import { ApiProperty } from "@nestjs/swagger";
+import { IsString, IsNotEmpty, Matches, MaxLength } from "class-validator";
+import { Transform } from "class-transformer";

 export class UpdateLastWorkspaceSlugDto {
   @ApiProperty({ type: String, description: "Workspace slug to set as last accessed" })
-  @IsString()
-  workspaceSlug: string;
+  @IsString()
+  @IsNotEmpty()
+  @MaxLength(64)
+  @Matches(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/, { message: "Invalid slug format" })
+  @Transform(({ value }) => String(value).trim().toLowerCase())
+  workspaceSlug: string;
 }

• Imports updated to include IsNotEmpty, MaxLength, Matches, and Transform.
• Enforces non-empty, max-length (64), and a canonical slug pattern.
• Normalizes incoming values by trimming whitespace and lowercasing.

These changes will prevent silent mismatches in routing and comparisons.

frontend/src/hooks/api/user.ts (1)

112-130: Guard against empty input and avoid sending whitespace-only slugs

A small guard + trim prevents no-op/invalid writes and reduces noise on the backend. Also keeps typing as void consistently returned.

 export const useUpdateLastWorkspaceSlugMutation = () => {
   const authStore = useSelector(selectAuth);
   const queryClient = useQueryClient();
 
   return useMutation({
-    mutationFn: async (workspaceSlug: string) => {
-      const res = await axios.patch<void>("/users/last-workspace-slug", {
-        workspaceSlug,
-      });
-
-      return res.data;
-    },
+    mutationFn: async (workspaceSlug: string) => {
+      const ws = workspaceSlug?.trim();
+      if (!ws) {
+        return;
+      }
+      const res = await axios.patch<void>("/users/last-workspace-slug", { workspaceSlug: ws });
+      return res.data;
+    },
     onSuccess: () => {
       queryClient.invalidateQueries({
         queryKey: generateGetUserQueryKey(authStore.accessToken || ""),
       });
     },
   });
 };

Optional follow-up: consider short-circuiting server-side (don’t update if the value is unchanged) to eliminate redundant writes under frequent navigations.

frontend/src/hooks/api/workspace.ts (1)

45-45: Avoid redundant PATCH calls when the slug hasn’t changed

Only update the last-workspace slug if it differs from the one we already have for the user. This reduces unnecessary network chatter and DB writes.

-      updateLastWorkspaceSlug(query.data.slug).catch(() => {});
+      // Only persist when the value actually changes
+      if (userStore.data?.lastWorkspaceSlug !== query.data.slug) {
+        updateLastWorkspaceSlug(query.data.slug).catch(() => {});
+      }
-  }, [dispatch, query.data, updateLastWorkspaceSlug]);
+  }, [dispatch, query.data, updateLastWorkspaceSlug, userStore]);

Add the minimal wiring outside this hunk:

// Add imports
import { useSelector } from "react-redux";
import { selectUser } from "../../store/userSlice";

// Inside the hook (near dispatch)
const userStore = useSelector(selectUser);

Optional: instead of .catch(() => {}), report errors to your telemetry (Sentry, console.warn) so you can observe unexpected membership failures.

If you want, I can push a follow-up commit wiring these imports and dependencies.

Also applies to: 52-52

backend/src/users/users.service.ts (1)

15-29: Prefer findUniqueOrThrow here to avoid returning null for a typed FindUserResponse.

The signature promises FindUserResponse, but findUnique may yield null (e.g., stale token). Using findUniqueOrThrow produces a consistent 404 and a safer type.

Here’s a minimal refactor:

- const foundUser = await this.prismaService.user.findUnique({
+ const foundUser = await this.prismaService.user.findUniqueOrThrow({
   select: {
     id: true,
     nickname: true,
     lastWorkspaceSlug: true,
     createdAt: true,
     updatedAt: true,
   },
   where: { id: userId },
 });

If you intentionally want null → 200, please confirm so we can align the controller docs accordingly.

backend/src/users/users.controller.ts (1)

1-1: Import HttpCode/HttpStatus for proper 204 semantics on PATCH.

You’re returning void; better to signal “no content” explicitly.

-import { Body, Controller, Get, Put, Req, Patch } from "@nestjs/common";
+import { Body, Controller, Get, Put, Req, Patch, HttpCode, HttpStatus } from "@nestjs/common";
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between cd7a320 and f0a554c.

📒 Files selected for processing (9)
  • backend/prisma/schema.prisma (1 hunks)
  • backend/src/users/dto/update-last-workspace-slug.dto.ts (1 hunks)
  • backend/src/users/users.controller.ts (3 hunks)
  • backend/src/users/users.service.ts (3 hunks)
  • frontend/src/components/common/GuestRoute.tsx (1 hunks)
  • frontend/src/components/common/WorkspaceRedirectHandler.tsx (1 hunks)
  • frontend/src/hooks/api/user.ts (1 hunks)
  • frontend/src/hooks/api/workspace.ts (3 hunks)
  • frontend/src/routes.tsx (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (4)
frontend/src/hooks/api/user.ts (1)
frontend/src/store/authSlice.ts (1)
  • selectAuth (36-36)
backend/src/users/users.controller.ts (2)
backend/src/users/dto/update-last-workspace-slug.dto.ts (1)
  • UpdateLastWorkspaceSlugDto (4-8)
backend/src/utils/types/req.type.ts (1)
  • AuthorizedRequest (6-6)
frontend/src/hooks/api/workspace.ts (3)
backend/src/users/users.controller.ts (1)
  • updateLastWorkspaceSlug (58-66)
backend/src/users/users.service.ts (1)
  • updateLastWorkspaceSlug (102-120)
frontend/src/hooks/api/user.ts (1)
  • useUpdateLastWorkspaceSlugMutation (112-130)
frontend/src/components/common/WorkspaceRedirectHandler.tsx (2)
frontend/src/store/userSlice.ts (1)
  • selectUser (33-33)
frontend/src/hooks/api/workspace.ts (1)
  • useGetWorkspaceListQuery (56-73)
🔇 Additional comments (4)
frontend/src/components/common/GuestRoute.tsx (1)

18-22: LGTM: avoids “/undefined” and keeps a clean redirect path

Redirect selection using lastWorkspaceSlug with a safe fallback to /workspace is concise and prevents bad URLs. Passing state={{ from: location }} is a nice touch.

backend/src/users/users.service.ts (1)

20-21: Verification Complete – lastWorkspaceSlug Is Properly Exposed as Optional

I’ve confirmed that:

  • FindUserResponse extends UserDomain, which declares
    @ApiProperty({ type: String, description: "Last worksace slug of user", required: false })
    lastWorkspaceSlug?: string;
    in backend/src/users/types/user-domain.type.ts (line 9).
  • The required: false flag on the @ApiProperty decorator ensures the OpenAPI schema marks lastWorkspaceSlug as optional.
  • The TypeScript definition already includes lastWorkspaceSlug?, so there’s no risk of contract drift.

No further changes are needed here—approving these code changes.

frontend/src/routes.tsx (2)

50-53: Route ordering is good.

The redirect route is placed before the dynamic “:workspaceSlug” route; combined with router ranking, this ensures deterministic matching.


50-53: Route collision: static “workspace” path shadows dynamic slug

I’ve confirmed in frontend/src/routes.tsx (lines 50–53) that this static route sits above both dynamic routes :workspaceSlug (lines 55 & 70), so navigating to /workspace always hits the WorkspaceRedirectHandler, making a real workspace whose slug is "workspace" unreachable.

Fix options (pick one):

  • Rename the redirect route to a non-colliding prefix (recommended): e.g. "app/workspace". You’ll also need to update any hard-coded /workspace redirects in:
    • frontend/src/components/common/GuestRoute.tsx (redirect default)
    • any UI links or tests pointing to /workspace
    -  { path: "workspace", accessType: AccessType.PRIVATE, element: <WorkspaceRedirectHandler /> },
    +  { path: "app/workspace", accessType: AccessType.PRIVATE, element: <WorkspaceRedirectHandler /> },
  • Or reserve “workspace” as a forbidden slug: add it to your backend’s slug-validation rules (e.g. in the CreateWorkspace DTO), migrate any existing collisions, and document the restriction.

Please confirm which approach you’d like, and I can prepare the corresponding route updates, GuestRoute adjustments, and any necessary test/UI changes.

socialProvider String @map("social_provider")
socialUid String @unique @map("social_uid")
nickname String?
lastWorkspaceSlug String? @map("last_workspace_slug")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Field addition looks right; consider canonicalization and verify slug uniqueness contract

Adding lastWorkspaceSlug String? @map("last_workspace_slug") on User matches the feature intent and aligns with the frontend usage. Two low-friction improvements:

  • Store slugs in a canonical form (lowercase, trimmed) to avoid future mismatches. Do this in the service layer when persisting (and/or validate format at the DTO layer).
  • Verify that Workspace.slug is globally unique. Your routes use /:workspaceSlug, and the service checks membership by slug; if slugs aren’t unique, that contract becomes ambiguous.

Run the following to confirm uniqueness and surface the current slug constraints:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Snippet around Workspace.slug:"
rg -nU -C3 $'model\\s+Workspace\\b(?s).*?^\\s*slug\\s+String.*$' backend/prisma/schema.prisma

echo -e "\nCheck for unique constraints on slug:"
rg -nP 'slug\s+String.*@unique|@@unique\(\s*\[\s*slug\s*\]\s*\)' backend/prisma/schema.prisma || echo "No explicit unique constraint for slug found."

Length of output: 3622


Ensure Workspace.slug is unique and slugs are stored canonically

The new lastWorkspaceSlug String? @map("last_workspace_slug") field is correct, but our verification shows that Workspace.slug currently has no unique constraint, which is required for unambiguous routing via /:workspaceSlug. Additionally, to prevent future mismatches, slugs should be stored in a canonical form (e.g. lowercase, trimmed).

• backend/prisma/schema.prisma (Workspace model)

  • Add a unique constraint to slug, for example:
    model Workspace {
      id        String   @id @default(auto()) @map("_id") @db.ObjectId
      title     String
    - slug      String
    + slug      String   @unique
      createdAt DateTime @default(now()) @map("created_at")
      updatedAt DateTime @updatedAt @map("updated_at")
      // …
    }
  • Alternatively, you can define a model-level unique index:
    @@unique([slug])

• Service/DTO layers

  • Normalize slugs on input (e.g. toLowerCase().trim()) and/or validate their format in DTOs to enforce canonical form before persisting.
🤖 Prompt for AI Agents
In backend/prisma/schema.prisma around line 18, the Workspace model currently
lacks a unique constraint on slug and slugs must be stored canonically; add a
unique constraint for slug (either mark the slug field as unique or add a
model-level @@unique([slug])) in the Workspace model, and update service/DTO
layers to normalize incoming slugs (e.g., toLowerCase().trim()) and validate
format before persisting to ensure canonical storage and unambiguous routing.

Comment on lines 49 to 66
@Patch("last-workspace-slug")
@ApiOperation({
summary: "Update Last Accessed Workspace",
description: "Update the last accessed workspace slug for the user",
})
@ApiBody({
type: UpdateLastWorkspaceSlugDto,
})
@ApiOkResponse()
async updateLastWorkspaceSlug(
@Req() req: AuthorizedRequest,
@Body() updateLastWorkspaceSlugDto: UpdateLastWorkspaceSlugDto
): Promise<void> {
return this.usersService.updateLastWorkspaceSlug(
req.user.id,
updateLastWorkspaceSlugDto.workspaceSlug
);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Return 204 No Content and document authz failures (403/404) for non-members.

  • For a field update with no body response, 204 is the right status.
  • Service now throws a ForbiddenException on non-membership (recommended). Reflect that in API docs.
 @Patch("last-workspace-slug")
 @ApiOperation({
   summary: "Update Last Accessed Workspace",
   description: "Update the last accessed workspace slug for the user",
 })
 @ApiBody({ type: UpdateLastWorkspaceSlugDto })
-@ApiOkResponse()
+@ApiOkResponse() // optionally swap to @ApiNoContentResponse() if you import it
+@HttpCode(HttpStatus.NO_CONTENT)
 async updateLastWorkspaceSlug(
   @Req() req: AuthorizedRequest,
   @Body() updateLastWorkspaceSlugDto: UpdateLastWorkspaceSlugDto
 ): Promise<void> {
   return this.usersService.updateLastWorkspaceSlug(
     req.user.id,
     updateLastWorkspaceSlugDto.workspaceSlug
   );
 }

Optional Swagger polish (outside the hunk):

  • Add @ApiForbiddenResponse({ description: "User is not a member of the workspace" }).
  • Prefer @ApiNoContentResponse() if you switch to 204 in docs too.

Also consider enhancing DTO validation to prevent empty strings and invalid slug shapes.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
backend/src/users/users.controller.ts lines 49-66: The endpoint currently
documents/returns a 200 with no body; change behavior and docs to reflect a 204
No Content for a successful update and document authorization failures (403) and
not-found (404) for non-members; add @HttpCode(204) (or equivalent) to the
controller method and replace @ApiOkResponse() with @ApiNoContentResponse(),
also add @ApiForbiddenResponse({ description: "User is not a member of the
workspace" }) and @ApiNotFoundResponse(...) as appropriate, and ensure the
method signature returns void (or Promise<void>) and lets the service throw
ForbiddenException for non-membership; optionally tighten DTO validation to
reject empty/invalid slugs.

Comment on lines 102 to 120
async updateLastWorkspaceSlug(userId: string, workspaceSlug: string): Promise<void> {
await this.prismaService.userWorkspace.findFirstOrThrow({
where: {
userId,
workspace: {
slug: workspaceSlug,
},
},
});

await this.prismaService.user.update({
where: {
id: userId,
},
data: {
lastWorkspaceSlug: workspaceSlug,
},
});
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Map membership failures to a 403 and perform the check+update in a transaction to avoid TOCTOU.

  • Today, findFirstOrThrow will raise a Prisma error that bubbles up as 500 unless handled. For non-members, a 403 (or 404 to avoid disclosure) is more appropriate.
  • Wrapping the membership check and update in a single transaction prevents a tiny race where membership could change between the two statements.
-  async updateLastWorkspaceSlug(userId: string, workspaceSlug: string): Promise<void> {
-    await this.prismaService.userWorkspace.findFirstOrThrow({
-      where: {
-        userId,
-        workspace: { slug: workspaceSlug },
-      },
-    });
-
-    await this.prismaService.user.update({
-      where: { id: userId },
-      data: { lastWorkspaceSlug: workspaceSlug },
-    });
-  }
+  async updateLastWorkspaceSlug(userId: string, workspaceSlug: string): Promise<void> {
+    try {
+      await this.prismaService.$transaction(async (tx) => {
+        await tx.userWorkspace.findFirstOrThrow({
+          where: {
+            userId,
+            workspace: { slug: workspaceSlug },
+          },
+        });
+        await tx.user.update({
+          where: { id: userId },
+          data: { lastWorkspaceSlug: workspaceSlug },
+        });
+      });
+    } catch (err) {
+      // Map "not found" on membership to a safe authz error (avoid leaking slug existence).
+      // Use Forbidden (403) or NotFound (404) depending on your policy.
+      throw new ForbiddenException("You are not a member of the specified workspace.");
+    }
+  }

Additionally, add the import outside this hunk:

import { ConflictException, ForbiddenException, Injectable } from "@nestjs/common";

Optionally, normalize input to avoid accidental whitespace mismatches:

  • In DTO: add @IsNotEmpty(), @Matches(/^[a-z0-9-]+$/) and @Transform(({ value }) => String(value).trim().toLowerCase()) if slugs are canonicalized as lowercase.

I can patch the DTO and add tests to assert 403 behavior on non-membership. Want me to open a follow-up PR?

Comment on lines 13 to 26
useEffect(() => {
if (isLoading) return;

if (user?.data?.lastWorkspaceSlug) {
navigate(`/${user.data.lastWorkspaceSlug}`, { replace: true });
return;
}

if (workspaceList?.pages?.[0]?.workspaces?.length) {
const firstWorkspace = workspaceList.pages[0].workspaces[0];
navigate(`/${firstWorkspace.slug}`, { replace: true });
return;
}
}, [user, workspaceList, navigate, isLoading]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Edge cases: stale lastWorkspaceSlug, pagination, and error handling can cause loops or dead-ends.

  • If lastWorkspaceSlug points to a workspace the user no longer can access, you’ll navigate to “/:slug”, likely get a 403/404, and many apps redirect back to "/workspace", creating a loop.
  • Fallback checks rely only on the first page of workspaces; if the last workspace isn’t on page 1, you may wrongly conclude it’s “not found”.
  • If fetching the list errors (network), you’ll render a spinner forever.

Mitigations:

  • Verify membership before redirecting to lastWorkspaceSlug (e.g., fire a lightweight GET /workspaces/:slug, and on 404/403 clear the server-side lastWorkspaceSlug and fall back).
  • If the workspace list errors or is empty, navigate to a clear fallback (e.g., an onboarding or “create workspace” page), or show a message with a CTA instead of an indefinite spinner.
  • Guard against redirect loops by setting a query flag or a local ref (e.g., ?fallback=1) and skipping lastWorkspaceSlug when that flag is present.

Concrete minimal improvements in this component:

-  const { data: workspaceList, isLoading } = useGetWorkspaceListQuery();
+  const { data: workspaceList, isLoading, isError } = useGetWorkspaceListQuery();
@@
-  useEffect(() => {
+  useEffect(() => {
     if (isLoading) return;
 
     if (user?.data?.lastWorkspaceSlug) {
       navigate(`/${user.data.lastWorkspaceSlug}`, { replace: true });
       return;
     }
 
-    if (workspaceList?.pages?.[0]?.workspaces?.length) {
+    if (workspaceList?.pages?.[0]?.workspaces?.length) {
       const firstWorkspace = workspaceList.pages[0].workspaces[0];
       navigate(`/${firstWorkspace.slug}`, { replace: true });
       return;
     }
-  }, [user, workspaceList, navigate, isLoading]);
+    // When there is no last slug, no workspaces, or fetch failed, move to a safe fallback route.
+    if (!user?.data?.lastWorkspaceSlug && (isError || workspaceList?.pages?.[0]?.workspaces?.length === 0)) {
+      navigate("/app/workspace-empty", { replace: true }); // pick your onboarding route
+    }
+  }, [user?.data?.lastWorkspaceSlug, workspaceList, navigate, isLoading, isError]);

If there’s no onboarding route yet, consider adding one, or at least return a friendly message (see next comment).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
if (isLoading) return;
if (user?.data?.lastWorkspaceSlug) {
navigate(`/${user.data.lastWorkspaceSlug}`, { replace: true });
return;
}
if (workspaceList?.pages?.[0]?.workspaces?.length) {
const firstWorkspace = workspaceList.pages[0].workspaces[0];
navigate(`/${firstWorkspace.slug}`, { replace: true });
return;
}
}, [user, workspaceList, navigate, isLoading]);
// frontend/src/components/common/WorkspaceRedirectHandler.tsx
// Add `isError` to the hook’s return values
const { data: workspaceList, isLoading, isError } = useGetWorkspaceListQuery();
useEffect(() => {
if (isLoading) return;
if (user?.data?.lastWorkspaceSlug) {
navigate(`/${user.data.lastWorkspaceSlug}`, { replace: true });
return;
}
if (workspaceList?.pages?.[0]?.workspaces?.length) {
const firstWorkspace = workspaceList.pages[0].workspaces[0];
navigate(`/${firstWorkspace.slug}`, { replace: true });
return;
}
// When there is no last slug, no workspaces, or fetch failed, move to a safe fallback route.
if (
!user?.data?.lastWorkspaceSlug &&
(isError || workspaceList?.pages?.[0]?.workspaces?.length === 0)
) {
navigate("/app/workspace-empty", { replace: true }); // adjust to your onboarding route
}
}, [
user?.data?.lastWorkspaceSlug,
workspaceList,
navigate,
isLoading,
isError,
]);
🤖 Prompt for AI Agents
In frontend/src/components/common/WorkspaceRedirectHandler.tsx around lines 13
to 26, the redirect logic can cause loops or dead-ends because it trusts
lastWorkspaceSlug, only checks page 1 of workspaceList, and never handles fetch
errors; change it to (1) before navigating to user.data.lastWorkspaceSlug
perform a lightweight verification GET /workspaces/:slug (or a backend endpoint)
and if you receive 403/404 clear the server-side lastWorkspaceSlug and proceed
to fallback; (2) when falling back, search the full paginated workspaceList or
call a dedicated list endpoint that returns all accessible workspaces instead of
just pages[0]; (3) if fetching workspaceList errors or returns empty, navigate
to a clear fallback route (onboarding or create-workspace) or render a friendly
CTA instead of leaving a spinner; and (4) guard against redirect loops by adding
a query flag or a local ref (e.g., ?redirectAttempt=1 or a ref) and skip trying
lastWorkspaceSlug when that flag/ref is present.

Comment on lines +28 to +36
return (
<Backdrop open>
<CircularProgress color="inherit" />
</Backdrop>
);
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Avoid an always-open backdrop; show progress only while loading or redirecting.

Right now, if there’s nothing to redirect to, the user faces an indefinite spinner with no action. At a minimum, tie Backdrop.open to isLoading. Ideally, render a small empty state with a link/button to create or join a workspace.

-  return (
-    <Backdrop open>
-      <CircularProgress color="inherit" />
-    </Backdrop>
-  );
+  return isLoading ? (
+    <Backdrop open>
+      <CircularProgress color="inherit" />
+    </Backdrop>
+  ) : null

If you introduce an onboarding route (e.g., “/app/workspace-empty”), render a lightweight message here instead of null until navigation happens.

I can wire an EmptyState component and a “Create Workspace” CTA if you share the intended route or modal.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return (
<Backdrop open>
<CircularProgress color="inherit" />
</Backdrop>
);
};
return isLoading ? (
<Backdrop open>
<CircularProgress color="inherit" />
</Backdrop>
) : null;
};
🤖 Prompt for AI Agents
In frontend/src/components/common/WorkspaceRedirectHandler.tsx around lines 28
to 33, the Backdrop is currently always open showing an indefinite spinner;
update the component to open the Backdrop only when isLoading is true (Backdrop
open={isLoading}) and, when not loading and there is no redirect target, render
a lightweight empty-state UI (or a link/button) instead of leaving the UI
blank—either render an inline message with a "Create/Join Workspace" CTA or
navigate/render to an onboarding route like "/app/workspace-empty" while
awaiting navigation; ensure the empty state is shown immediately when not
loading and no destination is available so users aren’t stuck with an infinite
spinner.

Copy link
Member

@blurfx blurfx left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your work.
However, I would like to suggest a different approach to the work you have done.
Is it necessary for information about the last accessed workspace to be persistently stored in the database?
This issue is sufficiently resolvable on the client side.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (2)
frontend/src/components/layouts/DocumentLayout.tsx (1)

13-17: Consider consolidating persistence logic.

Both DocumentLayout and WorkspaceLayout persist the workspace slug identically. While this ensures coverage, you could optionally consolidate this into a shared hook (e.g., useWorkspacePersistence) to reduce duplication.

frontend/src/hooks/api/user.ts (1)

45-72: Remove unnecessary dependency from interceptor effect.

The interceptor effect at line 72 now includes userStore.data?.id in its dependency array. Since the interceptor reads the current userStore.data?.id from the closure when errors occur, adding it to deps causes the interceptor to re-register whenever the user ID changes, which is unnecessary overhead.

Consider removing userStore.data?.id from the dependency array at line 72. The interceptor will still have access to the current user ID through the closure, and this avoids re-creating the interceptor when the user ID changes.

Alternatively, if you want to ensure the interceptor always has the latest user ID without relying on closure behavior, you could use a ref:

const userIdRef = useRef(userStore.data?.id);
useEffect(() => {
  userIdRef.current = userStore.data?.id;
}, [userStore.data?.id]);

Then reference userIdRef.current in the interceptor and remove userStore.data?.id from the interceptor effect's dependencies.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f0a554c and 86ad3f8.

📒 Files selected for processing (13)
  • api/backend-openapi-spec.json (0 hunks)
  • backend/src/users/types/user-domain.type.ts (0 hunks)
  • backend/src/users/users.service.ts (1 hunks)
  • frontend/src/components/common/GuestRoute.tsx (1 hunks)
  • frontend/src/components/common/WorkspaceRedirectHandler.tsx (1 hunks)
  • frontend/src/components/headers/SettingHeader.tsx (1 hunks)
  • frontend/src/components/layouts/DocumentLayout.tsx (1 hunks)
  • frontend/src/components/layouts/WorkspaceLayout.tsx (2 hunks)
  • frontend/src/components/popovers/ProfilePopover.tsx (1 hunks)
  • frontend/src/hooks/api/types/user.d.ts (0 hunks)
  • frontend/src/hooks/api/user.ts (5 hunks)
  • frontend/src/store/userSlice.ts (0 hunks)
  • frontend/src/utils/lastWorkspace.ts (1 hunks)
💤 Files with no reviewable changes (4)
  • frontend/src/hooks/api/types/user.d.ts
  • frontend/src/store/userSlice.ts
  • api/backend-openapi-spec.json
  • backend/src/users/types/user-domain.type.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • frontend/src/components/common/WorkspaceRedirectHandler.tsx
🧰 Additional context used
🧬 Code graph analysis (4)
frontend/src/components/popovers/ProfilePopover.tsx (2)
frontend/src/store/userSlice.ts (1)
  • selectUser (32-32)
frontend/src/utils/lastWorkspace.ts (1)
  • clearLastWorkspaceSlug (24-33)
frontend/src/components/layouts/DocumentLayout.tsx (2)
frontend/src/store/userSlice.ts (1)
  • selectUser (32-32)
frontend/src/utils/lastWorkspace.ts (1)
  • setLastWorkspaceSlug (13-22)
frontend/src/components/layouts/WorkspaceLayout.tsx (2)
frontend/src/store/userSlice.ts (1)
  • selectUser (32-32)
frontend/src/utils/lastWorkspace.ts (1)
  • setLastWorkspaceSlug (13-22)
frontend/src/hooks/api/user.ts (2)
frontend/src/store/userSlice.ts (1)
  • selectUser (32-32)
frontend/src/utils/lastWorkspace.ts (1)
  • clearLastWorkspaceSlug (24-33)
🔇 Additional comments (10)
frontend/src/components/headers/SettingHeader.tsx (1)

22-24: LGTM!

Simplifying to a fixed "/workspace" path properly delegates workspace resolution to the WorkspaceRedirectHandler, improving separation of concerns.

frontend/src/components/common/GuestRoute.tsx (1)

14-16: LGTM!

The simplified redirect to "/workspace" correctly delegates resolution to WorkspaceRedirectHandler. Preserving location state and using replace prevents back-button issues.

backend/src/users/users.service.ts (1)

15-29: LGTM!

Simplifying findOne to return the user record directly correctly implements the removal of lastWorkspaceSlug from the API surface.

frontend/src/components/layouts/WorkspaceLayout.tsx (1)

28-32: LGTM!

The effect correctly persists the workspace slug when both user ID and slug are available. The dependencies and guard logic are appropriate.

frontend/src/hooks/api/user.ts (1)

84-93: Verify clearing last workspace slug on all query errors.

Currently, any query error triggers clearLastWorkspaceSlug, including transient network failures. Consider whether the slug should only be cleared on authentication errors (401/403) rather than all errors, to avoid losing user preferences on temporary network issues.

frontend/src/components/popovers/ProfilePopover.tsx (1)

27-31: LGTM!

The logout flow correctly clears the last workspace slug before clearing the user session, ensuring complete cleanup.

frontend/src/utils/lastWorkspace.ts (4)

1-1: LGTM!

Simple key generation function that namespaces the workspace slug per user correctly.


3-11: LGTM!

Properly guards against missing userId and handles localStorage errors gracefully by returning null.


13-22: LGTM!

Correctly guards against invalid inputs and handles localStorage errors with appropriate DEV-only logging.


24-33: LGTM!

Correctly guards and handles removal with appropriate error logging. The idempotent nature of removeItem ensures safe repeated calls.

@LeeWxx LeeWxx force-pushed the feat/last-accessed-workspace branch from 86ad3f8 to b00d4e3 Compare October 3, 2025 12:48
@LeeWxx
Copy link
Contributor Author

LeeWxx commented Oct 3, 2025

@blurfx Thanks for the suggestion! I dropped the backend field and now record the last workspace entirely on the client via localStorage. The /workspace route reads that value first and falls back to the first workspace in the list if nothing is stored.

@@ -0,0 +1,33 @@
export const getLastWorkspaceKey = (userId: string) => `cp:last-workspace:${userId}`;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
export const getLastWorkspaceKey = (userId: string) => `cp:last-workspace:${userId}`;
const getLastWorkspaceKey = (userId: string) => `cp:last-workspace:${userId}`;

getLastWorkspaceKey does not need to be exported.

socialProvider String @map("social_provider")
socialUid String @unique @map("social_uid")
nickname String?
lastWorkspaceSlug String? @map("last_workspace_slug")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
lastWorkspaceSlug String? @map("last_workspace_slug")

lastWorkspaceSlug still remains on schema

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Start Codepair from the Last Accessed Workspace

2 participants