package jnpf.limsService.provider;
|
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
import jnpf.audit.sdk.annotation.AuditLog;
|
import jnpf.exception.DataException;
|
import jnpf.lims.api.model.ElnSourceReference;
|
import lombok.Data;
|
import lombok.Value;
|
|
import java.time.Instant;
|
import java.util.LinkedHashMap;
|
import java.util.List;
|
import java.util.Map;
|
import java.util.UUID;
|
import java.util.function.Supplier;
|
import java.util.stream.Collectors;
|
|
/** Configuration writes and authorized business-data reads use separate adapters. */
|
public class LimsElnSourceCatalog {
|
private final Store store;
|
private final Supplier<Actor> actors;
|
private final SourceReader reader;
|
private final ObjectMapper json = new ObjectMapper();
|
|
public LimsElnSourceCatalog(Store store, Supplier<Actor> actors) {
|
this(store, actors, (definition, actor, sampleId) -> {
|
throw new DataException("Source preview is unavailable");
|
});
|
}
|
|
public LimsElnSourceCatalog(Store store, Supplier<Actor> actors, SourceReader reader) {
|
this.store = store;
|
this.actors = actors;
|
this.reader = reader;
|
}
|
|
@AuditLog(action = "ELN_SOURCE_DRAFT_CREATE", bizType = "ELN_SOURCE",
|
bizCodeExpr = "#result?.propertyCode", bizCodeRequired = false,
|
targetTable = "lims_eln_provider_property_version", targetIdExpr = "#result?.id")
|
public Version createDraft(Draft draft) {
|
Actor actor = requireManager();
|
if (draft == null || draft.getPropertyCode() == null
|
|| !draft.getPropertyCode().matches("[a-zA-Z][a-zA-Z0-9_.-]{0,79}")) {
|
throw new DataException("Invalid source property code");
|
}
|
validateMetadata(draft);
|
if (store.list(actor.getTenantId()).stream()
|
.anyMatch(v -> draft.getPropertyCode().equals(v.getPropertyCode()))) {
|
throw new DataException("Source property already exists");
|
}
|
Version version = new Version(UUID.randomUUID().toString(), actor.getTenantId(),
|
draft.getPropertyCode(), 1, "DRAFT", draft.getDisplayName(), draft.getDataType(),
|
encode(draft.getDefinition()), null, null, null, null, 0);
|
store.insert(version);
|
return version;
|
}
|
|
@AuditLog(action = "ELN_SOURCE_PUBLISH", failureAction = "ELN_SOURCE_PUBLISH_FAILED",
|
bizType = "ELN_SOURCE", bizCodeExpr = "#result?.propertyCode", bizCodeRequired = false,
|
targetTable = "lims_eln_provider_property_version", targetIdExpr = "#id")
|
public Version publish(String id, int expectedEditVersion, String sampleId) {
|
Actor actor = requireManager();
|
Version draft = detail(id);
|
if (!"DRAFT".equals(draft.getStatus())) throw new DataException("Published source is immutable");
|
if (draft.getEditVersion() != expectedEditVersion) throw new LimsElnReadonlyResolver.SourceFailure("SOURCE_CONFLICT", "Source version has changed");
|
if (sampleId == null || sampleId.trim().isEmpty()) {
|
throw new DataException("An authorized preview sample is required");
|
}
|
Preview preview = reader.read(decode(draft.getDefinitionJson()), actor, sampleId);
|
if (preview == null || preview.getValues() == null || preview.getValues().size() != 1
|
|| preview.getValues().get(0) == null
|
|| preview.getValues().get(0) instanceof String
|
&& ((String) preview.getValues().get(0)).trim().isEmpty()) {
|
throw new DataException("Source preview must resolve one value");
|
}
|
if (!matchesType(draft.getDataType(), preview.getValues().get(0))) {
|
throw new DataException("Source preview type does not match");
|
}
|
try {
|
JsonNode frozen = preview.getFrozenDefinitionJson() == null
|
? null : json.readTree(preview.getFrozenDefinitionJson());
|
if (frozen == null || !frozen.isObject() || frozen.isEmpty()) {
|
throw new DataException("A complete resolved definition is required");
|
}
|
} catch (JsonProcessingException e) {
|
throw new DataException("Resolved source definition is invalid");
|
}
|
Version published = new Version(draft.getId(), draft.getTenantId(), draft.getPropertyCode(),
|
draft.getRevision(), "PUBLISHED", draft.getDisplayName(), draft.getDataType(),
|
draft.getDefinitionJson(), preview.getFrozenDefinitionJson(), sampleId,
|
actor.getUserId(), Instant.now().toString(), draft.getEditVersion() + 1);
|
if (!store.replaceDraft(draft, published)) throw new LimsElnReadonlyResolver.SourceFailure("SOURCE_CONFLICT", "Source version has changed");
|
return published;
|
}
|
|
public Preview preview(String id, String sampleId) {
|
Actor actor = requireManager();
|
Version version = detail(id);
|
if (sampleId == null || sampleId.trim().isEmpty()) {
|
throw new DataException("An authorized preview sample is required");
|
}
|
return reader.read(decode(version.getDefinitionJson()), actor, sampleId);
|
}
|
|
public Version detail(String id) {
|
Actor actor = requireManager();
|
Version version = store.find(actor.getTenantId(), id);
|
if (version == null) throw new DataException("Source version not found");
|
return version;
|
}
|
|
@AuditLog(action = "ELN_SOURCE_DRAFT_UPDATE", bizType = "ELN_SOURCE",
|
bizCodeExpr = "#result?.propertyCode", bizCodeRequired = false,
|
targetTable = "lims_eln_provider_property_version", targetIdExpr = "#id")
|
public Version updateDraft(String id, int expectedEditVersion, Draft changes) {
|
Version previous = detail(id);
|
if (!"DRAFT".equals(previous.getStatus())) throw new DataException("Published source is immutable");
|
if (previous.getEditVersion() != expectedEditVersion) throw new LimsElnReadonlyResolver.SourceFailure("SOURCE_CONFLICT", "Source version has changed");
|
if (changes == null || !previous.getPropertyCode().equals(changes.getPropertyCode())) {
|
throw new DataException("Source property code is immutable");
|
}
|
validateMetadata(changes);
|
Version replacement = new Version(previous.getId(), previous.getTenantId(), previous.getPropertyCode(),
|
previous.getRevision(), "DRAFT", changes.getDisplayName(), changes.getDataType(),
|
encode(changes.getDefinition()), null, null, null, null, previous.getEditVersion() + 1);
|
if (!store.replaceDraft(previous, replacement)) throw new LimsElnReadonlyResolver.SourceFailure("SOURCE_CONFLICT", "Source version has changed");
|
return replacement;
|
}
|
|
@AuditLog(action = "ELN_SOURCE_VERSION_CREATE", bizType = "ELN_SOURCE",
|
bizCodeExpr = "#result?.propertyCode", bizCodeRequired = false,
|
targetTable = "lims_eln_provider_property_version", targetIdExpr = "#result?.id")
|
public Version newVersion(String publishedId, Draft changes) {
|
Version previous = detail(publishedId);
|
if (!"PUBLISHED".equals(previous.getStatus())) throw new DataException("Published source required");
|
if (changes == null || !previous.getPropertyCode().equals(changes.getPropertyCode())) {
|
throw new DataException("Source property code is immutable");
|
}
|
validateMetadata(changes);
|
int revision = 0;
|
for (Version version : store.list(previous.getTenantId())) {
|
if (!previous.getPropertyCode().equals(version.getPropertyCode())) continue;
|
if ("DRAFT".equals(version.getStatus())) throw new DataException("Source already has a draft");
|
revision = Math.max(revision, version.getRevision());
|
}
|
Version draft = new Version(UUID.randomUUID().toString(), previous.getTenantId(),
|
previous.getPropertyCode(), revision + 1, "DRAFT", changes.getDisplayName(),
|
changes.getDataType(), encode(changes.getDefinition()), null, null, null, null, 0);
|
store.insert(draft);
|
return draft;
|
}
|
|
public ElnSourceReference reference(String code, int revision) {
|
Version version = published(code, revision);
|
return reference(version);
|
}
|
|
public List<ElnSourceReference> references() {
|
return catalog().stream().map(version -> reference(version, true)).collect(Collectors.toList());
|
}
|
|
private ElnSourceReference reference(Version version) {
|
return reference(version, store.enabled(version.getTenantId(), version.getPropertyCode()));
|
}
|
|
private ElnSourceReference reference(Version version, boolean enabled) {
|
ElnSourceReference result = new ElnSourceReference();
|
result.setPropertyCode(version.getPropertyCode());
|
result.setRevision(version.getRevision());
|
result.setDisplayName(version.getDisplayName());
|
result.setDataType(version.getDataType());
|
result.setEnabled(enabled);
|
return result;
|
}
|
|
public Version published(String code, int revision) {
|
return published(code, revision, requireActor());
|
}
|
|
Version published(String code, int revision, Actor actor) {
|
requireActor(actor);
|
return store.list(actor.getTenantId()).stream()
|
.filter(v -> code.equals(v.getPropertyCode()) && revision == v.getRevision()
|
&& "PUBLISHED".equals(v.getStatus())).findFirst()
|
.orElseThrow(() -> new LimsElnReadonlyResolver.SourceFailure("UNSUPPORTED_PROPERTY", "Published source version not found"));
|
}
|
|
@AuditLog(action = "ELN_SOURCE_SET_ENABLED", bizType = "ELN_SOURCE", bizCodeExpr = "#code",
|
targetTable = "lims_eln_provider_property", targetIdExpr = "#code")
|
public void setEnabled(String code, boolean enabled) {
|
Actor actor = requireManager();
|
if (store.list(actor.getTenantId()).stream().noneMatch(v -> code.equals(v.getPropertyCode()))) {
|
throw new DataException("Source property not found");
|
}
|
store.setEnabled(actor.getTenantId(), code, enabled);
|
}
|
|
public Definition definition(String id) {
|
return decode(detail(id).getDefinitionJson());
|
}
|
|
Definition definition(Version version) {
|
if (version == null) throw new DataException("Source version is required");
|
return decode(version.getDefinitionJson());
|
}
|
|
private Definition decode(String definitionJson) {
|
try {
|
return json.readValue(definitionJson, Definition.class);
|
} catch (JsonProcessingException e) {
|
throw new DataException("Source definition is invalid");
|
}
|
}
|
|
public List<Version> catalog() {
|
Actor actor = requireManager();
|
Map<String, Boolean> enabledStates = store.enabledStates(actor.getTenantId());
|
Map<String, Version> current = new LinkedHashMap<>();
|
for (Version version : store.list(actor.getTenantId())) {
|
if (!"PUBLISHED".equals(version.getStatus())) continue;
|
Version previous = current.get(version.getPropertyCode());
|
if (previous == null || previous.getRevision() < version.getRevision()) {
|
current.put(version.getPropertyCode(), version);
|
}
|
}
|
return current.values().stream()
|
.filter(v -> enabledStates.getOrDefault(v.getPropertyCode(), false))
|
.collect(Collectors.toList());
|
}
|
|
public List<Version> versions() {
|
return store.list(requireManager().getTenantId());
|
}
|
|
public boolean enabled(String code) {
|
return store.enabled(requireManager().getTenantId(), code);
|
}
|
|
Map<String, Boolean> enabledStates() {
|
return store.enabledStates(requireManager().getTenantId());
|
}
|
|
private Actor requireManager() {
|
Actor actor = requireActor();
|
if (!actor.isFieldManager()) throw new LimsElnReadonlyResolver.SourceFailure("SOURCE_FORBIDDEN", "Field management permission required");
|
return actor;
|
}
|
|
private Actor requireActor() {
|
return requireActor(actors.get());
|
}
|
|
static Actor requireActor(Actor actor) {
|
if (actor == null || actor.getUserId() == null
|
|| actor.getUserId().trim().isEmpty()) {
|
throw new LimsElnReadonlyResolver.SourceFailure("SOURCE_FORBIDDEN", "Authenticated source caller required");
|
}
|
if (!"0".equals(actor.getTenantId())) throw new LimsElnReadonlyResolver.SourceFailure("SOURCE_FORBIDDEN", "Invalid tenant context");
|
return actor;
|
}
|
|
private String encode(Definition definition) {
|
if (definition == null) throw new DataException("Source definition is required");
|
try {
|
return json.writeValueAsString(definition);
|
} catch (JsonProcessingException e) {
|
throw new DataException("Source definition is invalid");
|
}
|
}
|
|
private static boolean matchesType(String type, Object value) {
|
if ("TEXT".equals(type)) return value instanceof String;
|
if ("DECIMAL".equals(type)) {
|
return value instanceof java.math.BigDecimal || value instanceof java.math.BigInteger
|
|| value instanceof Byte || value instanceof Short || value instanceof Integer || value instanceof Long;
|
}
|
if ("DATE".equals(type)) return value instanceof java.time.LocalDate || value instanceof java.sql.Date;
|
if ("DATETIME".equals(type)) {
|
return value instanceof java.time.LocalDateTime || value instanceof java.time.OffsetDateTime
|
|| value instanceof Instant || value instanceof java.sql.Timestamp;
|
}
|
return "BOOLEAN".equals(type) && value instanceof Boolean;
|
}
|
|
private static void validateMetadata(Draft draft) {
|
if (draft.getDisplayName() == null || draft.getDisplayName().trim().isEmpty()
|
|| draft.getDisplayName().length() > 200) {
|
throw new DataException("Source business name is required and must not exceed 200 characters");
|
}
|
if (!java.util.Arrays.asList("TEXT", "DECIMAL", "DATE", "DATETIME", "BOOLEAN").contains(draft.getDataType())) {
|
throw new DataException("Unsupported source scalar type");
|
}
|
}
|
|
public interface Store {
|
void insert(Version version);
|
Version find(String tenantId, String id);
|
List<Version> list(String tenantId);
|
boolean replaceDraft(Version expected, Version replacement);
|
boolean enabled(String tenantId, String code);
|
Map<String, Boolean> enabledStates(String tenantId);
|
void setEnabled(String tenantId, String code, boolean enabled);
|
}
|
|
/**
|
* Trusted business-data adapter: validate controlled metadata, authorize the sample and enforce
|
* single-valued relationships. Return the complete executable snapshot, never a client preview token.
|
*/
|
public interface SourceReader {
|
Preview read(Definition definition, Actor actor, String sampleId);
|
}
|
|
@Value
|
public static class Preview {
|
List<?> values;
|
String frozenDefinitionJson;
|
}
|
|
/** Server-derived context, not a request body. */
|
@Value
|
public static class Actor {
|
String tenantId;
|
String userId;
|
boolean fieldManager;
|
}
|
|
@Value
|
public static class Draft {
|
String propertyCode;
|
String displayName;
|
String dataType;
|
Definition definition;
|
}
|
|
@Data
|
@com.fasterxml.jackson.databind.annotation.JsonNaming(com.fasterxml.jackson.databind.PropertyNamingStrategies.LowerCamelCaseStrategy.class)
|
public static class Definition {
|
private String objectId;
|
private String fieldId;
|
private String datasetCode;
|
private Integer datasetRevision;
|
}
|
|
@Value
|
@com.fasterxml.jackson.databind.annotation.JsonNaming(com.fasterxml.jackson.databind.PropertyNamingStrategies.LowerCamelCaseStrategy.class)
|
public static class Version {
|
String id;
|
String tenantId;
|
String propertyCode;
|
int revision;
|
String status;
|
String displayName;
|
String dataType;
|
String definitionJson;
|
@com.fasterxml.jackson.annotation.JsonIgnore
|
String frozenDefinitionJson;
|
String validationSampleId;
|
String publishedBy;
|
String publishedAt;
|
int editVersion;
|
}
|
}
|