diff --git a/client/package.json b/client/package.json index 2590e6cc9..6e333a4d4 100644 --- a/client/package.json +++ b/client/package.json @@ -1,7 +1,7 @@ { "name": "foodoasis-client", "description": "React Client for Food Oasis", - "version": "1.0.63", + "version": "1.0.64", "author": "Hack for LA", "license": "GPL-2.0", "private": true, diff --git a/client/src/App.js b/client/src/App.js index 589cc2ed6..e3fb73828 100644 --- a/client/src/App.js +++ b/client/src/App.js @@ -28,6 +28,7 @@ import VerificationDashboard from "components/Admin/VerificationDashboard"; import SecurityAdminDashboard from "components/Account/SecurityAdminDashboard/SecurityAdminDashboard"; import OrganizationEdit from "components/Admin/OrganizationEdit"; import ParentOrganizations from "components/Admin/ParentOrganizations"; +import TagAdmin from "components/Admin/TagAdmin"; import Donate from "components/StaticPages/Donate"; import About from "components/StaticPages/About"; import Faq from "components/StaticPages/Faq"; @@ -62,6 +63,7 @@ import ImportFile from "components/Admin/ImportOrganizations/ImportFile"; import adminTheme from "./theme/adminTheme"; import * as analytics from "../src/services/analytics"; import Suggestions from "components/Admin/Suggestions"; +import Logins from "components/Admin/Logins"; const useStyles = makeStyles({ app: () => ({ @@ -210,6 +212,9 @@ function App() { + + +
@@ -237,11 +242,21 @@ function App() {
+ +
+ +
+
+ +
+ +
+
({ + container: { + maxHeight: "500px", + }, + heading: { + marginBottom: theme.spacing(1), + display: "flex", + justifyContent: "space-between", + }, + formControl: { + margin: theme.spacing(1), + minWidth: 180, + }, +})); + +const Logins = () => { + const classes = useStyles(); + const [logins, setLogins] = useState([]); + const [page, setPage] = React.useState(0); + const [rowsPerPage, setRowsPerPage] = React.useState(10); + let { data, refetch } = useLogins(); + + useEffect(() => { + if (data) { + setLogins(data); + } + }, [data]); + + const handleChangePage = (event, newPage) => { + setPage(newPage); + }; + + const handleChangeRowsPerPage = (event) => { + setRowsPerPage(+event.target.value); + setPage(0); + }; + + const debouncedChangeHandler = useMemo(() => { + const changeHandler = (event) => { + refetch(event.target.value.toLowerCase()); + }; + return debounce(changeHandler, 300); + }, [refetch]); + + return ( + +
+

User Logins

+ + + +
+ + + + + + + {columns.map((column) => ( + + {column.label} + + ))} + + + + {logins + .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) + .map((logins) => { + return ( + + {columns.map((column) => { + const value = + column.id === "name" + ? `${logins["lastName"]}, ${logins["firstName"]}` + : logins[column.id]; + return ( + + {value} + + ); + })} + + ); + })} + +
+
+ +
+
+ ); +}; + +export default Logins; diff --git a/client/src/components/Admin/OrganizationEdit.js b/client/src/components/Admin/OrganizationEdit.js index 4337d18d9..159f5db4d 100644 --- a/client/src/components/Admin/OrganizationEdit.js +++ b/client/src/components/Admin/OrganizationEdit.js @@ -31,6 +31,7 @@ import { } from "@material-ui/core"; import * as stakeholderService from "services/stakeholder-service"; import { useCategories } from "hooks/useCategories/useCategories"; +import { useTags } from "hooks/useTags"; import * as geocoder from "services/geocode-tamu-service"; import OpenTimeForm from "components/Admin/OpenTimeForm"; import { TabPanel, a11yProps } from "components/Admin/ui/TabPanel"; @@ -200,6 +201,9 @@ const emptyOrganization = { foodDairy: false, foodPrepared: false, foodMeat: false, + hoursNotes: "", + allowWalkins: false, + tags: [], }; const FOOD_TYPES = [ @@ -260,6 +264,7 @@ const OrganizationEdit = (props) => { const { setToast } = useToasterContext(); const { data: categories } = useCategories(); + const { data: allTags } = useTags(); useEffect(() => { const fetchData = async () => { @@ -1038,6 +1043,52 @@ const OrganizationEdit = (props) => {
+ +
+ + Tags + + + + {touched.tags ? errors.tags : ""} + + +
+
@@ -1078,6 +1129,36 @@ const OrganizationEdit = (props) => { value={values.hours} /> + + + setFieldValue("allowWalkins", !values.allowWalkins) + } + onBlur={handleBlur} + /> + } + label="Allow Walk-Ins" + /> + + diff --git a/client/src/components/Admin/SearchCriteria.js b/client/src/components/Admin/SearchCriteria.js index 4ad6778fc..0f722c4b6 100644 --- a/client/src/components/Admin/SearchCriteria.js +++ b/client/src/components/Admin/SearchCriteria.js @@ -46,6 +46,7 @@ const SearchCriteria = ({ userLongitude, categories, neighborhoods, + tags, criteria, setCriteria, }) => { @@ -347,6 +348,32 @@ const SearchCriteria = ({ + + + + Tag + + + + diff --git a/client/src/components/Admin/SearchCriteriaDisplay.js b/client/src/components/Admin/SearchCriteriaDisplay.js index 18df11f7d..ba7b93bd9 100644 --- a/client/src/components/Admin/SearchCriteriaDisplay.js +++ b/client/src/components/Admin/SearchCriteriaDisplay.js @@ -34,6 +34,7 @@ function SearchCriteriaDisplay({ criteria, neighborhoods, categories, + tags, isLoading, }) { const classes = useStyles(); @@ -59,7 +60,8 @@ function SearchCriteriaDisplay({ criteria.minCompleteCriticalPercent !== defaultCriteria.minCompleteCriticalPercent || criteria.maxCompleteCriticalPercent !== - defaultCriteria.maxCompleteCriticalPercent + defaultCriteria.maxCompleteCriticalPercent || + criteria.tag !== defaultCriteria.tag // TODO: latituted and longitude are omitted because they are buggy // criteria.latitude != defaultCriteria.latitude || // criteria.longitude != defaultCriteria.longitude || @@ -93,6 +95,16 @@ function SearchCriteriaDisplay({ ); } + if (criteria.tag !== defaultCriteria.tag) { + criterias.push( + + ); + } + if (criteria.radius !== defaultCriteria.radius) { criterias.push( ({ + container: { + maxHeight: "500px", + cursor: "pointer", + }, + heading: { + marginBottom: theme.spacing(1), + display: "flex", + justifyContent: "space-between", + }, + paper: { + position: "absolute", + width: 400, + backgroundColor: theme.palette.background.paper, + boxShadow: theme.shadows[5], + padding: theme.spacing(2, 4, 3), + }, + error: { + color: theme.palette.error.main, + }, +})); + +function TagAdmin(props) { + let { data, status } = useTags(); + const [tags, setTags] = React.useState([]); + const classes = useStyles(); + const [page, setPage] = React.useState(0); + const [rowsPerPage, setRowsPerPage] = React.useState(10); + const [activeTag, setActiveTag] = React.useState(false); + const [modalStyle] = React.useState(getModalStyle); + const [error, setError] = React.useState(""); + const [deleteError, setDeleteError] = React.useState(""); + + React.useEffect(() => { + if (data) { + setTags(data); + } + }, [data]); + + React.useEffect(() => { + if (status === 401) { + return ( + + ); + } + }); + + const handleChangePage = (event, newPage) => { + setPage(newPage); + }; + + const handleChangeRowsPerPage = (event) => { + setRowsPerPage(+event.target.value); + setPage(0); + }; + + const handleSave = async (data) => { + try { + if (activeTag.id) { + await tagService.update({ ...data, id: activeTag.id }); + } else { + const { id } = await tagService.post(data); + setTags([...tags, { ...data, id }]); + } + setActiveTag(null); + } catch (e) { + setError(e.message); + } + setTimeout(() => { + setError(""); + }, 3000); + }; + + const handleAddNew = () => { + setActiveTag({ + name: "", + tenantId, + }); + }; + + const handleDelete = async (id) => { + try { + await tagService.remove(id); + const data = tags.filter((tag) => tag.id !== id); + setTags(data); + } catch (e) { + setDeleteError(e.message); + } + }; + + return ( + +
+

Tags

+ +
+ + {deleteError && ( +
Something went wrong.
+ )} + + + + + + + {columns.map((column) => ( + + {column.label} + + ))} + + + + {tags + .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) + .map((tag) => { + return ( + + {columns.map((column) => { + const value = tag[column.id]; + if (column.id === "edit") { + return ( + + { + const aTag = tags.find( + (t) => t.id === tag.id + ); + setActiveTag(aTag); + }} + /> + + ); + } + if (column.id === "delete") { + return ( + + handleDelete(tag.id)} + /> + + ); + } + return ( + + {column.format && typeof value === "number" + ? column.format(value) + : value} + + ); + })} + + ); + })} + +
+
+ + setActiveTag(null)} + aria-labelledby="tag-modal" + aria-describedby="tag-modal-description" + > +
+
+

Edit Tag

+
+ + handleSave(values)} + > + {({ + values, + handleChange, + handleSubmit, + touched, + errors, + isSubmitting, + }) => ( +
{ + e.preventDefault(); + handleSubmit(e); + }} + > + + + {error && ( +
Something went wrong.
+ )} + + + + +
+ )} +
+
+
+
+
+ ); +} + +export default withRouter(TagAdmin); diff --git a/client/src/components/Admin/VerificationAdmin.js b/client/src/components/Admin/VerificationAdmin.js index 15287f4c6..cefc30617 100644 --- a/client/src/components/Admin/VerificationAdmin.js +++ b/client/src/components/Admin/VerificationAdmin.js @@ -9,6 +9,7 @@ import { RotateLoader } from "react-spinners"; import { useOrganizations } from "hooks/useOrganizations"; import { useCategories } from "hooks/useCategories/useCategories"; import { useNeighborhoods } from "hooks/useNeighborhoods/useNeighborhoods"; +import { useTags } from "hooks/useTags"; import { needsVerification, assign, @@ -100,6 +101,7 @@ const defaultCriteria = { placeName: "", radius: 0, categoryIds: [], + tags: [], isInactive: "either", isAssigned: "either", isSubmitted: "either", @@ -113,6 +115,7 @@ const defaultCriteria = { maxCompleteCriticalPercent: 100, stakeholderId: "", isInactiveTemporary: "either", + tag: "", }; VerificationAdmin.propTypes = { @@ -147,6 +150,8 @@ function VerificationAdmin(props) { error: categoriesError, } = useCategories(); + const { data: tags, loading: tagsLoading, error: tagsError } = useTags(); + const { data: neighborhoods, loading: neighborhoodsLoading, @@ -324,6 +329,7 @@ function VerificationAdmin(props) { criteria={criteria} neighborhoods={neighborhoods} categories={categories} + tags={tags} isLoading={neighborhoodsLoading || categoriesLoading} />
@@ -349,6 +355,7 @@ function VerificationAdmin(props) { userCoordinates?.longitude || origin?.longitude || 0 } categories={categories && categories.filter((c) => !c.inactive)} + tags={tags} neighborhoods={neighborhoods} criteria={criteria} setCriteria={setCriteria} @@ -359,11 +366,15 @@ function VerificationAdmin(props) { />
) : null} - {categoriesError || neighborhoodsError || stakeholdersError ? ( + {categoriesError || + neighborhoodsError || + stakeholdersError || + tagsError ? (
Uh Oh! Something went wrong!
) : categoriesLoading || neighborhoodsLoading || - stakeholdersLoading ? ( + stakeholdersLoading || + tagsLoading ? (
<> - {categoriesError || stakeholdersError ? ( + {categoriesError || stakeholdersError || tagsError ? (
Uh Oh! Something went wrong!
- ) : categoriesLoading || stakeholdersLoading ? ( + ) : categoriesLoading || stakeholdersLoading | tagsLoading ? (
{ - if (match.path === "/") { - sessionStorage.clear(); - } - }, [match.path]); - const selectLocation = useCallback( (origin) => { setOrigin(origin); diff --git a/client/src/components/FoodSeeker/SearchResults/Details/index.js b/client/src/components/FoodSeeker/SearchResults/Details/index.js index 9382288cb..9dd957d75 100644 --- a/client/src/components/FoodSeeker/SearchResults/Details/index.js +++ b/client/src/components/FoodSeeker/SearchResults/Details/index.js @@ -58,13 +58,13 @@ const useStyles = makeStyles((theme) => ({ width: "100%", display: "inherit", flexDirection: "inherit", - fontSize: "1.2em", + fontSize: "1.1em", }, singleHourContainer: { width: "100%", display: "inherit", justifyContent: "space-between", - margin: ".8em 0", + margin: ".2em 0", }, fontSize: { fontSize: "14px", @@ -383,9 +383,34 @@ const StakeholderDetails = ({ selectedStakeholder, onClose }) => { Send Correction
+ +

Eligibility/Requirements

+ {selectedStakeholder.requirements ? ( + + ) : ( +
No special requirements
+ )} + +

Hours

+ {selectedStakeholder.allowWalkins ? ( +
Walk-ins welcome.
+ ) : null} + + {selectedStakeholder.hoursNotes ? ( +
+ ) : null} {selectedStakeholder.hours ? ( <> -

Hours

{selectedStakeholder.hours && selectedStakeholder.hours.length > 0 ? ( @@ -457,18 +482,6 @@ const StakeholderDetails = ({ selectedStakeholder, onClose }) => { No E-Mail Address on record )} -

Eligibility/Requirements

- {selectedStakeholder.requirements ? ( - - ) : ( - No special requirements - )} -

Languages

{selectedStakeholder.languages ? ( diff --git a/client/src/components/FoodSeeker/SearchResults/index.js b/client/src/components/FoodSeeker/SearchResults/index.js index 57227e5f4..1d773acb7 100644 --- a/client/src/components/FoodSeeker/SearchResults/index.js +++ b/client/src/components/FoodSeeker/SearchResults/index.js @@ -26,6 +26,13 @@ const ResultsContainer = ({ const { selectedStakeholder, doSelectStakeholder } = useSelectedStakeholder(); const [isVerifiedSelected, selectVerified] = useState(false); const [showList, setShowList] = useState(true); + // The following two states are temporarily hard-coded - they eventually should be + // set from query parameters on the url. This allows the url + // specified by an iframe (e.g. https://la.foodoasis.net/widget?neighborhoodId=3) + // to pass aneightborhoodId or tag parameter to filter the + // results by neighborhood or tag from an iframe host site. + const [tag] = useState(""); + const [neighborhoodId] = useState(null); useEffect(() => { const { zoom, dimensions } = mapRef.current.getViewport(); @@ -37,10 +44,12 @@ const ResultsContainer = ({ categoryIds, isInactive: "either", verificationStatusId: 0, + neighborhoodId: neighborhoodId, + tag: tag, }; search(criteria); analytics.postEvent("searchArea", criteria); - }, [origin, categoryIds, search]); + }, [origin, categoryIds, search, tag, neighborhoodId]); const searchMapArea = useCallback(() => { const { center } = mapRef.current.getViewport(); diff --git a/client/src/components/Layout/Menu.js b/client/src/components/Layout/Menu.js index ac5f5e799..22b12c37f 100644 --- a/client/src/components/Layout/Menu.js +++ b/client/src/components/Layout/Menu.js @@ -128,6 +128,7 @@ export default function Menu() { )} {user && user.isAdmin && ( <> + + )} diff --git a/client/src/hooks/useLogins.js b/client/src/hooks/useLogins.js new file mode 100644 index 000000000..7fc4e6316 --- /dev/null +++ b/client/src/hooks/useLogins.js @@ -0,0 +1,34 @@ +import React, { useState, useEffect } from "react"; +import * as loginsService from "../services/logins-service"; +import moment from "moment"; + +export const useLogins = (emailQuery) => { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetch = React.useCallback(async (email) => { + const fetchApi = async (email) => { + setLoading({ loading: true }); + try { + let logins = await loginsService.getAll(email); + logins = logins.map((login) => ({ + ...login, + loginTime: moment.utc(login.loginTime).local().format("llll"), + })); + setData(logins); + setLoading(false); + } catch (err) { + setError(err); + console.error(err); + } + }; + fetchApi(email); + }, []); + + useEffect(() => { + fetch(emailQuery); + }, [fetch, emailQuery]); + + return { data, error, loading, refetch: fetch }; +}; diff --git a/client/src/hooks/useOrganizationBests.js b/client/src/hooks/useOrganizationBests.js index f31242e24..2cb43481d 100644 --- a/client/src/hooks/useOrganizationBests.js +++ b/client/src/hooks/useOrganizationBests.js @@ -38,6 +38,8 @@ export default function useOrganizationBests() { categoryIds, isInactive, verificationStatusId, + neighborhoodId, + tag, }) => { if (!latitude || !longitude) { setState({ data: null, loading: false, error: true }); @@ -55,6 +57,8 @@ export default function useOrganizationBests() { categoryIds, isInactive, verificationStatusId, + neighborhoodId, + tag, }); //if (!categoryIds || categoryIds.length === 0) return; try { @@ -67,6 +71,8 @@ export default function useOrganizationBests() { distance: radius, isInactive, verificationStatusId, + neighborhoodId, + tag, }; if (bounds) { const { maxLat, maxLng, minLat, minLng } = bounds; diff --git a/client/src/hooks/useOrganizations.js b/client/src/hooks/useOrganizations.js index 257b6f773..6a8ff1955 100644 --- a/client/src/hooks/useOrganizations.js +++ b/client/src/hooks/useOrganizations.js @@ -29,6 +29,7 @@ export const useOrganizations = () => { neighborhoodId, minCompleteCriticalPercent, maxCompleteCriticalPercent, + tag, }) => { try { analytics.postEvent("searchAdmin", { @@ -48,6 +49,7 @@ export const useOrganizations = () => { neighborhoodId, minCompleteCriticalPercent, maxCompleteCriticalPercent, + tag, }); setState({ data: null, loading: true, error: false }); @@ -71,6 +73,7 @@ export const useOrganizations = () => { neighborhoodId, minCompleteCriticalPercent, maxCompleteCriticalPercent, + tag, }); setState({ data: stakeholders, loading: false, error: false }); return stakeholders; diff --git a/client/src/hooks/useTags.js b/client/src/hooks/useTags.js new file mode 100644 index 000000000..a32b1ecd8 --- /dev/null +++ b/client/src/hooks/useTags.js @@ -0,0 +1,30 @@ +import { useCallback, useState, useEffect } from "react"; +import * as tagService from "../services/tag-service"; + +export const useTags = () => { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetch = useCallback(() => { + const fetchApi = async () => { + setLoading({ loading: true }); + try { + const tags = await tagService.getAllByTenantId(); + + setData(tags); + setLoading(false); + } catch (err) { + setError(err); + console.error(err); + } + }; + fetchApi(); + }, []); + + useEffect(() => { + fetch(); + }, [fetch]); + + return { data, error, loading, refetch: fetch }; +}; diff --git a/client/src/services/logins-service.js b/client/src/services/logins-service.js new file mode 100644 index 000000000..d0c3f465f --- /dev/null +++ b/client/src/services/logins-service.js @@ -0,0 +1,9 @@ +import axios from "axios"; +import { tenantId } from "helpers/Configuration"; +const baseUrl = "/api/logins"; + +export const getAll = async (email = undefined) => { + const response = await axios.get(baseUrl, { params: { email, tenantId } }); + + return response.data; +}; diff --git a/client/src/services/tag-service.js b/client/src/services/tag-service.js new file mode 100644 index 000000000..4c29ecc5e --- /dev/null +++ b/client/src/services/tag-service.js @@ -0,0 +1,34 @@ +import axios from "axios"; +import { tenantId } from "helpers/Configuration"; + +const baseUrl = "/api/tags"; + +export const getAllByTenantId = async () => { + try { + const response = await axios.get(`${baseUrl}/${tenantId}`); + return response.data; + } catch (err) { + throw new Error(err.message); + } +}; + +export const update = async (data) => { + const response = await axios.put(`${baseUrl}/${data.id}`, { + ...data, + tenantId, + }); + return response.data; +}; + +export const post = async (data) => { + const response = await axios.post(`${baseUrl}`, { + ...data, + tenantId, + }); + return response.data; +}; + +export const remove = async (id) => { + const response = await axios.delete(`${baseUrl}/${id}`); + return response.data; +}; diff --git a/server/app/controllers/account-controller.js b/server/app/controllers/account-controller.js index 7a3a0dfd8..520d2824e 100644 --- a/server/app/controllers/account-controller.js +++ b/server/app/controllers/account-controller.js @@ -1,4 +1,5 @@ const accountService = require("../services/account-service"); +const loginsService = require("../services/logins-service"); const getAll = async (req, res) => { try { @@ -130,6 +131,7 @@ const login = async (req, res, next) => { const resp = await accountService.authenticate(email, password, tenantId); if (resp.isSuccess) { req.user = resp.user; + loginsService.insert(req.user.id, tenantId); next(); } else { res.json(resp); diff --git a/server/app/controllers/logins-controller.js b/server/app/controllers/logins-controller.js new file mode 100644 index 000000000..51e18c12f --- /dev/null +++ b/server/app/controllers/logins-controller.js @@ -0,0 +1,18 @@ +const loginsService = require("../services/logins-service"); + +const getAll = async (req, res) => { + try { + const resp = await loginsService.selectAll( + req.query.email, + req.query.tenantId + ); + res.send(resp); + } catch (err) { + console.error(err); + res.sendStatus(500); + } +}; + +module.exports = { + getAll, +}; diff --git a/server/app/controllers/tag-controller.js b/server/app/controllers/tag-controller.js new file mode 100644 index 000000000..a2a1095d5 --- /dev/null +++ b/server/app/controllers/tag-controller.js @@ -0,0 +1,60 @@ +const tagService = require("../services/tag-service"); + +const getAllByTenantId = async (req, res) => { + try { + const resp = await tagService.selectAllById(req.params.id); + res.send(resp); + } catch (err) { + console.error(err); + res.sendStatus(500); + } +}; + +const post = async (req, res) => { + try { + const resp = await tagService.insert(req.body); + res.status(201).json(resp); + } catch (err) { + if (err.message.includes("duplicate")) { + res.status(400).json({ error: "Cannot insert duplicate row." }); + } else { + console.error(err); + res.sendStatus(500); + } + } +}; + +const put = async (req, res) => { + try { + await tagService.update(req.body); + res.sendStatus(200); + } catch (err) { + console.error(err); + res.status(500); + } +}; + +const remove = async (req, res) => { + try { + // params are always strings, need to + // convert to correct Javascript type, so + // pg=promise can format SQL correctly. + const id = Number(req.params.id); + const rowCount = await tagService.remove(id); + if (rowCount !== 1) { + res.status(400).json({ error: "Record not found" }); + return; + } + res.sendStatus(204); + } catch (err) { + console.error(err); + res.sendStatus(500); + } +}; + +module.exports = { + getAllByTenantId, + post, + put, + remove, +}; diff --git a/server/app/routes/index.js b/server/app/routes/index.js index fc047dfcd..c9ec23783 100644 --- a/server/app/routes/index.js +++ b/server/app/routes/index.js @@ -6,6 +6,8 @@ const categoryRouter = require("./category-router"); const neighborhoodRouter = require("./neighborhood-router"); const suggestionRouter = require("./suggestion-router"); const parentOrganizationRouter = require("./parent-organization-router"); +const tagRouter = require("./tag-router"); +const loginsRouter = require("./logins-router"); const faqRouter = require("./faq-router"); const stakeholderRouter = require("./stakeholder-router"); @@ -34,3 +36,5 @@ router.use("/api/loads", loadRouter); router.use("/api/esri", esriRouter); router.use("/api/emails", emailRouter); router.use("/api/parent-organizations", parentOrganizationRouter); +router.use("/api/tags", tagRouter); +router.use("/api/logins", loginsRouter); diff --git a/server/app/routes/logins-router.js b/server/app/routes/logins-router.js new file mode 100644 index 000000000..789e68e66 --- /dev/null +++ b/server/app/routes/logins-router.js @@ -0,0 +1,6 @@ +const router = require("express").Router(); +const loginsController = require("../controllers/logins-controller"); + +router.get("/", loginsController.getAll); + +module.exports = router; diff --git a/server/app/routes/tag-router.js b/server/app/routes/tag-router.js new file mode 100644 index 000000000..9ee5d2399 --- /dev/null +++ b/server/app/routes/tag-router.js @@ -0,0 +1,24 @@ +const router = require("express").Router(); +const tagController = require("../controllers/tag-controller"); + +const jwtSession = require("../../middleware/jwt-session"); + +router.get("/:id", tagController.getAllByTenantId); +router.post( + "/", + jwtSession.validateUserHasRequiredRoles(["admin"]), + tagController.post +); +router.put( + "/:id", + jwtSession.validateUserHasRequiredRoles(["admin"]), + tagController.put +); + +router.delete( + "/:id", + jwtSession.validateUserHasRequiredRoles(["admin"]), + tagController.remove +); + +module.exports = router; diff --git a/server/app/services/logins-service.js b/server/app/services/logins-service.js new file mode 100644 index 000000000..e0e183f03 --- /dev/null +++ b/server/app/services/logins-service.js @@ -0,0 +1,44 @@ +const db = require("./db"); +const camelcaseKeys = require("camelcase-keys"); + +const insert = async (login_id, tenant_id) => { + const sql = `insert into logins (login_id, tenant_id) + values ($,'$') returning id`; + + const row = await db.one(sql, { login_id, tenant_id }); + return row; +}; + +// limit 500 +const selectAll = async (email, tenantId) => { + let sql = `select logins.id, login.first_name, login.last_name, login.email, logins.login_time + from logins + join login + on login.id = logins.login_id`; + + let queryValues = []; + let whereExpressions = []; + + if (tenantId !== undefined) { + queryValues.push(tenantId); + whereExpressions.push(`logins.tenant_id = $${queryValues.length}`); + } + + if (email !== undefined) { + queryValues.push("%" + email + "%"); + whereExpressions.push(`login.email like $${queryValues.length}`); + } + + if (whereExpressions.length === 1) { + sql += " where " + whereExpressions.join(" and "); + sql += " order by logins.login_time desc limit 500"; + } + if (whereExpressions.length > 1) { + sql += " where " + whereExpressions.join(" and "); + sql += " order by logins.login_time desc"; + } + const rows = await db.any(sql, queryValues); + return camelcaseKeys(rows); +}; + +module.exports = { insert, selectAll }; diff --git a/server/app/services/stakeholder-best-service.js b/server/app/services/stakeholder-best-service.js index 792b96f89..932af7544 100644 --- a/server/app/services/stakeholder-best-service.js +++ b/server/app/services/stakeholder-best-service.js @@ -37,9 +37,12 @@ const search = async ({ isInactive, verificationStatusId, tenantId, + name, + neighborhoodId, + tag, }) => { const locationClause = buildLocationClause(latitude, longitude); - const categoryClause = buildCTEClause(categoryIds, ""); + const categoryClause = buildCTEClause(categoryIds, name || ""); const sql = `${categoryClause} select s.id, s.name, s.address_1, s.address_2, s.city, s.state, s.zip, s.phone, s.latitude, s.longitude, s.website, s.notes, @@ -70,13 +73,15 @@ const search = async ({ s.v_name, s.v_categories, s.v_address, s.v_phone, s.v_email, s.v_hours, s.v_food_types, s.verification_status_id, s.inactive_temporary, array_to_json(s.hours) as hours, s.category_ids, - s.neighborhood_id, s.is_verified, + s.neighborhood_id, n.name as neighborhood_name, s.is_verified, s.food_bakery, s.food_dry_goods, s.food_produce, s.food_dairy, s.food_prepared, s.food_meat, s.parent_organization_id, + s.allow_walkins, s.hours_notes, s.tags, ${locationClause ? `${locationClause} AS distance,` : ""} ${buildLoginSelectsClause()} from stakeholder_set as s + left outer join neighborhood n on s.neighborhood_id = n.id ${buildLoginJoinsClause()} where s.tenant_id = ${tenantId} ${ @@ -92,6 +97,12 @@ const search = async ({ ? ` and s.verification_status_id = ${verificationStatusId} ` : "" } + ${ + Number(neighborhoodId) > 0 + ? ` and s.neighborhood_id = ${neighborhoodId} ` + : "" + } + ${tag ? ` and '${tag}' = ANY (s.tags) ` : ""} order by distance `; let stakeholders = []; @@ -198,6 +209,9 @@ const search = async ({ neighborhoodId: row.neighborhood_id, isVerified: row.is_verified, parentOrganizationId: row.parent_organization_id, + allowWalkins: row.allow_walkins, + hoursNotes: row.hours_notes, + tags: row.tags, }); }); @@ -238,12 +252,14 @@ const selectById = async (id) => { s.category_notes, s.eligibility_notes, s.food_types, s.languages, s.v_name, s.v_categories, s.v_address, s.v_phone, s.v_email, s.v_hours, s.v_food_types, s.verification_status_id, s.inactive_temporary, - s.neighborhood_id, s.is_verified, + s.neighborhood_id, n.name as neighborhood_name, s.is_verified, s.food_bakery, s.food_dry_goods, s.food_produce, s.food_dairy, s.food_prepared, s.food_meat, s.parent_organization_id, + s.allow_walkins, s.hours_notes, s.tags, ${buildLoginSelectsClause()} from stakeholder_best s + left outer join neighborhood n on s.neighborhood_id = n.id ${buildLoginJoinsClause()} where s.id = ${id}`; const row = await db.one(sql); @@ -332,6 +348,9 @@ const selectById = async (id) => { neighborhoodId: row.neighborhood_id, isVerified: row.is_verified, parentOrganizationId: row.parent_organization_id, + allowWalkins: row.allow_walkins, + hoursNotes: row.hours_notes, + tags: row.tags, }; // Don't have a distance, since we didn't specify origin diff --git a/server/app/services/stakeholder-service.js b/server/app/services/stakeholder-service.js index 40649166d..99516a475 100644 --- a/server/app/services/stakeholder-service.js +++ b/server/app/services/stakeholder-service.js @@ -48,10 +48,11 @@ const search = async (params) => { neighborhoodId, minCompleteCriticalPercent, maxCompleteCriticalPercent, + tag, } = params; const locationClause = buildLocationClause(latitude, longitude); - const categoryClause = buildCTEClause(categoryIds, name || "", false); + const categoryClause = buildCTEClause(categoryIds, name || ""); // false means search stakeholder table, not stakeholder_best, since this is // for the administrative dashboard @@ -87,7 +88,7 @@ const search = async (params) => { ${locationClause ? `${locationClause} AS distance,` : ""} s.food_bakery, s.food_dry_goods, s.food_produce, s.food_dairy, s.food_prepared, s.food_meat, - s.parent_organization_id, + s.parent_organization_id, s.hours_notes, s.allow_walkins, s.tags, ${buildLoginSelectsClause()} from stakeholder_set as s left outer join neighborhood n on s.neighborhood_id = n.id @@ -128,6 +129,7 @@ const search = async (params) => { ? ` and s.complete_critical_percent <= ${maxCompleteCriticalPercent} ` : "" } + ${tag ? ` and '${tag}' = ANY (s.tags) ` : ""} order by ${locationClause ? "distance" : "s.name"} `; @@ -210,6 +212,9 @@ const search = async (params) => { foodPrepared: row.food_prepared, foodMeat: row.food_meat, parentOrganizationId: row.parent_organization_id, + hoursNotes: row.hours_notes, + allowWalkins: row.allow_walkins, + tags: row.tags, }); }); @@ -260,6 +265,7 @@ const selectById = async (id) => { s.food_bakery, s.food_dry_goods, s.food_produce, s.food_dairy, s.food_prepared, s.food_meat, s.parent_organization_id, + s.hours_notes, s.allow_walkins, s.tags, ${buildLoginSelectsClause()} from stakeholder s ${buildLoginJoinsClause()} @@ -348,6 +354,9 @@ const selectById = async (id) => { foodPrepared: row.food_prepared, foodMeat: row.food_meat, parentOrganizationId: row.parent_organization_id, + hoursNotes: row.hours_notes, + allowWalkins: row.allow_walkins, + tags: row.tags, }; // Don't have a distance, since we didn't specify origin @@ -401,7 +410,11 @@ const selectCsv = async (ids) => { s.category_notes, s.eligibility_notes, s.food_types, s.languages, s.v_name, s.v_categories, s.v_address, s.v_phone, s.v_email, s.v_hours, s.verification_status_id, s.inactive_temporary, - s.neighborhood_id, n.name as neighborhood_name + s.neighborhood_id, n.name as neighborhood_name, + s.food_bakery, s.food_dry_goods, s.food_produce, + s.food_dairy, s.food_prepared, s.food_meat, + s.parent_organization_id, + s.hours_notes, s.allow_walkins, s.tags from stakeholder s left join login L1 on s.created_login_id = L1.id left join login L2 on s.modified_login_id = L2.id @@ -488,6 +501,16 @@ where s.id in (${ids.join(", ")})`; verificationStatusId: row.verification_status_id, inactiveTemporary: row.inactive_temporary, neighborhoodId: row.neighborhood_id, + foodBakery: row.food_bakery, + foodDryGoods: row.food_dry_goods, + foodProduce: row.food_produce, + foodDairy: row.food_dairy, + foodPrepared: row.food_prepared, + foodMeat: row.food_meat, + parentOrganizationId: row.parent_organization_id, + hoursNotes: row.hours_notes, + allowWalkins: row.allow_walkins, + tags: row.tags, }; }); return stakeholders; @@ -499,6 +522,16 @@ const insert = async (model) => { ? "{" + model.selectedCategoryIds.join(",") + "}" : "{1}"; + // Array of tags is formatted as, e.g., '{"tag1","tag2"}' + const tags = model.tags + ? "{" + + model.tags + .sort() + .map((t) => `"${t}"`) + .join(",") + + "}" + : null; + // Array of hours if formatted as, e.g., `{"(0,Mon,10:00:00,13:00:00)","(3,Sat,08:00:00,10:30:00)"} let hoursSqlValues; if (typeof model.hours === "string") { @@ -591,6 +624,9 @@ const insert = async (model) => { model.foodPrepared || false, model.foodMeat || false, Number(model.parentOrganizationId) || null, // INT + model.hoursNotes || "", + model.allowWalkins || false, + tags, ]; const result = await db.proc("create_stakeholder", params); @@ -713,6 +749,17 @@ const claim = async (model) => { const update = async (model) => { // Array of catetory_ids is formatted as, e.g., '{1,9}' const categories = "{" + model.selectedCategoryIds.join(",") + "}"; + + // Array of tags is formatted as, e.g., '{"tag1","tag2"}' + const tags = model.tags + ? "{" + + model.tags + .sort() + .map((t) => `"${t}"`) + .join(",") + + "}" + : null; + // Array of hours if formatted as, e.g., `{"(0,Mon,10:00:00,13:00:00)","(3,Sat,08:00:00,10:30:00)"} let hoursSqlValues = model.hours.length ? model.hours @@ -800,7 +847,10 @@ const update = async (model) => { model.foodDairy, model.foodPrepared, model.foodMeat, - model.parentOrganizationId, + model.parentOrganizationId || null, // INT + model.hoursNotes || "", + model.allowWalkins || false, + tags, ]; await db.proc("update_stakeholder", params); diff --git a/server/app/services/tag-service.js b/server/app/services/tag-service.js new file mode 100644 index 000000000..24397007a --- /dev/null +++ b/server/app/services/tag-service.js @@ -0,0 +1,45 @@ +const db = require("./db"); +const camelcaseKeys = require("camelcase-keys"); + +const selectAllById = async (tenantId) => { + // Need to cast id to number so pg-promise knows how + // to format SQL + const id = Number(tenantId); + const sql = `select id, name, tenant_id + from stakeholder_tag where tenant_id = $ + order by name`; + + const result = await db.manyOrNone(sql, { id }); + return result.map((r) => camelcaseKeys(r)); +}; + +const insert = async (model) => { + model.name = model.name.toUpperCase(); + const sql = `insert into stakeholder_tag (name, tenant_id) + values ($, $) + returning id`; + + const result = await db.one(sql, model); + return { id: result.id }; +}; + +const update = async (model) => { + model.name = model.name.toUpperCase(); + const sql = `update stakeholder_tag + set name = $ + where id = $`; + await db.none(sql, model); +}; + +const remove = async (id) => { + const sql = `delete from stakeholder_tag where id = $`; + const result = await db.result(sql, { id: Number(id) }); + return result.rowCount; +}; + +module.exports = { + selectAllById, + insert, + update, + remove, +}; diff --git a/server/migrations/1636826248368_add-logins-table.js b/server/migrations/1636826248368_add-logins-table.js new file mode 100644 index 000000000..f706df9fd --- /dev/null +++ b/server/migrations/1636826248368_add-logins-table.js @@ -0,0 +1,33 @@ +/* eslint-disable camelcase */ + +exports.shorthands = undefined; + +exports.up = (pgm) => { + pgm.createTable( + { schema: "public", name: "logins" }, + { + id: "id", + login_id: { + type: "integer", + notNull: true, + references: "login", + onDelete: "cascade", + }, + tenant_id: { + type: "integer", + notNull: true, + references: "tenant", + onDelete: "cascade", + }, + login_time: { + type: "timestamp", + notNull: true, + default: pgm.func("current_timestamp"), + }, + } + ); +}; + +exports.down = (pgm) => { + pgm.dropTable("logins"); +}; diff --git a/server/migrations/1637512893533_alter-stakeholder-1067.js b/server/migrations/1637512893533_alter-stakeholder-1067.js new file mode 100644 index 000000000..a0285caaf --- /dev/null +++ b/server/migrations/1637512893533_alter-stakeholder-1067.js @@ -0,0 +1,734 @@ +/* eslint-disable camelcase */ + +exports.shorthands = undefined; + +exports.up = (pgm) => { + pgm.sql(` + CREATE TABLE IF NOT EXISTS public.stakeholder_tag + ( + id integer NOT NULL GENERATED ALWAYS AS IDENTITY + ( INCREMENT 1 START 1 MINVALUE 1 MAXVALUE 2147483647 CACHE 1 ), + name character varying COLLATE pg_catalog."default" NOT NULL, + tenant_id integer, + CONSTRAINT stakeholder_tag_pkey PRIMARY KEY (id) + ); + + alter table stakeholder + add column hours_notes character varying default '' not null, + add column allow_walkins boolean NOT NULL DEFAULT false, + add column tags character varying[] ; + + alter table stakeholder_log + add column hours_notes character varying default '' not null, + add column allow_walkins boolean NOT NULL DEFAULT false, + add column tags character varying[] ; + + alter table stakeholder_best + add column hours_notes character varying default '' not null, + add column allow_walkins boolean NOT NULL DEFAULT false, + add column tags character varying[] ; + `); + + pgm.sql(` + DROP PROCEDURE IF EXISTS public.create_stakeholder( + integer, integer, character varying, character varying, character varying, + character varying, character varying, character varying, character varying, + numeric, numeric, character varying, boolean, character varying, + character varying, character varying, integer, character varying, + character varying, character varying, character varying, + character varying, character varying, character varying, + character varying, character varying, character varying, + timestamp with time zone, integer, timestamp without time zone, + integer, timestamp without time zone, integer, + timestamp without time zone, integer, character varying, + character varying, character varying, character varying, + character varying, character varying, character varying, + character varying, boolean, boolean, boolean, boolean, + character varying, character varying, character varying, + character varying, character varying, character varying, + character varying, character varying, boolean, boolean, + boolean, boolean, boolean, boolean, boolean, integer, + boolean, integer[], stakeholder_hours[], boolean, boolean, + boolean, boolean, boolean, boolean, integer); + + CREATE OR REPLACE PROCEDURE public.create_stakeholder( + INOUT s_id integer, + s_tenant_id integer, + s_name character varying, + s_address_1 character varying, + s_address_2 character varying, + s_city character varying, + s_state character varying, + s_zip character varying, + s_phone character varying, + s_latitude numeric, + s_longitude numeric, + s_website character varying, + s_inactive boolean, + s_notes character varying, + s_requirements character varying, + s_admin_notes character varying, + s_created_login_id integer, + s_parent_organization character varying, + s_physical_access character varying, + s_email character varying, + s_items character varying, + s_services character varying, + s_facebook character varying, + s_twitter character varying, + s_pinterest character varying, + s_linkedin character varying, + s_description character varying, + s_submitted_date timestamp with time zone, + s_submitted_login_id integer, + s_approved_date timestamp without time zone, + s_reviewed_login_id integer, + s_assigned_date timestamp without time zone, + s_assigned_login_id integer, + s_claimed_date timestamp without time zone, + s_claimed_login_id integer, + s_review_notes character varying, + s_instagram character varying, + s_admin_contact_name character varying, + s_admin_contact_phone character varying, + s_admin_contact_email character varying, + s_donation_contact_name character varying, + s_donation_contact_phone character varying, + s_donation_contact_email character varying, + s_donation_pickup boolean, + s_donation_accept_frozen boolean, + s_donation_accept_refrigerated boolean, + s_donation_accept_perishable boolean, + s_donation_schedule character varying, + s_donation_delivery_instructions character varying, + s_donation_notes character varying, + s_covid_notes character varying, + s_category_notes character varying, + s_eligibility_notes character varying, + s_food_types character varying, + s_languages character varying, + s_v_name boolean, + s_v_categories boolean, + s_v_address boolean, + s_v_phone boolean, + s_v_email boolean, + s_v_hours boolean, + s_v_food_types boolean, + s_verification_status_id integer, + s_inactive_temporary boolean, + categories integer[], + hours_array stakeholder_hours[], + s_food_bakery boolean, + s_food_dry_goods boolean, + s_food_produce boolean, + s_food_dairy boolean, + s_food_prepared boolean, + s_food_meat boolean, + s_parent_organization_id integer, + s_hours_notes character varying, + s_allow_walkins boolean, + s_tags character varying[] + ) + LANGUAGE 'plpgsql' + AS $BODY$ + DECLARE cat INT; + DECLARE hours_element stakeholder_hours; + DECLARE critical_percent INT; + BEGIN + SELECT CASE WHEN (s_inactive OR s_inactive_temporary) THEN + (s_v_name::integer + s_v_categories::integer + s_v_address::integer) *100/3 + ELSE + (s_v_name::integer + s_v_categories::integer + s_v_address::integer + + s_v_email::integer + s_v_phone::integer + s_v_hours::integer) *100/6 + END INTO critical_percent; + + INSERT INTO stakeholder ( + tenant_id, + name, address_1, address_2, city, state, zip, + phone, latitude, longitude, + website, inactive, notes, requirements, admin_notes, created_login_id, + parent_organization, physical_access, email, + items, services, facebook, twitter, pinterest, linkedin, description, + submitted_date, submitted_login_id, approved_date, reviewed_login_id, + assigned_date, assigned_login_id, claimed_date, claimed_login_id, + review_notes, instagram, admin_contact_name, + admin_contact_phone, admin_contact_email, + donation_contact_name, donation_contact_phone, + donation_contact_email, donation_pickup, + donation_accept_frozen, donation_accept_refrigerated, + donation_accept_perishable, donation_schedule, + donation_delivery_instructions, donation_notes, covid_notes, + category_notes, eligibility_notes, food_types, languages, + v_name, v_categories, v_address, + v_phone, v_email, v_hours, v_food_types, + verification_status_id, inactive_temporary, + hours, category_ids, neighborhood_id, complete_critical_percent, + food_bakery, food_dry_goods , food_produce, food_dairy, food_prepared, food_meat, + parent_organization_id, hours_notes, allow_walkins, tags) + VALUES ( + s_tenant_id, + s_name, s_address_1, s_address_2, s_city, s_state, s_zip, + s_phone, s_latitude, s_longitude, + s_website, s_inactive, s_notes, s_requirements, s_admin_notes, s_created_login_id, + s_parent_organization, s_physical_access, s_email, + s_items, s_services, s_facebook, s_twitter, s_pinterest, s_linkedin, s_description, + s_submitted_date, s_submitted_login_id, s_approved_date, s_reviewed_login_id, + s_assigned_date, s_assigned_login_id, s_claimed_date, s_claimed_login_id, + s_review_notes, s_instagram, s_admin_contact_name, + s_admin_contact_phone, s_admin_contact_email, + s_donation_contact_name, s_donation_contact_phone, + s_donation_contact_email, s_donation_pickup, + s_donation_accept_frozen, s_donation_accept_refrigerated, + s_donation_accept_perishable, s_donation_schedule, + s_donation_delivery_instructions, s_donation_notes, s_covid_notes, + s_category_notes, s_eligibility_notes, s_food_types, s_languages, + s_v_name, s_v_categories, s_v_address, + s_v_phone, s_v_email, s_v_hours, s_v_food_types, + s_verification_status_id, s_inactive_temporary, + hours_array, categories, + (SELECT id FROM neighborhood WHERE ST_Contains(geometry, ST_Point(s_longitude, s_latitude)) LIMIT 1), + critical_percent, + s_food_bakery, s_food_dry_goods , s_food_produce, s_food_dairy, s_food_prepared, s_food_meat, + s_parent_organization_id, s_hours_notes, s_allow_walkins, s_tags + ) RETURNING id INTO s_id; + + -- insert new stakeholder category(s) + FOREACH cat IN ARRAY categories + LOOP + INSERT INTO stakeholder_category + (stakeholder_id, category_id) + VALUES (s_id, cat); + END LOOP; + + -- insert new schedule(s) + FOREACH hours_element IN ARRAY hours_array + LOOP + INSERT INTO stakeholder_schedule( + stakeholder_id, day_of_week, open, close, week_of_month + ) VALUES( + s_id, + hours_element.day_of_week, + hours_element.open::time without time zone, + hours_element.close::time without time zone, + hours_element.week_of_month + ); + END LOOP; + + COMMIT; + END; + $BODY$; + `); + + pgm.sql(` + DROP PROCEDURE public.update_stakeholder(character varying, character varying, character varying, character varying, character varying, character varying, character varying, numeric, numeric, character varying, boolean, character varying, character varying, character varying, character varying, character varying, character varying, character varying, character varying, character varying, character varying, character varying, character varying, character varying, integer, timestamp with time zone, integer, timestamp without time zone, integer, timestamp without time zone, integer, timestamp without time zone, integer, character varying, character varying, character varying, character varying, character varying, character varying, character varying, character varying, boolean, boolean, boolean, boolean, character varying, character varying, character varying, character varying, character varying, character varying, character varying, character varying, boolean, boolean, boolean, boolean, boolean, boolean, boolean, integer, boolean, integer, integer[], stakeholder_hours[], boolean, boolean, boolean, boolean, boolean, boolean, integer); + + CREATE OR REPLACE PROCEDURE public.update_stakeholder( + s_name character varying, + s_address_1 character varying, + s_address_2 character varying, + s_city character varying, + s_state character varying, + s_zip character varying, + s_phone character varying, + s_latitude numeric, + s_longitude numeric, + s_website character varying, + s_inactive boolean, + s_notes character varying, + s_requirements character varying, + s_admin_notes character varying, + s_parent_organization character varying, + s_physical_access character varying, + s_email character varying, + s_items character varying, + s_services character varying, + s_facebook character varying, + s_twitter character varying, + s_pinterest character varying, + s_linkedin character varying, + s_description character varying, + s_modified_login_id integer, + s_submitted_date timestamp with time zone, + s_submitted_login_id integer, + s_approved_date timestamp without time zone, + s_reviewed_login_id integer, + s_assigned_date timestamp without time zone, + s_assigned_login_id integer, + s_claimed_date timestamp without time zone, + s_claimed_login_id integer, + s_review_notes character varying, + s_instagram character varying, + s_admin_contact_name character varying, + s_admin_contact_phone character varying, + s_admin_contact_email character varying, + s_donation_contact_name character varying, + s_donation_contact_phone character varying, + s_donation_contact_email character varying, + s_donation_pickup boolean, + s_donation_accept_frozen boolean, + s_donation_accept_refrigerated boolean, + s_donation_accept_perishable boolean, + s_donation_schedule character varying, + s_donation_delivery_instructions character varying, + s_donation_notes character varying, + s_covid_notes character varying, + s_category_notes character varying, + s_eligibility_notes character varying, + s_food_types character varying, + s_languages character varying, + s_v_name boolean, + s_v_categories boolean, + s_v_address boolean, + s_v_phone boolean, + s_v_email boolean, + s_v_hours boolean, + s_v_food_types boolean, + s_verification_status_id integer, + s_inactive_temporary boolean, + s_id integer, + categories integer[], + hours_array stakeholder_hours[], + s_food_bakery boolean, + s_food_dry_goods boolean, + s_food_produce boolean, + s_food_dairy boolean, + s_food_prepared boolean, + s_food_meat boolean, + s_parent_organization_id integer, + s_hours_notes character varying, + s_allow_walkins boolean, + s_tags character varying[]) + LANGUAGE 'plpgsql' + AS $BODY$ + DECLARE cat INT; + DECLARE hours_element stakeholder_hours; + DECLARE critical_percent INT; + + BEGIN + + SELECT CASE WHEN (s_inactive OR s_inactive_temporary) THEN + (s_v_name::integer + s_v_categories::integer + s_v_address::integer) *100/3 + ELSE + (s_v_name::integer + s_v_categories::integer + s_v_address::integer + + s_v_email::integer + s_v_phone::integer + s_v_hours::integer + + s_v_food_types::integer) *100/7 + END INTO critical_percent; + + -- update the stakeholder table itself + UPDATE stakeholder SET + name = s_name, + address_1 = s_address_1, + address_2 = s_address_2, + city = s_city, + state = s_state, + zip = s_zip, + phone = s_phone, + latitude = s_latitude, + longitude = s_longitude, + website = s_website, + inactive = s_inactive, + notes = s_notes, + requirements = s_requirements, + admin_notes = s_admin_notes, + parent_organization = s_parent_organization, + physical_access = s_physical_access, + email = s_email, + items = s_items, + services = s_services, + facebook = s_facebook, + twitter = s_twitter, + pinterest = s_pinterest, + linkedin = s_linkedin, + description = s_description, + modified_login_id = s_modified_login_id, + modified_date = CURRENT_TIMESTAMP, + submitted_date = s_submitted_date, + submitted_login_id = s_submitted_login_id, + approved_date = s_approved_date, + reviewed_login_id = s_reviewed_login_id, + assigned_date = s_assigned_date, + assigned_login_id = s_assigned_login_id, + claimed_date = s_claimed_date, + claimed_login_id = s_claimed_login_id, + review_notes = s_review_notes, + instagram = s_instagram, + admin_contact_name = s_admin_contact_name, + admin_contact_phone = s_admin_contact_phone, + admin_contact_email = s_admin_contact_email, + donation_contact_name = s_donation_contact_name, + donation_contact_phone = s_donation_contact_phone, + donation_contact_email = s_donation_contact_email, + donation_pickup = s_donation_pickup, + donation_accept_frozen = s_donation_accept_frozen, + donation_accept_refrigerated = s_donation_accept_refrigerated, + donation_accept_perishable = s_donation_accept_perishable, + donation_schedule = s_donation_schedule, + donation_delivery_instructions = s_donation_delivery_instructions, + donation_notes = s_donation_notes, + covid_notes = s_covid_notes, + category_notes = s_category_notes, + eligibility_notes = s_eligibility_notes, + food_types = s_food_types, + languages = s_languages, + v_name = s_v_name, + v_categories = s_v_categories, + v_address = s_v_address, + v_phone = s_v_phone, + v_email = s_v_email, + v_hours = s_v_hours, + v_food_types = s_v_food_types, + verification_status_id = s_verification_status_id, + inactive_temporary = s_inactive_temporary, + hours = hours_array, + category_ids = categories, + complete_critical_percent = critical_percent, + food_bakery = s_food_bakery, + food_dry_goods = s_food_dry_goods, + food_produce = s_food_produce, + food_dairy = s_food_dairy, + food_prepared = s_food_prepared, + food_meat = s_food_meat, + parent_organization_id = s_parent_organization_id, + hours_notes = s_hours_notes, + allow_walkins = s_allow_walkins, + tags = s_tags + WHERE + id=s_id; + + -- delete previous stakeholder category + DELETE FROM stakeholder_category WHERE stakeholder_id=s_id; + + -- ...and insert new stakeholder category(s) + FOREACH cat IN ARRAY categories + LOOP + INSERT INTO stakeholder_category + (stakeholder_id, category_id) + VALUES (s_id, cat); + END LOOP; + + -- delete previous schedule + DELETE FROM stakeholder_schedule WHERE stakeholder_id=s_id; + + -- ...and insert new schedule(s) + FOREACH hours_element IN ARRAY hours_array + LOOP + INSERT INTO stakeholder_schedule( + stakeholder_id, day_of_week, open, close, week_of_month + ) VALUES( + s_id, + hours_element.day_of_week, + hours_element.open::time without time zone, + hours_element.close::time without time zone, + hours_element.week_of_month + ); + END LOOP; + COMMIT; + END; + $BODY$; + `); + + pgm.sql(` + CREATE OR REPLACE FUNCTION public.on_insert_or_update_stakeholder() + RETURNS trigger + LANGUAGE 'plpgsql' + COST 100 + VOLATILE NOT LEAKPROOF + AS $BODY$ + DECLARE + best_row stakeholder_log%ROWTYPE; + latest_version INTEGER; + is_verified BOOLEAN := false; + categoryid INTEGER; + BEGIN + INSERT INTO public.stakeholder_log + (id, tenant_id, version, name, address_1, address_2, city, state, zip, + phone, latitude, longitude, website, fm_id, notes, + created_date, created_login_id, modified_date, modified_login_id, + requirements, admin_notes, inactive, parent_organization, + physical_access, email, items, services, facebook, twitter, + pinterest, + linkedin, + description, + approved_date, + reviewed_login_id, + assigned_login_id, + agency_type, + assigned_date, + review_notes, + claimed_login_id, + claimed_date, + instagram, + admin_contact_name, + admin_contact_phone, + admin_contact_email, + donation_contact_name, + donation_contact_phone, + donation_contact_email, + donation_pickup, + donation_accept_frozen, + donation_accept_refrigerated, + donation_accept_perishable, + donation_schedule, + donation_delivery_instructions, + covid_notes, + donation_notes, + category_notes, + eligibility_notes, + food_types, + languages, + verification_status_id, + inactive_temporary, + v_name, v_categories, v_address, v_email, v_phone, v_hours, v_food_types, + hours, category_ids, + neighborhood_id, + complete_critical_percent, + food_bakery, food_dry_goods , food_produce, food_dairy, food_prepared, food_meat, + parent_organization_id, hours_notes, allow_walkins, tags + ) + VALUES ( + NEW.id, NEW.tenant_id, + (SELECT greatest(max(version) + 1, 1) FROM public.stakeholder_log where id = NEW.id), + NEW.name, + NEW.address_1, + NEW.address_2, + NEW.city, + NEW.state, + NEW.zip, + NEW.phone, + NEW.latitude, + NEW.longitude, + NEW.website, + NEW.fm_id, + NEW.notes, + NEW.created_date, + NEW.created_login_id, + NEW.modified_date, + NEW.modified_login_id, + NEW.requirements, + NEW.admin_notes, + NEW.inactive, + NEW.parent_organization, + NEW.physical_access, + NEW.email, + NEW.items, + NEW.services, + NEW.facebook, + NEW.twitter, + NEW.pinterest, + NEW.linkedin, + NEW.description, + NEW.approved_date, + NEW.reviewed_login_id, + NEW.assigned_login_id, + NEW.agency_type, + NEW.assigned_date, + NEW.review_notes, + NEW.claimed_login_id, + NEW.claimed_date, + NEW.instagram, + NEW.admin_contact_name, + NEW.admin_contact_phone, + NEW.admin_contact_email, + NEW.donation_contact_name, + NEW.donation_contact_phone, + NEW.donation_contact_email, + NEW.donation_pickup, + NEW.donation_accept_frozen, + NEW.donation_accept_refrigerated, + NEW.donation_accept_perishable, + NEW.donation_schedule, + NEW.donation_delivery_instructions, + NEW.covid_notes, + NEW.donation_notes, + NEW.category_notes, + NEW.eligibility_notes, + NEW.food_types, + NEW.languages, + NEW.verification_status_id, + NEW.inactive_temporary, + NEW.v_name, + NEW.v_categories, + NEW.v_address, + NEW.v_email, + NEW.v_phone, + NEW.v_hours, + NEW.v_food_types, + NEW.hours, + NEW.category_ids, + (SELECT id FROM neighborhood WHERE ST_Contains(geometry, ST_Point(NEW.longitude, NEW.latitude)) LIMIT 1), + NEW.complete_critical_percent, + NEW.food_bakery, NEW.food_dry_goods , NEW.food_produce, NEW.food_dairy, NEW.food_prepared, NEW.food_meat, + NEW.parent_organization_id, NEW.hours_notes, NEW.allow_walkins, NEW.tags + ) RETURNING version INTO latest_version; + + -- We might need to select a new row as our "best" row for this stakeholder. + -- "best" is defined as the highest version in stakeholder_log with verification_status_id=4 + -- (4 means "verified"). + -- Barring that, the highest version is the "best". + + SELECT * INTO best_row FROM stakeholder_log + WHERE id=NEW.id + AND verification_status_id=4 + AND version=(select MAX(version) from stakeholder_log where id=NEW.id AND verification_status_id=4); + + -- Is there anything in best_row? (there might not be, if there are no verified rows) + IF NOT FOUND THEN + -- Fall back on finding the highest version number, which *just so happens* to be this row! + SELECT * INTO best_row FROM stakeholder_log + WHERE id=NEW.id + AND version=latest_version; + ELSE + is_verified = true; + END IF; + + IF FOUND THEN + DELETE FROM stakeholder_best where id=best_row.id; + INSERT INTO stakeholder_best + (id, tenant_id, name, address_1, address_2, city, state, zip, + phone, latitude, longitude, website, fm_id, notes, + created_date, created_login_id, modified_date, modified_login_id, + requirements, admin_notes, inactive, parent_organization, + physical_access, email, items, services, facebook, twitter, + pinterest, + linkedin, + description, + approved_date, + reviewed_login_id, + assigned_login_id, + agency_type, + assigned_date, + review_notes, + claimed_login_id, + claimed_date, + instagram, + admin_contact_name, + admin_contact_phone, + admin_contact_email, + donation_contact_name, + donation_contact_phone, + donation_contact_email, + donation_pickup, + donation_accept_frozen, + donation_accept_refrigerated, + donation_accept_perishable, + donation_schedule, + donation_delivery_instructions, + covid_notes, + donation_notes, + category_notes, + eligibility_notes, + food_types, + languages, + verification_status_id, + inactive_temporary, + v_name, v_categories, v_address, v_email, v_phone, v_hours, v_food_types, + hours, category_ids, + neighborhood_id, + complete_critical_percent, + food_bakery, food_dry_goods , food_produce, food_dairy, food_prepared, food_meat, + parent_organization_id, hours_notes, allow_walkins, tags, + is_verified + ) + VALUES ( + best_row.id, + best_row.tenant_id, + best_row.name, + best_row.address_1, + best_row.address_2, + best_row.city, + best_row.state, + best_row.zip, + best_row.phone, + best_row.latitude, + best_row.longitude, + best_row.website, + best_row.fm_id, + best_row.notes, + best_row.created_date, + best_row.created_login_id, + best_row.modified_date, + best_row.modified_login_id, + best_row.requirements, + best_row.admin_notes, + best_row.inactive, + best_row.parent_organization, + best_row.physical_access, + best_row.email, + best_row.items, + best_row.services, + best_row.facebook, + best_row.twitter, + best_row.pinterest, + best_row.linkedin, + best_row.description, + best_row.approved_date, + best_row.reviewed_login_id, + best_row.assigned_login_id, + best_row.agency_type, + best_row.assigned_date, + best_row.review_notes, + best_row.claimed_login_id, + best_row.claimed_date, + best_row.instagram, + best_row.admin_contact_name, + best_row.admin_contact_phone, + best_row.admin_contact_email, + best_row.donation_contact_name, + best_row.donation_contact_phone, + best_row.donation_contact_email, + best_row.donation_pickup, + best_row.donation_accept_frozen, + best_row.donation_accept_refrigerated, + best_row.donation_accept_perishable, + best_row.donation_schedule, + best_row.donation_delivery_instructions, + best_row.covid_notes, + best_row.donation_notes, + best_row.category_notes, + best_row.eligibility_notes, + best_row.food_types, + best_row.languages, + best_row.verification_status_id, + best_row.inactive_temporary, + best_row.v_name, + best_row.v_categories, + best_row.v_address, + best_row.v_email, + best_row.v_phone, + best_row.v_hours, + best_row.v_food_types, + best_row.hours, + best_row.category_ids, + best_row.neighborhood_id, + best_row.complete_critical_percent, + best_row.food_bakery, best_row.food_dry_goods , best_row.food_produce, + best_row.food_dairy, best_row.food_prepared, best_row.food_meat, + best_row.parent_organization_id, best_row.hours_notes, best_row.allow_walkins, best_row.tags, + is_verified); + + /* Populate normalized stakeholder_best_category table */ + IF best_row.category_ids IS NOT NULL THEN + FOREACH categoryid IN ARRAY best_row.category_ids + LOOP + INSERT INTO stakeholder_best_category + (stakeholder_id, category_id) + VALUES (best_row.id, categoryid); + END LOOP; + END IF; + ELSE + -- should probably log some sort of error, because this should never happen + RAISE EXCEPTION 'Could not find a best version of stakeholder id %', NEW.id; + END IF; + + RETURN NEW; + END; + $BODY$; + + ALTER FUNCTION public.on_insert_or_update_stakeholder() + OWNER TO postgres; + `); +}; + +exports.down = () => { + /* Not reversible */ +}; diff --git a/server/package.json b/server/package.json index a682b7d9d..9e4ce80dd 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "foodoasis-web-api", - "version": "1.0.63", + "version": "1.0.64", "author": "Hack for LA", "description": "Web API Server for Food Oasis", "main": "server.js",