Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 3.8k
feat(folders): add resource pinning and generalize the folders contract#6014
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
34cf6b6ebb1336cae72a6File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -5,7 +5,7 @@ import { createFolderContract, listFoldersContract } from '@/lib/api/contracts' | ||
| import { parseRequest } from '@/lib/api/server' | ||
| import { getSession } from '@/lib/auth' | ||
| import { withRouteHandler } from '@/lib/core/utils/with-route-handler' | ||
| import { listFoldersForWorkspace } from '@/lib/folders/queries' | ||
| import { listFoldersForWorkspace, toFolderApi } from '@/lib/folders/queries' | ||
| import { captureServerEvent } from '@/lib/posthog/server' | ||
| import { performCreateFolder } from '@/lib/workflows/orchestration' | ||
| import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' | ||
| @@ -29,7 +29,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { | ||
| const parsed = await parseRequest(listFoldersContract, request, {}) | ||
| if (!parsed.success) return parsed.response | ||
| const { workspaceId, scope } = parsed.data.query | ||
| const { workspaceId, resourceType, scope } = parsed.data.query | ||
| // Check if user has workspace permissions | ||
| const workspacePermission = await getUserEntityPermissions( | ||
| @@ -42,7 +42,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { | ||
| return NextResponse.json({ error: 'Access denied to this workspace' }, { status: 403 }) | ||
| } | ||
| const folders = await listFoldersForWorkspace(workspaceId, scope) | ||
| const folders = await listFoldersForWorkspace(workspaceId, scope, resourceType) | ||
| return NextResponse.json({ folders }) | ||
| } catch (error) { | ||
| @@ -66,7 +66,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { | ||
| name, | ||
| workspaceId, | ||
| parentId, | ||
| color, | ||
| sortOrder: providedSortOrder, | ||
| } = parsed.data.body | ||
| @@ -91,7 +90,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { | ||
| workspaceId, | ||
| name, | ||
| parentId, | ||
| color, | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| sortOrder: providedSortOrder, | ||
| }) | ||
| @@ -113,7 +111,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { | ||
| { groups: { workspace: workspaceId } } | ||
| ) | ||
| return NextResponse.json({ folder: newFolder }) | ||
| return NextResponse.json({ folder: toFolderApi(newFolder) }) | ||
| } catch (error) { | ||
| if (error instanceof FolderLockedError) { | ||
| return NextResponse.json({ error: error.message }, { status: error.status }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| /** | ||
| * Tests for the unpin API route. | ||
| * | ||
| * @vitest-environment node | ||
| */ | ||
| import { authMockFns, createMockRequest, schemaMock } from '@sim/testing' | ||
| import { beforeEach, describe, expect, it, vi } from 'vitest' | ||
| const { mockLogger, mockDb } = vi.hoisted(() => ({ | ||
| mockLogger: { | ||
| info: vi.fn(), | ||
| warn: vi.fn(), | ||
| error: vi.fn(), | ||
| debug: vi.fn(), | ||
| trace: vi.fn(), | ||
| fatal: vi.fn(), | ||
| child: vi.fn(), | ||
| }, | ||
| mockDb: { delete: vi.fn() }, | ||
| })) | ||
| vi.mock('@sim/logger', () => ({ | ||
| createLogger: vi.fn().mockReturnValue(mockLogger), | ||
| runWithRequestContext: <T>(_ctx: unknown, fn: () => T): T => fn(), | ||
| getRequestContext: () => undefined, | ||
| })) | ||
| vi.mock('@sim/db', () => ({ db: mockDb, ...schemaMock })) | ||
| import { DELETE } from '@/app/api/pinned-items/[resourceType]/[resourceId]/route' | ||
| const mockUser = { id: 'user-123', email: 'test@example.com', name: 'Test User' } | ||
| function routeContext(resourceType: string, resourceId: string) { | ||
| return { params: Promise.resolve({ resourceType, resourceId }) } | ||
| } | ||
| describe('Unpin API', () => { | ||
| const mockWhere = vi.fn() | ||
| const mockReturning = vi.fn() | ||
| beforeEach(() => { | ||
| vi.clearAllMocks() | ||
| mockDb.delete.mockReturnValue({ where: mockWhere }) | ||
| mockWhere.mockReturnValue({ returning: mockReturning }) | ||
| mockReturning.mockReturnValue([{ id: 'pinned-1' }]) | ||
| authMockFns.mockGetSession.mockResolvedValue({ user: mockUser }) | ||
| }) | ||
| it('unpins a resource', async () => { | ||
| const response = await DELETE( | ||
| createMockRequest('DELETE'), | ||
| routeContext('workflow', 'workflow-1') | ||
| ) | ||
| expect(response.status).toBe(200) | ||
| await expect(response.json()).resolves.toEqual({ success: true }) | ||
| expect(mockDb.delete).toHaveBeenCalled() | ||
| }) | ||
| it('returns 404 when no matching pin exists', async () => { | ||
| mockReturning.mockReturnValue([]) | ||
| const response = await DELETE( | ||
| createMockRequest('DELETE'), | ||
| routeContext('workflow', 'workflow-1') | ||
| ) | ||
| expect(response.status).toBe(404) | ||
| }) | ||
| it('rejects an unknown resourceType at the contract boundary', async () => { | ||
| const response = await DELETE(createMockRequest('DELETE'), routeContext('nope', 'resource-1')) | ||
| expect(response.status).toBe(400) | ||
| expect(mockDb.delete).not.toHaveBeenCalled() | ||
| }) | ||
| it('returns 401 when unauthenticated', async () => { | ||
| authMockFns.mockGetSession.mockResolvedValue(null) | ||
| const response = await DELETE( | ||
| createMockRequest('DELETE'), | ||
| routeContext('workflow', 'workflow-1') | ||
| ) | ||
| expect(response.status).toBe(401) | ||
| expect(mockDb.delete).not.toHaveBeenCalled() | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| import { db, pinnedItem } from '@sim/db' | ||
| import { createLogger } from '@sim/logger' | ||
| import { and, eq } from 'drizzle-orm' | ||
| import { type NextRequest, NextResponse } from 'next/server' | ||
| import { deletePinnedItemContract } from '@/lib/api/contracts' | ||
| import { parseRequest } from '@/lib/api/server' | ||
| import { getSession } from '@/lib/auth' | ||
| import { withRouteHandler } from '@/lib/core/utils/with-route-handler' | ||
| const logger = createLogger('PinnedItemDeleteAPI') | ||
| interface RouteContext { | ||
| params: Promise<{ resourceType: string; resourceId: string }> | ||
| } | ||
| /** | ||
| * Unpins a resource, addressed by its composite key rather than the pin's own id so | ||
| * callers can unpin from a resource row without first looking the pin up. | ||
| * | ||
| * No workspace permission check is needed: the delete is scoped to the session | ||
| * user's own pins, so a caller can only ever remove a row they created. | ||
| */ | ||
| export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { | ||
| const session = await getSession() | ||
| if (!session?.user?.id) { | ||
| return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) | ||
| } | ||
| const parsed = await parseRequest(deletePinnedItemContract, request, context) | ||
| if (!parsed.success) return parsed.response | ||
| const { resourceType, resourceId } = parsed.data.params | ||
| const deleted = await db | ||
| .delete(pinnedItem) | ||
| .where( | ||
| and( | ||
| eq(pinnedItem.userId, session.user.id), | ||
| eq(pinnedItem.resourceType, resourceType), | ||
| eq(pinnedItem.resourceId, resourceId) | ||
| ) | ||
| ) | ||
| .returning({ id: pinnedItem.id }) | ||
| if (deleted.length === 0) { | ||
| return NextResponse.json({ error: 'Pinned item not found' }, { status: 404 }) | ||
| } | ||
| logger.info('Unpinned resource', { resourceType, resourceId }) | ||
| return NextResponse.json({ success: true }) | ||
| }) |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.