feat(web): add workspace management UI (M2 #12)
- Create workspace listing page at /settings/workspaces - List all user workspaces with role badges - Create new workspace functionality - Display member count per workspace - Create workspace detail page at /settings/workspaces/[id] - Workspace settings (name, ID, created date) - Member management with role editing - Invite member functionality - Delete workspace (owner only) - Add workspace components: - WorkspaceCard: Display workspace info with role badge - WorkspaceSettings: Edit workspace settings and delete - MemberList: Display and manage workspace members - InviteMember: Send invitations with role selection - Add WorkspaceMemberWithUser type to shared package - Follow existing app patterns for styling and structure - Use mock data (ready for API integration)
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_preferences" (
|
||||
"id" UUID NOT NULL,
|
||||
"user_id" UUID NOT NULL,
|
||||
"theme" TEXT NOT NULL DEFAULT 'system',
|
||||
"locale" TEXT NOT NULL DEFAULT 'en',
|
||||
"timezone" TEXT,
|
||||
"settings" JSONB NOT NULL DEFAULT '{}',
|
||||
"updated_at" TIMESTAMPTZ NOT NULL,
|
||||
|
||||
CONSTRAINT "user_preferences_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_preferences_user_id_key" ON "user_preferences"("user_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_preferences" ADD CONSTRAINT "user_preferences_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -144,10 +144,24 @@ model User {
|
||||
relationships Relationship[] @relation("RelationshipCreator")
|
||||
agentSessions AgentSession[]
|
||||
userLayouts UserLayout[]
|
||||
userPreference UserPreference?
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model UserPreference {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @unique @map("user_id") @db.Uuid
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
theme String @default("system")
|
||||
locale String @default("en")
|
||||
timezone String?
|
||||
settings Json @default("{}")
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
||||
|
||||
@@map("user_preferences")
|
||||
}
|
||||
|
||||
model Workspace {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String
|
||||
|
||||
@@ -13,6 +13,7 @@ import { IdeasModule } from "./ideas/ideas.module";
|
||||
import { WidgetsModule } from "./widgets/widgets.module";
|
||||
import { LayoutsModule } from "./layouts/layouts.module";
|
||||
import { KnowledgeModule } from "./knowledge/knowledge.module";
|
||||
import { UsersModule } from "./users/users.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -28,6 +29,7 @@ import { KnowledgeModule } from "./knowledge/knowledge.module";
|
||||
WidgetsModule,
|
||||
LayoutsModule,
|
||||
KnowledgeModule,
|
||||
UsersModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
|
||||
314
apps/api/src/common/README.md
Normal file
314
apps/api/src/common/README.md
Normal file
@@ -0,0 +1,314 @@
|
||||
# Common Guards and Decorators
|
||||
|
||||
This directory contains shared guards and decorators for workspace-based permission management in the Mosaic Stack API.
|
||||
|
||||
## Overview
|
||||
|
||||
The permission system provides:
|
||||
- **Workspace isolation** via Row-Level Security (RLS)
|
||||
- **Role-based access control** (RBAC) using workspace member roles
|
||||
- **Declarative permission requirements** using decorators
|
||||
|
||||
## Guards
|
||||
|
||||
### AuthGuard
|
||||
|
||||
Located in `../auth/guards/auth.guard.ts`
|
||||
|
||||
Verifies user authentication and attaches user data to the request.
|
||||
|
||||
**Sets on request:**
|
||||
- `request.user` - Authenticated user object
|
||||
- `request.session` - User session data
|
||||
|
||||
### WorkspaceGuard
|
||||
|
||||
Validates workspace access and sets up RLS context.
|
||||
|
||||
**Responsibilities:**
|
||||
1. Extracts workspace ID from request (header, param, or body)
|
||||
2. Verifies user is a member of the workspace
|
||||
3. Sets the current user context for RLS policies
|
||||
4. Attaches workspace context to the request
|
||||
|
||||
**Sets on request:**
|
||||
- `request.workspace.id` - Validated workspace ID
|
||||
- `request.user.workspaceId` - Workspace ID (for backward compatibility)
|
||||
|
||||
**Workspace ID Sources (in priority order):**
|
||||
1. `X-Workspace-Id` header
|
||||
2. `:workspaceId` URL parameter
|
||||
3. `workspaceId` in request body
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
@Controller('tasks')
|
||||
@UseGuards(AuthGuard, WorkspaceGuard)
|
||||
export class TasksController {
|
||||
@Get()
|
||||
async getTasks(@Workspace() workspaceId: string) {
|
||||
// workspaceId is validated and RLS context is set
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### PermissionGuard
|
||||
|
||||
Enforces role-based access control using workspace member roles.
|
||||
|
||||
**Responsibilities:**
|
||||
1. Reads required permission from `@RequirePermission()` decorator
|
||||
2. Fetches user's role in the workspace
|
||||
3. Checks if role satisfies the required permission
|
||||
4. Attaches role to request for convenience
|
||||
|
||||
**Sets on request:**
|
||||
- `request.user.workspaceRole` - User's role in the workspace
|
||||
|
||||
**Must be used after AuthGuard and WorkspaceGuard.**
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
@Controller('admin')
|
||||
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||
export class AdminController {
|
||||
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||
@Delete('data')
|
||||
async deleteData() {
|
||||
// Only ADMIN or OWNER can execute
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Decorators
|
||||
|
||||
### @RequirePermission(permission: Permission)
|
||||
|
||||
Specifies the minimum permission level required for a route.
|
||||
|
||||
**Permission Levels:**
|
||||
|
||||
| Permission | Allowed Roles | Use Case |
|
||||
|------------|--------------|----------|
|
||||
| `WORKSPACE_OWNER` | OWNER | Critical operations (delete workspace, transfer ownership) |
|
||||
| `WORKSPACE_ADMIN` | OWNER, ADMIN | Administrative functions (manage members, settings) |
|
||||
| `WORKSPACE_MEMBER` | OWNER, ADMIN, MEMBER | Standard operations (create/edit content) |
|
||||
| `WORKSPACE_ANY` | All roles including GUEST | Read-only or basic access |
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||
@Post('invite')
|
||||
async inviteMember(@Body() inviteDto: InviteDto) {
|
||||
// Only admins can invite members
|
||||
}
|
||||
```
|
||||
|
||||
### @Workspace()
|
||||
|
||||
Parameter decorator to extract the validated workspace ID.
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
@Get()
|
||||
async getTasks(@Workspace() workspaceId: string) {
|
||||
// workspaceId is guaranteed to be valid
|
||||
}
|
||||
```
|
||||
|
||||
### @WorkspaceContext()
|
||||
|
||||
Parameter decorator to extract the full workspace context.
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
@Get()
|
||||
async getTasks(@WorkspaceContext() workspace: { id: string }) {
|
||||
console.log(workspace.id);
|
||||
}
|
||||
```
|
||||
|
||||
### @CurrentUser()
|
||||
|
||||
Located in `../auth/decorators/current-user.decorator.ts`
|
||||
|
||||
Extracts the authenticated user from the request.
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
@Post()
|
||||
async create(@CurrentUser() user: any, @Body() dto: CreateDto) {
|
||||
// user contains authenticated user data
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Patterns
|
||||
|
||||
### Basic Controller Setup
|
||||
|
||||
```typescript
|
||||
import { Controller, Get, Post, UseGuards } from "@nestjs/common";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||
import { Workspace, Permission, RequirePermission } from "../common/decorators";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
||||
|
||||
@Controller('resources')
|
||||
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||
export class ResourcesController {
|
||||
@Get()
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async list(@Workspace() workspaceId: string) {
|
||||
// All members can list
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
async create(
|
||||
@Workspace() workspaceId: string,
|
||||
@CurrentUser() user: any,
|
||||
@Body() dto: CreateDto
|
||||
) {
|
||||
// Members and above can create
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||
async delete(@Param('id') id: string) {
|
||||
// Only admins can delete
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Mixed Permissions
|
||||
|
||||
Different endpoints can have different permission requirements:
|
||||
|
||||
```typescript
|
||||
@Controller('projects')
|
||||
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||
export class ProjectsController {
|
||||
@Get()
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async list() { /* Anyone can view */ }
|
||||
|
||||
@Post()
|
||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
async create() { /* Members can create */ }
|
||||
|
||||
@Patch('settings')
|
||||
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||
async updateSettings() { /* Only admins */ }
|
||||
|
||||
@Delete()
|
||||
@RequirePermission(Permission.WORKSPACE_OWNER)
|
||||
async deleteProject() { /* Only owner */ }
|
||||
}
|
||||
```
|
||||
|
||||
### Workspace ID in Request
|
||||
|
||||
The workspace ID can be provided in multiple ways:
|
||||
|
||||
**Via Header (Recommended for SPAs):**
|
||||
```typescript
|
||||
// Frontend
|
||||
fetch('/api/tasks', {
|
||||
headers: {
|
||||
'Authorization': 'Bearer <token>',
|
||||
'X-Workspace-Id': 'workspace-uuid',
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Via URL Parameter:**
|
||||
```typescript
|
||||
@Get(':workspaceId/tasks')
|
||||
async getTasks(@Param('workspaceId') workspaceId: string) {
|
||||
// workspaceId extracted from URL
|
||||
}
|
||||
```
|
||||
|
||||
**Via Request Body:**
|
||||
```typescript
|
||||
@Post()
|
||||
async create(@Body() dto: { workspaceId: string; name: string }) {
|
||||
// workspaceId extracted from body
|
||||
}
|
||||
```
|
||||
|
||||
## Row-Level Security (RLS)
|
||||
|
||||
When `WorkspaceGuard` is applied, it automatically:
|
||||
1. Calls `setCurrentUser(userId)` to set the RLS context
|
||||
2. All subsequent database queries are automatically filtered by RLS policies
|
||||
3. Users can only access data in workspaces they're members of
|
||||
|
||||
**See:** `docs/design/multi-tenant-rls.md` for full RLS documentation.
|
||||
|
||||
## Testing
|
||||
|
||||
Tests are provided for both guards:
|
||||
- `workspace.guard.spec.ts` - WorkspaceGuard tests
|
||||
- `permission.guard.spec.ts` - PermissionGuard tests
|
||||
|
||||
**Run tests:**
|
||||
```bash
|
||||
npm test -- workspace.guard.spec
|
||||
npm test -- permission.guard.spec
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### WorkspaceGuard Errors
|
||||
|
||||
- `ForbiddenException("User not authenticated")` - No authenticated user
|
||||
- `BadRequestException("Workspace ID is required...")` - No workspace ID provided
|
||||
- `ForbiddenException("You do not have access to this workspace")` - User is not a workspace member
|
||||
|
||||
### PermissionGuard Errors
|
||||
|
||||
- `ForbiddenException("Authentication and workspace context required")` - Missing user or workspace context
|
||||
- `ForbiddenException("You are not a member of this workspace")` - User not found in workspace
|
||||
- `ForbiddenException("Insufficient permissions. Required: ...")` - User role doesn't meet requirements
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### Before (Manual Checks):
|
||||
|
||||
```typescript
|
||||
@Get()
|
||||
async getTasks(@Request() req: any) {
|
||||
const workspaceId = req.user?.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new UnauthorizedException("Authentication required");
|
||||
}
|
||||
return this.tasksService.findAll(workspaceId);
|
||||
}
|
||||
```
|
||||
|
||||
### After (Guard-Based):
|
||||
|
||||
```typescript
|
||||
@Get()
|
||||
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async getTasks(@Workspace() workspaceId: string) {
|
||||
return this.tasksService.findAll(workspaceId);
|
||||
}
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
✅ **Declarative** - Permission requirements are clear from decorators
|
||||
✅ **DRY** - No repetitive auth/workspace checks in every handler
|
||||
✅ **Type-safe** - Workspace ID is guaranteed to exist when using `@Workspace()`
|
||||
✅ **Secure** - RLS context automatically set, defense in depth
|
||||
✅ **Testable** - Guards are independently testable
|
||||
✅ **Maintainable** - Permission changes in one place
|
||||
|
||||
## Related Files
|
||||
|
||||
- `apps/api/src/lib/db-context.ts` - RLS utility functions
|
||||
- `docs/design/multi-tenant-rls.md` - RLS architecture documentation
|
||||
- `apps/api/prisma/schema.prisma` - Database schema with role definitions
|
||||
2
apps/api/src/common/decorators/index.ts
Normal file
2
apps/api/src/common/decorators/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./permissions.decorator";
|
||||
export * from "./workspace.decorator";
|
||||
48
apps/api/src/common/decorators/permissions.decorator.ts
Normal file
48
apps/api/src/common/decorators/permissions.decorator.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { SetMetadata } from "@nestjs/common";
|
||||
|
||||
/**
|
||||
* Permission levels for workspace access control.
|
||||
* These map to WorkspaceMemberRole from the database schema.
|
||||
*/
|
||||
export enum Permission {
|
||||
/** Requires OWNER role - full control over workspace */
|
||||
WORKSPACE_OWNER = "workspace:owner",
|
||||
|
||||
/** Requires ADMIN or OWNER role - administrative functions */
|
||||
WORKSPACE_ADMIN = "workspace:admin",
|
||||
|
||||
/** Requires MEMBER, ADMIN, or OWNER role - standard access */
|
||||
WORKSPACE_MEMBER = "workspace:member",
|
||||
|
||||
/** Any authenticated workspace member including GUEST */
|
||||
WORKSPACE_ANY = "workspace:any",
|
||||
}
|
||||
|
||||
export const PERMISSION_KEY = "permission";
|
||||
|
||||
/**
|
||||
* Decorator to specify required permission level for a route.
|
||||
* Use with PermissionGuard to enforce role-based access control.
|
||||
*
|
||||
* @param permission - The minimum permission level required
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||
* @Delete(':id')
|
||||
* async deleteWorkspace(@Param('id') id: string) {
|
||||
* // Only ADMIN or OWNER can execute this
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
* @Get()
|
||||
* async getTasks() {
|
||||
* // Any workspace member can execute this
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const RequirePermission = (permission: Permission) =>
|
||||
SetMetadata(PERMISSION_KEY, permission);
|
||||
40
apps/api/src/common/decorators/workspace.decorator.ts
Normal file
40
apps/api/src/common/decorators/workspace.decorator.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
|
||||
|
||||
/**
|
||||
* Decorator to extract workspace ID from the request.
|
||||
* Must be used with WorkspaceGuard which validates and attaches the workspace.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @Get()
|
||||
* @UseGuards(AuthGuard, WorkspaceGuard)
|
||||
* async getTasks(@Workspace() workspaceId: string) {
|
||||
* // workspaceId is validated and ready to use
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const Workspace = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext): string => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.workspace?.id;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Decorator to extract full workspace context from the request.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @Get()
|
||||
* @UseGuards(AuthGuard, WorkspaceGuard)
|
||||
* async getTasks(@WorkspaceContext() workspace: { id: string }) {
|
||||
* console.log(workspace.id);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const WorkspaceContext = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.workspace;
|
||||
}
|
||||
);
|
||||
2
apps/api/src/common/guards/index.ts
Normal file
2
apps/api/src/common/guards/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./workspace.guard";
|
||||
export * from "./permission.guard";
|
||||
278
apps/api/src/common/guards/permission.guard.spec.ts
Normal file
278
apps/api/src/common/guards/permission.guard.spec.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { PermissionGuard } from "./permission.guard";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { Permission } from "../decorators/permissions.decorator";
|
||||
import { WorkspaceMemberRole } from "@prisma/client";
|
||||
|
||||
describe("PermissionGuard", () => {
|
||||
let guard: PermissionGuard;
|
||||
let reflector: Reflector;
|
||||
let prismaService: PrismaService;
|
||||
|
||||
const mockReflector = {
|
||||
getAllAndOverride: vi.fn(),
|
||||
};
|
||||
|
||||
const mockPrismaService = {
|
||||
workspaceMember: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
PermissionGuard,
|
||||
{
|
||||
provide: Reflector,
|
||||
useValue: mockReflector,
|
||||
},
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: mockPrismaService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
guard = module.get<PermissionGuard>(PermissionGuard);
|
||||
reflector = module.get<Reflector>(Reflector);
|
||||
prismaService = module.get<PrismaService>(PrismaService);
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const createMockExecutionContext = (
|
||||
user: any,
|
||||
workspace: any
|
||||
): ExecutionContext => {
|
||||
const mockRequest = {
|
||||
user,
|
||||
workspace,
|
||||
};
|
||||
|
||||
return {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => mockRequest,
|
||||
}),
|
||||
getHandler: vi.fn(),
|
||||
getClass: vi.fn(),
|
||||
} as any;
|
||||
};
|
||||
|
||||
describe("canActivate", () => {
|
||||
const userId = "user-123";
|
||||
const workspaceId = "workspace-456";
|
||||
|
||||
it("should allow access when no permission is required", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(undefined);
|
||||
|
||||
const result = await guard.canActivate(context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow OWNER to access WORKSPACE_OWNER permission", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_OWNER);
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
role: WorkspaceMemberRole.OWNER,
|
||||
});
|
||||
|
||||
const result = await guard.canActivate(context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(context.switchToHttp().getRequest().user.workspaceRole).toBe(
|
||||
WorkspaceMemberRole.OWNER
|
||||
);
|
||||
});
|
||||
|
||||
it("should deny ADMIN access to WORKSPACE_OWNER permission", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_OWNER);
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
role: WorkspaceMemberRole.ADMIN,
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow OWNER and ADMIN to access WORKSPACE_ADMIN permission", async () => {
|
||||
const context1 = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context2 = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_ADMIN);
|
||||
|
||||
// Test OWNER
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
role: WorkspaceMemberRole.OWNER,
|
||||
});
|
||||
expect(await guard.canActivate(context1)).toBe(true);
|
||||
|
||||
// Test ADMIN
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
role: WorkspaceMemberRole.ADMIN,
|
||||
});
|
||||
expect(await guard.canActivate(context2)).toBe(true);
|
||||
});
|
||||
|
||||
it("should deny MEMBER access to WORKSPACE_ADMIN permission", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_ADMIN);
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
role: WorkspaceMemberRole.MEMBER,
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow OWNER, ADMIN, and MEMBER to access WORKSPACE_MEMBER permission", async () => {
|
||||
const context1 = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context2 = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context3 = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
|
||||
|
||||
// Test OWNER
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
role: WorkspaceMemberRole.OWNER,
|
||||
});
|
||||
expect(await guard.canActivate(context1)).toBe(true);
|
||||
|
||||
// Test ADMIN
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
role: WorkspaceMemberRole.ADMIN,
|
||||
});
|
||||
expect(await guard.canActivate(context2)).toBe(true);
|
||||
|
||||
// Test MEMBER
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
role: WorkspaceMemberRole.MEMBER,
|
||||
});
|
||||
expect(await guard.canActivate(context3)).toBe(true);
|
||||
});
|
||||
|
||||
it("should deny GUEST access to WORKSPACE_MEMBER permission", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
role: WorkspaceMemberRole.GUEST,
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow any role (including GUEST) to access WORKSPACE_ANY permission", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_ANY);
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
role: WorkspaceMemberRole.GUEST,
|
||||
});
|
||||
|
||||
const result = await guard.canActivate(context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should throw ForbiddenException when user context is missing", async () => {
|
||||
const context = createMockExecutionContext(null, { id: workspaceId });
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw ForbiddenException when workspace context is missing", async () => {
|
||||
const context = createMockExecutionContext({ id: userId }, null);
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw ForbiddenException when user is not a workspace member", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
"You are not a member of this workspace"
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle database errors gracefully", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
|
||||
mockPrismaService.workspaceMember.findUnique.mockRejectedValue(
|
||||
new Error("Database error")
|
||||
);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
165
apps/api/src/common/guards/permission.guard.ts
Normal file
165
apps/api/src/common/guards/permission.guard.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { PERMISSION_KEY, Permission } from "../decorators/permissions.decorator";
|
||||
import { WorkspaceMemberRole } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* PermissionGuard enforces role-based access control for workspace operations.
|
||||
*
|
||||
* This guard must be used after AuthGuard and WorkspaceGuard, as it depends on:
|
||||
* - request.user.id (set by AuthGuard)
|
||||
* - request.workspace.id (set by WorkspaceGuard)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @Controller('workspaces')
|
||||
* @UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||
* export class WorkspacesController {
|
||||
* @RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||
* @Delete(':id')
|
||||
* async deleteWorkspace() {
|
||||
* // Only ADMIN or OWNER can execute this
|
||||
* }
|
||||
*
|
||||
* @RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
* @Get('tasks')
|
||||
* async getTasks() {
|
||||
* // Any workspace member can execute this
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@Injectable()
|
||||
export class PermissionGuard implements CanActivate {
|
||||
private readonly logger = new Logger(PermissionGuard.name);
|
||||
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly prisma: PrismaService
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
// Get required permission from decorator
|
||||
const requiredPermission = this.reflector.getAllAndOverride<Permission>(
|
||||
PERMISSION_KEY,
|
||||
[context.getHandler(), context.getClass()]
|
||||
);
|
||||
|
||||
// If no permission is specified, allow access
|
||||
if (!requiredPermission) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const userId = request.user?.id;
|
||||
const workspaceId = request.workspace?.id;
|
||||
|
||||
if (!userId || !workspaceId) {
|
||||
this.logger.error(
|
||||
"PermissionGuard: Missing user or workspace context. Ensure AuthGuard and WorkspaceGuard are applied first."
|
||||
);
|
||||
throw new ForbiddenException(
|
||||
"Authentication and workspace context required"
|
||||
);
|
||||
}
|
||||
|
||||
// Get user's role in the workspace
|
||||
const userRole = await this.getUserWorkspaceRole(userId, workspaceId);
|
||||
|
||||
if (!userRole) {
|
||||
throw new ForbiddenException("You are not a member of this workspace");
|
||||
}
|
||||
|
||||
// Check if user's role meets the required permission
|
||||
const hasPermission = this.checkPermission(userRole, requiredPermission);
|
||||
|
||||
if (!hasPermission) {
|
||||
this.logger.warn(
|
||||
`Permission denied: User ${userId} with role ${userRole} attempted to access ${requiredPermission} in workspace ${workspaceId}`
|
||||
);
|
||||
throw new ForbiddenException(
|
||||
`Insufficient permissions. Required: ${requiredPermission}`
|
||||
);
|
||||
}
|
||||
|
||||
// Attach role to request for convenience
|
||||
request.user.workspaceRole = userRole;
|
||||
|
||||
this.logger.debug(
|
||||
`Permission granted: User ${userId} (${userRole}) → ${requiredPermission}`
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the user's role in a workspace
|
||||
*/
|
||||
private async getUserWorkspaceRole(
|
||||
userId: string,
|
||||
workspaceId: string
|
||||
): Promise<WorkspaceMemberRole | null> {
|
||||
try {
|
||||
const member = await this.prisma.workspaceMember.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
return member?.role ?? null;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to fetch user role: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a user's role satisfies the required permission level
|
||||
*/
|
||||
private checkPermission(
|
||||
userRole: WorkspaceMemberRole,
|
||||
requiredPermission: Permission
|
||||
): boolean {
|
||||
switch (requiredPermission) {
|
||||
case Permission.WORKSPACE_OWNER:
|
||||
return userRole === WorkspaceMemberRole.OWNER;
|
||||
|
||||
case Permission.WORKSPACE_ADMIN:
|
||||
return (
|
||||
userRole === WorkspaceMemberRole.OWNER ||
|
||||
userRole === WorkspaceMemberRole.ADMIN
|
||||
);
|
||||
|
||||
case Permission.WORKSPACE_MEMBER:
|
||||
return (
|
||||
userRole === WorkspaceMemberRole.OWNER ||
|
||||
userRole === WorkspaceMemberRole.ADMIN ||
|
||||
userRole === WorkspaceMemberRole.MEMBER
|
||||
);
|
||||
|
||||
case Permission.WORKSPACE_ANY:
|
||||
// Any role including GUEST
|
||||
return true;
|
||||
|
||||
default:
|
||||
this.logger.error(`Unknown permission: ${requiredPermission}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
219
apps/api/src/common/guards/workspace.guard.spec.ts
Normal file
219
apps/api/src/common/guards/workspace.guard.spec.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { ExecutionContext, ForbiddenException, BadRequestException } from "@nestjs/common";
|
||||
import { WorkspaceGuard } from "./workspace.guard";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import * as dbContext from "../../lib/db-context";
|
||||
|
||||
// Mock the db-context module
|
||||
vi.mock("../../lib/db-context", () => ({
|
||||
setCurrentUser: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("WorkspaceGuard", () => {
|
||||
let guard: WorkspaceGuard;
|
||||
let prismaService: PrismaService;
|
||||
|
||||
const mockPrismaService = {
|
||||
workspaceMember: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
$executeRaw: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
WorkspaceGuard,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: mockPrismaService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
guard = module.get<WorkspaceGuard>(WorkspaceGuard);
|
||||
prismaService = module.get<PrismaService>(PrismaService);
|
||||
|
||||
// Clear all mocks
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const createMockExecutionContext = (
|
||||
user: any,
|
||||
headers: Record<string, string> = {},
|
||||
params: Record<string, string> = {},
|
||||
body: Record<string, any> = {}
|
||||
): ExecutionContext => {
|
||||
const mockRequest = {
|
||||
user,
|
||||
headers,
|
||||
params,
|
||||
body,
|
||||
};
|
||||
|
||||
return {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => mockRequest,
|
||||
}),
|
||||
} as ExecutionContext;
|
||||
};
|
||||
|
||||
describe("canActivate", () => {
|
||||
const userId = "user-123";
|
||||
const workspaceId = "workspace-456";
|
||||
|
||||
it("should allow access when user is a workspace member (via header)", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ "x-workspace-id": workspaceId }
|
||||
);
|
||||
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
workspaceId,
|
||||
userId,
|
||||
role: "MEMBER",
|
||||
});
|
||||
|
||||
const result = await guard.canActivate(context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockPrismaService.workspaceMember.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(dbContext.setCurrentUser).toHaveBeenCalledWith(userId, prismaService);
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
expect(request.workspace).toEqual({ id: workspaceId });
|
||||
expect(request.user.workspaceId).toBe(workspaceId);
|
||||
});
|
||||
|
||||
it("should allow access when user is a workspace member (via URL param)", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{},
|
||||
{ workspaceId }
|
||||
);
|
||||
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
workspaceId,
|
||||
userId,
|
||||
role: "ADMIN",
|
||||
});
|
||||
|
||||
const result = await guard.canActivate(context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow access when user is a workspace member (via body)", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{},
|
||||
{},
|
||||
{ workspaceId }
|
||||
);
|
||||
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
workspaceId,
|
||||
userId,
|
||||
role: "OWNER",
|
||||
});
|
||||
|
||||
const result = await guard.canActivate(context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should prioritize header over param and body", async () => {
|
||||
const headerWorkspaceId = "workspace-header";
|
||||
const paramWorkspaceId = "workspace-param";
|
||||
const bodyWorkspaceId = "workspace-body";
|
||||
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ "x-workspace-id": headerWorkspaceId },
|
||||
{ workspaceId: paramWorkspaceId },
|
||||
{ workspaceId: bodyWorkspaceId }
|
||||
);
|
||||
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
workspaceId: headerWorkspaceId,
|
||||
userId,
|
||||
role: "MEMBER",
|
||||
});
|
||||
|
||||
await guard.canActivate(context);
|
||||
|
||||
expect(mockPrismaService.workspaceMember.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId: headerWorkspaceId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw ForbiddenException when user is not authenticated", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
null,
|
||||
{ "x-workspace-id": workspaceId }
|
||||
);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
"User not authenticated"
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw BadRequestException when workspace ID is missing", async () => {
|
||||
const context = createMockExecutionContext({ id: userId });
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
BadRequestException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
"Workspace ID is required"
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw ForbiddenException when user is not a workspace member", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ "x-workspace-id": workspaceId }
|
||||
);
|
||||
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
"You do not have access to this workspace"
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle database errors gracefully", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ "x-workspace-id": workspaceId }
|
||||
);
|
||||
|
||||
mockPrismaService.workspaceMember.findUnique.mockRejectedValue(
|
||||
new Error("Database connection failed")
|
||||
);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
150
apps/api/src/common/guards/workspace.guard.ts
Normal file
150
apps/api/src/common/guards/workspace.guard.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { setCurrentUser } from "../../lib/db-context";
|
||||
|
||||
/**
|
||||
* WorkspaceGuard ensures that:
|
||||
* 1. A workspace is specified in the request (header, param, or body)
|
||||
* 2. The authenticated user is a member of that workspace
|
||||
* 3. The user context is set for Row-Level Security (RLS)
|
||||
*
|
||||
* This guard should be used in combination with AuthGuard:
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @Controller('tasks')
|
||||
* @UseGuards(AuthGuard, WorkspaceGuard)
|
||||
* export class TasksController {
|
||||
* @Get()
|
||||
* async getTasks(@Workspace() workspaceId: string) {
|
||||
* // workspaceId is verified and available
|
||||
* // RLS context is automatically set
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* The workspace ID can be provided via:
|
||||
* - Header: `X-Workspace-Id`
|
||||
* - URL parameter: `:workspaceId`
|
||||
* - Request body: `workspaceId` field
|
||||
*
|
||||
* Priority: Header > Param > Body
|
||||
*/
|
||||
@Injectable()
|
||||
export class WorkspaceGuard implements CanActivate {
|
||||
private readonly logger = new Logger(WorkspaceGuard.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
if (!user || !user.id) {
|
||||
throw new ForbiddenException("User not authenticated");
|
||||
}
|
||||
|
||||
// Extract workspace ID from request
|
||||
const workspaceId = this.extractWorkspaceId(request);
|
||||
|
||||
if (!workspaceId) {
|
||||
throw new BadRequestException(
|
||||
"Workspace ID is required (via header X-Workspace-Id, URL parameter, or request body)"
|
||||
);
|
||||
}
|
||||
|
||||
// Verify user is a member of the workspace
|
||||
const isMember = await this.verifyWorkspaceMembership(
|
||||
user.id,
|
||||
workspaceId
|
||||
);
|
||||
|
||||
if (!isMember) {
|
||||
this.logger.warn(
|
||||
`Access denied: User ${user.id} is not a member of workspace ${workspaceId}`
|
||||
);
|
||||
throw new ForbiddenException(
|
||||
"You do not have access to this workspace"
|
||||
);
|
||||
}
|
||||
|
||||
// Set RLS context for this request
|
||||
await setCurrentUser(user.id, this.prisma);
|
||||
|
||||
// Attach workspace info to request for convenience
|
||||
request.workspace = {
|
||||
id: workspaceId,
|
||||
};
|
||||
|
||||
// Also attach workspaceId to user object for backward compatibility
|
||||
request.user.workspaceId = workspaceId;
|
||||
|
||||
this.logger.debug(
|
||||
`Workspace access granted: User ${user.id} → Workspace ${workspaceId}`
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts workspace ID from request in order of priority:
|
||||
* 1. X-Workspace-Id header
|
||||
* 2. :workspaceId URL parameter
|
||||
* 3. workspaceId in request body
|
||||
*/
|
||||
private extractWorkspaceId(request: any): string | undefined {
|
||||
// 1. Check header
|
||||
const headerWorkspaceId = request.headers["x-workspace-id"];
|
||||
if (headerWorkspaceId) {
|
||||
return headerWorkspaceId;
|
||||
}
|
||||
|
||||
// 2. Check URL params
|
||||
const paramWorkspaceId = request.params?.workspaceId;
|
||||
if (paramWorkspaceId) {
|
||||
return paramWorkspaceId;
|
||||
}
|
||||
|
||||
// 3. Check request body
|
||||
const bodyWorkspaceId = request.body?.workspaceId;
|
||||
if (bodyWorkspaceId) {
|
||||
return bodyWorkspaceId;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that a user is a member of the specified workspace
|
||||
*/
|
||||
private async verifyWorkspaceMembership(
|
||||
userId: string,
|
||||
workspaceId: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const member = await this.prisma.workspaceMember.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return member !== null;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to verify workspace membership: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
apps/api/src/common/index.ts
Normal file
2
apps/api/src/common/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./decorators";
|
||||
export * from "./guards";
|
||||
@@ -8,114 +8,96 @@ import {
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { KnowledgeService } from "./knowledge.service";
|
||||
import { CreateEntryDto, UpdateEntryDto, EntryQueryDto } from "./dto";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||
import { Workspace, Permission, RequirePermission } from "../common/decorators";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
||||
|
||||
/**
|
||||
* Controller for knowledge entry endpoints
|
||||
* All endpoints require authentication and enforce workspace isolation
|
||||
* All endpoints require authentication and workspace context
|
||||
* Uses the new guard-based permission system
|
||||
*/
|
||||
@Controller("knowledge/entries")
|
||||
@UseGuards(AuthGuard)
|
||||
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||
export class KnowledgeController {
|
||||
constructor(private readonly knowledgeService: KnowledgeService) {}
|
||||
|
||||
/**
|
||||
* GET /api/knowledge/entries
|
||||
* List all entries in the workspace with pagination and filtering
|
||||
* Requires: Any workspace member
|
||||
*/
|
||||
@Get()
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async findAll(
|
||||
@Request() req: any,
|
||||
@Workspace() workspaceId: string,
|
||||
@Query() query: EntryQueryDto
|
||||
) {
|
||||
const workspaceId = req.user?.workspaceId;
|
||||
|
||||
if (!workspaceId) {
|
||||
throw new UnauthorizedException("Workspace context required");
|
||||
}
|
||||
|
||||
return this.knowledgeService.findAll(workspaceId, query);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/knowledge/entries/:slug
|
||||
* Get a single entry by slug
|
||||
* Requires: Any workspace member
|
||||
*/
|
||||
@Get(":slug")
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async findOne(
|
||||
@Request() req: any,
|
||||
@Workspace() workspaceId: string,
|
||||
@Param("slug") slug: string
|
||||
) {
|
||||
const workspaceId = req.user?.workspaceId;
|
||||
|
||||
if (!workspaceId) {
|
||||
throw new UnauthorizedException("Workspace context required");
|
||||
}
|
||||
|
||||
return this.knowledgeService.findOne(workspaceId, slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/knowledge/entries
|
||||
* Create a new knowledge entry
|
||||
* Requires: MEMBER role or higher
|
||||
*/
|
||||
@Post()
|
||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
async create(
|
||||
@Request() req: any,
|
||||
@Workspace() workspaceId: string,
|
||||
@CurrentUser() user: any,
|
||||
@Body() createDto: CreateEntryDto
|
||||
) {
|
||||
const workspaceId = req.user?.workspaceId;
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!workspaceId || !userId) {
|
||||
throw new UnauthorizedException("Authentication required");
|
||||
}
|
||||
|
||||
return this.knowledgeService.create(workspaceId, userId, createDto);
|
||||
return this.knowledgeService.create(workspaceId, user.id, createDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/knowledge/entries/:slug
|
||||
* Update an existing entry
|
||||
* Requires: MEMBER role or higher
|
||||
*/
|
||||
@Put(":slug")
|
||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
async update(
|
||||
@Request() req: any,
|
||||
@Workspace() workspaceId: string,
|
||||
@Param("slug") slug: string,
|
||||
@CurrentUser() user: any,
|
||||
@Body() updateDto: UpdateEntryDto
|
||||
) {
|
||||
const workspaceId = req.user?.workspaceId;
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!workspaceId || !userId) {
|
||||
throw new UnauthorizedException("Authentication required");
|
||||
}
|
||||
|
||||
return this.knowledgeService.update(workspaceId, slug, userId, updateDto);
|
||||
return this.knowledgeService.update(workspaceId, slug, user.id, updateDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/knowledge/entries/:slug
|
||||
* Soft delete an entry (sets status to ARCHIVED)
|
||||
* Requires: ADMIN role or higher
|
||||
*/
|
||||
@Delete(":slug")
|
||||
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||
async remove(
|
||||
@Request() req: any,
|
||||
@Param("slug") slug: string
|
||||
@Workspace() workspaceId: string,
|
||||
@Param("slug") slug: string,
|
||||
@CurrentUser() user: any
|
||||
) {
|
||||
const workspaceId = req.user?.workspaceId;
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!workspaceId || !userId) {
|
||||
throw new UnauthorizedException("Authentication required");
|
||||
}
|
||||
|
||||
await this.knowledgeService.remove(workspaceId, slug, userId);
|
||||
await this.knowledgeService.remove(workspaceId, slug, user.id);
|
||||
return { message: "Entry archived successfully" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,18 @@
|
||||
* @see docs/design/multi-tenant-rls.md for full documentation
|
||||
*/
|
||||
|
||||
import { prisma } from '@mosaic/database';
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
// Global prisma instance for standalone usage
|
||||
// Note: In NestJS controllers/services, inject PrismaService instead
|
||||
let prisma: PrismaClient | null = null;
|
||||
|
||||
function getPrismaInstance(): PrismaClient {
|
||||
if (!prisma) {
|
||||
prisma = new PrismaClient();
|
||||
}
|
||||
return prisma;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the current user ID for RLS policies.
|
||||
@@ -26,9 +36,10 @@ import type { PrismaClient } from '@prisma/client';
|
||||
*/
|
||||
export async function setCurrentUser(
|
||||
userId: string,
|
||||
client: PrismaClient = prisma
|
||||
client?: PrismaClient
|
||||
): Promise<void> {
|
||||
await client.$executeRaw`SET LOCAL app.current_user_id = ${userId}`;
|
||||
const prismaClient = client || getPrismaInstance();
|
||||
await prismaClient.$executeRaw`SET LOCAL app.current_user_id = ${userId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,9 +49,10 @@ export async function setCurrentUser(
|
||||
* @param client - Optional Prisma client (defaults to global prisma)
|
||||
*/
|
||||
export async function clearCurrentUser(
|
||||
client: PrismaClient = prisma
|
||||
client?: PrismaClient
|
||||
): Promise<void> {
|
||||
await client.$executeRaw`SET LOCAL app.current_user_id = NULL`;
|
||||
const prismaClient = client || getPrismaInstance();
|
||||
await prismaClient.$executeRaw`SET LOCAL app.current_user_id = NULL`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,10 +115,11 @@ export async function withUserContext<T>(
|
||||
*/
|
||||
export async function withUserTransaction<T>(
|
||||
userId: string,
|
||||
fn: (tx: PrismaClient) => Promise<T>
|
||||
fn: (tx: any) => Promise<T>
|
||||
): Promise<T> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
await setCurrentUser(userId, tx);
|
||||
const prismaClient = getPrismaInstance();
|
||||
return prismaClient.$transaction(async (tx) => {
|
||||
await setCurrentUser(userId, tx as PrismaClient);
|
||||
return fn(tx);
|
||||
});
|
||||
}
|
||||
@@ -155,8 +168,9 @@ export async function verifyWorkspaceAccess(
|
||||
userId: string,
|
||||
workspaceId: string
|
||||
): Promise<boolean> {
|
||||
const prismaClient = getPrismaInstance();
|
||||
return withUserContext(userId, async () => {
|
||||
const member = await prisma.workspaceMember.findUnique({
|
||||
const member = await prismaClient.workspaceMember.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId,
|
||||
@@ -181,8 +195,9 @@ export async function verifyWorkspaceAccess(
|
||||
* ```
|
||||
*/
|
||||
export async function getUserWorkspaces(userId: string) {
|
||||
const prismaClient = getPrismaInstance();
|
||||
return withUserContext(userId, async () => {
|
||||
return prisma.workspace.findMany({
|
||||
return prismaClient.workspace.findMany({
|
||||
include: {
|
||||
members: {
|
||||
where: { userId },
|
||||
@@ -204,8 +219,9 @@ export async function isWorkspaceAdmin(
|
||||
userId: string,
|
||||
workspaceId: string
|
||||
): Promise<boolean> {
|
||||
const prismaClient = getPrismaInstance();
|
||||
return withUserContext(userId, async () => {
|
||||
const member = await prisma.workspaceMember.findUnique({
|
||||
const member = await prismaClient.workspaceMember.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId,
|
||||
|
||||
@@ -8,97 +8,96 @@ import {
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { TasksService } from "./tasks.service";
|
||||
import { CreateTaskDto, UpdateTaskDto, QueryTasksDto } from "./dto";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||
import { Workspace, Permission, RequirePermission } from "../common/decorators";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
||||
|
||||
/**
|
||||
* Controller for task endpoints
|
||||
* All endpoints require authentication
|
||||
* All endpoints require authentication and workspace context
|
||||
*
|
||||
* Guards are applied in order:
|
||||
* 1. AuthGuard - Verifies user authentication
|
||||
* 2. WorkspaceGuard - Validates workspace access and sets RLS context
|
||||
* 3. PermissionGuard - Checks role-based permissions
|
||||
*/
|
||||
@Controller("tasks")
|
||||
@UseGuards(AuthGuard)
|
||||
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||
export class TasksController {
|
||||
constructor(private readonly tasksService: TasksService) {}
|
||||
|
||||
/**
|
||||
* POST /api/tasks
|
||||
* Create a new task
|
||||
* Requires: MEMBER role or higher
|
||||
*/
|
||||
@Post()
|
||||
async create(@Body() createTaskDto: CreateTaskDto, @Request() req: any) {
|
||||
const workspaceId = req.user?.workspaceId;
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!workspaceId || !userId) {
|
||||
throw new UnauthorizedException("Authentication required");
|
||||
}
|
||||
|
||||
return this.tasksService.create(workspaceId, userId, createTaskDto);
|
||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
async create(
|
||||
@Body() createTaskDto: CreateTaskDto,
|
||||
@Workspace() workspaceId: string,
|
||||
@CurrentUser() user: any
|
||||
) {
|
||||
return this.tasksService.create(workspaceId, user.id, createTaskDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/tasks
|
||||
* Get paginated tasks with optional filters
|
||||
* Requires: Any workspace member (including GUEST)
|
||||
*/
|
||||
@Get()
|
||||
async findAll(@Query() query: QueryTasksDto, @Request() req: any) {
|
||||
const workspaceId = req.user?.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new UnauthorizedException("Authentication required");
|
||||
}
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async findAll(
|
||||
@Query() query: QueryTasksDto,
|
||||
@Workspace() workspaceId: string
|
||||
) {
|
||||
return this.tasksService.findAll({ ...query, workspaceId });
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/tasks/:id
|
||||
* Get a single task by ID
|
||||
* Requires: Any workspace member
|
||||
*/
|
||||
@Get(":id")
|
||||
async findOne(@Param("id") id: string, @Request() req: any) {
|
||||
const workspaceId = req.user?.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new UnauthorizedException("Authentication required");
|
||||
}
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async findOne(@Param("id") id: string, @Workspace() workspaceId: string) {
|
||||
return this.tasksService.findOne(id, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/tasks/:id
|
||||
* Update a task
|
||||
* Requires: MEMBER role or higher
|
||||
*/
|
||||
@Patch(":id")
|
||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
async update(
|
||||
@Param("id") id: string,
|
||||
@Body() updateTaskDto: UpdateTaskDto,
|
||||
@Request() req: any
|
||||
@Workspace() workspaceId: string,
|
||||
@CurrentUser() user: any
|
||||
) {
|
||||
const workspaceId = req.user?.workspaceId;
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!workspaceId || !userId) {
|
||||
throw new UnauthorizedException("Authentication required");
|
||||
}
|
||||
|
||||
return this.tasksService.update(id, workspaceId, userId, updateTaskDto);
|
||||
return this.tasksService.update(id, workspaceId, user.id, updateTaskDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/tasks/:id
|
||||
* Delete a task
|
||||
* Requires: ADMIN role or higher
|
||||
*/
|
||||
@Delete(":id")
|
||||
async remove(@Param("id") id: string, @Request() req: any) {
|
||||
const workspaceId = req.user?.workspaceId;
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!workspaceId || !userId) {
|
||||
throw new UnauthorizedException("Authentication required");
|
||||
}
|
||||
|
||||
return this.tasksService.remove(id, workspaceId, userId);
|
||||
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||
async remove(
|
||||
@Param("id") id: string,
|
||||
@Workspace() workspaceId: string,
|
||||
@CurrentUser() user: any
|
||||
) {
|
||||
return this.tasksService.remove(id, workspaceId, user.id);
|
||||
}
|
||||
}
|
||||
|
||||
2
apps/api/src/users/dto/index.ts
Normal file
2
apps/api/src/users/dto/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./update-preferences.dto";
|
||||
export * from "./preferences-response.dto";
|
||||
12
apps/api/src/users/dto/preferences-response.dto.ts
Normal file
12
apps/api/src/users/dto/preferences-response.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Response DTO for user preferences
|
||||
*/
|
||||
export interface PreferencesResponseDto {
|
||||
id: string;
|
||||
userId: string;
|
||||
theme: string;
|
||||
locale: string;
|
||||
timezone: string | null;
|
||||
settings: Record<string, unknown>;
|
||||
updatedAt: Date;
|
||||
}
|
||||
25
apps/api/src/users/dto/update-preferences.dto.ts
Normal file
25
apps/api/src/users/dto/update-preferences.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { IsString, IsOptional, IsObject, IsIn } from "class-validator";
|
||||
|
||||
/**
|
||||
* DTO for updating user preferences
|
||||
*/
|
||||
export class UpdatePreferencesDto {
|
||||
@IsOptional()
|
||||
@IsString({ message: "theme must be a string" })
|
||||
@IsIn(["light", "dark", "system"], {
|
||||
message: "theme must be one of: light, dark, system",
|
||||
})
|
||||
theme?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString({ message: "locale must be a string" })
|
||||
locale?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString({ message: "timezone must be a string" })
|
||||
timezone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject({ message: "settings must be an object" })
|
||||
settings?: Record<string, unknown>;
|
||||
}
|
||||
55
apps/api/src/users/preferences.controller.ts
Normal file
55
apps/api/src/users/preferences.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Put,
|
||||
Body,
|
||||
UseGuards,
|
||||
Request,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { PreferencesService } from "./preferences.service";
|
||||
import { UpdatePreferencesDto } from "./dto";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
|
||||
/**
|
||||
* Controller for user preferences endpoints
|
||||
* All endpoints require authentication
|
||||
*/
|
||||
@Controller("users/me/preferences")
|
||||
@UseGuards(AuthGuard)
|
||||
export class PreferencesController {
|
||||
constructor(private readonly preferencesService: PreferencesService) {}
|
||||
|
||||
/**
|
||||
* GET /api/users/me/preferences
|
||||
* Get current user's preferences
|
||||
*/
|
||||
@Get()
|
||||
async getPreferences(@Request() req: any) {
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException("Authentication required");
|
||||
}
|
||||
|
||||
return this.preferencesService.getPreferences(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/users/me/preferences
|
||||
* Update current user's preferences
|
||||
*/
|
||||
@Put()
|
||||
async updatePreferences(
|
||||
@Body() updatePreferencesDto: UpdatePreferencesDto,
|
||||
@Request() req: any
|
||||
) {
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException("Authentication required");
|
||||
}
|
||||
|
||||
return this.preferencesService.updatePreferences(userId, updatePreferencesDto);
|
||||
}
|
||||
}
|
||||
104
apps/api/src/users/preferences.service.ts
Normal file
104
apps/api/src/users/preferences.service.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import type {
|
||||
UpdatePreferencesDto,
|
||||
PreferencesResponseDto,
|
||||
} from "./dto";
|
||||
|
||||
/**
|
||||
* Service for managing user preferences
|
||||
*/
|
||||
@Injectable()
|
||||
export class PreferencesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Get user preferences (create with defaults if not exists)
|
||||
*/
|
||||
async getPreferences(userId: string): Promise<PreferencesResponseDto> {
|
||||
let preferences = await this.prisma.userPreference.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
// Create default preferences if they don't exist
|
||||
if (!preferences) {
|
||||
preferences = await this.prisma.userPreference.create({
|
||||
data: {
|
||||
userId,
|
||||
theme: "system",
|
||||
locale: "en",
|
||||
settings: {} as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: preferences.id,
|
||||
userId: preferences.userId,
|
||||
theme: preferences.theme,
|
||||
locale: preferences.locale,
|
||||
timezone: preferences.timezone,
|
||||
settings: preferences.settings as Record<string, unknown>,
|
||||
updatedAt: preferences.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user preferences
|
||||
*/
|
||||
async updatePreferences(
|
||||
userId: string,
|
||||
updateDto: UpdatePreferencesDto
|
||||
): Promise<PreferencesResponseDto> {
|
||||
// Check if preferences exist
|
||||
const existing = await this.prisma.userPreference.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
let preferences;
|
||||
|
||||
if (existing) {
|
||||
// Update existing preferences
|
||||
preferences = await this.prisma.userPreference.update({
|
||||
where: { userId },
|
||||
data: {
|
||||
...(updateDto.theme && { theme: updateDto.theme }),
|
||||
...(updateDto.locale && { locale: updateDto.locale }),
|
||||
...(updateDto.timezone !== undefined && {
|
||||
timezone: updateDto.timezone,
|
||||
}),
|
||||
...(updateDto.settings && {
|
||||
settings: updateDto.settings as unknown as Prisma.InputJsonValue,
|
||||
}),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Create new preferences
|
||||
const createData: Prisma.UserPreferenceUncheckedCreateInput = {
|
||||
userId,
|
||||
theme: updateDto.theme || "system",
|
||||
locale: updateDto.locale || "en",
|
||||
settings: (updateDto.settings || {}) as unknown as Prisma.InputJsonValue,
|
||||
};
|
||||
|
||||
if (updateDto.timezone !== undefined) {
|
||||
createData.timezone = updateDto.timezone;
|
||||
}
|
||||
|
||||
preferences = await this.prisma.userPreference.create({
|
||||
data: createData,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: preferences.id,
|
||||
userId: preferences.userId,
|
||||
theme: preferences.theme,
|
||||
locale: preferences.locale,
|
||||
timezone: preferences.timezone,
|
||||
settings: preferences.settings as Record<string, unknown>,
|
||||
updatedAt: preferences.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
16
apps/api/src/users/users.module.ts
Normal file
16
apps/api/src/users/users.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PreferencesController } from "./preferences.controller";
|
||||
import { PreferencesService } from "./preferences.service";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
|
||||
/**
|
||||
* Module for user-related endpoints (preferences, etc.)
|
||||
*/
|
||||
@Module({
|
||||
imports: [PrismaModule, AuthModule],
|
||||
controllers: [PreferencesController],
|
||||
providers: [PreferencesService],
|
||||
exports: [PreferencesService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
Reference in New Issue
Block a user