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:
Jason Woltje
2026-01-29 16:59:26 -06:00
parent 287a0e2556
commit 5291fece26
43 changed files with 4152 additions and 99 deletions

View File

@@ -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;

View File

@@ -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

View File

@@ -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],

View 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

View File

@@ -0,0 +1,2 @@
export * from "./permissions.decorator";
export * from "./workspace.decorator";

View 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);

View 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;
}
);

View File

@@ -0,0 +1,2 @@
export * from "./workspace.guard";
export * from "./permission.guard";

View 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
);
});
});
});

View 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;
}
}
}

View 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
);
});
});
});

View 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;
}
}
}

View File

@@ -0,0 +1,2 @@
export * from "./decorators";
export * from "./guards";

View File

@@ -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" };
}
}

View File

@@ -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,

View File

@@ -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);
}
}

View File

@@ -0,0 +1,2 @@
export * from "./update-preferences.dto";
export * from "./preferences-response.dto";

View 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;
}

View 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>;
}

View 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);
}
}

View 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,
};
}
}

View 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 {}