diff --git a/src/main/java/com/velox/sloan/cmo/workflows/CreateNewLabprocess/AirtableCardCreator.java b/src/main/java/com/velox/sloan/cmo/workflows/CreateNewLabprocess/AirtableCardCreator.java new file mode 100644 index 00000000..93148d3e --- /dev/null +++ b/src/main/java/com/velox/sloan/cmo/workflows/CreateNewLabprocess/AirtableCardCreator.java @@ -0,0 +1,312 @@ +package com.velox.sloan.cmo.workflows.CreateNewLabprocess; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.velox.api.datarecord.DataRecord; +import com.velox.api.plugin.PluginResult; +import com.velox.api.util.ServerException; +import com.velox.api.workflow.ActiveTask; +import com.velox.sapioutils.server.plugin.DefaultGenericPlugin; +import com.velox.sapioutils.shared.enums.PluginOrder; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.apache.http.HttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.util.EntityUtils; + +import java.io.*; +import java.rmi.RemoteException; +import java.text.SimpleDateFormat; +import java.util.*; + +/** + * Plugin that fires on task entry of the first task in a new downstream lab process. + * Reads Request metadata from LIMS and creates an Airtable card via the Airtable REST API. + * + * In Velox "Create New Lab Process for Samples" workflows the task has Samples attached + * (not a Request record directly), so the plugin reads fields off the Sample records and + * groups them by RequestId. If a Request record happens to be attached instead (other + * downstream workflows), that path is also handled. + * + * Task option required on the LIMS task: CREATE AIRTABLE CARD + */ +public class AirtableCardCreator extends DefaultGenericPlugin { + + private static final String AIRTABLE_ENV = "/srv/www/sapio/lims/lims-scripts/airtable/.env"; + private static final long DUE_DATE_OFFSET_MS = 28L * 24 * 60 * 60 * 1000; // 4 weeks + + public AirtableCardCreator() { + setTaskEntry(true); + setOrder(PluginOrder.LAST.getOrder()); + } + + @Override + public boolean shouldRun() throws RemoteException, ServerException { + return activeTask.getTask().getTaskOptions().containsKey("CREATE AIRTABLE CARD") + && activeTask.getStatus() != ActiveTask.COMPLETE; + } + + @Override + public PluginResult run() throws ServerException, RemoteException { + try { + // Primary path: "Create New Lab Process" tasks attach Samples, not Requests. + // Group samples by RequestId so we create exactly one card per downstream request. + List samples = activeTask.getAttachedDataRecords("Sample", user); + if (!samples.isEmpty()) { + processFromSamples(samples); + return new PluginResult(true); + } + + // Fallback path: some downstream workflows attach the Request record directly. + List requests = activeTask.getAttachedDataRecords("Request", user); + if (!requests.isEmpty()) { + processFromRequests(requests); + return new PluginResult(true); + } + + logInfo("AirtableCardCreator: no Sample or Request records attached — skipping."); + } catch (Exception e) { + // Non-fatal — log and let the task proceed + logError("AirtableCardCreator unexpected error: " + ExceptionUtils.getStackTrace(e)); + } + return new PluginResult(true); + } + + /** + * Groups attached samples by RequestId, then fires one Airtable card per unique request. + * Fields (recipe, materials, preservation) are read from the first sample in each group. + */ + private void processFromSamples(List samples) { + // Collect per-requestId: (recipe, materials, preservation, count) + Map byRequestId = new LinkedHashMap<>(); + + for (DataRecord sample : samples) { + String requestId = getStringField(sample, "RequestId"); + if (StringUtils.isBlank(requestId)) continue; + + if (!byRequestId.containsKey(requestId)) { + String recipe = getStringField(sample, "Recipe"); + String materials = getStringField(sample, "ExemplarSampleType"); // material type on sample + String preservation = getStringField(sample, "Preservation"); + byRequestId.put(requestId, new RequestInfo(recipe, materials, preservation, 0)); + } + byRequestId.get(requestId).count++; + } + + for (Map.Entry entry : byRequestId.entrySet()) { + String requestId = entry.getKey(); + RequestInfo info = entry.getValue(); + String error = createAirtableCard(requestId, info.recipe, info.materials, + info.preservation, info.count); + if (error == null) { + logInfo("AirtableCardCreator: card created for " + requestId + + " (" + info.count + " samples)"); + } else { + logError("AirtableCardCreator: card creation FAILED for " + requestId + ": " + error); + notifyUserOfFailure(requestId, error); + } + } + } + + /** + * Fallback when Request records are directly attached (other workflow configurations). + */ + private void processFromRequests(List requests) { + for (DataRecord request : requests) { + String requestId = getStringField(request, "RequestId"); + if (StringUtils.isBlank(requestId)) continue; + + String recipe = getStringField(request, "Recipe"); + String materials = getStringField(request, "RequestType"); + String preservation = getSamplePreservation(request); + int sampleCount = 0; + try { + sampleCount = request.getChildrenOfType("Sample", user).length; + } catch (Exception e) { + logError("AirtableCardCreator: could not count samples: " + e.getMessage()); + } + + String error = createAirtableCard(requestId, recipe, materials, preservation, sampleCount); + if (error == null) { + logInfo("AirtableCardCreator: card created for " + requestId); + } else { + logError("AirtableCardCreator: card creation FAILED for " + requestId + ": " + error); + notifyUserOfFailure(requestId, error); + } + } + } + + private void notifyUserOfFailure(String requestId, String error) { + try { + clientCallback.displayError("Airtable card was not created for request " + + requestId + ": " + error); + } catch (RemoteException | ServerException e) { + logError("AirtableCardCreator: could not display error popup: " + e.getMessage()); + } + } + + private static class RequestInfo { + String recipe, materials, preservation; + int count; + RequestInfo(String recipe, String materials, String preservation, int count) { + this.recipe = recipe; this.materials = materials; + this.preservation = preservation; this.count = count; + } + } + + // ── Airtable ────────────────────────────────────────────────────────────── + + /** Returns null on success, or a human-readable failure reason for the client popup. */ + private String createAirtableCard(String requestId, String recipe, String materials, + String preservation, int sampleCount) { + try { + Map cfg = readEnvFile(AIRTABLE_ENV); + if (cfg == null) { + return "Airtable config file not found or unreadable on the LIMS server."; + } + + String token = cfg.get("AIRTABLE_API_TOKEN"); + String baseId = cfg.get("AIRTABLE_BASE_ID"); + String tableId = cfg.get("AIRTABLE_TABLE_ID"); + if (StringUtils.isAnyBlank(token, baseId, tableId)) { + logError("AirtableCardCreator: missing credentials in .env"); + return "Airtable credentials are missing or incomplete on the LIMS server."; + } + + String url = "https://api.airtable.com/v0/" + baseId + "/" + tableId; + Map fields = buildFields(requestId, recipe, materials, preservation, sampleCount); + String jsonBody = toAirtableJson(fields); + logInfo("AirtableCardCreator JSON: " + jsonBody); + + int status = post(url, token, jsonBody); + if (status == 200 || status == 201) return null; + + if (status != 422) { + logError("AirtableCardCreator: unexpected HTTP status " + status); + return "Airtable rejected the request (HTTP " + status + ")."; + } + + // 422 with invalid select options → retry without multi-select fields + logError("AirtableCardCreator: 422 — retrying without Application/Material/Preservation"); + fields.remove("Application"); + fields.remove("Material"); + fields.remove("Preservation"); + status = post(url, token, toAirtableJson(fields)); + if (status == 200 || status == 201) return null; + + if (status != 422) { + logError("AirtableCardCreator: unexpected HTTP status on retry " + status); + return "Airtable rejected the request (HTTP " + status + ")."; + } + + // Still 422 for some other reason → fall back to a bare-minimum card with just the project number + logError("AirtableCardCreator: 422 again — retrying with Task Name only"); + Map minimalFields = new LinkedHashMap<>(); + minimalFields.put("Task Name", fields.get("Task Name")); + status = post(url, token, toAirtableJson(minimalFields)); + if (status == 200 || status == 201) return null; + + logError("AirtableCardCreator: unexpected HTTP status on minimal retry " + status); + return "Airtable rejected the request even with minimal fields (HTTP " + status + ")."; + + } catch (Exception e) { + logError("AirtableCardCreator failed: " + e.getMessage()); + return "Could not reach Airtable: " + e.getMessage(); + } + } + + private Map buildFields(String requestId, String recipe, String materials, + String preservation, int sampleCount) { + Map fields = new LinkedHashMap<>(); + + fields.put("Task Name", requestId + " (" + sampleCount + ")"); + fields.put("iLab Comment", ""); + + if (!isBlankOrNull(recipe)) fields.put("Application", Collections.singletonList(recipe)); + if (!isBlankOrNull(materials)) fields.put("Material", Collections.singletonList(materials)); + if (!isBlankOrNull(preservation)) fields.put("Preservation", Collections.singletonList(preservation)); + + String dueDate = new SimpleDateFormat("yyyy-MM-dd") + .format(new Date(System.currentTimeMillis() + DUE_DATE_OFFSET_MS)); + fields.put("Due Date", dueDate); + fields.put("Board Column", "Sample Receiving"); + + return fields; + } + + private String toAirtableJson(Map fields) throws Exception { + Map record = new LinkedHashMap<>(); + record.put("fields", fields); + Map body = new LinkedHashMap<>(); + body.put("records", Collections.singletonList(record)); + return new ObjectMapper().writeValueAsString(body); + } + + /** Returns HTTP status code. */ + private int post(String url, String token, String jsonBody) throws IOException { + try (CloseableHttpClient client = HttpClients.createDefault()) { + HttpPost request = new HttpPost(url); + request.setHeader("Authorization", "Bearer " + token); + request.setHeader("Content-Type", "application/json"); + request.setEntity(new StringEntity(jsonBody, "UTF-8")); + + HttpResponse response = client.execute(request); + int status = response.getStatusLine().getStatusCode(); + String responseBody = EntityUtils.toString(response.getEntity()); + logInfo("AirtableCardCreator response [" + status + "]: " + responseBody); + return status; + } + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private Map readEnvFile(String path) { + Map config = new HashMap<>(); + try (BufferedReader reader = new BufferedReader(new FileReader(path))) { + String line; + while ((line = reader.readLine()) != null) { + line = line.trim(); + if (line.isEmpty() || line.startsWith("#")) continue; + String[] parts = line.split("=", 2); + if (parts.length == 2) { + config.put(parts[0].trim(), parts[1].trim()); + } + } + } catch (FileNotFoundException e) { + logError("AirtableCardCreator: .env not found at " + path); + return null; + } catch (IOException e) { + logError("AirtableCardCreator: error reading .env: " + e.getMessage()); + return null; + } + return config; + } + + private String getSamplePreservation(DataRecord request) { + try { + DataRecord[] samples = request.getChildrenOfType("Sample", user); + if (samples.length > 0) { + return samples[0].getStringVal("Preservation", user); + } + } catch (Exception e) { + logError("AirtableCardCreator: could not read Preservation: " + e.getMessage()); + } + return ""; + } + + private String getStringField(DataRecord record, String field) { + try { + Object val = record.getValue(field, user); + return val != null ? val.toString() : ""; + } catch (Exception e) { + logError("AirtableCardCreator: could not read field " + field + ": " + e.getMessage()); + return ""; + } + } + + private boolean isBlankOrNull(String s) { + return s == null || s.trim().isEmpty() || s.equalsIgnoreCase("NULL"); + } +}