From 44e81bc568619f68e4dfc6a731fa844a0a6ea70e Mon Sep 17 00:00:00 2001 From: Chelsea Johnson <72229300+cbj0hns0n@users.noreply.github.com> Date: Mon, 1 Jun 2026 20:12:40 -0700 Subject: [PATCH 01/10] feat: modify update conversation endpoint to include newer fields --- src/services/conversation.service/index.ts | 82 +++++++++++- src/validations/conversation.validation.ts | 14 +- tests/services/conversation.service.test.ts | 138 ++++++++++++++++++++ 3 files changed, 232 insertions(+), 2 deletions(-) diff --git a/src/services/conversation.service/index.ts b/src/services/conversation.service/index.ts index c2101ee7..bc69dd30 100644 --- a/src/services/conversation.service/index.ts +++ b/src/services/conversation.service/index.ts @@ -249,6 +249,7 @@ const createConversationFromType = async (params, user) => { /** * Update a conversation * @param {Object} conversationBody + * @param {Object} user * @returns {Promise} */ const updateConversation = async (conversationBody, user) => { @@ -266,9 +267,88 @@ const updateConversation = async (conversationBody, user) => { throw new ApiError(httpStatus.BAD_REQUEST, 'Cannot update an active conversation') } - const { resources: incomingResources, ...restBody } = conversationBody + const { + resources: incomingResources, + properties: incomingProperties, + features: incomingFeatures, + topicId, + type, + ...restBody + } = conversationBody + const oldResources = incomingResources !== undefined ? [...conversationDoc.resources] : null + // updateDocument does a shallow doc[key] = body[key], which would wipe all existing + // property keys if `properties` passed through. Merge manually instead. + if (incomingProperties !== undefined) { + conversationDoc.properties = { ...conversationDoc.properties, ...incomingProperties } + conversationDoc.markModified('properties') // required for Mongoose Mixed fields + + // meetingUrl and botName are baked into the adapter config at creation from Handlebars + // templates — they don't update automatically when properties change. + const adapterConfigUpdates: Record = {} + if (incomingProperties.zoomMeetingUrl !== undefined) { + adapterConfigUpdates.meetingUrl = incomingProperties.zoomMeetingUrl + } + if (incomingProperties.botName !== undefined) { + adapterConfigUpdates.botName = incomingProperties.botName + } + if (Object.keys(adapterConfigUpdates).length > 0) { + const zoomAdapters = await Adapter.find({ conversation: conversationDoc._id, type: 'zoom' }) + for (const adapter of zoomAdapters) { + adapter.config = { ...adapter.config, ...adapterConfigUpdates } + adapter.markModified('config') + await adapter.save() + } + } + } + + if (incomingFeatures !== undefined) { + conversationDoc.features = incomingFeatures + } + + if (topicId !== undefined) { + const topic = await Topic.findById(topicId) + if (!topic) { + throw new ApiError(httpStatus.NOT_FOUND, 'Topic not found') + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + conversationDoc.topic = topic._id as any + } + + // Agents and adapters are type-specific — changing type requires recreating them. + if (type !== undefined && type !== conversationDoc.conversationType) { + const conversationType = getConversationType(type) + if (!conversationType) { + throw new ApiError(httpStatus.NOT_FOUND, `Conversation type ${type} not found`) + } + + await Agent.deleteMany({ conversation: conversationDoc._id }) + await Adapter.deleteMany({ conversation: conversationDoc._id }) + conversationDoc.agents = [] + conversationDoc.adapters = [] + + const resolved = resolveConversationType( + { + platforms: conversationDoc.platforms, + properties: conversationDoc.properties, + features: incomingFeatures + }, + conversationType + ) + + for (const agentType of resolved.agentTypes) { + const agent = await agentService.createAgent(agentType.name, conversationDoc, agentType.properties) + conversationDoc.agents.push(agent) + } + for (const adapterProps of resolved.adapters) { + const adapter = await adapterService.createAdapter(adapterProps, conversationDoc) + conversationDoc.adapters.push(adapter) + } + + conversationDoc.conversationType = type + } + conversationDoc = updateDocument(restBody, conversationDoc) if (incomingResources !== undefined) { diff --git a/src/validations/conversation.validation.ts b/src/validations/conversation.validation.ts index 0ad77512..9e5ab4a3 100644 --- a/src/validations/conversation.validation.ts +++ b/src/validations/conversation.validation.ts @@ -23,7 +23,19 @@ const updateConversation = { id: Joi.string().required(), name: Joi.string(), locked: Joi.boolean(), - description: Joi.string(), + description: Joi.string().allow('', null), + scheduledTime: Joi.date(), + scheduledEndTime: Joi.date(), + topicId: Joi.string(), + type: Joi.string(), + platforms: Joi.array().items(Joi.string()), + properties: Joi.object(), + features: Joi.array().items( + Joi.object().keys({ + name: Joi.string().required(), + config: Joi.object() + }) + ), moderators: Joi.array().items( Joi.object().keys({ name: Joi.string().required(), diff --git a/tests/services/conversation.service.test.ts b/tests/services/conversation.service.test.ts index bb93f8cf..4be0ee6c 100644 --- a/tests/services/conversation.service.test.ts +++ b/tests/services/conversation.service.test.ts @@ -1134,6 +1134,144 @@ describe('Conversation service methods', () => { }) }) + describe('updateConversation()', () => { + let conversation + let topicTwo + + beforeEach(async () => { + jest.spyOn(websocketGateway, 'broadcastConversationUpdate').mockImplementation() + await insertUsers([registeredUser]) + topicTwo = newPublicTopic() + await insertTopics([topicOne, topicTwo]) + + conversation = await conversationService.createConversationFromType( + { + type: 'eventAssistant', + name: 'Original Name', + platforms: ['zoom'], + topicId: topicOne._id.toString(), + scheduledTime: new Date(Date.now() + 3600000), + description: 'Original description', + properties: { + zoomMeetingUrl: 'https://zoom.us/j/111111111', + botName: 'OriginalBot' + } + }, + registeredUser + ) + }) + + test('should save changes to event name and description', async () => { + const result = await conversationService.updateConversation( + { id: conversation._id.toString(), name: 'Updated Name', description: 'Updated description' }, + registeredUser + ) + expect(result!.name).toBe('Updated Name') + expect(result!.description).toBe('Updated description') + }) + + test('should preserve other settings when only one property is updated', async () => { + const result = await conversationService.updateConversation( + { id: conversation._id.toString(), properties: { zoomMeetingUrl: 'https://zoom.us/j/999999999' } }, + registeredUser + ) + expect(result!.properties!.zoomMeetingUrl).toBe('https://zoom.us/j/999999999') + expect(result!.properties!.botName).toBe('OriginalBot') + }) + + test('should update the Zoom meeting URL on the linked adapter', async () => { + await conversationService.updateConversation( + { id: conversation._id.toString(), properties: { zoomMeetingUrl: 'https://zoom.us/j/999999999' } }, + registeredUser + ) + const adapters = await Adapter.find({ conversation: conversation._id }) + expect(adapters[0].config.meetingUrl).toBe('https://zoom.us/j/999999999') + }) + + test('should update the bot name on the linked adapter', async () => { + await conversationService.updateConversation( + { id: conversation._id.toString(), properties: { botName: 'NewBotName' } }, + registeredUser + ) + const adapters = await Adapter.find({ conversation: conversation._id }) + expect(adapters[0].config.botName).toBe('NewBotName') + }) + + test('should save changes to event start and end times', async () => { + const newStart = new Date(Date.now() + 7200000) + const newEnd = new Date(Date.now() + 10800000) + const result = await conversationService.updateConversation( + { id: conversation._id.toString(), scheduledTime: newStart, scheduledEndTime: newEnd }, + registeredUser + ) + expect(result!.scheduledTime).toEqual(newStart) + expect(result!.scheduledEndTime).toEqual(newEnd) + }) + + test('should save changes to moderators and speakers', async () => { + const result = await conversationService.updateConversation( + { + id: conversation._id.toString(), + moderators: [{ name: 'New Moderator', bio: 'Moderates things' }], + presenters: [{ name: 'New Speaker', bio: 'Speaks about things' }] + }, + registeredUser + ) + expect(result!.moderators).toHaveLength(1) + expect(result!.moderators![0].name).toBe('New Moderator') + expect(result!.presenters).toHaveLength(1) + expect(result!.presenters![0].name).toBe('New Speaker') + }) + + test('should move the event to a different event series', async () => { + const result = await conversationService.updateConversation( + { id: conversation._id.toString(), topicId: topicTwo._id.toString() }, + registeredUser + ) + expect(result!.topic.toString()).toBe(topicTwo._id.toString()) + }) + + test('should reject an update referencing a non-existent event series', async () => { + await expect( + conversationService.updateConversation( + { id: conversation._id.toString(), topicId: new mongoose.Types.ObjectId().toString() }, + registeredUser + ) + ).rejects.toMatchObject({ statusCode: httpStatus.NOT_FOUND, message: 'Topic not found' }) + }) + + test('should replace the AI agent and adapter when the conversation type changes', async () => { + const originalAgents = await Agent.find({ conversation: conversation._id }) + const originalAdapters = await Adapter.find({ conversation: conversation._id }) + expect(originalAgents.map((a) => a.agentType)).toContain('eventAssistant') + + await conversationService.updateConversation({ id: conversation._id.toString(), type: 'backChannel' }, registeredUser) + + const newAgents = await Agent.find({ conversation: conversation._id }) + const newAdapters = await Adapter.find({ conversation: conversation._id }) + + expect(newAgents.map((a) => a.agentType)).not.toContain('eventAssistant') + expect(newAgents.map((a) => a.agentType).sort()).toEqual(['backChannelInsights', 'backChannelMetrics']) + + const originalAdapterIds = originalAdapters.map((a) => a._id.toString()) + newAdapters.forEach((a) => expect(originalAdapterIds).not.toContain(a._id.toString())) + }) + + test('should reject updates from users who do not own the event', async () => { + const otherUser = { _id: new mongoose.Types.ObjectId(), role: 'user' } + await expect( + conversationService.updateConversation({ id: conversation._id.toString(), name: 'Hacked' }, otherUser) + ).rejects.toMatchObject({ statusCode: httpStatus.FORBIDDEN }) + }) + + test('should reject updates to an event that is currently live', async () => { + await conversation.updateOne({ active: true }) + await expect( + conversationService.updateConversation({ id: conversation._id.toString(), name: 'New Name' }, registeredUser) + ).rejects.toMatchObject({ statusCode: httpStatus.BAD_REQUEST, message: 'Cannot update an active conversation' }) + }) + }) + describe('findByIdFull()', () => { let conversation From a66a67d2f59226bf3260ca3d7dd6340491570aef Mon Sep 17 00:00:00 2001 From: Chelsea Johnson <72229300+cbj0hns0n@users.noreply.github.com> Date: Mon, 1 Jun 2026 20:26:05 -0700 Subject: [PATCH 02/10] test: update tests to refer to topics --- tests/services/conversation.service.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/services/conversation.service.test.ts b/tests/services/conversation.service.test.ts index 4be0ee6c..7a01327b 100644 --- a/tests/services/conversation.service.test.ts +++ b/tests/services/conversation.service.test.ts @@ -1223,7 +1223,7 @@ describe('Conversation service methods', () => { expect(result!.presenters![0].name).toBe('New Speaker') }) - test('should move the event to a different event series', async () => { + test('should move the event to a different topic', async () => { const result = await conversationService.updateConversation( { id: conversation._id.toString(), topicId: topicTwo._id.toString() }, registeredUser @@ -1231,7 +1231,7 @@ describe('Conversation service methods', () => { expect(result!.topic.toString()).toBe(topicTwo._id.toString()) }) - test('should reject an update referencing a non-existent event series', async () => { + test('should reject an update referencing a non-existent topic', async () => { await expect( conversationService.updateConversation( { id: conversation._id.toString(), topicId: new mongoose.Types.ObjectId().toString() }, From 149c314b5d0a9ad9d62b341e3f48879a903aab3d Mon Sep 17 00:00:00 2001 From: Chelsea Johnson <72229300+cbj0hns0n@users.noreply.github.com> Date: Tue, 2 Jun 2026 13:00:14 -0700 Subject: [PATCH 03/10] fix(conversations): remove immutable constraint on conversationType field --- src/models/conversation.model.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/models/conversation.model.ts b/src/models/conversation.model.ts index 9f2e6361..6b427873 100644 --- a/src/models/conversation.model.ts +++ b/src/models/conversation.model.ts @@ -73,8 +73,7 @@ const conversationSchema = new mongoose.Schema conversationType: { type: String, trim: true, - required: false, - immutable: true + required: false }, platforms: { type: [String], From 0e8fcd66807f18f72e522d2ccbae63f7e406218f Mon Sep 17 00:00:00 2001 From: Chelsea Johnson <72229300+cbj0hns0n@users.noreply.github.com> Date: Tue, 2 Jun 2026 13:00:49 -0700 Subject: [PATCH 04/10] feat(conversations): persist feature changes and include topic in full response --- src/services/conversation.service/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/services/conversation.service/index.ts b/src/services/conversation.service/index.ts index bc69dd30..cf891d6d 100644 --- a/src/services/conversation.service/index.ts +++ b/src/services/conversation.service/index.ts @@ -305,6 +305,7 @@ const updateConversation = async (conversationBody, user) => { if (incomingFeatures !== undefined) { conversationDoc.features = incomingFeatures + conversationDoc.markModified('features') // required for Mongoose Mixed array fields } if (topicId !== undefined) { @@ -403,10 +404,11 @@ const findById = async (id) => { const findByIdFull = async (id, user) => { const conversation = await Conversation.findOne({ _id: id }) - .select(`${returnFields} resources`) + .select(`${returnFields} resources topic`) .populate('agents') .populate('channels') .populate('adapters') + .populate('topic') .exec() if (!conversation) { throw new ApiError(httpStatus.NOT_FOUND, `Conversation with id ${id} not found`) From 72260340ddc740f202fcd029f7eb62e2c90074d9 Mon Sep 17 00:00:00 2001 From: Chelsea Johnson <72229300+cbj0hns0n@users.noreply.github.com> Date: Tue, 2 Jun 2026 13:02:13 -0700 Subject: [PATCH 05/10] feat: allow alternate speaker and moderator names when saving an event --- src/validations/conversation.validation.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/validations/conversation.validation.ts b/src/validations/conversation.validation.ts index 9e5ab4a3..d0c76efa 100644 --- a/src/validations/conversation.validation.ts +++ b/src/validations/conversation.validation.ts @@ -39,13 +39,15 @@ const updateConversation = { moderators: Joi.array().items( Joi.object().keys({ name: Joi.string().required(), - bio: Joi.string().allow('', null) + bio: Joi.string().allow('', null), + alternateName: Joi.string().allow('', null) }) ), presenters: Joi.array().items( Joi.object().keys({ name: Joi.string().required(), - bio: Joi.string().allow('', null) + bio: Joi.string().allow('', null), + alternateName: Joi.string().allow('', null) }) ), resources: Joi.array().items(updateResourceSchema) @@ -66,13 +68,15 @@ const createConversation = { moderators: Joi.array().items( Joi.object().keys({ name: Joi.string().required(), - bio: Joi.string().allow('', null) + bio: Joi.string().allow('', null), + alternateName: Joi.string().allow('', null) }) ), presenters: Joi.array().items( Joi.object().keys({ name: Joi.string().required(), - bio: Joi.string().allow('', null) + bio: Joi.string().allow('', null), + alternateName: Joi.string().allow('', null) }) ), resources: Joi.array().items(resourceSchema) From 899b1dfdc919fb3a7528c66497964b670c7a955b Mon Sep 17 00:00:00 2001 From: Chelsea Johnson <72229300+cbj0hns0n@users.noreply.github.com> Date: Tue, 2 Jun 2026 13:02:54 -0700 Subject: [PATCH 06/10] test: add service tests for editing conversation fields --- tests/services/conversation.service.test.ts | 113 ++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/tests/services/conversation.service.test.ts b/tests/services/conversation.service.test.ts index 7a01327b..f42f8b63 100644 --- a/tests/services/conversation.service.test.ts +++ b/tests/services/conversation.service.test.ts @@ -4,6 +4,7 @@ import setupIntTest from '../utils/setupIntTest.js' import { insertUsers, registeredUser } from '../fixtures/user.fixture.js' import { insertTopics, newPublicTopic } from '../fixtures/topic.fixture.js' import conversationService from '../../src/services/conversation.service/index.js' +import { Feature } from '../../src/types/index.types.js' import { Agent, Adapter, Conversation } from '../../src/models/index.js' import ApiError from '../../src/utils/ApiError.js' import websocketGateway from '../../src/websockets/websocketGateway.js' @@ -1257,6 +1258,118 @@ describe('Conversation service methods', () => { newAdapters.forEach((a) => expect(originalAdapterIds).not.toContain(a._id.toString())) }) + test('should save new features to the database when updated', async () => { + await conversationService.updateConversation( + { + id: conversation._id.toString(), + features: [{ name: 'moderatorSupport', config: {} }] + }, + registeredUser + ) + const updated = await Conversation.findById(conversation._id) + const features = updated!.features as Feature[] + expect(features).toHaveLength(1) + expect(features[0].name).toBe('moderatorSupport') + }) + + test('should update existing features in the database when sub-properties change', async () => { + await conversationService.updateConversation( + { + id: conversation._id.toString(), + features: [{ name: 'moderatorSupport', config: { minContributionInterval: 5 } }] + }, + registeredUser + ) + // Now update the sub-property value + await conversationService.updateConversation( + { + id: conversation._id.toString(), + features: [{ name: 'moderatorSupport', config: { minContributionInterval: 15 } }] + }, + registeredUser + ) + const updated = await Conversation.findById(conversation._id) + const features = updated!.features as Feature[] + expect(features).toHaveLength(1) + expect(features[0].config?.minContributionInterval).toBe(15) + }) + + test('should clear features in the database when updated to an empty array', async () => { + // First add a feature + await conversationService.updateConversation( + { id: conversation._id.toString(), features: [{ name: 'moderatorSupport', config: {} }] }, + registeredUser + ) + + // Now clear it + await conversationService.updateConversation({ id: conversation._id.toString(), features: [] }, registeredUser) + + const updated = await Conversation.findById(conversation._id) + expect(updated!.features).toHaveLength(0) + }) + + test('should persist the updated conversationType when the type changes', async () => { + await conversationService.updateConversation({ id: conversation._id.toString(), type: 'backChannel' }, registeredUser) + + const updated = await Conversation.findById(conversation._id) + expect(updated!.conversationType).toBe('backChannel') + }) + + test('should persist alternateName for speakers and moderators in the database', async () => { + await conversationService.updateConversation( + { + id: conversation._id.toString(), + presenters: [{ name: 'Dr. Smith', bio: 'Researcher', alternateName: 'John Smith' }], + moderators: [{ name: 'Ms. Jones', bio: 'Host', alternateName: 'Alice Jones' }] + }, + registeredUser + ) + const updated = await Conversation.findById(conversation._id) + expect(updated!.presenters![0].alternateName).toBe('John Smith') + expect(updated!.moderators![0].alternateName).toBe('Alice Jones') + }) + + test('should persist resources to the database when updated', async () => { + await conversationService.updateConversation( + { + id: conversation._id.toString(), + resources: [ + { + title: 'Attention Is All You Need', + url: 'https://arxiv.org/abs/1706.03762', + authors: ['Vaswani', 'Shazeer'], + year: '2017', + source: 'speaker', + category: 'required', + participantVisible: true + } + ] + }, + registeredUser + ) + const updated = await Conversation.findById(conversation._id) + expect(updated!.resources).toHaveLength(1) + expect(updated!.resources![0].title).toBe('Attention Is All You Need') + expect(updated!.resources![0].url).toBe('https://arxiv.org/abs/1706.03762') + expect(updated!.resources![0].source).toBe('speaker') + expect(updated!.resources![0].category).toBe('required') + }) + + test('should clear resources from the database when updated to an empty array', async () => { + // First add a resource + await conversationService.updateConversation( + { + id: conversation._id.toString(), + resources: [{ title: 'A Paper', source: 'speaker', category: 'suggested' }] + }, + registeredUser + ) + // Then clear it + await conversationService.updateConversation({ id: conversation._id.toString(), resources: [] }, registeredUser) + const updated = await Conversation.findById(conversation._id) + expect(updated!.resources).toHaveLength(0) + }) + test('should reject updates from users who do not own the event', async () => { const otherUser = { _id: new mongoose.Types.ObjectId(), role: 'user' } await expect( From 5bfd5c7d84e243a261cc4c452ca9def6151f29df Mon Sep 17 00:00:00 2001 From: Chelsea Johnson <72229300+cbj0hns0n@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:22:30 -0700 Subject: [PATCH 07/10] fix: correct event update behavior for scheduling, topic sync, and feature handling --- src/services/conversation.service/index.ts | 59 +++++++++++++++++----- src/validations/conversation.validation.ts | 1 + 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/src/services/conversation.service/index.ts b/src/services/conversation.service/index.ts index cf891d6d..c29194be 100644 --- a/src/services/conversation.service/index.ts +++ b/src/services/conversation.service/index.ts @@ -278,14 +278,16 @@ const updateConversation = async (conversationBody, user) => { const oldResources = incomingResources !== undefined ? [...conversationDoc.resources] : null - // updateDocument does a shallow doc[key] = body[key], which would wipe all existing - // property keys if `properties` passed through. Merge manually instead. + /* updateDocument sets doc[key] = body[key] directly, so passing properties through + would overwrite all existing keys with only what the caller sent. We merge manually + so only the changed keys are affected. */ if (incomingProperties !== undefined) { conversationDoc.properties = { ...conversationDoc.properties, ...incomingProperties } - conversationDoc.markModified('properties') // required for Mongoose Mixed fields + conversationDoc.markModified('properties') // Mongoose won't detect changes inside a Mixed field without this - // meetingUrl and botName are baked into the adapter config at creation from Handlebars - // templates — they don't update automatically when properties change. + /* The Zoom adapter's meetingUrl and botName are set once at creation from Handlebars + templates. They don't sync automatically when the conversation's properties change, + so we update the adapter config here too. */ const adapterConfigUpdates: Record = {} if (incomingProperties.zoomMeetingUrl !== undefined) { adapterConfigUpdates.meetingUrl = incomingProperties.zoomMeetingUrl @@ -305,35 +307,58 @@ const updateConversation = async (conversationBody, user) => { if (incomingFeatures !== undefined) { conversationDoc.features = incomingFeatures - conversationDoc.markModified('features') // required for Mongoose Mixed array fields + conversationDoc.markModified('features') // Mongoose won't detect changes inside a Mixed array without this } if (topicId !== undefined) { - const topic = await Topic.findById(topicId) - if (!topic) { + const newTopic = await Topic.findById(topicId) + if (!newTopic) { throw new ApiError(httpStatus.NOT_FOUND, 'Topic not found') } + + /* Keep topic membership in sync on both sides. Without this, the old topic's + conversations list would still include this event after it's been reassigned. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - conversationDoc.topic = topic._id as any + const oldTopic = conversationDoc.topic as any + if (oldTopic?._id?.toString() !== topicId) { + await Topic.findByIdAndUpdate(oldTopic._id, { $pull: { conversations: conversationDoc._id } }) + newTopic.conversations.push(conversationDoc.toObject()) + await newTopic.save() + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + conversationDoc.topic = newTopic._id as any } - // Agents and adapters are type-specific — changing type requires recreating them. + // Agents and adapters are wired to a specific conversation type, so switching types requires recreating them. if (type !== undefined && type !== conversationDoc.conversationType) { const conversationType = getConversationType(type) if (!conversationType) { throw new ApiError(httpStatus.NOT_FOUND, `Conversation type ${type} not found`) } + /* Verify the conversation's existing platforms are supported by the new type. + resolveConversationType silently produces no adapters for unrecognized platforms, + so we catch this here and return a clear error instead. */ + const incompatiblePlatforms = conversationDoc.platforms?.filter( + (p) => !conversationType.platforms.some((cp) => cp.name === p) + ) + if (incompatiblePlatforms?.length) { + throw new ApiError(httpStatus.BAD_REQUEST, `Platform(s) not supported by ${type}: ${incompatiblePlatforms.join(', ')}`) + } + await Agent.deleteMany({ conversation: conversationDoc._id }) await Adapter.deleteMany({ conversation: conversationDoc._id }) conversationDoc.agents = [] conversationDoc.adapters = [] + /* Fall back to the conversation's existing features if none were sent with this + request. Without this, a type-only update would drop all feature-gated agents. */ const resolved = resolveConversationType( { platforms: conversationDoc.platforms, properties: conversationDoc.properties, - features: incomingFeatures + features: incomingFeatures ?? conversationDoc.features }, conversationType ) @@ -364,6 +389,16 @@ const updateConversation = async (conversationBody, user) => { await conversationDoc!.save() + /* Reschedule auto-start and auto-stop jobs whenever the scheduled times change. + scheduleConversationAutoStart cancels the existing job before creating the new one, + so this is safe to call even if a job was already registered. */ + if (restBody.scheduledTime !== undefined && conversationDoc!.scheduledTime) { + await scheduleConversationAutoStart(conversationDoc!) + } + if (restBody.scheduledEndTime !== undefined && conversationDoc!.scheduledEndTime) { + await scheduleConversationAutoStop(conversationDoc!) + } + await transcript.loadEventMetadataIntoVectorStore(conversationDoc!) websocketGateway.broadcastConversationUpdate(conversationDoc) return conversationDoc @@ -408,7 +443,7 @@ const findByIdFull = async (id, user) => { .populate('agents') .populate('channels') .populate('adapters') - .populate('topic') + .populate({ path: 'topic', select: 'name slug description owner' }) .exec() if (!conversation) { throw new ApiError(httpStatus.NOT_FOUND, `Conversation with id ${id} not found`) diff --git a/src/validations/conversation.validation.ts b/src/validations/conversation.validation.ts index d0c76efa..e7c09fc2 100644 --- a/src/validations/conversation.validation.ts +++ b/src/validations/conversation.validation.ts @@ -33,6 +33,7 @@ const updateConversation = { features: Joi.array().items( Joi.object().keys({ name: Joi.string().required(), + enabled: Joi.boolean(), config: Joi.object() }) ), From f2d23955658123bbfd5f58f0b46cade071a1dc16 Mon Sep 17 00:00:00 2001 From: Chelsea Johnson <72229300+cbj0hns0n@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:23:15 -0700 Subject: [PATCH 08/10] test: add coverage for 6 event update edge cases --- tests/services/conversation.service.test.ts | 145 +++++++++++++++++++- 1 file changed, 143 insertions(+), 2 deletions(-) diff --git a/tests/services/conversation.service.test.ts b/tests/services/conversation.service.test.ts index f42f8b63..656bc4ad 100644 --- a/tests/services/conversation.service.test.ts +++ b/tests/services/conversation.service.test.ts @@ -2,10 +2,11 @@ import mongoose from 'mongoose' import httpStatus from 'http-status' import setupIntTest from '../utils/setupIntTest.js' import { insertUsers, registeredUser } from '../fixtures/user.fixture.js' -import { insertTopics, newPublicTopic } from '../fixtures/topic.fixture.js' +import { insertTopics, newPublicTopic, newPrivateTopic } from '../fixtures/topic.fixture.js' import conversationService from '../../src/services/conversation.service/index.js' import { Feature } from '../../src/types/index.types.js' -import { Agent, Adapter, Conversation } from '../../src/models/index.js' +import { Agent, Adapter, Conversation, Topic } from '../../src/models/index.js' +import { setConversationTypes, resetConversationTypes, getAllConversationTypes } from '../../src/conversations/index.js' import ApiError from '../../src/utils/ApiError.js' import websocketGateway from '../../src/websockets/websocketGateway.js' import { supportedModels, defaultLLMPlatform, defaultLLMModel } from '../../src/agents/helpers/getModelChat.js' @@ -1383,6 +1384,116 @@ describe('Conversation service methods', () => { conversationService.updateConversation({ id: conversation._id.toString(), name: 'New Name' }, registeredUser) ).rejects.toMatchObject({ statusCode: httpStatus.BAD_REQUEST, message: 'Cannot update an active conversation' }) }) + + /* Fix #1: auto-start and auto-stop jobs should be rescheduled when the event's + scheduled times change. Before this fix, updating times had no effect on + already-queued Agenda jobs. */ + test('should reschedule the auto-start job when scheduledTime changes', async () => { + const cancelSpy = jest.spyOn(schedule, 'cancelAutoStartConversation').mockResolvedValue(undefined) + const scheduleSpy = jest.spyOn(schedule, 'autoStartConversation').mockResolvedValue(undefined) + + const newStart = new Date(Date.now() + 7200000) + await conversationService.updateConversation( + { id: conversation._id.toString(), scheduledTime: newStart }, + registeredUser + ) + + expect(cancelSpy).toHaveBeenCalledWith(conversation._id) + expect(scheduleSpy).toHaveBeenCalled() + }) + + test('should reschedule the auto-stop job when scheduledEndTime changes', async () => { + const cancelSpy = jest.spyOn(schedule, 'cancelAutoStopConversation').mockResolvedValue(undefined) + const scheduleSpy = jest.spyOn(schedule, 'autoStopConversation').mockResolvedValue(undefined) + + const newEnd = new Date(Date.now() + 10800000) + await conversationService.updateConversation( + { id: conversation._id.toString(), scheduledEndTime: newEnd }, + registeredUser + ) + + expect(cancelSpy).toHaveBeenCalledWith(conversation._id) + expect(scheduleSpy).toHaveBeenCalled() + }) + + /* Fix #2: when a conversation moves to a different topic, the old topic's + conversations list should no longer include it. Before this fix, only the + new topic was updated. */ + test('should remove the conversation from the old topic when reassigned', async () => { + await conversationService.updateConversation( + { id: conversation._id.toString(), topicId: topicTwo._id.toString() }, + registeredUser + ) + + const oldTopic = await Topic.findById(topicOne._id) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const oldTopicConvIds = oldTopic!.conversations.map((c: any) => c._id?.toString() ?? c.toString()) + expect(oldTopicConvIds).not.toContain(conversation._id.toString()) + }) + + /* Fix #4: features with enabled: false should be saved as false, not dropped. + Before this fix, the Joi validation schema stripped the enabled field, + so disabled features would silently revert to their type defaults. */ + test('should persist a feature with enabled: false to the database', async () => { + await conversationService.updateConversation( + { + id: conversation._id.toString(), + features: [{ name: 'moderatorSupport', enabled: false, config: {} }] + }, + registeredUser + ) + + const updated = await Conversation.findById(conversation._id) + const features = updated!.features as Feature[] + expect(features).toHaveLength(1) + expect(features[0].name).toBe('moderatorSupport') + expect(features[0].enabled).toBe(false) + }) + + /* Fix #5: switching to a conversation type that doesn't support the event's + current platform should return a clear 400 error, not silently drop adapters. */ + test('should reject a type change when the existing platform is not supported by the new type', async () => { + /* Register a minimal conversation type that supports no platforms, so switching + to it from a zoom-based event should fail the platform compatibility check. */ + const zoomlessType = { + name: 'zoomlessType', + label: 'Zoomless Type', + description: 'A type that does not support zoom', + platforms: [], + properties: [], + features: [], + agentTypes: [] + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setConversationTypes({ ...getAllConversationTypes(), zoomlessType } as any) + + try { + await expect( + conversationService.updateConversation({ id: conversation._id.toString(), type: 'zoomlessType' }, registeredUser) + ).rejects.toMatchObject({ statusCode: httpStatus.BAD_REQUEST }) + } finally { + resetConversationTypes() + } + }) + + /* Fix #6: a type-only update (no features field sent) should keep the event's + existing features. Before this fix, resolveConversationType was always called + with an empty features array, dropping any features that were already saved. */ + test('should preserve existing features when only the type changes', async () => { + await conversationService.updateConversation( + { + id: conversation._id.toString(), + features: [{ name: 'moderatorSupport', config: { minContributionInterval: 5 } }] + }, + registeredUser + ) + + await conversationService.updateConversation({ id: conversation._id.toString(), type: 'backChannel' }, registeredUser) + + const updated = await Conversation.findById(conversation._id) + const features = updated!.features as Feature[] + expect(features.some((f) => f.name === 'moderatorSupport')).toBe(true) + }) }) describe('findByIdFull()', () => { @@ -1493,5 +1604,35 @@ describe('Conversation service methods', () => { expect(result).toBeDefined() expect(result.channels).toBeDefined() }) + + /* Fix #3: private topic fields (like passcode) should not appear in the response. + Before this fix, topic was populated with toObject() which bypasses the toJSON + plugin transform, leaking fields marked private: true. */ + test('should not expose private fields from the topic', async () => { + /* Override owner so registeredUser (the test caller) can create a conversation + on this private topic. Private topics only allow their owner to create events. */ + const privateTopic = { ...newPrivateTopic(), owner: registeredUser._id } + await insertTopics([privateTopic]) + + const params = { + type: 'eventAssistant', + name: 'Private Topic Event', + platforms: ['zoom'], + topicId: privateTopic._id.toString(), + /* Schedule 2 hours out so it doesn't conflict with the beforeEach conversation + (1 hour out). The adapter service rejects two Zoom events within 10 minutes. */ + scheduledTime: new Date(Date.now() + 7200000), + properties: { + zoomMeetingUrl: 'https://zoom.us/j/555555555' + } + } + const privateConversation = await conversationService.createConversationFromType(params, registeredUser) + + const result = await conversationService.findByIdFull(privateConversation._id.toString(), registeredUser) + + expect(result.topic).toBeDefined() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((result.topic as any).passcode).toBeUndefined() + }) }) }) From 85fc8917cde16d423fb728536c4c9daa510f5ae8 Mon Sep 17 00:00:00 2001 From: Chelsea Johnson <72229300+cbj0hns0n@users.noreply.github.com> Date: Tue, 9 Jun 2026 19:02:37 -0700 Subject: [PATCH 09/10] fix: prevent formatSummary crash when LLM returns string instead of array --- src/services/resource.service.ts | 10 ++++- tests/services/resource.service.test.ts | 60 +++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 tests/services/resource.service.test.ts diff --git a/src/services/resource.service.ts b/src/services/resource.service.ts index e12f7b56..fb7ca12c 100644 --- a/src/services/resource.service.ts +++ b/src/services/resource.service.ts @@ -28,8 +28,14 @@ const resourceSummarySchema = z.object({ .describe('Why this matters or how it connects to real-world practice as 1–2 bullet points.') }) -function formatSummary(s: z.infer) { - const bullets = (items: string[]) => items.map((b) => `- ${b}`).join('\n') +export function formatSummary(s: z.infer) { + /* The LLM occasionally returns a plain string for a single-item list field. + Coerce to an array so .map() doesn't crash on malformed Bedrock tool-call output. */ + const toList = (items: unknown): string[] => (Array.isArray(items) ? (items as string[]) : [String(items)]) + const bullets = (items: unknown) => + toList(items) + .map((b) => `- ${b}`) + .join('\n') const sections = [ `**Main Thesis**\n${bullets(s.mainThesis)}`, `**Key Findings**\n${bullets(s.keyFindings)}`, diff --git a/tests/services/resource.service.test.ts b/tests/services/resource.service.test.ts new file mode 100644 index 00000000..627b45b8 --- /dev/null +++ b/tests/services/resource.service.test.ts @@ -0,0 +1,60 @@ +import { formatSummary } from '../../src/services/resource.service.js' + +describe('formatSummary()', () => { + const validSummary = { + mainThesis: ['The central argument'], + keyFindings: ['Finding one', 'Finding two'], + practicalRelevance: ['Why it matters'] + } + + it('formats a well-formed summary into markdown sections', () => { + const result = formatSummary(validSummary) + + expect(result).toContain('**Main Thesis**') + expect(result).toContain('- The central argument') + expect(result).toContain('**Key Findings**') + expect(result).toContain('- Finding one') + expect(result).toContain('- Finding two') + expect(result).toContain('**Practical Relevance**') + expect(result).toContain('- Why it matters') + }) + + it('omits the Methodology section when the field is absent', () => { + const result = formatSummary(validSummary) + expect(result).not.toContain('**Methodology**') + }) + + it('includes the Methodology section when the field is present', () => { + const result = formatSummary({ ...validSummary, methodology: ['Qualitative interviews'] }) + expect(result).toContain('**Methodology**') + expect(result).toContain('- Qualitative interviews') + }) + + /* + * The LLM occasionally returns a plain string instead of a single-item array, + * especially on the Bedrock path where tool-call args are not Zod-validated + * before being passed to formatSummary. This test captures that crash and + * ensures the fix handles it gracefully. + */ + it('does not throw when the LLM returns a string instead of an array for a field', () => { + const malformed = { + // LLM returned a bare string for a single-item list + mainThesis: 'The central argument' as unknown as string[], + keyFindings: ['Finding one'], + practicalRelevance: ['Why it matters'] + } + + expect(() => formatSummary(malformed)).not.toThrow() + }) + + it('includes the string value as a bullet when the LLM returns a string for an array field', () => { + const malformed = { + mainThesis: 'The central argument' as unknown as string[], + keyFindings: ['Finding one'], + practicalRelevance: ['Why it matters'] + } + + const result = formatSummary(malformed) + expect(result).toContain('- The central argument') + }) +}) From 42b762c210467e49332c5ff98b4579e6f06e7964 Mon Sep 17 00:00:00 2001 From: Chelsea Johnson <72229300+cbj0hns0n@users.noreply.github.com> Date: Tue, 9 Jun 2026 19:12:11 -0700 Subject: [PATCH 10/10] fix: add hasPDF field so edit form shows existing pdfs, fully editable adapters/models/agents --- src/docs/components.yml | 6 +- src/models/conversation.model.ts | 9 +- src/models/plugins/hasPdf.plugin.ts | 22 +++ src/models/plugins/index.ts | 1 + src/services/conversation.service/index.ts | 125 ++++++++++++++++-- src/types/index.types.ts | 3 +- tests/services/conversation.service.test.ts | 94 +++++++++++++ .../unit/models/conversation.resource.test.ts | 49 +++++++ 8 files changed, 294 insertions(+), 15 deletions(-) create mode 100644 src/models/plugins/hasPdf.plugin.ts create mode 100644 tests/unit/models/conversation.resource.test.ts diff --git a/src/docs/components.yml b/src/docs/components.yml index 5a6ef516..3407a3b7 100644 --- a/src/docs/components.yml +++ b/src/docs/components.yml @@ -907,9 +907,9 @@ components: type: string url: type: string - fileName: - type: string - description: On-disk filename; present when the resource is an uploaded PDF file. + hasPdf: + type: boolean + description: True when a PDF file is attached to this resource. The on-disk filename is not exposed. citation: type: string description: Full formatted citation string. Used verbatim in RAG metadata when provided; falls back to title/authors/year otherwise. diff --git a/src/models/conversation.model.ts b/src/models/conversation.model.ts index 6b427873..1cb54a85 100644 --- a/src/models/conversation.model.ts +++ b/src/models/conversation.model.ts @@ -1,7 +1,7 @@ import mongoose, { HydratedDocument, Model } from 'mongoose' import slugify from 'slugify' -import { toJSON, paginate, lock } from './plugins/index.js' +import { toJSON, paginate, lock, hasPdf } from './plugins/index.js' import { IConversation, Profile, Resource } from '../types/index.types.js' import Message from './message.model.js' import transcriptSchema from './schemas/transcript.schema.js' @@ -12,8 +12,8 @@ interface ConversationMethods { type ConversationModel = Model, ConversationMethods> -// Resources are embedded rather than standalone: they are never queried outside their -// conversation, have no independent lifecycle, and cascade-delete naturally with the parent. +/* Resources are embedded rather than standalone: they are never queried outside their + conversation, have no independent lifecycle, and cascade-delete naturally with the parent. */ const resourceSchema = new mongoose.Schema({ source: { type: String, enum: ['speaker', 'ai'], required: true }, category: { type: String, enum: ['required', 'referenced', 'suggested'], required: true }, @@ -30,6 +30,9 @@ const resourceSchema = new mongoose.Schema({ addedAt: { type: Date, default: Date.now } }) resourceSchema.plugin(toJSON) +/* hasPdf must run after toJSON so it can read fileName from doc after toJSON + has already stripped it from ret. */ +resourceSchema.plugin(hasPdf) const profileSchema = new mongoose.Schema( { diff --git a/src/models/plugins/hasPdf.plugin.ts b/src/models/plugins/hasPdf.plugin.ts new file mode 100644 index 00000000..638a0362 --- /dev/null +++ b/src/models/plugins/hasPdf.plugin.ts @@ -0,0 +1,22 @@ +/* eslint-disable no-param-reassign */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import mongoose from 'mongoose' + +/* + * Adds hasPdf to the serialized output of any resource schema. Apply this + * after the toJSON plugin — by then, fileName is already stripped from ret, + * but it's still readable on doc (the raw Mongoose document), so we derive + * hasPdf there instead of exposing the actual path. + */ +const hasPdfPlugin = (schema: mongoose.Schema) => { + const prev = schema.options.toJSON?.transform + schema.options.toJSON = { + ...schema.options.toJSON, + transform(doc: any, ret: any, options: any) { + if (typeof prev === 'function') prev(doc, ret, options) + ret.hasPdf = !!doc.fileName + } + } +} + +export default hasPdfPlugin diff --git a/src/models/plugins/index.ts b/src/models/plugins/index.ts index 97ae044e..6ac3b1f5 100644 --- a/src/models/plugins/index.ts +++ b/src/models/plugins/index.ts @@ -1,3 +1,4 @@ export { default as toJSON } from './toJSON.plugin.js' export { default as paginate } from './paginate.plugin.js' export { default as lock } from './lock.plugin.js' +export { default as hasPdf } from './hasPdf.plugin.js' diff --git a/src/services/conversation.service/index.ts b/src/services/conversation.service/index.ts index c29194be..7e2fea87 100644 --- a/src/services/conversation.service/index.ts +++ b/src/services/conversation.service/index.ts @@ -271,6 +271,10 @@ const updateConversation = async (conversationBody, user) => { resources: incomingResources, properties: incomingProperties, features: incomingFeatures, + /* platforms is extracted manually so we can detect changes and recreate adapters. + Leaving it in restBody would let updateDocument overwrite platforms directly, + bypassing the adapter reconciliation below. */ + platforms: incomingPlatforms, topicId, type, ...restBody @@ -303,11 +307,110 @@ const updateConversation = async (conversationBody, user) => { await adapter.save() } } + + /* Agents get their llmModel and llmPlatform baked in at creation time via $ref + resolution. They don't pick up property changes automatically, so push any + model update to Agent documents now. */ + if (incomingProperties.llmModel !== undefined) { + /* llmModel is stored as { llmModel: string, llmPlatform: string }, an enum property + validated by the frontend. Check both keys before writing; a malformed payload + would otherwise null out the model on every agent. */ + const modelObj = incomingProperties.llmModel as Record + const { llmModel, llmPlatform } = modelObj + if (llmModel && llmPlatform) { + await Agent.updateMany({ conversation: conversationDoc._id }, { $set: { llmModel, llmPlatform } }) + } + } } if (incomingFeatures !== undefined) { conversationDoc.features = incomingFeatures conversationDoc.markModified('features') // Mongoose won't detect changes inside a Mixed array without this + + /* When features change without a type change, reconcile agents to match. Type changes + recreate all agents from scratch (see the block below), so skip this path when + type is also changing. */ + if (type === undefined || type === conversationDoc.conversationType) { + // conversationDoc.conversationType is always set for a persisted conversation + const convType = conversationDoc.conversationType ? getConversationType(conversationDoc.conversationType) : null + if (convType) { + /* A feature is enabled if it's present in the array and its enabled flag is + not explicitly false. */ + const enabledFeatureNames = new Set(incomingFeatures.filter((f) => f.enabled !== false).map((f) => f.name)) + + for (const featureDef of convType.features ?? []) { + for (const agentSpec of featureDef.agents ?? []) { + if (!enabledFeatureNames.has(featureDef.name)) { + /* Feature disabled: remove the agent document and drop the ref from + the conversation's agents array. */ + const agentToRemove = await Agent.findOne({ + conversation: conversationDoc._id, + agentType: agentSpec.name + }) + if (agentToRemove) { + await agentToRemove.deleteOne() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + conversationDoc.agents = (conversationDoc.agents as any[]).filter( + (a) => a.toString() !== agentToRemove._id.toString() + ) + } + } else { + /* Feature enabled: create the agent if it doesn't exist yet. */ + const exists = await Agent.findOne({ conversation: conversationDoc._id, agentType: agentSpec.name }) + if (!exists) { + const resolved = resolveConversationType( + { + platforms: conversationDoc.platforms, + properties: conversationDoc.properties, + features: incomingFeatures + }, + convType + ) + const agentDef = resolved.agentTypes.find((a) => a.name === agentSpec.name) + if (agentDef) { + const agent = await agentService.createAgent(agentDef.name, conversationDoc, agentDef.properties) + /* Push the ObjectId, not the full document. conversationDoc.agents holds + plain refs when not populated; mixing full docs in causes type errors + later. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + conversationDoc.agents.push(agent._id as any) + } + } + } + } + } + } + } + } + + /* When platforms change without a type change, delete the existing adapters and recreate + them for the new combination. Without this, switching from Zoom-only to Zoom+NextSpace + keeps the old adapter config with the wrong dmChannels count. */ + if (incomingPlatforms !== undefined && (type === undefined || type === conversationDoc.conversationType)) { + const currentSorted = (conversationDoc.platforms ?? []).slice().sort().join(',') + const newSorted = incomingPlatforms.slice().sort().join(',') + if (currentSorted !== newSorted) { + await Adapter.deleteMany({ conversation: conversationDoc._id }) + conversationDoc.adapters = [] + + // conversationDoc.conversationType is always set for a persisted conversation + const convType = conversationDoc.conversationType ? getConversationType(conversationDoc.conversationType) : null + if (convType) { + const resolved = resolveConversationType( + { + platforms: incomingPlatforms, + properties: conversationDoc.properties, + features: incomingFeatures ?? conversationDoc.features + }, + convType + ) + for (const adapterProps of resolved.adapters) { + const adapter = await adapterService.createAdapter(adapterProps, conversationDoc) + conversationDoc.adapters.push(adapter) + } + } + } + conversationDoc.platforms = incomingPlatforms } if (topicId !== undefined) { @@ -337,12 +440,15 @@ const updateConversation = async (conversationBody, user) => { throw new ApiError(httpStatus.NOT_FOUND, `Conversation type ${type} not found`) } - /* Verify the conversation's existing platforms are supported by the new type. + /* If a platform list was sent alongside the type change, use it; otherwise keep + the existing platforms. The platform reconciliation block above is skipped when + type is also changing, so this is the one place incomingPlatforms gets applied. */ + const effectivePlatforms = incomingPlatforms ?? conversationDoc.platforms + + /* Verify the effective platforms are supported by the new type. resolveConversationType silently produces no adapters for unrecognized platforms, so we catch this here and return a clear error instead. */ - const incompatiblePlatforms = conversationDoc.platforms?.filter( - (p) => !conversationType.platforms.some((cp) => cp.name === p) - ) + const incompatiblePlatforms = effectivePlatforms?.filter((p) => !conversationType.platforms.some((cp) => cp.name === p)) if (incompatiblePlatforms?.length) { throw new ApiError(httpStatus.BAD_REQUEST, `Platform(s) not supported by ${type}: ${incompatiblePlatforms.join(', ')}`) } @@ -356,7 +462,7 @@ const updateConversation = async (conversationBody, user) => { request. Without this, a type-only update would drop all feature-gated agents. */ const resolved = resolveConversationType( { - platforms: conversationDoc.platforms, + platforms: effectivePlatforms, properties: conversationDoc.properties, features: incomingFeatures ?? conversationDoc.features }, @@ -373,6 +479,9 @@ const updateConversation = async (conversationBody, user) => { } conversationDoc.conversationType = type + if (incomingPlatforms !== undefined) { + conversationDoc.platforms = incomingPlatforms + } } conversationDoc = updateDocument(restBody, conversationDoc) @@ -467,10 +576,10 @@ const findByIdFull = async (id, user) => { }) as any[] } const resources = cleanRet.resources?.map((r) => { - // strip internal fileName - // eslint-disable-next-line @typescript-eslint/no-unused-vars + /* Strip internal fileName and expose hasPdf so the client knows a PDF + is attached without seeing the on-disk path. */ const { _id: resourceId, fileName, ...rest } = r as unknown as Record - return { ...rest, id: (resourceId as { toString(): string }).toString() } + return { ...rest, id: (resourceId as { toString(): string }).toString(), hasPdf: !!fileName } }) return { ...cleanRet, diff --git a/src/types/index.types.ts b/src/types/index.types.ts index baa4520b..b0d67d77 100644 --- a/src/types/index.types.ts +++ b/src/types/index.types.ts @@ -305,7 +305,8 @@ export interface Resource { authors?: string[] year?: string url?: string - fileName?: string // on-disk name; present when resource is a PDF file + fileName?: string // on-disk name; present when resource is a PDF file (private — stripped from API responses) + hasPdf?: boolean // derived from fileName; true when a PDF is attached citation?: string // full formatted citation description?: string // creator-provided relevance note summary?: string // AI-generated; populated async for required readings diff --git a/tests/services/conversation.service.test.ts b/tests/services/conversation.service.test.ts index 656bc4ad..107e3786 100644 --- a/tests/services/conversation.service.test.ts +++ b/tests/services/conversation.service.test.ts @@ -1494,6 +1494,75 @@ describe('Conversation service methods', () => { const features = updated!.features as Feature[] expect(features.some((f) => f.name === 'moderatorSupport')).toBe(true) }) + + test('should recreate the adapter with the correct config when platforms change', async () => { + /* The beforeEach conversation uses platforms: ['zoom'], which resolves to the + zoom-only adapter config (2 dmChannels: direct agent DM + moderator DM). + Switching to nextspace+zoom should produce the 'nextspace,zoom' config + (1 dmChannel: direct agent DM only — moderator DMs go through NextSpace). */ + const adapterBefore = await Adapter.findOne({ conversation: conversation._id, type: 'zoom' }) + expect(adapterBefore!.dmChannels).toHaveLength(2) + + await conversationService.updateConversation( + { id: conversation._id.toString(), platforms: ['nextspace', 'zoom'] }, + registeredUser + ) + + const adapterAfter = await Adapter.findOne({ conversation: conversation._id, type: 'zoom' }) + /* After the fix, the adapter should be recreated with the nextspace,zoom config + (1 dmChannel). Before the fix, the old adapter is not recreated and still has 2. */ + expect(adapterAfter!.dmChannels).toHaveLength(1) + }) + + test('should update the llmModel on all agents when the property changes', async () => { + /* Agents inherit llmModel from conversation properties at creation time via $ref + resolution, but updateConversation currently only writes the new llmModel into + the conversation's properties object. The Agent documents are not updated, + so a model change set on the edit form has no effect on running agents. */ + const newModel = supportedModels[1] + + await conversationService.updateConversation( + { id: conversation._id.toString(), properties: { llmModel: newModel } }, + registeredUser + ) + + const agents = await Agent.find({ conversation: conversation._id }) + agents.forEach((agent) => { + expect(agent.llmModel).toBe(newModel.llmModel) + expect(agent.llmPlatform).toBe(newModel.llmPlatform) + }) + }) + + test('should remove the agent for a feature when that feature is disabled', async () => { + /* Feature-gated agents are created at conversation-creation time. When a feature is + later disabled via updateConversation, only the features array on the Conversation + document is updated — the Agent documents are not reconciled, so the agent stays. */ + const featureConv = await conversationService.createConversationFromType( + { + type: 'eventAssistant', + name: 'Feature Test Event', + platforms: ['zoom'], + topicId: topicOne._id.toString(), + /* Use a different scheduledTime to avoid a uniqueness conflict with the + beforeEach conversation (both are Zoom events). */ + scheduledTime: new Date(Date.now() + 7200000), + properties: { zoomMeetingUrl: 'https://zoom.us/j/feature-test' }, + features: [{ name: 'collectiveVoice' }] + }, + registeredUser + ) + + const agentsBefore = await Agent.find({ conversation: featureConv._id }) + expect(agentsBefore.map((a) => a.agentType)).toContain('eventMediator') + + await conversationService.updateConversation( + { id: featureConv._id.toString(), features: [{ name: 'collectiveVoice', enabled: false }] }, + registeredUser + ) + + const agentsAfter = await Agent.find({ conversation: featureConv._id }) + expect(agentsAfter.map((a) => a.agentType)).not.toContain('eventMediator') + }) }) describe('findByIdFull()', () => { @@ -1634,5 +1703,30 @@ describe('Conversation service methods', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any expect((result.topic as any).passcode).toBeUndefined() }) + + test('should expose hasPdf: true and omit fileName when a resource has a PDF attached', async () => { + /* Insert a resource with fileName directly in the DB to simulate what savePdf does. + findByIdFull should strip fileName and expose hasPdf: true instead. */ + await Conversation.updateOne( + { _id: conversation._id }, + { + $push: { + resources: { + source: 'speaker', + category: 'required', + title: 'Test Paper', + participantVisible: true, + fileName: 'test-resource.pdf' + } + } + } + ) + + const result = await conversationService.findByIdFull(conversation._id.toString(), registeredUser) + const resource = result.resources![0] as unknown as Record + + expect(resource.hasPdf).toBe(true) + expect(resource.fileName).toBeUndefined() + }) }) }) diff --git a/tests/unit/models/conversation.resource.test.ts b/tests/unit/models/conversation.resource.test.ts new file mode 100644 index 00000000..4ebce046 --- /dev/null +++ b/tests/unit/models/conversation.resource.test.ts @@ -0,0 +1,49 @@ +import mongoose from 'mongoose' +import toJSON from '../../../src/models/plugins/toJSON.plugin.js' +import hasPdf from '../../../src/models/plugins/hasPdf.plugin.js' + +/* + * Tests for the hasPdf plugin. Resources have a private fileName field that + * the toJSON plugin strips from API responses — hasPdf is derived from it so + * clients can tell whether a PDF is attached without seeing the actual path. + */ +describe('hasPdf plugin', () => { + let connection: mongoose.Connection + + beforeEach(() => { + connection = mongoose.createConnection() + }) + + /* Minimal schema that matches the real resourceSchema's relevant shape: + fileName is private, title is public. Both plugins applied in order. */ + const buildSchema = (name: string) => { + const schema = new mongoose.Schema({ + title: { type: String, required: true }, + fileName: { type: String, private: true }, + }) + schema.plugin(toJSON) + schema.plugin(hasPdf) + return connection.model(name, schema) + } + + it('keeps fileName out of the serialized output', () => { + const Resource = buildSchema('ResourcePrivate') + const doc = new Resource({ title: 'Test Paper', fileName: 'abc123.pdf' }) + + expect(doc.toJSON()).not.toHaveProperty('fileName') + }) + + it('sets hasPdf to true when the resource has a fileName', () => { + const Resource = buildSchema('ResourceWithFile') + const doc = new Resource({ title: 'Test Paper', fileName: 'abc123.pdf' }) + + expect(doc.toJSON()).toHaveProperty('hasPdf', true) + }) + + it('sets hasPdf to false when the resource has no fileName', () => { + const Resource = buildSchema('ResourceNoFile') + const doc = new Resource({ title: 'Link Only Resource' }) + + expect(doc.toJSON()).toHaveProperty('hasPdf', false) + }) +})