Files
cgm-remote-monitor/lib/server/treatments.js
T

477 lines
14 KiB
JavaScript

'use strict';
var _ = require('lodash');
var async = require('async');
var moment = require('moment');
var find_options = require('./query');
function storage (env, ctx) {
var ObjectID = require('mongodb-legacy').ObjectId;
var OBJECT_ID_HEX_RE = /^[0-9a-fA-F]{24}$/;
function create (objOrArray, fn) {
function done (err, result) {
ctx.bus.emit('data-received');
fn(err, result);
}
if (_.isArray(objOrArray)) {
if (objOrArray.length === 0) {
return done(null, []);
}
// Check if any docs have preBolus (need special handling with upsert)
// Don't call prepareData yet - that happens in upsert or before bulkWrite
var hasPreBolus = objOrArray.some(function(obj) {
// preBolus may be a string from API, so check truthiness and non-zero
var preBolus = Number(obj.preBolus);
return preBolus && preBolus !== 0;
});
// If any preBolus docs exist, fall back to sequential processing
// because preBolus creates additional treatment records
if (hasPreBolus) {
var allDocs = [];
var errs = [];
async.eachSeries(objOrArray, function (obj, callback) {
upsert(obj, function upserted (err, docs) {
allDocs = allDocs.concat(docs);
errs.push(err);
callback(err, docs);
});
}, function () {
errs = _.compact(errs);
done(errs.length > 0 ? errs : null, allDocs);
});
return;
}
// Build bulkWrite operations for regular docs (no preBolus)
// Prepare data and build bulk ops together
var bulkOps = objOrArray.map(function(obj) {
normalizeTreatmentId(obj);
var results = prepareData(obj);
return {
replaceOne: {
filter: upsertQueryFor(obj, results),
replacement: obj,
upsert: true
}
};
});
api().bulkWrite(bulkOps, { ordered: true }, function(err, bulkResult) {
if (err) {
console.error('Problem upserting treatments batch', err);
return done(err, []);
}
// Assign _ids from upserted results
if (bulkResult && bulkResult.upsertedIds) {
Object.keys(bulkResult.upsertedIds).forEach(function(index) {
objOrArray[index]._id = bulkResult.upsertedIds[index];
});
}
// REQ-SYNC-072: For docs that were updated (not inserted) via identifier,
// fetch their _id from the database (only identifier field, not others)
var docsNeedingId = objOrArray.filter(function(obj) {
return !obj._id && obj.identifier;
});
if (docsNeedingId.length > 0) {
var identifiers = docsNeedingId.map(function(obj) { return obj.identifier; });
api().find({ identifier: { $in: identifiers } }).toArray(function(findErr, existing) {
if (!findErr && existing) {
var idMap = {};
existing.forEach(function(doc) {
if (doc.identifier) idMap[doc.identifier] = doc._id;
});
docsNeedingId.forEach(function(obj) {
if (obj.identifier && idMap[obj.identifier]) {
obj._id = idMap[obj.identifier];
}
});
}
ctx.bus.emit('data-update', {
type: 'treatments',
op: 'update',
changes: ctx.ddata.processRawDataForRuntime(objOrArray)
});
done(null, objOrArray);
});
return;
}
ctx.bus.emit('data-update', {
type: 'treatments',
op: 'update',
changes: ctx.ddata.processRawDataForRuntime(objOrArray)
});
done(null, objOrArray);
});
} else {
upsert(objOrArray, function upserted (err, docs) {
done(err, docs);
});
}
}
function upsert (obj, fn) {
normalizeTreatmentId(obj);
var results = prepareData(obj);
var query = upsertQueryFor(obj, results);
api( ).replaceOne(query, obj, {upsert: true}, function complete (err, updateResults) {
if (err) console.error('Problem upserting treatment', err);
if (updateResults) {
if (updateResults.upsertedCount == 1) {
obj._id = updateResults.upsertedId;
} else if (updateResults.matchedCount >= 1 && obj.identifier && !obj._id) {
// REQ-SYNC-072: On update by identifier, fetch the existing _id
api().findOne(query, function(findErr, existing) {
if (!findErr && existing) {
obj._id = existing._id;
}
finishUpsert(err, obj, results);
});
return;
}
}
finishUpsert(err, obj, results);
});
function finishUpsert(err, obj, results) {
// TODO document this feature
if (!err && obj.preBolus) {
//create a new object to insert copying only the needed fields
var pbTreat = {
created_at: (new Date(new Date(results.created_at).getTime() + (obj.preBolus * 60000))).toISOString(),
eventType: obj.eventType,
carbs: results.preBolusCarbs
};
if (obj.notes) {
pbTreat.notes = obj.notes;
}
var pbQuery = {
created_at: pbTreat.created_at,
eventType: pbTreat.eventType
};
api( ).replaceOne(pbQuery, pbTreat, {upsert: true}, function pbComplete (err, updateResults) {
if (updateResults) {
if (updateResults.upsertedCount == 1) {
pbTreat._id = updateResults.upsertedId
}
}
var treatments = _.compact([obj, pbTreat]);
ctx.bus.emit('data-update', {
type: 'treatments',
op: 'update',
changes: ctx.ddata.processRawDataForRuntime(treatments)
});
fn(err, treatments);
});
} else {
ctx.bus.emit('data-update', {
type: 'treatments',
op: 'update',
changes: ctx.ddata.processRawDataForRuntime([obj])
});
fn(err, [obj]);
}
}
}
function list (opts, fn) {
function limit ( ) {
if (opts && opts.count) {
return this.limit(parseInt(opts.count));
}
return this;
}
return limit.call(api()
.find(query_for(opts))
.sort(opts && opts.sort || {created_at: -1}), opts)
.toArray(fn);
}
function query_for (opts) {
// Build queryOpts inside function to access env.uuidHandling
var queryOpts = {
walker: {
insulin: parseInt
, carbs: parseInt
, glucose: parseInt
, notes: find_options.parseRegEx
, eventType: find_options.parseRegEx
, enteredBy: find_options.parseRegEx
}
, dateField: 'created_at'
, uuidHandling: env.uuidHandling
};
return find_options(opts, queryOpts);
}
function remove (opts, fn) {
return api( ).deleteMany(query_for(opts), {}, function (err, stat) {
//TODO: this is triggering a read from Mongo, we can do better
//console.log('Treatment removed', opts); // , stat);
ctx.bus.emit('data-update', {
type: 'treatments',
op: 'remove',
count: stat.deletedCount,
changes: opts.find._id
});
ctx.bus.emit('data-received');
fn(err, stat);
});
}
function save (obj, fn) {
normalizeTreatmentId(obj);
prepareData(obj);
var query = upsertQueryFor(obj, { created_at: obj.created_at });
function saved (err, updateResults) {
if (!err) {
if (updateResults && updateResults.upsertedCount == 1) {
obj._id = updateResults.upsertedId;
} else if (updateResults && updateResults.matchedCount >= 1 && obj.identifier && !obj._id) {
// REQ-SYNC-072: On update by identifier, fetch the existing _id
api().findOne(query, function(findErr, existing) {
if (!findErr && existing) {
obj._id = existing._id;
}
finishSave(err, obj);
});
return;
}
// console.log('Treatment updated', created);
ctx.ddata.processRawDataForRuntime(obj);
ctx.bus.emit('data-update', {
type: 'treatments',
op: 'update',
changes: ctx.ddata.processRawDataForRuntime([obj])
});
}
if (err) console.error('Problem saving treating', err);
fn(err, obj);
}
function finishSave(err, obj) {
if (!err) {
ctx.ddata.processRawDataForRuntime(obj);
ctx.bus.emit('data-update', {
type: 'treatments',
op: 'update',
changes: ctx.ddata.processRawDataForRuntime([obj])
});
}
if (err) console.error('Problem saving treating', err);
fn(err, obj);
}
api().replaceOne(query, obj, {upsert: true}, saved);
ctx.bus.emit('data-received');
}
function api ( ) {
return ctx.store.collection(env.treatments_collection);
}
/**
* Build upsert query - REQ-SYNC-072: identifier-first lookup
* Priority: identifier > _id > time+type
*
* IMPORTANT: When returning identifier-based query, also removes _id from obj
* because MongoDB doesn't allow changing _id on upsert update.
*/
function upsertQueryFor (obj, results) {
// 1. Prefer identifier for dedup (AAPS, Loop UUID _id normalized)
if (obj.identifier) {
// Remove _id from replacement - MongoDB will use existing _id on update,
// or generate new one on insert
delete obj._id;
return { identifier: obj.identifier };
}
// 2. Fall back to _id if present and valid
if (Object.prototype.hasOwnProperty.call(obj, '_id') && obj._id !== null && obj._id !== '') {
return { _id: obj._id };
}
// 3. Last resort: time + eventType
return {
created_at: results.created_at
, eventType: obj.eventType
};
}
/**
* Normalize treatment ID - REQ-SYNC-072: Server-Controlled ID
*
* Scope: ONLY handles UUID values in _id field
* - Loop overrides: UUID in _id → moved to identifier
*
* Does NOT touch other client fields (syncIdentifier, uuid, etc.)
* Those fields are preserved as-is and used for dedup in upsertQueryFor().
*
* Note: _id handling is done here to properly handle update vs insert
*/
function normalizeTreatmentId (obj) {
// REQ-SYNC-072: Only handle UUID values in _id field
// Scope: ONLY the _id field when value is a valid UUID
// Does NOT touch syncIdentifier, uuid, or other client fields
if (typeof obj._id === 'string' && !OBJECT_ID_HEX_RE.test(obj._id)) {
// Non-ObjectId string in _id (UUID format)
// Only move to identifier when UUID_HANDLING is enabled
if (env.uuidHandling && !obj.identifier) {
obj.identifier = obj._id;
}
// Always delete invalid _id so server generates ObjectId
delete obj._id;
} else if (Object.prototype.hasOwnProperty.call(obj, '_id') && obj._id !== null && obj._id !== '') {
// Convert valid ObjectId strings to ObjectId objects
if (typeof obj._id === 'string' && OBJECT_ID_HEX_RE.test(obj._id)) {
obj._id = new ObjectID(obj._id);
}
}
}
api.list = list;
api.create = create;
api.query_for = query_for;
api.indexedFields = [
'created_at'
, 'eventType'
, 'insulin'
, 'carbs'
, 'glucose'
, 'enteredBy'
, 'boluscalc.foods._id'
, 'notes'
, 'NSCLIENT_ID'
, 'percent'
, 'absolute'
, 'duration'
, 'identifier' // REQ-SYNC-072: Client sync identity (UUID from _id field)
, { 'eventType' : 1, 'duration' : 1, 'created_at' : 1 }
];
api.remove = remove;
api.save = save;
api.aggregate = require('./aggregate')({ }, api);
return api;
}
function prepareData(obj) {
// Convert all dates to UTC dates
// TODO remove this -> must not create new date if missing
const d = moment(obj.created_at).isValid() ? moment.parseZone(obj.created_at) : moment();
obj.created_at = d.toISOString();
var results = {
created_at: obj.created_at
, preBolusCarbs: ''
};
const offset = d.utcOffset();
obj.utcOffset = offset;
results.offset = offset;
obj.glucose = Number(obj.glucose);
obj.targetTop = Number(obj.targetTop);
obj.targetBottom = Number(obj.targetBottom);
obj.carbs = Number(obj.carbs);
obj.insulin = Number(obj.insulin);
obj.duration = Number(obj.duration);
obj.percent = Number(obj.percent);
obj.absolute = Number(obj.absolute);
obj.relative = Number(obj.relative);
obj.preBolus = Number(obj.preBolus);
//NOTE: the eventTime is sent by the client, but deleted, we only store created_at
var eventTime;
if (obj.eventTime) {
eventTime = new Date(obj.eventTime).toISOString();
results.created_at = eventTime;
}
obj.created_at = results.created_at;
if (obj.preBolus && obj.preBolus !== 0 && obj.carbs) {
results.preBolusCarbs = obj.carbs;
delete obj.carbs;
}
if (obj.eventType === 'Announcement') {
obj.isAnnouncement = true;
}
// clean data
delete obj.eventTime;
function deleteIfEmpty (field) {
if (!obj[field] || obj[field] === 0) {
delete obj[field];
}
}
function deleteIfNaN (field) {
if (isNaN(obj[field])) {
delete obj[field];
}
}
deleteIfEmpty('targetTop');
deleteIfEmpty('targetBottom');
deleteIfEmpty('carbs');
deleteIfEmpty('insulin');
deleteIfEmpty('percent');
deleteIfEmpty('relative');
deleteIfEmpty('notes');
deleteIfEmpty('preBolus');
deleteIfNaN('absolute');
deleteIfNaN('duration');
if (obj.glucose === 0 || isNaN(obj.glucose)) {
delete obj.glucose;
delete obj.glucoseType;
delete obj.units;
}
return results;
}
module.exports = storage;