610 lines
18 KiB
JavaScript
610 lines
18 KiB
JavaScript
import mongoose from "mongoose";
|
||
import { Device, Image, Node, Patch } from "../db/models/index.js";
|
||
import config from "../config.js";
|
||
import { deleteOrphanImages } from "./images.js";
|
||
import {
|
||
DEFAULT_PRODUCE_ICON,
|
||
PRODUCE_ICON_IDS,
|
||
} from "../constants/produceIcons.js";
|
||
import { getLogger } from "../logger.js";
|
||
|
||
const logger = getLogger("Patches");
|
||
|
||
const DEFAULT_ROWS = 3;
|
||
const DEFAULT_COLUMNS = 8;
|
||
const DEFAULT_GROWING_POSITION = 100;
|
||
const DEFAULT_CURRENT_WITHERING = 0;
|
||
const DEFAULT_CURRENT_LEAVES = 0;
|
||
const DEFAULT_CURRENT_FLOWERS = 0;
|
||
const DEFAULT_CURRENT_FRUITING = 0;
|
||
|
||
const SUNLIGHT_VALUES = ["fullSun", "partialShade", "fullShade"];
|
||
const DEFAULT_SUNLIGHT = "fullSun";
|
||
const DEFAULT_SOIL_PH_MIN = 5.5;
|
||
const DEFAULT_SOIL_PH_MAX = 6.5;
|
||
const WATER_NEEDS_VALUES = ["low", "medium", "high"];
|
||
const DEFAULT_WATER_NEEDS = "medium";
|
||
|
||
const MAX_OFFSET_WEEKS = 520;
|
||
const MIN_CAL_WEEK = 0;
|
||
const MAX_CAL_WEEK = 52;
|
||
const MIN_PHASE_GROWING = 0;
|
||
const MAX_PHASE_GROWING = 100;
|
||
const MIN_PHASE_LEAVES = 0;
|
||
const MAX_PHASE_LEAVES = 6;
|
||
const MIN_PHASE_FLOWERS = 0;
|
||
const MAX_PHASE_FLOWERS = 3;
|
||
const MAX_PATCH_IMAGES = 24;
|
||
|
||
function clampInt(n, min, max) {
|
||
const x = Math.floor(Number(n));
|
||
if (!Number.isFinite(x)) return min;
|
||
return Math.min(max, Math.max(min, x));
|
||
}
|
||
|
||
/** Prefer camelCase; accept legacy snake_case from stored documents. */
|
||
function durationWeeksFromLifecycle(lifecycle) {
|
||
const v = lifecycle.durationWeeks ?? lifecycle.duration_weeks;
|
||
return clampInt(v, 1, MAX_OFFSET_WEEKS);
|
||
}
|
||
|
||
function biennialOffsetsFromPhase(ph) {
|
||
const start = ph?.startOffsetWeeks ?? ph?.start_offset_weeks;
|
||
const end = ph?.endOffsetWeeks ?? ph?.end_offset_weeks;
|
||
return {
|
||
startOffsetWeeks: clampInt(start, 0, MAX_OFFSET_WEEKS),
|
||
endOffsetWeeks: clampInt(end, 0, MAX_OFFSET_WEEKS),
|
||
};
|
||
}
|
||
|
||
function perennialWeeksFromPhase(ph) {
|
||
const start = ph?.startWeek ?? ph?.start_week;
|
||
const end = ph?.endWeek ?? ph?.end_week;
|
||
return {
|
||
startWeek: clampInt(start, MIN_CAL_WEEK, MAX_CAL_WEEK),
|
||
endWeek: clampInt(end, MIN_CAL_WEEK, MAX_CAL_WEEK),
|
||
};
|
||
}
|
||
|
||
/** Lottie growth stage 0–100 per lifecycle phase (camelCase; legacy `growing_position`). */
|
||
function growingPositionFromPhase(ph) {
|
||
const v = ph?.growingPosition ?? ph?.growing_position;
|
||
const n = Number(v);
|
||
if (!Number.isFinite(n)) return 0;
|
||
return Math.min(
|
||
MAX_PHASE_GROWING,
|
||
Math.max(MIN_PHASE_GROWING, Math.round(n)),
|
||
);
|
||
}
|
||
|
||
/** Withering tint 0–100 for plant Lottie (default 0). */
|
||
function witheringFromPhase(ph) {
|
||
const v = ph?.withering ?? ph?.withering_position;
|
||
const n = Number(v);
|
||
if (!Number.isFinite(n)) return MIN_PHASE_GROWING;
|
||
return Math.min(
|
||
MAX_PHASE_GROWING,
|
||
Math.max(MIN_PHASE_GROWING, Math.round(n)),
|
||
);
|
||
}
|
||
|
||
function leavesFromPhase(ph) {
|
||
const v = ph?.leaves ?? ph?.leaf_count;
|
||
return clampInt(v, MIN_PHASE_LEAVES, MAX_PHASE_LEAVES);
|
||
}
|
||
|
||
function flowersFromPhase(ph) {
|
||
const v = ph?.flowers ?? ph?.flower_count;
|
||
return clampInt(v, MIN_PHASE_FLOWERS, MAX_PHASE_FLOWERS);
|
||
}
|
||
|
||
/** Fruit development 0–100 for plant Lottie (with flowers). */
|
||
function fruitingFromPhase(ph) {
|
||
const v = ph?.fruiting;
|
||
const n = Number(v);
|
||
if (!Number.isFinite(n)) return MIN_PHASE_GROWING;
|
||
return Math.min(
|
||
MAX_PHASE_GROWING,
|
||
Math.max(MIN_PHASE_GROWING, Math.round(n)),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* @param {unknown} lifecycle
|
||
* @returns {{ type: 'biennial', durationWeeks: number, phases: object[] } | { type: 'perennial', recurring: boolean, phases: object[] } | null}
|
||
*/
|
||
export function sanitizeLifecycleForClient(lifecycle) {
|
||
if (!lifecycle || typeof lifecycle !== "object") return null;
|
||
const type = lifecycle.type;
|
||
if (type === "biennial") {
|
||
const phases = (Array.isArray(lifecycle.phases) ? lifecycle.phases : []).map(
|
||
(ph) => {
|
||
const { startOffsetWeeks, endOffsetWeeks } = biennialOffsetsFromPhase(ph);
|
||
return {
|
||
name:
|
||
typeof ph?.name === "string" && ph.name.trim()
|
||
? ph.name.trim()
|
||
: "phase",
|
||
startOffsetWeeks,
|
||
endOffsetWeeks,
|
||
growingPosition: growingPositionFromPhase(ph),
|
||
withering: witheringFromPhase(ph),
|
||
leaves: leavesFromPhase(ph),
|
||
flowers: flowersFromPhase(ph),
|
||
fruiting: fruitingFromPhase(ph),
|
||
};
|
||
},
|
||
);
|
||
return {
|
||
type: "biennial",
|
||
durationWeeks: Math.max(1, durationWeeksFromLifecycle(lifecycle)),
|
||
phases,
|
||
};
|
||
}
|
||
if (type === "perennial") {
|
||
const phases = (Array.isArray(lifecycle.phases) ? lifecycle.phases : []).map(
|
||
(ph) => {
|
||
const { startWeek, endWeek } = perennialWeeksFromPhase(ph);
|
||
return {
|
||
name:
|
||
typeof ph?.name === "string" && ph.name.trim()
|
||
? ph.name.trim()
|
||
: "phase",
|
||
startWeek,
|
||
endWeek,
|
||
growingPosition: growingPositionFromPhase(ph),
|
||
withering: witheringFromPhase(ph),
|
||
leaves: leavesFromPhase(ph),
|
||
flowers: flowersFromPhase(ph),
|
||
fruiting: fruitingFromPhase(ph),
|
||
};
|
||
},
|
||
);
|
||
return {
|
||
type: "perennial",
|
||
recurring: Boolean(lifecycle.recurring),
|
||
phases,
|
||
};
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* @param {unknown} raw
|
||
* @returns {{ type: 'biennial', durationWeeks: number, phases: object[] } | { type: 'perennial', recurring: boolean, phases: object[] }}
|
||
*/
|
||
function parseLifecycleFromUpdate(raw) {
|
||
if (!raw || typeof raw !== "object") {
|
||
throw new Error("lifecycle must be an object");
|
||
}
|
||
const type = raw.type;
|
||
if (type === "biennial") {
|
||
if (!Array.isArray(raw.phases) || raw.phases.length === 0) {
|
||
throw new Error("biennial lifecycle requires at least one phase");
|
||
}
|
||
const phases = raw.phases.map((ph, i) => {
|
||
if (!ph || typeof ph !== "object") {
|
||
throw new Error(`Invalid phase at index ${i}`);
|
||
}
|
||
const name = typeof ph.name === "string" ? ph.name.trim() : "";
|
||
if (!name) {
|
||
throw new Error(`Phase ${i + 1}: name is required`);
|
||
}
|
||
const { startOffsetWeeks, endOffsetWeeks } = biennialOffsetsFromPhase(ph);
|
||
return {
|
||
name,
|
||
startOffsetWeeks,
|
||
endOffsetWeeks,
|
||
growingPosition: growingPositionFromPhase(ph),
|
||
withering: witheringFromPhase(ph),
|
||
leaves: leavesFromPhase(ph),
|
||
flowers: flowersFromPhase(ph),
|
||
fruiting: fruitingFromPhase(ph),
|
||
};
|
||
});
|
||
return {
|
||
type: "biennial",
|
||
durationWeeks: Math.max(1, durationWeeksFromLifecycle(raw)),
|
||
phases,
|
||
};
|
||
}
|
||
if (type === "perennial") {
|
||
if (!Array.isArray(raw.phases) || raw.phases.length === 0) {
|
||
throw new Error("perennial lifecycle requires at least one phase");
|
||
}
|
||
const phases = raw.phases.map((ph, i) => {
|
||
if (!ph || typeof ph !== "object") {
|
||
throw new Error(`Invalid phase at index ${i}`);
|
||
}
|
||
const name = typeof ph.name === "string" ? ph.name.trim() : "";
|
||
if (!name) {
|
||
throw new Error(`Phase ${i + 1}: name is required`);
|
||
}
|
||
const { startWeek, endWeek } = perennialWeeksFromPhase(ph);
|
||
return {
|
||
name,
|
||
startWeek,
|
||
endWeek,
|
||
growingPosition: growingPositionFromPhase(ph),
|
||
withering: witheringFromPhase(ph),
|
||
leaves: leavesFromPhase(ph),
|
||
flowers: flowersFromPhase(ph),
|
||
fruiting: fruitingFromPhase(ph),
|
||
};
|
||
});
|
||
return {
|
||
type: "perennial",
|
||
recurring: Boolean(raw.recurring),
|
||
phases,
|
||
};
|
||
}
|
||
throw new Error('lifecycle.type must be "biennial" or "perennial"');
|
||
}
|
||
|
||
/**
|
||
* @param {unknown} value
|
||
* @returns {Date | null | undefined} undefined = omit update
|
||
*/
|
||
function parsePlantedAtUpdate(value) {
|
||
if (value === undefined) return undefined;
|
||
if (value === null || value === "") return null;
|
||
const d = new Date(value);
|
||
if (Number.isNaN(d.getTime())) {
|
||
throw new Error("Invalid plantedAt date");
|
||
}
|
||
return d;
|
||
}
|
||
|
||
export function normalizeProduceIcon(value) {
|
||
if (value == null || typeof value !== "string") return DEFAULT_PRODUCE_ICON;
|
||
const s = value.trim();
|
||
if (PRODUCE_ICON_IDS.includes(s)) return s;
|
||
return DEFAULT_PRODUCE_ICON;
|
||
}
|
||
|
||
function normalizeSunlight(value) {
|
||
if (value == null || typeof value !== "string") return DEFAULT_SUNLIGHT;
|
||
const s = value.trim();
|
||
return SUNLIGHT_VALUES.includes(s) ? s : DEFAULT_SUNLIGHT;
|
||
}
|
||
|
||
function normalizeWaterNeeds(value) {
|
||
if (value == null || typeof value !== "string") return DEFAULT_WATER_NEEDS;
|
||
const s = value.trim();
|
||
return WATER_NEEDS_VALUES.includes(s) ? s : DEFAULT_WATER_NEEDS;
|
||
}
|
||
|
||
/** @param {unknown} value @param {number} fallback */
|
||
function parseSoilPh(value, fallback) {
|
||
const n = Number(value);
|
||
if (!Number.isFinite(n)) return fallback;
|
||
return Math.min(14, Math.max(0, Math.round(n * 10) / 10));
|
||
}
|
||
|
||
/**
|
||
* @param {unknown} img
|
||
* @returns {{ id: string, blurHash: string } | null}
|
||
*/
|
||
function formatClientImageEntry(img) {
|
||
if (img == null) return null;
|
||
if (typeof img === "object" && img !== null) {
|
||
const oid = img._id ?? img.id;
|
||
if (oid != null) {
|
||
return {
|
||
id: String(oid),
|
||
blurHash: typeof img.blurHash === "string" ? img.blurHash : "",
|
||
};
|
||
}
|
||
}
|
||
if (mongoose.Types.ObjectId.isValid(String(img))) {
|
||
return { id: String(img), blurHash: "" };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* @param {unknown} raw
|
||
* @returns {Promise<mongoose.Types.ObjectId[]>}
|
||
*/
|
||
async function normalizeImagesForUpdate(raw) {
|
||
if (!Array.isArray(raw)) {
|
||
throw new Error("images must be an array");
|
||
}
|
||
if (raw.length > MAX_PATCH_IMAGES) {
|
||
throw new Error(`At most ${MAX_PATCH_IMAGES} images per patch`);
|
||
}
|
||
const ids = [];
|
||
for (const item of raw) {
|
||
const s =
|
||
typeof item === "string"
|
||
? item.trim()
|
||
: String(item?.id ?? "").trim();
|
||
if (!s) continue;
|
||
if (!mongoose.Types.ObjectId.isValid(s)) {
|
||
throw new Error("Invalid image id");
|
||
}
|
||
const exists = await Image.exists({ _id: s });
|
||
if (!exists) continue;
|
||
ids.push(new mongoose.Types.ObjectId(s));
|
||
}
|
||
return ids;
|
||
}
|
||
|
||
export function formatClientPatches(patchDocs) {
|
||
return (patchDocs || []).map((p) => ({
|
||
id: p.patchId,
|
||
name: p.name,
|
||
description: p.description ?? "",
|
||
icon: normalizeProduceIcon(p.icon),
|
||
rows: p.rows ?? DEFAULT_ROWS,
|
||
columns: p.columns ?? DEFAULT_COLUMNS,
|
||
currentGrowingPosition:
|
||
p.currentGrowingPosition ?? DEFAULT_GROWING_POSITION,
|
||
currentWithering: p.currentWithering ?? DEFAULT_CURRENT_WITHERING,
|
||
currentLeaves: p.currentLeaves ?? DEFAULT_CURRENT_LEAVES,
|
||
currentFlowers: p.currentFlowers ?? DEFAULT_CURRENT_FLOWERS,
|
||
currentFruiting: p.currentFruiting ?? DEFAULT_CURRENT_FRUITING,
|
||
currentGrowingPhase:
|
||
p.currentGrowingPhase != null &&
|
||
typeof p.currentGrowingPhase === "object"
|
||
? {
|
||
name: String(p.currentGrowingPhase.name ?? ""),
|
||
index: Number(p.currentGrowingPhase.index ?? 0),
|
||
}
|
||
: null,
|
||
soilHumidity: p.soilHumidity,
|
||
soilTemperature: p.soilTemperature,
|
||
sunlight: normalizeSunlight(p.sunlight),
|
||
soilPhMin: parseSoilPh(
|
||
p.soilPhMin ?? p.soil_ph_min,
|
||
DEFAULT_SOIL_PH_MIN,
|
||
),
|
||
soilPhMax: parseSoilPh(
|
||
p.soilPhMax ?? p.soil_ph_max,
|
||
DEFAULT_SOIL_PH_MAX,
|
||
),
|
||
waterNeeds: normalizeWaterNeeds(p.waterNeeds ?? p.water_needs),
|
||
plantedAt: p.plantedAt ? new Date(p.plantedAt).toISOString() : null,
|
||
lifecycle: sanitizeLifecycleForClient(p.lifecycle),
|
||
images: (Array.isArray(p.images) ? p.images : [])
|
||
.map(formatClientImageEntry)
|
||
.filter(Boolean),
|
||
updatedAt: p.updatedAt,
|
||
}));
|
||
}
|
||
|
||
async function getDeviceForPatches(config) {
|
||
const ttnApplicationId = config.device?.ttnApplicationId?.trim();
|
||
const ttnDeviceId = config.device?.ttnDeviceId?.trim();
|
||
if (!ttnApplicationId || !ttnDeviceId) return null;
|
||
return Device.findOne({ ttnApplicationId, ttnDeviceId });
|
||
}
|
||
|
||
/**
|
||
* Ensures Patch documents exist for each configured id only. Name, soilHumidity,
|
||
* and soilTemperature are never read from config — they live in MongoDB / UI / TTN.
|
||
* Patches removed from config are deleted for the configured device.
|
||
*/
|
||
export async function syncPatchesFromConfig(config) {
|
||
const device = await getDeviceForPatches(config);
|
||
if (!device) {
|
||
logger.warn(
|
||
"No device for patch sync — ensure config.device matches a synced Device document",
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (!Array.isArray(config.patches) || config.patches.length === 0) {
|
||
await Patch.deleteMany({ deviceId: device._id });
|
||
await Node.updateMany({ deviceId: device._id }, { $set: { patches: [] } });
|
||
return;
|
||
}
|
||
|
||
const ids = [];
|
||
for (const entry of config.patches) {
|
||
let patchId;
|
||
if (typeof entry === "string") {
|
||
patchId = entry.trim();
|
||
} else if (entry && typeof entry === "object") {
|
||
patchId = String(entry.id ?? entry.patchId ?? "").trim();
|
||
}
|
||
|
||
if (!patchId) {
|
||
logger.warn("Skipping invalid patches entry (expected a string id or { id })");
|
||
continue;
|
||
}
|
||
|
||
ids.push(patchId);
|
||
}
|
||
|
||
for (const patchId of ids) {
|
||
await Patch.updateOne(
|
||
{ deviceId: device._id, patchId },
|
||
{
|
||
$setOnInsert: {
|
||
deviceId: device._id,
|
||
patchId,
|
||
name: "",
|
||
description: "",
|
||
rows: DEFAULT_ROWS,
|
||
columns: DEFAULT_COLUMNS,
|
||
icon: DEFAULT_PRODUCE_ICON,
|
||
soilHumidity: null,
|
||
soilTemperature: null,
|
||
sunlight: DEFAULT_SUNLIGHT,
|
||
soilPhMin: DEFAULT_SOIL_PH_MIN,
|
||
soilPhMax: DEFAULT_SOIL_PH_MAX,
|
||
waterNeeds: DEFAULT_WATER_NEEDS,
|
||
},
|
||
},
|
||
{ upsert: true },
|
||
);
|
||
}
|
||
|
||
await Patch.deleteMany({
|
||
deviceId: device._id,
|
||
patchId: { $nin: ids },
|
||
});
|
||
await Node.updateMany(
|
||
{ deviceId: device._id },
|
||
{ $pull: { patches: { $nin: ids } } },
|
||
);
|
||
}
|
||
|
||
export async function updatePatchesFromUplinkPayload(dev, payload) {
|
||
if (!Array.isArray(payload.patches)) return;
|
||
|
||
for (const p of payload.patches) {
|
||
if (!p || !p.id) continue;
|
||
const $set = {};
|
||
if (p.soilTemperature != null) {
|
||
$set.soilTemperature = Number(p.soilTemperature);
|
||
}
|
||
if (p.soilHumidity != null) {
|
||
$set.soilHumidity = Number(p.soilHumidity);
|
||
}
|
||
if (Object.keys($set).length === 0) continue;
|
||
await Patch.updateOne({ deviceId: dev._id, patchId: p.id }, { $set });
|
||
}
|
||
}
|
||
|
||
const MAX_GRID = 24;
|
||
|
||
function parsePositiveInt(value, fallback) {
|
||
const n = Math.floor(Number(value));
|
||
if (!Number.isFinite(n)) return fallback;
|
||
return Math.min(MAX_GRID, Math.max(1, n));
|
||
}
|
||
|
||
/**
|
||
* Updates editable patch fields on the configured device only.
|
||
* Only keys present on `updates` (not undefined) are written.
|
||
* @param {string} patchId logical patch id (Mongo `patchId`)
|
||
* @param {{ name?: unknown, description?: unknown, icon?: unknown, rows?: unknown, columns?: unknown, lifecycle?: unknown, plantedAt?: unknown, sunlight?: unknown, soilPhMin?: unknown, soilPhMax?: unknown, waterNeeds?: unknown, images?: unknown }} updates
|
||
* @returns {Promise<ReturnType<typeof formatClientPatches>[0] | null>}
|
||
*/
|
||
export async function updatePatchForConfiguredDevice(patchId, updates = {}) {
|
||
const device = await getDeviceForPatches(config);
|
||
if (!device) {
|
||
throw new Error("No device configured");
|
||
}
|
||
const id = String(patchId ?? "").trim();
|
||
if (!id) {
|
||
throw new Error("patchId required");
|
||
}
|
||
|
||
const $set = {};
|
||
if (updates.name !== undefined) {
|
||
const nameStr =
|
||
typeof updates.name === "string"
|
||
? updates.name.trim()
|
||
: updates.name == null
|
||
? ""
|
||
: String(updates.name).trim();
|
||
$set.name = nameStr;
|
||
}
|
||
if (updates.description !== undefined) {
|
||
const descStr =
|
||
typeof updates.description === "string"
|
||
? updates.description.trim()
|
||
: updates.description == null
|
||
? ""
|
||
: String(updates.description).trim();
|
||
$set.description = descStr;
|
||
}
|
||
if (updates.icon !== undefined) {
|
||
$set.icon = normalizeProduceIcon(updates.icon);
|
||
}
|
||
if (updates.rows !== undefined) {
|
||
$set.rows = parsePositiveInt(updates.rows, DEFAULT_ROWS);
|
||
}
|
||
if (updates.columns !== undefined) {
|
||
$set.columns = parsePositiveInt(updates.columns, DEFAULT_COLUMNS);
|
||
}
|
||
if (updates.lifecycle !== undefined) {
|
||
$set.lifecycle = parseLifecycleFromUpdate(updates.lifecycle);
|
||
}
|
||
if (updates.plantedAt !== undefined) {
|
||
$set.plantedAt = parsePlantedAtUpdate(updates.plantedAt);
|
||
}
|
||
if (updates.sunlight !== undefined) {
|
||
$set.sunlight = normalizeSunlight(updates.sunlight);
|
||
}
|
||
if (updates.soilPhMin !== undefined || updates.soilPhMax !== undefined) {
|
||
const needExisting =
|
||
updates.soilPhMin === undefined || updates.soilPhMax === undefined;
|
||
const existing = needExisting
|
||
? await Patch.findOne({ deviceId: device._id, patchId: id }).lean()
|
||
: null;
|
||
const prevMin = parseSoilPh(
|
||
existing?.soilPhMin ?? existing?.soil_ph_min,
|
||
DEFAULT_SOIL_PH_MIN,
|
||
);
|
||
const prevMax = parseSoilPh(
|
||
existing?.soilPhMax ?? existing?.soil_ph_max,
|
||
DEFAULT_SOIL_PH_MAX,
|
||
);
|
||
let min =
|
||
updates.soilPhMin !== undefined
|
||
? parseSoilPh(updates.soilPhMin, prevMin)
|
||
: prevMin;
|
||
let max =
|
||
updates.soilPhMax !== undefined
|
||
? parseSoilPh(updates.soilPhMax, prevMax)
|
||
: prevMax;
|
||
if (min > max) [min, max] = [max, min];
|
||
$set.soilPhMin = min;
|
||
$set.soilPhMax = max;
|
||
}
|
||
if (updates.waterNeeds !== undefined) {
|
||
$set.waterNeeds = normalizeWaterNeeds(updates.waterNeeds);
|
||
}
|
||
if (updates.images !== undefined) {
|
||
$set.images = await normalizeImagesForUpdate(updates.images);
|
||
}
|
||
|
||
const populateImages = {
|
||
path: "images",
|
||
select: "blurHash contentType",
|
||
};
|
||
|
||
if (Object.keys($set).length === 0) {
|
||
const existing = await Patch.findOne({
|
||
deviceId: device._id,
|
||
patchId: id,
|
||
})
|
||
.populate(populateImages)
|
||
.lean();
|
||
if (!existing) return null;
|
||
try {
|
||
await deleteOrphanImages();
|
||
} catch (e) {
|
||
logger.error("deleteOrphanImages", e);
|
||
}
|
||
return formatClientPatches([existing])[0];
|
||
}
|
||
|
||
const updated = await Patch.findOneAndUpdate(
|
||
{ deviceId: device._id, patchId: id },
|
||
{ $set },
|
||
{ new: true },
|
||
)
|
||
.populate(populateImages)
|
||
.lean();
|
||
|
||
if (!updated) return null;
|
||
try {
|
||
await deleteOrphanImages();
|
||
} catch (e) {
|
||
logger.error("deleteOrphanImages", e);
|
||
}
|
||
const { recalculateGrowingStateForLogicalPatch } = await import(
|
||
"./patchGrowingState.js",
|
||
);
|
||
const { patch: withGrowing } = await recalculateGrowingStateForLogicalPatch(
|
||
device._id,
|
||
id,
|
||
);
|
||
return withGrowing ?? formatClientPatches([updated])[0];
|
||
}
|