From b1bc5ff835f285737bc3b61d69b5fb3cb3c7fcae Mon Sep 17 00:00:00 2001 From: joshwanf <17016446+joshwanf@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:07:35 -0400 Subject: [PATCH 1/6] Account creation hook added to frontend 'register account' action --- .../org/acme/controller/AccountResource.java | 46 +++++++++++++++++++ .../model/dto/Auth/AccountHookResponse.java | 4 ++ builder-frontend/src/api/account.ts | 27 +++++++++++ builder-frontend/src/context/AuthContext.jsx | 17 ++++++- 4 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 builder-api/src/main/java/org/acme/controller/AccountResource.java create mode 100644 builder-api/src/main/java/org/acme/model/dto/Auth/AccountHookResponse.java create mode 100644 builder-frontend/src/api/account.ts diff --git a/builder-api/src/main/java/org/acme/controller/AccountResource.java b/builder-api/src/main/java/org/acme/controller/AccountResource.java new file mode 100644 index 00000000..9e935da1 --- /dev/null +++ b/builder-api/src/main/java/org/acme/controller/AccountResource.java @@ -0,0 +1,46 @@ +package org.acme.controller; + +import io.quarkus.logging.Log; +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.inject.Inject; +import jakarta.validation.Validator; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.*; + +import org.acme.api.error.ApiError; +import org.acme.auth.AuthUtils; +import org.acme.model.dto.Auth.AccountHookResponse; + +@Path("/api") +public class AccountResource { + + @Inject + Validator validator; + + @GET + @Path("/account-hooks") + public Response getScreeners(@Context SecurityIdentity identity, + @QueryParam("action") String action) { + String userId = AuthUtils.getUserId(identity); + if (userId == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .entity(new ApiError(true, "Unauthorized.")).build(); + } + + if (action == null || action.isBlank()) { + return Response.status(Response.Status.BAD_REQUEST).entity( + new ApiError(true, "Query parameter 'action' is required.")) + .build(); + } + Log.info("Running account hooks for: " + userId); + + if (action.equals("add example screener")) { + Log.info("***** Adding an exaample screener to the account *****"); + } + + AccountHookResponse responseBody = new AccountHookResponse( + "add example screener", true); + + return Response.ok(responseBody).build(); + } +} diff --git a/builder-api/src/main/java/org/acme/model/dto/Auth/AccountHookResponse.java b/builder-api/src/main/java/org/acme/model/dto/Auth/AccountHookResponse.java new file mode 100644 index 00000000..d64cddb9 --- /dev/null +++ b/builder-api/src/main/java/org/acme/model/dto/Auth/AccountHookResponse.java @@ -0,0 +1,4 @@ +package org.acme.model.dto.Auth; + +public record AccountHookResponse(String action, boolean success) { +}; diff --git a/builder-frontend/src/api/account.ts b/builder-frontend/src/api/account.ts new file mode 100644 index 00000000..d01e3bea --- /dev/null +++ b/builder-frontend/src/api/account.ts @@ -0,0 +1,27 @@ +import { env } from "@/config/environment"; + +import { authGet } from "@/api/auth"; + +const apiUrl = env.apiUrl; + +export const getAccountHooks = async () => { + const searchParams = new URLSearchParams([ + ["action", "add example screener"], + ]); + const accountHookUrl = new URL( + `${apiUrl}/account-hooks?${searchParams.toString()}`, + ); + + try { + const response = await authGet(accountHookUrl.toString()); + + if (!response.ok) { + throw new Error(`Account hooks failed with status: ${response.status}`); + } + const data = await response.json(); + return data; + } catch (error) { + console.error("Error calling account hooks:", error); + throw error; // rethrow so you can handle it in your component if needed + } +}; diff --git a/builder-frontend/src/context/AuthContext.jsx b/builder-frontend/src/context/AuthContext.jsx index 3bca567f..176d0756 100644 --- a/builder-frontend/src/context/AuthContext.jsx +++ b/builder-frontend/src/context/AuthContext.jsx @@ -15,6 +15,8 @@ import { } from "firebase/auth"; import { auth } from "../firebase/firebase"; +import { authGet } from "@/api/auth"; +import { getAccountHooks } from "@/api/account"; const AuthContext = createContext(); const googleProvider = new GoogleAuthProvider(); @@ -41,7 +43,20 @@ export function AuthProvider(props) { }; const register = async (email, password) => { - return createUserWithEmailAndPassword(auth, email, password); + return createUserWithEmailAndPassword(auth, email, password).then( + (userCredential) => { + // Call "account creation hook" API endpoint here? + console.log("***** after account creation hook *****"); + getAccountHooks().then( + () => { + console.log("Successfully hooked the account."); + }, + (error) => { + console.log("Error hooking the account", error); + }, + ); + }, + ); }; const loginWithGoogle = async () => { From 6b815ad38b540e19ea6daf67b9783ac32d105370 Mon Sep 17 00:00:00 2001 From: joshwanf <17016446+joshwanf@users.noreply.github.com> Date: Sat, 14 Mar 2026 15:30:43 -0400 Subject: [PATCH 2/6] account-hooks endpoint adds example screener to account, synced frontend app loading to with account-hooks completion --- .../api/error/JsonServerExceptionMappers.java | 19 +++---- .../org/acme/controller/AccountResource.java | 48 +++++++++++------ .../org/acme/enums/AccountHookAction.java | 30 +++++++++++ .../java/org/acme/functions/AccountHooks.java | 34 ++++++++++++ .../model/dto/Auth/AccountHookRequest.java | 8 +++ .../model/dto/Auth/AccountHookResponse.java | 5 +- builder-frontend/src/App.tsx | 36 +++++++------ builder-frontend/src/api/account.ts | 15 +++--- .../src/components/homeScreen/HomeScreen.tsx | 14 +++++ .../{AuthContext.jsx => AuthContext.tsx} | 54 ++++++++++++++----- 10 files changed, 200 insertions(+), 63 deletions(-) create mode 100644 builder-api/src/main/java/org/acme/enums/AccountHookAction.java create mode 100644 builder-api/src/main/java/org/acme/functions/AccountHooks.java create mode 100644 builder-api/src/main/java/org/acme/model/dto/Auth/AccountHookRequest.java rename builder-frontend/src/context/{AuthContext.jsx => AuthContext.tsx} (54%) diff --git a/builder-api/src/main/java/org/acme/api/error/JsonServerExceptionMappers.java b/builder-api/src/main/java/org/acme/api/error/JsonServerExceptionMappers.java index abf0f4d0..ffaf9e44 100644 --- a/builder-api/src/main/java/org/acme/api/error/JsonServerExceptionMappers.java +++ b/builder-api/src/main/java/org/acme/api/error/JsonServerExceptionMappers.java @@ -15,15 +15,13 @@ public class JsonServerExceptionMappers { public Response map(MismatchedInputException e) { Log.warn(e); // e.g. screenerName is object but DTO expects String - String field = - e.getPath() != null && !e.getPath().isEmpty() - ? e.getPath().get(e.getPath().size() - 1).getFieldName() - : "request body"; + String field = e.getPath() != null && !e.getPath().isEmpty() + ? e.getPath().get(e.getPath().size() - 1).getFieldName() + : "request body"; return Response.status(Response.Status.BAD_REQUEST) .type(MediaType.APPLICATION_JSON) - .entity(ApiError.of("Invalid type for field '" + field + "'.")) - .build(); + .entity(ApiError.of("Invalid type for field '" + field + "'.")).build(); } @ServerExceptionMapper @@ -32,16 +30,16 @@ public Response map(JsonParseException e) { // malformed JSON like { "schema": } return Response.status(Response.Status.BAD_REQUEST) .type(MediaType.APPLICATION_JSON) - .entity(ApiError.of("Malformed JSON.")) - .build(); + .entity(ApiError.of("JsonParseException: Malformed JSON.")).build(); } @ServerExceptionMapper public Response map(WebApplicationException e) { + Log.info("Some malformed JSON"); Log.warn(e); return Response.status(Response.Status.BAD_REQUEST) .type(MediaType.APPLICATION_JSON) - .entity(ApiError.of("Malformed JSON.")) + .entity(ApiError.of("WebApplicationException: Malformed JSON.")) .build(); } @@ -51,7 +49,6 @@ public Response map(JsonMappingException e) { // other mapping errors return Response.status(Response.Status.BAD_REQUEST) .type(MediaType.APPLICATION_JSON) - .entity(ApiError.of("Invalid request body.")) - .build(); + .entity(ApiError.of("Invalid request body.")).build(); } } 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 9e935da1..472ff4a4 100644 --- a/builder-api/src/main/java/org/acme/controller/AccountResource.java +++ b/builder-api/src/main/java/org/acme/controller/AccountResource.java @@ -1,14 +1,21 @@ package org.acme.controller; -import io.quarkus.logging.Log; import io.quarkus.security.identity.SecurityIdentity; import jakarta.inject.Inject; import jakarta.validation.Validator; import jakarta.ws.rs.*; import jakarta.ws.rs.core.*; +import java.util.Map; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; + import org.acme.api.error.ApiError; import org.acme.auth.AuthUtils; +import org.acme.enums.AccountHookAction; +import org.acme.functions.AccountHooks; +import org.acme.model.dto.Auth.AccountHookRequest; import org.acme.model.dto.Auth.AccountHookResponse; @Path("/api") @@ -17,29 +24,40 @@ public class AccountResource { @Inject Validator validator; - @GET + @Inject + AccountHooks accountHooks; + + @POST + @Consumes(MediaType.APPLICATION_JSON) @Path("/account-hooks") - public Response getScreeners(@Context SecurityIdentity identity, - @QueryParam("action") String action) { + public Response accountHooks(@Context SecurityIdentity identity, + AccountHookRequest request) { + + Set hooks = request.hooks(); String userId = AuthUtils.getUserId(identity); + if (userId == null) { return Response.status(Response.Status.UNAUTHORIZED) .entity(new ApiError(true, "Unauthorized.")).build(); } - if (action == null || action.isBlank()) { - return Response.status(Response.Status.BAD_REQUEST).entity( - new ApiError(true, "Query parameter 'action' is required.")) - .build(); - } - Log.info("Running account hooks for: " + userId); + // Map of AccountHookAction to a hook side-effect function + // The function returns whether the side-effect was successful + Map> hooksMap = Map.of( + AccountHookAction.ADD_EXAMPLE_SCREENER, + accountHooks::addExampleScreenerToAccount, + AccountHookAction.UNABLE_TO_DETERMINE, + (String uId) -> true); - if (action.equals("add example screener")) { - Log.info("***** Adding an exaample screener to the account *****"); - } + // Run each action's function and determine whether successful + Map hookResults = hooks.stream() + .collect(Collectors.toMap(s -> s.toString(), s -> { + Predicate fn = hooksMap.get(s); + return fn.test(userId); + })); - AccountHookResponse responseBody = new AccountHookResponse( - "add example screener", true); + AccountHookResponse responseBody = new AccountHookResponse(true, + hookResults); return Response.ok(responseBody).build(); } diff --git a/builder-api/src/main/java/org/acme/enums/AccountHookAction.java b/builder-api/src/main/java/org/acme/enums/AccountHookAction.java new file mode 100644 index 00000000..153bd1a9 --- /dev/null +++ b/builder-api/src/main/java/org/acme/enums/AccountHookAction.java @@ -0,0 +1,30 @@ +package org.acme.enums; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public enum AccountHookAction { + ADD_EXAMPLE_SCREENER("add example screener"), + UNABLE_TO_DETERMINE("UNABLE_TO_DETERMINE"); + + private final String label; + + AccountHookAction(String label) { + this.label = label; + } + + @JsonValue + public String getLabel() { + return label; + } + + @JsonCreator + public static AccountHookAction fromValue(String value) { + for (AccountHookAction action : values()) { + if (action.label.equalsIgnoreCase(value)) { + return action; + } + } + return UNABLE_TO_DETERMINE; + } +} \ No newline at end of file diff --git a/builder-api/src/main/java/org/acme/functions/AccountHooks.java b/builder-api/src/main/java/org/acme/functions/AccountHooks.java new file mode 100644 index 00000000..05463557 --- /dev/null +++ b/builder-api/src/main/java/org/acme/functions/AccountHooks.java @@ -0,0 +1,34 @@ +package org.acme.functions; + +import io.quarkus.logging.Log; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import org.acme.model.domain.Screener; +import org.acme.persistence.ScreenerRepository; + +@ApplicationScoped +public class AccountHooks { + @Inject + ScreenerRepository screenerRepository; + + public Boolean addExampleScreenerToAccount(String userId) { + try { + Log.info("Running ADD_EXAMPLE_SCREENER hook for user: " + userId); + String screenerName = "Example screener - Philly Property Tax Relief"; + String screenerDescription = "Example description"; + Screener exampleScreener = Screener + .create(userId, screenerName, screenerDescription); + + String screenerId = screenerRepository + .saveNewWorkingScreener(exampleScreener); + return true; + } catch (Exception e) { + Log.error( + "Failed to run ADD_EXAMPLE_SCREENER hook for user: " + + userId, + e); + return false; + } + } +} diff --git a/builder-api/src/main/java/org/acme/model/dto/Auth/AccountHookRequest.java b/builder-api/src/main/java/org/acme/model/dto/Auth/AccountHookRequest.java new file mode 100644 index 00000000..158c4821 --- /dev/null +++ b/builder-api/src/main/java/org/acme/model/dto/Auth/AccountHookRequest.java @@ -0,0 +1,8 @@ +package org.acme.model.dto.Auth; + +import java.util.Set; + +import org.acme.enums.AccountHookAction; + +public record AccountHookRequest(Set hooks) { +} \ No newline at end of file diff --git a/builder-api/src/main/java/org/acme/model/dto/Auth/AccountHookResponse.java b/builder-api/src/main/java/org/acme/model/dto/Auth/AccountHookResponse.java index d64cddb9..8585daf7 100644 --- a/builder-api/src/main/java/org/acme/model/dto/Auth/AccountHookResponse.java +++ b/builder-api/src/main/java/org/acme/model/dto/Auth/AccountHookResponse.java @@ -1,4 +1,7 @@ package org.acme.model.dto.Auth; -public record AccountHookResponse(String action, boolean success) { +import java.util.Map; + +public record AccountHookResponse(Boolean success, + Map actions) { }; diff --git a/builder-frontend/src/App.tsx b/builder-frontend/src/App.tsx index 0e06f32a..2b954b51 100644 --- a/builder-frontend/src/App.tsx +++ b/builder-frontend/src/App.tsx @@ -9,25 +9,19 @@ import Screener from "./components/screener/Screener"; import Loading from "./components/Loading"; import { Match, Switch } from "solid-js"; - const ProtectedRoute = (props) => { - const { user, isAuthLoading } = useAuth(); - - const userThing = () => { - console.log(user()) - return user(); - } + const { user, isAuthLoading, isProvisioningAccount } = useAuth(); // If user is logged in, render the requested component, otherwise redirect to login return ( - + - + - + @@ -39,11 +33,23 @@ function App() { <> - } /> - } /> - } /> - -
404 - Page Not Found
} /> + } + /> + } + /> + } + /> + +
404 - Page Not Found
} + /> ); } diff --git a/builder-frontend/src/api/account.ts b/builder-frontend/src/api/account.ts index d01e3bea..5c62f228 100644 --- a/builder-frontend/src/api/account.ts +++ b/builder-frontend/src/api/account.ts @@ -1,19 +1,18 @@ import { env } from "@/config/environment"; -import { authGet } from "@/api/auth"; +import { authPost } from "@/api/auth"; const apiUrl = env.apiUrl; export const getAccountHooks = async () => { - const searchParams = new URLSearchParams([ - ["action", "add example screener"], - ]); - const accountHookUrl = new URL( - `${apiUrl}/account-hooks?${searchParams.toString()}`, - ); + const accountHookUrl = new URL(`${apiUrl}/account-hooks`); + + const hooksToCall = ["add example screener"]; try { - const response = await authGet(accountHookUrl.toString()); + const response = await authPost(accountHookUrl.toString(), { + hooks: hooksToCall, + }); if (!response.ok) { throw new Error(`Account hooks failed with status: ${response.status}`); diff --git a/builder-frontend/src/components/homeScreen/HomeScreen.tsx b/builder-frontend/src/components/homeScreen/HomeScreen.tsx index 86c35384..2433c8d9 100644 --- a/builder-frontend/src/components/homeScreen/HomeScreen.tsx +++ b/builder-frontend/src/components/homeScreen/HomeScreen.tsx @@ -5,6 +5,7 @@ import ProjectsList from "./ProjectsList"; import Header from "../Header/Header"; import BdtNavbar, { NavbarProps } from "@/components/shared/BdtNavbar"; +import { getAccountHooks } from "@/api/account"; 0; const HomeScreen = () => { @@ -12,6 +13,14 @@ const HomeScreen = () => { "screeners", ); + const handleTestHook = () => { + getAccountHooks().then((result) => { + if (result.success) { + console.log(result); + } + }); + }; + const navbarDefs: Accessor = () => { return { tabDefs: [ @@ -25,6 +34,11 @@ const HomeScreen = () => { label: "Eligibility checks", onClick: () => setScreenMode("checks"), }, + { + key: "testAccountHooks", + label: "Test Account Hooks", + onClick: handleTestHook, + }, ], activeTabKey: () => screenMode(), titleDef: null, diff --git a/builder-frontend/src/context/AuthContext.jsx b/builder-frontend/src/context/AuthContext.tsx similarity index 54% rename from builder-frontend/src/context/AuthContext.jsx rename to builder-frontend/src/context/AuthContext.tsx index 176d0756..fd84f8aa 100644 --- a/builder-frontend/src/context/AuthContext.jsx +++ b/builder-frontend/src/context/AuthContext.tsx @@ -15,7 +15,6 @@ import { } from "firebase/auth"; import { auth } from "../firebase/firebase"; -import { authGet } from "@/api/auth"; import { getAccountHooks } from "@/api/account"; const AuthContext = createContext(); @@ -24,6 +23,7 @@ const googleProvider = new GoogleAuthProvider(); export function AuthProvider(props) { const [user, setUser] = createSignal("loading"); const [isAuthLoading, setIsAuthLoading] = createSignal(true); + const [isProvisioningAccount, setIsProvisioningAccount] = createSignal(false); let unsubscribe; onMount(() => { @@ -43,25 +43,45 @@ export function AuthProvider(props) { }; const register = async (email, password) => { + setIsProvisioningAccount(true); return createUserWithEmailAndPassword(auth, email, password).then( (userCredential) => { - // Call "account creation hook" API endpoint here? - console.log("***** after account creation hook *****"); - getAccountHooks().then( - () => { - console.log("Successfully hooked the account."); - }, - (error) => { - console.log("Error hooking the account", error); - }, - ); + getAccountHooks() + .then( + () => { + console.log("Successfully hooked the account."); + }, + (error) => { + console.log("Error hooking the account", error); + }, + ) + .finally(() => { + setIsProvisioningAccount(false); + }); }, ); }; const loginWithGoogle = async () => { try { - return signInWithPopup(auth, googleProvider); + return signInWithPopup(auth, googleProvider).then((userCredential) => { + const { operationType } = userCredential; + if (operationType === "link" || operationType === "reauthenticate") { + setIsProvisioningAccount(true); + getAccountHooks() + .then( + () => { + console.log("Successfully hooked the account."); + }, + (error) => { + console.log("Error hooking the account", error); + }, + ) + .finally(() => { + setIsProvisioningAccount(false); + }); + } + }); } catch (error) { console.error("Google sign-in error:", error.message); } @@ -73,7 +93,15 @@ export function AuthProvider(props) { return ( {props.children} From 48ba520d80849759662e8976aa29e85cb3caf360 Mon Sep 17 00:00:00 2001 From: joshwanf <17016446+joshwanf@users.noreply.github.com> Date: Sun, 15 Mar 2026 13:26:40 -0400 Subject: [PATCH 3/6] Removed temporary testing button --- .../src/components/homeScreen/HomeScreen.tsx | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/builder-frontend/src/components/homeScreen/HomeScreen.tsx b/builder-frontend/src/components/homeScreen/HomeScreen.tsx index 2433c8d9..e2c60622 100644 --- a/builder-frontend/src/components/homeScreen/HomeScreen.tsx +++ b/builder-frontend/src/components/homeScreen/HomeScreen.tsx @@ -5,22 +5,12 @@ import ProjectsList from "./ProjectsList"; import Header from "../Header/Header"; import BdtNavbar, { NavbarProps } from "@/components/shared/BdtNavbar"; -import { getAccountHooks } from "@/api/account"; -0; const HomeScreen = () => { const [screenMode, setScreenMode] = createSignal<"screeners" | "checks">( "screeners", ); - const handleTestHook = () => { - getAccountHooks().then((result) => { - if (result.success) { - console.log(result); - } - }); - }; - const navbarDefs: Accessor = () => { return { tabDefs: [ @@ -34,11 +24,6 @@ const HomeScreen = () => { label: "Eligibility checks", onClick: () => setScreenMode("checks"), }, - { - key: "testAccountHooks", - label: "Test Account Hooks", - onClick: handleTestHook, - }, ], activeTabKey: () => screenMode(), titleDef: null, From 3d1e2567aa245a3bb5c5317a3ac2e6e183ba38ee Mon Sep 17 00:00:00 2001 From: joshwanf <17016446+joshwanf@users.noreply.github.com> Date: Sat, 14 Mar 2026 15:30:43 -0400 Subject: [PATCH 4/6] account-hooks endpoint adds example screener to account, synced frontend app loading to with account-hooks completion --- .../src/components/homeScreen/HomeScreen.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/builder-frontend/src/components/homeScreen/HomeScreen.tsx b/builder-frontend/src/components/homeScreen/HomeScreen.tsx index e2c60622..26c32547 100644 --- a/builder-frontend/src/components/homeScreen/HomeScreen.tsx +++ b/builder-frontend/src/components/homeScreen/HomeScreen.tsx @@ -11,6 +11,14 @@ const HomeScreen = () => { "screeners", ); + const handleTestHook = () => { + getAccountHooks().then((result) => { + if (result.success) { + console.log(result); + } + }); + }; + const navbarDefs: Accessor = () => { return { tabDefs: [ @@ -24,6 +32,11 @@ const HomeScreen = () => { label: "Eligibility checks", onClick: () => setScreenMode("checks"), }, + { + key: "testAccountHooks", + label: "Test Account Hooks", + onClick: handleTestHook, + }, ], activeTabKey: () => screenMode(), titleDef: null, From c726724beaaa26e93b20ef13e55df2fe19b50a40 Mon Sep 17 00:00:00 2001 From: joshwanf <17016446+joshwanf@users.noreply.github.com> Date: Sun, 15 Mar 2026 13:26:40 -0400 Subject: [PATCH 5/6] Removed temporary testing button --- .../src/components/homeScreen/HomeScreen.tsx | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/builder-frontend/src/components/homeScreen/HomeScreen.tsx b/builder-frontend/src/components/homeScreen/HomeScreen.tsx index 26c32547..e2c60622 100644 --- a/builder-frontend/src/components/homeScreen/HomeScreen.tsx +++ b/builder-frontend/src/components/homeScreen/HomeScreen.tsx @@ -11,14 +11,6 @@ const HomeScreen = () => { "screeners", ); - const handleTestHook = () => { - getAccountHooks().then((result) => { - if (result.success) { - console.log(result); - } - }); - }; - const navbarDefs: Accessor = () => { return { tabDefs: [ @@ -32,11 +24,6 @@ const HomeScreen = () => { label: "Eligibility checks", onClick: () => setScreenMode("checks"), }, - { - key: "testAccountHooks", - label: "Test Account Hooks", - onClick: handleTestHook, - }, ], activeTabKey: () => screenMode(), titleDef: null, From 4e56da1f8a371dda077c7b633b757bdf57bb5ed6 Mon Sep 17 00:00:00 2001 From: joshwanf <17016446+joshwanf@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:34:00 -0400 Subject: [PATCH 6/6] ExampleScreenerImportService reads Firestore exports and builds new screeners linked to the user --- .../java/org/acme/functions/AccountHooks.java | 14 +- .../service/ExampleScreenerImportService.java | 425 ++++++++++++++++++ 2 files changed, 429 insertions(+), 10 deletions(-) create mode 100644 builder-api/src/main/java/org/acme/service/ExampleScreenerImportService.java 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 05463557..b91b166e 100644 --- a/builder-api/src/main/java/org/acme/functions/AccountHooks.java +++ b/builder-api/src/main/java/org/acme/functions/AccountHooks.java @@ -4,24 +4,18 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; -import org.acme.model.domain.Screener; -import org.acme.persistence.ScreenerRepository; +import org.acme.service.ExampleScreenerImportService; @ApplicationScoped public class AccountHooks { @Inject - ScreenerRepository screenerRepository; + ExampleScreenerImportService exampleScreenerImportService; public Boolean addExampleScreenerToAccount(String userId) { try { Log.info("Running ADD_EXAMPLE_SCREENER hook for user: " + userId); - String screenerName = "Example screener - Philly Property Tax Relief"; - String screenerDescription = "Example description"; - Screener exampleScreener = Screener - .create(userId, screenerName, screenerDescription); - - String screenerId = screenerRepository - .saveNewWorkingScreener(exampleScreener); + String screenerId = exampleScreenerImportService.importForUser(userId); + Log.info("Imported example screener " + screenerId + " for user " + userId); return true; } catch (Exception e) { Log.error( diff --git a/builder-api/src/main/java/org/acme/service/ExampleScreenerImportService.java b/builder-api/src/main/java/org/acme/service/ExampleScreenerImportService.java new file mode 100644 index 00000000..8508ed3f --- /dev/null +++ b/builder-api/src/main/java/org/acme/service/ExampleScreenerImportService.java @@ -0,0 +1,425 @@ +package org.acme.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.quarkus.logging.Log; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.acme.model.domain.Benefit; +import org.acme.model.domain.BenefitDetail; +import org.acme.model.domain.CheckConfig; +import org.acme.model.domain.EligibilityCheck; +import org.acme.model.domain.Screener; +import org.acme.persistence.EligibilityCheckRepository; +import org.acme.persistence.ScreenerRepository; +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.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +@ApplicationScoped +public class ExampleScreenerImportService { + + private final ScreenerRepository screenerRepository; + private final EligibilityCheckRepository eligibilityCheckRepository; + private final StorageService storageService; + private final Optional configuredSeedPath; + private final ObjectMapper objectMapper; + + @Inject + public ExampleScreenerImportService( + ScreenerRepository screenerRepository, + EligibilityCheckRepository eligibilityCheckRepository, + StorageService storageService, + @ConfigProperty(name = "example-screener.seed-path") Optional configuredSeedPath + ) { + this.screenerRepository = screenerRepository; + this.eligibilityCheckRepository = eligibilityCheckRepository; + this.storageService = storageService; + this.configuredSeedPath = configuredSeedPath; + 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() + )); + } + + Screener importedScreener = new Screener(); + importedScreener.setOwnerId(userId); + importedScreener.setScreenerName(seedData.screener().getScreenerName()); + importedScreener.setBenefits(importedBenefitDetails); + + String newScreenerId = screenerRepository.saveNewWorkingScreener(importedScreener); + importedScreener.setId(newScreenerId); + + for (Benefit importedBenefit : importedBenefits) { + screenerRepository.saveNewCustomBenefit(newScreenerId, importedBenefit); + } + + String formPath = storageService.getScreenerWorkingFormSchemaPath(newScreenerId); + storageService.writeJsonToStorage(formPath, seedData.formSchema()); + + Log.info("Imported example screener " + newScreenerId + " for user " + userId); + return newScreenerId; + } + + 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); + } + } + } + + Map remappedCheckIds = new HashMap<>(); + for (String seedSourceCheckId : referencedCustomCheckIds) { + SeedCustomCheckVersions seedCustomCheckVersions = findSeedCustomCheckVersions(seedData, seedSourceCheckId); + + if (seedCustomCheckVersions.workingCheck() != null) { + String newWorkingId = upsertWorkingCustomCheck( + userId, + seedCustomCheckVersions.workingCheck(), + seedData.dmnByCheckId() + ); + remappedCheckIds.put(seedCustomCheckVersions.workingCheck().getId(), newWorkingId); + } + + if (seedCustomCheckVersions.publishedCheck() != null) { + String newPublishedId = upsertPublishedCustomCheck( + userId, + seedCustomCheckVersions.publishedCheck(), + seedData.dmnByCheckId() + ); + remappedCheckIds.put(seedCustomCheckVersions.publishedCheck().getId(), newPublishedId); + } + + if (!remappedCheckIds.containsKey(seedSourceCheckId)) { + throw new IllegalStateException("No imported check mapping found for seed check " + seedSourceCheckId); + } + } + + return remappedCheckIds; + } + + private SeedCustomCheckVersions findSeedCustomCheckVersions(SeedData seedData, String seedSourceCheckId) { + EligibilityCheck referencedCheck = seedData.workingCustomChecks().get(seedSourceCheckId); + if (referencedCheck == null) { + referencedCheck = seedData.publishedCustomChecks().get(seedSourceCheckId); + } + if (referencedCheck == null) { + throw new IllegalStateException("Missing seed custom check for referenced id " + seedSourceCheckId); + } + + String seedWorkingId = buildWorkingCheckId( + referencedCheck.getOwnerId(), + referencedCheck.getModule(), + referencedCheck.getName() + ); + String seedPublishedId = buildPublishedCheckId( + referencedCheck.getOwnerId(), + referencedCheck.getModule(), + referencedCheck.getName(), + referencedCheck.getVersion() + ); + + return new SeedCustomCheckVersions( + seedData.workingCustomChecks().get(seedWorkingId), + seedData.publishedCustomChecks().get(seedPublishedId) + ); + } + + private String upsertWorkingCustomCheck( + String userId, + EligibilityCheck seedCheck, + Map dmnByCheckId + ) throws Exception { + EligibilityCheck importedCheck = cloneEligibilityCheck(seedCheck); + importedCheck.setOwnerId(userId); + importedCheck.setIsArchived(false); + + String newWorkingId = buildWorkingCheckId(userId, importedCheck.getModule(), importedCheck.getName()); + importedCheck.setId(newWorkingId); + + if (eligibilityCheckRepository.getWorkingCustomCheck(userId, newWorkingId, true).isPresent()) { + eligibilityCheckRepository.updateWorkingCustomCheck(importedCheck); + } else { + eligibilityCheckRepository.saveNewWorkingCustomCheck(importedCheck); + } + + writeCheckDmn(newWorkingId, seedCheck, dmnByCheckId); + return newWorkingId; + } + + private String upsertPublishedCustomCheck( + String userId, + EligibilityCheck seedCheck, + Map dmnByCheckId + ) throws Exception { + EligibilityCheck importedCheck = cloneEligibilityCheck(seedCheck); + importedCheck.setOwnerId(userId); + importedCheck.setIsArchived(false); + + String newPublishedId = buildPublishedCheckId( + userId, + importedCheck.getModule(), + importedCheck.getName(), + importedCheck.getVersion() + ); + importedCheck.setId(newPublishedId); + + if (eligibilityCheckRepository.getPublishedCustomCheck(userId, newPublishedId).isPresent()) { + eligibilityCheckRepository.updatePublishedCustomCheck(importedCheck); + } else { + try { + eligibilityCheckRepository.saveNewPublishedCustomCheck(importedCheck); + } catch (Exception e) { + eligibilityCheckRepository.updatePublishedCustomCheck(importedCheck); + } + } + + writeCheckDmn(newPublishedId, seedCheck, dmnByCheckId); + return newPublishedId; + } + + private void writeCheckDmn(String newCheckId, EligibilityCheck seedCheck, Map dmnByCheckId) throws Exception { + String dmnModel = dmnByCheckId.get(seedCheck.getId()); + if ((dmnModel == null || dmnModel.isBlank()) && seedCheck.getDmnModel() != null) { + dmnModel = seedCheck.getDmnModel(); + } + if (dmnModel == null || dmnModel.isBlank()) { + throw new IllegalStateException("Missing DMN model for seed check " + seedCheck.getId()); + } + + storageService.writeStringToStorage( + storageService.getCheckDmnModelPath(newCheckId), + dmnModel, + "application/xml" + ); + } + + private Benefit cloneBenefit(Benefit seedBenefit, String userId, Map importedCustomCheckIds) { + Benefit importedBenefit = objectMapper.convertValue(seedBenefit, Benefit.class); + importedBenefit.setId(UUID.randomUUID().toString()); + importedBenefit.setOwnerId(userId); + importedBenefit.setChecks(remapCheckConfigs(seedBenefit.getChecks(), importedCustomCheckIds)); + return importedBenefit; + } + + private List remapCheckConfigs(List seedChecks, Map importedCustomCheckIds) { + if (seedChecks == null || seedChecks.isEmpty()) { + return Collections.emptyList(); + } + + List importedChecks = new ArrayList<>(); + for (CheckConfig seedCheck : seedChecks) { + CheckConfig importedCheck = objectMapper.convertValue(seedCheck, CheckConfig.class); + importedCheck.setCheckId(UUID.randomUUID().toString()); + + String sourceCheckId = resolveSourceCheckId(seedCheck); + if (sourceCheckId != null) { + if (isLibraryCheckId(sourceCheckId)) { + importedCheck.setSourceCheckId(sourceCheckId); + } else { + String remappedSourceCheckId = importedCustomCheckIds.get(sourceCheckId); + if (remappedSourceCheckId == null) { + throw new IllegalStateException("Missing imported custom check id for " + sourceCheckId); + } + importedCheck.setSourceCheckId(remappedSourceCheckId); + } + } + + importedChecks.add(importedCheck); + } + + return importedChecks; + } + + private EligibilityCheck cloneEligibilityCheck(EligibilityCheck seedCheck) { + return objectMapper.convertValue(seedCheck, EligibilityCheck.class); + } + + private String resolveSourceCheckId(CheckConfig checkConfig) { + if (checkConfig.getSourceCheckId() != null && !checkConfig.getSourceCheckId().isBlank()) { + return checkConfig.getSourceCheckId(); + } + return checkConfig.getCheckId(); + } + + private boolean isLibraryCheckId(String checkId) { + return checkId != null && checkId.startsWith("L"); + } + + 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()); + } + + Path screenerFile = screenerFiles.get(0); + 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)); + } + + JsonNode formSchema = objectMapper.readTree( + Files.readString(seedRoot.resolve("storage").resolve("form").resolve("working").resolve(screenerDocId + ".json")) + ); + + return new SeedData( + screener, + benefits, + formSchema, + loadChecks(seedRoot.resolve("firestore").resolve("workingCustomCheck")), + loadChecks(seedRoot.resolve("firestore").resolve("publishedCustomCheck")), + loadDmnFiles(seedRoot.resolve("storage").resolve("check")) + ); + } + + private Map loadChecks(Path checksDir) throws IOException { + Map checksById = new LinkedHashMap<>(); + if (!Files.isDirectory(checksDir)) { + return checksById; + } + + for (Path checkFile : listJsonFiles(checksDir)) { + EligibilityCheck check = readJsonFile(checkFile, EligibilityCheck.class); + checksById.put(check.getId(), check); + } + return checksById; + } + + private Map loadDmnFiles(Path dmnDir) throws IOException { + Map dmnByCheckId = new HashMap<>(); + if (!Files.isDirectory(dmnDir)) { + return dmnByCheckId; + } + + try (var stream = Files.list(dmnDir)) { + stream + .filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(".dmn")) + .sorted(Comparator.comparing(path -> path.getFileName().toString())) + .forEach(path -> { + try { + dmnByCheckId.put(stripExtension(path.getFileName().toString()), Files.readString(path)); + } catch (IOException e) { + throw new RuntimeException("Failed to read DMN file " + path, e); + } + }); + } catch (RuntimeException e) { + if (e.getCause() instanceof IOException ioException) { + throw ioException; + } + throw e; + } + + return dmnByCheckId; + } + + private T readJsonFile(Path path, Class clazz) throws IOException { + return objectMapper.readValue(Files.readString(path), clazz); + } + + private List listJsonFiles(Path directory) throws IOException { + if (!Files.isDirectory(directory)) { + return Collections.emptyList(); + } + + try (var stream = Files.list(directory)) { + return stream + .filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(".json")) + .sorted(Comparator.comparing(path -> path.getFileName().toString())) + .toList(); + } + } + + private Path resolveSeedRoot() { + List candidates = new ArrayList<>(); + 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")); + + for (Path candidate : candidates) { + Path absoluteCandidate = candidate.toAbsolutePath().normalize(); + if (Files.isDirectory(absoluteCandidate)) { + return absoluteCandidate; + } + } + + throw new IllegalStateException("Could not find example screener seed data in any expected location"); + } + + private String buildWorkingCheckId(String ownerId, String module, String name) { + return "W-" + ownerId + "-" + module + "-" + name; + } + + private String buildPublishedCheckId(String ownerId, String module, String name, String version) { + return "P-" + ownerId + "-" + module + "-" + name + "-" + version; + } + + private String stripExtension(String filename) { + int extensionIndex = filename.lastIndexOf('.'); + if (extensionIndex == -1) { + return filename; + } + return filename.substring(0, extensionIndex); + } + + private record SeedData( + Screener screener, + List benefits, + JsonNode formSchema, + Map workingCustomChecks, + Map publishedCustomChecks, + Map dmnByCheckId + ) {} + + private record SeedCustomCheckVersions( + EligibilityCheck workingCheck, + EligibilityCheck publishedCheck + ) {} +}