From 1c0e2907a4c5d9884bbbc87acc6a456295c1adbc Mon Sep 17 00:00:00 2001 From: Daniel Diaz <39510674+IslandRhythms@users.noreply.github.com> Date: Fri, 12 Dec 2025 15:38:12 -0500 Subject: [PATCH 01/17] begin mongoose sleuth --- backend/actions/Sleuth/createCaseReport.js | 25 ++ backend/actions/Sleuth/getCaseReports.js | 21 ++ backend/actions/Sleuth/index.js | 4 + backend/actions/index.js | 1 + backend/db/sleuthSchema.js | 20 ++ backend/index.js | 2 + frontend/src/api.js | 16 + frontend/src/case-reports/case-reports.html | 86 +++++ frontend/src/case-reports/case-reports.js | 34 ++ frontend/src/models/models.html | 7 + frontend/src/models/models.js | 5 + .../src/mongoose-sleuth/mongoose-sleuth.html | 200 ++++++++++++ .../src/mongoose-sleuth/mongoose-sleuth.js | 305 ++++++++++++++++++ frontend/src/navbar/navbar.html | 20 ++ frontend/src/navbar/navbar.js | 3 + frontend/src/routes.js | 26 +- 16 files changed, 770 insertions(+), 5 deletions(-) create mode 100644 backend/actions/Sleuth/createCaseReport.js create mode 100644 backend/actions/Sleuth/getCaseReports.js create mode 100644 backend/actions/Sleuth/index.js create mode 100644 backend/db/sleuthSchema.js create mode 100644 frontend/src/case-reports/case-reports.html create mode 100644 frontend/src/case-reports/case-reports.js create mode 100644 frontend/src/mongoose-sleuth/mongoose-sleuth.html create mode 100644 frontend/src/mongoose-sleuth/mongoose-sleuth.js diff --git a/backend/actions/Sleuth/createCaseReport.js b/backend/actions/Sleuth/createCaseReport.js new file mode 100644 index 00000000..1671918a --- /dev/null +++ b/backend/actions/Sleuth/createCaseReport.js @@ -0,0 +1,25 @@ +'use strict'; + +const Archetype = require('archetype'); +const authorize = require('../../authorize'); + +const CreateCaseReportParams = new Archetype({ + name: { + $type: 'string', + $required: true + }, + roles: { + $type: ['string'] + } +}).compile('CreateCaseReportParams'); + +module.exports = ({ db }) => async function createCaseReport(params) { + const { name, roles } = new CreateCaseReportParams(params); + const Sleuth = db.model('__Studio_Sleuth'); + + await authorize('Sleuth.createCaseReport', roles); + + const caseReport = await Sleuth.create({ name }); + + return { caseReport }; +}; diff --git a/backend/actions/Sleuth/getCaseReports.js b/backend/actions/Sleuth/getCaseReports.js new file mode 100644 index 00000000..5c1f83d9 --- /dev/null +++ b/backend/actions/Sleuth/getCaseReports.js @@ -0,0 +1,21 @@ +'use strict'; + +const Archetype = require('archetype'); +const authorize = require('../../authorize'); + +const GetCaseReportsParams = new Archetype({ + roles: { + $type: ['string'] + } +}).compile('GetCaseReportsParams'); + +module.exports = ({ db }) => async function getCaseReports(params) { + const { roles } = new GetCaseReportsParams(params); + const Sleuth = db.model('__Studio_Sleuth'); + + await authorize('Sleuth.getCaseReports', roles); + + const caseReports = await Sleuth.find({}).sort({ createdAt: -1 }).lean(); + + return { caseReports }; +}; diff --git a/backend/actions/Sleuth/index.js b/backend/actions/Sleuth/index.js new file mode 100644 index 00000000..dbced35d --- /dev/null +++ b/backend/actions/Sleuth/index.js @@ -0,0 +1,4 @@ +'use strict'; + +exports.createCaseReport = require('./createCaseReport'); +exports.getCaseReports = require('./getCaseReports'); diff --git a/backend/actions/index.js b/backend/actions/index.js index b5eb0679..837d8c18 100644 --- a/backend/actions/index.js +++ b/backend/actions/index.js @@ -5,4 +5,5 @@ exports.ChatThread = require('./ChatThread'); exports.Dashboard = require('./Dashboard'); exports.Model = require('./Model'); exports.Script = require('./Script'); +exports.Sleuth = require('./Sleuth'); exports.status = require('./status'); diff --git a/backend/db/sleuthSchema.js b/backend/db/sleuthSchema.js new file mode 100644 index 00000000..6c4828cf --- /dev/null +++ b/backend/db/sleuthSchema.js @@ -0,0 +1,20 @@ +'use strict'; + +const mongoose = require('mongoose'); + +const sleuthSchema = new mongoose.Schema({ + name: { + type: String, + required: true + }, + status: { + type: String, + $required: true, + default: 'created', + enum: ['created', 'in_progress', 'cancelled', 'resolved', 'archived'] + } +}, { + timestamps: true +}); + +module.exports = sleuthSchema; \ No newline at end of file diff --git a/backend/index.js b/backend/index.js index 27f925c2..50082794 100644 --- a/backend/index.js +++ b/backend/index.js @@ -7,6 +7,7 @@ const mongoose = require('mongoose'); const chatMessageSchema = require('./db/chatMessageSchema'); const chatThreadSchema = require('./db/chatThreadSchema'); const dashboardSchema = require('./db/dashboardSchema'); +const sleuthSchema = require('./db/sleuthSchema'); module.exports = function backend(db, studioConnection, options) { db = db || mongoose.connection; @@ -15,6 +16,7 @@ module.exports = function backend(db, studioConnection, options) { const Dashboard = studioConnection.model('__Studio_Dashboard', dashboardSchema, 'studio__dashboards'); const ChatMessage = studioConnection.model('__Studio_ChatMessage', chatMessageSchema, 'studio__chatMessages'); const ChatThread = studioConnection.model('__Studio_ChatThread', chatThreadSchema, 'studio__chatThreads'); + const Sleuth = studioConnection.model('__Studio_Sleuth', sleuthSchema, 'studio__sleuths'); const actions = applySpec(Actions, { db, studioConnection, options }); return actions; diff --git a/frontend/src/api.js b/frontend/src/api.js index d0bfeb70..5fcac60f 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -75,6 +75,14 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) { return client.post('', { action: 'ChatMessage.executeScript', ...params }).then(res => res.data); } }; + exports.Sleuth = { + createCaseReport(params) { + return client.post('', { action: 'Sleuth.createCaseReport', ...params }).then(res => res.data); + }, + getCaseReports(params) { + return client.post('', { action: 'Sleuth.getCaseReports', ...params }).then(res => res.data); + } + }; exports.Model = { addField(params) { return client.post('', { action: 'Model.addField', ...params }).then(res => res.data); @@ -240,6 +248,14 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) { return client.post('/ChatMessage/executeScript', params).then(res => res.data); } }; + exports.Sleuth = { + createCaseReport: function createCaseReport(params) { + return client.post('/Sleuth/createCaseReport', params).then(res => res.data); + }, + getCaseReports: function getCaseReports(params) { + return client.post('/Sleuth/getCaseReports', params).then(res => res.data); + } + }; exports.Model = { addField(params) { return client.post('/Model/addField', params).then(res => res.data); diff --git a/frontend/src/case-reports/case-reports.html b/frontend/src/case-reports/case-reports.html new file mode 100644 index 00000000..435f9cf3 --- /dev/null +++ b/frontend/src/case-reports/case-reports.html @@ -0,0 +1,86 @@ +
+
+ + + + +
+
+
+

No case reports yet

+

Get started by creating a new case report in Mongoose Sleuth.

+
+ + + New Case Report + +
+
+
+ +
+
+
+

Case Reports

+
+
+ + New Case Report + +
+
+
+
+
+ + + + + + + + + + + + + + + +
NameCreated +
{{caseReport.name}} + {{ formatDate(caseReport.createdAt) }} + + + Open + +
+
+
+
+
+
diff --git a/frontend/src/case-reports/case-reports.js b/frontend/src/case-reports/case-reports.js new file mode 100644 index 00000000..fba4e373 --- /dev/null +++ b/frontend/src/case-reports/case-reports.js @@ -0,0 +1,34 @@ +'use strict'; + +const api = require('../api'); +const template = require('./case-reports.html'); + +module.exports = app => app.component('case-reports', { + template: template, + data: () => ({ + status: 'loading', + caseReports: [] + }), + methods: { + formatDate(date) { + if (!date) return 'N/A'; + try { + const d = new Date(date); + if (isNaN(d.getTime())) return 'N/A'; + return d.toLocaleDateString() + ' ' + d.toLocaleTimeString(); + } catch (e) { + return 'N/A'; + } + } + }, + async mounted() { + try { + const { caseReports } = await api.Sleuth.getCaseReports(); + this.caseReports = caseReports; + this.status = 'loaded'; + } catch (error) { + console.error('Error loading case reports', error); + this.status = 'loaded'; + } + } +}); diff --git a/frontend/src/models/models.html b/frontend/src/models/models.html index a9ab0e8a..eac6e133 100644 --- a/frontend/src/models/models.html +++ b/frontend/src/models/models.html @@ -100,6 +100,13 @@ class="rounded bg-ultramarine-600 px-2 py-2 text-sm font-semibold text-white shadow-sm hover:bg-ultramarine-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ultramarine-600"> Fields + + + + +
+
+
+
+

Mongoose Sleuth

+
+ Step 1: Aggregating + + Step 2: Investigating + + Step 3: Summarize +
+
+ +
+
+ {{ selectedDocuments.length }} {{ selectedDocuments.length === 1 ? 'document' : 'documents' }} selected +
+
+
+
+

Select a model from the sidebar to view documents

+
+
+
+
+
+
+ + +
+ Loading ... + {{numDocuments === 1 ? numDocuments+ ' document' : numDocuments + ' documents'}} +
+ + + + +
+
+
+
+
+ +
+ + + + + + + + + + + +
+ {{path.path}} + + ({{(path.instance || 'unknown')}}) + +
+ + + + +
+
+
+
+ + Selected +
+ + +
+
+
+ +
+
+

No documents found. Try adjusting your search.

+
+
+
+
+ + + + diff --git a/frontend/src/mongoose-sleuth/mongoose-sleuth.js b/frontend/src/mongoose-sleuth/mongoose-sleuth.js new file mode 100644 index 00000000..faf0b7dd --- /dev/null +++ b/frontend/src/mongoose-sleuth/mongoose-sleuth.js @@ -0,0 +1,305 @@ +'use strict'; + +const api = require('../api'); +const template = require('./mongoose-sleuth.html'); +const mpath = require('mpath'); + +const limit = 20; +const OUTPUT_TYPE_STORAGE_KEY = 'studio:mongoose-sleuth-output-type'; + +module.exports = app => app.component('mongoose-sleuth', { + template: template, + props: [], + data: () => ({ + models: [], + currentModel: null, + documents: [], + schemaPaths: [], + filteredPaths: [], + numDocuments: null, + status: 'loading', + loadedAllDocs: false, + searchText: '', + sortBy: {}, + query: {}, + scrollHeight: 0, + outputType: 'json', // json, table + hideSidebar: null, + selectedDocuments: [], + error: null, + shouldShowCaseReportModal: false, + caseReportName: '' + }), + created() { + this.loadOutputPreference(); + }, + beforeDestroy() { + const container = this.$refs.documentsList?.querySelector('.documents-container'); + if (container) { + container.removeEventListener('scroll', this.onScroll, true); + } + }, + async mounted() { + this.onScroll = () => this.checkIfScrolledToBottom(); + const container = this.$refs.documentsList?.querySelector('.documents-container'); + if (container) { + container.addEventListener('scroll', this.onScroll, true); + } + const { models, readyState } = await api.Model.listModels(); + this.models = models; + if (this.models.length === 0) { + this.status = 'loaded'; + if (readyState === 0) { + this.error = 'No models found and Mongoose is not connected. Check our documentation for more information.'; + } + } else { + this.status = 'loaded'; + } + }, + updated() { + // Re-attach scroll listener when documents container is updated + this.$nextTick(() => { + const container = this.$refs.documentsList?.querySelector('.documents-container'); + if (container) { + container.removeEventListener('scroll', this.onScroll, true); + container.addEventListener('scroll', this.onScroll, true); + } + }); + }, + computed: { + referenceMap() { + const map = {}; + for (const path of this.filteredPaths) { + if (path?.ref) { + map[path.path] = path.ref; + } + } + return map; + } + }, + methods: { + loadOutputPreference() { + if (typeof window === 'undefined' || !window.localStorage) { + return; + } + const storedPreference = window.localStorage.getItem(OUTPUT_TYPE_STORAGE_KEY); + if (storedPreference === 'json' || storedPreference === 'table') { + this.outputType = storedPreference; + } + }, + setOutputType(type) { + if (type !== 'json' && type !== 'table') { + return; + } + this.outputType = type; + if (typeof window !== 'undefined' && window.localStorage) { + window.localStorage.setItem(OUTPUT_TYPE_STORAGE_KEY, type); + } + }, + buildDocumentFetchParams(options = {}) { + const params = { + model: this.currentModel, + limit + }; + + if (typeof options.skip === 'number') { + params.skip = options.skip; + } + + const sortKeys = Object.keys(this.sortBy); + if (sortKeys.length > 0) { + const key = sortKeys[0]; + if (typeof key === 'string' && key.length > 0) { + params.sortKey = key; + const direction = this.sortBy[key]; + if (direction !== undefined && direction !== null) { + params.sortDirection = direction; + } + } + } + + if (typeof this.searchText === 'string' && this.searchText.trim().length > 0) { + params.searchText = this.searchText; + } + + return params; + }, + async selectModel(model) { + this.currentModel = model; + this.documents = []; + this.schemaPaths = []; + this.filteredPaths = []; + this.numDocuments = null; + this.loadedAllDocs = false; + this.searchText = ''; + this.status = 'loading'; + this.error = null; + await this.getDocuments(); + this.status = 'loaded'; + // Attach scroll listener after documents are loaded + this.$nextTick(() => { + const container = this.$refs.documentsList?.querySelector('.documents-container'); + if (container) { + container.removeEventListener('scroll', this.onScroll, true); + container.addEventListener('scroll', this.onScroll, true); + } + }); + }, + async search(searchText) { + this.searchText = searchText; + this.documents = []; + this.loadedAllDocs = false; + this.status = 'loading'; + await this.loadMoreDocuments(); + this.status = 'loaded'; + }, + addPathFilter(path) { + if (this.$refs.documentSearch?.addPathFilter) { + this.$refs.documentSearch.addPathFilter(path); + } + }, + async getDocuments() { + // Clear previous data + this.documents = []; + this.schemaPaths = []; + this.numDocuments = null; + this.loadedAllDocs = false; + + let docsCount = 0; + let schemaPathsReceived = false; + + // Use async generator to stream SSEs + const params = this.buildDocumentFetchParams(); + for await (const event of api.Model.getDocumentsStream(params)) { + if (event.schemaPaths && !schemaPathsReceived) { + // Sort schemaPaths with _id first + this.schemaPaths = Object.keys(event.schemaPaths).sort((k1, k2) => { + if (k1 === '_id' && k2 !== '_id') { + return -1; + } + if (k1 !== '_id' && k2 === '_id') { + return 1; + } + return 0; + }).map(key => event.schemaPaths[key]); + this.filteredPaths = [...this.schemaPaths]; + schemaPathsReceived = true; + } + if (event.numDocs !== undefined) { + this.numDocuments = event.numDocs; + } + if (event.document) { + this.documents.push(event.document); + docsCount++; + } + if (event.message) { + this.status = 'loaded'; + throw new Error(event.message); + } + } + + if (docsCount < limit) { + this.loadedAllDocs = true; + } + }, + async loadMoreDocuments() { + let docsCount = 0; + let numDocsReceived = false; + + // Use async generator to stream SSEs + const params = this.buildDocumentFetchParams({ skip: this.documents.length }); + for await (const event of api.Model.getDocumentsStream(params)) { + if (event.numDocs !== undefined && !numDocsReceived) { + this.numDocuments = event.numDocs; + numDocsReceived = true; + } + if (event.document) { + this.documents.push(event.document); + docsCount++; + } + if (event.message) { + this.status = 'loaded'; + throw new Error(event.message); + } + } + + if (docsCount < limit) { + this.loadedAllDocs = true; + } + }, + async checkIfScrolledToBottom() { + if (this.status === 'loading' || this.loadedAllDocs || !this.currentModel) { + return; + } + const container = this.$refs.documentsList?.querySelector('.documents-container'); + if (container && container.scrollHeight - container.clientHeight - 100 < container.scrollTop) { + this.status = 'loading'; + await this.loadMoreDocuments(); + this.status = 'loaded'; + } + }, + filterDocument(doc) { + const filteredDoc = {}; + for (let i = 0; i < this.filteredPaths.length; i++) { + const path = this.filteredPaths[i].path; + const value = mpath.get(path, doc); + mpath.set(path, value, filteredDoc); + } + return filteredDoc; + }, + getComponentForPath(schemaPath) { + if (schemaPath.instance === 'Array') { + return 'list-array'; + } + if (schemaPath.instance === 'String') { + return 'list-string'; + } + if (schemaPath.instance == 'Embedded') { + return 'list-subdocument'; + } + if (schemaPath.instance == 'Mixed') { + return 'list-mixed'; + } + return 'list-default'; + }, + getReferenceModel(schemaPath) { + return schemaPath.ref; + }, + getValueForPath(doc, path) { + return mpath.get(path, doc); + }, + isDocumentSelected(document) { + return this.selectedDocuments.some(x => x._id.toString() === document._id.toString() && x.model === this.currentModel); + }, + handleDocumentSelection(document, event) { + const documentWithModel = { ...document, model: this.currentModel }; + const index = this.selectedDocuments.findIndex(x => + x._id.toString() === document._id.toString() && x.model === this.currentModel + ); + + if (index !== -1) { + // Deselect + this.selectedDocuments.splice(index, 1); + } else { + // Select + this.selectedDocuments.push(documentWithModel); + } + }, + async saveCaseReport() { + if (!this.caseReportName || this.caseReportName.trim().length === 0) { + return; + } + + try { + await api.Sleuth.createCaseReport({ name: this.caseReportName.trim() }); + this.shouldShowCaseReportModal = false; + this.caseReportName = ''; + // Show success message (you might want to use a toast notification here) + alert('Case report saved successfully!'); + } catch (error) { + console.error('Error saving case report', error); + alert('Error saving case report: ' + (error.message || 'Unknown error')); + } + } + } +}); diff --git a/frontend/src/navbar/navbar.html b/frontend/src/navbar/navbar.html index dd6f29ed..54a1ba35 100644 --- a/frontend/src/navbar/navbar.html +++ b/frontend/src/navbar/navbar.html @@ -39,6 +39,16 @@
+ Case Reports + + Case Reports + + + Case Reports + + Case Reports + +
-
- - -
-
-
-
-

Mongoose Sleuth

-
- Step 1: Aggregating - - Step 2: Investigating - - Step 3: Summarize -
-
- -
-
- {{ selectedDocuments.length }} {{ selectedDocuments.length === 1 ? 'document' : 'documents' }} selected -
-
-
-
-

Select a model from the sidebar to view documents

-
-
-
-
-
-
- - -
- Loading ... - {{numDocuments === 1 ? numDocuments+ ' document' : numDocuments + ' documents'}} -
- - - - -
-
-
-
-
- -
- - - - - - - - - - - -
- {{path.path}} - - ({{(path.instance || 'unknown')}}) - -
- - - - -
-
-
-
- - Selected -
- - -
-
-
- -
-
-

No documents found. Try adjusting your search.

-
-
-
-
- - - + + diff --git a/frontend/src/mongoose-sleuth/mongoose-sleuth.js b/frontend/src/mongoose-sleuth/mongoose-sleuth.js index faf0b7dd..54d24f74 100644 --- a/frontend/src/mongoose-sleuth/mongoose-sleuth.js +++ b/frontend/src/mongoose-sleuth/mongoose-sleuth.js @@ -3,6 +3,7 @@ const api = require('../api'); const template = require('./mongoose-sleuth.html'); const mpath = require('mpath'); +const vanillatoasts = require('vanillatoasts'); const limit = 20; const OUTPUT_TYPE_STORAGE_KEY = 'studio:mongoose-sleuth-output-type'; @@ -10,6 +11,11 @@ const OUTPUT_TYPE_STORAGE_KEY = 'studio:mongoose-sleuth-output-type'; module.exports = app => app.component('mongoose-sleuth', { template: template, props: [], + provide() { + return { + sleuthContext: this + }; + }, data: () => ({ models: [], currentModel: null, @@ -28,20 +34,24 @@ module.exports = app => app.component('mongoose-sleuth', { selectedDocuments: [], error: null, shouldShowCaseReportModal: false, - caseReportName: '' + caseReportName: '', + currentCaseReportId: null, + activeStep: 'aggregating', + investigationSelections: [], + documentNotes: {} }), created() { this.loadOutputPreference(); }, beforeDestroy() { - const container = this.$refs.documentsList?.querySelector('.documents-container'); + const container = this.$refs.aggregating?.$refs?.documentsList?.querySelector('.documents-container'); if (container) { container.removeEventListener('scroll', this.onScroll, true); } }, async mounted() { this.onScroll = () => this.checkIfScrolledToBottom(); - const container = this.$refs.documentsList?.querySelector('.documents-container'); + const container = this.$refs.aggregating?.$refs?.documentsList?.querySelector('.documents-container'); if (container) { container.addEventListener('scroll', this.onScroll, true); } @@ -55,11 +65,30 @@ module.exports = app => app.component('mongoose-sleuth', { } else { this.status = 'loaded'; } + + // If opened with an existing case report, load it and go to Step 2 + const caseReportId = this.$route?.query?.caseReportId; + if (caseReportId) { + this.currentCaseReportId = caseReportId; + try { + await this.loadCaseReport(caseReportId); + } catch (err) { + console.error('Error loading case report', err); + vanillatoasts.create({ + title: 'Error loading case report', + text: err?.message || 'Unknown error', + type: 'error', + timeout: 5000, + icon: 'images/failure.jpg', + positionClass: 'bottomRight' + }); + } + } }, updated() { // Re-attach scroll listener when documents container is updated this.$nextTick(() => { - const container = this.$refs.documentsList?.querySelector('.documents-container'); + const container = this.$refs.aggregating?.$refs?.documentsList?.querySelector('.documents-container'); if (container) { container.removeEventListener('scroll', this.onScroll, true); container.addEventListener('scroll', this.onScroll, true); @@ -78,6 +107,30 @@ module.exports = app => app.component('mongoose-sleuth', { } }, methods: { + goToAggregating() { + this.activeStep = 'aggregating'; + }, + goToInvestigating() { + // If there are already documents in the investigation set (e.g., from an existing case report), + // allow moving to Step 2 without enforcing Step 1 selection. + if (Array.isArray(this.investigationSelections) && this.investigationSelections.length > 0) { + this.activeStep = 'investigating'; + return; + } + + if (!Array.isArray(this.selectedDocuments) || this.selectedDocuments.length === 0) { + vanillatoasts.create({ + title: 'No documents selected', + text: 'Select one or more documents in Step 1 before moving to Investigating.', + type: 'warning', + timeout: 3000, + icon: 'images/failure.jpg', + positionClass: 'bottomRight' + }); + return; + } + this.activeStep = 'investigating'; + }, loadOutputPreference() { if (typeof window === 'undefined' || !window.localStorage) { return; @@ -138,13 +191,46 @@ module.exports = app => app.component('mongoose-sleuth', { this.status = 'loaded'; // Attach scroll listener after documents are loaded this.$nextTick(() => { - const container = this.$refs.documentsList?.querySelector('.documents-container'); + const container = this.$refs.aggregating?.$refs?.documentsList?.querySelector('.documents-container'); if (container) { container.removeEventListener('scroll', this.onScroll, true); container.addEventListener('scroll', this.onScroll, true); } }); }, + getDocumentKey(doc) { + if (!doc || !doc._id || !doc.model) { + return ''; + } + return `${String(doc.model)}:${String(doc._id)}`; + }, + isInInvestigation(doc) { + const key = this.getDocumentKey(doc); + return this.investigationSelections.some(d => this.getDocumentKey(d) === key); + }, + toggleInvestigationSelection(doc) { + const key = this.getDocumentKey(doc); + const idx = this.investigationSelections.findIndex(d => this.getDocumentKey(d) === key); + if (idx !== -1) { + this.investigationSelections.splice(idx, 1); + } else { + this.investigationSelections.push(doc); + } + }, + getDocumentNote(doc) { + const key = this.getDocumentKey(doc); + if (!key) { + return ''; + } + return this.documentNotes[key] || ''; + }, + setDocumentNote(doc, value) { + const key = this.getDocumentKey(doc); + if (!key) { + return; + } + this.documentNotes[key] = value; + }, async search(searchText) { this.searchText = searchText; this.documents = []; @@ -154,8 +240,9 @@ module.exports = app => app.component('mongoose-sleuth', { this.status = 'loaded'; }, addPathFilter(path) { - if (this.$refs.documentSearch?.addPathFilter) { - this.$refs.documentSearch.addPathFilter(path); + const aggregating = this.$refs.aggregating; + if (aggregating?.$refs?.documentSearch?.addPathFilter) { + aggregating.$refs.documentSearch.addPathFilter(path); } }, async getDocuments() { @@ -202,6 +289,51 @@ module.exports = app => app.component('mongoose-sleuth', { this.loadedAllDocs = true; } }, + async loadCaseReport(caseReportId) { + const { caseReport } = await api.Sleuth.getCaseReport({ caseReportId }); + if (!caseReport || !Array.isArray(caseReport.documents)) { + return; + } + + const loadedDocs = []; + for (const entry of caseReport.documents) { + if (!entry || !entry.document || !entry.documentModel) { + continue; + } + try { + const { doc } = await api.Model.getDocument({ + model: entry.documentModel, + documentId: entry.document + }); + if (!doc) { + continue; + } + const merged = { + ...doc, + model: entry.documentModel + }; + loadedDocs.push(merged); + + // Restore note if present + if (typeof entry.notes === 'string' && entry.notes.trim().length > 0) { + const key = this.getDocumentKey(merged); + if (key) { + this.documentNotes[key] = entry.notes.trim(); + } + } + } catch (err) { + console.error('Error loading document for case report', entry, err); + } + } + + if (loadedDocs.length > 0) { + this.selectedDocuments = loadedDocs; + // By default, investigate all documents in the case report + this.investigationSelections = loadedDocs.slice(); + // Open directly to Step 2 when a case report already has documents + this.activeStep = 'investigating'; + } + }, async loadMoreDocuments() { let docsCount = 0; let numDocsReceived = false; @@ -231,7 +363,7 @@ module.exports = app => app.component('mongoose-sleuth', { if (this.status === 'loading' || this.loadedAllDocs || !this.currentModel) { return; } - const container = this.$refs.documentsList?.querySelector('.documents-container'); + const container = this.$refs.aggregating?.$refs?.documentsList?.querySelector('.documents-container'); if (container && container.scrollHeight - container.clientHeight - 100 < container.scrollTop) { this.status = 'loading'; await this.loadMoreDocuments(); @@ -273,10 +405,10 @@ module.exports = app => app.component('mongoose-sleuth', { }, handleDocumentSelection(document, event) { const documentWithModel = { ...document, model: this.currentModel }; - const index = this.selectedDocuments.findIndex(x => + const index = this.selectedDocuments.findIndex(x => x._id.toString() === document._id.toString() && x.model === this.currentModel ); - + if (index !== -1) { // Deselect this.selectedDocuments.splice(index, 1); @@ -287,18 +419,70 @@ module.exports = app => app.component('mongoose-sleuth', { }, async saveCaseReport() { if (!this.caseReportName || this.caseReportName.trim().length === 0) { + vanillatoasts.create({ + title: 'Case report name is required', + type: 'warning', + timeout: 3000, + icon: 'images/failure.jpg', + positionClass: 'bottomRight' + }); + return; + } + + const documentsPayload = this.selectedDocuments.map(doc => { + const base = { + document: doc._id, + documentModel: doc.model + }; + const note = this.getDocumentNote(doc); + if (typeof note === 'string' && note.trim().length > 0) { + base.notes = note.trim(); + } + return base; + }); + + if (documentsPayload.length === 0) { + vanillatoasts.create({ + title: 'Select at least one document', + text: 'Choose one or more documents before creating a case report.', + type: 'warning', + timeout: 3000, + icon: 'images/failure.jpg', + positionClass: 'bottomRight' + }); return; } try { - await api.Sleuth.createCaseReport({ name: this.caseReportName.trim() }); + await api.Sleuth.createCaseReport({ + name: this.caseReportName.trim(), + documents: documentsPayload + }); this.shouldShowCaseReportModal = false; this.caseReportName = ''; - // Show success message (you might want to use a toast notification here) - alert('Case report saved successfully!'); + // Pre-populate investigation step with all currently selected documents + this.investigationSelections = Array.isArray(this.selectedDocuments) + ? this.selectedDocuments.slice() + : []; + // Move to Step 2 automatically + this.activeStep = 'investigating'; + vanillatoasts.create({ + title: 'Case report created!', + type: 'success', + timeout: 3000, + icon: 'images/success.png', + positionClass: 'bottomRight' + }); } catch (error) { console.error('Error saving case report', error); - alert('Error saving case report: ' + (error.message || 'Unknown error')); + vanillatoasts.create({ + title: 'Error saving case report', + text: error?.message || 'Unknown error', + type: 'error', + timeout: 5000, + icon: 'images/failure.jpg', + positionClass: 'bottomRight' + }); } } } diff --git a/frontend/src/mongoose-sleuth/sleuth-aggregating/sleuth-aggregating.html b/frontend/src/mongoose-sleuth/sleuth-aggregating/sleuth-aggregating.html new file mode 100644 index 00000000..71a329bd --- /dev/null +++ b/frontend/src/mongoose-sleuth/sleuth-aggregating/sleuth-aggregating.html @@ -0,0 +1,287 @@ +
+
+ +
+ + +
+
+
+
+

Mongoose Sleuth

+
+ + + + + Step 3: Summarize +
+
+ + +
+
+ {{ sleuthContext.selectedDocuments.length }} + {{ sleuthContext.selectedDocuments.length === 1 ? 'document' : 'documents' }} selected +
+
+ + {{ doc.model }} – {{ doc._id }} + +
+
+ +
+
+

Select a model from the sidebar to view documents

+
+
+ +
+
+
+
+ + +
+ Loading ... + + {{ + sleuthContext.numDocuments === 1 + ? sleuthContext.numDocuments + ' document' + : sleuthContext.numDocuments + ' documents' + }} + +
+ + + + +
+
+
+ +
+
+ +
+ + + + + + + + + + + + +
+ {{ path.path }} + ({{ path.instance || 'unknown' }}) +
+ + + + +
+ +
+
+
+ + + Selected + +
+ + +
+
+ +
+ +
+
+

No documents found. Try adjusting your search.

+
+
+
+
+ + + + +
diff --git a/frontend/src/mongoose-sleuth/sleuth-aggregating/sleuth-aggregating.js b/frontend/src/mongoose-sleuth/sleuth-aggregating/sleuth-aggregating.js new file mode 100644 index 00000000..5b36950f --- /dev/null +++ b/frontend/src/mongoose-sleuth/sleuth-aggregating/sleuth-aggregating.js @@ -0,0 +1,8 @@ +'use strict'; + +const template = require('./sleuth-aggregating.html'); + +module.exports = app => app.component('sleuth-aggregating', { + template, + inject: ['sleuthContext'] +}); diff --git a/frontend/src/mongoose-sleuth/sleuth-investigating/sleuth-investigating.html b/frontend/src/mongoose-sleuth/sleuth-investigating/sleuth-investigating.html new file mode 100644 index 00000000..b8216a3f --- /dev/null +++ b/frontend/src/mongoose-sleuth/sleuth-investigating/sleuth-investigating.html @@ -0,0 +1,143 @@ +
+ +
+
+
+

Mongoose Sleuth

+
+ + + + + Step 3: Summarize +
+
+
+
+ {{ sleuthContext.selectedDocuments.length }} + {{ sleuthContext.selectedDocuments.length === 1 ? 'document selected from Step 1' : 'documents selected from Step 1' }} +
+
+ +
+ +
+
+

Selected Documents

+ + No documents selected. Go back to Step 1. + +
+
+
+ +
+
+ + {{ doc.model }} – {{ doc._id }} + + +
+
+ +
+
+
+
+ No documents have been selected in Step 1. Go back to the Aggregating step to choose documents. +
+
+
+ + +
+
+

Investigation Notes

+ + Select documents on the left to add notes. + +
+
+
+
+
+
+ {{ doc.model }} – {{ doc._id }} +
+
+
+ + +
+ +
+ +
+ + +
+
+
+ No documents selected for investigation yet. Use the checkboxes on the left to choose which documents to + analyze in this step. +
+
+
+
+
diff --git a/frontend/src/mongoose-sleuth/sleuth-investigating/sleuth-investigating.js b/frontend/src/mongoose-sleuth/sleuth-investigating/sleuth-investigating.js new file mode 100644 index 00000000..aaed5622 --- /dev/null +++ b/frontend/src/mongoose-sleuth/sleuth-investigating/sleuth-investigating.js @@ -0,0 +1,33 @@ +'use strict'; + +const template = require('./sleuth-investigating.html'); + +module.exports = app => app.component('sleuth-investigating', { + template, + inject: ['sleuthContext'], + data: () => ({ + expandedLeftDocs: {} + }), + methods: { + getLeftDocKey(doc) { + if (!this.sleuthContext || !doc) { + return ''; + } + return this.sleuthContext.getDocumentKey(doc); + }, + isLeftExpanded(doc) { + const key = this.getLeftDocKey(doc); + if (!key) { + return false; + } + return !!this.expandedLeftDocs[key]; + }, + toggleLeftDetails(doc) { + const key = this.getLeftDocKey(doc); + if (!key) { + return; + } + this.expandedLeftDocs[key] = !this.expandedLeftDocs[key]; + } + } +}); From 34a6247bde97d52c8d8d6b4c360e3a8b14625afa Mon Sep 17 00:00:00 2001 From: Daniel Diaz <39510674+IslandRhythms@users.noreply.github.com> Date: Thu, 18 Dec 2025 15:21:37 -0500 Subject: [PATCH 03/17] Update sleuth-investigating.html --- .../sleuth-investigating/sleuth-investigating.html | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/mongoose-sleuth/sleuth-investigating/sleuth-investigating.html b/frontend/src/mongoose-sleuth/sleuth-investigating/sleuth-investigating.html index b8216a3f..797f77a8 100644 --- a/frontend/src/mongoose-sleuth/sleuth-investigating/sleuth-investigating.html +++ b/frontend/src/mongoose-sleuth/sleuth-investigating/sleuth-investigating.html @@ -60,6 +60,7 @@

Selected Documents

type="checkbox" class="mt-1 h-4 w-4 rounded border-gray-300 text-ultramarine-600 focus:ring-ultramarine-500 flex-shrink-0" :checked="sleuthContext.isInInvestigation(doc)" + @click.stop @change.stop="sleuthContext.toggleInvestigationSelection(doc)" />
From 08886571aa49fb8a9265a8c0f9062afb75943ea1 Mon Sep 17 00:00:00 2001 From: Daniel Diaz <39510674+IslandRhythms@users.noreply.github.com> Date: Thu, 18 Dec 2025 17:34:43 -0500 Subject: [PATCH 04/17] use new toasts --- .../src/mongoose-sleuth/mongoose-sleuth.js | 53 +++---------------- 1 file changed, 6 insertions(+), 47 deletions(-) diff --git a/frontend/src/mongoose-sleuth/mongoose-sleuth.js b/frontend/src/mongoose-sleuth/mongoose-sleuth.js index 54d24f74..6bd95986 100644 --- a/frontend/src/mongoose-sleuth/mongoose-sleuth.js +++ b/frontend/src/mongoose-sleuth/mongoose-sleuth.js @@ -3,7 +3,6 @@ const api = require('../api'); const template = require('./mongoose-sleuth.html'); const mpath = require('mpath'); -const vanillatoasts = require('vanillatoasts'); const limit = 20; const OUTPUT_TYPE_STORAGE_KEY = 'studio:mongoose-sleuth-output-type'; @@ -74,14 +73,7 @@ module.exports = app => app.component('mongoose-sleuth', { await this.loadCaseReport(caseReportId); } catch (err) { console.error('Error loading case report', err); - vanillatoasts.create({ - title: 'Error loading case report', - text: err?.message || 'Unknown error', - type: 'error', - timeout: 5000, - icon: 'images/failure.jpg', - positionClass: 'bottomRight' - }); + this.$toast.error(`Error loading case report: ${err?.message || 'Unknown error'}`); } } }, @@ -119,14 +111,7 @@ module.exports = app => app.component('mongoose-sleuth', { } if (!Array.isArray(this.selectedDocuments) || this.selectedDocuments.length === 0) { - vanillatoasts.create({ - title: 'No documents selected', - text: 'Select one or more documents in Step 1 before moving to Investigating.', - type: 'warning', - timeout: 3000, - icon: 'images/failure.jpg', - positionClass: 'bottomRight' - }); + this.$toast.warning('No documents selected. Select one or more documents in Step 1 before moving to Investigating.'); return; } this.activeStep = 'investigating'; @@ -419,13 +404,7 @@ module.exports = app => app.component('mongoose-sleuth', { }, async saveCaseReport() { if (!this.caseReportName || this.caseReportName.trim().length === 0) { - vanillatoasts.create({ - title: 'Case report name is required', - type: 'warning', - timeout: 3000, - icon: 'images/failure.jpg', - positionClass: 'bottomRight' - }); + this.$toast.warning('Case report name is required'); return; } @@ -442,14 +421,7 @@ module.exports = app => app.component('mongoose-sleuth', { }); if (documentsPayload.length === 0) { - vanillatoasts.create({ - title: 'Select at least one document', - text: 'Choose one or more documents before creating a case report.', - type: 'warning', - timeout: 3000, - icon: 'images/failure.jpg', - positionClass: 'bottomRight' - }); + this.$toast.warning('Select at least one document. Choose one or more documents before creating a case report.'); return; } @@ -466,23 +438,10 @@ module.exports = app => app.component('mongoose-sleuth', { : []; // Move to Step 2 automatically this.activeStep = 'investigating'; - vanillatoasts.create({ - title: 'Case report created!', - type: 'success', - timeout: 3000, - icon: 'images/success.png', - positionClass: 'bottomRight' - }); + this.$toast.success('Case report created!'); } catch (error) { console.error('Error saving case report', error); - vanillatoasts.create({ - title: 'Error saving case report', - text: error?.message || 'Unknown error', - type: 'error', - timeout: 5000, - icon: 'images/failure.jpg', - positionClass: 'bottomRight' - }); + this.$toast.error(error?.message || 'Error saving case report'); } } } From aca384023f270ade831d71882873d0ebd24f64db Mon Sep 17 00:00:00 2001 From: Daniel Diaz <39510674+IslandRhythms@users.noreply.github.com> Date: Thu, 18 Dec 2025 18:27:42 -0500 Subject: [PATCH 05/17] flesh out step 2 more --- backend/actions/Sleuth/index.js | 1 + backend/actions/Sleuth/updateCaseReport.js | 48 +++++++++++ backend/authorize.js | 7 +- frontend/src/api.js | 6 ++ .../src/mongoose-sleuth/mongoose-sleuth.js | 84 ++++++++++++++----- .../sleuth-investigating.html | 7 ++ 6 files changed, 131 insertions(+), 22 deletions(-) create mode 100644 backend/actions/Sleuth/updateCaseReport.js diff --git a/backend/actions/Sleuth/index.js b/backend/actions/Sleuth/index.js index 8eac2b7b..8e8acd47 100644 --- a/backend/actions/Sleuth/index.js +++ b/backend/actions/Sleuth/index.js @@ -3,3 +3,4 @@ exports.createCaseReport = require('./createCaseReport'); exports.getCaseReports = require('./getCaseReports'); exports.getCaseReport = require('./getCaseReport'); +exports.updateCaseReport = require('./updateCaseReport'); diff --git a/backend/actions/Sleuth/updateCaseReport.js b/backend/actions/Sleuth/updateCaseReport.js new file mode 100644 index 00000000..2767c61a --- /dev/null +++ b/backend/actions/Sleuth/updateCaseReport.js @@ -0,0 +1,48 @@ +'use strict'; + +const Archetype = require('archetype'); +const authorize = require('../../authorize'); + +const UpdateCaseReportParams = new Archetype({ + caseReportId: { + $type: 'string', + $required: true + }, + // Full replacement array of documents for this case report + documents: { + $type: Array, + $default: [] + }, + roles: { + $type: ['string'] + } +}).compile('UpdateCaseReportParams'); + +module.exports = ({ db }) => async function updateCaseReport(params) { + const { caseReportId, documents, roles } = new UpdateCaseReportParams(params); + const Sleuth = db.model('__Studio_Sleuth'); + + await authorize('Sleuth.updateCaseReport', roles); + + const docs = Array.isArray(documents) + ? documents + .filter(doc => doc && doc.document && doc.documentModel) + .map(doc => ({ + document: doc.document, + documentModel: doc.documentModel, + ...(doc.notes ? { notes: doc.notes } : {}) + })) + : []; + + const caseReport = await Sleuth.findByIdAndUpdate( + caseReportId, + { documents: docs }, + { new: true } + ).lean(); + + if (!caseReport) { + throw new Error('Case report not found'); + } + + return { caseReport }; +}; diff --git a/backend/authorize.js b/backend/authorize.js index 200c52ef..7a45b48b 100644 --- a/backend/authorize.js +++ b/backend/authorize.js @@ -23,7 +23,12 @@ const actionsToRequiredRoles = { 'Model.getDocumentsStream': ['owner', 'admin', 'member', 'readonly'], 'Model.getIndexes': ['owner', 'admin', 'member', 'readonly'], 'Model.listModels': ['owner', 'admin', 'member', 'readonly'], - 'Model.updateDocuments': ['owner', 'admin', 'member'] + 'Model.updateDocuments': ['owner', 'admin', 'member'], + // Sleuth / Mongoose Sleuth (Bug Hunt) actions + 'Sleuth.createCaseReport': ['owner', 'admin', 'member'], + 'Sleuth.getCaseReports': ['owner', 'admin', 'member', 'readonly'], + 'Sleuth.getCaseReport': ['owner', 'admin', 'member', 'readonly'], + 'Sleuth.updateCaseReport': ['owner', 'admin', 'member'] }; module.exports = function authorize(action, roles) { diff --git a/frontend/src/api.js b/frontend/src/api.js index acbd36a2..3c8bb331 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -84,6 +84,9 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) { }, getCaseReport(params) { return client.post('', { action: 'Sleuth.getCaseReport', ...params }).then(res => res.data); + }, + updateCaseReport(params) { + return client.post('', { action: 'Sleuth.updateCaseReport', ...params }).then(res => res.data); } }; exports.Model = { @@ -263,6 +266,9 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) { }, getCaseReport: function getCaseReport(params) { return client.post('/Sleuth/getCaseReport', params).then(res => res.data); + }, + updateCaseReport: function updateCaseReport(params) { + return client.post('/Sleuth/updateCaseReport', params).then(res => res.data); } }; exports.Model = { diff --git a/frontend/src/mongoose-sleuth/mongoose-sleuth.js b/frontend/src/mongoose-sleuth/mongoose-sleuth.js index 6bd95986..7f86c342 100644 --- a/frontend/src/mongoose-sleuth/mongoose-sleuth.js +++ b/frontend/src/mongoose-sleuth/mongoose-sleuth.js @@ -99,21 +99,51 @@ module.exports = app => app.component('mongoose-sleuth', { } }, methods: { - goToAggregating() { + async goToAggregating() { + // If we're coming from Step 2 and have an existing case report, + // persist current notes/document state before going back. + if (this.activeStep === 'investigating' && this.currentCaseReportId) { + await this.saveInvestigationProgress(); + } this.activeStep = 'aggregating'; }, - goToInvestigating() { - // If there are already documents in the investigation set (e.g., from an existing case report), - // allow moving to Step 2 without enforcing Step 1 selection. - if (Array.isArray(this.investigationSelections) && this.investigationSelections.length > 0) { - this.activeStep = 'investigating'; + buildDocumentsPayload() { + return this.selectedDocuments.map(doc => { + const base = { + document: doc._id, + documentModel: doc.model + }; + const note = this.getDocumentNote(doc); + base.notes = note.trim(); + return base; + }); + }, + async goToInvestigating() { + if (this.selectedDocuments.length === 0) { + this.$toast.warning('No documents selected. Select one or more documents in Step 1 before moving to Investigating.'); return; } - if (!Array.isArray(this.selectedDocuments) || this.selectedDocuments.length === 0) { - this.$toast.warning('No documents selected. Select one or more documents in Step 1 before moving to Investigating.'); - return; + // Keep investigation selections in sync with current step 1 selections + this.investigationSelections = this.selectedDocuments.slice(); + + // If we're editing an existing case report, persist the new document selection + if (this.currentCaseReportId) { + const documentsPayload = this.buildDocumentsPayload(); + + try { + await api.Sleuth.updateCaseReport({ + caseReportId: this.currentCaseReportId, + documents: documentsPayload + }); + this.$toast.success('Case report updated'); + } catch (err) { + console.error('Error updating case report', err); + this.$toast.error(err?.message || 'Error updating case report'); + return; + } } + this.activeStep = 'investigating'; }, loadOutputPreference() { @@ -408,17 +438,7 @@ module.exports = app => app.component('mongoose-sleuth', { return; } - const documentsPayload = this.selectedDocuments.map(doc => { - const base = { - document: doc._id, - documentModel: doc.model - }; - const note = this.getDocumentNote(doc); - if (typeof note === 'string' && note.trim().length > 0) { - base.notes = note.trim(); - } - return base; - }); + const documentsPayload = this.buildDocumentsPayload(); if (documentsPayload.length === 0) { this.$toast.warning('Select at least one document. Choose one or more documents before creating a case report.'); @@ -426,10 +446,13 @@ module.exports = app => app.component('mongoose-sleuth', { } try { - await api.Sleuth.createCaseReport({ + const { caseReport } = await api.Sleuth.createCaseReport({ name: this.caseReportName.trim(), documents: documentsPayload }); + if (caseReport && caseReport._id) { + this.currentCaseReportId = caseReport._id; + } this.shouldShowCaseReportModal = false; this.caseReportName = ''; // Pre-populate investigation step with all currently selected documents @@ -443,6 +466,25 @@ module.exports = app => app.component('mongoose-sleuth', { console.error('Error saving case report', error); this.$toast.error(error?.message || 'Error saving case report'); } + }, + async saveInvestigationProgress() { + if (!this.currentCaseReportId) { + this.$toast.error('No case report to save yet.'); + return; + } + + const documentsPayload = this.buildDocumentsPayload(); + + try { + await api.Sleuth.updateCaseReport({ + caseReportId: this.currentCaseReportId, + documents: documentsPayload + }); + this.$toast.success('Progress saved'); + } catch (err) { + console.error('Error saving progress', err); + this.$toast.error(err?.message || 'Error saving progress'); + } } } }); diff --git a/frontend/src/mongoose-sleuth/sleuth-investigating/sleuth-investigating.html b/frontend/src/mongoose-sleuth/sleuth-investigating/sleuth-investigating.html index 797f77a8..7dd5777c 100644 --- a/frontend/src/mongoose-sleuth/sleuth-investigating/sleuth-investigating.html +++ b/frontend/src/mongoose-sleuth/sleuth-investigating/sleuth-investigating.html @@ -30,6 +30,13 @@

Mongoose Sleuth

Step 3: Summarize
+ + Save Progress +
{{ sleuthContext.selectedDocuments.length }} From c2145cc72439f3ee5a8ccfb688258ecf93db3c33 Mon Sep 17 00:00:00 2001 From: Daniel Diaz <39510674+IslandRhythms@users.noreply.github.com> Date: Fri, 19 Dec 2025 12:02:15 -0500 Subject: [PATCH 06/17] update UI --- .../src/mongoose-sleuth/mongoose-sleuth.js | 60 +++++++++- .../sleuth-aggregating.html | 106 +++++++++++++++--- 2 files changed, 151 insertions(+), 15 deletions(-) diff --git a/frontend/src/mongoose-sleuth/mongoose-sleuth.js b/frontend/src/mongoose-sleuth/mongoose-sleuth.js index 7f86c342..f1cf21b0 100644 --- a/frontend/src/mongoose-sleuth/mongoose-sleuth.js +++ b/frontend/src/mongoose-sleuth/mongoose-sleuth.js @@ -37,7 +37,9 @@ module.exports = app => app.component('mongoose-sleuth', { currentCaseReportId: null, activeStep: 'aggregating', investigationSelections: [], - documentNotes: {} + documentNotes: {}, + showSelectedDocuments: false, + expandedModels: {} }), created() { this.loadOutputPreference(); @@ -96,6 +98,23 @@ module.exports = app => app.component('mongoose-sleuth', { } } return map; + }, + selectedDocumentsByModel() { + const grouped = {}; + for (const doc of this.selectedDocuments) { + if (!doc || !doc.model) { + continue; + } + if (!grouped[doc.model]) { + grouped[doc.model] = []; + } + grouped[doc.model].push(doc); + } + // Convert to array of { model, documents } for easier iteration + return Object.keys(grouped).map(model => ({ + model, + documents: grouped[model] + })); } }, methods: { @@ -219,6 +238,26 @@ module.exports = app => app.component('mongoose-sleuth', { } return `${String(doc.model)}:${String(doc._id)}`; }, + getDocumentPreview(doc) { + if (!doc || typeof doc !== 'object') { + return ''; + } + // Try common fields that might be useful for identification + const previewFields = ['name', 'title', 'email', 'username', 'label', 'description']; + for (const field of previewFields) { + if (doc[field] != null) { + const value = doc[field]; + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim(); + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + } + } + // If no preview field found, return empty string + return ''; + }, isInInvestigation(doc) { const key = this.getDocumentKey(doc); return this.investigationSelections.some(d => this.getDocumentKey(d) === key); @@ -432,6 +471,25 @@ module.exports = app => app.component('mongoose-sleuth', { this.selectedDocuments.push(documentWithModel); } }, + removeSelectedDocument(doc) { + const key = this.getDocumentKey(doc); + if (!key) { + return; + } + const index = this.selectedDocuments.findIndex(x => this.getDocumentKey(x) === key); + if (index !== -1) { + this.selectedDocuments.splice(index, 1); + } + }, + toggleModelExpansion(model) { + if (!model) { + return; + } + this.expandedModels[model] = !this.expandedModels[model]; + }, + isModelExpanded(model) { + return !!this.expandedModels[model]; + }, async saveCaseReport() { if (!this.caseReportName || this.caseReportName.trim().length === 0) { this.$toast.warning('Case report name is required'); diff --git a/frontend/src/mongoose-sleuth/sleuth-aggregating/sleuth-aggregating.html b/frontend/src/mongoose-sleuth/sleuth-aggregating/sleuth-aggregating.html index 71a329bd..94795daa 100644 --- a/frontend/src/mongoose-sleuth/sleuth-aggregating/sleuth-aggregating.html +++ b/frontend/src/mongoose-sleuth/sleuth-aggregating/sleuth-aggregating.html @@ -83,21 +83,99 @@

Mongoose Sleuth

Continue to Step 2
-
- {{ sleuthContext.selectedDocuments.length }} - {{ sleuthContext.selectedDocuments.length === 1 ? 'document' : 'documents' }} selected -
-
- + +
+
+
+
+
+
Model
+
Documents
+
+
+
+ +
+
+
+
From 5986f5a4d1b3d525767d9d302d3788952319f70a Mon Sep 17 00:00:00 2001 From: Daniel Diaz <39510674+IslandRhythms@users.noreply.github.com> Date: Fri, 19 Dec 2025 18:37:37 -0500 Subject: [PATCH 07/17] complete mongoose sleuth alpha --- backend/actions/Sleuth/updateCaseReport.js | 100 ++++++- backend/db/sleuthSchema.js | 51 ++-- frontend/src/case-reports/case-reports.html | 62 +++- frontend/src/case-reports/case-reports.js | 89 +++++- .../mongoose-loading/mongoose-loading.html | 18 ++ .../src/mongoose-loading/mongoose-loading.js | 10 + .../src/mongoose-loading/mongoose.loading.css | 273 ++++++++++++++++++ .../src/mongoose-sleuth/mongoose-sleuth.html | 1 + .../src/mongoose-sleuth/mongoose-sleuth.js | 191 +++++++++++- .../sleuth-aggregating.html | 16 +- .../sleuth-investigating.html | 124 +++++++- .../sleuth-summarize/sleuth-summarize.html | 129 +++++++++ .../sleuth-summarize/sleuth-summarize.js | 17 ++ 13 files changed, 1022 insertions(+), 59 deletions(-) create mode 100644 frontend/src/mongoose-loading/mongoose-loading.html create mode 100644 frontend/src/mongoose-loading/mongoose-loading.js create mode 100644 frontend/src/mongoose-loading/mongoose.loading.css create mode 100644 frontend/src/mongoose-sleuth/sleuth-summarize/sleuth-summarize.html create mode 100644 frontend/src/mongoose-sleuth/sleuth-summarize/sleuth-summarize.js diff --git a/backend/actions/Sleuth/updateCaseReport.js b/backend/actions/Sleuth/updateCaseReport.js index 2767c61a..a649e6f7 100644 --- a/backend/actions/Sleuth/updateCaseReport.js +++ b/backend/actions/Sleuth/updateCaseReport.js @@ -2,6 +2,7 @@ const Archetype = require('archetype'); const authorize = require('../../authorize'); +const callLLM = require('../../integrations/callLLM'); const UpdateCaseReportParams = new Archetype({ caseReportId: { @@ -13,30 +14,109 @@ const UpdateCaseReportParams = new Archetype({ $type: Array, $default: [] }, + summary: { + $type: 'string' + }, + status: { + $type: 'string' + }, roles: { $type: ['string'] } }).compile('UpdateCaseReportParams'); -module.exports = ({ db }) => async function updateCaseReport(params) { - const { caseReportId, documents, roles } = new UpdateCaseReportParams(params); +module.exports = ({ db, options }) => async function updateCaseReport(params) { + const { caseReportId, documents, summary, status, roles } = new UpdateCaseReportParams(params); const Sleuth = db.model('__Studio_Sleuth'); await authorize('Sleuth.updateCaseReport', roles); const docs = Array.isArray(documents) ? documents - .filter(doc => doc && doc.document && doc.documentModel) - .map(doc => ({ - document: doc.document, - documentModel: doc.documentModel, - ...(doc.notes ? { notes: doc.notes } : {}) - })) + .filter(doc => doc && doc.document && doc.documentModel) + .map(doc => ({ + document: doc.document, + documentModel: doc.documentModel, + ...(doc.notes ? { notes: doc.notes } : {}) + })) : []; + const updateData = { documents: docs }; + let aiSummary = null; + + // If status is explicitly provided, use it + if (status !== undefined) { + updateData.status = status; + } + + if (summary !== undefined) { + updateData.summary = summary; + // If summary is provided and status wasn't explicitly set, set to resolved + if (status === undefined) { + updateData.status = 'resolved'; + } + + // Generate AI summary if summary is provided + if (summary && summary.trim().length > 0) { + try { + // Fetch all documents with their data for context + const documentData = []; + for (const docEntry of docs) { + if (!docEntry.document || !docEntry.documentModel) { + continue; + } + try { + const Model = db.models[docEntry.documentModel]; + if (Model) { + const doc = await Model.findById(docEntry.document).setOptions({ sanitizeFilter: true }).lean(); + if (doc) { + documentData.push({ + model: docEntry.documentModel, + documentId: docEntry.document.toString(), + data: doc, + notes: docEntry.notes || '' + }); + } + } + } catch (err) { + console.error(`Error fetching document ${docEntry.document} from model ${docEntry.documentModel}:`, err); + // Continue with other documents even if one fails + } + } + + // Build prompt for AI summary + const documentsContext = documentData.map(doc => { + return `Model: ${doc.model}\nDocument ID: ${doc.documentId}\n${doc.notes ? `Notes: ${doc.notes}\n` : ''}Data: ${JSON.stringify(doc.data, null, 2)}`; + }).join('\n\n---\n\n'); + + const systemPrompt = 'You are a technical writing assistant that improves case report summaries. Your task is to enhance the user\'s summary by incorporating specific details from the document data and investigation notes. Write in clear, professional markdown format. Use the document data to illustrate and support the points made in the summary.'; + + const userPrompt = `Improve and enhance the following case report summary using the document data and investigation notes provided below. Make it more detailed, professional, and well-structured. Use specific examples from the document data to illustrate key points. Format the response as markdown.\n\nUser's Summary:\n${summary}\n\nDocument Data and Investigation Notes:\n${documentsContext}`; + + const llmResponse = await callLLM( + [{ + role: 'user', + content: [{ + type: 'text', + text: userPrompt + }] + }], + systemPrompt, + options + ); + + aiSummary = llmResponse.text; + updateData.AISummary = aiSummary; + } catch (err) { + console.error('Error generating AI summary:', err); + // Continue without AI summary if generation fails + } + } + } + const caseReport = await Sleuth.findByIdAndUpdate( caseReportId, - { documents: docs }, + updateData, { new: true } ).lean(); @@ -44,5 +124,5 @@ module.exports = ({ db }) => async function updateCaseReport(params) { throw new Error('Case report not found'); } - return { caseReport }; + return { caseReport, aiSummary }; }; diff --git a/backend/db/sleuthSchema.js b/backend/db/sleuthSchema.js index b2013314..bec8cd1d 100644 --- a/backend/db/sleuthSchema.js +++ b/backend/db/sleuthSchema.js @@ -3,34 +3,37 @@ const mongoose = require('mongoose'); const sleuthSchema = new mongoose.Schema({ - name: { - type: String, - required: true + name: { + type: String, + required: true + }, + status: { + type: String, + $required: true, + default: 'created', + enum: ['created', 'in_progress', 'cancelled', 'resolved', 'archived'] + }, + documents: [{ + document: { + type: mongoose.Schema.Types.ObjectId, + refPath: 'documentModel' }, - status: { - type: String, - $required: true, - default: 'created', - enum: ['created', 'in_progress', 'cancelled', 'resolved', 'archived'] + documentModel: { + type: String, + required: true }, - documents: [{ - document: { - type: mongoose.Schema.Types.ObjectId, - refPath: 'documentModel' - }, - documentModel: { - type: String, - required: true - }, - notes: { - type: String - } - }], - summary: { - type: String + notes: { + type: String } + }], + summary: { + type: String + }, + AISummary: { + type: String + } }, { - timestamps: true + timestamps: true }); module.exports = sleuthSchema; \ No newline at end of file diff --git a/frontend/src/case-reports/case-reports.html b/frontend/src/case-reports/case-reports.html index 435f9cf3..829f4cab 100644 --- a/frontend/src/case-reports/case-reports.html +++ b/frontend/src/case-reports/case-reports.html @@ -58,6 +58,7 @@

Case Reports

Name + Status Created @@ -66,15 +67,40 @@

Case Reports

{{caseReport.name}} + + + {{ formatStatus(caseReport.status) }} + + {{ formatDate(caseReport.createdAt) }} - - Open - +
+ + + + Open + +
@@ -83,4 +109,30 @@

Case Reports

+ + + + diff --git a/frontend/src/case-reports/case-reports.js b/frontend/src/case-reports/case-reports.js index fba4e373..c8513409 100644 --- a/frontend/src/case-reports/case-reports.js +++ b/frontend/src/case-reports/case-reports.js @@ -7,7 +7,13 @@ module.exports = app => app.component('case-reports', { template: template, data: () => ({ status: 'loading', - caseReports: [] + caseReports: [], + showConfirmModal: false, + confirmAction: null, + confirmCaseReportId: null, + confirmMessage: '', + confirmButtonText: '', + confirmButtonClass: '' }), methods: { formatDate(date) { @@ -19,6 +25,87 @@ module.exports = app => app.component('case-reports', { } catch (e) { return 'N/A'; } + }, + formatStatus(status) { + if (!status) return 'Unknown'; + const statusMap = { + created: 'Created', + in_progress: 'In Progress', + cancelled: 'Cancelled', + resolved: 'Resolved', + archived: 'Archived' + }; + return statusMap[status] || status; + }, + getStatusClass(status) { + if (!status) return 'bg-gray-100 text-gray-800'; + const classMap = { + created: 'bg-blue-100 text-blue-800', + in_progress: 'bg-yellow-100 text-yellow-800', + cancelled: 'bg-red-100 text-red-800', + resolved: 'bg-green-100 text-green-800', + archived: 'bg-gray-100 text-gray-800' + }; + return classMap[status] || 'bg-gray-100 text-gray-800'; + }, + openCancelConfirm(caseReportId) { + this.confirmCaseReportId = caseReportId; + this.confirmAction = 'cancel'; + this.confirmMessage = 'Are you sure you want to cancel this case report?'; + this.confirmButtonText = 'Cancel Case Report'; + this.confirmButtonClass = 'bg-red-600 hover:bg-red-500 focus-visible:outline-red-600'; + this.showConfirmModal = true; + }, + openArchiveConfirm(caseReportId) { + this.confirmCaseReportId = caseReportId; + this.confirmAction = 'archive'; + this.confirmMessage = 'Are you sure you want to archive this case report?'; + this.confirmButtonText = 'Archive Case Report'; + this.confirmButtonClass = 'bg-gray-600 hover:bg-gray-500 focus-visible:outline-gray-600'; + this.showConfirmModal = true; + }, + closeConfirmModal() { + this.showConfirmModal = false; + this.confirmCaseReportId = null; + this.confirmAction = null; + this.confirmMessage = ''; + this.confirmButtonText = ''; + this.confirmButtonClass = ''; + }, + async executeConfirmAction() { + if (!this.confirmCaseReportId || !this.confirmAction) { + return; + } + + try { + let status; + let successMessage; + + if (this.confirmAction === 'cancel') { + status = 'cancelled'; + successMessage = 'Case report cancelled'; + } else if (this.confirmAction === 'archive') { + status = 'archived'; + successMessage = 'Case report archived'; + } else { + return; + } + + await api.Sleuth.updateCaseReport({ + caseReportId: this.confirmCaseReportId, + status + }); + + this.$toast.success(successMessage); + this.closeConfirmModal(); + + // Reload case reports + const { caseReports } = await api.Sleuth.getCaseReports(); + this.caseReports = caseReports; + } catch (error) { + console.error(`Error ${this.confirmAction}ing case report`, error); + this.$toast.error(error?.message || `Error ${this.confirmAction}ing case report`); + } } }, async mounted() { diff --git a/frontend/src/mongoose-loading/mongoose-loading.html b/frontend/src/mongoose-loading/mongoose-loading.html new file mode 100644 index 00000000..9b441474 --- /dev/null +++ b/frontend/src/mongoose-loading/mongoose-loading.html @@ -0,0 +1,18 @@ + \ No newline at end of file diff --git a/frontend/src/mongoose-loading/mongoose-loading.js b/frontend/src/mongoose-loading/mongoose-loading.js new file mode 100644 index 00000000..7ccc58a7 --- /dev/null +++ b/frontend/src/mongoose-loading/mongoose-loading.js @@ -0,0 +1,10 @@ +'use strict'; + +const template = require('./mongoose-loading.html'); +const appendCSS = require('../appendCSS'); + +appendCSS(require('./mongoose.loading.css')); + +module.exports = app => app.component('mongoose-loading', { + template +}); diff --git a/frontend/src/mongoose-loading/mongoose.loading.css b/frontend/src/mongoose-loading/mongoose.loading.css new file mode 100644 index 00000000..5600f5a2 --- /dev/null +++ b/frontend/src/mongoose-loading/mongoose.loading.css @@ -0,0 +1,273 @@ +.wheel-and-mongoose { + --dur: 1s; + position: relative; + width: 12em; + height: 12em; + font-size: 14px; + } + + .wheel, + .mongoose, + .mongoose div, + .spoke { + position: absolute; + } + + .wheel, + .spoke { + border-radius: 50%; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + + .wheel { + background: radial-gradient(100% 100% at center,hsla(0,0%,60%,0) 47.8%,hsl(0,0%,60%) 48%); + z-index: 2; + } + + .mongoose { + animation: mongoose var(--dur) ease-in-out infinite; + top: 50%; + left: calc(50% - 3.5em); + width: 8em; + height: 3.5em; + transform: rotate(4deg) translate(-0.8em,1.85em); + transform-origin: 50% 0; + z-index: 1; + } + + .mongoose__head { + animation: mongooseHead var(--dur) ease-in-out infinite; + background: hsl(25,30%,45%); + border-radius: 60% 40% 0 100% / 50% 30% 30% 50%; + box-shadow: 0 -0.25em 0 hsl(25,30%,55%) inset, + 0.75em -1.55em 0 hsl(25,30%,60%) inset; + top: 0; + left: -2.5em; + width: 3.25em; + height: 2.25em; + transform-origin: 100% 50%; + } + + .mongoose__ear { + animation: mongooseEar var(--dur) ease-in-out infinite; + background: hsl(25,25%,40%); + border-radius: 50%; + box-shadow: -0.15em 0 hsl(25,30%,45%) inset; + top: 0.1em; + right: -0.2em; + width: 0.6em; + height: 0.6em; + transform-origin: 50% 75%; + } + + .mongoose__eye { + animation: mongooseEye var(--dur) linear infinite; + background-color: hsl(0,0%,0%); + border-radius: 50%; + top: 0.5em; + left: 1.5em; + width: 0.45em; + height: 0.45em; + } + + .mongoose__nose { + background: hsl(25,20%,35%); + border-radius: 50% 50% 50% 50% / 60% 60% 40% 40%; + top: 0.9em; + left: -0.1em; + width: 0.3em; + height: 0.3em; + } + + .mongoose__body { + animation: mongooseBody var(--dur) ease-in-out infinite; + background: hsl(25,30%,50%); + border-radius: 40% 30% 40% 30% / 20% 50% 30% 30%; + box-shadow: 0.1em 0.75em 0 hsl(25,30%,40%) inset, + 0.15em -0.5em 0 hsl(25,30%,55%) inset; + top: 0.3em; + left: 2.2em; + width: 5em; + height: 2.5em; + transform-origin: 15% 50%; + transform-style: preserve-3d; + } + + .mongoose__limb--fr, + .mongoose__limb--fl { + clip-path: polygon(0 0,100% 0,70% 80%,60% 100%,0% 100%,40% 80%); + top: 1.8em; + left: 0.6em; + width: 0.9em; + height: 1.6em; + transform-origin: 50% 0; + } + + .mongoose__limb--fr { + animation: mongooseFRLimb var(--dur) linear infinite; + background: linear-gradient(hsl(25,30%,45%) 80%,hsl(25,20%,35%) 80%); + transform: rotate(15deg) translateZ(-1px); + } + + .mongoose__limb--fl { + animation: mongooseFLLimb var(--dur) linear infinite; + background: linear-gradient(hsl(25,30%,50%) 80%,hsl(25,20%,40%) 80%); + transform: rotate(15deg); + } + + .mongoose__limb--br, + .mongoose__limb--bl { + border-radius: 0.75em 0.75em 0 0; + clip-path: polygon(0 0,100% 0,100% 30%,70% 90%,70% 100%,30% 100%,40% 90%,0% 30%); + top: 1em; + left: 3.2em; + width: 1.4em; + height: 2.6em; + transform-origin: 50% 30%; + } + + .mongoose__limb--br { + animation: mongooseBRLimb var(--dur) linear infinite; + background: linear-gradient(hsl(25,30%,45%) 90%,hsl(25,20%,35%) 90%); + transform: rotate(-25deg) translateZ(-1px); + } + + .mongoose__limb--bl { + animation: mongooseBLLimb var(--dur) linear infinite; + background: linear-gradient(hsl(25,30%,50%) 90%,hsl(25,20%,40%) 90%); + transform: rotate(-25deg); + } + + .mongoose__tail { + animation: mongooseTail var(--dur) linear infinite; + background: hsl(25,30%,45%); + border-radius: 0.3em 50% 50% 0.3em; + box-shadow: 0 -0.2em 0 hsl(25,20%,35%) inset; + top: 1.2em; + right: -0.8em; + width: 1.8em; + height: 0.4em; + transform: rotate(25deg) translateZ(-1px); + transform-origin: 0.3em 0.2em; + z-index: -1; + } + + .spoke { + animation: spoke var(--dur) linear infinite; + background: radial-gradient(100% 100% at center,hsl(0,0%,60%) 4.8%,hsla(0,0%,60%,0) 5%), + linear-gradient(hsla(0,0%,55%,0) 46.9%,hsl(0,0%,65%) 47% 52.9%,hsla(0,0%,65%,0) 53%) 50% 50% / 99% 99% no-repeat; + } + + /* Animations */ + @keyframes mongoose { + from, to { + transform: rotate(4deg) translate(-0.8em,1.85em); + } + + 50% { + transform: rotate(0) translate(-0.8em,1.85em); + } + } + + @keyframes mongooseHead { + from, 25%, 50%, 75%, to { + transform: rotate(0); + } + + 12.5%, 37.5%, 62.5%, 87.5% { + transform: rotate(8deg); + } + } + + @keyframes mongooseEye { + from, 90%, to { + transform: scaleY(1); + } + + 95% { + transform: scaleY(0); + } + } + + @keyframes mongooseEar { + from, 25%, 50%, 75%, to { + transform: rotate(0); + } + + 12.5%, 37.5%, 62.5%, 87.5% { + transform: rotate(10deg); + } + } + + @keyframes mongooseBody { + from, 25%, 50%, 75%, to { + transform: rotate(0); + } + + 12.5%, 37.5%, 62.5%, 87.5% { + transform: rotate(-2deg); + } + } + + @keyframes mongooseFRLimb { + from, 25%, 50%, 75%, to { + transform: rotate(50deg) translateZ(-1px); + } + + 12.5%, 37.5%, 62.5%, 87.5% { + transform: rotate(-30deg) translateZ(-1px); + } + } + + @keyframes mongooseFLLimb { + from, 25%, 50%, 75%, to { + transform: rotate(-30deg); + } + + 12.5%, 37.5%, 62.5%, 87.5% { + transform: rotate(50deg); + } + } + + @keyframes mongooseBRLimb { + from, 25%, 50%, 75%, to { + transform: rotate(-60deg) translateZ(-1px); + } + + 12.5%, 37.5%, 62.5%, 87.5% { + transform: rotate(20deg) translateZ(-1px); + } + } + + @keyframes mongooseBLLimb { + from, 25%, 50%, 75%, to { + transform: rotate(20deg); + } + + 12.5%, 37.5%, 62.5%, 87.5% { + transform: rotate(-60deg); + } + } + + @keyframes mongooseTail { + from, 25%, 50%, 75%, to { + transform: rotate(25deg) translateZ(-1px); + } + + 12.5%, 37.5%, 62.5%, 87.5% { + transform: rotate(8deg) translateZ(-1px); + } + } + + @keyframes spoke { + from { + transform: rotate(0); + } + + to { + transform: rotate(-1turn); + } + } \ No newline at end of file diff --git a/frontend/src/mongoose-sleuth/mongoose-sleuth.html b/frontend/src/mongoose-sleuth/mongoose-sleuth.html index 0407fe68..d2ef75f5 100644 --- a/frontend/src/mongoose-sleuth/mongoose-sleuth.html +++ b/frontend/src/mongoose-sleuth/mongoose-sleuth.html @@ -1,4 +1,5 @@
+
diff --git a/frontend/src/mongoose-sleuth/mongoose-sleuth.js b/frontend/src/mongoose-sleuth/mongoose-sleuth.js index f1cf21b0..2a82ca94 100644 --- a/frontend/src/mongoose-sleuth/mongoose-sleuth.js +++ b/frontend/src/mongoose-sleuth/mongoose-sleuth.js @@ -39,7 +39,14 @@ module.exports = app => app.component('mongoose-sleuth', { investigationSelections: [], documentNotes: {}, showSelectedDocuments: false, - expandedModels: {} + expandedModels: {}, + shouldShowFieldModal: false, + selectedPaths: {}, + filteredPathsByModel: {}, + expandedFieldModels: {}, + summary: '', + aiSummary: '', + savingSummary: false }), created() { this.loadOutputPreference(); @@ -384,8 +391,17 @@ module.exports = app => app.component('mongoose-sleuth', { this.selectedDocuments = loadedDocs; // By default, investigate all documents in the case report this.investigationSelections = loadedDocs.slice(); + // Restore summary if present + if (typeof caseReport.summary === 'string') { + this.summary = caseReport.summary; + } + // Restore AI summary if present + if (typeof caseReport.AISummary === 'string') { + this.aiSummary = caseReport.AISummary; + } // Open directly to Step 2 when a case report already has documents - this.activeStep = 'investigating'; + // If summary exists, go to Step 3 + this.activeStep = caseReport.summary ? 'summarize' : 'investigating'; } }, async loadMoreDocuments() { @@ -425,14 +441,146 @@ module.exports = app => app.component('mongoose-sleuth', { } }, filterDocument(doc) { + if (!doc || !doc.model) { + return doc; + } + const model = doc.model; + const filteredPaths = this.filteredPathsByModel[model]; + if (!filteredPaths || filteredPaths.length === 0) { + return doc; + } const filteredDoc = {}; - for (let i = 0; i < this.filteredPaths.length; i++) { - const path = this.filteredPaths[i].path; + for (let i = 0; i < filteredPaths.length; i++) { + const path = filteredPaths[i].path; const value = mpath.get(path, doc); mpath.set(path, value, filteredDoc); } return filteredDoc; }, + getSchemaPathsByModel() { + // Collect schema paths grouped by model + const pathsByModel = {}; + for (const doc of this.selectedDocuments) { + if (!doc || typeof doc !== 'object' || !doc.model) { + continue; + } + const model = doc.model; + if (!pathsByModel[model]) { + pathsByModel[model] = new Map(); + } + const pathMap = pathsByModel[model]; + const collectPaths = (obj, prefix = '') => { + for (const key in obj) { + if (key === '__v' || key === '_id') { + continue; + } + const fullPath = prefix ? `${prefix}.${key}` : key; + if (obj[key] != null && typeof obj[key] === 'object' && !Array.isArray(obj[key]) && !(obj[key] instanceof Date) && !(obj[key].constructor && obj[key].constructor.name === 'ObjectId')) { + collectPaths(obj[key], fullPath); + } else { + if (!pathMap.has(fullPath)) { + pathMap.set(fullPath, { + path: fullPath, + instance: Array.isArray(obj[key]) ? 'Array' : typeof obj[key] === 'string' ? 'String' : typeof obj[key] === 'number' ? 'Number' : typeof obj[key] === 'boolean' ? 'Boolean' : 'Mixed' + }); + } + } + } + }; + collectPaths(doc); + // Always include _id for each model + if (!pathMap.has('_id')) { + pathMap.set('_id', { path: '_id', instance: 'ObjectId' }); + } + } + // Convert Maps to sorted arrays + const result = {}; + for (const model in pathsByModel) { + const pathMap = pathsByModel[model]; + result[model] = Array.from(pathMap.values()).sort((a, b) => { + if (a.path === '_id' && b.path !== '_id') return -1; + if (a.path !== '_id' && b.path === '_id') return 1; + return a.path.localeCompare(b.path); + }); + } + return result; + }, + openFieldSelection() { + if (this.selectedDocuments.length === 0) { + this.$toast.warning('No documents selected. Select documents in Step 1 first.'); + return; + } + const pathsByModel = this.getSchemaPathsByModel(); + // Initialize selectedPaths per model + this.selectedPaths = {}; + // Initialize expandedFieldModels - expand first model by default + this.expandedFieldModels = {}; + const modelNames = Object.keys(pathsByModel); + if (modelNames.length > 0) { + this.expandedFieldModels[modelNames[0]] = true; + } + for (const model in pathsByModel) { + if (this.filteredPathsByModel[model] && this.filteredPathsByModel[model].length > 0) { + this.selectedPaths[model] = [...this.filteredPathsByModel[model]]; + } else { + this.selectedPaths[model] = pathsByModel[model].length > 0 ? [{ path: '_id' }] : []; + } + } + this.shouldShowFieldModal = true; + }, + toggleFieldModelExpansion(model) { + this.expandedFieldModels[model] = !this.expandedFieldModels[model]; + }, + isFieldModelExpanded(model) { + return !!this.expandedFieldModels[model]; + }, + addOrRemove(model, path) { + if (!this.selectedPaths[model]) { + this.selectedPaths[model] = []; + } + const index = this.selectedPaths[model].findIndex(p => p.path === path.path); + if (index !== -1) { + this.selectedPaths[model].splice(index, 1); + } else { + this.selectedPaths[model].push(path); + } + }, + isSelected(model, path) { + if (!this.selectedPaths[model]) { + return false; + } + const pathStr = typeof path === 'string' ? path : path.path; + return this.selectedPaths[model].find(p => p.path === pathStr); + }, + selectAll(model) { + const pathsByModel = this.getSchemaPathsByModel(); + if (pathsByModel[model]) { + this.selectedPaths[model] = [...pathsByModel[model]]; + } + }, + deselectAll(model) { + this.selectedPaths[model] = []; + }, + filterDocuments() { + this.filteredPathsByModel = {}; + for (const model in this.selectedPaths) { + if (this.selectedPaths[model] && this.selectedPaths[model].length > 0) { + this.filteredPathsByModel[model] = [...this.selectedPaths[model]]; + } else { + this.filteredPathsByModel[model] = []; + } + } + this.shouldShowFieldModal = false; + }, + resetDocuments() { + this.filteredPathsByModel = {}; + const pathsByModel = this.getSchemaPathsByModel(); + this.selectedPaths = {}; + for (const model in pathsByModel) { + this.selectedPaths[model] = pathsByModel[model].length > 0 ? [...pathsByModel[model]] : []; + } + this.shouldShowFieldModal = false; + }, getComponentForPath(schemaPath) { if (schemaPath.instance === 'Array') { return 'list-array'; @@ -543,6 +691,41 @@ module.exports = app => app.component('mongoose-sleuth', { console.error('Error saving progress', err); this.$toast.error(err?.message || 'Error saving progress'); } + }, + async goToSummarize() { + // Save investigation progress before moving to Step 3 + if (this.currentCaseReportId) { + await this.saveInvestigationProgress(); + } + this.activeStep = 'summarize'; + }, + async saveSummary() { + if (!this.currentCaseReportId) { + this.$toast.error('No case report to save yet.'); + return; + } + + const documentsPayload = this.buildDocumentsPayload(); + + this.savingSummary = true; + try { + const { caseReport, aiSummary } = await api.Sleuth.updateCaseReport({ + caseReportId: this.currentCaseReportId, + documents: documentsPayload, + summary: this.summary || '' + }); + if (aiSummary) { + this.aiSummary = aiSummary; + this.$toast.success('Summary saved and AI summary generated'); + } else { + this.$toast.success('Summary saved'); + } + } catch (err) { + console.error('Error saving summary', err); + this.$toast.error(err?.message || 'Error saving summary'); + } finally { + this.savingSummary = false; + } } } }); diff --git a/frontend/src/mongoose-sleuth/sleuth-aggregating/sleuth-aggregating.html b/frontend/src/mongoose-sleuth/sleuth-aggregating/sleuth-aggregating.html index 94795daa..b87811f7 100644 --- a/frontend/src/mongoose-sleuth/sleuth-aggregating/sleuth-aggregating.html +++ b/frontend/src/mongoose-sleuth/sleuth-aggregating/sleuth-aggregating.html @@ -63,7 +63,14 @@

Mongoose Sleuth

Step 2: Investigating - Step 3: Summarize + -
- ID: {{ doc._id }} -
@@ -314,8 +318,8 @@

Mongoose Sleuth

-
- +
+
Mongoose Sleuth Step 2: Investigating - Step 3: Summarize +
- - Save Progress - +
+ + + Save Progress + + +
{{ sleuthContext.selectedDocuments.length }} @@ -124,7 +149,7 @@

Investigation Notes

- +
@@ -148,4 +173,85 @@

Investigation Notes

+ + + + + diff --git a/frontend/src/mongoose-sleuth/sleuth-summarize/sleuth-summarize.html b/frontend/src/mongoose-sleuth/sleuth-summarize/sleuth-summarize.html new file mode 100644 index 00000000..0ac80f89 --- /dev/null +++ b/frontend/src/mongoose-sleuth/sleuth-summarize/sleuth-summarize.html @@ -0,0 +1,129 @@ +
+ +
+
+
+

Mongoose Sleuth

+
+ + + + + +
+
+ + Save Summary + +
+ Case report resolved +
+
+
+ +
+ +
+
+

Case Summary

+

Write a comprehensive summary of your findings and conclusions.

+
+
+
+ +
+
+ + +
+
+
+ +

This summary has been enhanced using AI and the case report is now resolved.

+
+
+
+
+
+ +
+ {{ sleuthContext.summary }} +
+
+
+
+
+ + +
+
+

Investigation Notes

+

Reference your notes while writing the summary.

+
+
+
+
+
+ {{ doc.model }} – {{ doc._id }} +
+
+
+
Notes:
+
+ {{ sleuthContext.getDocumentNote(doc) }} +
+
+
+ No notes for this document +
+
+
+ No documents selected for investigation. Go back to Step 2 to add notes. +
+
+
+
+
diff --git a/frontend/src/mongoose-sleuth/sleuth-summarize/sleuth-summarize.js b/frontend/src/mongoose-sleuth/sleuth-summarize/sleuth-summarize.js new file mode 100644 index 00000000..666272f9 --- /dev/null +++ b/frontend/src/mongoose-sleuth/sleuth-summarize/sleuth-summarize.js @@ -0,0 +1,17 @@ +'use strict'; + +const marked = require('marked').marked; +const template = require('./sleuth-summarize.html'); + +module.exports = app => app.component('sleuth-summarize', { + template: template, + inject: ['sleuthContext'], + methods: { + renderMarkdown(text) { + if (!text) { + return ''; + } + return marked(text); + } + } +}); From 3d5ab50adc7c35a6d51da116bc460973611a8818 Mon Sep 17 00:00:00 2001 From: Daniel Diaz <39510674+IslandRhythms@users.noreply.github.com> Date: Thu, 22 Jan 2026 11:27:40 -0500 Subject: [PATCH 08/17] rename Sleuth to CaseReport internally --- .../actions/{Sleuth => CaseReport}/createCaseReport.js | 8 ++++---- .../actions/{Sleuth => CaseReport}/getCaseReport.js | 6 +++--- .../actions/{Sleuth => CaseReport}/getCaseReports.js | 6 +++--- backend/actions/{Sleuth => CaseReport}/index.js | 0 .../actions/{Sleuth => CaseReport}/updateCaseReport.js | 6 +++--- backend/actions/index.js | 2 +- backend/authorize.js | 10 +++++----- backend/db/{sleuthSchema.js => caseReportSchema.js} | 4 ++-- backend/index.js | 4 ++-- 9 files changed, 23 insertions(+), 23 deletions(-) rename backend/actions/{Sleuth => CaseReport}/createCaseReport.js (84%) rename backend/actions/{Sleuth => CaseReport}/getCaseReport.js (74%) rename backend/actions/{Sleuth => CaseReport}/getCaseReports.js (66%) rename backend/actions/{Sleuth => CaseReport}/index.js (100%) rename backend/actions/{Sleuth => CaseReport}/updateCaseReport.js (96%) rename backend/db/{sleuthSchema.js => caseReportSchema.js} (87%) diff --git a/backend/actions/Sleuth/createCaseReport.js b/backend/actions/CaseReport/createCaseReport.js similarity index 84% rename from backend/actions/Sleuth/createCaseReport.js rename to backend/actions/CaseReport/createCaseReport.js index 076b1e5e..9b5f69a3 100644 --- a/backend/actions/Sleuth/createCaseReport.js +++ b/backend/actions/CaseReport/createCaseReport.js @@ -24,16 +24,16 @@ const CreateCaseReportParams = new Archetype({ module.exports = ({ db }) => async function createCaseReport(params) { const { name, documents, roles } = new CreateCaseReportParams(params); - const Sleuth = db.model('__Studio_Sleuth'); + const CaseReport = db.model('__Studio_CaseReport'); - await authorize('Sleuth.createCaseReport', roles); + await authorize('CaseReport.createCaseReport', roles); const normalizedName = name.trim(); // Count existing case reports with this base name or suffixed with (x) const base = escapeRegExp(normalizedName); const namePattern = new RegExp(`^${base}( \\(\\d+\\))?$`); - const existingCount = await Sleuth.countDocuments({ name: { $regex: namePattern } }); + const existingCount = await CaseReport.countDocuments({ name: { $regex: namePattern } }); const finalName = existingCount > 0 ? `${normalizedName} (${existingCount})` : normalizedName; @@ -48,7 +48,7 @@ module.exports = ({ db }) => async function createCaseReport(params) { })) : []; - const caseReport = await Sleuth.create({ + const caseReport = await CaseReport.create({ name: finalName, documents: docs }); diff --git a/backend/actions/Sleuth/getCaseReport.js b/backend/actions/CaseReport/getCaseReport.js similarity index 74% rename from backend/actions/Sleuth/getCaseReport.js rename to backend/actions/CaseReport/getCaseReport.js index 23f2bbc5..bf13cc0f 100644 --- a/backend/actions/Sleuth/getCaseReport.js +++ b/backend/actions/CaseReport/getCaseReport.js @@ -15,11 +15,11 @@ const GetCaseReportParams = new Archetype({ module.exports = ({ db }) => async function getCaseReport(params) { const { caseReportId, roles } = new GetCaseReportParams(params); - const Sleuth = db.model('__Studio_Sleuth'); + const CaseReport = db.model('__Studio_CaseReport'); - await authorize('Sleuth.getCaseReports', roles); + await authorize('CaseReport.getCaseReports', roles); - const caseReport = await Sleuth.findById(caseReportId).lean(); + const caseReport = await CaseReport.findById(caseReportId).lean(); if (!caseReport) { throw new Error('Case report not found'); } diff --git a/backend/actions/Sleuth/getCaseReports.js b/backend/actions/CaseReport/getCaseReports.js similarity index 66% rename from backend/actions/Sleuth/getCaseReports.js rename to backend/actions/CaseReport/getCaseReports.js index 5c1f83d9..776ca744 100644 --- a/backend/actions/Sleuth/getCaseReports.js +++ b/backend/actions/CaseReport/getCaseReports.js @@ -11,11 +11,11 @@ const GetCaseReportsParams = new Archetype({ module.exports = ({ db }) => async function getCaseReports(params) { const { roles } = new GetCaseReportsParams(params); - const Sleuth = db.model('__Studio_Sleuth'); + const CaseReport = db.model('__Studio_CaseReport'); - await authorize('Sleuth.getCaseReports', roles); + await authorize('CaseReport.getCaseReports', roles); - const caseReports = await Sleuth.find({}).sort({ createdAt: -1 }).lean(); + const caseReports = await CaseReport.find({}).sort({ createdAt: -1 }).lean(); return { caseReports }; }; diff --git a/backend/actions/Sleuth/index.js b/backend/actions/CaseReport/index.js similarity index 100% rename from backend/actions/Sleuth/index.js rename to backend/actions/CaseReport/index.js diff --git a/backend/actions/Sleuth/updateCaseReport.js b/backend/actions/CaseReport/updateCaseReport.js similarity index 96% rename from backend/actions/Sleuth/updateCaseReport.js rename to backend/actions/CaseReport/updateCaseReport.js index a649e6f7..cb7065f1 100644 --- a/backend/actions/Sleuth/updateCaseReport.js +++ b/backend/actions/CaseReport/updateCaseReport.js @@ -27,9 +27,9 @@ const UpdateCaseReportParams = new Archetype({ module.exports = ({ db, options }) => async function updateCaseReport(params) { const { caseReportId, documents, summary, status, roles } = new UpdateCaseReportParams(params); - const Sleuth = db.model('__Studio_Sleuth'); + const CaseReport = db.model('__Studio_CaseReport'); - await authorize('Sleuth.updateCaseReport', roles); + await authorize('CaseReport.updateCaseReport', roles); const docs = Array.isArray(documents) ? documents @@ -114,7 +114,7 @@ module.exports = ({ db, options }) => async function updateCaseReport(params) { } } - const caseReport = await Sleuth.findByIdAndUpdate( + const caseReport = await CaseReport.findByIdAndUpdate( caseReportId, updateData, { new: true } diff --git a/backend/actions/index.js b/backend/actions/index.js index 837d8c18..195c0901 100644 --- a/backend/actions/index.js +++ b/backend/actions/index.js @@ -5,5 +5,5 @@ exports.ChatThread = require('./ChatThread'); exports.Dashboard = require('./Dashboard'); exports.Model = require('./Model'); exports.Script = require('./Script'); -exports.Sleuth = require('./Sleuth'); +exports.CaseReport = require('./CaseReport'); exports.status = require('./status'); diff --git a/backend/authorize.js b/backend/authorize.js index 7a45b48b..92287b28 100644 --- a/backend/authorize.js +++ b/backend/authorize.js @@ -24,11 +24,11 @@ const actionsToRequiredRoles = { 'Model.getIndexes': ['owner', 'admin', 'member', 'readonly'], 'Model.listModels': ['owner', 'admin', 'member', 'readonly'], 'Model.updateDocuments': ['owner', 'admin', 'member'], - // Sleuth / Mongoose Sleuth (Bug Hunt) actions - 'Sleuth.createCaseReport': ['owner', 'admin', 'member'], - 'Sleuth.getCaseReports': ['owner', 'admin', 'member', 'readonly'], - 'Sleuth.getCaseReport': ['owner', 'admin', 'member', 'readonly'], - 'Sleuth.updateCaseReport': ['owner', 'admin', 'member'] + // CaseReport / Mongoose CaseReport (Bug Hunt) actions + 'CaseReport.createCaseReport': ['owner', 'admin', 'member'], + 'CaseReport.getCaseReports': ['owner', 'admin', 'member', 'readonly'], + 'CaseReport.getCaseReport': ['owner', 'admin', 'member', 'readonly'], + 'CaseReport.updateCaseReport': ['owner', 'admin', 'member'] }; module.exports = function authorize(action, roles) { diff --git a/backend/db/sleuthSchema.js b/backend/db/caseReportSchema.js similarity index 87% rename from backend/db/sleuthSchema.js rename to backend/db/caseReportSchema.js index bec8cd1d..8b387133 100644 --- a/backend/db/sleuthSchema.js +++ b/backend/db/caseReportSchema.js @@ -2,7 +2,7 @@ const mongoose = require('mongoose'); -const sleuthSchema = new mongoose.Schema({ +const caseReportSchema = new mongoose.Schema({ name: { type: String, required: true @@ -36,4 +36,4 @@ const sleuthSchema = new mongoose.Schema({ timestamps: true }); -module.exports = sleuthSchema; \ No newline at end of file +module.exports = caseReportSchema; diff --git a/backend/index.js b/backend/index.js index 50082794..83f06120 100644 --- a/backend/index.js +++ b/backend/index.js @@ -7,7 +7,7 @@ const mongoose = require('mongoose'); const chatMessageSchema = require('./db/chatMessageSchema'); const chatThreadSchema = require('./db/chatThreadSchema'); const dashboardSchema = require('./db/dashboardSchema'); -const sleuthSchema = require('./db/sleuthSchema'); +const caseReportSchema = require('./db/caseReportSchema'); module.exports = function backend(db, studioConnection, options) { db = db || mongoose.connection; @@ -16,7 +16,7 @@ module.exports = function backend(db, studioConnection, options) { const Dashboard = studioConnection.model('__Studio_Dashboard', dashboardSchema, 'studio__dashboards'); const ChatMessage = studioConnection.model('__Studio_ChatMessage', chatMessageSchema, 'studio__chatMessages'); const ChatThread = studioConnection.model('__Studio_ChatThread', chatThreadSchema, 'studio__chatThreads'); - const Sleuth = studioConnection.model('__Studio_Sleuth', sleuthSchema, 'studio__sleuths'); + const CaseReport = studioConnection.model('__Studio_CaseReport', caseReportSchema, 'studio__caseReports'); const actions = applySpec(Actions, { db, studioConnection, options }); return actions; From d91afdca28c3711b906464599cd46571aa29c2e2 Mon Sep 17 00:00:00 2001 From: Daniel Diaz <39510674+IslandRhythms@users.noreply.github.com> Date: Thu, 22 Jan 2026 14:23:59 -0500 Subject: [PATCH 09/17] rename --- .../actions/CaseReport/createCaseReport.js | 38 ++++++++++++++----- .../actions/CaseReport/updateCaseReport.js | 30 +++++++++------ backend/db/caseReportSchema.js | 5 ++- frontend/src/api.js | 20 +++++----- frontend/src/case-reports/case-reports.js | 6 +-- .../src/mongoose-sleuth/mongoose-sleuth.js | 19 ++++++---- 6 files changed, 75 insertions(+), 43 deletions(-) diff --git a/backend/actions/CaseReport/createCaseReport.js b/backend/actions/CaseReport/createCaseReport.js index 9b5f69a3..b62f36c6 100644 --- a/backend/actions/CaseReport/createCaseReport.js +++ b/backend/actions/CaseReport/createCaseReport.js @@ -2,11 +2,28 @@ const Archetype = require('archetype'); const authorize = require('../../authorize'); +const mongoose = require('mongoose'); function escapeRegExp(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } +const DocumentsParams = new Archetype({ + documentId: { + $type: 'string', + $required: true + }, + documentModel: { + $type: 'string' + }, + highlights: { + $type: ['string'] + }, + notes: { + $type: 'string' + } +}).compile('DocumentsParams') + const CreateCaseReportParams = new Archetype({ name: { $type: 'string', @@ -14,8 +31,7 @@ const CreateCaseReportParams = new Archetype({ }, // Array of documents associated with this case report documents: { - $type: Array, - $default: [] + $type: [DocumentsParams] }, roles: { $type: ['string'] @@ -36,16 +52,18 @@ module.exports = ({ db }) => async function createCaseReport(params) { const existingCount = await CaseReport.countDocuments({ name: { $regex: namePattern } }); const finalName = existingCount > 0 ? `${normalizedName} (${existingCount})` : normalizedName; - const docs = Array.isArray(documents) ? documents. - filter(doc => doc && doc.document && doc.documentModel). - map(doc => ({ - document: doc.document, - documentModel: doc.documentModel, - // notes is optional, include only if present - ...(doc.notes ? { notes: doc.notes } : {}) - })) : + filter(doc => doc && doc.documentId && doc.documentModel). + map(doc => { + return { + documentId: doc.documentId, + documentModel: doc.documentModel, + ...(doc.highlightedFields ? { highlightedFields: doc.highlightedFields } : {}), + // notes is optional, include only if present + ...(doc.notes ? { notes: doc.notes } : {}) + }; + }) : []; const caseReport = await CaseReport.create({ diff --git a/backend/actions/CaseReport/updateCaseReport.js b/backend/actions/CaseReport/updateCaseReport.js index cb7065f1..b9872485 100644 --- a/backend/actions/CaseReport/updateCaseReport.js +++ b/backend/actions/CaseReport/updateCaseReport.js @@ -3,6 +3,7 @@ const Archetype = require('archetype'); const authorize = require('../../authorize'); const callLLM = require('../../integrations/callLLM'); +const mongoose = require('mongoose'); const UpdateCaseReportParams = new Archetype({ caseReportId: { @@ -33,14 +34,21 @@ module.exports = ({ db, options }) => async function updateCaseReport(params) { const docs = Array.isArray(documents) ? documents - .filter(doc => doc && doc.document && doc.documentModel) - .map(doc => ({ - document: doc.document, - documentModel: doc.documentModel, - ...(doc.notes ? { notes: doc.notes } : {}) - })) + .filter(doc => doc && doc.documentId && doc.documentModel) + .map(doc => { + // Convert documentId to ObjectId if it's a string + let documentId = doc.documentId; + if (typeof documentId === 'string' && mongoose.Types.ObjectId.isValid(documentId)) { + documentId = new mongoose.Types.ObjectId(documentId); + } + return { + documentId: documentId, + documentModel: doc.documentModel, + ...(doc.highlightedFields ? { highlightedFields: doc.highlightedFields } : {}), + ...(doc.notes ? { notes: doc.notes } : {}) + }; + }) : []; - const updateData = { documents: docs }; let aiSummary = null; @@ -62,24 +70,24 @@ module.exports = ({ db, options }) => async function updateCaseReport(params) { // Fetch all documents with their data for context const documentData = []; for (const docEntry of docs) { - if (!docEntry.document || !docEntry.documentModel) { + if (!docEntry.documentId || !docEntry.documentModel) { continue; } try { const Model = db.models[docEntry.documentModel]; if (Model) { - const doc = await Model.findById(docEntry.document).setOptions({ sanitizeFilter: true }).lean(); + const doc = await Model.findById(docEntry.documentId).setOptions({ sanitizeFilter: true }).lean(); if (doc) { documentData.push({ model: docEntry.documentModel, - documentId: docEntry.document.toString(), + documentId: docEntry.documentId.toString(), data: doc, notes: docEntry.notes || '' }); } } } catch (err) { - console.error(`Error fetching document ${docEntry.document} from model ${docEntry.documentModel}:`, err); + console.error(`Error fetching document ${docEntry.documentId} from model ${docEntry.documentModel}:`, err); // Continue with other documents even if one fails } } diff --git a/backend/db/caseReportSchema.js b/backend/db/caseReportSchema.js index 8b387133..db70f60c 100644 --- a/backend/db/caseReportSchema.js +++ b/backend/db/caseReportSchema.js @@ -14,10 +14,11 @@ const caseReportSchema = new mongoose.Schema({ enum: ['created', 'in_progress', 'cancelled', 'resolved', 'archived'] }, documents: [{ - document: { + documentId: { type: mongoose.Schema.Types.ObjectId, - refPath: 'documentModel' + refPath: 'documents.documentModel' }, + highlightedFields: [String], documentModel: { type: String, required: true diff --git a/frontend/src/api.js b/frontend/src/api.js index 3c8bb331..72f103a8 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -75,18 +75,18 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) { return client.post('', { action: 'ChatMessage.executeScript', ...params }).then(res => res.data); } }; - exports.Sleuth = { + exports.CaseReport = { createCaseReport(params) { - return client.post('', { action: 'Sleuth.createCaseReport', ...params }).then(res => res.data); + return client.post('', { action: 'CaseReport.createCaseReport', ...params }).then(res => res.data); }, getCaseReports(params) { - return client.post('', { action: 'Sleuth.getCaseReports', ...params }).then(res => res.data); + return client.post('', { action: 'CaseReport.getCaseReports', ...params }).then(res => res.data); }, getCaseReport(params) { - return client.post('', { action: 'Sleuth.getCaseReport', ...params }).then(res => res.data); + return client.post('', { action: 'CaseReport.getCaseReport', ...params }).then(res => res.data); }, updateCaseReport(params) { - return client.post('', { action: 'Sleuth.updateCaseReport', ...params }).then(res => res.data); + return client.post('', { action: 'CaseReport.updateCaseReport', ...params }).then(res => res.data); } }; exports.Model = { @@ -257,18 +257,18 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) { return client.post('/ChatMessage/executeScript', params).then(res => res.data); } }; - exports.Sleuth = { + exports.CaseReport = { createCaseReport: function createCaseReport(params) { - return client.post('/Sleuth/createCaseReport', params).then(res => res.data); + return client.post('/CaseReport/createCaseReport', params).then(res => res.data); }, getCaseReports: function getCaseReports(params) { - return client.post('/Sleuth/getCaseReports', params).then(res => res.data); + return client.post('/CaseReport/getCaseReports', params).then(res => res.data); }, getCaseReport: function getCaseReport(params) { - return client.post('/Sleuth/getCaseReport', params).then(res => res.data); + return client.post('/CaseReport/getCaseReport', params).then(res => res.data); }, updateCaseReport: function updateCaseReport(params) { - return client.post('/Sleuth/updateCaseReport', params).then(res => res.data); + return client.post('/CaseReport/updateCaseReport', params).then(res => res.data); } }; exports.Model = { diff --git a/frontend/src/case-reports/case-reports.js b/frontend/src/case-reports/case-reports.js index c8513409..c142970a 100644 --- a/frontend/src/case-reports/case-reports.js +++ b/frontend/src/case-reports/case-reports.js @@ -91,7 +91,7 @@ module.exports = app => app.component('case-reports', { return; } - await api.Sleuth.updateCaseReport({ + await api.CaseReport.updateCaseReport({ caseReportId: this.confirmCaseReportId, status }); @@ -100,7 +100,7 @@ module.exports = app => app.component('case-reports', { this.closeConfirmModal(); // Reload case reports - const { caseReports } = await api.Sleuth.getCaseReports(); + const { caseReports } = await api.CaseReport.getCaseReports(); this.caseReports = caseReports; } catch (error) { console.error(`Error ${this.confirmAction}ing case report`, error); @@ -110,7 +110,7 @@ module.exports = app => app.component('case-reports', { }, async mounted() { try { - const { caseReports } = await api.Sleuth.getCaseReports(); + const { caseReports } = await api.CaseReport.getCaseReports(); this.caseReports = caseReports; this.status = 'loaded'; } catch (error) { diff --git a/frontend/src/mongoose-sleuth/mongoose-sleuth.js b/frontend/src/mongoose-sleuth/mongoose-sleuth.js index 2a82ca94..9dcbae30 100644 --- a/frontend/src/mongoose-sleuth/mongoose-sleuth.js +++ b/frontend/src/mongoose-sleuth/mongoose-sleuth.js @@ -136,11 +136,16 @@ module.exports = app => app.component('mongoose-sleuth', { buildDocumentsPayload() { return this.selectedDocuments.map(doc => { const base = { - document: doc._id, + documentId: doc._id, documentModel: doc.model }; const note = this.getDocumentNote(doc); - base.notes = note.trim(); + if (note && note.trim()) { + base.notes = note.trim(); + } + if (doc.highlightedFields && doc.highlightedFields.length > 0) { + base.highlightedFields = doc.highlightedFields; + } return base; }); }, @@ -158,7 +163,7 @@ module.exports = app => app.component('mongoose-sleuth', { const documentsPayload = this.buildDocumentsPayload(); try { - await api.Sleuth.updateCaseReport({ + await api.CaseReport.updateCaseReport({ caseReportId: this.currentCaseReportId, documents: documentsPayload }); @@ -351,7 +356,7 @@ module.exports = app => app.component('mongoose-sleuth', { } }, async loadCaseReport(caseReportId) { - const { caseReport } = await api.Sleuth.getCaseReport({ caseReportId }); + const { caseReport } = await api.CaseReport.getCaseReport({ caseReportId }); if (!caseReport || !Array.isArray(caseReport.documents)) { return; } @@ -652,7 +657,7 @@ module.exports = app => app.component('mongoose-sleuth', { } try { - const { caseReport } = await api.Sleuth.createCaseReport({ + const { caseReport } = await api.CaseReport.createCaseReport({ name: this.caseReportName.trim(), documents: documentsPayload }); @@ -682,7 +687,7 @@ module.exports = app => app.component('mongoose-sleuth', { const documentsPayload = this.buildDocumentsPayload(); try { - await api.Sleuth.updateCaseReport({ + await api.CaseReport.updateCaseReport({ caseReportId: this.currentCaseReportId, documents: documentsPayload }); @@ -709,7 +714,7 @@ module.exports = app => app.component('mongoose-sleuth', { this.savingSummary = true; try { - const { caseReport, aiSummary } = await api.Sleuth.updateCaseReport({ + const { caseReport, aiSummary } = await api.CaseReport.updateCaseReport({ caseReportId: this.currentCaseReportId, documents: documentsPayload, summary: this.summary || '' From bf30033728c2c73aa3224686dd20b9a58c8bafa4 Mon Sep 17 00:00:00 2001 From: Daniel Diaz <39510674+IslandRhythms@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:15:01 -0500 Subject: [PATCH 10/17] v1.1 UI --- frontend/src/case-reports/case-reports.html | 15 +- frontend/src/models/models.css | 11 +- frontend/src/models/models.html | 48 +++- frontend/src/models/models.js | 31 ++- .../src/mongoose-sleuth/mongoose-sleuth.html | 6 +- .../src/mongoose-sleuth/mongoose-sleuth.js | 59 +++-- .../sleuth-unified/sleuth-unified.html | 249 ++++++++++++++++++ .../sleuth-unified/sleuth-unified.js | 15 ++ frontend/src/routes.js | 22 +- 9 files changed, 383 insertions(+), 73 deletions(-) create mode 100644 frontend/src/mongoose-sleuth/sleuth-unified/sleuth-unified.html create mode 100644 frontend/src/mongoose-sleuth/sleuth-unified/sleuth-unified.js diff --git a/frontend/src/case-reports/case-reports.html b/frontend/src/case-reports/case-reports.html index 829f4cab..462cb8a4 100644 --- a/frontend/src/case-reports/case-reports.html +++ b/frontend/src/case-reports/case-reports.html @@ -24,15 +24,12 @@

No case reports yet

-

Get started by creating a new case report in Mongoose Sleuth.

+

Go to Documents and open the Sleuth panel on the right to create a case report.

- - New Case Report + Go to Documents
@@ -45,9 +42,9 @@

Case Reports

- New Case Report + Go to Documents
@@ -96,7 +93,7 @@

Case Reports

Archive Open diff --git a/frontend/src/models/models.css b/frontend/src/models/models.css index a5dd47fe..fcdd9872 100644 --- a/frontend/src/models/models.css +++ b/frontend/src/models/models.css @@ -82,17 +82,12 @@ } .models .documents-menu { - position: fixed; + position: sticky; + top: 0; z-index: 1; padding: 4px; display: flex; - width: 100vw; -} - -@media (min-width: 1024px) { - .models .documents-menu { - width: calc(100vw - 12rem); - } + width: 100%; } .models .documents-menu .search-input { diff --git a/frontend/src/models/models.html b/frontend/src/models/models.html index 76736b7b..a496129f 100644 --- a/frontend/src/models/models.html +++ b/frontend/src/models/models.html @@ -1,9 +1,6 @@ -
-
- -
-