mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-24 03:16:44 +02:00
fix(chat,picture,mukke): production readiness audit fixes and tests
Chat (62→82): Add DB indexes on all tables, rate limiting (ThrottlerModule), space authorization checks (member verification, invite permissions), input validation DTOs with @MaxLength, complete GDPR user deletion (templates + usage logs), fix HTML injection in hooks.server.ts. 78 tests added (conversation + space services). Picture (68→82): Add DB indexes on all tables, foreign key constraints with cascade rules, rate limiting, webhook endpoint security (secret header validation), input validation on generate DTO (@Min/@Max on dimensions/steps/guidance), transaction wrapping for board duplication and generation completion. 70 tests added (image + board services). Mukke (62→80): Add 73 new tests (beat, marker, project services) on top of existing 40 tests, bringing total to 113. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3da6cf2bd4
commit
8f0c747e08
41 changed files with 4236 additions and 321 deletions
17
apps/chat/apps/backend/jest.config.js
Normal file
17
apps/chat/apps/backend/jest.config.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
moduleFileExtensions: ['js', 'json', 'ts'],
|
||||
rootDir: 'src',
|
||||
testRegex: '.*\\.spec\\.ts$',
|
||||
transform: {
|
||||
'^.+\\.(t|j)s$': 'ts-jest',
|
||||
},
|
||||
collectCoverageFrom: ['**/*.(t|j)s'],
|
||||
coverageDirectory: '../coverage',
|
||||
testEnvironment: 'node',
|
||||
moduleNameMapper: {
|
||||
'^@chat/types$': '<rootDir>/../../packages/chat-types/src',
|
||||
'^@manacore/shared-nestjs-auth$': '<rootDir>/../../../../../packages/shared-nestjs-auth/src',
|
||||
'^@manacore/shared-errors$': '<rootDir>/../../../../../packages/shared-errors/src',
|
||||
},
|
||||
};
|
||||
|
|
@ -23,7 +23,10 @@
|
|||
"docker:down": "docker compose down",
|
||||
"docker:logs": "docker compose logs -f",
|
||||
"docker:restart": "docker compose restart",
|
||||
"docker:clean": "docker compose down -v --rmi local"
|
||||
"docker:clean": "docker compose down -v --rmi local",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/credit-operations": "workspace:*",
|
||||
|
|
@ -34,6 +37,7 @@
|
|||
"@manacore/shared-nestjs-metrics": "workspace:*",
|
||||
"@manacore/shared-nestjs-setup": "workspace:*",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/throttler": "^6.2.1",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
|
|
@ -58,8 +62,12 @@
|
|||
"eslint": "^9.17.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"@nestjs/testing": "^10.4.15",
|
||||
"@types/jest": "^30.0.0",
|
||||
"jest": "^30.2.0",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
|
|
|
|||
|
|
@ -101,7 +101,19 @@ export class AdminService {
|
|||
const deletedCounts: EntityCount[] = [];
|
||||
let totalDeleted = 0;
|
||||
|
||||
// Delete space memberships first
|
||||
// Delete usage logs first (references messages and conversations)
|
||||
const deletedUsageLogs = await this.db
|
||||
.delete(schema.usageLogs)
|
||||
.where(eq(schema.usageLogs.userId, userId))
|
||||
.returning();
|
||||
deletedCounts.push({
|
||||
entity: 'usage_logs',
|
||||
count: deletedUsageLogs.length,
|
||||
label: 'Usage Logs',
|
||||
});
|
||||
totalDeleted += deletedUsageLogs.length;
|
||||
|
||||
// Delete space memberships
|
||||
const deletedMemberships = await this.db
|
||||
.delete(schema.spaceMembers)
|
||||
.where(eq(schema.spaceMembers.userId, userId))
|
||||
|
|
@ -125,6 +137,18 @@ export class AdminService {
|
|||
});
|
||||
totalDeleted += deletedSpaces.length;
|
||||
|
||||
// Delete templates owned by user
|
||||
const deletedTemplates = await this.db
|
||||
.delete(schema.templates)
|
||||
.where(eq(schema.templates.userId, userId))
|
||||
.returning();
|
||||
deletedCounts.push({
|
||||
entity: 'templates',
|
||||
count: deletedTemplates.length,
|
||||
label: 'Templates',
|
||||
});
|
||||
totalDeleted += deletedTemplates.length;
|
||||
|
||||
// Delete conversations (cascades to messages and documents)
|
||||
const deletedConversations = await this.db
|
||||
.delete(schema.conversations)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { MetricsModule } from '@manacore/shared-nestjs-metrics';
|
||||
import { ManaCoreModule } from '@manacore/nestjs-integration';
|
||||
import { DatabaseModule } from './db/database.module';
|
||||
|
|
@ -18,6 +19,7 @@ import { HealthModule } from '@manacore/shared-nestjs-health';
|
|||
isGlobal: true,
|
||||
envFilePath: '.env',
|
||||
}),
|
||||
ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }]),
|
||||
ManaCoreModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
|
@ -15,6 +16,7 @@ export class ChatMessageDto {
|
|||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(50000)
|
||||
content: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,515 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConversationService } from '../conversation.service';
|
||||
import { DATABASE_CONNECTION } from '../../db/database.module';
|
||||
|
||||
describe('ConversationService', () => {
|
||||
let service: ConversationService;
|
||||
let mockDb: any;
|
||||
|
||||
const userId = 'user-123';
|
||||
const conversationId = 'conv-abc-123';
|
||||
const modelId = 'model-456';
|
||||
|
||||
const mockConversation = {
|
||||
id: conversationId,
|
||||
userId,
|
||||
modelId,
|
||||
templateId: null,
|
||||
spaceId: null,
|
||||
title: 'Test Conversation',
|
||||
conversationMode: 'free' as const,
|
||||
documentMode: false,
|
||||
isArchived: false,
|
||||
isPinned: false,
|
||||
createdAt: new Date('2025-01-01'),
|
||||
updatedAt: new Date('2025-01-01'),
|
||||
};
|
||||
|
||||
const mockMessage = {
|
||||
id: 'msg-001',
|
||||
conversationId,
|
||||
sender: 'user' as const,
|
||||
messageText: 'Hello, world!',
|
||||
createdAt: new Date('2025-01-01'),
|
||||
updatedAt: new Date('2025-01-01'),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockDb = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
values: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn(),
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
delete: jest.fn().mockReturnThis(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ConversationService,
|
||||
{
|
||||
provide: DATABASE_CONNECTION,
|
||||
useValue: mockDb,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ConversationService>(ConversationService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('getConversations', () => {
|
||||
it('should return non-archived conversations for a user', async () => {
|
||||
const conversations = [
|
||||
mockConversation,
|
||||
{ ...mockConversation, id: 'conv-2', title: 'Second' },
|
||||
];
|
||||
mockDb.orderBy.mockResolvedValue(conversations);
|
||||
|
||||
const result = await service.getConversations(userId);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toHaveLength(2);
|
||||
expect(result.value[0].id).toBe(conversationId);
|
||||
}
|
||||
expect(mockDb.select).toHaveBeenCalled();
|
||||
expect(mockDb.from).toHaveBeenCalled();
|
||||
expect(mockDb.where).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should filter by spaceId when provided', async () => {
|
||||
const spaceId = 'space-789';
|
||||
const spaceConversation = { ...mockConversation, spaceId };
|
||||
mockDb.orderBy.mockResolvedValue([spaceConversation]);
|
||||
|
||||
const result = await service.getConversations(userId, spaceId);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toHaveLength(1);
|
||||
expect(result.value[0].spaceId).toBe(spaceId);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return empty array when no conversations exist', async () => {
|
||||
mockDb.orderBy.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getConversations(userId);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error on database failure', async () => {
|
||||
mockDb.orderBy.mockRejectedValue(new Error('DB connection failed'));
|
||||
|
||||
const result = await service.getConversations(userId);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toContain('Failed to fetch conversations');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getArchivedConversations', () => {
|
||||
it('should return archived conversations for a user', async () => {
|
||||
const archivedConv = { ...mockConversation, isArchived: true };
|
||||
mockDb.orderBy.mockResolvedValue([archivedConv]);
|
||||
|
||||
const result = await service.getArchivedConversations(userId);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toHaveLength(1);
|
||||
expect(result.value[0].isArchived).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error on database failure', async () => {
|
||||
mockDb.orderBy.mockRejectedValue(new Error('DB error'));
|
||||
|
||||
const result = await service.getArchivedConversations(userId);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConversation', () => {
|
||||
it('should return a conversation when found', async () => {
|
||||
mockDb.limit.mockResolvedValue([mockConversation]);
|
||||
|
||||
const result = await service.getConversation(conversationId, userId);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.id).toBe(conversationId);
|
||||
expect(result.value.userId).toBe(userId);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return NotFoundError when conversation does not exist', async () => {
|
||||
mockDb.limit.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getConversation('nonexistent', userId);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toContain('Conversation');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error on database failure', async () => {
|
||||
mockDb.limit.mockRejectedValue(new Error('DB error'));
|
||||
|
||||
const result = await service.getConversation(conversationId, userId);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createConversation', () => {
|
||||
it('should create a conversation with default title', async () => {
|
||||
const newConv = { ...mockConversation, title: 'Neue Unterhaltung' };
|
||||
mockDb.returning.mockResolvedValue([newConv]);
|
||||
|
||||
const result = await service.createConversation(userId, modelId);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.userId).toBe(userId);
|
||||
expect(result.value.title).toBe('Neue Unterhaltung');
|
||||
}
|
||||
expect(mockDb.insert).toHaveBeenCalled();
|
||||
expect(mockDb.values).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create a conversation with custom options', async () => {
|
||||
const customConv = {
|
||||
...mockConversation,
|
||||
title: 'Custom Title',
|
||||
conversationMode: 'guided' as const,
|
||||
documentMode: true,
|
||||
spaceId: 'space-123',
|
||||
};
|
||||
mockDb.returning.mockResolvedValue([customConv]);
|
||||
|
||||
const result = await service.createConversation(userId, modelId, {
|
||||
title: 'Custom Title',
|
||||
conversationMode: 'guided',
|
||||
documentMode: true,
|
||||
spaceId: 'space-123',
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.title).toBe('Custom Title');
|
||||
expect(result.value.conversationMode).toBe('guided');
|
||||
expect(result.value.documentMode).toBe(true);
|
||||
expect(result.value.spaceId).toBe('space-123');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error on database failure', async () => {
|
||||
mockDb.returning.mockRejectedValue(new Error('DB error'));
|
||||
|
||||
const result = await service.createConversation(userId, modelId);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toContain('Failed to create conversation');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('addMessage', () => {
|
||||
it('should add a message to a valid conversation', async () => {
|
||||
// getConversation uses select().from().where().limit()
|
||||
mockDb.limit.mockResolvedValueOnce([mockConversation]);
|
||||
// insert().values().returning() for the message
|
||||
mockDb.returning.mockResolvedValueOnce([mockMessage]);
|
||||
// update().set().where() for updatedAt - the chain ends at where()
|
||||
// where() already returns this, which is fine for an awaited non-returning update
|
||||
|
||||
const result = await service.addMessage(conversationId, userId, 'user', 'Hello, world!');
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.messageText).toBe('Hello, world!');
|
||||
expect(result.value.sender).toBe('user');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error when conversation not found', async () => {
|
||||
mockDb.limit.mockResolvedValue([]);
|
||||
|
||||
const result = await service.addMessage('nonexistent', userId, 'user', 'Hello');
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toContain('Conversation');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error on database failure during insert', async () => {
|
||||
mockDb.limit.mockResolvedValueOnce([mockConversation]);
|
||||
mockDb.returning.mockRejectedValueOnce(new Error('DB error'));
|
||||
|
||||
const result = await service.addMessage(conversationId, userId, 'assistant', 'Response');
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMessages', () => {
|
||||
it('should return messages for a valid conversation', async () => {
|
||||
const msgs = [
|
||||
mockMessage,
|
||||
{ ...mockMessage, id: 'msg-002', sender: 'assistant', messageText: 'Hi there!' },
|
||||
];
|
||||
// getConversation uses select().from().where().limit()
|
||||
mockDb.limit.mockResolvedValueOnce([mockConversation]);
|
||||
// select().from().where().orderBy() for messages
|
||||
mockDb.orderBy.mockResolvedValueOnce(msgs);
|
||||
|
||||
const result = await service.getMessages(conversationId, userId);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toHaveLength(2);
|
||||
expect(result.value[0].sender).toBe('user');
|
||||
expect(result.value[1].sender).toBe('assistant');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error when conversation not found', async () => {
|
||||
mockDb.limit.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getMessages('nonexistent', userId);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTitle', () => {
|
||||
it('should update conversation title', async () => {
|
||||
const updatedConv = { ...mockConversation, title: 'New Title' };
|
||||
// getConversation call
|
||||
mockDb.limit.mockResolvedValueOnce([mockConversation]);
|
||||
// update().set().where().returning()
|
||||
mockDb.returning.mockResolvedValueOnce([updatedConv]);
|
||||
|
||||
const result = await service.updateTitle(conversationId, userId, 'New Title');
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.title).toBe('New Title');
|
||||
}
|
||||
expect(mockDb.update).toHaveBeenCalled();
|
||||
expect(mockDb.set).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error when conversation not found', async () => {
|
||||
mockDb.limit.mockResolvedValue([]);
|
||||
|
||||
const result = await service.updateTitle('nonexistent', userId, 'New Title');
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toContain('Conversation');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error on database failure', async () => {
|
||||
mockDb.limit.mockResolvedValueOnce([mockConversation]);
|
||||
mockDb.returning.mockRejectedValueOnce(new Error('DB error'));
|
||||
|
||||
const result = await service.updateTitle(conversationId, userId, 'New Title');
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('archiveConversation', () => {
|
||||
it('should archive a conversation', async () => {
|
||||
const archivedConv = { ...mockConversation, isArchived: true };
|
||||
// getConversation call
|
||||
mockDb.limit.mockResolvedValueOnce([mockConversation]);
|
||||
// update().set().where().returning()
|
||||
mockDb.returning.mockResolvedValueOnce([archivedConv]);
|
||||
|
||||
const result = await service.archiveConversation(conversationId, userId);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.isArchived).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error when conversation not found', async () => {
|
||||
mockDb.limit.mockResolvedValue([]);
|
||||
|
||||
const result = await service.archiveConversation('nonexistent', userId);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unarchiveConversation', () => {
|
||||
it('should unarchive a conversation', async () => {
|
||||
const unarchivedConv = { ...mockConversation, isArchived: false };
|
||||
// Direct select query uses select().from().where().limit()
|
||||
mockDb.limit.mockResolvedValueOnce([{ ...mockConversation, isArchived: true }]);
|
||||
// update().set().where().returning()
|
||||
mockDb.returning.mockResolvedValueOnce([unarchivedConv]);
|
||||
|
||||
const result = await service.unarchiveConversation(conversationId, userId);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.isArchived).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error when conversation not found', async () => {
|
||||
mockDb.limit.mockResolvedValue([]);
|
||||
|
||||
const result = await service.unarchiveConversation('nonexistent', userId);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toContain('Conversation');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteConversation', () => {
|
||||
it('should delete a conversation', async () => {
|
||||
// getConversation call
|
||||
mockDb.limit.mockResolvedValueOnce([mockConversation]);
|
||||
|
||||
const result = await service.deleteConversation(conversationId, userId);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(mockDb.delete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error when conversation not found', async () => {
|
||||
mockDb.limit.mockResolvedValue([]);
|
||||
|
||||
const result = await service.deleteConversation('nonexistent', userId);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toContain('Conversation');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error on database failure', async () => {
|
||||
mockDb.limit.mockResolvedValueOnce([mockConversation]);
|
||||
// delete() is called after getConversation succeeds; make the delete chain throw
|
||||
mockDb.delete.mockImplementationOnce(() => {
|
||||
throw new Error('DB error');
|
||||
});
|
||||
|
||||
const result = await service.deleteConversation(conversationId, userId);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMessageCount', () => {
|
||||
it('should return the message count for a conversation', async () => {
|
||||
// getConversation: select().from().where().limit() - limit is terminal
|
||||
mockDb.limit.mockResolvedValueOnce([mockConversation]);
|
||||
// count query: select().from().where() - where is terminal
|
||||
// Use mockReturnValueOnce(mockDb) for the first where() call (getConversation chain),
|
||||
// then mockResolvedValueOnce for the second where() call (count query terminal)
|
||||
mockDb.where.mockReturnValueOnce(mockDb).mockResolvedValueOnce([{ count: 42 }]);
|
||||
|
||||
const result = await service.getMessageCount(conversationId, userId);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toBe(42);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return 0 when no messages exist', async () => {
|
||||
mockDb.limit.mockResolvedValueOnce([mockConversation]);
|
||||
mockDb.where.mockReturnValueOnce(mockDb).mockResolvedValueOnce([{ count: 0 }]);
|
||||
|
||||
const result = await service.getMessageCount(conversationId, userId);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error when conversation not found', async () => {
|
||||
mockDb.limit.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.getMessageCount('nonexistent', userId);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pinConversation', () => {
|
||||
it('should pin a conversation', async () => {
|
||||
const pinnedConv = { ...mockConversation, isPinned: true };
|
||||
mockDb.limit.mockResolvedValueOnce([mockConversation]);
|
||||
mockDb.returning.mockResolvedValueOnce([pinnedConv]);
|
||||
|
||||
const result = await service.pinConversation(conversationId, userId);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.isPinned).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error when conversation not found', async () => {
|
||||
mockDb.limit.mockResolvedValue([]);
|
||||
|
||||
const result = await service.pinConversation('nonexistent', userId);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unpinConversation', () => {
|
||||
it('should unpin a conversation', async () => {
|
||||
const unpinnedConv = { ...mockConversation, isPinned: false };
|
||||
mockDb.limit.mockResolvedValueOnce([{ ...mockConversation, isPinned: true }]);
|
||||
mockDb.returning.mockResolvedValueOnce([unpinnedConv]);
|
||||
|
||||
const result = await service.unpinConversation(conversationId, userId);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.isPinned).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error when conversation not found', async () => {
|
||||
mockDb.limit.mockResolvedValue([]);
|
||||
|
||||
const result = await service.unpinConversation('nonexistent', userId);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -15,6 +15,7 @@ import { Conversation } from '../db/schema/conversations.schema';
|
|||
import { Message } from '../db/schema/messages.schema';
|
||||
import { JwtAuthGuard, CurrentUser } from '@manacore/shared-nestjs-auth';
|
||||
import type { CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { CreateConversationDto, AddMessageDto, UpdateTitleDto } from './dto/conversation.dto';
|
||||
|
||||
@Controller('conversations')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
|
|
@ -76,15 +77,7 @@ export class ConversationController {
|
|||
|
||||
@Post()
|
||||
async createConversation(
|
||||
@Body()
|
||||
body: {
|
||||
modelId: string;
|
||||
title?: string;
|
||||
templateId?: string;
|
||||
conversationMode?: 'free' | 'guided' | 'template';
|
||||
documentMode?: boolean;
|
||||
spaceId?: string;
|
||||
},
|
||||
@Body() body: CreateConversationDto,
|
||||
@CurrentUser() user: CurrentUserData
|
||||
): Promise<Conversation> {
|
||||
const result = await this.conversationService.createConversation(user.userId, body.modelId, {
|
||||
|
|
@ -105,7 +98,7 @@ export class ConversationController {
|
|||
@Post(':id/messages')
|
||||
async addMessage(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { sender: 'user' | 'assistant' | 'system'; messageText: string },
|
||||
@Body() body: AddMessageDto,
|
||||
@CurrentUser() user: CurrentUserData
|
||||
): Promise<Message> {
|
||||
const result = await this.conversationService.addMessage(
|
||||
|
|
@ -125,7 +118,7 @@ export class ConversationController {
|
|||
@Patch(':id/title')
|
||||
async updateTitle(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { title: string },
|
||||
@Body() body: UpdateTitleDto,
|
||||
@CurrentUser() user: CurrentUserData
|
||||
): Promise<Conversation> {
|
||||
const result = await this.conversationService.updateTitle(id, user.userId, body.title);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
import { IsBoolean, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateConversationDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
modelId: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(500)
|
||||
title?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
templateId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
conversationMode?: 'free' | 'guided' | 'template';
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
documentMode?: boolean;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
spaceId?: string;
|
||||
}
|
||||
|
||||
export class AddMessageDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
sender: 'user' | 'assistant' | 'system';
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(50000)
|
||||
messageText: string;
|
||||
}
|
||||
|
||||
export class UpdateTitleDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(500)
|
||||
title: string;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { pgTable, uuid, text, timestamp, boolean, pgEnum } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, uuid, text, timestamp, boolean, pgEnum, index } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { messages } from './messages.schema';
|
||||
import { documents } from './documents.schema';
|
||||
|
|
@ -8,20 +8,30 @@ import { templates } from './templates.schema';
|
|||
|
||||
export const conversationModeEnum = pgEnum('conversation_mode', ['free', 'guided', 'template']);
|
||||
|
||||
export const conversations = pgTable('conversations', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(), // TEXT to support Better Auth nanoid format
|
||||
modelId: uuid('model_id').references(() => models.id),
|
||||
templateId: uuid('template_id').references(() => templates.id),
|
||||
spaceId: uuid('space_id').references(() => spaces.id, { onDelete: 'set null' }),
|
||||
title: text('title'),
|
||||
conversationMode: conversationModeEnum('conversation_mode').default('free').notNull(),
|
||||
documentMode: boolean('document_mode').default(false).notNull(),
|
||||
isArchived: boolean('is_archived').default(false).notNull(),
|
||||
isPinned: boolean('is_pinned').default(false).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
export const conversations = pgTable(
|
||||
'conversations',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(), // TEXT to support Better Auth nanoid format
|
||||
modelId: uuid('model_id').references(() => models.id),
|
||||
templateId: uuid('template_id').references(() => templates.id),
|
||||
spaceId: uuid('space_id').references(() => spaces.id, { onDelete: 'set null' }),
|
||||
title: text('title'),
|
||||
conversationMode: conversationModeEnum('conversation_mode').default('free').notNull(),
|
||||
documentMode: boolean('document_mode').default(false).notNull(),
|
||||
isArchived: boolean('is_archived').default(false).notNull(),
|
||||
isPinned: boolean('is_pinned').default(false).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('conversations_user_id_idx').on(table.userId),
|
||||
index('conversations_space_id_idx').on(table.spaceId),
|
||||
index('conversations_created_at_idx').on(table.createdAt),
|
||||
index('conversations_model_id_idx').on(table.modelId),
|
||||
index('conversations_template_id_idx').on(table.templateId),
|
||||
]
|
||||
);
|
||||
|
||||
export const conversationsRelations = relations(conversations, ({ one, many }) => ({
|
||||
model: one(models, {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,21 @@
|
|||
import { pgTable, uuid, text, timestamp, integer } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, uuid, text, timestamp, integer, index } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { conversations } from './conversations.schema';
|
||||
|
||||
export const documents = pgTable('documents', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
conversationId: uuid('conversation_id')
|
||||
.references(() => conversations.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
version: integer('version').default(1).notNull(),
|
||||
content: text('content').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
export const documents = pgTable(
|
||||
'documents',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
conversationId: uuid('conversation_id')
|
||||
.references(() => conversations.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
version: integer('version').default(1).notNull(),
|
||||
content: text('content').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [index('documents_conversation_id_idx').on(table.conversationId)]
|
||||
);
|
||||
|
||||
export const documentsRelations = relations(documents, ({ one }) => ({
|
||||
conversation: one(conversations, {
|
||||
|
|
|
|||
|
|
@ -1,19 +1,23 @@
|
|||
import { pgTable, uuid, text, timestamp, pgEnum } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, uuid, text, timestamp, pgEnum, index } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { conversations } from './conversations.schema';
|
||||
|
||||
export const senderEnum = pgEnum('sender', ['user', 'assistant', 'system']);
|
||||
|
||||
export const messages = pgTable('messages', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
conversationId: uuid('conversation_id')
|
||||
.references(() => conversations.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
sender: senderEnum('sender').notNull(),
|
||||
messageText: text('message_text').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
export const messages = pgTable(
|
||||
'messages',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
conversationId: uuid('conversation_id')
|
||||
.references(() => conversations.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
sender: senderEnum('sender').notNull(),
|
||||
messageText: text('message_text').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [index('messages_conversation_id_idx').on(table.conversationId)]
|
||||
);
|
||||
|
||||
export const messagesRelations = relations(messages, ({ one }) => ({
|
||||
conversation: one(conversations, {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,13 @@
|
|||
import { pgTable, uuid, text, timestamp, boolean, pgEnum } from 'drizzle-orm/pg-core';
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
timestamp,
|
||||
boolean,
|
||||
pgEnum,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
|
||||
export const memberRoleEnum = pgEnum('member_role', ['owner', 'admin', 'member', 'viewer']);
|
||||
|
|
@ -8,30 +17,42 @@ export const invitationStatusEnum = pgEnum('invitation_status', [
|
|||
'declined',
|
||||
]);
|
||||
|
||||
export const spaces = pgTable('spaces', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
ownerId: text('owner_id').notNull(), // TEXT to support Better Auth nanoid format
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
isArchived: boolean('is_archived').default(false).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
export const spaces = pgTable(
|
||||
'spaces',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
ownerId: text('owner_id').notNull(), // TEXT to support Better Auth nanoid format
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
isArchived: boolean('is_archived').default(false).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [index('spaces_owner_id_idx').on(table.ownerId)]
|
||||
);
|
||||
|
||||
export const spaceMembers = pgTable('space_members', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
spaceId: uuid('space_id')
|
||||
.references(() => spaces.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
userId: text('user_id').notNull(), // TEXT to support Better Auth nanoid format
|
||||
role: memberRoleEnum('role').default('member').notNull(),
|
||||
invitationStatus: invitationStatusEnum('invitation_status').default('pending').notNull(),
|
||||
invitedBy: text('invited_by'), // TEXT to support Better Auth nanoid format
|
||||
invitedAt: timestamp('invited_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
joinedAt: timestamp('joined_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
export const spaceMembers = pgTable(
|
||||
'space_members',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
spaceId: uuid('space_id')
|
||||
.references(() => spaces.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
userId: text('user_id').notNull(), // TEXT to support Better Auth nanoid format
|
||||
role: memberRoleEnum('role').default('member').notNull(),
|
||||
invitationStatus: invitationStatusEnum('invitation_status').default('pending').notNull(),
|
||||
invitedBy: text('invited_by'), // TEXT to support Better Auth nanoid format
|
||||
invitedAt: timestamp('invited_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
joinedAt: timestamp('joined_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('space_members_user_id_idx').on(table.userId),
|
||||
index('space_members_space_id_idx').on(table.spaceId),
|
||||
uniqueIndex('space_members_space_id_user_id_idx').on(table.spaceId, table.userId),
|
||||
]
|
||||
);
|
||||
|
||||
export const spacesRelations = relations(spaces, ({ many }) => ({
|
||||
members: many(spaceMembers),
|
||||
|
|
|
|||
|
|
@ -1,21 +1,28 @@
|
|||
import { pgTable, uuid, text, timestamp, boolean } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, uuid, text, timestamp, boolean, index } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { models } from './models.schema';
|
||||
|
||||
export const templates = pgTable('templates', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(), // TEXT to support Better Auth nanoid format
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
systemPrompt: text('system_prompt').notNull(),
|
||||
initialQuestion: text('initial_question'),
|
||||
modelId: uuid('model_id').references(() => models.id),
|
||||
color: text('color').default('#3b82f6').notNull(),
|
||||
isDefault: boolean('is_default').default(false).notNull(),
|
||||
documentMode: boolean('document_mode').default(false).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
export const templates = pgTable(
|
||||
'templates',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(), // TEXT to support Better Auth nanoid format
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
systemPrompt: text('system_prompt').notNull(),
|
||||
initialQuestion: text('initial_question'),
|
||||
modelId: uuid('model_id').references(() => models.id),
|
||||
color: text('color').default('#3b82f6').notNull(),
|
||||
isDefault: boolean('is_default').default(false).notNull(),
|
||||
documentMode: boolean('document_mode').default(false).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('templates_user_id_idx').on(table.userId),
|
||||
index('templates_model_id_idx').on(table.modelId),
|
||||
]
|
||||
);
|
||||
|
||||
export const templatesRelations = relations(templates, ({ one }) => ({
|
||||
model: one(models, {
|
||||
|
|
|
|||
|
|
@ -1,25 +1,33 @@
|
|||
import { pgTable, uuid, text, timestamp, integer, numeric } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, uuid, text, timestamp, integer, numeric, index } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { conversations } from './conversations.schema';
|
||||
import { messages } from './messages.schema';
|
||||
import { models } from './models.schema';
|
||||
|
||||
export const usageLogs = pgTable('usage_logs', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
conversationId: uuid('conversation_id')
|
||||
.references(() => conversations.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
messageId: uuid('message_id')
|
||||
.references(() => messages.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
userId: text('user_id').notNull(), // TEXT to support Better Auth nanoid format
|
||||
modelId: uuid('model_id').references(() => models.id),
|
||||
promptTokens: integer('prompt_tokens').default(0).notNull(),
|
||||
completionTokens: integer('completion_tokens').default(0).notNull(),
|
||||
totalTokens: integer('total_tokens').default(0).notNull(),
|
||||
estimatedCost: numeric('estimated_cost', { precision: 10, scale: 6 }).default('0'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
export const usageLogs = pgTable(
|
||||
'usage_logs',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
conversationId: uuid('conversation_id')
|
||||
.references(() => conversations.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
messageId: uuid('message_id')
|
||||
.references(() => messages.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
userId: text('user_id').notNull(), // TEXT to support Better Auth nanoid format
|
||||
modelId: uuid('model_id').references(() => models.id),
|
||||
promptTokens: integer('prompt_tokens').default(0).notNull(),
|
||||
completionTokens: integer('completion_tokens').default(0).notNull(),
|
||||
totalTokens: integer('total_tokens').default(0).notNull(),
|
||||
estimatedCost: numeric('estimated_cost', { precision: 10, scale: 6 }).default('0'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('usage_logs_user_id_idx').on(table.userId),
|
||||
index('usage_logs_conversation_id_idx').on(table.conversationId),
|
||||
index('usage_logs_message_id_idx').on(table.messageId),
|
||||
]
|
||||
);
|
||||
|
||||
export const usageLogsRelations = relations(usageLogs, ({ one }) => ({
|
||||
conversation: one(conversations, {
|
||||
|
|
|
|||
609
apps/chat/apps/backend/src/space/__tests__/space.service.spec.ts
Normal file
609
apps/chat/apps/backend/src/space/__tests__/space.service.spec.ts
Normal file
|
|
@ -0,0 +1,609 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { SpaceService } from '../space.service';
|
||||
import { DATABASE_CONNECTION } from '../../db/database.module';
|
||||
|
||||
describe('SpaceService', () => {
|
||||
let service: SpaceService;
|
||||
let mockDb: any;
|
||||
|
||||
const ownerId = 'user-owner-1';
|
||||
const memberId = 'user-member-2';
|
||||
const spaceId = 'space-abc-123';
|
||||
|
||||
const mockSpace = {
|
||||
id: spaceId,
|
||||
ownerId,
|
||||
name: 'Test Space',
|
||||
description: 'A test space',
|
||||
isArchived: false,
|
||||
createdAt: new Date('2025-01-01'),
|
||||
updatedAt: new Date('2025-01-01'),
|
||||
};
|
||||
|
||||
const mockMember = {
|
||||
id: 'member-001',
|
||||
spaceId,
|
||||
userId: memberId,
|
||||
role: 'member' as const,
|
||||
invitationStatus: 'accepted' as const,
|
||||
invitedBy: ownerId,
|
||||
invitedAt: new Date('2025-01-01'),
|
||||
joinedAt: new Date('2025-01-02'),
|
||||
createdAt: new Date('2025-01-01'),
|
||||
updatedAt: new Date('2025-01-01'),
|
||||
};
|
||||
|
||||
const mockOwnerMember = {
|
||||
id: 'member-000',
|
||||
spaceId,
|
||||
userId: ownerId,
|
||||
role: 'owner' as const,
|
||||
invitationStatus: 'accepted' as const,
|
||||
invitedBy: null,
|
||||
invitedAt: new Date('2025-01-01'),
|
||||
joinedAt: new Date('2025-01-01'),
|
||||
createdAt: new Date('2025-01-01'),
|
||||
updatedAt: new Date('2025-01-01'),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockDb = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
values: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn(),
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
delete: jest.fn().mockReturnThis(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SpaceService,
|
||||
{
|
||||
provide: DATABASE_CONNECTION,
|
||||
useValue: mockDb,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<SpaceService>(SpaceService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('getUserSpaces', () => {
|
||||
it('should return spaces where user is an accepted member', async () => {
|
||||
// First query: get spaceIds from spaceMembers
|
||||
mockDb.where.mockResolvedValueOnce([{ spaceId }]);
|
||||
// Second query: get spaces by ids - ends at orderBy
|
||||
mockDb.orderBy.mockResolvedValueOnce([mockSpace]);
|
||||
|
||||
const result = await service.getUserSpaces(ownerId);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toHaveLength(1);
|
||||
expect(result.value[0].name).toBe('Test Space');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return empty array when user has no spaces', async () => {
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.getUserSpaces('user-no-spaces');
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error on database failure', async () => {
|
||||
mockDb.where.mockRejectedValueOnce(new Error('DB error'));
|
||||
|
||||
const result = await service.getUserSpaces(ownerId);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toContain('Failed to fetch user spaces');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOwnedSpaces', () => {
|
||||
it('should return spaces owned by the user', async () => {
|
||||
mockDb.orderBy.mockResolvedValueOnce([mockSpace]);
|
||||
|
||||
const result = await service.getOwnedSpaces(ownerId);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toHaveLength(1);
|
||||
expect(result.value[0].ownerId).toBe(ownerId);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return empty array when user owns no spaces', async () => {
|
||||
mockDb.orderBy.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.getOwnedSpaces('user-no-ownership');
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSpace', () => {
|
||||
it('should return a space when found', async () => {
|
||||
mockDb.limit.mockResolvedValueOnce([mockSpace]);
|
||||
|
||||
const result = await service.getSpace(spaceId);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.id).toBe(spaceId);
|
||||
expect(result.value.name).toBe('Test Space');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return NotFoundError when space does not exist', async () => {
|
||||
mockDb.limit.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.getSpace('nonexistent');
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toContain('Space');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error on database failure', async () => {
|
||||
mockDb.limit.mockRejectedValueOnce(new Error('DB error'));
|
||||
|
||||
const result = await service.getSpace(spaceId);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSpace', () => {
|
||||
it('should create a space and add owner as member', async () => {
|
||||
// insert().values().returning() for space creation
|
||||
mockDb.returning.mockResolvedValueOnce([mockSpace]);
|
||||
// Second insert().values() for adding owner as member - values() returns this (mockDb)
|
||||
// which is awaited and resolves to mockDb (non-thenable resolves to itself)
|
||||
|
||||
const result = await service.createSpace(ownerId, 'Test Space', 'A test space');
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.name).toBe('Test Space');
|
||||
expect(result.value.ownerId).toBe(ownerId);
|
||||
}
|
||||
expect(mockDb.insert).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should create a space without description', async () => {
|
||||
const spaceNoDesc = { ...mockSpace, description: null };
|
||||
mockDb.returning.mockResolvedValueOnce([spaceNoDesc]);
|
||||
|
||||
const result = await service.createSpace(ownerId, 'No Desc Space');
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.description).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error on database failure', async () => {
|
||||
mockDb.returning.mockRejectedValueOnce(new Error('DB error'));
|
||||
|
||||
const result = await service.createSpace(ownerId, 'Failing Space');
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toContain('Failed to create space');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSpace', () => {
|
||||
it('should update a space when user is the owner', async () => {
|
||||
const updatedSpace = { ...mockSpace, name: 'Updated Name' };
|
||||
// getSpace call (via limit)
|
||||
mockDb.limit.mockResolvedValueOnce([mockSpace]);
|
||||
// update().set().where().returning()
|
||||
mockDb.returning.mockResolvedValueOnce([updatedSpace]);
|
||||
|
||||
const result = await service.updateSpace(spaceId, ownerId, { name: 'Updated Name' });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.name).toBe('Updated Name');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error when user is not the owner', async () => {
|
||||
mockDb.limit.mockResolvedValueOnce([mockSpace]);
|
||||
|
||||
const result = await service.updateSpace(spaceId, 'other-user', { name: 'Hacked' });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('should return error when space not found', async () => {
|
||||
mockDb.limit.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.updateSpace('nonexistent', ownerId, { name: 'Nope' });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteSpace', () => {
|
||||
it('should delete a space when user is the owner', async () => {
|
||||
// getSpace call
|
||||
mockDb.limit.mockResolvedValueOnce([mockSpace]);
|
||||
|
||||
const result = await service.deleteSpace(spaceId, ownerId);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(mockDb.delete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error when user is not the owner', async () => {
|
||||
mockDb.limit.mockResolvedValueOnce([mockSpace]);
|
||||
|
||||
const result = await service.deleteSpace(spaceId, 'other-user');
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('should return error when space not found', async () => {
|
||||
mockDb.limit.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.deleteSpace('nonexistent', ownerId);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSpaceMembers', () => {
|
||||
it('should return all members of a space', async () => {
|
||||
const members = [mockOwnerMember, mockMember];
|
||||
mockDb.orderBy.mockResolvedValueOnce(members);
|
||||
|
||||
const result = await service.getSpaceMembers(spaceId);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toHaveLength(2);
|
||||
expect(result.value[0].role).toBe('owner');
|
||||
expect(result.value[1].role).toBe('member');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error on database failure', async () => {
|
||||
mockDb.orderBy.mockRejectedValueOnce(new Error('DB error'));
|
||||
|
||||
const result = await service.getSpaceMembers(spaceId);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inviteUserToSpace', () => {
|
||||
it('should create an invitation when inviter is the owner', async () => {
|
||||
const pendingMember = { ...mockMember, invitationStatus: 'pending' as const };
|
||||
// getSpace call
|
||||
mockDb.limit.mockResolvedValueOnce([mockSpace]);
|
||||
// Check existing member - none found
|
||||
mockDb.limit.mockResolvedValueOnce([]);
|
||||
// insert().values().returning() for new invitation
|
||||
mockDb.returning.mockResolvedValueOnce([pendingMember]);
|
||||
|
||||
const result = await service.inviteUserToSpace(spaceId, memberId, ownerId, 'member');
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.userId).toBe(memberId);
|
||||
expect(result.value.spaceId).toBe(spaceId);
|
||||
}
|
||||
});
|
||||
|
||||
it('should create an invitation when inviter is an admin', async () => {
|
||||
const adminId = 'user-admin';
|
||||
const adminMember = { ...mockMember, userId: adminId, role: 'admin' as const };
|
||||
const pendingMember = { ...mockMember, invitationStatus: 'pending' as const };
|
||||
|
||||
// getSpace call (inviter is not owner)
|
||||
mockDb.limit.mockResolvedValueOnce([mockSpace]);
|
||||
// Check inviter role - is admin
|
||||
mockDb.limit.mockResolvedValueOnce([adminMember]);
|
||||
// Check existing member - none found
|
||||
mockDb.limit.mockResolvedValueOnce([]);
|
||||
// insert().values().returning()
|
||||
mockDb.returning.mockResolvedValueOnce([pendingMember]);
|
||||
|
||||
const result = await service.inviteUserToSpace(spaceId, memberId, adminId, 'member');
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('should return error when inviter is a regular member', async () => {
|
||||
// getSpace call
|
||||
mockDb.limit.mockResolvedValueOnce([mockSpace]);
|
||||
// Check inviter role - is regular member (not admin)
|
||||
mockDb.limit.mockResolvedValueOnce([mockMember]);
|
||||
|
||||
const result = await service.inviteUserToSpace(spaceId, 'new-user', memberId, 'member');
|
||||
|
||||
// The service throws ForbiddenException which gets caught and returns err
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('should return existing member if already accepted', async () => {
|
||||
const acceptedMember = { ...mockMember, invitationStatus: 'accepted' as const };
|
||||
// getSpace call
|
||||
mockDb.limit.mockResolvedValueOnce([mockSpace]);
|
||||
// Check existing member - already accepted
|
||||
mockDb.limit.mockResolvedValueOnce([acceptedMember]);
|
||||
|
||||
const result = await service.inviteUserToSpace(spaceId, memberId, ownerId, 'member');
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.invitationStatus).toBe('accepted');
|
||||
}
|
||||
});
|
||||
|
||||
it('should update existing declined invitation', async () => {
|
||||
const declinedMember = { ...mockMember, invitationStatus: 'declined' as const };
|
||||
const updatedMember = { ...mockMember, invitationStatus: 'pending' as const };
|
||||
// getSpace call
|
||||
mockDb.limit.mockResolvedValueOnce([mockSpace]);
|
||||
// Check existing member - declined
|
||||
mockDb.limit.mockResolvedValueOnce([declinedMember]);
|
||||
// update().set().where().returning()
|
||||
mockDb.returning.mockResolvedValueOnce([updatedMember]);
|
||||
|
||||
const result = await service.inviteUserToSpace(spaceId, memberId, ownerId, 'member');
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.invitationStatus).toBe('pending');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('respondToInvitation', () => {
|
||||
it('should accept an invitation', async () => {
|
||||
const acceptedMember = { ...mockMember, invitationStatus: 'accepted' as const };
|
||||
mockDb.returning.mockResolvedValueOnce([acceptedMember]);
|
||||
|
||||
const result = await service.respondToInvitation(spaceId, memberId, 'accepted');
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.invitationStatus).toBe('accepted');
|
||||
}
|
||||
});
|
||||
|
||||
it('should decline an invitation', async () => {
|
||||
const declinedMember = { ...mockMember, invitationStatus: 'declined' as const };
|
||||
mockDb.returning.mockResolvedValueOnce([declinedMember]);
|
||||
|
||||
const result = await service.respondToInvitation(spaceId, memberId, 'declined');
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.invitationStatus).toBe('declined');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return NotFoundError when invitation does not exist', async () => {
|
||||
mockDb.returning.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.respondToInvitation(spaceId, 'unknown-user', 'accepted');
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toContain('SpaceMember');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeMember', () => {
|
||||
it('should remove a member when requester is the owner', async () => {
|
||||
// getSpace call
|
||||
mockDb.limit.mockResolvedValueOnce([mockSpace]);
|
||||
// Check requester role - owner is found in spaceMembers
|
||||
mockDb.limit.mockResolvedValueOnce([mockOwnerMember]);
|
||||
|
||||
const result = await service.removeMember(spaceId, memberId, ownerId);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(mockDb.delete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should remove a member when requester is an admin', async () => {
|
||||
const adminId = 'user-admin';
|
||||
const adminMember = { ...mockMember, userId: adminId, role: 'admin' as const };
|
||||
// getSpace call
|
||||
mockDb.limit.mockResolvedValueOnce([mockSpace]);
|
||||
// Check requester role - is admin
|
||||
mockDb.limit.mockResolvedValueOnce([adminMember]);
|
||||
|
||||
const result = await service.removeMember(spaceId, memberId, adminId);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('should return error when requester is a regular member', async () => {
|
||||
const regularUser = 'user-regular';
|
||||
const regularMember = { ...mockMember, userId: regularUser, role: 'member' as const };
|
||||
// getSpace call
|
||||
mockDb.limit.mockResolvedValueOnce([mockSpace]);
|
||||
// Check requester role - is regular member
|
||||
mockDb.limit.mockResolvedValueOnce([regularMember]);
|
||||
|
||||
const result = await service.removeMember(spaceId, memberId, regularUser);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('should return error when space not found', async () => {
|
||||
mockDb.limit.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.removeMember('nonexistent', memberId, ownerId);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changeMemberRole', () => {
|
||||
it('should change a member role when requester is the owner', async () => {
|
||||
const promotedMember = { ...mockMember, role: 'admin' as const };
|
||||
// getSpace call
|
||||
mockDb.limit.mockResolvedValueOnce([mockSpace]);
|
||||
// update().set().where().returning()
|
||||
mockDb.returning.mockResolvedValueOnce([promotedMember]);
|
||||
|
||||
const result = await service.changeMemberRole(spaceId, memberId, 'admin', ownerId);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.role).toBe('admin');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error when requester is not the owner', async () => {
|
||||
mockDb.limit.mockResolvedValueOnce([mockSpace]);
|
||||
|
||||
const result = await service.changeMemberRole(spaceId, memberId, 'admin', 'not-owner');
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('should return error when member not found', async () => {
|
||||
mockDb.limit.mockResolvedValueOnce([mockSpace]);
|
||||
mockDb.returning.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.changeMemberRole(spaceId, 'nonexistent', 'admin', ownerId);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserRoleInSpace', () => {
|
||||
it('should return owner when user is the space owner', async () => {
|
||||
// getSpace call
|
||||
mockDb.limit.mockResolvedValueOnce([mockSpace]);
|
||||
|
||||
const result = await service.getUserRoleInSpace(spaceId, ownerId);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toBe('owner');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return member role for accepted members', async () => {
|
||||
// getSpace call
|
||||
mockDb.limit.mockResolvedValueOnce([mockSpace]);
|
||||
// Check membership
|
||||
mockDb.limit.mockResolvedValueOnce([mockMember]);
|
||||
|
||||
const result = await service.getUserRoleInSpace(spaceId, memberId);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toBe('member');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return admin role for admin members', async () => {
|
||||
const adminMember = { ...mockMember, role: 'admin' as const };
|
||||
// getSpace call
|
||||
mockDb.limit.mockResolvedValueOnce([mockSpace]);
|
||||
// Check membership
|
||||
mockDb.limit.mockResolvedValueOnce([adminMember]);
|
||||
|
||||
const result = await service.getUserRoleInSpace(spaceId, adminMember.userId);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toBe('admin');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return null when user is not a member', async () => {
|
||||
// getSpace call
|
||||
mockDb.limit.mockResolvedValueOnce([mockSpace]);
|
||||
// Check membership - not found
|
||||
mockDb.limit.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.getUserRoleInSpace(spaceId, 'stranger');
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error when space not found', async () => {
|
||||
mockDb.limit.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.getUserRoleInSpace('nonexistent', ownerId);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPendingInvitations', () => {
|
||||
it('should return pending invitations with space details', async () => {
|
||||
const pendingMember = { ...mockMember, invitationStatus: 'pending' as const };
|
||||
// Get pending invitations - where is terminal
|
||||
mockDb.where.mockResolvedValueOnce([pendingMember]);
|
||||
// getSpace call for each invitation
|
||||
mockDb.limit.mockResolvedValueOnce([mockSpace]);
|
||||
|
||||
const result = await service.getPendingInvitations(memberId);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toHaveLength(1);
|
||||
expect(result.value[0].invitation.invitationStatus).toBe('pending');
|
||||
expect(result.value[0].space.id).toBe(spaceId);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return empty array when no pending invitations', async () => {
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.getPendingInvitations(memberId);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error on database failure', async () => {
|
||||
mockDb.where.mockRejectedValueOnce(new Error('DB error'));
|
||||
|
||||
const result = await service.getPendingInvitations(memberId);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,14 @@
|
|||
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { isOk } from '@manacore/shared-errors';
|
||||
import { SpaceService } from './space.service';
|
||||
import { Space } from '../db/schema/spaces.schema';
|
||||
|
|
@ -58,7 +68,16 @@ export class SpaceController {
|
|||
}
|
||||
|
||||
@Get(':id/members')
|
||||
async getSpaceMembers(@Param('id') id: string): Promise<SpaceMember[]> {
|
||||
async getSpaceMembers(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: CurrentUserData
|
||||
): Promise<SpaceMember[]> {
|
||||
// Verify the requesting user is a member of the space
|
||||
const roleResult = await this.spaceService.getUserRoleInSpace(id, user.userId);
|
||||
if (!isOk(roleResult) || roleResult.value === null) {
|
||||
throw new ForbiddenException('You are not a member of this space');
|
||||
}
|
||||
|
||||
const result = await this.spaceService.getSpaceMembers(id);
|
||||
|
||||
if (!isOk(result)) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { Injectable, Inject, Logger, ForbiddenException } from '@nestjs/common';
|
||||
import { eq, and, desc, inArray } from 'drizzle-orm';
|
||||
import { AsyncResult, ok, err, DatabaseError, NotFoundError } from '@manacore/shared-errors';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
|
|
@ -177,6 +177,26 @@ export class SpaceService {
|
|||
role: 'admin' | 'member' | 'viewer' = 'member'
|
||||
): AsyncResult<SpaceMember> {
|
||||
try {
|
||||
// Verify the inviting user is the owner or an admin
|
||||
const spaceResult = await this.getSpace(spaceId);
|
||||
if (!spaceResult.ok) {
|
||||
return err(spaceResult.error);
|
||||
}
|
||||
|
||||
const isOwner = spaceResult.value.ownerId === invitedByUserId;
|
||||
if (!isOwner) {
|
||||
const inviterMember = await this.db
|
||||
.select()
|
||||
.from(spaceMembers)
|
||||
.where(and(eq(spaceMembers.spaceId, spaceId), eq(spaceMembers.userId, invitedByUserId)))
|
||||
.limit(1);
|
||||
|
||||
const isAdmin = inviterMember.length > 0 && inviterMember[0].role === 'admin';
|
||||
if (!isAdmin) {
|
||||
throw new ForbiddenException('Only owners and admins can invite users');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user is already a member
|
||||
const existingMember = await this.db
|
||||
.select()
|
||||
|
|
|
|||
69
apps/chat/apps/backend/src/template/dto/template.dto.ts
Normal file
69
apps/chat/apps/backend/src/template/dto/template.dto.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { IsBoolean, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateTemplateDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(500)
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(500)
|
||||
description?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(5000)
|
||||
systemPrompt: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(5000)
|
||||
initialQuestion?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
modelId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
color?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
documentMode?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateTemplateDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(500)
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(500)
|
||||
description?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(5000)
|
||||
systemPrompt?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(5000)
|
||||
initialQuestion?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
modelId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
color?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
documentMode?: boolean;
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { TemplateService } from './template.service';
|
|||
import { Template } from '../db/schema/templates.schema';
|
||||
import { JwtAuthGuard, CurrentUser } from '@manacore/shared-nestjs-auth';
|
||||
import type { CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { CreateTemplateDto, UpdateTemplateDto } from './dto/template.dto';
|
||||
|
||||
@Controller('templates')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
|
|
@ -48,16 +49,7 @@ export class TemplateController {
|
|||
|
||||
@Post()
|
||||
async createTemplate(
|
||||
@Body()
|
||||
body: {
|
||||
name: string;
|
||||
description?: string;
|
||||
systemPrompt: string;
|
||||
initialQuestion?: string;
|
||||
modelId?: string;
|
||||
color?: string;
|
||||
documentMode?: boolean;
|
||||
},
|
||||
@Body() body: CreateTemplateDto,
|
||||
@CurrentUser() user: CurrentUserData
|
||||
): Promise<Template> {
|
||||
const result = await this.templateService.createTemplate(user.userId, body);
|
||||
|
|
@ -72,16 +64,7 @@ export class TemplateController {
|
|||
@Patch(':id')
|
||||
async updateTemplate(
|
||||
@Param('id') id: string,
|
||||
@Body()
|
||||
body: Partial<{
|
||||
name: string;
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
initialQuestion: string;
|
||||
modelId: string;
|
||||
color: string;
|
||||
documentMode: boolean;
|
||||
}>,
|
||||
@Body() body: UpdateTemplateDto,
|
||||
@CurrentUser() user: CurrentUserData
|
||||
): Promise<Template> {
|
||||
const result = await this.templateService.updateTemplate(id, user.userId, body);
|
||||
|
|
|
|||
|
|
@ -17,9 +17,10 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||
transformPageChunk: ({ html }) => {
|
||||
// Inject runtime environment variables into the HTML
|
||||
// These will be available on window.__PUBLIC_*__ for client-side code
|
||||
// Use JSON.stringify to prevent HTML/script injection
|
||||
const envScript = `<script>
|
||||
window.__PUBLIC_MANA_CORE_AUTH_URL__ = "${PUBLIC_MANA_CORE_AUTH_URL_CLIENT}";
|
||||
window.__PUBLIC_BACKEND_URL__ = "${PUBLIC_BACKEND_URL_CLIENT}";
|
||||
window.__PUBLIC_MANA_CORE_AUTH_URL__ = ${JSON.stringify(PUBLIC_MANA_CORE_AUTH_URL_CLIENT)};
|
||||
window.__PUBLIC_BACKEND_URL__ = ${JSON.stringify(PUBLIC_BACKEND_URL_CLIENT)};
|
||||
</script>`;
|
||||
return html.replace('<head>', `<head>${envScript}`);
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue