From 8d61cd6009719302682a096f888321ff94f115b6 Mon Sep 17 00:00:00 2001 From: joshwanf <17016446+joshwanf@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:59:13 -0400 Subject: [PATCH 1/2] Add Export Example Screener button into main menu, fixed screener import for prod --- .../org/acme/controller/AccountResource.java | 38 +++ .../java/org/acme/functions/AccountHooks.java | 4 +- .../service/ExampleScreenerExportService.java | 292 ++++++++++++++++++ .../service/ExampleScreenerImportService.java | 209 +++++++++---- ...saJ48BFa77NmDeL-testchecks-test-2.0.0.json | 0 .../firestore/system/config.json | 0 ...yzho27saJ48BFa77NmDeL-testchecks-test.json | 0 .../workingScreener/hEStvPeFmEte58GQTC7Y.json | 0 .../1c09392c-913c-4b2b-9870-a1951534c3fb.json | 0 .../fdd4405a-1a00-4650-8005-9595f16e3788.json | 0 .../seed-data}/example-screener/manifest.json | 0 ...7saJ48BFa77NmDeL-testchecks-test-2.0.0.dmn | 0 ...Gyzho27saJ48BFa77NmDeL-testchecks-test.dmn | 0 .../form/working/hEStvPeFmEte58GQTC7Y.json | 0 builder-frontend/src/api/account.ts | 17 + .../src/components/Header/Header.tsx | 51 ++- .../shared/HamburgerMenu/HamburgerMenu.css | 2 +- .../HamburgerMenu/HamburgerMenuWrapper.tsx | 14 +- .../src/components/shared/Modal.tsx | 6 +- 19 files changed, 559 insertions(+), 74 deletions(-) create mode 100644 builder-api/src/main/java/org/acme/service/ExampleScreenerExportService.java rename {seed-data => builder-api/src/main/resources/seed-data}/example-screener/firestore/publishedCustomCheck/P-i7d0OHGyzho27saJ48BFa77NmDeL-testchecks-test-2.0.0.json (100%) rename {seed-data => builder-api/src/main/resources/seed-data}/example-screener/firestore/system/config.json (100%) rename {seed-data => builder-api/src/main/resources/seed-data}/example-screener/firestore/workingCustomCheck/W-i7d0OHGyzho27saJ48BFa77NmDeL-testchecks-test.json (100%) rename {seed-data => builder-api/src/main/resources/seed-data}/example-screener/firestore/workingScreener/hEStvPeFmEte58GQTC7Y.json (100%) rename {seed-data => builder-api/src/main/resources/seed-data}/example-screener/firestore/workingScreener/hEStvPeFmEte58GQTC7Y/customBenefit/1c09392c-913c-4b2b-9870-a1951534c3fb.json (100%) rename {seed-data => builder-api/src/main/resources/seed-data}/example-screener/firestore/workingScreener/hEStvPeFmEte58GQTC7Y/customBenefit/fdd4405a-1a00-4650-8005-9595f16e3788.json (100%) rename {seed-data => builder-api/src/main/resources/seed-data}/example-screener/manifest.json (100%) rename {seed-data => builder-api/src/main/resources/seed-data}/example-screener/storage/check/P-i7d0OHGyzho27saJ48BFa77NmDeL-testchecks-test-2.0.0.dmn (100%) rename {seed-data => builder-api/src/main/resources/seed-data}/example-screener/storage/check/W-i7d0OHGyzho27saJ48BFa77NmDeL-testchecks-test.dmn (100%) rename {seed-data => builder-api/src/main/resources/seed-data}/example-screener/storage/form/working/hEStvPeFmEte58GQTC7Y.json (100%) diff --git a/builder-api/src/main/java/org/acme/controller/AccountResource.java b/builder-api/src/main/java/org/acme/controller/AccountResource.java index 5b522a6d..e66c3843 100644 --- a/builder-api/src/main/java/org/acme/controller/AccountResource.java +++ b/builder-api/src/main/java/org/acme/controller/AccountResource.java @@ -1,5 +1,7 @@ package org.acme.controller; +import io.quarkus.logging.Log; +import io.quarkus.runtime.LaunchMode; import io.quarkus.security.identity.SecurityIdentity; import jakarta.inject.Inject; import jakarta.ws.rs.*; @@ -16,6 +18,7 @@ import org.acme.functions.AccountHooks; import org.acme.model.dto.Auth.AccountHookRequest; import org.acme.model.dto.Auth.AccountHookResponse; +import org.acme.service.ExampleScreenerExportService; @Path("/api") public class AccountResource { @@ -23,6 +26,9 @@ public class AccountResource { @Inject AccountHooks accountHooks; + @Inject + ExampleScreenerExportService exampleScreenerExportService; + @POST @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) @@ -61,4 +67,36 @@ public Response accountHooks(@Context SecurityIdentity identity, return Response.ok(responseBody).build(); } + + @POST + @Produces(MediaType.APPLICATION_JSON) + @Path("/account/export-example-screener") + public Response exportExampleScreener(@Context SecurityIdentity identity) { + String userId = AuthUtils.getUserId(identity); + + if (userId == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .entity(new ApiError(true, "Unauthorized.")).build(); + } + + if (LaunchMode.current() != LaunchMode.DEVELOPMENT) { + return Response.status(Response.Status.NOT_FOUND).build(); + } + + try { + ExampleScreenerExportService.ExportSummary summary = exampleScreenerExportService.exportForUser(userId); + return Response.ok(Map.of( + "success", true, + "outputPath", summary.outputPath(), + "screenerCount", summary.screenerCount(), + "firestoreDocuments", summary.firestoreDocuments(), + "storageFiles", summary.storageFiles() + )).build(); + } catch (Exception e) { + Log.error("Failed to export example screener seed data for user " + userId, e); + return Response.serverError() + .entity(new ApiError(true, "Failed to export example screener seed data.")) + .build(); + } + } } diff --git a/builder-api/src/main/java/org/acme/functions/AccountHooks.java b/builder-api/src/main/java/org/acme/functions/AccountHooks.java index b91b166e..d53501e4 100644 --- a/builder-api/src/main/java/org/acme/functions/AccountHooks.java +++ b/builder-api/src/main/java/org/acme/functions/AccountHooks.java @@ -14,8 +14,8 @@ public class AccountHooks { public Boolean addExampleScreenerToAccount(String userId) { try { Log.info("Running ADD_EXAMPLE_SCREENER hook for user: " + userId); - String screenerId = exampleScreenerImportService.importForUser(userId); - Log.info("Imported example screener " + screenerId + " for user " + userId); + var screenerIds = exampleScreenerImportService.importForUser(userId); + Log.info("Imported example screeners " + screenerIds + " for user " + userId); return true; } catch (Exception e) { Log.error( diff --git a/builder-api/src/main/java/org/acme/service/ExampleScreenerExportService.java b/builder-api/src/main/java/org/acme/service/ExampleScreenerExportService.java new file mode 100644 index 00000000..f40428ad --- /dev/null +++ b/builder-api/src/main/java/org/acme/service/ExampleScreenerExportService.java @@ -0,0 +1,292 @@ +package org.acme.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.cloud.Timestamp; +import com.google.firebase.cloud.FirestoreClient; +import io.quarkus.logging.Log; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.acme.constants.CollectionNames; +import org.acme.constants.FieldNames; +import org.acme.persistence.FirestoreUtils; +import org.acme.persistence.StorageService; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +@ApplicationScoped +public class ExampleScreenerExportService { + private static final Path EXPORT_ROOT = Paths.get("src", "main", "resources", "seed-data", "example-screener"); + private static final String SYSTEM_COLLECTION = "system"; + private static final String SYSTEM_CONFIG_ID = "config"; + + private final StorageService storageService; + private final String bucketName; + private final ObjectMapper objectMapper; + + @Inject + public ExampleScreenerExportService( + StorageService storageService, + @ConfigProperty(name = "GCS_BUCKET_NAME", defaultValue = "demo-bdt-dev.appspot.com") String bucketName + ) { + this.storageService = storageService; + this.bucketName = bucketName; + this.objectMapper = new ObjectMapper(); + } + + public ExportSummary exportForUser(String userId) throws Exception { + resetExportRoot(); + + List> workingScreeners = getDocumentsByOwner(CollectionNames.WORKING_SCREENER_COLLECTION, userId); + List> workingCustomChecks = getDocumentsByOwner(CollectionNames.WORKING_CUSTOM_CHECK_COLLECTION, userId); + List> publishedCustomChecks = getDocumentsByOwner(CollectionNames.PUBLISHED_CUSTOM_CHECK_COLLECTION, userId); + + int firestoreDocuments = 0; + firestoreDocuments += exportScreeners(workingScreeners); + firestoreDocuments += exportChecks(CollectionNames.WORKING_CUSTOM_CHECK_COLLECTION, workingCustomChecks); + firestoreDocuments += exportChecks(CollectionNames.PUBLISHED_CUSTOM_CHECK_COLLECTION, publishedCustomChecks); + firestoreDocuments += exportSystemConfig(); + + int storageFiles = 0; + storageFiles += exportScreenerForms(workingScreeners); + storageFiles += exportCheckDmns(workingCustomChecks); + storageFiles += exportCheckDmns(publishedCustomChecks); + + writeManifest(firestoreDocuments, storageFiles); + + Log.info("Exported Firebase seed data for user " + userId + " to " + EXPORT_ROOT.toAbsolutePath().normalize()); + return new ExportSummary( + EXPORT_ROOT.toAbsolutePath().normalize().toString(), + workingScreeners.size(), + firestoreDocuments, + storageFiles + ); + } + + private List> getDocumentsByOwner(String collectionName, String userId) { + List> documents = new ArrayList<>( + FirestoreUtils.getFirestoreDocsByField(collectionName, FieldNames.OWNER_ID, userId) + ); + documents.sort(Comparator.comparing(document -> requiredString(document, FieldNames.ID, collectionName))); + return documents; + } + + private int exportScreeners(List> workingScreeners) throws IOException { + int firestoreDocuments = 0; + + for (Map screener : workingScreeners) { + String screenerId = requiredString(screener, FieldNames.ID, CollectionNames.WORKING_SCREENER_COLLECTION); + writeJsonFile( + EXPORT_ROOT.resolve("firestore").resolve("workingScreener").resolve(screenerId + ".json"), + firestoreDocumentForExport(screener, screenerId) + ); + firestoreDocuments++; + + firestoreDocuments += exportBenefits(screenerId); + } + + return firestoreDocuments; + } + + private int exportBenefits(String screenerId) throws IOException { + String collectionPath = CollectionNames.WORKING_SCREENER_COLLECTION + "/" + screenerId + "/customBenefit"; + List> benefits = new ArrayList<>(FirestoreUtils.getAllDocsInCollection(collectionPath)); + benefits.sort(Comparator.comparing(benefit -> requiredString(benefit, FieldNames.ID, collectionPath))); + + int exportedBenefits = 0; + for (Map benefit : benefits) { + String benefitId = requiredString(benefit, FieldNames.ID, collectionPath); + writeJsonFile( + EXPORT_ROOT.resolve("firestore") + .resolve("workingScreener") + .resolve(screenerId) + .resolve("customBenefit") + .resolve(benefitId + ".json"), + firestoreDocumentForExport(benefit, benefitId) + ); + exportedBenefits++; + } + + return exportedBenefits; + } + + private int exportChecks(String collectionName, List> checks) throws IOException { + int exportedChecks = 0; + for (Map check : checks) { + String checkId = requiredString(check, FieldNames.ID, collectionName); + writeJsonFile( + EXPORT_ROOT.resolve("firestore").resolve(collectionName).resolve(checkId + ".json"), + firestoreDocumentForExport(check, checkId) + ); + exportedChecks++; + } + return exportedChecks; + } + + private int exportSystemConfig() throws IOException { + Optional> config = FirestoreUtils.getFirestoreDocById(SYSTEM_COLLECTION, SYSTEM_CONFIG_ID); + if (config.isEmpty()) { + return 0; + } + + writeJsonFile( + EXPORT_ROOT.resolve("firestore").resolve(SYSTEM_COLLECTION).resolve(SYSTEM_CONFIG_ID + ".json"), + firestoreDocumentForExport(config.get(), SYSTEM_CONFIG_ID) + ); + return 1; + } + + private int exportScreenerForms(List> workingScreeners) throws IOException { + int exportedForms = 0; + + for (Map screener : workingScreeners) { + String screenerId = requiredString(screener, FieldNames.ID, CollectionNames.WORKING_SCREENER_COLLECTION); + Optional formSchema = storageService.getStringFromStorage( + storageService.getScreenerWorkingFormSchemaPath(screenerId) + ); + + if (formSchema.isEmpty()) { + continue; + } + + writeStringFile( + EXPORT_ROOT.resolve("storage").resolve("form").resolve("working").resolve(screenerId + ".json"), + formSchema.get() + ); + exportedForms++; + } + + return exportedForms; + } + + private int exportCheckDmns(List> checks) throws IOException { + int exportedDmns = 0; + Set exportedIds = new LinkedHashSet<>(); + + for (Map check : checks) { + String checkId = requiredString(check, FieldNames.ID, "customCheck"); + if (!exportedIds.add(checkId)) { + continue; + } + + Optional dmnModel = storageService.getStringFromStorage(storageService.getCheckDmnModelPath(checkId)); + if (dmnModel.isEmpty()) { + continue; + } + + writeStringFile( + EXPORT_ROOT.resolve("storage").resolve("check").resolve(checkId + ".dmn"), + dmnModel.get() + ); + exportedDmns++; + } + + return exportedDmns; + } + + private void writeManifest(int firestoreDocuments, int storageFiles) throws IOException { + Map manifest = new LinkedHashMap<>(); + manifest.put("exportedAt", Instant.now().toString()); + manifest.put("source", "builder-api"); + manifest.put("projectId", FirestoreClient.getFirestore().getOptions().getProjectId()); + manifest.put("storageBucket", bucketName); + manifest.put("firestoreDocuments", firestoreDocuments); + manifest.put("storageFiles", storageFiles); + + writeJsonFile(EXPORT_ROOT.resolve("manifest.json"), manifest); + } + + private Map firestoreDocumentForExport(Map rawData, String documentId) { + Map exportData = new LinkedHashMap<>(); + for (Map.Entry entry : rawData.entrySet()) { + exportData.put(entry.getKey(), normalizeFirestoreValue(entry.getValue())); + } + exportData.put("_id", documentId); + return exportData; + } + + private Object normalizeFirestoreValue(Object value) { + if (value instanceof Timestamp timestamp) { + Map exportedTimestamp = new LinkedHashMap<>(); + exportedTimestamp.put("_type", "timestamp"); + exportedTimestamp.put("value", timestamp.toDate().toInstant().toString()); + return exportedTimestamp; + } + + if (value instanceof Map mapValue) { + Map normalizedMap = new LinkedHashMap<>(); + for (Map.Entry entry : mapValue.entrySet()) { + normalizedMap.put(String.valueOf(entry.getKey()), normalizeFirestoreValue(entry.getValue())); + } + return normalizedMap; + } + + if (value instanceof List listValue) { + List normalizedList = new ArrayList<>(); + for (Object item : listValue) { + normalizedList.add(normalizeFirestoreValue(item)); + } + return normalizedList; + } + + return value; + } + + private void resetExportRoot() throws IOException { + if (Files.exists(EXPORT_ROOT)) { + try (var walk = Files.walk(EXPORT_ROOT)) { + walk.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.delete(path); + } catch (IOException e) { + throw new RuntimeException("Failed to delete " + path, e); + } + }); + } catch (RuntimeException e) { + if (e.getCause() instanceof IOException ioException) { + throw ioException; + } + throw e; + } + } + + Files.createDirectories(EXPORT_ROOT); + } + + private void writeJsonFile(Path path, Object data) throws IOException { + Files.createDirectories(path.getParent()); + Files.writeString(path, objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(data)); + } + + private void writeStringFile(Path path, String data) throws IOException { + Files.createDirectories(path.getParent()); + Files.writeString(path, data); + } + + private String requiredString(Map data, String fieldName, String context) { + Object value = data.get(fieldName); + if (!(value instanceof String stringValue) || stringValue.isBlank()) { + throw new IllegalStateException("Missing field '" + fieldName + "' for " + context); + } + return stringValue; + } + + public record ExportSummary( + String outputPath, + int screenerCount, + int firestoreDocuments, + int storageFiles + ) {} +} diff --git a/builder-api/src/main/java/org/acme/service/ExampleScreenerImportService.java b/builder-api/src/main/java/org/acme/service/ExampleScreenerImportService.java index 54cd3989..4f6c6b8c 100644 --- a/builder-api/src/main/java/org/acme/service/ExampleScreenerImportService.java +++ b/builder-api/src/main/java/org/acme/service/ExampleScreenerImportService.java @@ -16,7 +16,12 @@ import org.eclipse.microprofile.config.inject.ConfigProperty; import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; import java.nio.file.Files; +import java.nio.file.FileSystem; +import java.nio.file.FileSystemNotFoundException; +import java.nio.file.FileSystems; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; @@ -33,6 +38,8 @@ @ApplicationScoped public class ExampleScreenerImportService { + private static final String BUNDLED_SEED_ROOT = "seed-data/example-screener"; + private static final String BUNDLED_SEED_MANIFEST = BUNDLED_SEED_ROOT + "/manifest.json"; private final ScreenerRepository screenerRepository; private final EligibilityCheckRepository eligibilityCheckRepository; @@ -54,53 +61,64 @@ public ExampleScreenerImportService( this.objectMapper = new ObjectMapper(); } - public String importForUser(String userId) throws Exception { - Path seedRoot = resolveSeedRoot(); - SeedData seedData = loadSeedData(seedRoot); - - Map importedCustomCheckIds = importReferencedCustomChecks(seedData, userId); - - List importedBenefits = new ArrayList<>(); - List importedBenefitDetails = new ArrayList<>(); - for (Benefit seedBenefit : seedData.benefits()) { - Benefit importedBenefit = cloneBenefit(seedBenefit, userId, importedCustomCheckIds); - importedBenefits.add(importedBenefit); - importedBenefitDetails.add(new BenefitDetail( - importedBenefit.getId(), - importedBenefit.getName(), - importedBenefit.getDescription() - )); - } + public List importForUser(String userId) throws Exception { + SeedRoot seedRoot = resolveSeedRoot(); + try (seedRoot) { + SeedData seedData = loadSeedData(seedRoot.path()); + + Map importedCustomCheckIds = importReferencedCustomChecks(seedData, userId); + List importedScreenerIds = new ArrayList<>(); + + for (SeedScreenerData seedScreener : seedData.screeners()) { + List importedBenefits = new ArrayList<>(); + List importedBenefitDetails = new ArrayList<>(); + for (Benefit seedBenefit : seedScreener.benefits()) { + Benefit importedBenefit = cloneBenefit(seedBenefit, userId, importedCustomCheckIds); + importedBenefits.add(importedBenefit); + importedBenefitDetails.add(new BenefitDetail( + importedBenefit.getId(), + importedBenefit.getName(), + importedBenefit.getDescription() + )); + } - Screener importedScreener = new Screener(); - importedScreener.setOwnerId(userId); - importedScreener.setScreenerName(seedData.screener().getScreenerName()); - importedScreener.setBenefits(importedBenefitDetails); + Screener importedScreener = new Screener(); + importedScreener.setOwnerId(userId); + importedScreener.setScreenerName(seedScreener.screener().getScreenerName()); + importedScreener.setBenefits(importedBenefitDetails); - String newScreenerId = screenerRepository.saveNewWorkingScreener(importedScreener); - importedScreener.setId(newScreenerId); + String newScreenerId = screenerRepository.saveNewWorkingScreener(importedScreener); + importedScreener.setId(newScreenerId); - for (Benefit importedBenefit : importedBenefits) { - screenerRepository.saveNewCustomBenefit(newScreenerId, importedBenefit); - } + for (Benefit importedBenefit : importedBenefits) { + screenerRepository.saveNewCustomBenefit(newScreenerId, importedBenefit); + } - String formPath = storageService.getScreenerWorkingFormSchemaPath(newScreenerId); - storageService.writeJsonToStorage(formPath, seedData.formSchema()); + if (seedScreener.formSchema() != null) { + String formPath = storageService.getScreenerWorkingFormSchemaPath(newScreenerId); + storageService.writeJsonToStorage(formPath, seedScreener.formSchema()); + } - Log.info("Imported example screener " + newScreenerId + " for user " + userId); - return newScreenerId; + importedScreenerIds.add(newScreenerId); + Log.info("Imported example screener " + newScreenerId + " for user " + userId); + } + + return importedScreenerIds; + } } private Map importReferencedCustomChecks(SeedData seedData, String userId) throws Exception { Set referencedCustomCheckIds = new LinkedHashSet<>(); - for (Benefit benefit : seedData.benefits()) { - if (benefit.getChecks() == null) { - continue; - } - for (CheckConfig checkConfig : benefit.getChecks()) { - String sourceCheckId = resolveSourceCheckId(checkConfig); - if (sourceCheckId != null && !isLibraryCheckId(sourceCheckId)) { - referencedCustomCheckIds.add(sourceCheckId); + for (SeedScreenerData seedScreener : seedData.screeners()) { + for (Benefit benefit : seedScreener.benefits()) { + if (benefit.getChecks() == null) { + continue; + } + for (CheckConfig checkConfig : benefit.getChecks()) { + String sourceCheckId = resolveSourceCheckId(checkConfig); + if (sourceCheckId != null && !isLibraryCheckId(sourceCheckId)) { + referencedCustomCheckIds.add(sourceCheckId); + } } } } @@ -287,34 +305,44 @@ private boolean isLibraryCheckId(String checkId) { private SeedData loadSeedData(Path seedRoot) throws IOException { Path workingScreenersDir = seedRoot.resolve("firestore").resolve("workingScreener"); List screenerFiles = listJsonFiles(workingScreenersDir); - if (screenerFiles.size() != 1) { - throw new IllegalStateException("Expected exactly one working screener seed document, found " + screenerFiles.size()); + if (screenerFiles.isEmpty()) { + throw new IllegalStateException("Expected at least one working screener seed document"); } - Path screenerFile = screenerFiles.get(0); - Screener screener = readJsonFile(screenerFile, Screener.class); - String screenerDocId = stripExtension(screenerFile.getFileName().toString()); + List screeners = new ArrayList<>(); + for (Path screenerFile : screenerFiles) { + Screener screener = readJsonFile(screenerFile, Screener.class); + String screenerDocId = stripExtension(screenerFile.getFileName().toString()); - Path benefitsDir = workingScreenersDir.resolve(screenerDocId).resolve("customBenefit"); - List benefits = new ArrayList<>(); - for (Path benefitFile : listJsonFiles(benefitsDir)) { - benefits.add(readJsonFile(benefitFile, Benefit.class)); - } + Path benefitsDir = workingScreenersDir.resolve(screenerDocId).resolve("customBenefit"); + List benefits = new ArrayList<>(); + for (Path benefitFile : listJsonFiles(benefitsDir)) { + benefits.add(readJsonFile(benefitFile, Benefit.class)); + } - JsonNode formSchema = objectMapper.readTree( - Files.readString(seedRoot.resolve("storage").resolve("form").resolve("working").resolve(screenerDocId + ".json")) - ); + screeners.add(new SeedScreenerData( + screener, + benefits, + loadFormSchema(seedRoot, screenerDocId) + )); + } return new SeedData( - screener, - benefits, - formSchema, + screeners, loadChecks(seedRoot.resolve("firestore").resolve("workingCustomCheck")), loadChecks(seedRoot.resolve("firestore").resolve("publishedCustomCheck")), loadDmnFiles(seedRoot.resolve("storage").resolve("check")) ); } + private JsonNode loadFormSchema(Path seedRoot, String screenerDocId) throws IOException { + Path formSchemaPath = seedRoot.resolve("storage").resolve("form").resolve("working").resolve(screenerDocId + ".json"); + if (!Files.isRegularFile(formSchemaPath)) { + return null; + } + return objectMapper.readTree(Files.readString(formSchemaPath)); + } + private Map loadChecks(Path checksDir) throws IOException { Map checksById = new LinkedHashMap<>(); if (!Files.isDirectory(checksDir)) { @@ -374,24 +402,56 @@ private List listJsonFiles(Path directory) throws IOException { } } - private Path resolveSeedRoot() { - List candidates = new ArrayList<>(); - configuredSeedPath + private SeedRoot resolveSeedRoot() { + Optional configuredPath = configuredSeedPath .map(String::trim) .filter(path -> !path.isBlank()) - .map(Paths::get) - .ifPresent(candidates::add); - candidates.add(Paths.get("seed-data", "example-screener")); - candidates.add(Paths.get("..", "seed-data", "example-screener")); + .map(Paths::get); - for (Path candidate : candidates) { - Path absoluteCandidate = candidate.toAbsolutePath().normalize(); + if (configuredPath.isPresent()) { + Path absoluteCandidate = configuredPath.get().toAbsolutePath().normalize(); if (Files.isDirectory(absoluteCandidate)) { - return absoluteCandidate; + return new SeedRoot(absoluteCandidate, null); + } + } + + try { + return resolveBundledSeedRoot(); + } catch (IOException | URISyntaxException e) { + throw new IllegalStateException("Could not load bundled example screener seed data", e); + } + } + + private SeedRoot resolveBundledSeedRoot() throws IOException, URISyntaxException { + var manifestUrl = ExampleScreenerImportService.class.getClassLoader().getResource(BUNDLED_SEED_MANIFEST); + if (manifestUrl == null) { + throw new IllegalStateException( + "Could not find bundled example screener seed data at classpath:" + BUNDLED_SEED_ROOT + ); + } + + URI manifestUri = manifestUrl.toURI(); + if ("jar".equalsIgnoreCase(manifestUri.getScheme())) { + String manifestUriString = manifestUri.toString(); + int archiveSeparatorIndex = manifestUriString.indexOf("!/"); + if (archiveSeparatorIndex < 0) { + throw new IllegalStateException("Unexpected jar resource URI for bundled seed data: " + manifestUri); } + + URI archiveUri = URI.create(manifestUriString.substring(0, archiveSeparatorIndex)); + FileSystem fileSystem = getOrCreateFileSystem(archiveUri); + return new SeedRoot(fileSystem.getPath("/" + BUNDLED_SEED_ROOT), fileSystem); } - throw new IllegalStateException("Could not find example screener seed data in any expected location"); + return new SeedRoot(Paths.get(manifestUri).getParent(), null); + } + + private FileSystem getOrCreateFileSystem(URI archiveUri) throws IOException { + try { + return FileSystems.getFileSystem(archiveUri); + } catch (FileSystemNotFoundException ignored) { + return FileSystems.newFileSystem(archiveUri, Collections.emptyMap()); + } } private String buildWorkingCheckId(String ownerId, String module, String name) { @@ -411,16 +471,29 @@ private String stripExtension(String filename) { } private record SeedData( - Screener screener, - List benefits, - JsonNode formSchema, + List screeners, Map workingCustomChecks, Map publishedCustomChecks, Map dmnByCheckId ) {} + private record SeedScreenerData( + Screener screener, + List benefits, + JsonNode formSchema + ) {} + private record SeedCustomCheckVersions( EligibilityCheck workingCheck, EligibilityCheck publishedCheck ) {} + + private record SeedRoot(Path path, FileSystem fileSystem) implements AutoCloseable { + @Override + public void close() throws IOException { + if (fileSystem != null && fileSystem.isOpen()) { + fileSystem.close(); + } + } + } } diff --git a/seed-data/example-screener/firestore/publishedCustomCheck/P-i7d0OHGyzho27saJ48BFa77NmDeL-testchecks-test-2.0.0.json b/builder-api/src/main/resources/seed-data/example-screener/firestore/publishedCustomCheck/P-i7d0OHGyzho27saJ48BFa77NmDeL-testchecks-test-2.0.0.json similarity index 100% rename from seed-data/example-screener/firestore/publishedCustomCheck/P-i7d0OHGyzho27saJ48BFa77NmDeL-testchecks-test-2.0.0.json rename to builder-api/src/main/resources/seed-data/example-screener/firestore/publishedCustomCheck/P-i7d0OHGyzho27saJ48BFa77NmDeL-testchecks-test-2.0.0.json diff --git a/seed-data/example-screener/firestore/system/config.json b/builder-api/src/main/resources/seed-data/example-screener/firestore/system/config.json similarity index 100% rename from seed-data/example-screener/firestore/system/config.json rename to builder-api/src/main/resources/seed-data/example-screener/firestore/system/config.json diff --git a/seed-data/example-screener/firestore/workingCustomCheck/W-i7d0OHGyzho27saJ48BFa77NmDeL-testchecks-test.json b/builder-api/src/main/resources/seed-data/example-screener/firestore/workingCustomCheck/W-i7d0OHGyzho27saJ48BFa77NmDeL-testchecks-test.json similarity index 100% rename from seed-data/example-screener/firestore/workingCustomCheck/W-i7d0OHGyzho27saJ48BFa77NmDeL-testchecks-test.json rename to builder-api/src/main/resources/seed-data/example-screener/firestore/workingCustomCheck/W-i7d0OHGyzho27saJ48BFa77NmDeL-testchecks-test.json diff --git a/seed-data/example-screener/firestore/workingScreener/hEStvPeFmEte58GQTC7Y.json b/builder-api/src/main/resources/seed-data/example-screener/firestore/workingScreener/hEStvPeFmEte58GQTC7Y.json similarity index 100% rename from seed-data/example-screener/firestore/workingScreener/hEStvPeFmEte58GQTC7Y.json rename to builder-api/src/main/resources/seed-data/example-screener/firestore/workingScreener/hEStvPeFmEte58GQTC7Y.json diff --git a/seed-data/example-screener/firestore/workingScreener/hEStvPeFmEte58GQTC7Y/customBenefit/1c09392c-913c-4b2b-9870-a1951534c3fb.json b/builder-api/src/main/resources/seed-data/example-screener/firestore/workingScreener/hEStvPeFmEte58GQTC7Y/customBenefit/1c09392c-913c-4b2b-9870-a1951534c3fb.json similarity index 100% rename from seed-data/example-screener/firestore/workingScreener/hEStvPeFmEte58GQTC7Y/customBenefit/1c09392c-913c-4b2b-9870-a1951534c3fb.json rename to builder-api/src/main/resources/seed-data/example-screener/firestore/workingScreener/hEStvPeFmEte58GQTC7Y/customBenefit/1c09392c-913c-4b2b-9870-a1951534c3fb.json diff --git a/seed-data/example-screener/firestore/workingScreener/hEStvPeFmEte58GQTC7Y/customBenefit/fdd4405a-1a00-4650-8005-9595f16e3788.json b/builder-api/src/main/resources/seed-data/example-screener/firestore/workingScreener/hEStvPeFmEte58GQTC7Y/customBenefit/fdd4405a-1a00-4650-8005-9595f16e3788.json similarity index 100% rename from seed-data/example-screener/firestore/workingScreener/hEStvPeFmEte58GQTC7Y/customBenefit/fdd4405a-1a00-4650-8005-9595f16e3788.json rename to builder-api/src/main/resources/seed-data/example-screener/firestore/workingScreener/hEStvPeFmEte58GQTC7Y/customBenefit/fdd4405a-1a00-4650-8005-9595f16e3788.json diff --git a/seed-data/example-screener/manifest.json b/builder-api/src/main/resources/seed-data/example-screener/manifest.json similarity index 100% rename from seed-data/example-screener/manifest.json rename to builder-api/src/main/resources/seed-data/example-screener/manifest.json diff --git a/seed-data/example-screener/storage/check/P-i7d0OHGyzho27saJ48BFa77NmDeL-testchecks-test-2.0.0.dmn b/builder-api/src/main/resources/seed-data/example-screener/storage/check/P-i7d0OHGyzho27saJ48BFa77NmDeL-testchecks-test-2.0.0.dmn similarity index 100% rename from seed-data/example-screener/storage/check/P-i7d0OHGyzho27saJ48BFa77NmDeL-testchecks-test-2.0.0.dmn rename to builder-api/src/main/resources/seed-data/example-screener/storage/check/P-i7d0OHGyzho27saJ48BFa77NmDeL-testchecks-test-2.0.0.dmn diff --git a/seed-data/example-screener/storage/check/W-i7d0OHGyzho27saJ48BFa77NmDeL-testchecks-test.dmn b/builder-api/src/main/resources/seed-data/example-screener/storage/check/W-i7d0OHGyzho27saJ48BFa77NmDeL-testchecks-test.dmn similarity index 100% rename from seed-data/example-screener/storage/check/W-i7d0OHGyzho27saJ48BFa77NmDeL-testchecks-test.dmn rename to builder-api/src/main/resources/seed-data/example-screener/storage/check/W-i7d0OHGyzho27saJ48BFa77NmDeL-testchecks-test.dmn diff --git a/seed-data/example-screener/storage/form/working/hEStvPeFmEte58GQTC7Y.json b/builder-api/src/main/resources/seed-data/example-screener/storage/form/working/hEStvPeFmEte58GQTC7Y.json similarity index 100% rename from seed-data/example-screener/storage/form/working/hEStvPeFmEte58GQTC7Y.json rename to builder-api/src/main/resources/seed-data/example-screener/storage/form/working/hEStvPeFmEte58GQTC7Y.json diff --git a/builder-frontend/src/api/account.ts b/builder-frontend/src/api/account.ts index 28dad71a..fb04490d 100644 --- a/builder-frontend/src/api/account.ts +++ b/builder-frontend/src/api/account.ts @@ -24,3 +24,20 @@ export const runAccountHooks = async () => { throw error; // rethrow so you can handle it in your component if needed } }; + +export const exportExampleScreener = async () => { + const url = new URL(`${apiUrl}/account/export-example-screener`); + + try { + const response = await authPost(url.toString()); + + if (!response.ok) { + return { success: false }; + } + const data = (await response.json()) as { success: boolean }; + return data; + } catch (err) { + console.error("Error calling account hooks:", err); + return { success: false }; + } +}; diff --git a/builder-frontend/src/components/Header/Header.tsx b/builder-frontend/src/components/Header/Header.tsx index 290e80b0..3996c8e2 100644 --- a/builder-frontend/src/components/Header/Header.tsx +++ b/builder-frontend/src/components/Header/Header.tsx @@ -1,11 +1,24 @@ import { useAuth } from "../../context/AuthContext"; import { useLocation, useNavigate } from "@solidjs/router"; -import { Component, createMemo, For, Show } from "solid-js"; +import { + Component, + createMemo, + createSignal, + DEV, + For, + JSX, + Match, + Show, + Switch, +} from "solid-js"; import { HamburgerMenu } from "@/components/shared/HamburgerMenu"; import "./Header.css"; import { Menu } from "lucide-solid"; +import { Button } from "@/components/shared/Button"; +import { Modal } from "@/components/shared/Modal"; +import { exportExampleScreener } from "@/api/account"; const HeaderButton = ({ buttonText, @@ -35,6 +48,10 @@ interface MenuProps { const HeaderMenu: Component = (props) => { const navigate = useNavigate(); + const [showExportMenu, setShowExportMenu] = createSignal(false); + const [exportingMessage, setExportingMessage] = createSignal(""); + const [isExportingExample, setIsExportingExample] = createSignal(false); + const menuItems: { label: string; onClick: () => void }[] = [ { label: "Custom Checks", @@ -47,6 +64,20 @@ const HeaderMenu: Component = (props) => { { label: "Logout", onClick: props.logout }, ]; + const handleExportExampleScreener: JSX.EventHandler< + HTMLButtonElement, + MouseEvent + > = async (e) => { + setIsExportingExample(true); + setExportingMessage(""); + const result = await exportExampleScreener(); + if (!result.success) { + setExportingMessage("An error occurred exporting."); + } else { + setExportingMessage("Successfully exported screeners."); + } + setIsExportingExample(false); + }; return (
@@ -62,6 +93,24 @@ const HeaderMenu: Component = (props) => { )} + + + setShowExportMenu(false)}> +
Ready to save changes to the example screener?
+ + + + + + Waiting for export + +
{exportingMessage()}
+
+
); }; diff --git a/builder-frontend/src/components/shared/HamburgerMenu/HamburgerMenu.css b/builder-frontend/src/components/shared/HamburgerMenu/HamburgerMenu.css index 6a5f0b8c..91ebfd02 100644 --- a/builder-frontend/src/components/shared/HamburgerMenu/HamburgerMenu.css +++ b/builder-frontend/src/components/shared/HamburgerMenu/HamburgerMenu.css @@ -19,7 +19,7 @@ width: 25%; height: 100%; - z-index: 1000; + z-index: 5; background-color: white; padding: 0.5rem; diff --git a/builder-frontend/src/components/shared/HamburgerMenu/HamburgerMenuWrapper.tsx b/builder-frontend/src/components/shared/HamburgerMenu/HamburgerMenuWrapper.tsx index 336b4038..a7647aba 100644 --- a/builder-frontend/src/components/shared/HamburgerMenu/HamburgerMenuWrapper.tsx +++ b/builder-frontend/src/components/shared/HamburgerMenu/HamburgerMenuWrapper.tsx @@ -30,8 +30,20 @@ export const HamburgerMenuWrapper: ParentComponent = (props) => { const [showMenu, setShowMenu] = createSignal(false); const handleClickOutside = (ev: MouseEvent) => { + if (!showMenu()) return; + const el = root(); - if (showMenu() && el && !el.contains(ev.target as Node)) { + if (!el) return; + + const path = ev.composedPath(); + + const clickedInsideMenu = path.includes(el); + const clickedInsideModal = path.some( + (node) => + node instanceof HTMLElement && node.hasAttribute("data-modal-root"), + ); + + if (!clickedInsideMenu && !clickedInsideModal) { setShowMenu(false); } }; diff --git a/builder-frontend/src/components/shared/Modal.tsx b/builder-frontend/src/components/shared/Modal.tsx index 7d0db47f..85561add 100644 --- a/builder-frontend/src/components/shared/Modal.tsx +++ b/builder-frontend/src/components/shared/Modal.tsx @@ -12,7 +12,11 @@ export const Modal: Component> = (props) => { return ( -
props.onClose()}> +
props.onClose()} + data-modal-root + >
e.stopPropagation()} From bfb8f4f4b05add70e5f3ade846d692998326b48a Mon Sep 17 00:00:00 2001 From: joshwanf <17016446+joshwanf@users.noreply.github.com> Date: Wed, 8 Apr 2026 21:03:57 -0400 Subject: [PATCH 2/2] Updated account hook endpoint to match export-exmple-screener endpoint --- .../org/acme/controller/AccountResource.java | 39 ++++++++++++------- builder-frontend/src/api/account.ts | 2 +- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/builder-api/src/main/java/org/acme/controller/AccountResource.java b/builder-api/src/main/java/org/acme/controller/AccountResource.java index e66c3843..40d6cda6 100644 --- a/builder-api/src/main/java/org/acme/controller/AccountResource.java +++ b/builder-api/src/main/java/org/acme/controller/AccountResource.java @@ -32,7 +32,7 @@ public class AccountResource { @POST @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) - @Path("/account-hooks") + @Path("/account/hooks") public Response accountHooks(@Context SecurityIdentity identity, AccountHookRequest request) { @@ -76,7 +76,7 @@ public Response exportExampleScreener(@Context SecurityIdentity identity) { if (userId == null) { return Response.status(Response.Status.UNAUTHORIZED) - .entity(new ApiError(true, "Unauthorized.")).build(); + .entity(new ApiError(true, "Unauthorized.")).build(); } if (LaunchMode.current() != LaunchMode.DEVELOPMENT) { @@ -84,19 +84,30 @@ public Response exportExampleScreener(@Context SecurityIdentity identity) { } try { - ExampleScreenerExportService.ExportSummary summary = exampleScreenerExportService.exportForUser(userId); - return Response.ok(Map.of( - "success", true, - "outputPath", summary.outputPath(), - "screenerCount", summary.screenerCount(), - "firestoreDocuments", summary.firestoreDocuments(), - "storageFiles", summary.storageFiles() - )).build(); + ExampleScreenerExportService.ExportSummary summary = exampleScreenerExportService + .exportForUser(userId); + return Response.ok( + Map.of( + "success", + true, + "outputPath", + summary.outputPath(), + "screenerCount", + summary.screenerCount(), + "firestoreDocuments", + summary.firestoreDocuments(), + "storageFiles", + summary.storageFiles())) + .build(); } catch (Exception e) { - Log.error("Failed to export example screener seed data for user " + userId, e); - return Response.serverError() - .entity(new ApiError(true, "Failed to export example screener seed data.")) - .build(); + Log.error( + "Failed to export example screener seed data for user " + + userId, + e); + return Response.serverError().entity( + new ApiError(true, + "Failed to export example screener seed data.")) + .build(); } } } diff --git a/builder-frontend/src/api/account.ts b/builder-frontend/src/api/account.ts index fb04490d..025df1e3 100644 --- a/builder-frontend/src/api/account.ts +++ b/builder-frontend/src/api/account.ts @@ -5,7 +5,7 @@ import { authPost } from "@/api/auth"; const apiUrl = env.apiUrl; export const runAccountHooks = async () => { - const accountHookUrl = new URL(`${apiUrl}/account-hooks`); + const accountHookUrl = new URL(`${apiUrl}/account/hooks`); const hooksToCall = ["add example screener"];