diff --git a/.gitignore b/.gitignore index 5a03bc3..4a9ccc7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ Thumbs.db .DS_Store .gradle build/ +bin/ target/ out/ .micronaut/ diff --git a/build.gradle.kts b/build.gradle.kts index eda8aee..8636268 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -32,6 +32,8 @@ dependencies { implementation("io.micronaut.picocli:micronaut-picocli") implementation("io.micronaut.serde:micronaut-serde-jackson") implementation("io.micronaut:micronaut-retry") + implementation("org.simplejavamail:simple-java-mail:8.12.6") + implementation("org.jsoup:jsoup:1.21.2") testImplementation("net.datafaker:datafaker:2.5.1") compileOnly("org.projectlombok:lombok") runtimeOnly("ch.qos.logback:logback-classic") diff --git a/src/main/java/org/justserve/cli/JustServeCommand.java b/src/main/java/org/justserve/cli/JustServeCommand.java index 6fb1f7c..4bb71a2 100644 --- a/src/main/java/org/justserve/cli/JustServeCommand.java +++ b/src/main/java/org/justserve/cli/JustServeCommand.java @@ -4,12 +4,13 @@ import org.justserve.cli.command.BaseCommand; import org.justserve.cli.command.GetTempPassword; import org.justserve.cli.command.MakeOrgAdmin; +import org.justserve.cli.command.UnReassignProjects; import org.justserve.cli.util.JustServeVersionProvider; import picocli.CommandLine.Command; import picocli.CommandLine.ParameterException; import picocli.jansi.graalvm.AnsiConsole; -@Command(subcommands = {GetTempPassword.class, MakeOrgAdmin.class}, +@Command(subcommands = {GetTempPassword.class, MakeOrgAdmin.class, UnReassignProjects.class}, mixinStandardHelpOptions = true, name = "justserve", versionProvider = JustServeVersionProvider.class, description = "justserve-cli is a terminal tool to help specialists and admin using JustServe") diff --git a/src/main/java/org/justserve/cli/command/BaseCommand.java b/src/main/java/org/justserve/cli/command/BaseCommand.java index ad5215a..61451ee 100644 --- a/src/main/java/org/justserve/cli/command/BaseCommand.java +++ b/src/main/java/org/justserve/cli/command/BaseCommand.java @@ -23,14 +23,14 @@ public class BaseCommand implements ConsoleOutput { @Value("${justserve.token}") String token; - boolean validateToken() { + boolean isTokenInvalid() { if ("i-need-to-be-defined".equals(token) || null == token) { printError(("NO AUTHENTICATION PROVIDED" + System.lineSeparator() + "The Authentication token is not assigned as an environment variable." + System.lineSeparator() + "Please define the environment variable \"JUSTSERVE_TOKEN\" and try again.")); - return false; + return true; } - return true; + return false; } @Override diff --git a/src/main/java/org/justserve/cli/command/GetTempPassword.java b/src/main/java/org/justserve/cli/command/GetTempPassword.java index 5c9b4b4..72d9fea 100644 --- a/src/main/java/org/justserve/cli/command/GetTempPassword.java +++ b/src/main/java/org/justserve/cli/command/GetTempPassword.java @@ -26,7 +26,7 @@ public class GetTempPassword extends BaseCommand implements Runnable { @Override public void run() { - if (!validateToken()) { + if (isTokenInvalid()) { return; } HttpResponse response; diff --git a/src/main/java/org/justserve/cli/command/MakeOrgAdmin.java b/src/main/java/org/justserve/cli/command/MakeOrgAdmin.java index a1b6f95..d9f382a 100644 --- a/src/main/java/org/justserve/cli/command/MakeOrgAdmin.java +++ b/src/main/java/org/justserve/cli/command/MakeOrgAdmin.java @@ -40,7 +40,7 @@ public class MakeOrgAdmin extends BaseCommand implements Runnable { @Override public void run() { - if (!validateToken()) { + if (isTokenInvalid()) { return; } DynamicRoutingClient dynamicRoutingClient = dynamicRoutingClientProvider.get(); diff --git a/src/main/java/org/justserve/cli/command/UnReassignProjects.java b/src/main/java/org/justserve/cli/command/UnReassignProjects.java new file mode 100644 index 0000000..5cb943c --- /dev/null +++ b/src/main/java/org/justserve/cli/command/UnReassignProjects.java @@ -0,0 +1,123 @@ +package org.justserve.cli.command; + +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.client.exceptions.HttpClientResponseException; +import jakarta.inject.Inject; +import jakarta.inject.Provider; +import jakarta.mail.MessagingException; +import lombok.extern.slf4j.Slf4j; +import org.justserve.cli.util.JustServeEmailParserError; +import org.justserve.client.ProjectClient; +import org.justserve.model.GetProjectRequest; +import org.justserve.model.Project; +import org.justserve.model.ReassignProjectRequest; +import org.justserve.util.EmailParser; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Map; +import java.util.UUID; + +import static org.justserve.cli.util.JustServePrinter.printError; +import static org.justserve.cli.util.JustServePrinter.printNormal; + +@Slf4j +@Command(name = "unReassignProjects", description = "Reassigns projects from an email file to a user", mixinStandardHelpOptions = true) +public class UnReassignProjects extends BaseCommand implements Runnable { + + @Option(names = {"--user", "-u"}, description = "the user who will be assigned the projects", required = true) + private UUID userID; + + @Option(names = {"--file", "-f"}, description = "the .eml file containing the projects to reassign", required = true) + private File emlFile; + + @Inject + Provider projectClientProvider; + + @Override + public void run() { + if (isTokenInvalid()) { + return; + } + + if (!emlFile.exists() || !emlFile.isFile()) { + printError("The provided file does not exist or is not a file: " + emlFile.getAbsolutePath()); + return; + } + + String emlContent; + try { + emlContent = Files.readString(emlFile.toPath()); + } catch (IOException e) { + printError("Failed to read file: " + emlFile.getAbsolutePath()); + log.atError().setCause(e).log("Error reading file"); + return; + } + + Map projects; + try { + projects = EmailParser.getProjects(emlContent); + } catch (MessagingException | IOException | JustServeEmailParserError e) { + printError("Failed to parse email file: " + e.getMessage()); + log.atError().setCause(e).log("Error parsing email file"); + return; + } + + if (projects.isEmpty()) { + printNormal("No projects found in the email file."); + return; + } + + + ProjectClient client = projectClientProvider.get(); + int successCount = 0; + + for (Map.Entry entry : projects.entrySet()) { + String projectName = entry.getKey(); + UUID projectId = entry.getValue(); + GetProjectRequest getProjectRequest = new GetProjectRequest(); + Project project; + try { + project = client.getProject(projectId, " ", getProjectRequest).body(); + } catch (HttpClientResponseException | NullPointerException e) { + printError("Failed to get project " + projectName + " (" + projectId + ")"); + log.atError().setCause(e).log("Error getting project"); + continue; + } + if (null == project.getProjectOwnerUserId()) { + warning(String.format("Project %s (%s) has no owner", projectName, projectId)); + log.warn("Project {} ({}) has no owner", projectName, projectId); + continue; + } + if (project.getProjectOwnerUserId().equals(userID)) { + log.warn("Project {} ({}) is already assigned to user {}", projectName, projectId, userID); + continue; + } + + try { + ReassignProjectRequest reassignProjectRequest = new ReassignProjectRequest(userID, project.getProjectOwnerUserId()); + log.atTrace().log("Reassigning project {} ({}) to user {}", projectName, projectId, userID); + HttpResponse reassignResponse = client.reassignProject(projectId, reassignProjectRequest); + if (reassignResponse.status() == HttpStatus.OK) { + printNormal("Successfully reassigned project %s (%s) to user %s", projectName, projectId, userID); + log.atTrace().log("received api response status: {}", reassignResponse.status()); + successCount++; + continue; + } + printError("Failed to reassign project " + projectName + " (" + projectId + ") to user " + userID + + ". Expected HTTP Status 'OK', but got " + reassignResponse.status()); + log.atError().log("Failed to reassign project {} ({}) to user {}. Expected HTTP Status 'OK', but got {}", + projectName, projectId, userID, reassignResponse.status()); + } catch (HttpClientResponseException e) { + printError("Failed to reassign project " + projectName + " (" + projectId + ") to user " + userID); + log.atError().setCause(e).log("Error response from API: {}", e.getResponse().body()); + } + } + printNormal("Successfully reassigned %d projects to user %s", successCount, userID); + log.atTrace().log("Finished reassigning projects to user {}", userID); + } +} diff --git a/src/main/java/org/justserve/cli/util/JustServeEmailParserError.java b/src/main/java/org/justserve/cli/util/JustServeEmailParserError.java new file mode 100644 index 0000000..66809e6 --- /dev/null +++ b/src/main/java/org/justserve/cli/util/JustServeEmailParserError.java @@ -0,0 +1,7 @@ +package org.justserve.cli.util; + +public class JustServeEmailParserError extends Exception { + public JustServeEmailParserError(String message) { + super(message); + } +} diff --git a/src/main/java/org/justserve/util/EmailParser.java b/src/main/java/org/justserve/util/EmailParser.java new file mode 100644 index 0000000..968d48d --- /dev/null +++ b/src/main/java/org/justserve/util/EmailParser.java @@ -0,0 +1,142 @@ +package org.justserve.util; + +import jakarta.mail.MessagingException; +import jakarta.mail.Multipart; +import jakarta.mail.Part; +import jakarta.mail.Session; +import jakarta.mail.internet.MimeMessage; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Node; +import org.jsoup.select.Elements; +import org.justserve.cli.util.JustServeEmailParserError; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.regex.Pattern; + +public class EmailParser { + + + /** + * Parses an EML format string, extracts the HTML body, verifies it's a JustServe generated email, + * and then extracts project names and their corresponding UUIDs. + * + * @param emlFileContent The content of the EML file as a String. + * @return A map where keys are project names (String) and values are project UUIDs. + * @throws MessagingException If there is an issue with parsing the MimeMessage. + * @throws IOException If an I/O error occurs during stream processing. + * @throws JustServeEmailParserError If the email does not contain an HTML body, is not a JustServe generated email, + * or the HTML structure does not conform to the expected format for extracting projects. + */ + public static Map getProjects(String emlFileContent) throws MessagingException, IOException, JustServeEmailParserError { + return getProjects(parse(emlFileContent)); + } + + /** + * Parses an EML format string into a Jsoup Document, ensuring it's a JustServe generated email. + * + * @param emlFileContent The content of the EML file as a String. + * @return A Jsoup Document representing the HTML body of the email. + * @throws MessagingException If there is an issue with parsing the MimeMessage. + * @throws IOException If an I/O error occurs during stream processing. + * @throws JustServeEmailParserError If the email does not contain an HTML body or is not a JustServe generated email. + */ + public static Document parse(String emlFileContent) throws MessagingException, IOException, JustServeEmailParserError { + Properties props = new Properties(); + Session session = Session.getDefaultInstance(props, null); + try (InputStream is = new ByteArrayInputStream(emlFileContent.getBytes(StandardCharsets.UTF_8))) { + MimeMessage message = new MimeMessage(session, is); + String htmlBody = getHtmlBody(message); + if (htmlBody == null) { + throw new JustServeEmailParserError("Email does not contain an HTML body."); + } + Document doc = Jsoup.parse(htmlBody); + if (!isJustServeGeneratedEmail(doc)) { + throw new JustServeEmailParserError("Email is not a JustServe generated email."); + } + return doc; + } + } + + /** + * Extracts project names and their corresponding UUIDs for project ids on JustServe. + * The Document is expected to represent an HTML email body from an automated JustServe email regarding reassigned projects + * + * @param doc The Jsoup Document containing the HTML structure of the email. + * @return A map where keys are project names (String) and values are project UUIDs. + * @throws JustServeEmailParserError If the HTML structure does not conform to the expected format for extracting projects. + */ + public static Map getProjects(Document doc) throws JustServeEmailParserError { + Map projects = new HashMap<>(); + Collection errors = new ArrayList<>(); + doc.selectXpath("//li").forEach(element -> { + List children = element.childNodes(); + if (children.size() != 1 || children.getFirst().childNodes().size() != 1) { + errors.add(String.format("Expected 1 child node (with another single child node) in html list, but " + "found %d in list element with %d children", children.size(), children.getFirst().childNodes().size())); + } + projects.put(children.getFirst().childNode(0).toString(), getProjectIDFromUglyUrl(children.getFirst().attr("href"))); + }); + if (!errors.isEmpty()) { + throw new JustServeEmailParserError(String.join("\n", errors)); + } + return projects; + } + + /** + * Checks that the email contains an image whose source is the logo hosted in justserve's static assets. + * + * @param doc html content which may or may not have a justserve logo + * @return true if the html contains the target image, false if not. + */ + private static boolean isJustServeGeneratedEmail(Document doc) { + Elements elements = doc.selectXpath("//img[@src='https://static-assets.justserve.org/images/static/email/justserve-logo-title.gif']"); + return !elements.isEmpty(); + } + + + /** + * Recursively extracts the HTML body from a given {@link Part} of an email. + * + * @param part The email part to process. + * @return The HTML content as a String, or null if no HTML part is found. + * @throws MessagingException If there is an issue with the messaging system. + * @throws IOException If an I/O error occurs. + */ + private static String getHtmlBody(Part part) throws MessagingException, IOException { + if (part.isMimeType("text/html")) { + return (String) part.getContent(); + } + + if (part.isMimeType("multipart/*")) { + Multipart multipart = (Multipart) part.getContent(); + for (int i = 0; i < multipart.getCount(); i++) { + Part bodyPart = multipart.getBodyPart(i); + String html = getHtmlBody(bodyPart); + if (html != null) { + return html; + } + } + } + return null; + } + + /** + * gets the project ID from a url, which url is wrapped in proofpoint's URL defense service, and thus looks ugly. + * + * @param uglyUrl long url with an encoded justserve project url. + * @return the UUID of the project contained in the URL. returns null if the URL does + * not contain the 'www.justserve.org*2Fprojects*2F' string. + */ + static UUID getProjectIDFromUglyUrl(String uglyUrl) { + String start = "www.justserve.org*2Fprojects*2F"; + if (!uglyUrl.contains(start)) { + return null; + } + String uuid = uglyUrl.split(Pattern.quote(start))[1].split(Pattern.quote("/"))[0]; + return UUID.fromString(uuid); + } +} diff --git a/src/main/resources/schema.yml b/src/main/resources/schema.yml index 32d6540..f23e0b4 100644 --- a/src/main/resources/schema.yml +++ b/src/main/resources/schema.yml @@ -10,6 +10,7 @@ info: - Image - User - Organization + - Project paths: /api/v1/images: post: @@ -70,6 +71,85 @@ paths: 200: description: OK content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/UserSlimSearchResult' } } } } + /api/v2/projects/search: + post: + tags: [ Project ] + description: search active projects + operationId: searchProjects + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectSearchRequest' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectSearchResponse' + /api/v1/projects/{id}/{locale}: + post: + tags: [ Project ] + description: Get project details for a given project + operationId: getProject + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + - name: locale + in: path + required: true + schema: { type: string } + requestBody: + content: + application/json: + schema: + type: object + properties: + latitude: + type: string + longitude: + type: string + postalCode: + type: string + lang: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Project' + /api/v1/projects/{projectId}/users/reassignAndDelete: + put: + tags: [ Project ] + description: Reassigns multiple projects to a new user. + operationId: reassignProject + parameters: + - name: projectId + in: path + description: ID of the project to be reassigned + required: true + schema: { type: string, format: uuid } + requestBody: + content: + application/json: + schema: + type: object + properties: + assignId: { type: string, format: UUID, description: "UUID of the new owner of the project" } + deleteId: { type: string, format: UUID, description: "UUID of the previous owner of the project" } + additionalProperties: false + responses: + '200': + description: OK + content: + application/json: + schema: + type: null /api/v1/users: post: description: Register a new user on JustServe @@ -388,6 +468,150 @@ components: isLocationSupported: type: boolean additionalProperties: false + ProjectSearchRequest: + type: object + properties: + latitude: { type: number, format: double } + longitude: { type: number, format: double } + searchType: { $ref: '#/components/schemas/SearchLocationType' } + radius: { type: integer, format: int32 } + radiusType: { $ref: '#/components/schemas/DistanceType' } + start: { type: string, format: date-time } + page: { type: integer, format: int32 } + size: { type: integer, format: int32 } + sortBy: { type: string, nullable: true } + language: { type: string, nullable: true } + browserLocale: { type: string, nullable: true } + getProjectSearchOrderBy: { $ref: '#/components/schemas/ProjectSearchOrderBy' } + keywords: { type: string, nullable: true } + location: { type: string, nullable: true } + end: { type: string, format: date-time } + suitableAllAges: { type: boolean } + groupProject: { type: boolean } + volunteerRemotely: { type: boolean } + volunteerFromAnywhere: { type: boolean } + itemDonations: { type: boolean } + wheelchairAccessible: { type: boolean } + indoors: { type: boolean } + onGoing: { type: boolean } + dtl: { type: boolean } + skills: { type: array, items: { type: integer, format: int32 }, nullable: true } + interests: { type: array, items: { type: integer, format: int32 }, nullable: true } + userInitiatedSearch: { type: boolean } + includeOrgInfo: { type: boolean } + includeFilledProjects: { type: boolean } + disasterRecoveryProjectsOnly: { type: boolean } + daysOfWeek: { type: array, items: { $ref: '#/components/schemas/Time.DayOfWeek' }, nullable: true } + timesOfDay: { type: array, items: { $ref: '#/components/schemas/Time.TimeOfDay' }, nullable: true } + publishedOnly: { type: boolean } + additionalProperties: false + ProjectSearchResponse: + type: object + properties: + pageNumber: { type: integer, format: int32 } + pageSize: { type: integer, format: int32 } + items: + type: array + items: { $ref: '#/components/schemas/ProjectCard' } + nullable: true + pageCount: { type: integer, format: int32, nullable: true, readOnly: true } + itemCount: { type: integer, format: int32, nullable: true } + searchLatitude: { type: number, format: double } + searchLongitude: { type: number, format: double } + additionalProperties: false + ProjectCard: + type: object + properties: + id: { type: string, format: uuid } + statusId: { type: integer, format: int32 } + projectTypeId: { type: integer, format: int32 } + title: { type: string, nullable: true } + shortDescription: { type: string, nullable: true } + imagePath: { type: string, nullable: true } + organizationName: { type: string, nullable: true } + suitableAllAges: { type: boolean } + groupProjects: { type: boolean } + volunteerRemotely: { type: boolean } + volunteerFromAnywhere: { type: boolean } + itemDonations: { type: boolean } + wheelchairAccessible: { type: boolean } + indoors: { type: boolean } + forYouthGroups: { type: boolean } + totalNextOpportunities: { type: integer, format: int32, nullable: true } + regionSelected: { type: boolean } + nextOpportunity: { $ref: '#/components/schemas/ProjectCardOpportunity' } + location: { $ref: '#/components/schemas/ProjectCardLocation' } + countryCode: { type: string, nullable: true } + boundaries: + type: array + items: { $ref: '#/components/schemas/RegionCivicGeography' } + nullable: true + additionalProperties: false + ProjectCardOpportunity: + type: object + properties: + projectEventId: { type: string, format: uuid } + start: { type: string, nullable: true } + end: { type: string, nullable: true } + volunteersNeeded: { type: integer, format: int32, nullable: true } + timezone: { type: string, nullable: true } + additionalProperties: false + ProjectCardLocation: + type: object + properties: + city: { type: string, nullable: true } + state: { type: string, nullable: true } + postal: { type: string, nullable: true } + latitude: { type: number, format: double } + longitude: { type: number, format: double } + additionalProperties: false + RegionCivicGeography: + type: object + properties: + type: { $ref: '#/components/schemas/GeographyType' } + name: { type: string, nullable: true } + id: { type: string, nullable: true } + additionalProperties: false + GeographyType: + enum: [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 ] + x-enum-varnames: + - All + - Area + - Mission + - CC + - Stake + - Country + - State + - County + - City + - Zipcode + - Neighborhood + - National + - None + type: integer + format: int32 + SearchLocationType: + enum: [ 0, 1, 2, 3 ] + x-enum-varnames: + - Radius + - State + - Regional + - None + type: integer + format: int32 + ProjectSearchOrderBy: + enum: [ 0, 1, 2, 3, 4, 5, 6, 7 ] + x-enum-varnames: + - None + - Relativity + - Recent + - Title + - TitleReverse + - Created + - NextOpportunity + - Distance + type: integer + format: int32 UserHashRequest: description: | A request containing either the email or the userid for a user. @@ -490,7 +714,7 @@ components: ProjectSlimResponse: type: object properties: - id: { type: string } + id: { type: string, format: uuid} title: { type: string, nullable: true } description: { type: string, nullable: true } projectExpired: { type: boolean } @@ -510,7 +734,7 @@ components: isOwnedOrRepresentedViaOrganization: { type: boolean } isIndividualProject: { type: boolean } projectOwnerName: { type: string, nullable: true } - projectOwnerUserId: { type: string, nullable: true } + projectOwnerUserId: { type: string, format: uuid, nullable: true } relativityScore: { type: number, format: double } additionalProperties: false ProjectStatus: @@ -802,4 +1026,541 @@ components: items: $ref: '#/components/schemas/OrganizationCivicGeographyUserRole' nullable: true - additionalProperties: false \ No newline at end of file + additionalProperties: false + Project: + type: object + properties: + id: + type: string + projectOwners: + type: array + items: + type: object + properties: + id: + type: string + ownerType: + type: number + required: + - id + - ownerType + projectOwnerLocation: + type: object + properties: + mapId: {} + fullDisplayAddress: + type: string + address: {} + suite: {} + city: + type: string + civicCityId: + type: string + neighborhood: {} + county: + type: string + state: + type: string + postal: + type: string + country: + type: string + countryCode: + type: string + locationVisibilityId: + type: number + missionId: + type: string + ccId: + type: string + stakeId: + type: string + areaId: + type: string + latitude: + type: number + longitude: + type: number + maxLatitude: + type: number + minLatitude: + type: number + maxLongitude: + type: number + minLongitude: + type: number + geoCodeOverride: + type: boolean + timezone: {} + required: + - mapId + - fullDisplayAddress + - address + - suite + - city + - civicCityId + - neighborhood + - county + - state + - postal + - country + - countryCode + - locationVisibilityId + - missionId + - ccId + - stakeId + - areaId + - latitude + - longitude + - maxLatitude + - minLatitude + - maxLongitude + - minLongitude + - geoCodeOverride + - timezone + ownerLog: + type: array + items: {} + projectType: + type: number + status: + type: number + publishDate: + type: string + language: + type: string + country: + type: string + title: + type: string + shortDescription: + type: string + longDescription: + type: string + logo: + type: string + attachments: + type: array + items: {} + attachmentInfo: + type: array + items: {} + applicant: + type: object + properties: + submitterUserId: + type: string + firstName: + type: string + lastName: + type: string + email: + type: string + phone: + type: string + postal: + type: string + applicantPostal: + type: string + applicantCity: + type: string + applicantCountry: + type: string + applicantCountryCode: + type: string + assignmentLevel: + type: number + assignedOn: + type: string + required: + - submitterUserId + - firstName + - lastName + - email + - phone + - postal + - applicantPostal + - applicantCity + - applicantCountry + - applicantCountryCode + - assignmentLevel + - assignedOn + contact: + type: object + properties: + name: {} + phone: {} + email: {} + required: + - name + - phone + - email + sponsor: + type: object + properties: + name: + type: string + email: + type: string + phone: + type: string + userId: + type: string + required: + - name + - email + - phone + - userId + representative: + type: object + properties: + name: {} + email: {} + userId: {} + required: + - name + - email + - userId + organization: + type: object + properties: + authorization: + type: boolean + organizationAuthorization: {} + name: {} + description: {} + url: {} + internalUrl: {} + organizationId: {} + reviewedBy: {} + reviewedOn: {} + linked: + type: boolean + logo: {} + required: + - authorization + - organizationAuthorization + - name + - description + - url + - internalUrl + - organizationId + - reviewedBy + - reviewedOn + - linked + - logo + suitableAllAges: + type: boolean + groupProject: + type: boolean + volunteerRemotely: + type: boolean + itemDonations: + type: boolean + wheelchairAccessible: + type: boolean + indoors: + type: boolean + forYouthGroups: + type: boolean + volunteerFromAnywhere: + type: boolean + regionSelected: + type: boolean + temporaryFakeDistanceScore: + type: number + fakeDistanceScoreUpdate: + type: string + skills: + type: array + items: {} + interests: + type: array + items: {} + tools: + type: array + items: {} + projectSkills: + type: array + items: {} + projectInterests: + type: array + items: {} + projectTools: + type: array + items: {} + externalVolunteerURL: + type: string + isExternalProject: + type: boolean + isUnlistedProject: + type: boolean + archivedProject: + type: boolean + isActive: + type: boolean + externalVolunteerCount: + type: number + externalVolunteers: + type: array + items: + type: string + allEventsFilled: + type: boolean + daysOfWeek: + type: array + items: {} + timesOfDay: + type: array + items: {} + waiverURL: {} + dtl: + type: array + items: {} + onGoing: + type: array + items: + type: object + properties: + id: + type: string + start: + type: string + renewDate: + type: string + end: + type: string + schedule: + type: string + scheduleLanguage: + type: object + properties: {} + required: [] + specialDirections: {} + specialDirectionsLanguage: + type: object + properties: {} + required: [] + locationName: + type: string + locationLink: + type: string + location: + type: object + properties: + mapId: {} + fullDisplayAddress: + type: string + address: {} + suite: {} + city: + type: string + civicCityId: {} + neighborhood: {} + county: {} + state: {} + postal: {} + country: + type: string + countryCode: {} + locationVisibilityId: + type: number + missionId: {} + ccId: {} + stakeId: {} + areaId: {} + latitude: + type: number + longitude: + type: number + maxLatitude: + type: number + minLatitude: + type: number + maxLongitude: + type: number + minLongitude: + type: number + geoCodeOverride: + type: boolean + timezone: + type: string + required: + - mapId + - fullDisplayAddress + - address + - suite + - city + - civicCityId + - neighborhood + - county + - state + - postal + - country + - countryCode + - locationVisibilityId + - missionId + - ccId + - stakeId + - areaId + - latitude + - longitude + - maxLatitude + - minLatitude + - maxLongitude + - minLongitude + - geoCodeOverride + - timezone + interested: + type: array + items: {} + contacts: + type: array + items: + type: object + properties: + name: + type: string + phone: + type: string + email: + type: string + required: + - name + - phone + - email + boundaries: + type: array + items: {} + required: + - id + - start + - renewDate + - end + - schedule + - scheduleLanguage + - specialDirections + - specialDirectionsLanguage + - locationName + - locationLink + - location + - interested + - contacts + - boundaries + recurring: {} + lastChangeReason: {} + escalated: {} + created: + type: string + updated: + type: string + createdBy: + type: string + deleted: + type: boolean + deletedOn: {} + deletedBy: {} + firstStartDateTimeOffset: + type: string + lastEndDateTimeOffset: + type: string + cbfName: + type: string + cblName: + type: string + updatedBy: + type: string + ubfName: + type: string + ublName: + type: string + volunteerCentersData: + type: array + items: {} + relativityScore: + type: number + projectOwnerName: + type: string + projectOwnerLastName: + type: string + projectOwnerUserId: + type: string + format: uuid + projectLocationType: + type: number + underReview: + type: boolean + required: + - id + - projectOwners + - projectOwnerLocation + - ownerLog + - projectType + - status + - publishDate + - language + - country + - title + - shortDescription + - longDescription + - logo + - attachments + - attachmentInfo + - applicant + - contact + - sponsor + - representative + - organization + - suitableAllAges + - groupProject + - volunteerRemotely + - itemDonations + - wheelchairAccessible + - indoors + - forYouthGroups + - volunteerFromAnywhere + - regionSelected + - temporaryFakeDistanceScore + - fakeDistanceScoreUpdate + - skills + - interests + - tools + - projectSkills + - projectInterests + - projectTools + - externalVolunteerURL + - isExternalProject + - isUnlistedProject + - archivedProject + - isActive + - externalVolunteerCount + - externalVolunteers + - allEventsFilled + - daysOfWeek + - timesOfDay + - waiverURL + - dtl + - onGoing + - recurring + - lastChangeReason + - escalated + - created + - updated + - createdBy + - deleted + - deletedOn + - deletedBy + - firstStartDateTimeOffset + - lastEndDateTimeOffset + - cbfName + - cblName + - updatedBy + - ubfName + - ublName + - volunteerCentersData + - relativityScore + - projectOwnerName + - projectOwnerLastName + - projectOwnerUserId + - projectLocationType + - underReview diff --git a/src/test/groovy/org/justserve/JustServeSpec.groovy b/src/test/groovy/org/justserve/JustServeSpec.groovy index 675364c..277ddad 100644 --- a/src/test/groovy/org/justserve/JustServeSpec.groovy +++ b/src/test/groovy/org/justserve/JustServeSpec.groovy @@ -8,13 +8,12 @@ import io.micronaut.http.client.exceptions.HttpClientResponseException import io.micronaut.test.extensions.spock.annotation.MicronautTest import net.datafaker.Faker import org.justserve.client.* -import org.justserve.model.ImageUploadRequest -import org.justserve.model.ImageUploadResponse -import org.justserve.model.OrganizationCreateRequest -import org.justserve.model.OrganizationSearchRequest +import org.justserve.model.* import spock.lang.Shared import spock.lang.Specification +import static org.justserve.model.DistanceType.MILES + @MicronautTest() class JustServeSpec extends Specification { @@ -26,6 +25,9 @@ class JustServeSpec extends Specification { @Shared TestUser[] users + @Shared + List searchResults + @Shared Faker faker @@ -48,6 +50,15 @@ class JustServeSpec extends Specification { @Shared OrganizationClient authOrgClient + @Shared + UserClient userClient + + @Shared + TestUser readOnlyUser + + @Shared + ProjectClient projectClient + def setupSpec() { faker = new Faker() @@ -73,6 +84,11 @@ class JustServeSpec extends Specification { authOrgClient = ctx.getBean(OrganizationClient) authBoundaryPermissionClient = ctx.getBean(BoundaryPermissionClient) authDynamicRoutingClient = ctx.getBean(DynamicRoutingClient) + userClient = ctx.getBean(UserClient) + readOnlyUser = new TestUser(new Faker(Locale.of("en-us"))) + projectClient = ctx.getBean(ProjectClient) + readOnlyUser.uuid = createUser(noAuthUserClient, readOnlyUser).body().getId() + searchResults = getProjectsByLocation(faker.location().toString()) } void cleanupSpec() { @@ -105,6 +121,18 @@ class JustServeSpec extends Specification { user.countryCode) } + List getProjectsByLocation(String location) { + return getProjects(" ", 1, 10, location, "en-US") + } + + List getProjects(String keyword = " ", int page = 1, int size = 10, String location = " ", String locale = "en-US") { + return projectClient.searchProjects(new ProjectSearchRequest().setPage(Integer.valueOf(page)) + .setSize(size).setKeywords(keyword).setLocation(location).setRadiusType(MILES).setVolunteerFromAnywhere(false) + .setIncludeOrgInfo(true).setLanguage(locale).setBrowserLocale(locale).setPublishedOnly(false) + .setIncludeFilledProjects(true).setDisasterRecoveryProjectsOnly(false).setTimesOfDay(null)).body().getItems() + + } + /** * creates a random org for testing * @return diff --git a/src/test/groovy/org/justserve/command/BaseCommandSpec.groovy b/src/test/groovy/org/justserve/cli/command/BaseCommandSpec.groovy similarity index 98% rename from src/test/groovy/org/justserve/command/BaseCommandSpec.groovy rename to src/test/groovy/org/justserve/cli/command/BaseCommandSpec.groovy index b3491c5..1cc84d0 100644 --- a/src/test/groovy/org/justserve/command/BaseCommandSpec.groovy +++ b/src/test/groovy/org/justserve/cli/command/BaseCommandSpec.groovy @@ -1,4 +1,4 @@ -package org.justserve.command +package org.justserve.cli.command import io.micronaut.configuration.picocli.PicocliRunner import io.micronaut.context.ApplicationContext diff --git a/src/test/groovy/org/justserve/command/GetTempPasswordSpec.groovy b/src/test/groovy/org/justserve/cli/command/GetTempPasswordSpec.groovy similarity index 98% rename from src/test/groovy/org/justserve/command/GetTempPasswordSpec.groovy rename to src/test/groovy/org/justserve/cli/command/GetTempPasswordSpec.groovy index ce3bf4d..41d9ead 100644 --- a/src/test/groovy/org/justserve/command/GetTempPasswordSpec.groovy +++ b/src/test/groovy/org/justserve/cli/command/GetTempPasswordSpec.groovy @@ -1,4 +1,4 @@ -package org.justserve.command +package org.justserve.cli.command import io.micronaut.context.ApplicationContext import net.datafaker.Faker diff --git a/src/test/groovy/org/justserve/command/MakeOrgAdminSpec.groovy b/src/test/groovy/org/justserve/cli/command/MakeOrgAdminSpec.groovy similarity index 99% rename from src/test/groovy/org/justserve/command/MakeOrgAdminSpec.groovy rename to src/test/groovy/org/justserve/cli/command/MakeOrgAdminSpec.groovy index efebed5..f465bd6 100644 --- a/src/test/groovy/org/justserve/command/MakeOrgAdminSpec.groovy +++ b/src/test/groovy/org/justserve/cli/command/MakeOrgAdminSpec.groovy @@ -1,4 +1,4 @@ -package org.justserve.command +package org.justserve.cli.command import io.micronaut.context.ApplicationContext import spock.lang.Execution diff --git a/src/test/groovy/org/justserve/cli/command/UnReassignProjectsSpec.groovy b/src/test/groovy/org/justserve/cli/command/UnReassignProjectsSpec.groovy new file mode 100644 index 0000000..e228567 --- /dev/null +++ b/src/test/groovy/org/justserve/cli/command/UnReassignProjectsSpec.groovy @@ -0,0 +1,60 @@ +package org.justserve.cli.command + +import io.micronaut.core.io.ResourceResolver +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject +import net.datafaker.Faker +import org.justserve.model.ProjectCard +import org.justserve.util.TestEmailGenerator +import spock.lang.Execution +import spock.lang.Shared + +import java.nio.file.Files + +import static org.spockframework.runtime.model.parallel.ExecutionMode.SAME_THREAD + +@Execution(SAME_THREAD) +@MicronautTest +class UnReassignProjectsSpec extends BaseCommandSpec { + + @Inject + ResourceResolver resourceResolver + + @Shared + File tempEmlFile + + def setup() { + // Create a temporary file for the test + tempEmlFile = File.createTempFile("test-reassign", ".eml") + println "Temp file created at: " + tempEmlFile.absolutePath + } + + def cleanup() { + if (tempEmlFile != null && tempEmlFile.exists()) { + tempEmlFile.delete() + } + } + + def "can make reassignments from #emlFile to #user"() { + given: + List projects + do { + projects = getProjectsByLocation(new Faker().address().fullAddress()) + } while (projects.size() == 0) + + String emlContent = TestEmailGenerator.generateMockEmlContent(projects, readOnlyUser) + Files.writeString(tempEmlFile.toPath(), emlContent) + + def args = ["unReassignProjects", "-u", readOnlyUser.uuid.toString(), "-f", tempEmlFile.absolutePath] + + when: + def (outputStream, errorStream) = executeCommand(ctx, args as String[]) + + then: + errorStream.matches(blankRegex) + projects.each { project -> + outputStream.contains(project.id.toString()) + } + outputStream.contains("Successfully reassigned ${projects.size()} projects to user ${readOnlyUser.uuid}") + } +} diff --git a/src/test/groovy/org/justserve/client/ProjectClientSpec.groovy b/src/test/groovy/org/justserve/client/ProjectClientSpec.groovy new file mode 100644 index 0000000..6fa01f8 --- /dev/null +++ b/src/test/groovy/org/justserve/client/ProjectClientSpec.groovy @@ -0,0 +1,73 @@ +package org.justserve.client + +import io.micronaut.http.HttpResponse +import org.justserve.JustServeSpec +import org.justserve.model.* + +import static io.micronaut.http.HttpStatus.OK +import static org.justserve.model.DistanceType.MILES + +class ProjectClientSpec extends JustServeSpec { + + void "get project's current owner"(ProjectCard project) { + when: + GetProjectRequest projectRequest = new GetProjectRequest() + + def response = projectClient + .getProject((project as ProjectCard).getId(), "en-US", projectRequest) + + then: + verifyAll { + response.body() != null + response.body().getProjectOwnerUserId() != null + } + + + where: + project << searchResults + + } + + void "can reassign a project using either an empty string locale or 'en-US' locale"(ProjectCard project) { + given: + GetProjectRequest projectRequest = new GetProjectRequest() + String locale = new Random().nextBoolean() ? " " : "en-US" + UUID currentOwner = projectClient.getProject((project as ProjectCard).getId(), locale, projectRequest) + .body().getProjectOwnerUserId() + ReassignProjectRequest reassignProjectRequest = new ReassignProjectRequest(readOnlyUser.uuid, currentOwner) + + when: + def response = projectClient.reassignProject((project as ProjectCard).getId(), reassignProjectRequest) + + then: + noExceptionThrown() + verifyAll { + response.status() == OK + projectClient.getProject((project as ProjectCard).getId(), "en-US", projectRequest) + .body().getProjectOwnerUserId() == readOnlyUser.uuid + } + + where: + project << searchResults + } + + void "searchProjects should return results"() { + given: + def locale = "en-US" + def request = new ProjectSearchRequest() + .setPage(Integer.valueOf(1)).setSize(10).setKeywords(" ").setLocation(" ").setRadiusType(MILES) + .setVolunteerFromAnywhere(false).setIncludeOrgInfo(true).setLanguage(locale).setBrowserLocale(locale) + .setPublishedOnly(false).setIncludeFilledProjects(true).setDisasterRecoveryProjectsOnly(false).setTimesOfDay(null) + + when: + HttpResponse response = projectClient.searchProjects(request) + + then: + verifyAll { + response.status == OK + response.body() != null + response.body().items != null + } + } + +} diff --git a/src/test/groovy/org/justserve/client/UserClientSpec.groovy b/src/test/groovy/org/justserve/client/UserClientSpec.groovy index 37cf346..f8f36bd 100644 --- a/src/test/groovy/org/justserve/client/UserClientSpec.groovy +++ b/src/test/groovy/org/justserve/client/UserClientSpec.groovy @@ -5,25 +5,11 @@ import net.datafaker.Faker import org.justserve.JustServeSpec import org.justserve.TestUser import org.justserve.model.UserHashRequestByEmail -import spock.lang.Shared import static io.micronaut.http.HttpStatus.UNAUTHORIZED class UserClientSpec extends JustServeSpec { - @Shared - UserClient userClient - - @Shared - TestUser readOnlyUser - - - def setupSpec() { - userClient = ctx.getBean(UserClient) - readOnlyUser = new TestUser(new Faker(Locale.of("en-us"))) - readOnlyUser.uuid = createUser(readOnlyUser).body().getId() - } - def "create user #{user.firstname} #{user.lastname} #{user.email} #{user.password} #{user.postal} #{user.locale} #{user.country} #{user.countryCode}"() { when: TestUser user = new TestUser(new Faker(Locale.of("en-us"))) diff --git a/src/test/groovy/org/justserve/util/EmailParserSpec.groovy b/src/test/groovy/org/justserve/util/EmailParserSpec.groovy new file mode 100644 index 0000000..d75f07a --- /dev/null +++ b/src/test/groovy/org/justserve/util/EmailParserSpec.groovy @@ -0,0 +1,121 @@ +package org.justserve.util + +import io.micronaut.core.io.ResourceResolver +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject +import org.jsoup.nodes.Document +import org.justserve.cli.util.JustServeEmailParserError +import spock.lang.Shared +import spock.lang.Specification + +import java.util.stream.Collectors +import java.util.stream.Stream + +@MicronautTest +class EmailParserSpec extends Specification { + + @Shared + @Inject + ResourceResolver resourceResolver + + @Shared + Map testEmails + + @Shared + Map testTrackingUrls + + + def setupSpec() { + testEmails = new HashMap<>() + Stream.of("test-with-automated-email.eml", "test-without-automated-email.eml").forEach { file -> + def resource = resourceResolver.getResourceAsStream("classpath:$file") + resource.ifPresent { stream -> + try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream))) { + testEmails.put(file.replace(".eml", ""), reader.lines().collect(Collectors.joining(System.lineSeparator()))) + } catch (IOException e) { + throw new RuntimeException("Failed to read test file: $file", e) + } + } + } + + testTrackingUrls = new HashMap<>() + def yamlResource = resourceResolver.getResourceAsStream("classpath:projects.yaml") + yamlResource.ifPresent { stream -> + try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream))) { + testTrackingUrls = reader.lines() + .filter(line -> line.contains(':')) + .collect(Collectors.toMap( + { line -> ((line as String).split(':', 2)[0] as String).replaceAll('"', '').trim() }, + { line -> ((line as String).split(':', 2)[1] as String).replaceAll('"', '').trim() } + )) + } catch (IOException e) { + throw new RuntimeException("Failed to read projects.yaml", e) + } + } + } + + def "validate parse can read in an eml file and take in the html body of the email"() { + when: + def doc = EmailParser.parse(fileContent as String) + + then: + if (!(title as String).toLowerCase().contains("without")) { + null != doc + return + } + def error = thrown(JustServeEmailParserError) + error.message == "Email is not a JustServe generated email." + + + where: + [title, fileContent] << testEmails.collect { key, value -> [key, value] } + } + + + def "validate getProjectIdFromUglyUrl will properly get #projectName's id from the provided ugly url"() { + when: + UUID projectID = EmailParser.getProjectIDFromUglyUrl(uglyUrl as String) + + then: + null != projectID + noExceptionThrown() + + where: + [projectName, uglyUrl] << testTrackingUrls.collect { key, value -> [key, value] } + } + + def "Can use 'getProjects' to parse jsoup doc in #title file"() { + given: + Document doc = EmailParser.parse(fileContent as String) + + when: + Map projects = EmailParser.getProjects(doc) + + then: + if (!(title as String).toLowerCase().contains("without")) { + projects.size() > 0 + return + } + def error = thrown(JustServeEmailParserError) + error.message == "Email does not contain an HTML body." + + where: + [title, fileContent] << testEmails.findAll { key, _ -> !key.toLowerCase().contains("without") }.collect { key, value -> [key, value] } + } + + def "Can use 'getProjects' to parse entire #title file"() { + when: + Map projects = EmailParser.getProjects(fileContent as String) + + then: + if (!(title as String).toLowerCase().contains("without")) { + projects.size() > 0 + return + } + def error = thrown(JustServeEmailParserError) + error.message == "Email is not a JustServe generated email." + + where: + [title, fileContent] << testEmails.collect { key, value -> [key, value] } + } +} diff --git a/src/test/groovy/org/justserve/util/TestEmailGenerator.groovy b/src/test/groovy/org/justserve/util/TestEmailGenerator.groovy new file mode 100644 index 0000000..dfa67a0 --- /dev/null +++ b/src/test/groovy/org/justserve/util/TestEmailGenerator.groovy @@ -0,0 +1,128 @@ +package org.justserve.util + +import net.datafaker.Faker +import org.justserve.TestUser +import org.justserve.model.ProjectCard + +class TestEmailGenerator { + + static String generateMockEmlContent(List projects, TestUser recipient) { + StringBuilder sb = new StringBuilder() + sb.append("From: JustServe \n") + sb.append("To: " + recipient.firstName + " " + recipient.lastName + " <" + recipient.email + ">\n") + sb.append("Subject: Project Reassignment\n") + sb.append("Content-Type: text/html; charset=\"UTF-8\"\n") + sb.append("Content-Transfer-Encoding: 8bit\n\n") + + String recipientName = recipient.firstName + " " + recipient.lastName + String recipientEmail = recipient.email + Faker faker = new Faker() + String senderName = faker.name().fullName() + + String htmlTemplateStart = """
Here you go.  Thx for your help.  

---------- Forwarded message ---------
From: JustServe.org <noreply-js@mail.justserve.org>
Date: Tue, Dec 16, 2025 at 8:27 AM
Subject: Project Reassignment
To: <${recipientEmail}>


+ + + + +
+ + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + +
+ Logo Title +
+ + + + + + + + + + +
Project Reassignment
+

+ + The following projects have been reassigned from ${senderName}, to ${recipientName}, by ${senderName}: + +

+
    """ + + String htmlTemplateEnd = """
+

+ + ${recipientName} can now access these projects in their manage projects page for editing. + +

+
+
+
+ + + + + + + + + + + + + + +
+

JustServe.org is provided as a service by The Church of Jesus Christ of Latter-day Saints.

+

© 2020 by Intellectual Reserve, Inc. All rights reserved.

+

50 East North Temple, Salt Lake City, Utah, 84150

+

Terms of Use (Updated 8/12/2021) | Privacy Notice (Updated 10/12/2022)

+

This email was sent to: ${recipientEmail}

+

If you would like to stop receiving these types of emails you may unsubscribe from your account settings page.

+
+
+ +
+


--
${recipientName}
JustServe Assistant Area Director
Northern California 
925-699-1395  Cell and Text



+""" + + sb.append(htmlTemplateStart) + + projects.each { project -> + String uglyUrl = "https://urldefense.com/v3/__https://v6q93rxd.r.us-east-1.awstrack.me/L0/https:*2F*2Fwww.justserve.org*2Fprojects*2F" + project.id + "/1/0100019b27fd352e-a7b03409-4c41-4863-b76a-524b7f84f180-000000/DNbIEdheshbb39W79A1Ru2ox05c=3D457__;JSUlJQ!!Oz_3W2l6Vjs!5dejAA5tmpGXmMs_vdlbrwn4wHWu5ytbEcsfO4rx9OUup3ka-dRHFZEinoeDKzwDHUqRP5WvSOsZ5sS1l875dafyqVS6\$" + sb.append("
  • ").append(project.title).append("
  • ") + } + + sb.append(htmlTemplateEnd) + return sb.toString() + } +} diff --git a/src/test/resources/README.md b/src/test/resources/README.md new file mode 100644 index 0000000..7286b47 --- /dev/null +++ b/src/test/resources/README.md @@ -0,0 +1,13 @@ +# Test Email Files + +To properly run the `EmailParserSpec` tests, you need to provide your own `.eml` files in this directory, as the original files contain Personally Identifiable Information (PII) and cannot be committed to Git. + +Please include two types of email files: + +1. **With Automated Content:** An email that **contains** the standard JustServe automated email footer. The filename for this file must include the word `with`. + * Example: `email-with-content.eml` + +2. **Without Automated Content:** An email that **does not contain** the standard JustServe automated email footer. The filename for this file must include the word `without`. + * Example: `email-without-content.eml` + +The tests are designed to dynamically find and parse any `.eml` files in this directory and will assert different outcomes based on whether "with" or "without" is present in the filename. diff --git a/src/test/resources/projects.yaml b/src/test/resources/projects.yaml new file mode 100644 index 0000000..fffb12d --- /dev/null +++ b/src/test/resources/projects.yaml @@ -0,0 +1,5 @@ +"Thrift Store Adventurers Needed!-Coeur d'Alene": "https://urldefense.com/v3/__https://v6q93rxd.r.us-east-1.awstrack.me/L0=/https:*2F*2Fwww.justserve.org*2Fprojects*2Ff001912b-256f-44b5-87a8-61e5e922a783/1/0100019b27fd352e-a7b03409-4c41-4863-b76a-524b7f84f180-000000/ENCW9TCkcy4N949_TX_jtes4S7A=3D457__;JSUlJQ!!Oz_3W2l6Vjs!5dejAA5tmpGXmMs_vdlbrwn4wHWu5ytbEcsfO4rx9OUup3ka-dRHFZEinoeDKzwDHUqRP5WvSOsZ5sS1l875dYifqtaS$" +"American Red Cross Blood Drive": "https://urldefense.com/v3/__https://v6q93rxd.r.us-east-1.awstrack.me/L0=/https:*2F*2Fwww.justserve.org*2Fprojects*2Ff0fdc9d2-2cd5-4b00-b186-9f1003a34fb8/1/0100019b27fd352e-a7b03409-4c41-4863-b76a-524b7f84f180-000000/SOIQKQKGlArjeL0JnyUDfNrR-Lw=3D457__;JSUlJQ!!Oz_3W2l6Vjs!5dejAA5tmpGXmMs_vdlbrwn4wHWu5ytbEcsfO4rx9OUup3ka-dRHFZEinoeDKzwDHUqRP5WvSOsZ5sS1l875detDYK5M$" +"Do you feel guilty because you cannot give Blood? Come be a Helper!": "https://urldefense.com/v3/__https://v6q93rxd.r.us-east-1.awstrack.me/L0=/https:*2F*2Fwww.justserve.org*2Fprojects*2Ff1a72ef2-d433-4885-a548-bcb52c959a3f/1/0100019b27fd352e-a7b03409-4c41-4863-b76a-524b7f84f180-000000/cd0nNSMKv8MqcesG-I0dDmvXOh4=3D457__;JSUlJQ!!Oz_3W2l6Vjs!5dejAA5tmpGXmMs_vdlbrwn4wHWu5ytbEcsfO4rx9OUup3ka-dRHFZEinoeDKzwDHUqRP5WvSOsZ5sS1l875dRo6H__T$" +"Saint Vincent de Paul North Idaho": "https://urldefense.com/v3/__https://v6q93rxd.r.us-east-1.awstrack.me/L0=/https:*2F*2Fwww.justserve.org*2Fprojects*2Ff2e21dfc-e132-46e5-a97f-801089574c6c/1/0100019b27fd352e-a7b03409-4c41-4863-b76a-524b7f84f180-000000/EUSJSjx5GFnIY4Uw8ZKFvmdP3UQ=3D457__;JSUlJQ!!Oz_3W2l6Vjs!5dejAA5tmpGXmMs_vdlbrwn4wHWu5ytbEcsfO4rx9OUup3ka-dRHFZEinoeDKzwDHUqRP5WvSOsZ5sS1l875dTNOaCdu$" +"Habitat for Humanity Neighborhood Cleanup Day": "https://urldefense.com/v3/__https://v6q93rxd.r.us-east-1.awstrack.me/L0=/https://:*2F*2Fwww.justserve.org*2Fprojects*2Ff642ff60-0ef4-4954-a7ba-e7333e16738b/1/0100019b27fd352e-a7b03409-4c41-4863-b76a-524b7f84f180-000000/tGCPal-9awkiaon1eX24YLGYNZU=3D457__;JSUlJQ!!Oz_3W2l6Vjs!5dejAA5tmpGXmMs_vdlbrwn4wHWu5ytbEcsfO4rx9OUup3ka-dRHFZEinoeDKzwDHUqRP5WvSOsZ5sS1l875dUyrxvy0$"