/** * Unified API client for Additional Information endpoints * Provides consistent error handling, logging, and CRUD operations */ window.AdditionalInformationApi = { // Configuration _debug: false, /** * Enable or disable debug logging * @param {boolean} enabled - Whether to enable debug mode */ setDebug(enabled) { this._debug = enabled; }, /** * Internal logging helper * @private */ _log(action, data) { if (this._debug) { console.log(`[AdditionalInformationApi] ${action}:`, data); } }, //FIXME: this has the side effect of users not being able to explicitly set an empty string for a field. /** * Remove empty strings from payload recursively * @param {any} value * @returns {any} */ sanitizePayload(value) { if (Array.isArray(value)) { return value .map((item) => this.sanitizePayload(item)) .filter((item) => item !== ""); } if (value && typeof value === "object") { const cleaned = {}; for (const [key, item] of Object.entries(value)) { if (item === "") continue; cleaned[key] = this.sanitizePayload(item); } return cleaned; } return value; }, /** * Get CSRF token from meta tag * @returns {string} CSRF token */ getCsrfToken() { return document.querySelector("[name=csrf-token]")?.content || ""; }, /** * Build headers for API requests * @private */ _buildHeaders(includeContentType = true) { const headers = { "X-CSRFToken": this.getCsrfToken(), }; if (includeContentType) { headers["Content-Type"] = "application/json"; } return headers; }, /** * Handle API response with consistent error handling * @private */ async _handleResponse(response, action) { if (!response.ok) { let errorData; try { errorData = await response.json(); } catch { errorData = { error: response.statusText }; } // Try to parse the error if it's a JSON string let parsedError = errorData; const errorStr = errorData.detail || errorData.error; if (typeof errorStr === "string" && errorStr.startsWith("{")) { try { parsedError = JSON.parse(errorStr); } catch { // Not JSON, use as-is } } // If it's a structured validation error, throw with field errors if (parsedError.type === "validation_error" && parsedError.field_errors) { this._log(`${action} VALIDATION ERROR`, parsedError); const error = new Error(parsedError.message || "Validation failed"); error.fieldErrors = parsedError.field_errors; error.isValidationError = true; throw error; } // General error const errorMsg = parsedError.message || parsedError.error || parsedError.detail || `${action} failed: ${response.statusText}`; this._log(`${action} ERROR`, { status: response.status, error: errorMsg, }); throw new Error(errorMsg); } const data = await response.json(); this._log(`${action} SUCCESS`, data); return data; }, /** * Load all available schemas * @returns {Promise} Object with schema definitions */ async loadSchemas() { this._log("loadSchemas", "Starting..."); const response = await fetch("/api/v1/information/schema/"); return this._handleResponse(response, "loadSchemas"); }, /** * Load additional information items for a scenario * @param {string} scenarioUuid - UUID of the scenario * @returns {Promise} Array of additional information items */ async loadItems(scenarioUuid) { this._log("loadItems", { scenarioUuid }); const response = await fetch( `/api/v1/scenario/${scenarioUuid}/information/`, ); return this._handleResponse(response, "loadItems"); }, /** * Load both schemas and items in parallel * @param {string} scenarioUuid - UUID of the scenario * @returns {Promise<{schemas: Object, items: Array}>} */ async loadSchemasAndItems(scenarioUuid) { this._log("loadSchemasAndItems", { scenarioUuid }); const [schemas, items] = await Promise.all([ this.loadSchemas(), this.loadItems(scenarioUuid), ]); return { schemas, items }; }, /** * Create new additional information for a scenario * @param {string} scenarioUuid - UUID of the scenario * @param {string} modelName - Name/type of the additional information model * @param {Object} data - Data for the new item * @returns {Promise<{status: string, uuid: string}>} */ async createItem(scenarioUuid, modelName, data) { const sanitizedData = this.sanitizePayload(data); this._log("createItem", { scenarioUuid, modelName, data: sanitizedData }); // Normalize model name to lowercase const normalizedName = modelName.toLowerCase(); const response = await fetch( `/api/v1/scenario/${scenarioUuid}/information/${normalizedName}/`, { method: "POST", headers: this._buildHeaders(), body: JSON.stringify(sanitizedData), }, ); return this._handleResponse(response, "createItem"); }, /** * Delete additional information from a scenario * @param {string} scenarioUuid - UUID of the scenario * @param {string} itemUuid - UUID of the item to delete * @returns {Promise<{status: string}>} */ async deleteItem(scenarioUuid, itemUuid) { this._log("deleteItem", { scenarioUuid, itemUuid }); const response = await fetch( `/api/v1/scenario/${scenarioUuid}/information/item/${itemUuid}/`, { method: "DELETE", headers: this._buildHeaders(false), }, ); return this._handleResponse(response, "deleteItem"); }, /** * Update existing additional information * Tries PATCH first, falls back to delete+recreate if not supported * @param {string} scenarioUuid - UUID of the scenario * @param {Object} item - Item object with uuid, type, and data properties * @returns {Promise<{status: string, uuid: string}>} */ async updateItem(scenarioUuid, item) { const sanitizedData = this.sanitizePayload(item.data); this._log("updateItem", { scenarioUuid, item: { ...item, data: sanitizedData }, }); const { uuid, type } = item; // Try PATCH first (preferred method - preserves UUID) const response = await fetch( `/api/v1/scenario/${scenarioUuid}/information/item/${uuid}/`, { method: "PATCH", headers: this._buildHeaders(), body: JSON.stringify(sanitizedData), }, ); if (response.status === 405) { // PATCH not supported, fall back to delete+recreate this._log( "updateItem", "PATCH not supported, falling back to delete+recreate", ); await this.deleteItem(scenarioUuid, uuid); return await this.createItem(scenarioUuid, type, sanitizedData); } return this._handleResponse(response, "updateItem"); }, /** * Update multiple items sequentially to avoid race conditions * @param {string} scenarioUuid - UUID of the scenario * @param {Array} items - Array of items to update * @returns {Promise} Array of results with success status */ async updateItems(scenarioUuid, items) { this._log("updateItems", { scenarioUuid, itemCount: items.length }); const results = []; for (const item of items) { try { const result = await this.updateItem(scenarioUuid, item); results.push({ success: true, oldUuid: item.uuid, newUuid: result.uuid, }); } catch (error) { results.push({ success: false, oldUuid: item.uuid, error: error.message, fieldErrors: error.fieldErrors, isValidationError: error.isValidationError, }); } } const failed = results.filter((r) => !r.success); if (failed.length > 0) { // If all failures are validation errors, return all validation errors for display const validationErrors = failed.filter((f) => f.isValidationError); if (validationErrors.length === failed.length) { // All failures are validation errors - return all field errors by item UUID const allFieldErrors = {}; validationErrors.forEach((ve) => { allFieldErrors[ve.oldUuid] = ve.fieldErrors || {}; }); const error = new Error( `${failed.length} item(s) have validation errors. Please correct them.`, ); error.fieldErrors = allFieldErrors; // Map of UUID -> field errors error.isValidationError = true; error.isMultipleErrors = true; // Flag indicating multiple items have errors throw error; } // Multiple failures or mixed errors - show count throw new Error( `Failed to update ${failed.length} item(s). Please check the form for errors.`, ); } return results; }, /** * Create a new scenario with optional additional information * @param {string} packageUuid - UUID of the package * @param {Object} payload - Scenario data matching ScenarioCreateSchema * @param {string} payload.name - Scenario name (required) * @param {string} payload.description - Scenario description (optional, default: "") * @param {string} payload.scenario_date - Scenario date (optional, default: "No date") * @param {string} payload.scenario_type - Scenario type (optional, default: "Not specified") * @param {Array} payload.additional_information - Array of additional information (optional, default: []) * @returns {Promise<{uuid, url, name, description, review_status, package}>} */ async createScenario(packageUuid, payload) { this._log("createScenario", { packageUuid, payload }); const response = await fetch(`/api/v1/package/${packageUuid}/scenario/`, { method: "POST", headers: this._buildHeaders(), body: JSON.stringify(payload), }); return this._handleResponse(response, "createScenario"); }, /** * Load all available group names * @returns {Promise<{groups: string[]}>} */ async loadGroups() { this._log("loadGroups", "Starting..."); const response = await fetch("/api/v1/information/groups/"); return this._handleResponse(response, "loadGroups"); }, /** * Load model definitions for a specific group * @param {string} groupName - One of 'soil', 'sludge', 'sediment' * @returns {Promise} Object with subcategories as keys and arrays of model info */ async loadGroupModels(groupName) { this._log("loadGroupModels", { groupName }); const response = await fetch(`/api/v1/information/groups/${groupName}/`); return this._handleResponse(response, `loadGroupModels-${groupName}`); }, /** * Load model information for multiple groups in parallel * @param {Array} groupNames - Defaults to ['soil', 'sludge', 'sediment'] * @returns {Promise} Object with group names as keys */ async loadGroupsWithModels(groupNames = ["soil", "sludge", "sediment"]) { this._log("loadGroupsWithModels", { groupNames }); const results = {}; const promises = groupNames.map(async (groupName) => { try { results[groupName] = await this.loadGroupModels(groupName); } catch (err) { this._log(`loadGroupsWithModels-${groupName} ERROR`, err); results[groupName] = {}; } }); await Promise.all(promises); return results; }, /** * Helper to organize schemas by group based on group model information * @param {Object} schemas - Full schema map from loadSchemas() * @param {Object} groupModelsData - Group models data from loadGroupsWithModels() * @returns {Object} Object with group names as keys and filtered schemas as values */ organizeSchemasByGroup(schemas, groupModelsData) { this._log("organizeSchemasByGroup", { schemaCount: Object.keys(schemas).length, groupCount: Object.keys(groupModelsData).length, }); const organized = {}; for (const groupName in groupModelsData) { organized[groupName] = {}; const groupData = groupModelsData[groupName]; // Iterate through subcategories in the group for (const subcategory in groupData) { for (const model of groupData[subcategory]) { // Look up schema by lowercase model name if (schemas[model.name]) { organized[groupName][model.name] = schemas[model.name]; } } } } return organized; }, /** * Convenience method that loads schemas and organizes them by group in one call * @param {Array} groupNames - Defaults to ['soil', 'sludge', 'sediment'] * @returns {Promise<{schemas, groupSchemas, groupModels}>} */ async loadSchemasWithGroups(groupNames = ["soil", "sludge", "sediment"]) { this._log("loadSchemasWithGroups", { groupNames }); // Load schemas and all groups in parallel const [schemas, groupModels] = await Promise.all([ this.loadSchemas(), this.loadGroupsWithModels(groupNames), ]); // Organize schemas by group const groupSchemas = this.organizeSchemasByGroup(schemas, groupModels); return { schemas, groupSchemas, groupModels }; }, };