Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ public class CollectionNames {
public static final String PUBLISHED_SCREENER_COLLECTION = "publishedScreener";
public static final String WORKING_CUSTOM_CHECK_COLLECTION = "workingCustomCheck";
public static final String PUBLISHED_CUSTOM_CHECK_COLLECTION = "publishedCustomCheck";
public static final String CUSTOM_CHECK_ORIGIN_COLLECTION = "customCheckOrigin";
public static final String CUSTOM_CHECK_NAME_COLLECTION = "customCheckName";
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import org.codeforphilly.bdt.builder.persistence.DuplicateScreenerNameException;
import java.util.Optional;
import org.codeforphilly.bdt.builder.model.dto.screener.CreateScreenerRequest;
import org.codeforphilly.bdt.builder.model.dto.screener.EditScreenerRequest;
Expand Down Expand Up @@ -109,6 +110,10 @@ public Response postScreener(
String screenerId = screenerRepository.saveNewWorkingScreener(newScreener);
newScreener.setId(screenerId);
return Response.ok(newScreener, MediaType.APPLICATION_JSON).build();
} catch (DuplicateScreenerNameException duplicate) {
return Response.status(409).entity(Map.of("error", duplicate.getMessage())).build();
} catch (jakarta.ws.rs.BadRequestException invalid) {
return Response.status(400).entity(Map.of("error", invalid.getMessage())).build();
} catch (Exception e) {
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", "Could not save Screener"))
Expand Down Expand Up @@ -139,13 +144,22 @@ public Response updateScreener(
Log.info(request.toString());

// Update Screener fields from request
// Only a changed name is checked for uniqueness, so screeners that already share a name stay editable.
boolean renamed = request.screenerName() != null
&& !Screener.normalizeName(request.screenerName())
.equals(Screener.normalizeName(screener.getScreenerName()));
if (request.screenerName() != null) {
screener.setScreenerName(request.screenerName());
screener.setScreenerName(request.screenerName().strip());
}

try {
screenerRepository.updateWorkingScreener(screener);
if (renamed) screenerRepository.renameWorkingScreener(screener);
else screenerRepository.updateWorkingScreener(screener);
return Response.ok(screener, MediaType.APPLICATION_JSON).build();
} catch (DuplicateScreenerNameException duplicate) {
return Response.status(409).entity(Map.of("error", duplicate.getMessage())).build();
} catch (jakarta.ws.rs.BadRequestException invalid) {
return Response.status(400).entity(Map.of("error", invalid.getMessage())).build();
} catch (Exception e) {
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", "Could not update Screener"))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package org.codeforphilly.bdt.builder.controller;

import io.quarkus.logging.Log;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import org.codeforphilly.bdt.builder.auth.AuthUtils;
import org.codeforphilly.bdt.builder.model.dto.screener.ScreenerTransfer;
import org.codeforphilly.bdt.builder.service.ScreenerTransferService;
import org.codeforphilly.bdt.builder.service.CustomCheckImportConflictException;
import java.util.Map;
import org.codeforphilly.bdt.builder.persistence.DuplicateScreenerNameException;

@Path("/api")
@Produces(MediaType.APPLICATION_JSON)
public class ScreenerTransferResource {
@Inject ScreenerTransferService transferService;

@GET
@Path("/screener/{screenerId}/export")
public Response exportScreener(@Context SecurityIdentity identity, @PathParam("screenerId") String id) {
String owner = AuthUtils.getUserId(identity);
if (owner == null) return Response.status(Response.Status.UNAUTHORIZED).build();
try {
return Response.ok(transferService.exportScreener(owner, id)).build();
} catch (WebApplicationException rejected) {
return rejected.getResponse();
} catch (Exception failure) {
Log.error("Could not export screener", failure);
return Response.serverError().entity(Map.of("error", "Could not export the complete screener.")).build();
}
}

@POST
@Path("/screener/import")
@Consumes(MediaType.APPLICATION_JSON)
public Response importScreener(@Context SecurityIdentity identity, ScreenerTransfer transfer) {
String owner = AuthUtils.getUserId(identity);
if (owner == null) return Response.status(Response.Status.UNAUTHORIZED).build();
try {
return Response.status(Response.Status.CREATED).entity(transferService.importScreener(owner, transfer)).build();
} catch (DuplicateScreenerNameException duplicate) {
return Response.status(409).entity(Map.of("error", duplicate.getMessage())).build();
} catch (CustomCheckImportConflictException conflict) {
return Response.status(409).entity(Map.of("error", conflict.getMessage())).build();
} catch (BadRequestException rejected) {
return Response.status(Response.Status.BAD_REQUEST).entity(Map.of("error", rejected.getMessage())).build();
} catch (WebApplicationException rejected) {
return rejected.getResponse();
} catch (Exception failure) {
Log.error("Could not import screener", failure);
return Response.serverError().entity(Map.of("error", "Could not import the screener. Please try again.")).build();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ public class CheckConfig {
public CheckConfig() {
}

/* The check this configuration evaluates. Older configurations kept it in checkId. */
public String resolveSourceCheckId() {
return sourceCheckId != null && !sourceCheckId.isBlank() ? sourceCheckId : checkId;
}

public static boolean isLibraryCheckId(String checkId) {
return checkId != null && checkId.startsWith("L");
}

public CheckConfig(
String checkId,
String sourceCheckId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ public class EligibilityCheck {
private Boolean isArchived;
// working id of the example check this was imported from, so re-imports survive renames
private String exampleSourceId;
// Original family identity, preserved through screener exports and imports across accounts.
private String originCheckId;
// milliseconds since the epoch at which a published version was saved; null for working checks
private Long datePublished;

Expand Down Expand Up @@ -138,6 +140,10 @@ public void setExampleSourceId(String exampleSourceId) {
this.exampleSourceId = exampleSourceId;
}

public String getOriginCheckId() { return originCheckId; }

public void setOriginCheckId(String originCheckId) { this.originCheckId = originCheckId; }

public Long getDatePublished() {
return datePublished;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ public static Screener create(String ownerId, String screenerName, String descri
return s;
}

/* Screener names are unique per owner, ignoring case and surrounding spaces. */
public static String normalizeName(String name) {
return name == null ? "" : name.strip().toLowerCase(java.util.Locale.ROOT);
}

public Map<String, Object> getFormSchema() {
return formSchema;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package org.codeforphilly.bdt.builder.model.dto.screener;

import com.fasterxml.jackson.databind.JsonNode;
import org.codeforphilly.bdt.builder.model.domain.Benefit;
import org.codeforphilly.bdt.builder.model.domain.EligibilityCheck;
import java.util.List;

/** One portable draft screener, with DMN models embedded in its custom checks. */
public record ScreenerTransfer(String format, int formatVersion, String screenerName,
JsonNode formSchema, List<Benefit> benefits, List<EligibilityCheck> customChecks) {
public static final String FORMAT = "bdt-screener";
public static final int VERSION = 1;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package org.codeforphilly.bdt.builder.persistence;

public class DuplicateScreenerNameException extends RuntimeException {
public DuplicateScreenerNameException() {
super("You already have a screener with this name. Choose a different name.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@

public interface EligibilityCheckRepository {

/* Strict metadata read, including archived drafts and all published versions. */
List<EligibilityCheck> getCustomChecksForImport(String userId) throws Exception;

/* Prevents concurrent imports from creating separate families for the same origin. */
void reserveImportIdentity(String ownerId, String originId, String workingId) throws Exception;

void releaseImportIdentity(String ownerId, String originId, String workingId) throws Exception;

List<EligibilityCheck> getWorkingCustomChecks(String userId);

List<EligibilityCheck> getAllWorkingCustomChecks(String userId);
Expand All @@ -27,6 +35,8 @@ public interface EligibilityCheckRepository {

Optional<EligibilityCheck> getPublishedCustomCheck(String userId, String checkId);

Optional<EligibilityCheck> getPublishedCustomCheck(String userId, String checkId, boolean includeArchived);

String getWorkingId(EligibilityCheck check);

String newWorkingId();
Expand All @@ -46,6 +56,8 @@ public interface EligibilityCheckRepository {

void deleteWorkingCustomCheck(String checkId) throws Exception;

void deletePublishedCustomCheck(String checkId) throws Exception;

void updatePublishedCustomCheck(EligibilityCheck check) throws Exception;

/* The published document id a check would get at the given version. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.google.cloud.firestore.*;
import com.google.firebase.cloud.FirestoreClient;
import io.quarkus.logging.Log;
import org.codeforphilly.bdt.builder.model.domain.Screener;

import java.util.*;
import java.util.concurrent.ExecutionException;
Expand Down Expand Up @@ -238,6 +239,42 @@ public static void createDocumentUnlessHeld(String collection, String documentId
}
}

/** Serializes name changes per owner and includes existing screeners without a name index. */
public static void saveScreenerWithUniqueName(String collection, String id, Map<String, Object> data,
boolean create) throws Exception {
String owner = (String) data.get("ownerId");
String name = (String) data.get("screenerName");
DocumentReference ownerLock = db.collection("screenerNameLocks").document(owner);
DocumentReference document = db.collection(collection).document(id);
try {
db.runTransaction(transaction -> {
transaction.get(ownerLock).get();
QuerySnapshot screeners = transaction.get(db.collection(collection)
.whereEqualTo("ownerId", owner)).get();
for (QueryDocumentSnapshot existing : screeners.getDocuments()) {
if (!id.equals(existing.getId()) && Screener.normalizeName(name)
.equals(Screener.normalizeName(existing.getString("screenerName")))) {
throw new DuplicateScreenerNameException();
}
}
if (create) transaction.create(document, data);
else transaction.set(document, data, SetOptions.merge());
transaction.set(ownerLock, Map.of("lastChange", UUID.randomUUID().toString()));
return null;
}).get();
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
throw failure;
} catch (ExecutionException failure) {
Throwable cause = failure;
while (cause != null) {
if (cause instanceof DuplicateScreenerNameException duplicate) throw duplicate;
cause = cause.getCause();
}
throw failure;
}
}

public static void updateDocument(String collectionName, Map<String, Object> data, String docId) throws Exception {
try {
WriteResult result = db.collection(collectionName)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@

@ApplicationScoped
public class GoogleStorageService implements StorageService {
@Override
public void deleteFile(String filePath) {
storage.delete(BlobId.of(bucketName, filePath));
}

@Inject
Storage storage;

Expand Down Expand Up @@ -148,27 +153,24 @@ public String getCheckDmnModelPath(String checkId){
@Override
public Map<String, Object> getFormSchemaFromStorage(String filePath) {
try {
BlobId blobId = BlobId.of(bucketName, filePath);
Blob blob = storage.get(blobId);

if (blob == null || !blob.exists()) {
return null;
}

byte[] content = blob.getContent();

ObjectMapper mapper = new ObjectMapper();
Map<String, Object> formSchema = mapper.readValue(new ByteArrayInputStream(content), new TypeReference<Map<String, Object>>() {
});

return formSchema;

return readFormSchema(filePath).orElse(null);
} catch (Exception e){
Log.error("Error fetching form model from firebase storage: ", e);
return null;
}
}

@Override
public Optional<Map<String, Object>> readFormSchema(String filePath) throws Exception {
Blob blob = storage.get(BlobId.of(bucketName, filePath));
if (blob == null || !blob.exists()) {
return Optional.empty();
}
ObjectMapper mapper = new ObjectMapper();
return Optional.of(mapper.readValue(new ByteArrayInputStream(blob.getContent()),
new TypeReference<Map<String, Object>>() {}));
}

@Override
public void updatePublishedFormSchemaArtifact(String screenerId, String publishedScreenerId) throws Exception {
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ public interface ScreenerRepository {

public void updateWorkingScreener(Screener screener) throws Exception;

/** Saves a new name, rejecting one already used by another of the owner's screeners. */
public void renameWorkingScreener(Screener screener) throws Exception;

public void addBenefitDetailToWorkingScreener(String screenerId, BenefitDetail benefitDetail) throws Exception;

public void deleteWorkingScreener(String screenerId) throws Exception;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import java.util.Optional;

public interface StorageService {
void deleteFile(String filePath) throws Exception;

void writeStringToStorage(String filePath, String content, String contentType) throws Exception;

void writeBytesToStorage(String filePath, byte[] content, String contentType);
Expand All @@ -27,5 +29,8 @@ public interface StorageService {

Map<String, Object> getFormSchemaFromStorage(String filePath);

/* Empty when no form has been saved. Unlike getFormSchemaFromStorage, read failures are thrown. */
Optional<Map<String, Object>> readFormSchema(String filePath) throws Exception;

void updatePublishedFormSchemaArtifact(String screenerId, String publishedScreenerId) throws Exception;
}
Loading
Loading