diff --git a/adminapp/src/components/AddressInputs.jsx b/adminapp/src/components/AddressInputs.jsx index 42fdfcfd5..bb6d45c09 100644 --- a/adminapp/src/components/AddressInputs.jsx +++ b/adminapp/src/components/AddressInputs.jsx @@ -1,6 +1,6 @@ import api from "../api"; import { useGlobalApiState } from "../hooks/globalApiState"; -import formHelpers from "../modules/formHelpers"; +import { stub } from "../modules/formHelpers"; import ResponsiveStack from "./ResponsiveStack"; import AddIcon from "@mui/icons-material/Add"; import DeleteIcon from "@mui/icons-material/Delete"; @@ -23,7 +23,7 @@ export default function AddressInputs({ address, onFieldChange }) { onFieldChange({ address: { ...address, ...a } }); } function handleAddressOn() { - onFieldChange({ address: formHelpers.initialAddress }); + onFieldChange({ address: stub.address }); } function handleAddressOff() { onFieldChange({ address: null }); diff --git a/adminapp/src/components/AuditActivityList.jsx b/adminapp/src/components/AuditActivityList.jsx index a284c5d4e..7fba6ce4a 100644 --- a/adminapp/src/components/AuditActivityList.jsx +++ b/adminapp/src/components/AuditActivityList.jsx @@ -1,16 +1,16 @@ import formatDate from "../modules/formatDate"; import AdminLink from "./AdminLink"; import AdminMarkdown from "./AdminMarkdown"; -import RelatedList from "./RelatedList"; +import RelatedListRemote from "./RelatedListRemote"; import { makeStyles } from "@mui/styles"; import React from "react"; export default function AuditActivityList({ activities }) { return ( - [ formatDate(row.createdAt), diff --git a/adminapp/src/components/AuditLogs.jsx b/adminapp/src/components/AuditLogs.jsx index 880c9fc55..e6f90f7ad 100644 --- a/adminapp/src/components/AuditLogs.jsx +++ b/adminapp/src/components/AuditLogs.jsx @@ -1,15 +1,15 @@ import { dayjs } from "../modules/dayConfig"; import AdminLink from "./AdminLink"; -import RelatedList from "./RelatedList"; +import RelatedListRemote from "./RelatedListRemote"; import isEmpty from "lodash/isEmpty"; import React from "react"; export default function AuditLogs({ auditLogs }) { return ( - [dayjs(row.at).format("lll"), event(row), by(row), context(row)]} /> diff --git a/adminapp/src/components/CategoriesRelatedList.jsx b/adminapp/src/components/CategoriesRelatedList.jsx index 1a94a81e2..577823685 100644 --- a/adminapp/src/components/CategoriesRelatedList.jsx +++ b/adminapp/src/components/CategoriesRelatedList.jsx @@ -1,12 +1,12 @@ import AdminLink from "./AdminLink"; -import RelatedList from "./RelatedList"; +import RelatedListRemote from "./RelatedListRemote"; import React from "react"; export default function CategoriesRelatedList({ categories }) { return ( - [ diff --git a/adminapp/src/components/EligibilityAssignmentsRelatedList.jsx b/adminapp/src/components/EligibilityAssignmentsRelatedList.jsx index a6760ba5f..8dbbe1e34 100644 --- a/adminapp/src/components/EligibilityAssignmentsRelatedList.jsx +++ b/adminapp/src/components/EligibilityAssignmentsRelatedList.jsx @@ -1,13 +1,13 @@ import createRelativeUrl from "../shared/createRelativeUrl"; import AdminLink from "./AdminLink"; -import RelatedList from "./RelatedList"; +import RelatedListRemote from "./RelatedListRemote"; import React from "react"; export default function EligibilityAssignmentsRelatedList({ model, type, title }) { return ( - {title} } headers={["Id", "Applied", "Amount", "Originating", "Receiving"]} - rows={rows} + collection={collection} keyRowAttr="id" toCells={(row) => [ , diff --git a/adminapp/src/components/OneToManyEditor.jsx b/adminapp/src/components/OneToManyEditor.jsx index 267565800..c057d49e6 100644 --- a/adminapp/src/components/OneToManyEditor.jsx +++ b/adminapp/src/components/OneToManyEditor.jsx @@ -1,3 +1,4 @@ +import { assertFullCollection, setCollectionItems } from "../modules/apicollection"; import mergeAt from "../shared/mergeAt"; import withoutAt from "../shared/withoutAt"; import AutocompleteSearch from "./AutocompleteSearch"; @@ -7,20 +8,27 @@ import { Box, FormLabel, Icon, Stack } from "@mui/material"; import Button from "@mui/material/Button"; import React from "react"; -export default function OneToManyEditor({ title, items, setItems, apiItemSearch }) { +export default function OneToManyEditor({ + title, + collection, + setCollection, + apiItemSearch, +}) { + assertFullCollection(collection); + const handleAdd = () => { - setItems([...items, { id: 0 }]); + setCollectionItems(setCollection, [...collection.items, { id: 0 }]); }; const handleRemove = (index) => { - setItems(withoutAt(items, index)); + setCollectionItems(setCollection, withoutAt(collection.items, index)); }; function handleChange(index, fields) { - setItems(mergeAt(items, index, fields)); + setCollectionItems(setCollection, mergeAt(collection.items, index, fields)); } return ( <> {`${title}s`} - {items?.map((o, i) => ( + {collection.items?.map((o, i) => ( - [ @@ -36,9 +35,9 @@ export default function PaymentAccountRelatedLists({ paymentAccount }) { {row.amount}, ]} /> - [ @@ -48,16 +47,16 @@ export default function PaymentAccountRelatedLists({ paymentAccount }) { {row.amount}, ]} /> - { const cells = [ , row.currency, - map(row.vendorServiceCategories, "name").join(", "), + AdminLink.Array(row.categories.items, (c) => ), {row.balance}, ]; if (canCreateBook) { @@ -78,14 +77,24 @@ export default function PaymentAccountRelatedLists({ paymentAccount }) { return cells; }} /> - {paymentAccount.ledgers.map((ledger) => ( - - ))} + [ + row.id, + formatDate(row.appliedAt), + + {scaleMoney( + row.amount, + row.originatingLedger.accountId === paymentAccount.id ? -1 : 1 + )} + , + , + , + ]} + /> ); } diff --git a/adminapp/src/components/Programs.jsx b/adminapp/src/components/Programs.jsx index 439186dfd..0a044dc2c 100644 --- a/adminapp/src/components/Programs.jsx +++ b/adminapp/src/components/Programs.jsx @@ -40,7 +40,7 @@ export default function Programs({ } const combinedProgramStates = {}; - programs.forEach((c) => (combinedProgramStates[c.id] = true)); + programs.items.forEach((c) => (combinedProgramStates[c.id] = true)); merge(combinedProgramStates, newProgramStates); if (editing.isOff) { @@ -58,8 +58,8 @@ export default function Programs({ // whether the program is associated with the resource. // If all programs aren't loaded yet, // show just the ones associated with the resource. - const iterablePrograms = allPrograms || programs; - iterablePrograms?.forEach((c) => + const iterablePrograms = allPrograms || programs.items; + iterablePrograms.forEach((c) => displayables.push({ key: c.id, label: c.name.en || c.name, diff --git a/adminapp/src/components/RelatedListRemote.jsx b/adminapp/src/components/RelatedListRemote.jsx new file mode 100644 index 000000000..051692166 --- /dev/null +++ b/adminapp/src/components/RelatedListRemote.jsx @@ -0,0 +1,146 @@ +import api from "../api"; +import useErrorSnackbar from "../hooks/useErrorSnackbar"; +import useRoleAccess from "../hooks/useRoleAccess"; +import useDebugEffect from "../shared/react/useDebugEffect"; +import Link from "./Link"; +import "./RelatedList.css"; +import SimpleTable from "./SimpleTable"; +import ListAltIcon from "@mui/icons-material/ListAlt"; +import { Card, CardContent, Chip, Stack } from "@mui/material"; +import Button from "@mui/material/Button"; +import Typography from "@mui/material/Typography"; +import isEmpty from "lodash/isEmpty"; +import React from "react"; + +const PAGE_SIZE = 100; + +export default function RelatedListRemote({ + title, + headers, + pushLeft, + addNewLabel, + addNewLink, + addNewRole, + emptyState, + cardProps, + className, + onAddNewClick, + collection, + ...rest +}) { + const { enqueueErrorSnackbar } = useErrorSnackbar(); + const [allRows, setAllRows] = React.useState(collection.items); + const [latestCollection, setLatestCollection] = React.useState(collection); + const [nextPage, setNextPage] = React.useState(1); + const [pageLoading, setPageLoading] = React.useState(false); + + const { canWriteResource } = useRoleAccess(); + + const loadNextPage = React.useCallback( + ({ loadAll, page = nextPage, rows = allRows }) => { + setPageLoading(true); + api + .get(latestCollection.url, { page, pageSize: PAGE_SIZE }) + .then((r) => { + // Replace page 1 since we only have a partial list initially. + const newRows = page === 1 ? r.data.items : [...rows, ...r.data.items]; + setAllRows(newRows); + setLatestCollection(r.data); + setNextPage(page + 1); + if (!r.data.hasMore) { + setPageLoading(false); + return; + } + if (loadAll) { + return loadNextPage({ loadAll, page: page + 1, rows: newRows }); + } + setPageLoading(false); + }) + .catch((e) => { + enqueueErrorSnackbar(e); + setPageLoading(false); + }); + }, + [allRows, enqueueErrorSnackbar, latestCollection.url, nextPage] + ); + + function handleLoadMore(e) { + e.preventDefault(); + loadNextPage({ loadAll: false }); + } + + function handleLoadAll(e) { + e.preventDefault(); + loadNextPage({ loadAll: true }); + } + + if (pushLeft === undefined) { + pushLeft = headers?.length <= 2; + } + const addNew = Boolean(addNewLink || onAddNewClick) && canWriteResource(addNewRole); + + useDebugEffect(() => api.get(latestCollection.url, { page: 1, pageSize: 2 }), { + once: true, + }); + + if (!collection.totalCount && !addNew && !emptyState) { + return null; + } + + const disableLoadButtons = pageLoading || !latestCollection.hasMore; + + return ( + + + {title && ( + + {title} + + )} + {addNew && ( + + + {addNewLabel} + + )} + {isEmpty(allRows) ? ( + emptyState + ) : ( + + )} + {collection.hasMore && ( + + + + + )} + + + ); +} + +function ListCount({ count }) { + if (!count) { + return null; + } + return ( + + ); +} diff --git a/adminapp/src/components/ResourceCreate.jsx b/adminapp/src/components/ResourceCreate.jsx index 7b2fdac4c..25c557e7f 100644 --- a/adminapp/src/components/ResourceCreate.jsx +++ b/adminapp/src/components/ResourceCreate.jsx @@ -1,10 +1,11 @@ +import { simplifyCollections } from "../modules/apicollection"; import ResourceForm from "./ResourceForm"; import React from "react"; export default function ResourceCreate({ empty, apiCreate, Form }) { const handleApplyChange = React.useCallback( (changes) => { - return apiCreate({ ...empty, ...changes }); + return apiCreate(simplifyCollections({ ...empty, ...changes })); }, [apiCreate, empty] ); diff --git a/adminapp/src/components/ResourceEdit.jsx b/adminapp/src/components/ResourceEdit.jsx index 3e8961a08..fadb90470 100644 --- a/adminapp/src/components/ResourceEdit.jsx +++ b/adminapp/src/components/ResourceEdit.jsx @@ -1,5 +1,6 @@ import FormLayout from "../components/FormLayout"; import useErrorSnackbar from "../hooks/useErrorSnackbar"; +import { simplifyCollections } from "../modules/apicollection"; import useAsyncFetch from "../shared/react/useAsyncFetch"; import ResourceForm from "./ResourceForm"; import isEmpty from "lodash/isEmpty"; @@ -9,20 +10,21 @@ import { useParams } from "react-router-dom"; /** * @param apiGet API method to get the resource. + * @param expand Array of fields to expand in the API response. * @param apiUpdate API method to update the resource. * @param alwaysApply If false, show 'no changes to save' if there are no changes queued. * If true, always update on submit. Only useful where submitting no parameters * has some other side effect. * @param Form Form component to use. */ -export default function ResourceEdit({ apiGet, apiUpdate, alwaysApply, Form }) { +export default function ResourceEdit({ apiGet, expand, apiUpdate, alwaysApply, Form }) { const { enqueueErrorSnackbar } = useErrorSnackbar(); const { enqueueSnackbar } = useSnackbar(); const { id: idStr } = useParams(); const id = Number(idStr); const apiGetWithErr = React.useCallback(() => { - return apiGet({ id }).catch(enqueueErrorSnackbar); - }, [apiGet, enqueueErrorSnackbar, id]); + return apiGet({ id, expand }).catch(enqueueErrorSnackbar); + }, [apiGet, expand, enqueueErrorSnackbar, id]); const { state, loading, error } = useAsyncFetch(apiGetWithErr, { default: {}, pickData: true, @@ -34,9 +36,9 @@ export default function ResourceEdit({ apiGet, apiUpdate, alwaysApply, Form }) { window.history.back(); return Promise.resolve(); } - return apiUpdate({ id, ...changes }); + return apiUpdate(simplifyCollections({ id, expand, ...changes })); }, - [apiUpdate, enqueueSnackbar, id, alwaysApply] + [apiUpdate, expand, enqueueSnackbar, id, alwaysApply] ); // TODO: Add an error page at some point diff --git a/adminapp/src/components/ResourceForm.jsx b/adminapp/src/components/ResourceForm.jsx index 46b0055b0..c3e0bc39a 100644 --- a/adminapp/src/components/ResourceForm.jsx +++ b/adminapp/src/components/ResourceForm.jsx @@ -1,6 +1,7 @@ import api from "../api"; import useBusy from "../hooks/useBusy"; import useErrorSnackbar from "../hooks/useErrorSnackbar"; +import { isCollection } from "../modules/apicollection"; import HelmetTitle from "./HelmetTitle"; import assign from "lodash/assign"; import isArray from "lodash/isArray"; @@ -61,18 +62,29 @@ export default function ResourceForm({ InnerForm, baseResource, isCreate, applyC [changes] ); - const resource = mergeWith({}, baseResource, changes, (obj, src) => { - // Since `_.merge()` only merges array indexes and does not replace arrays, - // we need to check for empty arrays and return them, also return src when - // it's an image or not an object (like a string). - // This allows nested resources and sub-nested resources to be removed/set - const isEmptyArray = isArray(src) && !isNil(src); - if (!isObject(src) || isEmptyArray || src instanceof File) { - return src; + const resource = mergeWith( + {}, + baseResource, + changes, + (objValue, srcValue, _key, _object, _source, _stack) => { + // Since `_.merge()` only merges array indexes and does not replace arrays, + // we need to check for empty arrays and return them, also return src when + // it's an image or not an object (like a string). + // This allows nested resources and sub-nested resources to be removed/set + const isArrayValue = isArray(srcValue) && !isNil(srcValue); + const isCollectionValue = isCollection(srcValue); + if ( + !isObject(srcValue) || + isArrayValue || + isCollectionValue || + srcValue instanceof File + ) { + return srcValue; + } + // Otherwise, return default object and src merge + return merge({}, objValue, srcValue); } - // Otherwise, return default object and src merge - return merge({}, obj, src); - }); + ); return ( <> r.data.items }); @@ -21,18 +24,22 @@ export default function RoleEditor({ roles, setRoles }) { return null; } + function setRoleItems(items) { + setRoles({ ...roles, items }); + } + function deleteRole(id) { - const newRoles = roles.filter((c) => c.id !== id); - setRoles(newRoles); + const newRoles = roles.items.filter((c) => c.id !== id); + setRoleItems(newRoles); } function handleAdd(newRole) { - const newRoles = [...roles, newRole]; - setRoles(newRoles); + const newRoles = [...roles.items, newRole]; + setRoleItems(newRoles); } const hasRoleIds = new Set(); - roles.forEach((r) => hasRoleIds.add(r.id)); + roles.items.forEach((r) => hasRoleIds.add(r.id)); return ( diff --git a/adminapp/src/components/VendorServiceCategoryMultiSelect.jsx b/adminapp/src/components/VendorServiceCategoryMultiSelect.jsx index b49f40460..c70117f80 100644 --- a/adminapp/src/components/VendorServiceCategoryMultiSelect.jsx +++ b/adminapp/src/components/VendorServiceCategoryMultiSelect.jsx @@ -1,5 +1,6 @@ import api from "../api"; import { useGlobalApiState } from "../hooks/globalApiState"; +import { assertFullCollection, itemsToCollection } from "../modules/apicollection"; import { Box, Chip, @@ -20,9 +21,10 @@ import React from "react"; */ const VendorServiceCategoryMultiSelect = React.forwardRef( function VendorServiceCategoryMultiSelect( - { value, helperText, label, className, style, onChange, ...rest }, + { collection, helperText, label, className, style, onChange, ...rest }, ref ) { + assertFullCollection(collection); const theme = useTheme(); const categories = useGlobalApiState( api.getVendorServiceCategoriesMeta, @@ -43,7 +45,7 @@ const VendorServiceCategoryMultiSelect = React.forwardRef( idCounts[c.id] = (idCounts[c.id] || 0) + 1; }); const nonDupeValues = values.filter((c) => idCounts[c.id] === 1); - onChange(e, nonDupeValues); + onChange(e, itemsToCollection(nonDupeValues)); }, [onChange] ); @@ -55,7 +57,7 @@ const VendorServiceCategoryMultiSelect = React.forwardRef( id="vscategory-select" multiple ref={ref} - value={value} + value={collection.items} label={label} input={} renderValue={(selected) => ( @@ -70,7 +72,11 @@ const VendorServiceCategoryMultiSelect = React.forwardRef( {...rest} > {categories.map((c) => ( - + {c.label} ))} diff --git a/adminapp/src/modules/apicollection.js b/adminapp/src/modules/apicollection.js new file mode 100644 index 000000000..7f88bb240 --- /dev/null +++ b/adminapp/src/modules/apicollection.js @@ -0,0 +1,53 @@ +import isNil from "lodash/isNil"; + +/** + * Raise unless the collection has items, + * and all have been returned from the API (hasMore). + * Check this before collection editing. + * @param collection + */ +export function assertFullCollection(collection) { + if (!collection.items) { + throw new Error("need to pass an API collection"); + } + if (collection.currentPage !== 1) { + throw new Error("collection must be loaded from page 1"); + } + if (collection.hasMore) { + throw new Error("collection must be loaded with all:true in the entity"); + } +} + +export function isCollection(v) { + return typeof v === "object" && !isNil(v) && Array.isArray(v.items); +} +/** + * Call set with a collection object with the given items. + * currentPage and hasMore are set so assertFullCollection passes. + * @param {function} set + * @param {Array} items + */ +export function setCollectionItems(set, items) { + set(itemsToCollection(items)); +} + +export function itemsToCollection(items) { + return { items, currentPage: 1, hasMore: false }; +} + +/** + * Given a POST body, convert any collection hashes into just + * an array of their items. + * @param {object} h + */ +export function simplifyCollections(h) { + const r = {}; + Object.entries(h).forEach(([k, v]) => { + if (isCollection(v)) { + r[k] = v.items; + } else { + r[k] = v; + } + }); + return r; +} diff --git a/adminapp/src/modules/formHelpers.js b/adminapp/src/modules/formHelpers.js index 483c8fb6a..91109762e 100644 --- a/adminapp/src/modules/formHelpers.js +++ b/adminapp/src/modules/formHelpers.js @@ -1,8 +1,6 @@ -const initialTranslation = { en: "", es: "" }; +const translation = { en: "", es: "" }; -const initialFulfillmentOption = { type: "pickup", description: initialTranslation }; - -const initialAddress = { +const address = { address1: "", address2: "", city: "", @@ -10,10 +8,6 @@ const initialAddress = { postalCode: "", }; -const formHelpers = { - initialTranslation, - initialFulfillmentOption, - initialAddress, -}; +const collection = { items: [], hasMore: false, currentPage: 1 }; -export default formHelpers; +export const stub = { translation, address, collection }; diff --git a/adminapp/src/pages/AnonMemberContactDetailPage.jsx b/adminapp/src/pages/AnonMemberContactDetailPage.jsx index 13a55d3d6..df2ee7087 100644 --- a/adminapp/src/pages/AnonMemberContactDetailPage.jsx +++ b/adminapp/src/pages/AnonMemberContactDetailPage.jsx @@ -2,7 +2,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import Copyable from "../components/Copyable"; import ExternalLinks from "../components/ExternalLinks"; -import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import React from "react"; @@ -30,9 +30,9 @@ export default function AnonMemberContactDetailPage() { > {(model) => [ , - [ diff --git a/adminapp/src/pages/BookTransactionCreatePage.jsx b/adminapp/src/pages/BookTransactionCreatePage.jsx index 01673d9bf..fcb74b010 100644 --- a/adminapp/src/pages/BookTransactionCreatePage.jsx +++ b/adminapp/src/pages/BookTransactionCreatePage.jsx @@ -9,7 +9,7 @@ import VendorServiceCategorySelect from "../components/VendorServiceCategorySele import config from "../config"; import useBusy from "../hooks/useBusy"; import useErrorSnackbar from "../hooks/useErrorSnackbar"; -import formHelpers from "../modules/formHelpers"; +import { stub } from "../modules/formHelpers"; import useMountEffect from "../shared/react/useMountEffect"; import SwapHorizontalIcon from "@mui/icons-material/SwapHoriz"; import { FormLabel, Stack } from "@mui/material"; @@ -26,7 +26,7 @@ export default function BookTransactionCreatePage() { const [originatingLedger, setOriginatingLedger] = React.useState(null); const [receivingLedger, setReceivingLedger] = React.useState(null); const [amount, setAmount] = React.useState(config.defaultZeroMoney); - const [memo, setMemo] = React.useState(formHelpers.initialTranslation); + const [memo, setMemo] = React.useState(stub.translation); const [category, setCategory] = React.useState(null); const { isBusy, busy, notBusy } = useBusy(); const { register, handleSubmit } = useForm(); diff --git a/adminapp/src/pages/BookTransactionDetailPage.jsx b/adminapp/src/pages/BookTransactionDetailPage.jsx index bc1bb87aa..3f7a974ec 100644 --- a/adminapp/src/pages/BookTransactionDetailPage.jsx +++ b/adminapp/src/pages/BookTransactionDetailPage.jsx @@ -1,7 +1,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; -import RelatedList from "../components/RelatedList"; -import ResourceDetail from "../components/ResourceDetail"; +import DetailGrid from "../components/DetailGrid"; +import ResourceDetail, { ResourceSummary } from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import { dayjs } from "../modules/dayConfig"; import formatDate from "../modules/formatDate"; @@ -53,30 +53,64 @@ export default function BookTransactionDetailPage() { ]} > {(model) => [ - [ - , - formatDate(row.createdAt), - row.status, - {row.amount}, - ]} - />, - [ - , - formatDate(row.createdAt), - {row.undiscountedSubtotal}, - row.opaqueId, - ]} - />, + relatedExternalTransaction( + "Originating Funding Transaction", + model.originatingFundingTransaction + ), + relatedExternalTransaction( + "Originating Payout Transaction", + model.originatingPayoutTransaction + ), + relatedExternalTransaction( + "Credited Payout Transaction", + model.creditedPayoutTransaction + ), + model.chargeContributedTo && ( + + }, + { label: "At", value: formatDate(model.chargeContributedTo.createdAt) }, + { + label: "Undiscounted Total", + value: {model.chargeContributedTo.undiscountedSubtotal}, + }, + { label: "Opaque ID", value: model.chargeContributedTo.opaqueId }, + ]} + /> + + ), ]} ); } + +function relatedExternalTransaction(title, model) { + if (!model) { + return null; + } + // Must return ResourceSummary unwrapped so child detection for layout works. + return ( + + , + }, + { + label: "Created", + value: formatDate(model.createdAt), + }, + { label: "Status", value: model.status }, + { + label: "Amount", + value: {model.amount}, + }, + ]} + /> + + ); +} diff --git a/adminapp/src/pages/CardDetailPage.jsx b/adminapp/src/pages/CardDetailPage.jsx index 16b6a9825..04155e68f 100644 --- a/adminapp/src/pages/CardDetailPage.jsx +++ b/adminapp/src/pages/CardDetailPage.jsx @@ -2,7 +2,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import ExternalLinks from "../components/ExternalLinks"; import LegalEntity from "../components/LegalEntity"; -import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail, { ResourceSummary } from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import formatDate from "../modules/formatDate"; @@ -33,9 +33,9 @@ export default function CardDetailPage() { , , - [ diff --git a/adminapp/src/pages/ChargeDetailPage.jsx b/adminapp/src/pages/ChargeDetailPage.jsx index ddf1b13a2..cd867f635 100644 --- a/adminapp/src/pages/ChargeDetailPage.jsx +++ b/adminapp/src/pages/ChargeDetailPage.jsx @@ -2,7 +2,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import CommerceOrderDetailGrid from "../components/CommerceOrderDetailGrid"; import MobilityTripDetailGrid from "../components/MobilityTripDetailGrid"; -import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import formatDate from "../modules/formatDate"; @@ -51,10 +51,10 @@ export default function ChargeDetailPage() { {(model) => [ , , - [ row.id, @@ -64,9 +64,9 @@ export default function ChargeDetailPage() { row.memo.en, ]} />, - [ @@ -79,9 +79,9 @@ export default function ChargeDetailPage() { , ]} />, - [ diff --git a/adminapp/src/pages/EligibilityAttributeDetailPage.jsx b/adminapp/src/pages/EligibilityAttributeDetailPage.jsx index c665e3f01..92b9e8ea2 100644 --- a/adminapp/src/pages/EligibilityAttributeDetailPage.jsx +++ b/adminapp/src/pages/EligibilityAttributeDetailPage.jsx @@ -1,6 +1,6 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; -import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import createRelativeUrl from "../shared/createRelativeUrl"; @@ -23,9 +23,9 @@ export default function EligibilityAttributeDetailPage() { ]} > {(model) => [ - [ @@ -33,9 +33,9 @@ export default function EligibilityAttributeDetailPage() { {row.name}, ]} />, - , - [ diff --git a/adminapp/src/pages/EligibilityRequirementCreatePage.jsx b/adminapp/src/pages/EligibilityRequirementCreatePage.jsx index 08b090a0c..259cc5d94 100644 --- a/adminapp/src/pages/EligibilityRequirementCreatePage.jsx +++ b/adminapp/src/pages/EligibilityRequirementCreatePage.jsx @@ -1,10 +1,14 @@ import api from "../api"; import ResourceCreate from "../components/ResourceCreate"; +import { stub } from "../modules/formHelpers"; import EligibilityRequirementForm from "./EligibilityRequirementForm"; import React from "react"; export default function EligibilityRequirementCreatePage() { - const empty = { programs: [], paymentTriggers: [] }; + const empty = { + programs: stub.collection, + paymentTriggers: stub.collection, + }; return ( {(model) => [ - {row.label}, ]} />, - ); diff --git a/adminapp/src/pages/EligibilityRequirementExpressionEditor.jsx b/adminapp/src/pages/EligibilityRequirementExpressionEditor.jsx index 5ed28cfc0..4169ce610 100644 --- a/adminapp/src/pages/EligibilityRequirementExpressionEditor.jsx +++ b/adminapp/src/pages/EligibilityRequirementExpressionEditor.jsx @@ -4,21 +4,21 @@ import useDebounced from "../hooks/useDebounced"; import useErrorSnackbar from "../hooks/useErrorSnackbar"; import useAsyncFetch from "../shared/react/useAsyncFetch"; import { + Alert, Box, - Chip, Button, ButtonGroup, - Typography, - Paper, + Chip, + CircularProgress, Divider, - Alert, + Paper, Stack, - CircularProgress, Table, + TableBody, TableContainer, TableHead, TableRow, - TableBody, + Typography, } from "@mui/material"; import TableCell from "@mui/material/TableCell"; import { styled } from "@mui/material/styles"; diff --git a/adminapp/src/pages/EligibilityRequirementForm.jsx b/adminapp/src/pages/EligibilityRequirementForm.jsx index 8a44f172c..97facc5f9 100644 --- a/adminapp/src/pages/EligibilityRequirementForm.jsx +++ b/adminapp/src/pages/EligibilityRequirementForm.jsx @@ -26,14 +26,14 @@ export default function EligibilityRequirementForm({ setField("programs", o)} + collection={resource.programs} + setCollection={(o) => setField("programs", o)} apiItemSearch={api.searchPrograms} /> setField("paymentTriggers", o)} + collection={resource.paymentTriggers} + setCollection={(o) => setField("paymentTriggers", o)} apiItemSearch={api.searchPaymentTriggers} /> diff --git a/adminapp/src/pages/FinancialsPage.jsx b/adminapp/src/pages/FinancialsPage.jsx index 6fc664c78..75d4e76b5 100644 --- a/adminapp/src/pages/FinancialsPage.jsx +++ b/adminapp/src/pages/FinancialsPage.jsx @@ -3,7 +3,7 @@ import AdminLink from "../components/AdminLink"; import DetailGrid from "../components/DetailGrid"; import HelmetTitle from "../components/HelmetTitle"; import Link from "../components/Link"; -import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import formatDate from "../modules/formatDate"; import Money from "../shared/react/Money"; import useAsyncFetch from "../shared/react/useAsyncFetch"; @@ -69,9 +69,9 @@ export default function FinancialsPage() { - [ @@ -83,9 +83,9 @@ export default function FinancialsPage() { ...ledgerMonies(row), ]} /> - [ @@ -94,9 +94,9 @@ export default function FinancialsPage() { ...ledgerMonies(row), ]} /> - [ @@ -106,9 +106,9 @@ export default function FinancialsPage() { row.note, ]} /> - [ diff --git a/adminapp/src/pages/FundingTransactionDetailPage.jsx b/adminapp/src/pages/FundingTransactionDetailPage.jsx index 61b2c5014..ca6a6673a 100644 --- a/adminapp/src/pages/FundingTransactionDetailPage.jsx +++ b/adminapp/src/pages/FundingTransactionDetailPage.jsx @@ -5,7 +5,7 @@ import AuditLogs from "../components/AuditLogs"; import BookTransactionDetail from "../components/BookTransactionDetail"; import ExternalLinks from "../components/ExternalLinks"; import PaymentStrategyDetailGrid from "../components/PaymentStrategyDetailGrid"; -import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import formatDate from "../modules/formatDate"; @@ -50,9 +50,9 @@ export default function FundingTransactionDetailPage() { title="Reversal Book Transaction" transaction={model.reversaldBookTransaction} />, - ), - [ @@ -73,9 +73,9 @@ export default function MarketingListDetailPage() { row.formattedPhone, ]} />, - [ diff --git a/adminapp/src/pages/MarketingListEditPage.jsx b/adminapp/src/pages/MarketingListEditPage.jsx index 996cb0506..a14593c75 100644 --- a/adminapp/src/pages/MarketingListEditPage.jsx +++ b/adminapp/src/pages/MarketingListEditPage.jsx @@ -3,6 +3,7 @@ import FormLayout from "../components/FormLayout"; import ResourceEdit from "../components/ResourceEdit"; import useBusy from "../hooks/useBusy"; import useErrorSnackbar from "../hooks/useErrorSnackbar"; +import { assertFullCollection, setCollectionItems } from "../modules/apicollection"; import useMountEffect from "../shared/react/useMountEffect"; import AddIcon from "@mui/icons-material/Add"; import AddCircleOutlineIcon from "@mui/icons-material/AddCircleOutline"; @@ -35,6 +36,7 @@ export default function MarketingListEditPage() { return ( @@ -71,6 +73,7 @@ function EditForm({ resource, setField, setFieldFromInput, register, isBusy, onS } function Members({ members, setMembers }) { + assertFullCollection(members); const { enqueueErrorSnackbar } = useErrorSnackbar(); const [allMembers, setAllMembers] = React.useState([]); const [search, setSearchInner] = React.useState(""); @@ -101,24 +104,24 @@ function Members({ members, setMembers }) { useMountEffect(() => getMembersDebounced()); - const currentMemberIds = members.map(({ id }) => id); + const currentMemberIds = members.items.map(({ id }) => id); const eligibleMembers = allMembers.filter((m) => !currentMemberIds.includes(m.id)); function handleAdd(m) { - setMembers([...members, m]); + setCollectionItems(setMembers, [...members.items, m]); } function handleAddAll() { - setMembers([...members, ...eligibleMembers]); + setCollectionItems(setMembers, [...members.items, ...eligibleMembers]); } function handleRemoveAll() { - setMembers([]); + setCollectionItems(setMembers, []); } function handleRemove(m) { - const without = members.filter((m2) => m2.id !== m.id); - setMembers(without); + const without = members.items.filter((m2) => m2.id !== m.id); + setCollectionItems(setMembers, without); } return ( @@ -127,17 +130,17 @@ function Members({ members, setMembers }) { - List Members ({members.length}) + List Members ({members.items.length}) } onClick={handleRemoveAll} > Remove all members - {members.map((m) => ( + {members.items.map((m) => ( handleRemove(m)} dense> diff --git a/adminapp/src/pages/MarketingSmsBroadcastDetailPage.jsx b/adminapp/src/pages/MarketingSmsBroadcastDetailPage.jsx index e2d515e0f..c7184855a 100644 --- a/adminapp/src/pages/MarketingSmsBroadcastDetailPage.jsx +++ b/adminapp/src/pages/MarketingSmsBroadcastDetailPage.jsx @@ -1,7 +1,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import Link from "../components/Link"; -import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import formatDate from "../modules/formatDate"; @@ -46,9 +46,9 @@ export default function MarketingSmsBroadcastDetailPage() { > Review and {model.sentAt ? "Re-Send" : "Send"} , - [ @@ -58,9 +58,9 @@ export default function MarketingSmsBroadcastDetailPage() { , ]} />, - [ diff --git a/adminapp/src/pages/MarketingSmsBroadcastEditPage.jsx b/adminapp/src/pages/MarketingSmsBroadcastEditPage.jsx index fa4bd340f..7faab76c6 100644 --- a/adminapp/src/pages/MarketingSmsBroadcastEditPage.jsx +++ b/adminapp/src/pages/MarketingSmsBroadcastEditPage.jsx @@ -4,6 +4,7 @@ import ResourceEdit from "../components/ResourceEdit"; import ResponsiveStack from "../components/ResponsiveStack"; import { useGlobalApiState } from "../hooks/globalApiState"; import useErrorSnackbar from "../hooks/useErrorSnackbar"; +import { setCollectionItems } from "../modules/apicollection"; import withoutAt from "../shared/withoutAt"; import { Card, @@ -33,6 +34,7 @@ export default function MarketingSmsBroadcastEditPage() { ); @@ -186,17 +188,17 @@ function BodyPreview({ register, resource, onBodyChange, language, preview }) { } function MarketingLists({ allLists, lists, setLists }) { - const checkedListIds = lists.map((l) => l.id); + const checkedListIds = lists.items.map((l) => l.id); const handleToggle = (value) => { const existingCheckedIdx = checkedListIds.indexOf(value); let newlyCheckedLists; if (existingCheckedIdx > -1) { - newlyCheckedLists = withoutAt(lists, existingCheckedIdx); + newlyCheckedLists = withoutAt(lists.items, existingCheckedIdx); } else { - newlyCheckedLists = [...lists, allLists.find((l) => l.id === value)]; + newlyCheckedLists = [...lists.items, allLists.find((l) => l.id === value)]; } - setLists(newlyCheckedLists); + setCollectionItems(setLists, newlyCheckedLists); }; return ( diff --git a/adminapp/src/pages/MemberDetailPage.jsx b/adminapp/src/pages/MemberDetailPage.jsx index c9d0bce03..fb1f5e12d 100644 --- a/adminapp/src/pages/MemberDetailPage.jsx +++ b/adminapp/src/pages/MemberDetailPage.jsx @@ -10,6 +10,7 @@ import InlineEditField from "../components/InlineEditField"; import OrganizationMembership from "../components/OrganizationMembership"; import PaymentAccountRelatedLists from "../components/PaymentAccountRelatedLists"; import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail, { ResourceSummary } from "../components/ResourceDetail"; import ResponsiveStack from "../components/ResponsiveStack"; import SupportNoteModal from "../components/SupportNoteModal"; @@ -25,16 +26,16 @@ import useToggle from "../shared/react/useToggle"; import DeleteIcon from "@mui/icons-material/Delete"; import EditIcon from "@mui/icons-material/Edit"; import { - Typography, - Switch, Button, Chip, - DialogTitle, Dialog, - DialogContent, DialogActions, - Stack, + DialogContent, DialogContentText, + DialogTitle, + Stack, + Switch, + Typography, } from "@mui/material"; import IconButton from "@mui/material/IconButton"; import TableCell from "@mui/material/TableCell"; @@ -99,7 +100,7 @@ export default function MemberDetailPage() { }, { label: "Roles", - children: model.roles.map((role) => ( + children: model.roles.items.map((role) => ( )), hideEmpty: true, @@ -182,20 +183,20 @@ export default function MemberDetailPage() { , , , - [ , {row.label}, ]} />, - [ , @@ -240,10 +241,10 @@ function LegalEntity({ address }) { function OrganizationMemberships({ memberships, model }) { return ( - - [ @@ -348,10 +349,10 @@ function ExpandedEligibilityAssignments({ assignments }) { function Activities({ activities }) { return ( - [ formatDate(row.createdAt), @@ -366,10 +367,10 @@ function Activities({ activities }) { function ResetCodes({ resetCodes }) { return ( - [ formatDate(row.createdAt), @@ -413,14 +414,14 @@ function Sessions({ member, setMember, sessions }) { - [ formatDate(row.createdAt), @@ -436,9 +437,9 @@ function Sessions({ member, setMember, sessions }) { function Orders({ orders }) { return ( - [ @@ -456,9 +457,9 @@ function Orders({ orders }) { function MobilityTrips({ mobilityTrips }) { return ( - [ @@ -478,11 +479,11 @@ function MobilityTrips({ mobilityTrips }) { function Charges({ charges }) { return ( - [ , formatDate(row.createdAt), @@ -496,10 +497,10 @@ function Charges({ charges }) { function PaymentInstruments({ instruments }) { return ( - `${r.id}-${r.paymentMethodType}`} toCells={(row) => [ @@ -514,9 +515,6 @@ function PaymentInstruments({ instruments }) { } function MessagePreferences({ preferences }) { - if (!preferences) { - return null; - } const { subscriptions, publicUrl } = preferences; return ( @@ -559,10 +557,10 @@ function MessagePreferences({ preferences }) { function MessageDeliveries({ messageDeliveries }) { return ( - [ , @@ -577,7 +575,7 @@ function MessageDeliveries({ messageDeliveries }) { function VendorAccounts({ vendorAccounts }) { return ( - [ , @@ -611,10 +609,10 @@ function VendorAccounts({ vendorAccounts }) { function MemberContacts({ memberContacts }) { return ( - [, row.formattedAddress]} /> diff --git a/adminapp/src/pages/OfferingCreatePage.jsx b/adminapp/src/pages/OfferingCreatePage.jsx index edd60e738..589ab200a 100644 --- a/adminapp/src/pages/OfferingCreatePage.jsx +++ b/adminapp/src/pages/OfferingCreatePage.jsx @@ -1,19 +1,19 @@ import api from "../api"; import ResourceCreate from "../components/ResourceCreate"; import { dayjs } from "../modules/dayConfig"; -import formHelpers from "../modules/formHelpers"; +import { stub } from "../modules/formHelpers"; import OfferingForm from "./OfferingForm"; import React from "react"; export default function OfferingCreatePage() { const empty = { image: null, - imageCaption: formHelpers.initialTranslation, - description: formHelpers.initialTranslation, - fulfillmentPrompt: formHelpers.initialTranslation, - fulfillmentConfirmation: formHelpers.initialTranslation, - fulfillmentInstructions: formHelpers.initialTranslation, - fulfillmentOptions: [], + imageCaption: stub.translation, + description: stub.translation, + fulfillmentPrompt: stub.translation, + fulfillmentConfirmation: stub.translation, + fulfillmentInstructions: stub.translation, + fulfillmentOptions: stub.collection, periodBegin: dayjs().format(), periodEnd: dayjs().add(1, "day").format(), beginFulfillmentAt: null, diff --git a/adminapp/src/pages/OfferingDetailPage.jsx b/adminapp/src/pages/OfferingDetailPage.jsx index adc01b3af..1fb6cebca 100644 --- a/adminapp/src/pages/OfferingDetailPage.jsx +++ b/adminapp/src/pages/OfferingDetailPage.jsx @@ -2,8 +2,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import AuditActivityList from "../components/AuditActivityList"; import Link from "../components/Link"; -import Programs from "../components/Programs"; -import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import detailPageImageProperties from "../components/detailPageImageProperties"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; @@ -76,9 +75,9 @@ export default function OfferingDetailPage() { ]} > {(model, setModel) => [ - [ @@ -88,22 +87,22 @@ export default function OfferingDetailPage() { row.address && oneLineAddress(row.address, false), ]} />, - , - , + (row.closedAt ? classes.closed : "")} @@ -117,9 +116,9 @@ export default function OfferingDetailPage() { {row.undiscountedPrice}, ]} />, - [ diff --git a/adminapp/src/pages/OfferingEditPage.jsx b/adminapp/src/pages/OfferingEditPage.jsx index 2b5d4c875..91fd3ef87 100644 --- a/adminapp/src/pages/OfferingEditPage.jsx +++ b/adminapp/src/pages/OfferingEditPage.jsx @@ -8,6 +8,7 @@ export default function OfferingEditPage() { ); diff --git a/adminapp/src/pages/OfferingForm.jsx b/adminapp/src/pages/OfferingForm.jsx index 67583b2a4..42a162c05 100644 --- a/adminapp/src/pages/OfferingForm.jsx +++ b/adminapp/src/pages/OfferingForm.jsx @@ -5,7 +5,7 @@ import MultiLingualText from "../components/MultiLingualText"; import ResponsiveStack from "../components/ResponsiveStack"; import SafeDateTimePicker from "../components/SafeDateTimePicker"; import { formatOrNull } from "../modules/dayConfig"; -import formHelpers from "../modules/formHelpers"; +import { stub } from "../modules/formHelpers"; import mergeAt from "../shared/mergeAt"; import withoutAt from "../shared/withoutAt"; import AddIcon from "@mui/icons-material/Add"; @@ -15,10 +15,10 @@ import { Button, Divider, FormControl, + FormHelperText, FormLabel, Icon, InputLabel, - FormHelperText, MenuItem, Select, Stack, @@ -160,19 +160,24 @@ export default function OfferingForm({ ); } +const stubFulfillmentOption = { type: "pickup", description: stub.translation }; + function FulfillmentOptions({ options, setOptions }) { + function setOptionItems(items) { + setOptions({ ...options, items }); + } const handleAdd = () => { - setOptions([...options, formHelpers.initialFulfillmentOption]); + setOptionItems([...options.items, stubFulfillmentOption]); }; const handleRemove = (index) => { - setOptions(withoutAt(options, index)); + setOptionItems(withoutAt(options.items, index)); }; function handleChange(index, fields) { - setOptions(mergeAt(options, index, fields)); + setOptionItems(mergeAt(options.items, index, fields)); } return ( <> - {options.map((o, i) => ( + {options.items.map((o, i) => ( {(model) => [ - [ diff --git a/adminapp/src/pages/OrderDetailPage.jsx b/adminapp/src/pages/OrderDetailPage.jsx index 934b71263..fd25631d2 100644 --- a/adminapp/src/pages/OrderDetailPage.jsx +++ b/adminapp/src/pages/OrderDetailPage.jsx @@ -3,7 +3,7 @@ import AdminLink from "../components/AdminLink"; import AuditLogs from "../components/AuditLogs"; import ChargeDetailGrid from "../components/ChargeDetailGrid"; import DetailGrid from "../components/DetailGrid"; -import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import Money from "../shared/react/Money"; @@ -62,9 +62,9 @@ export default function OrderDetailPage() { ]} />, , - ( + children: model.roles.items.map((role) => ( )), hideEmpty: true, @@ -47,14 +47,14 @@ export default function OrganizationDetailPage() { ]} > {(model) => [ - -  Memberships ({model.memberships.length}){" "} + Memberships } - rows={model.memberships} + collection={model.memberships} addNewLabel="Create another membership" addNewLink={createRelativeUrl(`/membership/new`, { organizationId: model.id, @@ -71,14 +71,14 @@ export default function OrganizationDetailPage() { formatDate(row.createdAt), ]} />, - -  Former Memberships ({model.formerMemberships.length}) + Former Memberships } - rows={model.formerMemberships} + collection={model.formerMemberships} headers={["Id", "Member", "Added At", "Removed At"]} keyRowAttr="id" toCells={(row) => [ diff --git a/adminapp/src/pages/OrganizationEditPage.jsx b/adminapp/src/pages/OrganizationEditPage.jsx index 4b3ca0167..f19faf168 100644 --- a/adminapp/src/pages/OrganizationEditPage.jsx +++ b/adminapp/src/pages/OrganizationEditPage.jsx @@ -8,6 +8,7 @@ export default function OrganizationEditPage() { ); diff --git a/adminapp/src/pages/OrganizationMembershipVerificationDetailPage.jsx b/adminapp/src/pages/OrganizationMembershipVerificationDetailPage.jsx index d48300e87..34a899575 100644 --- a/adminapp/src/pages/OrganizationMembershipVerificationDetailPage.jsx +++ b/adminapp/src/pages/OrganizationMembershipVerificationDetailPage.jsx @@ -4,6 +4,7 @@ import AuditLogs from "../components/AuditLogs"; import InlineEditField from "../components/InlineEditField"; import OrganizationMembership from "../components/OrganizationMembership"; import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import useErrorSnackbar from "../hooks/useErrorSnackbar"; @@ -127,17 +128,17 @@ export default function OrganizationMembershipVerificationDetailPage() { ]} > {(model, setModel) => [ - [ row.id,
, formatDate(row.createdAt), - - {row.creator.name} + + {row.author?.name} , ]} />, diff --git a/adminapp/src/pages/OrganizationMembershipVerificationListPage.jsx b/adminapp/src/pages/OrganizationMembershipVerificationListPage.jsx index 123b3282f..43f53627b 100644 --- a/adminapp/src/pages/OrganizationMembershipVerificationListPage.jsx +++ b/adminapp/src/pages/OrganizationMembershipVerificationListPage.jsx @@ -519,17 +519,17 @@ function NotesViewer({ verification, makeApiCall }) { )} - {isEmpty(verification?.notes) && ( + {isEmpty(verification?.notes?.items) && ( Add a note using the note editor. )} - {verification?.notes.map((note) => ( + {verification?.notes.items.map((note) => (
- {note.creator?.name} at {formatDate(note.createdAt)} + {note.author?.name} at {formatDate(note.createdAt)} {note.editor && ( diff --git a/adminapp/src/pages/PaymentLedgerDetailPage.jsx b/adminapp/src/pages/PaymentLedgerDetailPage.jsx index fa4da477a..fd6f730a5 100644 --- a/adminapp/src/pages/PaymentLedgerDetailPage.jsx +++ b/adminapp/src/pages/PaymentLedgerDetailPage.jsx @@ -2,6 +2,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import LedgerBookTransactionsRelatedList from "../components/LedgerBookTransactionRelatedList"; import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import Money from "../shared/react/Money"; @@ -28,17 +29,17 @@ export default function PaymentLedgerDetailPage() { ]} > {(model) => [ - [row.id, row.name, row.slug]} />, , {(model) => [ , - [ @@ -89,33 +88,6 @@ export default function PaymentTriggerDetailPage() {
, ]} />, - [ - row.id, - formatDate(row.createdAt), - - {row.vendor.name} - , - - {row.appInstallLink} - , - row.usesEmail ? "Yes" : "No", - row.usesSms ? "Yes" : "No", - row.enabled ? "Yes" : "No", - ]} - />, , ]} diff --git a/adminapp/src/pages/PaymentTriggerForm.jsx b/adminapp/src/pages/PaymentTriggerForm.jsx index 14cef3f39..16cd3b69c 100644 --- a/adminapp/src/pages/PaymentTriggerForm.jsx +++ b/adminapp/src/pages/PaymentTriggerForm.jsx @@ -9,12 +9,12 @@ import config from "../config"; import { formatOrNull } from "../modules/dayConfig"; import { intToMoney } from "../shared/money"; import { - TextField, - Stack, + FormControl, + FormControlLabel, FormHelperText, + Stack, Switch, - FormControlLabel, - FormControl, + TextField, } from "@mui/material"; import React from "react"; diff --git a/adminapp/src/pages/ProductCreatePage.jsx b/adminapp/src/pages/ProductCreatePage.jsx index 4d6740f76..d47276e9e 100644 --- a/adminapp/src/pages/ProductCreatePage.jsx +++ b/adminapp/src/pages/ProductCreatePage.jsx @@ -1,18 +1,18 @@ import api from "../api"; import ResourceCreate from "../components/ResourceCreate"; -import formHelpers from "../modules/formHelpers"; +import { stub } from "../modules/formHelpers"; import ProductForm from "./ProductForm"; import React from "react"; export default function ProductCreatePage() { const product = { image: null, - imageCaption: formHelpers.initialTranslation, - description: formHelpers.initialTranslation, - name: formHelpers.initialTranslation, + imageCaption: stub.translation, + description: stub.translation, + name: stub.translation, vendor: null, ordinal: 0, - vendorServiceCategories: [], + vendorServiceCategories: stub.collection, inventory: { maxQuantityPerMemberPerOrder: null, limitedQuantity: false, diff --git a/adminapp/src/pages/ProductDetailPage.jsx b/adminapp/src/pages/ProductDetailPage.jsx index 5261e403d..8bfbe52ee 100644 --- a/adminapp/src/pages/ProductDetailPage.jsx +++ b/adminapp/src/pages/ProductDetailPage.jsx @@ -1,7 +1,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import CategoriesRelatedList from "../components/CategoriesRelatedList"; -import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import detailPageImageProperties from "../components/detailPageImageProperties"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; @@ -43,15 +43,15 @@ export default function ProductDetailPage() { > {(model) => [ , - [ @@ -64,9 +64,9 @@ export default function ProductDetailPage() { row.isClosed ? dayjs(row.closedAt).format("lll") : "", ]} />, - [ diff --git a/adminapp/src/pages/ProductEditPage.jsx b/adminapp/src/pages/ProductEditPage.jsx index 865af9aad..f4a82f891 100644 --- a/adminapp/src/pages/ProductEditPage.jsx +++ b/adminapp/src/pages/ProductEditPage.jsx @@ -8,6 +8,7 @@ export default function ProductEditPage() { ); diff --git a/adminapp/src/pages/ProductForm.jsx b/adminapp/src/pages/ProductForm.jsx index 4f82d32c1..2b1826076 100644 --- a/adminapp/src/pages/ProductForm.jsx +++ b/adminapp/src/pages/ProductForm.jsx @@ -99,7 +99,7 @@ export default function ProductForm({ {...register("category")} label="Category" helperText="What ledger funds can be used to purchase this product?" - value={resource.vendorServiceCategories} + collection={resource.vendorServiceCategories} style={{ flex: 1 }} onChange={(_, c) => setField("vendorServiceCategories", c)} /> diff --git a/adminapp/src/pages/ProgramCreatePage.jsx b/adminapp/src/pages/ProgramCreatePage.jsx index 91d30803f..77d5613ae 100644 --- a/adminapp/src/pages/ProgramCreatePage.jsx +++ b/adminapp/src/pages/ProgramCreatePage.jsx @@ -1,22 +1,21 @@ import api from "../api"; import ResourceCreate from "../components/ResourceCreate"; import { dayjs } from "../modules/dayConfig"; -import formHelpers from "../modules/formHelpers"; +import { stub } from "../modules/formHelpers"; import ProgramForm from "./ProgramForm"; import React from "react"; export default function ProgramCreatePage() { const empty = { image: null, - imageCaption: formHelpers.initialTranslation, - name: formHelpers.initialTranslation, - description: formHelpers.initialTranslation, + imageCaption: stub.translation, + name: stub.translation, + description: stub.translation, appLink: "", - appLinkText: formHelpers.initialTranslation, + appLinkText: stub.translation, periodBegin: dayjs().format(), periodEnd: dayjs().add(1, "day").format(), - vendorServices: [], - commerceOfferings: [], + commerceOfferings: stub.collection, ordinal: 0, }; return ( diff --git a/adminapp/src/pages/ProgramDetailPage.jsx b/adminapp/src/pages/ProgramDetailPage.jsx index f6e75219e..6fdda5171 100644 --- a/adminapp/src/pages/ProgramDetailPage.jsx +++ b/adminapp/src/pages/ProgramDetailPage.jsx @@ -2,7 +2,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import AuditActivityList from "../components/AuditActivityList"; import EligibilityRequirementsRelatedList from "../components/EligibilityRequirementsRelatedList"; -import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import detailPageImageProperties from "../components/detailPageImageProperties"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; @@ -35,9 +35,9 @@ export default function ProgramDetailPage() { ]} > {(model) => [ - [ @@ -47,7 +47,7 @@ export default function ProgramDetailPage() { formatDate(row.periodEnd), ]} />, - [ @@ -68,9 +68,9 @@ export default function ProgramDetailPage() { , ]} />, - ); diff --git a/adminapp/src/pages/ProgramForm.jsx b/adminapp/src/pages/ProgramForm.jsx index f41b25aae..f64b431ef 100644 --- a/adminapp/src/pages/ProgramForm.jsx +++ b/adminapp/src/pages/ProgramForm.jsx @@ -117,8 +117,8 @@ export default function ProgramForm({ /> setField("commerceOfferings", o)} + collection={resource.commerceOfferings} + setCollection={(o) => setField("commerceOfferings", o)} apiItemSearch={api.searchCommerceOffering} /> diff --git a/adminapp/src/pages/RegistrationLinkCreatePage.jsx b/adminapp/src/pages/RegistrationLinkCreatePage.jsx index fc8da2af1..23aac3ec3 100644 --- a/adminapp/src/pages/RegistrationLinkCreatePage.jsx +++ b/adminapp/src/pages/RegistrationLinkCreatePage.jsx @@ -1,12 +1,12 @@ import api from "../api"; import ResourceCreate from "../components/ResourceCreate"; -import formHelpers from "../modules/formHelpers"; +import { stub } from "../modules/formHelpers"; import RegistrationLinkForm from "./RegistrationLinkForm"; import React from "react"; export default function RegistrationLinkCreatePage() { const empty = { - intro: formHelpers.initialTranslation, + intro: stub.translation, icalDtstart: null, icalDtend: null, icalRrule: "", diff --git a/adminapp/src/pages/RegistrationLinkDetailPage.jsx b/adminapp/src/pages/RegistrationLinkDetailPage.jsx index c648041ab..ac9098af9 100644 --- a/adminapp/src/pages/RegistrationLinkDetailPage.jsx +++ b/adminapp/src/pages/RegistrationLinkDetailPage.jsx @@ -2,6 +2,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import Copyable from "../components/Copyable"; import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import formatDate from "../modules/formatDate"; @@ -48,9 +49,9 @@ export default function RegistrationLinkDetailPage() { keyRowAttr="startTime" toCells={(row) => [formatDate(row.startTime), formatDate(row.endTime)]} />, - [ diff --git a/adminapp/src/pages/RegistrationLinkListPage.jsx b/adminapp/src/pages/RegistrationLinkListPage.jsx index 25b8521d2..26f207d80 100644 --- a/adminapp/src/pages/RegistrationLinkListPage.jsx +++ b/adminapp/src/pages/RegistrationLinkListPage.jsx @@ -24,7 +24,7 @@ export default function RegistrationLinkListPage() { label: "Organization", align: "left", sortable: true, - render: (c) => , + render: (c) => , }, { id: "durableUrl", diff --git a/adminapp/src/pages/RoleDetailPage.jsx b/adminapp/src/pages/RoleDetailPage.jsx index f5ffe023a..4c1a27515 100644 --- a/adminapp/src/pages/RoleDetailPage.jsx +++ b/adminapp/src/pages/RoleDetailPage.jsx @@ -1,7 +1,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import EligibilityAssignmentsRelatedList from "../components/EligibilityAssignmentsRelatedList"; -import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import React from "react"; @@ -18,9 +18,9 @@ export default function RoleDetailPage() { ]} > {(model) => [ - [ @@ -29,9 +29,9 @@ export default function RoleDetailPage() { row.formattedPhone, ]} />, - [, row.name]} diff --git a/adminapp/src/pages/SignInPage.jsx b/adminapp/src/pages/SignInPage.jsx index 128da2a70..924570dd8 100644 --- a/adminapp/src/pages/SignInPage.jsx +++ b/adminapp/src/pages/SignInPage.jsx @@ -6,9 +6,9 @@ import { Button, Card, CardContent, + Container, FormControl, TextField, - Container, } from "@mui/material"; import { makeStyles } from "@mui/styles"; import React from "react"; diff --git a/adminapp/src/pages/VendorAccountDetailPage.jsx b/adminapp/src/pages/VendorAccountDetailPage.jsx index d613b16f9..32f410248 100644 --- a/adminapp/src/pages/VendorAccountDetailPage.jsx +++ b/adminapp/src/pages/VendorAccountDetailPage.jsx @@ -3,7 +3,7 @@ import AdminActions from "../components/AdminActions"; import AdminLink from "../components/AdminLink"; import BoolCheckmark from "../components/BoolCheckmark"; import DetailGrid from "../components/DetailGrid"; -import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import formatDate from "../modules/formatDate"; @@ -98,9 +98,9 @@ export default function VendorAccountDetailPage() { /> ), , - [ @@ -110,29 +110,17 @@ export default function VendorAccountDetailPage() { row.externalRegistrationId, ]} />, - [ row.id, - formatDate(row.createdAt), - row.messageContent, + formatDate(row.messageTimestamp), row.messageFrom, row.messageTo, - row.messageHandlerKey, - row.relayKey, - formatDate(row.messageTimestamp), + , ]} />, ]} diff --git a/adminapp/src/pages/VendorAccountForm.jsx b/adminapp/src/pages/VendorAccountForm.jsx index 2149c2fb9..8827844f5 100644 --- a/adminapp/src/pages/VendorAccountForm.jsx +++ b/adminapp/src/pages/VendorAccountForm.jsx @@ -29,14 +29,14 @@ export default function VendorAccountForm({ {...register("latestAccessCode")} label="Latest Access Code" name="latestAccessCode" - value={resource.latestAccessCode} + value={resource.latestAccessCode || ""} onChange={setFieldFromInput} /> diff --git a/adminapp/src/pages/VendorCreatePage.jsx b/adminapp/src/pages/VendorCreatePage.jsx index 95c3e02a4..80565577c 100644 --- a/adminapp/src/pages/VendorCreatePage.jsx +++ b/adminapp/src/pages/VendorCreatePage.jsx @@ -1,10 +1,10 @@ import api from "../api"; import ResourceCreate from "../components/ResourceCreate"; -import formHelpers from "../modules/formHelpers"; +import { stub } from "../modules/formHelpers"; import VendorForm from "./VendorForm"; import React from "react"; export default function VendorCreatePage() { - const empty = { image: null, imageCaption: formHelpers.initialTranslation, name: "" }; + const empty = { image: null, imageCaption: stub.translation, name: "" }; return ; } diff --git a/adminapp/src/pages/VendorDetailPage.jsx b/adminapp/src/pages/VendorDetailPage.jsx index 20c537151..b176ca159 100644 --- a/adminapp/src/pages/VendorDetailPage.jsx +++ b/adminapp/src/pages/VendorDetailPage.jsx @@ -1,7 +1,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import BoolCheckmark from "../components/BoolCheckmark"; -import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import detailPageImageProperties from "../components/detailPageImageProperties"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; @@ -22,9 +22,9 @@ export default function VendorDetailPage() { ]} > {(model) => [ - [ @@ -32,9 +32,9 @@ export default function VendorDetailPage() { {row.internalName}, ]} />, - [ @@ -44,9 +44,9 @@ export default function VendorDetailPage() { {row.enabled}, ]} />, - [ diff --git a/adminapp/src/pages/VendorServiceCategoryDetailPage.jsx b/adminapp/src/pages/VendorServiceCategoryDetailPage.jsx index b738fd83f..fdd903ea9 100644 --- a/adminapp/src/pages/VendorServiceCategoryDetailPage.jsx +++ b/adminapp/src/pages/VendorServiceCategoryDetailPage.jsx @@ -1,6 +1,6 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; -import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import React from "react"; @@ -22,9 +22,9 @@ export default function VendorServiceCategoryDetailPage() { ]} > {(model) => [ - [ diff --git a/adminapp/src/pages/VendorServiceCreatePage.jsx b/adminapp/src/pages/VendorServiceCreatePage.jsx index ba830ec5d..ece48dba9 100644 --- a/adminapp/src/pages/VendorServiceCreatePage.jsx +++ b/adminapp/src/pages/VendorServiceCreatePage.jsx @@ -1,6 +1,7 @@ import api from "../api"; import ResourceCreate from "../components/ResourceCreate"; import { dayjs } from "../modules/dayConfig"; +import { stub } from "../modules/formHelpers"; import VendorServiceForm from "./VendorServiceForm.jsx"; import React from "react"; @@ -9,7 +10,7 @@ export default function VendorServiceCreatePage() { internalName: "", externalName: "", vendor: { name: "" }, - categories: [], + categories: stub.collection, mobilityAdapterSetting: "no_adapter", periodBegin: dayjs().format(), periodEnd: dayjs().add(1, "day").format(), diff --git a/adminapp/src/pages/VendorServiceDetailPage.jsx b/adminapp/src/pages/VendorServiceDetailPage.jsx index 9133e86f8..8e41711ae 100644 --- a/adminapp/src/pages/VendorServiceDetailPage.jsx +++ b/adminapp/src/pages/VendorServiceDetailPage.jsx @@ -2,7 +2,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import AuditActivityList from "../components/AuditActivityList"; import CategoriesRelatedList from "../components/CategoriesRelatedList"; -import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import detailPageImageProperties from "../components/detailPageImageProperties"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; @@ -34,9 +34,9 @@ export default function VendorServiceDetailPage() { ]} > {(model) => [ - [ diff --git a/adminapp/src/pages/VendorServiceForm.jsx b/adminapp/src/pages/VendorServiceForm.jsx index 537cee8d1..caeada92f 100644 --- a/adminapp/src/pages/VendorServiceForm.jsx +++ b/adminapp/src/pages/VendorServiceForm.jsx @@ -95,7 +95,7 @@ export default function VendorServiceForm({ {...register("categories")} label="Categories" helperText="What ledger funds can be used to pay for this service?" - value={resource.categories} + collection={resource.categories} style={{ flex: 1 }} onChange={(_, c) => setField("categories", c)} /> @@ -136,14 +136,17 @@ export default function VendorServiceForm({ ); } -function MobilityAdapterSelect({ ...rest }) { +const MobilityAdapterSelect = React.forwardRef(function MobilityAdapterSelect( + { ...rest }, + ref +) { const data = useGlobalApiState( (data, ...args) => api.getVendorServiceMobilityAdapterOptions({ ...data }, ...args), { items: [] } ); return ( - {data.items.map(({ name, value }) => ( {name} @@ -151,4 +154,4 @@ function MobilityAdapterSelect({ ...rest }) { ))} ); -} +}); diff --git a/adminapp/src/pages/VendorServiceRateDetailPage.jsx b/adminapp/src/pages/VendorServiceRateDetailPage.jsx index 545c9700a..a5a4544fa 100644 --- a/adminapp/src/pages/VendorServiceRateDetailPage.jsx +++ b/adminapp/src/pages/VendorServiceRateDetailPage.jsx @@ -1,6 +1,6 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; -import RelatedList from "../components/RelatedList"; +import RelatedListRemote from "../components/RelatedListRemote"; import ResourceDetail from "../components/ResourceDetail"; import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import Money from "../shared/react/Money"; @@ -31,9 +31,21 @@ export default function VendorServiceRateDetailPage() { ]} > {(model) => [ - [ + {row.id}, + {row.internalName}, + {row.surcharge}, + {row.unitAmount}, + ]} + />, + [ diff --git a/lib/suma/admin_api/access.rb b/lib/suma/admin_api/access.rb index 7023ec6ca..1ba4e44b0 100644 --- a/lib/suma/admin_api/access.rb +++ b/lib/suma/admin_api/access.rb @@ -15,6 +15,8 @@ class Suma::AdminAPI::Access MAPPING = { Suma::AnonProxy::MemberContact => [:member_contact, COMMERCE, COMMERCE], Suma::AnonProxy::VendorAccount => [:vendor_account, COMMERCE, COMMERCE], + Suma::AnonProxy::VendorAccountMessage => [:vendor_account_message, COMMERCE, COMMERCE], + Suma::AnonProxy::VendorAccountRegistration => [:vendor_account_registration, COMMERCE, COMMERCE], Suma::AnonProxy::VendorConfiguration => [:vendor_configuration, COMMERCE, COMMERCE], Suma::Charge => [:charge, PAYMENTS, PAYMENTS], Suma::Commerce::OfferingProduct => [:offering_product, COMMERCE, COMMERCE], @@ -23,12 +25,14 @@ class Suma::AdminAPI::Access Suma::Commerce::Product => [:product, COMMERCE, COMMERCE], Suma::Eligibility::Assignment => [:eligibility_assignment, ALL, MANAGEMENT], Suma::Eligibility::Attribute => [:eligibility_attribute, ALL, MANAGEMENT], + Suma::Eligibility::MemberAssignment => [:eligibility_member_assignment, ALL, MANAGEMENT], Suma::Eligibility::Requirement => [:eligibility_requirement, ALL, MANAGEMENT], Suma::I18n::StaticString => [:static_string, ALL, LOCALIZATION], Suma::Marketing::List => [:marketing_list, MARKETING_SMS, MARKETING_SMS], Suma::Marketing::SmsBroadcast => [:marketing_sms_broadcast, MARKETING_SMS, MARKETING_SMS], Suma::Marketing::SmsDispatch => [:marketing_sms_dispatch, MARKETING_SMS, MARKETING_SMS], Suma::Member => [:member, MEMBERS, MEMBERS], + Suma::Member::ResetCode => [:member_reset_code, MEMBERS, MEMBERS], Suma::Member::Session => [:member_session, MEMBERS, MEMBERS], Suma::Message::Delivery => [:message_delivery, MEMBERS, MANAGEMENT], Suma::Mobility::Trip => [:mobility_trip, COMMERCE, MANAGEMENT], @@ -36,12 +40,15 @@ class Suma::AdminAPI::Access Suma::Organization::Membership::Verification => [:organization_membership_verification, MEMBERS, MEMBERS], Suma::Organization::RegistrationLink => [:organization_registration_link, MEMBERS, MEMBERS], Suma::Organization => [:organization, MEMBERS, MANAGEMENT], + Suma::Payment::Account => [:payment_account, PAYMENTS, PAYMENTS], Suma::Payment::BankAccount => [:bank_account, MEMBERS, MEMBERS], Suma::Payment::BookTransaction => [:book_transaction, PAYMENTS, PAYMENTS], Suma::Payment::Card => [:card, MEMBERS, MEMBERS], Suma::Payment::FundingTransaction => [:funding_transaction, PAYMENTS, PAYMENTS], + Suma::Payment::Instrument => [:payment_instrument, MEMBERS, MEMBERS], Suma::Payment::Ledger => [:ledger, PAYMENTS, PAYMENTS], Suma::Payment::PayoutTransaction => [:payout_transaction, PAYMENTS, PAYMENTS], + Suma::Payment::PlatformStatus::Calculated => [:platform_status, PAYMENTS, PAYMENTS], Suma::Payment::OffPlatformStrategy => [:off_platform_payment, PAYMENTS, PAYMENTS], Suma::Payment::Trigger => [:payment_trigger, PAYMENTS, MANAGEMENT], Suma::Program => [:program, ALL, MANAGEMENT], diff --git a/lib/suma/admin_api/anon_proxy_member_contacts.rb b/lib/suma/admin_api/anon_proxy_member_contacts.rb index a5f3e2ff9..92141ba05 100644 --- a/lib/suma/admin_api/anon_proxy_member_contacts.rb +++ b/lib/suma/admin_api/anon_proxy_member_contacts.rb @@ -13,7 +13,7 @@ class DetailedMemberContactEntity < AnonProxyMemberContactEntity expose :phone expose :email expose :external_relay_id - expose :vendor_accounts, with: AnonProxyVendorAccountEntity + expose_related :vendor_accounts, with: AnonProxyVendorAccountEntity end resource :anon_proxy_member_contacts do diff --git a/lib/suma/admin_api/anon_proxy_vendor_accounts.rb b/lib/suma/admin_api/anon_proxy_vendor_accounts.rb index 58311dde4..50da9a6b6 100644 --- a/lib/suma/admin_api/anon_proxy_vendor_accounts.rb +++ b/lib/suma/admin_api/anon_proxy_vendor_accounts.rb @@ -6,14 +6,32 @@ class Suma::AdminAPI::AnonProxyVendorAccounts < Suma::AdminAPI::V1 include Suma::Service::Types include Suma::AdminAPI::Entities - class VendorAccountRegistrationEntity < BaseEntity + class VendorAccountRegistrationEntity < BaseModelEntity include Suma::AdminAPI::Entities include AutoExposeBase + model Suma::AnonProxy::VendorAccountRegistration + expose :external_program_id expose :external_registration_id end + class VendorAccountMessageEntity < BaseModelEntity + include Suma::AdminAPI::Entities + include AutoExposeBase + + model Suma::AnonProxy::VendorAccountMessage + + expose :message_id + expose :message_from + expose :message_to + expose :message_content + expose :message_timestamp + expose :relay_key + expose :message_handler_key + expose :outbound_delivery, with: MessageDeliveryEntity + end + class DetailedVendorAccountEntity < AnonProxyVendorAccountEntity include Suma::AdminAPI::Entities include AutoExposeDetail @@ -24,7 +42,8 @@ class DetailedVendorAccountEntity < AnonProxyVendorAccountEntity expose :latest_access_code_magic_link expose :pending_closure expose :contact, with: AnonProxyMemberContactEntity - expose :registrations, with: VendorAccountRegistrationEntity + expose_related :registrations, with: VendorAccountRegistrationEntity + expose_related :messages, with: VendorAccountMessageEntity end resource :anon_proxy_vendor_accounts do diff --git a/lib/suma/admin_api/anon_proxy_vendor_configurations.rb b/lib/suma/admin_api/anon_proxy_vendor_configurations.rb index c696ada76..11700ffbd 100644 --- a/lib/suma/admin_api/anon_proxy_vendor_configurations.rb +++ b/lib/suma/admin_api/anon_proxy_vendor_configurations.rb @@ -10,8 +10,8 @@ class DetailedVendorConfigurationEntity < AnonProxyVendorConfigurationEntity include Suma::AdminAPI::Entities include AutoExposeDetail - expose :audit_activities, with: ActivityEntity - expose :programs, with: ProgramEntity + expose_related :audit_activities, with: ActivityEntity, inherit_permissions: true + expose_related :programs, with: ProgramEntity expose :description_text, with: TranslatedTextEntity expose :help_text, with: TranslatedTextEntity expose :terms_text, with: TranslatedTextEntity diff --git a/lib/suma/admin_api/bank_accounts.rb b/lib/suma/admin_api/bank_accounts.rb index 07f043812..028680652 100644 --- a/lib/suma/admin_api/bank_accounts.rb +++ b/lib/suma/admin_api/bank_accounts.rb @@ -6,6 +6,7 @@ class Suma::AdminAPI::BankAccounts < Suma::AdminAPI::V1 include Suma::AdminAPI::Entities class BankAccountEntity < PaymentInstrumentEntity + model Suma::Payment::BankAccount expose :verified_at expose :routing_number expose :masked_account_number diff --git a/lib/suma/admin_api/cards.rb b/lib/suma/admin_api/cards.rb index faf1280b5..3e91182ac 100644 --- a/lib/suma/admin_api/cards.rb +++ b/lib/suma/admin_api/cards.rb @@ -6,6 +6,7 @@ class Suma::AdminAPI::Cards < Suma::AdminAPI::V1 include Suma::AdminAPI::Entities class CardEntity < PaymentInstrumentEntity + model Suma::Payment::Card expose :last4 expose :brand expose :exp_month @@ -18,7 +19,7 @@ class DetailedCardEntity < CardEntity expose :stripe_id expose :member, with: MemberEntity - expose :originated_funding_transactions, with: FundingTransactionEntity + expose_related :originated_funding_transactions, with: FundingTransactionEntity end resource :cards do diff --git a/lib/suma/admin_api/charges.rb b/lib/suma/admin_api/charges.rb index ed95a79e2..f6239cca7 100644 --- a/lib/suma/admin_api/charges.rb +++ b/lib/suma/admin_api/charges.rb @@ -13,9 +13,9 @@ class DetailedChargeEntity < ChargeWithPricesEntity expose :member, with: MemberEntity expose :mobility_trip, with: MobilityTripEntity expose :commerce_order, with: OrderEntity - expose :line_items, with: ChargeLineItemEntity - expose :associated_funding_transactions, with: FundingTransactionEntity - expose :contributing_book_transactions, with: BookTransactionEntity + expose_related :line_items, with: ChargeLineItemEntity, all: true, inherit_permissions: true + expose_related :associated_funding_transactions, with: FundingTransactionEntity + expose_related :contributing_book_transactions, with: BookTransactionEntity end class ChargeEntityWithMember < ChargeEntity diff --git a/lib/suma/admin_api/commerce_offering_products.rb b/lib/suma/admin_api/commerce_offering_products.rb index d366a3764..5c7b36401 100644 --- a/lib/suma/admin_api/commerce_offering_products.rb +++ b/lib/suma/admin_api/commerce_offering_products.rb @@ -6,18 +6,13 @@ class Suma::AdminAPI::CommerceOfferingProducts < Suma::AdminAPI::V1 include Suma::AdminAPI::Entities - class DetailedCommerceOfferingProductEntity < BaseEntity + class DetailedCommerceOfferingProductEntity < OfferingProductEntity include Suma::AdminAPI::Entities - include AutoExposeBase include AutoExposeDetail expose :offering, with: OfferingEntity expose :product, with: ProductEntity - expose :customer_price, with: MoneyEntity - expose :undiscounted_price, with: MoneyEntity - expose :closed_at - - expose :orders, with: OrderEntity + expose_related :orders, with: OrderEntity end resource :commerce_offering_products do diff --git a/lib/suma/admin_api/commerce_offerings.rb b/lib/suma/admin_api/commerce_offerings.rb index 0e8c76eba..99f09f123 100644 --- a/lib/suma/admin_api/commerce_offerings.rb +++ b/lib/suma/admin_api/commerce_offerings.rb @@ -20,18 +20,19 @@ class DetailedOfferingEntity < OfferingEntity include Suma::AdminAPI::Entities include AutoExposeDetail - expose :audit_activities, with: ActivityEntity + expose_related :audit_activities, with: ActivityEntity, inherit_permissions: true expose :confirmation_template expose :description, with: TranslatedTextEntity expose :fulfillment_prompt, with: TranslatedTextEntity expose :fulfillment_instructions, with: TranslatedTextEntity expose :fulfillment_confirmation, with: TranslatedTextEntity - expose :fulfillment_options, with: OfferingFulfillmentOptionEntity + expose_related :fulfillment_options, + with: OfferingFulfillmentOptionEntity, all: true, inherit_permissions: true expose :begin_fulfillment_at expose_image :image - expose :offering_products, with: OfferingProductEntity - expose :orders, with: OrderInOfferingEntity - expose :programs, with: ProgramEntity + expose_related :offering_products, with: OfferingProductEntity + expose_related :orders, with: OrderInOfferingEntity + expose_related :programs, with: ProgramEntity expose :max_ordered_items_cumulative expose :max_ordered_items_per_member end diff --git a/lib/suma/admin_api/commerce_orders.rb b/lib/suma/admin_api/commerce_orders.rb index 763d616cf..743a727cd 100644 --- a/lib/suma/admin_api/commerce_orders.rb +++ b/lib/suma/admin_api/commerce_orders.rb @@ -10,19 +10,21 @@ class ListOrderEntity < OrderEntity expose :total_item_count end - class CheckoutItemEntity < BaseEntity + class CheckoutItemEntity < BaseModelEntity include Suma::AdminAPI::Entities + model Suma::Commerce::CheckoutItem expose :id expose :offering_product, with: OfferingProductEntity expose :quantity expose :checkout_id end - class CheckoutEntity < BaseEntity + class CheckoutEntity < BaseModelEntity include Suma::AdminAPI::Entities include AutoExposeBase + model Suma::Commerce::Checkout expose :undiscounted_cost, with: MoneyEntity expose :customer_cost, with: MoneyEntity expose :savings, with: MoneyEntity @@ -31,23 +33,22 @@ class CheckoutEntity < BaseEntity expose :total, with: MoneyEntity expose :payment_instrument, with: PaymentInstrumentEntity expose :fulfillment_option, with: OfferingFulfillmentOptionEntity + expose_related :items, with: CheckoutItemEntity, all: true, inherit_permissions: true end - class DetailedCommerceOrderEntity < BaseEntity + class OrderAuditLogEntity < AuditLogEntity + model Suma::Commerce::OrderAuditLog + end + + class DetailedCommerceOrderEntity < OrderEntity include Suma::AdminAPI::Entities - include AutoExposeBase include AutoExposeDetail - expose :order_status - expose :fulfillment_status - expose :admin_status_label, as: :status_label expose :serial expose :charge, with: ChargeWithPricesEntity - expose :audit_logs, with: AuditLogEntity + expose_related :audit_logs, with: OrderAuditLogEntity, inherit_permissions: true expose :offering, with: OfferingEntity, &self.delegate_to(:checkout, :cart, :offering) expose :checkout, with: CheckoutEntity - expose :items, with: CheckoutItemEntity, &self.delegate_to(:checkout, :items) - expose :member, with: MemberEntity, &self.delegate_to(:checkout, :cart, :member) end resource :commerce_orders do @@ -61,5 +62,17 @@ class DetailedCommerceOrderEntity < BaseEntity Suma::Commerce::Order, DetailedCommerceOrderEntity, ) + route_param :id, type: Integer do + Suma::AdminAPI::CommonEndpoints.related( + self, + Suma::Commerce::Order, + Suma::Commerce::CheckoutItem, + CheckoutItemEntity, + :checkout_items, + inherit_permissions: true, + route_name: :items, + dataset_method: :checkout_items_dataset, + ) + end end end diff --git a/lib/suma/admin_api/commerce_products.rb b/lib/suma/admin_api/commerce_products.rb index 2b696e63b..0751c68a2 100644 --- a/lib/suma/admin_api/commerce_products.rb +++ b/lib/suma/admin_api/commerce_products.rb @@ -25,11 +25,11 @@ class DetailedEntity < ProductEntity expose :quantity_on_hand, &self.delegate_to(:inventory!, :quantity_on_hand) expose :quantity_pending_fulfillment, &self.delegate_to(:inventory!, :quantity_pending_fulfillment) end - expose :offerings, with: OfferingEntity - expose :orders, with: OrderEntity - expose :offering_products, with: OfferingProductWithOfferingEntity + expose_related :offerings, with: OfferingEntity + expose_related :orders, with: OrderEntity + expose_related :offering_products, with: OfferingProductWithOfferingEntity expose_image :image - expose :vendor_service_categories, with: VendorServiceCategoryEntity + expose_related :vendor_service_categories, with: VendorServiceCategoryEntity end resource :commerce_products do diff --git a/lib/suma/admin_api/common_endpoints.rb b/lib/suma/admin_api/common_endpoints.rb index 1ee527fdf..60629496c 100644 --- a/lib/suma/admin_api/common_endpoints.rb +++ b/lib/suma/admin_api/common_endpoints.rb @@ -238,7 +238,8 @@ def self.list( end end - def self.get_one(route_def, model_type, entity) + def self.get_one(route_def, model_type, entity, expose_related: true) + cend = self route_def.instance_exec do route_param :id, type: Integer do get do @@ -246,10 +247,65 @@ def self.get_one(route_def, model_type, entity) (m = model_type[params[:id]]) or forbidden! present m, with: entity end + cend.related_children(route_def, entity) if expose_related end end end + # Expose a related sub-resources automatically, as /:id/, + # which uses pagination over the dataset or association. + # + # @param include_permissions [true,false] If true, use model_type for the role check, + # instead of the related entity model. Needed for related resources like support notes. + # @param route_name [Symbol] The route name to use instead of association_name. + # @param dataset_method [Symbol] The dataset method to use, where there is no associaiton with the given name. + def self.related( + route_def, + model_type, + related_model_type, + related_entity, + association_name, + inherit_permissions: false, + route_name: nil, + dataset_method: nil + ) + route_def.instance_exec do + params do + use :pagination + end + get route_name || association_name do + check_admin_role_access!(:read, model_type) + check_admin_role_access!(:read, inherit_permissions ? model_type : related_model_type) + (m = model_type[params[:id]]) or forbidden! + if dataset_method.nil? + dataset_method = :"#{association_name}_dataset" + unless m.class.method_defined?(dataset_method) + assoc = m.class.association_reflections.fetch(association_name) + dataset_method = assoc.fetch(:dataset_method) + end + end + ds = m.send(dataset_method) + ds = paginate(ds, params) + present_collection ds, with: related_entity + end + end + end + + # Expose related child entities, like `/charge/:id/items. + def self.related_children(route_def, entity) + entity.exposed_related.each do |h| + related_entity = h.fetch(:with) + self.related( + route_def, + entity.model, + related_entity.model, + related_entity, + h.fetch(:name), + inherit_permissions: h.fetch(:inherit_permissions), + ) + end + end + def self.create(route_def, model_type, entity, around: nil, &) around ||= ->(*, &b) { b.call } route_def.instance_exec do diff --git a/lib/suma/admin_api/eligibility_attributes.rb b/lib/suma/admin_api/eligibility_attributes.rb index e1d3f457b..07b86f7de 100644 --- a/lib/suma/admin_api/eligibility_attributes.rb +++ b/lib/suma/admin_api/eligibility_attributes.rb @@ -10,9 +10,9 @@ class DetailedEligibilityAttribute < EligibilityAttributeEntity include AutoExposeDetail expose :description - expose :children, with: EligibilityAttributeEntity - expose :assignments, with: EligibilityAssignmentEntity - expose :referenced_requirements, with: EligibilityRequirementEntity + expose_related :children, with: EligibilityAttributeEntity + expose_related :assignments, with: EligibilityAssignmentEntity + expose_related :referenced_requirements, with: EligibilityRequirementEntity end resource :eligibility_attributes do diff --git a/lib/suma/admin_api/eligibility_requirements.rb b/lib/suma/admin_api/eligibility_requirements.rb index ae72f721b..223268977 100644 --- a/lib/suma/admin_api/eligibility_requirements.rb +++ b/lib/suma/admin_api/eligibility_requirements.rb @@ -9,8 +9,8 @@ class DetailedEligibilityRequirement < EligibilityRequirementEntity include Suma::AdminAPI::Entities include AutoExposeDetail - expose :programs, with: ProgramEntity - expose :payment_triggers, with: PaymentTriggerEntity + expose_related :programs, with: ProgramEntity + expose_related :payment_triggers, with: PaymentTriggerEntity expose :expression, &self.delegate_to(:expression, :serialize) expose :expression_tokens, &self.delegate_to(:expression, :tokenize) end @@ -60,7 +60,7 @@ class EditorExpressionEvaluationEntity < BaseEntity EligibilityRequirementEntity, around: lambda do |_rt, m, &b| b.call - m.all_resources.each { |r| r.audit_activity("addeligibility", action: m) } + m.each_resource { |r| r.audit_activity("addeligibility", action: m) } end, ) do params do @@ -108,7 +108,7 @@ class EditorExpressionEvaluationEntity < BaseEntity Suma::Eligibility::Requirement, DetailedEligibilityRequirement, around: lambda do |_rt, m, &b| - m.all_resources.each { |r| r.audit_activity("removedeligibility", action: m) } + m.each_resource { |r| r.audit_activity("removedeligibility", action: m) } b.call end, ) diff --git a/lib/suma/admin_api/entities.rb b/lib/suma/admin_api/entities.rb index 83d86b614..6e814af79 100644 --- a/lib/suma/admin_api/entities.rb +++ b/lib/suma/admin_api/entities.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true -require "grape_entity" - require "suma/service/entities" +require "suma/service/collection" module Suma::AdminAPI::Entities class MoneyEntity < Suma::Service::Entities::Money; end @@ -25,13 +24,84 @@ class ImageEntity < Suma::Service::Entities::Base end class BaseEntity < Suma::Service::Entities::Base - def self.expose_image(name, &block) - self.expose(name, with: ImageEntity) do |instance, options| - evaluate_exposure(name, block, instance, options) + class << self + def expose_image(name, &block) + self.expose(name, with: ImageEntity) do |instance, options| + evaluate_exposure(name, block, instance, options) + end + self.expose("#{name}_caption", with: TranslatedTextEntity) do |instance, options| + img = evaluate_exposure(name, block, instance, options) + img&.caption + end + end + end + end + + # Base class for models. Allows exposure of related associations via #expore_related, + # and automatic route create during CommonEndpoints.get_one. + class BaseModelEntity < BaseEntity + class << self + attr_accessor :exposed_related + + def inherited(subclass) + super + subclass.exposed_related = self.exposed_related.dup + subclass.model(self.model) + end + + def model(type=nil) + return @model if type.nil? + @model = type + self.exposed_related ||= [] end - self.expose("#{name}_caption", with: TranslatedTextEntity) do |instance, options| - img = evaluate_exposure(name, block, instance, options) - img&.caption + + # Expose a list field of this entity. + # The field is exposed with a Collection entity so it can be paginated. + # + # NOTE: Callers must implement these collection endpoints, usually through CommonEndpoints.get_one. + # See CommonEndpoints.related. + # + # @param name [Symbol] Related name. The subroute gets this name if exposed with CommonEndpoints.get_one. + # The instance must have a _dataset method or name must be an association. + # @param with [Class] Entity to use in the expsoure. + # @param as [Symbol] The field on the entity will get this name. + # @param all [true,false] If true, load all items from the dataset when loading the collection. + # This preserves the 'collection' format exposure but does not require pagination. + # Useful when we always want to load all resources in admin. + # @param inherit_permissions [true,false] Some resources, like notes or audit logs, + # should inherit the permissions of their parent. + # If inherit_permissions is true, the permissions of the parent model are used, + # rather than the subresource. + # @param to_path [Proc] If given, this is called with (instance, options) + # to get the PATH_INFO (ie, /members/123) part of the route. + # Used for nested related exposures, so /member/123 + # can nest to something like /payment_accounts/123/ledgers. + def expose_related(name, with:, as: nil, all: false, inherit_permissions: false, to_path: nil) + collection_entity = Suma::Service::Collection.prepare_entity(with) + ds_method = :"#{name}_dataset" + unless self.model.method_defined?(ds_method) + raise ArgumentError, "must call #model before using expose_related, got: #{self.model.inspect}" unless + self.model.respond_to?(:association_reflections) + assoc = self.model.association_reflections[name] + raise ArgumentError, "#{self.model} does not has association #{name} or dataset #{ds_method}" if assoc.nil? + ds_method = assoc.fetch(:dataset_method) + end + self.exposed_related << {name:, with:, inherit_permissions:} + exposed_attr = (name || as).to_s + self.expose(name, as:, with: collection_entity) do |instance, options| + ds = instance.send(ds_method) + # Do NOT overwrite the closure all or we get leakage/corruption + req_all = all || Rack::Request.new(options[:env]).GET.fetch("expand", []).include?(exposed_attr) + if req_all + collection = Suma::Service::Collection.from_array(ds.all) + else + ds = ds.paginate(1, Suma::Service.related_list_size) + collection = Suma::Service::Collection.from_dataset(ds) + end + path_info = to_path ? to_path[instance, options] : nil + collection.url = Suma::Service.request_path(options[:env], path_info) + "/#{name}" + collection + end end end end @@ -78,21 +148,24 @@ class CurrentMemberEntity < Suma::Service::Entities::CurrentMember end end - class RoleEntity < BaseEntity + class RoleEntity < BaseModelEntity include AutoExposeBase + model Suma::Role expose :name end - class OrganizationEntity < BaseEntity + class OrganizationEntity < BaseModelEntity include AutoExposeBase + model Suma::Organization expose :name end - class PaymentInstrumentEntity < BaseEntity + class PaymentInstrumentEntity < BaseModelEntity include AutoExposeBase + model Suma::Payment::Instrument expose :payment_method_type expose :legal_entity, with: LegalEntityEntity expose :institution_name @@ -108,7 +181,7 @@ class AuditMemberEntity < BaseEntity expose :admin_link end - class AuditLogEntity < BaseEntity + class AuditLogEntity < BaseModelEntity expose :id expose :at expose :event @@ -119,20 +192,22 @@ class AuditLogEntity < BaseEntity expose :actor, with: AuditMemberEntity end - class SupportNoteEntity < BaseEntity + class SupportNoteEntity < BaseModelEntity include Suma::AdminAPI::Entities include AutoExposeBase + model Suma::Support::Note expose :author, with: AuditMemberEntity expose :authored_at expose :content expose :content_html end - class ActivityEntity < BaseEntity + class ActivityEntity < BaseModelEntity include Suma::AdminAPI::Entities include AutoExposeBase + model Suma::Member::Activity expose :member, with: AuditMemberEntity expose :message_name expose :message_vars @@ -140,9 +215,10 @@ class ActivityEntity < BaseEntity expose :summary_md end - class MemberEntity < BaseEntity + class MemberEntity < BaseModelEntity include AutoExposeBase + model Suma::Member expose :email expose :name expose :phone @@ -151,9 +227,10 @@ class MemberEntity < BaseEntity expose :onboarding_verified_at end - class MessageDeliveryEntity < BaseEntity + class MessageDeliveryEntity < BaseModelEntity include AutoExposeBase + model Suma::Message::Delivery expose :template expose :transport_type expose :carrier_key @@ -165,9 +242,10 @@ class MessageDeliveryEntity < BaseEntity expose :recipient, with: MemberEntity end - class ProgramEntity < BaseEntity + class ProgramEntity < BaseModelEntity include AutoExposeBase + model Suma::Program expose :name, with: TranslatedTextEntity expose :description, with: TranslatedTextEntity expose :period_begin @@ -177,38 +255,47 @@ class ProgramEntity < BaseEntity expose :app_link_text, with: TranslatedTextEntity end - class EligibilityAttributeEntity < BaseEntity + class EligibilityAttributeEntity < BaseModelEntity include AutoExposeBase + model Suma::Eligibility::Attribute expose :name expose :parent, with: self end - class EligibilityAssignmentEntity < BaseEntity + class EligibilityAssignmentEntity < BaseModelEntity include AutoExposeBase + model Suma::Eligibility::Assignment expose :assignee, with: AutoExposedBaseEntity expose :assignee_label expose :assignee_type expose :attribute, with: EligibilityAttributeEntity end - class EligibilityRequirementEntity < BaseEntity + class EligibilityRequirementResourceEntity < BaseEntity include AutoExposeBase + end - expose :all_resources, as: :resources, with: AutoExposedBaseEntity + class EligibilityRequirementEntity < BaseModelEntity + include AutoExposeBase + + model Suma::Eligibility::Requirement expose :cached_expression_string, as: :expression_formula_str + expose :all_resources, as: :resources, with: EligibilityRequirementResourceEntity end - class VendorEntity < BaseEntity + class VendorEntity < BaseModelEntity include AutoExposeBase + model Suma::Vendor expose :name end - class VendorServiceEntity < BaseEntity + class VendorServiceEntity < BaseModelEntity include AutoExposeBase + model Suma::Vendor::Service expose :internal_name expose :external_name expose :vendor, with: VendorEntity @@ -216,9 +303,10 @@ class VendorServiceEntity < BaseEntity expose :period_end end - class VendorServiceCategoryTerminalEntity < BaseEntity + class VendorServiceCategoryTerminalEntity < BaseModelEntity include AutoExposeBase + model Suma::Vendor::ServiceCategory expose :name expose :slug end @@ -228,41 +316,48 @@ class VendorServiceCategoryEntity < VendorServiceCategoryTerminalEntity end class VendorServiceRateUndiscountedrateEntity < BaseEntity - expose :id + include AutoExposeBase + expose :internal_name end - class VendorServiceRateEntity < BaseEntity + class VendorServiceRateEntity < BaseModelEntity include AutoExposeBase + model Suma::Vendor::ServiceRate expose :internal_name expose :external_name + expose :unit_offset + expose :ordinal expose :unit_amount, with: MoneyEntity expose :surcharge, with: MoneyEntity expose :undiscounted_rate, with: VendorServiceRateUndiscountedrateEntity end - class ProgramPricingEntity < BaseEntity + class ProgramPricingEntity < BaseModelEntity include AutoExposeBase + model Suma::Program::Pricing expose :program, with: ProgramEntity expose :vendor_service, with: VendorServiceEntity expose :vendor_service_rate, with: VendorServiceRateEntity end - class AnonProxyVendorConfigurationEntity < BaseEntity + class AnonProxyVendorConfigurationEntity < BaseModelEntity include AutoExposeBase + model Suma::AnonProxy::VendorConfiguration expose :vendor, with: VendorEntity expose :app_install_link expose :auth_to_vendor_key expose :enabled end - class AnonProxyMemberContactEntity < BaseEntity + class AnonProxyMemberContactEntity < BaseModelEntity include Suma::AdminAPI::Entities include AutoExposeBase + model Suma::AnonProxy::MemberContact expose :member, with: MemberEntity expose :formatted_address expose :relay_key @@ -275,18 +370,20 @@ class AnonProxyVendorAccountMemberContactEntity < BaseEntity expose :formatted_address end - class AnonProxyVendorAccountEntity < BaseEntity + class AnonProxyVendorAccountEntity < BaseModelEntity include Suma::AdminAPI::Entities include AutoExposeBase + model Suma::AnonProxy::VendorAccount expose :member, with: MemberEntity expose :configuration, with: AnonProxyVendorConfigurationEntity expose :contact, with: AnonProxyVendorAccountMemberContactEntity end - class ChargeEntity < BaseEntity + class ChargeEntity < BaseModelEntity include AutoExposeBase + model Suma::Charge expose :opaque_id expose :discounted_subtotal, with: MoneyEntity expose :undiscounted_subtotal, with: MoneyEntity @@ -298,9 +395,10 @@ class ChargeWithPricesEntity < ChargeEntity expose :noncash_paid_from_ledger, with: MoneyEntity end - class MobilityTripEntity < BaseEntity + class MobilityTripEntity < BaseModelEntity include AutoExposeBase + model Suma::Mobility::Trip expose :vehicle_id expose :begin_lat expose :begin_lng @@ -314,16 +412,20 @@ class MobilityTripEntity < BaseEntity expose :total_cost, with: MoneyEntity, &self.delegate_to(:charge, :discounted_subtotal, safe: true) end - class SimpleLedgerEntity < BaseEntity + class SimpleLedgerEntity < BaseModelEntity include AutoExposeBase + model Suma::Payment::Ledger expose :name + expose :currency + expose :account_id expose :account_name, &self.delegate_to(:account, :display_name) end - class SimplePaymentAccountEntity < BaseEntity + class SimplePaymentAccountEntity < BaseModelEntity include AutoExposeBase + model Suma::Payment::Account expose :display_name end @@ -333,26 +435,29 @@ class PaymentStrategyEntity < BaseEntity expose :admin_details_typed, as: :admin_details end - class FundingTransactionEntity < BaseEntity + class FundingTransactionEntity < BaseModelEntity include AutoExposeBase + model Suma::Payment::FundingTransaction expose :status expose :amount, with: MoneyEntity expose :originating_payment_account, with: SimplePaymentAccountEntity end - class PayoutTransactionEntity < BaseEntity + class PayoutTransactionEntity < BaseModelEntity include AutoExposeBase + model Suma::Payment::PayoutTransaction expose :status expose :classification expose :amount, with: MoneyEntity expose :originating_payment_account, with: SimplePaymentAccountEntity end - class BookTransactionEntity < BaseEntity + class BookTransactionEntity < BaseModelEntity include AutoExposeBase + model Suma::Payment::BookTransaction expose :apply_at expose :amount, with: MoneyEntity expose :memo, with: TranslatedTextEntity @@ -362,48 +467,54 @@ class BookTransactionEntity < BaseEntity expose :actor, with: AuditMemberEntity end - class DetailedPaymentAccountLedgerEntity < BaseEntity - include AutoExposeBase - include AutoExposeDetail - - expose :currency - expose :vendor_service_categories, with: VendorServiceCategoryEntity - expose :combined_book_transactions, with: BookTransactionEntity - expose :balance, with: MoneyEntity - end - - class DetailedPaymentAccountEntity < BaseEntity - include AutoExposeBase - include AutoExposeDetail - - expose :member, with: MemberEntity - expose :vendor, with: VendorEntity - expose :is_platform_account - expose :ledgers, with: DetailedPaymentAccountLedgerEntity - expose :total_balance, with: MoneyEntity - expose :originated_funding_transactions, with: FundingTransactionEntity - expose :originated_payout_transactions, with: PayoutTransactionEntity - end - - class PaymentTriggerEntity < BaseEntity + # class DetailedPaymentAccountLedgerEntity < BaseModelEntity + # include AutoExposeBase + # include AutoExposeDetail + # + # model Suma::Payment::Ledger + # route :payment_ledgers + # expose :currency + # expose_related :vendor_service_categories, with: VendorServiceCategoryEntity + # expose_related :combined_book_transactions, with: BookTransactionEntity + # expose :balance, with: MoneyEntity + # end + # + # class DetailedPaymentAccountEntity < BaseModelEntity + # include AutoExposeBase + # include AutoExposeDetail + # + # model Suma::Payment::Account + # expose :member, with: MemberEntity + # expose :vendor, with: VendorEntity + # expose :is_platform_account + # expose :ledgers, with: DetailedPaymentAccountLedgerEntity + # expose :total_balance, with: MoneyEntity + # expose_related :originated_funding_transactions, with: FundingTransactionEntity + # expose_related :originated_payout_transactions, with: PayoutTransactionEntity + # end + + class PaymentTriggerEntity < BaseModelEntity include Suma::AdminAPI::Entities include AutoExposeBase + model Suma::Payment::Trigger expose :active_during_begin expose :active_during_end end - class OfferingEntity < BaseEntity + class OfferingEntity < BaseModelEntity include AutoExposeBase + model Suma::Commerce::Offering expose :description, with: TranslatedTextEntity expose :period_end expose :period_begin end - class OfferingFulfillmentOptionEntity < BaseEntity + class OfferingFulfillmentOptionEntity < BaseModelEntity include AutoExposeBase + model Suma::Commerce::OfferingFulfillmentOption expose :description, with: TranslatedTextEntity expose :type expose :ordinal @@ -411,9 +522,10 @@ class OfferingFulfillmentOptionEntity < BaseEntity expose :address, with: AddressEntity, safe: true end - class OfferingProductEntity < BaseEntity + class OfferingProductEntity < BaseModelEntity include AutoExposeBase + model Suma::Commerce::OfferingProduct expose :closed_at expose :product_id expose_translated :product_name, &self.delegate_to(:product, :name) @@ -423,17 +535,19 @@ class OfferingProductEntity < BaseEntity expose :closed?, as: :is_closed end - class ProductEntity < BaseEntity + class ProductEntity < BaseModelEntity include AutoExposeBase + model Suma::Commerce::Product expose :vendor, with: VendorEntity expose :name, with: TranslatedTextEntity expose :description, with: TranslatedTextEntity end - class OrderEntity < BaseEntity + class OrderEntity < BaseModelEntity include AutoExposeBase + model Suma::Commerce::Order expose :order_status expose :fulfillment_status expose :admin_status_label, as: :status_label @@ -449,9 +563,10 @@ class BaseOrganizationMembershipVerificationEntity < BaseEntity expose :owner, with: MemberEntity end - class OrganizationMembershipEntity < BaseEntity + class OrganizationMembershipEntity < BaseModelEntity include AutoExposeBase + model Suma::Organization::Membership expose :member, with: MemberEntity expose :verified_organization, with: OrganizationEntity expose :unverified_organization_name @@ -462,17 +577,19 @@ class OrganizationMembershipEntity < BaseEntity expose :verification, with: BaseOrganizationMembershipVerificationEntity end - class OrganizationMembershipVerificationEntity < BaseEntity + class OrganizationMembershipVerificationEntity < BaseModelEntity include AutoExposeBase + model Suma::Organization::Membership::Verification expose :status expose :membership, with: OrganizationMembershipEntity expose :owner, with: MemberEntity end - class OrganizationRegistrationLinkEntity < BaseEntity + class OrganizationRegistrationLinkEntity < BaseModelEntity include AutoExposeBase + model Suma::Organization::RegistrationLink expose :organization, with: OrganizationEntity expose :opaque_id expose :ical_dtstart @@ -480,9 +597,10 @@ class OrganizationRegistrationLinkEntity < BaseEntity expose :ical_rrule end - class ChargeLineItemEntity < BaseEntity + class ChargeLineItemEntity < BaseModelEntity include AutoExposeBase + model Suma::Charge::LineItem expose :charge_id expose :amount, with: MoneyEntity expose :memo, with: TranslatedTextEntity @@ -496,22 +614,25 @@ class MarketingMemberEntity < MemberEntity expose :admin_link end - class MarketingListEntity < BaseEntity + class MarketingListEntity < BaseModelEntity include AutoExposeBase + model Suma::Marketing::List expose :managed end - class MarketingSmsBroadcastEntity < BaseEntity + class MarketingSmsBroadcastEntity < BaseModelEntity include AutoExposeBase + model Suma::Marketing::SmsBroadcast expose :sent_at end - class MarketingSmsDispatchEntity < BaseEntity + class MarketingSmsDispatchEntity < BaseModelEntity include Suma::AdminAPI::Entities include AutoExposeBase + model Suma::Marketing::SmsDispatch expose :member, with: MarketingMemberEntity expose :sms_broadcast, with: MarketingSmsBroadcastEntity expose :sent_at diff --git a/lib/suma/admin_api/financials.rb b/lib/suma/admin_api/financials.rb index 9c1bbe289..5eb7f8ef8 100644 --- a/lib/suma/admin_api/financials.rb +++ b/lib/suma/admin_api/financials.rb @@ -17,19 +17,21 @@ class LedgerEntity < SimpleLedgerEntity expose :count_debits end - class OffPlatformTransactionEntity < BaseEntity + class OffPlatformTransactionEntity < BaseModelEntity include Suma::AdminAPI::Entities include AutoExposeBase + model Suma::Payment::OffPlatformStrategy expose :amount, with: MoneyEntity expose :transacted_at, &self.delegate_to(:strategy, :transacted_at) expose :note, &self.delegate_to(:strategy, :note) expose :check_or_transaction_number, &self.delegate_to(:strategy, :check_or_transaction_number) end - class PlatformStatusEntity < BaseEntity + class PlatformStatusEntity < BaseModelEntity include Suma::AdminAPI::Entities + model Suma::Payment::PlatformStatus::Calculated expose :funding, with: MoneyEntity expose :funding_count expose :payouts, with: MoneyEntity @@ -38,17 +40,21 @@ class PlatformStatusEntity < BaseEntity expose :refund_count expose :member_liabilities, with: MoneyEntity expose :assets, with: MoneyEntity - expose :platform_ledgers, with: LedgerEntity - expose :unbalanced_ledgers, with: LedgerEntity - expose :off_platform_funding_transactions, with: OffPlatformTransactionEntity - expose :off_platform_payout_transactions, with: OffPlatformTransactionEntity + expose_related :platform_ledgers, with: LedgerEntity + expose_related :unbalanced_ledgers, with: LedgerEntity + expose_related :off_platform_funding_transactions, with: OffPlatformTransactionEntity + expose_related :off_platform_payout_transactions, with: OffPlatformTransactionEntity end resource :financials do - get :platform_status do - check_admin_role_access!(:read, :admin_payments) - res = Suma::Payment::PlatformStatus.new.calculate - present res, with: PlatformStatusEntity + resource :platform_status do + get do + check_admin_role_access!(:read, :admin_payments) + res = Suma::Payment::PlatformStatus::Calculated.new + present res, with: PlatformStatusEntity + end + + Suma::AdminAPI::CommonEndpoints.related_children(self, PlatformStatusEntity) end end end diff --git a/lib/suma/admin_api/funding_transactions.rb b/lib/suma/admin_api/funding_transactions.rb index ad891cda8..ff2483996 100644 --- a/lib/suma/admin_api/funding_transactions.rb +++ b/lib/suma/admin_api/funding_transactions.rb @@ -7,6 +7,10 @@ class Suma::AdminAPI::FundingTransactions < Suma::AdminAPI::V1 include Suma::AdminAPI::Entities + class FundingAuditLogEntity < AuditLogEntity + model Suma::Payment::FundingTransaction::AuditLog + end + class DetailedFundingTransactionEntity < FundingTransactionEntity include Suma::AdminAPI::Entities include AutoExposeDetail @@ -15,12 +19,12 @@ class DetailedFundingTransactionEntity < FundingTransactionEntity expose :can_refund?, as: :can_refund expose :refundable_amount, with: MoneyEntity expose :refunded_amount, with: MoneyEntity - expose :refund_payout_transactions, with: PayoutTransactionEntity + expose_related :refund_payout_transactions, with: PayoutTransactionEntity, all: true expose :platform_ledger, with: SimpleLedgerEntity expose :originated_book_transaction, with: BookTransactionEntity expose :reversal_book_transaction, with: BookTransactionEntity - expose :audit_activities, with: ActivityEntity - expose :audit_logs, with: AuditLogEntity + expose_related :audit_activities, with: ActivityEntity, inherit_permissions: true + expose_related :audit_logs, with: FundingAuditLogEntity, inherit_permissions: true expose :strategy, with: PaymentStrategyEntity end diff --git a/lib/suma/admin_api/marketing_lists.rb b/lib/suma/admin_api/marketing_lists.rb index d9ef0622d..6afdad997 100644 --- a/lib/suma/admin_api/marketing_lists.rb +++ b/lib/suma/admin_api/marketing_lists.rb @@ -9,8 +9,8 @@ class DetailedListEntity < MarketingListEntity include Suma::AdminAPI::Entities include AutoExposeDetail - expose :members, with: MarketingMemberEntity - expose :sms_broadcasts, with: MarketingSmsBroadcastEntity + expose_related :members, with: MarketingMemberEntity + expose_related :sms_broadcasts, with: MarketingSmsBroadcastEntity end resource :marketing_lists do diff --git a/lib/suma/admin_api/marketing_sms_broadcasts.rb b/lib/suma/admin_api/marketing_sms_broadcasts.rb index c26874fe0..bc368c673 100644 --- a/lib/suma/admin_api/marketing_sms_broadcasts.rb +++ b/lib/suma/admin_api/marketing_sms_broadcasts.rb @@ -15,14 +15,14 @@ class DetailedSmsBroadcastEntity < MarketingSmsBroadcastEntity expose :sending_number_formatted expose :preferences_optout_field expose :preferences_optout_name - expose :lists, with: MarketingListEntity + expose_related :lists, with: MarketingListEntity expose :all_lists, with: MarketingListEntity do |_inst| Suma::Marketing::List.dataset.order(:label).all end expose :preview do |instance, opts| instance.preview(opts.fetch(:env).fetch("yosoy").authenticated_object!.member) end - expose :sms_dispatches, with: MarketingSmsDispatchEntity + expose_related :sms_dispatches, with: MarketingSmsDispatchEntity expose :available_sending_numbers do |_instance| Suma::Marketing::SmsBroadcast.available_sending_numbers end diff --git a/lib/suma/admin_api/members.rb b/lib/suma/admin_api/members.rb index 07e1e0ec9..6d9f5bf5a 100644 --- a/lib/suma/admin_api/members.rb +++ b/lib/suma/admin_api/members.rb @@ -8,10 +8,11 @@ class Suma::AdminAPI::Members < Suma::AdminAPI::V1 include Suma::Service::Types include Suma::AdminAPI::Entities - class MemberResetCodeEntity < BaseEntity + class MemberResetCodeEntity < BaseModelEntity include Suma::AdminAPI::Entities include AutoExposeBase + model Suma::Member::ResetCode expose :transport expose :used expose :expire_at @@ -23,10 +24,11 @@ class MemberResetCodeEntity < BaseEntity expose :message_delivery, with: MessageDeliveryEntity end - class MemberSessionEntity < BaseEntity + class MemberSessionEntity < BaseModelEntity include Suma::AdminAPI::Entities include AutoExposeBase + model Suma::Member::Session expose :user_agent expose :peer_ip, &self.delegate_to(:peer_ip, :to_s) expose :logged_out_at @@ -42,21 +44,13 @@ class MemberOrderEntity < OrderEntity expose :offering, with: OfferingEntity, &self.delegate_to(:checkout, :cart, :offering) end - class MemberContactEntity < BaseEntity - include Suma::AdminAPI::Entities - include AutoExposeBase - - expose :formatted_address - end - - class MemberVendorAccountEntity < BaseEntity + class MemberVendorAccountEntity < AnonProxyVendorAccountEntity include Suma::AdminAPI::Entities include AutoExposeBase expose :latest_access_code expose :latest_access_code_magic_link expose :vendor, with: VendorEntity, &self.delegate_to(:configuration, :vendor) - expose :contact, with: MemberContactEntity end class PreferencesSubscriptionEntity < BaseEntity @@ -66,6 +60,9 @@ class PreferencesSubscriptionEntity < BaseEntity end class PreferencesEntity < BaseEntity + include Suma::AdminAPI::Entities + include AutoExposeBase + expose :public_url expose :subscriptions, with: PreferencesSubscriptionEntity expose :preferred_language_name @@ -73,18 +70,20 @@ class PreferencesEntity < BaseEntity expose :sms_undeliverable?, as: :sms_undeliverable end - class ReferralEntity < BaseEntity + class ReferralEntity < BaseModelEntity include Suma::AdminAPI::Entities include AutoExposeBase + model Suma::Member::Referral expose :source expose :campaign expose :medium end - class EligibilityMemberAssignmentEntity < BaseEntity + class EligibilityMemberAssignmentEntity < BaseModelEntity include Suma::AdminAPI::Entities + model Suma::Eligibility::MemberAssignment expose :unique_key expose :member, with: MemberEntity expose :attribute, with: EligibilityAttributeEntity @@ -95,39 +94,71 @@ class EligibilityMemberAssignmentEntity < BaseEntity expose :source_membership, with: OrganizationMembershipEntity end + class MemberDetailLedgerEntity < SimpleLedgerEntity + include Suma::AdminAPI::Entities + include AutoExposeDetail + + expose :balance, with: MoneyEntity + expose_related :vendor_service_categories, + as: :categories, + with: VendorServiceCategoryEntity, + all: true, + to_path: ->(inst, _) { "/v1/payment_ledgers/#{inst.id}" } + end + + class MemberDetailPaymentAccountEntity < SimplePaymentAccountEntity + include Suma::AdminAPI::Entities + include AutoExposeDetail + + expose :total_balance, with: MoneyEntity + expose_related :ledgers, + with: MemberDetailLedgerEntity, + all: true, + to_path: ->(inst, _) { "/v1/payment_accounts/#{inst.id}" } + expose_related :originated_funding_transactions, + with: FundingTransactionEntity, + to_path: ->(inst, _) { "/v1/payment_accounts/#{inst.id}" } + expose_related :originated_payout_transactions, + with: PayoutTransactionEntity, + to_path: ->(inst, _) { "/v1/payment_accounts/#{inst.id}" } + expose_related :all_book_transactions, + with: BookTransactionEntity, + to_path: ->(inst, _) { "/v1/payment_accounts/#{inst.id}" } + end + class DetailedMemberEntity < MemberEntity include Suma::AdminAPI::Entities include AutoExposeDetail expose :opaque_id - expose :roles, with: RoleEntity + expose_related :roles, with: RoleEntity expose :onboarding_verified?, as: :onboarding_verified expose :previous_phones do |instance| instance.previous_phones.map { |s| Suma::PhoneNumber.format_display(s) } end expose :previous_emails - expose :activities, with: ActivityEntity - expose :audit_activities, with: ActivityEntity + expose_related :activities, with: ActivityEntity, inherit_permissions: true + expose_related :audit_activities, with: ActivityEntity, inherit_permissions: true expose :legal_entity, with: LegalEntityEntity - expose :payment_account, with: DetailedPaymentAccountEntity - expose :charges, with: ChargeEntity - expose :eligibility_assignments, with: EligibilityAssignmentEntity - expose :expanded_eligibility_assignments, with: EligibilityMemberAssignmentEntity + expose :payment_account, with: MemberDetailPaymentAccountEntity + expose_related :charges, with: ChargeEntity + expose_related :eligibility_assignments, with: EligibilityAssignmentEntity + expose_related :expanded_eligibility_assignments, with: EligibilityMemberAssignmentEntity expose :referral, with: ReferralEntity - expose :reset_codes, with: MemberResetCodeEntity - expose :sessions, with: MemberSessionEntity - expose :orders, with: MemberOrderEntity - expose :payment_instruments, with: PaymentInstrumentEntity - expose :message_deliveries, with: MessageDeliveryEntity - expose :combined_notes, as: :notes, with: SupportNoteEntity + expose_related :reset_codes, with: MemberResetCodeEntity + expose_related :sessions, with: MemberSessionEntity + expose_related :orders, with: MemberOrderEntity + expose_related :payment_instruments, with: PaymentInstrumentEntity + expose_related :message_deliveries, with: MessageDeliveryEntity + expose_related :combined_notes, as: :notes, with: SupportNoteEntity, inherit_permissions: true expose :preferences!, as: :preferences, with: PreferencesEntity - expose :anon_proxy_vendor_accounts, as: :vendor_accounts, with: MemberVendorAccountEntity - expose :anon_proxy_contacts, as: :member_contacts, with: MemberContactEntity - expose :organization_memberships, with: OrganizationMembershipEntity - expose :marketing_lists, with: MarketingListEntity - expose :marketing_sms_dispatches, with: MarketingSmsDispatchEntity - expose :mobility_trips, with: MobilityTripEntity + expose_related :anon_proxy_vendor_accounts, as: :vendor_accounts, with: MemberVendorAccountEntity + expose_related :anon_proxy_contacts, as: :member_contacts, with: AnonProxyMemberContactEntity + expose_related :organization_memberships, with: OrganizationMembershipEntity + expose_related :marketing_lists, with: MarketingListEntity + expose_related :marketing_sms_dispatches, with: MarketingSmsDispatchEntity + expose_related :mobility_trips, with: MobilityTripEntity end ALL_TIMEZONES = Set.new(TZInfo::Timezone.all_identifiers) diff --git a/lib/suma/admin_api/off_platform_transactions.rb b/lib/suma/admin_api/off_platform_transactions.rb index eeeb3e3e7..bc119d1fd 100644 --- a/lib/suma/admin_api/off_platform_transactions.rb +++ b/lib/suma/admin_api/off_platform_transactions.rb @@ -5,16 +5,17 @@ class Suma::AdminAPI::OffPlatformTransactions < Suma::AdminAPI::V1 include Suma::AdminAPI::Entities - class DetailedOffPlatformTransactionEntity < BaseEntity + class DetailedOffPlatformTransactionEntity < BaseModelEntity include Suma::AdminAPI::Entities - include AutoExposeDetail + include AutoExposeBase include AutoExposeDetail + model Suma::Payment::OffPlatformStrategy expose :funding_transaction, with: FundingTransactionEntity expose :payout_transaction, with: PayoutTransactionEntity - expose :transaction_admin_link, &self.delegate_to(:transaction, :admin_link) + expose :transaction_admin_link, &self.delegate_to(:transaction, :admin_link, safe: true) expose :type - expose :amount, with: MoneyEntity, &self.delegate_to(:transaction, :amount) + expose :amount, with: MoneyEntity, &self.delegate_to(:transaction, :amount, safe: true) expose :transacted_at expose :note expose :check_or_transaction_number diff --git a/lib/suma/admin_api/organization_membership_verifications.rb b/lib/suma/admin_api/organization_membership_verifications.rb index fe96063d3..46e3125f0 100644 --- a/lib/suma/admin_api/organization_membership_verifications.rb +++ b/lib/suma/admin_api/organization_membership_verifications.rb @@ -16,7 +16,7 @@ class VerificationListEntity < OrganizationMembershipVerificationEntity expose :available_events, &self.delegate_to(:state_machine, :available_events) expose :front_partner_conversation_status expose :front_member_conversation_status - expose :combined_notes, as: :notes, with: SupportNoteEntity + expose_related :combined_notes, as: :notes, with: SupportNoteEntity, all: true, inherit_permissions: true expose :duplicate_risk end @@ -30,7 +30,7 @@ class DetailedMembershipVerificationEntity < VerificationListEntity expose :address, with: AddressEntity, &self.delegate_to(:membership, :member, :legal_entity, :address, safe: true) expose :organization_name expose :organization_name_editable?, as: :organization_name_editable - expose :audit_logs, with: AuditLogEntity + expose_related :audit_logs, with: AuditLogEntity, inherit_permissions: true expose :partner_outreach_front_conversation_id expose :member_outreach_front_conversation_id expose :duplicates do |instance| diff --git a/lib/suma/admin_api/organization_memberships.rb b/lib/suma/admin_api/organization_memberships.rb index dab0336c2..13160f56e 100644 --- a/lib/suma/admin_api/organization_memberships.rb +++ b/lib/suma/admin_api/organization_memberships.rb @@ -12,7 +12,7 @@ class DetailedOrganizationMembershipEntity < OrganizationMembershipEntity expose :matched_organization, with: OrganizationEntity expose :verification, with: OrganizationMembershipVerificationEntity - expose :audit_activities, with: ActivityEntity + expose_related :audit_activities, with: ActivityEntity, inherit_permissions: true end resource :organization_memberships do diff --git a/lib/suma/admin_api/organization_registration_links.rb b/lib/suma/admin_api/organization_registration_links.rb index 33584a23b..4756f889a 100644 --- a/lib/suma/admin_api/organization_registration_links.rb +++ b/lib/suma/admin_api/organization_registration_links.rb @@ -20,7 +20,7 @@ class DetailedOrganizationRegistrationLinkEntity < OrganizationRegistrationLinkE expose :durable_url expose :durable_url_qr_code_data_url, as: :durable_url_qr_code expose :intro, with: TranslatedTextEntity - expose :memberships, with: OrganizationMembershipEntity + expose_related :memberships, with: OrganizationMembershipEntity expose :scheduled_availabilities, with: ScheduledAvailabilityEntity end diff --git a/lib/suma/admin_api/organizations.rb b/lib/suma/admin_api/organizations.rb index d5d7af968..dfcfc1e37 100644 --- a/lib/suma/admin_api/organizations.rb +++ b/lib/suma/admin_api/organizations.rb @@ -14,11 +14,11 @@ class DetailedOrganizationEntity < OrganizationEntity expose :membership_verification_email expose :membership_verification_front_template_id expose :membership_verification_member_outreach_template, with: TranslatedTextEntity - expose :audit_activities, with: ActivityEntity - expose :memberships, with: OrganizationMembershipEntity - expose :former_memberships, with: OrganizationMembershipEntity - expose :eligibility_assignments, with: EligibilityAssignmentEntity - expose :roles, with: RoleEntity + expose_related :audit_activities, with: ActivityEntity, inherit_permissions: true + expose_related :memberships, with: OrganizationMembershipEntity + expose_related :former_memberships, with: OrganizationMembershipEntity + expose_related :eligibility_assignments, with: EligibilityAssignmentEntity + expose_related :roles, with: RoleEntity end resource :organizations do @@ -45,6 +45,7 @@ class DetailedOrganizationEntity < OrganizationEntity optional :membership_verification_email, type: String, allow_blank: true optional :membership_verification_front_template_id, type: String, allow_blank: true optional(:membership_verification_member_outreach_template, type: JSON) { use :translated_text, optional: true } + optional(:roles, type: Array[JSON]) { use :model_with_id } end end @@ -71,9 +72,7 @@ class DetailedOrganizationEntity < OrganizationEntity optional :membership_verification_email, type: String, allow_blank: true optional :membership_verification_front_template_id, type: String, allow_blank: true optional(:membership_verification_member_outreach_template, type: JSON) { use :translated_text, optional: true } - optional :roles, type: Array[JSON] do - use :model_with_id - end + optional(:roles, type: Array[JSON]) { use :model_with_id } end end end diff --git a/lib/suma/admin_api/payment_accounts.rb b/lib/suma/admin_api/payment_accounts.rb new file mode 100644 index 000000000..12d473a31 --- /dev/null +++ b/lib/suma/admin_api/payment_accounts.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "suma/admin_api" + +class Suma::AdminAPI::PaymentAccounts < Suma::AdminAPI::V1 + include Suma::AdminAPI::Entities + + class PaymentAccountEntity < SimplePaymentAccountEntity + include Suma::AdminAPI::Entities + + expose :member, with: MemberEntity + expose :vendor, with: VendorEntity + expose :is_platform_account + end + + class LedgerEntity < SimpleLedgerEntity + include Suma::AdminAPI::Entities + + expose :balance, with: MoneyEntity + end + + class DetailedPaymentAccountEntity < PaymentAccountEntity + include Suma::AdminAPI::Entities + include AutoExposeDetail + + expose_related :ledgers, with: LedgerEntity + expose :total_balance, with: MoneyEntity + expose_related :ledgers, with: LedgerEntity + expose_related :originated_funding_transactions, with: FundingTransactionEntity + expose_related :originated_payout_transactions, with: PayoutTransactionEntity + expose_related :all_book_transactions, with: BookTransactionEntity + end + + resource :payment_accounts do + Suma::AdminAPI::CommonEndpoints.get_one( + self, + Suma::Payment::Account, + DetailedPaymentAccountEntity, + ) + end +end diff --git a/lib/suma/admin_api/payment_ledgers.rb b/lib/suma/admin_api/payment_ledgers.rb index 04a7da007..2a08f33c1 100644 --- a/lib/suma/admin_api/payment_ledgers.rb +++ b/lib/suma/admin_api/payment_ledgers.rb @@ -7,13 +7,10 @@ class Suma::AdminAPI::PaymentLedgers < Suma::AdminAPI::V1 include Suma::AdminAPI::Entities - class LedgerEntity < BaseEntity + class LedgerEntity < SimpleLedgerEntity include Suma::AdminAPI::Entities - include AutoExposeBase - expose :name expose :is_platform_account, &self.delegate_to(:account, :is_platform_account) - expose :currency expose :balance, with: MoneyEntity expose :member, with: MemberEntity, &self.delegate_to(:account, :member) end @@ -28,8 +25,8 @@ class UnbalancedCounterpartyEntity < BaseEntity class DetailedLedgerEntity < LedgerEntity include AutoExposeDetail - expose :vendor_service_categories, with: VendorServiceCategoryEntity - expose :combined_book_transactions, with: BookTransactionEntity + expose_related :vendor_service_categories, with: VendorServiceCategoryEntity + expose_related :combined_book_transactions, with: BookTransactionEntity expose :find_unbalanced_counterparty_ledgers, as: :unbalanced_counterparties, with: UnbalancedCounterpartyEntity end diff --git a/lib/suma/admin_api/payment_triggers.rb b/lib/suma/admin_api/payment_triggers.rb index 0af74f63f..baf8dd51a 100644 --- a/lib/suma/admin_api/payment_triggers.rb +++ b/lib/suma/admin_api/payment_triggers.rb @@ -7,11 +7,11 @@ class Suma::AdminAPI::PaymentTriggers < Suma::AdminAPI::V1 include Suma::AdminAPI::Entities - class PaymentTriggerExecutionEntity < BaseEntity + class PaymentTriggerExecutionEntity < BaseModelEntity include Suma::AdminAPI::Entities + include AutoExposeBase - expose :id - expose :admin_link, &self.delegate_to(:book_transaction, :admin_link) + model Suma::Payment::Trigger::Execution expose :book_transaction_id expose :at, &self.delegate_to(:book_transaction, :created_at) expose :receiving_ledger, with: SimpleLedgerEntity, &self.delegate_to(:book_transaction, :receiving_ledger) @@ -21,7 +21,7 @@ class DetailedPaymentTriggerEntity < PaymentTriggerEntity include Suma::AdminAPI::Entities include AutoExposeDetail - expose :audit_activities, with: ActivityEntity + expose_related :audit_activities, with: ActivityEntity, inherit_permissions: true expose :match_multiplier, &self.delegate_to(:match_multiplier, :to_f) expose :match_fraction, &self.delegate_to(:match_fraction, :to_f) expose :payer_fraction, &self.delegate_to(:payer_fraction, :to_f) @@ -32,8 +32,8 @@ class DetailedPaymentTriggerEntity < PaymentTriggerEntity expose :originating_ledger, with: SimpleLedgerEntity expose :receiving_ledger_name expose :receiving_ledger_contribution_text, with: TranslatedTextEntity - expose :executions, with: PaymentTriggerExecutionEntity - expose :eligibility_requirements, with: EligibilityRequirementEntity + expose_related :executions, with: PaymentTriggerExecutionEntity, inherit_permissions: true + expose_related :eligibility_requirements, with: EligibilityRequirementEntity end resource :payment_triggers do diff --git a/lib/suma/admin_api/payout_transactions.rb b/lib/suma/admin_api/payout_transactions.rb index 2c8c9118b..42b96746b 100644 --- a/lib/suma/admin_api/payout_transactions.rb +++ b/lib/suma/admin_api/payout_transactions.rb @@ -7,6 +7,10 @@ class Suma::AdminAPI::PayoutTransactions < Suma::AdminAPI::V1 include Suma::AdminAPI::Entities + class PayoutAuditLogEntity < AuditLogEntity + model Suma::Payment::PayoutTransaction::AuditLog + end + class DetailedPayoutTransactionEntity < PayoutTransactionEntity include Suma::AdminAPI::Entities include AutoExposeDetail @@ -17,8 +21,8 @@ class DetailedPayoutTransactionEntity < PayoutTransactionEntity expose :originated_book_transaction, with: BookTransactionEntity expose :reversal_book_transaction, with: BookTransactionEntity expose :refunded_funding_transaction, with: FundingTransactionEntity - expose :audit_activities, with: ActivityEntity - expose :audit_logs, with: AuditLogEntity + expose_related :audit_activities, with: ActivityEntity, inherit_permissions: true + expose_related :audit_logs, with: PayoutAuditLogEntity, inherit_permissions: true expose :strategy, with: PaymentStrategyEntity end diff --git a/lib/suma/admin_api/programs.rb b/lib/suma/admin_api/programs.rb index 8c842ee72..cc964895b 100644 --- a/lib/suma/admin_api/programs.rb +++ b/lib/suma/admin_api/programs.rb @@ -11,11 +11,11 @@ class DetailedProgramEntity < ProgramEntity expose_image :image expose :lyft_pass_program_id - expose :commerce_offerings, with: OfferingEntity - expose :pricings, with: ProgramPricingEntity - expose :anon_proxy_vendor_configurations, as: :configurations, with: AnonProxyVendorConfigurationEntity - expose :eligibility_requirements, with: EligibilityRequirementEntity - expose :audit_activities, with: ActivityEntity + expose_related :commerce_offerings, with: OfferingEntity + expose_related :pricings, with: ProgramPricingEntity + expose_related :anon_proxy_vendor_configurations, as: :configurations, with: AnonProxyVendorConfigurationEntity + expose_related :eligibility_requirements, with: EligibilityRequirementEntity + expose_related :audit_activities, with: ActivityEntity, inherit_permissions: true end resource :programs do diff --git a/lib/suma/admin_api/roles.rb b/lib/suma/admin_api/roles.rb index 29c230cdf..d694413e3 100644 --- a/lib/suma/admin_api/roles.rb +++ b/lib/suma/admin_api/roles.rb @@ -18,9 +18,9 @@ class DetailedRoleEntity < RoleEntity include AutoExposeDetail expose :description - expose :members, with: Suma::AdminAPI::Entities::MemberEntity - expose :organizations, with: Suma::AdminAPI::Entities::OrganizationEntity - expose :eligibility_assignments, with: EligibilityAssignmentEntity + expose_related :members, with: Suma::AdminAPI::Entities::MemberEntity + expose_related :organizations, with: Suma::AdminAPI::Entities::OrganizationEntity + expose_related :eligibility_assignments, with: EligibilityAssignmentEntity end resource :roles do diff --git a/lib/suma/admin_api/vendor_service_categories.rb b/lib/suma/admin_api/vendor_service_categories.rb index 1e2f5df6f..113d63e63 100644 --- a/lib/suma/admin_api/vendor_service_categories.rb +++ b/lib/suma/admin_api/vendor_service_categories.rb @@ -16,7 +16,7 @@ class DetailedVendorServiceCategoryEntity < VendorServiceCategoryEntity include AutoExposeDetail expose :parent, with: VendorServiceCategoryEntity - expose :children, with: VendorServiceCategoryEntity + expose_related :children, with: VendorServiceCategoryEntity end resource :vendor_service_categories do diff --git a/lib/suma/admin_api/vendor_service_rates.rb b/lib/suma/admin_api/vendor_service_rates.rb index f51388c8f..25ced1efc 100644 --- a/lib/suma/admin_api/vendor_service_rates.rb +++ b/lib/suma/admin_api/vendor_service_rates.rb @@ -9,10 +9,8 @@ class DetailedVendorServiceRateEntity < VendorServiceRateEntity include Suma::AdminAPI::Entities include AutoExposeDetail - expose :unit_offset - expose :ordinal - expose :undiscounted_rate, with: VendorServiceRateEntity - expose :program_pricings, with: ProgramPricingEntity + expose_related :discounted_rates, with: VendorServiceRateEntity + expose_related :program_pricings, with: ProgramPricingEntity end resource :vendor_service_rates do diff --git a/lib/suma/admin_api/vendor_services.rb b/lib/suma/admin_api/vendor_services.rb index a9ae58bc2..1cad6dcfb 100644 --- a/lib/suma/admin_api/vendor_services.rb +++ b/lib/suma/admin_api/vendor_services.rb @@ -9,9 +9,9 @@ class DetailedVendorServiceEntity < VendorServiceEntity include Suma::AdminAPI::Entities include AutoExposeDetail - expose :audit_activities, with: ActivityEntity - expose :vendor_service_categories, as: :categories, with: VendorServiceCategoryEntity - expose :program_pricings, with: ProgramPricingEntity + expose_related :audit_activities, with: ActivityEntity, inherit_permissions: true + expose_related :categories, with: VendorServiceCategoryEntity + expose_related :program_pricings, with: ProgramPricingEntity expose_image :image expose :constraints diff --git a/lib/suma/admin_api/vendors.rb b/lib/suma/admin_api/vendors.rb index 105eb4ab7..79c096c81 100644 --- a/lib/suma/admin_api/vendors.rb +++ b/lib/suma/admin_api/vendors.rb @@ -11,9 +11,9 @@ class DetailedVendorEntity < VendorEntity include AutoExposeDetail expose :slug - expose :services, with: VendorServiceEntity - expose :products, with: ProductEntity - expose :configurations, with: AnonProxyVendorConfigurationEntity + expose_related :services, with: VendorServiceEntity + expose_related :products, with: ProductEntity + expose_related :configurations, with: AnonProxyVendorConfigurationEntity expose_image :image end diff --git a/lib/suma/apps.rb b/lib/suma/apps.rb index 1a552a3ea..5b95eb692 100644 --- a/lib/suma/apps.rb +++ b/lib/suma/apps.rb @@ -64,6 +64,7 @@ require "suma/admin_api/organization_memberships" require "suma/admin_api/organization_registration_links" require "suma/admin_api/off_platform_transactions" +require "suma/admin_api/payment_accounts" require "suma/admin_api/payment_ledgers" require "suma/admin_api/payment_triggers" require "suma/admin_api/payout_transactions" @@ -140,6 +141,7 @@ class AdminAPI < Suma::Service mount Suma::AdminAPI::OrganizationMembershipVerifications mount Suma::AdminAPI::OrganizationRegistrationLinks mount Suma::AdminAPI::OffPlatformTransactions + mount Suma::AdminAPI::PaymentAccounts mount Suma::AdminAPI::PaymentLedgers mount Suma::AdminAPI::PaymentTriggers mount Suma::AdminAPI::PayoutTransactions diff --git a/lib/suma/commerce/order.rb b/lib/suma/commerce/order.rb index e7ada5843..dc8d34d43 100644 --- a/lib/suma/commerce/order.rb +++ b/lib/suma/commerce/order.rb @@ -125,6 +125,8 @@ def member = self.checkout.cart.member delegate :undiscounted_cost, :customer_cost, :savings, :handling, :taxable_cost, :tax, :total, to: :checkout + def checkout_items_dataset = self.checkout.items_dataset + def after_open_order_canceled return if self.fulfillment_status == "fulfilled" self.items_and_product_inventories.each do |ci, inv| diff --git a/lib/suma/eligibility/requirement.rb b/lib/suma/eligibility/requirement.rb index 08e0da45a..1058c4d1d 100644 --- a/lib/suma/eligibility/requirement.rb +++ b/lib/suma/eligibility/requirement.rb @@ -29,6 +29,13 @@ class Suma::Eligibility::Requirement < Suma::Postgres::Model(:eligibility_requir def all_resources = self.programs + self.payment_triggers + def each_resource(&) + [:programs, :payment_triggers].each do |assoc| + iter = self.associations[assoc] || self.send("#{assoc}_dataset") + iter.each(&) + end + end + # Replace an expression with a serialized version (usually from an endpoint). # If the serialized version is the same as the current serialized expression, noop. # Otherwise, delete the current expression (and all children) diff --git a/lib/suma/payment/platform_status.rb b/lib/suma/payment/platform_status.rb index 973fdc048..91c3efb41 100644 --- a/lib/suma/payment/platform_status.rb +++ b/lib/suma/payment/platform_status.rb @@ -15,14 +15,9 @@ class Suma::Payment::PlatformStatus attr_accessor :assets # Ledgers belonging to the platform account. - def platform_ledgers - @platform_ledgers ||= Suma::Payment::Account.lookup_platform_account.ledgers.sort_by(&:name) - end - # Unbalanced ledgers. These do not belong to the platform account, - # since unbalanced member ledgers always mean unbalanced platform ledgers. - attr_accessor :unbalanced_ledgers + def platform_ledgers_dataset = Suma::Payment::Account.lookup_platform_account.ledgers_dataset.order(:name) - attr_accessor :off_platform_funding_transactions, :off_platform_payout_transactions + attr_accessor :off_platform_funding_transactions_dataset, :off_platform_payout_transactions_dataset def calculate funding_ds = Suma::Payment::FundingTransaction.dataset @@ -32,11 +27,11 @@ def calculate self.funding, self.funding_count = sumcnt(funding_ds) self.funding -= self.refunds self.funding_count -= self.refund_count - self.member_liabilities = self.platform_ledgers.sum(&:balance) * -1 + liability_cents = Suma::Payment::Ledger::Balance.where(ledger: self.platform_ledgers_dataset).sum(:balance_cents) + self.member_liabilities = Money.new((liability_cents || 0) * -1, Suma.default_currency) self.assets = self.funding - self.payouts - self.unbalanced_ledgers = self.find_unbalanced_ledgers_ds.all - self.off_platform_funding_transactions = offplatform_ds(funding_ds).all - self.off_platform_payout_transactions = offplatform_ds(payout_ds).all + self.off_platform_funding_transactions_dataset = offplatform_ds(funding_ds) + self.off_platform_payout_transactions_dataset = offplatform_ds(payout_ds) return self end @@ -77,10 +72,22 @@ def calculate unbalanced_ids = db. from(summed). exclude(total: 0). - exclude(ledger_id: self.platform_ledgers.map(&:id)). + exclude(ledger_id: self.platform_ledgers_dataset.select(:id)). select_map(:ledger_id) return Suma::Payment::Ledger.order(:account_id, :name, :id).where(id: unbalanced_ids) end + # Unbalanced ledgers. These do not belong to the platform account, + # since unbalanced member ledgers always mean unbalanced platform ledgers. def unbalanced_ledgers_dataset = self.find_unbalanced_ledgers_ds + + # Semantics for easier API usage. + class Calculated < self + def self.[](_) = self.new + + def initialize + super + self.calculate + end + end end diff --git a/lib/suma/service.rb b/lib/suma/service.rb index f8372dcd1..4cbf89183 100644 --- a/lib/suma/service.rb +++ b/lib/suma/service.rb @@ -54,6 +54,8 @@ class Suma::Service < Grape::API setting :endpoint_caching, false + setting :related_list_size, 5 + setting :verify_localized_error_codes, false setting :swagger_enabled, ENV["RACK_ENV"] == "development" @@ -103,6 +105,10 @@ def self.encode_cookie(h) return s end + def self.request_path(env, path_info=nil) + return env["SCRIPT_NAME"].to_s + (path_info || env["PATH_INFO"]).to_s + end + # Build the Rack app according to the configured environment. # Call build_app_pre and build_app_post with the Rack::Builder, # in case the app needs to run additional middleware. diff --git a/lib/suma/service/collection.rb b/lib/suma/service/collection.rb index d7ed89fff..ed57d60ac 100644 --- a/lib/suma/service/collection.rb +++ b/lib/suma/service/collection.rb @@ -13,6 +13,9 @@ class Suma::Service::Collection attr_reader :current_page, :items, :page_count, :total_count, :last_page + # Url for the collection. Use the current URL (PATH_INFO) if nil. + attr_accessor :url + class BaseEntity < Suma::Service::Entities::Base expose :object do |_| "list" @@ -21,6 +24,9 @@ class BaseEntity < Suma::Service::Entities::Base expose :page_count expose :total_count expose :more?, as: :has_more + expose :url do |inst, opts| + inst.url || Suma::Service.request_path(opts[:env]) + end # expose :items do |_| # raise "this must be exposed by the subclass, like: `expose :items, with: MyEntity`" # end @@ -43,6 +49,25 @@ def self.from_array(array) return self.new(array, current_page: 1, page_count: 1, total_count: array.size, last_page: true) end + # Given the entity for an item in the collection, + # return the collection entity using that subentity for each item. + def self.prepare_entity(item_entity) + # We can't use is_a? here, Grape entity is weird. + if item_entity&.ancestors&.include?(Suma::Service::Collection::BaseEntity) + collection_entity = item_entity + else + collection_entity = Suma::Service::Collection.collection_entity_cache[item_entity] + if collection_entity.nil? + collection_entity = Class.new(Suma::Service::Collection::BaseEntity) do + def self.name = "Suma::Service::Collection::Entity" + expose :items, using: item_entity + end + Suma::Service::Collection.collection_entity_cache[item_entity] = collection_entity + end + end + return collection_entity + end + def initialize(items, current_page:, page_count:, total_count:, last_page:) @items = items @current_page = current_page @@ -57,18 +82,7 @@ def more? = !@last_page module Helpers def present_collection(collection, opts={}) passed_entity = opts.delete(:with) || opts.delete(:using) - # We can't use is_a? here, Grape entity is weird. - if passed_entity&.ancestors&.include?(Suma::Service::Collection::BaseEntity) - collection_entity = passed_entity - else - collection_entity = Suma::Service::Collection.collection_entity_cache[passed_entity] - if collection_entity.nil? - collection_entity = Class.new(Suma::Service::Collection::BaseEntity) do - expose :items, using: passed_entity - end - Suma::Service::Collection.collection_entity_cache[passed_entity] = collection_entity - end - end + collection_entity = Suma::Service::Collection.prepare_entity(passed_entity) opts[:with] = collection_entity wrapped = diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 18fa8dde3..6cb51f554 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -79,5 +79,7 @@ config.include(Suma::SpecHelpers::Postgres) require "suma/spec_helpers/service" config.include(Suma::SpecHelpers::Service) + # Not widely needed so include only as needed + require "suma/spec_helpers/sentry" end end diff --git a/spec/suma/admin_api/anon_proxy_member_contacts_spec.rb b/spec/suma/admin_api/anon_proxy_member_contacts_spec.rb index 1aa9a8122..0268c76e1 100644 --- a/spec/suma/admin_api/anon_proxy_member_contacts_spec.rb +++ b/spec/suma/admin_api/anon_proxy_member_contacts_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/anon_proxy_member_contacts/#{Suma::Fixtures.anon_proxy_member_contact.create.id}" + end + end + describe "GET /v1/anon_proxy_member_contacts" do it "returns all anon proxy vendor accounts" do objs = Array.new(2) { Suma::Fixtures.anon_proxy_member_contact.create } diff --git a/spec/suma/admin_api/anon_proxy_vendor_accounts_spec.rb b/spec/suma/admin_api/anon_proxy_vendor_accounts_spec.rb index 58a1b64b4..f0f742ce7 100644 --- a/spec/suma/admin_api/anon_proxy_vendor_accounts_spec.rb +++ b/spec/suma/admin_api/anon_proxy_vendor_accounts_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/anon_proxy_vendor_accounts/#{Suma::Fixtures.anon_proxy_vendor_account.create.id}" + end + end + describe "GET /v1/anon_proxy_vendor_accounts" do it "returns all anon proxy vendor accounts" do objs = Array.new(2) { Suma::Fixtures.anon_proxy_vendor_account.create } diff --git a/spec/suma/admin_api/anon_proxy_vendor_configurations_spec.rb b/spec/suma/admin_api/anon_proxy_vendor_configurations_spec.rb index 5ea3030e6..4a5f3d680 100644 --- a/spec/suma/admin_api/anon_proxy_vendor_configurations_spec.rb +++ b/spec/suma/admin_api/anon_proxy_vendor_configurations_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/anon_proxy_vendor_configurations/#{Suma::Fixtures.anon_proxy_vendor_configuration.create.id}" + end + end + describe "GET /v1/anon_proxy_vendor_configurations" do it "returns all anon proxy vendor configurations" do objs = Array.new(2) { Suma::Fixtures.anon_proxy_vendor_configuration.create } @@ -68,7 +74,7 @@ def make_non_matching_items expect(last_response).to have_status(200) expect(last_response).to have_json_body.that_includes(id: config.id) expect(last_response).to have_json_body. - that_includes(programs: have_same_ids_as(to_add)) + that_includes(programs: include(items: have_same_ids_as(to_add))) end it "403s if the constraint does not exist" do diff --git a/spec/suma/admin_api/bank_accounts_spec.rb b/spec/suma/admin_api/bank_accounts_spec.rb index 5b610771f..0a3c9df08 100644 --- a/spec/suma/admin_api/bank_accounts_spec.rb +++ b/spec/suma/admin_api/bank_accounts_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/bank_accounts/#{Suma::Fixtures.bank_account.create.id}" + end + end + describe "GET /v1/bank_accounts" do it "returns all bank_accounts" do c = Array.new(2) { Suma::Fixtures.bank_account.create } diff --git a/spec/suma/admin_api/book_transactions_spec.rb b/spec/suma/admin_api/book_transactions_spec.rb index 871a65a7b..d143b4fda 100644 --- a/spec/suma/admin_api/book_transactions_spec.rb +++ b/spec/suma/admin_api/book_transactions_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/book_transactions/#{Suma::Fixtures.book_transaction.create.id}" + end + end + describe "GET /v1/book_transactions" do it "returns all transactions" do u = Array.new(2) { Suma::Fixtures.book_transaction.create } diff --git a/spec/suma/admin_api/cards_spec.rb b/spec/suma/admin_api/cards_spec.rb index d5293631f..121bfed2d 100644 --- a/spec/suma/admin_api/cards_spec.rb +++ b/spec/suma/admin_api/cards_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/cards/#{Suma::Fixtures.card.create.id}" + end + end + describe "GET /v1/cards" do it "returns all cards" do c = Array.new(2) { Suma::Fixtures.card.create } diff --git a/spec/suma/admin_api/charges_spec.rb b/spec/suma/admin_api/charges_spec.rb index 3d7cc775d..5cc2e6c88 100644 --- a/spec/suma/admin_api/charges_spec.rb +++ b/spec/suma/admin_api/charges_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/charges/#{Suma::Fixtures.charge.create.id}" + end + end + describe "GET /v1/charges" do it "returns all charges" do c = Array.new(2) { Suma::Fixtures.charge.create } diff --git a/spec/suma/admin_api/commerce_offering_products_spec.rb b/spec/suma/admin_api/commerce_offering_products_spec.rb index 8bf469287..0807b05b8 100644 --- a/spec/suma/admin_api/commerce_offering_products_spec.rb +++ b/spec/suma/admin_api/commerce_offering_products_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/commerce_offering_products/#{Suma::Fixtures.offering_product.create.id}" + end + end + describe "POST /v1/commerce_offering_products/create" do it "creates the offering product" do o = Suma::Fixtures.offering.create diff --git a/spec/suma/admin_api/commerce_offerings_spec.rb b/spec/suma/admin_api/commerce_offerings_spec.rb index 03c53f986..bde590cbc 100644 --- a/spec/suma/admin_api/commerce_offerings_spec.rb +++ b/spec/suma/admin_api/commerce_offerings_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/commerce_offerings/#{Suma::Fixtures.offering.create.id}" + end + end + describe "GET /v1/commerce_offerings" do it "returns all offerings" do objs = Array.new(2) { Suma::Fixtures.offering.create } @@ -134,8 +140,8 @@ def make_item(_i) expect(last_response).to have_status(200) expect(last_response).to have_json_body.that_includes( id: o.id, - orders: have_length(1), - offering_products: have_length(1), + orders: include(items: have_length(1)), + offering_products: include(items: have_length(1)), ) end @@ -277,7 +283,7 @@ def make_item(_i) expect(last_response).to have_status(200) expect(last_response).to have_json_body. - that_includes(programs: have_same_ids_as(new_program)) + that_includes(programs: include(items: have_same_ids_as(new_program))) end it "403s if program does not exist" do diff --git a/spec/suma/admin_api/commerce_orders_spec.rb b/spec/suma/admin_api/commerce_orders_spec.rb index b0ad67a73..2b88e2024 100644 --- a/spec/suma/admin_api/commerce_orders_spec.rb +++ b/spec/suma/admin_api/commerce_orders_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/commerce_orders/#{Suma::Fixtures.order.create.id}" + end + end + describe "GET /v1/commerce_orders" do it "returns all orders" do objs = Array.new(2) { Suma::Fixtures.order.create } @@ -70,7 +76,10 @@ def make_non_matching_items expect(last_response).to have_status(200) expect(last_response).to have_json_body.that_includes( id: o.id, - items: have_length(1), + checkout: include(items: include( + url: "/v1/commerce_orders/#{o.id}/items", + items: have_length(1), + )), ) end diff --git a/spec/suma/admin_api/commerce_products_spec.rb b/spec/suma/admin_api/commerce_products_spec.rb index 50b1363bd..ab92d57fd 100644 --- a/spec/suma/admin_api/commerce_products_spec.rb +++ b/spec/suma/admin_api/commerce_products_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/commerce_products/#{Suma::Fixtures.product.create.id}" + end + end + describe "GET /v1/commerce_products" do it "returns all products" do objs = Array.new(2) { Suma::Fixtures.product.create } diff --git a/spec/suma/admin_api/common_endpoints_spec.rb b/spec/suma/admin_api/common_endpoints_spec.rb index 2c25aef2b..341a85ecc 100644 --- a/spec/suma/admin_api/common_endpoints_spec.rb +++ b/spec/suma/admin_api/common_endpoints_spec.rb @@ -143,8 +143,8 @@ def make_item(_i) expect(last_response).to have_status(200) expect(last_response).to have_json_body.that_includes( id: v.id, - services: have_same_ids_as(service), - products: have_same_ids_as(*product_objs), + services: include(items: have_same_ids_as(service)), + products: include(items: have_same_ids_as(*product_objs)), ) end @@ -172,6 +172,18 @@ def make_item(_i) expect(last_response).to have_status(403) expect(last_response).to have_json_body.that_includes(error: include(code: "role_check")) end + + it "automatically registers exposed related paths" do + v = Suma::Fixtures.vendor.create + service = Suma::Fixtures.vendor_service.create(vendor: v) + + get "/v1/vendors/#{v.id}/services" + + expect(last_response).to have_status(200) + expect(last_response).to have_json_body.that_includes( + items: have_same_ids_as(service), + ) + end end describe "update" do diff --git a/spec/suma/admin_api/eligibility_assignments_spec.rb b/spec/suma/admin_api/eligibility_assignments_spec.rb index ef60fe6dc..accf17b5a 100644 --- a/spec/suma/admin_api/eligibility_assignments_spec.rb +++ b/spec/suma/admin_api/eligibility_assignments_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/eligibility_assignments/#{Suma::Fixtures.eligibility_assignment.create.id}" + end + end + describe "GET /v1/eligibility_assignments" do it "returns all instances" do objs = Array.new(2) { Suma::Fixtures.eligibility_assignment.to(Suma::Fixtures.member.create).create } diff --git a/spec/suma/admin_api/eligibility_attributes_spec.rb b/spec/suma/admin_api/eligibility_attributes_spec.rb index 4b60142bd..5b269cb80 100644 --- a/spec/suma/admin_api/eligibility_attributes_spec.rb +++ b/spec/suma/admin_api/eligibility_attributes_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/eligibility_attributes/#{Suma::Fixtures.eligibility_attribute.create.id}" + end + end + describe "GET /v1/eligibility_attributes" do it "returns all rows" do objs = Array.new(2) { Suma::Fixtures.eligibility_attribute.create } diff --git a/spec/suma/admin_api/eligibility_requirements_spec.rb b/spec/suma/admin_api/eligibility_requirements_spec.rb index 515e159e1..70fe9e3ae 100644 --- a/spec/suma/admin_api/eligibility_requirements_spec.rb +++ b/spec/suma/admin_api/eligibility_requirements_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/eligibility_requirements/#{Suma::Fixtures.eligibility_requirement.create.id}" + end + end + describe "GET /v1/eligibility_requirements" do it "returns all instances" do objs = Array.new(2) { Suma::Fixtures.eligibility_requirement.create } @@ -68,7 +74,7 @@ def make_non_matching_items expect(last_response).to have_status(200) expect(last_response).to have_json_body.that_includes( - id: requirement.id, resources: [], + id: requirement.id, programs: include(:items), ) end diff --git a/spec/suma/admin_api/financials_spec.rb b/spec/suma/admin_api/financials_spec.rb index 6bd95bb8a..77ea84c9b 100644 --- a/spec/suma/admin_api/financials_spec.rb +++ b/spec/suma/admin_api/financials_spec.rb @@ -28,4 +28,14 @@ that_includes(:platform_ledgers, :funding) end end + + it "has related children" do + pa = Suma::Payment::Account.lookup_platform_account + cash = Suma::Payment.ensure_cash_ledger(pa) + + get "/v1/financials/platform_status/platform_ledgers" + + expect(last_response).to have_status(200) + expect(last_response).to have_json_body.that_includes(items: have_same_ids_as(cash)) + end end diff --git a/spec/suma/admin_api/funding_transactions_spec.rb b/spec/suma/admin_api/funding_transactions_spec.rb index c7505433a..baabc83bb 100644 --- a/spec/suma/admin_api/funding_transactions_spec.rb +++ b/spec/suma/admin_api/funding_transactions_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/funding_transactions/#{Suma::Fixtures.funding_transaction.with_fake_strategy.create.id}" + end + end + describe "GET /v1/funding_transactions" do it "returns all transactions" do u = Array.new(2) { Suma::Fixtures.funding_transaction.with_fake_strategy.create } diff --git a/spec/suma/admin_api/marketing_lists_spec.rb b/spec/suma/admin_api/marketing_lists_spec.rb index b630693f7..59283c0cf 100644 --- a/spec/suma/admin_api/marketing_lists_spec.rb +++ b/spec/suma/admin_api/marketing_lists_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/marketing_lists/#{Suma::Fixtures.marketing_list.create.id}" + end + end + describe "GET /v1/marketing_lists" do it "returns all objects" do u = Array.new(2) { Suma::Fixtures.marketing_list.create } diff --git a/spec/suma/admin_api/marketing_sms_broadcasts_spec.rb b/spec/suma/admin_api/marketing_sms_broadcasts_spec.rb index 99c40b221..57e40365a 100644 --- a/spec/suma/admin_api/marketing_sms_broadcasts_spec.rb +++ b/spec/suma/admin_api/marketing_sms_broadcasts_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/marketing_sms_broadcasts/#{Suma::Fixtures.marketing_sms_broadcast.create.id}" + end + end + describe "GET /v1/marketing_sms_broadcasts" do it "returns all objects" do u = Array.new(2) { Suma::Fixtures.marketing_sms_broadcast.create } diff --git a/spec/suma/admin_api/marketing_sms_dispatches_spec.rb b/spec/suma/admin_api/marketing_sms_dispatches_spec.rb index 19b53e50a..056c4a6a5 100644 --- a/spec/suma/admin_api/marketing_sms_dispatches_spec.rb +++ b/spec/suma/admin_api/marketing_sms_dispatches_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/marketing_sms_dispatches/#{Suma::Fixtures.marketing_sms_dispatch.create.id}" + end + end + describe "GET /v1/marketing_sms_dispatches" do it "returns all objects" do u = Array.new(2) { Suma::Fixtures.marketing_sms_dispatch.create } diff --git a/spec/suma/admin_api/members_spec.rb b/spec/suma/admin_api/members_spec.rb index ac93927fb..c8a41c4cc 100644 --- a/spec/suma/admin_api/members_spec.rb +++ b/spec/suma/admin_api/members_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/members/#{Suma::Fixtures.member.create.id}" + end + end + describe "GET /v1/members" do it "returns all members" do u = Array.new(2) { Suma::Fixtures.member.create } @@ -92,6 +98,33 @@ def make_item(i) expect(last_response).to have_json_body.that_includes(:roles, id: admin.id) end + it "returns proper paths to related resources" do + m = Suma::Fixtures.member.create + acct = Suma::Payment.as_account(m) + led = Suma::Payment.ensure_cash_ledger(m) + + get "/v1/members/#{m.id}" + + expect(last_response).to have_status(200) + expect(last_response).to have_json_body.that_includes( + roles: include(url: "/v1/members/#{m.id}/roles"), + activities: include(url: "/v1/members/#{m.id}/activities"), + payment_instruments: include(url: "/v1/members/#{m.id}/payment_instruments"), + notes: include(url: "/v1/members/#{m.id}/combined_notes"), + eligibility_assignments: include(url: "/v1/members/#{m.id}/eligibility_assignments"), + expanded_eligibility_assignments: include(url: "/v1/members/#{m.id}/expanded_eligibility_assignments"), + ) + expect(last_response_json_body[:payment_account]).to include( + originated_funding_transactions: include( + url: "/v1/payment_accounts/#{acct.id}/originated_funding_transactions", + ), + ledgers: include(url: "/v1/payment_accounts/#{acct.id}/ledgers"), + ) + expect(last_response_json_body[:payment_account][:ledgers][:items].first).to include( + categories: include(url: "/v1/payment_ledgers/#{led.id}/vendor_service_categories"), + ) + end + it "403s if the member does not exist" do get "/v1/members/0" @@ -110,7 +143,7 @@ def make_item(i) expect(last_response).to have_status(200) expect(last_response).to have_json_body.that_includes( - sessions: contain_exactly(include(:ip_lookup_link)), + sessions: include(items: contain_exactly(include(:ip_lookup_link))), preferences: include(preferred_language_name: "Spanish"), ) end @@ -127,9 +160,7 @@ def make_item(i) expect(last_response).to have_status(200) expect(last_response).to have_json_body.that_includes( - reset_codes: contain_exactly( - include(token: rc.token), - ), + reset_codes: include(items: contain_exactly(include(token: rc.token))), ) end @@ -142,11 +173,8 @@ def make_item(i) get "/v1/members/#{rc.member.id}" expect(last_response).to have_status(200) - expect(last_response).to have_json_body.that_includes( - reset_codes: contain_exactly( - include(token: "******"), - ), - ) + expect(last_response).to have_json_body. + that_includes(reset_codes: include(items: contain_exactly(include(token: "******")))) end end end @@ -341,7 +369,9 @@ def make_item(i) expect(last_response).to have_status(200) expect(last_response).to have_json_body. - that_includes(notes: contain_exactly(include(content: "hello", author: include(id: admin.id)))) + that_includes( + notes: include(items: contain_exactly(include(content: "hello", author: include(id: admin.id)))), + ) end it "errors without role access" do @@ -364,7 +394,11 @@ def make_item(i) expect(last_response).to have_status(200) expect(last_response).to have_json_body. - that_includes(notes: contain_exactly(include(content: "hello", author: include(id: admin.id)))) + that_includes( + notes: include( + items: contain_exactly(include(content: "hello", author: include(id: admin.id))), + ), + ) end it "errors without role access" do diff --git a/spec/suma/admin_api/message_deliveries_spec.rb b/spec/suma/admin_api/message_deliveries_spec.rb index 31bfd8b80..de681f224 100644 --- a/spec/suma/admin_api/message_deliveries_spec.rb +++ b/spec/suma/admin_api/message_deliveries_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/message_deliveries/#{Suma::Fixtures.message_delivery.create.id}" + end + end + describe "GET /v1/message_deliveries" do it "returns all deliveries (no bodies)" do deliveries = Array.new(2) { Suma::Fixtures.message_delivery.create } diff --git a/spec/suma/admin_api/mobility_spec.rb b/spec/suma/admin_api/mobility_spec.rb deleted file mode 100644 index 98dc7eb26..000000000 --- a/spec/suma/admin_api/mobility_spec.rb +++ /dev/null @@ -1,110 +0,0 @@ -# frozen_string_literal: true - -require "suma/admin_api/mobility_trips" -require "suma/api/behaviors" - -RSpec.describe Suma::AdminAPI::MobilityTrips, :db do - include Rack::Test::Methods - - let(:app) { described_class.build_app } - let(:admin) { Suma::Fixtures.member.admin.create } - - before(:each) do - login_as(admin) - end - - describe "GET /v1/mobility_trips" do - it "returns all mobility trips" do - objs = Array.new(2) { Suma::Fixtures.mobility_trip.create } - - get "/v1/mobility_trips" - - expect(last_response).to have_status(200) - expect(last_response).to have_json_body. - that_includes(items: have_same_ids_as(*objs)) - end - - it_behaves_like "an endpoint capable of search" do - let(:url) { "/v1/mobility_trips" } - let(:search_term) { "zzz" } - - def make_matching_items - return [ - Suma::Fixtures.mobility_trip(external_trip_id: "zzz").create, - ] - end - - def make_non_matching_items - return [ - Suma::Fixtures.mobility_trip(external_trip_id: translated_text("wibble wobble")).create, - ] - end - end - - it_behaves_like "an endpoint with pagination" do - let(:url) { "/v1/mobility_trips" } - def make_item(i) - # Sorting is newest first, so the first items we create need to the the oldest. - created = Time.now - i.days - return Suma::Fixtures.mobility_trip.create(created_at: created) - end - end - - it_behaves_like "an endpoint with member-supplied ordering" do - let(:url) { "/v1/mobility_trips" } - let(:order_by_field) { "id" } - def make_item(_i) - return Suma::Fixtures.mobility_trip.create( - created_at: Time.now + rand(1..100).days, - ) - end - end - end - - describe "GET /v1/mobility_trips/:id" do - it "returns the mobility trip" do - rate = Suma::Fixtures.vendor_service_rate.create - service = Suma::Fixtures.vendor_service.mobility_deeplink.create - charge = Suma::Fixtures.charge(member: admin).create - trip = Suma::Fixtures.mobility_trip.create(vendor_service: service, vendor_service_rate: rate, member: admin) - trip.charge = charge - trip.save_changes - - get "/v1/mobility_trips/#{trip.refresh.id}" - - expect(last_response).to have_status(200) - expect(last_response).to have_json_body.that_includes( - id: trip.id, - vendor_service: include(id: service.id), - rate: include(id: rate.id), - member: include(id: admin.id), - charge: include(id: charge.id), - ) - end - - it "403s if the item does not exist" do - get "/v1/mobility_trips/0" - - expect(last_response).to have_status(403) - end - end - - describe "POST /v1/mobility_trips/:id" do - it "updates a mobility trip" do - trip = Suma::Fixtures.mobility_trip.create - - post "/v1/mobility_trips/#{trip.id}", - period_begin: "2024-07-01T00:00:00-0700", - period_end: "2024-07-02T00:00:00-0700", - begin_lat: 1, - begin_lng: 1, - end_lat: 2, - end_lng: 2, - began_at: "2024-07-01T00:00:00-0700", - ended_at: "2024-07-01T00:00:00-0700" - - expect(last_response).to have_status(200) - expect(trip.refresh).to have_attributes(begin_lat: 1, end_lat: 2) - end - end -end diff --git a/spec/suma/admin_api/mobility_trips_spec.rb b/spec/suma/admin_api/mobility_trips_spec.rb index 64c9722a2..40233ec88 100644 --- a/spec/suma/admin_api/mobility_trips_spec.rb +++ b/spec/suma/admin_api/mobility_trips_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/mobility_trips/#{Suma::Fixtures.mobility_trip.create.id}" + end + end + describe "GET /v1/mobility_trips" do it "returns all mobility trips" do objs = Array.new(2) { Suma::Fixtures.mobility_trip.create } @@ -95,6 +101,8 @@ def make_item(_i) trip = Suma::Fixtures.mobility_trip.create post "/v1/mobility_trips/#{trip.id}", + period_begin: "2024-07-01T00:00:00-0700", + period_end: "2024-07-02T00:00:00-0700", begin_lat: 1, begin_lng: 1, end_lat: 2, diff --git a/spec/suma/admin_api/off_platform_transactions_spec.rb b/spec/suma/admin_api/off_platform_transactions_spec.rb index 99b7bae27..46ad062a4 100644 --- a/spec/suma/admin_api/off_platform_transactions_spec.rb +++ b/spec/suma/admin_api/off_platform_transactions_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/off_platform_transactions/#{Suma::Fixtures.off_platform_payment_strategy.create.id}" + end + end + describe "POST /v1/off_platform_transactions/create" do it "can create and process an off-platform funding transaction", :i18n do post "/v1/off_platform_transactions/create", diff --git a/spec/suma/admin_api/organization_membership_verifications_spec.rb b/spec/suma/admin_api/organization_membership_verifications_spec.rb index a8210eb8f..a43923fb8 100644 --- a/spec/suma/admin_api/organization_membership_verifications_spec.rb +++ b/spec/suma/admin_api/organization_membership_verifications_spec.rb @@ -11,6 +11,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/organization_membership_verifications/#{Suma::Fixtures.organization_membership_verification.create.id}" + end + end + describe "GET /v1/organization_membership_verifications" do it "returns all organization memberships" do objs = Array.new(2) { Suma::Fixtures.organization_membership_verification.create } diff --git a/spec/suma/admin_api/organization_memberships_spec.rb b/spec/suma/admin_api/organization_memberships_spec.rb index 11abe243e..ab682c207 100644 --- a/spec/suma/admin_api/organization_memberships_spec.rb +++ b/spec/suma/admin_api/organization_memberships_spec.rb @@ -11,6 +11,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/organization_memberships/#{Suma::Fixtures.organization_membership.verified.create.id}" + end + end + describe "GET /v1/organization_memberships" do it "returns all organization memberships" do memberships = Array.new(2) { Suma::Fixtures.organization_membership.unverified.create } diff --git a/spec/suma/admin_api/organization_registration_links_spec.rb b/spec/suma/admin_api/organization_registration_links_spec.rb index 4abf53207..a832f9aac 100644 --- a/spec/suma/admin_api/organization_registration_links_spec.rb +++ b/spec/suma/admin_api/organization_registration_links_spec.rb @@ -11,6 +11,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/organization_registration_links/#{Suma::Fixtures.registration_link.create.id}" + end + end + describe "GET /v1/organization_registration_links" do it "returns all rows" do registration_links = Array.new(2) { Suma::Fixtures.registration_link.create } diff --git a/spec/suma/admin_api/organizations_spec.rb b/spec/suma/admin_api/organizations_spec.rb index c538da5e7..97159aa20 100644 --- a/spec/suma/admin_api/organizations_spec.rb +++ b/spec/suma/admin_api/organizations_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/organizations/#{Suma::Fixtures.organization.create.id}" + end + end + describe "GET /v1/organizations" do it "returns all organizations" do orgs = Array.new(2) { Suma::Fixtures.organization.create } @@ -79,7 +85,7 @@ def make_item(_i) expect(last_response).to have_status(200) expect(last_response).to have_json_body.that_includes( id: organization.id, - memberships: have_same_ids_as(membership), + memberships: include(items: have_same_ids_as(membership)), ) end diff --git a/spec/suma/admin_api/payment_accounts_spec.rb b/spec/suma/admin_api/payment_accounts_spec.rb new file mode 100644 index 000000000..a9e7d253c --- /dev/null +++ b/spec/suma/admin_api/payment_accounts_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "suma/admin_api/payment_accounts" +require "suma/api/behaviors" + +RSpec.describe Suma::AdminAPI::PaymentAccounts, :db do + include Rack::Test::Methods + + let(:app) { described_class.build_app } + let(:admin) { Suma::Fixtures.member.admin.create } + + before(:each) do + login_as(admin) + end + + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/payment_accounts/#{Suma::Fixtures.payment_account.create.id}" + end + end + + describe "GET /v1/payment_accounts/:id" do + it "returns the object" do + acct = Suma::Fixtures.payment_account.create + + get "/v1/payment_accounts/#{acct.id}" + + expect(last_response).to have_status(200) + expect(last_response).to have_json_body.that_includes(id: acct.id) + end + + it "403s if the item does not exist" do + get "/v1/payment_accounts/0" + + expect(last_response).to have_status(403) + end + end +end diff --git a/spec/suma/admin_api/payment_ledgers_spec.rb b/spec/suma/admin_api/payment_ledgers_spec.rb index 603b6aadf..906156320 100644 --- a/spec/suma/admin_api/payment_ledgers_spec.rb +++ b/spec/suma/admin_api/payment_ledgers_spec.rb @@ -14,6 +14,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/payment_ledgers/#{Suma::Fixtures.ledger.create.id}" + end + end + describe "GET /v1/payment_ledgers" do it "errors without role access" do replace_roles(admin, Suma::Role.cache.noop_admin) diff --git a/spec/suma/admin_api/payment_triggers_spec.rb b/spec/suma/admin_api/payment_triggers_spec.rb index bd8017ea3..b5bf1257f 100644 --- a/spec/suma/admin_api/payment_triggers_spec.rb +++ b/spec/suma/admin_api/payment_triggers_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/payment_triggers/#{Suma::Fixtures.payment_trigger.create.id}" + end + end + describe "GET /v1/payment_triggers" do it "returns all objects" do u = Array.new(2) { Suma::Fixtures.payment_trigger.create } @@ -75,7 +81,8 @@ def make_item(i) get "/v1/payment_triggers/#{o.id}" expect(last_response).to have_status(200) - expect(last_response).to have_json_body.that_includes(id: o.id, executions: have_length(1)) + expect(last_response).to have_json_body. + that_includes(id: o.id, executions: include(items: have_length(1))) end it "403s if the item does not exist" do diff --git a/spec/suma/admin_api/payout_transactions_spec.rb b/spec/suma/admin_api/payout_transactions_spec.rb index c7e2014b8..84021f1ed 100644 --- a/spec/suma/admin_api/payout_transactions_spec.rb +++ b/spec/suma/admin_api/payout_transactions_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/payout_transactions/#{Suma::Fixtures.payout_transaction.with_fake_strategy.create.id}" + end + end + describe "GET /v1/payout_transactions" do it "returns all transactions" do u = Array.new(2) { Suma::Fixtures.payout_transaction.with_fake_strategy.create } diff --git a/spec/suma/admin_api/program_pricings_spec.rb b/spec/suma/admin_api/program_pricings_spec.rb index 45f022647..433f4ff09 100644 --- a/spec/suma/admin_api/program_pricings_spec.rb +++ b/spec/suma/admin_api/program_pricings_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/program_pricings/#{Suma::Fixtures.program_pricing.create.id}" + end + end + describe "POST /v1/program_pricings/create" do it "creates a model" do program = Suma::Fixtures.program.create diff --git a/spec/suma/admin_api/programs_spec.rb b/spec/suma/admin_api/programs_spec.rb index e4a239aef..84588c010 100644 --- a/spec/suma/admin_api/programs_spec.rb +++ b/spec/suma/admin_api/programs_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/programs/#{Suma::Fixtures.program.create.id}" + end + end + describe "GET /v1/programs" do it "returns all programs" do objs = Array.new(2) { Suma::Fixtures.program.create } @@ -82,7 +88,7 @@ def make_item(_i) expect(last_response).to have_status(200) expect(Suma::Program.all).to have_length(1) expect(last_response).to have_json_body.that_includes( - commerce_offerings: contain_exactly(include(id: offering.id)), + commerce_offerings: include(items: contain_exactly(include(id: offering.id))), ) end end @@ -97,7 +103,7 @@ def make_item(_i) expect(last_response).to have_status(200) expect(last_response).to have_json_body.that_includes( id: program.id, - commerce_offerings: contain_exactly(include(id: o.id)), + commerce_offerings: include(items: have_same_ids_as(o)), ) end diff --git a/spec/suma/admin_api/roles_spec.rb b/spec/suma/admin_api/roles_spec.rb index b64c8f542..c3c33f983 100644 --- a/spec/suma/admin_api/roles_spec.rb +++ b/spec/suma/admin_api/roles_spec.rb @@ -12,6 +12,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/roles/#{Suma::Fixtures.role.create.id}" + end + end + describe "GET /v1/roles" do it "returns all roles" do c = Suma::Role.create(name: "c") diff --git a/spec/suma/admin_api/vendor_service_categories_spec.rb b/spec/suma/admin_api/vendor_service_categories_spec.rb index d13abe32f..0460a3a08 100644 --- a/spec/suma/admin_api/vendor_service_categories_spec.rb +++ b/spec/suma/admin_api/vendor_service_categories_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/vendor_service_categories/#{Suma::Fixtures.vendor_service_category.create.id}" + end + end + describe "GET /v1/vendor_service_categories" do it "returns all objects" do objs = Array.new(2) { Suma::Fixtures.vendor_service_category.create } diff --git a/spec/suma/admin_api/vendor_service_rates_spec.rb b/spec/suma/admin_api/vendor_service_rates_spec.rb index ce4fc6ed0..0588de551 100644 --- a/spec/suma/admin_api/vendor_service_rates_spec.rb +++ b/spec/suma/admin_api/vendor_service_rates_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/vendor_service_rates/#{Suma::Fixtures.vendor_service_rate.create.id}" + end + end + describe "GET /v1/vendor_service_rates" do it "returns all objects" do objs = Array.new(2) { Suma::Fixtures.vendor_service_rate.create } @@ -71,7 +77,7 @@ def make_item(_i) expect(last_response).to have_status(200) expect(last_response).to have_json_body.that_includes( id: rate.id, - program_pricings: have_same_ids_as(pricing), + program_pricings: include(items: have_same_ids_as(pricing)), ) end diff --git a/spec/suma/admin_api/vendor_services_spec.rb b/spec/suma/admin_api/vendor_services_spec.rb index ff9bee05c..13249f1b4 100644 --- a/spec/suma/admin_api/vendor_services_spec.rb +++ b/spec/suma/admin_api/vendor_services_spec.rb @@ -13,6 +13,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/vendor_services/#{Suma::Fixtures.vendor_service.create.id}" + end + end + describe "GET /v1/vendor_services" do it "returns all vendor services" do objs = Array.new(2) { Suma::Fixtures.vendor_service.create } @@ -108,7 +114,7 @@ def make_item(_i) expect(last_response).to have_json_body.that_includes( id: service.id, vendor: include(id: vendor.id), - program_pricings: have_same_ids_as(pricing), + program_pricings: include(items: have_same_ids_as(pricing)), ) end @@ -120,7 +126,7 @@ def make_item(_i) expect(last_response).to have_status(200) expect(last_response).to have_json_body.that_includes( id: service.id, - categories: contain_exactly(include(name: "Mobility")), + categories: include(items: contain_exactly(include(name: "Mobility"))), mobility_adapter_setting: "internal", ) end diff --git a/spec/suma/admin_api/vendors_spec.rb b/spec/suma/admin_api/vendors_spec.rb index 1fa7f1f4e..096ba8b3c 100644 --- a/spec/suma/admin_api/vendors_spec.rb +++ b/spec/suma/admin_api/vendors_spec.rb @@ -15,6 +15,12 @@ login_as(admin) end + it_behaves_like "an endpoint with subroutes for related resources" do + let(:detail_route) do + "/v1/vendors/#{Suma::Fixtures.vendor.create.id}" + end + end + describe "GET /v1/vendors" do it "returns all vendors" do objs = Array.new(2) { Suma::Fixtures.vendor.create } @@ -84,8 +90,8 @@ def make_item(_i) expect(last_response).to have_status(200) expect(last_response).to have_json_body.that_includes( id: v.id, - services: have_same_ids_as(service), - products: have_same_ids_as(*product_objs), + services: include(items: have_same_ids_as(service)), + products: include(items: have_same_ids_as(*product_objs)), ) end diff --git a/spec/suma/admin_api_spec.rb b/spec/suma/admin_api_spec.rb index e1fe709ec..a69fe6ce9 100644 --- a/spec/suma/admin_api_spec.rb +++ b/spec/suma/admin_api_spec.rb @@ -32,6 +32,35 @@ class Suma::AdminAPI::TestV1API < Suma::AdminAPI::V1 get :invalid_precond do raise Suma::InvalidPrecondition, "hello" end + + class ChildEntity < Suma::Service::Entities::Base + expose :id + end + + class ModelEntity < Suma::AdminAPI::Entities::BaseModelEntity + model Suma::Vendor + expose :name + expose_related :products, with: ChildEntity + expose_related :products, as: :children, with: ChildEntity + end + + route_setting :skip_role_check, true + resource :model_with_related do + route_param :id do + get do + vendor = Suma::Vendor.find!(id: params[:id]) + present vendor, with: ModelEntity + end + + Suma::AdminAPI::CommonEndpoints.related( + self, + Suma::Vendor, + Suma::Commerce::Product, + ChildEntity, + :products, + ) + end + end end RSpec.describe Suma::AdminAPI, :db do @@ -110,6 +139,86 @@ def method_missing(*); end expect(last_response).to have_json_body. that_includes(error: include(message: "Hello")) end + + describe "related lists", reset_configuration: Suma::Service do + before(:each) do + Suma::Service.related_list_size = 4 + end + + let(:vendor) do + vendor = Suma::Fixtures.vendor.create(name: "foo") + Array.new(5) { Suma::Fixtures.product.create(vendor:) } + vendor + end + + it "is exposed on the entity" do + get "/v1/model_with_related/#{vendor.id}" + + expect(last_response).to have_status(200) + expect(last_response).to have_json_body. + that_includes( + name: "foo", + products: include( + current_page: 1, + page_count: 2, + total_count: 5, + has_more: true, + url: "/v1/model_with_related/#{vendor.id}/products", + items: have_length(4), + ), + children: include(items: have_length(4)), + ) + end + + it "can be expanded" do + get "/v1/model_with_related/#{vendor.id}", expand: ["products"] + + expect(last_response).to have_status(200) + expect(last_response).to have_json_body. + that_includes( + products: include( + current_page: 1, + page_count: 1, + total_count: 5, + has_more: false, + items: have_length(5), + ), + ) + end + + it "can expose a paginated list endpoint" do + get "/v1/model_with_related/#{vendor.id}/products", page: 2, per_page: 2 + + expect(last_response).to have_status(200) + expect(last_response).to have_json_body. + that_includes( + current_page: 2, + page_count: 3, + total_count: 5, + has_more: true, + url: "/v1/model_with_related/#{vendor.id}/products", + items: have_length(2), + ) + end + + it "can sniff the dataset name from an association" do + expect(Suma::Vendor).to receive(:method_defined?). + with(:products_dataset). + and_return(false). + twice + ent = Class.new(Suma::AdminAPI::Entities::BaseModelEntity) do + model Suma::Vendor + expose_related :products, with: Suma::AdminAPI::Entities::BaseModelEntity + end + v = Suma::Fixtures.vendor.create + expect(JSON.parse(ent.represent(v, {env: {}}).to_json)).to include("products" => include("items")) + + get "/v1/model_with_related/#{vendor.id}/products" + + expect(last_response).to have_status(200) + expect(last_response).to have_json_body.that_includes(:items) + end + end end describe "entities" do diff --git a/spec/suma/anon_proxy/message_handler_spec.rb b/spec/suma/anon_proxy/message_handler_spec.rb index 0a9255aaa..69a162f67 100644 --- a/spec/suma/anon_proxy/message_handler_spec.rb +++ b/spec/suma/anon_proxy/message_handler_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require "url_shortener/spec_helpers" -require "suma/spec_helpers/sentry" RSpec.describe Suma::AnonProxy::MessageHandler, :db do include UrlShortener::SpecHelpers diff --git a/spec/suma/api/anon_proxy_spec.rb b/spec/suma/api/anon_proxy_spec.rb index c08bcf281..da9daeea4 100644 --- a/spec/suma/api/anon_proxy_spec.rb +++ b/spec/suma/api/anon_proxy_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require "suma/api/anon_proxy" -require "suma/spec_helpers/sentry" RSpec.describe Suma::API::AnonProxy, :db do include Rack::Test::Methods diff --git a/spec/suma/api/behaviors.rb b/spec/suma/api/behaviors.rb index 34400ddb0..ca4d10398 100644 --- a/spec/suma/api/behaviors.rb +++ b/spec/suma/api/behaviors.rb @@ -156,3 +156,26 @@ def reindex(items) end end end + +RSpec.shared_examples "an endpoint with subroutes for related resources" do + let(:detail_route) { super() } + let(:api_class) { described_class } + + it "has defined routes for all expose_related associations" do + get detail_route + + expect(last_response).to have_status(200) + + supported_routes = described_class.instances.first.routes.map(&:origin) + + missing = [] + j = last_response_json_body + j.each_value do |v| + next unless v.is_a?(Hash) && v[:object] == "list" + related = v[:url].split("/").last + next if supported_routes.any? { |sr| sr.end_with?(related) } + missing << v[:url] + end + expect(missing).to be_empty, "the following related routes are not defined, use CommonEndpoints#related: #{missing}" + end +end diff --git a/spec/suma/lime/sync_trips_from_report_spec.rb b/spec/suma/lime/sync_trips_from_report_spec.rb index 63fb73103..aa35a8f7b 100644 --- a/spec/suma/lime/sync_trips_from_report_spec.rb +++ b/spec/suma/lime/sync_trips_from_report_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require "suma/spec_helpers/sentry" - require "suma/lime/sync_trips_from_report" RSpec.describe Suma::Lime::SyncTripsFromReport, :db, reset_configuration: Suma::Lime do diff --git a/spec/suma/lyft/pass_spec.rb b/spec/suma/lyft/pass_spec.rb index f71d149e6..f6f937aa9 100644 --- a/spec/suma/lyft/pass_spec.rb +++ b/spec/suma/lyft/pass_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require "suma/spec_helpers/sentry" - require "suma/lyft/pass" # rubocop:disable Layout/LineLength diff --git a/spec/suma/payment/platform_status_spec.rb b/spec/suma/payment/platform_status_spec.rb index adbbef092..219d8b3ab 100644 --- a/spec/suma/payment/platform_status_spec.rb +++ b/spec/suma/payment/platform_status_spec.rb @@ -20,6 +20,6 @@ f1 = Suma::Fixtures.funding_transaction.with_fake_strategy.create f2 = Suma::Fixtures.funding_transaction.create(strategy: Suma::Fixtures.off_platform_payment_strategy.create) ps = described_class.new.calculate - expect(ps).to have_attributes(off_platform_funding_transactions: contain_exactly(have_attributes(id: f2.id))) + expect(ps.off_platform_funding_transactions_dataset.all).to have_same_ids_as(f2) end end diff --git a/spec/suma/postgres/model_spec.rb b/spec/suma/postgres/model_spec.rb index ac140de67..04b434f56 100755 --- a/spec/suma/postgres/model_spec.rb +++ b/spec/suma/postgres/model_spec.rb @@ -573,9 +573,11 @@ def inspect = "MyCls" end describe "large association plugin", reset_configuration: described_class, db: false do + include Suma::SpecHelpers::Sentry + it "warns to sentry" do described_class.reset_configuration(large_association_warning_threshold: 3) - expect(Sentry).to receive(:capture_message).with("Large association loaded") + expect_sentry_capture(type: :message, arg_matcher: eq("Large association loaded")) described_class.db.transaction(rollback: :always) do vendor = Suma::Fixtures.vendor.create Array.new(4) { Suma::Fixtures.vendor_service(vendor:).create } diff --git a/spec/suma/spec_helpers/sentry_spec.rb b/spec/suma/spec_helpers/sentry_spec.rb index 174736c76..9259543dc 100644 --- a/spec/suma/spec_helpers/sentry_spec.rb +++ b/spec/suma/spec_helpers/sentry_spec.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require "suma/spec_helpers/sentry" require "suma/spec_helpers/testing_helpers" RSpec.describe Suma::SpecHelpers::Sentry do