From 64d3ad810bea43c285b22056ba73c9dabeafb7e0 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 26 Feb 2026 22:48:23 -0600 Subject: [PATCH 1/2] fix(api,web): add workspace context to widget endpoints and auto-detect workspace ID - Add WorkspaceGuard to all widget data endpoints in WidgetsController - Use RequestWithWorkspace type for proper type safety (no non-null assertions) - Auto-detect workspace ID from localStorage in apiRequest when not explicitly provided, fixing all API calls missing X-Workspace-Id header - Replace hardcoded "default-workspace-id" in credentials pages with useWorkspaceId() hook Co-Authored-By: Claude Opus 4.6 --- apps/api/src/widgets/widgets.controller.ts | 70 ++++++------------- .../settings/credentials/audit/page.tsx | 13 ++-- .../settings/credentials/page.tsx | 12 ++-- apps/web/src/lib/api/client.ts | 10 ++- 4 files changed, 43 insertions(+), 62 deletions(-) diff --git a/apps/api/src/widgets/widgets.controller.ts b/apps/api/src/widgets/widgets.controller.ts index c90c33d..fb4bd5a 100644 --- a/apps/api/src/widgets/widgets.controller.ts +++ b/apps/api/src/widgets/widgets.controller.ts @@ -1,22 +1,14 @@ -import { - Controller, - Get, - Post, - Body, - Param, - UseGuards, - Request, - UnauthorizedException, -} from "@nestjs/common"; +import { Controller, Get, Post, Body, Param, UseGuards, Request } from "@nestjs/common"; import { WidgetsService } from "./widgets.service"; import { WidgetDataService } from "./widget-data.service"; import { AuthGuard } from "../auth/guards/auth.guard"; +import { WorkspaceGuard } from "../common/guards/workspace.guard"; import type { StatCardQueryDto, ChartQueryDto, ListQueryDto, CalendarPreviewQueryDto } from "./dto"; -import type { AuthenticatedRequest } from "../common/types/user.types"; +import type { RequestWithWorkspace } from "../common/types/user.types"; /** * Controller for widget definition and data endpoints - * All endpoints require authentication + * All endpoints require authentication; data endpoints also require workspace context */ @Controller("widgets") @UseGuards(AuthGuard) @@ -51,12 +43,9 @@ export class WidgetsController { * Get stat card widget data */ @Post("data/stat-card") - async getStatCardData(@Request() req: AuthenticatedRequest, @Body() query: StatCardQueryDto) { - const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId; - if (!workspaceId) { - throw new UnauthorizedException("Workspace ID required"); - } - return this.widgetDataService.getStatCardData(workspaceId, query); + @UseGuards(WorkspaceGuard) + async getStatCardData(@Request() req: RequestWithWorkspace, @Body() query: StatCardQueryDto) { + return this.widgetDataService.getStatCardData(req.workspace.id, query); } /** @@ -64,12 +53,9 @@ export class WidgetsController { * Get chart widget data */ @Post("data/chart") - async getChartData(@Request() req: AuthenticatedRequest, @Body() query: ChartQueryDto) { - const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId; - if (!workspaceId) { - throw new UnauthorizedException("Workspace ID required"); - } - return this.widgetDataService.getChartData(workspaceId, query); + @UseGuards(WorkspaceGuard) + async getChartData(@Request() req: RequestWithWorkspace, @Body() query: ChartQueryDto) { + return this.widgetDataService.getChartData(req.workspace.id, query); } /** @@ -77,12 +63,9 @@ export class WidgetsController { * Get list widget data */ @Post("data/list") - async getListData(@Request() req: AuthenticatedRequest, @Body() query: ListQueryDto) { - const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId; - if (!workspaceId) { - throw new UnauthorizedException("Workspace ID required"); - } - return this.widgetDataService.getListData(workspaceId, query); + @UseGuards(WorkspaceGuard) + async getListData(@Request() req: RequestWithWorkspace, @Body() query: ListQueryDto) { + return this.widgetDataService.getListData(req.workspace.id, query); } /** @@ -90,15 +73,12 @@ export class WidgetsController { * Get calendar preview widget data */ @Post("data/calendar-preview") + @UseGuards(WorkspaceGuard) async getCalendarPreviewData( - @Request() req: AuthenticatedRequest, + @Request() req: RequestWithWorkspace, @Body() query: CalendarPreviewQueryDto ) { - const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId; - if (!workspaceId) { - throw new UnauthorizedException("Workspace ID required"); - } - return this.widgetDataService.getCalendarPreviewData(workspaceId, query); + return this.widgetDataService.getCalendarPreviewData(req.workspace.id, query); } /** @@ -106,12 +86,9 @@ export class WidgetsController { * Get active projects widget data */ @Post("data/active-projects") - async getActiveProjectsData(@Request() req: AuthenticatedRequest) { - const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId; - if (!workspaceId) { - throw new UnauthorizedException("Workspace ID required"); - } - return this.widgetDataService.getActiveProjectsData(workspaceId); + @UseGuards(WorkspaceGuard) + async getActiveProjectsData(@Request() req: RequestWithWorkspace) { + return this.widgetDataService.getActiveProjectsData(req.workspace.id); } /** @@ -119,11 +96,8 @@ export class WidgetsController { * Get agent chains widget data (active agent sessions) */ @Post("data/agent-chains") - async getAgentChainsData(@Request() req: AuthenticatedRequest) { - const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId; - if (!workspaceId) { - throw new UnauthorizedException("Workspace ID required"); - } - return this.widgetDataService.getAgentChainsData(workspaceId); + @UseGuards(WorkspaceGuard) + async getAgentChainsData(@Request() req: RequestWithWorkspace) { + return this.widgetDataService.getAgentChainsData(req.workspace.id); } } diff --git a/apps/web/src/app/(authenticated)/settings/credentials/audit/page.tsx b/apps/web/src/app/(authenticated)/settings/credentials/audit/page.tsx index 4eb77d4..271df01 100644 --- a/apps/web/src/app/(authenticated)/settings/credentials/audit/page.tsx +++ b/apps/web/src/app/(authenticated)/settings/credentials/audit/page.tsx @@ -14,6 +14,7 @@ import { SelectValue, } from "@/components/ui/select"; import { fetchCredentialAuditLog, type AuditLogEntry } from "@/lib/api/credentials"; +import { useWorkspaceId } from "@/lib/hooks"; const ACTIVITY_ACTIONS = [ { value: "CREDENTIAL_CREATED", label: "Created" }, @@ -39,17 +40,17 @@ export default function CredentialAuditPage(): React.ReactElement { const [filters, setFilters] = useState({}); const [hasFilters, setHasFilters] = useState(false); - // TODO: Get workspace ID from context/auth - const workspaceId = "default-workspace-id"; // Placeholder + const workspaceId = useWorkspaceId(); useEffect(() => { - void loadLogs(); - }, [page, filters]); + if (!workspaceId) return; + void loadLogs(workspaceId); + }, [workspaceId, page, filters]); - async function loadLogs(): Promise { + async function loadLogs(wsId: string): Promise { try { setIsLoading(true); - const response = await fetchCredentialAuditLog(workspaceId, { + const response = await fetchCredentialAuditLog(wsId, { ...filters, page, limit, diff --git a/apps/web/src/app/(authenticated)/settings/credentials/page.tsx b/apps/web/src/app/(authenticated)/settings/credentials/page.tsx index 5df0c66..73adb5a 100644 --- a/apps/web/src/app/(authenticated)/settings/credentials/page.tsx +++ b/apps/web/src/app/(authenticated)/settings/credentials/page.tsx @@ -6,22 +6,24 @@ import Link from "next/link"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { fetchCredentials, type Credential } from "@/lib/api/credentials"; +import { useWorkspaceId } from "@/lib/hooks"; export default function CredentialsPage(): React.ReactElement { const [credentials, setCredentials] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const workspaceId = "default-workspace-id"; + const workspaceId = useWorkspaceId(); useEffect(() => { - void loadCredentials(); - }, []); + if (!workspaceId) return; + void loadCredentials(workspaceId); + }, [workspaceId]); - async function loadCredentials(): Promise { + async function loadCredentials(wsId: string): Promise { try { setIsLoading(true); - const response = await fetchCredentials(workspaceId); + const response = await fetchCredentials(wsId); setCredentials(response.data); setError(null); } catch (err) { diff --git a/apps/web/src/lib/api/client.ts b/apps/web/src/lib/api/client.ts index 7bdd8fc..e407fcd 100644 --- a/apps/web/src/lib/api/client.ts +++ b/apps/web/src/lib/api/client.ts @@ -202,9 +202,13 @@ export async function apiRequest(endpoint: string, options: ApiRequestOptions ...baseHeaders, }; - // Add workspace ID header if provided (recommended over query string) - if (workspaceId) { - headers["X-Workspace-Id"] = workspaceId; + // Add workspace ID header — use explicit value, or auto-detect from localStorage + const resolvedWorkspaceId = + workspaceId ?? + (typeof window !== "undefined" ? localStorage.getItem("mosaic-workspace-id") : null) ?? + undefined; + if (resolvedWorkspaceId) { + headers["X-Workspace-Id"] = resolvedWorkspaceId; } // Add CSRF token for state-changing requests (POST, PUT, PATCH, DELETE) -- 2.49.1 From 676d8707fedc8a7036d4a078eecd2962bf0e3db4 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 26 Feb 2026 23:13:01 -0600 Subject: [PATCH 2/2] fix(api): add sortBy, sortOrder, search, visibility to knowledge entry query DTO The File Manager frontend sends sortBy, sortOrder, search, and visibility query parameters but the API DTO rejected them with validation errors. Adds the missing fields to EntryQueryDto and wires them into the service's findAll method with proper Prisma query building. Co-Authored-By: Claude Opus 4.6 --- apps/api/src/knowledge/dto/entry-query.dto.ts | 22 +++++++++++++++++-- apps/api/src/knowledge/knowledge.service.ts | 22 ++++++++++++++++--- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/apps/api/src/knowledge/dto/entry-query.dto.ts b/apps/api/src/knowledge/dto/entry-query.dto.ts index 5a5f97b..c455838 100644 --- a/apps/api/src/knowledge/dto/entry-query.dto.ts +++ b/apps/api/src/knowledge/dto/entry-query.dto.ts @@ -1,6 +1,6 @@ -import { IsOptional, IsEnum, IsString, IsInt, Min, Max } from "class-validator"; +import { IsOptional, IsEnum, IsString, IsInt, IsIn, Min, Max } from "class-validator"; import { Type } from "class-transformer"; -import { EntryStatus } from "@prisma/client"; +import { EntryStatus, Visibility } from "@prisma/client"; /** * DTO for querying knowledge entries (list endpoint) @@ -10,10 +10,28 @@ export class EntryQueryDto { @IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" }) status?: EntryStatus; + @IsOptional() + @IsEnum(Visibility, { message: "visibility must be a valid Visibility" }) + visibility?: Visibility; + @IsOptional() @IsString({ message: "tag must be a string" }) tag?: string; + @IsOptional() + @IsString({ message: "search must be a string" }) + search?: string; + + @IsOptional() + @IsIn(["updatedAt", "createdAt", "title"], { + message: "sortBy must be updatedAt, createdAt, or title", + }) + sortBy?: "updatedAt" | "createdAt" | "title"; + + @IsOptional() + @IsIn(["asc", "desc"], { message: "sortOrder must be asc or desc" }) + sortOrder?: "asc" | "desc"; + @IsOptional() @Type(() => Number) @IsInt({ message: "page must be an integer" }) diff --git a/apps/api/src/knowledge/knowledge.service.ts b/apps/api/src/knowledge/knowledge.service.ts index f004d91..e1ef04c 100644 --- a/apps/api/src/knowledge/knowledge.service.ts +++ b/apps/api/src/knowledge/knowledge.service.ts @@ -48,6 +48,10 @@ export class KnowledgeService { where.status = query.status; } + if (query.visibility) { + where.visibility = query.visibility; + } + if (query.tag) { where.tags = { some: { @@ -58,6 +62,20 @@ export class KnowledgeService { }; } + if (query.search) { + where.OR = [ + { title: { contains: query.search, mode: "insensitive" } }, + { content: { contains: query.search, mode: "insensitive" } }, + ]; + } + + // Build orderBy + const sortField = query.sortBy ?? "updatedAt"; + const sortDirection = query.sortOrder ?? "desc"; + const orderBy: Prisma.KnowledgeEntryOrderByWithRelationInput = { + [sortField]: sortDirection, + }; + // Get total count const total = await this.prisma.knowledgeEntry.count({ where }); @@ -71,9 +89,7 @@ export class KnowledgeService { }, }, }, - orderBy: { - updatedAt: "desc", - }, + orderBy, skip, take: limit, }); -- 2.49.1